diff options
Diffstat (limited to 'toolkit/components/extensions')
938 files changed, 219770 insertions, 0 deletions
diff --git a/toolkit/components/extensions/.eslintrc.js b/toolkit/components/extensions/.eslintrc.js new file mode 100644 index 0000000000..dfa4e2a7bf --- /dev/null +++ b/toolkit/components/extensions/.eslintrc.js @@ -0,0 +1,239 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +module.exports = { + globals: { + // These are defined in the WebExtension script scopes by ExtensionCommon.jsm + Cc: true, + Ci: true, + Cr: true, + Cu: true, + AppConstants: true, + ExtensionAPI: true, + ExtensionAPIPersistent: true, + ExtensionCommon: true, + ExtensionUtils: true, + extensions: true, + global: true, + require: false, + Services: true, + XPCOMUtils: true, + }, + + rules: { + // Rules from the mozilla plugin + "mozilla/balanced-listeners": "error", + "mozilla/no-aArgs": "error", + "mozilla/var-only-at-top-level": "error", + // Disable reject-importGlobalProperties because we don't want to include + // these in the sandbox directly as that would potentially mean the + // imported properties would be instatiated up-front rather than lazily. + "mozilla/reject-importGlobalProperties": "off", + + // Functions are not required to consistently return something or nothing + "consistent-return": "off", + + // Disallow empty statements. This will report an error for: + // try { something(); } catch (e) {} + // but will not report it for: + // try { something(); } catch (e) { /* Silencing the error because ...*/ } + // which is a valid use case. + "no-empty": "error", + + // No expressions where a statement is expected + "no-unused-expressions": "error", + + // No declaring variables that are never used + "no-unused-vars": [ + "error", + { + args: "none", + vars: "all", + varsIgnorePattern: "^console$", + }, + ], + + // No using things before they're defined. + "no-use-before-define": [ + "error", + { + allowNamedExports: true, + classes: true, + // The next two being false allows idiomatic patterns which are more + // type-inference friendly. Functions are hoisted, so this is safe. + functions: false, + // This flag is only meaningful for `var` declarations. + // When false, it still disallows use-before-define in the same scope. + // Since we only allow `var` at the global scope, this is no worse than + // how we currently declare an uninitialized `let` at the top of file. + variables: false, + }, + ], + + // Disallow using variables outside the blocks they are defined (especially + // since only let and const are used, see "no-var"). + "block-scoped-var": "error", + + // Warn about cyclomatic complexity in functions. + complexity: "error", + + // Don't warn for inconsistent naming when capturing this (not so important + // with auto-binding fat arrow functions). + // "consistent-this": ["error", "self"], + + // Don't require a default case in switch statements. Avoid being forced to + // add a bogus default when you know all possible cases are handled. + "default-case": "off", + + // Allow using == instead of ===, in the interest of landing something since + // the devtools codebase is split on convention here. + eqeqeq: "off", + + // Don't require function expressions to have a name. + // This makes the code more verbose and hard to read. Our engine already + // does a fantastic job assigning a name to the function, which includes + // the enclosing function name, and worst case you have a line number that + // you can just look up. + "func-names": "off", + + // Allow use of function declarations and expressions. + "func-style": "off", + + // Maximum depth callbacks can be nested. + "max-nested-callbacks": ["error", 4], + + // Don't limit the number of parameters that can be used in a function. + "max-params": "off", + + // Don't limit the maximum number of statement allowed in a function. We + // already have the complexity rule that's a better measurement. + "max-statements": "off", + + // Don't require a capital letter for constructors, only check if all new + // operators are followed by a capital letter. Don't warn when capitalized + // functions are used without the new operator. + "new-cap": ["off", { capIsNew: false }], + + // Allow use of bitwise operators. + "no-bitwise": "off", + + // Disallow using the console API. + "no-console": "error", + + // Allow using constant expressions in conditions like while (true) + "no-constant-condition": "off", + + // Allow use of the continue statement. + "no-continue": "off", + + // Allow division operators explicitly at beginning of regular expression. + "no-div-regex": "off", + + // Disallow adding to native types + "no-extend-native": "error", + + // Allow comments inline after code. + "no-inline-comments": "off", + + // Disallow use of labels for anything other then loops and switches. + "no-labels": ["error", { allowLoop: true }], + + // Disallow use of multiline strings (use template strings instead). + "no-multi-str": "error", + + // Allow reassignment of function parameters. + "no-param-reassign": "off", + + // Allow string concatenation with __dirname and __filename (not a node env). + "no-path-concat": "off", + + // Allow use of unary operators, ++ and --. + "no-plusplus": "off", + + // Allow using process.env (not a node environment). + "no-process-env": "off", + + // Allow using process.exit (not a node environment). + "no-process-exit": "off", + + // Disallow usage of __proto__ property. + "no-proto": "error", + + // Don't restrict usage of specified node modules (not a node environment). + "no-restricted-modules": "off", + + // Disallow use of assignment in return statement. It is preferable for a + // single line of code to have only one easily predictable effect. + "no-return-assign": "error", + + // Don't warn about declaration of variables already declared in the outer scope. + "no-shadow": "off", + + // Allow use of synchronous methods (not a node environment). + "no-sync": "off", + + // Allow the use of ternary operators. + "no-ternary": "off", + + // Allow dangling underscores in identifiers (for privates). + "no-underscore-dangle": "off", + + // Allow use of undefined variable. + "no-undefined": "off", + + // We use var-only-at-top-level instead of no-var as we allow top level + // vars. + "no-var": "off", + + // Allow using TODO/FIXME comments. + "no-warning-comments": "off", + + // Don't require method and property shorthand syntax for object literals. + // We use this in the code a lot, but not consistently, and this seems more + // like something to check at code review time. + "object-shorthand": "off", + + // Allow more than one variable declaration per function. + "one-var": "off", + + // Require use of the second argument for parseInt(). + radix: "error", + + // Don't require to sort variables within the same declaration block. + // Anyway, one-var is disabled. + "sort-vars": "off", + + // Require "use strict" to be defined globally in the script. + strict: ["error", "global"], + + // Allow vars to be declared anywhere in the scope. + "vars-on-top": "off", + + // Disallow Yoda conditions (where literal value comes first). + yoda: "error", + + // Disallow function or variable declarations in nested blocks + "no-inner-declarations": "error", + + // Disallow labels that share a name with a variable + "no-label-var": "error", + }, + + overrides: [ + { + files: "test/xpcshell/head*.js", + rules: { + "no-unused-vars": [ + "error", + { + args: "none", + vars: "local", + }, + ], + }, + }, + ], +}; diff --git a/toolkit/components/extensions/ConduitsChild.sys.mjs b/toolkit/components/extensions/ConduitsChild.sys.mjs new file mode 100644 index 0000000000..c5774ab39c --- /dev/null +++ b/toolkit/components/extensions/ConduitsChild.sys.mjs @@ -0,0 +1,216 @@ +/* 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 @file implements the child side of Conduits, an abstraction over + * Fission IPC for extension API subject. See {@link ConduitsParent.jsm} + * for more details about the overall design. + * + * @typedef {object} MessageData + * @property {ConduitID} [target] + * @property {ConduitID} [sender] + * @property {boolean} query + * @property {object} arg + */ + +/** + * Base class for both child (Point) and parent (Broadcast) side of conduits, + * handles setting up send/receive method stubs. + */ +export class BaseConduit { + /** + * @param {object} subject + * @param {ConduitAddress} address + */ + constructor(subject, address) { + this.subject = subject; + this.address = address; + this.id = address.id; + + for (let name of address.send || []) { + this[`send${name}`] = this._send.bind(this, name, false); + } + for (let name of address.query || []) { + this[`query${name}`] = this._send.bind(this, name, true); + } + + this.recv = new Map(); + for (let name of address.recv || []) { + let method = this.subject[`recv${name}`]; + if (!method) { + throw new Error(`recv${name} not found for conduit ${this.id}`); + } + this.recv.set(name, method.bind(this.subject)); + } + } + + /** + * Internal, partially @abstract, uses the actor to send the message/query. + * + * @param {string} method + * @param {boolean} query Flag indicating a response is expected. + * @param {JSWindowActor} actor + * @param {MessageData} data + * @returns {Promise?} + */ + _send(method, query, actor, data) { + if (query) { + return actor.sendQuery(method, data); + } + actor.sendAsyncMessage(method, data); + } + + /** + * Internal, calls the specific recvX method based on the message. + * + * @param {string} name Message/method name. + * @param {object} arg Message data, the one and only method argument. + * @param {object} meta Metadata about the method call. + */ + async _recv(name, arg, meta) { + let method = this.recv.get(name); + if (!method) { + throw new Error(`recv${name} not found for conduit ${this.id}`); + } + try { + return await method(arg, meta); + } catch (e) { + if (meta.query) { + return Promise.reject(e); + } + Cu.reportError(e); + } + } +} + +/** + * Child side conduit, can only send/receive point-to-point messages via the + * one specific ConduitsChild actor. + */ +export class PointConduit extends BaseConduit { + constructor(subject, address, actor) { + super(subject, address); + this.actor = actor; + this.actor.sendAsyncMessage("ConduitOpened", { arg: address }); + } + + /** + * Internal, sends messages via the actor, used by sendX stubs. + * + * @param {string} method + * @param {boolean} query + * @param {object?} arg + * @returns {Promise?} + */ + _send(method, query, arg = {}) { + if (!this.actor) { + throw new Error(`send${method} on closed conduit ${this.id}`); + } + let sender = this.id; + return super._send(method, query, this.actor, { arg, query, sender }); + } + + /** + * Closes the conduit from further IPC, notifies the parent side by default. + * + * @param {boolean} silent + */ + close(silent = false) { + let { actor } = this; + if (actor) { + this.actor = null; + actor.conduits.delete(this.id); + if (!silent) { + // Catch any exceptions that can occur if the conduit is closed while + // the actor is being destroyed due to the containing browser being closed. + // This should be treated as if the silent flag was passed. + try { + actor.sendAsyncMessage("ConduitClosed", { sender: this.id }); + } catch (ex) {} + } + } + this.closeCallback?.(); + this.closeCallback = null; + } + + /** + * Set the callback to be called when the conduit is closed. + * + * @param {Function} callback + */ + setCloseCallback(callback) { + this.closeCallback = callback; + } +} + +/** + * Implements the child side of the Conduits actor, manages conduit lifetimes. + */ +export class ConduitsChild extends JSWindowActorChild { + constructor() { + super(); + this.conduits = new Map(); + } + + /** + * Public entry point a child-side subject uses to open a conduit. + * + * @param {object} subject + * @param {ConduitAddress} address + * @returns {PointConduit} + */ + openConduit(subject, address) { + let conduit = new PointConduit(subject, address, this); + this.conduits.set(conduit.id, conduit); + return conduit; + } + + /** + * JSWindowActor method, routes the message to the target subject. + * + * @param {object} options + * @param {string} options.name + * @param {MessageData | MessageData[]} options.data + * @returns {Promise?} + */ + receiveMessage({ name, data }) { + // Batch of webRequest events, run each and return results, ignoring errors. + if (Array.isArray(data)) { + let run = data => this.receiveMessage({ name, data }); + return Promise.all(data.map(data => run(data).catch(Cu.reportError))); + } + + let { target, arg, query, sender } = data; + let conduit = this.conduits.get(target); + if (!conduit) { + throw new Error(`${name} for closed conduit ${target}: ${uneval(arg)}`); + } + return conduit._recv(name, arg, { sender, query, actor: this }); + } + + /** + * JSWindowActor method, ensure cleanup. + */ + didDestroy() { + for (let conduit of this.conduits.values()) { + conduit.close(true); + } + this.conduits.clear(); + } +} + +/** + * Child side of the Conduits process actor. Same code as JSWindowActor. + */ +export class ProcessConduitsChild extends JSProcessActorChild { + constructor() { + super(); + this.conduits = new Map(); + } + + openConduit = ConduitsChild.prototype.openConduit; + receiveMessage = ConduitsChild.prototype.receiveMessage; + willDestroy = ConduitsChild.prototype.willDestroy; + didDestroy = ConduitsChild.prototype.didDestroy; +} diff --git a/toolkit/components/extensions/ConduitsParent.sys.mjs b/toolkit/components/extensions/ConduitsParent.sys.mjs new file mode 100644 index 0000000000..d90bc4afd7 --- /dev/null +++ b/toolkit/components/extensions/ConduitsParent.sys.mjs @@ -0,0 +1,487 @@ +/* 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 @file implements the parent side of Conduits, an abstraction over + * Fission IPC for extension Contexts, API managers, Ports/Messengers, and + * other types of "subjects" participating in implementation of extension APIs. + * + * Additionally, knowledge about conduits from all child processes is gathered + * here, and used together with the full CanonicalBrowsingContext tree to route + * IPC messages and queries directly to the right subjects. + * + * Each Conduit is tied to one subject, attached to a ConduitAddress descriptor, + * and exposes an API for sending/receiving via an actor, or multiple actors in + * case of the parent process. + * + * @typedef {number|string} ConduitID + * + * @typedef {object} ConduitAddress + * @property {ConduitID} [id] Globally unique across all processes. + * @property {string[]} [recv] + * @property {string[]} [send] + * @property {string[]} [query] + * @property {string[]} [cast] + * + * @property {*} [actor] + * @property {boolean} [verified] + * @property {string} [url] + * @property {number} [frameId] + * @property {string} [workerScriptURL] + * @property {string} [extensionId] + * @property {string} [envType] + * @property {string} [instanceId] + * @property {number} [portId] + * @property {boolean} [native] + * @property {boolean} [source] + * @property {string} [reportOnClosed] + * + * Lists of recvX, sendX, queryX and castX methods this subject will use. + * + * @typedef {"messenger"|"port"|"tab"} BroadcastKind + * Kinds of broadcast targeting filters. + * + * @example + * ```js + * { + * init(actor) { + * this.conduit = actor.openConduit(this, { + * id: this.id, + * recv: ["recvAddNumber"], + * send: ["sendNumberUpdate"], + * }); + * }, + * + * recvAddNumber({ num }, { actor, sender }) { + * num += 1; + * this.conduit.sendNumberUpdate(sender.id, { num }); + * } + * } + * ``` + */ + +import { BaseConduit } from "resource://gre/modules/ConduitsChild.sys.mjs"; +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; +import { WebNavigationFrames } from "resource://gre/modules/WebNavigationFrames.sys.mjs"; + +const { DefaultWeakMap, ExtensionError } = ExtensionUtils; + +const BATCH_TIMEOUT_MS = 250; +const ADDON_ENV = new Set(["addon_child", "devtools_child"]); + +/** + * Internal, keeps track of all parent and remote (child) conduits. + */ +const Hub = { + /** @type {Map<ConduitID, ConduitAddress>} Info about all child conduits. */ + remotes: new Map(), + + /** @type {Map<ConduitID, BroadcastConduit>} All open parent conduits. */ + conduits: new Map(), + + /** @type {Map<string, BroadcastConduit>} Parent conduits by recvMethod. */ + byMethod: new Map(), + + /** @type {WeakMap<ConduitsParent, Set<ConduitAddress>>} Conduits by actor. */ + byActor: new DefaultWeakMap(() => new Set()), + + /** @type {Map<string, BroadcastConduit>} */ + reportOnClosed: new Map(), + + /** + * Save info about a new parent conduit, register it as a global listener. + * + * @param {BroadcastConduit} conduit + */ + openConduit(conduit) { + this.conduits.set(conduit.id, conduit); + for (let name of conduit.address.recv || []) { + if (this.byMethod.get(name)) { + // For now, we only allow one parent conduit handling each recv method. + throw new Error(`Duplicate BroadcastConduit method name recv${name}`); + } + this.byMethod.set(name, conduit); + } + }, + + /** + * Cleanup. + * + * @param {BroadcastConduit} conduit + */ + closeConduit({ id, address }) { + this.conduits.delete(id); + for (let name of address.recv || []) { + this.byMethod.delete(name); + } + }, + + /** + * Confirm that a remote conduit comes from an extension background + * service worker. + * + * @see ExtensionPolicyService::CheckParentFrames + * @param {ConduitAddress} remote + * @returns {boolean} + */ + verifyWorkerEnv({ actor, extensionId, workerScriptURL }) { + const addonPolicy = WebExtensionPolicy.getByID(extensionId); + if (!addonPolicy) { + throw new Error(`No WebExtensionPolicy found for ${extensionId}`); + } + if (actor.manager.remoteType !== addonPolicy.extension.remoteType) { + throw new Error( + `Bad ${extensionId} process: ${actor.manager.remoteType}` + ); + } + if (!addonPolicy.isManifestBackgroundWorker(workerScriptURL)) { + throw new Error( + `Bad ${extensionId} background service worker script url: ${workerScriptURL}` + ); + } + return true; + }, + + /** + * Confirm that a remote conduit comes from an extension page or + * an extension background service worker. + * + * @see ExtensionPolicyService::CheckParentFrames + * @param {ConduitAddress} remote + * @returns {boolean} + */ + verifyEnv({ actor, envType, extensionId, ...rest }) { + if (!extensionId || !ADDON_ENV.has(envType)) { + return false; + } + + // ProcessConduit related to a background service worker context. + if (actor.manager && actor.manager instanceof Ci.nsIDOMProcessParent) { + return this.verifyWorkerEnv({ actor, envType, extensionId, ...rest }); + } + + let windowGlobal = actor.manager; + + while (windowGlobal) { + let { browsingContext: bc, documentPrincipal: prin } = windowGlobal; + + if (prin.addonId !== extensionId) { + throw new Error(`Bad ${extensionId} principal: ${prin.URI.spec}`); + } + if (bc.currentRemoteType !== prin.addonPolicy.extension.remoteType) { + throw new Error(`Bad ${extensionId} process: ${bc.currentRemoteType}`); + } + + if (!bc.parent) { + return true; + } + windowGlobal = bc.embedderWindowGlobal; + } + throw new Error(`Missing WindowGlobalParent for ${extensionId}`); + }, + + /** + * Fill in common address fields knowable from the parent process. + * + * @param {ConduitAddress} address + * @param {ConduitsParent} actor + */ + fillInAddress(address, actor) { + address.actor = actor; + address.verified = this.verifyEnv(address); + if (JSWindowActorParent.isInstance(actor)) { + address.frameId = WebNavigationFrames.getFrameId(actor.browsingContext); + address.url = actor.manager.documentURI?.spec; + } else { + // Background service worker contexts do not have an associated frame + // and there is no browsingContext to retrieve the expected url from. + // + // WorkerContextChild sent in the address part of the ConduitOpened request + // the worker script URL as address.workerScriptURL, and so we can use that + // as the address.url too. + address.frameId = -1; + address.url = address.workerScriptURL; + } + }, + + /** + * Save info about a new remote conduit. + * + * @param {ConduitAddress} address + * @param {ConduitsParent} actor + */ + recvConduitOpened(address, actor) { + this.fillInAddress(address, actor); + this.remotes.set(address.id, address); + this.byActor.get(actor).add(address); + }, + + /** + * Notifies listeners and cleans up after the remote conduit is closed. + * + * @param {ConduitAddress} remote + */ + recvConduitClosed(remote) { + this.remotes.delete(remote.id); + this.byActor.get(remote.actor).delete(remote); + + remote.actor = null; + for (let [key, conduit] of Hub.reportOnClosed.entries()) { + if (remote[key]) { + conduit.subject.recvConduitClosed(remote); + } + } + }, + + /** + * Close all remote conduits when the actor goes away. + * + * @param {ConduitsParent} actor + */ + actorClosed(actor) { + for (let remote of this.byActor.get(actor)) { + // When a Port is closed, we notify the other side, but it might share + // an actor, so we shouldn't sendQeury() in that case (see bug 1623976). + this.remotes.delete(remote.id); + } + for (let remote of this.byActor.get(actor)) { + this.recvConduitClosed(remote); + } + this.byActor.delete(actor); + }, +}; + +/** + * Parent side conduit, registers as a global listeners for certain messages, + * and can target specific child conduits when sending. + */ +export class BroadcastConduit extends BaseConduit { + /** + * @param {object} subject + * @param {ConduitAddress} address + */ + constructor(subject, address) { + super(subject, address); + + // Create conduit.castX() bidings. + for (let name of address.cast || []) { + this[`cast${name}`] = this._cast.bind(this, name); + } + + // Wants to know when conduits with a specific attribute are closed. + // `subject.recvConduitClosed(address)` method will be called. + if (address.reportOnClosed) { + Hub.reportOnClosed.set(address.reportOnClosed, this); + } + + this.open = true; + Hub.openConduit(this); + } + + /** + * Internal, sends a message to a specific conduit, used by sendX stubs. + * + * @param {string} method + * @param {boolean} query + * @param {ConduitID} target + * @param {object?} arg + * @returns {Promise<any>} + */ + _send(method, query, target, arg = {}) { + if (!this.open) { + throw new Error(`send${method} on closed conduit ${this.id}`); + } + + let sender = this.id; + let { actor } = Hub.remotes.get(target); + + if (method === "RunListener" && arg.path.startsWith("webRequest.")) { + return actor.batch(method, { target, arg, query, sender }); + } + return super._send(method, query, actor, { target, arg, query, sender }); + } + + /** + * Broadcasts a method call to all conduits of kind that satisfy filtering by + * kind-specific properties from arg, returns an array of response promises. + * + * @param {string} method + * @param {BroadcastKind} kind + * @param {object} arg + * @returns {Promise<any[]> | Promise<Response>} + */ + _cast(method, kind, arg) { + let filters = { + // Target Ports by portId and side (connect caller/onConnect receiver). + port: remote => + remote.portId === arg.portId && + (arg.source == null || remote.source === arg.source), + + // Target Messengers in extension pages by extensionId and envType. + messenger: r => + r.verified && + r.id !== arg.sender.contextId && + r.extensionId === arg.extensionId && + r.recv.includes(method) && + // TODO: Bug 1453343 - get rid of this: + (r.envType === "addon_child" || arg.sender.envType !== "content_child"), + + // Target Messengers by extensionId, tabId (topBC) and frameId. + tab: remote => + remote.extensionId === arg.extensionId && + remote.actor.manager.browsingContext?.top.id === arg.topBC && + (arg.frameId == null || remote.frameId === arg.frameId) && + remote.recv.includes(method), + + // Target Messengers by extensionId. + extension: remote => remote.instanceId === arg.instanceId, + }; + + let targets = Array.from(Hub.remotes.values()).filter(filters[kind]); + let promises = targets.map(c => this._send(method, true, c.id, arg)); + + return arg.firstResponse + ? this._raceResponses(promises) + : Promise.allSettled(promises); + } + + /** + * Custom Promise.race() function that ignores certain resolutions and errors. + * + * @typedef {{response?: any, received?: boolean}} Response + * + * @param {Promise<Response>[]} promises + * @returns {Promise<Response?>} + */ + _raceResponses(promises) { + return new Promise((resolve, reject) => { + let result; + promises.map(p => + p + .then(value => { + if (value.response) { + // We have an explicit response, resolve immediately. + resolve(value); + } else if (value.received) { + // Message was received, but no response. + // Resolve with this only if there is no other explicit response. + result = value; + } + }) + .catch(err => { + // Forward errors that are exposed to extension, but ignore + // internal errors such as actor destruction and DataCloneError. + if (err instanceof ExtensionError || err?.mozWebExtLocation) { + reject(err); + } else { + Cu.reportError(err); + } + }) + ); + // Ensure resolving when there are no responses. + Promise.allSettled(promises).then(() => resolve(result)); + }); + } + + async close() { + this.open = false; + Hub.closeConduit(this); + } +} + +/** + * Implements the parent side of the Conduits actor. + */ +export class ConduitsParent extends JSWindowActorParent { + constructor() { + super(); + this.batchData = []; + this.batchPromise = null; + this.batchResolve = null; + this.timerActive = false; + } + + /** + * Group webRequest events to send them as a batch, reducing IPC overhead. + * + * @param {string} name + * @param {import("ConduitsChild.sys.mjs").MessageData} data + * @returns {Promise<object>} + */ + batch(name, data) { + let pos = this.batchData.length; + this.batchData.push(data); + + let sendNow = idleDispatch => { + if (this.batchData.length && this.manager) { + this.batchResolve(this.sendQuery(name, this.batchData)); + } else { + this.batchResolve([]); + } + this.batchData = []; + this.timerActive = !idleDispatch; + }; + + if (!pos) { + this.batchPromise = new Promise(r => (this.batchResolve = r)); + if (!this.timerActive) { + ChromeUtils.idleDispatch(sendNow, { timeout: BATCH_TIMEOUT_MS }); + this.timerActive = true; + } + } + + if (data.arg.urgentSend) { + // If this is an urgent blocking event, run this batch right away. + sendNow(false); + } + + return this.batchPromise.then(results => results[pos]); + } + + /** + * JSWindowActor method, routes the message to the target subject. + * + * @param {object} options + * @param {string} options.name + * @param {import("ConduitsChild.sys.mjs").MessageData} options.data + * @returns {Promise?} + */ + async receiveMessage({ name, data: { arg, query, sender } }) { + if (name === "ConduitOpened") { + return Hub.recvConduitOpened(arg, this); + } + + let remote = Hub.remotes.get(sender); + if (!remote || remote.actor !== this) { + throw new Error(`Unknown sender or wrong actor for recv${name}`); + } + + if (name === "ConduitClosed") { + return Hub.recvConduitClosed(remote); + } + + let conduit = Hub.byMethod.get(name); + if (!conduit) { + throw new Error(`Parent conduit for recv${name} not found`); + } + + return conduit._recv(name, arg, { actor: this, query, sender: remote }); + } + + /** + * JSWindowActor method, ensure cleanup. + */ + didDestroy() { + Hub.actorClosed(this); + } +} + +/** + * Parent side of the Conduits process actor. Same code as JSWindowActor. + */ +export class ProcessConduitsParent extends JSProcessActorParent { + receiveMessage = ConduitsParent.prototype.receiveMessage; + willDestroy = ConduitsParent.prototype.willDestroy; + didDestroy = ConduitsParent.prototype.didDestroy; +} diff --git a/toolkit/components/extensions/DocumentObserver.h b/toolkit/components/extensions/DocumentObserver.h new file mode 100644 index 0000000000..b9b0dc6f78 --- /dev/null +++ b/toolkit/components/extensions/DocumentObserver.h @@ -0,0 +1,59 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_extensions_DocumentObserver_h +#define mozilla_extensions_DocumentObserver_h + +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/dom/MozDocumentObserverBinding.h" + +#include "mozilla/extensions/WebExtensionContentScript.h" + +class nsILoadInfo; +class nsPIDOMWindowOuter; + +namespace mozilla { +namespace extensions { + +class DocumentObserver final : public nsISupports, public nsWrapperCache { + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(DocumentObserver) + + static already_AddRefed<DocumentObserver> Constructor( + dom::GlobalObject& aGlobal, dom::MozDocumentCallback& aCallbacks); + + void Observe(const dom::Sequence<OwningNonNull<MozDocumentMatcher>>& matchers, + ErrorResult& aRv); + + void Disconnect(); + + const nsTArray<RefPtr<MozDocumentMatcher>>& Matchers() const { + return mMatchers; + } + + void NotifyMatch(MozDocumentMatcher& aMatcher, nsPIDOMWindowOuter* aWindow); + void NotifyMatch(MozDocumentMatcher& aMatcher, nsILoadInfo* aLoadInfo); + + nsISupports* GetParentObject() const { return mParent; } + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + protected: + virtual ~DocumentObserver() = default; + + private: + explicit DocumentObserver(nsISupports* aParent, + dom::MozDocumentCallback& aCallbacks) + : mParent(aParent), mCallbacks(&aCallbacks) {} + + nsCOMPtr<nsISupports> mParent; + RefPtr<dom::MozDocumentCallback> mCallbacks; + nsTArray<RefPtr<MozDocumentMatcher>> mMatchers; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_DocumentObserver_h diff --git a/toolkit/components/extensions/Extension.sys.mjs b/toolkit/components/extensions/Extension.sys.mjs new file mode 100644 index 0000000000..4bbaa56199 --- /dev/null +++ b/toolkit/components/extensions/Extension.sys.mjs @@ -0,0 +1,4088 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +/* + * This file is the main entry point for extensions. When an extension + * loads, its bootstrap.js file creates a Extension instance + * and calls .startup() on it. It calls .shutdown() when the extension + * unloads. Extension manages any extension-specific state in + * the chrome process. + * + * TODO(rpl): we are current restricting the extensions to a single process + * (set as the current default value of the "dom.ipc.processCount.extension" + * preference), if we switch to use more than one extension process, we have to + * be sure that all the browser's frameLoader are associated to the same process, + * e.g. by enabling the `maychangeremoteness` attribute, and/or setting + * `initialBrowsingContextGroupId` attribute to the correct value. + * + * At that point we are going to keep track of the existing browsers associated to + * a webextension to ensure that they are all running in the same process (and we + * are also going to do the same with the browser element provided to the + * addon debugging Remote Debugging actor, e.g. because the addon has been + * reloaded by the user, we have to ensure that the new extension pages are going + * to run in the same process of the existing addon debugging browser element). + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs"; +import { ExtensionParent } from "resource://gre/modules/ExtensionParent.sys.mjs"; +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs", + AddonSettings: "resource://gre/modules/addons/AddonSettings.sys.mjs", + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", + E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", + ExtensionDNR: "resource://gre/modules/ExtensionDNR.sys.mjs", + ExtensionDNRStore: "resource://gre/modules/ExtensionDNRStore.sys.mjs", + ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs", + ExtensionPreferencesManager: + "resource://gre/modules/ExtensionPreferencesManager.sys.mjs", + ExtensionProcessScript: + "resource://gre/modules/ExtensionProcessScript.sys.mjs", + ExtensionScriptingStore: + "resource://gre/modules/ExtensionScriptingStore.sys.mjs", + ExtensionStorage: "resource://gre/modules/ExtensionStorage.sys.mjs", + ExtensionStorageIDB: "resource://gre/modules/ExtensionStorageIDB.sys.mjs", + ExtensionTelemetry: "resource://gre/modules/ExtensionTelemetry.sys.mjs", + LightweightThemeManager: + "resource://gre/modules/LightweightThemeManager.sys.mjs", + Log: "resource://gre/modules/Log.sys.mjs", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", + SITEPERMS_ADDON_TYPE: + "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs", + Schemas: "resource://gre/modules/Schemas.sys.mjs", + ServiceWorkerCleanUp: "resource://gre/modules/ServiceWorkerCleanUp.sys.mjs", + extensionStorageSync: "resource://gre/modules/ExtensionStorageSync.sys.mjs", + PERMISSION_L10N: "resource://gre/modules/ExtensionPermissionMessages.sys.mjs", + permissionToL10nId: + "resource://gre/modules/ExtensionPermissionMessages.sys.mjs", + QuarantinedDomains: "resource://gre/modules/ExtensionPermissions.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "resourceProtocol", () => + Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler) +); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + aomStartup: [ + "@mozilla.org/addons/addon-manager-startup;1", + "amIAddonManagerStartup", + ], + spellCheck: ["@mozilla.org/spellchecker/engine;1", "mozISpellCheckingEngine"], +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "processCount", + "dom.ipc.processCount.extension" +); + +// Temporary pref to be turned on when ready. +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "userContextIsolation", + "extensions.userContextIsolation.enabled", + false +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "userContextIsolationDefaultRestricted", + "extensions.userContextIsolation.defaults.restricted", + "[]" +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "dnrEnabled", + "extensions.dnr.enabled", + true +); + +// This pref modifies behavior for MV2. MV3 is enabled regardless. +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "eventPagesEnabled", + "extensions.eventPages.enabled" +); + +// This pref is used to check if storage.sync is still the Kinto-based backend +// (GeckoView should be the only one still using it). +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "storageSyncOldKintoBackend", + "webextensions.storage.sync.kinto", + false +); + +// Deprecation of browser_style, through .supported & .same_as_mv2 prefs: +// - true true = warn only: deprecation message only (no behavioral changes). +// - true false = deprecate: default to false, even if default was true in MV2. +// - false = remove: always use false, even when true is specified. +// (if .same_as_mv2 is set, also warn if the default changed) +// Deprecation plan: https://bugzilla.mozilla.org/show_bug.cgi?id=1827910#c1 +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "browserStyleMV3supported", + "extensions.browser_style_mv3.supported", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "browserStyleMV3sameAsMV2", + "extensions.browser_style_mv3.same_as_mv2", + false +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "processCrashThreshold", + "extensions.webextensions.crash.threshold", + // The default number of times an extension process is allowed to crash + // within a timeframe. + 5 +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "processCrashTimeframe", + "extensions.webextensions.crash.timeframe", + // The default timeframe used to count crashes, in milliseconds. + 30 * 1000 +); + +var { + GlobalManager, + IconDetails, + ParentAPIManager, + StartupCache, + apiManager: Management, +} = ExtensionParent; + +export { Management }; + +const { getUniqueId, promiseTimeout } = ExtensionUtils; + +const { EventEmitter, redefineGetter, updateAllowedOrigins } = ExtensionCommon; + +ChromeUtils.defineLazyGetter( + lazy, + "LocaleData", + () => ExtensionCommon.LocaleData +); + +ChromeUtils.defineLazyGetter(lazy, "NO_PROMPT_PERMISSIONS", async () => { + // Wait until all extension API schemas have been loaded and parsed. + await Management.lazyInit(); + return new Set( + lazy.Schemas.getPermissionNames([ + "PermissionNoPrompt", + "OptionalPermissionNoPrompt", + "PermissionPrivileged", + ]) + ); +}); + +// TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed. +ChromeUtils.defineLazyGetter(lazy, "SCHEMA_SITE_PERMISSIONS", async () => { + // Wait until all extension API schemas have been loaded and parsed. + await Management.lazyInit(); + return lazy.Schemas.getPermissionNames(["SitePermission"]); +}); + +const { sharedData } = Services.ppmm; + +const PRIVATE_ALLOWED_PERMISSION = "internal:privateBrowsingAllowed"; +const SVG_CONTEXT_PROPERTIES_PERMISSION = + "internal:svgContextPropertiesAllowed"; + +// The userContextID reserved for the extension storage (its purpose is ensuring that the IndexedDB +// storage used by the browser.storage.local API is not directly accessible from the extension code, +// it is defined and reserved as "userContextIdInternal.webextStorageLocal" in ContextualIdentityService.sys.mjs). +const WEBEXT_STORAGE_USER_CONTEXT_ID = -1 >>> 0; + +// The maximum time to wait for extension child shutdown blockers to complete. +const CHILD_SHUTDOWN_TIMEOUT_MS = 8000; + +// Permissions that are only available to privileged extensions. +const PRIVILEGED_PERMS = new Set([ + "activityLog", + "mozillaAddons", + "networkStatus", + "normandyAddonStudy", + "telemetry", +]); + +const PRIVILEGED_PERMS_ANDROID_ONLY = new Set([ + "geckoViewAddons", + "nativeMessagingFromContent", + "nativeMessaging", +]); + +const PRIVILEGED_PERMS_DESKTOP_ONLY = new Set(["normandyAddonStudy"]); + +if (AppConstants.platform == "android") { + for (const perm of PRIVILEGED_PERMS_ANDROID_ONLY) { + PRIVILEGED_PERMS.add(perm); + } +} + +if ( + AppConstants.MOZ_APP_NAME != "firefox" || + AppConstants.platform == "android" +) { + for (const perm of PRIVILEGED_PERMS_DESKTOP_ONLY) { + PRIVILEGED_PERMS.delete(perm); + } +} + +// Message included in warnings and errors related to privileged permissions and +// privileged manifest properties. Provides a link to the firefox-source-docs.mozilla.org +// section related to developing and sign Privileged Add-ons. +const PRIVILEGED_ADDONS_DEVDOCS_MESSAGE = + "See https://mzl.la/3NS9KJd for more details about how to develop a privileged add-on."; + +const INSTALL_AND_UPDATE_STARTUP_REASONS = new Set([ + "ADDON_INSTALL", + "ADDON_UPGRADE", + "ADDON_DOWNGRADE", +]); + +const PROTOCOL_HANDLER_OPEN_PERM_KEY = "open-protocol-handler"; +const PERMISSION_KEY_DELIMITER = "^"; + +// These are used for manipulating jar entry paths, which always use Unix +// separators (originally copied from `ospath_unix.jsm` as part of the "OS.Path +// to PathUtils" migration). + +/** + * Return the final part of the path. + * The final part of the path is everything after the last "/". + */ +function basename(path) { + return path.slice(path.lastIndexOf("/") + 1); +} + +/** + * Return the directory part of the path. + * The directory part of the path is everything before the last + * "/". If the last few characters of this part are also "/", + * they are ignored. + * + * If the path contains no directory, return ".". + */ +function dirname(path) { + let index = path.lastIndexOf("/"); + if (index == -1) { + return "."; + } + while (index >= 0 && path[index] == "/") { + --index; + } + return path.slice(0, index + 1); +} + +// Returns true if the extension is owned by Mozilla (is either privileged, +// using one of the @mozilla.com/@mozilla.org protected addon id suffixes). +// +// This method throws if the extension's startupReason is not one of the +// expected ones (either ADDON_INSTALL, ADDON_UPGRADE or ADDON_DOWNGRADE). +// +// TODO(Bug 1835787): Consider to remove the restriction based on the +// startupReason now that the recommendationState property is always +// included in the addonData with any of the startupReason. +function isMozillaExtension(extension) { + const { addonData, id, isPrivileged, startupReason } = extension; + + if (!INSTALL_AND_UPDATE_STARTUP_REASONS.has(startupReason)) { + throw new Error( + `isMozillaExtension called with unexpected startupReason: ${startupReason}` + ); + } + + if (isPrivileged) { + return true; + } + + if (id.endsWith("@mozilla.com") || id.endsWith("@mozilla.org")) { + return true; + } + + // This check is a subset of what is being checked in AddonWrapper's + // recommendationStates (states expire dates for line extensions are + // not considered important in determining that the extension is + // provided by mozilla, and so they are omitted here on purpose). + const isMozillaLineExtension = + addonData.recommendationState?.states?.includes("line"); + const isSigned = + addonData.signedState > lazy.AddonManager.SIGNEDSTATE_MISSING; + + return isSigned && isMozillaLineExtension; +} + +/** + * Classify an individual permission from a webextension manifest + * as a host/origin permission, an api permission, or a regular permission. + * + * @param {string} perm The permission string to classify + * @param {boolean} restrictSchemes + * @param {boolean} isPrivileged whether or not the webextension is privileged + * + * @returns {object} + * An object with exactly one of the following properties: + * "origin" to indicate this is a host/origin permission. + * "api" to indicate this is an api permission + * (as used for webextensions experiments). + * "permission" to indicate this is a regular permission. + * "invalid" to indicate that the given permission cannot be used. + */ +function classifyPermission(perm, restrictSchemes, isPrivileged) { + let match = /^(\w+)(?:\.(\w+)(?:\.\w+)*)?$/.exec(perm); + if (!match) { + try { + let { pattern } = new MatchPattern(perm, { + restrictSchemes, + ignorePath: true, + }); + return { origin: pattern }; + } catch (e) { + return { invalid: perm }; + } + } else if (match[1] == "experiments" && match[2]) { + return { api: match[2] }; + } else if (!isPrivileged && PRIVILEGED_PERMS.has(match[1])) { + return { invalid: perm, privileged: true }; + } else if (perm.startsWith("declarativeNetRequest") && !lazy.dnrEnabled) { + return { invalid: perm }; + } + return { permission: perm }; +} + +const LOGGER_ID_BASE = "addons.webextension."; +const UUID_MAP_PREF = "extensions.webextensions.uuids"; +const LEAVE_STORAGE_PREF = "extensions.webextensions.keepStorageOnUninstall"; +const LEAVE_UUID_PREF = "extensions.webextensions.keepUuidOnUninstall"; + +const COMMENT_REGEXP = new RegExp( + String.raw` + ^ + ( + (?: + [^"\n] | + " (?:[^"\\\n] | \\.)* " + )*? + ) + + //.* + `.replace(/\s+/g, ""), + "gm" +); + +// All moz-extension URIs use a machine-specific UUID rather than the +// extension's own ID in the host component. This makes it more +// difficult for web pages to detect whether a user has a given add-on +// installed (by trying to load a moz-extension URI referring to a +// web_accessible_resource from the extension). UUIDMap.get() +// returns the UUID for a given add-on ID. +var UUIDMap = { + _read() { + let pref = Services.prefs.getStringPref(UUID_MAP_PREF, "{}"); + try { + return JSON.parse(pref); + } catch (e) { + Cu.reportError(`Error parsing ${UUID_MAP_PREF}.`); + return {}; + } + }, + + _write(map) { + Services.prefs.setStringPref(UUID_MAP_PREF, JSON.stringify(map)); + }, + + get(id, create = true) { + let map = this._read(); + + if (id in map) { + return map[id]; + } + + let uuid = null; + if (create) { + uuid = Services.uuid.generateUUID().number; + uuid = uuid.slice(1, -1); // Strip { and } off the UUID. + + map[id] = uuid; + this._write(map); + } + return uuid; + }, + + remove(id) { + let map = this._read(); + delete map[id]; + this._write(map); + }, +}; + +function clearCacheForExtensionPrincipal(principal, clearAll = false) { + if (!principal.schemeIs("moz-extension")) { + return Promise.reject(new Error("Unexpected non extension principal")); + } + + // TODO(Bug 1750053): replace the two specific flags with a "clear all caches one" + // (along with covering the other kind of cached data with tests). + const clearDataFlags = clearAll + ? Ci.nsIClearDataService.CLEAR_ALL_CACHES + : Ci.nsIClearDataService.CLEAR_IMAGE_CACHE | + Ci.nsIClearDataService.CLEAR_CSS_CACHE; + + return new Promise(resolve => + Services.clearData.deleteDataFromPrincipal( + principal, + false, + clearDataFlags, + () => resolve() + ) + ); +} + +/** + * Observer AddonManager events and translate them into extension events, + * as well as handle any last cleanup after uninstalling an extension. + */ +var ExtensionAddonObserver = { + initialized: false, + + init() { + if (!this.initialized) { + lazy.AddonManager.addAddonListener(this); + this.initialized = true; + } + }, + + // AddonTestUtils will call this as necessary. + uninit() { + if (this.initialized) { + lazy.AddonManager.removeAddonListener(this); + this.initialized = false; + } + }, + + onEnabling(addon) { + if (addon.type !== "extension") { + return; + } + Management._callHandlers([addon.id], "enabling", "onEnabling"); + }, + + onDisabled(addon) { + if (addon.type !== "extension") { + return; + } + if (Services.appinfo.inSafeMode) { + // Ensure ExtensionPreferencesManager updates its data and + // modules can run any disable logic they need to. We only + // handle safeMode here because there is a bunch of additional + // logic that happens in Extension.shutdown when running in + // normal mode. + Management._callHandlers([addon.id], "disable", "onDisable"); + } + }, + + onUninstalling(addon) { + let extension = GlobalManager.extensionMap.get(addon.id); + if (extension) { + // Let any other interested listeners respond + // (e.g., display the uninstall URL) + Management.emit("uninstalling", extension); + } + }, + + onUninstalled(addon) { + // Cleanup anything that is used by non-extension addon types + // since only extensions have uuid's. + lazy.ExtensionPermissions.removeAll(addon.id); + + lazy.QuarantinedDomains.clearUserPref(addon.id); + + let uuid = UUIDMap.get(addon.id, false); + if (!uuid) { + return; + } + + let baseURI = Services.io.newURI(`moz-extension://${uuid}/`); + let principal = Services.scriptSecurityManager.createContentPrincipal( + baseURI, + {} + ); + + // Clear all cached resources (e.g. CSS and images); + lazy.AsyncShutdown.profileChangeTeardown.addBlocker( + `Clear cache for ${addon.id}`, + clearCacheForExtensionPrincipal(principal, /* clearAll */ true) + ); + + // Clear all the registered service workers for the extension + // principal (the one that may have been registered through the + // manifest.json file and the ones that may have been registered + // from an extension page through the service worker API). + // + // Any stored data would be cleared below (if the pref + // "extensions.webextensions.keepStorageOnUninstall has not been + // explicitly set to true, which is usually only done in + // tests and by some extensions developers for testing purpose). + // + // TODO: ServiceWorkerCleanUp may go away once Bug 1183245 + // is fixed, and so this may actually go away, replaced by + // marking the registration as disabled or to be removed on + // shutdown (where we do know if the extension is shutting + // down because is being uninstalled) and then cleared from + // the persisted serviceworker registration on the next + // startup. + lazy.AsyncShutdown.profileChangeTeardown.addBlocker( + `Clear ServiceWorkers for ${addon.id}`, + lazy.ServiceWorkerCleanUp.removeFromPrincipal(principal) + ); + + // Clear the persisted dynamic content scripts created with the scripting + // API (if any). + lazy.AsyncShutdown.profileChangeTeardown.addBlocker( + `Clear scripting store for ${addon.id}`, + lazy.ExtensionScriptingStore.clearOnUninstall(addon.id) + ); + + // Clear the DNR API's rules data persisted on disk (if any). + lazy.AsyncShutdown.profileChangeTeardown.addBlocker( + `Clear declarativeNetRequest store for ${addon.id}`, + lazy.ExtensionDNRStore.clearOnUninstall(uuid) + ); + + if (!Services.prefs.getBoolPref(LEAVE_STORAGE_PREF, false)) { + // Clear browser.storage.local backends. + lazy.AsyncShutdown.profileChangeTeardown.addBlocker( + `Clear Extension Storage ${addon.id} (File Backend)`, + lazy.ExtensionStorage.clear(addon.id, { shouldNotifyListeners: false }) + ); + + // Clear browser.storage.sync rust-based backend. + // (storage.sync clearOnUninstall will resolve and log an error on the + // browser console in case of unexpected failures). + if (!lazy.storageSyncOldKintoBackend) { + lazy.AsyncShutdown.profileChangeTeardown.addBlocker( + `Clear Extension StorageSync ${addon.id}`, + lazy.extensionStorageSync.clearOnUninstall(addon.id) + ); + } + + // Clear any IndexedDB and Cache API storage created by the extension. + // If LSNG is enabled, this also clears localStorage. + Services.qms.clearStoragesForPrincipal(principal); + + // Clear any storage.local data stored in the IDBBackend. + let storagePrincipal = + Services.scriptSecurityManager.createContentPrincipal(baseURI, { + userContextId: WEBEXT_STORAGE_USER_CONTEXT_ID, + }); + Services.qms.clearStoragesForPrincipal(storagePrincipal); + + lazy.ExtensionStorageIDB.clearMigratedExtensionPref(addon.id); + + // If LSNG is not enabled, we need to clear localStorage explicitly using + // the old API. + if (!Services.domStorageManager.nextGenLocalStorageEnabled) { + // Clear localStorage created by the extension + let storage = Services.domStorageManager.getStorage( + null, + principal, + principal + ); + if (storage) { + storage.clear(); + } + } + + // Remove any permissions related to the unlimitedStorage permission + // if we are also removing all the data stored by the extension. + Services.perms.removeFromPrincipal( + principal, + "WebExtensions-unlimitedStorage" + ); + Services.perms.removeFromPrincipal(principal, "persistent-storage"); + } + + // Clear any protocol handler permissions granted to this add-on. + let permissions = Services.perms.getAllWithTypePrefix( + PROTOCOL_HANDLER_OPEN_PERM_KEY + PERMISSION_KEY_DELIMITER + ); + for (let perm of permissions) { + if (perm.principal.equalsURI(baseURI)) { + Services.perms.removePermission(perm); + } + } + + if (!Services.prefs.getBoolPref(LEAVE_UUID_PREF, false)) { + // Clear the entry in the UUID map + UUIDMap.remove(addon.id); + } + }, + + onPropertyChanged(addon, properties) { + let extension = GlobalManager.extensionMap.get(addon.id); + if (extension && properties.includes("quarantineIgnoredByUser")) { + extension.ignoreQuarantine = addon.quarantineIgnoredByUser; + extension.policy.ignoreQuarantine = addon.quarantineIgnoredByUser; + + extension.setSharedData("", extension.serialize()); + Services.ppmm.sharedData.flush(); + + extension.emit("update-ignore-quarantine"); + extension.broadcast("Extension:UpdateIgnoreQuarantine", { + id: extension.id, + ignoreQuarantine: addon.quarantineIgnoredByUser, + }); + } + }, +}; + +ExtensionAddonObserver.init(); + +/** + * Observer ExtensionProcess crashes and notify all the extensions + * using a Management event named "extension-process-crash". + */ +export var ExtensionProcessCrashObserver = { + initialized: false, + + // For Android apps we initially consider the app as always starting + // in the background, then we expect to be setting it to foreground + // when GeckoView LifecycleListener onResume method is called on the + // Android app first startup. After the application has got on the + // foreground for the first time then onPause/onResumed LifecycleListener + // are called, the application-foreground/-background topics will be + // notified to Gecko and this flag will be updated accordingly. + _appInForeground: AppConstants.platform !== "android", + _isAndroid: AppConstants.platform === "android", + _processSpawningDisabled: false, + + // Technically there is at most one child extension process, + // but we may need to adjust this assumption to account for more + // than one if that ever changes in the future. + currentProcessChildID: undefined, + lastCrashedProcessChildID: undefined, + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + + // Collect the timestamps of the crashes happened over the last + // `processCrashTimeframe` milliseconds. + lastCrashTimestamps: [], + + init() { + if (!this.initialized) { + Services.obs.addObserver(this, "ipc:content-created"); + Services.obs.addObserver(this, "process-type-set"); + Services.obs.addObserver(this, "ipc:content-shutdown"); + this.logger = lazy.Log.repository.getLogger( + "addons.process-crash-observer" + ); + if (this._isAndroid) { + Services.obs.addObserver(this, "geckoview-initial-foreground"); + Services.obs.addObserver(this, "application-foreground"); + Services.obs.addObserver(this, "application-background"); + } + this.initialized = true; + } + }, + + uninit() { + if (this.initialized) { + try { + Services.obs.removeObserver(this, "ipc:content-created"); + Services.obs.removeObserver(this, "process-type-set"); + Services.obs.removeObserver(this, "ipc:content-shutdown"); + if (this._isAndroid) { + Services.obs.removeObserver(this, "geckoview-initial-foreground"); + Services.obs.removeObserver(this, "application-foreground"); + Services.obs.removeObserver(this, "application-background"); + } + } catch (err) { + // Removing the observer may fail if they are not registered anymore, + // this shouldn't happen in practice, but let's still log the error + // in case it does. + Cu.reportError(err); + } + this.initialized = false; + } + }, + + observe(subject, topic, data) { + let childID = data; + switch (topic) { + case "geckoview-initial-foreground": + this._appInForeground = true; + this.logger.debug( + `Detected Android application moved in the foreground (geckoview-initial-foreground)` + ); + break; + case "application-foreground": + // Intentional fall-through + case "application-background": + this._appInForeground = topic === "application-foreground"; + this.logger.debug( + `Detected Android application moved in the ${ + this._appInForeground ? "foreground" : "background" + }` + ); + if (this._appInForeground) { + Management.emit("application-foreground", { + appInForeground: this._appInForeground, + childID: this.currentProcessChildID, + processSpawningDisabled: this.processSpawningDisabled, + }); + } + break; + case "process-type-set": + // Intentional fall-through + case "ipc:content-created": { + let pp = subject.QueryInterface(Ci.nsIDOMProcessParent); + if (pp.remoteType === "extension") { + this.currentProcessChildID = childID; + Glean.extensions.processEvent[ + this.appInForeground ? "created_fg" : "created_bg" + ].add(1); + } + break; + } + case "ipc:content-shutdown": { + if (Services.startup.shuttingDown) { + // The application is shutting down, don't bother + // signaling process crashes anymore. + return; + } + if (this.currentProcessChildID !== childID) { + // Ignore non-extension child process shutdowns. + return; + } + + // At this point we are sure that the current extension + // process is gone, and so even if the process did shutdown + // cleanly instead of crashing, we can clear the property + // that keeps track of the current extension process childID. + this.currentProcessChildID = undefined; + + subject.QueryInterface(Ci.nsIPropertyBag2); + if (!subject.get("abnormal")) { + // Ignore non-abnormal child process shutdowns. + return; + } + + this.lastCrashedProcessChildID = childID; + + const now = Cu.now(); + // Filter crash timestamps older than processCrashTimeframe. + this.lastCrashTimestamps = this.lastCrashTimestamps.filter( + timestamp => now - timestamp < lazy.processCrashTimeframe + ); + // Push the new timeframe. + this.lastCrashTimestamps.push(now); + // Set the flag that disable process spawning when we exceed the + // `processCrashThreshold`. + this._processSpawningDisabled = + this.lastCrashTimestamps.length > lazy.processCrashThreshold; + + this.logger.debug( + `Extension process crashed ${this.lastCrashTimestamps.length} times over the last ${lazy.processCrashTimeframe}ms` + ); + + const { appInForeground } = this; + + if (this.processSpawningDisabled) { + if (appInForeground) { + Glean.extensions.processEvent.crashed_over_threshold_fg.add(1); + } else { + Glean.extensions.processEvent.crashed_over_threshold_bg.add(1); + } + this.logger.warn( + `Extension process respawning disabled because it crashed too often in the last ${lazy.processCrashTimeframe}ms (${this.lastCrashTimestamps.length} > ${lazy.processCrashThreshold}).` + ); + } + + Glean.extensions.processEvent[ + appInForeground ? "crashed_fg" : "crashed_bg" + ].add(1); + Management.emit("extension-process-crash", { + childID, + processSpawningDisabled: this.processSpawningDisabled, + appInForeground, + }); + break; + } + } + }, + + enableProcessSpawning() { + const crashCounter = this.lastCrashTimestamps.length; + this.lastCrashTimestamps = []; + this.logger.debug(`reset crash counter (was ${crashCounter})`); + this._processSpawningDisabled = false; + Management.emit("extension-enable-process-spawning"); + }, + + get appInForeground() { + // Only account for application in the background for + // android builds. + return this._isAndroid ? this._appInForeground : true; + }, + + get processSpawningDisabled() { + return this._processSpawningDisabled; + }, +}; + +ExtensionProcessCrashObserver.init(); + +const manifestTypes = new Map([ + ["theme", "manifest.ThemeManifest"], + ["locale", "manifest.WebExtensionLangpackManifest"], + ["dictionary", "manifest.WebExtensionDictionaryManifest"], + ["extension", "manifest.WebExtensionManifest"], + // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed. + ["sitepermission-deprecated", "manifest.WebExtensionSitePermissionsManifest"], +]); + +/** + * Represents the data contained in an extension, contained either + * in a directory or a zip file, which may or may not be installed. + * This class implements the functionality of the Extension class, + * primarily related to manifest parsing and localization, which is + * useful prior to extension installation or initialization. + * + * No functionality of this class is guaranteed to work before + * `loadManifest` has been called, and completed. + */ +export class ExtensionData { + /** + * Note: These fields are only available and meant to be used on Extension + * instances, declared here because methods from this class reference them. + */ + /** @type {object} TODO: move to the Extension class, bug 1871094. */ + addonData; + /** @type {nsIURI} */ + baseURI; + /** @type {nsIPrincipal} */ + principal; + /** @type {boolean} */ + temporarilyInstalled; + + constructor(rootURI, isPrivileged = false) { + this.rootURI = rootURI; + this.resourceURL = rootURI.spec; + this.isPrivileged = isPrivileged; + + this.manifest = null; + this.type = null; + this.id = null; + this.uuid = null; + this.localeData = null; + this.fluentL10n = null; + this._promiseLocales = null; + + this.apiNames = new Set(); + this.dependencies = new Set(); + this.permissions = new Set(); + + this.startupData = null; + + this.errors = []; + this.warnings = []; + this.eventPagesEnabled = lazy.eventPagesEnabled; + } + + /** + * A factory function that allows the construction of ExtensionData, with + * the isPrivileged flag computed asynchronously. + * + * @param {object} options + * @param {nsIURI} options.rootURI + * The URI pointing to the extension root. + * @param {function(type, id): boolean} options.checkPrivileged + * An (async) function that takes the addon type and addon ID and returns + * whether the given add-on is privileged. + * @param {boolean} options.temporarilyInstalled + * whether the given add-on is installed as temporary. + * @returns {Promise<ExtensionData>} + */ + static async constructAsync({ + rootURI, + checkPrivileged, + temporarilyInstalled, + }) { + let extension = new ExtensionData(rootURI); + // checkPrivileged depends on the extension type and id. + await extension.initializeAddonTypeAndID(); + let { type, id } = extension; + extension.isPrivileged = await checkPrivileged(type, id); + extension.temporarilyInstalled = temporarilyInstalled; + return extension; + } + + static getIsPrivileged({ signedState, builtIn, temporarilyInstalled }) { + return ( + signedState === lazy.AddonManager.SIGNEDSTATE_PRIVILEGED || + signedState === lazy.AddonManager.SIGNEDSTATE_SYSTEM || + builtIn || + (lazy.AddonSettings.EXPERIMENTS_ENABLED && temporarilyInstalled) + ); + } + + get builtinMessages() { + return null; + } + + get logger() { + let id = this.id || "<unknown>"; + return lazy.Log.repository.getLogger(LOGGER_ID_BASE + id); + } + + /** + * Report an error about the extension's manifest file. + * + * @param {string} message The error message + */ + manifestError(message) { + this.packagingError(`Reading manifest: ${message}`); + } + + /** + * Report a warning about the extension's manifest file. + * + * @param {string} message The warning message + */ + manifestWarning(message) { + this.packagingWarning(`Reading manifest: ${message}`); + } + + // Report an error about the extension's general packaging. + packagingError(message) { + this.errors.push(message); + this.logError(message); + } + + packagingWarning(message) { + this.warnings.push(message); + this.logWarning(message); + } + + logWarning(message) { + this._logMessage(message, "warn"); + } + + logError(message) { + this._logMessage(message, "error"); + } + + _logMessage(message, severity) { + this.logger[severity](`Loading extension '${this.id}': ${message}`); + } + + ensureNoErrors() { + if (this.errors.length) { + // startup() repeatedly checks whether there are errors after parsing the + // extension/manifest before proceeding with starting up. + throw new Error(this.errors.join("\n")); + } + } + + /** + * Returns the moz-extension: URL for the given path within this + * extension. + * + * Must not be called unless either the `id` or `uuid` property has + * already been set. + * + * @param {string} path The path portion of the URL. + * @returns {string} + */ + getURL(path = "") { + if (!(this.id || this.uuid)) { + throw new Error( + "getURL may not be called before an `id` or `uuid` has been set" + ); + } + if (!this.uuid) { + this.uuid = UUIDMap.get(this.id); + } + return `moz-extension://${this.uuid}/${path}`; + } + + /** + * Discovers the file names within a directory or JAR file. + * + * @param {string} path + * The path to the directory or jar file to look at. + * @param {boolean} [directoriesOnly] + * If true, this will return only the directories present within the directory. + * @returns {Promise<string[]>} + * An array of names of files/directories (only the name, not the path). + */ + async _readDirectory(path, directoriesOnly = false) { + if (this.rootURI instanceof Ci.nsIFileURL) { + let uri = Services.io.newURI("./" + path, null, this.rootURI); + let fullPath = uri.QueryInterface(Ci.nsIFileURL).file.path; + + let results = []; + try { + let children = await IOUtils.getChildren(fullPath); + for (let child of children) { + if ( + !directoriesOnly || + (await IOUtils.stat(child)).type == "directory" + ) { + results.push(PathUtils.filename(child)); + } + } + } catch (ex) { + // Fall-through, return what we have. + } + return results; + } + + let uri = this.rootURI.QueryInterface(Ci.nsIJARURI); + + // Append the sub-directory path to the base JAR URI and normalize the + // result. + let entry = `${uri.JAREntry}/${path}/` + .replace(/\/\/+/g, "/") + .replace(/^\//, ""); + uri = Services.io.newURI(`jar:${uri.JARFile.spec}!/${entry}`); + + let results = []; + for (let name of lazy.aomStartup.enumerateJARSubtree(uri)) { + if (!name.startsWith(entry)) { + throw new Error("Unexpected ZipReader entry"); + } + + // The enumerator returns the full path of all entries. + // Trim off the leading path, and filter out entries from + // subdirectories. + name = name.slice(entry.length); + if ( + name && + !/\/./.test(name) && + (!directoriesOnly || name.endsWith("/")) + ) { + results.push(name.replace("/", "")); + } + } + + return results; + } + + readJSON(path) { + return new Promise((resolve, reject) => { + let uri = this.rootURI.resolve(`./${path}`); + + lazy.NetUtil.asyncFetch( + { uri, loadUsingSystemPrincipal: true }, + (inputStream, status) => { + if (!Components.isSuccessCode(status)) { + // Convert status code to a string + let e = Components.Exception("", status); + reject(new Error(`Error while loading '${uri}' (${e.name})`)); + return; + } + try { + let text = lazy.NetUtil.readInputStreamToString( + inputStream, + inputStream.available(), + { charset: "utf-8" } + ); + + text = text.replace(COMMENT_REGEXP, "$1"); + + resolve(JSON.parse(text)); + } catch (e) { + reject(e); + } + } + ); + }); + } + + get restrictSchemes() { + return !(this.isPrivileged && this.hasPermission("mozillaAddons")); + } + + /** + * Given an array of host and permissions, generate a structured permissions object + * that contains seperate host origins and permissions arrays. + * + * @param {Array} permissionsArray + * @param {Array} [hostPermissions] + * @returns {object} permissions object + */ + permissionsObject(permissionsArray = [], hostPermissions = []) { + let permissions = new Set(); + let origins = new Set(); + let { restrictSchemes, isPrivileged } = this; + + for (let perm of permissionsArray.concat(hostPermissions)) { + let type = classifyPermission(perm, restrictSchemes, isPrivileged); + if (type.origin) { + origins.add(perm); + } else if (type.permission) { + permissions.add(perm); + } + } + + return { + permissions, + origins, + }; + } + + /** + * Returns an object representing any capabilities that the extension + * has access to based on fixed properties in the manifest. The result + * includes the contents of the "permissions" property as well as other + * capabilities that are derived from manifest fields that users should + * be informed of (e.g., origins where content scripts are injected). + */ + get manifestPermissions() { + if (this.type !== "extension") { + return null; + } + + let { permissions } = this.permissionsObject(this.manifest.permissions); + + if ( + this.manifest.devtools_page && + !this.manifest.optional_permissions.includes("devtools") + ) { + permissions.add("devtools"); + } + + return { + permissions: Array.from(permissions), + origins: this.originControls ? [] : this.getManifestOrigins(), + }; + } + + /** + * @returns {string[]} all origins that are referenced in manifest via + * permissions, host_permissions, or content_scripts keys. + */ + getManifestOrigins() { + if (this.type !== "extension") { + return null; + } + + let { origins } = this.permissionsObject( + this.manifest.permissions, + this.manifest.host_permissions + ); + + for (let entry of this.manifest.content_scripts || []) { + for (let origin of entry.matches) { + origins.add(origin); + } + } + + return Array.from(origins); + } + + /** + * Returns optional permissions from the manifest, including host permissions + * if originControls is true. + */ + get manifestOptionalPermissions() { + if (this.type !== "extension") { + return null; + } + + let { permissions, origins } = this.permissionsObject( + this.manifest.optional_permissions + ); + if (this.originControls) { + for (let origin of this.getManifestOrigins()) { + origins.add(origin); + } + } + + return { + permissions: Array.from(permissions), + origins: Array.from(origins), + }; + } + + /** + * Returns an object representing all capabilities this extension has + * access to, including fixed ones from the manifest as well as dynamically + * granted permissions. + */ + get activePermissions() { + if (this.type !== "extension") { + return null; + } + + let result = { + origins: this.allowedOrigins.patterns + .map(matcher => matcher.pattern) + // moz-extension://id/* is always added to allowedOrigins, but it + // is not a valid host permission in the API. So, remove it. + .filter(pattern => !pattern.startsWith("moz-extension:")), + apis: [...this.apiNames], + }; + + const EXP_PATTERN = /^experiments\.\w+/; + result.permissions = [...this.permissions].filter( + p => !result.origins.includes(p) && !EXP_PATTERN.test(p) + ); + return result; + } + + // Returns whether the front end should prompt for this permission + static async shouldPromptFor(permission) { + return !(await lazy.NO_PROMPT_PERMISSIONS).has(permission); + } + + // Compute the difference between two sets of permissions, suitable + // for presenting to the user. + static comparePermissions(oldPermissions, newPermissions) { + let oldMatcher = new MatchPatternSet(oldPermissions.origins, { + restrictSchemes: false, + }); + return { + // formatPermissionStrings ignores any scheme, so only look at the domain. + origins: newPermissions.origins.filter( + perm => + !oldMatcher.subsumesDomain( + new MatchPattern(perm, { restrictSchemes: false }) + ) + ), + permissions: newPermissions.permissions.filter( + perm => !oldPermissions.permissions.includes(perm) + ), + }; + } + + // Return those permissions in oldPermissions that also exist in newPermissions. + static intersectPermissions(oldPermissions, newPermissions) { + let matcher = new MatchPatternSet(newPermissions.origins, { + restrictSchemes: false, + }); + + return { + origins: oldPermissions.origins.filter(perm => + matcher.subsumesDomain( + new MatchPattern(perm, { restrictSchemes: false }) + ) + ), + permissions: oldPermissions.permissions.filter(perm => + newPermissions.permissions.includes(perm) + ), + }; + } + + /** + * When updating the addon, find and migrate permissions that have moved from required + * to optional. This also handles any updates required for permission removal. + * + * @param {string} id The id of the addon being updated + * @param {object} oldPermissions + * @param {object} oldOptionalPermissions + * @param {object} newPermissions + * @param {object} newOptionalPermissions + */ + static async migratePermissions( + id, + oldPermissions, + oldOptionalPermissions, + newPermissions, + newOptionalPermissions + ) { + let migrated = ExtensionData.intersectPermissions( + oldPermissions, + newOptionalPermissions + ); + // If a permission is optional in this version and was mandatory in the previous + // version, it was already accepted by the user at install time so add it to the + // list of granted optional permissions now. + await lazy.ExtensionPermissions.add(id, migrated); + + // Now we need to update ExtensionPreferencesManager, removing any settings + // for old permissions that no longer exist. + let permSet = new Set( + newPermissions.permissions.concat(newOptionalPermissions.permissions) + ); + let oldPerms = oldPermissions.permissions.concat( + oldOptionalPermissions.permissions + ); + + let removed = oldPerms.filter(x => !permSet.has(x)); + // Force the removal here to ensure the settings are removed prior + // to startup. This will remove both required or optional permissions, + // whereas the call from within ExtensionPermissions would only result + // in a removal for optional permissions that were removed. + await lazy.ExtensionPreferencesManager.removeSettingsForPermissions( + id, + removed + ); + + // Remove any optional permissions that have been removed from the manifest. + await lazy.ExtensionPermissions.remove(id, { + permissions: removed, + origins: [], + }); + } + + canUseAPIExperiment() { + return ( + this.type == "extension" && + (this.isPrivileged || + // TODO(Bug 1771341): Allowing the "experiment_apis" property when only + // AddonSettings.EXPERIMENTS_ENABLED is true is currently needed to allow, + // while running under automation, the test harness extensions (like mochikit + // and specialpowers) to use that privileged manifest property. + lazy.AddonSettings.EXPERIMENTS_ENABLED) + ); + } + + canUseThemeExperiment() { + return ( + ["extension", "theme"].includes(this.type) && + (this.isPrivileged || + // "theme_experiment" MDN docs are currently explicitly mentioning this is expected + // to be allowed also for non-signed extensions installed non-temporarily on builds + // where the signature checks can be disabled). + // + // NOTE: be careful to don't regress "theme_experiment" (see Bug 1773076) while changing + // AddonSettings.EXPERIMENTS_ENABLED (e.g. as part of fixing Bug 1771341). + lazy.AddonSettings.EXPERIMENTS_ENABLED) + ); + } + + get manifestVersion() { + return this.manifest.manifest_version; + } + + get persistentBackground() { + let { manifest } = this; + if ( + !manifest.background || + (manifest.background.service_worker && + WebExtensionPolicy.backgroundServiceWorkerEnabled) || + this.manifestVersion > 2 + ) { + return false; + } + // V2 addons can only use event pages if the pref is also flipped and + // persistent is explicilty set to false. + return !this.eventPagesEnabled || manifest.background.persistent; + } + + /** + * backgroundState can be starting, running, suspending or stopped. + * It is undefined if the extension has no background page. + * See ext-backgroundPage.js for more details. + * + * @param {string} state starting, running, suspending or stopped + */ + set backgroundState(state) { + this._backgroundState = state; + } + + get backgroundState() { + return this._backgroundState; + } + + async getExtensionVersionWithoutValidation() { + return (await this.readJSON("manifest.json")).version; + } + + /** + * Load a locale and return a localized manifest. The extension must + * be initialized, and manifest parsed prior to calling. + * + * @param {string} locale to load, if necessary. + * @returns {Promise<object>} normalized manifest. + */ + async getLocalizedManifest(locale) { + if (!this.type || !this.localeData) { + throw new Error("The extension has not been initialized."); + } + // Upon update or reinstall, the Extension.manifest may be read from + // StartupCache.manifest, however rawManifest is *not*. We need the + // raw manifest in order to get a localized manifest. + if (!this.rawManifest) { + this.rawManifest = await this.readJSON("manifest.json"); + } + + if (!this.localeData.has(locale)) { + // Locales are not avialable until some additional + // initialization is done. We could just call initAllLocales, + // but that is heavy handed, especially when we likely only + // need one out of 20. + let locales = await this.promiseLocales(); + if (locales.get(locale)) { + await this.initLocale(locale); + } + if (!this.localeData.has(locale)) { + throw new Error(`The extension does not contain the locale ${locale}`); + } + } + let normalized = await this._getNormalizedManifest(locale); + if (normalized.error) { + throw new Error(normalized.error); + } + return normalized.value; + } + + async _getNormalizedManifest(locale) { + let manifestType = manifestTypes.get(this.type); + + let context = { + url: this.baseURI && this.baseURI.spec, + principal: this.principal, + logError: error => { + this.manifestWarning(error); + }, + preprocessors: {}, + manifestVersion: this.manifestVersion, + }; + + if (this.fluentL10n || this.localeData) { + context.preprocessors.localize = (value, context) => + this.localize(value, locale); + } + + return lazy.Schemas.normalize(this.rawManifest, manifestType, context); + } + + #parseBrowserStyleInManifest(manifest, manifestKey, defaultValueInMV2) { + const obj = manifest[manifestKey]; + if (!obj) { + return; + } + const browserStyleIsVoid = obj.browser_style == null; + obj.browser_style ??= defaultValueInMV2; + if (this.manifestVersion < 3 || !obj.browser_style) { + // MV2 (true or false), or MV3 (false set explicitly or default false). + // No changes in observed behavior, return now to avoid logspam. + return; + } + // Now there are two cases (MV3 only): + // - browser_style was not specified, but defaults to true. + // - browser_style was set to true by the extension. + // + // These will eventually be deprecated. For the deprecation plan, see + // https://bugzilla.mozilla.org/show_bug.cgi?id=1827910#c1 + let warning; + if (!lazy.browserStyleMV3supported) { + obj.browser_style = false; + if (browserStyleIsVoid && !lazy.browserStyleMV3sameAsMV2) { + // defaultValueInMV2 is true, but there was no intent to use these + // defaults. Don't warn. + return; + } + warning = `"browser_style:true" is no longer supported in Manifest Version 3.`; + } else { + warning = `"browser_style:true" has been deprecated in Manifest Version 3 and will be unsupported in the near future.`; + } + if (browserStyleIsVoid) { + warning += ` While "${manifestKey}.browser_style" was not explicitly specified in manifest.json, its default value was true.`; + if (!lazy.browserStyleMV3sameAsMV2) { + obj.browser_style = false; + warning += ` The default value of "${manifestKey}.browser_style" has changed from true to false in Manifest Version 3.`; + } else { + warning += ` Its default will change to false in Manifest Version 3 starting from Firefox 115.`; + } + } + + this.manifestWarning( + `Warning processing ${manifestKey}.browser_style: ${warning}` + ); + } + + async initializeAddonTypeAndID() { + if (this.type) { + // Already initialized. + return; + } + this.rawManifest = await this.readJSON("manifest.json"); + let manifest = this.rawManifest; + + if (manifest.theme) { + this.type = "theme"; + } else if (manifest.langpack_id) { + this.type = "locale"; + } else if (manifest.dictionaries) { + this.type = "dictionary"; + } else if (manifest.site_permissions) { + // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed. + this.type = "sitepermission-deprecated"; + } else { + this.type = "extension"; + } + + if (!this.id) { + let bss = + manifest.browser_specific_settings?.gecko || + manifest.applications?.gecko; + let id = bss?.id; + // This is a basic type check. + // When parseManifest is called, the ID is validated more thoroughly + // because the id is defined to be an ExtensionID type in + // toolkit/components/extensions/schemas/manifest.json + if (typeof id == "string") { + this.id = id; + } + } + } + + // eslint-disable-next-line complexity + async parseManifest() { + await Promise.all([this.initializeAddonTypeAndID(), Management.lazyInit()]); + + let manifest = this.rawManifest; + this.manifest = manifest; + + if (manifest.default_locale) { + await this.initLocale(); + } + + if (manifest.l10n_resources) { + if (this.isPrivileged) { + // TODO (Bug 1733466): For historical reasons fluent isn't being used to + // localize manifest properties read from the add-on manager (e.g., author, + // homepage, etc.), the changes introduced by Bug 1734987 does now ensure + // that isPrivileged will be set while parsing the manifest and so this + // can be now supported but requires some additional changes, being tracked + // by Bug 1733466. + if (this.constructor != ExtensionData) { + this.fluentL10n = new Localization(manifest.l10n_resources, true); + } + } else if (this.temporarilyInstalled) { + this.manifestError( + `Using 'l10n_resources' requires a privileged add-on. ` + + PRIVILEGED_ADDONS_DEVDOCS_MESSAGE + ); + } else { + // Warn but don't make this fatal. + this.manifestWarning( + "Ignoring l10n_resources in unprivileged extension" + ); + } + } + + let normalized = await this._getNormalizedManifest(); + if (normalized.error) { + this.manifestError(normalized.error); + return null; + } + + manifest = normalized.value; + + // `browser_specific_settings` is the recommended key to use in the + // manifest, and the only possible choice in MV3+. For MV2 extensions, we + // still allow `applications`, though. Because `applications` used to be + // the only key in the distant past, most internal code is written using + // applications. That's why we end up re-assigning `browser_specific_settings` + // to `applications` below. + // + // Also, when a MV3+ extension specifies `applications`, the key isn't + // recognized and therefore filtered out from the normalized manifest as + // part of the JSONSchema normalization. + if (manifest.browser_specific_settings?.gecko) { + if (manifest.applications) { + this.manifestWarning( + `"applications" property ignored and overridden by "browser_specific_settings"` + ); + } + manifest.applications = manifest.browser_specific_settings; + } + + // On Android, override the browser specific settings with those found in + // `bss.gecko_android`, if any. + // + // It is also worth noting that the `gecko_android` key in `applications` + // is marked as "unsupported" in the JSON schema. + if ( + AppConstants.platform == "android" && + manifest.browser_specific_settings?.gecko_android + ) { + const { strict_min_version, strict_max_version } = + manifest.browser_specific_settings.gecko_android; + + // When the manifest doesn't define `browser_specific_settings.gecko`, it + // is still possible to reach this block but `manifest.applications` + // won't be defined yet. + if (!manifest?.applications) { + manifest.applications = { + // All properties should be optional in `gecko` so we omit them here. + gecko: {}, + }; + } + + if (strict_min_version?.length) { + manifest.applications.gecko.strict_min_version = strict_min_version; + } + + if (strict_max_version?.length) { + manifest.applications.gecko.strict_max_version = strict_max_version; + } + } + + if ( + this.manifestVersion < 3 && + manifest.background && + !this.eventPagesEnabled && + !manifest.background.persistent + ) { + this.logWarning("Event pages are not currently supported."); + } + + if ( + this.isPrivileged && + manifest.hidden && + (manifest.action || manifest.browser_action || manifest.page_action) + ) { + this.manifestError( + "Cannot use browser and/or page actions in hidden add-ons" + ); + } + + if (manifest.options_ui) { + if (manifest.options_ui.open_in_tab) { + // browser_style:true has no effect when open_in_tab is true. + manifest.options_ui.browser_style = false; + } else { + this.#parseBrowserStyleInManifest(manifest, "options_ui", true); + } + } + if (this.manifestVersion < 3) { + this.#parseBrowserStyleInManifest(manifest, "browser_action", false); + } else { + this.#parseBrowserStyleInManifest(manifest, "action", false); + } + this.#parseBrowserStyleInManifest(manifest, "page_action", false); + if (AppConstants.MOZ_BUILD_APP === "browser") { + this.#parseBrowserStyleInManifest(manifest, "sidebar_action", true); + } + + let apiNames = new Set(); + let dependencies = new Set(); + let originPermissions = new Set(); + let permissions = new Set(); + let webAccessibleResources = []; + + let schemaPromises = new Map(); + + // Note: this.id and this.type were computed in initializeAddonTypeAndID. + // The format of `this.id` was confirmed to be a valid extensionID by the + // Schema validation as part of the _getNormalizedManifest() call. + let result = { + apiNames, + dependencies, + id: this.id, + manifest, + modules: null, + // Whether to treat all origin permissions (including content scripts) + // from the manifestas as optional, and enable users to control them. + originControls: this.manifestVersion >= 3, + originPermissions, + permissions, + schemaURLs: null, + type: this.type, + webAccessibleResources, + }; + + if (this.type === "extension") { + let { isPrivileged } = this; + let restrictSchemes = !( + isPrivileged && manifest.permissions.includes("mozillaAddons") + ); + + // Privileged and temporary extensions still get OriginControls, but + // can have host permissions automatically granted during install. + // For all other cases, ensure granted_host_permissions is false. + if (!isPrivileged && !this.temporarilyInstalled) { + manifest.granted_host_permissions = false; + } + + let host_permissions = manifest.host_permissions ?? []; + + for (let perm of manifest.permissions.concat(host_permissions)) { + if (perm === "geckoProfiler" && !isPrivileged) { + const acceptedExtensions = Services.prefs.getStringPref( + "extensions.geckoProfiler.acceptedExtensionIds", + "" + ); + if (!acceptedExtensions.split(",").includes(this.id)) { + this.manifestError( + "Only specific extensions are allowed to access the geckoProfiler." + ); + continue; + } + } + + let type = classifyPermission(perm, restrictSchemes, isPrivileged); + if (type.origin) { + perm = type.origin; + if (!result.originControls) { + originPermissions.add(perm); + } + } else if (type.api) { + apiNames.add(type.api); + } else if (type.invalid) { + // If EXPERIMENTS_ENABLED is not enabled prevent the install + // to ensure developer awareness. + if (this.temporarilyInstalled && type.privileged) { + this.manifestError( + `Using the privileged permission '${perm}' requires a privileged add-on. ` + + PRIVILEGED_ADDONS_DEVDOCS_MESSAGE + ); + continue; + } + this.manifestWarning(`Invalid extension permission: ${perm}`); + continue; + } + + // Unfortunately, we treat <all_urls> as an API permission as well. + if (!type.origin || (perm === "<all_urls>" && !result.originControls)) { + permissions.add(perm); + } + } + + if (this.id) { + // An extension always gets permission to its own url. + let matcher = new MatchPattern(this.getURL(), { ignorePath: true }); + originPermissions.add(matcher.pattern); + + // Apply optional permissions + let perms = await lazy.ExtensionPermissions.get(this.id); + for (let perm of perms.permissions) { + permissions.add(perm); + } + for (let origin of perms.origins) { + originPermissions.add(origin); + } + } + + for (let api of apiNames) { + dependencies.add(`${api}@experiments.addons.mozilla.org`); + } + + let moduleData = data => ({ + url: this.rootURI.resolve(data.script), + events: data.events, + paths: data.paths, + scopes: data.scopes, + }); + + let computeModuleInit = (scope, modules) => { + let manager = new ExtensionCommon.SchemaAPIManager(scope); + return manager.initModuleJSON([modules]); + }; + + result.contentScripts = []; + for (let options of manifest.content_scripts || []) { + result.contentScripts.push({ + allFrames: options.all_frames, + matchAboutBlank: options.match_about_blank, + frameID: options.frame_id, + runAt: options.run_at, + + matches: options.matches, + excludeMatches: options.exclude_matches || [], + includeGlobs: options.include_globs, + excludeGlobs: options.exclude_globs, + + jsPaths: options.js || [], + cssPaths: options.css || [], + }); + } + + if (manifest.experiment_apis) { + if (this.canUseAPIExperiment()) { + let parentModules = {}; + let childModules = {}; + + for (let [name, data] of Object.entries(manifest.experiment_apis)) { + let schema = this.getURL(data.schema); + + if (!schemaPromises.has(schema)) { + schemaPromises.set( + schema, + this.readJSON(data.schema).then(json => + lazy.Schemas.processSchema(json) + ) + ); + } + + if (data.parent) { + parentModules[name] = moduleData(data.parent); + } + + if (data.child) { + childModules[name] = moduleData(data.child); + } + } + + result.modules = { + child: computeModuleInit("addon_child", childModules), + parent: computeModuleInit("addon_parent", parentModules), + }; + } else if (this.temporarilyInstalled) { + // Hard error for un-privileged temporary installs using experimental apis. + this.manifestError( + `Using 'experiment_apis' requires a privileged add-on. ` + + PRIVILEGED_ADDONS_DEVDOCS_MESSAGE + ); + } else { + this.manifestWarning( + `Using experimental APIs requires a privileged add-on.` + ); + } + } + + // Normalize all patterns to contain a single leading / + if (manifest.web_accessible_resources) { + // Normalize into V3 objects + let wac = + this.manifestVersion >= 3 + ? manifest.web_accessible_resources + : [{ resources: manifest.web_accessible_resources }]; + webAccessibleResources.push( + ...wac.map(obj => { + obj.resources = obj.resources.map(path => + path.replace(/^\/*/, "/") + ); + return obj; + }) + ); + } + } else if (this.type == "locale") { + // Langpack startup is performance critical, so we want to compute as much + // as possible here to make startup not trigger async DB reads. + // We'll store the four items below in the startupData. + + // 1. Compute the chrome resources to be registered for this langpack. + const platform = AppConstants.platform; + const chromeEntries = []; + for (const [language, entry] of Object.entries(manifest.languages)) { + for (const [alias, path] of Object.entries( + entry.chrome_resources || {} + )) { + if (typeof path === "string") { + chromeEntries.push(["locale", alias, language, path]); + } else if (platform in path) { + // If the path is not a string, it's an object with path per + // platform where the keys are taken from AppConstants.platform + chromeEntries.push(["locale", alias, language, path[platform]]); + } + } + } + + // 2. Compute langpack ID. + const productCodeName = AppConstants.MOZ_BUILD_APP.replace("/", "-"); + + // The result path looks like this: + // Firefox - `langpack-pl-browser` + // Fennec - `langpack-pl-mobile-android` + const langpackId = `langpack-${manifest.langpack_id}-${productCodeName}`; + + // 3. Compute L10nRegistry sources for this langpack. + const l10nRegistrySources = {}; + + // Check if there's a root directory `/localization` in the langpack. + // If there is one, add it with the name `toolkit` as a FileSource. + const entries = await this._readDirectory("localization"); + if (entries.length) { + l10nRegistrySources.toolkit = ""; + } + + // Add any additional sources listed in the manifest + if (manifest.sources) { + for (const [sourceName, { base_path }] of Object.entries( + manifest.sources + )) { + l10nRegistrySources[sourceName] = base_path; + } + } + + // 4. Save the list of languages handled by this langpack. + const languages = Object.keys(manifest.languages); + + this.startupData = { + chromeEntries, + langpackId, + l10nRegistrySources, + languages, + }; + } else if (this.type == "dictionary") { + let dictionaries = {}; + for (let [lang, path] of Object.entries(manifest.dictionaries)) { + path = path.replace(/^\/+/, ""); + + let dir = dirname(path); + if (dir === ".") { + dir = ""; + } + let leafName = basename(path); + let affixPath = leafName.slice(0, -3) + "aff"; + + let entries = await this._readDirectory(dir); + if (!entries.includes(leafName)) { + this.manifestError( + `Invalid dictionary path specified for '${lang}': ${path}` + ); + } + if (!entries.includes(affixPath)) { + this.manifestError( + `Invalid dictionary path specified for '${lang}': Missing affix file: ${path}` + ); + } + + dictionaries[lang] = path; + } + + this.startupData = { dictionaries }; + } + + if (schemaPromises.size) { + let schemas = new Map(); + for (let [url, promise] of schemaPromises) { + schemas.set(url, await promise); + } + result.schemaURLs = schemas; + } + + return result; + } + + // Reads the extension's |manifest.json| file, and stores its + // parsed contents in |this.manifest|. + async loadManifest() { + let [manifestData] = await Promise.all([ + this.parseManifest(), + Management.lazyInit(), + ]); + + if (!manifestData) { + return; + } + + // Do not override the add-on id that has been already assigned. + if (!this.id) { + this.id = manifestData.id; + } + + this.manifest = manifestData.manifest; + this.apiNames = manifestData.apiNames; + this.contentScripts = manifestData.contentScripts; + this.dependencies = manifestData.dependencies; + this.permissions = manifestData.permissions; + this.schemaURLs = manifestData.schemaURLs; + this.type = manifestData.type; + + this.modules = manifestData.modules; + + this.apiManager = this.getAPIManager(); + await this.apiManager.lazyInit(); + + this.webAccessibleResources = manifestData.webAccessibleResources; + + this.originControls = manifestData.originControls; + this.allowedOrigins = new MatchPatternSet(manifestData.originPermissions, { + restrictSchemes: this.restrictSchemes, + }); + + return this.manifest; + } + + hasPermission(perm, includeOptional = false) { + // If the permission is a "manifest property" permission, we check if the extension + // does have the required property in its manifest. + let manifest_ = "manifest:"; + if (perm.startsWith(manifest_)) { + // Handle nested "manifest property" permission (e.g. as in "manifest:property.nested"). + let value = this.manifest; + for (let prop of perm.substr(manifest_.length).split(".")) { + if (!value) { + break; + } + value = value[prop]; + } + + return value != null; + } + + if (this.permissions.has(perm)) { + return true; + } + + if (includeOptional && this.manifest.optional_permissions.includes(perm)) { + return true; + } + + return false; + } + + getAPIManager() { + /** @type {(InstanceType<typeof ExtensionCommon.LazyAPIManager>)[]} */ + let apiManagers = [Management]; + + for (let id of this.dependencies) { + let policy = WebExtensionPolicy.getByID(id); + if (policy) { + if (policy.extension.experimentAPIManager) { + apiManagers.push(policy.extension.experimentAPIManager); + } else if (AppConstants.DEBUG) { + Cu.reportError(`Cannot find experimental API exported from ${id}`); + } + } + } + + if (this.modules) { + this.experimentAPIManager = new ExtensionCommon.LazyAPIManager( + "main", + this.modules.parent, + this.schemaURLs + ); + + apiManagers.push(this.experimentAPIManager); + } + + if (apiManagers.length == 1) { + return apiManagers[0]; + } + + return new ExtensionCommon.MultiAPIManager("main", apiManagers.reverse()); + } + + localizeMessage(...args) { + return this.localeData.localizeMessage(...args); + } + + localize(str, locale) { + // If the extension declares fluent resources in the manifest, try + // first to localize with fluent. Also use the original webextension + // method (_locales/xx.json) so extensions can migrate bit by bit. + // Note also that fluent keys typically use hyphense, so hyphens are + // allowed in the __MSG_foo__ keys used by fluent, though they are + // not allowed in the keys used for json translations. + if (this.fluentL10n) { + str = str.replace(/__MSG_([-A-Za-z0-9@_]+?)__/g, (matched, message) => { + let translation = this.fluentL10n.formatValueSync(message); + return translation !== undefined ? translation : matched; + }); + } + if (this.localeData) { + str = this.localeData.localize(str, locale); + } + return str; + } + + // If a "default_locale" is specified in that manifest, returns it + // as a Gecko-compatible locale string. Otherwise, returns null. + get defaultLocale() { + if (this.manifest.default_locale != null) { + return this.normalizeLocaleCode(this.manifest.default_locale); + } + + return null; + } + + // Returns true if an addon is builtin to Firefox or + // distributed via Normandy into a system location. + get isAppProvided() { + return this.addonData.builtIn || this.addonData.isSystem; + } + + get isHidden() { + return ( + this.addonData.locationHidden || + (this.isPrivileged && this.manifest.hidden) + ); + } + + // Normalizes a Chrome-compatible locale code to the appropriate + // Gecko-compatible variant. Currently, this means simply + // replacing underscores with hyphens. + normalizeLocaleCode(locale) { + return locale.replace(/_/g, "-"); + } + + // Reads the locale file for the given Gecko-compatible locale code, and + // stores its parsed contents in |this.localeMessages.get(locale)|. + async readLocaleFile(locale) { + let locales = await this.promiseLocales(); + let dir = locales.get(locale) || locale; + let file = `_locales/${dir}/messages.json`; + + try { + let messages = await this.readJSON(file); + return this.localeData.addLocale(locale, messages, this); + } catch (e) { + this.packagingError(`Loading locale file ${file}: ${e}`); + return new Map(); + } + } + + async _promiseLocaleMap() { + let locales = new Map(); + + let entries = await this._readDirectory("_locales", true); + for (let name of entries) { + let locale = this.normalizeLocaleCode(name); + locales.set(locale, name); + } + + return locales; + } + + _setupLocaleData(locales) { + if (this.localeData) { + return this.localeData.locales; + } + + this.localeData = new lazy.LocaleData({ + defaultLocale: this.defaultLocale, + locales, + builtinMessages: this.builtinMessages, + }); + + return locales; + } + + // Reads the list of locales available in the extension, and returns a + // Promise which resolves to a Map upon completion. + // Each map key is a Gecko-compatible locale code, and each value is the + // "_locales" subdirectory containing that locale: + // + // Map(gecko-locale-code -> locale-directory-name) + promiseLocales() { + if (!this._promiseLocales) { + this._promiseLocales = (async () => { + let locales = this._promiseLocaleMap(); + return this._setupLocaleData(locales); + })(); + } + + return this._promiseLocales; + } + + // Reads the locale messages for all locales, and returns a promise which + // resolves to a Map of locale messages upon completion. Each key in the map + // is a Gecko-compatible locale code, and each value is a locale data object + // as returned by |readLocaleFile|. + async initAllLocales() { + let locales = await this.promiseLocales(); + + await Promise.all( + Array.from(locales.keys(), locale => this.readLocaleFile(locale)) + ); + + let defaultLocale = this.defaultLocale; + if (defaultLocale) { + if (!locales.has(defaultLocale)) { + this.manifestError( + 'Value for "default_locale" property must correspond to ' + + 'a directory in "_locales/". Not found: ' + + JSON.stringify(`_locales/${this.manifest.default_locale}/`) + ); + } + } else if (locales.size) { + this.manifestError( + 'The "default_locale" property is required when a ' + + '"_locales/" directory is present.' + ); + } + + return this.localeData.messages; + } + + // Reads the locale file for the given Gecko-compatible locale code, or the + // default locale if no locale code is given, and sets it as the currently + // selected locale on success. + // + // Pre-loads the default locale for fallback message processing, regardless + // of the locale specified. + // + // If no locales are unavailable, resolves to |null|. + async initLocale(locale = this.defaultLocale) { + if (locale == null) { + return null; + } + + let promises = [this.readLocaleFile(locale)]; + + let { defaultLocale } = this; + if (locale != defaultLocale && !this.localeData.has(defaultLocale)) { + promises.push(this.readLocaleFile(defaultLocale)); + } + + let results = await Promise.all(promises); + + this.localeData.selectedLocale = locale; + return results[0]; + } + + /** + * @param {string} origin + * @returns {boolean} If this is one of the "all sites" permission. + */ + static isAllSitesPermission(origin) { + try { + let info = ExtensionData.classifyOriginPermissions([origin], true); + return !!info.allUrls; + } catch (e) { + // Passed string is not an origin permission. + return false; + } + } + + /** + * @typedef {object} HostPermissions + * @param {string} allUrls permission used to obtain all urls access + * @param {Set} wildcards set contains permissions with wildcards + * @param {Set} sites set contains explicit host permissions + * @param {Map} wildcardsMap mapping origin wildcards to labels + * @param {Map} sitesMap mapping origin patterns to labels + */ + + /** + * Classify host permissions + * + * @param {Array<string>} origins + * permission origins + * @param {boolean} ignoreNonWebSchemes + * return only these schemes: *, http, https, ws, wss + * + * @returns {HostPermissions} + */ + static classifyOriginPermissions(origins = [], ignoreNonWebSchemes = false) { + let allUrls = null, + wildcards = new Set(), + sites = new Set(), + // TODO: use map.values() instead of these sets. Note: account for two + // match patterns producing the same permission string, see bug 1765828. + wildcardsMap = new Map(), + sitesMap = new Map(); + + // https://searchfox.org/mozilla-central/rev/6f6cf28107/toolkit/components/extensions/MatchPattern.cpp#235 + const wildcardSchemes = ["*", "http", "https", "ws", "wss"]; + + for (let permission of origins) { + if (permission == "<all_urls>") { + allUrls = permission; + continue; + } + + // Privileged extensions may request access to "about:"-URLs, such as + // about:reader. + let match = /^([a-z*]+):\/\/([^/]*)\/|^about:/.exec(permission); + if (!match) { + throw new Error(`Unparseable host permission ${permission}`); + } + + // Note: the scheme is ignored in the permission warnings. If this ever + // changes, update the comparePermissions method as needed. + let [, scheme, host] = match; + if (ignoreNonWebSchemes && !wildcardSchemes.includes(scheme)) { + continue; + } + + if (!host || host == "*") { + if (!allUrls) { + allUrls = permission; + } + } else if (host.startsWith("*.")) { + wildcards.add(host.slice(2)); + // Using MatchPattern to normalize the pattern string. + let pat = new MatchPattern(permission, { ignorePath: true }); + wildcardsMap.set(pat.pattern, `${scheme}://${host.slice(2)}`); + } else { + sites.add(host); + let pat = new MatchPattern(permission, { + ignorePath: true, + // Safe because used just for normalization, not for granting access. + restrictSchemes: false, + }); + sitesMap.set(pat.pattern, `${scheme}://${host}`); + } + } + return { allUrls, wildcards, sites, wildcardsMap, sitesMap }; + } + + /** + * @typedef {object} Permissions + * @property {Array<string>} origins Origin permissions. + * @property {Array<string>} permissions Regular (non-origin) permissions. + */ + + /** + * Formats all the strings for a permissions dialog/notification. + * + * @param {object} info Information about the permissions being requested. + * + * @param {object} [info.addon] Optional information about the addon. + * @param {Permissions} [info.optionalPermissions] + * Optional permissions listed in the manifest. + * @param {Permissions} info.permissions Requested permissions. + * @param {string} info.siteOrigin + * @param {Array<string>} [info.sitePermissions] + * @param {boolean} info.unsigned + * True if the prompt is for installing an unsigned addon. + * @param {string} info.type + * The type of prompt being shown. May be one of "update", + * "sideload", "optional", or omitted for a regular + * install prompt. + * @param {object} options + * @param {boolean} [options.collapseOrigins] + * Wether to limit the number of displayed host permissions. + * Default is false. + * @param {boolean} [options.buildOptionalOrigins] + * Wether to build optional origins Maps for permission + * controls. Defaults to false. + * + * @returns {object} An object with properties containing localized strings + * for various elements of a permission dialog. The "header" + * property on this object is the notification header text + * and it has the string "<>" as a placeholder for the + * addon name. + * + * "object.msgs" is an array of localized strings describing required permissions + * + * "object.optionalPermissions" is a map of permission name to localized + * strings describing the permission. + * + * "object.optionalOrigins" is a map of a host permission to localized strings + * describing the host permission, where appropriate. Currently only + * all url style permissions are included. + */ + static formatPermissionStrings( + { + addon, + optionalPermissions, + permissions, + siteOrigin, + sitePermissions, + type, + unsigned, + }, + { collapseOrigins = false, buildOptionalOrigins = false } = {} + ) { + const l10n = lazy.PERMISSION_L10N; + + const msgIds = []; + const headerArgs = { extension: "<>" }; + let acceptId = "webext-perms-add"; + let cancelId = "webext-perms-cancel"; + + const result = { + msgs: [], + optionalPermissions: {}, + optionalOrigins: {}, + text: "", + listIntro: "", + }; + + // To keep the label & accesskey in sync for localizations, + // they need to be stored as attributes of the same Fluent message. + // This unpacks them into the shape expected of them in `result`. + function setAcceptCancel(acceptId, cancelId) { + const haveAccessKeys = AppConstants.platform !== "android"; + + const [accept, cancel] = l10n.formatMessagesSync([ + { id: acceptId }, + { id: cancelId }, + ]); + + for (let { name, value } of accept.attributes) { + if (name === "label") { + result.acceptText = value; + } else if (name === "accesskey" && haveAccessKeys) { + result.acceptKey = value; + } + } + + for (let { name, value } of cancel.attributes) { + if (name === "label") { + result.cancelText = value; + } else if (name === "accesskey" && haveAccessKeys) { + result.cancelKey = value; + } + } + } + + // Synthetic addon install can only grant access to a single permission so we can have + // a less-generic message than addons with site permissions. + // NOTE: this is used as part of the synthetic addon install flow implemented for the + // SitePermissionAddonProvider. + // (and so it should not be removed as part of Bug 1789718 changes, while this additional note should be). + // FIXME + if (addon?.type === lazy.SITEPERMS_ADDON_TYPE) { + // We simplify the origin to make it more user friendly. The origin is assured to be + // available because the SitePermsAddon install is always expected to be triggered + // from a website, making the siteOrigin always available through the installing principal. + headerArgs.hostname = new URL(siteOrigin).hostname; + + // messages are specific to the type of gated permission being installed + const headerId = + sitePermissions[0] === "midi-sysex" + ? "webext-site-perms-header-with-gated-perms-midi-sysex" + : "webext-site-perms-header-with-gated-perms-midi"; + result.header = l10n.formatValueSync(headerId, headerArgs); + + // We use the same string for midi and midi-sysex, and don't support any + // other types of site permission add-ons. So we just hard-code the + // descriptor for now. See bug 1826747. + result.text = l10n.formatValueSync( + "webext-site-perms-description-gated-perms-midi" + ); + + setAcceptCancel(acceptId, cancelId); + return result; + } + + // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed. + if (sitePermissions) { + for (let permission of sitePermissions) { + let permMsg; + switch (permission) { + case "midi": + permMsg = l10n.formatValueSync("webext-site-perms-midi"); + break; + case "midi-sysex": + permMsg = l10n.formatValueSync("webext-site-perms-midi-sysex"); + break; + default: + Cu.reportError( + `site_permission ${permission} missing readable text property` + ); + // We must never have a DOM api permission that is hidden so in + // the case of any error, we'll use the plain permission string. + // test_ext_sitepermissions.js tests for no missing messages, this + // is just an extra fallback. + permMsg = permission; + } + result.msgs.push(permMsg); + } + + // We simplify the origin to make it more user friendly. The origin is + // assured to be available via schema requirement. + headerArgs.hostname = new URL(siteOrigin).hostname; + + const headerId = unsigned + ? "webext-site-perms-header-unsigned-with-perms" + : "webext-site-perms-header-with-perms"; + result.header = l10n.formatValueSync(headerId, headerArgs); + setAcceptCancel(acceptId, cancelId); + return result; + } + + if (permissions) { + // First classify our host permissions + let { allUrls, wildcards, sites } = + ExtensionData.classifyOriginPermissions(permissions.origins); + + // Format the host permissions. If we have a wildcard for all urls, + // a single string will suffice. Otherwise, show domain wildcards + // first, then individual host permissions. + if (allUrls) { + msgIds.push("webext-perms-host-description-all-urls"); + } else { + // Formats a list of host permissions. If we have 4 or fewer, display + // them all, otherwise display the first 3 followed by an item that + // says "...plus N others" + const addMessages = (set, l10nId, moreL10nId) => { + if (collapseOrigins && set.size > 4) { + for (let domain of Array.from(set).slice(0, 3)) { + msgIds.push({ id: l10nId, args: { domain } }); + } + msgIds.push({ + id: moreL10nId, + args: { domainCount: set.size - 3 }, + }); + } else { + for (let domain of set) { + msgIds.push({ id: l10nId, args: { domain } }); + } + } + }; + + addMessages( + wildcards, + "webext-perms-host-description-wildcard", + "webext-perms-host-description-too-many-wildcards" + ); + addMessages( + sites, + "webext-perms-host-description-one-site", + "webext-perms-host-description-too-many-sites" + ); + } + + // Finally, show remaining permissions, in the same order as AMO. + // The permissions are sorted alphabetically by the permission + // string to match AMO. + // Show the native messaging permission first if it is present. + const NATIVE_MSG_PERM = "nativeMessaging"; + const permissionsSorted = permissions.permissions.sort((a, b) => { + if (a === NATIVE_MSG_PERM) { + return -1; + } else if (b === NATIVE_MSG_PERM) { + return 1; + } + return a < b ? -1 : 1; + }); + for (let permission of permissionsSorted) { + const l10nId = lazy.permissionToL10nId(permission); + // We deliberately do not include all permissions in the prompt. + // So if we don't find one then just skip it. + if (l10nId) { + msgIds.push(l10nId); + } + } + } + + if (optionalPermissions) { + // Generate a map of permission names to permission strings for optional + // permissions. The key is necessary to handle toggling those permissions. + const opKeys = []; + const opL10nIds = []; + for (let permission of optionalPermissions.permissions) { + const l10nId = lazy.permissionToL10nId(permission); + // We deliberately do not include all permissions in the prompt. + // So if we don't find one then just skip it. + if (l10nId) { + opKeys.push(permission); + opL10nIds.push(l10nId); + } + } + if (opKeys.length) { + const opRes = l10n.formatValuesSync(opL10nIds); + for (let i = 0; i < opKeys.length; ++i) { + result.optionalPermissions[opKeys[i]] = opRes[i]; + } + } + + const { allUrls, sitesMap, wildcardsMap } = + ExtensionData.classifyOriginPermissions( + optionalPermissions.origins, + true + ); + const ooKeys = []; + const ooL10nIds = []; + if (allUrls) { + ooKeys.push(allUrls); + ooL10nIds.push("webext-perms-host-description-all-urls"); + } + + // Current UX controls are meant for developer testing with mv3. + if (buildOptionalOrigins) { + for (let [pattern, domain] of wildcardsMap.entries()) { + ooKeys.push(pattern); + ooL10nIds.push({ + id: "webext-perms-host-description-wildcard", + args: { domain }, + }); + } + for (let [pattern, domain] of sitesMap.entries()) { + ooKeys.push(pattern); + ooL10nIds.push({ + id: "webext-perms-host-description-one-site", + args: { domain }, + }); + } + } + + if (ooKeys.length) { + const res = l10n.formatValuesSync(ooL10nIds); + for (let i = 0; i < res.length; ++i) { + result.optionalOrigins[ooKeys[i]] = res[i]; + } + } + } + + let headerId; + switch (type) { + case "sideload": + headerId = "webext-perms-sideload-header"; + acceptId = "webext-perms-sideload-enable"; + cancelId = "webext-perms-sideload-cancel"; + result.text = l10n.formatValueSync( + msgIds.length + ? "webext-perms-sideload-text" + : "webext-perms-sideload-text-no-perms" + ); + break; + case "update": + headerId = "webext-perms-update-text"; + acceptId = "webext-perms-update-accept"; + break; + case "optional": + headerId = "webext-perms-optional-perms-header"; + acceptId = "webext-perms-optional-perms-allow"; + cancelId = "webext-perms-optional-perms-deny"; + result.listIntro = l10n.formatValueSync( + "webext-perms-optional-perms-list-intro" + ); + break; + default: + if (msgIds.length) { + headerId = unsigned + ? "webext-perms-header-unsigned-with-perms" + : "webext-perms-header-with-perms"; + } else { + headerId = unsigned + ? "webext-perms-header-unsigned" + : "webext-perms-header"; + } + } + + result.header = l10n.formatValueSync(headerId, headerArgs); + result.msgs = l10n.formatValuesSync(msgIds); + setAcceptCancel(acceptId, cancelId); + return result; + } +} + +const PROXIED_EVENTS = new Set([ + "test-harness-message", + "background-script-suspend", + "background-script-suspend-canceled", + "background-script-suspend-ignored", +]); + +class BootstrapScope { + install(data, reason) {} + uninstall(data, reason) { + lazy.AsyncShutdown.profileChangeTeardown.addBlocker( + `Uninstalling add-on: ${data.id}`, + Management.emit("uninstall", { id: data.id }).then(() => { + Management.emit("uninstall-complete", { id: data.id }); + }) + ); + } + + fetchState() { + if (this.extension) { + return { state: this.extension.state }; + } + return null; + } + + async update(data, reason) { + // For updates that happen during startup, such as sideloads + // and staged updates, the extension startupReason will be + // APP_STARTED. In some situations, such as background and + // persisted listeners, we also need to know that the addon + // was updated. + this.updateReason = BootstrapScope.BOOTSTRAP_REASON_MAP[reason]; + // Retain any previously granted permissions that may have migrated + // into the optional list. + if (data.oldPermissions) { + // New permissions may be null, ensure we have an empty + // permission set in that case. + let emptyPermissions = { permissions: [], origins: [] }; + await ExtensionData.migratePermissions( + data.id, + data.oldPermissions, + data.oldOptionalPermissions, + data.userPermissions || emptyPermissions, + data.optionalPermissions || emptyPermissions + ); + } + + return Management.emit("update", { + id: data.id, + resourceURI: data.resourceURI, + isPrivileged: data.isPrivileged, + }); + } + + startup(data, reason) { + // eslint-disable-next-line no-use-before-define + this.extension = new Extension( + data, + BootstrapScope.BOOTSTRAP_REASON_MAP[reason], + this.updateReason + ); + return this.extension.startup(); + } + + async shutdown(data, reason) { + let result = await this.extension.shutdown( + BootstrapScope.BOOTSTRAP_REASON_MAP[reason] + ); + this.extension = null; + return result; + } + + static get BOOTSTRAP_REASON_MAP() { + const BR = lazy.AddonManagerPrivate.BOOTSTRAP_REASONS; + const value = Object.freeze({ + [BR.APP_STARTUP]: "APP_STARTUP", + [BR.APP_SHUTDOWN]: "APP_SHUTDOWN", + [BR.ADDON_ENABLE]: "ADDON_ENABLE", + [BR.ADDON_DISABLE]: "ADDON_DISABLE", + [BR.ADDON_INSTALL]: "ADDON_INSTALL", + [BR.ADDON_UNINSTALL]: "ADDON_UNINSTALL", + [BR.ADDON_UPGRADE]: "ADDON_UPGRADE", + [BR.ADDON_DOWNGRADE]: "ADDON_DOWNGRADE", + }); + return redefineGetter(this, "BOOTSTRAP_REASON_TO_STRING_MAP", value); + } +} + +class DictionaryBootstrapScope extends BootstrapScope { + install(data, reason) {} + uninstall(data, reason) {} + + startup(data, reason) { + // eslint-disable-next-line no-use-before-define + this.dictionary = new Dictionary(data); + return this.dictionary.startup(BootstrapScope.BOOTSTRAP_REASON_MAP[reason]); + } + + async shutdown(data, reason) { + this.dictionary.shutdown(BootstrapScope.BOOTSTRAP_REASON_MAP[reason]); + this.dictionary = null; + } +} + +class LangpackBootstrapScope extends BootstrapScope { + install(data, reason) {} + uninstall(data, reason) {} + async update(data, reason) {} + + startup(data, reason) { + // eslint-disable-next-line no-use-before-define + this.langpack = new Langpack(data); + return this.langpack.startup(BootstrapScope.BOOTSTRAP_REASON_MAP[reason]); + } + + async shutdown(data, reason) { + this.langpack.shutdown(BootstrapScope.BOOTSTRAP_REASON_MAP[reason]); + this.langpack = null; + } +} + +// TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed. +class SitePermissionBootstrapScope extends BootstrapScope { + install(data, reason) {} + uninstall(data, reason) {} + + startup(data, reason) { + // eslint-disable-next-line no-use-before-define + this.sitepermission = new SitePermission(data); + return this.sitepermission.startup( + BootstrapScope.BOOTSTRAP_REASON_MAP[reason] + ); + } + + async shutdown(data, reason) { + this.sitepermission.shutdown(BootstrapScope.BOOTSTRAP_REASON_MAP[reason]); + this.sitepermission = null; + } +} + +let activeExtensionIDs = new Set(); + +let pendingExtensions = new Map(); + +/** + * This class is the main representation of an active WebExtension + * in the main process. + * + * @augments ExtensionData + */ +export class Extension extends ExtensionData { + /** @type {Map<string, Map<string, any>>} */ + persistentListeners; + + constructor(addonData, startupReason, updateReason) { + super(addonData.resourceURI, addonData.isPrivileged); + + this.startupStates = new Set(); + this.state = "Not started"; + this.userContextIsolation = lazy.userContextIsolation; + + this.sharedDataKeys = new Set(); + + this.uuid = UUIDMap.get(addonData.id); + this.instanceId = getUniqueId(); + + this.MESSAGE_EMIT_EVENT = `Extension:EmitEvent:${this.instanceId}`; + Services.ppmm.addMessageListener(this.MESSAGE_EMIT_EVENT, this); + + if (addonData.cleanupFile) { + Services.obs.addObserver(this, "xpcom-shutdown"); + this.cleanupFile = addonData.cleanupFile || null; + delete addonData.cleanupFile; + } + + if (addonData.TEST_NO_ADDON_MANAGER) { + this.dontSaveStartupData = true; + } + if (addonData.TEST_NO_DELAYED_STARTUP) { + this.testNoDelayedStartup = true; + } + + this.addonData = addonData; + this.startupData = addonData.startupData || {}; + this.startupReason = startupReason; + this.updateReason = updateReason; + this.temporarilyInstalled = !!addonData.temporarilyInstalled; + + if ( + updateReason || + ["ADDON_UPGRADE", "ADDON_DOWNGRADE"].includes(startupReason) + ) { + this.startupClearCachePromise = StartupCache.clearAddonData(addonData.id); + } + + this.remote = !WebExtensionPolicy.isExtensionProcess; + this.remoteType = this.remote ? lazy.E10SUtils.EXTENSION_REMOTE_TYPE : null; + + if (this.remote && lazy.processCount !== 1) { + throw new Error( + "Out-of-process WebExtensions are not supported with multiple child processes" + ); + } + + // This is filled in the first time an extension child is created. + this.parentMessageManager = null; + + this.id = addonData.id; + this.version = addonData.version; + this.baseURL = this.getURL(""); + this.baseURI = Services.io.newURI(this.baseURL).QueryInterface(Ci.nsIURL); + this.principal = this.createPrincipal(); + + // Privileged extensions and any extensions with a recommendation state are + // exempt from the quarantined domains. + // NOTE: privileged extensions are also exempted from quarantined domains + // by the WebExtensionPolicy internal logic and so ignoreQuarantine set to + // false for a privileged extension does not make any difference in + // practice (but we still set the ignoreQuarantine flag here accordingly + // to the expected behavior for consistency). + this.ignoreQuarantine = + addonData.isPrivileged || + !!addonData.recommendationState?.states?.length || + lazy.QuarantinedDomains.isUserAllowedAddonId(this.id); + + this.views = new Set(); + this._backgroundPageFrameLoader = null; + + this.onStartup = null; + + this.hasShutdown = false; + this.onShutdown = new Set(); + + this.uninstallURL = null; + + this.allowedOrigins = null; + this._optionalOrigins = null; + this.webAccessibleResources = null; + + this.registeredContentScripts = new Map(); + + this.emitter = new EventEmitter(); + + if (this.startupData.lwtData && this.startupReason == "APP_STARTUP") { + lazy.LightweightThemeManager.fallbackThemeData = this.startupData.lwtData; + } + + /* eslint-disable mozilla/balanced-listeners */ + this.on("add-permissions", (ignoreEvent, permissions) => { + for (let perm of permissions.permissions) { + this.permissions.add(perm); + } + this.policy.permissions = Array.from(this.permissions); + + updateAllowedOrigins(this.policy, permissions.origins, /* isAdd */ true); + this.allowedOrigins = this.policy.allowedOrigins; + + if (this.policy.active) { + this.setSharedData("", this.serialize()); + Services.ppmm.sharedData.flush(); + this.broadcast("Extension:UpdatePermissions", { + id: this.id, + origins: permissions.origins, + permissions: permissions.permissions, + add: true, + }); + } + + this.cachePermissions(); + this.updatePermissions(); + }); + + this.on("remove-permissions", (ignoreEvent, permissions) => { + for (let perm of permissions.permissions) { + this.permissions.delete(perm); + } + this.policy.permissions = Array.from(this.permissions); + + updateAllowedOrigins(this.policy, permissions.origins, /* isAdd */ false); + this.allowedOrigins = this.policy.allowedOrigins; + + if (this.policy.active) { + this.setSharedData("", this.serialize()); + Services.ppmm.sharedData.flush(); + this.broadcast("Extension:UpdatePermissions", { + id: this.id, + origins: permissions.origins, + permissions: permissions.permissions, + add: false, + }); + } + + this.cachePermissions(); + this.updatePermissions(); + }); + /* eslint-enable mozilla/balanced-listeners */ + } + + set state(startupState) { + this.startupStates.clear(); + this.startupStates.add(startupState); + } + + get state() { + return `${Array.from(this.startupStates).join(", ")}`; + } + + async addStartupStatePromise(name, fn) { + this.startupStates.add(name); + try { + await fn(); + } finally { + this.startupStates.delete(name); + } + } + + // Some helpful properties added elsewhere: + + static getBootstrapScope() { + return new BootstrapScope(); + } + + get browsingContextGroupId() { + return this.policy.browsingContextGroupId; + } + + get groupFrameLoader() { + let frameLoader = this._backgroundPageFrameLoader; + for (let view of this.views) { + if (view.viewType === "background" && view.xulBrowser) { + return view.xulBrowser.frameLoader; + } + if (!frameLoader && view.xulBrowser) { + frameLoader = view.xulBrowser.frameLoader; + } + } + return frameLoader || ExtensionParent.DebugUtils.getFrameLoader(this.id); + } + + get backgroundContext() { + for (let view of this.views) { + if (view.isBackgroundContext) { + return view; + } + } + return undefined; + } + + on(hook, f) { + return this.emitter.on(hook, f); + } + + off(hook, f) { + return this.emitter.off(hook, f); + } + + once(hook, f) { + return this.emitter.once(hook, f); + } + + emit(event, ...args) { + if (PROXIED_EVENTS.has(event)) { + Services.ppmm.broadcastAsyncMessage(this.MESSAGE_EMIT_EVENT, { + event, + args, + }); + } + + return this.emitter.emit(event, ...args); + } + + receiveMessage({ name, data }) { + if (name === this.MESSAGE_EMIT_EVENT) { + this.emitter.emit(data.event, ...data.args); + } + } + + testMessage(...args) { + this.emit("test-harness-message", ...args); + } + + createPrincipal(uri = this.baseURI, originAttributes = {}) { + return Services.scriptSecurityManager.createContentPrincipal( + uri, + originAttributes + ); + } + + // Checks that the given URL is a child of our baseURI. + isExtensionURL(url) { + let uri = Services.io.newURI(url); + + let common = this.baseURI.getCommonBaseSpec(uri); + return common == this.baseURL; + } + + checkLoadURI(uri, options = {}) { + return ExtensionCommon.checkLoadURI(uri, this.principal, options); + } + + // Note: use checkLoadURI instead of checkLoadURL if you already have a URI. + checkLoadURL(url, options = {}) { + // As an optimization, if the URL starts with the extension's base URL, + // don't do any further checks. It's always allowed to load it. + if (url.startsWith(this.baseURL)) { + return true; + } + + return ExtensionCommon.checkLoadURL(url, this.principal, options); + } + + async promiseLocales(locale) { + let locales = await StartupCache.locales.get( + [this.id, "@@all_locales"], + () => this._promiseLocaleMap() + ); + + return this._setupLocaleData(locales); + } + + readLocaleFile(locale) { + return StartupCache.locales + .get([this.id, this.version, locale], () => super.readLocaleFile(locale)) + .then(result => { + this.localeData.messages.set(locale, result); + }); + } + + get manifestCacheKey() { + return [this.id, this.version, Services.locale.appLocaleAsBCP47]; + } + + saveStartupData() { + if (this.dontSaveStartupData) { + return; + } + lazy.AddonManagerPrivate.setAddonStartupData(this.id, this.startupData); + } + + async parseManifest() { + await this.startupClearCachePromise; + return StartupCache.manifests.get(this.manifestCacheKey, () => + super.parseManifest() + ); + } + + async cachePermissions() { + let manifestData = await this.parseManifest(); + + manifestData.originPermissions = this.allowedOrigins.patterns.map( + pat => pat.pattern + ); + manifestData.permissions = this.permissions; + return StartupCache.manifests.set(this.manifestCacheKey, manifestData); + } + + async loadManifest() { + let manifest = await super.loadManifest(); + + this.ensureNoErrors(); + + return manifest; + } + + get extensionPageCSP() { + const { content_security_policy } = this.manifest; + // While only manifest v3 should contain an object, + // we'll remain lenient here. + if ( + content_security_policy && + typeof content_security_policy === "object" + ) { + return content_security_policy.extension_pages; + } + return content_security_policy; + } + + get backgroundScripts() { + return this.manifest.background?.scripts; + } + + get backgroundTypeModule() { + return this.manifest.background?.type === "module"; + } + + get backgroundWorkerScript() { + return this.manifest.background?.service_worker; + } + + get optionalPermissions() { + return this.manifest.optional_permissions; + } + + get privateBrowsingAllowed() { + return this.policy.privateBrowsingAllowed; + } + + canAccessWindow(window) { + return this.policy.canAccessWindow(window); + } + + // TODO bug 1699481: move this logic to WebExtensionPolicy + canAccessContainer(userContextId) { + userContextId = userContextId ?? 0; // firefox-default has userContextId as 0. + let defaultRestrictedContainers = JSON.parse( + lazy.userContextIsolationDefaultRestricted + ); + let extensionRestrictedContainers = JSON.parse( + Services.prefs.getStringPref( + `extensions.userContextIsolation.${this.id}.restricted`, + "[]" + ) + ); + if ( + extensionRestrictedContainers.includes(userContextId) || + defaultRestrictedContainers.includes(userContextId) + ) { + return false; + } + + return true; + } + + // Representation of the extension to send to content + // processes. This should include anything the content process might + // need. + serialize() { + return { + id: this.id, + uuid: this.uuid, + name: this.name, + type: this.type, + manifestVersion: this.manifestVersion, + extensionPageCSP: this.extensionPageCSP, + instanceId: this.instanceId, + resourceURL: this.resourceURL, + contentScripts: this.contentScripts, + webAccessibleResources: this.webAccessibleResources, + allowedOrigins: this.allowedOrigins.patterns.map(pat => pat.pattern), + permissions: this.permissions, + optionalPermissions: this.optionalPermissions, + isPrivileged: this.isPrivileged, + ignoreQuarantine: this.ignoreQuarantine, + temporarilyInstalled: this.temporarilyInstalled, + }; + } + + // Extended serialized data which is only needed in the extensions process, + // and is never deserialized in web content processes. + // Keep in sync with BrowserExtensionContent in ExtensionChild.jsm + serializeExtended() { + return { + backgroundScripts: this.backgroundScripts, + backgroundWorkerScript: this.backgroundWorkerScript, + backgroundTypeModule: this.backgroundTypeModule, + childModules: this.modules && this.modules.child, + dependencies: this.dependencies, + persistentBackground: this.persistentBackground, + schemaURLs: this.schemaURLs, + }; + } + + broadcast(msg, data) { + return new Promise(resolve => { + let { ppmm } = Services; + let children = new Set(); + for (let i = 0; i < ppmm.childCount; i++) { + children.add(ppmm.getChildAt(i)); + } + + let maybeResolve; + function listener(data) { + children.delete(data.target); + maybeResolve(); + } + function observer(subject, topic, data) { + children.delete(subject); + maybeResolve(); + } + + maybeResolve = () => { + if (children.size === 0) { + ppmm.removeMessageListener(msg + "Complete", listener); + Services.obs.removeObserver(observer, "message-manager-close"); + Services.obs.removeObserver(observer, "message-manager-disconnect"); + resolve(); + } + }; + ppmm.addMessageListener(msg + "Complete", listener, true); + Services.obs.addObserver(observer, "message-manager-close"); + Services.obs.addObserver(observer, "message-manager-disconnect"); + + ppmm.broadcastAsyncMessage(msg, data); + }); + } + + setSharedData(key, value) { + key = `extension/${this.id}/${key}`; + this.sharedDataKeys.add(key); + + sharedData.set(key, value); + } + + getSharedData(key, value) { + key = `extension/${this.id}/${key}`; + return sharedData.get(key); + } + + initSharedData() { + this.setSharedData("", this.serialize()); + this.setSharedData("extendedData", this.serializeExtended()); + this.setSharedData("locales", this.localeData.serialize()); + this.setSharedData("manifest", this.manifest); + this.updateContentScripts(); + } + + updateContentScripts() { + this.setSharedData("contentScripts", this.registeredContentScripts); + } + + runManifest(manifest) { + let promises = []; + let addPromise = (name, fn) => { + promises.push(this.addStartupStatePromise(name, fn)); + }; + + for (let directive in manifest) { + if (manifest[directive] !== null) { + addPromise(`asyncEmitManifestEntry("${directive}")`, () => + Management.asyncEmitManifestEntry(this, directive) + ); + } + } + + activeExtensionIDs.add(this.id); + sharedData.set("extensions/activeIDs", activeExtensionIDs); + + pendingExtensions.delete(this.id); + sharedData.set("extensions/pending", pendingExtensions); + + Services.ppmm.sharedData.flush(); + this.broadcast("Extension:Startup", this.id); + + return Promise.all(promises); + } + + /** + * Call the close() method on the given object when this extension + * is shut down. This can happen during browser shutdown, or when + * an extension is manually disabled or uninstalled. + * + * @param {object} obj + * An object on which to call the close() method when this + * extension is shut down. + */ + callOnClose(obj) { + this.onShutdown.add(obj); + } + + forgetOnClose(obj) { + this.onShutdown.delete(obj); + } + + get builtinMessages() { + return new Map([["@@extension_id", this.uuid]]); + } + + // Reads the locale file for the given Gecko-compatible locale code, or if + // no locale is given, the available locale closest to the UI locale. + // Sets the currently selected locale on success. + async initLocale(locale = undefined) { + if (locale === undefined) { + let locales = await this.promiseLocales(); + + let matches = Services.locale.negotiateLanguages( + Services.locale.appLocalesAsBCP47, + Array.from(locales.keys()), + this.defaultLocale + ); + + locale = matches[0]; + } + + return super.initLocale(locale); + } + + /** + * Clear cached resources associated to the extension principal + * when an extension is installed (in case we were unable to do that at + * uninstall time) or when it is being upgraded or downgraded. + * + * @param {string|undefined} reason + * BOOTSTRAP_REASON string, if provided. The value is expected to be + * `undefined` for extension objects without a corresponding AddonManager + * addon wrapper (e.g. test extensions created using `ExtensionTestUtils` + * without `useAddonManager` optional property). + * + * @returns {Promise<void>} + * Promise resolved when the nsIClearDataService async method call + * has been completed. + */ + async clearCache(reason) { + switch (reason) { + case "ADDON_INSTALL": + case "ADDON_UPGRADE": + case "ADDON_DOWNGRADE": + return clearCacheForExtensionPrincipal(this.principal); + } + } + + /** + * Update site permissions as necessary. + * + * @param {string} [reason] + * If provided, this is a BOOTSTRAP_REASON string. If reason is undefined, + * addon permissions are being added or removed that may effect the site permissions. + */ + updatePermissions(reason) { + const { principal } = this; + + const testPermission = perm => + Services.perms.testPermissionFromPrincipal(principal, perm); + + const addUnlimitedStoragePermissions = () => { + // Set the indexedDB permission and a custom "WebExtensions-unlimitedStorage" to + // remember that the permission hasn't been selected manually by the user. + Services.perms.addFromPrincipal( + principal, + "WebExtensions-unlimitedStorage", + Services.perms.ALLOW_ACTION + ); + Services.perms.addFromPrincipal( + principal, + "persistent-storage", + Services.perms.ALLOW_ACTION + ); + }; + + // Only update storage permissions when the extension changes in + // some way. + if (reason !== "APP_STARTUP" && reason !== "APP_SHUTDOWN") { + if (this.hasPermission("unlimitedStorage")) { + addUnlimitedStoragePermissions(); + } else { + // Remove the indexedDB permission if it has been enabled using the + // unlimitedStorage WebExtensions permissions. + Services.perms.removeFromPrincipal( + principal, + "WebExtensions-unlimitedStorage" + ); + Services.perms.removeFromPrincipal(principal, "persistent-storage"); + } + } else if ( + reason === "APP_STARTUP" && + this.hasPermission("unlimitedStorage") && + testPermission("persistent-storage") !== Services.perms.ALLOW_ACTION + ) { + // If the extension does have the unlimitedStorage permission, but the + // expected site permissions are missing during the app startup, then + // add them back (See Bug 1454192). + addUnlimitedStoragePermissions(); + } + + // Never change geolocation permissions at shutdown, since it uses a + // session-only permission. + if (reason !== "APP_SHUTDOWN") { + if (this.hasPermission("geolocation")) { + if (testPermission("geo") === Services.perms.UNKNOWN_ACTION) { + Services.perms.addFromPrincipal( + principal, + "geo", + Services.perms.ALLOW_ACTION, + Services.perms.EXPIRE_SESSION + ); + } + } else if ( + reason !== "APP_STARTUP" && + testPermission("geo") === Services.perms.ALLOW_ACTION + ) { + Services.perms.removeFromPrincipal(principal, "geo"); + } + } + } + + async startup() { + this.state = "Startup"; + + // readyPromise is resolved with the policy upon success, + // and with null if startup was interrupted. + /** @type {callback} */ + let resolveReadyPromise; + let readyPromise = new Promise(resolve => { + resolveReadyPromise = resolve; + }); + + // Create a temporary policy object for the devtools and add-on + // manager callers that depend on it being available early. + this.policy = new WebExtensionPolicy({ + id: this.id, + mozExtensionHostname: this.uuid, + baseURL: this.resourceURL, + isPrivileged: this.isPrivileged, + ignoreQuarantine: this.ignoreQuarantine, + temporarilyInstalled: this.temporarilyInstalled, + allowedOrigins: new MatchPatternSet([]), + localizeCallback() {}, + readyPromise, + }); + + this.policy.extension = this; + if (!WebExtensionPolicy.getByID(this.id)) { + this.policy.active = true; + } + + pendingExtensions.set(this.id, { + mozExtensionHostname: this.uuid, + baseURL: this.resourceURL, + isPrivileged: this.isPrivileged, + ignoreQuarantine: this.ignoreQuarantine, + }); + sharedData.set("extensions/pending", pendingExtensions); + + lazy.ExtensionTelemetry.extensionStartup.stopwatchStart(this); + try { + this.state = "Startup: Loading manifest"; + await this.loadManifest(); + this.state = "Startup: Loaded manifest"; + + if (!this.hasShutdown) { + this.state = "Startup: Init locale"; + await this.initLocale(); + this.state = "Startup: Initted locale"; + } + + this.ensureNoErrors(); + + if (this.hasShutdown) { + // Startup was interrupted and shutdown() has taken care of unloading + // the extension and running cleanup logic. + return; + } + + await this.clearCache(this.startupReason); + this._setupStartupPermissions(); + + GlobalManager.init(this); + + if (this.hasPermission("scripting")) { + this.state = "Startup: Initialize scripting store"; + // We have to await here because `initSharedData` depends on the data + // fetched from the scripting store. This has to be done early because + // we need the data to run the content scripts in existing pages at + // startup. + try { + await lazy.ExtensionScriptingStore.initExtension(this); + this.state = "Startup: Scripting store initialized"; + } catch (err) { + this.logError(`Failed to initialize scripting store: ${err}`); + } + } + + this.initSharedData(); + + this.policy.active = false; + this.policy = lazy.ExtensionProcessScript.initExtension(this); + this.policy.extension = this; + + this.updatePermissions(this.startupReason); + + // Select the storage.local backend if it is already known, + // and start the data migration if needed. + if (this.hasPermission("storage")) { + if (!lazy.ExtensionStorageIDB.isBackendEnabled) { + this.setSharedData("storageIDBBackend", false); + } else if (lazy.ExtensionStorageIDB.isMigratedExtension(this)) { + this.setSharedData("storageIDBBackend", true); + this.setSharedData( + "storageIDBPrincipal", + lazy.ExtensionStorageIDB.getStoragePrincipal(this) + ); + } else if ( + this.startupReason === "ADDON_INSTALL" && + !Services.prefs.getBoolPref(LEAVE_STORAGE_PREF, false) + ) { + // If the extension has been just installed, set it as migrated, + // because there will not be any data to migrate. + lazy.ExtensionStorageIDB.setMigratedExtensionPref(this, true); + this.setSharedData("storageIDBBackend", true); + this.setSharedData( + "storageIDBPrincipal", + lazy.ExtensionStorageIDB.getStoragePrincipal(this) + ); + } + } + + // Initialize DNR for the extension, only if the extension + // has the required DNR permissions and without blocking + // the extension startup on DNR being fully initialized. + if ( + this.hasPermission("declarativeNetRequest") || + this.hasPermission("declarativeNetRequestWithHostAccess") + ) { + lazy.ExtensionDNR.ensureInitialized(this); + } + + resolveReadyPromise(this.policy); + + // The "startup" Management event sent on the extension instance itself + // is emitted just before the Management "startup" event, + // and it is used to run code that needs to be executed before + // any of the "startup" listeners. + this.emit("startup", this); + + this.startupStates.clear(); + await Promise.all([ + this.addStartupStatePromise("Startup: Emit startup", () => + Management.emit("startup", this) + ), + this.addStartupStatePromise("Startup: Run manifest", () => + this.runManifest(this.manifest) + ), + ]); + this.state = "Startup: Ran manifest"; + + Management.emit("ready", this); + this.emit("ready"); + + this.state = "Startup: Complete"; + } catch (e) { + this.state = `Startup: Error: ${e}`; + + Cu.reportError(e); + + if (this.policy) { + this.policy.active = false; + } + + this.cleanupGeneratedFile(); + + throw e; + } finally { + lazy.ExtensionTelemetry.extensionStartup.stopwatchFinish(this); + // Mark readyPromise as resolved in case it has not happened before, + // e.g. due to an early return or an error. + resolveReadyPromise(null); + } + } + + // Setup initial permissions on extension startup based on manifest + // and potentially previous manifest and permissions values. None of + // the ExtensionPermissions.add/remove() calls are are awaited here + // because we update the in-memory representation at the same time. + _setupStartupPermissions() { + // If we add/remove permissions conditionally based on startupReason, + // we need to update the cache, or changes will be lost after restart. + let updateCache = false; + + // We automatically add permissions to system/built-in extensions. + // Extensions expliticy stating not_allowed will never get permission. + let isAllowed = this.permissions.has(PRIVATE_ALLOWED_PERMISSION); + if (this.manifest.incognito === "not_allowed") { + // If an extension previously had permission, but upgrades/downgrades to + // a version that specifies "not_allowed" in manifest, remove the + // permission. + if (isAllowed) { + lazy.ExtensionPermissions.remove(this.id, { + permissions: [PRIVATE_ALLOWED_PERMISSION], + origins: [], + }); + this.permissions.delete(PRIVATE_ALLOWED_PERMISSION); + } + } else if (!isAllowed && this.isPrivileged && !this.temporarilyInstalled) { + // Add to EP so it is preserved after ADDON_INSTALL. + lazy.ExtensionPermissions.add(this.id, { + permissions: [PRIVATE_ALLOWED_PERMISSION], + origins: [], + }); + this.permissions.add(PRIVATE_ALLOWED_PERMISSION); + } + + // Allow other extensions to access static themes in private browsing windows + // (See Bug 1790115). + if (this.type === "theme") { + this.permissions.add(PRIVATE_ALLOWED_PERMISSION); + } + + // We only want to update the SVG_CONTEXT_PROPERTIES_PERMISSION during + // install and upgrade/downgrade startups. + if (INSTALL_AND_UPDATE_STARTUP_REASONS.has(this.startupReason)) { + if (isMozillaExtension(this)) { + // Add to EP so it is preserved after ADDON_INSTALL. + lazy.ExtensionPermissions.add(this.id, { + permissions: [SVG_CONTEXT_PROPERTIES_PERMISSION], + origins: [], + }); + this.permissions.add(SVG_CONTEXT_PROPERTIES_PERMISSION); + } else { + lazy.ExtensionPermissions.remove(this.id, { + permissions: [SVG_CONTEXT_PROPERTIES_PERMISSION], + origins: [], + }); + this.permissions.delete(SVG_CONTEXT_PROPERTIES_PERMISSION); + } + updateCache = true; + } + + // Ensure devtools permission is set. + if ( + this.manifest.devtools_page && + !this.manifest.optional_permissions.includes("devtools") + ) { + lazy.ExtensionPermissions.add(this.id, { + permissions: ["devtools"], + origins: [], + }); + this.permissions.add("devtools"); + } + + if ( + this.originControls && + this.manifest.granted_host_permissions && + this.startupReason === "ADDON_INSTALL" + ) { + let origins = this.getManifestOrigins(); + lazy.ExtensionPermissions.add(this.id, { permissions: [], origins }); + updateCache = true; + + let allowed = this.allowedOrigins.patterns.map(p => p.pattern); + this.allowedOrigins = new MatchPatternSet(origins.concat(allowed), { + restrictSchemes: this.restrictSchemes, + ignorePath: true, + }); + } + + if (updateCache) { + this.cachePermissions(); + } + } + + cleanupGeneratedFile() { + if (!this.cleanupFile) { + return; + } + + let file = this.cleanupFile; + this.cleanupFile = null; + + Services.obs.removeObserver(this, "xpcom-shutdown"); + + return this.broadcast("Extension:FlushJarCache", { path: file.path }) + .then(() => { + // We can't delete this file until everyone using it has + // closed it (because Windows is dumb). So we wait for all the + // child processes (including the parent) to flush their JAR + // caches. These caches may keep the file open. + file.remove(false); + }) + .catch(Cu.reportError); + } + + async shutdown(reason) { + this.state = "Shutdown"; + + this.hasShutdown = true; + + if (!this.policy) { + return; + } + + if ( + this.hasPermission("storage") && + lazy.ExtensionStorageIDB.selectedBackendPromises.has(this) + ) { + this.state = "Shutdown: Storage"; + + // Wait the data migration to complete. + try { + await lazy.ExtensionStorageIDB.selectedBackendPromises.get(this); + } catch (err) { + Cu.reportError( + `Error while waiting for extension data migration on shutdown: ${this.policy.debugName} - ${err.message}::${err.stack}` + ); + } + this.state = "Shutdown: Storage complete"; + } + + if (this.rootURI instanceof Ci.nsIJARURI) { + this.state = "Shutdown: Flush jar cache"; + let file = this.rootURI.JARFile.QueryInterface(Ci.nsIFileURL).file; + Services.ppmm.broadcastAsyncMessage("Extension:FlushJarCache", { + path: file.path, + }); + this.state = "Shutdown: Flushed jar cache"; + } + + const isAppShutdown = reason === "APP_SHUTDOWN"; + if (this.cleanupFile || !isAppShutdown) { + StartupCache.clearAddonData(this.id); + } + + activeExtensionIDs.delete(this.id); + sharedData.set("extensions/activeIDs", activeExtensionIDs); + + for (let key of this.sharedDataKeys) { + sharedData.delete(key); + } + + Services.ppmm.removeMessageListener(this.MESSAGE_EMIT_EVENT, this); + + this.updatePermissions(reason); + + // The service worker registrations related to the extensions are unregistered + // only when the extension is not shutting down as part of the application + // shutdown (a previously registered service worker is expected to stay + // active across browser restarts), the service worker may have been + // registered through the manifest.json background.service_worker property + // or from an extension page through the service worker API if allowed + // through the about:config pref. + if (!isAppShutdown) { + this.state = "Shutdown: ServiceWorkers"; + // TODO: ServiceWorkerCleanUp may go away once Bug 1183245 is fixed. + await lazy.ServiceWorkerCleanUp.removeFromPrincipal(this.principal); + this.state = "Shutdown: ServiceWorkers completed"; + } + + if (!this.manifest) { + this.state = "Shutdown: Complete: No manifest"; + this.policy.active = false; + + return this.cleanupGeneratedFile(); + } + + GlobalManager.uninit(this); + + for (let obj of this.onShutdown) { + obj.close(); + } + + ParentAPIManager.shutdownExtension(this.id, reason); + + Management.emit("shutdown", this); + this.emit("shutdown", isAppShutdown); + + const TIMED_OUT = Symbol(); + + this.state = "Shutdown: Emit shutdown"; + let result = await Promise.race([ + this.broadcast("Extension:Shutdown", { id: this.id }), + promiseTimeout(CHILD_SHUTDOWN_TIMEOUT_MS).then(() => TIMED_OUT), + ]); + this.state = `Shutdown: Emitted shutdown: ${result === TIMED_OUT}`; + if (result === TIMED_OUT) { + Cu.reportError( + `Timeout while waiting for extension child to shutdown: ${this.policy.debugName}` + ); + } + + this.policy.active = false; + + this.state = `Shutdown: Complete (${this.cleanupFile})`; + return this.cleanupGeneratedFile(); + } + + observe(subject, topic, data) { + if (topic === "xpcom-shutdown") { + this.cleanupGeneratedFile(); + } + } + + get name() { + return this.manifest.name; + } + + get optionalOrigins() { + if (this._optionalOrigins == null) { + let { origins } = this.manifestOptionalPermissions; + this._optionalOrigins = new MatchPatternSet(origins, { + restrictSchemes: this.restrictSchemes, + ignorePath: true, + }); + } + return this._optionalOrigins; + } + + get hasBrowserActionUI() { + return this.manifest.browser_action || this.manifest.action; + } + + getPreferredIcon(size = 16) { + return IconDetails.getPreferredIcon(this.manifest.icons ?? {}, this, size) + .icon; + } +} + +export class Dictionary extends ExtensionData { + constructor(addonData, startupReason) { + super(addonData.resourceURI); + this.id = addonData.id; + this.startupData = addonData.startupData; + } + + static getBootstrapScope() { + return new DictionaryBootstrapScope(); + } + + async startup(reason) { + this.dictionaries = {}; + for (let [lang, path] of Object.entries(this.startupData.dictionaries)) { + let uri = Services.io.newURI( + path.slice(0, -4) + ".aff", + null, + this.rootURI + ); + this.dictionaries[lang] = uri; + + lazy.spellCheck.addDictionary(lang, uri); + } + + Management.emit("ready", this); + } + + async shutdown(reason) { + if (reason !== "APP_SHUTDOWN") { + lazy.AddonManagerPrivate.unregisterDictionaries(this.dictionaries); + } + } +} + +export class Langpack extends ExtensionData { + constructor(addonData, startupReason) { + super(addonData.resourceURI); + this.startupData = addonData.startupData; + this.manifestCacheKey = [addonData.id, addonData.version]; + } + + static getBootstrapScope() { + return new LangpackBootstrapScope(); + } + + async promiseLocales(locale) { + let locales = await StartupCache.locales.get( + [this.id, "@@all_locales"], + () => this._promiseLocaleMap() + ); + + return this._setupLocaleData(locales); + } + + parseManifest() { + return StartupCache.manifests.get(this.manifestCacheKey, () => + super.parseManifest() + ); + } + + async startup(reason) { + this.chromeRegistryHandle = null; + if (this.startupData.chromeEntries.length) { + const manifestURI = Services.io.newURI( + "manifest.json", + null, + this.rootURI + ); + this.chromeRegistryHandle = lazy.aomStartup.registerChrome( + manifestURI, + this.startupData.chromeEntries + ); + } + + const langpackId = this.startupData.langpackId; + const l10nRegistrySources = this.startupData.l10nRegistrySources; + + lazy.resourceProtocol.setSubstitution(langpackId, this.rootURI); + + const fileSources = Object.entries(l10nRegistrySources).map(entry => { + const [sourceName, basePath] = entry; + return new L10nFileSource( + `${sourceName}-${langpackId}`, + langpackId, + this.startupData.languages, + `resource://${langpackId}/${basePath}localization/{locale}/` + ); + }); + + L10nRegistry.getInstance().registerSources(fileSources); + + Services.obs.notifyObservers( + { wrappedJSObject: { langpack: this } }, + "webextension-langpack-startup" + ); + } + + async shutdown(reason) { + if (reason === "APP_SHUTDOWN") { + // If we're shutting down, let's not bother updating the state of each + // system. + return; + } + + const sourcesToRemove = Object.keys( + this.startupData.l10nRegistrySources + ).map(sourceName => `${sourceName}-${this.startupData.langpackId}`); + L10nRegistry.getInstance().removeSources(sourcesToRemove); + + if (this.chromeRegistryHandle) { + this.chromeRegistryHandle.destruct(); + this.chromeRegistryHandle = null; + } + + lazy.resourceProtocol.setSubstitution(this.startupData.langpackId, null); + } +} + +// TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed. +export class SitePermission extends ExtensionData { + constructor(addonData, startupReason) { + super(addonData.resourceURI); + this.id = addonData.id; + this.hasShutdown = false; + } + + async loadManifest() { + let [manifestData] = await Promise.all([this.parseManifest()]); + + if (!manifestData) { + return; + } + + this.manifest = manifestData.manifest; + this.type = manifestData.type; + this.sitePermissions = this.manifest.site_permissions; + // 1 install_origins is mandatory for this addon type + this.siteOrigin = this.manifest.install_origins[0]; + + return this.manifest; + } + + static getBootstrapScope() { + return new SitePermissionBootstrapScope(); + } + + // Array of principals that may be set by the addon. + getSupportedPrincipals() { + if (!this.siteOrigin) { + return []; + } + const uri = Services.io.newURI(this.siteOrigin); + return [ + Services.scriptSecurityManager.createContentPrincipal(uri, {}), + Services.scriptSecurityManager.createContentPrincipal(uri, { + privateBrowsingId: 1, + }), + ]; + } + + async startup(reason) { + await this.loadManifest(); + + this.ensureNoErrors(); + + let site_permissions = await lazy.SCHEMA_SITE_PERMISSIONS; + let perms = await lazy.ExtensionPermissions.get(this.id); + + if (this.hasShutdown) { + // Startup was interrupted and shutdown() has taken care of unloading + // the extension and running cleanup logic. + return; + } + + let privateAllowed = perms.permissions.includes(PRIVATE_ALLOWED_PERMISSION); + let principals = this.getSupportedPrincipals(); + + // Remove any permissions not contained in site_permissions + for (let principal of principals) { + let existing = Services.perms.getAllForPrincipal(principal); + for (let perm of existing) { + if ( + site_permissions.includes(perm) && + !this.sitePermissions.includes(perm) + ) { + Services.perms.removeFromPrincipal(principal, perm.type); + } + } + } + + // Ensure all permissions in site_permissions have been set, but do not + // overwrite the permission so the user can override the values in preferences. + for (let perm of this.sitePermissions) { + for (let principal of principals) { + let permission = Services.perms.testExactPermissionFromPrincipal( + principal, + perm + ); + if (permission == Ci.nsIPermissionManager.UNKNOWN_ACTION) { + let { privateBrowsingId } = principal.originAttributes; + let allow = privateBrowsingId == 0 || privateAllowed; + Services.perms.addFromPrincipal( + principal, + perm, + allow ? Services.perms.ALLOW_ACTION : Services.perms.DENY_ACTION, + Services.perms.EXPIRE_NEVER + ); + } + } + } + + Services.obs.notifyObservers( + { wrappedJSObject: { sitepermissions: this } }, + "webextension-sitepermissions-startup" + ); + } + + async shutdown(reason) { + this.hasShutdown = true; + // Permissions are retained across restarts + if (reason == "APP_SHUTDOWN") { + return; + } + let principals = this.getSupportedPrincipals(); + + for (let perm of this.sitePermissions || []) { + for (let principal of principals) { + Services.perms.removeFromPrincipal(principal, perm); + } + } + } +} + +// Exported for testing purposes. +export { ExtensionAddonObserver, PRIVILEGED_PERMS }; diff --git a/toolkit/components/extensions/ExtensionActions.sys.mjs b/toolkit/components/extensions/ExtensionActions.sys.mjs new file mode 100644 index 0000000000..8e8cf3abd2 --- /dev/null +++ b/toolkit/components/extensions/ExtensionActions.sys.mjs @@ -0,0 +1,667 @@ +/* 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 { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { ExtensionError } = ExtensionUtils; + +import { ExtensionParent } from "resource://gre/modules/ExtensionParent.sys.mjs"; + +const { IconDetails, StartupCache } = ExtensionParent; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "MV2_ACTION_POPURL_RESTRICTED", + "extensions.manifestV2.actionsPopupURLRestricted", + false +); + +function parseColor(color, kind) { + if (typeof color == "string") { + let rgba = InspectorUtils.colorToRGBA(color); + if (!rgba) { + throw new ExtensionError(`Invalid badge ${kind} color: "${color}"`); + } + color = [rgba.r, rgba.g, rgba.b, Math.round(rgba.a * 255)]; + } + return color; +} + +/** Common base class for Page and Browser actions. */ +class PanelActionBase { + constructor(options, tabContext, extension) { + this.tabContext = tabContext; + this.extension = extension; + + // These are always defined on the action + this.defaults = { + enabled: true, + title: options.default_title || extension.name, + popup: options.default_popup || "", + icon: null, + }; + this.globals = Object.create(this.defaults); + + // eslint-disable-next-line mozilla/balanced-listeners + this.tabContext.on("location-change", this.handleLocationChange.bind(this)); + + // eslint-disable-next-line mozilla/balanced-listeners + this.tabContext.on("tab-select", (evt, tab) => { + this.updateOnChange(tab); + }); + + // eslint-disable-next-line mozilla/balanced-listeners + extension.on("add-permissions", () => this.updateOnChange()); + // eslint-disable-next-line mozilla/balanced-listeners + extension.on("remove-permissions", () => this.updateOnChange()); + // eslint-disable-next-line mozilla/balanced-listeners + extension.on("update-ignore-quarantine", () => this.updateOnChange()); + + // When preloading a popup we temporarily grant active tab permissions to + // the preloaded popup. If we don't end up opening we need to clear this + // permission when clearing the popup. + this.activeTabForPreload = null; + } + + onShutdown() { + this.tabContext.shutdown(); + } + + setPropertyFromDetails(details, prop, value) { + return this.setProperty(this.getTargetFromDetails(details), prop, value); + } + + /** + * Set a global, window specific or tab specific property. + * + * @param {XULElement|ChromeWindow|null} target + * A XULElement tab, a ChromeWindow, or null for the global data. + * @param {string} prop + * String property to set. Should should be one of "icon", "title", "badgeText", + * "popup", "badgeBackgroundColor", "badgeTextColor" or "enabled". + * @param {string} value + * Value for prop. + * @returns {object} + * The object to which the property has been set. + */ + setProperty(target, prop, value) { + let values = this.getContextData(target); + if (value === null) { + delete values[prop]; + } else { + values[prop] = value; + } + + this.updateOnChange(target); + return values; + } + + /** + * Gets the data associated with a tab, window, or the global one. + * + * @param {XULElement|ChromeWindow|null} target + * A XULElement tab, a ChromeWindow, or null for the global data. + * @returns {object} + * The icon, title, badge, etc. associated with the target. + */ + getContextData(target) { + if (target) { + return this.tabContext.get(target); + } + return this.globals; + } + + /** + * Retrieve the value of a global, window specific or tab specific property. + * + * @param {XULElement|ChromeWindow|null} target + * A XULElement tab, a ChromeWindow, or null for the global data. + * @param {string} prop + * Name of property to retrieve. Should should be one of "icon", + * "title", "badgeText", "popup", "badgeBackgroundColor" or "enabled". + * @returns {any} value + * Value of prop. + */ + getProperty(target, prop) { + return this.getContextData(target)[prop]; + } + + getPropertyFromDetails(details, prop) { + return this.getProperty(this.getTargetFromDetails(details), prop); + } + + enable(tabId) { + this.setPropertyFromDetails({ tabId }, "enabled", true); + } + + disable(tabId) { + this.setPropertyFromDetails({ tabId }, "enabled", false); + } + + getIcon(details = {}) { + return this.getPropertyFromDetails(details, "icon"); + } + + normalizeIcon(details, extension, context) { + let icon = IconDetails.normalize(details, extension, context); + if (!Object.keys(icon).length) { + return null; + } + return icon; + } + + /** + * Updates the `tabData` for any location change, however it only updates the button + * when the selected tab has a location change, or the selected tab has changed. + * + * @param {string} eventType + * The type of the event, should be "location-change". + * @param {XULElement} tab + * The tab whose location changed, or which has become selected. + * @param {boolean} [fromBrowse] + * - `true` if navigation occurred in `tab`. + * - `false` if the location changed but no navigation occurred, e.g. due to + a hash change or `history.pushState`. + * - Omitted if TabSelect has occurred, tabData does not need to be updated. + */ + handleLocationChange(eventType, tab, fromBrowse) { + if (fromBrowse) { + this.tabContext.clear(tab); + } + } + + /** + * Gets the popup url for a given tab. + * + * @param {XULElement} tab + * The tab the popup refers to. + * @param {boolean} strict + * If errors should be thrown if a URL is not available. + * @returns {string} + * The popup URL if a popup is present, undefined otherwise. + */ + getPopupUrl(tab, strict = false) { + if (!this.isShownForTab(tab)) { + if (strict) { + throw new ExtensionError("Popup is disabled"); + } + + return undefined; + } + let popupUrl = this.getProperty(tab, "popup"); + + if (strict && !popupUrl) { + throw new ExtensionError("No popup URL is set"); + } + + return popupUrl; + } + + /** + * Grants activeTab permission for a tab when preloading the popup. + * + * Will clear any existing activeTab permissions previously granted for any + * other tab. + * + * @param {XULElement} tab + * The tab that should be granted activeTab permission for. Set to + * null to clear previously granted activeTab permission. + */ + setActiveTabForPreload(tab = null) { + let oldTab = this.activeTabForPreload; + if (oldTab === tab) { + return; + } + this.activeTabForPreload = tab; + if (tab) { + this.extension.tabManager.addActiveTabPermission(tab); + } + if (oldTab) { + this.extension.tabManager.revokeActiveTabPermission(oldTab); + } + } + + /** + * Triggers this action and sends the appropriate event if needed. + * + * @param {XULElement} tab + * The tab on which the action was fired. + * @param {object} clickInfo + * Extra data passed to the second parameter to the action API's + * onClicked event. + * @returns {string} + * the popup URL if a popup should be open, undefined otherwise. + */ + triggerClickOrPopup(tab, clickInfo = undefined) { + if (!this.isShownForTab(tab)) { + return null; + } + + // Now that the action is actually being triggered we can clear any + // existing preloaded activeTab permission. + this.setActiveTabForPreload(null); + this.extension.tabManager.addActiveTabPermission(tab); + this.extension.tabManager.activateScripts(tab); + + let popupUrl = this.getProperty(tab, "popup"); + // The "click" event is only dispatched when the popup is not shown. This + // is done for compatibility with the Google Chrome onClicked extension + // API. + if (!popupUrl) { + this.dispatchClick(tab, clickInfo); + } + this.updateOnChange(tab); + return popupUrl; + } + + api(context) { + let { extension } = context; + return { + setTitle: details => { + this.setPropertyFromDetails(details, "title", details.title); + }, + getTitle: details => { + return this.getPropertyFromDetails(details, "title"); + }, + setIcon: details => { + details.iconType = "browserAction"; + this.setPropertyFromDetails( + details, + "icon", + this.normalizeIcon(details, extension, context) + ); + }, + setPopup: details => { + // Note: Chrome resolves arguments to setIcon relative to the calling + // context, but resolves arguments to setPopup relative to the extension + // root. + // For internal consistency, we currently resolve both relative to the + // calling context. + let url = details.popup && context.uri.resolve(details.popup); + + if (url && !context.checkLoadURL(url)) { + return Promise.reject({ message: `Access denied for URL ${url}` }); + } + + // On manifest_version 3 is mandatory for the resolved URI to belong to the + // current extension (see Bug 1760608). + // + // The same restriction is extended extend to MV2 extensions if the + // "extensions.manifestV2.actionsPopupURLRestricted" preference is set to true. + // + // (Currently set to true by default on GeckoView builds, where the set of + // extensions supported is limited to a small set and so less risks of + // unexpected regressions for the existing extensions). + if ( + url && + !url.startsWith(extension.baseURI.spec) && + (context.extension.manifestVersion >= 3 || + lazy.MV2_ACTION_POPURL_RESTRICTED) + ) { + return Promise.reject({ message: `Access denied for URL ${url}` }); + } + + this.setPropertyFromDetails(details, "popup", url); + }, + getPopup: details => { + return this.getPropertyFromDetails(details, "popup"); + }, + }; + } + + // Override these + + /** + * Update the toolbar button when the extension changes the icon, title, url, etc. + * If it only changes a parameter for a single tab, `target` will be that tab. + * If it only changes a parameter for a single window, `target` will be that window. + * Otherwise `target` will be null. + * + * @param {XULElement|ChromeWindow|null} target + * Browser tab or browser chrome window, may be null. + */ + updateOnChange(target) {} + + /** + * Get tab object from tabId. + * + * @param {string} tabId + * Internal id of the tab to get. + */ + getTab(tabId) {} + + /** + * Get window object from windowId + * + * @param {string} windowId + * Internal id of the window to get. + */ + getWindow(windowId) {} + + /** + * Gets the target object corresponding to the `details` parameter of the various + * get* and set* API methods. + * + * @param {object} details + * An object with optional `tabId` or `windowId` properties. + * @param {number} [details.tabId] + * @param {number} [details.windowId] + * @throws if both `tabId` and `windowId` are specified, or if they are invalid. + * @returns {XULElement|ChromeWindow|null} + * If a `tabId` was specified, the corresponding XULElement tab. + * If a `windowId` was specified, the corresponding ChromeWindow. + * Otherwise, `null`. + */ + getTargetFromDetails({ tabId, windowId }) { + return null; + } + + /** + * Triggers a click event. + * + * @param {XULElement} tab + * The tab where this event should be fired. + * @param {object} clickInfo + * Extra data passed to the second parameter to the action API's + * onClicked event. + */ + dispatchClick(tab, clickInfo) {} + + /** + * Checks whether this action is shown. + * + * @param {XULElement} tab + * The tab to be checked + * @returns {boolean} + */ + isShownForTab(tab) { + return false; + } +} + +export class PageActionBase extends PanelActionBase { + constructor(tabContext, extension) { + const options = extension.manifest.page_action; + super(options, tabContext, extension); + + // `enabled` can have three different values: + // - `false`. This means the page action is not shown. + // It's set as default if show_matches is empty. Can also be set in a tab via + // `pageAction.hide(tabId)`, e.g. in order to override show_matches. + // - `true`. This means the page action is shown. + // It's never set as default because <all_urls> doesn't really match all URLs + // (e.g. "about:" URLs). But can be set in a tab via `pageAction.show(tabId)`. + // - `undefined`. + // This is the default value when there are some patterns in show_matches. + // Can't be set as a tab-specific value. + let enabled, showMatches, hideMatches; + let show_matches = options.show_matches || []; + let hide_matches = options.hide_matches || []; + if (!show_matches.length) { + // Always hide by default. No need to do any pattern matching. + enabled = false; + } else { + // Might show or hide depending on the URL. Enable pattern matching. + const { restrictSchemes } = extension; + showMatches = new MatchPatternSet(show_matches, { restrictSchemes }); + hideMatches = new MatchPatternSet(hide_matches, { restrictSchemes }); + } + + this.defaults = { + ...this.defaults, + enabled, + showMatches, + hideMatches, + pinned: options.pinned, + }; + this.globals = Object.create(this.defaults); + } + + handleLocationChange(eventType, tab, fromBrowse) { + super.handleLocationChange(eventType, tab, fromBrowse); + if (fromBrowse === false) { + // Clear pattern matching cache when URL changes. + let tabData = this.tabContext.get(tab); + if (tabData.patternMatching !== undefined) { + tabData.patternMatching = undefined; + } + } + + if (tab.selected) { + // isShownForTab will do pattern matching (if necessary) and store the result + // so that updateButton knows whether the page action should be shown. + this.isShownForTab(tab); + this.updateOnChange(tab); + } + } + + // Checks whether the tab action is shown when the specified tab becomes active. + // Does pattern matching if necessary, and caches the result as a tab-specific value. + // @param {XULElement} tab + // The tab to be checked + // @return boolean + isShownForTab(tab) { + let tabData = this.getContextData(tab); + + // If there is a "show" value, return it. Can be due to show(), hide() or empty show_matches. + if (tabData.enabled !== undefined) { + return tabData.enabled; + } + + // Otherwise pattern matching must have been configured. Do it, caching the result. + if (tabData.patternMatching === undefined) { + let uri = tab.linkedBrowser.currentURI; + tabData.patternMatching = + tabData.showMatches.matches(uri) && !tabData.hideMatches.matches(uri); + } + return tabData.patternMatching; + } + + async loadIconData() { + const { extension } = this; + const options = extension.manifest.page_action; + this.defaults.icon = await StartupCache.get( + extension, + ["pageAction", "default_icon"], + () => + this.normalizeIcon( + { path: options.default_icon || "" }, + extension, + null + ) + ); + } + + getPinned() { + return this.globals.pinned; + } + + getTargetFromDetails({ tabId, windowId }) { + // PageActionBase doesn't support |windowId| + if (tabId != null) { + return this.getTab(tabId); + } + return null; + } + + api(context) { + return { + ...super.api(context), + show: (...args) => this.enable(...args), + hide: (...args) => this.disable(...args), + isShown: ({ tabId }) => { + let tab = this.getTab(tabId); + return this.isShownForTab(tab); + }, + }; + } +} + +export class BrowserActionBase extends PanelActionBase { + constructor(tabContext, extension) { + const options = + extension.manifest.browser_action || extension.manifest.action; + super(options, tabContext, extension); + + let default_area = + Services.policies?.getExtensionSettings(extension.id)?.default_area || + options.default_area || + "menupanel"; + + this.defaults = { + ...this.defaults, + badgeText: "", + badgeBackgroundColor: [0xd9, 0, 0, 255], + badgeDefaultColor: [255, 255, 255, 255], + badgeTextColor: null, + default_area, + }; + this.globals = Object.create(this.defaults); + } + + async loadIconData() { + const { extension } = this; + const options = + extension.manifest.browser_action || extension.manifest.action; + this.defaults.icon = await StartupCache.get( + extension, + ["browserAction", "default_icon"], + () => + IconDetails.normalize( + { + path: options.default_icon || extension.manifest.icons, + iconType: "browserAction", + themeIcons: options.theme_icons, + }, + extension + ) + ); + } + + handleLocationChange(eventType, tab, fromBrowse) { + super.handleLocationChange(eventType, tab, fromBrowse); + if (fromBrowse) { + this.updateOnChange(tab); + } + } + + getTargetFromDetails({ tabId, windowId }) { + if (tabId != null && windowId != null) { + throw new ExtensionError( + "Only one of tabId and windowId can be specified." + ); + } + if (tabId != null) { + return this.getTab(tabId); + } else if (windowId != null) { + return this.getWindow(windowId); + } + return null; + } + + getDefaultArea() { + return this.globals.default_area; + } + + /** + * Determines the text badge color to be used in a tab, window, or globally. + * + * @param {object} values + * The values associated with the tab or window, or global values. + * @returns {ColorArray} + */ + getTextColor(values) { + // If a text color has been explicitly provided, use it. + let { badgeTextColor } = values; + if (badgeTextColor) { + return badgeTextColor; + } + + // Otherwise, check if the default color to be used has been cached previously. + let { badgeDefaultColor } = values; + if (badgeDefaultColor) { + return badgeDefaultColor; + } + + // Choose a color among white and black, maximizing contrast with background + // according to https://www.w3.org/TR/WCAG20-TECHS/G18.html#G18-procedure + let [r, g, b] = values.badgeBackgroundColor + .slice(0, 3) + .map(function (channel) { + channel /= 255; + if (channel <= 0.03928) { + return channel / 12.92; + } + return ((channel + 0.055) / 1.055) ** 2.4; + }); + let lum = 0.2126 * r + 0.7152 * g + 0.0722 * b; + + // The luminance is 0 for black, 1 for white, and `lum` for the background color. + // Since `0 <= lum`, the contrast ratio for black is `c0 = (lum + 0.05) / 0.05`. + // Since `lum <= 1`, the contrast ratio for white is `c1 = 1.05 / (lum + 0.05)`. + // We want to maximize contrast, so black is chosen if `c1 < c0`, that is, if + // `1.05 * 0.05 < (L + 0.05) ** 2`. Otherwise white is chosen. + let channel = 1.05 * 0.05 < (lum + 0.05) ** 2 ? 0 : 255; + let result = [channel, channel, channel, 255]; + + // Cache the result as high as possible in the prototype chain + while (!Object.getOwnPropertyDescriptor(values, "badgeDefaultColor")) { + values = Object.getPrototypeOf(values); + } + values.badgeDefaultColor = result; + return result; + } + + isShownForTab(tab) { + return this.getProperty(tab, "enabled"); + } + + api(context) { + return { + ...super.api(context), + enable: (...args) => this.enable(...args), + disable: (...args) => this.disable(...args), + isEnabled: details => { + return this.getPropertyFromDetails(details, "enabled"); + }, + setBadgeText: details => { + this.setPropertyFromDetails(details, "badgeText", details.text); + }, + getBadgeText: details => { + return this.getPropertyFromDetails(details, "badgeText"); + }, + setBadgeBackgroundColor: details => { + let color = parseColor(details.color, "background"); + let values = this.setPropertyFromDetails( + details, + "badgeBackgroundColor", + color + ); + if (color === null) { + // Let the default text color inherit after removing background color + delete values.badgeDefaultColor; + } else { + // Invalidate a cached default color calculated with the old background + values.badgeDefaultColor = null; + } + }, + getBadgeBackgroundColor: details => { + return this.getPropertyFromDetails(details, "badgeBackgroundColor"); + }, + setBadgeTextColor: details => { + let color = parseColor(details.color, "text"); + this.setPropertyFromDetails(details, "badgeTextColor", color); + }, + getBadgeTextColor: details => { + let target = this.getTargetFromDetails(details); + let values = this.getContextData(target); + return this.getTextColor(values); + }, + }; + } +} diff --git a/toolkit/components/extensions/ExtensionActivityLog.sys.mjs b/toolkit/components/extensions/ExtensionActivityLog.sys.mjs new file mode 100644 index 0000000000..dd0fd695a1 --- /dev/null +++ b/toolkit/components/extensions/ExtensionActivityLog.sys.mjs @@ -0,0 +1,118 @@ +/* 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 { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", +}); +ChromeUtils.defineLazyGetter(lazy, "tabTracker", () => { + return lazy.ExtensionParent.apiManager.global.tabTracker; +}); + +var { DefaultMap } = ExtensionUtils; + +const MSG_SET_ENABLED = "Extension:ActivityLog:SetEnabled"; +const MSG_LOG = "Extension:ActivityLog:DoLog"; + +export const ExtensionActivityLog = { + initialized: false, + + // id => Set(callbacks) + listeners: new DefaultMap(() => new Set()), + watchedIds: new Set(), + + init() { + if (this.initialized) { + return; + } + + this.initialized = true; + + Services.ppmm.sharedData.set("extensions/logging", this.watchedIds); + + Services.ppmm.addMessageListener(MSG_LOG, this); + }, + + /** + * Notify all listeners of an extension activity. + * + * @param {string} id The ID of the extension that caused the activity. + * @param {string} viewType The view type the activity is in. + * @param {string} type The type of the activity. + * @param {string} name The API name or path. + * @param {object} data Activity specific data. + * @param {Date} [timeStamp] The timestamp for the activity. + */ + log(id, viewType, type, name, data, timeStamp) { + if (!this.initialized) { + return; + } + let callbacks = this.listeners.get(id); + if (callbacks) { + if (!timeStamp) { + timeStamp = new Date(); + } + + for (let callback of callbacks) { + try { + callback({ id, viewType, timeStamp, type, name, data }); + } catch (e) { + Cu.reportError(e); + } + } + } + }, + + addListener(id, callback) { + this.init(); + let callbacks = this.listeners.get(id); + if (callbacks.size === 0) { + this.watchedIds.add(id); + Services.ppmm.sharedData.set("extensions/logging", this.watchedIds); + Services.ppmm.sharedData.flush(); + Services.ppmm.broadcastAsyncMessage(MSG_SET_ENABLED, { id, value: true }); + } + callbacks.add(callback); + }, + + removeListener(id, callback) { + let callbacks = this.listeners.get(id); + if (callbacks.size > 0) { + callbacks.delete(callback); + if (callbacks.size === 0) { + this.watchedIds.delete(id); + Services.ppmm.sharedData.set("extensions/logging", this.watchedIds); + Services.ppmm.sharedData.flush(); + Services.ppmm.broadcastAsyncMessage(MSG_SET_ENABLED, { + id, + value: false, + }); + } + } + }, + + receiveMessage({ name, data }) { + if (name === MSG_LOG) { + let { viewType, browsingContextId } = data; + if (browsingContextId && (!viewType || viewType == "tab")) { + let browser = + BrowsingContext.get(browsingContextId).top.embedderElement; + let browserData = lazy.tabTracker.getBrowserData(browser); + if (browserData && browserData.tabId !== undefined) { + data.data.tabId = browserData.tabId; + } + } + this.log( + data.id, + data.viewType, + data.type, + data.name, + data.data, + new Date(data.timeStamp) + ); + } + }, +}; diff --git a/toolkit/components/extensions/ExtensionChild.sys.mjs b/toolkit/components/extensions/ExtensionChild.sys.mjs new file mode 100644 index 0000000000..20c3c8f2ab --- /dev/null +++ b/toolkit/components/extensions/ExtensionChild.sys.mjs @@ -0,0 +1,1025 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +/** + * This file handles addon logic that is independent of the chrome process and + * may run in all web content and extension processes. + * + * Don't put contentscript logic here, use ExtensionContent.jsm instead. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "finalizationService", + "@mozilla.org/toolkit/finalizationwitness;1", + "nsIFinalizationWitnessService" +); + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionContent: "resource://gre/modules/ExtensionContent.sys.mjs", + ExtensionPageChild: "resource://gre/modules/ExtensionPageChild.sys.mjs", + ExtensionProcessScript: + "resource://gre/modules/ExtensionProcessScript.sys.mjs", + NativeApp: "resource://gre/modules/NativeMessaging.sys.mjs", +}); + +import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs"; +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { DefaultMap, ExtensionError, LimitedSet, getUniqueId } = ExtensionUtils; + +const { + redefineGetter, + EventEmitter, + EventManager, + LocalAPIImplementation, + LocaleData, + NoCloneSpreadArgs, + SchemaAPIInterface, + withHandlingUserInput, +} = ExtensionCommon; + +const { sharedData } = Services.cpmm; + +const MSG_SET_ENABLED = "Extension:ActivityLog:SetEnabled"; +const MSG_LOG = "Extension:ActivityLog:DoLog"; + +export const ExtensionActivityLogChild = { + _initialized: false, + enabledExtensions: new Set(), + + init() { + if (this._initialized) { + return; + } + this._initialized = true; + + Services.cpmm.addMessageListener(MSG_SET_ENABLED, this); + + this.enabledExtensions = new Set( + Services.cpmm.sharedData.get("extensions/logging") + ); + }, + + receiveMessage({ name, data }) { + if (name === MSG_SET_ENABLED) { + if (data.value) { + this.enabledExtensions.add(data.id); + } else { + this.enabledExtensions.delete(data.id); + } + } + }, + + async log(context, type, name, data) { + this.init(); + let { id } = context.extension; + if (this.enabledExtensions.has(id)) { + this._sendActivity({ + timeStamp: Date.now(), + id, + viewType: context.viewType, + type, + name, + data, + browsingContextId: context.browsingContextId, + }); + } + }, + + _sendActivity(data) { + Services.cpmm.sendAsyncMessage(MSG_LOG, data); + }, +}; + +// A helper to allow us to distinguish trusted errors from unsanitized errors. +// Extensions can create plain objects with arbitrary properties (such as +// mozWebExtLocation), but not create instances of ExtensionErrorHolder. +class ExtensionErrorHolder { + constructor(trustedErrorObject) { + this.trustedErrorObject = trustedErrorObject; + } +} + +/** + * A finalization witness helper that wraps a sendMessage response and + * guarantees to either get the promise resolved, or rejected when the + * wrapped promise goes out of scope. + */ +const StrongPromise = { + stillAlive: new Map(), + + wrap(promise, location) { + let id = String(getUniqueId()); + let witness = lazy.finalizationService.make( + "extensions-onMessage-witness", + id + ); + + return new Promise((resolve, reject) => { + this.stillAlive.set(id, { reject, location }); + promise.then(resolve, reject).finally(() => { + this.stillAlive.delete(id); + witness.forget(); + }); + }); + }, + + observe(subject, topic, id) { + let message = "Promised response from onMessage listener went out of scope"; + let { reject, location } = this.stillAlive.get(id); + reject(new ExtensionErrorHolder({ message, mozWebExtLocation: location })); + this.stillAlive.delete(id); + }, +}; +Services.obs.addObserver(StrongPromise, "extensions-onMessage-witness"); + +// Simple single-event emitter-like helper, exposes the EventManager api. +class SimpleEventAPI extends EventManager { + constructor(context, name) { + let fires = new Set(); + let register = fire => { + fires.add(fire); + fire.location = context.getCaller(); + return () => fires.delete(fire); + }; + super({ context, name, register }); + this.fires = fires; + } + /** @returns {any} */ + emit(...args) { + return [...this.fires].map(fire => fire.asyncWithoutClone(...args)); + } +} + +// runtime.OnMessage event helper, handles custom async/sendResponse logic. +class MessageEvent extends SimpleEventAPI { + emit(holder, sender) { + if (!this.fires.size || !this.context.active) { + return { received: false }; + } + + sender = Cu.cloneInto(sender, this.context.cloneScope); + let message = holder.deserialize(this.context.cloneScope); + + let responses = [...this.fires] + .map(fire => this.wrapResponse(fire, message, sender)) + .filter(x => x !== undefined); + + return !responses.length + ? { received: true, response: false } + : Promise.race(responses).then( + value => ({ response: true, value }), + error => Promise.reject(this.unwrapOrSanitizeError(error)) + ); + } + + unwrapOrSanitizeError(error) { + if (error instanceof ExtensionErrorHolder) { + return error.trustedErrorObject; + } + // If not a wrapped error, sanitize it and convert to ExtensionError, so + // that context.normalizeError will use the error message. + return new ExtensionError(error?.message ?? "An unexpected error occurred"); + } + + wrapResponse(fire, message, sender) { + let response, sendResponse; + let promise = new Promise(resolve => { + sendResponse = Cu.exportFunction(value => { + resolve(value); + response = promise; + }, this.context.cloneScope); + }); + + let result; + try { + result = fire.raw(message, sender, sendResponse); + } catch (e) { + return Promise.reject(e); + } + if ( + result && + typeof result === "object" && + Cu.getClassName(result, true) === "Promise" && + this.context.principal.subsumes(Cu.getObjectPrincipal(result)) + ) { + return StrongPromise.wrap(result, fire.location); + } else if (result === true) { + return StrongPromise.wrap(promise, fire.location); + } + return response; + } +} + +function holdMessage(name, anonymizedName, data, native = null) { + if (native && AppConstants.platform !== "android") { + data = lazy.NativeApp.encodeMessage(native.context, data); + } + return new StructuredCloneHolder(name, anonymizedName, data); +} + +// Implements the runtime.Port extension API object. +class Port { + /** + * @param {BaseContext} context The context that owns this port. + * @param {number} portId Uniquely identifies this port's channel. + * @param {string} name Arbitrary port name as defined by the addon. + * @param {boolean} native Is this a Port for native messaging. + * @param {object} sender The `Port.sender` property. + */ + constructor(context, portId, name, native, sender) { + this.context = context; + this.name = name; + this.sender = sender; + this.holdMessage = native + ? (name, anonymizedName, data) => + holdMessage(name, anonymizedName, data, this) + : holdMessage; + this.conduit = context.openConduit(this, { + portId, + native, + source: !sender, + recv: ["PortMessage", "PortDisconnect"], + send: ["PortMessage"], + }); + this.initEventManagers(); + } + + initEventManagers() { + const { context } = this; + this.onMessage = new SimpleEventAPI(context, "Port.onMessage"); + this.onDisconnect = new SimpleEventAPI(context, "Port.onDisconnect"); + } + + getAPI() { + // Public Port object handed to extensions from `connect()` and `onConnect`. + return { + name: this.name, + sender: this.sender, + error: null, + onMessage: this.onMessage.api(), + onDisconnect: this.onDisconnect.api(), + postMessage: this.sendPortMessage.bind(this), + disconnect: () => this.conduit.close(), + }; + } + + recvPortMessage({ holder }) { + this.onMessage.emit(holder.deserialize(this.api), this.api); + } + + recvPortDisconnect({ error = null }) { + this.conduit.close(); + if (this.context.active) { + this.api.error = error && this.context.normalizeError(error); + this.onDisconnect.emit(this.api); + } + } + + sendPortMessage(json) { + if (this.conduit.actor) { + return this.conduit.sendPortMessage({ + holder: this.holdMessage( + `Port/${this.context.extension.id}/sendPortMessage/${this.name}`, + `Port/${this.context.extension.id}/sendPortMessage/<anonymized>`, + json + ), + }); + } + throw new this.context.Error("Attempt to postMessage on disconnected port"); + } + + get api() { + const scope = this.context.cloneScope; + const value = Cu.cloneInto(this.getAPI(), scope, { cloneFunctions: true }); + return redefineGetter(this, "api", value); + } +} + +/** + * Each extension context gets its own Messenger object. It handles the + * basics of sendMessage, onMessage, connect and onConnect. + */ +class Messenger { + constructor(context) { + this.context = context; + this.conduit = context.openConduit(this, { + childId: context.childManager.id, + query: ["NativeMessage", "RuntimeMessage", "PortConnect"], + recv: ["RuntimeMessage", "PortConnect"], + }); + this.initEventManagers(); + } + + initEventManagers() { + const { context } = this; + this.onConnect = new SimpleEventAPI(context, "runtime.onConnect"); + this.onConnectEx = new SimpleEventAPI(context, "runtime.onConnectExternal"); + this.onMessage = new MessageEvent(context, "runtime.onMessage"); + this.onMessageEx = new MessageEvent(context, "runtime.onMessageExternal"); + } + + sendNativeMessage(nativeApp, json) { + let holder = holdMessage( + `Messenger/${this.context.extension.id}/sendNativeMessage/${nativeApp}`, + null, + json, + this + ); + return this.conduit.queryNativeMessage({ nativeApp, holder }); + } + + sendRuntimeMessage({ extensionId, message, callback, ...args }) { + let response = this.conduit.queryRuntimeMessage({ + extensionId: extensionId || this.context.extension.id, + holder: holdMessage( + `Messenger/${this.context.extension.id}/sendRuntimeMessage`, + null, + message + ), + ...args, + }); + // If |response| is a rejected promise, the value will be sanitized by + // wrapPromise, according to the rules of context.normalizeError. + return this.context.wrapPromise(response, callback); + } + + connect({ name, native, ...args }) { + let portId = getUniqueId(); + let port = new Port(this.context, portId, name, !!native); + this.conduit + .queryPortConnect({ portId, name, native, ...args }) + .catch(error => port.recvPortDisconnect({ error })); + return port.api; + } + + recvPortConnect({ extensionId, portId, name, sender }) { + let event = sender.id === extensionId ? this.onConnect : this.onConnectEx; + if (this.context.active && event.fires.size) { + let port = new Port(this.context, portId, name, false, sender); + return event.emit(port.api).length; + } + } + + recvRuntimeMessage({ extensionId, holder, sender }) { + let event = sender.id === extensionId ? this.onMessage : this.onMessageEx; + return event.emit(holder, sender); + } +} + +// For test use only. +var ExtensionManager = { + extensions: new Map(), +}; + +// Represents a browser extension in the content process. +class BrowserExtensionContent extends EventEmitter { + constructor(policy) { + super(); + + this.policy = policy; + // Set a weak reference to this instance on the WebExtensionPolicy expando properties + // (because it makes it easier to reach the extension instance from the policy object + // without leaking it due to a circular dependency keeping it alive). + this.policy.weakExtension = Cu.getWeakReference(this); + + this.instanceId = policy.instanceId; + this.optionalPermissions = policy.optionalPermissions; + + if (WebExtensionPolicy.isExtensionProcess) { + // Keep in sync with serializeExtended in Extension.jsm + let ed = this.getSharedData("extendedData"); + this.backgroundScripts = ed.backgroundScripts; + this.backgroundWorkerScript = ed.backgroundWorkerScript; + this.childModules = ed.childModules; + this.dependencies = ed.dependencies; + this.persistentBackground = ed.persistentBackground; + this.schemaURLs = ed.schemaURLs; + } + + this.MESSAGE_EMIT_EVENT = `Extension:EmitEvent:${this.instanceId}`; + Services.cpmm.addMessageListener(this.MESSAGE_EMIT_EVENT, this); + + this.apiManager = this.getAPIManager(); + + this._manifest = null; + this._localeData = null; + + this.baseURI = Services.io.newURI(`moz-extension://${this.uuid}/`); + this.baseURL = this.baseURI.spec; + + this.principal = Services.scriptSecurityManager.createContentPrincipal( + this.baseURI, + {} + ); + + // Only used in addon processes. + this.blockedParsingDocuments = new WeakSet(); + this.views = new Set(); + + // Only used for devtools views. + this.devtoolsViews = new Set(); + + ExtensionManager.extensions.set(this.id, this); + } + + get id() { + return this.policy.id; + } + + get uuid() { + return this.policy.mozExtensionHostname; + } + + get permissions() { + return new Set(this.policy.permissions); + } + + get allowedOrigins() { + return this.policy.allowedOrigins; + } + + getSharedData(key, value) { + return sharedData.get(`extension/${this.id}/${key}`); + } + + get localeData() { + if (!this._localeData) { + this._localeData = new LocaleData(this.getSharedData("locales")); + } + return this._localeData; + } + + get manifest() { + if (!this._manifest) { + this._manifest = this.getSharedData("manifest"); + } + return this._manifest; + } + + get manifestVersion() { + return this.manifest.manifest_version; + } + + get privateBrowsingAllowed() { + return this.policy.privateBrowsingAllowed; + } + + canAccessWindow(window) { + return this.policy.canAccessWindow(window); + } + + getAPIManager() { + /** @type {InstanceType<typeof ExtensionCommon.LazyAPIManager>[]} */ + let apiManagers = [lazy.ExtensionPageChild.apiManager]; + + if (this.dependencies) { + for (let id of this.dependencies) { + let extension = lazy.ExtensionProcessScript.getExtensionChild(id); + if (extension) { + apiManagers.push(extension.experimentAPIManager); + } + } + } + + if (this.childModules) { + this.experimentAPIManager = new ExtensionCommon.LazyAPIManager( + "addon", + this.childModules, + this.schemaURLs + ); + + apiManagers.push(this.experimentAPIManager); + } + + if (apiManagers.length == 1) { + return apiManagers[0]; + } + + return new ExtensionCommon.MultiAPIManager("addon", apiManagers.reverse()); + } + + shutdown() { + ExtensionManager.extensions.delete(this.id); + lazy.ExtensionContent.shutdownExtension(this); + Services.cpmm.removeMessageListener(this.MESSAGE_EMIT_EVENT, this); + this.emit("shutdown"); + } + + getContext(window) { + return lazy.ExtensionContent.getContext(this, window); + } + + emit(event, ...args) { + Services.cpmm.sendAsyncMessage(this.MESSAGE_EMIT_EVENT, { event, args }); + return super.emit(event, ...args); + } + + // TODO(Bug 1768471): consider folding this back into emit if we will change it to + // return a value as EventEmitter and Extension emit methods do. + emitLocalWithResult(event, ...args) { + return super.emit(event, ...args); + } + + receiveMessage({ name, data }) { + if (name === this.MESSAGE_EMIT_EVENT) { + super.emit(data.event, ...data.args); + } + } + + localizeMessage(...args) { + return this.localeData.localizeMessage(...args); + } + + localize(...args) { + return this.localeData.localize(...args); + } + + hasPermission(perm) { + // If the permission is a "manifest property" permission, we check if the extension + // does have the required property in its manifest. + let manifest_ = "manifest:"; + if (perm.startsWith(manifest_)) { + // Handle nested "manifest property" permission (e.g. as in "manifest:property.nested"). + let value = this.manifest; + for (let prop of perm.substr(manifest_.length).split(".")) { + if (!value) { + break; + } + value = value[prop]; + } + + return value != null; + } + return this.permissions.has(perm); + } + + trackBlockedParsingDocument(doc) { + this.blockedParsingDocuments.add(doc); + } + + untrackBlockedParsingDocument(doc) { + this.blockedParsingDocuments.delete(doc); + } + + hasContextBlockedParsingDocument(extContext) { + return this.blockedParsingDocuments.has(extContext.contentWindow?.document); + } +} + +/** + * An object that runs an remote implementation of an API. + */ +class ProxyAPIImplementation extends SchemaAPIInterface { + /** + * @param {string} namespace The full path to the namespace that contains the + * `name` member. This may contain dots, e.g. "storage.local". + * @param {string} name The name of the method or property. + * @param {ChildAPIManager} childApiManager The owner of this implementation. + * @param {boolean} alreadyLogged Whether the child already logged the event. + */ + constructor(namespace, name, childApiManager, alreadyLogged = false) { + super(); + this.path = `${namespace}.${name}`; + this.childApiManager = childApiManager; + this.alreadyLogged = alreadyLogged; + } + + revoke() { + let map = this.childApiManager.listeners.get(this.path); + for (let listener of map.listeners.keys()) { + this.removeListener(listener); + } + + this.path = null; + this.childApiManager = null; + } + + callFunctionNoReturn(args) { + this.childApiManager.callParentFunctionNoReturn(this.path, args); + } + + callAsyncFunction(args, callback, requireUserInput) { + const context = this.childApiManager.context; + const isHandlingUserInput = + context.contentWindow?.windowUtils?.isHandlingUserInput; + if (requireUserInput) { + if (!isHandlingUserInput) { + let err = new context.cloneScope.Error( + `${this.path} may only be called from a user input handler` + ); + return context.wrapPromise(Promise.reject(err), callback); + } + } + return this.childApiManager.callParentAsyncFunction( + this.path, + args, + callback, + { + alreadyLogged: this.alreadyLogged, + isHandlingUserInput, + } + ); + } + + addListener(listener, args) { + let map = this.childApiManager.listeners.get(this.path); + + if (map.listeners.has(listener)) { + // TODO: Called with different args? + return; + } + + let id = getUniqueId(); + + map.ids.set(id, listener); + map.listeners.set(listener, id); + + this.childApiManager.conduit.sendAddListener({ + childId: this.childApiManager.id, + listenerId: id, + path: this.path, + args, + alreadyLogged: this.alreadyLogged, + }); + } + + removeListener(listener) { + let map = this.childApiManager.listeners.get(this.path); + + if (!map.listeners.has(listener)) { + return; + } + + let id = map.listeners.get(listener); + map.listeners.delete(listener); + map.ids.delete(id); + map.removedIds.add(id); + + this.childApiManager.conduit.sendRemoveListener({ + childId: this.childApiManager.id, + listenerId: id, + path: this.path, + alreadyLogged: this.alreadyLogged, + }); + } + + hasListener(listener) { + let map = this.childApiManager.listeners.get(this.path); + return map.listeners.has(listener); + } +} + +class ChildLocalAPIImplementation extends LocalAPIImplementation { + constructor(pathObj, namespace, name, childApiManager) { + super(pathObj, name, childApiManager.context); + this.childApiManagerId = childApiManager.id; + this.fullname = `${namespace}.${name}`; + } + + /** + * Call the given function and also log the call as appropriate + * (i.e., with activity logging and/or profiler markers) + * + * @param {Function} callable The actual implementation to invoke. + * @param {Array} args Arguments to the function call. + * @returns {any} The return result of callable. + */ + callAndLog(callable, args) { + this.context.logActivity("api_call", this.fullname, { args }); + let start = Cu.now(); + try { + return callable(); + } finally { + ChromeUtils.addProfilerMarker( + "ExtensionChild", + { startTime: start }, + `${this.context.extension.id}, api_call: ${this.fullname}` + ); + } + } + + callFunction(args) { + return this.callAndLog(() => super.callFunction(args), args); + } + + callFunctionNoReturn(args) { + return this.callAndLog(() => super.callFunctionNoReturn(args), args); + } + + callAsyncFunction(args, callback, requireUserInput) { + return this.callAndLog( + () => super.callAsyncFunction(args, callback, requireUserInput), + args + ); + } +} + +// We create one instance of this class for every extension context that +// needs to use remote APIs. It uses the the JSWindowActor and +// JSProcessActor Conduits actors (see ConduitsChild.jsm) to communicate +// with the ParentAPIManager singleton in ExtensionParent.jsm. +// It handles asynchronous function calls as well as event listeners. +class ChildAPIManager { + constructor(context, messageManager, localAPICan, contextData) { + this.context = context; + this.messageManager = messageManager; + this.url = contextData.url; + + // The root namespace of all locally implemented APIs. If an extension calls + // an API that does not exist in this object, then the implementation is + // delegated to the ParentAPIManager. + this.localApis = localAPICan.root; + this.apiCan = localAPICan; + this.schema = this.apiCan.apiManager.schema; + + this.id = `${context.extension.id}.${context.contextId}`; + + this.conduit = context.openConduit(this, { + childId: this.id, + send: [ + "CreateProxyContext", + "ContextLoaded", + "APICall", + "AddListener", + "RemoveListener", + ], + recv: ["CallResult", "RunListener", "StreamFilterSuspendCancel"], + }); + + this.conduit.sendCreateProxyContext({ + childId: this.id, + extensionId: context.extension.id, + principal: context.principal, + ...contextData, + }); + + this.listeners = new DefaultMap(() => ({ + ids: new Map(), + listeners: new Map(), + removedIds: new LimitedSet(10), + })); + + // Map[callId -> Deferred] + this.callPromises = new Map(); + + this.permissionsChangedCallbacks = new Set(); + this.updatePermissions = null; + if (this.context.extension.optionalPermissions.length) { + this.updatePermissions = () => { + for (let callback of this.permissionsChangedCallbacks) { + try { + callback(); + } catch (err) { + Cu.reportError(err); + } + } + }; + this.context.extension.on("update-permissions", this.updatePermissions); + } + } + + inject(obj) { + this.schema.inject(obj, this); + } + + recvCallResult(data) { + let deferred = this.callPromises.get(data.callId); + this.callPromises.delete(data.callId); + if ("error" in data) { + deferred.reject(data.error); + } else { + let result = data.result.deserialize(this.context.cloneScope); + + deferred.resolve(new NoCloneSpreadArgs(result)); + } + } + + recvRunListener(data) { + let map = this.listeners.get(data.path); + let listener = map.ids.get(data.listenerId); + + if (listener) { + if (!this.context.active) { + Services.console.logStringMessage( + `Ignored listener for inactive context at childId=${data.childId} path=${data.path} listenerId=${data.listenerId}\n` + ); + return; + } + + let args = data.args.deserialize(this.context.cloneScope); + let fire = () => this.context.applySafeWithoutClone(listener, args); + return Promise.resolve( + data.handlingUserInput + ? withHandlingUserInput(this.context.contentWindow, fire) + : fire() + ).then(result => { + if (result !== undefined) { + return new StructuredCloneHolder( + `ChildAPIManager/${this.context.extension.id}/${data.path}`, + null, + result, + this.context.cloneScope + ); + } + return result; + }); + } + if (!map.removedIds.has(data.listenerId)) { + Services.console.logStringMessage( + `Unknown listener at childId=${data.childId} path=${data.path} listenerId=${data.listenerId}\n` + ); + } + } + + async recvStreamFilterSuspendCancel() { + const promise = this.context.extension.emitLocalWithResult( + "internal:stream-filter-suspend-cancel" + ); + // if all listeners throws emitLocalWithResult returns undefined. + if (!promise) { + return false; + } + + return promise.then(results => + results.some(hasActiveStreamFilter => hasActiveStreamFilter === true) + ); + } + + /** + * Call a function in the parent process and ignores its return value. + * + * @param {string} path The full name of the method, e.g. "tabs.create". + * @param {Array} args The parameters for the function. + */ + callParentFunctionNoReturn(path, args) { + this.conduit.sendAPICall({ childId: this.id, path, args }); + } + + /** + * Calls a function in the parent process and returns its result + * asynchronously. + * + * @param {string} path The full name of the method, e.g. "tabs.create". + * @param {Array} args The parameters for the function. + * @param {callback} [callback] The callback to be called when the + * function completes. + * @param {object} [options] Extra options. + * @returns {Promise|undefined} Must be void if `callback` is set, and a + * promise otherwise. The promise is resolved when the function completes. + */ + callParentAsyncFunction(path, args, callback, options = {}) { + let callId = getUniqueId(); + let deferred = Promise.withResolvers(); + this.callPromises.set(callId, deferred); + + let { + // Any child api that calls into a parent function will have already + // logged the api_call. Flag it so the parent doesn't log again. + alreadyLogged = true, + // Propagating the isHAndlingUserInput flag to the API call handler + // executed on the parent process side. + isHandlingUserInput = false, + } = options; + + // TODO: conduit.queryAPICall() + this.conduit.sendAPICall({ + childId: this.id, + callId, + path, + args, + options: { alreadyLogged, isHandlingUserInput }, + }); + return this.context.wrapPromise(deferred.promise, callback); + } + + /** + * Create a proxy for an event in the parent process. The returned event + * object shares its internal state with other instances. For instance, if + * `removeListener` is used on a listener that was added on another object + * through `addListener`, then the event is unregistered. + * + * @param {string} path The full name of the event, e.g. "tabs.onCreated". + * @returns {object} An object with the addListener, removeListener and + * hasListener methods. See SchemaAPIInterface for documentation. + */ + getParentEvent(path) { + let parts = path.split("."); + + let name = parts.pop(); + let namespace = parts.join("."); + + let impl = new ProxyAPIImplementation(namespace, name, this, true); + return { + addListener: (listener, ...args) => impl.addListener(listener, args), + removeListener: listener => impl.removeListener(listener), + hasListener: listener => impl.hasListener(listener), + }; + } + + close() { + // Reports CONDUIT_CLOSED on the parent side. + this.conduit.close(); + + if (this.updatePermissions) { + this.context.extension.off("update-permissions", this.updatePermissions); + } + } + + get cloneScope() { + return this.context.cloneScope; + } + + get principal() { + return this.context.principal; + } + + get manifestVersion() { + return this.context.manifestVersion; + } + + shouldInject(namespace, name, allowedContexts) { + // Do not generate content script APIs, unless explicitly allowed. + if ( + this.context.envType === "content_child" && + !allowedContexts.includes("content") + ) { + return false; + } + + // Do not generate devtools APIs, unless explicitly allowed. + if ( + this.context.envType === "devtools_child" && + !allowedContexts.includes("devtools") + ) { + return false; + } + + // Do not generate devtools APIs, unless explicitly allowed. + if ( + this.context.envType !== "devtools_child" && + allowedContexts.includes("devtools_only") + ) { + return false; + } + + // Do not generate content_only APIs, unless explicitly allowed. + if ( + this.context.envType !== "content_child" && + allowedContexts.includes("content_only") + ) { + return false; + } + + return true; + } + + getImplementation(namespace, name) { + this.apiCan.findAPIPath(`${namespace}.${name}`); + let obj = this.apiCan.findAPIPath(namespace); + + if (obj && name in obj) { + return new ChildLocalAPIImplementation(obj, namespace, name, this); + } + + return this.getFallbackImplementation(namespace, name); + } + + getFallbackImplementation(namespace, name) { + // No local API found, defer implementation to the parent. + return new ProxyAPIImplementation(namespace, name, this); + } + + hasPermission(permission) { + return this.context.extension.hasPermission(permission); + } + + isPermissionRevokable(permission) { + return this.context.extension.optionalPermissions.includes(permission); + } + + setPermissionsChangedCallback(callback) { + this.permissionsChangedCallbacks.add(callback); + } +} + +export var ExtensionChild = { + BrowserExtensionContent, + ChildAPIManager, + ChildLocalAPIImplementation, + MessageEvent, + Messenger, + Port, + ProxyAPIImplementation, + SimpleEventAPI, +}; diff --git a/toolkit/components/extensions/ExtensionChildDevToolsUtils.sys.mjs b/toolkit/components/extensions/ExtensionChildDevToolsUtils.sys.mjs new file mode 100644 index 0000000000..084fe8f940 --- /dev/null +++ b/toolkit/components/extensions/ExtensionChildDevToolsUtils.sys.mjs @@ -0,0 +1,111 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +/** + * @file + * This module contains utilities for interacting with DevTools + * from the child process. + */ + +import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; + +// Create a variable to hold the cached ThemeChangeObserver which does not +// get created until a devtools context has been created. +let themeChangeObserver; + +/** + * An observer that watches for changes to the devtools theme and provides + * that information to the devtools.panels.themeName API property, as well as + * emits events for the devtools.panels.onThemeChanged event. It also caches + * the current value of devtools.themeName. + */ +class ThemeChangeObserver extends EventEmitter { + constructor(themeName, onDestroyed) { + super(); + this.themeName = themeName; + this.onDestroyed = onDestroyed; + this.contexts = new Set(); + + Services.cpmm.addMessageListener("Extension:DevToolsThemeChanged", this); + } + + addContext(context) { + if (this.contexts.has(context)) { + throw new Error( + "addContext on the ThemeChangeObserver was called more than once" + + " for the context." + ); + } + + context.callOnClose({ + close: () => this.onContextClosed(context), + }); + + this.contexts.add(context); + } + + onContextClosed(context) { + this.contexts.delete(context); + + if (this.contexts.size === 0) { + this.destroy(); + } + } + + onThemeChanged(themeName) { + // Update the cached themeName and emit an event for the API. + this.themeName = themeName; + this.emit("themeChanged", themeName); + } + + receiveMessage({ name, data }) { + if (name === "Extension:DevToolsThemeChanged") { + this.onThemeChanged(data.themeName); + } + } + + destroy() { + Services.cpmm.removeMessageListener("Extension:DevToolsThemeChanged", this); + this.onDestroyed(); + this.onDestroyed = null; + this.contexts.clear(); + this.contexts = null; + } +} + +export var ExtensionChildDevToolsUtils = { + /** + * Creates an cached instance of the ThemeChangeObserver class and + * initializes it with the current themeName. This cached instance is + * destroyed when all of the contexts added to it are closed. + * + * @param {string} themeName The name of the current devtools theme. + * @param {import("ExtensionPageChild.sys.mjs").DevToolsContextChild} context + * The newly created devtools page context. + */ + initThemeChangeObserver(themeName, context) { + if (!themeChangeObserver) { + themeChangeObserver = new ThemeChangeObserver(themeName, function () { + themeChangeObserver = null; + }); + } + themeChangeObserver.addContext(context); + }, + + /** + * Returns the cached instance of ThemeChangeObserver. + * + * @returns {ThemeChangeObserver} The cached instance of ThemeChangeObserver. + */ + getThemeChangeObserver() { + if (!themeChangeObserver) { + throw new Error( + "A ThemeChangeObserver must be created before being retrieved." + ); + } + return themeChangeObserver; + }, +}; diff --git a/toolkit/components/extensions/ExtensionCommon.sys.mjs b/toolkit/components/extensions/ExtensionCommon.sys.mjs new file mode 100644 index 0000000000..86c99042b6 --- /dev/null +++ b/toolkit/components/extensions/ExtensionCommon.sys.mjs @@ -0,0 +1,3082 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +/** + * This module contains utilities and base classes for logic which is + * common between the parent and child process, and in particular + * between ExtensionParent.jsm and ExtensionChild.jsm. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ConsoleAPI: "resource://gre/modules/Console.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + SchemaRoot: "resource://gre/modules/Schemas.sys.mjs", + Schemas: "resource://gre/modules/Schemas.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "styleSheetService", + "@mozilla.org/content/style-sheet-service;1", + "nsIStyleSheetService" +); + +const ScriptError = Components.Constructor( + "@mozilla.org/scripterror;1", + "nsIScriptError", + "initWithWindowID" +); + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +var { + DefaultMap, + DefaultWeakMap, + ExtensionError, + filterStack, + getInnerWindowID, + getUniqueId, +} = ExtensionUtils; + +function getConsole() { + return new lazy.ConsoleAPI({ + maxLogLevelPref: "extensions.webextensions.log.level", + prefix: "WebExtensions", + }); +} + +// Run a function and report exceptions. +function runSafeSyncWithoutClone(f, ...args) { + try { + return f(...args); + } catch (e) { + // This method is called with `this` unbound and it doesn't have + // access to a BaseContext instance and so we can't check if `e` + // is an instance of the extension context's Error constructor + // (like we do in BaseContext applySafeWithoutClone method). + dump( + `Extension error: ${e} ${e?.fileName} ${ + e?.lineNumber + }\n[[Exception stack\n${ + e?.stack ? filterStack(e) : undefined + }Current stack\n${filterStack(Error())}]]\n` + ); + Cu.reportError(e); + } +} + +// Return true if the given value is an instance of the given +// native type. +function instanceOf(value, type) { + return ( + value && + typeof value === "object" && + ChromeUtils.getClassName(value) === type + ); +} + +/** + * Convert any of several different representations of a date/time to a Date object. + * Accepts several formats: + * a Date object, an ISO8601 string, or a number of milliseconds since the epoch as + * either a number or a string. + * + * @param {Date|string|number} date + * The date to convert. + * @returns {Date} + * A Date object + */ +function normalizeTime(date) { + // Of all the formats we accept the "number of milliseconds since the epoch as a string" + // is an outlier, everything else can just be passed directly to the Date constructor. + return new Date( + typeof date == "string" && /^\d+$/.test(date) ? parseInt(date, 10) : date + ); +} + +function withHandlingUserInput(window, callable) { + let handle = window.windowUtils.setHandlingUserInput(true); + try { + return callable(); + } finally { + handle.destruct(); + } +} + +/** + * Defines a lazy getter for the given property on the given object. The + * first time the property is accessed, the return value of the getter + * is defined on the current `this` object with the given property name. + * Importantly, this means that a lazy getter defined on an object + * prototype will be invoked separately for each object instance that + * it's accessed on. + * + * Note: for better type inference, prefer redefineGetter() below. + * + * @param {object} object + * The prototype object on which to define the getter. + * @param {string | symbol} prop + * The property name for which to define the getter. + * @param {callback} getter + * The function to call in order to generate the final property + * value. + */ +function defineLazyGetter(object, prop, getter) { + Object.defineProperty(object, prop, { + enumerable: true, + configurable: true, + get() { + return redefineGetter(this, prop, getter.call(this), true); + }, + set(value) { + redefineGetter(this, prop, value, true); + }, + }); +} + +/** + * A more type-inference friendly version of defineLazyGetter() above. + * Call it from a real getter (and setter) for your class or object. + * On first run, it will redefine the property with the final value. + * + * @template Value + * @param {object} object + * @param {string | symbol} key + * @param {Value} value + * @returns {Value} + */ +function redefineGetter(object, key, value, writable = false) { + Object.defineProperty(object, key, { + enumerable: true, + configurable: true, + writable, + value, + }); + return value; +} + +function checkLoadURI(uri, principal, options) { + let ssm = Services.scriptSecurityManager; + + let flags = ssm.STANDARD; + if (!options.allowScript) { + flags |= ssm.DISALLOW_SCRIPT; + } + if (!options.allowInheritsPrincipal) { + flags |= ssm.DISALLOW_INHERIT_PRINCIPAL; + } + if (options.dontReportErrors) { + flags |= ssm.DONT_REPORT_ERRORS; + } + + try { + ssm.checkLoadURIWithPrincipal(principal, uri, flags); + } catch (e) { + return false; + } + return true; +} + +function checkLoadURL(url, principal, options) { + try { + return checkLoadURI(Services.io.newURI(url), principal, options); + } catch (e) { + return false; // newURI threw. + } +} + +function makeWidgetId(id) { + id = id.toLowerCase(); + // FIXME: This allows for collisions. + return id.replace(/[^a-z0-9_-]/g, "_"); +} + +/** + * A sentinel class to indicate that an array of values should be + * treated as an array when used as a promise resolution value, but as a + * spread expression (...args) when passed to a callback. + */ +class SpreadArgs extends Array { + constructor(args) { + super(); + this.push(...args); + } +} + +/** + * Like SpreadArgs, but also indicates that the array values already + * belong to the target compartment, and should not be cloned before + * being passed. + * + * The `unwrappedValues` property contains an Array object which belongs + * to the target compartment, and contains the same unwrapped values + * passed the NoCloneSpreadArgs constructor. + */ +class NoCloneSpreadArgs { + constructor(args) { + this.unwrappedValues = args; + } + + [Symbol.iterator]() { + return this.unwrappedValues[Symbol.iterator](); + } +} + +const LISTENERS = Symbol("listeners"); +const ONCE_MAP = Symbol("onceMap"); + +class EventEmitter { + constructor() { + this[LISTENERS] = new Map(); + this[ONCE_MAP] = new WeakMap(); + } + + /** + * Checks whether there is some listener for the given event. + * + * @param {string} event + * The name of the event to listen for. + * @returns {boolean} + */ + has(event) { + return this[LISTENERS].has(event); + } + + /** + * Adds the given function as a listener for the given event. + * + * The listener function may optionally return a Promise which + * resolves when it has completed all operations which event + * dispatchers may need to block on. + * + * @param {string} event + * The name of the event to listen for. + * @param {function(string, ...any): any} listener + * The listener to call when events are emitted. + */ + on(event, listener) { + let listeners = this[LISTENERS].get(event); + if (!listeners) { + listeners = new Set(); + this[LISTENERS].set(event, listeners); + } + + listeners.add(listener); + } + + /** + * Removes the given function as a listener for the given event. + * + * @param {string} event + * The name of the event to stop listening for. + * @param {function(string, ...any): any} listener + * The listener function to remove. + */ + off(event, listener) { + let set = this[LISTENERS].get(event); + if (set) { + set.delete(listener); + set.delete(this[ONCE_MAP].get(listener)); + if (!set.size) { + this[LISTENERS].delete(event); + } + } + } + + /** + * Adds the given function as a listener for the given event once. + * + * @param {string} event + * The name of the event to listen for. + * @param {function(string, ...any): any} listener + * The listener to call when events are emitted. + */ + once(event, listener) { + let wrapper = (event, ...args) => { + this.off(event, wrapper); + this[ONCE_MAP].delete(listener); + + return listener(event, ...args); + }; + this[ONCE_MAP].set(listener, wrapper); + + this.on(event, wrapper); + } + + /** + * Triggers all listeners for the given event. If any listeners return + * a value, returns a promise which resolves when all returned + * promises have resolved. Otherwise, returns undefined. + * + * @param {string} event + * The name of the event to emit. + * @param {any} args + * Arbitrary arguments to pass to the listener functions, after + * the event name. + * @returns {Promise?} + */ + emit(event, ...args) { + let listeners = this[LISTENERS].get(event); + + if (listeners) { + let promises = []; + + for (let listener of listeners) { + try { + let result = listener(event, ...args); + if (result !== undefined) { + promises.push(result); + } + } catch (e) { + Cu.reportError(e); + } + } + + if (promises.length) { + return Promise.all(promises); + } + } + } +} + +/** + * Base class for WebExtension APIs. Each API creates a new class + * that inherits from this class, the derived class is instantiated + * once for each extension that uses the API. + */ +class ExtensionAPI extends EventEmitter { + constructor(extension) { + super(); + + this.extension = extension; + + extension.once("shutdown", (what, isAppShutdown) => { + if (this.onShutdown) { + this.onShutdown(isAppShutdown); + } + this.extension = null; + }); + } + + destroy() {} + + /** @param {string} entryName */ + onManifestEntry(entryName) {} + + /** @param {boolean} isAppShutdown */ + onShutdown(isAppShutdown) {} + + /** @param {BaseContext} context */ + getAPI(context) { + throw new Error("Not Implemented"); + } + + /** @param {string} id */ + static onDisable(id) {} + + /** @param {string} id */ + static onUninstall(id) {} + + /** + * @param {string} id + * @param {Record<string, JSONValue>} manifest + */ + static onUpdate(id, manifest) {} +} + +/** + * Subclass to add APIs commonly used with persistent events. + * If a namespace uses events, it should use this subclass. + * + * this.apiNamespace = class extends ExtensionAPIPersistent {}; + */ +class ExtensionAPIPersistent extends ExtensionAPI { + /** @type {Record<string, callback>} */ + PERSISTENT_EVENTS; + + /** + * Check for event entry. + * + * @param {string} event The event name e.g. onStateChanged + * @returns {boolean} + */ + hasEventRegistrar(event) { + return ( + this.PERSISTENT_EVENTS && Object.hasOwn(this.PERSISTENT_EVENTS, event) + ); + } + + /** + * Get the event registration fuction + * + * @param {string} event The event name e.g. onStateChanged + * @returns {Function} register is used to start the listener + * register returns an object containing + * a convert and unregister function. + */ + getEventRegistrar(event) { + if (this.hasEventRegistrar(event)) { + return this.PERSISTENT_EVENTS[event].bind(this); + } + } + + /** + * Used when instantiating an EventManager instance to register the listener. + * + * @param {object} options Options used for event registration + * @param {BaseContext} options.context Extension Context passed when creating an EventManager instance. + * @param {string} options.event The eAPI vent name. + * @param {Function} options.fire The function passed to the listener to fire the event. + * @param {Array<any>} params An optional array of parameters received along with the + * addListener request. + * @returns {Function} The unregister function used in the EventManager. + */ + registerEventListener(options, params) { + const apiRegistar = this.getEventRegistrar(options.event); + return apiRegistar?.(options, params).unregister; + } + + /** + * Used to prime a listener for when the background script is not running. + * + * @param {string} event The event name e.g. onStateChanged or captiveURL.onChange. + * @param {Function} fire The function passed to the listener to fire the event. + * @param {Array} params Params passed to the event listener. + * @param {boolean} isInStartup unused here but passed for subclass use. + * @returns {object} the unregister and convert functions used in the EventManager. + */ + primeListener(event, fire, params, isInStartup) { + const apiRegistar = this.getEventRegistrar(event); + return apiRegistar?.({ fire, isInStartup }, params); + } +} + +/** + * This class contains the information we have about an individual + * extension. It is never instantiated directly, instead subclasses + * for each type of process extend this class and add members that are + * relevant for that process. + * + * @abstract + */ +class BaseContext { + /** @type {boolean} */ + isTopContext; + /** @type {string} */ + viewType; + + constructor(envType, extension) { + this.envType = envType; + this.onClose = new Set(); + this.checkedLastError = false; + this._lastError = null; + this.contextId = getUniqueId(); + this.unloaded = false; + this.extension = extension; + this.manifestVersion = extension.manifestVersion; + this.jsonSandbox = null; + this.active = true; + this.incognito = null; + this.messageManager = null; + this.contentWindow = null; + this.innerWindowID = 0; + + // These two properties are assigned in ContentScriptContextChild subclass + // to keep a copy of the content script sandbox Error and Promise globals + // (which are used by the WebExtensions internals) before any extension + // content script code had any chance to redefine them. + this.cloneScopeError = null; + this.cloneScopePromise = null; + } + + get isProxyContextParent() { + return false; + } + + get Error() { + // Return the copy stored in the context instance (when the context is an instance of + // ContentScriptContextChild or the global from extension page window otherwise). + return this.cloneScopeError || this.cloneScope.Error; + } + + get Promise() { + // Return the copy stored in the context instance (when the context is an instance of + // ContentScriptContextChild or the global from extension page window otherwise). + return this.cloneScopePromise || this.cloneScope.Promise; + } + + get privateBrowsingAllowed() { + return this.extension.privateBrowsingAllowed; + } + + get isBackgroundContext() { + if (this.viewType === "background") { + if (this.isProxyContextParent) { + return !!this.isTopContext; // Set in ExtensionPageContextParent. + } + const { contentWindow } = this; + return !!contentWindow && contentWindow.top === contentWindow; + } + return this.viewType === "background_worker"; + } + + /** + * Whether the extension context is using the WebIDL bindings for the + * WebExtensions APIs. + * To be overridden in subclasses (e.g. WorkerContextChild) and to be + * optionally used in ExtensionAPI classes to customize the behavior of the + * API when the calls to the extension API are originated from the WebIDL + * bindings. + */ + get useWebIDLBindings() { + return false; + } + + canAccessWindow(window) { + return this.extension.canAccessWindow(window); + } + + canAccessContainer(userContextId) { + return this.extension.canAccessContainer(userContextId); + } + + /** + * Opens a conduit linked to this context, populating related address fields. + * Only available in child contexts with an associated contentWindow. + * + * @param {object} subject + * @param {ConduitAddress} address + * @returns {import("ConduitsChild.sys.mjs").PointConduit} + * @type {ConduitOpen} + */ + openConduit(subject, address) { + let wgc = this.contentWindow.windowGlobalChild; + let conduit = wgc.getActor("Conduits").openConduit(subject, { + id: subject.id || getUniqueId(), + extensionId: this.extension.id, + envType: this.envType, + ...address, + }); + this.callOnClose(conduit); + conduit.setCloseCallback(() => { + this.forgetOnClose(conduit); + }); + return conduit; + } + + setContentWindow(contentWindow) { + if (!this.canAccessWindow(contentWindow)) { + throw new Error( + "BaseContext attempted to load when extension is not allowed due to incognito settings." + ); + } + + this.innerWindowID = getInnerWindowID(contentWindow); + this.messageManager = contentWindow.docShell.messageManager; + + if (this.incognito == null) { + this.incognito = + lazy.PrivateBrowsingUtils.isContentWindowPrivate(contentWindow); + } + + let wgc = contentWindow.windowGlobalChild; + Object.defineProperty(this, "active", { + configurable: true, + enumerable: true, + get: () => wgc.isCurrentGlobal && !wgc.windowContext.isInBFCache, + }); + Object.defineProperty(this, "contentWindow", { + configurable: true, + enumerable: true, + get: () => (this.active ? wgc.browsingContext.window : null), + }); + this.callOnClose({ + close: () => { + // Allow other "close" handlers to use these properties, until the next tick. + Promise.resolve().then(() => { + Object.defineProperty(this, "contentWindow", { value: null }); + Object.defineProperty(this, "active", { value: false }); + wgc = null; + }); + }, + }); + } + + // All child contexts must implement logActivity. This is handled if the child + // context subclasses ExtensionBaseContextChild. ProxyContextParent overrides + // this with a noop for parent contexts. + logActivity(type, name, data) { + throw new Error(`Not implemented for ${this.envType}`); + } + + /** @type {object} */ + get cloneScope() { + throw new Error("Not implemented"); + } + + /** @type {nsIPrincipal} */ + get principal() { + throw new Error("Not implemented"); + } + + runSafe(callback, ...args) { + return this.applySafe(callback, args); + } + + runSafeWithoutClone(callback, ...args) { + return this.applySafeWithoutClone(callback, args); + } + + applySafe(callback, args, caller) { + if (this.unloaded) { + Cu.reportError("context.runSafe called after context unloaded", caller); + } else if (!this.active) { + Cu.reportError( + "context.runSafe called while context is inactive", + caller + ); + } else { + try { + let { cloneScope } = this; + args = args.map(arg => Cu.cloneInto(arg, cloneScope)); + } catch (e) { + Cu.reportError(e); + dump( + `runSafe failure: cloning into ${ + this.cloneScope + }: ${e}\n\n${filterStack(Error())}` + ); + } + + return this.applySafeWithoutClone(callback, args, caller); + } + } + + applySafeWithoutClone(callback, args, caller) { + if (this.unloaded) { + Cu.reportError( + "context.runSafeWithoutClone called after context unloaded", + caller + ); + } else if (!this.active) { + Cu.reportError( + "context.runSafeWithoutClone called while context is inactive", + caller + ); + } else { + try { + return Reflect.apply(callback, null, args); + } catch (e) { + // An extension listener may as well be throwing an object that isn't + // an instance of Error, in that case we have to use fallbacks for the + // error message, fileName, lineNumber and columnNumber properties. + const isError = e instanceof this.Error; + let message; + let fileName; + let lineNumber; + let columnNumber; + + if (isError) { + message = `${e.name}: ${e.message}`; + lineNumber = e.lineNumber; + columnNumber = e.columnNumber; + fileName = e.fileName; + } else { + message = `uncaught exception: ${e}`; + + try { + // TODO(Bug 1810582): the following fallback logic may go away once + // we introduced a better way to capture and log the exception in + // the right window and in all cases (included when the extension + // code is raising undefined or an object that isn't an instance of + // the Error constructor). + // + // Fallbacks for the error location: + // - the callback location if it is registered directly from the + // extension code (and not wrapped by the child/ext-APINAMe.js + // implementation, like e.g. browser.storage, browser.devtools.network + // are doing and browser.menus). + // - if the location of the extension callback is not directly + // available (e.g. browser.storage onChanged events, and similarly + // for browser.devtools.network and browser.menus events): + // - the extension page url if the context is an extension page + // - the extension base url if the context is a content script + const cbLoc = Cu.getFunctionSourceLocation(callback); + fileName = cbLoc.filename; + lineNumber = cbLoc.lineNumber ?? lineNumber; + + const extBaseUrl = this.extension.baseURI.resolve("/"); + if (fileName.startsWith(extBaseUrl)) { + fileName = cbLoc.filename; + lineNumber = cbLoc.lineNumber ?? lineNumber; + } else { + fileName = this.contentWindow?.location?.href; + if (!fileName || !fileName.startsWith(extBaseUrl)) { + fileName = extBaseUrl; + } + } + } catch { + // Ignore errors on retrieving the callback source location. + } + } + + dump( + `Extension error: ${message} ${fileName} ${lineNumber}\n[[Exception stack\n${ + isError ? filterStack(e) : undefined + }Current stack\n${filterStack(Error())}]]\n` + ); + + // If the error is coming from an extension context associated + // to a window (e.g. an extension page or extension content script). + // + // TODO(Bug 1810574): for the background service worker we will need to do + // something similar, but not tied to the innerWindowID because there + // wouldn't be one set for extension contexts related to the + // background service worker. + // + // TODO(Bug 1810582): change the error associated to the innerWindowID to also + // include a full stack from the original error. + if (!this.isProxyContextParent && this.contentWindow) { + Services.console.logMessage( + new ScriptError( + message, + fileName, + null, + lineNumber, + columnNumber, + Ci.nsIScriptError.errorFlag, + "content javascript", + this.innerWindowID + ) + ); + } + // Also report the original error object (because it also includes + // the full error stack). + Cu.reportError(e); + } + } + } + + checkLoadURL(url, options = {}) { + // As an optimization, f the URL starts with the extension's base URL, + // don't do any further checks. It's always allowed to load it. + if (url.startsWith(this.extension.baseURL)) { + return true; + } + + return checkLoadURL(url, this.principal, options); + } + + /** + * Safely call JSON.stringify() on an object that comes from an + * extension. + * + * @param {[any, callback?, number?]} args for JSON.stringify() + * @returns {string} The stringified representation of obj + */ + jsonStringify(...args) { + if (!this.jsonSandbox) { + this.jsonSandbox = Cu.Sandbox(this.principal, { + sameZoneAs: this.cloneScope, + wantXrays: false, + }); + } + + return Cu.waiveXrays(this.jsonSandbox.JSON).stringify(...args); + } + + callOnClose(obj) { + this.onClose.add(obj); + } + + forgetOnClose(obj) { + this.onClose.delete(obj); + } + + get lastError() { + this.checkedLastError = true; + return this._lastError; + } + + set lastError(val) { + this.checkedLastError = false; + this._lastError = val; + } + + /** + * Normalizes the given error object for use by the target scope. If + * the target is an error object which belongs to that scope, it is + * returned as-is. If it is an ordinary object with a `message` + * property, it is converted into an error belonging to the target + * scope. If it is an Error object which does *not* belong to the + * clone scope, it is reported, and converted to an unexpected + * exception error. + * + * @param {Error|object} error + * @param {SavedFrame?} [caller] + * @returns {Error} + */ + normalizeError(error, caller) { + if (error instanceof this.Error) { + return error; + } + let message, fileName; + if (error && typeof error === "object") { + const isPlain = ChromeUtils.getClassName(error) === "Object"; + if (isPlain && error.mozWebExtLocation) { + caller = error.mozWebExtLocation; + } + if (isPlain && caller && (error.mozWebExtLocation || !error.fileName)) { + caller = Cu.cloneInto(caller, this.cloneScope); + return ChromeUtils.createError(error.message, caller); + } + + if ( + isPlain || + error instanceof ExtensionError || + this.principal.subsumes(Cu.getObjectPrincipal(error)) + ) { + message = error.message; + fileName = error.fileName; + } + } + + if (!message) { + Cu.reportError(error); + message = "An unexpected error occurred"; + } + return new this.Error(message, fileName); + } + + /** + * Sets the value of `.lastError` to `error`, calls the given + * callback, and reports an error if the value has not been checked + * when the callback returns. + * + * @param {object} error An object with a `message` property. May + * optionally be an `Error` object belonging to the target scope. + * @param {SavedFrame?} caller + * The optional caller frame which triggered this callback, to be used + * in error reporting. + * @param {Function} callback The callback to call. + * @returns {*} The return value of callback. + */ + withLastError(error, caller, callback) { + this.lastError = this.normalizeError(error); + try { + return callback(); + } finally { + if (!this.checkedLastError) { + Cu.reportError(`Unchecked lastError value: ${this.lastError}`, caller); + } + this.lastError = null; + } + } + + /** + * Captures the most recent stack frame which belongs to the extension. + * + * @returns {SavedFrame?} + */ + getCaller() { + return ChromeUtils.getCallerLocation(this.principal); + } + + /** + * Wraps the given promise so it can be safely returned to extension + * code in this context. + * + * If `callback` is provided, however, it is used as a completion + * function for the promise, and no promise is returned. In this case, + * the callback is called when the promise resolves or rejects. In the + * latter case, `lastError` is set to the rejection value, and the + * callback function must check `browser.runtime.lastError` or + * `extension.runtime.lastError` in order to prevent it being reported + * to the console. + * + * @param {Promise} promise The promise with which to wrap the + * callback. May resolve to a `SpreadArgs` instance, in which case + * each element will be used as a separate argument. + * + * Unless the promise object belongs to the cloneScope global, its + * resolution value is cloned into cloneScope prior to calling the + * `callback` function or resolving the wrapped promise. + * + * @param {Function} [callback] The callback function to wrap + * + * @returns {Promise|undefined} If callback is null, a promise object + * belonging to the target scope. Otherwise, undefined. + */ + wrapPromise(promise, callback = null) { + let caller = this.getCaller(); + let applySafe = this.applySafe.bind(this); + if (Cu.getGlobalForObject(promise) === this.cloneScope) { + applySafe = this.applySafeWithoutClone.bind(this); + } + + if (callback) { + promise.then( + args => { + if (this.unloaded) { + Cu.reportError(`Promise resolved after context unloaded\n`, caller); + } else if (!this.active) { + Cu.reportError( + `Promise resolved while context is inactive\n`, + caller + ); + } else if (args instanceof NoCloneSpreadArgs) { + this.applySafeWithoutClone(callback, args.unwrappedValues, caller); + } else if (args instanceof SpreadArgs) { + applySafe(callback, args, caller); + } else { + applySafe(callback, [args], caller); + } + }, + error => { + this.withLastError(error, caller, () => { + if (this.unloaded) { + Cu.reportError( + `Promise rejected after context unloaded\n`, + caller + ); + } else if (!this.active) { + Cu.reportError( + `Promise rejected while context is inactive\n`, + caller + ); + } else { + this.applySafeWithoutClone(callback, [], caller); + } + }); + } + ); + } else { + return new this.Promise((resolve, reject) => { + promise.then( + value => { + if (this.unloaded) { + Cu.reportError( + `Promise resolved after context unloaded\n`, + caller + ); + } else if (!this.active) { + Cu.reportError( + `Promise resolved while context is inactive\n`, + caller + ); + } else if (value instanceof NoCloneSpreadArgs) { + let values = value.unwrappedValues; + this.applySafeWithoutClone( + resolve, + values.length == 1 ? [values[0]] : [values], + caller + ); + } else if (value instanceof SpreadArgs) { + applySafe(resolve, value.length == 1 ? value : [value], caller); + } else { + applySafe(resolve, [value], caller); + } + }, + value => { + if (this.unloaded) { + Cu.reportError( + `Promise rejected after context unloaded: ${ + value && value.message + }\n`, + caller + ); + } else if (!this.active) { + Cu.reportError( + `Promise rejected while context is inactive: ${ + value && value.message + }\n`, + caller + ); + } else { + this.applySafeWithoutClone( + reject, + [this.normalizeError(value, caller)], + caller + ); + } + } + ); + }); + } + } + + unload() { + this.unloaded = true; + + for (let obj of this.onClose) { + obj.close(); + } + this.onClose.clear(); + } + + /** + * A simple proxy for unload(), for use with callOnClose(). + */ + close() { + this.unload(); + } +} + +/** + * An object that runs the implementation of a schema API. Instantiations of + * this interfaces are used by Schemas.jsm. + * + * @interface + */ +class SchemaAPIInterface { + /** + * Calls this as a function that returns its return value. + * + * @abstract + * @param {Array} args The parameters for the function. + * @returns {*} The return value of the invoked function. + */ + callFunction(args) { + throw new Error("Not implemented"); + } + + /** + * Calls this as a function and ignores its return value. + * + * @abstract + * @param {Array} args The parameters for the function. + */ + callFunctionNoReturn(args) { + throw new Error("Not implemented"); + } + + /** + * Calls this as a function that completes asynchronously. + * + * @abstract + * @param {Array} args The parameters for the function. + * @param {callback} [callback] The callback to be called when the function + * completes. + * @param {boolean} [requireUserInput=false] If true, the function should + * fail if the browser is not currently handling user input. + * @returns {Promise|undefined} Must be void if `callback` is set, and a + * promise otherwise. The promise is resolved when the function completes. + */ + callAsyncFunction(args, callback, requireUserInput = false) { + throw new Error("Not implemented"); + } + + /** + * Retrieves the value of this as a property. + * + * @abstract + * @returns {*} The value of the property. + */ + getProperty() { + throw new Error("Not implemented"); + } + + /** + * Assigns the value to this as property. + * + * @abstract + * @param {string} value The new value of the property. + */ + setProperty(value) { + throw new Error("Not implemented"); + } + + /** + * Registers a `listener` to this as an event. + * + * @abstract + * @param {Function} listener The callback to be called when the event fires. + * @param {Array} args Extra parameters for EventManager.addListener. + * @see EventManager.addListener + */ + addListener(listener, args) { + throw new Error("Not implemented"); + } + + /** + * Checks whether `listener` is listening to this as an event. + * + * @abstract + * @param {Function} listener The event listener. + * @returns {boolean} Whether `listener` is registered with this as an event. + * @see EventManager.hasListener + */ + hasListener(listener) { + throw new Error("Not implemented"); + } + + /** + * Unregisters `listener` from this as an event. + * + * @abstract + * @param {Function} listener The event listener. + * @see EventManager.removeListener + */ + removeListener(listener) { + throw new Error("Not implemented"); + } + + /** + * Revokes the implementation object, and prevents any further method + * calls from having external effects. + * + * @abstract + */ + revoke() { + throw new Error("Not implemented"); + } +} + +/** + * An object that runs a locally implemented API. + */ +class LocalAPIImplementation extends SchemaAPIInterface { + /** + * Constructs an implementation of the `name` method or property of `pathObj`. + * + * @param {object} pathObj The object containing the member with name `name`. + * @param {string} name The name of the implemented member. + * @param {BaseContext} context The context in which the schema is injected. + */ + constructor(pathObj, name, context) { + super(); + this.pathObj = pathObj; + this.name = name; + this.context = context; + } + + revoke() { + if (this.pathObj[this.name][lazy.Schemas.REVOKE]) { + this.pathObj[this.name][lazy.Schemas.REVOKE](); + } + + this.pathObj = null; + this.name = null; + this.context = null; + } + + callFunction(args) { + try { + return this.pathObj[this.name](...args); + } catch (e) { + throw this.context.normalizeError(e); + } + } + + callFunctionNoReturn(args) { + try { + this.pathObj[this.name](...args); + } catch (e) { + throw this.context.normalizeError(e); + } + } + + callAsyncFunction(args, callback, requireUserInput) { + let promise; + try { + if (requireUserInput) { + if (!this.context.contentWindow.windowUtils.isHandlingUserInput) { + throw new ExtensionError( + `${this.name} may only be called from a user input handler` + ); + } + } + promise = this.pathObj[this.name](...args) || Promise.resolve(); + } catch (e) { + promise = Promise.reject(e); + } + return this.context.wrapPromise(promise, callback); + } + + getProperty() { + return this.pathObj[this.name]; + } + + setProperty(value) { + this.pathObj[this.name] = value; + } + + addListener(listener, args) { + try { + this.pathObj[this.name].addListener.call(null, listener, ...args); + } catch (e) { + throw this.context.normalizeError(e); + } + } + + hasListener(listener) { + return this.pathObj[this.name].hasListener.call(null, listener); + } + + removeListener(listener) { + this.pathObj[this.name].removeListener.call(null, listener); + } +} + +// Recursively copy properties from source to dest. +function deepCopy(dest, source) { + for (let prop in source) { + let desc = Object.getOwnPropertyDescriptor(source, prop); + if (typeof desc.value == "object") { + if (!(prop in dest)) { + dest[prop] = {}; + } + deepCopy(dest[prop], source[prop]); + } else { + Object.defineProperty(dest, prop, desc); + } + } +} + +function getChild(map, key) { + let child = map.children.get(key); + if (!child) { + child = { + modules: new Set(), + children: new Map(), + }; + + map.children.set(key, child); + } + return child; +} + +function getPath(map, path) { + for (let key of path) { + map = getChild(map, key); + } + return map; +} + +function mergePaths(dest, source) { + for (let name of source.modules) { + dest.modules.add(name); + } + + for (let [name, child] of source.children.entries()) { + mergePaths(getChild(dest, name), child); + } +} + +/** + * Manages loading and accessing a set of APIs for a specific extension + * context. + * + * @param {BaseContext} context + * The context to manage APIs for. + * @param {SchemaAPIManager} apiManager + * The API manager holding the APIs to manage. + * @param {object} root + * The root object into which APIs will be injected. + */ +class CanOfAPIs { + constructor(context, apiManager, root) { + this.context = context; + this.scopeName = context.envType; + this.apiManager = apiManager; + this.root = root; + + this.apiPaths = new Map(); + + this.apis = new Map(); + } + + /** + * Synchronously loads and initializes an ExtensionAPI instance. + * + * @param {string} name + * The name of the API to load. + */ + loadAPI(name) { + if (this.apis.has(name)) { + return; + } + + let { extension } = this.context; + + let api = this.apiManager.getAPI(name, extension, this.scopeName); + if (!api) { + return; + } + + this.apis.set(name, api); + + deepCopy(this.root, api.getAPI(this.context)); + } + + /** + * Asynchronously loads and initializes an ExtensionAPI instance. + * + * @param {string} name + * The name of the API to load. + */ + async asyncLoadAPI(name) { + if (this.apis.has(name)) { + return; + } + + let { extension } = this.context; + if (!lazy.Schemas.checkPermissions(name, extension)) { + return; + } + + let api = await this.apiManager.asyncGetAPI( + name, + extension, + this.scopeName + ); + // Check again, because async; + if (this.apis.has(name)) { + return; + } + + this.apis.set(name, api); + + deepCopy(this.root, api.getAPI(this.context)); + } + + /** + * Finds the API at the given path from the root object, and + * synchronously loads the API that implements it if it has not + * already been loaded. + * + * @param {string} path + * The "."-separated path to find. + * @returns {*} + */ + findAPIPath(path) { + if (this.apiPaths.has(path)) { + return this.apiPaths.get(path); + } + + let obj = this.root; + let modules = this.apiManager.modulePaths; + + let parts = path.split("."); + for (let [i, key] of parts.entries()) { + if (!obj) { + return; + } + modules = getChild(modules, key); + + for (let name of modules.modules) { + if (!this.apis.has(name)) { + this.loadAPI(name); + } + } + + if (!(key in obj) && i < parts.length - 1) { + obj[key] = {}; + } + obj = obj[key]; + } + + this.apiPaths.set(path, obj); + return obj; + } + + /** + * Finds the API at the given path from the root object, and + * asynchronously loads the API that implements it if it has not + * already been loaded. + * + * @param {string} path + * The "."-separated path to find. + * @returns {Promise<*>} + */ + async asyncFindAPIPath(path) { + if (this.apiPaths.has(path)) { + return this.apiPaths.get(path); + } + + let obj = this.root; + let modules = this.apiManager.modulePaths; + + let parts = path.split("."); + for (let [i, key] of parts.entries()) { + if (!obj) { + return; + } + modules = getChild(modules, key); + + for (let name of modules.modules) { + if (!this.apis.has(name)) { + await this.asyncLoadAPI(name); + } + } + + if (!(key in obj) && i < parts.length - 1) { + obj[key] = {}; + } + + if (typeof obj[key] === "function") { + obj = obj[key].bind(obj); + } else { + obj = obj[key]; + } + } + + this.apiPaths.set(path, obj); + return obj; + } +} + +/** + * @class APIModule + * @abstract + * + * @property {string} url + * The URL of the script which contains the module's + * implementation. This script must define a global property + * matching the modules name, which must be a class constructor + * which inherits from {@link ExtensionAPI}. + * + * @property {string} schema + * The URL of the JSON schema which describes the module's API. + * + * @property {Array<string>} scopes + * The list of scope names into which the API may be loaded. + * + * @property {Array<string>} manifest + * The list of top-level manifest properties which will trigger + * the module to be loaded, and its `onManifestEntry` method to be + * called. + * + * @property {Array<string>} events + * The list events which will trigger the module to be loaded, and + * its appropriate event handler method to be called. Currently + * only accepts "startup". + * + * @property {Array<string>} permissions + * An optional list of permissions, any of which must be present + * in order for the module to load. + * + * @property {Array<Array<string>>} paths + * A list of paths from the root API object which, when accessed, + * will cause the API module to be instantiated and injected. + */ + +/** + * This object loads the ext-*.js scripts that define the extension API. + * + * This class instance is shared with the scripts that it loads, so that the + * ext-*.js scripts and the instantiator can communicate with each other. + */ +class SchemaAPIManager extends EventEmitter { + /** + * @param {string} processType + * "main" - The main, one and only chrome browser process. + * "addon" - An addon process. + * "content" - A content process. + * "devtools" - A devtools process. + * @param {import("Schemas.sys.mjs").SchemaRoot} [schema] + */ + constructor(processType, schema) { + super(); + this.processType = processType; + this.global = null; + if (schema) { + this.schema = schema; + } + + this.modules = new Map(); + this.modulePaths = { children: new Map(), modules: new Set() }; + this.manifestKeys = new Map(); + this.eventModules = new DefaultMap(() => new Set()); + this.settingsModules = new Set(); + + this._modulesJSONLoaded = false; + + this.schemaURLs = new Map(); + + this.apis = new DefaultWeakMap(() => new Map()); + + this._scriptScopes = []; + } + + onStartup(extension) { + let promises = []; + for (let apiName of this.eventModules.get("startup")) { + promises.push( + extension.apiManager.asyncGetAPI(apiName, extension).then(api => { + if (api) { + api.onStartup(); + } + }) + ); + } + + return Promise.all(promises); + } + + async loadModuleJSON(urls) { + let promises = urls.map(url => fetch(url).then(resp => resp.json())); + + return this.initModuleJSON(await Promise.all(promises)); + } + + initModuleJSON(blobs) { + for (let json of blobs) { + this.registerModules(json); + } + + this._modulesJSONLoaded = true; + + return new StructuredCloneHolder("SchemaAPIManager/initModuleJSON", null, { + modules: this.modules, + modulePaths: this.modulePaths, + manifestKeys: this.manifestKeys, + eventModules: this.eventModules, + settingsModules: this.settingsModules, + schemaURLs: this.schemaURLs, + }); + } + + initModuleData(moduleData) { + if (!this._modulesJSONLoaded) { + let data = moduleData.deserialize({}, true); + + this.modules = data.modules; + this.modulePaths = data.modulePaths; + this.manifestKeys = data.manifestKeys; + this.eventModules = new DefaultMap(() => new Set(), data.eventModules); + this.settingsModules = new Set(data.settingsModules); + this.schemaURLs = data.schemaURLs; + } + + this._modulesJSONLoaded = true; + } + + /** + * Registers a set of ExtensionAPI modules to be lazily loaded and + * managed by this manager. + * + * @param {object} obj + * An object containing property for eacy API module to be + * registered. Each value should be an object implementing the + * APIModule interface. + */ + registerModules(obj) { + for (let [name, details] of Object.entries(obj)) { + details.namespaceName = name; + + if (this.modules.has(name)) { + throw new Error(`Module '${name}' already registered`); + } + this.modules.set(name, details); + + if (details.schema) { + let content = + details.scopes && + (details.scopes.includes("content_parent") || + details.scopes.includes("content_child")); + this.schemaURLs.set(details.schema, { content }); + } + + for (let event of details.events || []) { + this.eventModules.get(event).add(name); + } + + if (details.settings) { + this.settingsModules.add(name); + } + + for (let key of details.manifest || []) { + if (this.manifestKeys.has(key)) { + throw new Error( + `Manifest key '${key}' already registered by '${this.manifestKeys.get( + key + )}'` + ); + } + + this.manifestKeys.set(key, name); + } + + for (let path of details.paths || []) { + getPath(this.modulePaths, path).modules.add(name); + } + } + } + + /** + * Emits an `onManifestEntry` event for the top-level manifest entry + * on all relevant {@link ExtensionAPI} instances for the given + * extension. + * + * The API modules will be synchronously loaded if they have not been + * loaded already. + * + * @param {Extension} extension + * The extension for which to emit the events. + * @param {string} entry + * The name of the top-level manifest entry. + * + * @returns {*} + */ + emitManifestEntry(extension, entry) { + let apiName = this.manifestKeys.get(entry); + if (apiName) { + let api = extension.apiManager.getAPI(apiName, extension); + return api.onManifestEntry(entry); + } + } + /** + * Emits an `onManifestEntry` event for the top-level manifest entry + * on all relevant {@link ExtensionAPI} instances for the given + * extension. + * + * The API modules will be asynchronously loaded if they have not been + * loaded already. + * + * @param {Extension} extension + * The extension for which to emit the events. + * @param {string} entry + * The name of the top-level manifest entry. + * + * @returns {Promise<*>} + */ + async asyncEmitManifestEntry(extension, entry) { + let apiName = this.manifestKeys.get(entry); + if (apiName) { + let api = await extension.apiManager.asyncGetAPI(apiName, extension); + return api.onManifestEntry(entry); + } + } + + /** + * Returns the {@link ExtensionAPI} instance for the given API module, + * for the given extension, in the given scope, synchronously loading + * and instantiating it if necessary. + * + * @param {string} name + * The name of the API module to load. + * @param {Extension} extension + * The extension for which to load the API. + * @param {string} [scope = null] + * The scope type for which to retrieve the API, or null if not + * being retrieved for a particular scope. + * + * @returns {ExtensionAPI?} + */ + getAPI(name, extension, scope = null) { + if (!this._checkGetAPI(name, extension, scope)) { + return; + } + + let apis = this.apis.get(extension); + if (apis.has(name)) { + return apis.get(name); + } + + let module = this.loadModule(name); + + let api = new module(extension); + apis.set(name, api); + return api; + } + /** + * Returns the {@link ExtensionAPI} instance for the given API module, + * for the given extension, in the given scope, asynchronously loading + * and instantiating it if necessary. + * + * @param {string} name + * The name of the API module to load. + * @param {Extension} extension + * The extension for which to load the API. + * @param {string} [scope = null] + * The scope type for which to retrieve the API, or null if not + * being retrieved for a particular scope. + * + * @returns {Promise<ExtensionAPI>?} + */ + async asyncGetAPI(name, extension, scope = null) { + if (!this._checkGetAPI(name, extension, scope)) { + return; + } + + let apis = this.apis.get(extension); + if (apis.has(name)) { + return apis.get(name); + } + + let module = await this.asyncLoadModule(name); + + // Check again, because async. + if (apis.has(name)) { + return apis.get(name); + } + + let api = new module(extension); + apis.set(name, api); + return api; + } + + /** + * Synchronously loads an API module, if not already loaded, and + * returns its ExtensionAPI constructor. + * + * @param {string} name + * The name of the module to load. + * @returns {typeof ExtensionAPI} + */ + loadModule(name) { + let module = this.modules.get(name); + if (module.loaded) { + return this.global[name]; + } + + this._checkLoadModule(module, name); + + this.initGlobal(); + + Services.scriptloader.loadSubScript(module.url, this.global); + + module.loaded = true; + + return this.global[name]; + } + /** + * aSynchronously loads an API module, if not already loaded, and + * returns its ExtensionAPI constructor. + * + * @param {string} name + * The name of the module to load. + * + * @returns {Promise<typeof ExtensionAPI>} + */ + asyncLoadModule(name) { + let module = this.modules.get(name); + if (module.loaded) { + return Promise.resolve(this.global[name]); + } + if (module.asyncLoaded) { + return module.asyncLoaded; + } + + this._checkLoadModule(module, name); + + module.asyncLoaded = ChromeUtils.compileScript(module.url).then(script => { + this.initGlobal(); + script.executeInGlobal(this.global); + + module.loaded = true; + + return this.global[name]; + }); + + return module.asyncLoaded; + } + + asyncLoadSettingsModules() { + return Promise.all( + Array.from(this.settingsModules).map(apiName => + this.asyncLoadModule(apiName) + ) + ); + } + + getModule(name) { + return this.modules.get(name); + } + + /** + * Checks whether the given API module may be loaded for the given + * extension, in the given scope. + * + * @param {string} name + * The name of the API module to check. + * @param {Extension} extension + * The extension for which to check the API. + * @param {string} [scope = null] + * The scope type for which to check the API, or null if not + * being checked for a particular scope. + * + * @returns {boolean} + * Whether the module may be loaded. + */ + _checkGetAPI(name, extension, scope = null) { + let module = this.getModule(name); + if (!module) { + // A module may not exist for a particular manifest version, but + // we allow keys in the manifest. An example is pageAction. + return false; + } + + if ( + module.permissions && + !module.permissions.some(perm => extension.hasPermission(perm)) + ) { + return false; + } + + if (!scope) { + return true; + } + + if (!module.scopes.includes(scope)) { + return false; + } + + if (!lazy.Schemas.checkPermissions(module.namespaceName, extension)) { + return false; + } + + return true; + } + + _checkLoadModule(module, name) { + if (!module) { + throw new Error(`Module '${name}' does not exist`); + } + if (module.asyncLoaded) { + throw new Error(`Module '${name}' currently being lazily loaded`); + } + if (this.global && this.global[name]) { + throw new Error( + `Module '${name}' conflicts with existing global property` + ); + } + } + + /** + * Create a global object that is used as the shared global for all ext-*.js + * scripts that are loaded via `loadScript`. + * + * @returns {object} A sandbox that is used as the global by `loadScript`. + */ + _createExtGlobal() { + let global = Cu.Sandbox( + Services.scriptSecurityManager.getSystemPrincipal(), + { + wantXrays: false, + wantGlobalProperties: ["ChromeUtils"], + sandboxName: `Namespace of ext-*.js scripts for ${this.processType} (from: resource://gre/modules/ExtensionCommon.jsm)`, + } + ); + + Object.assign(global, { + AppConstants, + Cc, + ChromeWorker, + Ci, + Cr, + Cu, + ExtensionAPI, + ExtensionAPIPersistent, + ExtensionCommon, + IOUtils, + MatchGlob, + MatchPattern, + MatchPatternSet, + PathUtils, + Services, + StructuredCloneHolder, + WebExtensionPolicy, + XPCOMUtils, + extensions: this, + global, + }); + + ChromeUtils.defineLazyGetter(global, "console", getConsole); + // eslint-disable-next-line mozilla/lazy-getter-object-name + ChromeUtils.defineESModuleGetters(global, { + ExtensionUtils: "resource://gre/modules/ExtensionUtils.sys.mjs", + }); + + return global; + } + + initGlobal() { + if (!this.global) { + this.global = this._createExtGlobal(); + } + } + + /** + * Load an ext-*.js script. The script runs in its own scope, if it wishes to + * share state with another script it can assign to the `global` variable. If + * it wishes to communicate with this API manager, use `extensions`. + * + * @param {string} scriptUrl The URL of the ext-*.js script. + */ + loadScript(scriptUrl) { + // Create the object in the context of the sandbox so that the script runs + // in the sandbox's context instead of here. + let scope = Cu.createObjectIn(this.global); + + Services.scriptloader.loadSubScript(scriptUrl, scope); + + // Save the scope to avoid it being garbage collected. + this._scriptScopes.push(scope); + } +} + +class LazyAPIManager extends SchemaAPIManager { + constructor(processType, moduleData, schemaURLs) { + super(processType); + + /** @type {Promise | boolean} */ + this.initialized = false; + + this.initModuleData(moduleData); + + this.schemaURLs = schemaURLs; + } + + lazyInit() {} +} + +defineLazyGetter(LazyAPIManager.prototype, "schema", function () { + let root = new lazy.SchemaRoot(lazy.Schemas.rootSchema, this.schemaURLs); + root.parseSchemas(); + return root; +}); + +class MultiAPIManager extends SchemaAPIManager { + constructor(processType, children) { + super(processType); + + this.initialized = false; + + this.children = children; + } + + async lazyInit() { + if (!this.initialized) { + this.initialized = true; + + for (let child of this.children) { + if (child.lazyInit) { + let res = child.lazyInit(); + if (res && typeof res.then === "function") { + await res; + } + } + + mergePaths(this.modulePaths, child.modulePaths); + } + } + } + + onStartup(extension) { + return Promise.all(this.children.map(child => child.onStartup(extension))); + } + + getModule(name) { + for (let child of this.children) { + if (child.modules.has(name)) { + return child.modules.get(name); + } + } + } + + loadModule(name) { + for (let child of this.children) { + if (child.modules.has(name)) { + return child.loadModule(name); + } + } + } + + asyncLoadModule(name) { + for (let child of this.children) { + if (child.modules.has(name)) { + return child.asyncLoadModule(name); + } + } + } +} + +defineLazyGetter(MultiAPIManager.prototype, "schema", function () { + let bases = this.children.map(child => child.schema); + + // All API manager schema roots should derive from the global schema root, + // so it doesn't need its own entry. + if (bases[bases.length - 1] === lazy.Schemas) { + bases.pop(); + } + + if (bases.length === 1) { + bases = bases[0]; + } + return new lazy.SchemaRoot(bases, new Map()); +}); + +export function LocaleData(data) { + this.defaultLocale = data.defaultLocale; + this.selectedLocale = data.selectedLocale; + this.locales = data.locales || new Map(); + this.warnedMissingKeys = new Set(); + + // Map(locale-name -> Map(message-key -> localized-string)) + // + // Contains a key for each loaded locale, each of which is a + // Map of message keys to their localized strings. + this.messages = data.messages || new Map(); + + if (data.builtinMessages) { + this.messages.set(this.BUILTIN, data.builtinMessages); + } +} + +LocaleData.prototype = { + // Representation of the object to send to content processes. This + // should include anything the content process might need. + serialize() { + return { + defaultLocale: this.defaultLocale, + selectedLocale: this.selectedLocale, + messages: this.messages, + locales: this.locales, + }; + }, + + BUILTIN: "@@BUILTIN_MESSAGES", + + has(locale) { + return this.messages.has(locale); + }, + + // https://developer.chrome.com/extensions/i18n + localizeMessage(message, substitutions = [], options = {}) { + let defaultOptions = { + defaultValue: "", + cloneScope: null, + }; + + let locales = this.availableLocales; + if (options.locale) { + locales = new Set( + [this.BUILTIN, options.locale, this.defaultLocale].filter(locale => + this.messages.has(locale) + ) + ); + } + + options = Object.assign(defaultOptions, options); + + // Message names are case-insensitive, so normalize them to lower-case. + message = message.toLowerCase(); + for (let locale of locales) { + let messages = this.messages.get(locale); + if (messages.has(message)) { + let str = messages.get(message); + + if (!str.includes("$")) { + return str; + } + + if (!Array.isArray(substitutions)) { + substitutions = [substitutions]; + } + + let replacer = (matched, index, dollarSigns) => { + if (index) { + // This is not quite Chrome-compatible. Chrome consumes any number + // of digits following the $, but only accepts 9 substitutions. We + // accept any number of substitutions. + index = parseInt(index, 10) - 1; + return index in substitutions ? substitutions[index] : ""; + } + // For any series of contiguous `$`s, the first is dropped, and + // the rest remain in the output string. + return dollarSigns; + }; + return str.replace(/\$(?:([1-9]\d*)|(\$+))/g, replacer); + } + } + + // Check for certain pre-defined messages. + if (message == "@@ui_locale") { + return this.uiLocale; + } else if (message.startsWith("@@bidi_")) { + let rtl = Services.locale.isAppLocaleRTL; + + if (message == "@@bidi_dir") { + return rtl ? "rtl" : "ltr"; + } else if (message == "@@bidi_reversed_dir") { + return rtl ? "ltr" : "rtl"; + } else if (message == "@@bidi_start_edge") { + return rtl ? "right" : "left"; + } else if (message == "@@bidi_end_edge") { + return rtl ? "left" : "right"; + } + } + + if (!this.warnedMissingKeys.has(message)) { + let error = `Unknown localization message ${message}`; + if (options.cloneScope) { + error = new options.cloneScope.Error(error); + } + Cu.reportError(error); + this.warnedMissingKeys.add(message); + } + return options.defaultValue; + }, + + // Localize a string, replacing all |__MSG_(.*)__| tokens with the + // matching string from the current locale, as determined by + // |this.selectedLocale|. + // + // This may not be called before calling either |initLocale| or + // |initAllLocales|. + localize(str, locale = this.selectedLocale) { + if (!str) { + return str; + } + + return str.replace(/__MSG_([A-Za-z0-9@_]+?)__/g, (matched, message) => { + return this.localizeMessage(message, [], { + locale, + defaultValue: matched, + }); + }); + }, + + // Validates the contents of a locale JSON file, normalizes the + // messages into a Map of message key -> localized string pairs. + addLocale(locale, messages, extension) { + let result = new Map(); + + let isPlainObject = obj => + obj && + typeof obj === "object" && + ChromeUtils.getClassName(obj) === "Object"; + + // Chrome does not document the semantics of its localization + // system very well. It handles replacements by pre-processing + // messages, replacing |$[a-zA-Z0-9@_]+$| tokens with the value of their + // replacements. Later, it processes the resulting string for + // |$[0-9]| replacements. + // + // Again, it does not document this, but it accepts any number + // of sequential |$|s, and replaces them with that number minus + // 1. It also accepts |$| followed by any number of sequential + // digits, but refuses to process a localized string which + // provides more than 9 substitutions. + if (!isPlainObject(messages)) { + extension.packagingError(`Invalid locale data for ${locale}`); + return result; + } + + for (let key of Object.keys(messages)) { + let msg = messages[key]; + + if (!isPlainObject(msg) || typeof msg.message != "string") { + extension.packagingError( + `Invalid locale message data for ${locale}, message ${JSON.stringify( + key + )}` + ); + continue; + } + + // Substitutions are case-insensitive, so normalize all of their names + // to lower-case. + let placeholders = new Map(); + if ("placeholders" in msg && isPlainObject(msg.placeholders)) { + for (let key of Object.keys(msg.placeholders)) { + placeholders.set(key.toLowerCase(), msg.placeholders[key]); + } + } + + let replacer = (match, name) => { + let replacement = placeholders.get(name.toLowerCase()); + if (isPlainObject(replacement) && "content" in replacement) { + return replacement.content; + } + return ""; + }; + + let value = msg.message.replace(/\$([A-Za-z0-9@_]+)\$/g, replacer); + + // Message names are also case-insensitive, so normalize them to lower-case. + result.set(key.toLowerCase(), value); + } + + this.messages.set(locale, result); + return result; + }, + + get acceptLanguages() { + let result = Services.prefs.getComplexValue( + "intl.accept_languages", + Ci.nsIPrefLocalizedString + ).data; + return result.split(/\s*,\s*/g); + }, + + get uiLocale() { + return Services.locale.appLocaleAsBCP47; + }, + + get availableLocales() { + const locales = [this.BUILTIN, this.selectedLocale, this.defaultLocale]; + const value = new Set(locales.filter(locale => this.messages.has(locale))); + return redefineGetter(this, "availableLocales", value); + }, +}; + +/** + * This is a generic class for managing event listeners. + * + * @example + * new EventManager({ + * context, + * name: "api.subAPI", + * register: fire => { + * let listener = (...) => { + * // Fire any listeners registered with addListener. + * fire.async(arg1, arg2); + * }; + * // Register the listener. + * SomehowRegisterListener(listener); + * return () => { + * // Return a way to unregister the listener. + * SomehowUnregisterListener(listener); + * }; + * } + * }).api() + * + * The result is an object with addListener, removeListener, and + * hasListener methods. `context` is an add-on scope (either an + * ExtensionContext in the chrome process or ExtensionContext in a + * content process). + */ +class EventManager { + /* + * A persistent event must provide module and name. Additionally the + * module must implement primeListeners in the ExtensionAPI class. + * + * A startup blocking event must also add the startupBlocking flag in + * ext-toolkit.json or ext-browser.json. + * + * Listeners synchronously added from a background extension context + * will be persisted, for a persistent background script only the + * "startup blocking" events will be persisted. + * + * EventManager instances created in a child process can't persist any listener. + * + * @param {object} params + * Parameters that control this EventManager. + * @param {BaseContext} params.context + * An object representing the extension instance using this event. + * @param {string} params.module + * The API module name, required for persistent events. + * @param {string} params.event + * The API event name, required for persistent events. + * @param {ExtensionAPI} params.extensionApi + * The API intance. If the API uses the ExtensionAPIPersistent class, some simplification is + * possible by passing the api (self or this) and the internal register function will be used. + * @param {string} [params.name] + * A name used only for debugging. If not provided, name is built from module and event. + * @param {functon} params.register + * A function called whenever a new listener is added. + * @param {boolean} [params.inputHandling=false] + * If true, the "handling user input" flag is set while handlers + * for this event are executing. + */ + constructor(params) { + let { + context, + module, + event, + name, + register, + extensionApi, + inputHandling = false, + resetIdleOnEvent = true, + } = params; + this.context = context; + this.module = module; + this.event = event; + this.name = name; + this.register = register; + this.inputHandling = inputHandling; + this.resetIdleOnEvent = resetIdleOnEvent; + + const isBackgroundParent = + this.context.envType === "addon_parent" && + this.context.isBackgroundContext; + + // TODO(Bug 1844041): ideally we should restrict resetIdleOnEvent to + // EventManager instances that belongs to the event page, but along + // with that we should consider if calling sendMessage from an event + // page should also reset idle timer, and so in the shorter term + // here we are allowing listeners from other extension pages to + // also reset the idle timer. + const isAddonContext = ["addon_parent", "addon_child"].includes( + this.context.envType + ); + + // Avoid resetIdleOnEvent overhead by only consider it when applicable. + if (!isAddonContext || context.extension.persistentBackground) { + this.resetIdleOnEvent = false; + } + + if (!name) { + this.name = `${module}.${event}`; + } + + if (!this.register && extensionApi instanceof ExtensionAPIPersistent) { + this.register = (fire, ...params) => { + return extensionApi.registerEventListener( + { context, event, fire }, + params + ); + }; + } + if (!this.register) { + throw new Error( + `EventManager requires register method for ${this.name}.` + ); + } + + this.canPersistEvents = module && event && isBackgroundParent; + + if (this.canPersistEvents) { + let { extension } = context; + if (extension.persistentBackground) { + // Persistent backgrounds will only persist startup blocking APIs. + let api_module = extension.apiManager.getModule(this.module); + if (!api_module?.startupBlocking) { + this.canPersistEvents = false; + } + } else { + // Event pages will persist all APIs that implement primeListener. + // The api is already loaded so this does not have performance effect. + let api = extension.apiManager.getAPI( + this.module, + extension, + "addon_parent" + ); + + // If the api doesn't implement primeListener we do not persist the events. + if (!api?.primeListener) { + this.canPersistEvents = false; + } + } + } + + this.unregister = new Map(); + this.remove = new Map(); + } + + /* + * Information about listeners to persistent events is associated with + * the extension to which they belong. Any extension thas has such + * listeners has a property called `persistentListeners` that is a + * 3-level Map: + * + * - the first 2 keys are the module name (e.g., webRequest) + * and the name of the event within the module (e.g., onBeforeRequest). + * + * - the third level of the map is used to track multiple listeners for + * the same event, these listeners are distinguished by the extra arguments + * passed to addListener() + * + * - for quick lookups, the key to the third Map is the result of calling + * uneval() on the array of extra arguments. + * + * - the value stored in the Map or persistent listeners we keep in memory + * is a plain object with: + * - a property called `params` that is the original (ie, not uneval()ed) + * extra arguments to addListener() + * - and a property called `listeners` that is an array of plain object + * each representing a listener to be primed and a `primeId` autoincremented + * integer that represents each of the primed listeners that belongs to the + * group listeners with the same set of extra params. + * - a `nextPrimeId` property keeps track of the numeric primeId that should + * be assigned to new persistent listeners added for the same event and + * same set of extra params. + * + * For a primed listener (i.e., the stub listener created during browser startup + * before the extension background page is started, and after an event page is + * suspended on idle), the object will be later populated (by the callers of + * EventManager.primeListeners) with an additional `primed` property that serves + * as a placeholder listener, collecting all events that got emitted while the + * background page was not yet started, and eventually replaced by a callback + * registered from the extension code, once the background page scripts have been + * executed (or dropped if the background page scripts do not register the same + * listener anymore). + * + * @param {Extension} extension + * @returns {boolean} True if the extension had any persistent listeners. + */ + static _initPersistentListeners(extension) { + if (extension.persistentListeners) { + return !!extension.persistentListeners.size; + } + + let listeners = new DefaultMap(() => new DefaultMap(() => new Map())); + extension.persistentListeners = listeners; + + let persistentListeners = extension.startupData?.persistentListeners; + if (!persistentListeners) { + return false; + } + + let found = false; + for (let [module, savedModuleEntry] of Object.entries( + persistentListeners + )) { + for (let [event, savedEventEntry] of Object.entries(savedModuleEntry)) { + for (let paramList of savedEventEntry) { + /* Before Bug 1795801 (Firefox < 113) each entry was related to a listener + * registered with a different set of extra params (and so only one listener + * could be persisted for the same set of extra params) + * + * After Bug 1795801 (Firefox >= 113) each entry still represents a listener + * registered for that event, but multiple listeners registered with the same + * set of extra params will be captured as multiple entries in the + * paramsList array. + * + * NOTE: persisted listeners are stored in the startupData part of the Addon DB + * and are expected to be preserved across Firefox and Addons upgrades and downgrades + * (unlike the WebExtensions startupCache data which is cleared when Firefox or the + * addon is updated) and so we are taking special care about forward and backward + * compatibility of the persistentListeners on-disk format: + * + * - forward compatibility: when this new version of this startupData loading logic + * is loading the old persistentListeners on-disk format: + * - on the first run only one listener will be primed for each of the extra params + * recorded in the startupData (same as in older Firefox versions) + * and Bug 1795801 will still be hit, but once the background + * context is started once the startupData will be updated to + * include each of the listeners (indipendently if the set of + * extra params is the same as another listener already been + * persisted). + * - after the first run, all listeners will be primed separately, even if the extra + * params are the same as other listeners already primed, and so + * each of the listener will receive the pending events collected + * by their related primed listener and Bug 1795801 not to be hit anymore. + * + * - backward compatibility: when the old version of this startupData loading logic + * (https://searchfox.org/mozilla-central/rev/cd2121e7d8/toolkit/components/extensions/ExtensionCommon.jsm#2360-2371) + * is loading the new persistentListeners on-disk format, the last + * entry with the same set of extra params will be eventually overwritting the + * entry for another primed listener with the same extra params, Bug 1795801 will still + * be hit, but no actual change in behavior is expected. + */ + let key = uneval(paramList); + const eventEntry = listeners.get(module).get(event); + + if (eventEntry.has(key)) { + const keyEntry = eventEntry.get(key); + let primeId = keyEntry.nextPrimeId; + keyEntry.listeners.push({ primeId }); + keyEntry.nextPrimeId++; + } else { + eventEntry.set(key, { + params: paramList, + nextPrimeId: 1, + listeners: [{ primeId: 0 }], + }); + } + found = true; + } + } + } + return found; + } + + // Extract just the information needed at startup for all persistent + // listeners, and arrange for it to be saved. This should be called + // whenever the set of persistent listeners for an extension changes. + static _writePersistentListeners(extension) { + let startupListeners = {}; + for (let [module, moduleEntry] of extension.persistentListeners) { + startupListeners[module] = {}; + for (let [event, eventEntry] of moduleEntry) { + // Turn the per-event entries from the format they are being kept + // in memory: + // + // [ + // { params: paramList1, listeners: [listener1, listener2, ...] }, + // { params: paramList2, listeners: [listener3, listener3, ...] }, + // ... + // ] + // + // into the format used for storing them on disk (in the startupData), + // which is an array of the params for each listener (with the param list + // included as many times as many listeners are persisted for the same + // set of params): + // + // [paramList1, paramList1, ..., paramList2, paramList2, ...] + // + // This format will also work as expected on older Firefox versions where + // only one listener was being persisted for each set of params. + startupListeners[module][event] = Array.from( + eventEntry.values() + ).flatMap(keyEntry => keyEntry.listeners.map(() => keyEntry.params)); + } + } + + extension.startupData.persistentListeners = startupListeners; + extension.saveStartupData(); + } + + // Set up "primed" event listeners for any saved event listeners + // in an extension's startup data. + // This function is only called during browser startup, it stores details + // about all primed listeners in the extension's persistentListeners Map. + static primeListeners(extension, isInStartup = false) { + if (!EventManager._initPersistentListeners(extension)) { + return; + } + + for (let [module, moduleEntry] of extension.persistentListeners) { + // If we're in startup, we only want to continue attempting to prime a + // subset of events that should be startup blocking. + if (isInStartup) { + let api_module = extension.apiManager.getModule(module); + if (!api_module.startupBlocking) { + continue; + } + } + + let api = extension.apiManager.getAPI(module, extension, "addon_parent"); + + // If an extension is upgraded and a permission, such as webRequest, is + // removed, we will have been called but the API is no longer available. + if (!api?.primeListener) { + // The runtime module no longer implements primed listeners, drop them. + extension.persistentListeners.delete(module); + EventManager._writePersistentListeners(extension); + continue; + } + for (let [event, eventEntry] of moduleEntry) { + for (let [key, { params, listeners }] of eventEntry) { + for (let listener of listeners) { + // Reset the `listener.added` flag by setting it to `false` while + // re-priming the listeners because the event page has suspended + // and the previous converted listener is no longer listening. + const listenerWasAdded = listener.added; + listener.added = false; + listener.params = params; + let primed = { pendingEvents: [] }; + + let fireEvent = (...args) => + new Promise((resolve, reject) => { + if (!listener.primed) { + reject( + new Error( + `primed listener ${module}.${event} not re-registered` + ) + ); + return; + } + primed.pendingEvents.push({ args, resolve, reject }); + extension.emit("background-script-event"); + }); + + let fire = { + wakeup: () => extension.wakeupBackground(), + sync: fireEvent, + async: fireEvent, + // fire.async for ProxyContextParent is already not cloning. + raw: fireEvent, + }; + + try { + let handler = api.primeListener( + event, + fire, + listener.params, + isInStartup + ); + if (handler) { + listener.primed = primed; + Object.assign(primed, handler); + } + } catch (e) { + Cu.reportError( + `Error priming listener ${module}.${event}: ${e} :: ${e.stack}` + ); + // Force this listener to be cleared. + listener.error = true; + } + + // If an attempt to prime a listener failed, ensure it is cleared now. + // If a module is a startup blocking module, not all listeners may + // get primed during early startup. For that reason, we don't clear + // persisted listeners during early startup. At the end of background + // execution any listeners that were not renewed will be cleared. + // + // TODO(Bug 1797474): consider priming runtime.onStartup and + // avoid to special handling it here. + if ( + listener.error || + (!isInStartup && + !( + (`${module}.${event}` === "runtime.onStartup" && + listenerWasAdded) || + listener.primed + )) + ) { + EventManager.clearPersistentListener( + extension, + module, + event, + key, + listener.primeId + ); + } + } + } + } + } + } + + /** + * This is called as a result of background script startup-finished and shutdown. + * + * After startup, it removes any remaining primed listeners. These exist if the + * listener was not renewed during startup. In this case the persisted listener + * data is also removed. + * + * During shutdown, care should be taken to set clearPersistent to false. + * persisted listener data should NOT be cleared during shutdown. + * + * @param {Extension} extension + * @param {boolean} clearPersistent whether the persisted listener data should be cleared. + */ + static clearPrimedListeners(extension, clearPersistent = true) { + if (!extension.persistentListeners) { + return; + } + + for (let [module, moduleEntry] of extension.persistentListeners) { + for (let [event, eventEntry] of moduleEntry) { + for (let [key, { listeners }] of eventEntry) { + for (let listener of listeners) { + let { primed, added, primeId } = listener; + // When a primed listener is added or renewed during initial + // background execution we set an added flag. If it was primed + // when added, primed is set to null. + if (added) { + continue; + } + + if (primed) { + // When a primed listener was not renewed, primed will still be truthy. + // These need to be cleared on shutdown (important for event pages), but + // we only clear the persisted listener data after the startup of a background. + // Release any pending events and unregister the primed handler. + listener.primed = null; + + for (let evt of primed.pendingEvents) { + evt.reject(new Error("listener not re-registered")); + } + primed.unregister(); + } + + // Clear any persisted events that were not renewed, should typically + // only be done at the end of the background page load. + if (clearPersistent) { + EventManager.clearPersistentListener( + extension, + module, + event, + key, + primeId + ); + } + } + } + } + } + } + + // Record the fact that there is a listener for the given event in + // the given extension. `args` is an Array containing any extra + // arguments that were passed to addListener(). + static savePersistentListener(extension, module, event, args = []) { + EventManager._initPersistentListeners(extension); + let key = uneval(args); + const eventEntry = extension.persistentListeners.get(module).get(event); + + let primeId; + if (!eventEntry.has(key)) { + // when writing, only args are written, other properties are dropped + primeId = 0; + eventEntry.set(key, { + params: args, + listeners: [{ added: true, primeId }], + nextPrimeId: 1, + }); + } else { + const keyEntry = eventEntry.get(key); + primeId = keyEntry.nextPrimeId; + keyEntry.listeners.push({ added: true, primeId }); + keyEntry.nextPrimeId = primeId + 1; + } + + EventManager._writePersistentListeners(extension); + return [module, event, key, primeId]; + } + + // Remove the record for the given event listener from the extension's + // startup data. `key` must be a string, the result of calling uneval() + // on the array of extra arguments originally passed to addListener(). + static clearPersistentListener( + extension, + module, + event, + key = uneval([]), + primeId = undefined + ) { + let eventEntry = extension.persistentListeners.get(module).get(event); + + let keyEntry = eventEntry.get(key); + + if (primeId != undefined && keyEntry) { + keyEntry.listeners = keyEntry.listeners.filter( + listener => listener.primeId !== primeId + ); + } + + if (primeId == undefined || keyEntry?.listeners.length === 0) { + eventEntry.delete(key); + if (eventEntry.size == 0) { + let moduleEntry = extension.persistentListeners.get(module); + moduleEntry.delete(event); + if (moduleEntry.size == 0) { + extension.persistentListeners.delete(module); + } + } + } + + EventManager._writePersistentListeners(extension); + } + + addListener(callback, ...args) { + if (this.unregister.has(callback)) { + return; + } + this.context.logActivity("api_call", `${this.name}.addListener`, { args }); + + let shouldFire = () => { + if (this.context.unloaded) { + dump(`${this.name} event fired after context unloaded.\n`); + } else if (!this.context.active) { + dump(`${this.name} event fired while context is inactive.\n`); + } else if (this.unregister.has(callback)) { + return true; + } + return false; + }; + + let { extension } = this.context; + const resetIdle = () => { + if (this.resetIdleOnEvent) { + extension?.emit("background-script-reset-idle", { + reason: "event", + eventName: this.name, + }); + } + }; + + let fire = { + // Bug 1754866 fire.sync doesn't match documentation. + sync: (...args) => { + if (shouldFire()) { + resetIdle(); + let result = this.context.applySafe(callback, args); + this.context.logActivity("api_event", this.name, { args, result }); + return result; + } + }, + async: (...args) => { + return Promise.resolve().then(() => { + if (shouldFire()) { + resetIdle(); + let result = this.context.applySafe(callback, args); + this.context.logActivity("api_event", this.name, { args, result }); + return result; + } + }); + }, + raw: (...args) => { + if (!shouldFire()) { + throw new Error("Called raw() on unloaded/inactive context"); + } + resetIdle(); + let result = Reflect.apply(callback, null, args); + this.context.logActivity("api_event", this.name, { args, result }); + return result; + }, + asyncWithoutClone: (...args) => { + return Promise.resolve().then(() => { + if (shouldFire()) { + resetIdle(); + let result = this.context.applySafeWithoutClone(callback, args); + this.context.logActivity("api_event", this.name, { args, result }); + return result; + } + }); + }, + }; + + let { module, event } = this; + + let unregister = null; + let recordStartupData = false; + + // If this is a persistent event, check for a listener that was already + // created during startup. If there is one, use it and don't create a + // new one. + if (this.canPersistEvents) { + // Once a background is started, listenerPromises is set to null. At + // that point, we stop recording startup data. + recordStartupData = !!this.context.listenerPromises; + + let key = uneval(args); + EventManager._initPersistentListeners(extension); + let keyEntry = extension.persistentListeners + .get(module) + .get(event) + .get(key); + + // Get the first persistent listener which matches the module, event and extra arguments + // and not added back by the extension yet, the persistent listener found may be either + // primed or not (in particular API Events that belongs to APIs that should not be blocking + // startup may have persistent listeners that are not primed during the first execution + // of the background context happening as part of the applications startup, whereas they + // will be primed when the background context will be suspended on the idle timeout). + let listener = keyEntry?.listeners.find(listener => !listener.added); + if (listener) { + // During startup only a subset of persisted listeners are primed. As + // well, each API determines whether to prime a specific listener. + let { primed } = listener; + if (primed) { + listener.primed = null; + + primed.convert(fire, this.context); + unregister = primed.unregister; + + for (let evt of primed.pendingEvents) { + evt.resolve(fire.async(...evt.args)); + } + } + listener.added = true; + + recordStartupData = false; + this.remove.set(callback, () => { + EventManager.clearPersistentListener( + extension, + module, + event, + uneval(args), + listener.primeId + ); + }); + } + } + + if (!unregister) { + unregister = this.register(fire, ...args); + } + + this.unregister.set(callback, unregister); + this.context.callOnClose(this); + + // If this is a new listener for a persistent event, record + // the details for subsequent startups. + if (recordStartupData) { + const [, , , /* _module */ /* _event */ /* _key */ primeId] = + EventManager.savePersistentListener(extension, module, event, args); + this.remove.set(callback, () => { + EventManager.clearPersistentListener( + extension, + module, + event, + uneval(args), + primeId + ); + }); + } + } + + removeListener(callback, clearPersistentListener = true) { + if (!this.unregister.has(callback)) { + return; + } + this.context.logActivity("api_call", `${this.name}.removeListener`, { + args: [], + }); + + let unregister = this.unregister.get(callback); + this.unregister.delete(callback); + try { + unregister(); + } catch (e) { + Cu.reportError(e); + } + + if (clearPersistentListener && this.remove.has(callback)) { + let cleanup = this.remove.get(callback); + this.remove.delete(callback); + cleanup(); + } + + if (this.unregister.size == 0) { + this.context.forgetOnClose(this); + } + } + + hasListener(callback) { + return this.unregister.has(callback); + } + + revoke() { + for (let callback of this.unregister.keys()) { + this.removeListener(callback, false); + } + } + + close() { + this.revoke(); + } + + api() { + return { + addListener: (...args) => this.addListener(...args), + removeListener: (...args) => this.removeListener(...args), + hasListener: (...args) => this.hasListener(...args), + setUserInput: this.inputHandling, + [lazy.Schemas.REVOKE]: () => this.revoke(), + }; + } +} + +// Simple API for event listeners where events never fire. +function ignoreEvent(context, name) { + return { + addListener: function (callback) { + let id = context.extension.id; + let frame = Components.stack.caller; + let msg = `In add-on ${id}, attempting to use listener "${name}", which is unimplemented.`; + let scriptError = Cc["@mozilla.org/scripterror;1"].createInstance( + Ci.nsIScriptError + ); + scriptError.init( + msg, + frame.filename, + null, + frame.lineNumber, + frame.columnNumber, + Ci.nsIScriptError.warningFlag, + "content javascript" + ); + Services.console.logMessage(scriptError); + }, + removeListener: function (callback) {}, + hasListener: function (callback) {}, + }; +} + +const stylesheetMap = new DefaultMap(url => { + let uri = Services.io.newURI(url); + return lazy.styleSheetService.preloadSheet( + uri, + // Note: keep in sync with ext-browser-content.js. This used to be + // AGENT_SHEET, but changed to AUTHOR_SHEET, see bug 1873024. + lazy.styleSheetService.AUTHOR_SHEET + ); +}); + +/** + * Updates the in-memory representation of extension host permissions, i.e. + * policy.allowedOrigins. + * + * @param {WebExtensionPolicy} policy + * A policy. All MatchPattern instances in policy.allowedOrigins are + * expected to have been constructed with ignorePath: true. + * @param {string[]} origins + * A list of already-normalized origins, equivalent to using the + * MatchPattern constructor with ignorePath: true. + * @param {boolean} isAdd + * Whether to add instead of removing the host permissions. + */ +function updateAllowedOrigins(policy, origins, isAdd) { + if (!origins.length) { + // Nothing to modify. + return; + } + let patternMap = new Map(); + for (let pattern of policy.allowedOrigins.patterns) { + patternMap.set(pattern.pattern, pattern); + } + if (!isAdd) { + for (let origin of origins) { + patternMap.delete(origin); + } + } else { + // In the parent process, policy.extension.restrictSchemes is available. + // In the content process, we need to check the mozillaAddons permission, + // which is only available if approved by the parent. + const restrictSchemes = + policy.extension?.restrictSchemes ?? + policy.hasPermission("mozillaAddons"); + for (let origin of origins) { + if (patternMap.has(origin)) { + continue; + } + patternMap.set( + origin, + new MatchPattern(origin, { restrictSchemes, ignorePath: true }) + ); + } + } + // patternMap contains only MatchPattern instances, so we don't need to set + // the options parameter (with restrictSchemes, etc.) since that is only used + // if the input is a string. + policy.allowedOrigins = new MatchPatternSet(Array.from(patternMap.values())); +} + +export var ExtensionCommon = { + BaseContext, + CanOfAPIs, + EventManager, + ExtensionAPI, + ExtensionAPIPersistent, + EventEmitter, + LocalAPIImplementation, + LocaleData, + NoCloneSpreadArgs, + SchemaAPIInterface, + SchemaAPIManager, + SpreadArgs, + checkLoadURI, + checkLoadURL, + defineLazyGetter, + redefineGetter, + getConsole, + ignoreEvent, + instanceOf, + makeWidgetId, + normalizeTime, + runSafeSyncWithoutClone, + stylesheetMap, + updateAllowedOrigins, + withHandlingUserInput, + + MultiAPIManager, + LazyAPIManager, +}; diff --git a/toolkit/components/extensions/ExtensionContent.sys.mjs b/toolkit/components/extensions/ExtensionContent.sys.mjs new file mode 100644 index 0000000000..131d555bf0 --- /dev/null +++ b/toolkit/components/extensions/ExtensionContent.sys.mjs @@ -0,0 +1,1308 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionProcessScript: + "resource://gre/modules/ExtensionProcessScript.sys.mjs", + ExtensionTelemetry: "resource://gre/modules/ExtensionTelemetry.sys.mjs", + LanguageDetector: + "resource://gre/modules/translation/LanguageDetector.sys.mjs", + Schemas: "resource://gre/modules/Schemas.sys.mjs", + WebNavigationFrames: "resource://gre/modules/WebNavigationFrames.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "styleSheetService", + "@mozilla.org/content/style-sheet-service;1", + "nsIStyleSheetService" +); + +const Timer = Components.Constructor( + "@mozilla.org/timer;1", + "nsITimer", + "initWithCallback" +); + +const ScriptError = Components.Constructor( + "@mozilla.org/scripterror;1", + "nsIScriptError", + "initWithWindowID" +); + +import { + ExtensionChild, + ExtensionActivityLogChild, +} from "resource://gre/modules/ExtensionChild.sys.mjs"; +import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs"; +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { + DefaultMap, + DefaultWeakMap, + getInnerWindowID, + promiseDocumentIdle, + promiseDocumentLoaded, + promiseDocumentReady, +} = ExtensionUtils; + +const { + BaseContext, + CanOfAPIs, + SchemaAPIManager, + defineLazyGetter, + redefineGetter, + runSafeSyncWithoutClone, +} = ExtensionCommon; + +const { BrowserExtensionContent, ChildAPIManager, Messenger } = ExtensionChild; + +ChromeUtils.defineLazyGetter(lazy, "isContentScriptProcess", () => { + return ( + Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_CONTENT || + !WebExtensionPolicy.useRemoteWebExtensions || + // Thunderbird still loads some content in the parent process. + AppConstants.MOZ_APP_NAME == "thunderbird" + ); +}); + +var DocumentManager; + +const CATEGORY_EXTENSION_SCRIPTS_CONTENT = "webextension-scripts-content"; + +var apiManager = new (class extends SchemaAPIManager { + constructor() { + super("content", lazy.Schemas); + this.initialized = false; + } + + lazyInit() { + if (!this.initialized) { + this.initialized = true; + this.initGlobal(); + for (let { value } of Services.catMan.enumerateCategory( + CATEGORY_EXTENSION_SCRIPTS_CONTENT + )) { + this.loadScript(value); + } + } + } +})(); + +const SCRIPT_EXPIRY_TIMEOUT_MS = 5 * 60 * 1000; +const SCRIPT_CLEAR_TIMEOUT_MS = 5 * 1000; + +const CSS_EXPIRY_TIMEOUT_MS = 30 * 60 * 1000; +const CSSCODE_EXPIRY_TIMEOUT_MS = 10 * 60 * 1000; + +const scriptCaches = new WeakSet(); +const sheetCacheDocuments = new DefaultWeakMap(() => new WeakSet()); + +class CacheMap extends DefaultMap { + constructor(timeout, getter, extension) { + super(getter); + + this.expiryTimeout = timeout; + + scriptCaches.add(this); + + // This ensures that all the cached scripts and stylesheets are deleted + // from the cache and the xpi is no longer actively used. + // See Bug 1435100 for rationale. + extension.once("shutdown", () => { + this.clear(-1); + }); + } + + get(url) { + let promise = super.get(url); + + promise.lastUsed = Date.now(); + if (promise.timer) { + promise.timer.cancel(); + } + promise.timer = Timer( + this.delete.bind(this, url), + this.expiryTimeout, + Ci.nsITimer.TYPE_ONE_SHOT + ); + + return promise; + } + + delete(url) { + if (this.has(url)) { + super.get(url).timer.cancel(); + } + + return super.delete(url); + } + + clear(timeout = SCRIPT_CLEAR_TIMEOUT_MS) { + let now = Date.now(); + for (let [url, promise] of this.entries()) { + // Delete the entry if expired or if clear has been called with timeout -1 + // (which is used to force the cache to clear all the entries, e.g. when the + // extension is shutting down). + if (timeout === -1 || now - promise.lastUsed >= timeout) { + this.delete(url); + } + } + } +} + +class ScriptCache extends CacheMap { + constructor(options, extension) { + super( + SCRIPT_EXPIRY_TIMEOUT_MS, + url => { + let promise = ChromeUtils.compileScript(url, options); + promise.then(script => { + promise.script = script; + }); + return promise; + }, + extension + ); + } +} + +/** + * Shared base class for the two specialized CSS caches: + * CSSCache (for the "url"-based stylesheets) and CSSCodeCache + * (for the stylesheet defined by plain CSS content as a string). + */ +class BaseCSSCache extends CacheMap { + constructor(expiryTimeout, defaultConstructor, extension) { + super(expiryTimeout, defaultConstructor, extension); + } + + addDocument(key, document) { + sheetCacheDocuments.get(this.get(key)).add(document); + } + + deleteDocument(key, document) { + sheetCacheDocuments.get(this.get(key)).delete(document); + } + + delete(key) { + if (this.has(key)) { + let promise = this.get(key); + + // Never remove a sheet from the cache if it's still being used by a + // document. Rule processors can be shared between documents with the + // same preloaded sheet, so we only lose by removing them while they're + // still in use. + let docs = ChromeUtils.nondeterministicGetWeakSetKeys( + sheetCacheDocuments.get(promise) + ); + if (docs.length) { + return; + } + } + + return super.delete(key); + } +} + +/** + * Cache of the preloaded stylesheet defined by url. + */ +class CSSCache extends BaseCSSCache { + constructor(sheetType, extension) { + super( + CSS_EXPIRY_TIMEOUT_MS, + url => { + let uri = Services.io.newURI(url); + return lazy.styleSheetService + .preloadSheetAsync(uri, sheetType) + .then(sheet => { + return { url, sheet }; + }); + }, + extension + ); + } +} + +/** + * Cache of the preloaded stylesheet defined by plain CSS content as a string, + * the key of the cached stylesheet is the hash of its "CSSCode" string. + */ +class CSSCodeCache extends BaseCSSCache { + constructor(sheetType, extension) { + super( + CSSCODE_EXPIRY_TIMEOUT_MS, + hash => { + if (!this.has(hash)) { + // Do not allow the getter to be used to lazily create the cached stylesheet, + // the cached CSSCode stylesheet has to be explicitly set. + throw new Error( + "Unexistent cached cssCode stylesheet: " + Error().stack + ); + } + + return super.get(hash); + }, + extension + ); + + // Store the preferred sheetType (used to preload the expected stylesheet type in + // the addCSSCode method). + this.sheetType = sheetType; + } + + addCSSCode(hash, cssCode) { + if (this.has(hash)) { + // This cssCode have been already cached, no need to create it again. + return; + } + // The `webext=style` portion is added metadata to help us distinguish + // different kinds of data URL loads that are triggered with the + // SystemPrincipal. It shall be removed with bug 1699425. + const uri = Services.io.newURI( + "data:text/css;extension=style;charset=utf-8," + + encodeURIComponent(cssCode) + ); + const value = lazy.styleSheetService + .preloadSheetAsync(uri, this.sheetType) + .then(sheet => { + return { sheet, uri }; + }); + + super.set(hash, value); + } +} + +defineLazyGetter( + BrowserExtensionContent.prototype, + "staticScripts", + function () { + return new ScriptCache({ hasReturnValue: false }, this); + } +); + +defineLazyGetter( + BrowserExtensionContent.prototype, + "dynamicScripts", + function () { + return new ScriptCache({ hasReturnValue: true }, this); + } +); + +defineLazyGetter(BrowserExtensionContent.prototype, "userCSS", function () { + return new CSSCache(Ci.nsIStyleSheetService.USER_SHEET, this); +}); + +defineLazyGetter(BrowserExtensionContent.prototype, "authorCSS", function () { + return new CSSCache(Ci.nsIStyleSheetService.AUTHOR_SHEET, this); +}); + +// These two caches are similar to the above but specialized to cache the cssCode +// using an hash computed from the cssCode string as the key (instead of the generated data +// URI which can be pretty long for bigger injected cssCode). +defineLazyGetter(BrowserExtensionContent.prototype, "userCSSCode", function () { + return new CSSCodeCache(Ci.nsIStyleSheetService.USER_SHEET, this); +}); + +defineLazyGetter( + BrowserExtensionContent.prototype, + "authorCSSCode", + function () { + return new CSSCodeCache(Ci.nsIStyleSheetService.AUTHOR_SHEET, this); + } +); + +// Represents a content script. +class Script { + /** + * @param {BrowserExtensionContent} extension + * @param {WebExtensionContentScript|object} matcher + * An object with a "matchesWindowGlobal" method and content script + * execution details. This is usually a plain WebExtensionContentScript + * except when the script is run via `tabs.executeScript`. In this + * case, the object may have some extra properties: + * wantReturnValue, removeCSS, cssOrigin, jsCode + */ + constructor(extension, matcher) { + this.scriptType = "content_script"; + this.extension = extension; + this.matcher = matcher; + + this.runAt = this.matcher.runAt; + this.js = this.matcher.jsPaths; + this.css = this.matcher.cssPaths.slice(); + this.cssCodeHash = null; + + this.removeCSS = this.matcher.removeCSS; + this.cssOrigin = this.matcher.cssOrigin; + + this.cssCache = + extension[this.cssOrigin === "user" ? "userCSS" : "authorCSS"]; + this.cssCodeCache = + extension[this.cssOrigin === "user" ? "userCSSCode" : "authorCSSCode"]; + this.scriptCache = + extension[matcher.wantReturnValue ? "dynamicScripts" : "staticScripts"]; + + /** @type {WeakSet<Document>} A set of documents injected into. */ + this.injectedInto = new WeakSet(); + + if (matcher.wantReturnValue) { + this.compileScripts(); + this.loadCSS(); + } + } + + get requiresCleanup() { + return !this.removeCSS && (!!this.css.length || this.cssCodeHash); + } + + async addCSSCode(cssCode) { + if (!cssCode) { + return; + } + + // Store the hash of the cssCode. + const buffer = await crypto.subtle.digest( + "SHA-1", + new TextEncoder().encode(cssCode) + ); + this.cssCodeHash = String.fromCharCode(...new Uint16Array(buffer)); + + // Cache and preload the cssCode stylesheet. + this.cssCodeCache.addCSSCode(this.cssCodeHash, cssCode); + } + + compileScripts() { + return this.js.map(url => this.scriptCache.get(url)); + } + + loadCSS() { + return this.css.map(url => this.cssCache.get(url)); + } + + preload() { + this.loadCSS(); + this.compileScripts(); + } + + cleanup(window) { + if (this.requiresCleanup) { + if (window) { + let { windowUtils } = window; + + let type = + this.cssOrigin === "user" + ? windowUtils.USER_SHEET + : windowUtils.AUTHOR_SHEET; + + for (let url of this.css) { + this.cssCache.deleteDocument(url, window.document); + + if (!window.closed) { + runSafeSyncWithoutClone( + windowUtils.removeSheetUsingURIString, + url, + type + ); + } + } + + const { cssCodeHash } = this; + + if (cssCodeHash && this.cssCodeCache.has(cssCodeHash)) { + if (!window.closed) { + this.cssCodeCache.get(cssCodeHash).then(({ uri }) => { + runSafeSyncWithoutClone(windowUtils.removeSheet, uri, type); + }); + } + this.cssCodeCache.deleteDocument(cssCodeHash, window.document); + } + } + + // Clear any sheets that were kept alive past their timeout as + // a result of living in this document. + this.cssCodeCache.clear(CSSCODE_EXPIRY_TIMEOUT_MS); + this.cssCache.clear(CSS_EXPIRY_TIMEOUT_MS); + } + } + + matchesWindowGlobal(windowGlobal, ignorePermissions) { + return this.matcher.matchesWindowGlobal(windowGlobal, ignorePermissions); + } + + async injectInto(window, reportExceptions = true) { + if ( + !lazy.isContentScriptProcess || + this.injectedInto.has(window.document) + ) { + return; + } + this.injectedInto.add(window.document); + + let context = this.extension.getContext(window); + for (let script of this.matcher.jsPaths) { + context.logActivity(this.scriptType, script, { + url: window.location.href, + }); + } + + try { + if (this.runAt === "document_end") { + await promiseDocumentReady(window.document); + } else if (this.runAt === "document_idle") { + await Promise.race([ + promiseDocumentIdle(window), + promiseDocumentLoaded(window.document), + ]); + } + + return this.inject(context, reportExceptions); + } catch (e) { + return Promise.reject(context.normalizeError(e)); + } + } + + /** + * Tries to inject this script into the given window and sandbox, if + * there are pending operations for the window's current load state. + * + * @param {ContentScriptContextChild} context + * The content script context into which to inject the scripts. + * @param {boolean} reportExceptions + * Defaults to true and reports any exception directly to the console + * and no exception will be thrown out of this function. + * @returns {Promise<any>} + * Resolves to the last value in the evaluated script, when + * execution is complete. + */ + async inject(context, reportExceptions = true) { + DocumentManager.lazyInit(); + if (this.requiresCleanup) { + context.addScript(this); + } + + const { cssCodeHash } = this; + + let cssPromise; + if (this.css.length || cssCodeHash) { + let window = context.contentWindow; + let { windowUtils } = window; + + let type = + this.cssOrigin === "user" + ? windowUtils.USER_SHEET + : windowUtils.AUTHOR_SHEET; + + if (this.removeCSS) { + for (let url of this.css) { + this.cssCache.deleteDocument(url, window.document); + + runSafeSyncWithoutClone( + windowUtils.removeSheetUsingURIString, + url, + type + ); + } + + if (cssCodeHash && this.cssCodeCache.has(cssCodeHash)) { + const { uri } = await this.cssCodeCache.get(cssCodeHash); + this.cssCodeCache.deleteDocument(cssCodeHash, window.document); + + runSafeSyncWithoutClone(windowUtils.removeSheet, uri, type); + } + } else { + cssPromise = Promise.all(this.loadCSS()).then(sheets => { + let window = context.contentWindow; + if (!window) { + return; + } + + for (let { url, sheet } of sheets) { + this.cssCache.addDocument(url, window.document); + + runSafeSyncWithoutClone(windowUtils.addSheet, sheet, type); + } + }); + + if (cssCodeHash) { + cssPromise = cssPromise.then(async () => { + const { sheet } = await this.cssCodeCache.get(cssCodeHash); + this.cssCodeCache.addDocument(cssCodeHash, window.document); + + runSafeSyncWithoutClone(windowUtils.addSheet, sheet, type); + }); + } + + // We're loading stylesheets via the stylesheet service, which means + // that the normal mechanism for blocking layout and onload for pending + // stylesheets aren't in effect (since there's no document to block). So + // we need to do something custom here, similar to what we do for + // scripts. Blocking parsing is overkill, since we really just want to + // block layout and onload. But we have an API to do the former and not + // the latter, so we do it that way. This hopefully isn't a performance + // problem since there are no network loads involved, and since we cache + // the stylesheets on first load. We should fix this up if it does becomes + // a problem. + if (this.css.length) { + context.contentWindow.document.blockParsing(cssPromise, { + blockScriptCreated: false, + }); + } + } + } + + let scripts = this.getCompiledScripts(context); + if (scripts instanceof Promise) { + scripts = await scripts; + } + + // Make sure we've injected any related CSS before we run content scripts. + await cssPromise; + + let result; + + const { extension } = context; + + // The evaluations below may throw, in which case the promise will be + // automatically rejected. + lazy.ExtensionTelemetry.contentScriptInjection.stopwatchStart( + extension, + context + ); + try { + for (let script of scripts) { + result = script.executeInGlobal(context.cloneScope, { + reportExceptions, + }); + } + + if (this.matcher.jsCode) { + result = Cu.evalInSandbox( + this.matcher.jsCode, + context.cloneScope, + "latest", + "sandbox eval code", + 1 + ); + } + } finally { + lazy.ExtensionTelemetry.contentScriptInjection.stopwatchFinish( + extension, + context + ); + } + + return result; + } + + /** + * Get the compiled scripts (if they are already precompiled and cached) or a promise which resolves + * to the precompiled scripts (once they have been compiled and cached). + * + * @param {ContentScriptContextChild} context + * The document to block the parsing on, if the scripts are not yet precompiled and cached. + * + * @returns {Array<PreloadedScript> | Promise<Array<PreloadedScript>>} + * Returns an array of preloaded scripts if they are already available, or a promise which + * resolves to the array of the preloaded scripts once they are precompiled and cached. + */ + getCompiledScripts(context) { + let scriptPromises = this.compileScripts(); + let scripts = scriptPromises.map(promise => promise.script); + + // If not all scripts are already available in the cache, block + // parsing and wait all promises to resolve. + if (!scripts.every(script => script)) { + let promise = Promise.all(scriptPromises); + + // If there is any syntax error, the script promises will be rejected. + // + // Notify the exception directly to the console so that it can + // be displayed in the web console by flagging the error with the right + // innerWindowID. + for (const p of scriptPromises) { + p.catch(error => { + Services.console.logMessage( + new ScriptError( + `${error.name}: ${error.message}`, + error.fileName, + null, + error.lineNumber, + error.columnNumber, + Ci.nsIScriptError.errorFlag, + "content javascript", + context.innerWindowID + ) + ); + }); + } + + // If we're supposed to inject at the start of the document load, + // and we haven't already missed that point, block further parsing + // until the scripts have been loaded. + const { document } = context.contentWindow; + if ( + this.runAt === "document_start" && + document.readyState !== "complete" + ) { + document.blockParsing(promise, { blockScriptCreated: false }); + } + + return promise; + } + + return scripts; + } +} + +// Represents a user script. +class UserScript extends Script { + /** + * @param {BrowserExtensionContent} extension + * @param {WebExtensionContentScript|object} matcher + * An object with a "matchesWindowGlobal" method and content script + * execution details. + */ + constructor(extension, matcher) { + super(extension, matcher); + this.scriptType = "user_script"; + + // This is an opaque object that the extension provides, it is associated to + // the particular userScript and it is passed as a parameter to the custom + // userScripts APIs defined by the extension. + this.scriptMetadata = matcher.userScriptOptions.scriptMetadata; + this.apiScriptURL = + extension.manifest.user_scripts && + extension.manifest.user_scripts.api_script; + + // Add the apiScript to the js scripts to compile. + if (this.apiScriptURL) { + this.js = [this.apiScriptURL].concat(this.js); + } + + // WeakMap<ContentScriptContextChild, Sandbox> + this.sandboxes = new DefaultWeakMap(context => { + return this.createSandbox(context); + }); + } + + async inject(context) { + DocumentManager.lazyInit(); + + let scripts = this.getCompiledScripts(context); + if (scripts instanceof Promise) { + scripts = await scripts; + } + + let apiScript, sandboxScripts; + + if (this.apiScriptURL) { + [apiScript, ...sandboxScripts] = scripts; + } else { + sandboxScripts = scripts; + } + + // Load and execute the API script once per context. + if (apiScript) { + context.executeAPIScript(apiScript); + } + + let userScriptSandbox = this.sandboxes.get(context); + + context.callOnClose({ + close: () => { + // Destroy the userScript sandbox when the related ContentScriptContextChild instance + // is being closed. + this.sandboxes.delete(context); + Cu.nukeSandbox(userScriptSandbox); + }, + }); + + // Notify listeners subscribed to the userScripts.onBeforeScript API event, + // to allow extension API script to provide its custom APIs to the userScript. + if (apiScript) { + context.userScriptsEvents.emit( + "on-before-script", + this.scriptMetadata, + userScriptSandbox + ); + } + + for (let script of sandboxScripts) { + script.executeInGlobal(userScriptSandbox); + } + } + + createSandbox(context) { + const { contentWindow } = context; + const contentPrincipal = contentWindow.document.nodePrincipal; + const ssm = Services.scriptSecurityManager; + + let principal; + if (contentPrincipal.isSystemPrincipal) { + principal = ssm.createNullPrincipal(contentPrincipal.originAttributes); + } else { + principal = [contentPrincipal]; + } + + const sandbox = Cu.Sandbox(principal, { + sandboxName: `User Script registered by ${this.extension.policy.debugName}`, + sandboxPrototype: contentWindow, + sameZoneAs: contentWindow, + wantXrays: true, + wantGlobalProperties: ["XMLHttpRequest", "fetch", "WebSocket"], + originAttributes: contentPrincipal.originAttributes, + metadata: { + "inner-window-id": context.innerWindowID, + addonId: this.extension.policy.id, + }, + }); + + return sandbox; + } +} + +var contentScripts = new DefaultWeakMap(matcher => { + const extension = lazy.ExtensionProcessScript.extensions.get( + matcher.extension + ); + + if ("userScriptOptions" in matcher) { + return new UserScript(extension, matcher); + } + + return new Script(extension, matcher); +}); + +/** + * An execution context for semi-privileged extension content scripts. + * + * This is the child side of the ContentScriptContextParent class + * defined in ExtensionParent.jsm. + */ +class ContentScriptContextChild extends BaseContext { + constructor(extension, contentWindow) { + super("content_child", extension); + + this.setContentWindow(contentWindow); + + let frameId = lazy.WebNavigationFrames.getFrameId(contentWindow); + this.frameId = frameId; + + this.browsingContextId = contentWindow.docShell.browsingContext.id; + + this.scripts = []; + + let contentPrincipal = contentWindow.document.nodePrincipal; + let ssm = Services.scriptSecurityManager; + + // Copy origin attributes from the content window origin attributes to + // preserve the user context id. + let attrs = contentPrincipal.originAttributes; + let extensionPrincipal = ssm.createContentPrincipal( + this.extension.baseURI, + attrs + ); + + this.isExtensionPage = contentPrincipal.equals(extensionPrincipal); + + if (this.isExtensionPage) { + // This is an iframe with content script API enabled and its principal + // should be the contentWindow itself. We create a sandbox with the + // contentWindow as principal and with X-rays disabled because it + // enables us to create the APIs object in this sandbox object and then + // copying it into the iframe's window. See bug 1214658. + this.sandbox = Cu.Sandbox(contentWindow, { + sandboxName: `Web-Accessible Extension Page ${extension.policy.debugName}`, + sandboxPrototype: contentWindow, + sameZoneAs: contentWindow, + wantXrays: false, + isWebExtensionContentScript: true, + }); + } else { + let principal; + if (contentPrincipal.isSystemPrincipal) { + // Make sure we don't hand out the system principal by accident. + // Also make sure that the null principal has the right origin attributes. + principal = ssm.createNullPrincipal(attrs); + } else { + principal = [contentPrincipal, extensionPrincipal]; + } + // This metadata is required by the Developer Tools, in order for + // the content script to be associated with both the extension and + // the tab holding the content page. + let metadata = { + "inner-window-id": this.innerWindowID, + addonId: extensionPrincipal.addonId, + }; + + let isMV2 = extension.manifestVersion == 2; + let wantGlobalProperties; + if (isMV2) { + // In MV2, fetch/XHR support cross-origin requests. + // WebSocket was also included to avoid CSP effects (bug 1676024). + wantGlobalProperties = ["XMLHttpRequest", "fetch", "WebSocket"]; + } else { + // In MV3, fetch/XHR have the same capabilities as the web page. + wantGlobalProperties = []; + } + this.sandbox = Cu.Sandbox(principal, { + metadata, + sandboxName: `Content Script ${extension.policy.debugName}`, + sandboxPrototype: contentWindow, + sameZoneAs: contentWindow, + wantXrays: true, + isWebExtensionContentScript: true, + wantExportHelpers: true, + wantGlobalProperties, + originAttributes: attrs, + }); + + // Preserve a copy of the original Error and Promise globals from the sandbox object, + // which are used in the WebExtensions internals (before any content script code had + // any chance to redefine them). + this.cloneScopePromise = this.sandbox.Promise; + this.cloneScopeError = this.sandbox.Error; + + if (isMV2) { + // Preserve a copy of the original window's XMLHttpRequest and fetch + // in a content object (fetch is manually binded to the window + // to prevent it from raising a TypeError because content object is not + // a real window). + Cu.evalInSandbox( + ` + this.content = { + XMLHttpRequest: window.XMLHttpRequest, + fetch: window.fetch.bind(window), + WebSocket: window.WebSocket, + }; + + window.JSON = JSON; + window.XMLHttpRequest = XMLHttpRequest; + window.fetch = fetch; + window.WebSocket = WebSocket; + `, + this.sandbox + ); + } else { + // The sandbox's JSON API can deal with values from the sandbox and the + // contentWindow, but window.JSON cannot (and it could potentially be + // spoofed by the web page). jQuery.parseJSON relies on window.JSON. + Cu.evalInSandbox("window.JSON = JSON;", this.sandbox); + } + } + + Object.defineProperty(this, "principal", { + value: Cu.getObjectPrincipal(this.sandbox), + enumerable: true, + configurable: true, + }); + + this.url = contentWindow.location.href; + + lazy.Schemas.exportLazyGetter( + this.sandbox, + "browser", + () => this.chromeObj + ); + lazy.Schemas.exportLazyGetter(this.sandbox, "chrome", () => this.chromeObj); + + // Keep track if the userScript API script has been already executed in this context + // (e.g. because there are more then one UserScripts that match the related webpage + // and so the UserScript apiScript has already been executed). + this.hasUserScriptAPIs = false; + + // A lazy created EventEmitter related to userScripts-specific events. + defineLazyGetter(this, "userScriptsEvents", () => { + return new ExtensionCommon.EventEmitter(); + }); + } + + injectAPI() { + if (!this.isExtensionPage) { + throw new Error("Cannot inject extension API into non-extension window"); + } + + // This is an iframe with content script API enabled (See Bug 1214658) + lazy.Schemas.exportLazyGetter( + this.contentWindow, + "browser", + () => this.chromeObj + ); + lazy.Schemas.exportLazyGetter( + this.contentWindow, + "chrome", + () => this.chromeObj + ); + } + + async logActivity(type, name, data) { + ExtensionActivityLogChild.log(this, type, name, data); + } + + get cloneScope() { + return this.sandbox; + } + + async executeAPIScript(apiScript) { + // Execute the UserScript apiScript only once per context (e.g. more then one UserScripts + // match the same webpage and the apiScript has already been executed). + if (apiScript && !this.hasUserScriptAPIs) { + this.hasUserScriptAPIs = true; + apiScript.executeInGlobal(this.cloneScope); + } + } + + addScript(script) { + if (script.requiresCleanup) { + this.scripts.push(script); + } + } + + close() { + super.unload(); + + // Cleanup the scripts even if the contentWindow have been destroyed. + for (let script of this.scripts) { + script.cleanup(this.contentWindow); + } + + if (this.contentWindow) { + // Overwrite the content script APIs with an empty object if the APIs objects are still + // defined in the content window (See Bug 1214658). + if (this.isExtensionPage) { + Cu.createObjectIn(this.contentWindow, { defineAs: "browser" }); + Cu.createObjectIn(this.contentWindow, { defineAs: "chrome" }); + } + } + Cu.nukeSandbox(this.sandbox); + + this.sandbox = null; + } + + get childManager() { + apiManager.lazyInit(); + let can = new CanOfAPIs(this, apiManager, {}); + let childManager = new ChildAPIManager(this, this.messageManager, can, { + envType: "content_parent", + url: this.url, + }); + this.callOnClose(childManager); + return redefineGetter(this, "childManager", childManager); + } + + get chromeObj() { + let chromeObj = Cu.createObjectIn(this.sandbox); + this.childManager.inject(chromeObj); + return redefineGetter(this, "chromeObj", chromeObj); + } + + get messenger() { + return redefineGetter(this, "messenger", new Messenger(this)); + } +} + +// Responsible for creating ExtensionContexts and injecting content +// scripts into them when new documents are created. +DocumentManager = { + // Map[windowId -> Map[ExtensionChild -> ContentScriptContextChild]] + contexts: new Map(), + + initialized: false, + + lazyInit() { + if (this.initialized) { + return; + } + this.initialized = true; + + Services.obs.addObserver(this, "inner-window-destroyed"); + Services.obs.addObserver(this, "memory-pressure"); + }, + + uninit() { + Services.obs.removeObserver(this, "inner-window-destroyed"); + Services.obs.removeObserver(this, "memory-pressure"); + }, + + observers: { + "inner-window-destroyed"(subject, topic, data) { + let windowId = subject.QueryInterface(Ci.nsISupportsPRUint64).data; + + // Close any existent content-script context for the destroyed window. + if (this.contexts.has(windowId)) { + let extensions = this.contexts.get(windowId); + for (let context of extensions.values()) { + context.close(); + } + + this.contexts.delete(windowId); + } + }, + "memory-pressure"(subject, topic, data) { + let timeout = data === "heap-minimize" ? 0 : undefined; + + for (let cache of ChromeUtils.nondeterministicGetWeakSetKeys( + scriptCaches + )) { + cache.clear(timeout); + } + }, + }, + + /** + * @param {object} subject + * @param {keyof typeof DocumentManager.observers} topic + * @param {any} data + */ + observe(subject, topic, data) { + this.observers[topic].call(this, subject, topic, data); + }, + + shutdownExtension(extension) { + for (let extensions of this.contexts.values()) { + let context = extensions.get(extension); + if (context) { + context.close(); + extensions.delete(extension); + } + } + }, + + getContexts(window) { + let winId = getInnerWindowID(window); + + let extensions = this.contexts.get(winId); + if (!extensions) { + extensions = new Map(); + this.contexts.set(winId, extensions); + } + + return extensions; + }, + + // For test use only. + getContext(extensionId, window) { + for (let [extension, context] of this.getContexts(window)) { + if (extension.id === extensionId) { + return context; + } + } + }, + + getContentScriptGlobals(window) { + let extensions = this.contexts.get(getInnerWindowID(window)); + + if (extensions) { + return Array.from(extensions.values(), ctx => ctx.sandbox); + } + + return []; + }, + + initExtensionContext(extension, window) { + extension.getContext(window).injectAPI(); + }, +}; + +export var ExtensionContent = { + BrowserExtensionContent, + + contentScripts, + + shutdownExtension(extension) { + DocumentManager.shutdownExtension(extension); + }, + + // This helper is exported to be integrated in the devtools RDP actors, + // that can use it to retrieve the existent WebExtensions ContentScripts + // of a target window and be able to show the ContentScripts source in the + // DevTools Debugger panel. + getContentScriptGlobals(window) { + return DocumentManager.getContentScriptGlobals(window); + }, + + initExtensionContext(extension, window) { + DocumentManager.initExtensionContext(extension, window); + }, + + getContext(extension, window) { + let extensions = DocumentManager.getContexts(window); + + let context = extensions.get(extension); + if (!context) { + context = new ContentScriptContextChild(extension, window); + extensions.set(extension, context); + } + return context; + }, + + // For test use only. + getContextByExtensionId(extensionId, window) { + return DocumentManager.getContext(extensionId, window); + }, + + async handleDetectLanguage({ windows }) { + let wgc = WindowGlobalChild.getByInnerWindowId(windows[0]); + let doc = wgc.browsingContext.window.document; + await promiseDocumentReady(doc); + + // The CLD2 library can analyze HTML, but that uses more memory, and + // emscripten can't shrink its heap, so we use plain text instead. + let encoder = Cu.createDocumentEncoder("text/plain"); + encoder.init(doc, "text/plain", Ci.nsIDocumentEncoder.SkipInvisibleContent); + + let result = await lazy.LanguageDetector.detectLanguage({ + language: + doc.documentElement.getAttribute("xml:lang") || + doc.documentElement.getAttribute("lang") || + doc.contentLanguage || + null, + tld: doc.location.hostname.match(/[a-z]*$/)[0], + text: encoder.encodeToStringWithMaxLength(60 * 1024), + encoding: doc.characterSet, + }); + return result.language === "un" ? "und" : result.language; + }, + + // Activate MV3 content scripts in all same-origin frames for this tab. + handleActivateScripts({ options, windows }) { + let policy = WebExtensionPolicy.getByID(options.id); + + // Order content scripts by run_at timing. + let runAt = { document_start: [], document_end: [], document_idle: [] }; + for (let matcher of policy.contentScripts) { + runAt[matcher.runAt].push(this.contentScripts.get(matcher)); + } + + // If we got here, checks in TabManagerBase.activateScripts assert: + // 1) this is a MV3 extension, with Origin Controls, + // 2) with a host permission (or content script) for the tab's top origin, + // 3) and that host permission hasn't been granted yet. + + // We treat the action click as implicit user's choice to activate the + // extension on the current site, so we can safely run (matching) content + // scripts in all sameOriginWithTop frames while ignoring host permission. + + let { browsingContext } = WindowGlobalChild.getByInnerWindowId(windows[0]); + for (let bc of browsingContext.getAllBrowsingContextsInSubtree()) { + let wgc = bc.currentWindowContext.windowGlobalChild; + if (wgc?.sameOriginWithTop) { + // This is TOCTOU safe: if a frame navigated after same-origin check, + // wgc.isClosed would be true and .matchesWindowGlobal() would fail. + const runScript = cs => { + if (cs.matchesWindowGlobal(wgc, /* ignorePermissions */ true)) { + return cs.injectInto(bc.window); + } + }; + + // Inject all matching content scripts in proper run_at order. + Promise.all(runAt.document_start.map(runScript)) + .then(() => Promise.all(runAt.document_end.map(runScript))) + .then(() => Promise.all(runAt.document_idle.map(runScript))); + } + } + }, + + // Used to executeScript, insertCSS and removeCSS. + async handleActorExecute({ options, windows }) { + let policy = WebExtensionPolicy.getByID(options.extensionId); + // `WebExtensionContentScript` uses `MozDocumentMatcher::Matches` to ensure + // that a script can be run in a document. That requires either `frameId` + // or `allFrames` to be set. When `frameIds` (plural) is used, we force + // `allFrames` to be `true` in order to match any frame. This is OK because + // `executeInWin()` below looks up the window for the given `frameIds` + // immediately before `script.injectInto()`. Due to this, we won't run + // scripts in windows with non-matching `frameId`, despite `allFrames` + // being set to `true`. + if (options.frameIds) { + options.allFrames = true; + } + let matcher = new WebExtensionContentScript(policy, options); + + Object.assign(matcher, { + wantReturnValue: options.wantReturnValue, + removeCSS: options.removeCSS, + cssOrigin: options.cssOrigin, + jsCode: options.jsCode, + }); + let script = contentScripts.get(matcher); + + // Add the cssCode to the script, so that it can be converted into a cached URL. + await script.addCSSCode(options.cssCode); + delete options.cssCode; + + const executeInWin = innerId => { + let wg = WindowGlobalChild.getByInnerWindowId(innerId); + if (wg?.isCurrentGlobal && script.matchesWindowGlobal(wg)) { + let bc = wg.browsingContext; + + return { + frameId: bc.parent ? bc.id : 0, + // Disable exception reporting directly to the console + // in order to pass the exceptions back to the callsite. + promise: script.injectInto(bc.window, false), + }; + } + }; + + let promisesWithFrameIds = windows.map(executeInWin).filter(obj => obj); + + let result = await Promise.all( + promisesWithFrameIds.map(async ({ frameId, promise }) => { + if (!options.returnResultsWithFrameIds) { + return promise; + } + + try { + const result = await promise; + + return { frameId, result }; + } catch (error) { + return { frameId, error }; + } + }) + ).catch( + // This is useful when we do not return results/errors with frame IDs in + // the promises above. + e => Promise.reject({ message: e.message }) + ); + + try { + // Check if the result can be structured-cloned before sending back. + return Cu.cloneInto(result, this); + } catch (e) { + let path = options.jsPaths.slice(-1)[0] ?? "<anonymous code>"; + let message = `Script '${path}' result is non-structured-clonable data`; + return Promise.reject({ message, fileName: path }); + } + }, +}; + +/** + * Child side of the ExtensionContent process actor, handles some tabs.* APIs. + */ +export class ExtensionContentChild extends JSProcessActorChild { + receiveMessage({ name, data }) { + if (!lazy.isContentScriptProcess) { + return; + } + switch (name) { + case "DetectLanguage": + return ExtensionContent.handleDetectLanguage(data); + case "Execute": + return ExtensionContent.handleActorExecute(data); + case "ActivateScripts": + return ExtensionContent.handleActivateScripts(data); + } + } +} diff --git a/toolkit/components/extensions/ExtensionDNR.sys.mjs b/toolkit/components/extensions/ExtensionDNR.sys.mjs new file mode 100644 index 0000000000..cd01d52b72 --- /dev/null +++ b/toolkit/components/extensions/ExtensionDNR.sys.mjs @@ -0,0 +1,2436 @@ +/* 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/. */ + +// Each extension that uses DNR has one RuleManager. All registered RuleManagers +// are checked whenever a network request occurs. Individual extensions may +// occasionally modify their rules (e.g. via the updateSessionRules API). +const gRuleManagers = []; + +/** + * Whenever a request occurs, the rules of each RuleManager are matched against + * the request to determine the final action to take. The RequestEvaluator class + * is responsible for evaluating rules, and its behavior is described below. + * + * Short version: + * Find the highest-priority rule that matches the given request. If the + * request is not canceled, all matching allowAllRequests and modifyHeaders + * actions are returned. + * + * Longer version: + * Unless stated otherwise, the explanation below describes the behavior within + * an extension. + * An extension can specify rules, optionally in multiple rulesets. The ability + * to have multiple ruleset exists to support bulk updates of rules. Rulesets + * are NOT independent - rules from different rulesets can affect each other. + * + * When multiple rules match, the order between rules are defined as follows: + * - Ruleset precedence: session > dynamic > static (order from manifest.json). + * - Rules in ruleset precedence: ordered by rule.id, lowest (numeric) ID first. + * - Across all rules+rulesets: highest rule.priority (default 1) first, + * action precedence if rule priority are the same. + * + * The primary documented way for extensions to describe precedence is by + * specifying rule.priority. Between same-priority rules, their precedence is + * dependent on the rule action. The ruleset/rule ID precedence is only used to + * have a defined ordering if multiple rules have the same priority+action. + * + * Rule actions have the following order of precedence and meaning: + * - "allow" can be used to ignore other same-or-lower-priority rules. + * - "allowAllRequests" (for main_frame / sub_frame resourceTypes only) has the + * same effect as allow, but also applies to (future) subresource loads in + * the document (including descendant frames) generated from the request. + * - "block" cancels the matched request. + * - "upgradeScheme" upgrades the scheme of the request. + * - "redirect" redirects the request. + * - "modifyHeaders" rewrites request/response headers. + * + * The matched rules are evaluated in two passes: + * 1. findMatchingRules(): + * Find the highest-priority rule(s), and choose the action with the highest + * precedence (across all rulesets, any action except modifyHeaders). + * This also accounts for any allowAllRequests from an ancestor frame. + * + * 2. getMatchingModifyHeadersRules(): + * Find matching rules with the "modifyHeaders" action, minus ignored rules. + * Reaching this step implies that the request was not canceled, so either + * the first step did not yield a rule, or the rule action is "allow" or + * "allowAllRequests" (i.e. ignore same-or-lower-priority rules). + * + * If an extension does not have sufficient permissions for the action, the + * resulting action is ignored. + * + * The above describes the evaluation within one extension. When a sequence of + * (multiple) extensions is given, they may return conflicting actions in the + * first pass. This is resolved by choosing the action with the following order + * of precedence, in RequestEvaluator.evaluateRequest(): + * - block + * - redirect / upgradeScheme + * - allow / allowAllRequests + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionDNRLimits: "resource://gre/modules/ExtensionDNRLimits.sys.mjs", + ExtensionDNRStore: "resource://gre/modules/ExtensionDNRStore.sys.mjs", + WebRequest: "resource://gre/modules/WebRequest.sys.mjs", +}); + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { ExtensionError } = ExtensionUtils; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "gMatchRequestsFromOtherExtensions", + "extensions.dnr.match_requests_from_other_extensions", + false +); + +// As documented above: +// Ruleset precedence: session > dynamic > static (order from manifest.json). +const PRECEDENCE_SESSION_RULESET = 1; +const PRECEDENCE_DYNAMIC_RULESET = 2; +const PRECEDENCE_STATIC_RULESETS_BASE = 3; + +// The RuleCondition class represents a rule's "condition" type as described in +// schemas/declarative_net_request.json. This class exists to allow the JS +// engine to use one Shape for all Rule instances. +class RuleCondition { + #compiledUrlFilter; + #compiledRegexFilter; + + constructor(cond) { + this.urlFilter = cond.urlFilter; + this.regexFilter = cond.regexFilter; + this.isUrlFilterCaseSensitive = cond.isUrlFilterCaseSensitive; + this.initiatorDomains = cond.initiatorDomains; + this.excludedInitiatorDomains = cond.excludedInitiatorDomains; + this.requestDomains = cond.requestDomains; + this.excludedRequestDomains = cond.excludedRequestDomains; + this.resourceTypes = cond.resourceTypes; + this.excludedResourceTypes = cond.excludedResourceTypes; + this.requestMethods = cond.requestMethods; + this.excludedRequestMethods = cond.excludedRequestMethods; + this.domainType = cond.domainType; + this.tabIds = cond.tabIds; + this.excludedTabIds = cond.excludedTabIds; + } + + // See CompiledUrlFilter for documentation. + urlFilterMatches(requestDataForUrlFilter) { + if (!this.#compiledUrlFilter) { + // eslint-disable-next-line no-use-before-define + this.#compiledUrlFilter = new CompiledUrlFilter( + this.urlFilter, + this.isUrlFilterCaseSensitive + ); + } + return this.#compiledUrlFilter.matchesRequest(requestDataForUrlFilter); + } + + // Used for testing regexFilter matches in RuleEvaluator.#matchRuleCondition + // and to get redirect URL from regexSubstitution in applyRegexSubstitution. + getCompiledRegexFilter() { + return this.#compiledRegexFilter; + } + + // RuleValidator compiles regexFilter before this Rule class is instantiated. + // To avoid unnecessarily compiling it again, the result is assigned here. + setCompiledRegexFilter(compiledRegexFilter) { + this.#compiledRegexFilter = compiledRegexFilter; + } +} + +export class Rule { + constructor(rule) { + this.id = rule.id; + this.priority = rule.priority; + this.condition = new RuleCondition(rule.condition); + this.action = rule.action; + } + + // The precedence of rules within an extension. This method is frequently + // used during the first pass of the RequestEvaluator. + actionPrecedence() { + switch (this.action.type) { + case "allow": + return 1; // Highest precedence. + case "allowAllRequests": + return 2; + case "block": + return 3; + case "upgradeScheme": + return 4; + case "redirect": + return 5; + case "modifyHeaders": + return 6; + default: + throw new Error(`Unexpected action type: ${this.action.type}`); + } + } + + isAllowOrAllowAllRequestsAction() { + const type = this.action.type; + return type === "allow" || type === "allowAllRequests"; + } +} + +class Ruleset { + /** + * @param {string} rulesetId - extension-defined ruleset ID. + * @param {integer} rulesetPrecedence + * @param {Rule[]} rules - extension-defined rules + * @param {RuleManager} ruleManager - owner of this ruleset. + */ + constructor(rulesetId, rulesetPrecedence, rules, ruleManager) { + this.id = rulesetId; + this.rulesetPrecedence = rulesetPrecedence; + this.rules = rules; + // For use by MatchedRule. + this.ruleManager = ruleManager; + } +} + +/** + * @param {string} uriQuery - The query of a nsIURI to transform. + * @param {object} queryTransform - The value of the + * Rule.action.redirect.transform.queryTransform property as defined in + * declarative_net_request.json. + * @returns {string} The uriQuery with the queryTransform applied to it. + */ +function applyQueryTransform(uriQuery, queryTransform) { + // URLSearchParams cannot be applied to the full query string, because that + // API formats the full query string using form-urlencoding. But the input + // may be in a different format. So we try to only modify matched params. + + function urlencode(s) { + // Encode in application/x-www-form-urlencoded format. + // The only JS API to do that is URLSearchParams. encodeURIComponent is not + // the same, it differs in how it handles " " ("%20") and "!'()~" (raw). + // But urlencoded space should be "+" and the latter be "%21%27%28%29%7E". + return new URLSearchParams({ s }).toString().slice(2); + } + if (!uriQuery.length && !queryTransform.addOrReplaceParams) { + // Nothing to do. + return ""; + } + const removeParamsSet = new Set(queryTransform.removeParams?.map(urlencode)); + const addParams = (queryTransform.addOrReplaceParams || []).map(orig => ({ + normalizedKey: urlencode(orig.key), + orig, + })); + const finalParams = []; + if (uriQuery.length) { + for (let part of uriQuery.split("&")) { + let key = part.split("=", 1)[0]; + if (removeParamsSet.has(key)) { + continue; + } + let i = addParams.findIndex(p => p.normalizedKey === key); + if (i !== -1) { + // Replace found param with the key-value from addOrReplaceParams. + finalParams.push(`${key}=${urlencode(addParams[i].orig.value)}`); + // Omit param so that a future search for the same key can find the next + // specified key-value pair, if any. And to prevent the already-used + // key-value pairs from being appended after the loop. + addParams.splice(i, 1); + } else { + finalParams.push(part); + } + } + } + // Append remaining, unused key-value pairs. + for (let { normalizedKey, orig } of addParams) { + if (!orig.replaceOnly) { + finalParams.push(`${normalizedKey}=${urlencode(orig.value)}`); + } + } + return finalParams.length ? `?${finalParams.join("&")}` : ""; +} + +/** + * @param {nsIURI} uri - Usually a http(s) URL. + * @param {object} transform - The value of the Rule.action.redirect.transform + * property as defined in declarative_net_request.json. + * @returns {nsIURI} uri - The new URL. + * @throws if the transformation is invalid. + */ +function applyURLTransform(uri, transform) { + let mut = uri.mutate(); + if (transform.scheme) { + // Note: declarative_net_request.json only allows http(s)/moz-extension:. + mut.setScheme(transform.scheme); + if (uri.port !== -1 || transform.port) { + // If the URI contains a port or transform.port was specified, the default + // port is significant. So we must set it in that case. + if (transform.scheme === "https") { + mut.QueryInterface(Ci.nsIStandardURLMutator).setDefaultPort(443); + } else if (transform.scheme === "http") { + mut.QueryInterface(Ci.nsIStandardURLMutator).setDefaultPort(80); + } + } + } + if (transform.username != null) { + mut.setUsername(transform.username); + } + if (transform.password != null) { + mut.setPassword(transform.password); + } + if (transform.host != null) { + mut.setHost(transform.host); + } + if (transform.port != null) { + // The caller ensures that transform.port is a string consisting of digits + // only. When it is an empty string, it should be cleared (-1). + mut.setPort(transform.port || -1); + } + if (transform.path != null) { + mut.setFilePath(transform.path); + } + if (transform.query != null) { + mut.setQuery(transform.query); + } else if (transform.queryTransform) { + mut.setQuery(applyQueryTransform(uri.query, transform.queryTransform)); + } + if (transform.fragment != null) { + mut.setRef(transform.fragment); + } + return mut.finalize(); +} + +/** + * @param {nsIURI} uri - Usually a http(s) URL. + * @param {MatchedRule} matchedRule - The matched rule with a regexFilter + * condition and regexSubstitution action. + * @returns {nsIURI} The new URL derived from the regexSubstitution combined + * with capturing group from regexFilter applied to the input uri. + * @throws if the resulting URL is an invalid redirect target. + */ +function applyRegexSubstitution(uri, matchedRule) { + const rule = matchedRule.rule; + const extension = matchedRule.ruleManager.extension; + const regexSubstitution = rule.action.redirect.regexSubstitution; + const compiledRegexFilter = rule.condition.getCompiledRegexFilter(); + // This method being called implies that regexFilter matched, so |matches| is + // always non-null, i.e. an array of string/undefined values. + const matches = compiledRegexFilter.exec(uri.spec); + + let redirectUrl = regexSubstitution.replace(/\\(.)/g, (_, char) => { + // #checkActionRedirect ensures that every \ is followed by a \ or digit. + return char === "\\" ? char : matches[char] ?? ""; + }); + + // Throws if the URL is invalid: + let redirectUri; + try { + redirectUri = Services.io.newURI(redirectUrl); + } catch (e) { + throw new Error( + `Extension ${extension.id} tried to redirect to an invalid URL: ${redirectUrl}` + ); + } + if (!extension.checkLoadURI(redirectUri, { dontReportErrors: true })) { + throw new Error( + `Extension ${extension.id} may not redirect to: ${redirectUrl}` + ); + } + return redirectUri; +} + +/** + * An urlFilter is a string pattern to match a canonical http(s) URL. + * urlFilter matches anywhere in the string, unless an anchor is present: + * - ||... ("Domain name anchor") - domain or subdomain starts with ... + * - |... ("Left anchor") - URL starts with ... + * - ...| ("Right anchor") - URL ends with ... + * + * Other than the anchors, the following special characters exist: + * - ^ = end of URL, or any char except: alphanum _ - . % ("Separator") + * - * = any number of characters ("Wildcard") + * + * Ambiguous cases (undocumented but actual Chrome behavior): + * - Plain "||" is a domain name anchor, not left + empty + right anchor. + * - "^" repeated at end of pattern: "^" matches end of URL only once. + * - "^|" at end of pattern: "^" is allowed to match end of URL. + * + * Implementation details: + * - CompiledUrlFilter's constructor (+#initializeUrlFilter) extracts the + * actual urlFilter and anchors, for matching against URLs later. + * - RequestDataForUrlFilter class precomputes the URL / domain anchors to + * support matching more efficiently. + * - CompiledUrlFilter's matchesRequest(request) checks whether the request is + * actually matched, using the precomputed information. + * + * The class was designed to minimize the number of string allocations during + * request evaluation, because the matchesRequest method may be called very + * often for every network request. + */ +class CompiledUrlFilter { + #isUrlFilterCaseSensitive; + #urlFilterParts; // = parts of urlFilter, minus anchors, split at "*". + // isAnchorLeft and isAnchorDomain are mutually exclusive. + #isAnchorLeft = false; + #isAnchorDomain = false; + #isAnchorRight = false; + #isTrailingSeparator = false; // Whether urlFilter ends with "^". + + /** + * @param {string} urlFilter - non-empty urlFilter + * @param {boolean} [isUrlFilterCaseSensitive] + */ + constructor(urlFilter, isUrlFilterCaseSensitive) { + this.#isUrlFilterCaseSensitive = isUrlFilterCaseSensitive; + this.#initializeUrlFilter(urlFilter, isUrlFilterCaseSensitive); + } + + #initializeUrlFilter(urlFilter, isUrlFilterCaseSensitive) { + let start = 0; + let end = urlFilter.length; + + // First, trim the anchors off urlFilter. + if (urlFilter[0] === "|") { + if (urlFilter[1] === "|") { + start = 2; + this.#isAnchorDomain = true; + // ^ will not revert to false below, because "||*" is already rejected + // by RuleValidator's #checkCondUrlFilterAndRegexFilter method. + } else { + start = 1; + this.#isAnchorLeft = true; // may revert to false below. + } + } + if (end > start && urlFilter[end - 1] === "|") { + --end; + this.#isAnchorRight = true; // may revert to false below. + } + + // Skip unnecessary wildcards, and adjust meaningless anchors accordingly: + // "|*" and "*|" are not effective anchors, they could have been omitted. + while (start < end && urlFilter[start] === "*") { + ++start; + this.#isAnchorLeft = false; + } + while (end > start && urlFilter[end - 1] === "*") { + --end; + this.#isAnchorRight = false; + } + + // Special-case the last "^", so that the matching algorithm can rely on + // the simple assumption that a "^" in the filter matches exactly one char: + // The "^" at the end of the pattern is specified to match either one char + // as usual, or as an anchor for the end of the URL (i.e. zero characters). + this.#isTrailingSeparator = urlFilter[end - 1] === "^"; + + let urlFilterWithoutAnchors = urlFilter.slice(start, end); + if (!isUrlFilterCaseSensitive) { + urlFilterWithoutAnchors = urlFilterWithoutAnchors.toLowerCase(); + } + this.#urlFilterParts = urlFilterWithoutAnchors.split("*"); + } + + /** + * Tests whether |request| matches the urlFilter. + * + * @param {RequestDataForUrlFilter} requestDataForUrlFilter + * @returns {boolean} Whether the condition matches the URL. + */ + matchesRequest(requestDataForUrlFilter) { + const url = requestDataForUrlFilter.getUrl(this.#isUrlFilterCaseSensitive); + const domainAnchors = requestDataForUrlFilter.domainAnchors; + + const urlFilterParts = this.#urlFilterParts; + + const REAL_END_OF_URL = url.length - 1; // minus trailing "^" + + // atUrlIndex is the position after the most recently matched part. + // If a match is not found, it is -1 and we should return false. + let atUrlIndex = 0; + + // The head always exists, potentially even an empty string. + const head = urlFilterParts[0]; + if (this.#isAnchorLeft) { + if (!this.#startsWithPart(head, url, 0)) { + return false; + } + atUrlIndex = head.length; + } else if (this.#isAnchorDomain) { + atUrlIndex = this.#indexAfterDomainPart(head, url, domainAnchors); + } else { + atUrlIndex = this.#indexAfterPart(head, url, 0); + } + + let previouslyAtUrlIndex = 0; + for (let i = 1; i < urlFilterParts.length && atUrlIndex !== -1; ++i) { + previouslyAtUrlIndex = atUrlIndex; + atUrlIndex = this.#indexAfterPart(urlFilterParts[i], url, atUrlIndex); + } + if (atUrlIndex === -1) { + return false; + } + if (atUrlIndex === url.length) { + // We always append a "^" to the URL, so if the match is at the end of the + // URL (REAL_END_OF_URL), only accept if the pattern ended with a "^". + return this.#isTrailingSeparator; + } + if (!this.#isAnchorRight || atUrlIndex === REAL_END_OF_URL) { + // Either not interested in the end, or already at the end of the URL. + return true; + } + + // #isAnchorRight is true but we are not at the end of the URL. + // Backtrack once, to retry the last pattern (tail) with the end of the URL. + + const tail = urlFilterParts[urlFilterParts.length - 1]; + // The expected offset where the tail should be located. + const expectedTailIndex = REAL_END_OF_URL - tail.length; + // If #isTrailingSeparator is true, then accept the URL's trailing "^". + const expectedTailIndexPlus1 = expectedTailIndex + 1; + if (urlFilterParts.length === 1) { + if (this.#isAnchorLeft) { + // If matched, we would have returned at the REAL_END_OF_URL checks. + return false; + } + if (this.#isAnchorDomain) { + // The tail must be exactly at one of the domain anchors. + return ( + (domainAnchors.includes(expectedTailIndex) && + this.#startsWithPart(tail, url, expectedTailIndex)) || + (this.#isTrailingSeparator && + domainAnchors.includes(expectedTailIndexPlus1) && + this.#startsWithPart(tail, url, expectedTailIndexPlus1)) + ); + } + // head has no left/domain anchor, fall through. + } + // The tail is not left/domain anchored, accept it as long as it did not + // overlap with an already-matched part of the URL. + return ( + (expectedTailIndex > previouslyAtUrlIndex && + this.#startsWithPart(tail, url, expectedTailIndex)) || + (this.#isTrailingSeparator && + expectedTailIndexPlus1 > previouslyAtUrlIndex && + this.#startsWithPart(tail, url, expectedTailIndexPlus1)) + ); + } + + // Whether a character should match "^" in an urlFilter. + // The "match end of URL" meaning of "^" is covered by #isTrailingSeparator. + static #regexIsSep = /[^A-Za-z0-9_\-.%]/; + + #matchPartAt(part, url, urlIndex, sepStart) { + if (sepStart === -1) { + // Fast path. + return url.startsWith(part, urlIndex); + } + if (urlIndex + part.length > url.length) { + return false; + } + for (let i = 0; i < part.length; ++i) { + let partChar = part[i]; + let urlChar = url[urlIndex + i]; + if ( + partChar !== urlChar && + (partChar !== "^" || !CompiledUrlFilter.#regexIsSep.test(urlChar)) + ) { + return false; + } + } + return true; + } + + #startsWithPart(part, url, urlIndex) { + const sepStart = part.indexOf("^"); + return this.#matchPartAt(part, url, urlIndex, sepStart); + } + + #indexAfterPart(part, url, urlIndex) { + let sepStart = part.indexOf("^"); + if (sepStart === -1) { + // Fast path. + let i = url.indexOf(part, urlIndex); + return i === -1 ? i : i + part.length; + } + let maxUrlIndex = url.length - part.length; + for (let i = urlIndex; i <= maxUrlIndex; ++i) { + if (this.#matchPartAt(part, url, i, sepStart)) { + return i + part.length; + } + } + return -1; + } + + #indexAfterDomainPart(part, url, domainAnchors) { + const sepStart = part.indexOf("^"); + for (let offset of domainAnchors) { + if (this.#matchPartAt(part, url, offset, sepStart)) { + return offset + part.length; + } + } + return -1; + } +} + +// See CompiledUrlFilter for documentation of RequestDataForUrlFilter. +class RequestDataForUrlFilter { + /** + * @param {string} requestURIspec - The URL to match against. + */ + constructor(requestURIspec) { + // "^" is appended, see CompiledUrlFilter's #initializeUrlFilter. + this.urlAnyCase = requestURIspec + "^"; + this.urlLowerCase = this.urlAnyCase.toLowerCase(); + // For "||..." (Domain name anchor): where (sub)domains start in the URL. + this.domainAnchors = this.#getDomainAnchors(this.urlAnyCase); + } + + getUrl(isUrlFilterCaseSensitive) { + return isUrlFilterCaseSensitive ? this.urlAnyCase : this.urlLowerCase; + } + + #getDomainAnchors(url) { + let hostStart = url.indexOf("://") + 3; + let hostEnd = url.indexOf("/", hostStart); + let userpassEnd = url.lastIndexOf("@", hostEnd) + 1; + if (userpassEnd) { + hostStart = userpassEnd; + } + let host = url.slice(hostStart, hostEnd); + let domainAnchors = [hostStart]; + let offset = 0; + // Find all offsets after ".". If not found, -1 + 1 = 0, and the loop ends. + while ((offset = host.indexOf(".", offset) + 1)) { + domainAnchors.push(hostStart + offset); + } + return domainAnchors; + } +} + +function compileRegexFilter(regexFilter, isUrlFilterCaseSensitive) { + // TODO bug 1821033: Restrict supported regex to avoid perf issues. For + // discussion on the desired syntax, see + // https://github.com/w3c/webextensions/issues/344 + return new RegExp(regexFilter, isUrlFilterCaseSensitive ? "" : "i"); +} + +class ModifyHeadersBase { + // Map<string,MatchedRule> - The first MatchedRule that modified the header. + // After modifying a header, it cannot be modified further, with the exception + // of the "append" operation, provided that they are from the same extension. + #alreadyModifiedMap = new Map(); + // Set<string> - The list of headers allowed to be modified with "append", + // despite having been modified. Allowed for "set"/"append", not for "remove". + #appendStillAllowed = new Set(); + + /** + * @param {ChannelWrapper} channel + */ + constructor(channel) { + this.channel = channel; + } + + /** + * @param {MatchedRule} matchedRule + * @returns {object[]} + */ + headerActionsFor(matchedRule) { + throw new Error("Not implemented."); + } + + /** + * @param {MatchedRule} matchedrule + * @param {string} name + * @param {string} value + * @param {boolean} merge + */ + setHeaderImpl(matchedrule, name, value, merge) { + throw new Error("Not implemented."); + } + + /** @param {MatchedRule[]} matchedRules */ + applyModifyHeaders(matchedRules) { + for (const matchedRule of matchedRules) { + for (const headerAction of this.headerActionsFor(matchedRule)) { + const { header: name, operation, value } = headerAction; + if (!this.#isOperationAllowed(name, operation, matchedRule)) { + continue; + } + let ok; + switch (operation) { + case "set": + ok = this.setHeader(matchedRule, name, value, /* merge */ false); + if (ok) { + this.#appendStillAllowed.add(name); + } + break; + case "append": + ok = this.setHeader(matchedRule, name, value, /* merge */ true); + if (ok) { + this.#appendStillAllowed.add(name); + } + break; + case "remove": + ok = this.setHeader(matchedRule, name, "", /* merge */ false); + // Note: removal is final, so we don't add to #appendStillAllowed. + break; + } + if (ok) { + this.#alreadyModifiedMap.set(name, matchedRule); + } + } + } + } + + #isOperationAllowed(name, operation, matchedRule) { + const modifiedBy = this.#alreadyModifiedMap.get(name); + if (!modifiedBy) { + return true; + } + if ( + operation === "append" && + this.#appendStillAllowed.has(name) && + matchedRule.ruleManager === modifiedBy.ruleManager + ) { + return true; + } + // TODO bug 1803369: dev experience improvement: consider logging when + // a header modification was rejected. + return false; + } + + setHeader(matchedRule, name, value, merge) { + try { + this.setHeaderImpl(matchedRule, name, value, merge); + return true; + } catch (e) { + const extension = matchedRule.ruleManager.extension; + extension.logger.error( + `Failed to apply modifyHeaders action to header "${name}" (DNR rule id ${matchedRule.rule.id} from ruleset "${matchedRule.ruleset.id}"): ${e}` + ); + } + return false; + } + + // kName should already be in lower case. + isHeaderNameEqual(name, kName) { + return name.length === kName.length && name.toLowerCase() === kName; + } +} + +class ModifyRequestHeaders extends ModifyHeadersBase { + static maybeApplyModifyHeaders(channel, matchedRules) { + matchedRules = matchedRules.filter(mr => { + const action = mr.rule.action; + return action.type === "modifyHeaders" && action.requestHeaders?.length; + }); + if (matchedRules.length) { + new ModifyRequestHeaders(channel).applyModifyHeaders(matchedRules); + } + } + + /** @param {MatchedRule} matchedRule */ + headerActionsFor(matchedRule) { + return matchedRule.rule.action.requestHeaders; + } + + setHeaderImpl(matchedRule, name, value, merge) { + if (this.isHeaderNameEqual(name, "host")) { + this.#checkHostHeader(matchedRule, value); + } + if (merge && value && this.isHeaderNameEqual(name, "cookie")) { + // By default, headers are merged with ",". But Cookie should use "; ". + // HTTP/1.1 allowed only one Cookie header, but HTTP/2.0 allows multiple, + // but recommends concatenation on one line. Relevant RFCs: + // - https://www.rfc-editor.org/rfc/rfc6265#section-5.4 + // - https://www.rfc-editor.org/rfc/rfc7540#section-8.1.2.5 + // Consistent with Firefox internals, we ensure that there is at most one + // Cookie header, by overwriting the previous one, if any. + let existingCookie = this.channel.getRequestHeader("cookie"); + if (existingCookie) { + value = existingCookie + "; " + value; + merge = false; + } + } + this.channel.setRequestHeader(name, value, merge); + } + + #checkHostHeader(matchedRule, value) { + let uri = Services.io.newURI(`https://${value}/`); + let { policy } = matchedRule.ruleManager.extension; + + if (!policy.allowedOrigins.matches(uri)) { + throw new Error( + `Unable to set host header, url missing from permissions.` + ); + } + + if (WebExtensionPolicy.isRestrictedURI(uri)) { + throw new Error(`Unable to set host header to restricted url.`); + } + } +} + +class ModifyResponseHeaders extends ModifyHeadersBase { + static maybeApplyModifyHeaders(channel, matchedRules) { + matchedRules = matchedRules.filter(mr => { + const action = mr.rule.action; + return action.type === "modifyHeaders" && action.responseHeaders?.length; + }); + if (matchedRules.length) { + new ModifyResponseHeaders(channel).applyModifyHeaders(matchedRules); + } + } + + headerActionsFor(matchedRule) { + return matchedRule.rule.action.responseHeaders; + } + + setHeaderImpl(matchedRule, name, value, merge) { + this.channel.setResponseHeader(name, value, merge); + } +} + +class RuleValidator { + constructor(alreadyValidatedRules, { isSessionRuleset = false } = {}) { + this.rulesMap = new Map(alreadyValidatedRules.map(r => [r.id, r])); + this.failures = []; + this.isSessionRuleset = isSessionRuleset; + } + + /** + * Static method used to deserialize Rule class instances from a plain + * js object rule as serialized implicitly by aomStartup.encodeBlob + * when we store the rules into the startup cache file. + * + * @param {object} rule + * @returns {Rule} + */ + static deserializeRule(rule) { + const newRule = new Rule(rule); + if (newRule.condition.regexFilter) { + newRule.condition.setCompiledRegexFilter( + compileRegexFilter( + newRule.condition.regexFilter, + newRule.condition.isUrlFilterCaseSensitive + ) + ); + } + return newRule; + } + + removeRuleIds(ruleIds) { + for (const ruleId of ruleIds) { + this.rulesMap.delete(ruleId); + } + } + + /** + * @param {object[]} rules - A list of objects that adhere to the Rule type + * from declarative_net_request.json. + */ + addRules(rules) { + for (const rule of rules) { + if (this.rulesMap.has(rule.id)) { + this.#collectInvalidRule(rule, `Duplicate rule ID: ${rule.id}`); + continue; + } + // declarative_net_request.json defines basic types, such as the expected + // object properties and (primitive) type. Trivial constraints such as + // minimum array lengths are also expressed in the schema. + // Anything more complex is validated here. In particular, constraints + // involving multiple properties (e.g. mutual exclusiveness). + // + // The following conditions have already been validated by the schema: + // - isUrlFilterCaseSensitive (boolean) + // - domainType (enum string) + // - initiatorDomains & excludedInitiatorDomains & requestDomains & + // excludedRequestDomains (array of string in canonicalDomain format) + if ( + !this.#checkCondResourceTypes(rule) || + !this.#checkCondRequestMethods(rule) || + !this.#checkCondTabIds(rule) || + !this.#checkCondUrlFilterAndRegexFilter(rule) || + !this.#checkAction(rule) + ) { + continue; + } + + const newRule = new Rule(rule); + // #lastCompiledRegexFilter is set if regexFilter is set, and null + // otherwise by the above call to #checkCondUrlFilterAndRegexFilter(). + if (this.#lastCompiledRegexFilter) { + newRule.condition.setCompiledRegexFilter(this.#lastCompiledRegexFilter); + } + + this.rulesMap.set(rule.id, newRule); + } + } + + // #checkCondUrlFilterAndRegexFilter() compiles the regexFilter to check its + // validity. To avoid having to compile it again when the Rule (RuleCondition) + // is constructed, we temporarily cache the result. + #lastCompiledRegexFilter; + + // Checks: resourceTypes & excludedResourceTypes + #checkCondResourceTypes(rule) { + const { resourceTypes, excludedResourceTypes } = rule.condition; + if (this.#hasOverlap(resourceTypes, excludedResourceTypes)) { + this.#collectInvalidRule( + rule, + "resourceTypes and excludedResourceTypes should not overlap" + ); + return false; + } + if (rule.action.type === "allowAllRequests") { + if (!resourceTypes) { + this.#collectInvalidRule( + rule, + "An allowAllRequests rule must have a non-empty resourceTypes array" + ); + return false; + } + if (resourceTypes.some(r => r !== "main_frame" && r !== "sub_frame")) { + this.#collectInvalidRule( + rule, + "An allowAllRequests rule may only include main_frame/sub_frame in resourceTypes" + ); + return false; + } + } + return true; + } + + // Checks: requestMethods & excludedRequestMethods + #checkCondRequestMethods(rule) { + const { requestMethods, excludedRequestMethods } = rule.condition; + if (this.#hasOverlap(requestMethods, excludedRequestMethods)) { + this.#collectInvalidRule( + rule, + "requestMethods and excludedRequestMethods should not overlap" + ); + return false; + } + const isInvalidRequestMethod = method => method.toLowerCase() !== method; + if ( + requestMethods?.some(isInvalidRequestMethod) || + excludedRequestMethods?.some(isInvalidRequestMethod) + ) { + this.#collectInvalidRule(rule, "request methods must be in lower case"); + return false; + } + return true; + } + + // Checks: tabIds & excludedTabIds + #checkCondTabIds(rule) { + const { tabIds, excludedTabIds } = rule.condition; + + if ((tabIds || excludedTabIds) && !this.isSessionRuleset) { + this.#collectInvalidRule( + rule, + "tabIds and excludedTabIds can only be specified in session rules" + ); + return false; + } + + if (this.#hasOverlap(tabIds, excludedTabIds)) { + this.#collectInvalidRule( + rule, + "tabIds and excludedTabIds should not overlap" + ); + return false; + } + return true; + } + + static #regexNonASCII = /[^\x00-\x7F]/; // eslint-disable-line no-control-regex + static #regexDigitOrBackslash = /^[0-9\\]$/; + + // Checks: urlFilter & regexFilter + #checkCondUrlFilterAndRegexFilter(rule) { + const { urlFilter, regexFilter } = rule.condition; + + this.#lastCompiledRegexFilter = null; + + const checkEmptyOrNonASCII = (str, prop) => { + if (!str) { + this.#collectInvalidRule(rule, `${prop} should not be an empty string`); + return false; + } + // Non-ASCII in URLs are always encoded in % (or punycode in domains). + if (RuleValidator.#regexNonASCII.test(str)) { + this.#collectInvalidRule( + rule, + `${prop} should not contain non-ASCII characters` + ); + return false; + } + return true; + }; + if (urlFilter != null) { + if (regexFilter != null) { + this.#collectInvalidRule( + rule, + "urlFilter and regexFilter are mutually exclusive" + ); + return false; + } + if (!checkEmptyOrNonASCII(urlFilter, "urlFilter")) { + // #collectInvalidRule already called by checkEmptyOrNonASCII. + return false; + } + if (urlFilter.startsWith("||*")) { + // Rejected because Chrome does too. '||*' is equivalent to '*'. + this.#collectInvalidRule(rule, "urlFilter should not start with '||*'"); + return false; + } + } else if (regexFilter != null) { + if (!checkEmptyOrNonASCII(regexFilter, "regexFilter")) { + // #collectInvalidRule already called by checkEmptyOrNonASCII. + return false; + } + try { + this.#lastCompiledRegexFilter = compileRegexFilter( + regexFilter, + rule.condition.isUrlFilterCaseSensitive + ); + } catch (e) { + this.#collectInvalidRule( + rule, + "regexFilter is not a valid regular expression" + ); + return false; + } + } + return true; + } + + #checkAction(rule) { + switch (rule.action.type) { + case "allow": + case "allowAllRequests": + case "block": + case "upgradeScheme": + // These actions have no extra properties. + break; + case "redirect": + return this.#checkActionRedirect(rule); + case "modifyHeaders": + return this.#checkActionModifyHeaders(rule); + default: + // Other values are not possible because declarative_net_request.json + // only accepts the above action types. + throw new Error(`Unexpected action type: ${rule.action.type}`); + } + return true; + } + + #checkActionRedirect(rule) { + const { url, extensionPath, transform, regexSubstitution } = + rule.action.redirect ?? {}; + const hasExtensionPath = extensionPath != null; + const hasRegexSubstitution = regexSubstitution != null; + const redirectKeyCount = // @ts-ignore trivial/noisy + !!url + !!hasExtensionPath + !!transform + !!hasRegexSubstitution; + if (redirectKeyCount !== 1) { + if (redirectKeyCount === 0) { + this.#collectInvalidRule( + rule, + "A redirect rule must have a non-empty action.redirect object" + ); + return false; + } + // Side note: Chrome silently ignores excess keys, and skips validation + // for ignored keys, in this order: + // - url > extensionPath > transform > regexSubstitution + this.#collectInvalidRule( + rule, + "redirect.url, redirect.extensionPath, redirect.transform and redirect.regexSubstitution are mutually exclusive" + ); + return false; + } + + if (hasExtensionPath && !extensionPath.startsWith("/")) { + this.#collectInvalidRule( + rule, + "redirect.extensionPath should start with a '/'" + ); + return false; + } + + // If specified, the "url" property is described as "format": "url" in the + // JSON schema, which ensures that the URL is a canonical form, and that + // the extension is allowed to trigger a navigation to the URL. + // E.g. javascript: and privileged about:-URLs cannot be navigated to, but + // http(s) URLs can (regardless of extension permissions). + // data:-URLs are currently blocked due to bug 1622986. + + if (transform) { + if (transform.query != null && transform.queryTransform) { + this.#collectInvalidRule( + rule, + "redirect.transform.query and redirect.transform.queryTransform are mutually exclusive" + ); + return false; + } + // Most of the validation is done by nsIURIMutator via applyURLTransform. + // nsIURIMutator is not very strict, so we perform some extra checks here + // to reject values that are not technically valid URLs. + + if (transform.port && /\D/.test(transform.port)) { + // nsIURIMutator's setPort takes an int, so any string will implicitly + // be converted to a number. This part verifies that the input only + // consists of digits. setPort will ensure that it is at most 65535. + this.#collectInvalidRule( + rule, + "redirect.transform.port should be empty or an integer" + ); + return false; + } + + // Note: we don't verify whether transform.query starts with '/', because + // Chrome does not require it, and nsIURIMutator prepends it if missing. + + if (transform.query && !transform.query.startsWith("?")) { + this.#collectInvalidRule( + rule, + "redirect.transform.query should be empty or start with a '?'" + ); + return false; + } + if (transform.fragment && !transform.fragment.startsWith("#")) { + this.#collectInvalidRule( + rule, + "redirect.transform.fragment should be empty or start with a '#'" + ); + return false; + } + try { + const dummyURI = Services.io.newURI("http://dummy"); + // applyURLTransform uses nsIURIMutator to transform a URI, and throws + // if |transform| is invalid, e.g. invalid host, port, etc. + applyURLTransform(dummyURI, transform); + } catch (e) { + this.#collectInvalidRule( + rule, + "redirect.transform does not describe a valid URL transformation" + ); + return false; + } + } + + if (hasRegexSubstitution) { + if (!rule.condition.regexFilter) { + this.#collectInvalidRule( + rule, + "redirect.regexSubstitution requires the regexFilter condition to be specified" + ); + return false; + } + let i = 0; + // i will be index after \. Loop breaks if not found (-1+1=0 = false). + while ((i = regexSubstitution.indexOf("\\", i) + 1)) { + let c = regexSubstitution[i++]; // may be undefined if \ is at end. + if (c === undefined || !RuleValidator.#regexDigitOrBackslash.test(c)) { + this.#collectInvalidRule( + rule, + "redirect.regexSubstitution only allows digit or \\ after \\." + ); + return false; + } + } + } + + return true; + } + + #checkActionModifyHeaders(rule) { + const { requestHeaders, responseHeaders } = rule.action; + if (!requestHeaders && !responseHeaders) { + this.#collectInvalidRule( + rule, + "A modifyHeaders rule must have a non-empty requestHeaders or modifyHeaders list" + ); + return false; + } + + const isValidModifyHeadersOp = ({ header, operation, value }) => { + if (!header) { + this.#collectInvalidRule(rule, "header must be non-empty"); + return false; + } + if (!value && (operation === "append" || operation === "set")) { + this.#collectInvalidRule( + rule, + "value is required for operations append/set" + ); + return false; + } + if (value && operation === "remove") { + this.#collectInvalidRule( + rule, + "value must not be provided for operation remove" + ); + return false; + } + return true; + }; + if ( + (requestHeaders && !requestHeaders.every(isValidModifyHeadersOp)) || + (responseHeaders && !responseHeaders.every(isValidModifyHeadersOp)) + ) { + // #collectInvalidRule already called by isValidModifyHeadersOp. + return false; + } + return true; + } + + // Conditions with a filter and an exclude-filter should reject overlapping + // lists, because they can never simultaneously be true. + #hasOverlap(arrayA, arrayB) { + return arrayA && arrayB && arrayA.some(v => arrayB.includes(v)); + } + + #collectInvalidRule(rule, message) { + this.failures.push({ rule, message }); + } + + getValidatedRules() { + return Array.from(this.rulesMap.values()); + } + + getFailures() { + return this.failures; + } +} + +export class RuleQuotaCounter { + constructor(isStaticRulesets) { + this.isStaticRulesets = isStaticRulesets; + this.ruleLimitName = isStaticRulesets + ? "GUARANTEED_MINIMUM_STATIC_RULES" + : "MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES"; + this.ruleLimitRemaining = lazy.ExtensionDNRLimits[this.ruleLimitName]; + this.regexRemaining = lazy.ExtensionDNRLimits.MAX_NUMBER_OF_REGEX_RULES; + } + + tryAddRules(rulesetId, rules) { + if (rules.length > this.ruleLimitRemaining) { + this.#throwQuotaError(rulesetId, "rules", this.ruleLimitName); + } + let regexCount = 0; + for (let rule of rules) { + if (rule.condition.regexFilter && ++regexCount > this.regexRemaining) { + this.#throwQuotaError( + rulesetId, + "regexFilter rules", + "MAX_NUMBER_OF_REGEX_RULES" + ); + } + } + + // Update counters only when there are no quota errors. + this.ruleLimitRemaining -= rules.length; + this.regexRemaining -= regexCount; + } + + #throwQuotaError(rulesetId, what, limitName) { + if (this.isStaticRulesets) { + throw new ExtensionError( + `Number of ${what} across all enabled static rulesets exceeds ${limitName} if ruleset "${rulesetId}" were to be enabled.` + ); + } + throw new ExtensionError( + `Number of ${what} in ruleset "${rulesetId}" exceeds ${limitName}.` + ); + } +} + +/** + * Compares two rules to determine the relative order of precedence. + * Rules are only comparable if they are from the same extension! + * + * @param {Rule} ruleA + * @param {Rule} ruleB + * @param {Ruleset} rulesetA - the ruleset ruleA is part of. + * @param {Ruleset} rulesetB - the ruleset ruleB is part of. + * @returns {integer} + * 0 if equal. + * <0 if ruleA comes before ruleB. + * >0 if ruleA comes after ruleB. + */ +function compareRule(ruleA, ruleB, rulesetA, rulesetB) { + // Comparators: 0 if equal, >0 if a after b, <0 if a before b. + function cmpHighestNumber(a, b) { + return a === b ? 0 : b - a; + } + function cmpLowestNumber(a, b) { + return a === b ? 0 : a - b; + } + return ( + // All compared operands are non-negative integers. + cmpHighestNumber(ruleA.priority, ruleB.priority) || + cmpLowestNumber(ruleA.actionPrecedence(), ruleB.actionPrecedence()) || + // As noted in the big comment at the top of the file, the following two + // comparisons only exist in order to have a stable ordering of rules. The + // specific comparison is somewhat arbitrary and matches Chrome's behavior. + // For context, see https://github.com/w3c/webextensions/issues/280 + cmpLowestNumber(rulesetA.rulesetPrecedence, rulesetB.rulesetPrecedence) || + cmpLowestNumber(ruleA.id, ruleB.id) + ); +} + +class MatchedRule { + /** + * @param {Rule} rule + * @param {Ruleset} ruleset + */ + constructor(rule, ruleset) { + this.rule = rule; + this.ruleset = ruleset; + } + + // The RuleManager that generated this MatchedRule. + get ruleManager() { + return this.ruleset.ruleManager; + } +} + +// tabId computation is currently not free, and depends on the initialization of +// ExtensionParent.apiManager.global (see WebRequest.getTabIdForChannelWrapper). +// Fortunately, DNR only supports tabIds in session rules, so by keeping track +// of session rules with tabIds/excludedTabIds conditions, we can find tabId +// exactly and only when necessary. +let gHasAnyTabIdConditions = false; + +class RequestDetails { + /** + * @param {object} options + * @param {nsIURI} options.requestURI - URL of the requested resource. + * @param {nsIURI} [options.initiatorURI] - URL of triggering principal, + * provided that it is a content principal. Otherwise null. + * @param {string} options.type - ResourceType (MozContentPolicyType). + * @param {string} [options.method] - HTTP method + * @param {integer} [options.tabId] + * @param {BrowsingContext} [options.browsingContext] - The BrowsingContext + * associated with the request. Typically the bc for which the subresource + * request is initiated, if any. For document requests, this is the parent + * (i.e. the parent frame for sub_frame, null for main_frame). + */ + constructor({ + requestURI, + initiatorURI, + type, + method, + tabId, + browsingContext, + }) { + this.requestURI = requestURI; + this.initiatorURI = initiatorURI; + this.type = type; + this.method = method; + this.tabId = tabId; + this.browsingContext = browsingContext; + + this.requestDomain = this.#domainFromURI(requestURI); + this.initiatorDomain = initiatorURI + ? this.#domainFromURI(initiatorURI) + : null; + + this.requestURIspec = requestURI.spec; + this.requestDataForUrlFilter = new RequestDataForUrlFilter( + this.requestURIspec + ); + } + + static fromChannelWrapper(channel) { + let tabId = -1; + if (gHasAnyTabIdConditions) { + tabId = lazy.WebRequest.getTabIdForChannelWrapper(channel); + } + return new RequestDetails({ + requestURI: channel.finalURI, + // Note: originURI may be null, if missing or null principal, as desired. + initiatorURI: channel.originURI, + type: channel.type, + method: channel.method.toLowerCase(), + tabId, + browsingContext: channel.loadInfo.browsingContext, + }); + } + + #ancestorRequestDetails; + get ancestorRequestDetails() { + if (this.#ancestorRequestDetails) { + return this.#ancestorRequestDetails; + } + this.#ancestorRequestDetails = []; + if (!this.browsingContext?.ancestorsAreCurrent) { + // this.browsingContext is set for real requests (via fromChannelWrapper). + // It may be void for testMatchOutcome and for the ancestor requests + // simulated below. + // + // ancestorsAreCurrent being false is unexpected, but could theoretically + // happen if the request is triggered from an unloaded (sub)frame. In that + // case we don't want to use potentially incorrect ancestor information. + // + // In any case, nothing left to do. + return this.#ancestorRequestDetails; + } + // Reconstruct the frame hierarchy of the request's document, in order to + // retroactively recompute the relevant matches of allowAllRequests rules. + // + // The allowAllRequests rule is supposedly applying to all subresource + // requests. For non-document requests, this is usually the document if any. + // In case of document requests, there is some ambiguity: + // - Usually, the initiator is the parent document that created the frame. + // - Sometimes, the initiator is a different frame or even another window. + // + // In RequestDetails.fromChannelWrapper, the actual initiator is used and + // reflected in initiatorURI, but here we use the document's parent. This + // is done because the chain of initiators is unstable (e.g. an opener can + // navigate/unload), whereas frame ancestor chain is constant as long as + // the leaf BrowsingContext is current. Moreover, allowAllRequests was + // originally designed to operate on frame hierarchies (crbug.com/1038831). + // + // This implementation of "initiator" for "allowAllRequests" is consistent + // with Chrome and Safari. + for (let bc = this.browsingContext; bc; bc = bc.parent) { + // Note: requestURI may differ from the document's initial requestURI, + // e.g. due to same-document navigations. + const requestURI = bc.currentURI; + if (!requestURI.schemeIs("https") && !requestURI.schemeIs("http")) { + // DNR is currently only hooked up to http(s) requests. Ignore other + // URLs, e.g. about:, blob:, moz-extension:, data:, etc. + continue; + } + const isTop = !bc.parent; + const parentPrin = bc.parentWindowContext?.documentPrincipal; + const requestDetails = new RequestDetails({ + requestURI, + // Note: initiatorURI differs from RequestDetails.fromChannelWrapper; + // See the above comment for more info. + initiatorURI: parentPrin?.isContentPrincipal ? parentPrin.URI : null, + type: isTop ? "main_frame" : "sub_frame", + method: bc.activeSessionHistoryEntry?.hasPostData ? "post" : "get", + tabId: this.tabId, + // In this loop we are already explicitly accounting for ancestors, so + // we intentionally omit browsingContext even though we have |bc|. If + // we were to set `browsingContext: bc`, the output would be the same, + // but be derived from unnecessarily repeated request evaluations. + browsingContext: null, + }); + this.#ancestorRequestDetails.unshift(requestDetails); + } + return this.#ancestorRequestDetails; + } + + canExtensionModify(extension) { + const policy = extension.policy; + if (!policy.canAccessURI(this.requestURI)) { + return false; + } + if ( + this.initiatorURI && + this.type !== "main_frame" && + this.type !== "sub_frame" && + !policy.canAccessURI(this.initiatorURI) + ) { + // Host permissions for the initiator is required except for navigation + // requests: https://bugzilla.mozilla.org/show_bug.cgi?id=1825824#c2 + return false; + } + return true; + } + + #domainFromURI(uri) { + try { + let hostname = uri.host; + // nsIURI omits brackets from IPv6 addresses. But the canonical form of an + // IPv6 address is with brackets, so add them. + return hostname.includes(":") ? `[${hostname}]` : hostname; + } catch (e) { + // uri.host throws for some schemes (e.g. about:). In practice we won't + // encounter this for network (via NetworkIntegration.startDNREvaluation) + // because isRestrictedPrincipalURI filters the initiatorURI. Furthermore, + // because only http(s) requests are observed, requestURI is http(s). + // + // declarativeNetRequest.testMatchOutcome can pass arbitrary URIs and thus + // trigger the error in nsIURI::GetHost. + Cu.reportError(e); + return null; + } + } +} + +/** + * This RequestEvaluator class's logic is documented at the top of this file. + */ +class RequestEvaluator { + // private constructor, only used by RequestEvaluator.evaluateRequest. + constructor(request, ruleManager) { + this.req = request; + this.ruleManager = ruleManager; + this.canModify = request.canExtensionModify(ruleManager.extension); + + // These values are initialized by findMatchingRules(): + this.matchedRule = null; + this.matchedModifyHeadersRules = []; + this.didCheckAncestors = false; + this.findMatchingRules(); + } + + /** + * Finds the matched rules for the given request and extensions, + * according to the logic documented at the top of this file. + * + * @param {RequestDetails} request + * @param {RuleManager[]} ruleManagers + * The list of RuleManagers, ordered by importance of its extension. + * @returns {MatchedRule[]} + */ + static evaluateRequest(request, ruleManagers) { + // Helper to determine precedence of rules from different extensions. + function precedence(matchedRule) { + switch (matchedRule.rule.action.type) { + case "block": + return 1; + case "redirect": + case "upgradeScheme": + return 2; + case "allow": + case "allowAllRequests": + return 3; + // case "modifyHeaders": not comparable after the first pass. + default: + throw new Error(`Unexpected action: ${matchedRule.rule.action.type}`); + } + } + + let requestEvaluators = []; + let finalMatch; + for (let ruleManager of ruleManagers) { + // Evaluate request with findMatchingRules(): + const requestEvaluator = new RequestEvaluator(request, ruleManager); + // RequestEvaluator may be used after the loop when the request is + // accepted, to collect modifyHeaders/allow/allowAllRequests actions. + requestEvaluators.push(requestEvaluator); + let matchedRule = requestEvaluator.matchedRule; + if ( + matchedRule && + (!finalMatch || precedence(matchedRule) < precedence(finalMatch)) + ) { + // Before choosing the matched rule as finalMatch, check whether there + // is an allowAllRequests rule override among the ancestors. + requestEvaluator.findAncestorRuleOverride(); + matchedRule = requestEvaluator.matchedRule; + if (!finalMatch || precedence(matchedRule) < precedence(finalMatch)) { + finalMatch = matchedRule; + if (finalMatch.rule.action.type === "block") { + break; + } + } + } + } + if (finalMatch && !finalMatch.rule.isAllowOrAllowAllRequestsAction()) { + // Found block/redirect/upgradeScheme, request will be replaced. + return [finalMatch]; + } + // Request not canceled, collect all modifyHeaders actions: + let matchedRules = requestEvaluators + .map(re => re.getMatchingModifyHeadersRules()) + .flat(1); + + // ... and collect the allowAllRequests actions: + // Note: Only needed for testMatchOutcome, getMatchedRules (bug 1745765) and + // onRuleMatchedDebug (bug 1745773). Not for regular requests, since regular + // requests do not distinguish between no rule vs allow vs allowAllRequests. + let finalAllowAllRequestsMatches = []; + for (let requestEvaluator of requestEvaluators) { + // TODO bug 1745765 / bug 1745773: Uncomment findAncestorRuleOverride() + // when getMatchedRules() or onRuleMatchedDebug are implemented. + // requestEvaluator.findAncestorRuleOverride(); + let matchedRule = requestEvaluator.matchedRule; + if (matchedRule && matchedRule.rule.action.type === "allowAllRequests") { + // Even if a different extension wins the final match, an extension + // may want to record the "allowAllRequests" action for the future. + finalAllowAllRequestsMatches.push(matchedRule); + } + } + if (finalAllowAllRequestsMatches.length) { + matchedRules = finalAllowAllRequestsMatches.concat(matchedRules); + } + + // ... and collect the "allow" action. At this point, finalMatch could also + // be a modifyHeaders or allowAllRequests action, but these would already + // have been added to the matchedRules result before. + if (finalMatch && finalMatch.rule.action.type === "allow") { + matchedRules.unshift(finalMatch); + } + return matchedRules; + } + + /** + * Finds the matching rules, as documented in the comment before the class. + */ + findMatchingRules() { + if (!this.canModify && !this.ruleManager.hasBlockPermission) { + // If the extension cannot apply any action, don't bother. + return; + } + + this.#collectMatchInRuleset(this.ruleManager.sessionRules); + this.#collectMatchInRuleset(this.ruleManager.dynamicRules); + for (let ruleset of this.ruleManager.enabledStaticRules) { + this.#collectMatchInRuleset(ruleset); + } + + if (this.matchedRule && !this.#isRuleActionAllowed(this.matchedRule.rule)) { + this.matchedRule = null; + // Note: this.matchedModifyHeadersRules is [] because canModify access is + // checked before populating the list. + } + } + + /** + * Find an "allowAllRequests" rule among the ancestors that may override the + * current matchedRule and/or matchedModifyHeadersRules rules. + */ + findAncestorRuleOverride() { + if (this.didCheckAncestors) { + return; + } + this.didCheckAncestors = true; + + if (!this.ruleManager.hasRulesWithAllowAllRequests) { + // Optimization: Skip ancestorRequestDetails lookup and/or request + // evaluation if there are no allowAllRequests rules. + return; + } + + // Now we need to check whether any of the ancestor frames had a matching + // allowAllRequests rule. matchedRule and/or matchedModifyHeadersRules + // results may be ignored if their priority is lower or equal to the + // highest-priority allowAllRequests rule among the frame ancestors. + // + // In theory, every ancestor may potentially yield an allowAllRequests rule, + // and should therefore be checked unconditionally. But logically, if there + // are no existing matches, then any matching allowAllRequests rules will + // not have any effect on the request outcome. As an optimization, we + // therefore skip ancestor checks in this case. + if ( + (!this.matchedRule || + this.matchedRule.rule.isAllowOrAllowAllRequestsAction()) && + !this.matchedModifyHeadersRules.length + ) { + // Optimization: Do not look up ancestors if no rules were matched. + // + // TODO bug 1745773: onRuleMatchedDebug is supposed to report when a rule + // has been matched. To be pedantic, when there is an onRuleMatchedDebug + // listener, the parents need to be checked unconditionally, in order to + // report potential allowAllRequests matches among ancestors. + // TODO bug 1745765: the above may also apply to getMatchedRules(). + return; + } + + for (let request of this.req.ancestorRequestDetails) { + // TODO: Optimize by only evaluating allow/allowAllRequests rules, because + // the request being seen here implies that the request was not canceled, + // i.e. that there were no block/redirect/upgradeScheme rules in any of + // the ancestors (across all extensions!). + let requestEvaluator = new RequestEvaluator(request, this.ruleManager); + let ancestorMatchedRule = requestEvaluator.matchedRule; + if ( + ancestorMatchedRule && + ancestorMatchedRule.rule.action.type === "allowAllRequests" && + (!this.matchedRule || + compareRule( + this.matchedRule.rule, + ancestorMatchedRule.rule, + this.matchedRule.ruleset, + ancestorMatchedRule.ruleset + ) > 0) + ) { + // Found an allowAllRequests rule that takes precedence over whatever + // the current rule was. + this.matchedRule = ancestorMatchedRule; + } + } + } + + /** + * Retrieves the list of matched modifyHeaders rules that should apply. + * + * @returns {MatchedRule[]} + */ + getMatchingModifyHeadersRules() { + if (this.matchedModifyHeadersRules.length) { + // Find parent allowAllRequests rules, if any, to make sure that we can + // appropriately ignore same-or-lower-priority modifyHeaders rules. + this.findAncestorRuleOverride(); + } + // The minimum priority is 1. Defaulting to 0 = include all. + let priorityThreshold = 0; + if (this.matchedRule?.rule.isAllowOrAllowAllRequestsAction()) { + priorityThreshold = this.matchedRule.rule.priority; + } + // Note: the result cannot be non-empty if this.matchedRule is a non-allow + // action, because if that were to be the case, then the request would have + // been canceled, and therefore there would not be any header to modify. + // Even if another extension were to override the action, it could only be + // any other non-allow action, which would still cancel the request. + let matchedRules = this.matchedModifyHeadersRules.filter(matchedRule => { + return matchedRule.rule.priority > priorityThreshold; + }); + // Sort output for a deterministic order. + // NOTE: Sorting rules at registration (in RuleManagers) would avoid the + // need to sort here. Since the number of matched modifyHeaders rules are + // expected to be small, we don't bother optimizing. + matchedRules.sort((a, b) => { + return compareRule(a.rule, b.rule, a.ruleset, b.ruleset); + }); + return matchedRules; + } + + /** @param {Ruleset} ruleset */ + #collectMatchInRuleset(ruleset) { + for (let rule of ruleset.rules) { + if (!this.#matchesRuleCondition(rule.condition)) { + continue; + } + if (rule.action.type === "modifyHeaders") { + if (this.canModify) { + this.matchedModifyHeadersRules.push(new MatchedRule(rule, ruleset)); + } + continue; + } + if ( + this.matchedRule && + compareRule( + this.matchedRule.rule, + rule, + this.matchedRule.ruleset, + ruleset + ) <= 0 + ) { + continue; + } + this.matchedRule = new MatchedRule(rule, ruleset); + } + } + + /** + * @param {RuleCondition} cond + * @returns {boolean} Whether the condition matched. + */ + #matchesRuleCondition(cond) { + if (cond.resourceTypes) { + if (!cond.resourceTypes.includes(this.req.type)) { + return false; + } + } else if (cond.excludedResourceTypes) { + if (cond.excludedResourceTypes.includes(this.req.type)) { + return false; + } + } else if (this.req.type === "main_frame") { + // When resourceTypes/excludedResourceTypes are not specified, the + // documented behavior is to ignore main_frame requests. + return false; + } + + // Check this.req.requestURI: + if (cond.urlFilter) { + if (!cond.urlFilterMatches(this.req.requestDataForUrlFilter)) { + return false; + } + } else if (cond.regexFilter) { + if (!cond.getCompiledRegexFilter().test(this.req.requestURIspec)) { + return false; + } + } + if ( + cond.excludedRequestDomains && + this.#matchesDomains(cond.excludedRequestDomains, this.req.requestDomain) + ) { + return false; + } + if ( + cond.requestDomains && + !this.#matchesDomains(cond.requestDomains, this.req.requestDomain) + ) { + return false; + } + if ( + cond.excludedInitiatorDomains && + // Note: unable to only match null principals (bug 1798225). + this.req.initiatorDomain && + this.#matchesDomains( + cond.excludedInitiatorDomains, + this.req.initiatorDomain + ) + ) { + return false; + } + if ( + cond.initiatorDomains && + // Note: unable to only match null principals (bug 1798225). + (!this.req.initiatorDomain || + !this.#matchesDomains(cond.initiatorDomains, this.req.initiatorDomain)) + ) { + return false; + } + + // TODO bug 1797408: domainType + + if (cond.requestMethods) { + if (!cond.requestMethods.includes(this.req.method)) { + return false; + } + } else if (cond.excludedRequestMethods?.includes(this.req.method)) { + return false; + } + + if (cond.tabIds) { + if (!cond.tabIds.includes(this.req.tabId)) { + return false; + } + } else if (cond.excludedTabIds?.includes(this.req.tabId)) { + return false; + } + + return true; + } + + /** + * @param {string[]} domains - A list of canonicalized domain patterns. + * Canonical means punycode, no ports, and IPv6 without brackets, and not + * starting with a dot. May end with a dot if it is a FQDN. + * @param {string} host - The canonical representation of the host of a URL. + * @returns {boolean} Whether the given host is a (sub)domain of any of the + * given domains. + */ + #matchesDomains(domains, host) { + return domains.some(domain => { + return ( + host.endsWith(domain) && + // either host === domain + (host.length === domain.length || + // or host = "something." + domain (WITH a domain separator). + host.charAt(host.length - domain.length - 1) === ".") + ); + }); + } + + /** + * @param {Rule} rule - The final rule from the first pass. + * @returns {boolean} Whether the extension is allowed to execute the rule. + */ + #isRuleActionAllowed(rule) { + if (this.canModify) { + return true; + } + switch (rule.action.type) { + case "allow": + case "allowAllRequests": + case "block": + case "upgradeScheme": + return this.ruleManager.hasBlockPermission; + case "redirect": + return false; + // case "modifyHeaders" is never an action for this.matchedRule. + default: + throw new Error(`Unexpected action type: ${rule.action.type}`); + } + } +} + +/** + * Checks whether a request from a document with the given URI is allowed to + * be modified by an unprivileged extension (e.g. an extension without host + * permissions but the "declarativeNetRequest" permission). + * The output is comparable to WebExtensionPolicy::CanAccessURI for an extension + * with the `<all_urls>` permission, for consistency with the webRequest API. + * + * @param {nsIURI} [uri] The URI of a request's loadingPrincipal. May be void + * if missing (e.g. top-level requests) or not a content principal. + * @returns {boolean} Whether there is any extension that is allowed to see + * requests from a document with the given URI. Callers are expected to: + * - check system requests (and treat as true). + * - check WebExtensionPolicy.isRestrictedURI (and treat as true). + */ +function isRestrictedPrincipalURI(uri) { + if (!uri) { + // No URI, could be: + // - System principal (caller should have checked and disallowed access). + // - Expanded principal, typically content script in documents. If an + // extension content script managed to run there, that implies that an + // extension was able to access it. + // - Null principal (e.g. sandboxed document, about:blank, data:). + return false; + } + + // An unprivileged extension with maximal host permissions has allowedOrigins + // set to [`<all_urls>`, `moz-extension://extensions-own-uuid-here`]. + // `<all_urls>` matches PermittedSchemes from MatchPattern.cpp: + // https://searchfox.org/mozilla-central/rev/55d5c4b9dffe5e59eb6b019c1a930ec9ada47e10/toolkit/components/extensions/MatchPattern.cpp#209-211 + // i.e. "http", "https", "ws", "wss", "file", "ftp", "data". + // - It is not possible to have a loadingPrincipal for: ws, wss, ftp. + // - data:-URIs always have an opaque origin, i.e. the principal is not a + // content principal, thus void here. + // - The remaining schemes from `<all_urls>` are: http, https, file, data, + // and checked below. + // + // Privileged addons can also access resource: and about:, but we do not need + // to support these now. + + // http(s) are common, and allowed, except for some restricted domains. The + // caller is expected to check WebExtensionPolicy.isRestrictedURI. + if (uri.schemeIs("http") || uri.schemeIs("https")) { + return false; // Very common. + } + + // moz-extension: is not restricted because an extension always has permission + // to its own moz-extension:-origin. The caller is expected to verify that an + // extension can only access its own URI. + if (uri.schemeIs("moz-extension")) { + return false; + } + + // Requests from local files are intentionally allowed (bug 1621935). + if (uri.schemeIs("file")) { + return false; + } + + // Anything else (e.g. resource:, about:newtab, etc.) is not allowed. + return true; +} + +const NetworkIntegration = { + maxEvaluatedRulesCount: 0, + + register() { + // We register via WebRequest.jsm to ensure predictable ordering of DNR and + // WebRequest behavior. + lazy.WebRequest.setDNRHandlingEnabled(true); + }, + unregister() { + lazy.WebRequest.setDNRHandlingEnabled(false); + }, + maybeUpdateTabIdChecker() { + gHasAnyTabIdConditions = gRuleManagers.some(rm => rm.hasRulesWithTabIds); + }, + + startDNREvaluation(channel) { + let ruleManagers = gRuleManagers; + // TODO bug 1827422: Merge isRestrictedPrincipalURI with canModify. + if (!channel.canModify || isRestrictedPrincipalURI(channel.documentURI)) { + // Ignore system requests or requests to restricted domains. + ruleManagers = []; + } + if (channel.loadInfo.originAttributes.privateBrowsingId > 0) { + ruleManagers = ruleManagers.filter( + rm => rm.extension.privateBrowsingAllowed + ); + } + if (ruleManagers.length && !lazy.gMatchRequestsFromOtherExtensions) { + const policy = channel.loadInfo.loadingPrincipal?.addonPolicy; + if (policy) { + ruleManagers = ruleManagers.filter( + rm => rm.extension.policy === policy + ); + } + } + let matchedRules; + if (ruleManagers.length) { + const evaluateRulesTimerId = + Glean.extensionsApisDnr.evaluateRulesTime.start(); + try { + const request = RequestDetails.fromChannelWrapper(channel); + matchedRules = RequestEvaluator.evaluateRequest(request, ruleManagers); + } finally { + if (evaluateRulesTimerId !== undefined) { + Glean.extensionsApisDnr.evaluateRulesTime.stopAndAccumulate( + evaluateRulesTimerId + ); + } + } + const evaluateRulesCount = ruleManagers.reduce( + (sum, ruleManager) => sum + ruleManager.getRulesCount(), + 0 + ); + if (evaluateRulesCount > this.maxEvaluatedRulesCount) { + Glean.extensionsApisDnr.evaluateRulesCountMax.set(evaluateRulesCount); + this.maxEvaluatedRulesCount = evaluateRulesCount; + } + } + // Cache for later. In case of redirects, _dnrMatchedRules may exist for + // the pre-redirect HTTP channel, and is overwritten here again. + channel._dnrMatchedRules = matchedRules; + }, + + /** + * Applies the actions of the DNR rules. + * + * @param {ChannelWrapper} channel + * @returns {boolean} Whether to ignore any responses from the webRequest API. + */ + onBeforeRequest(channel) { + let matchedRules = channel._dnrMatchedRules; + if (!matchedRules?.length) { + return false; + } + // If a matched rule closes the channel, it is the sole match. + const finalMatch = matchedRules[0]; + switch (finalMatch.rule.action.type) { + case "block": + this.applyBlock(channel, finalMatch); + return true; + case "redirect": + this.applyRedirect(channel, finalMatch); + return true; + case "upgradeScheme": + this.applyUpgradeScheme(channel, finalMatch); + return true; + } + // If there are multiple rules, then it may be a combination of allow, + // allowAllRequests and/or modifyHeaders. + + // "modifyHeaders" is handled by onBeforeSendHeaders/onHeadersReceived. + // "allow" and "allowAllRequests" require no further action now. + // "allowAllRequests" is applied to new requests in the future (if any) + // through RequestEvaluator's findAncestorRuleOverride(). + + return false; + }, + + onBeforeSendHeaders(channel) { + let matchedRules = channel._dnrMatchedRules; + if (!matchedRules?.length) { + return; + } + ModifyRequestHeaders.maybeApplyModifyHeaders(channel, matchedRules); + }, + + onHeadersReceived(channel) { + let matchedRules = channel._dnrMatchedRules; + if (!matchedRules?.length) { + return; + } + ModifyResponseHeaders.maybeApplyModifyHeaders(channel, matchedRules); + }, + + applyBlock(channel, matchedRule) { + // TODO bug 1802259: Consider a DNR-specific reason. + channel.cancel( + Cr.NS_ERROR_ABORT, + Ci.nsILoadInfo.BLOCKING_REASON_EXTENSION_WEBREQUEST + ); + const addonId = matchedRule.ruleManager.extension.id; + let properties = channel.channel.QueryInterface(Ci.nsIWritablePropertyBag); + properties.setProperty("cancelledByExtension", addonId); + }, + + applyUpgradeScheme(channel, matchedRule) { + // Request upgrade. No-op if already secure (i.e. https). + channel.upgradeToSecure(); + }, + + applyRedirect(channel, matchedRule) { + // Ambiguity resolution order of redirect dict keys, consistent with Chrome: + // - url > extensionPath > transform > regexSubstitution + const redirect = matchedRule.rule.action.redirect; + const extension = matchedRule.ruleManager.extension; + const preRedirectUri = channel.finalURI; + let redirectUri; + if (redirect.url) { + // redirect.url already validated by checkActionRedirect. + redirectUri = Services.io.newURI(redirect.url); + } else if (redirect.extensionPath) { + redirectUri = extension.baseURI + .mutate() + .setPathQueryRef(redirect.extensionPath) + .finalize(); + } else if (redirect.transform) { + redirectUri = applyURLTransform(preRedirectUri, redirect.transform); + } else if (redirect.regexSubstitution) { + // Note: may throw if regexSubstitution results in an invalid redirect. + // The error propagates up to handleRequest, which will just allow the + // request to continue. + redirectUri = applyRegexSubstitution(preRedirectUri, matchedRule); + } else { + // #checkActionRedirect ensures that the redirect action is non-empty. + } + + if (preRedirectUri.equals(redirectUri)) { + // URL did not change. Sometimes it is a bug in the extension, but there + // are also cases where the result is unavoidable. E.g. redirect.transform + // with queryTransform.removeParams that does not remove anything. + // TODO: consider logging to help with debugging. + return; + } + + channel.redirectTo(redirectUri); + + let properties = channel.channel.QueryInterface(Ci.nsIWritablePropertyBag); + properties.setProperty("redirectedByExtension", extension.id); + + let origin = channel.getRequestHeader("Origin"); + if (origin) { + channel.setResponseHeader("Access-Control-Allow-Origin", origin); + channel.setResponseHeader("Access-Control-Allow-Credentials", "true"); + channel.setResponseHeader("Access-Control-Max-Age", "0"); + } + }, +}; + +class RuleManager { + constructor(extension) { + this.extension = extension; + this.sessionRules = this.makeRuleset( + "_session", + PRECEDENCE_SESSION_RULESET + ); + this.dynamicRules = this.makeRuleset( + "_dynamic", + PRECEDENCE_DYNAMIC_RULESET + ); + this.enabledStaticRules = []; + + this.hasBlockPermission = extension.hasPermission("declarativeNetRequest"); + this.hasRulesWithTabIds = false; + this.hasRulesWithAllowAllRequests = false; + this.totalRulesCount = 0; + } + + get availableStaticRuleCount() { + return Math.max( + lazy.ExtensionDNRLimits.GUARANTEED_MINIMUM_STATIC_RULES - + this.enabledStaticRules.reduce( + (acc, ruleset) => acc + ruleset.rules.length, + 0 + ), + 0 + ); + } + + get enabledStaticRulesetIds() { + return this.enabledStaticRules.map(ruleset => ruleset.id); + } + + makeRuleset(rulesetId, rulesetPrecedence, rules = []) { + return new Ruleset(rulesetId, rulesetPrecedence, rules, this); + } + + setSessionRules(validatedSessionRules) { + let oldRulesCount = this.sessionRules.rules.length; + let newRulesCount = validatedSessionRules.length; + this.sessionRules.rules = validatedSessionRules; + this.totalRulesCount += newRulesCount - oldRulesCount; + this.hasRulesWithTabIds = !!this.sessionRules.rules.find(rule => { + return rule.condition.tabIds || rule.condition.excludedTabIds; + }); + this.#updateAllowAllRequestRules(); + NetworkIntegration.maybeUpdateTabIdChecker(); + } + + setDynamicRules(validatedDynamicRules) { + let oldRulesCount = this.dynamicRules.rules.length; + let newRulesCount = validatedDynamicRules.length; + this.dynamicRules.rules = validatedDynamicRules; + this.totalRulesCount += newRulesCount - oldRulesCount; + this.#updateAllowAllRequestRules(); + } + + /** + * Set the enabled static rulesets. + * + * @param {Array<{ id, rules }>} enabledStaticRulesets + * Array of objects including the ruleset id and rules. + * The order of the rulesets in the Array is expected to + * match the order of the rulesets in the extension manifest. + */ + setEnabledStaticRulesets(enabledStaticRulesets) { + const rulesets = []; + for (const [idx, { id, rules }] of enabledStaticRulesets.entries()) { + rulesets.push( + this.makeRuleset(id, idx + PRECEDENCE_STATIC_RULESETS_BASE, rules) + ); + } + const countRules = rulesets => + rulesets.reduce((sum, ruleset) => sum + ruleset.rules.length, 0); + const oldRulesCount = countRules(this.enabledStaticRules); + const newRulesCount = countRules(rulesets); + this.enabledStaticRules = rulesets; + this.totalRulesCount += newRulesCount - oldRulesCount; + this.#updateAllowAllRequestRules(); + } + + getSessionRules() { + return this.sessionRules.rules; + } + + getDynamicRules() { + return this.dynamicRules.rules; + } + + getRulesCount() { + return this.totalRulesCount; + } + + #updateAllowAllRequestRules() { + const filterAAR = rule => rule.action.type === "allowAllRequests"; + this.hasRulesWithAllowAllRequests = + this.sessionRules.rules.some(filterAAR) || + this.dynamicRules.rules.some(filterAAR) || + this.enabledStaticRules.some(ruleset => ruleset.rules.some(filterAAR)); + } +} + +function getRuleManager(extension, createIfMissing = true) { + let ruleManager = gRuleManagers.find(rm => rm.extension === extension); + if (!ruleManager && createIfMissing) { + if (extension.hasShutdown) { + throw new Error( + `Error on creating new DNR RuleManager after extension shutdown: ${extension.id}` + ); + } + ruleManager = new RuleManager(extension); + // The most recently installed extension gets priority, i.e. appears at the + // start of the gRuleManagers list. It is not yet possible to determine the + // installation time of a given Extension, so currently the last to + // instantiate a RuleManager claims the highest priority. + // TODO bug 1786059: order extensions by "installation time". + gRuleManagers.unshift(ruleManager); + if (gRuleManagers.length === 1) { + // The first DNR registration. + NetworkIntegration.register(); + } + } + return ruleManager; +} + +function clearRuleManager(extension) { + let i = gRuleManagers.findIndex(rm => rm.extension === extension); + if (i !== -1) { + gRuleManagers.splice(i, 1); + NetworkIntegration.maybeUpdateTabIdChecker(); + if (gRuleManagers.length === 0) { + // The last DNR registration. + NetworkIntegration.unregister(); + } + } +} + +/** + * Finds all matching rules for a request, optionally restricted to one + * extension. Used by declarativeNetRequest.testMatchOutcome. + * + * @param {object|RequestDetails} request + * @param {Extension} [extension] + * @returns {MatchedRule[]} + */ +function getMatchedRulesForRequest(request, extension) { + let requestDetails = new RequestDetails(request); + const { requestURI, initiatorURI } = requestDetails; + let ruleManagers = gRuleManagers; + if (extension) { + ruleManagers = ruleManagers.filter(rm => rm.extension === extension); + } + if ( + // NetworkIntegration.startDNREvaluation does not check requestURI, but we + // do that here to filter URIs that are obviously disallowed. In practice, + // anything other than http(s) is bogus and unsupported in DNR. + isRestrictedPrincipalURI(requestURI) || + // Equivalent to NetworkIntegration.startDNREvaluation's channel.canModify + // check, which excludes system requests and restricted domains. + WebExtensionPolicy.isRestrictedURI(requestURI) || + (initiatorURI && WebExtensionPolicy.isRestrictedURI(initiatorURI)) || + isRestrictedPrincipalURI(initiatorURI) + ) { + ruleManagers = []; + } + // While this simulated request is not really from another extension, apply + // the same access control checks from NetworkIntegration.startDNREvaluation + // for consistency. + if ( + !lazy.gMatchRequestsFromOtherExtensions && + initiatorURI?.schemeIs("moz-extension") + ) { + const extUuid = initiatorURI.host; + ruleManagers = ruleManagers.filter(rm => rm.extension.uuid === extUuid); + } + return RequestEvaluator.evaluateRequest(requestDetails, ruleManagers); +} + +/** + * Runs before any webRequest event is notified. Headers may be modified, but + * the request should not be canceled (see handleRequest instead). + * + * @param {ChannelWrapper} channel + * @param {string} kind - The name of the webRequest event. + */ +function beforeWebRequestEvent(channel, kind) { + try { + switch (kind) { + case "onBeforeRequest": + NetworkIntegration.startDNREvaluation(channel); + break; + case "onBeforeSendHeaders": + NetworkIntegration.onBeforeSendHeaders(channel); + break; + case "onHeadersReceived": + NetworkIntegration.onHeadersReceived(channel); + break; + } + } catch (e) { + Cu.reportError(e); + } +} + +/** + * Applies matching DNR rules, some of which may potentially cancel the request. + * + * @param {ChannelWrapper} channel + * @param {string} kind - The name of the webRequest event. + * @returns {boolean} Whether to ignore any responses from the webRequest API. + */ +function handleRequest(channel, kind) { + try { + if (kind === "onBeforeRequest") { + return NetworkIntegration.onBeforeRequest(channel); + } + } catch (e) { + Cu.reportError(e); + } + return false; +} + +async function initExtension(extension) { + // These permissions are NOT an OptionalPermission, so their status can be + // assumed to be constant for the lifetime of the extension. + if ( + extension.hasPermission("declarativeNetRequest") || + extension.hasPermission("declarativeNetRequestWithHostAccess") + ) { + if (extension.hasShutdown) { + throw new Error( + `Aborted ExtensionDNR.initExtension call, extension "${extension.id}" is not active anymore` + ); + } + extension.once("shutdown", () => clearRuleManager(extension)); + await lazy.ExtensionDNRStore.initExtension(extension); + } +} + +function ensureInitialized(extension) { + return (extension._dnrReady ??= initExtension(extension)); +} + +function validateManifestEntry(extension) { + const ruleResourcesArray = + extension.manifest.declarative_net_request.rule_resources; + + const getWarningMessage = msg => + `Warning processing declarative_net_request: ${msg}`; + + const { MAX_NUMBER_OF_STATIC_RULESETS } = lazy.ExtensionDNRLimits; + if (ruleResourcesArray.length > MAX_NUMBER_OF_STATIC_RULESETS) { + extension.manifestWarning( + getWarningMessage( + `Static rulesets are exceeding the MAX_NUMBER_OF_STATIC_RULESETS limit (${MAX_NUMBER_OF_STATIC_RULESETS}).` + ) + ); + } + + const seenRulesetIds = new Set(); + const seenRulesetPaths = new Set(); + const duplicatedRulesetIds = []; + const duplicatedRulesetPaths = []; + for (const [idx, { id, path }] of ruleResourcesArray.entries()) { + if (seenRulesetIds.has(id)) { + duplicatedRulesetIds.push({ idx, id }); + } + if (seenRulesetPaths.has(path)) { + duplicatedRulesetPaths.push({ idx, path }); + } + seenRulesetIds.add(id); + seenRulesetPaths.add(path); + } + + if (duplicatedRulesetIds.length) { + const errorDetails = duplicatedRulesetIds + .map(({ idx, id }) => `"${id}" at index ${idx}`) + .join(", "); + extension.manifestWarning( + getWarningMessage( + `Static ruleset ids should be unique, duplicated ruleset ids: ${errorDetails}.` + ) + ); + } + + if (duplicatedRulesetPaths.length) { + // NOTE: technically Chrome allows duplicated paths without any manifest + // validation warnings or errors, but if this happens it not unlikely to be + // actually a mistake in the manifest that may have been missed. + // + // In Firefox we decided to allow the same behavior to avoid introducing a chrome + // incompatibility, but we still warn about it to avoid extension developers + // to investigate more easily issue that may be due to duplicated rulesets + // paths. + const errorDetails = duplicatedRulesetPaths + .map(({ idx, path }) => `"${path}" at index ${idx}`) + .join(", "); + extension.manifestWarning( + getWarningMessage( + `Static rulesets paths are not unique, duplicated ruleset paths: ${errorDetails}.` + ) + ); + } + + const { MAX_NUMBER_OF_ENABLED_STATIC_RULESETS } = lazy.ExtensionDNRLimits; + + const enabledRulesets = ruleResourcesArray.filter(rs => rs.enabled); + if (enabledRulesets.length > MAX_NUMBER_OF_ENABLED_STATIC_RULESETS) { + const exceedingRulesetIds = enabledRulesets + .slice(MAX_NUMBER_OF_ENABLED_STATIC_RULESETS) + .map(ruleset => `"${ruleset.id}"`) + .join(", "); + extension.manifestWarning( + getWarningMessage( + `Enabled static rulesets are exceeding the MAX_NUMBER_OF_ENABLED_STATIC_RULESETS limit (${MAX_NUMBER_OF_ENABLED_STATIC_RULESETS}): ${exceedingRulesetIds}.` + ) + ); + } +} + +async function updateEnabledStaticRulesets(extension, updateRulesetOptions) { + await ensureInitialized(extension); + await lazy.ExtensionDNRStore.updateEnabledStaticRulesets( + extension, + updateRulesetOptions + ); +} + +async function updateDynamicRules(extension, updateRuleOptions) { + await ensureInitialized(extension); + await lazy.ExtensionDNRStore.updateDynamicRules(extension, updateRuleOptions); +} + +// exports used by the DNR API implementation. +export const ExtensionDNR = { + RuleValidator, + RuleQuotaCounter, + clearRuleManager, + ensureInitialized, + getMatchedRulesForRequest, + getRuleManager, + updateDynamicRules, + updateEnabledStaticRulesets, + validateManifestEntry, + beforeWebRequestEvent, + handleRequest, +}; diff --git a/toolkit/components/extensions/ExtensionDNRLimits.sys.mjs b/toolkit/components/extensions/ExtensionDNRLimits.sys.mjs new file mode 100644 index 0000000000..ac4cd79c44 --- /dev/null +++ b/toolkit/components/extensions/ExtensionDNRLimits.sys.mjs @@ -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/. */ + +// TODO(Bug 1803370): consider allowing changing DNR limits through about:config prefs). + +/** + * The minimum number of static rules guaranteed to an extension across its + * enabled static rulesets. Any rules above this limit will count towards the + * global static rule limit. + */ +const GUARANTEED_MINIMUM_STATIC_RULES = 30000; + +/** + * The maximum number of static Rulesets an extension can specify as part of + * the "rule_resources" manifest key. + * + * NOTE: this limit may be increased in the future, see https://github.com/w3c/webextensions/issues/318 + */ +const MAX_NUMBER_OF_STATIC_RULESETS = 50; + +/** + * The maximum number of static Rulesets an extension can enable at any one time. + * + * NOTE: this limit may be increased in the future, see https://github.com/w3c/webextensions/issues/318 + */ +const MAX_NUMBER_OF_ENABLED_STATIC_RULESETS = 10; + +/** + * The maximum number of dynamic and session rules an extension can add. + * NOTE: in the Firefox we are enforcing this limit to the session and dynamic rules count separately, + * instead of enforcing it to the rules count for both combined as the Chrome implementation does. + * + * NOTE: this limit may be increased in the future, see https://github.com/w3c/webextensions/issues/319 + */ +const MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES = 5000; + +/** + * The maximum number of regular expression rules that an extension can add. + * Session, dynamic and static rules have their own quota. + * + * TODO bug 1821033: Bump limit after optimizing regexFilter. + */ +const MAX_NUMBER_OF_REGEX_RULES = 1000; + +// TODO(Bug 1803370): allow extension to exceed the GUARANTEED_MINIMUM_STATIC_RULES limit. +// +// The maximum number of static rules exceeding the per-extension +// GUARANTEED_MINIMUM_STATIC_RULES across every extensions. +// +// const MAX_GLOBAL_NUMBER_OF_STATIC_RULES = 300000; + +export const ExtensionDNRLimits = { + GUARANTEED_MINIMUM_STATIC_RULES, + MAX_NUMBER_OF_STATIC_RULESETS, + MAX_NUMBER_OF_ENABLED_STATIC_RULESETS, + MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES, + MAX_NUMBER_OF_REGEX_RULES, +}; diff --git a/toolkit/components/extensions/ExtensionDNRStore.sys.mjs b/toolkit/components/extensions/ExtensionDNRStore.sys.mjs new file mode 100644 index 0000000000..7221e2cd3b --- /dev/null +++ b/toolkit/components/extensions/ExtensionDNRStore.sys.mjs @@ -0,0 +1,1700 @@ +/* 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 { ExtensionParent } from "resource://gre/modules/ExtensionParent.sys.mjs"; + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", + Extension: "resource://gre/modules/Extension.sys.mjs", + ExtensionDNR: "resource://gre/modules/ExtensionDNR.sys.mjs", + ExtensionDNRLimits: "resource://gre/modules/ExtensionDNRLimits.sys.mjs", + Schemas: "resource://gre/modules/Schemas.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + aomStartup: [ + "@mozilla.org/addons/addon-manager-startup;1", + "amIAddonManagerStartup", + ], +}); + +const LAST_UPDATE_TAG_PREF_PREFIX = "extensions.dnr.lastStoreUpdateTag."; + +const { DefaultMap, ExtensionError } = ExtensionUtils; +const { StartupCache } = ExtensionParent; + +// DNR Rules store subdirectory/file names and file extensions. +// +// NOTE: each extension's stored rules are stored in a per-extension file +// and stored rules filename is derived from the extension uuid assigned +// at install time. +const RULES_STORE_DIRNAME = "extension-dnr"; +const RULES_STORE_FILEEXT = ".json.lz4"; +const RULES_CACHE_FILENAME = "extensions-dnr.sc.lz4"; + +const requireTestOnlyCallers = () => { + if (!Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) { + throw new Error("This should only be called from XPCShell tests"); + } +}; + +/** + * Internal representation of the enabled static rulesets (used in StoreData + * and Store methods type signatures). + * + * @typedef {object} EnabledStaticRuleset + * @inner + * @property {number} idx + * Represent the position of the static ruleset in the manifest + * `declarative_net_request.rule_resources` array. + * @property {Array<Rule>} rules + * Represent the array of the DNR rules associated with the static + * ruleset. + */ + +// Class defining the format of the data stored into the per-extension files +// managed by RulesetsStore. +// +// StoreData instances are saved in the profile extension-dir subdirectory as +// lz4-compressed JSON files, only the ruleset_id is stored on disk for the +// enabled static rulesets (while the actual rules would need to be loaded back +// from the related rules JSON files part of the extension assets). +class StoreData { + // NOTE: Update schema version upgrade handling code in `RulesetsStore.#readData` + // along with bumps to the schema version here. + static VERSION = 1; + + static getLastUpdateTagPref(extensionUUID) { + return `${LAST_UPDATE_TAG_PREF_PREFIX}${extensionUUID}`; + } + + static getLastUpdateTag(extensionUUID) { + return Services.prefs.getCharPref( + this.getLastUpdateTagPref(extensionUUID), + null + ); + } + + static storeLastUpdateTag(extensionUUID, lastUpdateTag) { + Services.prefs.setCharPref( + this.getLastUpdateTagPref(extensionUUID), + lastUpdateTag + ); + } + + static clearLastUpdateTagPref(extensionUUID) { + Services.prefs.clearUserPref(this.getLastUpdateTagPref(extensionUUID)); + } + + static isStaleCacheEntry(extensionUUID, cacheStoreData) { + return ( + // Drop the cache entry if the data stored doesn't match the current + // StoreData schema version (this shouldn't happen unless the file + // have been manually restored by the user from an older firefox version). + cacheStoreData.schemaVersion !== this.VERSION || + // Drop the cache entry if the lastUpdateTag from the cached data entry + // doesn't match the lastUpdateTag recorded in the prefs, the tag is applied + // with a per-extension granularity to reduce the chances of cache misses + // last update on the cached data for an unrelated extensions did not make it + // to disk). + cacheStoreData.lastUpdateTag != this.getLastUpdateTag(extensionUUID) + ); + } + + #extUUID; + #initialLastUdateTag; + #temporarilyInstalled; + + /** + * @param {Extension} extension + * The extension the StoreData is associated to. + * @param {object} params + * @param {string} [params.extVersion] + * extension version + * @param {string} [params.lastUpdateTag] + * a tag associated to the data. It is only passed when we are loading the data + * from the StartupCache file, while a new tag uuid string will be generated + * for brand new data (and then new ones generated on each calls to the `updateRulesets` + * method). + * @param {number} [params.schemaVersion=StoreData.VERSION] + * file schema version + * @param {Map<string, EnabledStaticRuleset>} [params.staticRulesets=new Map()] + * map of the enabled static rulesets by ruleset_id, as resolved by + * `Store.prototype.#getManifestStaticRulesets`. + * NOTE: This map is converted in an array of the ruleset_id strings when the StoreData + * instance is being stored on disk (see `toJSON` method) and then converted back to a Map + * by `Store.prototype.#getManifestStaticRulesets` when the data is loaded back from disk. + * @param {Array<Rule>} [params.dynamicRuleset=[]] + * array of dynamic rules stored by the extension. + */ + constructor( + extension, + { + extVersion, + lastUpdateTag, + dynamicRuleset, + staticRulesets, + schemaVersion, + } = {} + ) { + if (!(extension instanceof lazy.Extension)) { + throw new Error("Missing mandatory extension parameter"); + } + this.schemaVersion = schemaVersion || StoreData.VERSION; + this.extVersion = extVersion ?? extension.version; + this.#extUUID = extension.uuid; + // Used to skip storing the data in the startupCache or storing the lastUpdateTag in + // the about:config prefs. + this.#temporarilyInstalled = extension.temporarilyInstalled; + // The lastUpdateTag gets set (and updated) by calls to updateRulesets. + this.lastUpdateTag = undefined; + this.#initialLastUdateTag = lastUpdateTag; + this.#updateRulesets({ + staticRulesets: staticRulesets ?? new Map(), + dynamicRuleset: dynamicRuleset ?? [], + lastUpdateTag, + }); + } + + isFromStartupCache() { + return this.#initialLastUdateTag == this.lastUpdateTag; + } + + isFromTemporarilyInstalled() { + return this.#temporarilyInstalled; + } + + get isEmpty() { + return !this.staticRulesets.size && !this.dynamicRuleset.length; + } + + /** + * Updates the static and or dynamic rulesets stored for the related + * extension. + * + * NOTE: This method also: + * - regenerates the lastUpdateTag associated as an unique identifier + * of the revision for the stored data (used to detect stale startup + * cache data) + * - stores the lastUpdateTag into an about:config pref associated to + * the extension uuid (also used as part of detecting stale startup + * cache data), unless the extension is installed temporarily. + * + * @param {object} params + * @param {Map<string, EnabledStaticRuleset>} [params.staticRulesets] + * optional new updated Map of static rulesets + * (static rulesets are unchanged if not passed). + * @param {Array<Rule>} [params.dynamicRuleset=[]] + * optional array of updated dynamic rules + * (dynamic rules are unchanged if not passed). + */ + updateRulesets({ staticRulesets, dynamicRuleset } = {}) { + let currentUpdateTag = this.lastUpdateTag; + let lastUpdateTag = this.#updateRulesets({ + staticRulesets, + dynamicRuleset, + }); + + // Tag each cache data entry with a value synchronously stored in an + // about:config prefs, if on a browser restart the tag in the startupCache + // data entry doesn't match the one in the about:config pref then the startup + // cache entry is dropped as stale (assuming an issue prevented the updated + // cache data to be written on disk, e.g. browser crash, failure on writing + // on disk etc.), each entry is tagged separately to decrease the chances + // of cache misses on unrelated cache data entries if only a few extension + // got stale data in the startup cache file. + if ( + !this.isFromTemporarilyInstalled() && + currentUpdateTag != lastUpdateTag + ) { + StoreData.storeLastUpdateTag(this.#extUUID, lastUpdateTag); + } + } + + #updateRulesets({ + staticRulesets = null, + dynamicRuleset = null, + lastUpdateTag = Services.uuid.generateUUID().toString(), + } = {}) { + if (staticRulesets) { + this.staticRulesets = staticRulesets; + } + + if (dynamicRuleset) { + this.dynamicRuleset = dynamicRuleset; + } + + if (staticRulesets || dynamicRuleset) { + this.lastUpdateTag = lastUpdateTag; + } + + return this.lastUpdateTag; + } + + // This method is used to convert the data in the format stored on disk + // as a JSON file. + toJSON() { + const data = { + schemaVersion: this.schemaVersion, + extVersion: this.extVersion, + // Only store the array of the enabled ruleset_id in the set of data + // persisted in a JSON form. + staticRulesets: this.staticRulesets + ? Array.from(this.staticRulesets.entries(), ([id, _ruleset]) => id) + : undefined, + dynamicRuleset: this.dynamicRuleset, + }; + return data; + } + + // This method is used to convert the data back to a StoreData class from + // the format stored on disk as a JSON file. + // NOTE: this method should be kept in sync with toJSON and make sure that + // we do deserialize the same property we are serializing into the JSON file. + static fromJSON(paramsFromJSON, extension) { + let { schemaVersion, extVersion, staticRulesets, dynamicRuleset } = + paramsFromJSON; + return new StoreData(extension, { + schemaVersion, + extVersion, + staticRulesets, + dynamicRuleset, + }); + } +} + +class Queue { + #tasks = []; + #runningTask = null; + #closed = false; + + get hasPendingTasks() { + return !!this.#runningTask || !!this.#tasks.length; + } + + get isClosed() { + return this.#closed; + } + + async close() { + if (this.#closed) { + const lastTask = this.#tasks[this.#tasks.length - 1]; + return lastTask?.deferred.promise; + } + const drainedQueuePromise = this.queueTask(() => {}); + this.#closed = true; + return drainedQueuePromise; + } + + queueTask(callback) { + if (this.#closed) { + throw new Error("Unexpected queueTask call on closed queue"); + } + const deferred = Promise.withResolvers(); + this.#tasks.push({ callback, deferred }); + // Run the queued task right away if there isn't one already running. + if (!this.#runningTask) { + this.#runNextTask(); + } + return deferred.promise; + } + + async #runNextTask() { + if (!this.#tasks.length) { + this.#runningTask = null; + return; + } + + this.#runningTask = this.#tasks.shift(); + const { callback, deferred } = this.#runningTask; + try { + let result = callback(); + if (result instanceof Promise) { + result = await result; + } + deferred.resolve(result); + } catch (err) { + deferred.reject(err); + } + + this.#runNextTask(); + } +} + +/** + * Class managing the rulesets persisted across browser sessions. + * + * The data gets stored in two per-extension files: + * + * - `ProfD/extension-dnr/EXT_UUID.json.lz4` is a lz4-compressed JSON file that is expected to include + * the ruleset ids for the enabled static rulesets and the dynamic rules. + * + * All browser data stored is expected to be persisted across browser updates, but the enabled static ruleset + * ids are expected to be reset and reinitialized from the extension manifest.json properties when the + * add-on is being updated (either downgraded or upgraded). + * + * In case of unexpected data schema downgrades (which may be hit if the user explicit pass --allow-downgrade + * while using an older browser version than the one used when the data has been stored), the entire stored + * data is reset and re-initialized from scratch based on the manifest.json file. + */ +class RulesetsStore { + constructor() { + // Map<extensionUUID, StoreData> + this._data = new Map(); + // Map<extensionUUID, Promise<StoreData>> + this._dataPromises = new Map(); + // Map<extensionUUID, Promise<void>> + this._savePromises = new Map(); + // Map<extensionUUID, Queue> + this._dataUpdateQueues = new DefaultMap(() => new Queue()); + // Promise to await on to ensure the store parent directory exist + // (the parent directory is shared by all extensions and so we only need one). + this._ensureStoreDirectoryPromise = null; + // Promise to await on to ensure (there is only one startupCache file for all + // extensions and so we only need one): + // - the cache file parent directory exist + // - the cache file data has been loaded (if any was available and matching + // the last DNR data stored on disk) + // - the cache file data has been saved. + this._ensureCacheDirectoryPromise = null; + this._ensureCacheLoaded = null; + this._saveCacheTask = null; + // Map of the raw data read from the startupCache. + // Map<extensionUUID, Object> + this._startupCacheData = new Map(); + } + + /** + * Wait for the startup cache data to be stored on disk. + * + * NOTE: Only meant to be used in xpcshell tests. + * + * @returns {Promise<void>} + */ + async waitSaveCacheDataForTesting() { + requireTestOnlyCallers(); + if (this._saveCacheTask) { + if (this._saveCacheTask.isRunning) { + await this._saveCacheTask._runningPromise; + } + // #saveCacheDataNow() may schedule another save if anything has changed in between + while (this._saveCacheTask.isArmed) { + this._saveCacheTask.disarm(); + await this.#saveCacheDataNow(); + } + } + } + + /** + * Remove store file for the given extension UUId from disk (used to remove all + * data on addon uninstall). + * + * @param {string} extensionUUID + * @returns {Promise<void>} + */ + async clearOnUninstall(extensionUUID) { + // TODO(Bug 1825510): call scheduleCacheDataSave to update the startup cache data + // stored on disk, but skip it if it is late in the application shutdown. + StoreData.clearLastUpdateTagPref(extensionUUID); + const storeFile = this.#getStoreFilePath(extensionUUID); + + // TODO(Bug 1803363): consider collect telemetry on DNR store file removal errors. + // TODO: consider catch and report unexpected errors + await IOUtils.remove(storeFile, { ignoreAbsent: true }); + } + + /** + * Load (or initialize) the store file data for the given extension and + * return an Array of the dynamic rules. + * + * @param {Extension} extension + * + * @returns {Promise<Array<Rule>>} + * Resolve to a reference to the dynamic rules array. + * NOTE: the caller should never mutate the content of this array, + * updates to the dynamic rules should always go through + * the `updateDynamicRules` method. + */ + async getDynamicRules(extension) { + let data = await this.#getDataPromise(extension); + return data.dynamicRuleset; + } + + /** + * Load (or initialize) the store file data for the given extension and + * return a Map of the enabled static rulesets and their related rules. + * + * - if the extension manifest doesn't have any static rulesets declared in the + * manifest, returns null + * + * - if the extension version from the stored data doesn't match the current + * extension versions, the static rules are being reloaded from the manifest. + * + * @param {Extension} extension + * + * @returns {Promise<Map<ruleset_id, EnabledStaticRuleset>>} + * Resolves to a reference to the static rulesets map. + * NOTE: the caller should never mutate the content of this map, + * updates to the enabled static rulesets should always go through + * the `updateEnabledStaticRulesets` method. + */ + async getEnabledStaticRulesets(extension) { + let data = await this.#getDataPromise(extension); + return data.staticRulesets; + } + + async getAvailableStaticRuleCount(extension) { + const { GUARANTEED_MINIMUM_STATIC_RULES } = lazy.ExtensionDNRLimits; + + const ruleResources = + extension.manifest.declarative_net_request?.rule_resources; + // TODO: return maximum rules count when no static rules is listed in the manifest? + if (!Array.isArray(ruleResources)) { + return GUARANTEED_MINIMUM_STATIC_RULES; + } + + const enabledRulesets = await this.getEnabledStaticRulesets(extension); + const enabledRulesCount = Array.from(enabledRulesets.values()).reduce( + (acc, ruleset) => acc + ruleset.rules.length, + 0 + ); + + return GUARANTEED_MINIMUM_STATIC_RULES - enabledRulesCount; + } + + /** + * Initialize the DNR store for the given extension, it does also queue the task to make + * sure that extension DNR API calls triggered while the initialization may still be + * in progress will be executed sequentially. + * + * @param {Extension} extension + * + * @returns {Promise<void>} A promise resolved when the async initialization has been + * completed. + */ + async initExtension(extension) { + const ensureExtensionRunning = () => { + if (extension.hasShutdown) { + throw new Error( + `DNR store initialization abort, extension is already shutting down: ${extension.id}` + ); + } + }; + + // Make sure we wait for pending save promise to have been + // completed and old data unloaded (this may be hit if an + // extension updates or reloads while there are still + // rules updates being processed and then stored on disk). + ensureExtensionRunning(); + if (this._savePromises.has(extension.uuid)) { + Cu.reportError( + `Unexpected pending save task while reading DNR data after an install/update of extension "${extension.id}"` + ); + // await pending saving data to be saved and unloaded. + await this.#unloadData(extension.uuid); + // Make sure the extension is still running after awaiting on + // unloadData to be completed. + ensureExtensionRunning(); + } + + return this._dataUpdateQueues.get(extension.uuid).queueTask(() => { + return this.#initExtension(extension); + }); + } + + /** + * Update the dynamic rules, queue changes to prevent races between calls + * that may be triggered while an update is still in process. + * + * @param {Extension} extension + * @param {object} params + * @param {Array<string>} [params.removeRuleIds=[]] + * @param {Array<Rule>} [params.addRules=[]] + * + * @returns {Promise<void>} A promise resolved when the dynamic rules async update has + * been completed. + */ + async updateDynamicRules(extension, { removeRuleIds, addRules }) { + return this._dataUpdateQueues.get(extension.uuid).queueTask(() => { + return this.#updateDynamicRules(extension, { + removeRuleIds, + addRules, + }); + }); + } + + /** + * Update the enabled rulesets, queue changes to prevent races between calls + * that may be triggered while an update is still in process. + * + * @param {Extension} extension + * @param {object} params + * @param {Array<string>} [params.disableRulesetIds=[]] + * @param {Array<string>} [params.enableRulesetIds=[]] + * + * @returns {Promise<void>} A promise resolved when the enabled static rulesets async + * update has been completed. + */ + async updateEnabledStaticRulesets( + extension, + { disableRulesetIds, enableRulesetIds } + ) { + return this._dataUpdateQueues.get(extension.uuid).queueTask(() => { + return this.#updateEnabledStaticRulesets(extension, { + disableRulesetIds, + enableRulesetIds, + }); + }); + } + + /** + * Update DNR RulesetManager rules to match the current DNR rules enabled in the DNRStore. + * + * @param {Extension} extension + * @param {object} [params] + * @param {boolean} [params.updateStaticRulesets=true] + * @param {boolean} [params.updateDynamicRuleset=true] + */ + updateRulesetManager( + extension, + { updateStaticRulesets = true, updateDynamicRuleset = true } = {} + ) { + if (!updateStaticRulesets && !updateDynamicRuleset) { + return; + } + + if ( + !this._dataPromises.has(extension.uuid) || + !this._data.has(extension.uuid) + ) { + throw new Error( + `Unexpected call to updateRulesetManager before DNR store was fully initialized for extension "${extension.id}"` + ); + } + const data = this._data.get(extension.uuid); + const ruleManager = lazy.ExtensionDNR.getRuleManager(extension); + + if (updateStaticRulesets) { + let staticRulesetsMap = data.staticRulesets; + // Convert into array and ensure order match the order of the rulesets in + // the extension manifest. + const enabledStaticRules = []; + // Order the static rulesets by index of rule_resources in manifest.json. + const orderedRulesets = Array.from(staticRulesetsMap.entries()).sort( + ([_idA, rsA], [_idB, rsB]) => rsA.idx - rsB.idx + ); + for (const [rulesetId, ruleset] of orderedRulesets) { + enabledStaticRules.push({ id: rulesetId, rules: ruleset.rules }); + } + ruleManager.setEnabledStaticRulesets(enabledStaticRules); + } + + if (updateDynamicRuleset) { + ruleManager.setDynamicRules(data.dynamicRuleset); + } + } + + /** + * Return the store file path for the given the extension's uuid and the cache + * file with startupCache data for all the extensions. + * + * @param {string} extensionUUID + * @returns {{ storeFile: string | void, cacheFile: string}} + * An object including the full paths to both the per-extension store file + * for the given extension UUID and the full path to the single startupCache + * file (which would include the cached data for all the extensions). + */ + getFilePaths(extensionUUID) { + return { + storeFile: this.#getStoreFilePath(extensionUUID), + cacheFile: this.#getCacheFilePath(), + }; + } + + /** + * Save the data for the given extension on disk. + * + * @param {Extension} extension + */ + async save(extension) { + const { uuid, id } = extension; + let savePromise = this._savePromises.get(uuid); + + if (!savePromise) { + savePromise = this.#saveNow(uuid, id); + this._savePromises.set(uuid, savePromise); + IOUtils.profileBeforeChange.addBlocker( + `Flush WebExtension DNR RulesetsStore: ${id}`, + savePromise + ); + } + + return savePromise; + } + + /** + * Register an onClose shutdown handler to cleanup the data from memory when + * the extension is shutting down. + * + * @param {Extension} extension + * @returns {void} + */ + unloadOnShutdown(extension) { + if (extension.hasShutdown) { + throw new Error( + `DNR store registering an extension shutdown handler too late, the extension is already shutting down: ${extension.id}` + ); + } + + const extensionUUID = extension.uuid; + extension.callOnClose({ + close: async () => this.#unloadData(extensionUUID), + }); + } + + /** + * Return a branch new StoreData instance given an extension. + * + * @param {Extension} extension + * @returns {StoreData} + */ + #getDefaults(extension) { + return new StoreData(extension, { extVersion: extension.version }); + } + + /** + * Return the cache file path. + * + * @returns {string} + * The absolute path to the startupCache file. + */ + #getCacheFilePath() { + // When the application version changes, this file is removed by + // RemoveComponentRegistries in nsAppRunner.cpp. + return PathUtils.join( + Services.dirsvc.get("ProfLD", Ci.nsIFile).path, + "startupCache", + RULES_CACHE_FILENAME + ); + } + + /** + * Return the path to the store file given the extension's uuid. + * + * @param {string} extensionUUID + * @returns {string} Full path to the store file for the extension. + */ + #getStoreFilePath(extensionUUID) { + return PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + RULES_STORE_DIRNAME, + `${extensionUUID}${RULES_STORE_FILEEXT}` + ); + } + + #ensureCacheDirectory() { + if (this._ensureCacheDirectoryPromise === null) { + const file = this.#getCacheFilePath(); + this._ensureCacheDirectoryPromise = IOUtils.makeDirectory( + PathUtils.parent(file), + { + ignoreExisting: true, + createAncestors: true, + } + ); + } + return this._ensureCacheDirectoryPromise; + } + + #ensureStoreDirectory(extensionUUID) { + // Currently all extensions share the same directory, so we can re-use this promise across all + // `#ensureStoreDirectory` calls. + if (this._ensureStoreDirectoryPromise === null) { + const file = this.#getStoreFilePath(extensionUUID); + this._ensureStoreDirectoryPromise = IOUtils.makeDirectory( + PathUtils.parent(file), + { + ignoreExisting: true, + createAncestors: true, + } + ); + } + return this._ensureStoreDirectoryPromise; + } + + #getDataPromise(extension) { + let dataPromise = this._dataPromises.get(extension.uuid); + if (!dataPromise) { + if (extension.hasShutdown) { + throw new Error( + `DNR store data loading aborted, the extension is already shutting down: ${extension.id}` + ); + } + + this.unloadOnShutdown(extension); + dataPromise = this.#readData(extension); + this._dataPromises.set(extension.uuid, dataPromise); + } + return dataPromise; + } + + /** + * Reads the store file for the given extensions and all rules + * for the enabled static ruleset ids listed in the store file. + * + * @typedef {string} ruleset_id + * + * @param {Extension} extension + * @param {object} [options] + * @param {Array<string>} [options.enabledRulesetIds] + * An optional array of enabled ruleset ids to be loaded + * (used to load a specific group of static rulesets, + * either when the list of static rules needs to be recreated based + * on the enabled rulesets, or when the extension is + * changing the enabled rulesets using the `updateEnabledRulesets` + * API method). + * @param {boolean} [options.isUpdateEnabledRulesets] + * Whether this is a call by updateEnabledRulesets. When true, + * `enabledRulesetIds` contains the IDs of disabled rulesets that + * should be enabled. Already-enabled rulesets are not included in + * `enabledRulesetIds`. + * @param {import("ExtensionDNR.sys.mjs").RuleQuotaCounter} [options.ruleQuotaCounter] + * The counter of already-enabled rules that are not part of + * `enabledRulesetIds`. Set when `isUpdateEnabledRulesets` is true. + * This method may mutate its internal counters. + * @returns {Promise<Map<ruleset_id, EnabledStaticRuleset>>} + * map of the enabled static rulesets by ruleset_id. + */ + async #getManifestStaticRulesets( + extension, + { + enabledRulesetIds = null, + isUpdateEnabledRulesets = false, + ruleQuotaCounter, + } = {} + ) { + // Map<ruleset_id, EnabledStaticRuleset>} + const rulesets = new Map(); + + const ruleResources = + extension.manifest.declarative_net_request?.rule_resources; + if (!Array.isArray(ruleResources)) { + return rulesets; + } + + if (!isUpdateEnabledRulesets) { + ruleQuotaCounter = new lazy.ExtensionDNR.RuleQuotaCounter( + /* isStaticRulesets */ true + ); + } + + const { + MAX_NUMBER_OF_ENABLED_STATIC_RULESETS, + // Warnings on MAX_NUMBER_OF_STATIC_RULESETS are already + // reported (see ExtensionDNR.validateManifestEntry, called + // from the DNR API onManifestEntry callback). + } = lazy.ExtensionDNRLimits; + + for (let [idx, { id, enabled, path }] of ruleResources.entries()) { + // If passed enabledRulesetIds is used to determine if the enabled + // rules in the manifest should be overridden from the list of + // enabled static rulesets stored on disk. + if (Array.isArray(enabledRulesetIds)) { + enabled = enabledRulesetIds.includes(id); + } + + // Duplicated ruleset ids are validated as part of the JSONSchema validation, + // here we log a warning to signal that we are ignoring it if when the validation + // error isn't strict (e.g. for non temporarily installed, which shouldn't normally + // hit in the long run because we can also validate it before signing the extension). + if (rulesets.has(id)) { + Cu.reportError( + `Disabled static ruleset with duplicated ruleset_id "${id}"` + ); + continue; + } + + if (enabled && rulesets.size >= MAX_NUMBER_OF_ENABLED_STATIC_RULESETS) { + // This is technically reported from the manifest validation, as a warning + // on extension installed non temporarily, and so checked and logged here + // in case we are hitting it while loading the enabled rulesets. + Cu.reportError( + `Ignoring enabled static ruleset exceeding the MAX_NUMBER_OF_ENABLED_STATIC_RULESETS limit (${MAX_NUMBER_OF_ENABLED_STATIC_RULESETS}): ruleset_id "${id}" (extension: "${extension.id}")` + ); + continue; + } + + const readJSONStartTime = Cu.now(); + const rawRules = + enabled && + (await fetch(path) + .then(res => res.json()) + .catch(err => { + Cu.reportError(err); + enabled = false; + extension.packagingError( + `Reading declarative_net_request static rules file ${path}: ${err.message}` + ); + })); + ChromeUtils.addProfilerMarker( + "ExtensionDNRStore", + { startTime: readJSONStartTime }, + `StaticRulesetsReadJSON, addonId: ${extension.id}` + ); + + // Skip rulesets that are not enabled or can't be enabled (e.g. if we got error on loading or + // parsing the rules JSON file). + if (!enabled) { + continue; + } + + if (!Array.isArray(rawRules)) { + extension.packagingError( + `Reading declarative_net_request static rules file ${path}: rules file must contain an Array of rules` + ); + continue; + } + + // TODO(Bug 1803369): consider to only report the errors and warnings about invalid static rules for + // temporarily installed extensions (chrome only shows them for unpacked extensions). + const logRuleValidationError = err => extension.packagingWarning(err); + + const validatedRules = this.#getValidatedRules(extension, id, rawRules, { + logRuleValidationError, + }); + + // NOTE: this is currently only accounting for valid rules because + // only the valid rules will be actually be loaded. Reconsider if + // we should instead also account for the rules that have been + // ignored as invalid. + try { + ruleQuotaCounter.tryAddRules(id, validatedRules); + } catch (e) { + // If this is an API call (updateEnabledRulesets), just propagate the + // error. Otherwise we are intializing the extension and should just + // ignore the ruleset while reporting the error. + if (isUpdateEnabledRulesets) { + throw e; + } + // TODO(Bug 1803363): consider collect telemetry. + Cu.reportError( + `Ignoring static ruleset "${id}" in extension "${extension.id}" because: ${e.message}` + ); + continue; + } + + rulesets.set(id, { idx, rules: validatedRules }); + } + + return rulesets; + } + + /** + * Returns an array of validated and normalized Rule instances given an array + * of raw rules data (e.g. in form of plain objects read from the static rules + * JSON files or the dynamicRuleset property from the extension DNR store data). + * + * @typedef {import("ExtensionDNR.sys.mjs").Rule} Rule + * + * @param {Extension} extension + * @param {string} rulesetId + * @param {Array<object>} rawRules + * @param {object} options + * @param {Function} [options.logRuleValidationError] + * an optional callback to call for logging the + * validation errors, defaults to use Cu.reportError + * (but getManifestStaticRulesets overrides it to use + * extensions.packagingWarning instead). + * + * @returns {Array<Rule>} + */ + #getValidatedRules( + extension, + rulesetId, + rawRules, + { logRuleValidationError = err => Cu.reportError(err) } = {} + ) { + const startTime = Cu.now(); + const validatedRulesTimerId = + Glean.extensionsApisDnr.validateRulesTime.start(); + try { + const ruleValidator = new lazy.ExtensionDNR.RuleValidator([]); + // Normalize rules read from JSON. + const validationContext = { + url: extension.baseURI.spec, + principal: extension.principal, + logError: logRuleValidationError, + preprocessors: {}, + manifestVersion: extension.manifestVersion, + }; + + // TODO(Bug 1803369): consider to also include the rule id if one was available. + const getInvalidRuleMessage = (ruleIndex, msg) => + `Invalid rule at index ${ruleIndex} from ruleset "${rulesetId}", ${msg}`; + + for (const [rawIndex, rawRule] of rawRules.entries()) { + try { + const normalizedRule = lazy.Schemas.normalize( + rawRule, + "declarativeNetRequest.Rule", + validationContext + ); + if (normalizedRule.value) { + ruleValidator.addRules([normalizedRule.value]); + } else { + logRuleValidationError( + getInvalidRuleMessage( + rawIndex, + normalizedRule.error ?? "Unexpected undefined rule" + ) + ); + } + } catch (err) { + Cu.reportError(err); + logRuleValidationError( + getInvalidRuleMessage(rawIndex, "An unexpected error occurred") + ); + } + } + + // TODO(Bug 1803369): consider including an index in the invalid rules warnings. + if (ruleValidator.getFailures().length) { + logRuleValidationError( + `Invalid rules found in ruleset "${rulesetId}": ${ruleValidator + .getFailures() + .map(f => f.message) + .join(", ")}` + ); + } + + return ruleValidator.getValidatedRules(); + } finally { + ChromeUtils.addProfilerMarker( + "ExtensionDNRStore", + { startTime }, + `#getValidatedRules, addonId: ${extension.id}` + ); + Glean.extensionsApisDnr.validateRulesTime.stopAndAccumulate( + validatedRulesTimerId + ); + } + } + + #hasInstallOrUpdateStartupReason(extension) { + switch (extension.startupReason) { + case "ADDON_INSTALL": + case "ADDON_UPGRADE": + case "ADDON_DOWNGRADE": + return true; + } + + return false; + } + + /** + * Load and add the DNR stored rules to the RuleManager instance for the given + * extension. + * + * @param {Extension} extension + * @returns {Promise<void>} + */ + async #initExtension(extension) { + // - on new installs the stored rules should be recreated from scratch + // (and any stale previously stored data to be ignored) + // - on upgrades/downgrades: + // - the dynamic rules are expected to be preserved + // - the static rules are expected to be refreshed from the new + // manifest data (also the enabled rulesets are expected to be + // reset to the state described in the manifest) + // + // TODO(Bug 1803369): consider also setting to true if the extension is installed temporarily. + if (this.#hasInstallOrUpdateStartupReason(extension)) { + // Reset the stored static rules on addon updates. + await StartupCache.delete(extension, ["dnr", "hasEnabledStaticRules"]); + } + + const hasEnabledStaticRules = await StartupCache.get( + extension, + ["dnr", "hasEnabledStaticRules"], + async () => { + const staticRulesets = await this.getEnabledStaticRulesets(extension); + + return staticRulesets.size; + } + ); + const hasDynamicRules = await StartupCache.get( + extension, + ["dnr", "hasDynamicRules"], + async () => { + const dynamicRuleset = await this.getDynamicRules(extension); + + return dynamicRuleset.length; + } + ); + + if (hasEnabledStaticRules || hasDynamicRules) { + const data = await this.#getDataPromise(extension); + if (!data.isFromStartupCache() && !data.isFromTemporarilyInstalled()) { + this.scheduleCacheDataSave(); + } + if (extension.hasShutdown) { + return; + } + this.updateRulesetManager(extension, { + updateStaticRulesets: hasEnabledStaticRules, + updateDynamicRuleset: hasDynamicRules, + }); + } + } + + #promiseStartupCacheLoaded() { + if (!this._ensureCacheLoaded) { + if (this._data.size) { + return Promise.reject( + new Error( + "Unexpected non-empty DNRStore data. DNR startupCache data load aborted." + ) + ); + } + + const startTime = Cu.now(); + const timerId = Glean.extensionsApisDnr.startupCacheReadTime.start(); + this._ensureCacheLoaded = (async () => { + const cacheFilePath = this.#getCacheFilePath(); + const { buffer, byteLength } = await IOUtils.read(cacheFilePath); + Glean.extensionsApisDnr.startupCacheReadSize.accumulate(byteLength); + const decodedData = lazy.aomStartup.decodeBlob(buffer); + const emptyOrCorruptedCache = !(decodedData?.cacheData instanceof Map); + if (emptyOrCorruptedCache) { + Cu.reportError( + `Unexpected corrupted DNRStore startupCache data. DNR startupCache data load dropped.` + ); + // Remove the cache file right away on corrupted (unexpected empty) + // or obsolete cache content. + await IOUtils.remove(cacheFilePath, { ignoreAbsent: true }); + return; + } + if (this._data.size) { + Cu.reportError( + `Unexpected non-empty DNRStore data. DNR startupCache data load dropped.` + ); + return; + } + for (const [ + extUUID, + cacheStoreData, + ] of decodedData.cacheData.entries()) { + if (StoreData.isStaleCacheEntry(extUUID, cacheStoreData)) { + StoreData.clearLastUpdateTagPref(extUUID); + continue; + } + // TODO(Bug 1825510): schedule a task long enough after startup to detect and + // remove unused entries in the _startupCacheData Map sooner. + this._startupCacheData.set(extUUID, { + extUUID: extUUID, + ...cacheStoreData, + }); + } + })() + .catch(err => { + // TODO: collect telemetry on unexpected cache load failures. + if (!DOMException.isInstance(err) || err.name !== "NotFoundError") { + Cu.reportError(err); + } + }) + .finally(() => { + ChromeUtils.addProfilerMarker( + "ExtensionDNRStore", + { startTime }, + "_ensureCacheLoaded" + ); + Glean.extensionsApisDnr.startupCacheReadTime.stopAndAccumulate( + timerId + ); + }); + } + + return this._ensureCacheLoaded; + } + + /** + * Read the stored data for the given extension, either from: + * - store file (if available and not detected as a data schema downgrade) + * - manifest file and packaged ruleset JSON files (if there was no valid stored data found) + * + * This private method is only called from #getDataPromise, which caches the return value + * in memory. + * + * @param {Extension} extension + * + * @returns {Promise<StoreData>} + */ + async #readData(extension) { + const startTime = Cu.now(); + try { + let result; + // Try to load data from the startupCache. + if (extension.startupReason === "APP_STARTUP") { + result = await this.#readStoreDataFromStartupCache(extension); + } + // Fallback to load the data stored in the json file. + result ??= await this.#readStoreData(extension); + + // Reset the stored data if a data schema version downgrade has been + // detected (this should only be hit on downgrades if the user have + // also explicitly passed --allow-downgrade CLI option). + if (result && result.schemaVersion > StoreData.VERSION) { + Cu.reportError( + `Unsupport DNR store schema version downgrade: resetting stored data for ${extension.id}` + ); + result = null; + } + + // Use defaults and extension manifest if no data stored was found + // (or it got reset due to an unsupported profile downgrade being detected). + if (!result) { + // We don't have any data stored, load the static rules from the manifest. + result = this.#getDefaults(extension); + // Initialize the staticRules data from the manifest. + result.updateRulesets({ + staticRulesets: await this.#getManifestStaticRulesets(extension), + }); + } + + // TODO: handle DNR store schema changes here when the StoreData.VERSION is being bumped. + // if (result && result.version < StoreData.VERSION) { + // result = this.upgradeStoreDataSchema(result); + // } + + // The extension has already shutting down and we may already got past + // the unloadData cleanup (given that there is still a promise in + // the _dataPromises Map). + if (extension.hasShutdown && !this._dataPromises.has(extension.uuid)) { + throw new Error( + `DNR store data loading aborted, the extension is already shutting down: ${extension.id}` + ); + } + + this._data.set(extension.uuid, result); + + return result; + } finally { + ChromeUtils.addProfilerMarker( + "ExtensionDNRStore", + { startTime }, + `readData, addonId: ${extension.id}` + ); + } + } + + // Convert extension entries in the startCache map back to StoreData instances + // (because the StoreData instances get converted into plain objects when + // serialized into the startupCache structured clone blobs). + async #readStoreDataFromStartupCache(extension) { + await this.#promiseStartupCacheLoaded(); + + if (!this._startupCacheData.has(extension.uuid)) { + Glean.extensionsApisDnr.startupCacheEntries.miss.add(1); + return; + } + + const extCacheData = this._startupCacheData.get(extension.uuid); + this._startupCacheData.delete(extension.uuid); + + if (extCacheData.extVersion != extension.version) { + StoreData.clearLastUpdateTagPref(extension.uuid); + Glean.extensionsApisDnr.startupCacheEntries.miss.add(1); + return; + } + + Glean.extensionsApisDnr.startupCacheEntries.hit.add(1); + for (const ruleset of extCacheData.staticRulesets.values()) { + ruleset.rules = ruleset.rules.map(rule => + lazy.ExtensionDNR.RuleValidator.deserializeRule(rule) + ); + } + extCacheData.dynamicRuleset = extCacheData.dynamicRuleset.map(rule => + lazy.ExtensionDNR.RuleValidator.deserializeRule(rule) + ); + return new StoreData(extension, extCacheData); + } + + /** + * Reads the store file for the given extensions and all rules + * for the enabled static ruleset ids listed in the store file. + * + * @param {Extension} extension + * + * @returns {Promise<StoreData|void>} + */ + async #readStoreData(extension) { + // TODO(Bug 1803363): record into Glean telemetry DNR RulesetsStore store load time. + let file = this.#getStoreFilePath(extension.uuid); + let data; + let isCorrupted = false; + let storeFileFound = false; + try { + data = await IOUtils.readJSON(file, { decompress: true }); + storeFileFound = true; + } catch (e) { + if (!(DOMException.isInstance(e) && e.name === "NotFoundError")) { + Cu.reportError(e); + isCorrupted = true; + storeFileFound = true; + } + // TODO(Bug 1803363) record store read errors in telemetry scalar. + } + + // Reset data read from disk if its type isn't the expected one. + isCorrupted ||= + !data || + !Array.isArray(data.staticRulesets) || + // DNR data stored in 109 would not have any dynamicRuleset + // property and so don't consider the data corrupted if + // there isn't any dynamicRuleset property at all. + ("dynamicRuleset" in data && !Array.isArray(data.dynamicRuleset)); + + if (isCorrupted && storeFileFound) { + // Wipe the corrupted data and backup the corrupted file. + data = null; + try { + let uniquePath = await IOUtils.createUniqueFile( + PathUtils.parent(file), + PathUtils.filename(file) + ".corrupt", + 0o600 + ); + Cu.reportError( + `Detected corrupted DNR store data for ${extension.id}, renaming store data file to ${uniquePath}` + ); + await IOUtils.move(file, uniquePath); + } catch (err) { + Cu.reportError(err); + } + } + + if (!data) { + return null; + } + + const resetStaticRulesets = + // Reset the static rulesets on install or updating the extension. + // + // NOTE: this method is called only once and its return value cached in + // memory for the entire lifetime of the extension and so we don't need + // to store any flag to avoid resetting the static rulesets more than + // once for the same Extension instance. + this.#hasInstallOrUpdateStartupReason(extension) || + // Ignore the stored enabled ruleset ids if the current extension version + // mismatches the version the store data was generated from. + data.extVersion !== extension.version; + + if (resetStaticRulesets) { + data.staticRulesets = undefined; + data.extVersion = extension.version; + } + + // If the data is being loaded for a new addon install, make sure to clear + // any potential stale dynamic rules stored on disk. + // + // NOTE: this is expected to only be hit if there was a failure to cleanup + // state data upon uninstall (e.g. in case the machine shutdowns or + // Firefox crashes before we got to update the data stored on disk). + if (extension.startupReason === "ADDON_INSTALL") { + data.dynamicRuleset = []; + } + + // In the JSON stored data we only store the enabled rulestore_id and + // the actual rules have to be loaded. + data.staticRulesets = await this.#getManifestStaticRulesets( + extension, + // Only load the rules from rulesets that are enabled in the stored DNR data, + // if the array (eventually empty) of the enabled static rules isn't in the + // stored data, then load all the ones enabled in the manifest. + { enabledRulesetIds: data.staticRulesets } + ); + + if (data.dynamicRuleset?.length) { + // Make sure all dynamic rules loaded from disk as validated and normalized + // (in case they may have been tempered, but also for when we are loading + // data stored by a different Firefox version from the one that stored the + // data on disk, e.g. in case validation or normalization logic may have been + // different in the two Firefox version). + const validatedDynamicRules = this.#getValidatedRules( + extension, + "_dynamic" /* rulesetId */, + data.dynamicRuleset + ); + + let ruleQuotaCounter = new lazy.ExtensionDNR.RuleQuotaCounter(); + try { + ruleQuotaCounter.tryAddRules("_dynamic", validatedDynamicRules); + data.dynamicRuleset = validatedDynamicRules; + } catch (e) { + // This should not happen in practice, because updateDynamicRules + // rejects quota errors. If we get here, the data on disk may have been + // tampered with, or the limit was lowered in a browser update. + Cu.reportError( + `Ignoring dynamic ruleset in extension "${extension.id}" because: ${e.message}` + ); + data.dynamicRuleset = []; + } + } + // We use StoreData.fromJSON here to prevent properties that are not expected to + // be stored in the JSON file from overriding other StoreData constructor properties + // that are not included in the JSON data returned by StoreData toJSON. + return StoreData.fromJSON(data, extension); + } + + async scheduleCacheDataSave() { + this.#ensureCacheDirectory(); + if (!this._saveCacheTask) { + this._saveCacheTask = new lazy.DeferredTask( + () => this.#saveCacheDataNow(), + 5000 + ); + IOUtils.profileBeforeChange.addBlocker( + "Flush WebExtensions DNR RulesetsStore startupCache", + async () => { + await this._saveCacheTask.finalize(); + this._saveCacheTask = null; + } + ); + } + + return this._saveCacheTask.arm(); + } + + getStartupCacheData() { + const filteredData = new Map(); + const seenLastUpdateTags = new Set(); + for (const [extUUID, dataEntry] of this._data) { + // Only store in the startup cache extensions that are permanently + // installed (the temporarilyInstalled extension are removed + // automatically either on shutdown or startup, and so the data + // stored and then loaded back from the startup cache file + // would never be used). + if (dataEntry.isFromTemporarilyInstalled()) { + continue; + } + filteredData.set(extUUID, dataEntry); + seenLastUpdateTags.add(dataEntry.lastUpdateTag); + } + return { + seenLastUpdateTags, + filteredData, + }; + } + + detectStartupCacheDataChanged(seenLastUpdateTags) { + // Detect if there are changes to the stored data applied while we + // have been writing the cache data on disk, and reschedule a new + // cache data save if that is the case. + // TODO(Bug 1825510): detect also obsoleted entries to make sure + // they are removed from the startup cache data stored on disk + // sooner. + for (const dataEntry of this._data.values()) { + if (dataEntry.isFromTemporarilyInstalled()) { + continue; + } + if (!seenLastUpdateTags.has(dataEntry.lastUpdateTag)) { + return true; + } + } + return false; + } + + async #saveCacheDataNow() { + const startTime = Cu.now(); + const timerId = Glean.extensionsApisDnr.startupCacheWriteTime.start(); + try { + const cacheFilePath = this.#getCacheFilePath(); + const { filteredData, seenLastUpdateTags } = this.getStartupCacheData(); + const data = new Uint8Array( + lazy.aomStartup.encodeBlob({ + cacheData: filteredData, + }) + ); + await this._ensureCacheDirectoryPromise; + await IOUtils.write(cacheFilePath, data, { + tmpPath: `${cacheFilePath}.tmp`, + }); + Glean.extensionsApisDnr.startupCacheWriteSize.accumulate(data.byteLength); + + if (this.detectStartupCacheDataChanged(seenLastUpdateTags)) { + this.scheduleCacheDataSave(); + } + } finally { + ChromeUtils.addProfilerMarker( + "ExtensionDNRStore", + { startTime }, + "#saveCacheDataNow" + ); + Glean.extensionsApisDnr.startupCacheWriteTime.stopAndAccumulate(timerId); + } + } + + /** + * Save the data for the given extension on disk. + * + * @param {string} extensionUUID + * @param {string} extensionId + * @returns {Promise<void>} + */ + async #saveNow(extensionUUID, extensionId) { + const startTime = Cu.now(); + try { + if ( + !this._dataPromises.has(extensionUUID) || + !this._data.has(extensionUUID) + ) { + throw new Error( + `Unexpected uninitialized DNR store on saving data for extension uuid "${extensionUUID}"` + ); + } + const storeFile = this.#getStoreFilePath(extensionUUID); + const data = this._data.get(extensionUUID); + await this.#ensureStoreDirectory(extensionUUID); + await IOUtils.writeJSON(storeFile, data, { + tmpPath: `${storeFile}.tmp`, + compress: true, + }); + + this.scheduleCacheDataSave(); + + // TODO(Bug 1803363): report jsonData lengths into a telemetry scalar. + // TODO(Bug 1803363): report jsonData time to write into a telemetry scalar. + } catch (err) { + Cu.reportError(err); + throw err; + } finally { + this._savePromises.delete(extensionUUID); + ChromeUtils.addProfilerMarker( + "ExtensionDNRStore", + { startTime }, + `#saveNow, addonId: ${extensionId}` + ); + } + } + + /** + * Unload data for the given extension UUID from memory (e.g. when the extension is disabled or uninstalled), + * waits for a pending save promise to be settled if any. + * + * NOTE: this method clear the data cached in memory and close the update queue + * and so it should only be called from the extension shutdown handler and + * by the initExtension method before pushing into the update queue for the + * for the extension the initExtension task. + * + * @param {string} extensionUUID + * @returns {Promise<void>} + */ + async #unloadData(extensionUUID) { + // Wait for the update tasks to have been executed, then + // wait for the data to have been saved and finally unload + // the data cached in memory. + const dataUpdateQueue = this._dataUpdateQueues.has(extensionUUID) + ? this._dataUpdateQueues.get(extensionUUID) + : undefined; + + if (dataUpdateQueue) { + try { + await dataUpdateQueue.close(); + } catch (err) { + // Unexpected error on closing the update queue. + Cu.reportError(err); + } + this._dataUpdateQueues.delete(extensionUUID); + } + + const savePromise = this._savePromises.get(extensionUUID); + if (savePromise) { + await savePromise; + this._savePromises.delete(extensionUUID); + } + + this._dataPromises.delete(extensionUUID); + this._data.delete(extensionUUID); + } + + /** + * Internal implementation for updating the dynamic ruleset and enforcing + * dynamic rules count limits. + * + * Callers ensure that there is never a concurrent call of #updateDynamicRules + * for a given extension, so we can safely modify ruleManager.dynamicRules + * from inside this method, even asynchronously. + * + * @param {Extension} extension + * @param {object} params + * @param {Array<string>} [params.removeRuleIds=[]] + * @param {Array<Rule>} [params.addRules=[]] + */ + async #updateDynamicRules(extension, { removeRuleIds, addRules }) { + const ruleManager = lazy.ExtensionDNR.getRuleManager(extension); + const ruleValidator = new lazy.ExtensionDNR.RuleValidator( + ruleManager.getDynamicRules() + ); + if (removeRuleIds) { + ruleValidator.removeRuleIds(removeRuleIds); + } + if (addRules) { + ruleValidator.addRules(addRules); + } + let failures = ruleValidator.getFailures(); + if (failures.length) { + throw new ExtensionError(failures[0].message); + } + + const validatedRules = ruleValidator.getValidatedRules(); + let ruleQuotaCounter = new lazy.ExtensionDNR.RuleQuotaCounter(); + ruleQuotaCounter.tryAddRules("_dynamic", validatedRules); + + this._data.get(extension.uuid).updateRulesets({ + dynamicRuleset: validatedRules, + }); + await this.save(extension); + // updateRulesetManager calls ruleManager.setDynamicRules using the + // validated rules assigned above to this._data. + this.updateRulesetManager(extension, { + updateDynamicRuleset: true, + updateStaticRulesets: false, + }); + } + + /** + * Internal implementation for updating the enabled rulesets and enforcing + * static rulesets and rules count limits. + * + * @param {Extension} extension + * @param {object} params + * @param {Array<string>} [params.disableRulesetIds=[]] + * @param {Array<string>} [params.enableRulesetIds=[]] + */ + async #updateEnabledStaticRulesets( + extension, + { disableRulesetIds, enableRulesetIds } + ) { + const ruleResources = + extension.manifest.declarative_net_request?.rule_resources; + if (!Array.isArray(ruleResources)) { + return; + } + + const enabledRulesets = await this.getEnabledStaticRulesets(extension); + const updatedEnabledRulesets = new Map(); + let disableIds = new Set(disableRulesetIds); + let enableIds = new Set(enableRulesetIds); + + // valiate the ruleset ids for existence (which will also reject calls + // including the reserved _session and _dynamic, because static rulesets + // id are validated as part of the manifest validation and they are not + // allowed to start with '_'). + const existingIds = new Set(ruleResources.map(rs => rs.id)); + const errorOnInvalidRulesetIds = rsIdSet => { + for (const rsId of rsIdSet) { + if (!existingIds.has(rsId)) { + throw new ExtensionError(`Invalid ruleset id: "${rsId}"`); + } + } + }; + errorOnInvalidRulesetIds(disableIds); + errorOnInvalidRulesetIds(enableIds); + + // Copy into the updatedEnabledRulesets Map any ruleset that is not + // requested to be disabled or is enabled back in the same request. + for (const [rulesetId, ruleset] of enabledRulesets) { + if (!disableIds.has(rulesetId) || enableIds.has(rulesetId)) { + updatedEnabledRulesets.set(rulesetId, ruleset); + enableIds.delete(rulesetId); + } + } + + const { MAX_NUMBER_OF_ENABLED_STATIC_RULESETS } = lazy.ExtensionDNRLimits; + + const maxNewRulesetsCount = + MAX_NUMBER_OF_ENABLED_STATIC_RULESETS - updatedEnabledRulesets.size; + + if (enableIds.size > maxNewRulesetsCount) { + // Log an error for the developer. + throw new ExtensionError( + `updatedEnabledRulesets request is exceeding MAX_NUMBER_OF_ENABLED_STATIC_RULESETS` + ); + } + + // At this point, every item in |updatedEnabledRulesets| is an enabled + // ruleset with already-valid rules. In order to not exceed the rule quota + // when previously-disabled rulesets are enabled, we need to count what we + // already have. + let ruleQuotaCounter = new lazy.ExtensionDNR.RuleQuotaCounter( + /* isStaticRulesets */ true + ); + for (let [rulesetId, ruleset] of updatedEnabledRulesets) { + ruleQuotaCounter.tryAddRules(rulesetId, ruleset.rules); + } + + const newRulesets = await this.#getManifestStaticRulesets(extension, { + enabledRulesetIds: Array.from(enableIds), + ruleQuotaCounter, + isUpdateEnabledRulesets: true, + }); + + for (const [rulesetId, ruleset] of newRulesets.entries()) { + updatedEnabledRulesets.set(rulesetId, ruleset); + } + + this._data.get(extension.uuid).updateRulesets({ + staticRulesets: updatedEnabledRulesets, + }); + await this.save(extension); + this.updateRulesetManager(extension, { + updateDynamicRuleset: false, + updateStaticRulesets: true, + }); + } +} + +let store = new RulesetsStore(); + +export const ExtensionDNRStore = { + async clearOnUninstall(extensionUUID) { + return store.clearOnUninstall(extensionUUID); + }, + async initExtension(extension) { + await store.initExtension(extension); + }, + async updateDynamicRules(extension, updateRuleOptions) { + await store.updateDynamicRules(extension, updateRuleOptions); + }, + async updateEnabledStaticRulesets(extension, updateRulesetOptions) { + await store.updateEnabledStaticRulesets(extension, updateRulesetOptions); + }, + // Test-only helpers + _getLastUpdateTag(extensionUUID) { + requireTestOnlyCallers(); + return StoreData.getLastUpdateTag(extensionUUID); + }, + _getStoreForTesting() { + requireTestOnlyCallers(); + return store; + }, + _getStoreDataClassForTesting() { + requireTestOnlyCallers(); + return StoreData; + }, + _recreateStoreForTesting() { + requireTestOnlyCallers(); + store = new RulesetsStore(); + return store; + }, + _storeLastUpdateTag(extensionUUID, lastUpdateTag) { + requireTestOnlyCallers(); + return StoreData.storeLastUpdateTag(extensionUUID, lastUpdateTag); + }, +}; diff --git a/toolkit/components/extensions/ExtensionPageChild.sys.mjs b/toolkit/components/extensions/ExtensionPageChild.sys.mjs new file mode 100644 index 0000000000..d84459f1ed --- /dev/null +++ b/toolkit/components/extensions/ExtensionPageChild.sys.mjs @@ -0,0 +1,510 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +/** + * This file handles privileged extension page logic that runs in the + * child process. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionChildDevToolsUtils: + "resource://gre/modules/ExtensionChildDevToolsUtils.sys.mjs", + Schemas: "resource://gre/modules/Schemas.sys.mjs", +}); + +const CATEGORY_EXTENSION_SCRIPTS_ADDON = "webextension-scripts-addon"; +const CATEGORY_EXTENSION_SCRIPTS_DEVTOOLS = "webextension-scripts-devtools"; + +import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs"; +import { + ExtensionChild, + ExtensionActivityLogChild, +} from "resource://gre/modules/ExtensionChild.sys.mjs"; +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { getInnerWindowID, promiseEvent } = ExtensionUtils; + +const { BaseContext, CanOfAPIs, SchemaAPIManager, redefineGetter } = + ExtensionCommon; + +const { ChildAPIManager, Messenger } = ExtensionChild; + +const initializeBackgroundPage = context => { + // Override the `alert()` method inside background windows; + // we alias it to console.log(). + // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1203394 + let alertDisplayedWarning = false; + const innerWindowID = getInnerWindowID(context.contentWindow); + + /** @param {{ text, filename, lineNumber?, columnNumber? }} options */ + function logWarningMessage({ text, filename, lineNumber, columnNumber }) { + let consoleMsg = Cc["@mozilla.org/scripterror;1"].createInstance( + Ci.nsIScriptError + ); + consoleMsg.initWithWindowID( + text, + filename, + null, + lineNumber, + columnNumber, + Ci.nsIScriptError.warningFlag, + "webextension", + innerWindowID + ); + Services.console.logMessage(consoleMsg); + } + + function ignoredSuspendListener() { + logWarningMessage({ + text: "Background event page was not terminated on idle because a DevTools toolbox is attached to the extension.", + filename: context.contentWindow.location.href, + }); + } + + if (!context.extension.manifest.background.persistent) { + context.extension.on( + "background-script-suspend-ignored", + ignoredSuspendListener + ); + context.callOnClose({ + close: () => { + context.extension.off( + "background-script-suspend-ignored", + ignoredSuspendListener + ); + }, + }); + } + + let alertOverwrite = text => { + const { filename, columnNumber, lineNumber } = Components.stack.caller; + + if (!alertDisplayedWarning) { + context.childManager.callParentAsyncFunction( + "runtime.openBrowserConsole", + [] + ); + + logWarningMessage({ + text: "alert() is not supported in background windows; please use console.log instead.", + filename, + lineNumber, + columnNumber, + }); + + alertDisplayedWarning = true; + } + + logWarningMessage({ text, filename, lineNumber, columnNumber }); + }; + Cu.exportFunction(alertOverwrite, context.contentWindow, { + defineAs: "alert", + }); +}; + +var apiManager = new (class extends SchemaAPIManager { + constructor() { + super("addon", lazy.Schemas); + this.initialized = false; + } + + lazyInit() { + if (!this.initialized) { + this.initialized = true; + this.initGlobal(); + for (let { value } of Services.catMan.enumerateCategory( + CATEGORY_EXTENSION_SCRIPTS_ADDON + )) { + this.loadScript(value); + } + } + } +})(); + +var devtoolsAPIManager = new (class extends SchemaAPIManager { + constructor() { + super("devtools", lazy.Schemas); + this.initialized = false; + } + + lazyInit() { + if (!this.initialized) { + this.initialized = true; + this.initGlobal(); + for (let { value } of Services.catMan.enumerateCategory( + CATEGORY_EXTENSION_SCRIPTS_DEVTOOLS + )) { + this.loadScript(value); + } + } + } +})(); + +export function getContextChildManagerGetter( + { envType }, + ChildAPIManagerClass = ChildAPIManager +) { + return function () { + let apiManager = + envType === "devtools_parent" + ? devtoolsAPIManager + : this.extension.apiManager; + + apiManager.lazyInit(); + + let localApis = {}; + let can = new CanOfAPIs(this, apiManager, localApis); + + let childManager = new ChildAPIManagerClass( + this, + this.messageManager, + can, + { + envType, + viewType: this.viewType, + url: this.uri.spec, + incognito: this.incognito, + // Additional data a BaseContext subclass may optionally send + // as part of the CreateProxyContext request sent to the main process + // (e.g. WorkerContexChild implements this method to send the service + // worker descriptor id along with the details send by default here). + ...this.getCreateProxyContextData?.(), + } + ); + + this.callOnClose(childManager); + + return childManager; + }; +} + +export class ExtensionBaseContextChild extends BaseContext { + /** + * This ExtensionBaseContextChild represents an addon execution environment + * that is running in an addon or devtools child process. + * + * @param {BrowserExtensionContent} extension This context's owner. + * @param {object} params + * @param {string} params.envType One of "addon_child" or "devtools_child". + * @param {nsIDOMWindow} params.contentWindow The window where the addon runs. + * @param {string} params.viewType One of "background", "popup", "tab", + * "sidebar", "devtools_page" or "devtools_panel". + * @param {number} [params.tabId] This tab's ID, used if viewType is "tab". + * @param {nsIURI} [params.uri] The URI of the page. + */ + constructor(extension, params) { + if (!params.envType) { + throw new Error("Missing envType"); + } + + super(params.envType, extension); + let { viewType = "tab", uri, contentWindow, tabId } = params; + this.viewType = viewType; + this.uri = uri || extension.baseURI; + + this.setContentWindow(contentWindow); + this.browsingContextId = contentWindow.docShell.browsingContext.id; + + if (viewType == "tab") { + Object.defineProperty(this, "tabId", { + value: tabId, + enumerable: true, + configurable: true, + }); + } + + lazy.Schemas.exportLazyGetter(contentWindow, "browser", () => { + return this.browserObj; + }); + + lazy.Schemas.exportLazyGetter(contentWindow, "chrome", () => { + // For MV3 and later, this is just an alias for browser. + if (extension.manifestVersion > 2) { + return this.browserObj; + } + // Chrome compat is only used with MV2 + let chromeApiWrapper = Object.create(this.childManager); + chromeApiWrapper.isChromeCompat = true; + + let chromeObj = Cu.createObjectIn(contentWindow); + chromeApiWrapper.inject(chromeObj); + return chromeObj; + }); + } + + get browserObj() { + const browserObj = Cu.createObjectIn(this.contentWindow); + this.childManager.inject(browserObj); + return redefineGetter(this, "browserObj", browserObj); + } + + logActivity(type, name, data) { + ExtensionActivityLogChild.log(this, type, name, data); + } + + get cloneScope() { + return this.contentWindow; + } + + get principal() { + return this.contentWindow.document.nodePrincipal; + } + + get tabId() { + // Will be overwritten in the constructor if necessary. + return -1; + } + + // Called when the extension shuts down. + shutdown() { + if (this.contentWindow) { + this.contentWindow.close(); + } + + this.unload(); + } + + // This method is called when an extension page navigates away or + // its tab is closed. + unload() { + // Note that without this guard, we end up running unload code + // multiple times for tab pages closed by the "page-unload" handlers + // triggered below. + if (this.unloaded) { + return; + } + + super.unload(); + } + + get messenger() { + return redefineGetter(this, "messenger", new Messenger(this)); + } + + /** @type {ReturnType<ReturnType<getContextChildManagerGetter>>} */ + get childManager() { + throw new Error("childManager getter must be overridden"); + } +} + +class ExtensionPageContextChild extends ExtensionBaseContextChild { + /** + * This ExtensionPageContextChild represents a privileged addon + * execution environment that has full access to the WebExtensions + * APIs (provided that the correct permissions have been requested). + * + * This is the child side of the ExtensionPageContextParent class + * defined in ExtensionParent.jsm. + * + * @param {BrowserExtensionContent} extension This context's owner. + * @param {object} params + * @param {nsIDOMWindow} params.contentWindow The window where the addon runs. + * @param {string} params.viewType One of "background", "popup", "sidebar" or "tab". + * "background", "sidebar" and "tab" are used by `browser.extension.getViews`. + * "popup" is only used internally to identify page action and browser + * action popups and options_ui pages. + * @param {number} [params.tabId] This tab's ID, used if viewType is "tab". + * @param {nsIURI} [params.uri] The URI of the page. + */ + constructor(extension, params) { + super(extension, Object.assign(params, { envType: "addon_child" })); + + if (this.viewType == "background") { + initializeBackgroundPage(this); + } + + this.extension.views.add(this); + } + + unload() { + super.unload(); + this.extension.views.delete(this); + } + + get childManager() { + const childManager = getContextChildManagerGetter({ + envType: "addon_parent", + }).call(this); + return redefineGetter(this, "childManager", childManager); + } +} + +export class DevToolsContextChild extends ExtensionBaseContextChild { + /** + * This DevToolsContextChild represents a devtools-related addon execution + * environment that has access to the devtools API namespace and to the same subset + * of APIs available in a content script execution environment. + * + * @param {BrowserExtensionContent} extension This context's owner. + * @param {object} params + * @param {nsIDOMWindow} params.contentWindow The window where the addon runs. + * @param {string} params.viewType One of "devtools_page" or "devtools_panel". + * @param {object} [params.devtoolsToolboxInfo] This devtools toolbox's information, + * used if viewType is "devtools_page" or "devtools_panel". + * @param {number} [params.tabId] This tab's ID, used if viewType is "tab". + * @param {nsIURI} [params.uri] The URI of the page. + */ + constructor(extension, params) { + super(extension, Object.assign(params, { envType: "devtools_child" })); + + this.devtoolsToolboxInfo = params.devtoolsToolboxInfo; + lazy.ExtensionChildDevToolsUtils.initThemeChangeObserver( + params.devtoolsToolboxInfo.themeName, + this + ); + + this.extension.devtoolsViews.add(this); + } + + unload() { + super.unload(); + this.extension.devtoolsViews.delete(this); + } + + get childManager() { + const childManager = getContextChildManagerGetter({ + envType: "devtools_parent", + }).call(this); + return redefineGetter(this, "childManager", childManager); + } +} + +export var ExtensionPageChild = { + initialized: false, + + // Map<innerWindowId, ExtensionPageContextChild> + extensionContexts: new Map(), + + apiManager, + + _init() { + if (this.initialized) { + return; + } + this.initialized = true; + + Services.obs.addObserver(this, "inner-window-destroyed"); // eslint-ignore-line mozilla/balanced-listeners + }, + + observe(subject, topic, data) { + if (topic === "inner-window-destroyed") { + let windowId = subject.QueryInterface(Ci.nsISupportsPRUint64).data; + + this.destroyExtensionContext(windowId); + } + }, + + expectViewLoad(global, viewType) { + promiseEvent( + global, + "DOMContentLoaded", + true, + /** @param {{target: Window|any}} event */ + event => + event.target.location != "about:blank" && + // Ignore DOMContentLoaded bubbled from child frames: + event.target.defaultView === global.content + ).then(() => { + let windowId = getInnerWindowID(global.content); + let context = this.extensionContexts.get(windowId); + // This initializes ChildAPIManager (and creation of ProxyContextParent) + // if they don't exist already at this point. + let childId = context?.childManager.id; + if (viewType === "background") { + global.sendAsyncMessage("Extension:BackgroundViewLoaded", { childId }); + } + }); + }, + + /** + * Create a privileged context at initial-document-element-inserted. + * + * @param {BrowserExtensionContent} extension + * The extension for which the context should be created. + * @param {nsIDOMWindow} contentWindow The global of the page. + */ + initExtensionContext(extension, contentWindow) { + this._init(); + + if (!WebExtensionPolicy.isExtensionProcess) { + throw new Error( + "Cannot create an extension page context in current process" + ); + } + + let windowId = getInnerWindowID(contentWindow); + let context = this.extensionContexts.get(windowId); + if (context) { + if (context.extension !== extension) { + throw new Error( + "A different extension context already exists for this frame" + ); + } + throw new Error( + "An extension context was already initialized for this frame" + ); + } + + let uri = contentWindow.document.documentURIObject; + + let mm = contentWindow.docShell.messageManager; + let data = mm.sendSyncMessage("Extension:GetFrameData")[0]; + if (!data) { + let policy = WebExtensionPolicy.getByHostname(uri.host); + // TODO bug 1749116: Handle this unexpected result, because data + // (viewType in particular) should never be void for extension documents. + Cu.reportError(`FrameData missing for ${policy?.id} page ${uri.spec}`); + } + let { viewType, tabId, devtoolsToolboxInfo } = data ?? {}; + + if (viewType && contentWindow.top === contentWindow) { + ExtensionPageChild.expectViewLoad(mm, viewType); + } + + if (devtoolsToolboxInfo) { + context = new DevToolsContextChild(extension, { + viewType, + contentWindow, + uri, + tabId, + devtoolsToolboxInfo, + }); + } else { + context = new ExtensionPageContextChild(extension, { + viewType, + contentWindow, + uri, + tabId, + }); + } + + this.extensionContexts.set(windowId, context); + }, + + /** + * Close the ExtensionPageContextChild belonging to the given window, if any. + * + * @param {number} windowId The inner window ID of the destroyed context. + */ + destroyExtensionContext(windowId) { + let context = this.extensionContexts.get(windowId); + if (context) { + context.unload(); + this.extensionContexts.delete(windowId); + } + }, + + shutdownExtension(extensionId) { + for (let [windowId, context] of this.extensionContexts) { + if (context.extension.id == extensionId) { + context.shutdown(); + this.extensionContexts.delete(windowId); + } + } + }, +}; diff --git a/toolkit/components/extensions/ExtensionParent.sys.mjs b/toolkit/components/extensions/ExtensionParent.sys.mjs new file mode 100644 index 0000000000..22ba021e15 --- /dev/null +++ b/toolkit/components/extensions/ExtensionParent.sys.mjs @@ -0,0 +1,2300 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +/** + * This module contains code for managing APIs that need to run in the + * parent process, and handles the parent side of operations that need + * to be proxied from ExtensionChild.jsm. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", + BroadcastConduit: "resource://gre/modules/ConduitsParent.sys.mjs", + DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", + DevToolsShim: "chrome://devtools-startup/content/DevToolsShim.sys.mjs", + ExtensionActivityLog: "resource://gre/modules/ExtensionActivityLog.sys.mjs", + ExtensionData: "resource://gre/modules/Extension.sys.mjs", + GeckoViewConnection: "resource://gre/modules/GeckoViewWebExtension.sys.mjs", + MessageManagerProxy: "resource://gre/modules/MessageManagerProxy.sys.mjs", + NativeApp: "resource://gre/modules/NativeMessaging.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + Schemas: "resource://gre/modules/Schemas.sys.mjs", + getErrorNameForTelemetry: "resource://gre/modules/ExtensionTelemetry.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + aomStartup: [ + "@mozilla.org/addons/addon-manager-startup;1", + "amIAddonManagerStartup", + ], +}); + +import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs"; +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const DUMMY_PAGE_URI = Services.io.newURI( + "chrome://extensions/content/dummy.xhtml" +); + +var { BaseContext, CanOfAPIs, SchemaAPIManager, SpreadArgs, redefineGetter } = + ExtensionCommon; + +var { + DefaultMap, + DefaultWeakMap, + ExtensionError, + promiseDocumentLoaded, + promiseEvent, + promiseObserved, +} = ExtensionUtils; + +const ERROR_NO_RECEIVERS = + "Could not establish connection. Receiving end does not exist."; + +const BASE_SCHEMA = "chrome://extensions/content/schemas/manifest.json"; +const CATEGORY_EXTENSION_MODULES = "webextension-modules"; +const CATEGORY_EXTENSION_SCHEMAS = "webextension-schemas"; +const CATEGORY_EXTENSION_SCRIPTS = "webextension-scripts"; + +let schemaURLs = new Set(); + +schemaURLs.add("chrome://extensions/content/schemas/experiments.json"); + +let GlobalManager; +let ParentAPIManager; + +function verifyActorForContext(actor, context) { + if (JSWindowActorParent.isInstance(actor)) { + let target = actor.browsingContext.top.embedderElement; + if (context.parentMessageManager !== target.messageManager) { + throw new Error("Got message on unexpected message manager"); + } + } else if (JSProcessActorParent.isInstance(actor)) { + if (actor.manager.remoteType !== context.extension.remoteType) { + throw new Error("Got message from unexpected process"); + } + } +} + +// This object loads the ext-*.js scripts that define the extension API. +let apiManager = new (class extends SchemaAPIManager { + constructor() { + super("main", lazy.Schemas); + this.initialized = null; + + /* eslint-disable mozilla/balanced-listeners */ + this.on("startup", (e, extension) => { + return extension.apiManager.onStartup(extension); + }); + + this.on("update", async (e, { id, resourceURI, isPrivileged }) => { + let modules = this.eventModules.get("update"); + if (modules.size == 0) { + return; + } + + let extension = new lazy.ExtensionData(resourceURI, isPrivileged); + await extension.loadManifest(); + + return Promise.all( + Array.from(modules).map(async apiName => { + let module = await this.asyncLoadModule(apiName); + module.onUpdate(id, extension.manifest); + }) + ); + }); + + this.on("uninstall", (e, { id }) => { + let modules = this.eventModules.get("uninstall"); + return Promise.all( + Array.from(modules).map(async apiName => { + let module = await this.asyncLoadModule(apiName); + return module.onUninstall(id); + }) + ); + }); + /* eslint-enable mozilla/balanced-listeners */ + + // Handle any changes that happened during startup + let disabledIds = lazy.AddonManager.getStartupChanges( + lazy.AddonManager.STARTUP_CHANGE_DISABLED + ); + if (disabledIds.length) { + this._callHandlers(disabledIds, "disable", "onDisable"); + } + + let uninstalledIds = lazy.AddonManager.getStartupChanges( + lazy.AddonManager.STARTUP_CHANGE_UNINSTALLED + ); + if (uninstalledIds.length) { + this._callHandlers(uninstalledIds, "uninstall", "onUninstall"); + } + } + + getModuleJSONURLs() { + return Array.from( + Services.catMan.enumerateCategory(CATEGORY_EXTENSION_MODULES), + ({ value }) => value + ); + } + + // Loads all the ext-*.js scripts currently registered. + lazyInit() { + if (this.initialized) { + return this.initialized; + } + + let modulesPromise = StartupCache.other.get(["parentModules"], () => + this.loadModuleJSON(this.getModuleJSONURLs()) + ); + + let scriptURLs = []; + for (let { value } of Services.catMan.enumerateCategory( + CATEGORY_EXTENSION_SCRIPTS + )) { + scriptURLs.push(value); + } + + let promise = (async () => { + let scripts = await Promise.all( + scriptURLs.map(url => ChromeUtils.compileScript(url)) + ); + + this.initModuleData(await modulesPromise); + + this.initGlobal(); + for (let script of scripts) { + script.executeInGlobal(this.global); + } + + // Load order matters here. The base manifest defines types which are + // extended by other schemas, so needs to be loaded first. + return lazy.Schemas.load(BASE_SCHEMA).then(() => { + let promises = []; + for (let { value } of Services.catMan.enumerateCategory( + CATEGORY_EXTENSION_SCHEMAS + )) { + promises.push(lazy.Schemas.load(value)); + } + for (let [url, { content }] of this.schemaURLs) { + promises.push(lazy.Schemas.load(url, content)); + } + for (let url of schemaURLs) { + promises.push(lazy.Schemas.load(url)); + } + return Promise.all(promises).then(() => { + lazy.Schemas.updateSharedSchemas(); + }); + }); + })(); + + Services.mm.addMessageListener("Extension:GetFrameData", this); + + this.initialized = promise; + return this.initialized; + } + + receiveMessage({ target }) { + let data = GlobalManager.frameData.get(target) || {}; + Object.assign(data, this.global.tabTracker.getBrowserData(target)); + return data; + } + + // Call static handlers for the given event on the given extension ids, + // and set up a shutdown blocker to ensure they all complete. + _callHandlers(ids, event, method) { + let promises = Array.from(this.eventModules.get(event)) + .map(async modName => { + let module = await this.asyncLoadModule(modName); + return ids.map(id => module[method](id)); + }) + .flat(); + if (event === "disable") { + promises.push(...ids.map(id => this.emit("disable", id))); + } + if (event === "enabling") { + promises.push(...ids.map(id => this.emit("enabling", id))); + } + + lazy.AsyncShutdown.profileBeforeChange.addBlocker( + `Extension API ${event} handlers for ${ids.join(",")}`, + Promise.all(promises) + ); + } +})(); + +/** + * @typedef {object} ParentPort + * @property {boolean} [native] + * @property {string} [senderChildId] + * @property {function(StructuredCloneHolder): any} onPortMessage + * @property {Function} onPortDisconnect + */ + +// Receives messages related to the extension messaging API and forwards them +// to relevant child messengers. Also handles Native messaging and GeckoView. +/** @typedef {typeof ProxyMessenger} NativeMessenger */ +const ProxyMessenger = { + /** @type {Map<number, Partial<ParentPort>&Promise<ParentPort>>} */ + ports: new Map(), + + init() { + this.conduit = new lazy.BroadcastConduit(ProxyMessenger, { + id: "ProxyMessenger", + reportOnClosed: "portId", + recv: ["PortConnect", "PortMessage", "NativeMessage", "RuntimeMessage"], + cast: ["PortConnect", "PortMessage", "PortDisconnect", "RuntimeMessage"], + }); + }, + + openNative(nativeApp, sender) { + let context = ParentAPIManager.getContextById(sender.childId); + if (context.extension.hasPermission("geckoViewAddons")) { + return new lazy.GeckoViewConnection( + this.getSender(context.extension, sender), + sender.actor.browsingContext.top.embedderElement, + nativeApp, + context.extension.hasPermission("nativeMessagingFromContent") + ); + } else if (sender.verified) { + return new lazy.NativeApp(context, nativeApp); + } + sender = this.getSender(context.extension, sender); + throw new Error(`Native messaging not allowed: ${JSON.stringify(sender)}`); + }, + + recvNativeMessage({ nativeApp, holder }, { sender }) { + const app = this.openNative(nativeApp, sender); + + // Track in-flight NativeApp sendMessage requests as + // a NativeApp port destroyed when the request + // has been handled. + const promiseSendMessage = app.sendMessage(holder); + const sendMessagePort = { + native: true, + senderChildId: sender.childId, + }; + this.trackNativeAppPort(sendMessagePort); + const untrackSendMessage = () => this.untrackNativeAppPort(sendMessagePort); + promiseSendMessage.then(untrackSendMessage, untrackSendMessage); + + return promiseSendMessage; + }, + + getSender(extension, source) { + let sender = { + contextId: source.id, + id: source.extensionId, + envType: source.envType, + url: source.url, + }; + + if (JSWindowActorParent.isInstance(source.actor)) { + let browser = source.actor.browsingContext.top.embedderElement; + let data = + browser && apiManager.global.tabTracker.getBrowserData(browser); + if (data?.tabId > 0) { + sender.tab = extension.tabManager.get(data.tabId, null)?.convert(); + // frameId is documented to only be set if sender.tab is set. + sender.frameId = source.frameId; + } + } + + return sender; + }, + + getTopBrowsingContextId(tabId) { + // If a tab alredy has content scripts, no need to check private browsing. + let tab = apiManager.global.tabTracker.getTab(tabId, null); + if (!tab || (tab.browser || tab).getAttribute("pending") === "true") { + // No receivers in discarded tabs, so bail early to keep the browser lazy. + throw new ExtensionError(ERROR_NO_RECEIVERS); + } + let browser = tab.linkedBrowser || tab.browser; + return browser.browsingContext.id; + }, + + // TODO: Rework/simplify this and getSender/getTopBC after bug 1580766. + async normalizeArgs(arg, sender) { + arg.extensionId = arg.extensionId || sender.extensionId; + let extension = GlobalManager.extensionMap.get(arg.extensionId); + if (!extension) { + return Promise.reject({ message: ERROR_NO_RECEIVERS }); + } + // TODO bug 1852317: This should not be unconditional. + await extension.wakeupBackground?.(); + + arg.sender = this.getSender(extension, sender); + arg.topBC = arg.tabId && this.getTopBrowsingContextId(arg.tabId); + return arg.tabId ? "tab" : "messenger"; + }, + + async recvRuntimeMessage(arg, { sender }) { + arg.firstResponse = true; + let kind = await this.normalizeArgs(arg, sender); + let result = await this.conduit.castRuntimeMessage(kind, arg); + if (!result) { + // "throw new ExtensionError" cannot be used because then the stack of the + // sendMessage call would not be added to the error object generated by + // context.normalizeError. Test coverage by test_ext_error_location.js. + return Promise.reject({ message: ERROR_NO_RECEIVERS }); + } + return result.value; + }, + + async recvPortConnect(arg, { sender }) { + if (arg.native) { + let port = this.openNative(arg.name, sender).onConnect(arg.portId, this); + port.senderChildId = sender.childId; + port.native = true; + this.ports.set(arg.portId, port); + this.trackNativeAppPort(port); + return; + } + + // PortMessages that follow will need to wait for the port to be opened. + /** @type {callback} */ + let resolvePort; + this.ports.set(arg.portId, new Promise(res => (resolvePort = res))); + + let kind = await this.normalizeArgs(arg, sender); + let all = await this.conduit.castPortConnect(kind, arg); + resolvePort(); + + // If there are no active onConnect listeners. + if (!all.some(x => x.value)) { + throw new ExtensionError(ERROR_NO_RECEIVERS); + } + }, + + async recvPortMessage({ holder }, { sender }) { + if (sender.native) { + // If the nativeApp port connect fails (e.g. if triggered by a content + // script), the portId may not be in the map (because it did throw in + // the openNative method). + return this.ports.get(sender.portId)?.onPortMessage(holder); + } + // NOTE: the following await make sure we await for promised ports + // (ports that were not yet open when added to the Map, + // see recvPortConnect). + await this.ports.get(sender.portId); + this.sendPortMessage(sender.portId, holder, !sender.source); + }, + + recvConduitClosed(sender) { + let app = this.ports.get(sender.portId); + if (this.ports.delete(sender.portId) && sender.native) { + this.untrackNativeAppPort(app); + return app.onPortDisconnect(); + } + this.sendPortDisconnect(sender.portId, null, !sender.source); + }, + + sendPortMessage(portId, holder, source = true) { + this.conduit.castPortMessage("port", { portId, source, holder }); + }, + + sendPortDisconnect(portId, error, source = true) { + let port = this.ports.get(portId); + this.untrackNativeAppPort(port); + this.conduit.castPortDisconnect("port", { portId, source, error }); + this.ports.delete(portId); + }, + + trackNativeAppPort(port) { + if (!port?.native) { + return; + } + + try { + let context = ParentAPIManager.getContextById(port.senderChildId); + context?.trackNativeAppPort(port); + } catch { + // getContextById will throw if the context has been destroyed + // in the meantime. + } + }, + + untrackNativeAppPort(port) { + if (!port?.native) { + return; + } + + try { + let context = ParentAPIManager.getContextById(port.senderChildId); + context?.untrackNativeAppPort(port); + } catch { + // getContextById will throw if the context has been destroyed + // in the meantime. + } + }, +}; +ProxyMessenger.init(); + +// Responsible for loading extension APIs into the right globals. +GlobalManager = { + // Map[extension ID -> Extension]. Determines which extension is + // responsible for content under a particular extension ID. + extensionMap: new Map(), + initialized: false, + + /** @type {WeakMap<Browser, object>} Extension Context init data. */ + frameData: new WeakMap(), + + init(extension) { + if (this.extensionMap.size == 0) { + apiManager.on("extension-browser-inserted", this._onExtensionBrowser); + this.initialized = true; + } + this.extensionMap.set(extension.id, extension); + }, + + uninit(extension) { + this.extensionMap.delete(extension.id); + + if (this.extensionMap.size == 0 && this.initialized) { + apiManager.off("extension-browser-inserted", this._onExtensionBrowser); + this.initialized = false; + } + }, + + _onExtensionBrowser(type, browser, data = {}) { + data.viewType = browser.getAttribute("webextension-view-type"); + if (data.viewType) { + GlobalManager.frameData.set(browser, data); + } + }, + + getExtension(extensionId) { + return this.extensionMap.get(extensionId); + }, +}; + +/** + * The proxied parent side of a context in ExtensionChild.jsm, for the + * parent side of a proxied API. + */ +class ProxyContextParent extends BaseContext { + constructor(envType, extension, params, browsingContext, principal) { + super(envType, extension); + + this.childId = params.childId; + this.uri = Services.io.newURI(params.url); + + this.incognito = params.incognito; + + this.listenerPromises = new Set(); + + // browsingContext is null when subclassed by BackgroundWorkerContextParent. + const xulBrowser = browsingContext?.top.embedderElement; + // This message manager is used by ParentAPIManager to send messages and to + // close the ProxyContext if the underlying message manager closes. This + // message manager object may change when `xulBrowser` swaps docshells, e.g. + // when a tab is moved to a different window. + // TODO: Is xulBrowser correct for ContentScriptContextParent? Messages + // through the xulBrowser won't reach cross-process iframes. + this.messageManagerProxy = + xulBrowser && new lazy.MessageManagerProxy(xulBrowser); + + Object.defineProperty(this, "principal", { + value: principal, + enumerable: true, + configurable: true, + }); + + this.listenerProxies = new Map(); + + this.pendingEventBrowser = null; + this.callContextData = null; + + // Set of active NativeApp ports. + this.activeNativePorts = new WeakSet(); + + // Set of pending queryRunListener promises. + this.runListenerPromises = new Set(); + + apiManager.emit("proxy-context-load", this); + } + + get isProxyContextParent() { + return true; + } + + trackRunListenerPromise(runListenerPromise) { + if ( + // The extension was already shutdown. + !this.extension || + // Not a non persistent background script context. + !this.isBackgroundContext || + this.extension.persistentBackground + ) { + return; + } + const clearFromSet = () => + this.runListenerPromises.delete(runListenerPromise); + runListenerPromise.then(clearFromSet, clearFromSet); + this.runListenerPromises.add(runListenerPromise); + } + + clearPendingRunListenerPromises() { + this.runListenerPromises.clear(); + } + + get pendingRunListenerPromisesCount() { + return this.runListenerPromises.size; + } + + trackNativeAppPort(port) { + if ( + // Not a native port. + !port?.native || + // Not a non persistent background script context. + !this.isBackgroundContext || + this.extension?.persistentBackground || + // The extension was already shutdown. + !this.extension + ) { + return; + } + this.activeNativePorts.add(port); + } + + untrackNativeAppPort(port) { + this.activeNativePorts.delete(port); + } + + get hasActiveNativeAppPorts() { + return !!ChromeUtils.nondeterministicGetWeakSetKeys(this.activeNativePorts) + .length; + } + + /** + * Call the `callable` parameter with `context.callContextData` set to the value passed + * as the first parameter of this method. + * + * `context.callContextData` is expected to: + * - don't be set when context.withCallContextData is being called + * - be set back to null right after calling the `callable` function, without + * awaiting on any async code that the function may be running internally + * + * The callable method itself is responsabile of eventually retrieve the value initially set + * on the `context.callContextData` before any code executed asynchronously (e.g. from a + * callback or after awaiting internally on a promise if the `callable` function was async). + * + * @param {object} callContextData + * @param {boolean} callContextData.isHandlingUserInput + * @param {Function} callable + * + * @returns {any} Returns the value returned by calling the `callable` method. + */ + withCallContextData({ isHandlingUserInput }, callable) { + if (this.callContextData) { + Cu.reportError( + `Unexpected pre-existing callContextData on "${this.extension?.policy.debugName}" contextId ${this.contextId}` + ); + } + + try { + this.callContextData = { + isHandlingUserInput, + }; + return callable(); + } finally { + this.callContextData = null; + } + } + + async withPendingBrowser(browser, callable) { + let savedBrowser = this.pendingEventBrowser; + this.pendingEventBrowser = browser; + try { + let result = await callable(); + return result; + } finally { + this.pendingEventBrowser = savedBrowser; + } + } + + logActivity(type, name, data) { + // The base class will throw so we catch any subclasses that do not implement. + // We do not want to throw here, but we also do not log here. + } + + get cloneScope() { + return this.sandbox; + } + + applySafe(callback, args) { + // There's no need to clone when calling listeners for a proxied + // context. + return this.applySafeWithoutClone(callback, args); + } + + get xulBrowser() { + return this.messageManagerProxy?.eventTarget; + } + + get parentMessageManager() { + // TODO bug 1595186: Replace use of parentMessageManager. + return this.messageManagerProxy?.messageManager; + } + + shutdown() { + this.unload(); + } + + unload() { + if (this.unloaded) { + return; + } + + this.messageManagerProxy?.dispose(); + + super.unload(); + apiManager.emit("proxy-context-unload", this); + } + + get apiCan() { + const apiCan = new CanOfAPIs(this, this.extension.apiManager, {}); + return redefineGetter(this, "apiCan", apiCan); + } + + get apiObj() { + return redefineGetter(this, "apiObj", this.apiCan.root); + } + + get sandbox() { + // Note: Blob and URL globals are used in ext-contentScripts.js. + const sandbox = Cu.Sandbox(this.principal, { + sandboxName: this.uri.spec, + wantGlobalProperties: ["Blob", "URL"], + }); + return redefineGetter(this, "sandbox", sandbox); + } +} + +/** + * The parent side of proxied API context for extension content script + * running in ExtensionContent.jsm. + */ +class ContentScriptContextParent extends ProxyContextParent {} + +/** + * The parent side of proxied API context for extension page, such as a + * background script, a tab page, or a popup, running in + * ExtensionChild.jsm. + */ +class ExtensionPageContextParent extends ProxyContextParent { + constructor(envType, extension, params, browsingContext) { + super(envType, extension, params, browsingContext, extension.principal); + + this.viewType = params.viewType; + this.isTopContext = browsingContext.top === browsingContext; + + this.extension.views.add(this); + + extension.emit("extension-proxy-context-load", this); + } + + // The window that contains this context. This may change due to moving tabs. + get appWindow() { + let win = this.xulBrowser.ownerGlobal; + return win.browsingContext.topChromeWindow; + } + + get currentWindow() { + if (this.viewType !== "background") { + return this.appWindow; + } + } + + get tabId() { + let { tabTracker } = apiManager.global; + let data = tabTracker.getBrowserData(this.xulBrowser); + if (data.tabId >= 0) { + return data.tabId; + } + } + + unload() { + super.unload(); + this.extension.views.delete(this); + } + + shutdown() { + apiManager.emit("page-shutdown", this); + super.shutdown(); + } +} + +/** + * The parent side of proxied API context for devtools extension page, such as a + * devtools pages and panels running in ExtensionChild.jsm. + */ +class DevToolsExtensionPageContextParent extends ExtensionPageContextParent { + constructor(...params) { + super(...params); + + // Set all attributes that are lazily defined to `null` here. + // + // Note that we can't do that for `this._devToolsToolbox` because it will + // be defined when calling our parent constructor and so would override it back to `null`. + this._devToolsCommands = null; + this._onNavigatedListeners = null; + + this._onResourceAvailable = this._onResourceAvailable.bind(this); + } + + set devToolsToolbox(toolbox) { + if (this._devToolsToolbox) { + throw new Error("Cannot set the context DevTools toolbox twice"); + } + + this._devToolsToolbox = toolbox; + } + + get devToolsToolbox() { + return this._devToolsToolbox; + } + + async addOnNavigatedListener(listener) { + if (!this._onNavigatedListeners) { + this._onNavigatedListeners = new Set(); + + await this.devToolsToolbox.resourceCommand.watchResources( + [this.devToolsToolbox.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: this._onResourceAvailable, + ignoreExistingResources: true, + } + ); + } + + this._onNavigatedListeners.add(listener); + } + + removeOnNavigatedListener(listener) { + if (this._onNavigatedListeners) { + this._onNavigatedListeners.delete(listener); + } + } + + /** + * The returned "commands" object, exposing modules implemented from devtools/shared/commands. + * Each attribute being a static interface to communicate with the server backend. + * + * @returns {Promise<object>} + */ + async getDevToolsCommands() { + // Ensure that we try to instantiate a commands only once, + // even if createCommandsForTabForWebExtension is async. + if (this._devToolsCommandsPromise) { + return this._devToolsCommandsPromise; + } + if (this._devToolsCommands) { + return this._devToolsCommands; + } + + this._devToolsCommandsPromise = (async () => { + const commands = + await lazy.DevToolsShim.createCommandsForTabForWebExtension( + this.devToolsToolbox.commands.descriptorFront.localTab + ); + await commands.targetCommand.startListening(); + this._devToolsCommands = commands; + this._devToolsCommandsPromise = null; + return commands; + })(); + return this._devToolsCommandsPromise; + } + + unload() { + // Bail if the toolbox reference was already cleared. + if (!this.devToolsToolbox) { + return; + } + + if (this._onNavigatedListeners) { + this.devToolsToolbox.resourceCommand.unwatchResources( + [this.devToolsToolbox.resourceCommand.TYPES.DOCUMENT_EVENT], + { onAvailable: this._onResourceAvailable } + ); + } + + if (this._devToolsCommands) { + this._devToolsCommands.destroy(); + this._devToolsCommands = null; + } + + if (this._onNavigatedListeners) { + this._onNavigatedListeners.clear(); + this._onNavigatedListeners = null; + } + + this._devToolsToolbox = null; + + super.unload(); + } + + async _onResourceAvailable(resources) { + for (const resource of resources) { + const { targetFront } = resource; + if (targetFront.isTopLevel && resource.name === "dom-complete") { + for (const listener of this._onNavigatedListeners) { + listener(targetFront.url); + } + } + } + } +} + +/** + * The parent side of proxied API context for extension background service + * worker script. + */ +class BackgroundWorkerContextParent extends ProxyContextParent { + constructor(envType, extension, params) { + // TODO: split out from ProxyContextParent a base class that + // doesn't expect a browsingContext and one for contexts that are + // expected to have a browsingContext associated. + super(envType, extension, params, null, extension.principal); + + this.viewType = params.viewType; + this.workerDescriptorId = params.workerDescriptorId; + + this.extension.views.add(this); + + extension.emit("extension-proxy-context-load", this); + } +} + +ParentAPIManager = { + proxyContexts: new Map(), + + init() { + // TODO: Bug 1595186 - remove/replace all usage of MessageManager below. + Services.obs.addObserver(this, "message-manager-close"); + + this.conduit = new lazy.BroadcastConduit(this, { + id: "ParentAPIManager", + reportOnClosed: "childId", + recv: [ + "CreateProxyContext", + "ContextLoaded", + "APICall", + "AddListener", + "RemoveListener", + ], + send: ["CallResult"], + query: ["RunListener", "StreamFilterSuspendCancel"], + }); + }, + + attachMessageManager(extension, processMessageManager) { + extension.parentMessageManager = processMessageManager; + }, + + async observe(subject, topic, data) { + if (topic === "message-manager-close") { + let mm = subject; + for (let [childId, context] of this.proxyContexts) { + if (context.parentMessageManager === mm) { + this.closeProxyContext(childId); + } + } + + // Reset extension message managers when their child processes shut down. + for (let extension of GlobalManager.extensionMap.values()) { + if (extension.parentMessageManager === mm) { + extension.parentMessageManager = null; + } + } + } + }, + + shutdownExtension(extensionId, reason) { + if (["ADDON_DISABLE", "ADDON_UNINSTALL"].includes(reason)) { + apiManager._callHandlers([extensionId], "disable", "onDisable"); + } + + for (let [childId, context] of this.proxyContexts) { + if (context.extension.id == extensionId) { + context.shutdown(); + this.proxyContexts.delete(childId); + } + } + }, + + queryStreamFilterSuspendCancel(childId) { + return this.conduit.queryStreamFilterSuspendCancel(childId); + }, + + recvCreateProxyContext(data, { actor, sender }) { + let { envType, extensionId, childId, principal } = data; + + if (this.proxyContexts.has(childId)) { + throw new Error( + "A WebExtension context with the given ID already exists!" + ); + } + + let extension = GlobalManager.getExtension(extensionId); + if (!extension) { + throw new Error(`No WebExtension found with ID ${extensionId}`); + } + + let context; + if (envType == "addon_parent" || envType == "devtools_parent") { + if (!sender.verified) { + throw new Error(`Bad sender context envType: ${sender.envType}`); + } + + let isBackgroundWorker = false; + if (JSWindowActorParent.isInstance(actor)) { + const target = actor.browsingContext.top.embedderElement; + let processMessageManager = + target.messageManager.processMessageManager || + Services.ppmm.getChildAt(0); + + if (!extension.parentMessageManager) { + if (target.remoteType === extension.remoteType) { + this.attachMessageManager(extension, processMessageManager); + } + } + + if (processMessageManager !== extension.parentMessageManager) { + throw new Error( + "Attempt to create privileged extension parent from incorrect child process" + ); + } + } else if (JSProcessActorParent.isInstance(actor)) { + if (actor.manager.remoteType !== extension.remoteType) { + throw new Error( + "Attempt to create privileged extension parent from incorrect child process" + ); + } + + if (envType !== "addon_parent") { + throw new Error( + `Unexpected envType ${envType} on an extension process actor` + ); + } + if (data.viewType !== "background_worker") { + throw new Error( + `Unexpected viewType ${data.viewType} on an extension process actor` + ); + } + isBackgroundWorker = true; + } else { + // Unreacheable: JSWindowActorParent and JSProcessActorParent are the + // only actors. + throw new Error( + "Attempt to create privileged extension parent via incorrect actor" + ); + } + + if (isBackgroundWorker) { + context = new BackgroundWorkerContextParent(envType, extension, data); + } else if (envType == "addon_parent") { + context = new ExtensionPageContextParent( + envType, + extension, + data, + actor.browsingContext + ); + } else if (envType == "devtools_parent") { + context = new DevToolsExtensionPageContextParent( + envType, + extension, + data, + actor.browsingContext + ); + } + } else if (envType == "content_parent") { + // Note: actor is always a JSWindowActorParent, with a browsingContext. + context = new ContentScriptContextParent( + envType, + extension, + data, + actor.browsingContext, + principal + ); + } else { + throw new Error(`Invalid WebExtension context envType: ${envType}`); + } + this.proxyContexts.set(childId, context); + }, + + recvContextLoaded(data, { actor, sender }) { + let context = this.getContextById(data.childId); + verifyActorForContext(actor, context); + const { extension } = context; + extension.emit("extension-proxy-context-load:completed", context); + }, + + recvConduitClosed(sender) { + this.closeProxyContext(sender.id); + }, + + closeProxyContext(childId) { + let context = this.proxyContexts.get(childId); + if (context) { + context.unload(); + this.proxyContexts.delete(childId); + } + }, + + /** + * Call the given function and also log the call as appropriate + * (i.e., with activity logging and/or profiler markers) + * + * @param {BaseContext} context The context making this call. + * @param {object} data Additional data about the call. + * @param {Function} callable The actual implementation to invoke. + */ + async callAndLog(context, data, callable) { + let { id } = context.extension; + // If we were called via callParentAsyncFunction we don't want + // to log again, check for the flag. + const { alreadyLogged } = data.options || {}; + if (!alreadyLogged) { + lazy.ExtensionActivityLog.log( + id, + context.viewType, + "api_call", + data.path, + { + args: data.args, + } + ); + } + + let start = Cu.now(); + try { + return callable(); + } finally { + ChromeUtils.addProfilerMarker( + "ExtensionParent", + { startTime: start }, + `${id}, api_call: ${data.path}` + ); + } + }, + + async recvAPICall(data, { actor }) { + let context = this.getContextById(data.childId); + let target = actor.browsingContext?.top.embedderElement; + + verifyActorForContext(actor, context); + + let reply = result => { + if (target && !context.parentMessageManager) { + Services.console.logStringMessage( + "Cannot send function call result: other side closed connection " + + `(call data: ${uneval({ path: data.path, args: data.args })})` + ); + return; + } + + this.conduit.sendCallResult(data.childId, { + childId: data.childId, + callId: data.callId, + path: data.path, + ...result, + }); + }; + + try { + if ( + context.isBackgroundContext && + !context.extension.persistentBackground + ) { + context.extension.emit("background-script-reset-idle", { + reason: "parentApiCall", + path: data.path, + }); + } + + let args = data.args; + let { isHandlingUserInput = false } = data.options || {}; + let pendingBrowser = context.pendingEventBrowser; + let fun = await context.apiCan.asyncFindAPIPath(data.path); + let result = this.callAndLog(context, data, () => { + return context.withPendingBrowser(pendingBrowser, () => + context.withCallContextData({ isHandlingUserInput }, () => + fun(...args) + ) + ); + }); + + if (data.callId) { + result = result || Promise.resolve(); + + result.then( + result => { + result = result instanceof SpreadArgs ? [...result] : [result]; + + let holder = new StructuredCloneHolder( + `ExtensionParent/${context.extension.id}/recvAPICall/${data.path}`, + null, + result + ); + + reply({ result: holder }); + }, + error => { + error = context.normalizeError(error); + reply({ + error: { message: error.message, fileName: error.fileName }, + }); + } + ); + } + } catch (e) { + if (data.callId) { + let error = context.normalizeError(e); + reply({ error: { message: error.message } }); + } else { + Cu.reportError(e); + } + } + }, + + async recvAddListener(data, { actor }) { + let context = this.getContextById(data.childId); + + verifyActorForContext(actor, context); + + let { childId, alreadyLogged = false } = data; + let handlingUserInput = false; + + let listener = async (...listenerArgs) => { + let startTime = Cu.now(); + // Extract urgentSend flag to avoid deserializing args holder later. + let urgentSend = false; + if (listenerArgs[0] && data.path.startsWith("webRequest.")) { + urgentSend = listenerArgs[0].urgentSend; + delete listenerArgs[0].urgentSend; + } + let runListenerPromise = this.conduit.queryRunListener(childId, { + childId, + handlingUserInput, + listenerId: data.listenerId, + path: data.path, + urgentSend, + get args() { + return new StructuredCloneHolder( + `ExtensionParent/${context.extension.id}/recvAddListener/${data.path}`, + null, + listenerArgs + ); + }, + }); + context.trackRunListenerPromise(runListenerPromise); + + const result = await runListenerPromise; + let rv = result && result.deserialize(globalThis); + ChromeUtils.addProfilerMarker( + "ExtensionParent", + { startTime }, + `${context.extension.id}, api_event: ${data.path}` + ); + lazy.ExtensionActivityLog.log( + context.extension.id, + context.viewType, + "api_event", + data.path, + { args: listenerArgs, result: rv } + ); + return rv; + }; + + context.listenerProxies.set(data.listenerId, listener); + + let args = data.args; + let promise = context.apiCan.asyncFindAPIPath(data.path); + + // Store pending listener additions so we can be sure they're all + // fully initialize before we consider extension startup complete. + if (context.isBackgroundContext && context.listenerPromises) { + const { listenerPromises } = context; + listenerPromises.add(promise); + let remove = () => { + listenerPromises.delete(promise); + }; + promise.then(remove, remove); + } + + let handler = await promise; + if (handler.setUserInput) { + handlingUserInput = true; + } + handler.addListener(listener, ...args); + if (!alreadyLogged) { + lazy.ExtensionActivityLog.log( + context.extension.id, + context.viewType, + "api_call", + `${data.path}.addListener`, + { args } + ); + } + }, + + async recvRemoveListener(data) { + let context = this.getContextById(data.childId); + let listener = context.listenerProxies.get(data.listenerId); + + let handler = await context.apiCan.asyncFindAPIPath(data.path); + handler.removeListener(listener); + + let { alreadyLogged = false } = data; + if (!alreadyLogged) { + lazy.ExtensionActivityLog.log( + context.extension.id, + context.viewType, + "api_call", + `${data.path}.removeListener`, + { args: [] } + ); + } + }, + + getContextById(childId) { + let context = this.proxyContexts.get(childId); + if (!context) { + throw new Error("WebExtension context not found!"); + } + return context; + }, +}; + +ParentAPIManager.init(); + +/** + * A hidden window which contains the extension pages that are not visible + * (i.e., background pages and devtools pages), and is also used by + * ExtensionDebuggingUtils to contain the browser elements used by the + * addon debugger to connect to the devtools actors running in the same + * process of the target extension (and be able to stay connected across + * the addon reloads). + */ +class HiddenXULWindow { + constructor() { + this._windowlessBrowser = null; + this.unloaded = false; + this.waitInitialized = this.initWindowlessBrowser(); + } + + shutdown() { + if (this.unloaded) { + throw new Error( + "Unable to shutdown an unloaded HiddenXULWindow instance" + ); + } + + this.unloaded = true; + + this.waitInitialized = null; + + if (!this._windowlessBrowser) { + Cu.reportError("HiddenXULWindow was shut down while it was loading."); + // initWindowlessBrowser will close windowlessBrowser when possible. + return; + } + + this._windowlessBrowser.close(); + this._windowlessBrowser = null; + } + + get chromeDocument() { + return this._windowlessBrowser.document; + } + + /** + * Private helper that create a HTMLDocument in a windowless browser. + * + * @returns {Promise<void>} + * A promise which resolves when the windowless browser is ready. + */ + async initWindowlessBrowser() { + if (this.waitInitialized) { + throw new Error("HiddenXULWindow already initialized"); + } + + // The invisible page is currently wrapped in a XUL window to fix an issue + // with using the canvas API from a background page (See Bug 1274775). + let windowlessBrowser = Services.appShell.createWindowlessBrowser(true); + + // The windowless browser is a thin wrapper around a docShell that keeps + // its related resources alive. It implements nsIWebNavigation and + // forwards its methods to the underlying docShell. That .docShell + // needs `QueryInterface(nsIWebNavigation)` to give us access to the + // webNav methods that are already available on the windowless browser. + let chromeShell = windowlessBrowser.docShell; + chromeShell.QueryInterface(Ci.nsIWebNavigation); + + if (lazy.PrivateBrowsingUtils.permanentPrivateBrowsing) { + let attrs = chromeShell.getOriginAttributes(); + attrs.privateBrowsingId = 1; + chromeShell.setOriginAttributes(attrs); + } + + windowlessBrowser.browsingContext.useGlobalHistory = false; + chromeShell.loadURI(DUMMY_PAGE_URI, { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + + await promiseObserved( + "chrome-document-global-created", + win => win.document == chromeShell.document + ); + await promiseDocumentLoaded(windowlessBrowser.document); + if (this.unloaded) { + windowlessBrowser.close(); + return; + } + this._windowlessBrowser = windowlessBrowser; + } + + /** + * Creates the browser XUL element that will contain the WebExtension Page. + * + * @param {object} xulAttributes + * An object that contains the xul attributes to set of the newly + * created browser XUL element. + * + * @returns {Promise<XULElement>} + * A Promise which resolves to the newly created browser XUL element. + */ + async createBrowserElement(xulAttributes) { + if (!xulAttributes || Object.keys(xulAttributes).length === 0) { + throw new Error("missing mandatory xulAttributes parameter"); + } + + await this.waitInitialized; + + const chromeDoc = this.chromeDocument; + + const browser = chromeDoc.createXULElement("browser"); + browser.setAttribute("type", "content"); + browser.setAttribute("disableglobalhistory", "true"); + browser.setAttribute("messagemanagergroup", "webext-browsers"); + browser.setAttribute("manualactiveness", "true"); + + for (const [name, value] of Object.entries(xulAttributes)) { + if (value != null) { + browser.setAttribute(name, value); + } + } + + let awaitFrameLoader; + + if (browser.getAttribute("remote") === "true") { + awaitFrameLoader = promiseEvent(browser, "XULFrameLoaderCreated"); + } + + chromeDoc.documentElement.appendChild(browser); + + // Forcibly flush layout so that we get a pres shell soon enough, see + // bug 1274775. + browser.getBoundingClientRect(); + await awaitFrameLoader; + + // FIXME(emilio): This unconditionally active frame seems rather + // unfortunate, but matches previous behavior. + browser.docShellIsActive = true; + + return browser; + } +} + +const SharedWindow = { + _window: null, + _count: 0, + + acquire() { + if (this._window == null) { + if (this._count != 0) { + throw new Error( + `Shared window already exists with count ${this._count}` + ); + } + + this._window = new HiddenXULWindow(); + } + + this._count++; + return this._window; + }, + + release() { + if (this._count < 1) { + throw new Error(`Releasing shared window with count ${this._count}`); + } + + this._count--; + if (this._count == 0) { + this._window.shutdown(); + this._window = null; + } + }, +}; + +/** + * This is a base class used by the ext-backgroundPage and ext-devtools API implementations + * to inherits the shared boilerplate code needed to create a parent document for the hidden + * extension pages (e.g. the background page, the devtools page) in the BackgroundPage and + * DevToolsPage classes. + * + * @param {Extension} extension + * The Extension which owns the hidden extension page created (used to decide + * if the hidden extension page parent doc is going to be a windowlessBrowser or + * a visible XUL window). + * @param {string} viewType + * The viewType of the WebExtension page that is going to be loaded + * in the created browser element (e.g. "background" or "devtools_page"). + */ +class HiddenExtensionPage { + constructor(extension, viewType) { + if (!extension || !viewType) { + throw new Error("extension and viewType parameters are mandatory"); + } + + this.extension = extension; + this.viewType = viewType; + this.browser = null; + this.unloaded = false; + } + + /** + * Destroy the created parent document. + */ + shutdown() { + if (this.unloaded) { + throw new Error( + "Unable to shutdown an unloaded HiddenExtensionPage instance" + ); + } + + this.unloaded = true; + + if (this.browser) { + this._releaseBrowser(); + } + } + + _releaseBrowser() { + this.browser.remove(); + this.browser = null; + SharedWindow.release(); + } + + /** + * Creates the browser XUL element that will contain the WebExtension Page. + * + * @returns {Promise<XULElement>} + * A Promise which resolves to the newly created browser XUL element. + */ + async createBrowserElement() { + if (this.browser) { + throw new Error("createBrowserElement called twice"); + } + + let window = SharedWindow.acquire(); + try { + this.browser = await window.createBrowserElement({ + "webextension-view-type": this.viewType, + remote: this.extension.remote ? "true" : null, + remoteType: this.extension.remoteType, + initialBrowsingContextGroupId: this.extension.browsingContextGroupId, + }); + } catch (e) { + SharedWindow.release(); + throw e; + } + + if (this.unloaded) { + this._releaseBrowser(); + throw new Error("Extension shut down before browser element was created"); + } + + return this.browser; + } +} + +/** + * This object provides utility functions needed by the devtools actors to + * be able to connect and debug an extension (which can run in the main or in + * a child extension process). + */ +const DebugUtils = { + // A lazily created hidden XUL window, which contains the browser elements + // which are used to connect the webextension patent actor to the extension process. + hiddenXULWindow: null, + + // Map<extensionId, Promise<XULElement>> + debugBrowserPromises: new Map(), + // DefaultWeakMap<Promise<browser XULElement>, Set<WebExtensionParentActor>> + debugActors: new DefaultWeakMap(() => new Set()), + + _extensionUpdatedWatcher: null, + watchExtensionUpdated() { + if (!this._extensionUpdatedWatcher) { + // Watch the updated extension objects. + this._extensionUpdatedWatcher = async (evt, extension) => { + const browserPromise = this.debugBrowserPromises.get(extension.id); + if (browserPromise) { + const browser = await browserPromise; + if ( + browser.isRemoteBrowser !== extension.remote && + this.debugBrowserPromises.get(extension.id) === browserPromise + ) { + // If the cached browser element is not anymore of the same + // remote type of the extension, remove it. + this.debugBrowserPromises.delete(extension.id); + browser.remove(); + } + } + }; + + apiManager.on("ready", this._extensionUpdatedWatcher); + } + }, + + unwatchExtensionUpdated() { + if (this._extensionUpdatedWatcher) { + apiManager.off("ready", this._extensionUpdatedWatcher); + delete this._extensionUpdatedWatcher; + } + }, + + getExtensionManifestWarnings(id) { + const addon = GlobalManager.extensionMap.get(id); + if (addon) { + return addon.warnings; + } + return []; + }, + + /** + * Determine if the extension does have a non-persistent background script + * (either an event page or a background service worker): + * + * Based on this the DevTools client will determine if this extension should provide + * to the extension developers a button to forcefully terminate the background + * script. + * + * @param {string} addonId + * The id of the addon + * + * @returns {void|boolean} + * - undefined => does not apply (no background script in the manifest) + * - true => the background script is persistent. + * - false => the background script is an event page or a service worker. + */ + hasPersistentBackgroundScript(addonId) { + const policy = WebExtensionPolicy.getByID(addonId); + + // The addon doesn't have any background script or we + // can't be sure yet. + if ( + policy?.extension?.type !== "extension" || + !policy?.extension?.manifest?.background + ) { + return undefined; + } + + return policy.extension.persistentBackground; + }, + + /** + * Determine if the extension background page is running. + * + * Based on this the DevTools client will show the status of the background + * script in about:debugging. + * + * @param {string} addonId + * The id of the addon + * + * @returns {void|boolean} + * - undefined => does not apply (no background script in the manifest) + * - true => the background script is running. + * - false => the background script is stopped. + */ + isBackgroundScriptRunning(addonId) { + const policy = WebExtensionPolicy.getByID(addonId); + + // The addon doesn't have any background script or we + // can't be sure yet. + if (!(this.hasPersistentBackgroundScript(addonId) === false)) { + return undefined; + } + + const views = policy?.extension?.views || []; + for (const view of views) { + if ( + view.viewType === "background" || + (view.viewType === "background_worker" && !view.unloaded) + ) { + return true; + } + } + + return false; + }, + + async terminateBackgroundScript(addonId) { + // Terminate the background if the extension does have + // a non-persistent background script (event page or background + // service worker). + if (this.hasPersistentBackgroundScript(addonId) === false) { + const policy = WebExtensionPolicy.getByID(addonId); + // When the event page is being terminated through the Devtools + // action, we should terminate it even if there are DevTools + // toolboxes attached to the extension. + return policy.extension.terminateBackground({ + ignoreDevToolsAttached: true, + }); + } + throw Error(`Unable to terminate background script for ${addonId}`); + }, + + /** + * Determine whether a devtools toolbox attached to the extension. + * + * This method is called by the background page idle timeout handler, + * to inhibit terminating the event page when idle while the extension + * developer is debugging the extension through the Addon Debugging window + * (similarly to how service workers are kept alive while the devtools are + * attached). + * + * @param {string} id + * The id of the extension. + * + * @returns {boolean} + * true when a devtools toolbox is attached to an extension with + * the given id, false otherwise. + */ + hasDevToolsAttached(id) { + return this.debugBrowserPromises.has(id); + }, + + /** + * Retrieve a XUL browser element which has been configured to be able to connect + * the devtools actor with the process where the extension is running. + * + * @param {WebExtensionParentActor} webExtensionParentActor + * The devtools actor that is retrieving the browser element. + * + * @returns {Promise<XULElement>} + * A promise which resolves to the configured browser XUL element. + */ + async getExtensionProcessBrowser(webExtensionParentActor) { + const extensionId = webExtensionParentActor.addonId; + const extension = GlobalManager.getExtension(extensionId); + if (!extension) { + throw new Error(`Extension not found: ${extensionId}`); + } + + const createBrowser = () => { + if (!this.hiddenXULWindow) { + this.hiddenXULWindow = new HiddenXULWindow(); + this.watchExtensionUpdated(); + } + + return this.hiddenXULWindow.createBrowserElement({ + "webextension-addon-debug-target": extensionId, + remote: extension.remote ? "true" : null, + remoteType: extension.remoteType, + initialBrowsingContextGroupId: extension.browsingContextGroupId, + }); + }; + + let browserPromise = this.debugBrowserPromises.get(extensionId); + + // Create a new promise if there is no cached one in the map. + if (!browserPromise) { + browserPromise = createBrowser(); + this.debugBrowserPromises.set(extensionId, browserPromise); + browserPromise.then(browser => { + browserPromise.browser = browser; + }); + browserPromise.catch(e => { + Cu.reportError(e); + this.debugBrowserPromises.delete(extensionId); + }); + } + + this.debugActors.get(browserPromise).add(webExtensionParentActor); + + return browserPromise; + }, + + getFrameLoader(extensionId) { + let promise = this.debugBrowserPromises.get(extensionId); + return promise && promise.browser && promise.browser.frameLoader; + }, + + /** + * Given the devtools actor that has retrieved an addon debug browser element, + * it destroys the XUL browser element, and it also destroy the hidden XUL window + * if it is not currently needed. + * + * @param {WebExtensionParentActor} webExtensionParentActor + * The devtools actor that has retrieved an addon debug browser element. + */ + async releaseExtensionProcessBrowser(webExtensionParentActor) { + const extensionId = webExtensionParentActor.addonId; + const browserPromise = this.debugBrowserPromises.get(extensionId); + + if (browserPromise) { + const actorsSet = this.debugActors.get(browserPromise); + actorsSet.delete(webExtensionParentActor); + if (actorsSet.size === 0) { + this.debugActors.delete(browserPromise); + this.debugBrowserPromises.delete(extensionId); + await browserPromise.then(browser => browser.remove()); + } + } + + if (this.debugBrowserPromises.size === 0 && this.hiddenXULWindow) { + this.hiddenXULWindow.shutdown(); + this.hiddenXULWindow = null; + this.unwatchExtensionUpdated(); + } + }, +}; + +/** + * Returns a Promise which resolves with the message data when the given message + * was received by the message manager. The promise is rejected if the message + * manager was closed before a message was received. + * + * @param {nsIMessageListenerManager} messageManager + * The message manager on which to listen for messages. + * @param {string} messageName + * The message to listen for. + * @returns {Promise<*>} + */ +function promiseMessageFromChild(messageManager, messageName) { + return new Promise((resolve, reject) => { + let unregister; + function listener(message) { + unregister(); + resolve(message.data); + } + function observer(subject, topic, data) { + if (subject === messageManager) { + unregister(); + reject( + new Error( + `Message manager was disconnected before receiving ${messageName}` + ) + ); + } + } + unregister = () => { + Services.obs.removeObserver(observer, "message-manager-close"); + messageManager.removeMessageListener(messageName, listener); + }; + messageManager.addMessageListener(messageName, listener); + Services.obs.addObserver(observer, "message-manager-close"); + }); +} + +// This should be called before browser.loadURI is invoked. +async function promiseBackgroundViewLoaded(browser) { + let { childId } = await promiseMessageFromChild( + browser.messageManager, + "Extension:BackgroundViewLoaded" + ); + if (childId) { + return ParentAPIManager.getContextById(childId); + } +} + +/** + * This helper is used to subscribe a listener (e.g. in the ext-devtools API implementation) + * to be called for every ExtensionProxyContext created for an extension page given + * its related extension, viewType and browser element (both the top level context and any context + * created for the extension urls running into its iframe descendants). + * + * @param {object} params + * @param {object} params.extension + * The Extension on which we are going to listen for the newly created ExtensionProxyContext. + * @param {string} params.viewType + * The viewType of the WebExtension page that we are watching (e.g. "background" or + * "devtools_page"). + * @param {XULElement} params.browser + * The browser element of the WebExtension page that we are watching. + * @param {Function} onExtensionProxyContextLoaded + * The callback that is called when a new context has been loaded (as `callback(context)`); + * + * @returns {Function} + * Unsubscribe the listener. + */ +function watchExtensionProxyContextLoad( + { extension, viewType, browser }, + onExtensionProxyContextLoaded +) { + if (typeof onExtensionProxyContextLoaded !== "function") { + throw new Error("Missing onExtensionProxyContextLoaded handler"); + } + + const listener = (event, context) => { + if (context.viewType == viewType && context.xulBrowser == browser) { + onExtensionProxyContextLoaded(context); + } + }; + + extension.on("extension-proxy-context-load", listener); + + return () => { + extension.off("extension-proxy-context-load", listener); + }; +} + +/** + * This helper is used to subscribe a listener (e.g. in the ext-backgroundPage) + * to be called for every ExtensionProxyContext created for an extension + * background service worker given its related extension. + * + * @param {object} params + * @param {object} params.extension + * The Extension on which we are going to listen for the newly created ExtensionProxyContext. + * @param {Function} onExtensionWorkerContextLoaded + * The callback that is called when the worker script has been fully loaded (as `callback(context)`); + * + * @returns {Function} + * Unsubscribe the listener. + */ +function watchExtensionWorkerContextLoaded( + { extension }, + onExtensionWorkerContextLoaded +) { + if (typeof onExtensionWorkerContextLoaded !== "function") { + throw new Error("Missing onExtensionWorkerContextLoaded handler"); + } + + const listener = (event, context) => { + if (context.viewType == "background_worker") { + onExtensionWorkerContextLoaded(context); + } + }; + + extension.on("extension-proxy-context-load:completed", listener); + + return () => { + extension.off("extension-proxy-context-load:completed", listener); + }; +} + +// Manages icon details for toolbar buttons in the |pageAction| and +// |browserAction| APIs. +let IconDetails = { + DEFAULT_ICON: "chrome://mozapps/skin/extensions/extensionGeneric.svg", + + // WeakMap<Extension -> Map<url-string -> Map<iconType-string -> object>>> + iconCache: new DefaultWeakMap(() => { + return new DefaultMap(() => new DefaultMap(() => new Map())); + }), + + // Normalizes the various acceptable input formats into an object + // with icon size as key and icon URL as value. + // + // If a context is specified (function is called from an extension): + // Throws an error if an invalid icon size was provided or the + // extension is not allowed to load the specified resources. + // + // If no context is specified, instead of throwing an error, this + // function simply logs a warning message. + normalize(details, extension, context = null) { + if (!details.imageData && details.path != null) { + // Pick a cache key for the icon paths. If the path is a string, + // use it directly. Otherwise, stringify the path object. + let key = details.path; + if (typeof key !== "string") { + key = uneval(key); + } + + let icons = this.iconCache + .get(extension) + .get(context && context.uri.spec) + .get(details.iconType); + + let icon = icons.get(key); + if (!icon) { + icon = this._normalize(details, extension, context); + icons.set(key, icon); + } + return icon; + } + + return this._normalize(details, extension, context); + }, + + _normalize(details, extension, context = null) { + let result = {}; + + try { + let { imageData, path, themeIcons } = details; + + if (imageData) { + if (typeof imageData == "string") { + imageData = { 19: imageData }; + } + + for (let size of Object.keys(imageData)) { + result[size] = imageData[size]; + } + } + + let baseURI = context ? context.uri : extension.baseURI; + + if (path != null) { + if (typeof path != "object") { + path = { 19: path }; + } + + for (let size of Object.keys(path)) { + let url = path[size]; + if (url) { + url = baseURI.resolve(path[size]); + + // The Chrome documentation specifies these parameters as + // relative paths. We currently accept absolute URLs as well, + // which means we need to check that the extension is allowed + // to load them. This will throw an error if it's not allowed. + this._checkURL(url, extension); + } + result[size] = url || this.DEFAULT_ICON; + } + } + + if (themeIcons) { + themeIcons.forEach(({ size, light, dark }) => { + let lightURL = baseURI.resolve(light); + let darkURL = baseURI.resolve(dark); + + this._checkURL(lightURL, extension); + this._checkURL(darkURL, extension); + + let defaultURL = result[size] || result[19]; // always fallback to default first + result[size] = { + default: defaultURL || darkURL, // Fallback to the dark url if no default is specified. + light: lightURL, + dark: darkURL, + }; + }); + } + } catch (e) { + // Function is called from extension code, delegate error. + if (context) { + throw e; + } + // If there's no context, it's because we're handling this + // as a manifest directive. Log a warning rather than + // raising an error. + extension.manifestError(`Invalid icon data: ${e}`); + } + + return result; + }, + + // Checks if the extension is allowed to load the given URL with the specified principal. + // This will throw an error if the URL is not allowed. + _checkURL(url, extension) { + if (!extension.checkLoadURL(url, { allowInheritsPrincipal: true })) { + throw new ExtensionError(`Illegal URL ${url}`); + } + }, + + // Returns the appropriate icon URL for the given icons object and the + // screen resolution of the given window. + getPreferredIcon(icons, extension = null, size = 16) { + const DEFAULT = "chrome://mozapps/skin/extensions/extensionGeneric.svg"; + + let bestSize = null; + if (icons[size]) { + bestSize = size; + } else if (icons[2 * size]) { + bestSize = 2 * size; + } else { + let sizes = Object.keys(icons) + .map(key => parseInt(key, 10)) + .sort((a, b) => a - b); + + bestSize = sizes.find(candidate => candidate > size) || sizes.pop(); + } + + if (bestSize) { + return { size: bestSize, icon: icons[bestSize] || DEFAULT }; + } + + return { size, icon: DEFAULT }; + }, + + // These URLs should already be properly escaped, but make doubly sure CSS + // string escape characters are escaped here, since they could lead to a + // sandbox break. + escapeUrl(url) { + return url.replace(/[\\\s"]/g, encodeURIComponent); + }, +}; + +class CacheStore { + constructor(storeName) { + this.storeName = storeName; + } + + async getStore(path = null) { + let data = await StartupCache.dataPromise; + + let store = data.get(this.storeName); + if (!store) { + store = new Map(); + data.set(this.storeName, store); + } + + let key = path; + if (Array.isArray(path)) { + for (let elem of path.slice(0, -1)) { + let next = store.get(elem); + if (!next) { + next = new Map(); + store.set(elem, next); + } + store = next; + } + key = path[path.length - 1]; + } + + return [store, key]; + } + + async get(path, createFunc) { + let [store, key] = await this.getStore(path); + + let result = store.get(key); + + if (result === undefined) { + result = await createFunc(path); + store.set(key, result); + StartupCache.save(); + } + + return result; + } + + async set(path, value) { + let [store, key] = await this.getStore(path); + + store.set(key, value); + StartupCache.save(); + } + + async getAll() { + let [store] = await this.getStore(); + + return new Map(store); + } + + async delete(path) { + let [store, key] = await this.getStore(path); + + if (store.delete(key)) { + StartupCache.save(); + } + } +} + +// A cache to support faster initialization of extensions at browser startup. +// All cached data is removed when the browser is updated. +// Extension-specific data is removed when the add-on is updated. +var StartupCache = { + _ensureDirectoryPromise: null, + _saveTask: null, + + _ensureDirectory() { + if (this._ensureDirectoryPromise === null) { + this._ensureDirectoryPromise = IOUtils.makeDirectory( + PathUtils.parent(this.file), + { + ignoreExisting: true, + createAncestors: true, + } + ); + } + + return this._ensureDirectoryPromise; + }, + + // When the application version changes, this file is removed by + // RemoveComponentRegistries in nsAppRunner.cpp. + file: PathUtils.join( + Services.dirsvc.get("ProfLD", Ci.nsIFile).path, + "startupCache", + "webext.sc.lz4" + ), + + async _saveNow() { + let data = new Uint8Array(lazy.aomStartup.encodeBlob(this._data)); + await this._ensureDirectoryPromise; + await IOUtils.write(this.file, data, { tmpPath: `${this.file}.tmp` }); + + Glean.extensions.startupCacheWriteBytelength.set(data.byteLength); + }, + + save() { + this._ensureDirectory(); + + if (!this._saveTask) { + this._saveTask = new lazy.DeferredTask(() => this._saveNow(), 5000); + + IOUtils.profileBeforeChange.addBlocker( + "Flush WebExtension StartupCache", + async () => { + await this._saveTask.finalize(); + this._saveTask = null; + } + ); + } + + return this._saveTask.arm(); + }, + + _data: null, + async _readData() { + let result = new Map(); + try { + Glean.extensions.startupCacheLoadTime.start(); + let { buffer } = await IOUtils.read(this.file); + + result = lazy.aomStartup.decodeBlob(buffer); + Glean.extensions.startupCacheLoadTime.stop(); + } catch (e) { + Glean.extensions.startupCacheLoadTime.cancel(); + if (!DOMException.isInstance(e) || e.name !== "NotFoundError") { + Cu.reportError(e); + } + let error = lazy.getErrorNameForTelemetry(e); + Glean.extensions.startupCacheReadErrors[error].add(1); + } + + this._data = result; + return result; + }, + + get dataPromise() { + if (!this._dataPromise) { + this._dataPromise = this._readData(); + } + return this._dataPromise; + }, + + clearAddonData(id) { + return Promise.all([ + this.general.delete(id), + this.locales.delete(id), + this.manifests.delete(id), + this.permissions.delete(id), + this.menus.delete(id), + ]).catch(e => { + // Ignore the error. It happens when we try to flush the add-on + // data after the AddonManager has flushed the entire startup cache. + }); + }, + + observe(subject, topic, data) { + if (topic === "startupcache-invalidate") { + this._data = new Map(); + this._dataPromise = Promise.resolve(this._data); + } + }, + + get(extension, path, createFunc) { + return this.general.get( + [extension.id, extension.version, ...path], + createFunc + ); + }, + + delete(extension, path) { + return this.general.delete([extension.id, extension.version, ...path]); + }, + + general: new CacheStore("general"), + locales: new CacheStore("locales"), + manifests: new CacheStore("manifests"), + other: new CacheStore("other"), + permissions: new CacheStore("permissions"), + schemas: new CacheStore("schemas"), + menus: new CacheStore("menus"), +}; + +Services.obs.addObserver(StartupCache, "startupcache-invalidate"); + +export var ExtensionParent = { + GlobalManager, + HiddenExtensionPage, + IconDetails, + ParentAPIManager, + StartupCache, + WebExtensionPolicy, + apiManager, + promiseBackgroundViewLoaded, + watchExtensionProxyContextLoad, + watchExtensionWorkerContextLoaded, + DebugUtils, +}; + +// browserPaintedPromise and browserStartupPromise are promises that +// resolve after the first browser window is painted and after browser +// windows have been restored, respectively. Alternatively, +// browserStartupPromise also resolves from the extensions-late-startup +// notification sent by Firefox Reality on desktop platforms, because it +// doesn't support SessionStore. +// _resetStartupPromises should only be called from outside this file in tests. +ExtensionParent._resetStartupPromises = () => { + ExtensionParent.browserPaintedPromise = promiseObserved( + "browser-delayed-startup-finished" + ).then(() => {}); + ExtensionParent.browserStartupPromise = Promise.race([ + promiseObserved("sessionstore-windows-restored"), + promiseObserved("extensions-late-startup"), + ]).then(() => {}); +}; +ExtensionParent._resetStartupPromises(); + +ChromeUtils.defineLazyGetter(ExtensionParent, "PlatformInfo", () => { + return Object.freeze({ + os: (function () { + let os = AppConstants.platform; + if (os == "macosx") { + os = "mac"; + } + return os; + })(), + arch: (function () { + let abi = Services.appinfo.XPCOMABI; + let [arch] = abi.split("-"); + if (arch == "x86") { + arch = "x86-32"; + } else if (arch == "x86_64") { + arch = "x86-64"; + } + return arch; + })(), + }); +}); diff --git a/toolkit/components/extensions/ExtensionPermissionMessages.sys.mjs b/toolkit/components/extensions/ExtensionPermissionMessages.sys.mjs new file mode 100644 index 0000000000..2edf3a5d7b --- /dev/null +++ b/toolkit/components/extensions/ExtensionPermissionMessages.sys.mjs @@ -0,0 +1,95 @@ +/* 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/. */ + +/** + * Localization object holding the fluent definitions of permission descriptions + * of WebExtension APIs defined in toolkit. + * + * This is exported to allow builds (e.g. Thunderbird) to extend or modify the + * object via its addResourceIds() method. + */ +export const PERMISSION_L10N = new Localization( + [ + "toolkit/global/extensions.ftl", + "toolkit/global/extensionPermissions.ftl", + "branding/brand.ftl", + ], + true +); + +/** + * List of permissions that are associated with a permission message. + * + * Keep this list in sync with: + * - The messages in `toolkit/locales/en-US/toolkit/global/extensionPermissions.ftl` + * - `permissionToTranslation` at https://github.com/mozilla-mobile/firefox-android/blob/d9c08c387917e3e53963386ad53229e69d52da6e/android-components/components/feature/addons/src/main/java/mozilla/components/feature/addons/Addon.kt#L174-L206 + * - https://extensionworkshop.com/documentation/develop/request-the-right-permissions/#advised-permissions + * - https://support.mozilla.org/en-US/kb/permission-request-messages-firefox-extensions + * + * This is exported to allow builds (e.g. Thunderbird) to extend or modify the set. + */ +export const PERMISSIONS_WITH_MESSAGE = new Set([ + "bookmarks", + "browserSettings", + "browsingData", + "clipboardRead", + "clipboardWrite", + "declarativeNetRequest", + "declarativeNetRequestFeedback", + "devtools", + "downloads", + "downloads.open", + "find", + "geolocation", + "history", + "management", + "nativeMessaging", + "notifications", + "pkcs11", + "privacy", + "proxy", + "sessions", + "tabs", + "tabHide", + "topSites", + "webNavigation", +]); + +/** + * Overrides for permission description l10n identifiers, + * which by default use the pattern `webext-perms-description-${permission}` + * where `permission` is sanitized to be a valid Fluent identifier. + * + * This is exported to allow builds (e.g. Thunderbird) to extend or modify the map. + */ +export const PERMISSION_L10N_ID_OVERRIDES = new Map(); + +/** + * Maps a permission name to its l10n identifier. + * + * Returns `null` for permissions not in `PERMISSIONS_WITH_MESSAGE`. + * + * The default `webext-perms-description-${permission}` mapping + * may be overridden by entries in `PERMISSION_L10N_ID_OVERRIDES`. + * + * @param {string} permission + * @returns {string | null} + */ +export function permissionToL10nId(permission) { + if (!PERMISSIONS_WITH_MESSAGE.has(permission)) { + return null; + } + + if (PERMISSION_L10N_ID_OVERRIDES.has(permission)) { + return PERMISSION_L10N_ID_OVERRIDES.get(permission); + } + + // Sanitize input to end up with a valid l10n id. + // E.g. "<all_urls>" to "all-urls", "downloads.open" to "downloads-open". + const sanitized = permission + .replace(/[^a-zA-Z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + + return `webext-perms-description-${sanitized}`; +} diff --git a/toolkit/components/extensions/ExtensionPermissions.sys.mjs b/toolkit/components/extensions/ExtensionPermissions.sys.mjs new file mode 100644 index 0000000000..8308a4369a --- /dev/null +++ b/toolkit/components/extensions/ExtensionPermissions.sys.mjs @@ -0,0 +1,804 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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 { computeSha1HashAsString } from "resource://gre/modules/addons/crypto-utils.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs", + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + JSONFile: "resource://gre/modules/JSONFile.sys.mjs", + KeyValueService: "resource://gre/modules/kvstore.sys.mjs", +}); + +ChromeUtils.defineLazyGetter( + lazy, + "StartupCache", + () => lazy.ExtensionParent.StartupCache +); + +ChromeUtils.defineLazyGetter( + lazy, + "Management", + () => lazy.ExtensionParent.apiManager +); + +function emptyPermissions() { + return { permissions: [], origins: [] }; +} + +const DEFAULT_VALUE = JSON.stringify(emptyPermissions()); + +const KEY_PREFIX = "id-"; + +// This is the old preference file pre-migration to rkv. +const OLD_JSON_FILENAME = "extension-preferences.json"; +// This is the old path to the rkv store dir (which used to be shared with ExtensionScriptingStore). +const OLD_RKV_DIRNAME = "extension-store"; +// This is the new path to the rkv store dir. +const RKV_DIRNAME = "extension-store-permissions"; + +const VERSION_KEY = "_version"; + +const VERSION_VALUE = 1; + +// Bug 1646182: remove once we fully migrate to rkv +let prefs; + +// Bug 1646182: remove once we fully migrate to rkv +class LegacyPermissionStore { + async lazyInit() { + if (!this._initPromise) { + this._initPromise = this._init(); + } + return this._initPromise; + } + + async _init() { + let path = PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + OLD_JSON_FILENAME + ); + + prefs = new lazy.JSONFile({ path }); + prefs.data = {}; + + try { + prefs.data = await IOUtils.readJSON(path); + } catch (e) { + if (!(DOMException.isInstance(e) && e.name == "NotFoundError")) { + Cu.reportError(e); + } + } + } + + async has(extensionId) { + await this.lazyInit(); + return !!prefs.data[extensionId]; + } + + async get(extensionId) { + await this.lazyInit(); + + let perms = prefs.data[extensionId]; + if (!perms) { + perms = emptyPermissions(); + } + + return perms; + } + + async put(extensionId, permissions) { + await this.lazyInit(); + prefs.data[extensionId] = permissions; + prefs.saveSoon(); + } + + async delete(extensionId) { + await this.lazyInit(); + if (prefs.data[extensionId]) { + delete prefs.data[extensionId]; + prefs.saveSoon(); + } + } + + async uninitForTest() { + if (!this._initPromise) { + return; + } + + await this._initPromise; + await prefs.finalize(); + prefs = null; + this._initPromise = null; + } + + async resetVersionForTest() { + throw new Error("Not supported"); + } +} + +class PermissionStore { + _shouldMigrateFromOldKVStorePath = AppConstants.NIGHTLY_BUILD; + + async _init() { + const storePath = lazy.FileUtils.getDir("ProfD", [RKV_DIRNAME]).path; + // Make sure the folder exists + await IOUtils.makeDirectory(storePath, { ignoreExisting: true }); + this._store = await lazy.KeyValueService.getOrCreate( + storePath, + "permissions" + ); + if (!(await this._store.has(VERSION_KEY))) { + // If _shouldMigrateFromOldKVStorePath is true (default only on Nightly channel + // where the rkv store has been enabled by default for a while), we need to check + // if we would need to import data from the old kvstore path (ProfD/extensions-store) + // first, and fallback to try to import from the JSONFile if there was no data in + // the old kvstore path. + // NOTE: _shouldMigrateFromOldKVStorePath is also explicitly set to true in unit tests + // that are meant to explicitly cover this path also when running on on non-Nightly channels. + if (this._shouldMigrateFromOldKVStorePath) { + // Try to import data from the old kvstore path (ProfD/extensions-store). + await this.maybeImportFromOldKVStorePath(); + if (!(await this._store.has(VERSION_KEY))) { + // There was no data in the old kvstore path, migrate any data + // available from the LegacyPermissionStore JSONFile if any. + await this.maybeMigrateDataFromOldJSONFile(); + } + } else { + // On non-Nightly channels, where LegacyPermissionStore was still the + // only backend ever enabled, try to import permissions data from the + // legacy JSONFile, if any data is available there. + await this.maybeMigrateDataFromOldJSONFile(); + } + } + } + + lazyInit() { + if (!this._initPromise) { + this._initPromise = this._init(); + } + return this._initPromise; + } + + validateMigratedData(json) { + let data = {}; + for (let [extensionId, permissions] of Object.entries(json)) { + // If both arrays are empty there's no need to include the value since + // it's the default + if ( + "permissions" in permissions && + "origins" in permissions && + (permissions.permissions.length || permissions.origins.length) + ) { + data[extensionId] = permissions; + } + } + return data; + } + + async maybeMigrateDataFromOldJSONFile() { + let migrationWasSuccessful = false; + let oldStore = PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + OLD_JSON_FILENAME + ); + try { + await this.migrateFrom(oldStore); + migrationWasSuccessful = true; + } catch (e) { + if (!(DOMException.isInstance(e) && e.name == "NotFoundError")) { + Cu.reportError(e); + } + } + + await this._store.put(VERSION_KEY, VERSION_VALUE); + + if (migrationWasSuccessful) { + IOUtils.remove(oldStore); + } + } + + async maybeImportFromOldKVStorePath() { + try { + const oldStorePath = lazy.FileUtils.getDir("ProfD", [ + OLD_RKV_DIRNAME, + ]).path; + if (!(await IOUtils.exists(oldStorePath))) { + return; + } + const oldStore = await lazy.KeyValueService.getOrCreate( + oldStorePath, + "permissions" + ); + const enumerator = await oldStore.enumerate(); + const kvpairs = []; + while (enumerator.hasMoreElements()) { + const { key, value } = enumerator.getNext(); + kvpairs.push([key, value]); + } + + // NOTE: we don't add a VERSION_KEY entry explicitly here because + // if the database was not empty the VERSION_KEY is already set to + // 1 and will be copied into the new file as part of the pairs + // written below (along with the entries for the actual extensions + // permissions). + if (kvpairs.length) { + await this._store.writeMany(kvpairs); + } + + // NOTE: the old rkv store path used to be shared with the + // ExtensionScriptingStore, and so we are not removing the old + // rkv store dir here (that is going to be left to a separate + // migration we will be adding to ExtensionScriptingStore). + } catch (err) { + Cu.reportError(err); + } + } + + async migrateFrom(oldStore) { + // Some other migration job might have started and not completed, let's + // start from scratch + await this._store.clear(); + + let json = await IOUtils.readJSON(oldStore); + let data = this.validateMigratedData(json); + + if (data) { + let entries = Object.entries(data).map(([extensionId, permissions]) => [ + this.makeKey(extensionId), + JSON.stringify(permissions), + ]); + if (entries.length) { + await this._store.writeMany(entries); + } + } + } + + makeKey(extensionId) { + // We do this so that the extensionId field cannot clash with internal + // fields like `_version` + return KEY_PREFIX + extensionId; + } + + async has(extensionId) { + await this.lazyInit(); + return this._store.has(this.makeKey(extensionId)); + } + + async get(extensionId) { + await this.lazyInit(); + return this._store + .get(this.makeKey(extensionId), DEFAULT_VALUE) + .then(JSON.parse); + } + + async put(extensionId, permissions) { + await this.lazyInit(); + return this._store.put( + this.makeKey(extensionId), + JSON.stringify(permissions) + ); + } + + async delete(extensionId) { + await this.lazyInit(); + return this._store.delete(this.makeKey(extensionId)); + } + + async resetVersionForTest() { + await this.lazyInit(); + return this._store.delete(VERSION_KEY); + } + + async uninitForTest() { + // Nothing special to do to unitialize, let's just + // make sure we're not in the middle of initialization + return this._initPromise; + } +} + +// Bug 1646182: turn on rkv on all channels +function createStore(useRkv = AppConstants.NIGHTLY_BUILD) { + if (useRkv) { + return new PermissionStore(); + } + return new LegacyPermissionStore(); +} + +let store = createStore(); + +export var ExtensionPermissions = { + async _update(extensionId, perms) { + await store.put(extensionId, perms); + return lazy.StartupCache.permissions.set(extensionId, perms); + }, + + async _get(extensionId) { + return store.get(extensionId); + }, + + async _getCached(extensionId) { + return lazy.StartupCache.permissions.get(extensionId, () => + this._get(extensionId) + ); + }, + + /** + * Retrieves the optional permissions for the given extension. + * The information may be retrieved from the StartupCache, and otherwise fall + * back to data from the disk (and cache the result in the StartupCache). + * + * @param {string} extensionId The extensionId + * @returns {object} An object with "permissions" and "origins" array. + * The object may be a direct reference to the storage or cache, so its + * value should immediately be used and not be modified by callers. + */ + get(extensionId) { + return this._getCached(extensionId); + }, + + _fixupAllUrlsPerms(perms) { + // Unfortunately, we treat <all_urls> as an API permission as well. + // If it is added to either, ensure it is added to both. + if (perms.origins.includes("<all_urls>")) { + perms.permissions.push("<all_urls>"); + } else if (perms.permissions.includes("<all_urls>")) { + perms.origins.push("<all_urls>"); + } + }, + + /** + * Add new permissions for the given extension. `permissions` is + * in the format that is passed to browser.permissions.request(). + * + * @param {string} extensionId The extension id + * @param {object} perms Object with permissions and origins array. + * @param {EventEmitter} [emitter] optional object implementing emitter interfaces + */ + async add(extensionId, perms, emitter) { + let { permissions, origins } = await this._get(extensionId); + + let added = emptyPermissions(); + + this._fixupAllUrlsPerms(perms); + + for (let perm of perms.permissions) { + if (!permissions.includes(perm)) { + added.permissions.push(perm); + permissions.push(perm); + } + } + + for (let origin of perms.origins) { + origin = new MatchPattern(origin, { ignorePath: true }).pattern; + if (!origins.includes(origin)) { + added.origins.push(origin); + origins.push(origin); + } + } + + if (added.permissions.length || added.origins.length) { + await this._update(extensionId, { permissions, origins }); + lazy.Management.emit("change-permissions", { extensionId, added }); + if (emitter) { + emitter.emit("add-permissions", added); + } + } + }, + + /** + * Revoke permissions from the given extension. `permissions` is + * in the format that is passed to browser.permissions.request(). + * + * @param {string} extensionId The extension id + * @param {object} perms Object with permissions and origins array. + * @param {EventEmitter} [emitter] optional object implementing emitter interfaces + */ + async remove(extensionId, perms, emitter) { + let { permissions, origins } = await this._get(extensionId); + + let removed = emptyPermissions(); + + this._fixupAllUrlsPerms(perms); + + for (let perm of perms.permissions) { + let i = permissions.indexOf(perm); + if (i >= 0) { + removed.permissions.push(perm); + permissions.splice(i, 1); + } + } + + for (let origin of perms.origins) { + origin = new MatchPattern(origin, { ignorePath: true }).pattern; + + let i = origins.indexOf(origin); + if (i >= 0) { + removed.origins.push(origin); + origins.splice(i, 1); + } + } + + if (removed.permissions.length || removed.origins.length) { + await this._update(extensionId, { permissions, origins }); + lazy.Management.emit("change-permissions", { extensionId, removed }); + if (emitter) { + emitter.emit("remove-permissions", removed); + } + } + }, + + async removeAll(extensionId) { + lazy.StartupCache.permissions.delete(extensionId); + + let removed = store.get(extensionId); + await store.delete(extensionId); + lazy.Management.emit("change-permissions", { + extensionId, + removed: await removed, + }); + }, + + // This is meant for tests only + async _has(extensionId) { + return store.has(extensionId); + }, + + // This is meant for tests only + async _resetVersion() { + await store.resetVersionForTest(); + }, + + // This is meant for tests only + _useLegacyStorageBackend: false, + + // This is meant for tests only + async _uninit({ recreateStore = true } = {}) { + await store?.uninitForTest(); + store = null; + if (recreateStore) { + store = createStore(!this._useLegacyStorageBackend); + } + }, + + // This is meant for tests only + _getStore() { + return store; + }, + + // Convenience listener members for all permission changes. + addListener(listener) { + lazy.Management.on("change-permissions", listener); + }, + + removeListener(listener) { + lazy.Management.off("change-permissions", listener); + }, +}; + +export var OriginControls = { + allDomains: new MatchPattern("*://*/*"), + + /** + * @typedef {object} OriginControlState + * @param {boolean} noAccess no options, can never access host. + * @param {boolean} whenClicked option to access host when clicked. + * @param {boolean} alwaysOn option to always access this host. + * @param {boolean} allDomains option to access to all domains. + * @param {boolean} hasAccess extension currently has access to host. + * @param {boolean} temporaryAccess extension has temporary access to the tab. + */ + + /** + * Get origin controls state for a given extension on a given tab. + * + * @param {WebExtensionPolicy} policy + * @param {NativeTab} nativeTab + * @returns {OriginControlState} Extension origin controls for this host include: + */ + getState(policy, nativeTab) { + // Note: don't use the nativeTab directly because it's different on mobile. + let tab = policy?.extension?.tabManager.getWrapper(nativeTab); + let temporaryAccess = tab?.hasActiveTabPermission; + let uri = tab?.browser.currentURI; + + if (!uri) { + return { noAccess: true }; + } + + // activeTab and the resulting whenClicked state is only applicable for MV2 + // extensions with a browser action and MV3 extensions (with or without). + let activeTab = + policy.permissions.includes("activeTab") && + (policy.manifestVersion >= 3 || policy.extension?.hasBrowserActionUI); + + let couldRequest = policy.extension.optionalOrigins.matches(uri); + let hasAccess = policy.canAccessURI(uri); + + // If any of (MV2) content script patterns match the URI. + let csPatternMatches = false; + let quarantinedFrom = policy.quarantinedFromURI(uri); + + if (policy.manifestVersion < 3 && !hasAccess) { + csPatternMatches = policy.contentScripts.some(cs => + cs.matches.patterns.some(p => p.matches(uri)) + ); + // MV2 access through content scripts is implicit. + hasAccess = csPatternMatches && !quarantinedFrom; + } + + // If extension is quarantined from this host, but could otherwise have + // access (via activeTab, optional, allowedOrigins or content scripts). + let quarantined = + quarantinedFrom && + (activeTab || + couldRequest || + csPatternMatches || + policy.allowedOrigins.matches(uri)); + + if ( + quarantined || + !this.allDomains.matches(uri) || + WebExtensionPolicy.isRestrictedURI(uri) || + (!couldRequest && !hasAccess && !activeTab) + ) { + return { noAccess: true, quarantined }; + } + + if (!couldRequest && !hasAccess && activeTab) { + return { whenClicked: true, temporaryAccess }; + } + if (policy.allowedOrigins.subsumes(this.allDomains)) { + return { allDomains: true, hasAccess }; + } + + return { + whenClicked: true, + alwaysOn: true, + temporaryAccess, + hasAccess, + }; + }, + + /** + * Whether to show the attention indicator for extension on current tab. We + * usually show attention when: + * + * - some permissions are needed (in MV3+) + * - the extension is not allowed on the domain (quarantined) + * + * @param {WebExtensionPolicy} policy an extension's policy + * @param {Window} window The window for which we can get the attention state + * @returns {{attention: boolean, quarantined: boolean}} + */ + getAttentionState(policy, window) { + if (policy?.manifestVersion >= 3) { + const state = this.getState(policy, window.gBrowser.selectedTab); + // quarantined is always false when the feature is disabled. + const quarantined = !!state.quarantined; + const attention = + quarantined || + (!!state.whenClicked && !state.hasAccess && !state.temporaryAccess); + + return { attention, quarantined }; + } + + // No need to check whether the Quarantined Domains feature is enabled + // here, it's already done in `getState()`. + const state = this.getState(policy, window.gBrowser.selectedTab); + const attention = !!state.quarantined; + // If it needs attention, it's because of the quarantined domains. + return { attention, quarantined: attention }; + }, + + // Grant extension host permission to always run on this host. + setAlwaysOn(policy, uri) { + if (!policy.active) { + return; + } + let perms = { permissions: [], origins: ["*://" + uri.host] }; + return ExtensionPermissions.add(policy.id, perms, policy.extension); + }, + + // Revoke permission, extension should run only when clicked on this host. + setWhenClicked(policy, uri) { + if (!policy.active) { + return; + } + let perms = { permissions: [], origins: ["*://" + uri.host] }; + return ExtensionPermissions.remove(policy.id, perms, policy.extension); + }, + + /** + * @typedef {object} FluentIdInfo + * @param {string} default the message ID corresponding to the state + * that should be displayed by default. + * @param {string | null} onHover an optional message ID to be shown when + * users hover interactive elements (e.g. a + * button). + */ + + /** + * Get origin controls messages (fluent IDs) to be shown to users for a given + * extension on a given host. The messages might be different for extensions + * with a browser action (that might or might not open a popup). + * + * @param {object} params + * @param {WebExtensionPolicy} params.policy an extension's policy + * @param {NativeTab} params.tab the current tab + * @param {boolean} params.isAction this should be true for + * extensions with a browser + * action, false otherwise. + * @param {boolean} params.hasPopup this should be true when the + * browser action opens a popup, + * false otherwise. + * + * @returns {FluentIdInfo?} An object with origin controls message IDs or + * `null` when there is no message for the state. + */ + getStateMessageIDs({ policy, tab, isAction = false, hasPopup = false }) { + const state = this.getState(policy, tab); + + const onHoverForAction = hasPopup + ? "origin-controls-state-runnable-hover-open" + : "origin-controls-state-runnable-hover-run"; + + if (state.noAccess) { + return { + default: state.quarantined + ? "origin-controls-state-quarantined" + : "origin-controls-state-no-access", + onHover: isAction ? onHoverForAction : null, + }; + } + + if (state.allDomains || (state.alwaysOn && state.hasAccess)) { + return { + default: "origin-controls-state-always-on", + onHover: isAction ? onHoverForAction : null, + }; + } + + if (state.whenClicked) { + return { + default: state.temporaryAccess + ? "origin-controls-state-temporary-access" + : "origin-controls-state-when-clicked", + onHover: "origin-controls-state-hover-run-visit-only", + }; + } + + return null; + }, +}; + +export var QuarantinedDomains = { + getUserAllowedAddonIdPrefName(addonId) { + return `${this.PREF_ADDONS_BRANCH_NAME}${addonId}`; + }, + isUserAllowedAddonId(addonId) { + return Services.prefs.getBoolPref( + this.getUserAllowedAddonIdPrefName(addonId), + false + ); + }, + setUserAllowedAddonIdPref(addonId, userAllowed) { + Services.prefs.setBoolPref( + this.getUserAllowedAddonIdPrefName(addonId), + userAllowed + ); + }, + clearUserPref(addonId) { + Services.prefs.clearUserPref(this.getUserAllowedAddonIdPrefName(addonId)); + }, + + // Implementation internals. + + PREF_ADDONS_BRANCH_NAME: `extensions.quarantineIgnoredByUser.`, + PREF_DOMAINSLIST_NAME: `extensions.quarantinedDomains.list`, + _initialized: false, + _init() { + if (this._initialized) { + return; + } + + const onUserAllowedPrefChanged = this._onUserAllowedPrefChanged.bind(this); + Services.prefs.addObserver( + this.PREF_ADDONS_BRANCH_NAME, + onUserAllowedPrefChanged + ); + + const onUpdatedDomainsListTelemetry = + this._onUpdatedDomainsListTelemetry.bind(this); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "currentDomainsList", + this.PREF_DOMAINSLIST_NAME, + "", + onUpdatedDomainsListTelemetry, + value => this._transformDomainsListPrefValue(value || "") + ); + // Collect it at least once per session (and update it when the pref value changes). + onUpdatedDomainsListTelemetry(); + + const onAMRemoteSettingsSetPref = + this._onAMRemoteSettingsSetPref.bind(this); + Services.obs.addObserver( + onAMRemoteSettingsSetPref, + "am-remote-settings-setpref" + ); + + this._initialized = true; + }, + async _onAMRemoteSettingsSetPref(subject, _topic) { + const { prefName, prefValue } = subject?.wrappedJSObject ?? {}; + if (prefName !== this.PREF_DOMAINSLIST_NAME) { + return; + } + Glean.extensionsQuarantinedDomains.remotehash.set( + computeSha1HashAsString(prefValue || "") + ); + }, + async _onUserAllowedPrefChanged(_subject, _topic, prefName) { + let addonId = prefName.slice(this.PREF_ADDONS_BRANCH_NAME.length); + // Sanity check. + if (!addonId || prefName !== this.getUserAllowedAddonIdPrefName(addonId)) { + return; + } + + // Notify listeners, e.g. to update details in TelemetryEnvironment. + const addon = await lazy.AddonManager.getAddonByID(addonId); + // Do not call onPropertyChanged listeners if the addon cannot be found + // anymore (e.g. it has been uninstalled). + if (addon) { + lazy.AddonManagerPrivate.callAddonListeners("onPropertyChanged", addon, [ + "quarantineIgnoredByUser", + ]); + } + }, + _onUpdatedDomainsListTelemetry(_subject, _topic, _prefName) { + Glean.extensionsQuarantinedDomains.listsize.set( + this.currentDomainsList.set.size + ); + Glean.extensionsQuarantinedDomains.listhash.set( + this.currentDomainsList.hash + ); + }, + _transformDomainsListPrefValue(value) { + try { + return { + // NOTE: using a sha1 hash to make sure the resulting string will fit into the + // unified telemetry scalar string the glean metrics is mirrored to (which is + // limited to 50 characters). + hash: computeSha1HashAsString(value || ""), + set: new Set( + value + .split(",") + .map(v => v.trim()) + .filter(v => v.length) + ), + }; + } catch (err) { + return { hash: "unexpected-error", set: new Set() }; + } + }, +}; +QuarantinedDomains._init(); + +// Constants exported for testing purpose. +export { + OLD_JSON_FILENAME, + OLD_RKV_DIRNAME, + RKV_DIRNAME, + VERSION_KEY, + VERSION_VALUE, +}; diff --git a/toolkit/components/extensions/ExtensionPolicyService.cpp b/toolkit/components/extensions/ExtensionPolicyService.cpp new file mode 100644 index 0000000000..031abca444 --- /dev/null +++ b/toolkit/components/extensions/ExtensionPolicyService.cpp @@ -0,0 +1,762 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "mozilla/ExtensionPolicyService.h" +#include "mozilla/extensions/DocumentObserver.h" +#include "mozilla/extensions/WebExtensionContentScript.h" +#include "mozilla/extensions/WebExtensionPolicy.h" + +#include "mozilla/BasePrincipal.h" +#include "mozilla/ClearOnShutdown.h" +#include "mozilla/Preferences.h" +#include "mozilla/ResultExtensions.h" +#include "mozilla/Services.h" +#include "mozilla/SimpleEnumerator.h" +#include "mozilla/StaticPrefs_extensions.h" +#include "mozilla/Try.h" +#include "mozilla/dom/BrowsingContext.h" +#include "mozilla/dom/BrowsingContextGroup.h" +#include "mozilla/dom/ContentChild.h" +#include "mozilla/dom/ContentParent.h" +#include "mozilla/dom/Promise.h" +#include "mozilla/dom/Promise-inl.h" +#include "mozIExtensionProcessScript.h" +#include "nsEscape.h" +#include "nsGkAtoms.h" +#include "nsHashKeys.h" +#include "nsIChannel.h" +#include "nsIContentPolicy.h" +#include "mozilla/dom/Document.h" +#include "nsGlobalWindowInner.h" +#include "nsILoadInfo.h" +#include "nsIXULRuntime.h" +#include "nsImportModule.h" +#include "nsNetUtil.h" +#include "nsPrintfCString.h" +#include "nsPIDOMWindow.h" +#include "nsXULAppAPI.h" +#include "nsQueryObject.h" + +namespace mozilla { + +using namespace extensions; + +using dom::AutoJSAPI; +using dom::Document; +using dom::Promise; + +#define DEFAULT_CSP_PREF \ + "extensions.webextensions.default-content-security-policy" +#define DEFAULT_DEFAULT_CSP "script-src 'self' 'wasm-unsafe-eval';" + +#define DEFAULT_CSP_PREF_V3 \ + "extensions.webextensions.default-content-security-policy.v3" +#define DEFAULT_DEFAULT_CSP_V3 "script-src 'self'; upgrade-insecure-requests;" + +#define RESTRICTED_DOMAINS_PREF "extensions.webextensions.restrictedDomains" + +#define QUARANTINED_DOMAINS_PREF "extensions.quarantinedDomains.list" +#define QUARANTINED_DOMAINS_ENABLED "extensions.quarantinedDomains.enabled" + +#define OBS_TOPIC_PRELOAD_SCRIPT "web-extension-preload-content-script" +#define OBS_TOPIC_LOAD_SCRIPT "web-extension-load-content-script" + +static const char kDocElementInserted[] = "initial-document-element-inserted"; + +/***************************************************************************** + * ExtensionPolicyService + *****************************************************************************/ + +using CoreByHostMap = nsTHashMap<nsCStringASCIICaseInsensitiveHashKey, + RefPtr<extensions::WebExtensionPolicyCore>>; + +static StaticRWLock sEPSLock; +static StaticAutoPtr<CoreByHostMap> sCoreByHost MOZ_GUARDED_BY(sEPSLock); +static StaticRefPtr<AtomSet> sRestrictedDomains MOZ_GUARDED_BY(sEPSLock); +static StaticRefPtr<AtomSet> sQuarantinedDomains MOZ_GUARDED_BY(sEPSLock); + +/* static */ +mozIExtensionProcessScript& ExtensionPolicyService::ProcessScript() { + static nsCOMPtr<mozIExtensionProcessScript> sProcessScript; + + MOZ_ASSERT(NS_IsMainThread()); + + if (MOZ_UNLIKELY(!sProcessScript)) { + sProcessScript = + do_ImportModule("resource://gre/modules/ExtensionProcessScript.jsm", + "ExtensionProcessScript"); + ClearOnShutdown(&sProcessScript); + } + return *sProcessScript; +} + +/* static */ ExtensionPolicyService& ExtensionPolicyService::GetSingleton() { + MOZ_ASSERT(NS_IsMainThread()); + + static RefPtr<ExtensionPolicyService> sExtensionPolicyService; + + if (MOZ_UNLIKELY(!sExtensionPolicyService)) { + sExtensionPolicyService = new ExtensionPolicyService(); + RegisterWeakMemoryReporter(sExtensionPolicyService); + ClearOnShutdown(&sExtensionPolicyService); + } + return *sExtensionPolicyService.get(); +} + +/* static */ +RefPtr<extensions::WebExtensionPolicyCore> +ExtensionPolicyService::GetCoreByHost(const nsACString& aHost) { + StaticAutoReadLock lock(sEPSLock); + return sCoreByHost ? sCoreByHost->Get(aHost) : nullptr; +} + +ExtensionPolicyService::ExtensionPolicyService() { + mObs = services::GetObserverService(); + MOZ_RELEASE_ASSERT(mObs); + + mDefaultCSP.SetIsVoid(true); + mDefaultCSPV3.SetIsVoid(true); + + RegisterObservers(); + + { + StaticAutoWriteLock lock(sEPSLock); + MOZ_DIAGNOSTIC_ASSERT(!sCoreByHost, + "ExtensionPolicyService created twice?"); + sCoreByHost = new CoreByHostMap(); + } + + UpdateRestrictedDomains(); + UpdateQuarantinedDomains(); +} + +ExtensionPolicyService::~ExtensionPolicyService() { + UnregisterWeakMemoryReporter(this); + + { + StaticAutoWriteLock lock(sEPSLock); + sCoreByHost = nullptr; + sRestrictedDomains = nullptr; + sQuarantinedDomains = nullptr; + } +} + +bool ExtensionPolicyService::UseRemoteExtensions() const { + static Maybe<bool> sRemoteExtensions; + if (MOZ_UNLIKELY(sRemoteExtensions.isNothing())) { + sRemoteExtensions = Some(StaticPrefs::extensions_webextensions_remote()); + } + return sRemoteExtensions.value() && BrowserTabsRemoteAutostart(); +} + +bool ExtensionPolicyService::IsExtensionProcess() const { + bool isRemote = UseRemoteExtensions(); + + if (isRemote && XRE_IsContentProcess()) { + auto& remoteType = dom::ContentChild::GetSingleton()->GetRemoteType(); + return remoteType == EXTENSION_REMOTE_TYPE; + } + return !isRemote && XRE_IsParentProcess(); +} + +bool ExtensionPolicyService::GetQuarantinedDomainsEnabled() const { + StaticAutoReadLock lock(sEPSLock); + return sQuarantinedDomains != nullptr; +} + +WebExtensionPolicy* ExtensionPolicyService::GetByURL(const URLInfo& aURL) { + if (aURL.Scheme() == nsGkAtoms::moz_extension) { + return GetByHost(aURL.Host()); + } + return nullptr; +} + +WebExtensionPolicy* ExtensionPolicyService::GetByHost( + const nsACString& aHost) const { + AssertIsOnMainThread(); + RefPtr<WebExtensionPolicyCore> core = GetCoreByHost(aHost); + return core ? core->GetMainThreadPolicy() : nullptr; +} + +void ExtensionPolicyService::GetAll( + nsTArray<RefPtr<WebExtensionPolicy>>& aResult) { + AppendToArray(aResult, mExtensions.Values()); +} + +bool ExtensionPolicyService::RegisterExtension(WebExtensionPolicy& aPolicy) { + bool ok = + (!GetByID(aPolicy.Id()) && !GetByHost(aPolicy.MozExtensionHostname())); + MOZ_ASSERT(ok); + + if (!ok) { + return false; + } + + mExtensions.InsertOrUpdate(aPolicy.Id(), RefPtr{&aPolicy}); + + { + StaticAutoWriteLock lock(sEPSLock); + sCoreByHost->InsertOrUpdate(aPolicy.MozExtensionHostname(), aPolicy.Core()); + } + return true; +} + +bool ExtensionPolicyService::UnregisterExtension(WebExtensionPolicy& aPolicy) { + bool ok = (GetByID(aPolicy.Id()) == &aPolicy && + GetByHost(aPolicy.MozExtensionHostname()) == &aPolicy); + MOZ_ASSERT(ok); + + if (!ok) { + return false; + } + + mExtensions.Remove(aPolicy.Id()); + + { + StaticAutoWriteLock lock(sEPSLock); + sCoreByHost->Remove(aPolicy.MozExtensionHostname()); + } + return true; +} + +bool ExtensionPolicyService::RegisterObserver(DocumentObserver& aObserver) { + bool inserted = false; + mObservers.LookupOrInsertWith(&aObserver, [&] { + inserted = true; + return RefPtr{&aObserver}; + }); + return inserted; +} + +bool ExtensionPolicyService::UnregisterObserver(DocumentObserver& aObserver) { + return mObservers.Remove(&aObserver); +} + +/***************************************************************************** + * nsIMemoryReporter + *****************************************************************************/ + +NS_IMETHODIMP +ExtensionPolicyService::CollectReports(nsIHandleReportCallback* aHandleReport, + nsISupports* aData, bool aAnonymize) { + for (const auto& ext : mExtensions.Values()) { + nsAtomCString id(ext->Id()); + + NS_ConvertUTF16toUTF8 name(ext->Name()); + name.ReplaceSubstring("\"", ""); + name.ReplaceSubstring("\\", ""); + + nsString url; + MOZ_TRY_VAR(url, ext->GetURL(u""_ns)); + + nsPrintfCString desc("Extension(id=%s, name=\"%s\", baseURL=%s)", id.get(), + name.get(), NS_ConvertUTF16toUTF8(url).get()); + desc.ReplaceChar('/', '\\'); + + nsCString path("extensions/"); + path.Append(desc); + + aHandleReport->Callback(""_ns, path, KIND_NONHEAP, UNITS_COUNT, 1, + "WebExtensions that are active in this session"_ns, + aData); + } + + return NS_OK; +} + +/***************************************************************************** + * Content script management + *****************************************************************************/ + +void ExtensionPolicyService::RegisterObservers() { + mObs->AddObserver(this, kDocElementInserted, false); + if (XRE_IsContentProcess()) { + mObs->AddObserver(this, "http-on-opening-request", false); + mObs->AddObserver(this, "document-on-opening-request", false); + } + + Preferences::AddStrongObserver(this, DEFAULT_CSP_PREF); + Preferences::AddStrongObserver(this, DEFAULT_CSP_PREF_V3); + Preferences::AddStrongObserver(this, RESTRICTED_DOMAINS_PREF); + Preferences::AddStrongObserver(this, QUARANTINED_DOMAINS_PREF); + Preferences::AddStrongObserver(this, QUARANTINED_DOMAINS_ENABLED); +} + +void ExtensionPolicyService::UnregisterObservers() { + mObs->RemoveObserver(this, kDocElementInserted); + if (XRE_IsContentProcess()) { + mObs->RemoveObserver(this, "http-on-opening-request"); + mObs->RemoveObserver(this, "document-on-opening-request"); + } + + Preferences::RemoveObserver(this, DEFAULT_CSP_PREF); + Preferences::RemoveObserver(this, DEFAULT_CSP_PREF_V3); + Preferences::RemoveObserver(this, RESTRICTED_DOMAINS_PREF); + Preferences::RemoveObserver(this, QUARANTINED_DOMAINS_PREF); + Preferences::RemoveObserver(this, QUARANTINED_DOMAINS_ENABLED); +} + +nsresult ExtensionPolicyService::Observe(nsISupports* aSubject, + const char* aTopic, + const char16_t* aData) { + if (!strcmp(aTopic, kDocElementInserted)) { + nsCOMPtr<Document> doc = do_QueryInterface(aSubject); + if (doc) { + CheckDocument(doc); + } + } else if (!strcmp(aTopic, "http-on-opening-request") || + !strcmp(aTopic, "document-on-opening-request")) { + nsCOMPtr<nsIChannel> chan = do_QueryInterface(aSubject); + if (chan) { + CheckRequest(chan); + } + } else if (!strcmp(aTopic, NS_PREFBRANCH_PREFCHANGE_TOPIC_ID)) { + const nsCString converted = NS_ConvertUTF16toUTF8(aData); + const char* pref = converted.get(); + if (!strcmp(pref, DEFAULT_CSP_PREF)) { + mDefaultCSP.SetIsVoid(true); + } else if (!strcmp(pref, DEFAULT_CSP_PREF_V3)) { + mDefaultCSPV3.SetIsVoid(true); + } else if (!strcmp(pref, RESTRICTED_DOMAINS_PREF)) { + UpdateRestrictedDomains(); + } else if (!strcmp(pref, QUARANTINED_DOMAINS_PREF) || + !strcmp(pref, QUARANTINED_DOMAINS_ENABLED)) { + UpdateQuarantinedDomains(); + } + } + return NS_OK; +} + +already_AddRefed<Promise> ExtensionPolicyService::ExecuteContentScript( + nsPIDOMWindowInner* aWindow, WebExtensionContentScript& aScript) { + if (!aWindow->IsCurrentInnerWindow()) { + return nullptr; + } + + RefPtr<Promise> promise; + ProcessScript().LoadContentScript(&aScript, aWindow, getter_AddRefs(promise)); + return promise.forget(); +} + +RefPtr<Promise> ExtensionPolicyService::ExecuteContentScripts( + JSContext* aCx, nsPIDOMWindowInner* aWindow, + const nsTArray<RefPtr<WebExtensionContentScript>>& aScripts) { + AutoTArray<RefPtr<Promise>, 8> promises; + + for (auto& script : aScripts) { + if (RefPtr<Promise> promise = ExecuteContentScript(aWindow, *script)) { + promises.AppendElement(std::move(promise)); + } + } + + RefPtr<Promise> promise = Promise::All(aCx, promises, IgnoreErrors()); + MOZ_RELEASE_ASSERT(promise); + return promise; +} + +// Use browser's MessageManagerGroup to decide if we care about it, to inject +// extension APIs or content scripts. Tabs use "browsers", and all custom +// extension browsers use "webext-browsers", including popups & sidebars, +// background & options pages, and xpcshell tests. +static bool IsTabOrExtensionBrowser(dom::BrowsingContext* aBC) { + const auto& group = aBC->Top()->GetMessageManagerGroup(); + bool rv = group == u"browsers"_ns || group == u"webext-browsers"_ns; + +#ifdef MOZ_THUNDERBIRD + // ...unless it's Thunderbird, which has extra groups for unrelated reasons. + rv = rv || group == u"single-site"_ns || group == u"single-page"_ns; +#endif + + return rv; +} + +static nsTArray<RefPtr<dom::BrowsingContext>> GetAllInProcessContentBCs() { + nsTArray<RefPtr<dom::BrowsingContext>> contentBCs; + nsTArray<RefPtr<dom::BrowsingContextGroup>> groups; + dom::BrowsingContextGroup::GetAllGroups(groups); + for (const auto& group : groups) { + for (const auto& toplevel : group->Toplevels()) { + if (!toplevel->IsContent() || toplevel->IsDiscarded() || + !IsTabOrExtensionBrowser(toplevel)) { + continue; + } + + toplevel->PreOrderWalk([&](dom::BrowsingContext* aContext) { + contentBCs.AppendElement(aContext); + }); + } + } + return contentBCs; +} + +nsresult ExtensionPolicyService::InjectContentScripts( + WebExtensionPolicy* aExtension) { + AutoJSAPI jsapi; + MOZ_ALWAYS_TRUE(jsapi.Init(xpc::PrivilegedJunkScope())); + + auto contentBCs = GetAllInProcessContentBCs(); + for (dom::BrowsingContext* bc : contentBCs) { + auto* win = bc->GetDOMWindow(); + + if (bc->Top()->IsDiscarded() || !win || !win->GetDocumentURI()) { + continue; + } + DocInfo docInfo(win); + + using RunAt = dom::ContentScriptRunAt; + namespace RunAtValues = dom::ContentScriptRunAtValues; + using Scripts = AutoTArray<RefPtr<WebExtensionContentScript>, 8>; + + Scripts scripts[RunAtValues::Count]; + + auto GetScripts = [&](RunAt aRunAt) -> Scripts&& { + static_assert(sizeof(aRunAt) == 1, "Our cast is wrong"); + return std::move(scripts[uint8_t(aRunAt)]); + }; + + for (const auto& script : aExtension->ContentScripts()) { + if (script->Matches(docInfo)) { + GetScripts(script->RunAt()).AppendElement(script); + } + } + + nsCOMPtr<nsPIDOMWindowInner> inner = win->GetCurrentInnerWindow(); + + MOZ_TRY(ExecuteContentScripts(jsapi.cx(), inner, + GetScripts(RunAt::Document_start)) + ->ThenWithCycleCollectedArgs( + [](JSContext* aCx, JS::Handle<JS::Value> aValue, + ErrorResult& aRv, ExtensionPolicyService* aSelf, + nsPIDOMWindowInner* aInner, Scripts&& aScripts) { + return aSelf->ExecuteContentScripts(aCx, aInner, aScripts) + .forget(); + }, + this, inner, GetScripts(RunAt::Document_end)) + .andThen([&](auto aPromise) { + return aPromise->ThenWithCycleCollectedArgs( + [](JSContext* aCx, JS::Handle<JS::Value> aValue, + ErrorResult& aRv, ExtensionPolicyService* aSelf, + nsPIDOMWindowInner* aInner, Scripts&& aScripts) { + return aSelf + ->ExecuteContentScripts(aCx, aInner, aScripts) + .forget(); + }, + this, inner, GetScripts(RunAt::Document_idle)); + })); + } + return NS_OK; +} + +// Checks a request for matching content scripts, and begins pre-loading them +// if necessary. +void ExtensionPolicyService::CheckRequest(nsIChannel* aChannel) { + nsCOMPtr<nsILoadInfo> loadInfo = aChannel->LoadInfo(); + auto loadType = loadInfo->GetExternalContentPolicyType(); + if (loadType != ExtContentPolicy::TYPE_DOCUMENT && + loadType != ExtContentPolicy::TYPE_SUBDOCUMENT) { + return; + } + + nsCOMPtr<nsIURI> uri; + if (NS_FAILED(aChannel->GetURI(getter_AddRefs(uri)))) { + return; + } + + CheckContentScripts({uri.get(), loadInfo}, true); +} + +static bool CheckParentFrames(nsPIDOMWindowOuter* aWindow, + WebExtensionPolicy& aPolicy) { + nsCOMPtr<nsIURI> aboutAddons; + if (NS_FAILED(NS_NewURI(getter_AddRefs(aboutAddons), "about:addons"))) { + return false; + } + nsCOMPtr<nsIURI> htmlAboutAddons; + if (NS_FAILED( + NS_NewURI(getter_AddRefs(htmlAboutAddons), + "chrome://mozapps/content/extensions/aboutaddons.html"))) { + return false; + } + + dom::WindowContext* wc = aWindow->GetCurrentInnerWindow()->GetWindowContext(); + while ((wc = wc->GetParentWindowContext())) { + if (!wc->IsInProcess()) { + return false; + } + + nsGlobalWindowInner* win = wc->GetInnerWindow(); + + auto* principal = BasePrincipal::Cast(win->GetPrincipal()); + if (principal->IsSystemPrincipal()) { + // The add-on manager is a special case, since it contains extension + // options pages in same-type <browser> frames. + nsIURI* uri = win->GetDocumentURI(); + bool equals; + if ((NS_SUCCEEDED(uri->Equals(aboutAddons, &equals)) && equals) || + (NS_SUCCEEDED(uri->Equals(htmlAboutAddons, &equals)) && equals)) { + return true; + } + } + + if (principal->AddonPolicy() != &aPolicy) { + return false; + } + } + + return true; +} + +// Checks a document, just after the document element has been inserted, for +// matching content scripts or extension principals, and loads them if +// necessary. +void ExtensionPolicyService::CheckDocument(Document* aDocument) { + nsCOMPtr<nsPIDOMWindowOuter> win = aDocument->GetWindow(); + if (win) { + if (!IsTabOrExtensionBrowser(win->GetBrowsingContext())) { + return; + } + + if (win->GetDocumentURI()) { + CheckContentScripts(win.get(), false); + } + + nsIPrincipal* principal = aDocument->NodePrincipal(); + + RefPtr<WebExtensionPolicy> policy = + BasePrincipal::Cast(principal)->AddonPolicy(); + if (policy) { + bool privileged = IsExtensionProcess() && CheckParentFrames(win, *policy); + + ProcessScript().InitExtensionDocument(policy, aDocument, privileged); + } + } +} + +void ExtensionPolicyService::CheckContentScripts(const DocInfo& aDocInfo, + bool aIsPreload) { + nsCOMPtr<nsPIDOMWindowInner> win; + if (!aIsPreload) { + win = aDocInfo.GetWindow()->GetCurrentInnerWindow(); + } + + nsTArray<RefPtr<WebExtensionContentScript>> scriptsToLoad; + + for (RefPtr<WebExtensionPolicy> policy : mExtensions.Values()) { + for (auto& script : policy->ContentScripts()) { + if (script->Matches(aDocInfo)) { + if (aIsPreload) { + ProcessScript().PreloadContentScript(script); + } else { + // Collect the content scripts to load instead of loading them + // right away (to prevent a loaded content script from being + // able to invalidate the iterator by triggering a call to + // policy->UnregisterContentScript while we are still iterating + // over all its content scripts). See Bug 1593240. + scriptsToLoad.AppendElement(script); + } + } + } + + for (auto& script : scriptsToLoad) { + if (!win->IsCurrentInnerWindow()) { + break; + } + + RefPtr<Promise> promise; + ProcessScript().LoadContentScript(script, win, getter_AddRefs(promise)); + } + + scriptsToLoad.ClearAndRetainStorage(); + } + + for (RefPtr<DocumentObserver> observer : mObservers.Values()) { + for (auto& matcher : observer->Matchers()) { + if (matcher->Matches(aDocInfo)) { + if (aIsPreload) { + observer->NotifyMatch(*matcher, aDocInfo.GetLoadInfo()); + } else { + observer->NotifyMatch(*matcher, aDocInfo.GetWindow()); + } + } + } + } +} + +/* static */ +RefPtr<AtomSet> ExtensionPolicyService::RestrictedDomains() { + StaticAutoReadLock lock(sEPSLock); + return sRestrictedDomains; +} + +/* static */ +RefPtr<AtomSet> ExtensionPolicyService::QuarantinedDomains() { + StaticAutoReadLock lock(sEPSLock); + return sQuarantinedDomains; +} + +void ExtensionPolicyService::UpdateRestrictedDomains() { + nsAutoCString eltsString; + Unused << Preferences::GetCString(RESTRICTED_DOMAINS_PREF, eltsString); + + AutoTArray<nsString, 32> elts; + for (const nsACString& elt : eltsString.Split(',')) { + elts.AppendElement(NS_ConvertUTF8toUTF16(elt)); + elts.LastElement().StripWhitespace(); + } + RefPtr<AtomSet> atomSet = new AtomSet(elts); + + StaticAutoWriteLock lock(sEPSLock); + sRestrictedDomains = atomSet; +} + +void ExtensionPolicyService::UpdateQuarantinedDomains() { + if (!Preferences::GetBool(QUARANTINED_DOMAINS_ENABLED)) { + StaticAutoWriteLock lock(sEPSLock); + sQuarantinedDomains = nullptr; + return; + } + + nsAutoCString eltsString; + AutoTArray<nsString, 32> elts; + if (NS_SUCCEEDED( + Preferences::GetCString(QUARANTINED_DOMAINS_PREF, eltsString))) { + for (const nsACString& elt : eltsString.Split(',')) { + elts.AppendElement(NS_ConvertUTF8toUTF16(elt)); + elts.LastElement().StripWhitespace(); + } + } + RefPtr<AtomSet> atomSet = new AtomSet(elts); + + StaticAutoWriteLock lock(sEPSLock); + sQuarantinedDomains = atomSet; +} + +/***************************************************************************** + * nsIAddonPolicyService + *****************************************************************************/ + +nsresult ExtensionPolicyService::GetDefaultCSP(nsAString& aDefaultCSP) { + if (mDefaultCSP.IsVoid()) { + nsresult rv = Preferences::GetString(DEFAULT_CSP_PREF, mDefaultCSP); + if (NS_FAILED(rv)) { + mDefaultCSP.AssignLiteral(DEFAULT_DEFAULT_CSP); + } + mDefaultCSP.SetIsVoid(false); + } + + aDefaultCSP.Assign(mDefaultCSP); + return NS_OK; +} + +nsresult ExtensionPolicyService::GetDefaultCSPV3(nsAString& aDefaultCSP) { + if (mDefaultCSPV3.IsVoid()) { + nsresult rv = Preferences::GetString(DEFAULT_CSP_PREF_V3, mDefaultCSPV3); + if (NS_FAILED(rv)) { + mDefaultCSPV3.AssignLiteral(DEFAULT_DEFAULT_CSP_V3); + } + mDefaultCSPV3.SetIsVoid(false); + } + + aDefaultCSP.Assign(mDefaultCSPV3); + return NS_OK; +} + +nsresult ExtensionPolicyService::GetBaseCSP(const nsAString& aAddonId, + nsAString& aResult) { + if (WebExtensionPolicy* policy = GetByID(aAddonId)) { + policy->GetBaseCSP(aResult); + return NS_OK; + } + return NS_ERROR_INVALID_ARG; +} + +nsresult ExtensionPolicyService::GetExtensionPageCSP(const nsAString& aAddonId, + nsAString& aResult) { + if (WebExtensionPolicy* policy = GetByID(aAddonId)) { + policy->GetExtensionPageCSP(aResult); + return NS_OK; + } + return NS_ERROR_INVALID_ARG; +} + +nsresult ExtensionPolicyService::GetGeneratedBackgroundPageUrl( + const nsACString& aHostname, nsACString& aResult) { + if (WebExtensionPolicy* policy = GetByHost(aHostname)) { + nsAutoCString url("data:text/html,"); + + nsCString html = policy->BackgroundPageHTML(); + nsAutoCString escaped; + + url.Append(NS_EscapeURL(html, esc_Minimal, escaped)); + + aResult = url; + return NS_OK; + } + return NS_ERROR_INVALID_ARG; +} + +nsresult ExtensionPolicyService::AddonHasPermission(const nsAString& aAddonId, + const nsAString& aPerm, + bool* aResult) { + if (WebExtensionPolicy* policy = GetByID(aAddonId)) { + *aResult = policy->HasPermission(aPerm); + return NS_OK; + } + return NS_ERROR_INVALID_ARG; +} + +nsresult ExtensionPolicyService::AddonMayLoadURI(const nsAString& aAddonId, + nsIURI* aURI, bool aExplicit, + bool* aResult) { + if (WebExtensionPolicy* policy = GetByID(aAddonId)) { + *aResult = policy->CanAccessURI(aURI, aExplicit); + return NS_OK; + } + return NS_ERROR_INVALID_ARG; +} + +nsresult ExtensionPolicyService::GetExtensionName(const nsAString& aAddonId, + nsAString& aName) { + if (WebExtensionPolicy* policy = GetByID(aAddonId)) { + aName.Assign(policy->Name()); + return NS_OK; + } + return NS_ERROR_INVALID_ARG; +} + +nsresult ExtensionPolicyService::SourceMayLoadExtensionURI( + nsIURI* aSourceURI, nsIURI* aExtensionURI, bool* aResult) { + URLInfo source(aSourceURI); + URLInfo url(aExtensionURI); + if (WebExtensionPolicy* policy = GetByURL(url)) { + *aResult = policy->SourceMayAccessPath(source, url.FilePath()); + return NS_OK; + } + return NS_ERROR_INVALID_ARG; +} + +nsresult ExtensionPolicyService::ExtensionURIToAddonId(nsIURI* aURI, + nsAString& aResult) { + if (WebExtensionPolicy* policy = GetByURL(aURI)) { + policy->GetId(aResult); + } else { + aResult.SetIsVoid(true); + } + return NS_OK; +} + +NS_IMPL_CYCLE_COLLECTION(ExtensionPolicyService, mExtensions, mObservers) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(ExtensionPolicyService) + NS_INTERFACE_MAP_ENTRY(nsIAddonPolicyService) + NS_INTERFACE_MAP_ENTRY(nsIObserver) + NS_INTERFACE_MAP_ENTRY(nsIMemoryReporter) + NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIAddonPolicyService) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(ExtensionPolicyService) +NS_IMPL_CYCLE_COLLECTING_RELEASE(ExtensionPolicyService) + +} // namespace mozilla diff --git a/toolkit/components/extensions/ExtensionPolicyService.h b/toolkit/components/extensions/ExtensionPolicyService.h new file mode 100644 index 0000000000..a767b48cd7 --- /dev/null +++ b/toolkit/components/extensions/ExtensionPolicyService.h @@ -0,0 +1,145 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_ExtensionPolicyService_h +#define mozilla_ExtensionPolicyService_h + +#include "mozilla/MemoryReporting.h" +#include "mozilla/extensions/WebExtensionPolicy.h" +#include "mozIExtensionProcessScript.h" +#include "nsCOMPtr.h" +#include "nsCycleCollectionParticipant.h" +#include "nsHashKeys.h" +#include "nsIAddonPolicyService.h" +#include "nsAtom.h" +#include "nsIMemoryReporter.h" +#include "nsIObserver.h" +#include "nsIObserverService.h" +#include "nsISupports.h" +#include "nsPointerHashKeys.h" +#include "nsRefPtrHashtable.h" +#include "nsTHashSet.h" +#include "nsAtomHashKeys.h" + +class nsIChannel; +class nsIObserverService; + +class nsIPIDOMWindowInner; +class nsIPIDOMWindowOuter; + +namespace mozilla { +namespace dom { +class Promise; +} // namespace dom +namespace extensions { +class DocInfo; +class DocumentObserver; +class WebExtensionContentScript; +} // namespace extensions + +using extensions::DocInfo; +using extensions::WebExtensionPolicy; + +class ExtensionPolicyService final : public nsIAddonPolicyService, + public nsIObserver, + public nsIMemoryReporter { + public: + NS_DECL_CYCLE_COLLECTION_CLASS_AMBIGUOUS(ExtensionPolicyService, + nsIAddonPolicyService) + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_NSIADDONPOLICYSERVICE + NS_DECL_NSIOBSERVER + NS_DECL_NSIMEMORYREPORTER + + static mozIExtensionProcessScript& ProcessScript(); + + static ExtensionPolicyService& GetSingleton(); + + // Helper for fetching an AtomSet of restricted domains as configured by the + // extensions.webextensions.restrictedDomains pref. Safe to call from any + // thread. + static RefPtr<extensions::AtomSet> RestrictedDomains(); + + // Thread-safe AtomSet from extensions.quarantinedDomains.list. + static RefPtr<extensions::AtomSet> QuarantinedDomains(); + + static already_AddRefed<ExtensionPolicyService> GetInstance() { + return do_AddRef(&GetSingleton()); + } + + // Unlike the other methods on the ExtensionPolicyService, this method is + // threadsafe, and can look up a WebExtensionPolicyCore by hostname on any + // thread. + static RefPtr<extensions::WebExtensionPolicyCore> GetCoreByHost( + const nsACString& aHost); + + WebExtensionPolicy* GetByID(nsAtom* aAddonId) { + return mExtensions.GetWeak(aAddonId); + } + + WebExtensionPolicy* GetByID(const nsAString& aAddonId) { + RefPtr<nsAtom> atom = NS_AtomizeMainThread(aAddonId); + return GetByID(atom); + } + + WebExtensionPolicy* GetByURL(const extensions::URLInfo& aURL); + + WebExtensionPolicy* GetByHost(const nsACString& aHost) const; + + void GetAll(nsTArray<RefPtr<WebExtensionPolicy>>& aResult); + + bool RegisterExtension(WebExtensionPolicy& aPolicy); + bool UnregisterExtension(WebExtensionPolicy& aPolicy); + + bool RegisterObserver(extensions::DocumentObserver& aPolicy); + bool UnregisterObserver(extensions::DocumentObserver& aPolicy); + + bool UseRemoteExtensions() const; + bool IsExtensionProcess() const; + bool GetQuarantinedDomainsEnabled() const; + + nsresult InjectContentScripts(WebExtensionPolicy* aExtension); + + protected: + virtual ~ExtensionPolicyService(); + + private: + ExtensionPolicyService(); + + void RegisterObservers(); + void UnregisterObservers(); + + void CheckRequest(nsIChannel* aChannel); + void CheckDocument(dom::Document* aDocument); + + void CheckContentScripts(const DocInfo& aDocInfo, bool aIsPreload); + + already_AddRefed<dom::Promise> ExecuteContentScript( + nsPIDOMWindowInner* aWindow, + extensions::WebExtensionContentScript& aScript); + + RefPtr<dom::Promise> ExecuteContentScripts( + JSContext* aCx, nsPIDOMWindowInner* aWindow, + const nsTArray<RefPtr<extensions::WebExtensionContentScript>>& aScripts); + + void UpdateRestrictedDomains(); + void UpdateQuarantinedDomains(); + + // The WebExtensionPolicy object keeps the key alive. + nsRefPtrHashtable<nsWeakAtomHashKey, WebExtensionPolicy> mExtensions; + + nsRefPtrHashtable<nsPtrHashKey<const extensions::DocumentObserver>, + extensions::DocumentObserver> + mObservers; + + nsCOMPtr<nsIObserverService> mObs; + + nsString mDefaultCSP; + nsString mDefaultCSPV3; +}; + +} // namespace mozilla + +#endif // mozilla_ExtensionPolicyService_h diff --git a/toolkit/components/extensions/ExtensionPreferencesManager.sys.mjs b/toolkit/components/extensions/ExtensionPreferencesManager.sys.mjs new file mode 100644 index 0000000000..b2b7bbac96 --- /dev/null +++ b/toolkit/components/extensions/ExtensionPreferencesManager.sys.mjs @@ -0,0 +1,712 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +/** + * @file + * This module is used for managing preferences from WebExtension APIs. + * It takes care of the precedence chain and decides whether a preference + * needs to be updated when a change is requested by an API. + * + * It deals with preferences via settings objects, which are objects with + * the following properties: + * + * prefNames: An array of strings, each of which is a preference on + * which the setting depends. + * setCallback: A function that returns an object containing properties and + * values that correspond to the prefs to be set. + */ + +export let ExtensionPreferencesManager; + +import { Management } from "resource://gre/modules/Extension.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs", + ExtensionSettingsStore: + "resource://gre/modules/ExtensionSettingsStore.sys.mjs", + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { ExtensionError } = ExtensionUtils; + +ChromeUtils.defineLazyGetter(lazy, "defaultPreferences", function () { + return new lazy.Preferences({ defaultBranch: true }); +}); + +/* eslint-disable mozilla/balanced-listeners */ +Management.on("uninstall", async (type, { id }) => { + // Ensure managed preferences are cleared if they were + // not cleared at the module level. + await Management.asyncLoadSettingsModules(); + return ExtensionPreferencesManager.removeAll(id); +}); + +Management.on("disable", async (type, id) => { + await Management.asyncLoadSettingsModules(); + return ExtensionPreferencesManager.disableAll(id); +}); + +Management.on("enabling", async (type, id) => { + await Management.asyncLoadSettingsModules(); + return ExtensionPreferencesManager.enableAll(id); +}); + +Management.on("change-permissions", (type, change) => { + // Called for added or removed, but we only care about removed here. + if (!change.removed) { + return; + } + ExtensionPreferencesManager.removeSettingsForPermissions( + change.extensionId, + change.removed.permissions + ); +}); + +/* eslint-enable mozilla/balanced-listeners */ + +const STORE_TYPE = "prefs"; + +// Definitions of settings, each of which correspond to a different API. +let settingsMap = new Map(); + +/** + * This function is passed into the ExtensionSettingsStore to determine the + * initial value of the setting. It reads an array of preference names from + * the this scope, which gets bound to a settings object. + * + * @returns {object} + * An object with one property per preference, which holds the current + * value of that preference. + */ +function initialValueCallback() { + let initialValue = {}; + for (let pref of this.prefNames) { + // If there is a prior user-set value, get it. + if (lazy.Preferences.isSet(pref)) { + initialValue[pref] = lazy.Preferences.get(pref); + } + } + return initialValue; +} + +/** + * Updates the initialValue stored to exclude any values that match + * default preference values. + * + * @param {object} initialValue Initial Value data from settings store. + * @returns {object} + * The initialValue object after updating the values. + */ +function settingsUpdate(initialValue) { + for (let pref of this.prefNames) { + try { + if ( + initialValue[pref] !== undefined && + initialValue[pref] === lazy.defaultPreferences.get(pref) + ) { + initialValue[pref] = undefined; + } + } catch (e) { + // Exception thrown if a default value doesn't exist. We + // presume that this pref had a user-set value initially. + } + } + return initialValue; +} + +/** + * Loops through a set of prefs, either setting or resetting them. + * + * @param {string} name + * The api name of the setting. + * @param {object} setting + * An object that represents a setting, which will have a setCallback + * property. If a onPrefsChanged function is provided it will be called + * with item when the preferences change. + * @param {object} item + * An object that represents an item handed back from the setting store + * from which the new pref values can be calculated. + */ +function setPrefs(name, setting, item) { + let prefs = item.initialValue || setting.setCallback(item.value); + let changed = false; + for (let pref of setting.prefNames) { + if (prefs[pref] === undefined) { + if (lazy.Preferences.isSet(pref)) { + changed = true; + lazy.Preferences.reset(pref); + } + } else if (lazy.Preferences.get(pref) != prefs[pref]) { + lazy.Preferences.set(pref, prefs[pref]); + changed = true; + } + } + if (changed && typeof setting.onPrefsChanged == "function") { + setting.onPrefsChanged(item); + } + Management.emit(`extension-setting-changed:${name}`); +} + +/** + * Commits a change to a setting and conditionally sets preferences. + * + * If the change to the setting causes a different extension to gain + * control of the pref (or removes all extensions with control over the pref) + * then the prefs should be updated, otherwise they should not be. + * In addition, if the current value of any of the prefs does not + * match what we expect the value to be (which could be the result of a + * user manually changing the pref value), then we do not change any + * of the prefs. + * + * @param {string} id + * The id of the extension for which a setting is being modified. Also + * see selectSetting. + * @param {string} name + * The name of the setting being processed. + * @param {string} action + * The action that is being performed. Will be one of disable, enable + * or removeSetting. + + * @returns {Promise} + * Resolves to true if preferences were set as a result and to false + * if preferences were not set. +*/ +async function processSetting(id, name, action) { + await lazy.ExtensionSettingsStore.initialize(); + let expectedItem = lazy.ExtensionSettingsStore.getSetting(STORE_TYPE, name); + let item = lazy.ExtensionSettingsStore[action](id, STORE_TYPE, name); + if (item) { + let setting = settingsMap.get(name); + let expectedPrefs = + expectedItem.initialValue || setting.setCallback(expectedItem.value); + if ( + Object.keys(expectedPrefs).some( + pref => + expectedPrefs[pref] && + lazy.Preferences.get(pref) != expectedPrefs[pref] + ) + ) { + return false; + } + setPrefs(name, setting, item); + return true; + } + return false; +} + +ExtensionPreferencesManager = { + /** + * Adds a setting to the settingsMap. This is how an API tells the + * preferences manager what its setting object is. The preferences + * manager needs to know this when settings need to be removed + * automatically. + * + * @param {string} name The unique id of the setting. + * @param {object} setting + * A setting object that should have properties for + * prefNames, getCallback and setCallback. + */ + addSetting(name, setting) { + settingsMap.set(name, setting); + }, + + /** + * Gets the default value for a preference. + * + * @param {string} prefName The name of the preference. + * + * @returns {string|number|boolean} The default value of the preference. + */ + getDefaultValue(prefName) { + return lazy.defaultPreferences.get(prefName); + }, + + /** + * Returns a map of prefName to setting Name for use in about:config, about:preferences or + * other areas of Firefox that need to know whether a specific pref is controlled by an + * extension. + * + * Given a prefName, you can get the settingName. Call EPM.getSetting(settingName) to + * get the details of the setting, including which id if any is in control of the + * setting. + * + * @returns {Promise} + * Resolves to a Map of prefName->settingName + */ + async getManagedPrefDetails() { + await Management.asyncLoadSettingsModules(); + let prefs = new Map(); + settingsMap.forEach((setting, name) => { + for (let prefName of setting.prefNames) { + prefs.set(prefName, name); + } + }); + return prefs; + }, + + /** + * Indicates that an extension would like to change the value of a previously + * defined setting. + * + * @param {string} id + * The id of the extension for which a setting is being set. + * @param {string} name + * The unique id of the setting. + * @param {any} value + * The value to be stored in the settings store for this + * group of preferences. + * + * @returns {Promise} + * Resolves to true if the preferences were changed and to false if + * the preferences were not changed. + */ + async setSetting(id, name, value) { + let setting = settingsMap.get(name); + await lazy.ExtensionSettingsStore.initialize(); + let item = await lazy.ExtensionSettingsStore.addSetting( + id, + STORE_TYPE, + name, + value, + initialValueCallback.bind(setting), + name, + settingsUpdate.bind(setting) + ); + if (item) { + setPrefs(name, setting, item); + return true; + } + return false; + }, + + /** + * Indicates that this extension wants to temporarily cede control over the + * given setting. + * + * @param {string} id + * The id of the extension for which a preference setting is being disabled. + * @param {string} name + * The unique id of the setting. + * + * @returns {Promise} + * Resolves to true if the preferences were changed and to false if + * the preferences were not changed. + */ + disableSetting(id, name) { + return processSetting(id, name, "disable"); + }, + + /** + * Enable a setting that has been disabled. + * + * @param {string} id + * The id of the extension for which a setting is being enabled. + * @param {string} name + * The unique id of the setting. + * + * @returns {Promise} + * Resolves to true if the preferences were changed and to false if + * the preferences were not changed. + */ + enableSetting(id, name) { + return processSetting(id, name, "enable"); + }, + + /** + * Specifically select an extension, the user, or the precedence order that will + * be in control of this setting. + * + * @param {string | null} id + * The id of the extension for which a setting is being selected, or + * ExtensionSettingStore.SETTING_USER_SET (null). + * @param {string} name + * The unique id of the setting. + * + * @returns {Promise} + * Resolves to true if the preferences were changed and to false if + * the preferences were not changed. + */ + selectSetting(id, name) { + return processSetting(id, name, "select"); + }, + + /** + * Indicates that this extension no longer wants to set the given setting. + * + * @param {string} id + * The id of the extension for which a preference setting is being removed. + * @param {string} name + * The unique id of the setting. + * + * @returns {Promise} + * Resolves to true if the preferences were changed and to false if + * the preferences were not changed. + */ + removeSetting(id, name) { + return processSetting(id, name, "removeSetting"); + }, + + /** + * Disables all previously set settings for an extension. This can be called when + * an extension is being disabled, for example. + * + * @param {string} id + * The id of the extension for which all settings are being unset. + */ + async disableAll(id) { + await lazy.ExtensionSettingsStore.initialize(); + let settings = lazy.ExtensionSettingsStore.getAllForExtension( + id, + STORE_TYPE + ); + let disablePromises = []; + for (let name of settings) { + disablePromises.push(this.disableSetting(id, name)); + } + await Promise.all(disablePromises); + }, + + /** + * Enables all disabled settings for an extension. This can be called when + * an extension has finished updating or is being re-enabled, for example. + * + * @param {string} id + * The id of the extension for which all settings are being enabled. + */ + async enableAll(id) { + await lazy.ExtensionSettingsStore.initialize(); + let settings = lazy.ExtensionSettingsStore.getAllForExtension( + id, + STORE_TYPE + ); + let enablePromises = []; + for (let name of settings) { + enablePromises.push(this.enableSetting(id, name)); + } + await Promise.all(enablePromises); + }, + + /** + * Removes all previously set settings for an extension. This can be called when + * an extension is being uninstalled, for example. + * + * @param {string} id + * The id of the extension for which all settings are being unset. + */ + async removeAll(id) { + await lazy.ExtensionSettingsStore.initialize(); + let settings = lazy.ExtensionSettingsStore.getAllForExtension( + id, + STORE_TYPE + ); + let removePromises = []; + for (let name of settings) { + removePromises.push(this.removeSetting(id, name)); + } + await Promise.all(removePromises); + }, + + /** + * Removes a set of settings that are available under certain addon permissions. + * + * @param {string} id + * The extension id. + * @param {Array<string>} permissions + * The permission name from the extension manifest. + * @returns {Promise} + * A promise that resolves when all related settings are removed. + */ + async removeSettingsForPermissions(id, permissions) { + if (!permissions || !permissions.length) { + return; + } + await Management.asyncLoadSettingsModules(); + let removePromises = []; + settingsMap.forEach((setting, name) => { + if (permissions.includes(setting.permission)) { + removePromises.push(this.removeSetting(id, name)); + } + }); + return Promise.all(removePromises); + }, + + /** + * Return the currently active value for a setting. + * + * @param {string} name + * The unique id of the setting. + * + * @returns {Promise<object>} The current setting object. + */ + async getSetting(name) { + await lazy.ExtensionSettingsStore.initialize(); + return lazy.ExtensionSettingsStore.getSetting(STORE_TYPE, name); + }, + + /** + * Return the levelOfControl for a setting / extension combo. + * This queries the levelOfControl from the ExtensionSettingsStore and also + * takes into account whether any of the setting's preferences are locked. + * + * @param {string} id + * The id of the extension for which levelOfControl is being requested. + * @param {string} name + * The unique id of the setting. + * @param {string} storeType + * The name of the store in ExtensionSettingsStore. + * Defaults to STORE_TYPE. + * + * @returns {Promise} + * Resolves to the level of control of the extension over the setting. + */ + async getLevelOfControl(id, name, storeType = STORE_TYPE) { + // This could be called for a setting that isn't defined to the PreferencesManager, + // in which case we simply defer to the SettingsStore. + if (storeType === STORE_TYPE) { + let setting = settingsMap.get(name); + if (!setting) { + return "not_controllable"; + } + for (let prefName of setting.prefNames) { + if (lazy.Preferences.locked(prefName)) { + return "not_controllable"; + } + } + } + await lazy.ExtensionSettingsStore.initialize(); + return lazy.ExtensionSettingsStore.getLevelOfControl(id, storeType, name); + }, + + /** + * Returns an API object with get/set/clear used for a setting. + * + * @param {string|object} extensionId or params object + * @param {string} name + * The unique id of the setting. + * @param {Function} callback + * The function that retreives the current setting from prefs. + * @param {string} storeType + * The name of the store in ExtensionSettingsStore. + * Defaults to STORE_TYPE. + * @param {boolean} readOnly + * @param {Function} validate + * Utility function for any specific validation, such as checking + * for supported platform. Function should throw an error if necessary. + * + * @returns {object} API object with get/set/clear methods + */ + getSettingsAPI( + extensionId, + name, + callback, + storeType, + readOnly = false, + validate + ) { + if (arguments.length > 1) { + Services.console.logStringMessage( + `ExtensionPreferencesManager.getSettingsAPI for ${name} should be updated to use a single paramater object.` + ); + } + return ExtensionPreferencesManager._getInternalSettingsAPI( + arguments.length === 1 + ? extensionId + : { + extensionId, + name, + callback, + storeType, + readOnly, + validate, + } + ).api; + }, + + /** + * getPrimedSettingsListener returns a function used to create + * a primed event listener. + * + * If a module overrides onChange then it must provide it's own + * persistent listener logic. See homepage_override in browserSettings + * for an example. + * + * addSetting must be called prior to priming listeners. + * + * @param {object} config see getSettingsAPI + * {Extension} extension, passed through to validate and used for extensionId + * {string} name + * The unique id of the settings api in the module, e.g. "settings" + * @returns {object} prime listener object + */ + getPrimedSettingsListener(config) { + let { name, extension } = config; + if (!name || !extension) { + throw new Error( + `name and extension are required for getPrimedSettingListener` + ); + } + if (!settingsMap.get(name)) { + throw new Error( + `addSetting must be called prior to getPrimedSettingListener` + ); + } + return ExtensionPreferencesManager._getInternalSettingsAPI({ + name, + extension, + }).registerEvent; + }, + + /** + * Returns an object with a public API containing get/set/clear used for a setting, + * and a registerEvent function used for registering the event listener. + * + * @param {object} params The params object contains the following: + * {BaseContext} context + * {Extension} extension, optional, passed through to validate and used for extensionId + * {string} extensionId, optional to support old API + * {string} module + * The name of the api module, e.g. "proxy" + * {string} name + * The unique id of the settings api in the module, e.g. "settings" + * "name" should match the name given in the addSetting call. + * {Function} callback + * The function that retreives the current setting from prefs. + * {string} storeType + * The name of the store in ExtensionSettingsStore. + * Defaults to STORE_TYPE. + * {boolean} readOnly + * {Function} validate + * Utility function for any specific validation, such as checking + * for supported platform. Function should throw an error if necessary. + * + * @returns {object} internal API object with + * {object} api + * the public api available to extensions + * {Function} registerEvent + * the registration function used for priming events + */ + _getInternalSettingsAPI(params) { + let { + extensionId, + context, + extension, + module, + name, + callback, + storeType, + readOnly = false, + onChange, + validate, + } = params; + if (context) { + extension = context.extension; + } + if (!extensionId && extension) { + extensionId = extension.id; + } + + const checkScope = details => { + let { scope } = details; + if (scope && scope !== "regular") { + throw new ExtensionError( + `Firefox does not support the ${scope} settings scope.` + ); + } + }; + + // Check the setting for anything we may need. + let setting = settingsMap.get(name); + readOnly = readOnly || !!setting?.readOnly; + validate = validate || setting?.validate || (() => {}); + let getValue = callback || setting?.getCallback; + if (!getValue || typeof getValue !== "function") { + throw new Error(`Invalid get callback for setting ${name} in ${module}`); + } + + let settingsAPI = { + async get(details) { + validate(extension); + let levelOfControl = details.incognito + ? "not_controllable" + : await ExtensionPreferencesManager.getLevelOfControl( + extensionId, + name, + storeType + ); + levelOfControl = + readOnly && levelOfControl === "controllable_by_this_extension" + ? "not_controllable" + : levelOfControl; + return { + levelOfControl, + value: await getValue(), + }; + }, + set(details) { + validate(extension); + checkScope(details); + if (!readOnly) { + return ExtensionPreferencesManager.setSetting( + extensionId, + name, + details.value + ); + } + return false; + }, + clear(details) { + validate(extension); + checkScope(details); + if (!readOnly) { + return ExtensionPreferencesManager.removeSetting(extensionId, name); + } + return false; + }, + onChange, + }; + let registerEvent = fire => { + let listener = async () => { + fire.async(await settingsAPI.get({})); + }; + Management.on(`extension-setting-changed:${name}`, listener); + return { + unregister: () => { + Management.off(`extension-setting-changed:${name}`, listener); + }, + convert(_fire) { + fire = _fire; + }, + }; + }; + + // Any caller using the old call signature will not have passed + // context to us. This should only be experimental addons in the + // wild. + if (onChange === undefined && context) { + // Some settings that are read-only may not have called addSetting, in + // which case we have no way to listen on the pref changes. + if (setting) { + settingsAPI.onChange = new lazy.ExtensionCommon.EventManager({ + context, + module, + event: name, + name: `${name}.onChange`, + register: fire => { + return registerEvent(fire).unregister; + }, + }).api(); + } else { + Services.console.logStringMessage( + `ExtensionPreferencesManager API ${name} created but addSetting was not called.` + ); + } + } + return { api: settingsAPI, registerEvent }; + }, +}; diff --git a/toolkit/components/extensions/ExtensionProcessScript.sys.mjs b/toolkit/components/extensions/ExtensionProcessScript.sys.mjs new file mode 100644 index 0000000000..2fcf113a88 --- /dev/null +++ b/toolkit/components/extensions/ExtensionProcessScript.sys.mjs @@ -0,0 +1,525 @@ +/* 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 script contains the minimum, skeleton content process code that we need + * in order to lazily load other extension modules when they are first + * necessary. Anything which is not likely to be needed immediately, or shortly + * after startup, in *every* browser process live outside of this file. + */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionChild: "resource://gre/modules/ExtensionChild.sys.mjs", + ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs", + ExtensionContent: "resource://gre/modules/ExtensionContent.sys.mjs", + ExtensionPageChild: "resource://gre/modules/ExtensionPageChild.sys.mjs", + ExtensionWorkerChild: "resource://gre/modules/ExtensionWorkerChild.sys.mjs", + Schemas: "resource://gre/modules/Schemas.sys.mjs", +}); + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { DefaultWeakMap } = ExtensionUtils; + +const { sharedData } = Services.cpmm; + +function getData(extension, key = "") { + return sharedData.get(`extension/${extension.id}/${key}`); +} + +// We need to avoid touching Services.appinfo here in order to prevent +// the wrong version from being cached during xpcshell test startup. +// eslint-disable-next-line mozilla/use-services +ChromeUtils.defineLazyGetter(lazy, "isContentProcess", () => { + return Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT; +}); + +ChromeUtils.defineLazyGetter(lazy, "isContentScriptProcess", () => { + return ( + lazy.isContentProcess || + !WebExtensionPolicy.useRemoteWebExtensions || + // Thunderbird still loads some content in the parent process. + AppConstants.MOZ_APP_NAME == "thunderbird" + ); +}); + +var extensions = new DefaultWeakMap(policy => { + return new lazy.ExtensionChild.BrowserExtensionContent(policy); +}); + +var pendingExtensions = new Map(); + +var ExtensionManager; + +ExtensionManager = { + // WeakMap<WebExtensionPolicy, Map<number, WebExtensionContentScript>> + registeredContentScripts: new DefaultWeakMap(policy => new Map()), + + init() { + Services.cpmm.addMessageListener("Extension:Startup", this); + Services.cpmm.addMessageListener("Extension:Shutdown", this); + Services.cpmm.addMessageListener("Extension:FlushJarCache", this); + Services.cpmm.addMessageListener("Extension:RegisterContentScripts", this); + Services.cpmm.addMessageListener( + "Extension:UnregisterContentScripts", + this + ); + Services.cpmm.addMessageListener("Extension:UpdateContentScripts", this); + Services.cpmm.addMessageListener("Extension:UpdatePermissions", this); + Services.cpmm.addMessageListener("Extension:UpdateIgnoreQuarantine", this); + + this.updateStubExtensions(); + + for (let id of sharedData.get("extensions/activeIDs") || []) { + this.initExtension(getData({ id })); + } + }, + + initStubPolicy(id, data) { + let resolveReadyPromise; + let readyPromise = new Promise(resolve => { + resolveReadyPromise = resolve; + }); + + let policy = new WebExtensionPolicy({ + id, + localizeCallback() {}, + readyPromise, + allowedOrigins: new MatchPatternSet([]), + ...data, + }); + + try { + policy.active = true; + + pendingExtensions.set(id, { policy, resolveReadyPromise }); + } catch (e) { + Cu.reportError(e); + } + }, + + updateStubExtensions() { + for (let [id, data] of sharedData.get("extensions/pending") || []) { + if (!pendingExtensions.has(id)) { + this.initStubPolicy(id, data); + } + } + }, + + initExtensionPolicy(extension) { + let policy = WebExtensionPolicy.getByID(extension.id); + if (!policy || pendingExtensions.has(extension.id)) { + let localizeCallback; + if (extension.localize) { + // We have a real Extension object. + localizeCallback = extension.localize.bind(extension); + } else { + // We have serialized extension data; + localizeCallback = str => extensions.get(policy).localize(str); + } + + let { backgroundScripts } = extension; + if (!backgroundScripts && WebExtensionPolicy.isExtensionProcess) { + ({ backgroundScripts } = getData(extension, "extendedData") || {}); + } + + let { backgroundWorkerScript } = extension; + if (!backgroundWorkerScript && WebExtensionPolicy.isExtensionProcess) { + ({ backgroundWorkerScript } = getData(extension, "extendedData") || {}); + } + + let { backgroundTypeModule } = extension; + if ( + backgroundTypeModule == null && + WebExtensionPolicy.isExtensionProcess + ) { + ({ backgroundTypeModule } = getData(extension, "extendedData") || {}); + } + + policy = new WebExtensionPolicy({ + id: extension.id, + mozExtensionHostname: extension.uuid, + name: extension.name, + type: extension.type, + baseURL: extension.resourceURL, + + isPrivileged: extension.isPrivileged, + ignoreQuarantine: extension.ignoreQuarantine, + temporarilyInstalled: extension.temporarilyInstalled, + permissions: extension.permissions, + allowedOrigins: extension.allowedOrigins, + webAccessibleResources: extension.webAccessibleResources, + + manifestVersion: extension.manifestVersion, + extensionPageCSP: extension.extensionPageCSP, + + localizeCallback, + + backgroundScripts, + backgroundWorkerScript, + backgroundTypeModule, + + contentScripts: extension.contentScripts, + }); + + policy.debugName = `${JSON.stringify(policy.name)} (ID: ${ + policy.id + }, ${policy.getURL()})`; + + // Register any existent dynamically registered content script for the extension + // when a content process is started for the first time (which also cover + // a content process that crashed and it has been recreated). + const registeredContentScripts = + this.registeredContentScripts.get(policy); + + for (let [scriptId, options] of getData(extension, "contentScripts") || + []) { + const script = new WebExtensionContentScript(policy, options); + + // If the script is a userScript, add the additional userScriptOptions + // property to the WebExtensionContentScript instance. + if ("userScriptOptions" in options) { + script.userScriptOptions = options.userScriptOptions; + } + + policy.registerContentScript(script); + registeredContentScripts.set(scriptId, script); + } + + let stub = pendingExtensions.get(extension.id); + if (stub) { + pendingExtensions.delete(extension.id); + stub.policy.active = false; + stub.resolveReadyPromise(policy); + } + + policy.active = true; + policy.instanceId = extension.instanceId; + policy.optionalPermissions = extension.optionalPermissions; + } + return policy; + }, + + initExtension(data) { + if (typeof data === "string") { + data = getData({ id: data }); + } + let policy = this.initExtensionPolicy(data); + + policy.injectContentScripts(); + }, + + handleEvent(event) { + if ( + event.type === "change" && + event.changedKeys.includes("extensions/pending") + ) { + this.updateStubExtensions(); + } + }, + + receiveMessage({ name, data }) { + try { + switch (name) { + case "Extension:Startup": + this.initExtension(data); + break; + + case "Extension:Shutdown": { + let policy = WebExtensionPolicy.getByID(data.id); + if (policy) { + if (extensions.has(policy)) { + extensions.get(policy).shutdown(); + } + + if (lazy.isContentProcess) { + policy.active = false; + } + } + break; + } + + case "Extension:FlushJarCache": + ExtensionUtils.flushJarCache(data.path); + break; + + case "Extension:RegisterContentScripts": { + let policy = WebExtensionPolicy.getByID(data.id); + + if (policy) { + const registeredContentScripts = + this.registeredContentScripts.get(policy); + + for (const { scriptId, options } of data.scripts) { + const type = + "userScriptOptions" in options ? "userScript" : "contentScript"; + + if (registeredContentScripts.has(scriptId)) { + Cu.reportError( + new Error( + `Registering ${type} ${scriptId} on ${data.id} more than once` + ) + ); + } else { + const script = new WebExtensionContentScript(policy, options); + + // If the script is a userScript, add the additional + // userScriptOptions property to the WebExtensionContentScript + // instance. + if (type === "userScript") { + script.userScriptOptions = options.userScriptOptions; + } + + policy.registerContentScript(script); + registeredContentScripts.set(scriptId, script); + } + } + } + break; + } + + case "Extension:UnregisterContentScripts": { + let policy = WebExtensionPolicy.getByID(data.id); + + if (policy) { + const registeredContentScripts = + this.registeredContentScripts.get(policy); + + for (const scriptId of data.scriptIds) { + const script = registeredContentScripts.get(scriptId); + if (script) { + policy.unregisterContentScript(script); + registeredContentScripts.delete(scriptId); + } + } + } + break; + } + + case "Extension:UpdateContentScripts": { + let policy = WebExtensionPolicy.getByID(data.id); + + if (policy) { + const registeredContentScripts = + this.registeredContentScripts.get(policy); + + for (const { scriptId, options } of data.scripts) { + const oldScript = registeredContentScripts.get(scriptId); + const newScript = new WebExtensionContentScript(policy, options); + + policy.unregisterContentScript(oldScript); + policy.registerContentScript(newScript); + registeredContentScripts.set(scriptId, newScript); + } + } + break; + } + + case "Extension:UpdatePermissions": { + let policy = WebExtensionPolicy.getByID(data.id); + if (!policy) { + break; + } + // In the parent process, Extension.jsm updates the policy. + if (lazy.isContentProcess) { + lazy.ExtensionCommon.updateAllowedOrigins( + policy, + data.origins, + data.add + ); + + if (data.permissions.length) { + let perms = new Set(policy.permissions); + for (let perm of data.permissions) { + if (data.add) { + perms.add(perm); + } else { + perms.delete(perm); + } + } + policy.permissions = perms; + } + } + + if (data.permissions.length && extensions.has(policy)) { + // Notify ChildApiManager of permission changes. + extensions.get(policy).emit("update-permissions"); + } + break; + } + + case "Extension:UpdateIgnoreQuarantine": { + let policy = WebExtensionPolicy.getByID(data.id); + if (policy?.active) { + policy.ignoreQuarantine = data.ignoreQuarantine; + } + break; + } + } + } catch (e) { + Cu.reportError(e); + } + Services.cpmm.sendAsyncMessage(`${name}Complete`); + }, +}; + +export var ExtensionProcessScript = { + extensions, + + initExtension(extension) { + return ExtensionManager.initExtensionPolicy(extension); + }, + + initExtensionDocument(policy, doc, privileged) { + let extension = extensions.get(policy); + if (privileged) { + lazy.ExtensionPageChild.initExtensionContext(extension, doc.defaultView); + } else { + lazy.ExtensionContent.initExtensionContext(extension, doc.defaultView); + } + }, + + getExtensionChild(id) { + let policy = WebExtensionPolicy.getByID(id); + if (policy) { + return extensions.get(policy); + } + }, + + preloadContentScript(contentScript) { + if (lazy.isContentScriptProcess) { + lazy.ExtensionContent.contentScripts.get(contentScript).preload(); + } + }, + + loadContentScript(contentScript, window) { + return lazy.ExtensionContent.contentScripts + .get(contentScript) + .injectInto(window); + }, +}; + +export var ExtensionAPIRequestHandler = { + initExtensionWorker(policy, serviceWorkerInfo) { + let extension = extensions.get(policy); + + if (!extension) { + throw new Error(`Extension instance not found for addon ${policy.id}`); + } + + lazy.ExtensionWorkerChild.initExtensionWorkerContext( + extension, + serviceWorkerInfo + ); + }, + + onExtensionWorkerLoaded(policy, serviceWorkerDescriptorId) { + lazy.ExtensionWorkerChild.notifyExtensionWorkerContextLoaded( + serviceWorkerDescriptorId, + policy + ); + }, + + onExtensionWorkerDestroyed(policy, serviceWorkerDescriptorId) { + lazy.ExtensionWorkerChild.destroyExtensionWorkerContext( + serviceWorkerDescriptorId + ); + }, + + handleAPIRequest(policy, request) { + let context; + + try { + let extension = extensions.get(policy); + + if (!extension) { + throw new Error(`Extension instance not found for addon ${policy.id}`); + } + + context = this.getExtensionContextForAPIRequest({ + extension, + request, + }); + + if (!context) { + throw new Error( + `Extension context not found for API request: ${request}` + ); + } + + // Add a property to the request object for the normalizedArgs. + request.normalizedArgs = this.validateAndNormalizeRequestArgs({ + context, + request, + }); + + return context.childManager.handleWebIDLAPIRequest(request); + } catch (error) { + // Propagate errors related to parameter validation when the error object + // belongs to the extension context that initiated the call. + if (context?.Error && error instanceof context.Error) { + return { + type: Ci.mozIExtensionAPIRequestResult.EXTENSION_ERROR, + value: error, + }; + } + // Do not propagate errors that are not meant to be accessible to the + // extension, report it to the console and just throw the generic + // "An unexpected error occurred". + Cu.reportError(error); + return { + type: Ci.mozIExtensionAPIRequestResult.EXTENSION_ERROR, + value: new Error("An unexpected error occurred"), + }; + } + }, + + getExtensionContextForAPIRequest({ extension, request }) { + if (request.serviceWorkerInfo) { + return lazy.ExtensionWorkerChild.getExtensionWorkerContext( + extension, + request.serviceWorkerInfo + ); + } + + return null; + }, + + validateAndNormalizeRequestArgs({ context, request }) { + if ( + !lazy.Schemas.checkPermissions(request.apiNamespace, context.extension) + ) { + throw new context.Error( + `Not enough privileges to access ${request.apiNamespace}` + ); + } + if (request.requestType === "getProperty") { + return []; + } + + if (request.apiObjectType) { + // skip parameter validation on request targeting an api object, + // even the JS-based implementation of the API objects are not + // going through the same kind of Schema based validation that + // the API namespaces methods and events go through. + // + // TODO(Bug 1728535): validate and normalize also this request arguments + // as a low priority follow up. + return request.args; + } + + // Validate and normalize parameters, set the normalized args on the + // mozIExtensionAPIRequest normalizedArgs property. + return lazy.Schemas.checkWebIDLRequestParameters( + context.childManager, + request + ); + }, +}; + +ExtensionManager.init(); diff --git a/toolkit/components/extensions/ExtensionScriptingStore.sys.mjs b/toolkit/components/extensions/ExtensionScriptingStore.sys.mjs new file mode 100644 index 0000000000..444af8e41f --- /dev/null +++ b/toolkit/components/extensions/ExtensionScriptingStore.sys.mjs @@ -0,0 +1,351 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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 { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +import { ExtensionParent } from "resource://gre/modules/ExtensionParent.sys.mjs"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const { StartupCache } = ExtensionParent; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + KeyValueService: "resource://gre/modules/kvstore.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "matchAboutBlankDefaultFalse", + "extensions.scripting.matchAboutBlankDefaultFalse", + false +); + +class Store { + async _init() { + const { path: storePath } = lazy.FileUtils.getDir("ProfD", [ + "extension-store", + ]); + // Make sure the folder exists. + await IOUtils.makeDirectory(storePath, { ignoreExisting: true }); + this._store = await lazy.KeyValueService.getOrCreate( + storePath, + "scripting-contentScripts" + ); + } + + lazyInit() { + if (!this._initPromise) { + this._initPromise = this._init(); + } + + return this._initPromise; + } + + /** + * Returns all the stored scripts for a given extension (ID). + * + * @param {string} extensionId An extension ID + * @returns {Promise<Array>} An array of scripts + */ + async getAll(extensionId) { + await this.lazyInit(); + const pairs = await this.getByExtensionId(extensionId); + + return pairs.map(([_, script]) => script); + } + + /** + * Writes all the scripts provided for a given extension (ID) to the internal + * store (which is eventually stored on disk). + * + * We store each script of an extension as a key/value pair where the key is + * `<extensionId>/<scriptId>` and the value is the corresponding script + * details as a JSON string. + * + * The format on disk should look like this one: + * + * ``` + * { + * "@extension-id/script-1": {"id: "script-1", <other props>}, + * "@extension-id/script-2": {"id: "script-2", <other props>} + * } + * ``` + * + * @param {string} extensionId An extension ID + * @param {Array} scripts An array of scripts to store on disk + */ + async writeMany(extensionId, scripts) { + await this.lazyInit(); + + return this._store.writeMany( + scripts.map(script => [ + `${extensionId}/${script.id}`, + JSON.stringify(script), + ]) + ); + } + + /** + * Deletes all the stored scripts for a given extension (ID). + * + * @param {string} extensionId An extension ID + */ + async deleteAll(extensionId) { + await this.lazyInit(); + const pairs = await this.getByExtensionId(extensionId); + + return Promise.all(pairs.map(([key, _]) => this._store.delete(key))); + } + + /** + * Returns an array of key/script pairs from the internal store belonging to + * the given extension (ID). + * + * The data returned by this method should look like this (assuming we have + * two scripts named `script-1` and `script-2` for the extension with ID + * `@extension-id`): + * + * ``` + * [ + * ["@extension-id/script-1", {"id: "script-1", <other props>}], + * ["@extension-id/script-2", {"id: "script-2", <other props>}] + * ] + * ``` + * + * @param {string} extensionId An extension ID + * @returns {Promise<Array>} An array of key/script pairs + */ + async getByExtensionId(extensionId) { + await this.lazyInit(); + + const entries = []; + // Retrieve all the scripts registered for the given extension ID by + // enumerating all keys that are stored in a lexical order. + const enumerator = await this._store.enumerate( + `${extensionId}/`, // from_key (inclusive) + `${extensionId}0` // to_key (exclusive) + ); + + while (enumerator.hasMoreElements()) { + const { key, value } = enumerator.getNext(); + entries.push([key, JSON.parse(value)]); + } + + return entries; + } +} + +const store = new Store(); + +/** + * Given an extension and some content script options, this function returns + * the content script representation we use internally, which is an object with + * a `scriptId` and a nested object containing `options`. These (internal) + * objects are shared with all content processes using IPC/sharedData. + * + * This function can optionally prepend the extension's base URL to the CSS and + * JS paths, which is needed when we load internal scripts from the scripting + * store (because the UUID in the base URL changes). + * + * @param {Extension} extension + * The extension that owns the content script. + * @param {object} options + * Content script options. + * @param {boolean} prependBaseURL + * Whether to prepend JS and CSS paths with the extension's base URL. + * + * @returns {object} + */ +export const makeInternalContentScript = ( + extension, + options, + prependBaseURL = false +) => { + let cssPaths = options.css || []; + let jsPaths = options.js || []; + + if (prependBaseURL) { + cssPaths = cssPaths.map(css => `${extension.baseURL}${css}`); + jsPaths = jsPaths.map(js => `${extension.baseURL}${js}`); + } + + return { + scriptId: ExtensionUtils.getUniqueId(), + options: { + // We need to store the user-supplied script ID for persisted scripts. + id: options.id, + allFrames: options.allFrames || false, + // Although this flag defaults to true with MV3, it is not with MV2. + // Check permissions at runtime since we aren't checking permissions + // upfront. + checkPermissions: true, + cssPaths, + excludeMatches: options.excludeMatches, + jsPaths, + // TODO(Bug 1853411): revert the short-term workaround special casing + // webcompat extension id once it is not necessary anymore. + matchAboutBlank: lazy.matchAboutBlankDefaultFalse + ? false // If the hidden pref is set, then forcefully set matchAboutBlank to false + : extension.id !== "webcompat@mozilla.org", + matches: options.matches, + originAttributesPatterns: null, + persistAcrossSessions: options.persistAcrossSessions, + runAt: options.runAt || "document_idle", + }, + }; +}; + +/** + * Given an internal content script registered with the "scripting" API (and an + * extension), this function returns a new object that matches the public + * "scripting" API. + * + * This function is primarily in `scripting.getRegisteredContentScripts()`. + * + * @param {Extension} extension + * The extension that owns the content script. + * @param {object} internalScript + * An internal script (see also: `makeInternalContentScript()`). + * + * @returns {object} + */ +export const makePublicContentScript = (extension, internalScript) => { + let script = { + id: internalScript.id, + allFrames: internalScript.allFrames, + matches: internalScript.matches, + runAt: internalScript.runAt, + persistAcrossSessions: internalScript.persistAcrossSessions, + }; + + if (internalScript.cssPaths.length) { + script.css = internalScript.cssPaths.map(cssPath => + cssPath.replace(extension.baseURL, "") + ); + } + + if (internalScript.excludeMatches?.length) { + script.excludeMatches = internalScript.excludeMatches; + } + + if (internalScript.jsPaths.length) { + script.js = internalScript.jsPaths.map(jsPath => + jsPath.replace(extension.baseURL, "") + ); + } + + return script; +}; + +export const ExtensionScriptingStore = { + async initExtension(extension) { + let scripts; + + // On downgrades/upgrades (and re-installation on top of an existing one), + // we do clear any previously stored scripts and return earlier. + switch (extension.startupReason) { + case "ADDON_INSTALL": + case "ADDON_UPGRADE": + case "ADDON_DOWNGRADE": + // On extension upgrades/downgrades the StartupCache data for the + // extension would already be cleared, and so we set the hasPersistedScripts + // flag here just to avoid having to check that (by loading the rkv store data) + // on the next startup. + StartupCache.general.set( + [extension.id, extension.version, "scripting", "hasPersistedScripts"], + false + ); + store.deleteAll(extension.id); + return; + } + + const hasPersistedScripts = await StartupCache.get( + extension, + ["scripting", "hasPersistedScripts"], + async () => { + scripts = await store.getAll(extension.id); + return !!scripts.length; + } + ); + + if (!hasPersistedScripts) { + return; + } + + // Load the scripts from the storage, then convert them to their internal + // representation and add them to the extension's registered scripts. + scripts ??= await store.getAll(extension.id); + + scripts.forEach(script => { + const { scriptId, options } = makeInternalContentScript( + extension, + script, + true /* prepend the css/js paths with the extension base URL */ + ); + extension.registeredContentScripts.set(scriptId, options); + }); + }, + + getInitialScriptIdsMap(extension) { + // This returns the current map of public script IDs to internal IDs. + // `extension.registeredContentScripts` is initialized in `initExtension`, + // which may be updated later via the scripting API. In practice, the map + // of script IDs is retrieved before any scripting API method is exposed, + // so the return value always matches the initial result from + // `initExtension`. + return new Map( + Array.from(extension.registeredContentScripts.entries()) + .filter( + // Filter out entries without an options.id property, which are the + // ones registered through the contentScripts API namespace where the + // id attribute is not allowed, while it is mandatory for the + // scripting API namespace. + ([_id, options]) => options.id?.length + ) + .map(([scriptId, options]) => [options.id, scriptId]) + ); + }, + + async persistAll(extension) { + // We only persist the scripts that should be persisted and we convert each + // script to their "public" representation before storing them. This is + // because we don't want to deal with data migrations if we ever want to + // change the internal representation (the "public" representation is less + // likely to change because it is bound to the public scripting API). + const scripts = Array.from(extension.registeredContentScripts.values()) + .filter(options => options.persistAcrossSessions) + .map(options => makePublicContentScript(extension, options)); + + // We want to replace all the scripts for the extension so we should delete + // the existing ones first, and then write the new ones. + // + // TODO: Bug 1783131 - Implement individual updates without requiring all + // data to be erased and written. + await store.deleteAll(extension.id); + await store.writeMany(extension.id, scripts); + StartupCache.general.set( + [extension.id, extension.version, "scripting", "hasPersistedScripts"], + !!scripts.length + ); + }, + + // Delete all the persisted scripts for the given extension (id). + // + // NOTE: to be only used on addon uninstall, the extension entry in the StartupCache + // is expected to also be fully cleared as part of handling the addon uninstall. + async clearOnUninstall(extensionId) { + await store.deleteAll(extensionId); + }, + + // As its name implies, don't use this method for anything but an easy access + // to the internal store for testing purposes. + _getStoreForTesting() { + return store; + }, +}; diff --git a/toolkit/components/extensions/ExtensionSettingsStore.sys.mjs b/toolkit/components/extensions/ExtensionSettingsStore.sys.mjs new file mode 100644 index 0000000000..ed139bcf12 --- /dev/null +++ b/toolkit/components/extensions/ExtensionSettingsStore.sys.mjs @@ -0,0 +1,681 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +/** + * @file + * This module is used for storing changes to settings that are + * requested by extensions, and for finding out what the current value + * of a setting should be, based on the precedence chain. + * + * When multiple extensions request to make a change to a particular + * setting, the most recently installed extension will be given + * precedence. + * + * This precedence chain of settings is stored in JSON format, + * without indentation, using UTF-8 encoding. + * With indentation applied, the file would look like this: + * + * { + * type: { // The type of settings being stored in this object, i.e., prefs. + * key: { // The unique key for the setting. + * initialValue, // The initial value of the setting. + * precedenceList: [ + * { + * id, // The id of the extension requesting the setting. + * installDate, // The install date of the extension, stored as a number. + * value, // The value of the setting requested by the extension. + * enabled // Whether the setting is currently enabled. + * } + * ], + * }, + * key: { + * // ... + * } + * } + * } + * + */ + +import { ExtensionParent } from "resource://gre/modules/ExtensionParent.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + JSONFile: "resource://gre/modules/JSONFile.sys.mjs", +}); + +// Defined for readability of precedence and selection code. keyInfo.selected will be +// one of these defines, or the id of an extension if an extension has been explicitly +// selected. +const SETTING_USER_SET = null; +const SETTING_PRECEDENCE_ORDER = undefined; + +const JSON_FILE_NAME = "extension-settings.json"; +const JSON_FILE_VERSION = 3; +const STORE_PATH = PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + JSON_FILE_NAME +); + +let _initializePromise; +let _store = {}; + +// Processes the JSON data when read from disk to convert string dates into numbers. +function dataPostProcessor(json) { + if (json.version !== JSON_FILE_VERSION) { + for (let storeType in json) { + for (let setting in json[storeType]) { + for (let extData of json[storeType][setting].precedenceList) { + if (setting == "overrideContentColorScheme" && extData.value > 2) { + extData.value = 2; + } + if (typeof extData.installDate != "number") { + extData.installDate = new Date(extData.installDate).valueOf(); + } + } + } + } + json.version = JSON_FILE_VERSION; + } + return json; +} + +// Loads the data from the JSON file into memory. +function initialize() { + if (!_initializePromise) { + _store = new lazy.JSONFile({ + path: STORE_PATH, + dataPostProcessor, + }); + _initializePromise = _store.load(); + } + return _initializePromise; +} + +// Test-only method to force reloading of the JSON file. +async function reloadFile(saveChanges) { + if (!saveChanges) { + // Disarm the saver so that the current changes are dropped. + _store._saver.disarm(); + } + await _store.finalize(); + _initializePromise = null; + return initialize(); +} + +// Checks that the store is ready and that the requested type exists. +function ensureType(type) { + if (!_store.dataReady) { + throw new Error( + "The ExtensionSettingsStore was accessed before the initialize promise resolved." + ); + } + + // Ensure a property exists for the given type. + if (!_store.data[type]) { + _store.data[type] = {}; + } +} + +/** + * Return an object with properties for key, value|initialValue, id|null, or + * null if no setting has been stored for that key. + * + * If no id is passed then return the highest priority item for the key. + * + * @param {string} type + * The type of setting to be retrieved. + * @param {string} key + * A string that uniquely identifies the setting. + * @param {string} [id] + * The id of the extension for which the item is being retrieved. + * If no id is passed, then the highest priority item for the key + * is returned. + * + * @returns {object | null} + * Either an object with properties for key and value, or + * null if no key is found. + */ +function getItem(type, key, id) { + ensureType(type); + + let keyInfo = _store.data[type][key]; + if (!keyInfo) { + return null; + } + + // If no id was provided, the selected entry will have precedence. + if (!id && keyInfo.selected) { + id = keyInfo.selected; + } + if (id) { + // Return the item that corresponds to the extension with id of id. + let item = keyInfo.precedenceList.find(item => item.id === id); + return item ? { key, value: item.value, id } : null; + } + + // Find the highest precedence, enabled setting, if it has not been + // user set. + if (keyInfo.selected === SETTING_PRECEDENCE_ORDER) { + for (let item of keyInfo.precedenceList) { + if (item.enabled) { + return { key, value: item.value, id: item.id }; + } + } + } + + // Nothing found in the precedenceList or the setting is user-set, + // return the initialValue. + return { key, initialValue: keyInfo.initialValue }; +} + +/** + * Return an array of objects with properties for key, value, id, and enabled + * or an empty array if no settings have been stored for that key. + * + * @param {string} type + * The type of setting to be retrieved. + * @param {string} key + * A string that uniquely identifies the setting. + * + * @returns {Array} an array of objects with properties for key, value, id, and enabled + */ +function getAllItems(type, key) { + ensureType(type); + + let keyInfo = _store.data[type][key]; + if (!keyInfo) { + return []; + } + + let items = keyInfo.precedenceList; + return items + ? items.map(item => ({ + key, + value: item.value, + id: item.id, + enabled: item.enabled, + })) + : []; +} + +// Comparator used when sorting the precedence list. +function precedenceComparator(a, b) { + if (a.enabled && !b.enabled) { + return -1; + } + if (b.enabled && !a.enabled) { + return 1; + } + return b.installDate - a.installDate; +} + +/** + * Helper method that alters a setting, either by changing its enabled status + * or by removing it. + * + * @param {string|null} id + * The id of the extension for which a setting is being altered, may also + * be SETTING_USER_SET (null). + * @param {string} type + * The type of setting to be altered. + * @param {string} key + * A string that uniquely identifies the setting. + * @param {string} action + * The action to perform on the setting. + * Will be one of remove|enable|disable. + * + * @returns {object | null} + * Either an object with properties for key and value, which + * corresponds to the current top precedent setting, or null if + * the current top precedent setting has not changed. + */ +function alterSetting(id, type, key, action) { + let returnItem = null; + ensureType(type); + + let keyInfo = _store.data[type][key]; + if (!keyInfo) { + if (action === "remove") { + return null; + } + throw new Error( + `Cannot alter the setting for ${type}:${key} as it does not exist.` + ); + } + + let foundIndex = keyInfo.precedenceList.findIndex(item => item.id == id); + + if (foundIndex === -1 && (action !== "select" || id !== SETTING_USER_SET)) { + if (action === "remove") { + return null; + } + throw new Error( + `Cannot alter the setting for ${type}:${key} as ${id} does not exist.` + ); + } + + let selected = keyInfo.selected; + switch (action) { + case "select": + if (foundIndex >= 0 && !keyInfo.precedenceList[foundIndex].enabled) { + throw new Error( + `Cannot select the setting for ${type}:${key} as ${id} is disabled.` + ); + } + keyInfo.selected = id; + keyInfo.selectedDate = Date.now(); + break; + + case "remove": + // Removing a user-set setting reverts to precedence order. + if (id === keyInfo.selected) { + keyInfo.selected = SETTING_PRECEDENCE_ORDER; + delete keyInfo.selectedDate; + } + keyInfo.precedenceList.splice(foundIndex, 1); + break; + + case "enable": + keyInfo.precedenceList[foundIndex].enabled = true; + keyInfo.precedenceList.sort(precedenceComparator); + // Enabling a setting does not change a user-set setting, so we + // save and bail early. + if (keyInfo.selected !== SETTING_PRECEDENCE_ORDER) { + _store.saveSoon(); + return null; + } + foundIndex = keyInfo.precedenceList.findIndex(item => item.id == id); + break; + + case "disable": + // Disabling a user-set setting reverts to precedence order. + if (keyInfo.selected === id) { + keyInfo.selected = SETTING_PRECEDENCE_ORDER; + delete keyInfo.selectedDate; + } + keyInfo.precedenceList[foundIndex].enabled = false; + keyInfo.precedenceList.sort(precedenceComparator); + break; + + default: + throw new Error(`${action} is not a valid action for alterSetting.`); + } + + if (selected !== keyInfo.selected || foundIndex === 0) { + returnItem = getItem(type, key); + } + + if (action === "remove" && keyInfo.precedenceList.length === 0) { + delete _store.data[type][key]; + } + + _store.saveSoon(); + ExtensionParent.apiManager.emit("extension-setting-changed", { + action, + id, + type, + key, + item: returnItem, + }); + return returnItem; +} + +export var ExtensionSettingsStore = { + SETTING_USER_SET, + + /** + * Loads the JSON file for the SettingsStore into memory. + * The promise this returns must be resolved before asking the SettingsStore + * to perform any other operations. + * + * @returns {Promise} + * A promise that resolves when the Store is ready to be accessed. + */ + initialize() { + return initialize(); + }, + + /** + * Adds a setting to the store, returning the new setting if it changes. + * + * @param {string} id + * The id of the extension for which a setting is being added. + * @param {string} type + * The type of setting to be stored. + * @param {string} key + * A string that uniquely identifies the setting. + * @param {string} value + * The value to be stored in the setting. + * @param {Function} initialValueCallback + * A function to be called to determine the initial value for the + * setting. This will be passed the value in the callbackArgument + * argument. If omitted the initial value will be undefined. + * @param {any} callbackArgument + * The value to be passed into the initialValueCallback. It defaults to + * the value of the key argument. + * @param {Function} settingDataUpdate + * A function to be called to modify the initial value if necessary. + * + * @returns {Promise<object?>} Either an object with properties for key and + * value, which corresponds to the item that was + * just added, or null if the item that was just + * added does not need to be set because it is not + * selected or at the top of the precedence list. + */ + async addSetting( + id, + type, + key, + value, + initialValueCallback = () => undefined, + callbackArgument = key, + settingDataUpdate = val => val + ) { + if (typeof initialValueCallback != "function") { + throw new Error("initialValueCallback must be a function."); + } + + ensureType(type); + + if (!_store.data[type][key]) { + // The setting for this key does not exist. Set the initial value. + let initialValue = await initialValueCallback(callbackArgument); + _store.data[type][key] = { + initialValue, + precedenceList: [], + }; + } + let keyInfo = _store.data[type][key]; + + // Allow settings to upgrade the initial value if necessary. + keyInfo.initialValue = settingDataUpdate(keyInfo.initialValue); + + // Check for this item in the precedenceList. + let foundIndex = keyInfo.precedenceList.findIndex(item => item.id == id); + let newInstall = false; + if (foundIndex === -1) { + // No item for this extension, so add a new one. + let addon = await lazy.AddonManager.getAddonByID(id); + keyInfo.precedenceList.push({ + id, + installDate: addon.installDate.valueOf(), + value, + enabled: true, + }); + newInstall = addon.installDate.valueOf() > keyInfo.selectedDate; + } else { + // Item already exists or this extension, so update it. + let item = keyInfo.precedenceList[foundIndex]; + item.value = value; + // Ensure the item is enabled. + item.enabled = true; + } + + // Sort the list. + keyInfo.precedenceList.sort(precedenceComparator); + foundIndex = keyInfo.precedenceList.findIndex(item => item.id == id); + + // If our new setting is top of precedence, then reset the selected entry. + if (foundIndex === 0 && newInstall) { + keyInfo.selected = SETTING_PRECEDENCE_ORDER; + delete keyInfo.selectedDate; + } + + _store.saveSoon(); + + // Check whether this is currently selected item if one is + // selected, otherwise the top item has precedence. + if ( + keyInfo.selected !== SETTING_USER_SET && + (keyInfo.selected === id || foundIndex === 0) + ) { + return { id, key, value }; + } + return null; + }, + + /** + * Removes a setting from the store, returning the new setting if it changes. + * + * @param {string} id + * The id of the extension for which a setting is being removed. + * @param {string} type + * The type of setting to be removed. + * @param {string} key + * A string that uniquely identifies the setting. + * + * @returns {object | null} + * Either an object with properties for key and value if the setting changes, or null. + */ + removeSetting(id, type, key) { + return alterSetting(id, type, key, "remove"); + }, + + /** + * Enables a setting in the store, returning the new setting if it changes. + * + * @param {string} id + * The id of the extension for which a setting is being enabled. + * @param {string} type + * The type of setting to be enabled. + * @param {string} key + * A string that uniquely identifies the setting. + * + * @returns {object | null} + * Either an object with properties for key and value if the setting changes, or null. + */ + enable(id, type, key) { + return alterSetting(id, type, key, "enable"); + }, + + /** + * Disables a setting in the store, returning the new setting if it changes. + * + * @param {string} id + * The id of the extension for which a setting is being disabled. + * @param {string} type + * The type of setting to be disabled. + * @param {string} key + * A string that uniquely identifies the setting. + * + * @returns {object | null} + * Either an object with properties for key and value if the setting changes, or null. + */ + disable(id, type, key) { + return alterSetting(id, type, key, "disable"); + }, + + /** + * Specifically select an extension, or no extension, that will be in control of + * this setting. + * + * To select a specific extension that controls this setting, pass the extension id. + * + * To select as user-set pass SETTING_USER_SET as the id. In this case, no extension + * will have control of the setting. + * + * Once a specific selection is made, precedence order will not be used again unless the selected + * extension is disabled, removed, or a new extension takes control of the setting. + * + * @param {string | null} id + * The id of the extension being selected or SETTING_USER_SET (null). + * @param {string} type + * The type of setting to be selected. + * @param {string} key + * A string that uniquely identifies the setting. + * + * @returns {object | null} + * Either an object with properties for key and value if the setting changes, or null. + */ + select(id, type, key) { + return alterSetting(id, type, key, "select"); + }, + + /** + * Retrieves all settings from the store for a given extension. + * + * @param {string} id + * The id of the extension for which a settings are being retrieved. + * @param {string} type + * The type of setting to be returned. + * + * @returns {Array} + * A list of settings which have been stored for the extension. + */ + getAllForExtension(id, type) { + ensureType(type); + + let keysObj = _store.data[type]; + let items = []; + for (let key in keysObj) { + if (keysObj[key].precedenceList.find(item => item.id == id)) { + items.push(key); + } + } + return items; + }, + + /** + * Retrieves a setting from the store, either for a specific extension, + * or current top precedent setting for the key. + * + * @param {string} type The type of setting to be returned. + * @param {string} key A string that uniquely identifies the setting. + * @param {string} id + * The id of the extension for which the setting is being retrieved. + * Defaults to undefined, in which case the top setting is returned. + * + * @returns {object} An object with properties for key, value and id. + */ + getSetting(type, key, id) { + return getItem(type, key, id); + }, + + /** + * Retrieves an array of objects representing extensions attempting to control the specified setting + * or an empty array if no settings have been stored for that key. + * + * @param {string} type + * The type of setting to be retrieved. + * @param {string} key + * A string that uniquely identifies the setting. + * + * @returns {Array} an array of objects with properties for key, value, id, and enabled + */ + getAllSettings(type, key) { + return getAllItems(type, key); + }, + + /** + * Returns whether an extension currently has a stored setting for a given + * key. + * + * @param {string} id The id of the extension which is being checked. + * @param {string} type The type of setting to be checked. + * @param {string} key A string that uniquely identifies the setting. + * + * @returns {boolean} Whether the extension currently has a stored setting. + */ + hasSetting(id, type, key) { + return this.getAllForExtension(id, type).includes(key); + }, + + /** + * Return the levelOfControl for a key / extension combo. + * levelOfControl is required by Google's ChromeSetting prototype which + * in turn is used by the privacy API among others. + * + * It informs a caller of the state of a setting with respect to the current + * extension, and can be one of the following values: + * + * controlled_by_other_extensions: controlled by extensions with higher precedence + * controllable_by_this_extension: can be controlled by this extension + * controlled_by_this_extension: controlled by this extension + * + * @param {string} id + * The id of the extension for which levelOfControl is being requested. + * @param {string} type + * The type of setting to be returned. For example `pref`. + * @param {string} key + * A string that uniquely identifies the setting, for example, a + * preference name. + * + * @returns {Promise<string>} + * The level of control of the extension over the key. + */ + async getLevelOfControl(id, type, key) { + ensureType(type); + + let keyInfo = _store.data[type][key]; + if (!keyInfo || !keyInfo.precedenceList.length) { + return "controllable_by_this_extension"; + } + + if (keyInfo.selected !== SETTING_PRECEDENCE_ORDER) { + if (id === keyInfo.selected) { + return "controlled_by_this_extension"; + } + // When user set, the setting is never "controllable" unless the installDate + // is later than the user date. + let addon = await lazy.AddonManager.getAddonByID(id); + return !addon || keyInfo.selectedDate > addon.installDate.valueOf() + ? "not_controllable" + : "controllable_by_this_extension"; + } + + let enabledItems = keyInfo.precedenceList.filter(item => item.enabled); + if (!enabledItems.length) { + return "controllable_by_this_extension"; + } + + let topItem = enabledItems[0]; + if (topItem.id == id) { + return "controlled_by_this_extension"; + } + + let addon = await lazy.AddonManager.getAddonByID(id); + return !addon || topItem.installDate > addon.installDate.valueOf() + ? "controlled_by_other_extensions" + : "controllable_by_this_extension"; + }, + + /** + * Test-only method to force reloading of the JSON file. + * + * Note that this method simply clears the local variable that stores the + * file, so the next time the file is accessed it will be reloaded. + * + * @param {boolean} saveChanges + * When false, discard any changes that have been made since the last + * time the store was saved. + * @returns {Promise} + * A promise that resolves once the settings store has been cleared. + */ + _reloadFile(saveChanges = true) { + return reloadFile(saveChanges); + }, +}; + +// eslint-disable-next-line mozilla/balanced-listeners +ExtensionParent.apiManager.on("uninstall-complete", async (type, { id }) => { + // Catch any settings that were not properly removed during "uninstall". + await ExtensionSettingsStore.initialize(); + for (let type in _store.data) { + // prefs settings must be handled by ExtensionPreferencesManager. + if (type === "prefs") { + continue; + } + let items = ExtensionSettingsStore.getAllForExtension(id, type); + for (let key of items) { + ExtensionSettingsStore.removeSetting(id, type, key); + Services.console.logStringMessage( + `Post-Uninstall removal of addon settings for ${id}, type: ${type} key: ${key}` + ); + } + } +}); diff --git a/toolkit/components/extensions/ExtensionShortcuts.sys.mjs b/toolkit/components/extensions/ExtensionShortcuts.sys.mjs new file mode 100644 index 0000000000..7b30fa2cdf --- /dev/null +++ b/toolkit/components/extensions/ExtensionShortcuts.sys.mjs @@ -0,0 +1,513 @@ +/* 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 { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs"; + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", + ExtensionSettingsStore: + "resource://gre/modules/ExtensionSettingsStore.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs", +}); + +/** + * These properties cannot be lazy getters otherwise they + * get defined on first use, at a time when some modules + * may not have been loaded. In that case, the getter would + * become undefined until next app restart. + */ +Object.defineProperties(lazy, { + windowTracker: { + get() { + return lazy.ExtensionParent.apiManager.global.windowTracker; + }, + }, + browserActionFor: { + get() { + return lazy.ExtensionParent.apiManager.global.browserActionFor; + }, + }, + pageActionFor: { + get() { + return lazy.ExtensionParent.apiManager.global.pageActionFor; + }, + }, + sidebarActionFor: { + get() { + return lazy.ExtensionParent.apiManager.global.sidebarActionFor; + }, + }, +}); + +const { ExtensionError, DefaultMap } = ExtensionUtils; +const { makeWidgetId } = ExtensionCommon; + +const EXECUTE_SIDEBAR_ACTION = "_execute_sidebar_action"; + +function normalizeShortcut(shortcut) { + return shortcut ? shortcut.replace(/\s+/g, "") : ""; +} + +export class ExtensionShortcutKeyMap extends DefaultMap { + async buildForAddonIds(addonIds) { + this.clear(); + for (const addonId of addonIds) { + const policy = WebExtensionPolicy.getByID(addonId); + if (policy?.extension?.shortcuts) { + const { shortcuts } = policy.extension; + for (const command of await shortcuts.allCommands()) { + this.recordShortcut(command.shortcut, policy.name, command.name); + } + } + } + } + + recordShortcut(shortcutString, addonName, commandName) { + if (!shortcutString) { + return; + } + + const valueSet = this.get(shortcutString); + valueSet.add({ addonName, commandName }); + } + + removeShortcut(shortcutString, addonName, commandName) { + if (!this.has(shortcutString)) { + return; + } + + const valueSet = this.get(shortcutString); + for (const entry of valueSet.values()) { + if (entry.addonName === addonName && entry.commandName === commandName) { + valueSet.delete(entry); + } + } + if (valueSet.size === 0) { + this.delete(shortcutString); + } + } + + getFirstAddonName(shortcutString) { + if (this.has(shortcutString)) { + return this.get(shortcutString).values().next().value.addonName; + } + return null; + } + + has(shortcutString) { + const platformShortcut = this.getPlatformShortcutString(shortcutString); + return super.has(platformShortcut) && super.get(platformShortcut).size > 0; + } + + // Class internals. + + constructor() { + super(() => new Set()); + + // Overridden in some unit test to make it easier to cover some + // platform specific behaviors (in particular the platform specific. + // normalization of the shortcuts using the Ctrl modifier on macOS). + this._os = lazy.ExtensionParent.PlatformInfo.os; + } + + getPlatformShortcutString(shortcutString) { + if (this._os == "mac") { + // when running on macos, make sure to also track in the shortcutKeyMap + // (which is used to check for duplicated shortcuts) a shortcut string + // that replace the `Ctrl` modifiers with the `Command` modified: + // they are going to be the same accel in the key element generated, + // by tracking both of them shortcut string value would confuse the about:addons "Manager Shortcuts" + // view and make it unable to correctly catch conflicts on mac + // (See bug 1565854). + shortcutString = shortcutString + .split("+") + .map(p => (p === "Ctrl" ? "Command" : p)) + .join("+"); + } + + return shortcutString; + } + + get(shortcutString) { + const platformShortcut = this.getPlatformShortcutString(shortcutString); + return super.get(platformShortcut); + } + + add(shortcutString, addonCommandValue) { + const setValue = this.get(shortcutString); + setValue.add(addonCommandValue); + } + + delete(shortcutString) { + const platformShortcut = this.getPlatformShortcutString(shortcutString); + return super.delete(platformShortcut); + } +} + +/** + * An instance of this class is assigned to the shortcuts property of each + * active webextension that has commands defined. + * + * It manages loading any updated shortcuts along with the ones defined in + * the manifest and registering them to a browser window. It also provides + * the list, update and reset APIs for the browser.commands interface and + * the about:addons manage shortcuts page. + */ +export class ExtensionShortcuts { + static async removeCommandsFromStorage(extensionId) { + // Cleanup the updated commands. In some cases the extension is installed + // and uninstalled so quickly that `this.commands` hasn't loaded yet. To + // handle that we need to make sure ExtensionSettingsStore is initialized + // before we clean it up. + await lazy.ExtensionSettingsStore.initialize(); + lazy.ExtensionSettingsStore.getAllForExtension( + extensionId, + "commands" + ).forEach(key => { + lazy.ExtensionSettingsStore.removeSetting(extensionId, "commands", key); + }); + } + + constructor({ extension, onCommand, onShortcutChanged }) { + this.keysetsMap = new WeakMap(); + this.windowOpenListener = null; + this.extension = extension; + this.onCommand = onCommand; + this.onShortcutChanged = onShortcutChanged; + this.id = makeWidgetId(extension.id); + } + + async allCommands() { + let commands = await this.commands; + return Array.from(commands, ([name, command]) => { + return { + name, + description: command.description, + shortcut: command.shortcut, + }; + }); + } + + async updateCommand({ name, description, shortcut }) { + let { extension } = this; + let commands = await this.commands; + let command = commands.get(name); + + if (!command) { + throw new ExtensionError(`Unknown command "${name}"`); + } + + // Only store the updates so manifest changes can take precedence + // later. + let previousUpdates = await lazy.ExtensionSettingsStore.getSetting( + "commands", + name, + extension.id + ); + let commandUpdates = (previousUpdates && previousUpdates.value) || {}; + + if (description && description != command.description) { + commandUpdates.description = description; + command.description = description; + } + + let oldShortcut = command.shortcut; + + if (shortcut != null && shortcut != command.shortcut) { + shortcut = normalizeShortcut(shortcut); + commandUpdates.shortcut = shortcut; + command.shortcut = shortcut; + } + + await lazy.ExtensionSettingsStore.addSetting( + extension.id, + "commands", + name, + commandUpdates + ); + + this.registerKeys(commands); + + if (command.shortcut !== oldShortcut) { + this.onShortcutChanged({ + name, + newShortcut: command.shortcut, + oldShortcut, + }); + } + } + + async resetCommand(name) { + let { extension, manifestCommands } = this; + let commands = await this.commands; + let command = commands.get(name); + + if (!command) { + throw new ExtensionError(`Unknown command "${name}"`); + } + + let storedCommand = lazy.ExtensionSettingsStore.getSetting( + "commands", + name, + extension.id + ); + + if (storedCommand && storedCommand.value) { + commands.set(name, { ...manifestCommands.get(name) }); + lazy.ExtensionSettingsStore.removeSetting(extension.id, "commands", name); + this.registerKeys(commands); + } + } + + loadCommands() { + let { extension } = this; + + // Map[{String} commandName -> {Object} commandProperties] + this.manifestCommands = this.loadCommandsFromManifest(extension.manifest); + + this.commands = (async () => { + // Deep copy the manifest commands to commands so we can keep the original + // manifest commands and update commands as needed. + let commands = new Map(); + this.manifestCommands.forEach((command, name) => { + commands.set(name, { ...command }); + }); + + // Update the manifest commands with the persisted updates from + // browser.commands.update(). + let savedCommands = await this.loadCommandsFromStorage(extension.id); + savedCommands.forEach((update, name) => { + let command = commands.get(name); + if (command) { + // We will only update commands, not add them. + Object.assign(command, update); + } + }); + + return commands; + })(); + } + + registerKeys(commands) { + for (let window of lazy.windowTracker.browserWindows()) { + this.registerKeysToDocument(window, commands); + } + } + + /** + * Registers the commands to all open windows and to any which + * are later created. + */ + async register() { + let commands = await this.commands; + this.registerKeys(commands); + + this.windowOpenListener = window => { + if (!this.keysetsMap.has(window)) { + this.registerKeysToDocument(window, commands); + } + }; + + lazy.windowTracker.addOpenListener(this.windowOpenListener); + } + + /** + * Unregisters the commands from all open windows and stops commands + * from being registered to windows which are later created. + */ + unregister() { + for (let window of lazy.windowTracker.browserWindows()) { + if (this.keysetsMap.has(window)) { + this.keysetsMap.get(window).remove(); + } + } + + lazy.windowTracker.removeOpenListener(this.windowOpenListener); + } + + /** + * Creates a Map from commands for each command in the manifest.commands object. + * + * @param {object} manifest The manifest JSON object. + * @returns {Map<string, object>} + */ + loadCommandsFromManifest(manifest) { + let commands = new Map(); + // For Windows, chrome.runtime expects 'win' while chrome.commands + // expects 'windows'. We can special case this for now. + let { PlatformInfo } = lazy.ExtensionParent; + let os = PlatformInfo.os == "win" ? "windows" : PlatformInfo.os; + for (let [name, command] of Object.entries(manifest.commands)) { + let suggested_key = command.suggested_key || {}; + let shortcut = normalizeShortcut( + suggested_key[os] || suggested_key.default + ); + commands.set(name, { + description: command.description, + shortcut, + }); + } + return commands; + } + + async loadCommandsFromStorage(extensionId) { + await lazy.ExtensionSettingsStore.initialize(); + let names = lazy.ExtensionSettingsStore.getAllForExtension( + extensionId, + "commands" + ); + return names.reduce((map, name) => { + let command = lazy.ExtensionSettingsStore.getSetting( + "commands", + name, + extensionId + ).value; + return map.set(name, command); + }, new Map()); + } + + /** + * Registers the commands to a document. + * + * @param {ChromeWindow} window The XUL window to insert the Keyset. + * @param {Map} commands The commands to be set. + */ + registerKeysToDocument(window, commands) { + if ( + !this.extension.privateBrowsingAllowed && + lazy.PrivateBrowsingUtils.isWindowPrivate(window) + ) { + return; + } + + let doc = window.document; + let keyset = doc.createXULElement("keyset"); + keyset.id = `ext-keyset-id-${this.id}`; + if (this.keysetsMap.has(window)) { + this.keysetsMap.get(window).remove(); + } + let sidebarKey; + for (let [name, command] of commands) { + if (command.shortcut) { + let parts = command.shortcut.split("+"); + + // The key is always the last element. + let key = parts.pop(); + + if (/^[0-9]$/.test(key)) { + let shortcutWithNumpad = command.shortcut.replace( + /[0-9]$/, + "Numpad$&" + ); + let numpadKeyElement = this.buildKey(doc, name, shortcutWithNumpad); + keyset.appendChild(numpadKeyElement); + } + + let keyElement = this.buildKey(doc, name, command.shortcut); + keyset.appendChild(keyElement); + if (name == EXECUTE_SIDEBAR_ACTION) { + sidebarKey = keyElement; + } + } + } + doc.documentElement.appendChild(keyset); + if (sidebarKey) { + window.SidebarUI.updateShortcut({ keyId: sidebarKey.id }); + } + this.keysetsMap.set(window, keyset); + } + + /** + * Builds a XUL Key element and attaches an onCommand listener which + * emits a command event with the provided name when fired. + * + * @param {Document} doc The XUL document. + * @param {string} name The name of the command. + * @param {string} shortcut The shortcut provided in the manifest. + * @see https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XUL/key + * + * @returns {Element} The newly created Key element. + */ + buildKey(doc, name, shortcut) { + let keyElement = this.buildKeyFromShortcut(doc, name, shortcut); + + // We need to have the attribute "oncommand" for the "command" listener to fire, + // and it is currently ignored when set to the empty string. + keyElement.setAttribute("oncommand", "//"); + + /* eslint-disable mozilla/balanced-listeners */ + // We remove all references to the key elements when the extension is shutdown, + // therefore the listeners for these elements will be garbage collected. + keyElement.addEventListener("command", event => { + let action; + let _execute_action = + this.extension.manifestVersion < 3 + ? "_execute_browser_action" + : "_execute_action"; + + let actionFor = { + [_execute_action]: lazy.browserActionFor, + _execute_page_action: lazy.pageActionFor, + _execute_sidebar_action: lazy.sidebarActionFor, + }[name]; + + if (actionFor) { + action = actionFor(this.extension); + let win = event.target.ownerGlobal; + action.triggerAction(win); + } else { + this.extension.tabManager.addActiveTabPermission(); + this.onCommand(name); + } + }); + /* eslint-enable mozilla/balanced-listeners */ + + return keyElement; + } + + /** + * Builds a XUL Key element from the provided shortcut. + * + * @param {Document} doc The XUL document. + * @param {string} name The name of the shortcut. + * @param {string} shortcut The shortcut provided in the manifest. + * + * @see https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XUL/key + * @returns {Element} The newly created Key element. + */ + buildKeyFromShortcut(doc, name, shortcut) { + let keyElement = doc.createXULElement("key"); + + let parts = shortcut.split("+"); + + // The key is always the last element. + let chromeKey = parts.pop(); + + // The modifiers are the remaining elements. + keyElement.setAttribute( + "modifiers", + lazy.ShortcutUtils.getModifiersAttribute(parts) + ); + + // A keyElement with key "NumpadX" is created above and isn't from the + // manifest. The id will be set on the keyElement with key "X" only. + if (name == EXECUTE_SIDEBAR_ACTION && !chromeKey.startsWith("Numpad")) { + let id = `ext-key-id-${this.id}-sidebar-action`; + keyElement.setAttribute("id", id); + } + + let [attribute, value] = lazy.ShortcutUtils.getKeyAttribute(chromeKey); + keyElement.setAttribute(attribute, value); + if (attribute == "keycode") { + keyElement.setAttribute("event", "keydown"); + } + + return keyElement; + } +} diff --git a/toolkit/components/extensions/ExtensionStorage.sys.mjs b/toolkit/components/extensions/ExtensionStorage.sys.mjs new file mode 100644 index 0000000000..4155fbaa24 --- /dev/null +++ b/toolkit/components/extensions/ExtensionStorage.sys.mjs @@ -0,0 +1,573 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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 { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { DefaultWeakMap, ExtensionError } = ExtensionUtils; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs", + JSONFile: "resource://gre/modules/JSONFile.sys.mjs", +}); + +function isStructuredCloneHolder(value) { + return ( + value && + typeof value === "object" && + Cu.getClassName(value, true) === "StructuredCloneHolder" + ); +} + +class SerializeableMap extends Map { + toJSON() { + let result = {}; + for (let [key, value] of this) { + if (isStructuredCloneHolder(value)) { + value = value.deserialize(globalThis); + this.set(key, value); + } + + result[key] = value; + } + return result; + } + + /** + * Like toJSON, but attempts to serialize every value separately, and + * elides any which fail to serialize. Should only be used if initial + * JSON serialization fails. + * + * @returns {object} + */ + toJSONSafe() { + let result = {}; + for (let [key, value] of this) { + try { + void JSON.stringify(value); + + result[key] = value; + } catch (e) { + Cu.reportError( + new Error(`Failed to serialize browser.storage key "${key}": ${e}`) + ); + } + } + return result; + } +} + +/** + * Serializes an arbitrary value into a StructuredCloneHolder, if + * appropriate. Existing StructuredCloneHolders are returned unchanged. + * Non-object values are also returned unchanged. Anything else is + * serialized, and a new StructuredCloneHolder returned. + * + * This allows us to avoid a second structured clone operation after + * sending a storage value across a message manager, before cloning it + * into an extension scope. + * + * @param {string} name + * A debugging name for the value, which will appear in the + * StructuredCloneHolder's about:memory path. + * @param {string?} anonymizedName + * An anonymized version of `name`, to be used in anonymized memory + * reports. If `null`, then `name` will be used instead. + * @param {StructuredCloneHolder|*} value + * A value to serialize. + * @returns {*} + */ +function serialize(name, anonymizedName, value) { + if (value && typeof value === "object" && !isStructuredCloneHolder(value)) { + return new StructuredCloneHolder(name, anonymizedName, value); + } + return value; +} + +export var ExtensionStorage = { + // Map<extension-id, Promise<JSONFile>> + jsonFilePromises: new Map(), + + listeners: new Map(), + + /** + * Asynchronously reads the storage file for the given extension ID + * and returns a Promise for its initialized JSONFile object. + * + * @param {string} extensionId + * The ID of the extension for which to return a file. + * @returns {Promise<InstanceType<Lazy['JSONFile']>>} + */ + async _readFile(extensionId) { + await IOUtils.makeDirectory(this.getExtensionDir(extensionId)); + + let jsonFile = new lazy.JSONFile({ + path: this.getStorageFile(extensionId), + }); + await jsonFile.load(); + + jsonFile.data = this._serializableMap(jsonFile.data); + return jsonFile; + }, + + _serializableMap(data) { + return new SerializeableMap(Object.entries(data)); + }, + + /** + * Returns a Promise for initialized JSONFile instance for the + * extension's storage file. + * + * @param {string} extensionId + * The ID of the extension for which to return a file. + * @returns {Promise<InstanceType<Lazy['JSONFile']>>} + */ + getFile(extensionId) { + let promise = this.jsonFilePromises.get(extensionId); + if (!promise) { + promise = this._readFile(extensionId); + this.jsonFilePromises.set(extensionId, promise); + } + return promise; + }, + + /** + * Clear the cached jsonFilePromise for a given extensionId + * (used by ExtensionStorageIDB to free the jsonFile once the data migration + * has been completed). + * + * @param {string} extensionId + * The ID of the extension for which to return a file. + */ + async clearCachedFile(extensionId) { + let promise = this.jsonFilePromises.get(extensionId); + if (promise) { + this.jsonFilePromises.delete(extensionId); + await promise.then(jsonFile => jsonFile.finalize()); + } + }, + + /** + * Sanitizes the given value, and returns a JSON-compatible + * representation of it, based on the privileges of the given global. + * + * @param {any} value + * The value to sanitize. + * @param {Context} context + * The extension context in which to sanitize the value + * @returns {value} + * The sanitized value. + */ + sanitize(value, context) { + let json = context.jsonStringify(value === undefined ? null : value); + if (json == undefined) { + throw new ExtensionError( + "DataCloneError: The object could not be cloned." + ); + } + return JSON.parse(json); + }, + + /** + * Returns the path to the storage directory within the profile for + * the given extension ID. + * + * @param {string} extensionId + * The ID of the extension for which to return a directory path. + * @returns {string} + */ + getExtensionDir(extensionId) { + return PathUtils.join(this.extensionDir, extensionId); + }, + + /** + * Returns the path to the JSON storage file for the given extension + * ID. + * + * @param {string} extensionId + * The ID of the extension for which to return a file path. + * @returns {string} + */ + getStorageFile(extensionId) { + return PathUtils.join(this.extensionDir, extensionId, "storage.js"); + }, + + /** + * Asynchronously sets the values of the given storage items for the + * given extension. + * + * @param {string} extensionId + * The ID of the extension for which to set storage values. + * @param {object} items + * The storage items to set. For each property in the object, + * the storage value for that property is set to its value in + * said object. Any values which are StructuredCloneHolder + * instances are deserialized before being stored. + * @returns {Promise<void>} + */ + async set(extensionId, items) { + let jsonFile = await this.getFile(extensionId); + + let changes = {}; + for (let prop in items) { + let item = items[prop]; + changes[prop] = { + oldValue: serialize( + `set/${extensionId}/old/${prop}`, + `set/${extensionId}/old/<anonymized>`, + jsonFile.data.get(prop) + ), + newValue: serialize( + `set/${extensionId}/new/${prop}`, + `set/${extensionId}/new/<anonymized>`, + item + ), + }; + jsonFile.data.set(prop, item); + } + + this.notifyListeners(extensionId, changes); + + jsonFile.saveSoon(); + return null; + }, + + /** + * Asynchronously removes the given storage items for the given + * extension ID. + * + * @param {string} extensionId + * The ID of the extension for which to remove storage values. + * @param {Array<string>} items + * A list of storage items to remove. + * @returns {Promise<void>} + */ + async remove(extensionId, items) { + let jsonFile = await this.getFile(extensionId); + + let changed = false; + let changes = {}; + + for (let prop of [].concat(items)) { + if (jsonFile.data.has(prop)) { + changes[prop] = { + oldValue: serialize( + `remove/${extensionId}/${prop}`, + `remove/${extensionId}/<anonymized>`, + jsonFile.data.get(prop) + ), + }; + jsonFile.data.delete(prop); + changed = true; + } + } + + if (changed) { + this.notifyListeners(extensionId, changes); + jsonFile.saveSoon(); + } + return null; + }, + + /** + * Asynchronously clears all storage entries for the given extension + * ID. + * + * @param {string} extensionId + * The ID of the extension for which to clear storage. + * @param {object} options + * @param {boolean} [options.shouldNotifyListeners = true] + * Whether or not collect and send the changes to the listeners, + * used when the extension data is being cleared on uninstall. + * @returns {Promise<void>} + */ + async clear(extensionId, { shouldNotifyListeners = true } = {}) { + let jsonFile = await this.getFile(extensionId); + + let changed = false; + let changes = {}; + + for (let [prop, oldValue] of jsonFile.data.entries()) { + if (shouldNotifyListeners) { + changes[prop] = { + oldValue: serialize( + `clear/${extensionId}/${prop}`, + `clear/${extensionId}/<anonymized>`, + oldValue + ), + }; + } + + jsonFile.data.delete(prop); + changed = true; + } + + if (changed) { + if (shouldNotifyListeners) { + this.notifyListeners(extensionId, changes); + } + + jsonFile.saveSoon(); + } + return null; + }, + + /** + * Asynchronously retrieves the values for the given storage items for + * the given extension ID. + * + * @param {string} extensionId + * The ID of the extension for which to get storage values. + * @param {Array<string>|object|null} [keys] + * The storage items to get. If an array, the value of each key + * in the array is returned. If null, the values of all items + * are returned. If an object, the value for each key in the + * object is returned, or that key's value if the item is not + * set. + * @returns {Promise<object>} + * An object which a property for each requested key, + * containing that key's storage value. Values are + * StructuredCloneHolder objects which can be deserialized to + * the original storage value. + */ + async get(extensionId, keys) { + let jsonFile = await this.getFile(extensionId); + return this._filterProperties(extensionId, jsonFile.data, keys); + }, + + async _filterProperties(extensionId, data, keys) { + let result = {}; + if (keys === null) { + Object.assign(result, data.toJSON()); + } else if (typeof keys == "object" && !Array.isArray(keys)) { + for (let prop in keys) { + if (data.has(prop)) { + result[prop] = serialize( + `filterProperties/${extensionId}/${prop}`, + `filterProperties/${extensionId}/<anonymized>`, + data.get(prop) + ); + } else { + result[prop] = keys[prop]; + } + } + } else { + for (let prop of [].concat(keys)) { + if (data.has(prop)) { + result[prop] = serialize( + `filterProperties/${extensionId}/${prop}`, + `filterProperties/${extensionId}/<anonymized>`, + data.get(prop) + ); + } + } + } + + return result; + }, + + addOnChangedListener(extensionId, listener) { + let listeners = this.listeners.get(extensionId) || new Set(); + listeners.add(listener); + this.listeners.set(extensionId, listeners); + }, + + removeOnChangedListener(extensionId, listener) { + let listeners = this.listeners.get(extensionId); + listeners.delete(listener); + }, + + notifyListeners(extensionId, changes) { + let listeners = this.listeners.get(extensionId); + if (listeners) { + for (let listener of listeners) { + listener(changes); + } + } + }, + + init() { + if (Services.appinfo.processType != Services.appinfo.PROCESS_TYPE_DEFAULT) { + return; + } + Services.obs.addObserver(this, "extension-invalidate-storage-cache"); + Services.obs.addObserver(this, "xpcom-shutdown"); + }, + + observe(subject, topic, data) { + if (topic == "xpcom-shutdown") { + Services.obs.removeObserver(this, "extension-invalidate-storage-cache"); + Services.obs.removeObserver(this, "xpcom-shutdown"); + } else if (topic == "extension-invalidate-storage-cache") { + for (let promise of this.jsonFilePromises.values()) { + promise.then(jsonFile => { + jsonFile.finalize(); + }); + } + this.jsonFilePromises.clear(); + } + }, + + // Serializes an arbitrary value into a StructuredCloneHolder, if appropriate. + serialize, + + /** + * Serializes the given storage items for transporting between processes. + * + * @param {BaseContext} context + * The context to use for the created StructuredCloneHolder + * objects. + * @param {Array<string>|object} items + * The items to serialize. If an object is provided, its + * values are serialized to StructuredCloneHolder objects. + * Otherwise, it is returned as-is. + * @returns {Array<string>|object} + */ + serializeForContext(context, items) { + if (items && typeof items === "object" && !Array.isArray(items)) { + let result = {}; + for (let [key, value] of Object.entries(items)) { + try { + result[key] = new StructuredCloneHolder( + `serializeForContext/${context.extension.id}`, + null, + value, + context.cloneScope + ); + } catch (e) { + throw new ExtensionError(String(e)); + } + } + return result; + } + return items; + }, + + /** + * Deserializes the given storage items into the given extension context. + * + * @param {BaseContext} context + * The context to use to deserialize the StructuredCloneHolder objects. + * @param {object} items + * The items to deserialize. Any property of the object which + * is a StructuredCloneHolder instance is deserialized into + * the extension scope. Any other object is cloned into the + * extension scope directly. + * @returns {object} + */ + deserializeForContext(context, items) { + let result = new context.cloneScope.Object(); + for (let [key, value] of Object.entries(items)) { + if ( + value && + typeof value === "object" && + Cu.getClassName(value, true) === "StructuredCloneHolder" + ) { + value = value.deserialize(context.cloneScope, true); + } else { + value = Cu.cloneInto(value, context.cloneScope); + } + result[key] = value; + } + return result; + }, +}; + +ChromeUtils.defineLazyGetter(ExtensionStorage, "extensionDir", () => + PathUtils.join(PathUtils.profileDir, "browser-extension-data") +); + +ExtensionStorage.init(); + +export var extensionStorageSession = { + /** @type {WeakMap<Extension, Map<string, any>>} */ + buckets: new DefaultWeakMap(_extension => new Map()), + + /** @type {WeakMap<Extension, Set<callback>>} */ + listeners: new DefaultWeakMap(_extension => new Set()), + + /** + * @param {Extension} extension + * @param {null | undefined | string | string[] | object} items + * Schema normalization ensures items are normalized to one of above types. + */ + get(extension, items) { + let bucket = this.buckets.get(extension); + + let result = {}; + /** @type {Iterable<string>} */ + let keys = []; + + if (!items) { + keys = bucket.keys(); + } else if (typeof items !== "object" || Array.isArray(items)) { + keys = [].concat(items); + } else { + keys = Object.keys(items); + result = items; + } + + for (let prop of keys) { + if (bucket.has(prop)) { + result[prop] = bucket.get(prop); + } + } + return result; + }, + + set(extension, items) { + let bucket = this.buckets.get(extension); + + let changes = {}; + for (let [key, value] of Object.entries(items)) { + changes[key] = { + oldValue: bucket.get(key), + newValue: value, + }; + bucket.set(key, value); + } + this.notifyListeners(extension, changes); + }, + + remove(extension, keys) { + let bucket = this.buckets.get(extension); + let changes = {}; + for (let k of [].concat(keys)) { + if (bucket.has(k)) { + changes[k] = { oldValue: bucket.get(k) }; + bucket.delete(k); + } + } + this.notifyListeners(extension, changes); + }, + + clear(extension) { + let bucket = this.buckets.get(extension); + let changes = {}; + for (let k of bucket.keys()) { + changes[k] = { oldValue: bucket.get(k) }; + } + bucket.clear(); + this.notifyListeners(extension, changes); + }, + + registerListener(extension, listener) { + this.listeners.get(extension).add(listener); + return () => { + this.listeners.get(extension).delete(listener); + }; + }, + + notifyListeners(extension, changes) { + if (!Object.keys(changes).length) { + return; + } + for (let listener of this.listeners.get(extension)) { + lazy.ExtensionCommon.runSafeSyncWithoutClone(listener, changes); + } + }, +}; diff --git a/toolkit/components/extensions/ExtensionStorageIDB.sys.mjs b/toolkit/components/extensions/ExtensionStorageIDB.sys.mjs new file mode 100644 index 0000000000..26df3eacdb --- /dev/null +++ b/toolkit/components/extensions/ExtensionStorageIDB.sys.mjs @@ -0,0 +1,878 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { IndexedDB } from "resource://gre/modules/IndexedDB.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionStorage: "resource://gre/modules/ExtensionStorage.sys.mjs", + ExtensionUtils: "resource://gre/modules/ExtensionUtils.sys.mjs", + getTrimmedString: "resource://gre/modules/ExtensionTelemetry.sys.mjs", +}); + +// The userContextID reserved for the extension storage (its purpose is ensuring that the IndexedDB +// storage used by the browser.storage.local API is not directly accessible from the extension code, +// it is defined and reserved as "userContextIdInternal.webextStorageLocal" in ContextualIdentityService.sys.mjs). +const WEBEXT_STORAGE_USER_CONTEXT_ID = -1 >>> 0; + +const IDB_NAME = "webExtensions-storage-local"; +const IDB_DATA_STORENAME = "storage-local-data"; +const IDB_VERSION = 1; +const IDB_MIGRATE_RESULT_HISTOGRAM = + "WEBEXT_STORAGE_LOCAL_IDB_MIGRATE_RESULT_COUNT"; + +// Whether or not the installed extensions should be migrated to the storage.local IndexedDB backend. +const BACKEND_ENABLED_PREF = + "extensions.webextensions.ExtensionStorageIDB.enabled"; +const IDB_MIGRATED_PREF_BRANCH = + "extensions.webextensions.ExtensionStorageIDB.migrated"; + +class DataMigrationAbortedError extends Error { + get name() { + return "DataMigrationAbortedError"; + } +} + +var ErrorsTelemetry = { + initialized: false, + + lazyInit() { + if (this.initialized) { + return; + } + this.initialized = true; + + // Ensure that these telemetry events category is enabled. + Services.telemetry.setEventRecordingEnabled("extensions.data", true); + + this.resultHistogram = Services.telemetry.getHistogramById( + IDB_MIGRATE_RESULT_HISTOGRAM + ); + }, + + /** + * Get the DOMException error name for a given error object. + * + * @param {Error | undefined} error + * The Error object to convert into a string, or undefined if there was no error. + * + * @returns {string | undefined} + * The DOMException error name (sliced to a maximum of 80 chars), + * "OtherError" if the error object is not a DOMException instance, + * or `undefined` if there wasn't an error. + */ + getErrorName(error) { + if (!error) { + return undefined; + } + + if ( + DOMException.isInstance(error) || + error instanceof DataMigrationAbortedError + ) { + if (error.name.length > 80) { + return lazy.getTrimmedString(error.name); + } + + return error.name; + } + + return "OtherError"; + }, + + /** + * Record telemetry related to a data migration result. + * + * @param {object} telemetryData + * @param {string} telemetryData.backend + * The backend selected ("JSONFile" or "IndexedDB"). + * @param {boolean} [telemetryData.dataMigrated] + * Old extension data has been migrated successfully. + * @param {string} telemetryData.extensionId + * The id of the extension migrated. + * @param {Error | undefined} telemetryData.error + * The error raised during the data migration, if any. + * @param {boolean} [telemetryData.hasJSONFile] + * The extension has an existing JSONFile to migrate. + * @param {boolean} [telemetryData.hasOldData] + * The extension's JSONFile wasn't empty. + * @param {string} telemetryData.histogramCategory + * The histogram category for the result ("success" or "failure"). + */ + recordDataMigrationResult(telemetryData) { + try { + const { + backend, + dataMigrated, + extensionId, + error, + hasJSONFile, + hasOldData, + histogramCategory, + } = telemetryData; + + this.lazyInit(); + this.resultHistogram.add(histogramCategory); + + const extra = { backend }; + + if (dataMigrated != null) { + extra.data_migrated = dataMigrated ? "y" : "n"; + } + + if (hasJSONFile != null) { + extra.has_jsonfile = hasJSONFile ? "y" : "n"; + } + + if (hasOldData != null) { + extra.has_olddata = hasOldData ? "y" : "n"; + } + + if (error) { + extra.error_name = this.getErrorName(error); + } + + let addon_id = lazy.getTrimmedString(extensionId); + Services.telemetry.recordEvent( + "extensions.data", + "migrateResult", + "storageLocal", + addon_id, + extra + ); + Glean.extensionsData.migrateResult.record({ + addon_id, + backend: extra.backend, + data_migrated: extra.data_migrated, + has_jsonfile: extra.has_jsonfile, + has_olddata: extra.has_olddata, + error_name: extra.error_name, + }); + } catch (err) { + // Report any telemetry error on the browser console, but + // we treat it as a non-fatal error and we don't re-throw + // it to the caller. + Cu.reportError(err); + } + }, + + /** + * Record telemetry related to the unexpected errors raised while executing + * a storage.local API call. + * + * @param {object} options + * @param {string} options.extensionId + * The id of the extension migrated. + * @param {string} options.storageMethod + * The storage.local API method being run. + * @param {Error} options.error + * The unexpected error raised during the API call. + */ + recordStorageLocalError({ extensionId, storageMethod, error }) { + this.lazyInit(); + let addon_id = lazy.getTrimmedString(extensionId); + let error_name = this.getErrorName(error); + + Services.telemetry.recordEvent( + "extensions.data", + "storageLocalError", + storageMethod, + addon_id, + { error_name } + ); + Glean.extensionsData.storageLocalError.record({ + addon_id, + method: storageMethod, + error_name, + }); + }, +}; + +class ExtensionStorageLocalIDB extends IndexedDB { + onupgradeneeded(event) { + if (event.oldVersion < 1) { + this.createObjectStore(IDB_DATA_STORENAME); + } + } + + static openForPrincipal(storagePrincipal) { + // The db is opened using an extension principal isolated in a reserved user context id. + return super.openForPrincipal(storagePrincipal, IDB_NAME, IDB_VERSION); + } + + async isEmpty() { + const cursor = await this.objectStore( + IDB_DATA_STORENAME, + "readonly" + ).openKeyCursor(); + return cursor.done; + } + + /** + * Asynchronously sets the values of the given storage items. + * + * @param {object} items + * The storage items to set. For each property in the object, + * the storage value for that property is set to its value in + * said object. Any values which are StructuredCloneHolder + * instances are deserialized before being stored. + * @param {object} options + * @param {callback} [options.serialize] + * Set to a function which will be used to serialize the values into + * a StructuredCloneHolder object (if appropriate) and being sent + * across the processes (it is also used to detect data cloning errors + * and raise an appropriate error to the caller). + * + * @returns {Promise<null|object>} + * Return a promise which resolves to the computed "changes" object + * or null. + */ + async set(items, { serialize } = {}) { + const changes = {}; + let changed = false; + + // Explicitly create a transaction, so that we can explicitly abort it + // as soon as one of the put requests fails. + const transaction = this.transaction(IDB_DATA_STORENAME, "readwrite"); + const objectStore = transaction.objectStore(IDB_DATA_STORENAME); + const transactionCompleted = transaction.promiseComplete(); + + if (!serialize) { + serialize = (name, anonymizedName, value) => value; + } + + for (let key of Object.keys(items)) { + try { + let oldValue = await objectStore.get(key); + + await objectStore.put(items[key], key); + + changes[key] = { + oldValue: + oldValue && serialize(`old/${key}`, `old/<anonymized>`, oldValue), + newValue: serialize(`new/${key}`, `new/<anonymized>`, items[key]), + }; + changed = true; + } catch (err) { + transactionCompleted.catch(err => { + // We ignore this rejection because we are explicitly aborting the transaction, + // the transaction.error will be null, and we throw the original error below. + }); + transaction.abort(); + + throw err; + } + } + + await transactionCompleted; + + return changed ? changes : null; + } + + /** + * Asynchronously retrieves the values for the given storage items. + * + * @param {Array<string>|object|null} [keysOrItems] + * The storage items to get. If an array, the value of each key + * in the array is returned. If null, the values of all items + * are returned. If an object, the value for each key in the + * object is returned, or that key's value if the item is not + * set. + * @returns {Promise<object>} + * An object which has a property for each requested key, + * containing that key's value as stored in the IndexedDB + * storage. + */ + async get(keysOrItems) { + let keys; + let defaultValues; + + if (typeof keysOrItems === "string") { + keys = [keysOrItems]; + } else if (Array.isArray(keysOrItems)) { + keys = keysOrItems; + } else if (keysOrItems && typeof keysOrItems === "object") { + keys = Object.keys(keysOrItems); + defaultValues = keysOrItems; + } + + const result = {}; + + // Retrieve all the stored data using a cursor when browser.storage.local.get() + // has been called with no keys. + if (keys == null) { + const cursor = await this.objectStore( + IDB_DATA_STORENAME, + "readonly" + ).openCursor(); + while (!cursor.done) { + result[cursor.key] = cursor.value; + await cursor.continue(); + } + } else { + const objectStore = this.objectStore(IDB_DATA_STORENAME); + for (let key of keys) { + const storedValue = await objectStore.get(key); + if (storedValue === undefined) { + if (defaultValues && defaultValues[key] !== undefined) { + result[key] = defaultValues[key]; + } + } else { + result[key] = storedValue; + } + } + } + + return result; + } + + /** + * Asynchronously removes the given storage items. + * + * @param {string|Array<string>} keys + * A string key of a list of storage items keys to remove. + * @returns {Promise<object>} + * Returns an object which contains applied changes. + */ + async remove(keys) { + // Ensure that keys is an array of strings. + keys = [].concat(keys); + + if (keys.length === 0) { + // Early exit if there is nothing to remove. + return null; + } + + const changes = {}; + let changed = false; + + const objectStore = this.objectStore(IDB_DATA_STORENAME, "readwrite"); + + let promises = []; + + for (let key of keys) { + promises.push( + objectStore.getKey(key).then(async foundKey => { + if (foundKey === key) { + changed = true; + changes[key] = { oldValue: await objectStore.get(key) }; + return objectStore.delete(key); + } + }) + ); + } + + await Promise.all(promises); + + return changed ? changes : null; + } + + /** + * Asynchronously clears all storage entries. + * + * @returns {Promise<object>} + * Returns an object which contains applied changes. + */ + async clear() { + const changes = {}; + let changed = false; + + const objectStore = this.objectStore(IDB_DATA_STORENAME, "readwrite"); + + const cursor = await objectStore.openCursor(); + while (!cursor.done) { + changes[cursor.key] = { oldValue: cursor.value }; + changed = true; + await cursor.continue(); + } + + await objectStore.clear(); + + return changed ? changes : null; + } +} + +/** + * Migrate the data stored in the JSONFile backend to the IDB Backend. + * + * Returns a promise which is resolved once the data migration has been + * completed and the new IDB backend can be enabled. + * Rejects if the data has been read successfully from the JSONFile backend + * but it failed to be saved in the new IDB backend. + * + * This method is called only from the main process (where the file + * can be opened). + * + * @param {Extension} extension + * The extension to migrate to the new IDB backend. + * @param {nsIPrincipal} storagePrincipal + * The "internally reserved" extension storagePrincipal to be used to create + * the ExtensionStorageLocalIDB instance. + */ +async function migrateJSONFileData(extension, storagePrincipal) { + let oldStoragePath; + let oldStorageExists; + let idbConn; + let jsonFile; + let hasEmptyIDB; + let nonFatalError; + let dataMigrateCompleted = false; + let hasOldData = false; + + function abortIfShuttingDown() { + if (extension.hasShutdown || Services.startup.shuttingDown) { + throw new DataMigrationAbortedError("extension or app is shutting down"); + } + } + + if (ExtensionStorageIDB.isMigratedExtension(extension)) { + return; + } + + try { + abortIfShuttingDown(); + idbConn = await ExtensionStorageIDB.open( + storagePrincipal, + extension.hasPermission("unlimitedStorage") + ); + abortIfShuttingDown(); + + hasEmptyIDB = await idbConn.isEmpty(); + + if (!hasEmptyIDB) { + // If the IDB backend is enabled and there is data already stored in the IDB backend, + // there is no "going back": any data that has not been migrated will be still on disk + // but it is not going to be migrated anymore, it could be eventually used to allow + // a user to manually retrieve the old data file). + ExtensionStorageIDB.setMigratedExtensionPref(extension, true); + return; + } + } catch (err) { + extension.logWarning( + `storage.local data migration cancelled, unable to open IDB connection: ${err.message}::${err.stack}` + ); + + ErrorsTelemetry.recordDataMigrationResult({ + backend: "JSONFile", + extensionId: extension.id, + error: err, + histogramCategory: "failure", + }); + + throw err; + } + + try { + abortIfShuttingDown(); + + oldStoragePath = lazy.ExtensionStorage.getStorageFile(extension.id); + oldStorageExists = await IOUtils.exists(oldStoragePath).catch(fileErr => { + // If we can't access the oldStoragePath here, then extension is also going to be unable to + // access it, and so we log the error but we don't stop the extension from switching to + // the IndexedDB backend. + extension.logWarning( + `Unable to access extension storage.local data file: ${fileErr.message}::${fileErr.stack}` + ); + return false; + }); + + // Migrate any data stored in the JSONFile backend (if any), and remove the old data file + // if the migration has been completed successfully. + if (oldStorageExists) { + // Do not load the old JSON file content if shutting down is already in progress. + abortIfShuttingDown(); + + Services.console.logStringMessage( + `Migrating storage.local data for ${extension.policy.debugName}...` + ); + + jsonFile = await lazy.ExtensionStorage.getFile(extension.id); + + abortIfShuttingDown(); + + const data = {}; + for (let [key, value] of jsonFile.data.entries()) { + data[key] = value; + hasOldData = true; + } + + await idbConn.set(data); + Services.console.logStringMessage( + `storage.local data successfully migrated to IDB Backend for ${extension.policy.debugName}.` + ); + } + + dataMigrateCompleted = true; + } catch (err) { + extension.logWarning( + `Error on migrating storage.local data file: ${err.message}::${err.stack}` + ); + + if (oldStorageExists && !dataMigrateCompleted) { + ErrorsTelemetry.recordDataMigrationResult({ + backend: "JSONFile", + dataMigrated: dataMigrateCompleted, + extensionId: extension.id, + error: err, + hasJSONFile: oldStorageExists, + hasOldData, + histogramCategory: "failure", + }); + + // If the data failed to be stored into the IndexedDB backend, then we clear the IndexedDB + // backend to allow the extension to retry the migration on its next startup, and reject + // the data migration promise explicitly (which would prevent the new backend + // from being enabled for this session). + await new Promise(resolve => { + let req = Services.qms.clearStoragesForPrincipal(storagePrincipal); + req.callback = resolve; + }); + + throw err; + } + + // This error is not preventing the extension from switching to the IndexedDB backend, + // but we may still want to know that it has been triggered and include it into the + // telemetry data collected for the extension. + nonFatalError = err; + } finally { + // Clear the jsonFilePromise cached by the ExtensionStorage. + await lazy.ExtensionStorage.clearCachedFile(extension.id).catch(err => { + extension.logWarning(err.message); + }); + } + + // If the IDB backend has been enabled, rename the old storage.local data file, but + // do not prevent the extension from switching to the IndexedDB backend if it fails. + if (oldStorageExists && dataMigrateCompleted) { + try { + // Only migrate the file when it actually exists (e.g. the file name is not going to exist + // when it is corrupted, because JSONFile internally rename it to `.corrupt`. + if (await IOUtils.exists(oldStoragePath)) { + const uniquePath = await IOUtils.createUniqueFile( + PathUtils.parent(oldStoragePath), + `${PathUtils.filename(oldStoragePath)}.migrated` + ); + await IOUtils.move(oldStoragePath, uniquePath); + } + } catch (err) { + nonFatalError = err; + extension.logWarning(err.message); + } + } + + ExtensionStorageIDB.setMigratedExtensionPref(extension, true); + + ErrorsTelemetry.recordDataMigrationResult({ + backend: "IndexedDB", + dataMigrated: dataMigrateCompleted, + extensionId: extension.id, + error: nonFatalError, + hasJSONFile: oldStorageExists, + hasOldData, + histogramCategory: "success", + }); +} + +/** + * This ExtensionStorage class implements a backend for the storage.local API which + * uses IndexedDB to store the data. + */ +export var ExtensionStorageIDB = { + BACKEND_ENABLED_PREF, + IDB_MIGRATED_PREF_BRANCH, + IDB_MIGRATE_RESULT_HISTOGRAM, + + // Map<extension-id, Set<Function>> + listeners: new Map(), + + // Keep track if the IDB backend has been selected or not for a running extension + // (the selected backend should never change while the extension is running, even if the + // related preference has been changed in the meantime): + // + // WeakMap<extension -> Promise<boolean> + selectedBackendPromises: new WeakMap(), + + init() { + XPCOMUtils.defineLazyPreferenceGetter( + this, + "isBackendEnabled", + BACKEND_ENABLED_PREF, + false + ); + }, + + isMigratedExtension(extension) { + return Services.prefs.getBoolPref( + `${IDB_MIGRATED_PREF_BRANCH}.${extension.id}`, + false + ); + }, + + setMigratedExtensionPref(extension, val) { + Services.prefs.setBoolPref( + `${IDB_MIGRATED_PREF_BRANCH}.${extension.id}`, + !!val + ); + }, + + clearMigratedExtensionPref(extensionId) { + Services.prefs.clearUserPref(`${IDB_MIGRATED_PREF_BRANCH}.${extensionId}`); + }, + + getStoragePrincipal(extension) { + return extension.createPrincipal(extension.baseURI, { + userContextId: WEBEXT_STORAGE_USER_CONTEXT_ID, + }); + }, + + /** + * Select the preferred backend and return a promise which is resolved once the + * selected backend is ready to be used (e.g. if the extension is switching from + * the old JSONFile storage to the new IDB backend, any previously stored data will + * be migrated to the backend before the promise is resolved). + * + * This method is called from both the main and child (content or extension) processes: + * - an extension child context will call this method lazily, when the browser.storage.local + * is being used for the first time, and it will result into asking the main process + * to call the same method in the main process + * - on the main process side, it will check if the new IDB backend can be used (and if it can, + * it will migrate any existing data into the new backend, which needs to happen in the + * main process where the file can directly be accessed) + * + * The result will be cached while the extension is still running, and so an extension + * child context is going to ask the main process only once per child process, and on the + * main process side the backend selection and data migration will happen only once. + * + * @param {import("ExtensionPageChild.sys.mjs").ExtensionBaseContextChild} context + * The extension context that is selecting the storage backend. + * + * @returns {Promise<object>} + * Returns a promise which resolves to an object which provides a + * `backendEnabled` boolean property, and if it is true the extension should use + * the IDB backend and the object also includes a `storagePrincipal` property + * of type nsIPrincipal, otherwise `backendEnabled` will be false when the + * extension should use the old JSONFile backend (e.g. because the IDB backend has + * not been enabled from the preference). + */ + selectBackend(context) { + const { extension } = context; + + if (!this.selectedBackendPromises.has(extension)) { + let promise; + + if (context.childManager) { + return context.childManager + .callParentAsyncFunction("storage.local.IDBBackend.selectBackend", []) + .then(parentResult => { + let result; + + if (!parentResult.backendEnabled) { + result = { backendEnabled: false }; + } else { + result = { + ...parentResult, + // In the child process, we need to deserialize the storagePrincipal + // from the StructuredCloneHolder used to send it across the processes. + storagePrincipal: parentResult.storagePrincipal.deserialize( + this, + true + ), + }; + } + + // Cache the result once we know that it has been resolved. The promise returned by + // context.childManager.callParentAsyncFunction will be dead when context.cloneScope + // is destroyed. To keep a promise alive in the cache, we wrap the result in an + // independent promise. + this.selectedBackendPromises.set( + extension, + Promise.resolve(result) + ); + + return result; + }); + } + + // If migrating to the IDB backend is not enabled by the preference, then we + // don't need to migrate any data and the new backend is not enabled. + if (!this.isBackendEnabled) { + promise = Promise.resolve({ backendEnabled: false }); + } else { + // In the main process, lazily create a storagePrincipal isolated in a + // reserved user context id (its purpose is ensuring that the IndexedDB storage used + // by the browser.storage.local API is not directly accessible from the extension code). + const storagePrincipal = this.getStoragePrincipal(extension); + + // Serialize the nsIPrincipal object into a StructuredCloneHolder related to the privileged + // js global, ready to be sent to the child processes. + const serializedPrincipal = new StructuredCloneHolder( + "ExtensionStorageIDB/selectBackend/serializedPrincipal", + null, + storagePrincipal, + this + ); + + promise = migrateJSONFileData(extension, storagePrincipal) + .then(() => { + extension.setSharedData("storageIDBBackend", true); + extension.setSharedData("storageIDBPrincipal", storagePrincipal); + Services.ppmm.sharedData.flush(); + return { + backendEnabled: true, + storagePrincipal: serializedPrincipal, + }; + }) + .catch(err => { + // If the data migration promise is rejected, the old data has been read + // successfully from the old JSONFile backend but it failed to be saved + // into the IndexedDB backend (which is likely unrelated to the kind of + // data stored and more likely a general issue with the IndexedDB backend) + // In this case we keep the JSONFile backend enabled for this session + // and we will retry to migrate to the IDB Backend the next time the + // extension is being started. + // TODO Bug 1465129: This should be a very unlikely scenario, some telemetry + // data about it may be useful. + extension.logWarning( + "JSONFile backend is being kept enabled by an unexpected " + + `IDBBackend failure: ${err.message}::${err.stack}` + ); + extension.setSharedData("storageIDBBackend", false); + Services.ppmm.sharedData.flush(); + + return { backendEnabled: false }; + }); + } + + this.selectedBackendPromises.set(extension, promise); + } + + return this.selectedBackendPromises.get(extension); + }, + + persist(storagePrincipal) { + return new Promise((resolve, reject) => { + const request = Services.qms.persist(storagePrincipal); + request.callback = () => { + if (request.resultCode === Cr.NS_OK) { + resolve(); + } else { + reject( + new Error( + `Failed to persist storage for principal: ${storagePrincipal.originNoSuffix}` + ) + ); + } + }; + }); + }, + + /** + * Open a connection to the IDB storage.local db for a given extension. + * given extension. + * + * @param {nsIPrincipal} storagePrincipal + * The "internally reserved" extension storagePrincipal to be used to create + * the ExtensionStorageLocalIDB instance. + * @param {boolean} persisted + * A boolean which indicates if the storage should be set into persistent mode. + * + * @returns {Promise<ExtensionStorageLocalIDB>} + * Return a promise which resolves to the opened IDB connection. + */ + open(storagePrincipal, persisted) { + if (!storagePrincipal) { + return Promise.reject(new Error("Unexpected empty principal")); + } + let setPersistentMode = persisted + ? this.persist(storagePrincipal) + : Promise.resolve(); + return setPersistentMode.then(() => + ExtensionStorageLocalIDB.openForPrincipal(storagePrincipal) + ); + }, + + /** + * Ensure that an error originated from the ExtensionStorageIDB methods is normalized + * into an ExtensionError (e.g. DataCloneError and QuotaExceededError instances raised + * from the internal IndexedDB operations have to be converted into an ExtensionError + * to be accessible to the extension code). + * + * @param {object} params + * @param {Error|ExtensionError|DOMException} params.error + * The error object to normalize. + * @param {string} params.extensionId + * The id of the extension that was executing the storage.local method. + * @param {string} params.storageMethod + * The storage method being executed when the error has been thrown + * (used to keep track of the unexpected error incidence in telemetry). + * + * @returns {ExtensionError} + * Return an ExtensionError error instance. + */ + normalizeStorageError({ error, extensionId, storageMethod }) { + const { ExtensionError } = lazy.ExtensionUtils; + + if (error instanceof ExtensionError) { + // @ts-ignore (will go away after `lazy` is properly typed) + return error; + } + + let errorMessage; + + if (DOMException.isInstance(error)) { + switch (error.name) { + case "DataCloneError": + errorMessage = String(error); + break; + case "QuotaExceededError": + errorMessage = `${error.name}: storage.local API call exceeded its quota limitations.`; + break; + } + } + + if (!errorMessage) { + Cu.reportError(error); + + errorMessage = "An unexpected error occurred"; + + ErrorsTelemetry.recordStorageLocalError({ + error, + extensionId, + storageMethod, + }); + } + + return new ExtensionError(errorMessage); + }, + + addOnChangedListener(extensionId, listener) { + let listeners = this.listeners.get(extensionId) || new Set(); + listeners.add(listener); + this.listeners.set(extensionId, listeners); + }, + + removeOnChangedListener(extensionId, listener) { + let listeners = this.listeners.get(extensionId); + listeners.delete(listener); + }, + + notifyListeners(extensionId, changes) { + let listeners = this.listeners.get(extensionId); + if (listeners) { + for (let listener of listeners) { + listener(changes); + } + } + }, + + hasListeners(extensionId) { + let listeners = this.listeners.get(extensionId); + return listeners && listeners.size > 0; + }, +}; + +ExtensionStorageIDB.init(); diff --git a/toolkit/components/extensions/ExtensionStorageSync.sys.mjs b/toolkit/components/extensions/ExtensionStorageSync.sys.mjs new file mode 100644 index 0000000000..d41cf5af12 --- /dev/null +++ b/toolkit/components/extensions/ExtensionStorageSync.sys.mjs @@ -0,0 +1,201 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +const STORAGE_SYNC_ENABLED_PREF = "webextensions.storage.sync.enabled"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const NS_ERROR_DOM_QUOTA_EXCEEDED_ERR = 0x80530016; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs", + ExtensionUtils: "resource://gre/modules/ExtensionUtils.sys.mjs", + // We might end up falling back to kinto... + extensionStorageSyncKinto: + "resource://gre/modules/ExtensionStorageSyncKinto.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "prefPermitsStorageSync", + STORAGE_SYNC_ENABLED_PREF, + true +); + +// This xpcom service implements a "bridge" from the JS world to the Rust world. +// It sets up the database and implements a callback-based version of the +// browser.storage API. +ChromeUtils.defineLazyGetter(lazy, "storageSvc", () => + Cc["@mozilla.org/extensions/storage/sync;1"] + .getService(Ci.nsIInterfaceRequestor) + .getInterface(Ci.mozIExtensionStorageArea) +); + +// The interfaces which define the callbacks used by the bridge. There's a +// callback for success, failure, and to record data changes. +function ExtensionStorageApiCallback(resolve, reject, changeCallback) { + this.resolve = resolve; + this.reject = reject; + this.changeCallback = changeCallback; +} + +ExtensionStorageApiCallback.prototype = { + QueryInterface: ChromeUtils.generateQI([ + "mozIExtensionStorageListener", + "mozIExtensionStorageCallback", + ]), + + handleSuccess(result) { + this.resolve(result ? JSON.parse(result) : null); + }, + + handleError(code, message) { + let e = new Error(message); + e.code = code; + Cu.reportError(e); + this.reject(e); + }, + + onChanged(extId, json) { + if (this.changeCallback && json) { + try { + this.changeCallback(extId, JSON.parse(json)); + } catch (ex) { + Cu.reportError(ex); + } + } + }, +}; + +// The backing implementation of the browser.storage.sync web extension API. +export class ExtensionStorageSync { + constructor() { + this.listeners = new Map(); + // We are optimistic :) If we ever see the special nsresult which indicates + // migration failure, it will become false. In practice, this will only ever + // happen on the first operation. + this.migrationOk = true; + } + + // The main entry-point to our bridge. It performs some important roles: + // * Ensures the API is allowed to be used. + // * Works out what "extension id" to use. + // * Turns the callback API into a promise API. + async _promisify(fnName, extension, context, ...args) { + let extId = extension.id; + if (lazy.prefPermitsStorageSync !== true) { + throw new lazy.ExtensionUtils.ExtensionError( + `Please set ${STORAGE_SYNC_ENABLED_PREF} to true in about:config` + ); + } + + if (this.migrationOk) { + // We can call ours. + try { + return await new Promise((resolve, reject) => { + let callback = new ExtensionStorageApiCallback( + resolve, + reject, + (extId, changes) => this.notifyListeners(extId, changes) + ); + let sargs = args.map(val => JSON.stringify(val)); + lazy.storageSvc[fnName](extId, ...sargs, callback); + }); + } catch (ex) { + if (ex.code != Cr.NS_ERROR_CANNOT_CONVERT_DATA) { + // Some non-migration related error we want to sanitize and propagate. + // The only "public" exception here is for quota failure - all others + // are sanitized. + let sanitized = + ex.code == NS_ERROR_DOM_QUOTA_EXCEEDED_ERR + ? // The same message as the local IDB implementation + `QuotaExceededError: storage.sync API call exceeded its quota limitations.` + : // The standard, generic extension error. + "An unexpected error occurred"; + throw new lazy.ExtensionUtils.ExtensionError(sanitized); + } + // This means "migrate failed" so we must fall back to kinto. + Cu.reportError( + "migration of extension-storage failed - will fall back to kinto" + ); + this.migrationOk = false; + } + } + // We've detected failure to migrate, so we want to use kinto. + return lazy.extensionStorageSyncKinto[fnName](extension, ...args, context); + } + + set(extension, items, context) { + return this._promisify("set", extension, context, items); + } + + remove(extension, keys, context) { + return this._promisify("remove", extension, context, keys); + } + + clear(extension, context) { + return this._promisify("clear", extension, context); + } + + clearOnUninstall(extensionId) { + if (!this.migrationOk) { + // If the rust-based backend isn't being used, + // no need to clear it. + return; + } + // Resolve the returned promise once the request has been either resolved + // or rejected (and report the error on the browser console in case of + // unexpected clear failures on addon uninstall). + return new Promise(resolve => { + const callback = new ExtensionStorageApiCallback( + resolve, + err => { + Cu.reportError(err); + resolve(); + }, + // empty changeCallback (no need to notify the extension + // while clearing the extension on uninstall). + () => {} + ); + lazy.storageSvc.clear(extensionId, callback); + }); + } + + get(extension, spec, context) { + return this._promisify("get", extension, context, spec); + } + + getBytesInUse(extension, keys, context) { + return this._promisify("getBytesInUse", extension, context, keys); + } + + addOnChangedListener(extension, listener, context) { + let listeners = this.listeners.get(extension.id) || new Set(); + listeners.add(listener); + this.listeners.set(extension.id, listeners); + } + + removeOnChangedListener(extension, listener) { + let listeners = this.listeners.get(extension.id); + listeners.delete(listener); + if (listeners.size == 0) { + this.listeners.delete(extension.id); + } + } + + notifyListeners(extId, changes) { + let listeners = this.listeners.get(extId) || new Set(); + if (listeners) { + for (let listener of listeners) { + lazy.ExtensionCommon.runSafeSyncWithoutClone(listener, changes); + } + } + } +} + +export var extensionStorageSync = new ExtensionStorageSync(); diff --git a/toolkit/components/extensions/ExtensionStorageSyncKinto.sys.mjs b/toolkit/components/extensions/ExtensionStorageSyncKinto.sys.mjs new file mode 100644 index 0000000000..d10b140f7e --- /dev/null +++ b/toolkit/components/extensions/ExtensionStorageSyncKinto.sys.mjs @@ -0,0 +1,1386 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +// TODO: +// * find out how the Chrome implementation deals with conflicts + +// TODO bug 1637465: Remove the Kinto-based storage implementation. + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const KINTO_PROD_SERVER_URL = + "https://webextensions.settings.services.mozilla.com/v1"; +const KINTO_DEFAULT_SERVER_URL = KINTO_PROD_SERVER_URL; + +const STORAGE_SYNC_ENABLED_PREF = "webextensions.storage.sync.enabled"; +const STORAGE_SYNC_SERVER_URL_PREF = "webextensions.storage.sync.serverURL"; +const STORAGE_SYNC_SCOPE = "sync:addon_storage"; +const STORAGE_SYNC_CRYPTO_COLLECTION_NAME = "storage-sync-crypto"; +const STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID = "keys"; +const STORAGE_SYNC_CRYPTO_SALT_LENGTH_BYTES = 32; +const FXA_OAUTH_OPTIONS = { + scope: STORAGE_SYNC_SCOPE, +}; +// Default is 5sec, which seems a bit aggressive on the open internet +const KINTO_REQUEST_TIMEOUT = 30000; + +import { Log } from "resource://gre/modules/Log.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + BulkKeyBundle: "resource://services-sync/keys.sys.mjs", + CollectionKeyManager: "resource://services-sync/record.sys.mjs", + CommonUtils: "resource://services-common/utils.sys.mjs", + CryptoUtils: "resource://services-crypto/utils.sys.mjs", + ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs", + FirefoxAdapter: "resource://services-common/kinto-storage-adapter.sys.mjs", + KintoHttpClient: "resource://services-common/kinto-http-client.sys.mjs", + Observers: "resource://services-common/observers.sys.mjs", + Utils: "resource://services-sync/util.sys.mjs", +}); + +/** + * @typedef {any} Collection + * @typedef {any} CollectionKeyManager + * @typedef {any} FXAccounts + * @typedef {any} KeyBundle + * @typedef {any} SyncResultObject + */ +XPCOMUtils.defineLazyModuleGetters(lazy, { + Kinto: "resource://services-common/kinto-offline-client.js", +}); + +ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => { + return ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" + ).getFxAccountsSingleton(); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "prefPermitsStorageSync", + STORAGE_SYNC_ENABLED_PREF, + true +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "prefStorageSyncServerURL", + STORAGE_SYNC_SERVER_URL_PREF, + KINTO_DEFAULT_SERVER_URL +); +ChromeUtils.defineLazyGetter(lazy, "WeaveCrypto", function () { + let { WeaveCrypto } = ChromeUtils.importESModule( + "resource://services-crypto/WeaveCrypto.sys.mjs" + ); + return new WeaveCrypto(); +}); + +const { DefaultMap } = ExtensionUtils; + +// Map of Extensions to Set<Contexts> to track contexts that are still +// "live" and use storage.sync. +const extensionContexts = new DefaultMap(() => new Set()); +// Borrow logger from Sync. +const log = Log.repository.getLogger("Sync.Engine.Extension-Storage"); + +// A global that is fxAccounts, or null if (as on android) fxAccounts +// isn't available. +let _fxaService = null; +if (AppConstants.platform != "android") { + _fxaService = lazy.fxAccounts; +} + +class ServerKeyringDeleted extends Error { + constructor() { + super( + "server keyring appears to have disappeared; we were called to decrypt null" + ); + } +} + +/** + * Check for FXA and throw an exception if we don't have access. + * + * @param {object} fxAccounts The reference we were hoping to use to + * access FxA + * @param {string} action The thing we were doing when we decided to + * see if we had access to FxA + */ +function throwIfNoFxA(fxAccounts, action) { + if (!fxAccounts) { + throw new Error( + `${action} is impossible because FXAccounts is not available; are you on Android?` + ); + } +} + +// Global ExtensionStorageSyncKinto instance that extensions and Fx Sync use. +// On Android, because there's no FXAccounts instance, any syncing +// operations will fail. +export var extensionStorageSyncKinto = null; + +/** + * Utility function to enforce an order of fields when computing an HMAC. + * + * @param {KeyBundle} keyBundle The key bundle to use to compute the HMAC + * @param {string} id The record ID to use when computing the HMAC + * @param {string} IV The IV to use when computing the HMAC + * @param {string} ciphertext The ciphertext over which to compute the HMAC + * @returns {Promise<string>} The computed HMAC + */ +async function ciphertextHMAC(keyBundle, id, IV, ciphertext) { + const hmacKey = lazy.CommonUtils.byteStringToArrayBuffer(keyBundle.hmacKey); + const encoder = new TextEncoder(); + const data = encoder.encode(id + IV + ciphertext); + const hmac = await lazy.CryptoUtils.hmac("SHA-256", hmacKey, data); + return lazy.CommonUtils.bytesAsHex( + lazy.CommonUtils.arrayBufferToByteString(hmac) + ); +} + +/** + * Get the current user's hashed kB. + * + * @param {FXAccounts} fxaService The service to use to get the + * current user. + * @returns {Promise<string>} sha256 of the user's kB as a hex string + */ +const getKBHash = async function (fxaService) { + const key = await fxaService.keys.getKeyForScope(STORAGE_SYNC_SCOPE); + return fxaService.keys.kidAsHex(key); +}; + +/** + * A "remote transformer" that the Kinto library will use to + * encrypt/decrypt records when syncing. + * + * This is an "abstract base class". Subclass this and override + * getKeys() to use it. + */ +class EncryptionRemoteTransformer { + async encode(record) { + const keyBundle = await this.getKeys(); + if (record.ciphertext) { + throw new Error("Attempt to reencrypt??"); + } + let id = await this.getEncodedRecordId(record); + if (!id) { + throw new Error("Record ID is missing or invalid"); + } + + let IV = lazy.WeaveCrypto.generateRandomIV(); + let ciphertext = await lazy.WeaveCrypto.encrypt( + JSON.stringify(record), + keyBundle.encryptionKeyB64, + IV + ); + let hmac = await ciphertextHMAC(keyBundle, id, IV, ciphertext); + const encryptedResult = { ciphertext, IV, hmac, id }; + + // Copy over the _status field, so that we handle concurrency + // headers (If-Match, If-None-Match) correctly. + // DON'T copy over "deleted" status, because then we'd leak + // plaintext deletes. + encryptedResult._status = + record._status == "deleted" ? "updated" : record._status; + if (record.hasOwnProperty("last_modified")) { + encryptedResult.last_modified = record.last_modified; + } + + return encryptedResult; + } + + async decode(record) { + if (!record.ciphertext) { + // This can happen for tombstones if a record is deleted. + if (record.deleted) { + return record; + } + throw new Error("No ciphertext: nothing to decrypt?"); + } + const keyBundle = await this.getKeys(); + // Authenticate the encrypted blob with the expected HMAC + let computedHMAC = await ciphertextHMAC( + keyBundle, + record.id, + record.IV, + record.ciphertext + ); + + if (computedHMAC != record.hmac) { + lazy.Utils.throwHMACMismatch(record.hmac, computedHMAC); + } + + // Handle invalid data here. Elsewhere we assume that cleartext is an object. + let cleartext = await lazy.WeaveCrypto.decrypt( + record.ciphertext, + keyBundle.encryptionKeyB64, + record.IV + ); + let jsonResult = JSON.parse(cleartext); + if (!jsonResult || typeof jsonResult !== "object") { + throw new Error( + "Decryption failed: result is <" + jsonResult + ">, not an object." + ); + } + + if (record.hasOwnProperty("last_modified")) { + jsonResult.last_modified = record.last_modified; + } + + // _status: deleted records were deleted on a client, but + // uploaded as an encrypted blob so we don't leak deletions. + // If we get such a record, flag it as deleted. + if (jsonResult._status == "deleted") { + jsonResult.deleted = true; + } + + return jsonResult; + } + + /** + * Retrieve keys to use during encryption. + * + * @returns {Promise<KeyBundle>} + */ + getKeys() { + throw new Error("override getKeys in a subclass"); + } + + /** + * Compute the record ID to use for the encoded version of the + * record. + * + * The default version just re-uses the record's ID. + * + * @param {object} record The record being encoded. + * @returns {Promise<string>} The ID to use. + */ + getEncodedRecordId(record) { + return Promise.resolve(record.id); + } +} + +/** + * An EncryptionRemoteTransformer that provides a keybundle derived + * from the user's kB, suitable for encrypting a keyring. + */ +class KeyRingEncryptionRemoteTransformer extends EncryptionRemoteTransformer { + constructor(fxaService) { + super(); + this._fxaService = fxaService; + } + + getKeys() { + throwIfNoFxA(this._fxaService, "encrypting chrome.storage.sync records"); + const self = this; + return (async function () { + let key = await self._fxaService.keys.getKeyForScope(STORAGE_SYNC_SCOPE); + return lazy.BulkKeyBundle.fromJWK(key); + })(); + } + // Pass through the kbHash field from the unencrypted record. If + // encryption fails, we can use this to try to detect whether we are + // being compromised or if the record here was encoded with a + // different kB. + async encode(record) { + const encoded = await super.encode(record); + encoded.kbHash = record.kbHash; + return encoded; + } + + async decode(record) { + try { + return await super.decode(record); + } catch (e) { + if (lazy.Utils.isHMACMismatch(e)) { + const currentKBHash = await getKBHash(this._fxaService); + if (record.kbHash != currentKBHash) { + // Some other client encoded this with a kB that we don't + // have access to. + KeyRingEncryptionRemoteTransformer.throwOutdatedKB( + currentKBHash, + record.kbHash + ); + } + } + throw e; + } + } + + // Generator and discriminator for KB-is-outdated exceptions. + static throwOutdatedKB(shouldBe, is) { + throw new Error( + `kB hash on record is outdated: should be ${shouldBe}, is ${is}` + ); + } + + static isOutdatedKB(exc) { + const kbMessage = "kB hash on record is outdated: "; + return ( + exc && + exc.message && + exc.message.indexOf && + exc.message.indexOf(kbMessage) == 0 + ); + } +} + +/** + * A Promise that centralizes initialization of ExtensionStorageSyncKinto. + * + * This centralizes the use of the Sqlite database, to which there is + * only one connection which is shared by all threads. + * + * Fields in the object returned by this Promise: + * + * - connection: a Sqlite connection. Meant for internal use only. + * - kinto: a KintoBase object, suitable for using in Firefox. All + * collections in this database will use the same Sqlite connection. + * + * @returns {Promise<object>} + */ +async function storageSyncInit() { + // Memoize the result to share the connection. + if (storageSyncInit.promise === undefined) { + const path = "storage-sync.sqlite"; + storageSyncInit.promise = lazy.FirefoxAdapter.openConnection({ path }) + .then(connection => { + return { + connection, + kinto: new lazy.Kinto({ + adapter: lazy.FirefoxAdapter, + adapterOptions: { sqliteHandle: connection }, + timeout: KINTO_REQUEST_TIMEOUT, + retry: 0, + }), + }; + }) + .catch(e => { + // Ensure one failure doesn't break us forever. + Cu.reportError(e); + storageSyncInit.promise = undefined; + throw e; + }); + } + return storageSyncInit.promise; +} +storageSyncInit.promise = undefined; + +// Kinto record IDs have two conditions: +// +// - They must contain only ASCII alphanumerics plus - and _. To fix +// this, we encode all non-letters using _C_, where C is the +// percent-encoded character, so space becomes _20_ +// and underscore becomes _5F_. +// +// - They must start with an ASCII letter. To ensure this, we prefix +// all keys with "key-". +function keyToId(key) { + function escapeChar(match) { + return "_" + match.codePointAt(0).toString(16).toUpperCase() + "_"; + } + return "key-" + key.replace(/[^a-zA-Z0-9]/g, escapeChar); +} + +// Convert a Kinto ID back into a chrome.storage key. +// Returns null if a key couldn't be parsed. +function idToKey(id) { + function unescapeNumber(match, group1) { + return String.fromCodePoint(parseInt(group1, 16)); + } + // An escaped ID should match this regex. + // An escaped ID should consist of only letters and numbers, plus + // code points escaped as _[0-9a-f]+_. + const ESCAPED_ID_FORMAT = /^(?:[a-zA-Z0-9]|_[0-9A-F]+_)*$/; + + if (!id.startsWith("key-")) { + return null; + } + const unprefixed = id.slice(4); + // Verify that the ID is the correct format. + if (!ESCAPED_ID_FORMAT.test(unprefixed)) { + return null; + } + return unprefixed.replace(/_([0-9A-F]+)_/g, unescapeNumber); +} + +// An "id schema" used to validate Kinto IDs and generate new ones. +const storageSyncIdSchema = { + // We should never generate IDs; chrome.storage only acts as a + // key-value store, so we should always have a key. + generate() { + throw new Error("cannot generate IDs"); + }, + + // See keyToId and idToKey for more details. + validate(id) { + return idToKey(id) !== null; + }, +}; + +// An "id schema" used for the system collection, which doesn't +// require validation or generation of IDs. +const cryptoCollectionIdSchema = { + generate() { + throw new Error("cannot generate IDs for system collection"); + }, + + validate(id) { + return true; + }, +}; + +/** + * Wrapper around the crypto collection providing some handy utilities. + */ +class CryptoCollection { + constructor(fxaService) { + this._fxaService = fxaService; + } + + async getCollection() { + throwIfNoFxA(this._fxaService, "tried to access cryptoCollection"); + const { kinto } = await storageSyncInit(); + return kinto.collection(STORAGE_SYNC_CRYPTO_COLLECTION_NAME, { + idSchema: cryptoCollectionIdSchema, + remoteTransformers: [ + new KeyRingEncryptionRemoteTransformer(this._fxaService), + ], + }); + } + + /** + * Generate a new salt for use in hashing extension and record + * IDs. + * + * @returns {string} A base64-encoded string of the salt + */ + getNewSalt() { + return btoa( + lazy.CryptoUtils.generateRandomBytesLegacy( + STORAGE_SYNC_CRYPTO_SALT_LENGTH_BYTES + ) + ); + } + + /** + * Retrieve the keyring record from the crypto collection. + * + * You can use this if you want to check metadata on the keyring + * record rather than use the keyring itself. + * + * The keyring record, if present, should have the structure: + * + * - kbHash: a hash of the user's kB. When this changes, we will + * try to sync the collection. + * - uuid: a record identifier. This will only change when we wipe + * the collection (due to kB getting reset). + * - keys: a "WBO" form of a CollectionKeyManager. + * - salts: a normal JS Object with keys being collection IDs and + * values being base64-encoded salts to use when hashing IDs + * for that collection. + * + * @returns {Promise<object>} + */ + async getKeyRingRecord() { + const collection = await this.getCollection(); + const cryptoKeyRecord = await collection.getAny( + STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID + ); + + let data = cryptoKeyRecord.data; + if (!data) { + // This is a new keyring. Invent an ID for this record. If this + // changes, it means a client replaced the keyring, so we need to + // reupload everything. + const uuid = Services.uuid.generateUUID().toString(); + data = { uuid, id: STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID }; + } + return data; + } + + async getSalts() { + const cryptoKeyRecord = await this.getKeyRingRecord(); + return cryptoKeyRecord && cryptoKeyRecord.salts; + } + + /** + * Used for testing with a known salt. + * + * @param {string} extensionId The extension ID for which to set a + * salt. + * @param {string} salt The salt to use for this extension, as a + * base64-encoded salt. + */ + async _setSalt(extensionId, salt) { + const cryptoKeyRecord = await this.getKeyRingRecord(); + cryptoKeyRecord.salts = cryptoKeyRecord.salts || {}; + cryptoKeyRecord.salts[extensionId] = salt; + await this.upsert(cryptoKeyRecord); + } + + /** + * Hash an extension ID for a given user so that an attacker can't + * identify the extensions a user has installed. + * + * The extension ID is assumed to be a string (i.e. series of + * code points), and its UTF8 encoding is prefixed with the salt + * for that collection and hashed. + * + * The returned hash must conform to the syntax for Kinto + * identifiers, which (as of this writing) must match + * [a-zA-Z0-9][a-zA-Z0-9_-]*. We thus encode the hash using + * "base64-url" without padding (so that we don't get any equals + * signs (=)). For fear that a hash could start with a hyphen + * (-) or an underscore (_), prefix it with "ext-". + * + * @param {string} extensionId The extension ID to obfuscate. + * @returns {Promise<bytestring>} A collection ID suitable for use to sync to. + */ + extensionIdToCollectionId(extensionId) { + return this.hashWithExtensionSalt( + lazy.CommonUtils.encodeUTF8(extensionId), + extensionId + ).then(hash => `ext-${hash}`); + } + + /** + * Hash some value with the salt for the given extension. + * + * The value should be a "bytestring", i.e. a string whose + * "characters" are values, each within [0, 255]. You can produce + * such a bytestring using e.g. CommonUtils.encodeUTF8. + * + * The returned value is a base64url-encoded string of the hash. + * + * @param {bytestring} value The value to be hashed. + * @param {string} extensionId The ID of the extension whose salt + * we should use. + * @returns {Promise<bytestring>} The hashed value. + */ + async hashWithExtensionSalt(value, extensionId) { + const salts = await this.getSalts(); + const saltBase64 = salts && salts[extensionId]; + if (!saltBase64) { + // This should never happen; salts should be populated before + // we need them by ensureCanSync. + throw new Error( + `no salt available for ${extensionId}; how did this happen?` + ); + } + + const hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + hasher.init(hasher.SHA256); + + const salt = atob(saltBase64); + const message = `${salt}\x00${value}`; + const hash = lazy.CryptoUtils.digestBytes(message, hasher); + return lazy.CommonUtils.encodeBase64URL(hash, false); + } + + /** + * Retrieve the actual keyring from the crypto collection. + * + * @returns {Promise<CollectionKeyManager>} + */ + async getKeyRing() { + const cryptoKeyRecord = await this.getKeyRingRecord(); + const collectionKeys = new lazy.CollectionKeyManager(); + if (cryptoKeyRecord.keys) { + collectionKeys.setContents( + cryptoKeyRecord.keys, + cryptoKeyRecord.last_modified + ); + } else { + // We never actually use the default key, so it's OK if we + // generate one multiple times. + await collectionKeys.generateDefaultKey(); + } + // Pass through uuid field so that we can save it if we need to. + collectionKeys.uuid = cryptoKeyRecord.uuid; + return collectionKeys; + } + + async updateKBHash(kbHash) { + const coll = await this.getCollection(); + await coll.update( + { id: STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID, kbHash: kbHash }, + { patch: true } + ); + } + + async upsert(record) { + const collection = await this.getCollection(); + await collection.upsert(record); + } + + async sync(extensionStorageSyncKinto) { + const collection = await this.getCollection(); + return extensionStorageSyncKinto._syncCollection(collection, { + strategy: "server_wins", + }); + } + + /** + * Reset sync status for ALL collections by directly + * accessing the FirefoxAdapter. + */ + async resetSyncStatus() { + const coll = await this.getCollection(); + await coll.db.resetSyncStatus(); + } + + // Used only for testing. + async _clear() { + const collection = await this.getCollection(); + await collection.clear(); + } +} + +/** + * An EncryptionRemoteTransformer for extension records. + * + * It uses the special "keys" record to find a key for a given + * extension, thus its name + * CollectionKeyEncryptionRemoteTransformer. + * + * Also, during encryption, it will replace the ID of the new record + * with a hashed ID, using the salt for this collection. + * + * @param {string} extensionId The extension ID for which to find a key. + */ +let CollectionKeyEncryptionRemoteTransformer = class extends EncryptionRemoteTransformer { + constructor(cryptoCollection, keyring, extensionId) { + super(); + this.cryptoCollection = cryptoCollection; + this.keyring = keyring; + this.extensionId = extensionId; + } + + async getKeys() { + if (!this.keyring.hasKeysFor([this.extensionId])) { + // This should never happen. Keys should be created (and + // synced) at the beginning of the sync cycle. + throw new Error( + `tried to encrypt records for ${this.extensionId}, but key is not present` + ); + } + return this.keyring.keyForCollection(this.extensionId); + } + + getEncodedRecordId(record) { + // It isn't really clear whether kinto.js record IDs are + // bytestrings or strings that happen to only contain ASCII + // characters, so encode them to be sure. + const id = lazy.CommonUtils.encodeUTF8(record.id); + // Like extensionIdToCollectionId, the rules about Kinto record + // IDs preclude equals signs or strings starting with a + // non-alphanumeric, so prefix all IDs with a constant "id-". + return this.cryptoCollection + .hashWithExtensionSalt(id, this.extensionId) + .then(hash => `id-${hash}`); + } +}; + +/** + * Clean up now that one context is no longer using this extension's collection. + * + * @param {Extension} extension + * The extension whose context just ended. + * @param {Context} context + * The context that just ended. + */ +function cleanUpForContext(extension, context) { + const contexts = extensionContexts.get(extension); + contexts.delete(context); + if (contexts.size === 0) { + // Nobody else is using this collection. Clean up. + extensionContexts.delete(extension); + } +} + +/** + * Generate a promise that produces the Collection for an extension. + * + * @param {Extension} extension + * The extension whose collection needs to + * be opened. + * @param {object} options + * Options to be passed to the call to `.collection()`. + * @returns {Promise<Collection>} + */ +const openCollection = async function (extension, options = {}) { + let collectionId = extension.id; + const { kinto } = await storageSyncInit(); + const coll = kinto.collection(collectionId, { + ...options, + idSchema: storageSyncIdSchema, + }); + return coll; +}; + +export class ExtensionStorageSyncKinto { + /** + * @param {FXAccounts} fxaService (Optional) If not + * present, trying to sync will fail. + */ + constructor(fxaService) { + this._fxaService = fxaService; + this.cryptoCollection = new CryptoCollection(fxaService); + this.listeners = new WeakMap(); + } + + /** + * Get a set of extensions to sync (including the ones with an + * active extension context that used the storage.sync API and + * the extensions that are enabled and have been synced before). + * + * @returns {Promise<Set<Extension>>} + * A promise which resolves to the set of the extensions to sync. + */ + async getExtensions() { + // Start from the set of the extensions with an active + // context that used the storage.sync APIs. + const extensions = new Set(extensionContexts.keys()); + + const allEnabledExtensions = await lazy.AddonManager.getAddonsByTypes([ + "extension", + ]); + + // Get the existing extension collections salts. + const keysRecord = await this.cryptoCollection.getKeyRingRecord(); + + // Add any enabled extensions that have been synced before. + for (const addon of allEnabledExtensions) { + if (this.hasSaltsFor(keysRecord, [addon.id])) { + const policy = WebExtensionPolicy.getByID(addon.id); + if (policy && policy.extension) { + extensions.add(policy.extension); + } + } + } + + return extensions; + } + + async syncAll() { + const extensions = await this.getExtensions(); + const extIds = Array.from(extensions, extension => extension.id); + log.debug(`Syncing extension settings for ${JSON.stringify(extIds)}`); + if (!extIds.length) { + // No extensions to sync. Get out. + return; + } + await this.ensureCanSync(extIds); + await this.checkSyncKeyRing(); + const keyring = await this.cryptoCollection.getKeyRing(); + const promises = Array.from(extensions, extension => { + const remoteTransformers = [ + new CollectionKeyEncryptionRemoteTransformer( + this.cryptoCollection, + keyring, + extension.id + ), + ]; + return openCollection(extension, { remoteTransformers }).then(coll => { + return this.sync(extension, coll); + }); + }); + await Promise.all(promises); + } + + async sync(extension, collection) { + throwIfNoFxA(this._fxaService, "syncing chrome.storage.sync"); + const isSignedIn = !!(await this._fxaService.getSignedInUser()); + if (!isSignedIn) { + // FIXME: this should support syncing to self-hosted + log.info("User was not signed into FxA; cannot sync"); + throw new Error("Not signed in to FxA"); + } + const collectionId = await this.cryptoCollection.extensionIdToCollectionId( + extension.id + ); + let syncResults; + try { + syncResults = await this._syncCollection(collection, { + strategy: "client_wins", + collection: collectionId, + }); + } catch (err) { + log.warn("Syncing failed", err); + throw err; + } + + let changes = {}; + for (const record of syncResults.created) { + changes[record.key] = { + newValue: record.data, + }; + } + for (const record of syncResults.updated) { + // N.B. It's safe to just pick old.key because it's not + // possible to "rename" a record in the storage.sync API. + const key = record.old.key; + changes[key] = { + oldValue: record.old.data, + newValue: record.new.data, + }; + } + for (const record of syncResults.deleted) { + changes[record.key] = { + oldValue: record.data, + }; + } + for (const resolution of syncResults.resolved) { + // FIXME: We can't send a "changed" notification because + // kinto.js only provides the newly-resolved value. But should + // we even send a notification? We use CLIENT_WINS so nothing + // has really "changed" on this end. (The change will come on + // the other end when it pulls down the update, which is handled + // by the "updated" case above.) If we are going to send a + // notification, what best values for "old" and "new"? This + // might violate client code's assumptions, since from their + // perspective, we were in state L, but this diff is from R -> + // L. + const accepted = resolution.accepted; + changes[accepted.key] = { + newValue: accepted.data, + }; + } + if (Object.keys(changes).length) { + this.notifyListeners(extension, changes); + } + log.info(`Successfully synced '${collection.name}'`); + } + + /** + * Utility function that handles the common stuff about syncing all + * Kinto collections (including "meta" collections like the crypto + * one). + * + * @param {Collection} collection + * @param {object} options + * Additional options to be passed to sync(). + * @returns {Promise<SyncResultObject>} + */ + _syncCollection(collection, options) { + // FIXME: this should support syncing to self-hosted + return this._requestWithToken( + `Syncing ${collection.name}`, + function (token) { + const allOptions = Object.assign( + {}, + { + remote: lazy.prefStorageSyncServerURL, + headers: { + Authorization: "Bearer " + token, + }, + }, + options + ); + + return collection.sync(allOptions); + } + ); + } + + // Make a Kinto request with a current FxA token. + // If the response indicates that the token might have expired, + // retry the request. + async _requestWithToken(description, f) { + throwIfNoFxA( + this._fxaService, + "making remote requests from chrome.storage.sync" + ); + const fxaToken = await this._fxaService.getOAuthToken(FXA_OAUTH_OPTIONS); + try { + return await f(fxaToken); + } catch (e) { + if (e && e.response && e.response.status == 401) { + // Our token might have expired. Refresh and retry. + log.info("Token might have expired"); + await this._fxaService.removeCachedOAuthToken({ token: fxaToken }); + const newToken = await this._fxaService.getOAuthToken( + FXA_OAUTH_OPTIONS + ); + + // If this fails too, let it go. + return f(newToken); + } + // Otherwise, we don't know how to handle this error, so just reraise. + log.error(`${description}: request failed`, e); + throw e; + } + } + + /** + * Helper similar to _syncCollection, but for deleting the user's bucket. + * + * @returns {Promise<void>} + */ + _deleteBucket() { + log.error("Deleting default bucket and everything in it"); + return this._requestWithToken("Clearing server", function (token) { + const headers = { Authorization: "Bearer " + token }; + const kintoHttp = new lazy.KintoHttpClient( + lazy.prefStorageSyncServerURL, + { + headers: headers, + timeout: KINTO_REQUEST_TIMEOUT, + } + ); + return kintoHttp.deleteBucket("default"); + }); + } + + async ensureSaltsFor(keysRecord, extIds) { + const newSalts = Object.assign({}, keysRecord.salts); + for (let collectionId of extIds) { + if (newSalts[collectionId]) { + continue; + } + + newSalts[collectionId] = this.cryptoCollection.getNewSalt(); + } + + return newSalts; + } + + /** + * Check whether the keys record (provided) already has salts for + * all the extensions given in extIds. + * + * @param {object} keysRecord A previously-retrieved keys record. + * @param {Array<string>} extIds The IDs of the extensions which + * need salts. + * @returns {boolean} + */ + hasSaltsFor(keysRecord, extIds) { + if (!keysRecord.salts) { + return false; + } + + for (let collectionId of extIds) { + if (!keysRecord.salts[collectionId]) { + return false; + } + } + + return true; + } + + /** + * Recursive promise that terminates when our local collectionKeys, + * as well as that on the server, have keys for all the extensions + * in extIds. + * + * @param {Array<string>} extIds + * The IDs of the extensions which need keys. + * @returns {Promise<CollectionKeyManager>} + */ + async ensureCanSync(extIds) { + const keysRecord = await this.cryptoCollection.getKeyRingRecord(); + const collectionKeys = await this.cryptoCollection.getKeyRing(); + if ( + collectionKeys.hasKeysFor(extIds) && + this.hasSaltsFor(keysRecord, extIds) + ) { + return collectionKeys; + } + + log.info(`Need to create keys and/or salts for ${JSON.stringify(extIds)}`); + const kbHash = await getKBHash(this._fxaService); + const newKeys = await collectionKeys.ensureKeysFor(extIds); + const newSalts = await this.ensureSaltsFor(keysRecord, extIds); + const newRecord = { + id: STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID, + keys: newKeys.asWBO().cleartext, + salts: newSalts, + uuid: collectionKeys.uuid, + // Add a field for the current kB hash. + kbHash: kbHash, + }; + await this.cryptoCollection.upsert(newRecord); + const result = await this._syncKeyRing(newRecord); + if (result.resolved.length) { + // We had a conflict which was automatically resolved. We now + // have a new keyring which might have keys for the + // collections. Recurse. + return this.ensureCanSync(extIds); + } + + // No conflicts. We're good. + return newKeys; + } + + /** + * Update the kB in the crypto record. + */ + async updateKeyRingKB() { + throwIfNoFxA(this._fxaService, 'use of chrome.storage.sync "keyring"'); + const isSignedIn = !!(await this._fxaService.getSignedInUser()); + if (!isSignedIn) { + // Although this function is meant to be called on login, + // it's not unreasonable to check any time, even if we aren't + // logged in. + // + // If we aren't logged in, we don't have any information about + // the user's kB, so we can't be sure that the user changed + // their kB, so just return. + return; + } + + const thisKBHash = await getKBHash(this._fxaService); + await this.cryptoCollection.updateKBHash(thisKBHash); + } + + /** + * Make sure the keyring is up to date and synced. + * + * This is called on syncs to make sure that we don't sync anything + * to any collection unless the key for that collection is on the + * server. + */ + async checkSyncKeyRing() { + await this.updateKeyRingKB(); + + const cryptoKeyRecord = await this.cryptoCollection.getKeyRingRecord(); + if (cryptoKeyRecord && cryptoKeyRecord._status !== "synced") { + // We haven't successfully synced the keyring since the last + // change. This could be because kB changed and we touched the + // keyring, or it could be because we failed to sync after + // adding a key. Either way, take this opportunity to sync the + // keyring. + await this._syncKeyRing(cryptoKeyRecord); + } + } + + async _syncKeyRing(cryptoKeyRecord) { + throwIfNoFxA(this._fxaService, 'syncing chrome.storage.sync "keyring"'); + try { + // Try to sync using server_wins. + // + // We use server_wins here because whatever is on the server is + // at least consistent with itself -- the crypto in the keyring + // matches the crypto on the collection records. This is because + // we generate and upload keys just before syncing data. + // + // It's possible that we can't decode the version on the server. + // This can happen if a user is locked out of their account, and + // does a "reset password" to get in on a new device. In this + // case, we are in a bind -- we can't decrypt the record on the + // server, so we can't merge keys. If this happens, we try to + // figure out if we're the one with the correct (new) kB or if + // we just got locked out because we have the old kB. If we're + // the one with the correct kB, we wipe the server and reupload + // everything, including a new keyring. + // + // If another device has wiped the server, we need to reupload + // everything we have on our end too, so we detect this by + // adding a UUID to the keyring. UUIDs are preserved throughout + // the lifetime of a keyring, so the only time a keyring UUID + // changes is when a new keyring is uploaded, which only happens + // after a server wipe. So when we get a "conflict" (resolved by + // server_wins), we check whether the server version has a new + // UUID. If so, reset our sync status, so that we'll reupload + // everything. + const result = await this.cryptoCollection.sync(this); + if (result.resolved.length) { + // Automatically-resolved conflict. It should + // be for the keys record. + const resolutionIds = result.resolved.map(resolution => resolution.id); + if (resolutionIds > 1) { + // This should never happen -- there is only ever one record + // in this collection. + log.error( + `Too many resolutions for sync-storage-crypto collection: ${JSON.stringify( + resolutionIds + )}` + ); + } + const keyResolution = result.resolved[0]; + if (keyResolution.id != STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID) { + // This should never happen -- there should only ever be the + // keyring in this collection. + log.error( + `Strange conflict in sync-storage-crypto collection: ${JSON.stringify( + resolutionIds + )}` + ); + } + + // Due to a bug in the server-side code (see + // https://github.com/Kinto/kinto/issues/1209), lots of users' + // keyrings were deleted. We discover this by trying to push a + // new keyring (because the user aded a new extension), and we + // get a conflict. We have SERVER_WINS, so the client will + // accept this deleted keyring and delete it locally. Discover + // this and undo it. + if (keyResolution.accepted === null) { + log.error("Conflict spotted -- the server keyring was deleted"); + await this.cryptoCollection.upsert(keyResolution.rejected); + // It's possible that the keyring on the server that was + // deleted had keys for other extensions, which had already + // encrypted data. For this to happen, another client would + // have had to upload the keyring and then the delete happened + // before this client did a sync (and got the new extension + // and tried to sync the keyring again). Just to be safe, + // let's signal that something went wrong and we should wipe + // the bucket. + throw new ServerKeyringDeleted(); + } + + if (keyResolution.accepted.uuid != cryptoKeyRecord.uuid) { + log.info( + `Detected a new UUID (${keyResolution.accepted.uuid}, was ${cryptoKeyRecord.uuid}). Resetting sync status for everything.` + ); + await this.cryptoCollection.resetSyncStatus(); + + // Server version is now correct. Return that result. + return result; + } + } + // No conflicts, or conflict was just someone else adding keys. + return result; + } catch (e) { + if ( + KeyRingEncryptionRemoteTransformer.isOutdatedKB(e) || + e instanceof ServerKeyringDeleted || + // This is another way that ServerKeyringDeleted can + // manifest; see bug 1350088 for more details. + e.message.includes("Server has been flushed.") + ) { + // Check if our token is still valid, or if we got locked out + // between starting the sync and talking to Kinto. + const isSessionValid = await this._fxaService.checkAccountStatus(); + if (isSessionValid) { + log.error( + "Couldn't decipher old keyring; deleting the default bucket and resetting sync status" + ); + await this._deleteBucket(); + await this.cryptoCollection.resetSyncStatus(); + + // Reupload our keyring, which is the only new keyring. + // We don't want client_wins here because another device + // could have uploaded another keyring in the meantime. + return this.cryptoCollection.sync(this); + } + } + throw e; + } + } + + registerInUse(extension, context) { + // Register that the extension and context are in use. + const contexts = extensionContexts.get(extension); + if (!contexts.has(context)) { + // New context. Register it and make sure it cleans itself up + // when it closes. + contexts.add(context); + context.callOnClose({ + close: () => cleanUpForContext(extension, context), + }); + } + } + + /** + * Get the collection for an extension, and register the extension + * as being "in use". + * + * @param {Extension} extension + * The extension for which we are seeking + * a collection. + * @param {Context} context + * The context of the extension, so that we can + * stop syncing the collection when the extension ends. + * @returns {Promise<Collection>} + */ + getCollection(extension, context) { + if (lazy.prefPermitsStorageSync !== true) { + return Promise.reject({ + message: `Please set ${STORAGE_SYNC_ENABLED_PREF} to true in about:config`, + }); + } + this.registerInUse(extension, context); + return openCollection(extension); + } + + async set(extension, items, context) { + const coll = await this.getCollection(extension, context); + const keys = Object.keys(items); + const ids = keys.map(keyToId); + const changes = await coll.execute( + txn => { + let changes = {}; + for (let [i, key] of keys.entries()) { + const id = ids[i]; + let item = items[key]; + let { oldRecord } = txn.upsert({ + id, + key, + data: item, + }); + changes[key] = { + newValue: item, + }; + if (oldRecord) { + // Extract the "data" field from the old record, which + // represents the value part of the key-value store + changes[key].oldValue = oldRecord.data; + } + } + return changes; + }, + { preloadIds: ids } + ); + this.notifyListeners(extension, changes); + } + + async remove(extension, keys, context) { + const coll = await this.getCollection(extension, context); + keys = [].concat(keys); + const ids = keys.map(keyToId); + let changes = {}; + await coll.execute( + txn => { + for (let [i, key] of keys.entries()) { + const id = ids[i]; + const res = txn.deleteAny(id); + if (res.deleted) { + changes[key] = { + oldValue: res.data.data, + }; + } + } + return changes; + }, + { preloadIds: ids } + ); + if (Object.keys(changes).length) { + this.notifyListeners(extension, changes); + } + } + + /* Wipe local data for all collections without causing the changes to be synced */ + async clearAll() { + const extensions = await this.getExtensions(); + const extIds = Array.from(extensions, extension => extension.id); + log.debug(`Clearing extension data for ${JSON.stringify(extIds)}`); + if (extIds.length) { + const promises = Array.from(extensions, extension => { + return openCollection(extension).then(coll => { + return coll.clear(); + }); + }); + await Promise.all(promises); + } + + // and clear the crypto collection. + const cc = await this.cryptoCollection.getCollection(); + await cc.clear(); + } + + async clear(extension, context) { + // We can't call Collection#clear here, because that just clears + // the local database. We have to explicitly delete everything so + // that the deletions can be synced as well. + const coll = await this.getCollection(extension, context); + const res = await coll.list(); + const records = res.data; + const keys = records.map(record => record.key); + await this.remove(extension, keys, context); + } + + async get(extension, spec, context) { + const coll = await this.getCollection(extension, context); + let keys, records; + if (spec === null) { + records = {}; + const res = await coll.list(); + for (let record of res.data) { + records[record.key] = record.data; + } + return records; + } + if (typeof spec === "string") { + keys = [spec]; + records = {}; + } else if (Array.isArray(spec)) { + keys = spec; + records = {}; + } else { + keys = Object.keys(spec); + records = Cu.cloneInto(spec, {}); + } + + for (let key of keys) { + const res = await coll.getAny(keyToId(key)); + if (res.data && res.data._status != "deleted") { + records[res.data.key] = res.data.data; + } + } + + return records; + } + + async getBytesInUse(extension, keys, context) { + // This is defined by the chrome spec as being the length of the key and + // the length of the json repr of the value. + let size = 0; + let data = await this.get(extension, keys, context); + for (const [key, value] of Object.entries(data)) { + size += key.length + JSON.stringify(value).length; + } + return size; + } + + addOnChangedListener(extension, listener, context) { + let listeners = this.listeners.get(extension) || new Set(); + listeners.add(listener); + this.listeners.set(extension, listeners); + + this.registerInUse(extension, context); + } + + removeOnChangedListener(extension, listener) { + let listeners = this.listeners.get(extension); + listeners.delete(listener); + if (listeners.size == 0) { + this.listeners.delete(extension); + } + } + + notifyListeners(extension, changes) { + lazy.Observers.notify("ext.storage.sync-changed"); + let listeners = this.listeners.get(extension) || new Set(); + if (listeners) { + for (let listener of listeners) { + lazy.ExtensionCommon.runSafeSyncWithoutClone(listener, changes); + } + } + } +} + +extensionStorageSyncKinto = new ExtensionStorageSyncKinto(_fxaService); + +// For test use only. +export const KintoStorageTestUtils = { + CollectionKeyEncryptionRemoteTransformer, + CryptoCollection, + EncryptionRemoteTransformer, + KeyRingEncryptionRemoteTransformer, + cleanUpForContext, + idToKey, + keyToId, +}; diff --git a/toolkit/components/extensions/ExtensionTelemetry.sys.mjs b/toolkit/components/extensions/ExtensionTelemetry.sys.mjs new file mode 100644 index 0000000000..06137b9a23 --- /dev/null +++ b/toolkit/components/extensions/ExtensionTelemetry.sys.mjs @@ -0,0 +1,343 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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 { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { DefaultWeakMap } = ExtensionUtils; + +// Map of the base histogram ids for the metrics recorded for the extensions. +const HISTOGRAMS_IDS = { + backgroundPageLoad: "WEBEXT_BACKGROUND_PAGE_LOAD_MS", + browserActionPopupOpen: "WEBEXT_BROWSERACTION_POPUP_OPEN_MS", + browserActionPreloadResult: "WEBEXT_BROWSERACTION_POPUP_PRELOAD_RESULT_COUNT", + contentScriptInjection: "WEBEXT_CONTENT_SCRIPT_INJECTION_MS", + eventPageRunningTime: "WEBEXT_EVENTPAGE_RUNNING_TIME_MS", + eventPageIdleResult: "WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT", + extensionStartup: "WEBEXT_EXTENSION_STARTUP_MS", + pageActionPopupOpen: "WEBEXT_PAGEACTION_POPUP_OPEN_MS", + storageLocalGetJson: "WEBEXT_STORAGE_LOCAL_GET_MS", + storageLocalSetJson: "WEBEXT_STORAGE_LOCAL_SET_MS", + storageLocalGetIdb: "WEBEXT_STORAGE_LOCAL_IDB_GET_MS", + storageLocalSetIdb: "WEBEXT_STORAGE_LOCAL_IDB_SET_MS", +}; + +const GLEAN_METRICS_TYPES = { + backgroundPageLoad: "timing_distribution", + browserActionPopupOpen: "timing_distribution", + browserActionPreloadResult: "labeled_counter", + contentScriptInjection: "timing_distribution", + eventPageRunningTime: "custom_distribution", + eventPageIdleResult: "labeled_counter", + extensionStartup: "timing_distribution", + pageActionPopupOpen: "timing_distribution", + storageLocalGetJson: "timing_distribution", + storageLocalSetJson: "timing_distribution", + storageLocalGetIdb: "timing_distribution", + storageLocalSetIdb: "timing_distribution", +}; + +/** + * Get a trimmed version of the given string if it is longer than 80 chars (used in telemetry + * when a string may be longer than allowed). + * + * @param {string} str + * The original string content. + * + * @returns {string} + * The trimmed version of the string when longer than 80 chars, or the given string + * unmodified otherwise. + */ +export function getTrimmedString(str) { + if (str.length <= 80) { + return str; + } + + const length = str.length; + + // Trim the string to prevent a flood of warnings messages logged internally by recordEvent, + // the trimmed version is going to be composed by the first 40 chars and the last 37 and 3 dots + // that joins the two parts, to visually indicate that the string has been trimmed. + return `${str.slice(0, 40)}...${str.slice(length - 37, length)}`; +} + +/** + * Get a string representing the error which can be included in telemetry data. + * If the resulting string is longer than 80 characters it is going to be + * trimmed using the `getTrimmedString` helper function. + * + * @param {Error | DOMException | Components.Exception} error + * The error object to convert into a string representation. + * + * @returns {string} + * - The `error.name` string on DOMException or Components.Exception + * (trimmed to 80 chars). + * - "NoError" if error is falsey. + * - "UnkownError" as a fallback. + */ +export function getErrorNameForTelemetry(error) { + let text = "UnknownError"; + if (!error) { + text = "NoError"; + } else if ( + DOMException.isInstance(error) || + error instanceof Components.Exception + ) { + text = error.name; + if (text.length > 80) { + text = getTrimmedString(text); + } + } + return text; +} + +/** + * This is a internal helper object which contains a collection of helpers used to make it easier + * to collect extension telemetry (in both the general histogram and in the one keyed by addon id). + * + * This helper object is not exported from ExtensionUtils, it is used by the ExtensionTelemetry + * Proxy which is exported and used by the callers to record telemetry data for one of the + * supported metrics. + */ +class ExtensionTelemetryMetric { + constructor(metric) { + this.metric = metric; + this.gleanTimerIdsMap = new DefaultWeakMap(ext => new WeakMap()); + } + + // Stopwatch methods. + stopwatchStart(extension, obj = extension) { + this._wrappedStopwatchMethod("start", this.metric, extension, obj); + this._wrappedTimingDistributionMethod("start", this.metric, extension, obj); + } + + stopwatchFinish(extension, obj = extension) { + this._wrappedStopwatchMethod("finish", this.metric, extension, obj); + this._wrappedTimingDistributionMethod( + "stopAndAccumulate", + this.metric, + extension, + obj + ); + } + + stopwatchCancel(extension, obj = extension) { + this._wrappedStopwatchMethod("cancel", this.metric, extension, obj); + this._wrappedTimingDistributionMethod( + "cancel", + this.metric, + extension, + obj + ); + } + + // Histogram counters methods. + histogramAdd(opts) { + this._histogramAdd(this.metric, opts); + } + + /** + * Wraps a call to Glean timing_distribution methods for a given metric and extension. + * + * @param {string} method + * The Glean timing_distribution method to call ("start", "stopAndAccumulate" or "cancel"). + * @param {string} metric + * The Glean timing_distribution metric to record (used to retrieve the Glean metric type from the + * GLEAN_METRICS_TYPES map). + * @param {Extension | BrowserExtensionContent} extension + * The extension to record the telemetry for. + * @param {any | undefined} [obj = extension] + * An optional object the timing_distribution method call should be related to + * (defaults to the extension parameter when missing). + */ + _wrappedTimingDistributionMethod(method, metric, extension, obj = extension) { + if (!extension) { + Cu.reportError(`Mandatory extension parameter is undefined`); + return; + } + + const gleanMetricType = GLEAN_METRICS_TYPES[metric]; + if (!gleanMetricType) { + Cu.reportError(`Unknown metric ${metric}`); + return; + } + + if (gleanMetricType !== "timing_distribution") { + Cu.reportError( + `Glean metric ${metric} is of type ${gleanMetricType}, expected timing_distribution` + ); + return; + } + + switch (method) { + case "start": { + const timerId = Glean.extensionsTiming[metric].start(); + this.gleanTimerIdsMap.get(extension).set(obj, timerId); + break; + } + case "stopAndAccumulate": // Intentional fall-through. + case "cancel": { + if ( + !this.gleanTimerIdsMap.has(extension) || + !this.gleanTimerIdsMap.get(extension).has(obj) + ) { + Cu.reportError( + `timerId not found for Glean timing_distribution ${metric}` + ); + return; + } + const timerId = this.gleanTimerIdsMap.get(extension).get(obj); + this.gleanTimerIdsMap.get(extension).delete(obj); + Glean.extensionsTiming[metric][method](timerId); + break; + } + default: + Cu.reportError( + `Unknown method ${method} call for Glean metric ${metric}` + ); + } + } + + /** + * Wraps a call to a TelemetryStopwatch method for a given metric and extension. + * + * @param {string} method + * The stopwatch method to call ("start", "finish" or "cancel"). + * @param {string} metric + * The stopwatch metric to record (used to retrieve the base histogram id from the HISTOGRAMS_IDS object). + * @param {Extension | BrowserExtensionContent} extension + * The extension to record the telemetry for. + * @param {any | undefined} [obj = extension] + * An optional telemetry stopwatch object (which defaults to the extension parameter when missing). + */ + _wrappedStopwatchMethod(method, metric, extension, obj = extension) { + if (!extension) { + Cu.reportError(`Mandatory extension parameter is undefined`); + return; + } + + const baseId = HISTOGRAMS_IDS[metric]; + if (!baseId) { + Cu.reportError(`Unknown metric ${metric}`); + return; + } + + // Record metric in the general histogram. + TelemetryStopwatch[method](baseId, obj); + + // Record metric in the histogram keyed by addon id. + let extensionId = getTrimmedString(extension.id); + TelemetryStopwatch[`${method}Keyed`]( + `${baseId}_BY_ADDONID`, + extensionId, + obj + ); + } + + /** + * Record a telemetry category and/or value for a given metric. + * + * @param {string} metric + * The metric to record (used to retrieve the base histogram id from the _histogram object). + * @param {object} options + * @param {Extension | BrowserExtensionContent} options.extension + * The extension to record the telemetry for. + * @param {string | undefined} [options.category] + * An optional histogram category. + * @param {number | undefined} [options.value] + * An optional value to record. + */ + _histogramAdd(metric, { category, extension, value }) { + if (!extension) { + Cu.reportError(`Mandatory extension parameter is undefined`); + return; + } + + const baseId = HISTOGRAMS_IDS[metric]; + if (!baseId) { + Cu.reportError(`Unknown metric ${metric}`); + return; + } + + const histogram = Services.telemetry.getHistogramById(baseId); + if (typeof category === "string") { + histogram.add(category, value); + } else { + histogram.add(value); + } + + const keyedHistogram = Services.telemetry.getKeyedHistogramById( + `${baseId}_BY_ADDONID` + ); + const extensionId = getTrimmedString(extension.id); + + if (typeof category === "string") { + keyedHistogram.add(extensionId, category, value); + } else { + keyedHistogram.add(extensionId, value); + } + + switch (GLEAN_METRICS_TYPES[metric]) { + case "custom_distribution": { + if (typeof category === "string") { + Cu.reportError( + `Unexpected unsupported category parameter set on Glean metric ${metric}` + ); + return; + } + // NOTE: extensionsTiming may become a property of the GLEAN_METRICS_TYPES + // map once we may introduce new histograms that are not part of the + // extensionsTiming Glean metrics category. + Glean.extensionsTiming[metric].accumulateSamples([value]); + break; + } + case "labeled_counter": { + if (typeof category !== "string") { + Cu.reportError( + `Missing mandatory category on adding data to labeled Glean metric ${metric}` + ); + return; + } + Glean.extensionsCounters[metric][category].add(value ?? 1); + break; + } + default: + Cu.reportError( + `Unexpected unsupported Glean metric type "${GLEAN_METRICS_TYPES[metric]}" for metric ${metric}` + ); + } + } +} + +// Cache of the ExtensionTelemetryMetric instances that has been lazily created by the +// Extension Telemetry Proxy. +/** @type {Map<string|symbol, ExtensionTelemetryMetric>} */ +const metricsCache = new Map(); + +/** + * This proxy object provides the telemetry helpers for the currently supported metrics (the ones listed in + * HISTOGRAMS_IDS), the telemetry helpers for a particular metric are lazily created + * when the related property is being accessed on this object for the first time, e.g.: + * + * ExtensionTelemetry.extensionStartup.stopwatchStart(extension); + * ExtensionTelemetry.browserActionPreloadResult.histogramAdd({category: "Shown", extension}); + */ +export var ExtensionTelemetry = new Proxy(metricsCache, { + get(target, prop, receiver) { + // NOTE: if we would be start adding glean probes that do not have a unified + // telemetry histogram counterpart, we would need to change this check + // accordingly. + if (!(prop in HISTOGRAMS_IDS)) { + throw new Error(`Unknown metric ${String(prop)}`); + } + + // Lazily create and cache the metric result object. + if (!target.has(prop)) { + target.set(prop, new ExtensionTelemetryMetric(prop)); + } + + return target.get(prop); + }, +}); diff --git a/toolkit/components/extensions/ExtensionTestCommon.sys.mjs b/toolkit/components/extensions/ExtensionTestCommon.sys.mjs new file mode 100644 index 0000000000..94cb801cc5 --- /dev/null +++ b/toolkit/components/extensions/ExtensionTestCommon.sys.mjs @@ -0,0 +1,677 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +/** + * This module contains extension testing helper logic which is common + * between all test suites. + */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + Assert: "resource://testing-common/Assert.sys.mjs", + Extension: "resource://gre/modules/Extension.sys.mjs", + ExtensionData: "resource://gre/modules/Extension.sys.mjs", + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", + ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", +}); + +ChromeUtils.defineLazyGetter( + lazy, + "apiManager", + () => lazy.ExtensionParent.apiManager +); + +import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs"; +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { flushJarCache } = ExtensionUtils; + +const { instanceOf } = ExtensionCommon; + +/** + * A skeleton Extension-like object, used for testing, which installs an + * add-on via the add-on manager when startup() is called, and + * uninstalles it on shutdown(). + * + * @param {string} id + * @param {nsIFile} file + * @param {nsIURI} rootURI + * @param {string} installType + */ +export class MockExtension { + constructor(file, rootURI, addonData) { + this.id = null; + this.file = file; + this.rootURI = rootURI; + this.installType = addonData.useAddonManager; + this.addonData = addonData; + this.addon = null; + + let promiseEvent = eventName => + new Promise(resolve => { + let onstartup = async (msg, extension) => { + this.maybeSetID(extension.rootURI, extension.id); + if (!this.id && this.addonPromise) { + await this.addonPromise; + } + + if (extension.id == this.id) { + lazy.apiManager.off(eventName, onstartup); + this._extension = extension; + resolve(extension); + } + }; + lazy.apiManager.on(eventName, onstartup); + }); + + this._extension = null; + this._extensionPromise = promiseEvent("startup"); + this._readyPromise = promiseEvent("ready"); + this._uninstallPromise = promiseEvent("uninstall-complete"); + } + + maybeSetID(uri, id) { + if ( + !this.id && + uri instanceof Ci.nsIJARURI && + uri.JARFile.QueryInterface(Ci.nsIFileURL).file.equals(this.file) + ) { + this.id = id; + } + } + + testMessage(...args) { + return this._extension.testMessage(...args); + } + + get tabManager() { + return this._extension.tabManager; + } + + on(...args) { + this._extensionPromise.then(extension => { + extension.on(...args); + }); + // Extension.jsm emits a "startup" event on |extension| before emitting the + // "startup" event on |apiManager|. Trigger the "startup" event anyway, to + // make sure that the MockExtension behaves like an Extension with regards + // to the startup event. + if (args[0] === "startup" && !this._extension) { + this._extensionPromise.then(extension => { + args[1]("startup", extension); + }); + } + } + + off(...args) { + this._extensionPromise.then(extension => { + extension.off(...args); + }); + } + + _setIncognitoOverride() { + let { addonData } = this; + if (addonData && addonData.incognitoOverride) { + try { + let { id } = addonData.manifest.browser_specific_settings.gecko; + if (id) { + return ExtensionTestCommon.setIncognitoOverride({ id, addonData }); + } + } catch (e) {} + throw new Error( + "Extension ID is required for setting incognito permission." + ); + } + } + + async startup() { + await this._setIncognitoOverride(); + + if (this.installType == "temporary") { + return lazy.AddonManager.installTemporaryAddon(this.file).then( + async addon => { + this.addon = addon; + this.id = addon.id; + return this._readyPromise; + } + ); + } else if (this.installType == "permanent") { + this.addonPromise = new Promise(resolve => { + this.resolveAddon = resolve; + }); + let install = await lazy.AddonManager.getInstallForFile(this.file); + return new Promise((resolve, reject) => { + let listener = { + onInstallFailed: reject, + onInstallEnded: async (install, newAddon) => { + this.addon = newAddon; + this.id = newAddon.id; + this.resolveAddon(newAddon); + resolve(this._readyPromise); + }, + }; + + install.addListener(listener); + install.install(); + }); + } + throw new Error("installType must be one of: temporary, permanent"); + } + + shutdown() { + this.addon.uninstall(); + return this.cleanupGeneratedFile(); + } + + cleanupGeneratedFile() { + return this._extensionPromise + .then(extension => { + return extension.broadcast("Extension:FlushJarCache", { + path: this.file.path, + }); + }) + .then(() => { + return IOUtils.remove(this.file.path, { retryReadonly: true }); + }); + } + + terminateBackground(...args) { + return this._extensionPromise.then(extension => { + return extension.terminateBackground(...args); + }); + } + + wakeupBackground() { + return this._extensionPromise.then(extension => { + return extension.wakeupBackground(); + }); + } +} + +function provide(obj, keys, value, override = false) { + if (keys.length == 1) { + if (!(keys[0] in obj) || override) { + obj[keys[0]] = value; + } + } else { + if (!(keys[0] in obj)) { + obj[keys[0]] = {}; + } + provide(obj[keys[0]], keys.slice(1), value, override); + } +} + +// Some test assertions to work in both mochitest and xpcshell. This +// will be revisited later. +const ExtensionTestAssertions = { + getPersistentListeners(extWrapper, apiNs, apiEvent) { + let policy = WebExtensionPolicy.getByID(extWrapper.id); + const extension = policy?.extension || extWrapper.extension; + + if (!extension || !(extension instanceof lazy.Extension)) { + throw new Error( + `Unable to retrieve the Extension class instance for ${extWrapper.id}` + ); + } + + const { persistentListeners } = extension; + if ( + !persistentListeners?.size || + !persistentListeners.get(apiNs)?.has(apiEvent) + ) { + return []; + } + + return Array.from(persistentListeners.get(apiNs).get(apiEvent).values()); + }, + + assertPersistentListeners( + extWrapper, + apiNs, + apiEvent, + { primed, persisted = true, primedListenersCount } + ) { + if (primed && !persisted) { + throw new Error( + "Inconsistent assertion, can't assert a primed listener if it is not persisted" + ); + } + + let listenersInfo = ExtensionTestAssertions.getPersistentListeners( + extWrapper, + apiNs, + apiEvent + ); + lazy.Assert.equal( + persisted, + !!listenersInfo?.length, + `Got a persistent listener for ${apiNs}.${apiEvent}` + ); + for (const info of listenersInfo) { + if (primed) { + lazy.Assert.ok( + info.listeners.some(listener => listener.primed), + `${apiNs}.${apiEvent} listener expected to be primed` + ); + } else { + lazy.Assert.ok( + !info.listeners.some(listener => listener.primed), + `${apiNs}.${apiEvent} listener expected to not be primed` + ); + } + } + if (primed && primedListenersCount > 0) { + lazy.Assert.equal( + listenersInfo.reduce((acc, info) => { + acc += info.listeners.length; + return acc; + }, 0), + primedListenersCount, + `Got the expected number of ${apiNs}.${apiEvent} listeners to be primed` + ); + } + }, +}; + +export var ExtensionTestCommon = class ExtensionTestCommon { + static get testAssertions() { + return ExtensionTestAssertions; + } + + // Called by AddonTestUtils.promiseShutdownManager to reset startup promises + static resetStartupPromises() { + lazy.ExtensionParent._resetStartupPromises(); + } + + // Called to notify "browser-delayed-startup-finished", which resolves + // ExtensionParent.browserPaintedPromise. Thus must be resolved for + // primed listeners to be able to wake the extension. + static notifyEarlyStartup() { + Services.obs.notifyObservers(null, "browser-delayed-startup-finished"); + return lazy.ExtensionParent.browserPaintedPromise; + } + + // Called to notify "extensions-late-startup", which resolves + // ExtensionParent.browserStartupPromise. Normally, in Firefox, the + // notification would be "sessionstore-windows-restored", however + // mobile listens for "extensions-late-startup" so that is more useful + // in testing. + static notifyLateStartup() { + Services.obs.notifyObservers(null, "extensions-late-startup"); + return lazy.ExtensionParent.browserStartupPromise; + } + + /** + * Shortcut to more easily access WebExtensionPolicy.backgroundServiceWorkerEnabled + * from mochitest-plain tests. + * + * @returns {boolean} true if the background service worker are enabled. + */ + static getBackgroundServiceWorkerEnabled() { + return WebExtensionPolicy.backgroundServiceWorkerEnabled; + } + + /** + * A test helper mainly used to skip test tasks if running in "backgroundServiceWorker" test mode + * (e.g. while running test files shared across multiple test modes: e.g. in-process-webextensions, + * remote-webextensions, sw-webextensions etc.). + * + * The underlying pref "extension.backgroundServiceWorker.forceInTestExtension": + * - is set to true in the xpcshell-serviceworker.ini and mochitest-serviceworker.ini manifests + * (and so it is going to be set to true while running the test files listed in those manifests) + * - when set to true, all test extension using a background script without explicitly listing it + * in the test extension manifest will be automatically executed as background service workers + * (instead of background scripts loaded in a background page) + * + * @returns {boolean} true if the test is running in "background service worker mode" + */ + static isInBackgroundServiceWorkerTests() { + return Services.prefs.getBoolPref( + "extensions.backgroundServiceWorker.forceInTestExtension", + false + ); + } + + /** + * This code is designed to make it easy to test a WebExtension + * without creating a bunch of files. Everything is contained in a + * single JS object. + * + * Properties: + * "background": "<JS code>" + * A script to be loaded as the background script. + * The "background" section of the "manifest" property is overwritten + * if this is provided. + * "manifest": {...} + * Contents of manifest.json + * "files": {"filename1": "contents1", ...} + * Data to be included as files. Can be referenced from the manifest. + * If a manifest file is provided here, it takes precedence over + * a generated one. Always use "/" as a directory separator. + * Directories should appear here only implicitly (as a prefix + * to file names) + * + * To make things easier, the value of "background" and "files"[] can + * be a function, which is converted to source that is run. + * + * @param {object} data + * @returns {object} + */ + static generateFiles(data) { + let files = {}; + + Object.assign(files, data.files); + + let manifest = data.manifest; + if (!manifest) { + manifest = {}; + } + + provide(manifest, ["name"], "Generated extension"); + provide(manifest, ["manifest_version"], 2); + provide(manifest, ["version"], "1.0"); + + // Make it easier to test same manifest in both MV2 and MV3 configurations. + if (manifest.manifest_version === 2 && manifest.host_permissions) { + manifest.permissions = [].concat( + manifest.permissions || [], + manifest.host_permissions + ); + delete manifest.host_permissions; + } + + if (data.useServiceWorker === undefined) { + // If we're force-testing service workers we will turn the background + // script part of ExtensionTestUtils test extensions into a background + // service worker. + data.useServiceWorker = + ExtensionTestCommon.isInBackgroundServiceWorkerTests(); + } + + // allowInsecureRequests is a shortcut to removing upgrade-insecure-requests from default csp. + if (data.allowInsecureRequests) { + // upgrade-insecure-requests is only added automatically to MV3. + // This flag is therefore not needed in MV2. + if (manifest.manifest_version < 3) { + throw new Error("allowInsecureRequests requires manifest_version 3"); + } + if (manifest.content_security_policy) { + throw new Error( + "allowInsecureRequests cannot be used with manifest.content_security_policy" + ); + } + manifest.content_security_policy = { + extension_pages: `script-src 'self'`, + }; + } + + if (data.background) { + let bgScript = Services.uuid.generateUUID().number + ".js"; + + // If persistent is set keep the flag. + let persistent = manifest.background?.persistent; + let scriptKey = data.useServiceWorker + ? ["background", "service_worker"] + : ["background", "scripts"]; + let scriptVal = data.useServiceWorker ? bgScript : [bgScript]; + provide(manifest, scriptKey, scriptVal, true); + provide(manifest, ["background", "persistent"], persistent); + + files[bgScript] = data.background; + } + + provide(files, ["manifest.json"], JSON.stringify(manifest)); + + for (let filename in files) { + let contents = files[filename]; + if (typeof contents == "function") { + files[filename] = this.serializeScript(contents); + } else if ( + typeof contents != "string" && + !instanceOf(contents, "ArrayBuffer") + ) { + files[filename] = JSON.stringify(contents); + } + } + + return files; + } + + /** + * Write an xpi file to disk for a webextension. + * The generated extension is stored in the system temporary directory, + * and an nsIFile object pointing to it is returned. + * + * @param {object} data In the format handled by generateFiles. + * @returns {nsIFile} + */ + static generateXPI(data) { + let files = this.generateFiles(data); + return this.generateZipFile(files); + } + + static generateZipFile(files, baseName = "generated-extension.xpi") { + let ZipWriter = Components.Constructor( + "@mozilla.org/zipwriter;1", + "nsIZipWriter" + ); + let zipW = new ZipWriter(); + + let file = new lazy.FileUtils.File( + PathUtils.join(PathUtils.tempDir, baseName) + ); + file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, lazy.FileUtils.PERMS_FILE); + + const MODE_WRONLY = 0x02; + const MODE_TRUNCATE = 0x20; + zipW.open(file, MODE_WRONLY | MODE_TRUNCATE); + + // Needs to be in microseconds for some reason. + let time = Date.now() * 1000; + + function generateFile(filename) { + let components = filename.split("/"); + let path = ""; + for (let component of components.slice(0, -1)) { + path += component + "/"; + if (!zipW.hasEntry(path)) { + zipW.addEntryDirectory(path, time, false); + } + } + } + + for (let filename in files) { + let script = files[filename]; + if (!instanceOf(script, "ArrayBuffer")) { + script = new TextEncoder().encode(script).buffer; + } + + let stream = Cc[ + "@mozilla.org/io/arraybuffer-input-stream;1" + ].createInstance(Ci.nsIArrayBufferInputStream); + stream.setData(script, 0, script.byteLength); + + generateFile(filename); + zipW.addEntryStream(filename, time, 0, stream, false); + } + + zipW.close(); + + return file; + } + + /** + * Properly serialize a function into eval-able code string. + * + * @param {Function} script + * @returns {string} + */ + static serializeFunction(script) { + // Serialization of object methods doesn't include `function` anymore. + const method = /^(async )?(?:(\w+)|"(\w+)\.js")\(/; + + let code = script.toString(); + let match = code.match(method); + if (match && match[2] !== "function") { + code = code.replace(method, "$1function $2$3("); + } + return code; + } + + /** + * Properly serialize a script into eval-able code string. + * + * @param {string | Function | Array} script + * @returns {string} + */ + static serializeScript(script) { + if (Array.isArray(script)) { + return Array.from(script, this.serializeScript, this).join(";"); + } + if (typeof script !== "function") { + return script; + } + return `(${this.serializeFunction(script)})();`; + } + + static setIncognitoOverride(extension) { + let { id, addonData } = extension; + if (!addonData || !addonData.incognitoOverride) { + return; + } + if (addonData.incognitoOverride == "not_allowed") { + return lazy.ExtensionPermissions.remove(id, { + permissions: ["internal:privateBrowsingAllowed"], + origins: [], + }); + } + return lazy.ExtensionPermissions.add(id, { + permissions: ["internal:privateBrowsingAllowed"], + origins: [], + }); + } + + static setExtensionID(data) { + try { + if (data.manifest.browser_specific_settings.gecko.id) { + return; + } + } catch (e) { + // No ID is set. + } + provide( + data, + ["manifest", "browser_specific_settings", "gecko", "id"], + Services.uuid.generateUUID().number + ); + } + + /** + * Generates a new extension using |Extension.generateXPI|, and initializes a + * new |Extension| instance which will execute it. + * + * @param {object} data + * @returns {Partial<Extension>} + */ + static generate(data) { + if (data.useAddonManager === "android-only") { + // Some extension APIs are partially implemented in Java, and the + // interface between the JS and Java side (GeckoViewWebExtension) + // expects extensions to be registered with the AddonManager. + // This is at least necessary for tests that use the following APIs: + // - browserAction/pageAction. + // - tabs.create, tabs.update, tabs.remove (uses GeckoViewTabBridge). + // - downloads API + // - browsingData API (via ExtensionBrowsingData.sys.mjs). + // + // In xpcshell tests, the AddonManager is optional, so the AddonManager + // cannot unconditionally be enabled. + // In mochitests, tests are run in an actual browser, so the AddonManager + // is always enabled and hence useAddonManager is always set by default. + if (AppConstants.platform === "android") { + // Many MV3 tests set temporarilyInstalled for granted_host_permissions. + // The granted_host_permissions flag is only effective for temporarily + // installed extensions, so make sure to use "temporary" in this case. + if (data.temporarilyInstalled) { + data.useAddonManager = "temporary"; + } else { + data.useAddonManager = "permanent"; + } + // MockExtension requires data.manifest.applications.gecko.id to be set. + // The AddonManager requires an ID in the manifest for unsigned XPIs. + this.setExtensionID(data); + } else { + // On non-Android, default to not using the AddonManager. + data.useAddonManager = null; + } + } + + let file = this.generateXPI(data); + + flushJarCache(file.path); + Services.ppmm.broadcastAsyncMessage("Extension:FlushJarCache", { + path: file.path, + }); + + let fileURI = Services.io.newFileURI(file); + let jarURI = Services.io.newURI("jar:" + fileURI.spec + "!/"); + + // This may be "temporary" or "permanent". + if (data.useAddonManager) { + return new MockExtension(file, jarURI, data); + } + + let id; + if (data.manifest) { + if (data.manifest.applications && data.manifest.applications.gecko) { + id = data.manifest.applications.gecko.id; + } else if ( + data.manifest.browser_specific_settings && + data.manifest.browser_specific_settings.gecko + ) { + id = data.manifest.browser_specific_settings.gecko.id; + } + } + if (!id) { + id = Services.uuid.generateUUID().number; + } + + let signedState = lazy.AddonManager.SIGNEDSTATE_SIGNED; + if (data.isPrivileged) { + signedState = lazy.AddonManager.SIGNEDSTATE_PRIVILEGED; + } + if (data.isSystem) { + signedState = lazy.AddonManager.SIGNEDSTATE_SYSTEM; + } + + let isPrivileged = lazy.ExtensionData.getIsPrivileged({ + signedState, + builtIn: false, + temporarilyInstalled: !!data.temporarilyInstalled, + }); + + return new lazy.Extension( + { + id, + resourceURI: jarURI, + cleanupFile: file, + signedState, + incognitoOverride: data.incognitoOverride, + temporarilyInstalled: !!data.temporarilyInstalled, + isPrivileged, + TEST_NO_ADDON_MANAGER: true, + // By default we set TEST_NO_DELAYED_STARTUP to true + TEST_NO_DELAYED_STARTUP: !data.delayedStartup, + }, + data.startupReason ?? "ADDON_INSTALL" + ); + } +}; diff --git a/toolkit/components/extensions/ExtensionUtils.sys.mjs b/toolkit/components/extensions/ExtensionUtils.sys.mjs new file mode 100644 index 0000000000..cbdf900d14 --- /dev/null +++ b/toolkit/components/extensions/ExtensionUtils.sys.mjs @@ -0,0 +1,349 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +// xpcshell doesn't handle idle callbacks well. +ChromeUtils.defineLazyGetter(lazy, "idleTimeout", () => + Services.appinfo.name === "XPCShell" ? 500 : undefined +); + +// It would be nicer to go through `Services.appinfo`, but some tests need to be +// able to replace that field with a custom implementation before it is first +// called. +// eslint-disable-next-line mozilla/use-services +const appinfo = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime); + +let nextId = 0; +const uniqueProcessID = appinfo.uniqueProcessID; +// Store the process ID in a 16 bit field left shifted to end of a +// double's mantissa. +// Note: We can't use bitwise ops here, since they truncate to a 32 bit +// integer and we need all 53 mantissa bits. +const processIDMask = (uniqueProcessID & 0xffff) * 2 ** 37; + +function getUniqueId() { + // Note: We can't use bitwise ops here, since they truncate to a 32 bit + // integer and we need all 53 mantissa bits. + return processIDMask + nextId++; +} + +function promiseTimeout(delay) { + return new Promise(resolve => lazy.setTimeout(resolve, delay)); +} + +/** + * An Error subclass for which complete error messages are always passed + * to extensions, rather than being interpreted as an unknown error. + */ +class ExtensionError extends DOMException { + constructor(message) { + super(message, "ExtensionError"); + } + // Custom JS classes can't survive IPC, so need to check error name. + static [Symbol.hasInstance](e) { + return DOMException.isInstance(e) && e.name === "ExtensionError"; + } +} + +function filterStack(error) { + return String(error.stack).replace( + /(^.*(Task\.jsm|Promise-backend\.js).*\n)+/gm, + "<Promise Chain>\n" + ); +} + +/** + * An Error subclass used to recognize the errors that should + * to be forwarded to the worker thread and being accessible + * to the extension worker script (vs. the errors that should be + * only logged internally and raised to the worker script as + * the generic unexpected error). + */ +class WorkerExtensionError extends DOMException { + constructor(message) { + super(message, "Error"); + } +} + +/** + * Similar to a WeakMap, but creates a new key with the given + * constructor if one is not present. + */ +// @ts-ignore (https://github.com/microsoft/TypeScript/issues/56664) +class DefaultWeakMap extends WeakMap { + constructor(defaultConstructor = undefined, init = undefined) { + super(init); + if (defaultConstructor) { + this.defaultConstructor = defaultConstructor; + } + } + + get(key) { + let value = super.get(key); + if (value === undefined && !this.has(key)) { + value = this.defaultConstructor(key); + this.set(key, value); + } + return value; + } +} + +class DefaultMap extends Map { + constructor(defaultConstructor = undefined, init = undefined) { + super(init); + if (defaultConstructor) { + this.defaultConstructor = defaultConstructor; + } + } + + get(key) { + let value = super.get(key); + if (value === undefined && !this.has(key)) { + value = this.defaultConstructor(key); + this.set(key, value); + } + return value; + } +} + +function getInnerWindowID(window) { + return window.windowGlobalChild?.innerWindowId; +} + +/** + * A set with a limited number of slots, which flushes older entries as + * newer ones are added. + * + * @param {integer} limit + * The maximum size to trim the set to after it grows too large. + * @param {integer} [slop = limit * .25] + * The number of extra entries to allow in the set after it + * reaches the size limit, before it is truncated to the limit. + * @param {Iterable} [iterable] + * An iterable of initial entries to add to the set. + */ +class LimitedSet extends Set { + constructor(limit, slop = Math.round(limit * 0.25), iterable = undefined) { + super(iterable); + this.limit = limit; + this.slop = slop; + } + + truncate(limit) { + for (let item of this) { + // Live set iterators can ge relatively expensive, since they need + // to be updated after every modification to the set. Since + // breaking out of the loop early will keep the iterator alive + // until the next full GC, we're currently better off finishing + // the entire loop even after we're done truncating. + if (this.size > limit) { + this.delete(item); + } + } + } + + add(item) { + if (this.size >= this.limit + this.slop && !this.has(item)) { + this.truncate(this.limit - 1); + } + return super.add(item); + } +} + +/** + * Returns a Promise which resolves when the given document's DOM has + * fully loaded. + * + * @param {Document} doc The document to await the load of. + * @returns {Promise<Document>} + */ +function promiseDocumentReady(doc) { + if (doc.readyState == "interactive" || doc.readyState == "complete") { + return Promise.resolve(doc); + } + + return new Promise(resolve => { + doc.addEventListener( + "DOMContentLoaded", + function onReady(event) { + if (event.target === event.currentTarget) { + doc.removeEventListener("DOMContentLoaded", onReady, true); + resolve(doc); + } + }, + true + ); + }); +} + +/** + * Returns a Promise which resolves when the given window's document's DOM has + * fully loaded, the <head> stylesheets have fully loaded, and we have hit an + * idle time. + * + * @param {Window} window The window whose document we will await + the readiness of. + * @returns {Promise<IdleDeadline>} + */ +function promiseDocumentIdle(window) { + return window.document.documentReadyForIdle.then(() => { + return new Promise(resolve => + window.requestIdleCallback(resolve, { timeout: lazy.idleTimeout }) + ); + }); +} + +/** + * Returns a Promise which resolves when the given document is fully + * loaded. + * + * @param {Document} doc The document to await the load of. + * @returns {Promise<Document>} + */ +function promiseDocumentLoaded(doc) { + if (doc.readyState == "complete") { + return Promise.resolve(doc); + } + + return new Promise(resolve => { + doc.defaultView.addEventListener("load", () => resolve(doc), { + once: true, + }); + }); +} + +/** + * Returns a Promise which resolves when the given event is dispatched to the + * given element. + * + * @param {Element} element + * The element on which to listen. + * @param {string} eventName + * The event to listen for. + * @param {boolean} [useCapture = true] + * If true, listen for the even in the capturing rather than + * bubbling phase. + * @param {function(Event): boolean} [test] + * An optional test function which, when called with the + * observer's subject and data, should return true if this is the + * expected event, false otherwise. + * @returns {Promise<Event>} + */ +function promiseEvent( + element, + eventName, + useCapture = true, + test = event => true +) { + return new Promise(resolve => { + function listener(event) { + if (test(event)) { + element.removeEventListener(eventName, listener, useCapture); + resolve(event); + } + } + element.addEventListener(eventName, listener, useCapture); + }); +} + +/** + * Returns a Promise which resolves the given observer topic has been + * observed. + * + * @param {string} topic + * The topic to observe. + * @param {function(any, string): boolean} [test] + * An optional test function which, when called with the + * observer's subject and data, should return true if this is the + * expected notification, false otherwise. + * @returns {Promise<object>} + */ +function promiseObserved(topic, test = () => true) { + return new Promise(resolve => { + let observer = (subject, topic, data) => { + if (test(subject, data)) { + Services.obs.removeObserver(observer, topic); + resolve({ subject, data }); + } + }; + Services.obs.addObserver(observer, topic); + }); +} + +function getMessageManager(target) { + if (target.frameLoader) { + return target.frameLoader.messageManager; + } + return target; +} + +function flushJarCache(jarPath) { + Services.obs.notifyObservers(null, "flush-cache-entry", jarPath); +} +function parseMatchPatterns(patterns, options) { + try { + return new MatchPatternSet(patterns, options); + } catch (e) { + let pattern; + for (pattern of patterns) { + try { + new MatchPattern(pattern, options); + } catch (e) { + throw new ExtensionError(`Invalid url pattern: ${pattern}`); + } + } + // Unexpectedly MatchPatternSet threw, but MatchPattern did not. + throw e; + } +} + +/** + * Fetch icon content and convert it to a data: URI. + * + * @param {string} iconUrl Icon url to fetch. + * @returns {Promise<string>} + */ +async function makeDataURI(iconUrl) { + let response; + try { + response = await fetch(iconUrl); + } catch (e) { + // Failed to fetch, ignore engine's favicon. + Cu.reportError(e); + return; + } + let buffer = await response.arrayBuffer(); + let contentType = response.headers.get("content-type"); + let bytes = new Uint8Array(buffer); + let str = String.fromCharCode.apply(null, bytes); + return `data:${contentType};base64,${btoa(str)}`; +} + +export var ExtensionUtils = { + flushJarCache, + getInnerWindowID, + getMessageManager, + getUniqueId, + filterStack, + makeDataURI, + parseMatchPatterns, + promiseDocumentIdle, + promiseDocumentLoaded, + promiseDocumentReady, + promiseEvent, + promiseObserved, + promiseTimeout, + DefaultMap, + DefaultWeakMap, + ExtensionError, + LimitedSet, + WorkerExtensionError, +}; diff --git a/toolkit/components/extensions/ExtensionWorkerChild.sys.mjs b/toolkit/components/extensions/ExtensionWorkerChild.sys.mjs new file mode 100644 index 0000000000..f93f6968e9 --- /dev/null +++ b/toolkit/components/extensions/ExtensionWorkerChild.sys.mjs @@ -0,0 +1,818 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +/** + * This file handles extension background service worker logic that runs in the + * child process. + */ + +import { + ExtensionChild, + ExtensionActivityLogChild, +} from "resource://gre/modules/ExtensionChild.sys.mjs"; + +import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs"; +import { + ExtensionPageChild, + getContextChildManagerGetter, +} from "resource://gre/modules/ExtensionPageChild.sys.mjs"; +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { BaseContext, redefineGetter } = ExtensionCommon; + +const { + ChildAPIManager, + ChildLocalAPIImplementation, + MessageEvent, + Messenger, + Port, + ProxyAPIImplementation, + SimpleEventAPI, +} = ExtensionChild; + +const { DefaultMap, getUniqueId } = ExtensionUtils; + +/** + * SimpleEventAPI subclass specialized for the worker port events + * used by WorkerMessenger. + */ +class WorkerRuntimePortEvent extends SimpleEventAPI { + api() { + return { + ...super.api(), + createListenerForAPIRequest: (...args) => + this.createListenerForAPIRequest(...args), + }; + } + + createListenerForAPIRequest(request) { + const { eventListener } = request; + return function (port, ...args) { + return eventListener.callListener(args, { + apiObjectType: Ci.mozIExtensionListenerCallOptions.RUNTIME_PORT, + apiObjectDescriptor: { portId: port.portId, name: port.name }, + }); + }; + } +} + +/** + * SimpleEventAPI subclass specialized for the worker runtime messaging events + * used by WorkerMessenger. + */ +class WorkerMessageEvent extends MessageEvent { + api() { + return { + ...super.api(), + createListenerForAPIRequest: (...args) => + this.createListenerForAPIRequest(...args), + }; + } + + createListenerForAPIRequest(request) { + const { eventListener } = request; + return function (message, sender) { + return eventListener.callListener([message, sender], { + eventListenerType: + Ci.mozIExtensionListenerCallOptions.CALLBACK_SEND_RESPONSE, + }); + }; + } +} + +/** + * MessageEvent subclass specialized for the worker's port API events + * used by WorkerPort. + */ +class WorkerPortEvent extends SimpleEventAPI { + api() { + return { + ...super.api(), + createListenerForAPIRequest: (...args) => + this.createListenerForAPIRequest(...args), + }; + } + + createListenerForAPIRequest(request) { + const { eventListener } = request; + switch (this.name) { + case "Port.onDisconnect": + return function (port) { + eventListener.callListener([], { + apiObjectType: Ci.mozIExtensionListenerCallOptions.RUNTIME_PORT, + apiObjectDescriptor: { + portId: port.portId, + name: port.name, + }, + }); + }; + case "Port.onMessage": + return function (message, port) { + eventListener.callListener([message], { + apiObjectType: Ci.mozIExtensionListenerCallOptions.RUNTIME_PORT, + apiObjectDescriptor: { + portId: port.portId, + name: port.name, + }, + }); + }; + } + return undefined; + } +} + +/** + * Port subclass specialized for the workers and used by WorkerMessager. + */ +class WorkerPort extends Port { + constructor(context, portId, name, native, sender) { + const { viewType, contextId } = context; + if (viewType !== "background_worker") { + throw new Error( + `Unexpected viewType "${viewType}" on context ${contextId}` + ); + } + + super(context, portId, name, native, sender); + this.portId = portId; + } + + initEventManagers() { + const { context } = this; + this.onMessage = new WorkerPortEvent(context, "Port.onMessage"); + this.onDisconnect = new WorkerPortEvent(context, "Port.onDisconnect"); + } + + getAPI() { + const api = super.getAPI(); + // Add the portId to the API object, needed by the WorkerMessenger + // to retrieve the port given the apiObjectId part of the + // mozIExtensionAPIRequest sent from the ExtensionPort webidl. + api.portId = this.portId; + return api; + } + + get api() { + // No need to clone this for the worker, it's on a separate JSRuntime. + return redefineGetter(this, "api", this.getAPI()); + } +} + +/** + * A Messenger subclass specialized for the background service worker. + */ +class WorkerMessenger extends Messenger { + constructor(context) { + const { viewType, contextId } = context; + if (viewType !== "background_worker") { + throw new Error( + `Unexpected viewType "${viewType}" on context ${contextId}` + ); + } + + super(context); + + // Used by WebIDL API requests to get a port instance given the apiObjectId + // received in the API request coming from the ExtensionPort instance + // returned in the thread where the request was originating from. + this.portsById = new Map(); + this.context.callOnClose(this); + } + + initEventManagers() { + const { context } = this; + this.onConnect = new WorkerRuntimePortEvent(context, "runtime.onConnect"); + this.onConnectEx = new WorkerRuntimePortEvent( + context, + "runtime.onConnectExternal" + ); + this.onMessage = new WorkerMessageEvent(this.context, "runtime.onMessage"); + this.onMessageEx = new WorkerMessageEvent( + context, + "runtime.onMessageExternal" + ); + } + + close() { + this.portsById.clear(); + } + + getPortById(portId) { + return this.portsById.get(portId); + } + + /** + * @typedef {object} ExtensionPortDescriptor + * https://phabricator.services.mozilla.com/D196385?id=801874#inline-1093734 + * + * @returns {ExtensionPortDescriptor} + */ + connect({ name, native, ...args }) { + let portId = getUniqueId(); + let port = new WorkerPort(this.context, portId, name, !!native); + this.conduit + .queryPortConnect({ portId, name, native, ...args }) + .catch(error => port.recvPortDisconnect({ error })); + this.portsById.set(`${portId}`, port); + // Extension worker calls this method through the WebIDL bindings, + // and the Port instance returned by the runtime.connect/connectNative + // methods will be an instance of ExtensionPort webidl interface based + // on the ExtensionPortDescriptor dictionary returned by this method. + return { portId, name }; + } + + recvPortConnect({ extensionId, portId, name, sender }) { + let event = sender.id === extensionId ? this.onConnect : this.onConnectEx; + if (this.context.active && event.fires.size) { + let port = new WorkerPort(this.context, portId, name, false, sender); + this.portsById.set(`${port.portId}`, port); + return event.emit(port).length; + } + } +} + +/** + * APIImplementation subclass specialized for handling mozIExtensionAPIRequests + * originated from webidl bindings. + * + * Provides a createListenerForAPIRequest method which is used by + * WebIDLChildAPIManager to retrieve an API event specific wrapper + * for the mozIExtensionEventListener for the API events that needs + * special handling (e.g. runtime.onConnect). + * + * createListenerForAPIRequest delegates to the API event the creation + * of the special event listener wrappers, the EventManager api objects + * for the events that needs special wrapper are expected to provide + * a method with the same name. + */ +class ChildLocalWebIDLAPIImplementation extends ChildLocalAPIImplementation { + constructor(pathObj, namespace, name, childApiManager) { + super(pathObj, namespace, name, childApiManager); + this.childApiManager = childApiManager; + } + + createListenerForAPIRequest(request) { + return this.pathObj[this.name].createListenerForAPIRequest?.(request); + } + + setProperty() { + // mozIExtensionAPIRequest doesn't support this requestType at the moment, + // setting a pref would just replace the previous value on the wrapper + // object living in the owner thread. + // To be implemented if we have an actual use case where that is needed. + throw new Error("Unexpected call to setProperty"); + } + + hasListener(listener) { + // hasListener is implemented in C++ by ExtensionEventManager, and so + // a call to this method is unexpected. + throw new Error("Unexpected call to hasListener"); + } +} + +/** + * APIImplementation subclass specialized for handling API requests related + * to an API Object type. + * + * Retrieving the apiObject instance is delegated internally to the + * ExtensionAPI subclass that implements the request apiNamespace, + * through an optional getAPIObjectForRequest method expected to be + * available on the ExtensionAPI class. + */ +class ChildWebIDLObjectTypeImplementation extends ChildLocalWebIDLAPIImplementation { + constructor(request, childApiManager) { + const { apiNamespace, apiName, apiObjectType, apiObjectId } = request; + const api = childApiManager.getExtensionAPIInstance(apiNamespace); + const pathObj = api.getAPIObjectForRequest?.( + childApiManager.context, + request + ); + if (!pathObj) { + throw new Error(`apiObject instance not found for ${request}`); + } + super(pathObj, apiNamespace, apiName, childApiManager); + this.fullname = `${apiNamespace}.${apiObjectType}(${apiObjectId}).${apiName}`; + } +} + +/** + * A ChildAPIManager subclass specialized for handling mozIExtensionAPIRequest + * originated from the WebIDL bindings. + * + * Currently used only for the extension contexts related to the background + * service worker. + */ +class WebIDLChildAPIManager extends ChildAPIManager { + constructor(...args) { + super(...args); + // Map<apiPathToEventString, WeakMap<nsIExtensionEventListener, Function>> + // + // apiPathToEventString is a string that represents the full API path + // related to the event name (e.g. "runtime.onConnect", or "runtime.Port.onMessage") + this.eventListenerWrappers = new DefaultMap(() => new WeakMap()); + } + + getImplementation(namespace, name) { + this.apiCan.findAPIPath(`${namespace}.${name}`); + let obj = this.apiCan.findAPIPath(namespace); + + if (obj && name in obj) { + return new ChildLocalWebIDLAPIImplementation(obj, namespace, name, this); + } + + return this.getFallbackImplementation(namespace, name); + } + + getImplementationForRequest(request) { + const { apiNamespace, apiName, apiObjectType } = request; + if (apiObjectType) { + return new ChildWebIDLObjectTypeImplementation(request, this); + } + return this.getImplementation(apiNamespace, apiName); + } + + /** + * Handles an ExtensionAPIRequest originated by the Extension APIs WebIDL bindings. + * + * @param {mozIExtensionAPIRequest} request + * The object that represents the API request received + * (including arguments, an event listener wrapper etc) + * + * @returns {mozIExtensionAPIRequestResult} + * Result for the API request, either a value to be returned + * (which has to be a value that can be structure cloned + * if the request was originated from the worker thread) or + * an error to raise to the extension code. + */ + handleWebIDLAPIRequest(request) { + try { + const impl = this.getImplementationForRequest(request); + let result; + this.context.withAPIRequest(request, () => { + if (impl instanceof ProxyAPIImplementation) { + result = this.handleForProxyAPIImplementation(request, impl); + } else { + result = this.callAPIImplementation(request, impl); + } + }); + + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: result, + }; + } catch (error) { + return this.handleExtensionError(error); + } + } + + /** + * Convert an error raised while handling an API request, + * into the expected mozIExtensionAPIRequestResult. + * + * @param {Error | WorkerExtensionError} error + * @returns {mozIExtensionAPIRequestResult} + */ + + handleExtensionError(error) { + // Propagate an extension error to the caller on the worker thread. + if (error instanceof this.context.Error) { + return { + type: Ci.mozIExtensionAPIRequestResult.EXTENSION_ERROR, + value: error, + }; + } + + // Otherwise just log it and throw a generic error. + Cu.reportError(error); + return { + type: Ci.mozIExtensionAPIRequestResult.EXTENSION_ERROR, + value: new this.context.Error("An unexpected error occurred"), + }; + } + + /** + * Handle the given mozIExtensionAPIRequest using the given + * APIImplementation instance. + * + * @param {mozIExtensionAPIRequest} request + * @param {ChildLocalWebIDLAPIImplementation | ProxyAPIImplementation} impl + * @returns {any} + * @throws {Error | WorkerExtensionError} + */ + + callAPIImplementation(request, impl) { + const { requestType, normalizedArgs } = request; + + switch (requestType) { + // TODO (Bug 1728328): follow up to take callAsyncFunction requireUserInput + // parameter into account (until then callAsyncFunction, callFunction + // and callFunctionNoReturn calls do not differ yet). + case "callAsyncFunction": + case "callFunction": + case "callFunctionNoReturn": + case "getProperty": + return impl[requestType](normalizedArgs); + case "addListener": { + const listener = this.getOrCreateListenerWrapper(request, impl); + impl.addListener(listener, normalizedArgs); + + return undefined; + } + case "removeListener": { + const listener = this.getListenerWrapper(request); + if (listener) { + // Remove the previously added listener and forget the cleanup + // observer previously passed to context.callOnClose. + listener._callOnClose.close(); + this.context.forgetOnClose(listener._callOnClose); + this.forgetListenerWrapper(request); + } + return undefined; + } + default: + throw new Error( + `Unexpected requestType ${requestType} while handling "${request}"` + ); + } + } + + /** + * Handle the given mozIExtensionAPIRequest using the given + * ProxyAPIImplementation instance. + * + * @param {mozIExtensionAPIRequest} request + * @param {ProxyAPIImplementation} impl + * @returns {any} + * @throws {Error | WorkerExtensionError} + */ + handleForProxyAPIImplementation(request, impl) { + const { requestType } = request; + switch (requestType) { + case "callAsyncFunction": + case "callFunctionNoReturn": + case "addListener": + case "removeListener": + return this.callAPIImplementation(request, impl); + default: + // Any other request types (e.g. getProperty or callFunction) are + // unexpected and so we raise a more detailed error to be logged + // on the browser console (while the extension will receive the + // generic "An unexpected error occurred" one). + throw new Error( + `Unexpected requestType ${requestType} while handling "${request}"` + ); + } + } + + getAPIPathForWebIDLRequest(request) { + const { apiNamespace, apiName, apiObjectType } = request; + if (apiObjectType) { + return `${apiNamespace}.${apiObjectType}.${apiName}`; + } + + return `${apiNamespace}.${apiName}`; + } + + /** + * Return an ExtensionAPI class instance given its namespace. + * + * @param {string} namespace + * @returns {ExtensionAPI} + */ + getExtensionAPIInstance(namespace) { + return this.apiCan.apis.get(namespace); + } + + getOrCreateListenerWrapper(request, impl) { + let listener = this.getListenerWrapper(request); + if (listener) { + return listener; + } + + // Look for special wrappers that are needed for some API events + // (e.g. runtime.onMessage/onConnect/...). + if (impl instanceof ChildLocalWebIDLAPIImplementation) { + listener = impl.createListenerForAPIRequest(request); + } + + const { eventListener } = request; + listener = + listener ?? + function (...args) { + // Default wrapper just forwards all the arguments to the + // extension callback (all arguments has to be structure cloneable + // if the extension callback is on the worker thread). + eventListener.callListener(args); + }; + listener._callOnClose = { + close: () => { + this.eventListenerWrappers.delete(eventListener); + // Failing to send the request to remove the listener in the parent + // process shouldn't prevent the extension or context shutdown, + // otherwise we would leak a WebExtensionPolicy instance. + try { + impl.removeListener(listener); + } catch (err) { + // Removing a listener when the extension context is being closed can + // fail if the API is proxied to the parent process and the conduit + // has been already closed, and so we ignore the error if we are not + // processing a call proxied to the parent process. + if (impl instanceof ChildLocalWebIDLAPIImplementation) { + Cu.reportError(err); + } + } + }, + }; + this.storeListenerWrapper(request, listener); + this.context.callOnClose(listener._callOnClose); + return listener; + } + + getListenerWrapper(request) { + const { eventListener } = request; + if (!(eventListener instanceof Ci.mozIExtensionEventListener)) { + throw new Error(`Unexpected eventListener type for request: ${request}`); + } + const apiPath = this.getAPIPathForWebIDLRequest(request); + if (!this.eventListenerWrappers.has(apiPath)) { + return undefined; + } + return this.eventListenerWrappers.get(apiPath).get(eventListener); + } + + storeListenerWrapper(request, listener) { + const { eventListener } = request; + if (!(eventListener instanceof Ci.mozIExtensionEventListener)) { + throw new Error(`Missing eventListener for request: ${request}`); + } + const apiPath = this.getAPIPathForWebIDLRequest(request); + this.eventListenerWrappers.get(apiPath).set(eventListener, listener); + } + + forgetListenerWrapper(request) { + const { eventListener } = request; + if (!(eventListener instanceof Ci.mozIExtensionEventListener)) { + throw new Error(`Missing eventListener for request: ${request}`); + } + const apiPath = this.getAPIPathForWebIDLRequest(request); + if (this.eventListenerWrappers.has(apiPath)) { + this.eventListenerWrappers.get(apiPath).delete(eventListener); + } + } +} + +class WorkerContextChild extends BaseContext { + /** + * This WorkerContextChild represents an addon execution environment + * that is running on the worker thread in an extension child process. + * + * @param {BrowserExtensionContent} extension This context's owner. + * @param {object} params + * @param {mozIExtensionServiceWorkerInfo} params.serviceWorkerInfo + */ + constructor(extension, { serviceWorkerInfo }) { + if ( + !serviceWorkerInfo?.scriptURL || + !serviceWorkerInfo?.clientInfoId || + !serviceWorkerInfo?.principal + ) { + throw new Error("Missing or invalid serviceWorkerInfo"); + } + + super("addon_child", extension); + this.viewType = "background_worker"; + this.uri = Services.io.newURI(serviceWorkerInfo.scriptURL); + this.workerClientInfoId = serviceWorkerInfo.clientInfoId; + this.workerDescriptorId = serviceWorkerInfo.descriptorId; + this.workerPrincipal = serviceWorkerInfo.principal; + this.incognito = serviceWorkerInfo.principal.privateBrowsingId > 0; + + // A mozIExtensionAPIRequest being processed (set by the withAPIRequest + // method while executing a given callable, can be optionally used by + // the API implementation methods to access the mozIExtensionAPIRequest + // being processed and customize their result if necessary to handle + // requests originated by the webidl bindings). + this.webidlAPIRequest = null; + + // This context uses a plain object as a cloneScope (anyway the values + // moved across thread are going to be automatically serialized/deserialized + // as structure clone data, we may remove this if we are changing the + // internals to not use the context.cloneScope). + this.workerCloneScope = { + Promise, + // The instances of this Error constructor will be recognized by the + // ExtensionAPIRequestHandler as errors that should be propagated to + // the worker thread and received by extension code that originated + // the API request. + Error: ExtensionUtils.WorkerExtensionError, + }; + } + + getCreateProxyContextData() { + const { workerDescriptorId } = this; + return { workerDescriptorId }; + } + + openConduit(subject, address) { + let proc = ChromeUtils.domProcessChild; + let conduit = proc.getActor("ProcessConduits").openConduit(subject, { + id: subject.id || getUniqueId(), + extensionId: this.extension.id, + envType: this.envType, + workerScriptURL: this.uri.spec, + workerDescriptorId: this.workerDescriptorId, + ...address, + }); + this.callOnClose(conduit); + conduit.setCloseCallback(() => { + this.forgetOnClose(conduit); + }); + return conduit; + } + + notifyWorkerLoaded() { + this.childManager.conduit.sendContextLoaded({ + childId: this.childManager.id, + extensionId: this.extension.id, + workerDescriptorId: this.workerDescriptorId, + }); + } + + withAPIRequest(request, callable) { + this.webidlAPIRequest = request; + try { + return callable(); + } finally { + this.webidlAPIRequest = null; + } + } + + getAPIRequest() { + return this.webidlAPIRequest; + } + + /** + * Captures the most recent stack frame from the WebIDL API request being + * processed. + * + * @returns {SavedFrame?} + */ + getCaller() { + return this.webidlAPIRequest?.callerSavedFrame; + } + + logActivity(type, name, data) { + ExtensionActivityLogChild.log(this, type, name, data); + } + + get cloneScope() { + return this.workerCloneScope; + } + + get principal() { + return this.workerPrincipal; + } + + get tabId() { + return -1; + } + + get useWebIDLBindings() { + return true; + } + + shutdown() { + this.unload(); + } + + unload() { + if (this.unloaded) { + return; + } + + super.unload(); + } + + get childManager() { + const childManager = getContextChildManagerGetter( + { envType: "addon_parent" }, + WebIDLChildAPIManager + ).call(this); + return redefineGetter(this, "childManager", childManager); + } + + get messenger() { + return redefineGetter(this, "messenger", new WorkerMessenger(this)); + } +} + +export var ExtensionWorkerChild = { + /** @type {Map<number, WorkerContextChild>} */ + extensionWorkerContexts: new Map(), + + apiManager: ExtensionPageChild.apiManager, + + /** + * Create an extension worker context (on a mozExtensionAPIRequest with + * requestType "initWorkerContext"). + * + * @param {BrowserExtensionContent} extension + * The extension for which the context should be created. + * @param {mozIExtensionServiceWorkerInfo} serviceWorkerInfo + */ + initExtensionWorkerContext(extension, serviceWorkerInfo) { + if (!WebExtensionPolicy.isExtensionProcess) { + throw new Error( + "Cannot create an extension worker context in current process" + ); + } + + const swId = serviceWorkerInfo.descriptorId; + let context = this.extensionWorkerContexts.get(swId); + if (context) { + if (context.extension !== extension) { + throw new Error( + "A different extension context already exists for this service worker" + ); + } + throw new Error( + "An extension context was already initialized for this service worker" + ); + } + + context = new WorkerContextChild(extension, { serviceWorkerInfo }); + this.extensionWorkerContexts.set(swId, context); + }, + + /** + * Get an existing extension worker context for the given extension and + * service worker. + * + * @param {BrowserExtensionContent} extension + * The extension for which the context should be created. + * @param {mozIExtensionServiceWorkerInfo} serviceWorkerInfo + * + * @returns {WorkerContextChild} + */ + getExtensionWorkerContext(extension, serviceWorkerInfo) { + if (!serviceWorkerInfo) { + return null; + } + + const context = this.extensionWorkerContexts.get( + serviceWorkerInfo.descriptorId + ); + + if (context?.extension === extension) { + return context; + } + + return null; + }, + + /** + * Notify the main process when an extension worker script has been loaded. + * + * @param {number} descriptorId The service worker descriptor ID of the destroyed context. + * @param {WebExtensionPolicy} policy + */ + notifyExtensionWorkerContextLoaded(descriptorId, policy) { + let context = this.extensionWorkerContexts.get(descriptorId); + if (context) { + if (context.extension.id !== policy.id) { + Cu.reportError( + new Error( + `ServiceWorker ${descriptorId} does not belong to the expected extension: ${policy.id}` + ) + ); + return; + } + context.notifyWorkerLoaded(); + } + }, + + /** + * Close the WorkerContextChild belonging to the given service worker, if any. + * + * @param {number} descriptorId The service worker descriptor ID of the destroyed context. + */ + destroyExtensionWorkerContext(descriptorId) { + let context = this.extensionWorkerContexts.get(descriptorId); + if (context) { + context.unload(); + this.extensionWorkerContexts.delete(descriptorId); + } + }, + + shutdownExtension(extensionId) { + for (let [workerClientInfoId, context] of this.extensionWorkerContexts) { + if (context.extension.id == extensionId) { + context.shutdown(); + this.extensionWorkerContexts.delete(workerClientInfoId); + } + } + }, +}; diff --git a/toolkit/components/extensions/ExtensionXPCShellUtils.sys.mjs b/toolkit/components/extensions/ExtensionXPCShellUtils.sys.mjs new file mode 100644 index 0000000000..165c37fac1 --- /dev/null +++ b/toolkit/components/extensions/ExtensionXPCShellUtils.sys.mjs @@ -0,0 +1,780 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { XPCShellContentUtils } from "resource://testing-common/XPCShellContentUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs", + ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + Management: "resource://gre/modules/Extension.sys.mjs", + Schemas: "resource://gre/modules/Schemas.sys.mjs", +}); + +let BASE_MANIFEST = Object.freeze({ + browser_specific_settings: Object.freeze({ + gecko: Object.freeze({ + id: "test@web.ext", + }), + }), + + manifest_version: 2, + + name: "name", + version: "0", +}); + +class ExtensionWrapper { + /** @type {AddonWrapper} */ + addon; + /** @type {Promise<AddonWrapper>} */ + addonPromise; + /** @type {nsIFile[]} */ + cleanupFiles; + + constructor(testScope, extension = null) { + this.testScope = testScope; + + this.extension = null; + + this.handleResult = this.handleResult.bind(this); + this.handleMessage = this.handleMessage.bind(this); + + this.state = "uninitialized"; + + this.testResolve = null; + this.testDone = new Promise(resolve => { + this.testResolve = resolve; + }); + + this.messageHandler = new Map(); + this.messageAwaiter = new Map(); + + this.messageQueue = new Set(); + + this.testScope.registerCleanupFunction(() => { + this.clearMessageQueues(); + + if (this.state == "pending" || this.state == "running") { + this.testScope.equal( + this.state, + "unloaded", + "Extension left running at test shutdown" + ); + return this.unload(); + } else if (this.state == "unloading") { + this.testScope.equal( + this.state, + "unloaded", + "Extension not fully unloaded at test shutdown" + ); + } + this.destroy(); + }); + + if (extension) { + this.id = extension.id; + this.attachExtension(extension); + } + } + + destroy() { + // This method should be implemented in subclasses which need to + // perform cleanup when destroyed. + } + + attachExtension(extension) { + if (extension === this.extension) { + return; + } + + if (this.extension) { + this.extension.off("test-eq", this.handleResult); + this.extension.off("test-log", this.handleResult); + this.extension.off("test-result", this.handleResult); + this.extension.off("test-done", this.handleResult); + this.extension.off("test-message", this.handleMessage); + this.clearMessageQueues(); + } + this.uuid = extension.uuid; + this.extension = extension; + + extension.on("test-eq", this.handleResult); + extension.on("test-log", this.handleResult); + extension.on("test-result", this.handleResult); + extension.on("test-done", this.handleResult); + extension.on("test-message", this.handleMessage); + + this.testScope.info(`Extension attached`); + } + + clearMessageQueues() { + if (this.messageQueue.size) { + let names = Array.from(this.messageQueue, ([msg]) => msg); + this.testScope.equal( + JSON.stringify(names), + "[]", + "message queue is empty" + ); + this.messageQueue.clear(); + } + if (this.messageAwaiter.size) { + let names = Array.from(this.messageAwaiter.keys()); + this.testScope.equal( + JSON.stringify(names), + "[]", + "no tasks awaiting on messages" + ); + for (let promise of this.messageAwaiter.values()) { + promise.reject(); + } + this.messageAwaiter.clear(); + } + } + + handleResult(kind, pass, msg, expected, actual) { + switch (kind) { + case "test-eq": + this.testScope.ok( + pass, + `${msg} - Expected: ${expected}, Actual: ${actual}` + ); + break; + + case "test-log": + this.testScope.info(msg); + break; + + case "test-result": + this.testScope.ok(pass, msg); + break; + + case "test-done": + this.testScope.ok(pass, msg); + this.testResolve(msg); + break; + } + } + + handleMessage(kind, msg, ...args) { + let handler = this.messageHandler.get(msg); + if (handler) { + handler(...args); + } else { + this.messageQueue.add([msg, ...args]); + this.checkMessages(); + } + } + + awaitStartup() { + return this.startupPromise; + } + + awaitBackgroundStarted() { + if (!this.extension.manifest.background) { + throw new Error("Extension has no background"); + } + return Promise.all([ + this.startupPromise, + this.extension.promiseBackgroundStarted(), + ]); + } + + async startup() { + if (this.state != "uninitialized") { + throw new Error("Extension already started"); + } + this.state = "pending"; + + await lazy.ExtensionTestCommon.setIncognitoOverride(this.extension); + + this.startupPromise = this.extension.startup().then( + result => { + this.state = "running"; + + return result; + }, + error => { + this.state = "failed"; + + return Promise.reject(error); + } + ); + + return this.startupPromise; + } + + async unload() { + if (this.state != "running") { + throw new Error("Extension not running"); + } + this.state = "unloading"; + + if (this.addonPromise) { + // If addonPromise is still pending resolution, wait for it to make sure + // that add-ons that are installed through the AddonManager are properly + // uninstalled. + await this.addonPromise; + } + + if (this.addon) { + await this.addon.uninstall(); + } else { + await this.extension.shutdown(); + } + + if (AppConstants.platform === "android") { + // We need a way to notify the embedding layer that an extension has been + // uninstalled, so that the java layer can be updated too. + Services.obs.notifyObservers( + null, + "testing-uninstalled-addon", + this.addon ? this.addon.id : this.extension.id + ); + } + + this.state = "unloaded"; + } + + /** + * This method sends the message to force-sleep the background scripts. + * + * @returns {Promise} resolves after the background is asleep and listeners primed. + */ + terminateBackground(...args) { + return this.extension.terminateBackground(...args); + } + + wakeupBackground() { + return this.extension.wakeupBackground(); + } + + sendMessage(...args) { + this.extension.testMessage(...args); + } + + awaitFinish(msg) { + return this.testDone.then(actual => { + if (msg) { + this.testScope.equal(actual, msg, "test result correct"); + } + return actual; + }); + } + + checkMessages() { + for (let message of this.messageQueue) { + let [msg, ...args] = message; + + let listener = this.messageAwaiter.get(msg); + if (listener) { + this.messageQueue.delete(message); + this.messageAwaiter.delete(msg); + + listener.resolve(...args); + return; + } + } + } + + checkDuplicateListeners(msg) { + if (this.messageHandler.has(msg) || this.messageAwaiter.has(msg)) { + throw new Error("only one message handler allowed"); + } + } + + awaitMessage(msg) { + return new Promise((resolve, reject) => { + this.checkDuplicateListeners(msg); + + this.messageAwaiter.set(msg, { resolve, reject }); + this.checkMessages(); + }); + } + + onMessage(msg, callback) { + this.checkDuplicateListeners(msg); + this.messageHandler.set(msg, callback); + } +} + +class AOMExtensionWrapper extends ExtensionWrapper { + constructor(testScope) { + super(testScope); + + this.onEvent = this.onEvent.bind(this); + + lazy.Management.on("ready", this.onEvent); + lazy.Management.on("shutdown", this.onEvent); + lazy.Management.on("startup", this.onEvent); + + lazy.AddonTestUtils.on("addon-manager-shutdown", this.onEvent); + lazy.AddonTestUtils.on("addon-manager-started", this.onEvent); + + lazy.AddonManager.addAddonListener(this); + } + + destroy() { + this.id = null; + this.addon = null; + + lazy.Management.off("ready", this.onEvent); + lazy.Management.off("shutdown", this.onEvent); + lazy.Management.off("startup", this.onEvent); + + lazy.AddonTestUtils.off("addon-manager-shutdown", this.onEvent); + lazy.AddonTestUtils.off("addon-manager-started", this.onEvent); + + lazy.AddonManager.removeAddonListener(this); + } + + setRestarting() { + if (this.state !== "restarting") { + this.startupPromise = new Promise(resolve => { + this.resolveStartup = resolve; + }).then(async result => { + await this.addonPromise; + return result; + }); + } + this.state = "restarting"; + } + + onEnabling(addon) { + if (addon.id === this.id) { + this.setRestarting(); + } + } + + onInstalling(addon) { + if (addon.id === this.id) { + this.setRestarting(); + } + } + + onInstalled(addon) { + if (addon.id === this.id) { + this.addon = addon; + } + } + + onUninstalled(addon) { + if (addon.id === this.id) { + this.destroy(); + } + } + + onEvent(kind, ...args) { + switch (kind) { + case "addon-manager-started": + if (this.state === "uninitialized") { + // startup() not called yet, ignore AddonManager startup notification. + return; + } + this.addonPromise = lazy.AddonManager.getAddonByID(this.id).then( + addon => { + this.addon = addon; + this.addonPromise = null; + } + ); + // FALLTHROUGH + case "addon-manager-shutdown": + if (this.state === "uninitialized") { + return; + } + this.addon = null; + + this.setRestarting(); + break; + + case "startup": { + let [extension] = args; + + this.maybeSetID(extension.rootURI, extension.id); + + if (extension.id === this.id) { + this.attachExtension(extension); + this.state = "pending"; + } + break; + } + + case "shutdown": { + let [extension] = args; + if (extension.id === this.id && this.state !== "restarting") { + this.state = "unloaded"; + } + break; + } + + case "ready": { + let [extension] = args; + if (extension.id === this.id) { + this.state = "running"; + if (AppConstants.platform === "android") { + // We need a way to notify the embedding layer that a new extension + // has been installed, so that the java layer can be updated too. + Services.obs.notifyObservers( + null, + "testing-installed-addon", + extension.id + ); + } + this.resolveStartup(extension); + } + break; + } + } + } + + async _flushCache() { + if (this.extension && this.extension.rootURI instanceof Ci.nsIJARURI) { + let file = this.extension.rootURI.JARFile.QueryInterface( + Ci.nsIFileURL + ).file; + await Services.ppmm.broadcastAsyncMessage("Extension:FlushJarCache", { + path: file.path, + }); + } + } + + get version() { + return this.addon && this.addon.version; + } + + async unload() { + await this._flushCache(); + return super.unload(); + } + + /** + * Override for subclasses which don't set an ID in the constructor. + * + * @param {nsIURI} uri + * @param {string} id + */ + maybeSetID(uri, id) {} +} + +class InstallableWrapper extends AOMExtensionWrapper { + constructor(testScope, xpiFile, addonData = {}) { + super(testScope); + + this.file = xpiFile; + this.addonData = addonData; + this.installType = addonData.useAddonManager || "temporary"; + this.installTelemetryInfo = addonData.amInstallTelemetryInfo; + + this.cleanupFiles = [xpiFile]; + } + + destroy() { + super.destroy(); + + for (let file of this.cleanupFiles.splice(0)) { + try { + Services.obs.notifyObservers(file, "flush-cache-entry"); + file.remove(false); + } catch (e) { + Cu.reportError(e); + } + } + } + + maybeSetID(uri, id) { + if ( + !this.id && + uri instanceof Ci.nsIJARURI && + uri.JARFile.QueryInterface(Ci.nsIFileURL).file.equals(this.file) + ) { + this.id = id; + } + } + + _setIncognitoOverride() { + // this.id is not set yet so grab it from the manifest data to set + // the incognito permission. + let { addonData } = this; + if (addonData && addonData.incognitoOverride) { + try { + let { id } = addonData.manifest.browser_specific_settings.gecko; + if (id) { + return lazy.ExtensionTestCommon.setIncognitoOverride({ + id, + addonData, + }); + } + } catch (e) {} + throw new Error( + "Extension ID is required for setting incognito permission." + ); + } + } + + async _install(xpiFile) { + await this._setIncognitoOverride(); + + if (this.installType === "temporary") { + return lazy.AddonManager.installTemporaryAddon(xpiFile) + .then(addon => { + this.id = addon.id; + this.addon = addon; + + return this.startupPromise; + }) + .catch(e => { + this.state = "unloaded"; + return Promise.reject(e); + }); + } else if (this.installType === "permanent") { + return lazy.AddonManager.getInstallForFile( + xpiFile, + null, + this.installTelemetryInfo + ).then(install => { + let listener = { + onDownloadFailed: () => { + this.state = "unloaded"; + this.resolveStartup(Promise.reject(new Error("Install failed"))); + }, + onInstallFailed: () => { + this.state = "unloaded"; + this.resolveStartup(Promise.reject(new Error("Install failed"))); + }, + onInstallEnded: (install, newAddon) => { + this.id = newAddon.id; + this.addon = newAddon; + }, + }; + + install.addListener(listener); + install.install(); + + return this.startupPromise; + }); + } + } + + startup() { + if (this.state != "uninitialized") { + throw new Error("Extension already started"); + } + + this.state = "pending"; + this.startupPromise = new Promise(resolve => { + this.resolveStartup = resolve; + }); + + return this._install(this.file); + } + + async upgrade(data) { + this.startupPromise = new Promise(resolve => { + this.resolveStartup = resolve; + }); + this.state = "restarting"; + + await this._flushCache(); + + let xpiFile = lazy.ExtensionTestCommon.generateXPI(data); + + this.cleanupFiles.push(xpiFile); + + return this._install(xpiFile); + } +} + +class ExternallyInstalledWrapper extends AOMExtensionWrapper { + constructor(testScope, id) { + super(testScope); + + this.id = id; + this.startupPromise = new Promise(resolve => { + this.resolveStartup = resolve; + }); + + this.state = "restarting"; + } +} + +export var ExtensionTestUtils = { + BASE_MANIFEST, + + get testAssertions() { + return lazy.ExtensionTestCommon.testAssertions; + }, + + // Shortcut to more easily access WebExtensionPolicy.backgroundServiceWorkerEnabled + // from mochitest-plain tests. + getBackgroundServiceWorkerEnabled() { + return lazy.ExtensionTestCommon.getBackgroundServiceWorkerEnabled(); + }, + + // A test helper used to check if the pref "extension.backgroundServiceWorker.forceInTestExtension" + // is set to true. + isInBackgroundServiceWorkerTests() { + return lazy.ExtensionTestCommon.isInBackgroundServiceWorkerTests(); + }, + + async normalizeManifest( + manifest, + manifestType = "manifest.WebExtensionManifest", + baseManifest = BASE_MANIFEST + ) { + await lazy.Management.lazyInit(); + + manifest = Object.assign({}, baseManifest, manifest); + + let errors = []; + let context = { + url: null, + manifestVersion: manifest.manifest_version, + + logError: error => { + errors.push(error); + }, + + preprocessors: {}, + }; + + let normalized = lazy.Schemas.normalize(manifest, manifestType, context); + normalized.errors = errors; + + return normalized; + }, + + currentScope: null, + + profileDir: null, + + init(scope) { + XPCShellContentUtils.ensureInitialized(scope); + + this.currentScope = scope; + + this.profileDir = scope.do_get_profile(); + + let tmpD = this.profileDir.clone(); + tmpD.append("tmp"); + tmpD.create(Ci.nsIFile.DIRECTORY_TYPE, lazy.FileUtils.PERMS_DIRECTORY); + + let dirProvider = { + getFile(prop, persistent) { + persistent.value = false; + if (prop == "TmpD") { + return tmpD.clone(); + } + return null; + }, + + QueryInterface: ChromeUtils.generateQI(["nsIDirectoryServiceProvider"]), + }; + Services.dirsvc.registerProvider(dirProvider); + + scope.registerCleanupFunction(() => { + try { + tmpD.remove(true); + } catch (e) { + Cu.reportError(e); + } + Services.dirsvc.unregisterProvider(dirProvider); + + this.currentScope = null; + }); + }, + + addonManagerStarted: false, + + mockAppInfo() { + lazy.AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "48", + "48" + ); + }, + + startAddonManager() { + if (this.addonManagerStarted) { + return; + } + this.addonManagerStarted = true; + this.mockAppInfo(); + + return lazy.AddonTestUtils.promiseStartupManager(); + }, + + loadExtension(data) { + if (data.useAddonManager) { + // If we're using incognitoOverride, we'll need to ensure + // an ID is available before generating the XPI. + if (data.incognitoOverride) { + lazy.ExtensionTestCommon.setExtensionID(data); + } + let xpiFile = lazy.ExtensionTestCommon.generateXPI(data); + + return this.loadExtensionXPI(xpiFile, data); + } + + let extension = lazy.ExtensionTestCommon.generate(data); + + return new ExtensionWrapper(this.currentScope, extension); + }, + + loadExtensionXPI(xpiFile, data) { + return new InstallableWrapper(this.currentScope, xpiFile, data); + }, + + // Create a wrapper for a webextension that will be installed + // by some external process (e.g., Normandy) + expectExtension(id) { + return new ExternallyInstalledWrapper(this.currentScope, id); + }, + + failOnSchemaWarnings(warningsAsErrors = true) { + let prefName = "extensions.webextensions.warnings-as-errors"; + Services.prefs.setBoolPref(prefName, warningsAsErrors); + if (!warningsAsErrors) { + this.currentScope.registerCleanupFunction(() => { + Services.prefs.setBoolPref(prefName, true); + }); + } + }, + + /** @param {[origin: string, url: string, options: object]} args */ + async fetch(...args) { + return XPCShellContentUtils.fetch(...args); + }, + + /** + * Loads a content page into a hidden docShell. + * + * @param {string} url + * The URL to load. + * @param {object} [options = {}] + * @param {ExtensionWrapper} [options.extension] + * If passed, load the URL as an extension page for the given + * extension. + * @param {boolean} [options.remote] + * If true, load the URL in a content process. If false, load + * it in the parent process. + * @param {boolean} [options.remoteSubframes] + * If true, load cross-origin frames in separate content processes. + * This is ignored if |options.remote| is false. + * @param {string} [options.redirectUrl] + * An optional URL that the initial page is expected to + * redirect to. + * + * @returns {XPCShellContentUtils.ContentPage} + */ + loadContentPage(url, options) { + return XPCShellContentUtils.loadContentPage(url, options); + }, +}; diff --git a/toolkit/components/extensions/ExtensionsChild.cpp b/toolkit/components/extensions/ExtensionsChild.cpp new file mode 100644 index 0000000000..258c2ed1b7 --- /dev/null +++ b/toolkit/components/extensions/ExtensionsChild.cpp @@ -0,0 +1,73 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "mozilla/extensions/ExtensionsChild.h" +#include "mozilla/extensions/ExtensionsParent.h" + +#include "mozilla/ClearOnShutdown.h" +#include "mozilla/dom/ContentChild.h" +#include "mozilla/dom/InProcessChild.h" +#include "mozilla/dom/InProcessParent.h" +#include "mozilla/ipc/Endpoint.h" +#include "nsXULAppAPI.h" + +using namespace mozilla::dom; + +namespace mozilla { +namespace extensions { + +NS_IMPL_ISUPPORTS(ExtensionsChild, nsIObserver) + +/* static */ +ExtensionsChild& ExtensionsChild::Get() { + static RefPtr<ExtensionsChild> sInstance; + + if (MOZ_UNLIKELY(!sInstance)) { + sInstance = new ExtensionsChild(); + sInstance->Init(); + ClearOnShutdown(&sInstance); + } + return *sInstance; +} + +/* static */ +already_AddRefed<ExtensionsChild> ExtensionsChild::GetSingleton() { + return do_AddRef(&Get()); +} + +void ExtensionsChild::Init() { + if (XRE_IsContentProcess()) { + ContentChild::GetSingleton()->SendPExtensionsConstructor(this); + } else { + InProcessChild* ipChild = InProcessChild::Singleton(); + InProcessParent* ipParent = InProcessParent::Singleton(); + if (!ipChild || !ipParent) { + return; + } + + RefPtr parent = new ExtensionsParent(); + + ManagedEndpoint<PExtensionsParent> endpoint = + ipChild->OpenPExtensionsEndpoint(this); + ipParent->BindPExtensionsEndpoint(std::move(endpoint), parent); + } +} + +void ExtensionsChild::ActorDestroy(ActorDestroyReason aWhy) {} + +/* nsIObserver */ + +NS_IMETHODIMP ExtensionsChild::Observe(nsISupports*, const char* aTopic, + const char16_t*) { + // Since this class is created at startup by the Category Manager, it's + // expected to implement nsIObserver; however, we have nothing interesting + // to do here. + MOZ_ASSERT(strcmp(aTopic, "app-startup") == 0); + + return NS_OK; +} + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/ExtensionsChild.h b/toolkit/components/extensions/ExtensionsChild.h new file mode 100644 index 0000000000..4e7adcdd48 --- /dev/null +++ b/toolkit/components/extensions/ExtensionsChild.h @@ -0,0 +1,37 @@ +/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_extensions_ExtensionsChild_h +#define mozilla_extensions_ExtensionsChild_h + +#include "mozilla/extensions/PExtensionsChild.h" +#include "nsISupportsImpl.h" + +namespace mozilla { +namespace extensions { + +class ExtensionsChild final : public nsIObserver, public PExtensionsChild { + public: + NS_DECL_ISUPPORTS + NS_DECL_NSIOBSERVER + + static already_AddRefed<ExtensionsChild> GetSingleton(); + + static ExtensionsChild& Get(); + + private: + ExtensionsChild() = default; + ~ExtensionsChild() = default; + + void Init(); + + protected: + void ActorDestroy(ActorDestroyReason aWhy) override; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_ExtensionsChild_h diff --git a/toolkit/components/extensions/ExtensionsParent.cpp b/toolkit/components/extensions/ExtensionsParent.cpp new file mode 100644 index 0000000000..0e10af241f --- /dev/null +++ b/toolkit/components/extensions/ExtensionsParent.cpp @@ -0,0 +1,126 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "extIWebNavigation.h" +#include "mozilla/extensions/ExtensionsParent.h" +#include "mozilla/dom/BrowsingContext.h" +#include "mozilla/dom/ContentParent.h" +#include "mozilla/dom/ScriptSettings.h" +#include "mozilla/RefPtr.h" +#include "jsapi.h" +#include "js/PropertyAndElement.h" // JS_SetProperty +#include "nsImportModule.h" +#include "xpcpublic.h" + +namespace mozilla { +namespace extensions { + +ExtensionsParent::ExtensionsParent() {} +ExtensionsParent::~ExtensionsParent() {} + +extIWebNavigation* ExtensionsParent::WebNavigation() { + if (!mWebNavigation) { + mWebNavigation = do_ImportModule("resource://gre/modules/WebNavigation.jsm", + "WebNavigationManager"); + } + return mWebNavigation; +} + +void ExtensionsParent::ActorDestroy(ActorDestroyReason aWhy) {} + +static inline JS::Handle<JS::Value> ToJSBoolean(bool aValue) { + return aValue ? JS::TrueHandleValue : JS::FalseHandleValue; +} + +JS::Value FrameTransitionDataToJSValue(const FrameTransitionData& aData) { + JS::Rooted<JS::Value> ret(dom::RootingCx(), JS::UndefinedValue()); + { + dom::AutoJSAPI jsapi; + MOZ_ALWAYS_TRUE(jsapi.Init(xpc::PrivilegedJunkScope())); + JSContext* cx = jsapi.cx(); + + JS::Rooted<JSObject*> obj(cx, JS_NewPlainObject(cx)); + if (obj && + JS_SetProperty(cx, obj, "forward_back", + ToJSBoolean(aData.forwardBack())) && + JS_SetProperty(cx, obj, "form_submit", + ToJSBoolean(aData.formSubmit())) && + JS_SetProperty(cx, obj, "reload", ToJSBoolean(aData.reload())) && + JS_SetProperty(cx, obj, "server_redirect", + ToJSBoolean(aData.serverRedirect())) && + JS_SetProperty(cx, obj, "client_redirect", + ToJSBoolean(aData.clientRedirect()))) { + ret.setObject(*obj); + } + } + return ret; +} + +ipc::IPCResult ExtensionsParent::RecvDocumentChange( + MaybeDiscardedBrowsingContext&& aBC, FrameTransitionData&& aTransitionData, + nsIURI* aLocation) { + if (aBC.IsNullOrDiscarded()) { + return IPC_OK(); + } + + JS::Rooted<JS::Value> transitionData( + dom::RootingCx(), FrameTransitionDataToJSValue(aTransitionData)); + + WebNavigation()->OnDocumentChange(aBC.get(), transitionData, aLocation); + return IPC_OK(); +} + +ipc::IPCResult ExtensionsParent::RecvHistoryChange( + MaybeDiscardedBrowsingContext&& aBC, FrameTransitionData&& aTransitionData, + nsIURI* aLocation, bool aIsHistoryStateUpdated, + bool aIsReferenceFragmentUpdated) { + if (aBC.IsNullOrDiscarded()) { + return IPC_OK(); + } + + JS::Rooted<JS::Value> transitionData( + dom::RootingCx(), FrameTransitionDataToJSValue(aTransitionData)); + + WebNavigation()->OnHistoryChange(aBC.get(), transitionData, aLocation, + aIsHistoryStateUpdated, + aIsReferenceFragmentUpdated); + return IPC_OK(); +} + +ipc::IPCResult ExtensionsParent::RecvStateChange( + MaybeDiscardedBrowsingContext&& aBC, nsIURI* aRequestURI, nsresult aStatus, + uint32_t aStateFlags) { + if (aBC.IsNullOrDiscarded()) { + return IPC_OK(); + } + + WebNavigation()->OnStateChange(aBC.get(), aRequestURI, aStatus, aStateFlags); + return IPC_OK(); +} + +ipc::IPCResult ExtensionsParent::RecvCreatedNavigationTarget( + MaybeDiscardedBrowsingContext&& aBC, + MaybeDiscardedBrowsingContext&& aSourceBC, const nsCString& aURL) { + if (aBC.IsNullOrDiscarded() || aSourceBC.IsNull()) { + return IPC_OK(); + } + + WebNavigation()->OnCreatedNavigationTarget( + aBC.get(), aSourceBC.GetMaybeDiscarded(), aURL); + return IPC_OK(); +} + +ipc::IPCResult ExtensionsParent::RecvDOMContentLoaded( + MaybeDiscardedBrowsingContext&& aBC, nsIURI* aDocumentURI) { + if (aBC.IsNullOrDiscarded()) { + return IPC_OK(); + } + + WebNavigation()->OnDOMContentLoaded(aBC.get(), aDocumentURI); + return IPC_OK(); +} + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/ExtensionsParent.h b/toolkit/components/extensions/ExtensionsParent.h new file mode 100644 index 0000000000..039e6c9093 --- /dev/null +++ b/toolkit/components/extensions/ExtensionsParent.h @@ -0,0 +1,58 @@ +/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_extensions_ExtensionsParent_h +#define mozilla_extensions_ExtensionsParent_h + +#include "mozilla/extensions/PExtensionsParent.h" +#include "nsISupportsImpl.h" + +class extIWebNavigation; + +namespace mozilla { +namespace extensions { + +class ExtensionsParent final : public PExtensionsParent { + public: + NS_INLINE_DECL_REFCOUNTING(ExtensionsParent, final) + + ExtensionsParent(); + + ipc::IPCResult RecvDocumentChange(MaybeDiscardedBrowsingContext&& aBC, + FrameTransitionData&& aTransitionData, + nsIURI* aLocation); + + ipc::IPCResult RecvHistoryChange(MaybeDiscardedBrowsingContext&& aBC, + FrameTransitionData&& aTransitionData, + nsIURI* aLocation, + bool aIsHistoryStateUpdated, + bool aIsReferenceFragmentUpdated); + + ipc::IPCResult RecvStateChange(MaybeDiscardedBrowsingContext&& aBC, + nsIURI* aRequestURI, nsresult aStatus, + uint32_t aStateFlags); + + ipc::IPCResult RecvCreatedNavigationTarget( + MaybeDiscardedBrowsingContext&& aBC, + MaybeDiscardedBrowsingContext&& aSourceBC, const nsCString& aURI); + + ipc::IPCResult RecvDOMContentLoaded(MaybeDiscardedBrowsingContext&& aBC, + nsIURI* aDocumentURI); + + private: + ~ExtensionsParent(); + + extIWebNavigation* WebNavigation(); + + nsCOMPtr<extIWebNavigation> mWebNavigation; + + protected: + void ActorDestroy(ActorDestroyReason aWhy) override; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_ExtensionsParent_h diff --git a/toolkit/components/extensions/FindContent.sys.mjs b/toolkit/components/extensions/FindContent.sys.mjs new file mode 100644 index 0000000000..264d56f556 --- /dev/null +++ b/toolkit/components/extensions/FindContent.sys.mjs @@ -0,0 +1,250 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Finder: "resource://gre/modules/Finder.sys.mjs", + FinderHighlighter: "resource://gre/modules/FinderHighlighter.sys.mjs", + FinderIterator: "resource://gre/modules/FinderIterator.sys.mjs", +}); + +export class FindContent { + constructor(docShell) { + this.finder = new lazy.Finder(docShell); + } + + get iterator() { + if (!this._iterator) { + this._iterator = new lazy.FinderIterator(); + } + return this._iterator; + } + + get highlighter() { + if (!this._highlighter) { + this._highlighter = new lazy.FinderHighlighter(this.finder, true); + } + return this._highlighter; + } + + /** + * findRanges + * + * Performs a search which will cache found ranges in `iterator._previousRanges`. Cached + * data can then be used by `highlightResults`, `_collectRectData` and `_serializeRangeData`. + * + * @param {object} params - the params. + * @param {string} params.queryphrase - the text to search for. + * @param {boolean} params.caseSensitive - whether to use case sensitive matches. + * @param {boolean} params.includeRangeData - whether to collect and return range data. + * @param {boolean} params.matchDiacritics - whether diacritics must match. + * @param {boolean} params.searchString - whether to collect and return rect data. + * @param {boolean} params.entireWord - whether to match entire words. + * @param {boolean} params.includeRectData - collect and return rect data. + * + * @returns {object} that includes: + * {number} count - number of results found. + * {array} rangeData (if opted) - serialized representation of ranges found. + * {array} rectData (if opted) - rect data of ranges found. + */ + findRanges(params) { + return new Promise(resolve => { + let { + queryphrase, + caseSensitive, + entireWord, + includeRangeData, + includeRectData, + matchDiacritics, + } = params; + + this.iterator.reset(); + + // Cast `caseSensitive` and `entireWord` to boolean, otherwise _iterator.start will throw. + let iteratorPromise = this.iterator.start({ + word: queryphrase, + caseSensitive: !!caseSensitive, + entireWord: !!entireWord, + finder: this.finder, + listener: this.finder, + matchDiacritics: !!matchDiacritics, + useSubFrames: false, + }); + + iteratorPromise.then(() => { + let rangeData; + let rectData; + if (includeRangeData) { + rangeData = this._serializeRangeData(); + } + if (includeRectData) { + rectData = this._collectRectData(); + } + + resolve({ + count: this.iterator._previousRanges.length, + rangeData, + rectData, + }); + }); + }); + } + + /** + * _serializeRangeData + * + * Optionally returned by `findRanges`. + * Collects DOM data from ranges found on the most recent search made by `findRanges` + * and encodes it into a serializable form. Useful to extensions for custom UI presentation + * of search results, eg, getting surrounding context of results. + * + * @returns {Array} - serializable range data. + */ + _serializeRangeData() { + let ranges = this.iterator._previousRanges; + + let rangeData = []; + let nodeCountWin = 0; + let lastDoc; + let walker; + let node; + + for (let range of ranges) { + let startContainer = range.startContainer; + let doc = startContainer.ownerDocument; + + if (lastDoc !== doc) { + walker = doc.createTreeWalker( + doc, + doc.defaultView.NodeFilter.SHOW_TEXT, + null, + false + ); + // Get first node. + node = walker.nextNode(); + // Reset node count. + nodeCountWin = 0; + } + lastDoc = doc; + + // The framePos will be set by the parent process later. + let data = { framePos: 0, text: range.toString() }; + rangeData.push(data); + + if (node != range.startContainer) { + node = walker.nextNode(); + while (node) { + nodeCountWin++; + if (node == range.startContainer) { + break; + } + node = walker.nextNode(); + } + } + data.startTextNodePos = nodeCountWin; + data.startOffset = range.startOffset; + + if (range.startContainer != range.endContainer) { + node = walker.nextNode(); + while (node) { + nodeCountWin++; + if (node == range.endContainer) { + break; + } + node = walker.nextNode(); + } + } + data.endTextNodePos = nodeCountWin; + data.endOffset = range.endOffset; + } + + return rangeData; + } + + /** + * _collectRectData + * + * Optionally returned by `findRanges`. + * Collects rect data of ranges found by most recent search made by `findRanges`. + * Useful to extensions for custom highlighting of search results. + * + * @returns {Array} rectData - serializable rect data. + */ + _collectRectData() { + let rectData = []; + + let ranges = this.iterator._previousRanges; + for (let range of ranges) { + let rectsAndTexts = this.highlighter._getRangeRectsAndTexts(range); + rectData.push({ text: range.toString(), rectsAndTexts }); + } + + return rectData; + } + + /** + * highlightResults + * + * Highlights range(s) found in previous browser.find.find. + * + * @param {object} params - may contain any of the following properties: + * all of which are optional: + * {number} rangeIndex - + * Found range to be highlighted held in API's ranges array for the tabId. + * Default highlights all ranges. + * {number} tabId - Tab to highlight. Defaults to the active tab. + * {boolean} noScroll - Don't scroll to highlighted item. + * + * @returns {string} - a string describing the resulting status of the highlighting, + * which will be used as criteria for resolving or rejecting the promise. + * This can be: + * "Success" - Highlighting succeeded. + * "OutOfRange" - The index supplied was out of range. + * "NoResults" - There were no search results to highlight. + */ + highlightResults(params) { + let { rangeIndex, noScroll } = params; + + this.highlighter.highlight(false); + let ranges = this.iterator._previousRanges; + + let status = "Success"; + + if (ranges.length) { + if (typeof rangeIndex == "number") { + if (rangeIndex < ranges.length) { + let foundRange = ranges[rangeIndex]; + this.highlighter.highlightRange(foundRange); + + if (!noScroll) { + let node = foundRange.startContainer; + let editableNode = this.highlighter._getEditableNode(node); + let controller = editableNode + ? editableNode.editor.selectionController + : this.finder._getSelectionController(node.ownerGlobal); + + controller.scrollSelectionIntoView( + controller.SELECTION_FIND, + controller.SELECTION_ON, + controller.SCROLL_CENTER_VERTICALLY + ); + } + } else { + status = "OutOfRange"; + } + } else { + for (let range of ranges) { + this.highlighter.highlightRange(range); + } + } + } else { + status = "NoResults"; + } + + return status; + } +} diff --git a/toolkit/components/extensions/MatchGlob.h b/toolkit/components/extensions/MatchGlob.h new file mode 100644 index 0000000000..ad31759aaf --- /dev/null +++ b/toolkit/components/extensions/MatchGlob.h @@ -0,0 +1,113 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_extensions_MatchGlob_h +#define mozilla_extensions_MatchGlob_h + +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/dom/MatchGlobBinding.h" + +#include "jspubtd.h" +#include "js/RootingAPI.h" + +#include "mozilla/RustRegex.h" +#include "nsCOMPtr.h" +#include "nsCycleCollectionParticipant.h" +#include "nsISupports.h" +#include "nsWrapperCache.h" + +namespace mozilla { +namespace extensions { + +class MatchPattern; + +class MatchGlobCore final { + public: + NS_INLINE_DECL_THREADSAFE_REFCOUNTING(MatchGlobCore) + + MatchGlobCore(const nsACString& aGlob, bool aAllowQuestion, bool aIsPathGlob, + ErrorResult& aRv); + + bool Matches(const nsACString& aString) const; + + bool IsWildcard() const { return mIsPrefix && mPathLiteral.IsEmpty(); } + + void GetGlob(nsACString& aGlob) const { aGlob = mGlob; } + + private: + ~MatchGlobCore() = default; + + // The original glob string that this glob object represents. + const nsCString mGlob; + + // The literal path string to match against. If this contains a non-void + // value, the glob matches against this exact literal string, rather than + // performng a pattern match. If mIsPrefix is true, the literal must appear + // at the start of the matched string. If it is false, the the literal must + // be exactly equal to the matched string. + nsCString mPathLiteral; + bool mIsPrefix = false; + + // The regular expression object which is equivalent to this glob pattern. + // Used for matching if, and only if, mPathLiteral is non-void. + RustRegex mRegExp; +}; + +class MatchGlob final : public nsISupports, public nsWrapperCache { + public: + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(MatchGlob) + + static already_AddRefed<MatchGlob> Constructor(dom::GlobalObject& aGlobal, + const nsACString& aGlob, + bool aAllowQuestion, + ErrorResult& aRv); + + explicit MatchGlob(nsISupports* aParent, + already_AddRefed<MatchGlobCore> aCore) + : mParent(aParent), mCore(std::move(aCore)) {} + + bool Matches(const nsACString& aString) const { + return Core()->Matches(aString); + } + + bool IsWildcard() const { return Core()->IsWildcard(); } + + void GetGlob(nsACString& aGlob) const { Core()->GetGlob(aGlob); } + + MatchGlobCore* Core() const { return mCore; } + + nsISupports* GetParentObject() const { return mParent; } + + virtual JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + private: + ~MatchGlob() = default; + + nsCOMPtr<nsISupports> mParent; + + RefPtr<MatchGlobCore> mCore; +}; + +class MatchGlobSet final : public CopyableTArray<RefPtr<MatchGlobCore>> { + public: + // Note: We can't use the nsTArray constructors directly, since the static + // analyzer doesn't handle their MOZ_IMPLICIT annotations correctly. + MatchGlobSet() = default; + explicit MatchGlobSet(size_type aCapacity) : CopyableTArray(aCapacity) {} + explicit MatchGlobSet(const nsTArray& aOther) : CopyableTArray(aOther) {} + MOZ_IMPLICIT MatchGlobSet(nsTArray&& aOther) + : CopyableTArray(std::move(aOther)) {} + MOZ_IMPLICIT MatchGlobSet(std::initializer_list<RefPtr<MatchGlobCore>> aIL) + : CopyableTArray(aIL) {} + + bool Matches(const nsACString& aValue) const; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_MatchGlob_h diff --git a/toolkit/components/extensions/MatchPattern.cpp b/toolkit/components/extensions/MatchPattern.cpp new file mode 100644 index 0000000000..448399bd65 --- /dev/null +++ b/toolkit/components/extensions/MatchPattern.cpp @@ -0,0 +1,777 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "mozilla/extensions/MatchPattern.h" +#include "mozilla/extensions/MatchGlob.h" + +#include "js/RegExp.h" // JS::NewUCRegExpObject, JS::ExecuteRegExpNoStatics +#include "mozilla/dom/ScriptSettings.h" +#include "mozilla/ErrorResult.h" +#include "mozilla/HoldDropJSObjects.h" +#include "mozilla/Unused.h" + +#include "nsGkAtoms.h" +#include "nsIProtocolHandler.h" +#include "nsIURL.h" +#include "nsNetUtil.h" + +namespace mozilla { +namespace extensions { + +using namespace mozilla::dom; + +/***************************************************************************** + * AtomSet + *****************************************************************************/ + +template <typename Range, typename AsAtom> +static AtomSet::ArrayType AtomSetFromRange(Range&& aRange, + AsAtom&& aTransform) { + AtomSet::ArrayType atoms; + atoms.SetCapacity(RangeSize(aRange)); + std::transform(aRange.begin(), aRange.end(), MakeBackInserter(atoms), + std::forward<AsAtom>(aTransform)); + + atoms.Sort(); + + nsAtom* prev = nullptr; + atoms.RemoveElementsBy([&prev](const RefPtr<nsAtom>& aAtom) { + bool remove = aAtom == prev; + prev = aAtom; + return remove; + }); + + atoms.Compact(); + return atoms; +} + +AtomSet::AtomSet(const nsTArray<nsString>& aElems) + : mElems(AtomSetFromRange( + aElems, [](const nsString& elem) { return NS_Atomize(elem); })) {} + +AtomSet::AtomSet(std::initializer_list<nsAtom*> aIL) + : mElems(AtomSetFromRange(aIL, [](nsAtom* elem) { return elem; })) {} + +bool AtomSet::Intersects(const AtomSet& aOther) const { + for (const auto& atom : *this) { + if (aOther.Contains(atom)) { + return true; + } + } + for (const auto& atom : aOther) { + if (Contains(atom)) { + return true; + } + } + return false; +} + +/***************************************************************************** + * URLInfo + *****************************************************************************/ + +nsAtom* URLInfo::Scheme() const { + if (!mScheme) { + nsCString scheme; + if (NS_SUCCEEDED(mURI->GetScheme(scheme))) { + mScheme = NS_AtomizeMainThread(NS_ConvertASCIItoUTF16(scheme)); + } + } + return mScheme; +} + +const nsCString& URLInfo::Host() const { + if (mHost.IsVoid()) { + Unused << mURI->GetHost(mHost); + } + return mHost; +} + +const nsAtom* URLInfo::HostAtom() const { + if (!mHostAtom) { + mHostAtom = NS_Atomize(Host()); + } + return mHostAtom; +} + +const nsCString& URLInfo::FilePath() const { + if (mFilePath.IsEmpty()) { + nsCOMPtr<nsIURL> url = do_QueryInterface(mURI); + if (!url || NS_FAILED(url->GetFilePath(mFilePath))) { + mFilePath = Path(); + } + } + return mFilePath; +} + +const nsCString& URLInfo::Path() const { + if (mPath.IsEmpty()) { + if (NS_FAILED(URINoRef()->GetPathQueryRef(mPath))) { + mPath.Truncate(); + } + } + return mPath; +} + +const nsCString& URLInfo::CSpec() const { + if (mCSpec.IsEmpty()) { + Unused << URINoRef()->GetSpec(mCSpec); + } + return mCSpec; +} + +const nsString& URLInfo::Spec() const { + if (mSpec.IsEmpty()) { + AppendUTF8toUTF16(CSpec(), mSpec); + } + return mSpec; +} + +nsIURI* URLInfo::URINoRef() const { + if (!mURINoRef) { + if (NS_FAILED(NS_GetURIWithoutRef(mURI, getter_AddRefs(mURINoRef)))) { + mURINoRef = mURI; + } + } + return mURINoRef; +} + +bool URLInfo::InheritsPrincipal() const { + if (!mInheritsPrincipal.isSome()) { + // For our purposes, about:blank and about:srcdoc are treated as URIs that + // inherit principals. + bool inherits = Spec().EqualsLiteral("about:blank") || + Spec().EqualsLiteral("about:srcdoc"); + + if (!inherits) { + nsresult rv = NS_URIChainHasFlags( + mURI, nsIProtocolHandler::URI_INHERITS_SECURITY_CONTEXT, &inherits); + Unused << NS_WARN_IF(NS_FAILED(rv)); + } + + mInheritsPrincipal.emplace(inherits); + } + return mInheritsPrincipal.ref(); +} + +/***************************************************************************** + * CookieInfo + *****************************************************************************/ + +bool CookieInfo::IsDomain() const { + if (mIsDomain.isNothing()) { + mIsDomain.emplace(false); + MOZ_ALWAYS_SUCCEEDS(mCookie->GetIsDomain(mIsDomain.ptr())); + } + return mIsDomain.ref(); +} + +bool CookieInfo::IsSecure() const { + if (mIsSecure.isNothing()) { + mIsSecure.emplace(false); + MOZ_ALWAYS_SUCCEEDS(mCookie->GetIsSecure(mIsSecure.ptr())); + } + return mIsSecure.ref(); +} + +const nsCString& CookieInfo::Host() const { + if (mHost.IsEmpty()) { + MOZ_ALWAYS_SUCCEEDS(mCookie->GetHost(mHost)); + } + return mHost; +} + +const nsCString& CookieInfo::RawHost() const { + if (mRawHost.IsEmpty()) { + MOZ_ALWAYS_SUCCEEDS(mCookie->GetRawHost(mRawHost)); + } + return mRawHost; +} + +/***************************************************************************** + * MatchPatternCore + *****************************************************************************/ + +#define DEFINE_STATIC_ATOM_SET(name, ...) \ + static already_AddRefed<AtomSet> name() { \ + MOZ_ASSERT(NS_IsMainThread()); \ + static StaticRefPtr<AtomSet> sAtomSet; \ + RefPtr<AtomSet> atomSet = sAtomSet; \ + if (!atomSet) { \ + atomSet = sAtomSet = new AtomSet{__VA_ARGS__}; \ + ClearOnShutdown(&sAtomSet); \ + } \ + return atomSet.forget(); \ + } + +DEFINE_STATIC_ATOM_SET(PermittedSchemes, nsGkAtoms::http, nsGkAtoms::https, + nsGkAtoms::ws, nsGkAtoms::wss, nsGkAtoms::file, + nsGkAtoms::ftp, nsGkAtoms::data); + +// Known schemes that are followed by "://" instead of ":". +DEFINE_STATIC_ATOM_SET(HostLocatorSchemes, nsGkAtoms::http, nsGkAtoms::https, + nsGkAtoms::ws, nsGkAtoms::wss, nsGkAtoms::file, + nsGkAtoms::ftp, nsGkAtoms::moz_extension, + nsGkAtoms::chrome, nsGkAtoms::resource, nsGkAtoms::moz, + nsGkAtoms::moz_icon, nsGkAtoms::moz_gio); + +DEFINE_STATIC_ATOM_SET(WildcardSchemes, nsGkAtoms::http, nsGkAtoms::https, + nsGkAtoms::ws, nsGkAtoms::wss); + +#undef DEFINE_STATIC_ATOM_SET + +MatchPatternCore::MatchPatternCore(const nsAString& aPattern, bool aIgnorePath, + bool aRestrictSchemes, ErrorResult& aRv) { + MOZ_ASSERT(NS_IsMainThread()); + RefPtr<AtomSet> permittedSchemes = PermittedSchemes(); + + mPattern = aPattern; + + if (aPattern.EqualsLiteral("<all_urls>")) { + mSchemes = permittedSchemes; + mMatchSubdomain = true; + return; + } + + // The portion of the URL we're currently examining. + uint32_t offset = 0; + auto tail = Substring(aPattern, offset); + + /*************************************************************************** + * Scheme + ***************************************************************************/ + int32_t index = aPattern.FindChar(':'); + if (index <= 0) { + aRv.Throw(NS_ERROR_INVALID_ARG); + return; + } + + RefPtr<nsAtom> scheme = NS_AtomizeMainThread(StringHead(aPattern, index)); + bool requireHostLocatorScheme = true; + if (scheme == nsGkAtoms::_asterisk) { + mSchemes = WildcardSchemes(); + } else if (!aRestrictSchemes || permittedSchemes->Contains(scheme) || + scheme == nsGkAtoms::moz_extension) { + RefPtr<AtomSet> hostLocatorSchemes = HostLocatorSchemes(); + mSchemes = new AtomSet({scheme}); + requireHostLocatorScheme = hostLocatorSchemes->Contains(scheme); + } else { + aRv.Throw(NS_ERROR_INVALID_ARG); + return; + } + + /*************************************************************************** + * Host + ***************************************************************************/ + offset = index + 1; + tail.Rebind(aPattern, offset); + + if (!requireHostLocatorScheme) { + // Unrecognized schemes and some schemes such as about: and data: URIs + // don't have hosts, so just match on the path. + // And so, ignorePath doesn't make sense for these matchers. + aIgnorePath = false; + } else { + if (!StringHead(tail, 2).EqualsLiteral("//")) { + aRv.Throw(NS_ERROR_INVALID_ARG); + return; + } + + offset += 2; + tail.Rebind(aPattern, offset); + index = tail.FindChar('/'); + if (index < 0) { + index = tail.Length(); + } + + auto host = StringHead(tail, index); + if (host.IsEmpty() && scheme != nsGkAtoms::file) { + aRv.Throw(NS_ERROR_INVALID_ARG); + return; + } + + offset += index; + tail.Rebind(aPattern, offset); + + if (host.EqualsLiteral("*")) { + mMatchSubdomain = true; + } else if (StringHead(host, 2).EqualsLiteral("*.")) { + CopyUTF16toUTF8(Substring(host, 2), mDomain); + mMatchSubdomain = true; + } else if (host.Length() > 1 && host[0] == '[' && + host[host.Length() - 1] == ']') { + // This is an IPv6 literal, we drop the enclosing `[]` to be + // consistent with nsIURI. + CopyUTF16toUTF8(Substring(host, 1, host.Length() - 2), mDomain); + } else { + CopyUTF16toUTF8(host, mDomain); + } + } + + /*************************************************************************** + * Path + ***************************************************************************/ + if (aIgnorePath) { + mPattern.Truncate(offset); + mPattern.AppendLiteral("/*"); + return; + } + + NS_ConvertUTF16toUTF8 path(tail); + if (path.IsEmpty()) { + aRv.Throw(NS_ERROR_INVALID_ARG); + return; + } + + // Anything matched against one of the hosts in hostLocatorSchemes is expected + // to have a path starting with "/". Pass isPathGlob=true in these cases to + // ensure that MatchGlobCore treats "/*" paths as a wildcard (IsWildcard()). + bool isPathGlob = requireHostLocatorScheme; + mPath = new MatchGlobCore(path, false, isPathGlob, aRv); +} + +bool MatchPatternCore::MatchesAllWebUrls() const { + // Returns true if the match pattern matches any http(s) URL, i.e.: + // - ["<all_urls>"] + // - ["*://*/*"] + return (mSchemes->Contains(nsGkAtoms::http) && + MatchesAllUrlsWithScheme(nsGkAtoms::https)); +} + +bool MatchPatternCore::MatchesAllUrlsWithScheme(const nsAtom* scheme) const { + return (mSchemes->Contains(scheme) && DomainIsWildcard() && + (!mPath || mPath->IsWildcard())); +} + +bool MatchPatternCore::MatchesDomain(const nsACString& aDomain) const { + if (DomainIsWildcard() || mDomain == aDomain) { + return true; + } + + if (mMatchSubdomain) { + int64_t offset = (int64_t)aDomain.Length() - mDomain.Length(); + if (offset > 0 && aDomain[offset - 1] == '.' && + Substring(aDomain, offset) == mDomain) { + return true; + } + } + + return false; +} + +bool MatchPatternCore::Matches(const nsAString& aURL, bool aExplicit, + ErrorResult& aRv) const { + nsCOMPtr<nsIURI> uri; + nsresult rv = NS_NewURI(getter_AddRefs(uri), aURL); + if (NS_FAILED(rv)) { + aRv.Throw(rv); + return false; + } + + return Matches(uri.get(), aExplicit); +} + +bool MatchPatternCore::Matches(const URLInfo& aURL, bool aExplicit) const { + if (aExplicit && mMatchSubdomain) { + return false; + } + + if (!mSchemes->Contains(aURL.Scheme())) { + return false; + } + + if (!MatchesDomain(aURL.Host())) { + return false; + } + + if (mPath && !mPath->IsWildcard() && !mPath->Matches(aURL.Path())) { + return false; + } + + return true; +} + +bool MatchPatternCore::MatchesCookie(const CookieInfo& aCookie) const { + if (!mSchemes->Contains(nsGkAtoms::https) && + (aCookie.IsSecure() || !mSchemes->Contains(nsGkAtoms::http))) { + return false; + } + + if (MatchesDomain(aCookie.RawHost())) { + return true; + } + + if (!aCookie.IsDomain()) { + return false; + } + + // Things get tricker for domain cookies. The extension needs to be able + // to read any cookies that could be read by any host it has permissions + // for. This means that our normal host matching checks won't work, + // since the pattern "*://*.foo.example.com/" doesn't match ".example.com", + // but it does match "bar.foo.example.com", which can read cookies + // with the domain ".example.com". + // + // So, instead, we need to manually check our filters, and accept any + // with hosts that end with our cookie's host. + + auto& host = aCookie.Host(); + return StringTail(mDomain, host.Length()) == host; +} + +bool MatchPatternCore::SubsumesDomain(const MatchPatternCore& aPattern) const { + if (!mMatchSubdomain && aPattern.mMatchSubdomain && + aPattern.mDomain == mDomain) { + return false; + } + + return MatchesDomain(aPattern.mDomain); +} + +bool MatchPatternCore::Subsumes(const MatchPatternCore& aPattern) const { + for (auto& scheme : *aPattern.mSchemes) { + if (!mSchemes->Contains(scheme)) { + return false; + } + } + + return SubsumesDomain(aPattern); +} + +bool MatchPatternCore::Overlaps(const MatchPatternCore& aPattern) const { + if (!mSchemes->Intersects(*aPattern.mSchemes)) { + return false; + } + + return SubsumesDomain(aPattern) || aPattern.SubsumesDomain(*this); +} + +/***************************************************************************** + * MatchPattern + *****************************************************************************/ + +/* static */ +already_AddRefed<MatchPattern> MatchPattern::Constructor( + dom::GlobalObject& aGlobal, const nsAString& aPattern, + const MatchPatternOptions& aOptions, ErrorResult& aRv) { + RefPtr<MatchPattern> pattern = new MatchPattern( + aGlobal.GetAsSupports(), + MakeAndAddRef<MatchPatternCore>(aPattern, aOptions.mIgnorePath, + aOptions.mRestrictSchemes, aRv)); + if (aRv.Failed()) { + return nullptr; + } + return pattern.forget(); +} + +JSObject* MatchPattern::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return MatchPattern_Binding::Wrap(aCx, this, aGivenProto); +} + +/* static */ +bool MatchPattern::MatchesAllURLs(const URLInfo& aURL) { + RefPtr<AtomSet> permittedSchemes = PermittedSchemes(); + return permittedSchemes->Contains(aURL.Scheme()); +} + +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(MatchPattern, mParent) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(MatchPattern) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(MatchPattern) +NS_IMPL_CYCLE_COLLECTING_RELEASE(MatchPattern) + +bool MatchPatternSetCore::Matches(const nsAString& aURL, bool aExplicit, + ErrorResult& aRv) const { + nsCOMPtr<nsIURI> uri; + nsresult rv = NS_NewURI(getter_AddRefs(uri), aURL); + if (NS_FAILED(rv)) { + aRv.Throw(rv); + return false; + } + + return Matches(uri.get(), aExplicit); +} + +bool MatchPatternSetCore::Matches(const URLInfo& aURL, bool aExplicit) const { + for (const auto& pattern : mPatterns) { + if (pattern->Matches(aURL, aExplicit)) { + return true; + } + } + return false; +} + +bool MatchPatternSetCore::MatchesAllWebUrls() const { + // Returns true if the match pattern matches any http(s) URL, i.e.: + // - ["<all_urls>"] + // - ["*://*/*"] + // - ["https://*/*", "http://*/*"] + bool hasHttp = false; + bool hasHttps = false; + for (const auto& pattern : mPatterns) { + if (!hasHttp && pattern->MatchesAllUrlsWithScheme(nsGkAtoms::http)) { + hasHttp = true; + } + if (!hasHttps && pattern->MatchesAllUrlsWithScheme(nsGkAtoms::https)) { + hasHttps = true; + } + if (hasHttp && hasHttps) { + return true; + } + } + return false; +} + +bool MatchPatternSetCore::MatchesCookie(const CookieInfo& aCookie) const { + for (const auto& pattern : mPatterns) { + if (pattern->MatchesCookie(aCookie)) { + return true; + } + } + return false; +} + +bool MatchPatternSetCore::Subsumes(const MatchPatternCore& aPattern) const { + // Note: the implementation below assumes that a pattern can only be subsumed + // if it is fully contained within another pattern. Logically, this is an + // incorrect assumption: "*://example.com/" matches multiple schemes, and is + // equivalent to a MatchPatternSet that lists all schemes explicitly. + // TODO bug 1856380: account for all patterns if aPattern has a wildcard + // scheme (such as when aPattern.MatchesAllWebUrls() is true). + for (const auto& pattern : mPatterns) { + if (pattern->Subsumes(aPattern)) { + return true; + } + } + return false; +} + +bool MatchPatternSetCore::SubsumesDomain( + const MatchPatternCore& aPattern) const { + for (const auto& pattern : mPatterns) { + if (pattern->SubsumesDomain(aPattern)) { + return true; + } + } + return false; +} + +bool MatchPatternSetCore::Overlaps( + const MatchPatternSetCore& aPatternSet) const { + for (const auto& pattern : aPatternSet.mPatterns) { + if (Overlaps(*pattern)) { + return true; + } + } + return false; +} + +bool MatchPatternSetCore::Overlaps(const MatchPatternCore& aPattern) const { + for (const auto& pattern : mPatterns) { + if (pattern->Overlaps(aPattern)) { + return true; + } + } + return false; +} + +bool MatchPatternSetCore::OverlapsAll( + const MatchPatternSetCore& aPatternSet) const { + for (const auto& pattern : aPatternSet.mPatterns) { + if (!Overlaps(*pattern)) { + return false; + } + } + return aPatternSet.mPatterns.Length() > 0; +} + +/***************************************************************************** + * MatchPatternSet + *****************************************************************************/ + +/* static */ +already_AddRefed<MatchPatternSet> MatchPatternSet::Constructor( + dom::GlobalObject& aGlobal, + const nsTArray<dom::OwningStringOrMatchPattern>& aPatterns, + const MatchPatternOptions& aOptions, ErrorResult& aRv) { + MatchPatternSetCore::ArrayType patterns; + + for (auto& elem : aPatterns) { + if (elem.IsMatchPattern()) { + patterns.AppendElement(elem.GetAsMatchPattern()->Core()); + } else { + RefPtr<MatchPatternCore> pattern = + new MatchPatternCore(elem.GetAsString(), aOptions.mIgnorePath, + aOptions.mRestrictSchemes, aRv); + + if (aRv.Failed()) { + return nullptr; + } + patterns.AppendElement(std::move(pattern)); + } + } + + RefPtr<MatchPatternSet> patternSet = new MatchPatternSet( + aGlobal.GetAsSupports(), + do_AddRef(new MatchPatternSetCore(std::move(patterns)))); + return patternSet.forget(); +} + +void MatchPatternSet::GetPatterns(ArrayType& aPatterns) { + if (!mPatternsCache) { + mPatternsCache.emplace(Core()->mPatterns.Length()); + for (auto& elem : Core()->mPatterns) { + mPatternsCache->AppendElement(new MatchPattern(this, do_AddRef(elem))); + } + } + aPatterns.AppendElements(*mPatternsCache); +} + +JSObject* MatchPatternSet::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return MatchPatternSet_Binding::Wrap(aCx, this, aGivenProto); +} + +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(MatchPatternSet, mPatternsCache, mParent) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(MatchPatternSet) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(MatchPatternSet) +NS_IMPL_CYCLE_COLLECTING_RELEASE(MatchPatternSet) + +/***************************************************************************** + * MatchGlobCore + *****************************************************************************/ + +MatchGlobCore::MatchGlobCore(const nsACString& aGlob, bool aAllowQuestion, + bool aIsPathGlob, ErrorResult& aRv) + : mGlob(aGlob) { + // Check for a literal match with no glob metacharacters. + auto index = mGlob.FindCharInSet(aAllowQuestion ? "*?" : "*"); + if (index < 0) { + mPathLiteral = mGlob; + return; + } + + // Check for a prefix match, where the only glob metacharacter is a "*" + // at the end of the string (or a sequence of it). + for (int32_t i = mGlob.Length() - 1; i >= index && mGlob[i] == '*'; --i) { + if (i == index) { + mPathLiteral = StringHead(mGlob, index); + if (aIsPathGlob && mPathLiteral.EqualsLiteral("/")) { + // Ensure that IsWildcard() correctly treats us as a wildcard. + mPathLiteral.Truncate(); + } + mIsPrefix = true; + return; + } + } + + // Fall back to the regexp slow path. + constexpr auto metaChars = ".+*?^${}()|[]\\"_ns; + + nsAutoCString escaped; + escaped.Append('^'); + + // For any continuous string of * (and ? if aAllowQuestion) wildcards, only + // emit the first *, later ones are redundant, and can hang regex matching. + bool emittedFirstStar = false; + + for (uint32_t i = 0; i < mGlob.Length(); i++) { + auto c = mGlob[i]; + if (c == '*') { + if (!emittedFirstStar) { + escaped.AppendLiteral(".*"); + emittedFirstStar = true; + } + } else if (c == '?' && aAllowQuestion) { + escaped.Append('.'); + } else { + if (metaChars.Contains(c)) { + escaped.Append('\\'); + } + escaped.Append(c); + + // String of wildcards broken by a non-wildcard char, reset tracking flag. + emittedFirstStar = false; + } + } + + escaped.Append('$'); + + mRegExp = RustRegex(escaped); + if (!mRegExp) { + aRv.ThrowTypeError("failed to compile regex for glob"); + } +} + +bool MatchGlobCore::Matches(const nsACString& aString) const { + if (mRegExp) { + return mRegExp.IsMatch(aString); + } + + if (mIsPrefix) { + return mPathLiteral == StringHead(aString, mPathLiteral.Length()); + } + + return mPathLiteral == aString; +} + +/***************************************************************************** + * MatchGlob + *****************************************************************************/ + +/* static */ +already_AddRefed<MatchGlob> MatchGlob::Constructor(dom::GlobalObject& aGlobal, + const nsACString& aGlob, + bool aAllowQuestion, + ErrorResult& aRv) { + RefPtr<MatchGlob> glob = new MatchGlob( + aGlobal.GetAsSupports(), + MakeAndAddRef<MatchGlobCore>(aGlob, aAllowQuestion, false, aRv)); + if (aRv.Failed()) { + return nullptr; + } + return glob.forget(); +} + +JSObject* MatchGlob::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return MatchGlob_Binding::Wrap(aCx, this, aGivenProto); +} + +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(MatchGlob, mParent) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(MatchGlob) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(MatchGlob) +NS_IMPL_CYCLE_COLLECTING_RELEASE(MatchGlob) + +/***************************************************************************** + * MatchGlobSet + *****************************************************************************/ + +bool MatchGlobSet::Matches(const nsACString& aValue) const { + for (auto& glob : *this) { + if (glob->Matches(aValue)) { + return true; + } + } + return false; +} + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/MatchPattern.h b/toolkit/components/extensions/MatchPattern.h new file mode 100644 index 0000000000..ebfd1c62a1 --- /dev/null +++ b/toolkit/components/extensions/MatchPattern.h @@ -0,0 +1,403 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_extensions_MatchPattern_h +#define mozilla_extensions_MatchPattern_h + +#include <utility> + +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/dom/MatchPatternBinding.h" +#include "mozilla/extensions/MatchGlob.h" + +#include "jspubtd.h" + +#include "mozilla/ClearOnShutdown.h" +#include "mozilla/Likely.h" +#include "mozilla/Maybe.h" +#include "mozilla/RefCounted.h" +#include "nsCOMPtr.h" +#include "nsCycleCollectionParticipant.h" +#include "nsTArray.h" +#include "nsAtom.h" +#include "nsICookie.h" +#include "nsISupports.h" +#include "nsIURI.h" +#include "nsWrapperCache.h" + +namespace mozilla { +namespace extensions { + +using dom::MatchPatternOptions; + +// A sorted, immutable, binary-search-backed set of atoms, optimized for +// frequent lookups. +class AtomSet final { + public: + using ArrayType = AutoTArray<RefPtr<nsAtom>, 1>; + + NS_INLINE_DECL_THREADSAFE_REFCOUNTING(AtomSet) + + explicit AtomSet(const nsTArray<nsString>& aElems); + + MOZ_IMPLICIT AtomSet(std::initializer_list<nsAtom*> aIL); + + bool Contains(const nsAString& elem) const { + RefPtr<nsAtom> atom = NS_Atomize(elem); + return Contains(atom); + } + + bool Contains(const nsACString& aElem) const { + RefPtr<nsAtom> atom = NS_Atomize(aElem); + return Contains(atom); + } + + bool Contains(const nsAtom* aAtom) const { + return mElems.ContainsSorted(aAtom); + } + + bool Intersects(const AtomSet& aOther) const; + + void Get(nsTArray<nsString>& aResult) const { + aResult.SetCapacity(mElems.Length()); + + for (const auto& atom : mElems) { + aResult.AppendElement(nsDependentAtomString(atom)); + } + } + + auto begin() const -> decltype(std::declval<const ArrayType>().begin()) { + return mElems.begin(); + } + + auto end() const -> decltype(std::declval<const ArrayType>().end()) { + return mElems.end(); + } + + private: + ~AtomSet() = default; + + const ArrayType mElems; +}; + +// A helper class to lazily retrieve, transcode, and atomize certain URI +// properties the first time they're used, and cache the results, so that they +// can be used across multiple match operations. +class URLInfo final { + public: + MOZ_IMPLICIT URLInfo(nsIURI* aURI) : mURI(aURI) { mHost.SetIsVoid(true); } + + URLInfo(nsIURI* aURI, bool aNoRef) : URLInfo(aURI) { + if (aNoRef) { + mURINoRef = mURI; + } + } + + URLInfo(const URLInfo& aOther) : URLInfo(aOther.mURI.get()) {} + + nsIURI* URI() const { return mURI; } + + nsAtom* Scheme() const; + const nsCString& Host() const; + const nsAtom* HostAtom() const; + const nsCString& Path() const; + const nsCString& FilePath() const; + const nsString& Spec() const; + const nsCString& CSpec() const; + + bool InheritsPrincipal() const; + + private: + nsIURI* URINoRef() const; + + nsCOMPtr<nsIURI> mURI; + mutable nsCOMPtr<nsIURI> mURINoRef; + + mutable RefPtr<nsAtom> mScheme; + mutable nsCString mHost; + mutable RefPtr<nsAtom> mHostAtom; + + mutable nsCString mPath; + mutable nsCString mFilePath; + mutable nsString mSpec; + mutable nsCString mCSpec; + + mutable Maybe<bool> mInheritsPrincipal; +}; + +// Similar to URLInfo, but for cookies. +class MOZ_STACK_CLASS CookieInfo final { + public: + MOZ_IMPLICIT CookieInfo(nsICookie* aCookie) : mCookie(aCookie) {} + + bool IsSecure() const; + bool IsDomain() const; + + const nsCString& Host() const; + const nsCString& RawHost() const; + + private: + nsCOMPtr<nsICookie> mCookie; + + mutable Maybe<bool> mIsSecure; + mutable Maybe<bool> mIsDomain; + + mutable nsCString mHost; + mutable nsCString mRawHost; +}; + +class MatchPatternCore final { + public: + NS_INLINE_DECL_THREADSAFE_REFCOUNTING(MatchPatternCore) + + // NOTE: Must be constructed on the main thread! + MatchPatternCore(const nsAString& aPattern, bool aIgnorePath, + bool aRestrictSchemes, ErrorResult& aRv); + + bool Matches(const nsAString& aURL, bool aExplicit, ErrorResult& aRv) const; + + bool Matches(const URLInfo& aURL, bool aExplicit = false) const; + + bool MatchesAllWebUrls() const; + // Helper for MatchPatternSetCore::MatchesAllWebUrls: + bool MatchesAllUrlsWithScheme(const nsAtom* aScheme) const; + + bool MatchesCookie(const CookieInfo& aCookie) const; + + bool MatchesDomain(const nsACString& aDomain) const; + + bool Subsumes(const MatchPatternCore& aPattern) const; + + bool SubsumesDomain(const MatchPatternCore& aPattern) const; + + bool Overlaps(const MatchPatternCore& aPattern) const; + + bool DomainIsWildcard() const { return mMatchSubdomain && mDomain.IsEmpty(); } + + void GetPattern(nsAString& aPattern) const { aPattern = mPattern; } + + private: + ~MatchPatternCore() = default; + + // The normalized match pattern string that this object represents. + nsString mPattern; + + // The set of atomized URI schemes that this pattern matches. + RefPtr<AtomSet> mSchemes; + + // The domain that this matcher matches. If mMatchSubdomain is false, only + // matches the exact domain. If it's true, matches the domain or any + // subdomain. + // + // For instance, "*.foo.com" gives mDomain = "foo.com" and mMatchSubdomain = + // true, and matches "foo.com" or "bar.foo.com" but not "barfoo.com". + // + // While "foo.com" gives mDomain = "foo.com" and mMatchSubdomain = false, + // and matches "foo.com" but not "bar.foo.com". + nsCString mDomain; + bool mMatchSubdomain = false; + + // The glob against which the URL path must match. If null, the path is + // ignored entirely. If non-null, the path must match this glob. + RefPtr<MatchGlobCore> mPath; +}; + +class MatchPattern final : public nsISupports, public nsWrapperCache { + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(MatchPattern) + + static already_AddRefed<MatchPattern> Constructor( + dom::GlobalObject& aGlobal, const nsAString& aPattern, + const MatchPatternOptions& aOptions, ErrorResult& aRv); + + bool Matches(const nsAString& aURL, bool aExplicit, ErrorResult& aRv) const { + return Core()->Matches(aURL, aExplicit, aRv); + } + + bool MatchesAllWebUrls() const { return Core()->MatchesAllWebUrls(); } + + bool Matches(const URLInfo& aURL, bool aExplicit = false) const { + return Core()->Matches(aURL, aExplicit); + } + + bool Matches(const URLInfo& aURL, bool aExplicit, ErrorResult& aRv) const { + return Matches(aURL, aExplicit); + } + + bool MatchesCookie(const CookieInfo& aCookie) const { + return Core()->MatchesCookie(aCookie); + } + + bool MatchesDomain(const nsACString& aDomain) const { + return Core()->MatchesDomain(aDomain); + } + + bool Subsumes(const MatchPattern& aPattern) const { + return Core()->Subsumes(*aPattern.Core()); + } + + bool SubsumesDomain(const MatchPattern& aPattern) const { + return Core()->SubsumesDomain(*aPattern.Core()); + } + + bool Overlaps(const MatchPattern& aPattern) const { + return Core()->Overlaps(*aPattern.Core()); + } + + bool DomainIsWildcard() const { return Core()->DomainIsWildcard(); } + + void GetPattern(nsAString& aPattern) const { Core()->GetPattern(aPattern); } + + MatchPatternCore* Core() const { return mCore; } + + nsISupports* GetParentObject() const { return mParent; } + + virtual JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + protected: + virtual ~MatchPattern() = default; + + private: + friend class MatchPatternSet; + + explicit MatchPattern(nsISupports* aParent, + already_AddRefed<MatchPatternCore> aCore) + : mParent(aParent), mCore(std::move(aCore)) {} + + void Init(JSContext* aCx, const nsAString& aPattern, bool aIgnorePath, + bool aRestrictSchemes, ErrorResult& aRv); + + nsCOMPtr<nsISupports> mParent; + + RefPtr<MatchPatternCore> mCore; + + public: + // A quick way to check if a particular URL matches <all_urls> without + // actually instantiating a MatchPattern + static bool MatchesAllURLs(const URLInfo& aURL); +}; + +class MatchPatternSetCore final { + public: + NS_INLINE_DECL_THREADSAFE_REFCOUNTING(MatchPatternSetCore) + + using ArrayType = nsTArray<RefPtr<MatchPatternCore>>; + + explicit MatchPatternSetCore(ArrayType&& aPatterns) + : mPatterns(std::move(aPatterns)) {} + + static already_AddRefed<MatchPatternSet> Constructor( + dom::GlobalObject& aGlobal, + const nsTArray<dom::OwningStringOrMatchPattern>& aPatterns, + const MatchPatternOptions& aOptions, ErrorResult& aRv); + + bool Matches(const nsAString& aURL, bool aExplicit, ErrorResult& aRv) const; + + bool Matches(const URLInfo& aURL, bool aExplicit = false) const; + + bool MatchesAllWebUrls() const; + + bool MatchesCookie(const CookieInfo& aCookie) const; + + bool Subsumes(const MatchPatternCore& aPattern) const; + + bool SubsumesDomain(const MatchPatternCore& aPattern) const; + + bool Overlaps(const MatchPatternCore& aPattern) const; + + bool Overlaps(const MatchPatternSetCore& aPatternSet) const; + + bool OverlapsAll(const MatchPatternSetCore& aPatternSet) const; + + void GetPatterns(ArrayType& aPatterns) { + aPatterns.AppendElements(mPatterns); + } + + private: + friend class MatchPatternSet; + + ~MatchPatternSetCore() = default; + + ArrayType mPatterns; +}; + +class MatchPatternSet final : public nsISupports, public nsWrapperCache { + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(MatchPatternSet) + + using ArrayType = nsTArray<RefPtr<MatchPattern>>; + + static already_AddRefed<MatchPatternSet> Constructor( + dom::GlobalObject& aGlobal, + const nsTArray<dom::OwningStringOrMatchPattern>& aPatterns, + const MatchPatternOptions& aOptions, ErrorResult& aRv); + + bool Matches(const nsAString& aURL, bool aExplicit, ErrorResult& aRv) const { + return Core()->Matches(aURL, aExplicit, aRv); + } + + bool Matches(const URLInfo& aURL, bool aExplicit = false) const { + return Core()->Matches(aURL, aExplicit); + } + + bool Matches(const URLInfo& aURL, bool aExplicit, ErrorResult& aRv) const { + return Matches(aURL, aExplicit); + } + + bool MatchesAllWebUrls() const { return Core()->MatchesAllWebUrls(); } + + bool MatchesCookie(const CookieInfo& aCookie) const { + return Core()->MatchesCookie(aCookie); + } + + bool Subsumes(const MatchPattern& aPattern) const { + return Core()->Subsumes(*aPattern.Core()); + } + + bool SubsumesDomain(const MatchPattern& aPattern) const { + return Core()->SubsumesDomain(*aPattern.Core()); + } + + bool Overlaps(const MatchPattern& aPattern) const { + return Core()->Overlaps(*aPattern.Core()); + } + + bool Overlaps(const MatchPatternSet& aPatternSet) const { + return Core()->Overlaps(*aPatternSet.Core()); + } + + bool OverlapsAll(const MatchPatternSet& aPatternSet) const { + return Core()->OverlapsAll(*aPatternSet.Core()); + } + + void GetPatterns(ArrayType& aPatterns); + + MatchPatternSetCore* Core() const { return mCore; } + + nsISupports* GetParentObject() const { return mParent; } + + virtual JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + protected: + virtual ~MatchPatternSet() = default; + + private: + explicit MatchPatternSet(nsISupports* aParent, + already_AddRefed<MatchPatternSetCore> aCore) + : mParent(aParent), mCore(std::move(aCore)) {} + + nsCOMPtr<nsISupports> mParent; + + RefPtr<MatchPatternSetCore> mCore; + + mozilla::Maybe<ArrayType> mPatternsCache; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_MatchPattern_h diff --git a/toolkit/components/extensions/MatchURLFilters.sys.mjs b/toolkit/components/extensions/MatchURLFilters.sys.mjs new file mode 100644 index 0000000000..22060151e7 --- /dev/null +++ b/toolkit/components/extensions/MatchURLFilters.sys.mjs @@ -0,0 +1,174 @@ +/* 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/. */ + +// Match WebNavigation URL Filters. +export class MatchURLFilters { + constructor(filters) { + if (!Array.isArray(filters)) { + throw new TypeError("filters should be an array"); + } + + if (!filters.length) { + throw new Error("filters array should not be empty"); + } + + this.filters = filters; + } + + matches(url) { + let uri = Services.io.newURI(url); + // Set uriURL to an empty object (needed because some schemes, e.g. about doesn't support nsIURL). + let uriURL = {}; + if (uri instanceof Ci.nsIURL) { + uriURL = uri; + } + + // Set host to a empty string by default (needed so that schemes without an host, + // e.g. about, can pass an empty string for host based event filtering as expected). + let host = ""; + try { + host = uri.host; + } catch (e) { + // 'uri.host' throws an exception with some uri schemes (e.g. about). + } + + let port; + try { + port = uri.port; + } catch (e) { + // 'uri.port' throws an exception with some uri schemes (e.g. about), + // in which case it will be |undefined|. + } + + let data = { + // NOTE: This properties are named after the name of their related + // filters (e.g. `pathContains/pathEquals/...` will be tested against the + // `data.path` property, and the same is done for the `host`, `query` and `url` + // components as well). + path: uriURL.filePath, + query: uriURL.query, + host, + port, + url, + }; + + // If any of the filters matches, matches returns true. + return this.filters.some(filter => + this.matchURLFilter({ filter, data, uri, uriURL }) + ); + } + + matchURLFilter({ filter, data, uri, uriURL }) { + // Test for scheme based filtering. + if (filter.schemes) { + // Return false if none of the schemes matches. + if (!filter.schemes.some(scheme => uri.schemeIs(scheme))) { + return false; + } + } + + // Test for exact port matching or included in a range of ports. + if (filter.ports) { + let port = data.port; + if (port === -1) { + // NOTE: currently defaultPort for "resource" and "chrome" schemes defaults to -1, + // for "about", "data" and "javascript" schemes defaults to undefined. + if (["resource", "chrome"].includes(uri.scheme)) { + port = undefined; + } else { + port = Services.io.getDefaultPort(uri.scheme); + } + } + + // Return false if none of the ports (or port ranges) is verified + const portMatch = filter.ports.some(filterPort => { + if (Array.isArray(filterPort)) { + let [lower, upper] = filterPort; + return port >= lower && port <= upper; + } + + return port === filterPort; + }); + + if (!portMatch) { + return false; + } + } + + // Filters on host, url, path, query: + // hostContains, hostEquals, hostSuffix, hostPrefix, + // urlContains, urlEquals, ... + for (let urlComponent of ["host", "path", "query", "url"]) { + if (!this.testMatchOnURLComponent({ urlComponent, data, filter })) { + return false; + } + } + + // urlMatches is a regular expression string and it is tested for matches + // on the "url without the ref". + if (filter.urlMatches) { + let urlWithoutRef = uri.specIgnoringRef; + if (!urlWithoutRef.match(filter.urlMatches)) { + return false; + } + } + + // originAndPathMatches is a regular expression string and it is tested for matches + // on the "url without the query and the ref". + if (filter.originAndPathMatches) { + let urlWithoutQueryAndRef = uri.resolve(uriURL.filePath); + // The above 'uri.resolve(...)' will be null for some URI schemes + // (e.g. about). + // TODO: handle schemes which will not be able to resolve the filePath + // (e.g. for "about:blank", 'urlWithoutQueryAndRef' should be "about:blank" instead + // of null) + if ( + !urlWithoutQueryAndRef || + !urlWithoutQueryAndRef.match(filter.originAndPathMatches) + ) { + return false; + } + } + + return true; + } + + testMatchOnURLComponent({ urlComponent: key, data, filter }) { + // Test for equals. + // NOTE: an empty string should not be considered a filter to skip. + if (filter[`${key}Equals`] != null) { + if (data[key] !== filter[`${key}Equals`]) { + return false; + } + } + + // Test for contains. + if (filter[`${key}Contains`]) { + let value = (key == "host" ? "." : "") + data[key]; + if (!data[key] || !value.includes(filter[`${key}Contains`])) { + return false; + } + } + + // Test for prefix. + if (filter[`${key}Prefix`]) { + if (!data[key] || !data[key].startsWith(filter[`${key}Prefix`])) { + return false; + } + } + + // Test for suffix. + if (filter[`${key}Suffix`]) { + if (!data[key] || !data[key].endsWith(filter[`${key}Suffix`])) { + return false; + } + } + + return true; + } + + serialize() { + return this.filters; + } +} diff --git a/toolkit/components/extensions/MessageChannel.sys.mjs b/toolkit/components/extensions/MessageChannel.sys.mjs new file mode 100644 index 0000000000..65ab2720aa --- /dev/null +++ b/toolkit/components/extensions/MessageChannel.sys.mjs @@ -0,0 +1,1168 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +// @ts-nocheck TODO bug 1580774: Remove this file and its uses. + +/** + * This module provides wrappers around standard message managers to + * simplify bidirectional communication. It currently allows a caller to + * send a message to a single listener, and receive a reply. If there + * are no matching listeners, or the message manager disconnects before + * a reply is received, the caller is returned an error. + * + * The listener end may specify filters for the messages it wishes to + * receive, and the sender end likewise may specify recipient tags to + * match the filters. + * + * The message handler on the listener side may return its response + * value directly, or may return a promise, the resolution or rejection + * of which will be returned instead. The sender end likewise receives a + * promise which resolves or rejects to the listener's response. + * + * + * A basic setup works something like this: + * + * A content script adds a message listener to its global + * ContentFrameMessageManager, with an appropriate set of filters: + * + * { + * init(messageManager, window, extensionID) { + * this.window = window; + * + * MessageChannel.addListener( + * messageManager, "ContentScript:TouchContent", + * this); + * + * this.messageFilterStrict = { + * innerWindowID: getInnerWindowID(window), + * extensionID: extensionID, + * }; + * + * this.messageFilterPermissive = { + * outerWindowID: getOuterWindowID(window), + * }; + * }, + * + * receiveMessage({ target, messageName, sender, recipient, data }) { + * if (messageName == "ContentScript:TouchContent") { + * return new Promise(resolve => { + * this.touchWindow(data.touchWith, result => { + * resolve({ touchResult: result }); + * }); + * }); + * } + * }, + * }; + * + * A script in the parent process sends a message to the content process + * via a tab message manager, including recipient tags to match its + * filter, and an optional sender tag to identify itself: + * + * let data = { touchWith: "pencil" }; + * let sender = { extensionID, contextID }; + * let recipient = { innerWindowID: tab.linkedBrowser.innerWindowID, extensionID }; + * + * MessageChannel.sendMessage( + * tab.linkedBrowser.messageManager, "ContentScript:TouchContent", + * data, {recipient, sender} + * ).then(result => { + * alert(result.touchResult); + * }); + * + * Since the lifetimes of message senders and receivers may not always + * match, either side of the message channel may cancel pending + * responses which match its sender or recipient tags. + * + * For the above client, this might be done from an + * inner-window-destroyed observer, when its target scope is destroyed: + * + * observe(subject, topic, data) { + * if (topic == "inner-window-destroyed") { + * let innerWindowID = subject.QueryInterface(Ci.nsISupportsPRUint64).data; + * + * MessageChannel.abortResponses({ innerWindowID }); + * } + * }, + * + * From the parent, it may be done when its context is being destroyed: + * + * onDestroy() { + * MessageChannel.abortResponses({ + * extensionID: this.extensionID, + * contextID: this.contextID, + * }); + * }, + * + */ + +export let MessageChannel; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + MessageManagerProxy: "resource://gre/modules/MessageManagerProxy.sys.mjs", +}); + +function getMessageManager(target) { + if (typeof target.sendAsyncMessage === "function") { + return target; + } + return new lazy.MessageManagerProxy(target); +} + +function matches(target, messageManager) { + return target === messageManager || target.messageManager === messageManager; +} + +const { DEBUG } = AppConstants; + +// Idle callback timeout for low-priority message dispatch. +const LOW_PRIORITY_TIMEOUT_MS = 250; + +const MESSAGE_MESSAGES = "MessageChannel:Messages"; +const MESSAGE_RESPONSE = "MessageChannel:Response"; + +var _deferredResult; +var _makeDeferred = (resolve, reject) => { + // We use arrow functions here and refer to the outer variables via + // `this`, to avoid a lexical name lookup. Yes, it makes a difference. + // No, I don't like it any more than you do. + _deferredResult.resolve = resolve; + _deferredResult.reject = reject; +}; + +/** + * Helper to create a new Promise without allocating any closures to + * receive its resolution functions. + * + * I know what you're thinking: "This is crazy. There is no possible way + * this can be necessary. Just use the ordinary Promise constructor the + * way it was meant to be used, you lunatic." + * + * And, against all odds, it turns out that you're wrong. Creating + * lambdas to receive promise resolution functions consistently turns + * out to be one of the most expensive parts of message dispatch in this + * code. + * + * So we do the stupid micro-optimization, and try to live with + * ourselves for it. + * + * (See also bug 1404950.) + * + * @returns {object} + */ +let Deferred = () => { + let res = {}; + _deferredResult = res; + res.promise = new Promise(_makeDeferred); + _deferredResult = null; + return res; +}; + +/** + * Handles the mapping and dispatching of messages to their registered + * handlers. There is one broker per message manager and class of + * messages. Each class of messages is mapped to one native message + * name, e.g., "MessageChannel:Message", and is dispatched to handlers + * based on an internal message name, e.g., "Extension:ExecuteScript". + */ +class FilteringMessageManager { + /** + * @param {string} messageName + * The name of the native message this broker listens for. + * @param {Function} callback + * A function which is called for each message after it has been + * mapped to its handler. The function receives two arguments: + * + * result: + * An object containing either a `handler` or an `error` property. + * If no error occurs, `handler` will be a matching handler that + * was registered by `addHandler`. Otherwise, the `error` property + * will contain an object describing the error. + * + * data: + * An object describing the message, as defined in + * `MessageChannel.addListener`. + * @param {nsIMessageListenerManager} messageManager + */ + constructor(messageName, callback, messageManager) { + this.messageName = messageName; + this.callback = callback; + this.messageManager = messageManager; + + this.messageManager.addMessageListener(this.messageName, this, true); + + this.handlers = new Map(); + } + + /** + * Receives a set of messages from our message manager, maps each to a + * handler, and passes the results to our message callbacks. + */ + receiveMessage({ data, target }) { + data.forEach(msg => { + if (msg) { + let handlers = Array.from( + this.getHandlers(msg.messageName, msg.sender || null, msg.recipient) + ); + + msg.target = target; + this.callback(handlers, msg); + } + }); + } + + /** + * Iterates over all handlers for the given message name. If `recipient` + * is provided, only iterates over handlers whose filters match it. + * + * @param {string|number} messageName + * The message for which to return handlers. + * @param {object} sender + * The sender data on which to filter handlers. + * @param {object} recipient + * The recipient data on which to filter handlers. + */ + *getHandlers(messageName, sender, recipient) { + let handlers = this.handlers.get(messageName) || new Set(); + for (let handler of handlers) { + if ( + MessageChannel.matchesFilter( + handler.messageFilterStrict || null, + recipient + ) && + MessageChannel.matchesFilter( + handler.messageFilterPermissive || null, + recipient, + false + ) && + (!handler.filterMessage || handler.filterMessage(sender, recipient)) + ) { + yield handler; + } + } + } + + /** + * Registers a handler for the given message. + * + * @param {string} messageName + * The internal message name for which to register the handler. + * @param {object} handler + * An opaque handler object. The object may have a + * `messageFilterStrict` and/or a `messageFilterPermissive` + * property and/or a `filterMessage` method on which to filter messages. + * + * Final dispatching is handled by the message callback passed to + * the constructor. + */ + addHandler(messageName, handler) { + if (!this.handlers.has(messageName)) { + this.handlers.set(messageName, new Set()); + } + + this.handlers.get(messageName).add(handler); + } + + /** + * Unregisters a handler for the given message. + * + * @param {string} messageName + * The internal message name for which to unregister the handler. + * @param {object} handler + * The handler object to unregister. + */ + removeHandler(messageName, handler) { + if (this.handlers.has(messageName)) { + this.handlers.get(messageName).delete(handler); + } + } +} + +/** + * A message dispatch and response manager that wrapse a single native + * message manager. Handles dispatching messages through the manager + * (optionally coalescing several low-priority messages and dispatching + * them during an idle slice), and mapping their responses to the + * appropriate response callbacks. + * + * Note that this is a simplified subclass of FilteringMessageManager + * that only supports one handler per message, and does not support + * filtering. + */ +class ResponseManager extends FilteringMessageManager { + constructor(messageName, callback, messageManager) { + super(messageName, callback, messageManager); + + this.idleMessages = []; + this.idleScheduled = false; + this.onIdle = this.onIdle.bind(this); + } + + /** + * Schedules a new idle callback to dispatch pending low-priority + * messages, if one is not already scheduled. + */ + scheduleIdleCallback() { + if (!this.idleScheduled) { + ChromeUtils.idleDispatch(this.onIdle, { + timeout: LOW_PRIORITY_TIMEOUT_MS, + }); + this.idleScheduled = true; + } + } + + /** + * Called when the event queue is idle, and dispatches any pending + * low-priority messages in a single chunk. + * + * @param {IdleDeadline} deadline + */ + onIdle(deadline) { + this.idleScheduled = false; + + let messages = this.idleMessages; + this.idleMessages = []; + + let msgs = messages.map(msg => msg.getMessage()); + try { + this.messageManager.sendAsyncMessage(MESSAGE_MESSAGES, msgs); + } catch (e) { + for (let msg of messages) { + msg.reject(e); + } + } + } + + /** + * Sends a message through our wrapped message manager, or schedules + * it for low-priority dispatch during an idle callback. + * + * @param {any} message + * The message to send. + * @param {object} [options] + * Message dispatch options. + * @param {boolean} [options.lowPriority = false] + * If true, dispatches the message in a single chunk with other + * low-priority messages the next time the event queue is idle. + */ + sendMessage(message, options = {}) { + if (options.lowPriority) { + this.idleMessages.push(message); + this.scheduleIdleCallback(); + } else { + this.messageManager.sendAsyncMessage(MESSAGE_MESSAGES, [ + message.getMessage(), + ]); + } + } + + receiveMessage({ data, target }) { + data.target = target; + + this.callback(this.handlers.get(data.messageName), data); + } + + *getHandlers(messageName, sender, recipient) { + let handler = this.handlers.get(messageName); + if (handler) { + yield handler; + } + } + + addHandler(messageName, handler) { + if (DEBUG && this.handlers.has(messageName)) { + throw new Error( + `Handler already registered for response ID ${messageName}` + ); + } + this.handlers.set(messageName, handler); + } + + /** + * Unregisters a handler for the given message. + * + * @param {string} messageName + * The internal message name for which to unregister the handler. + * @param {object} handler + * The handler object to unregister. + */ + removeHandler(messageName, handler) { + if (DEBUG && this.handlers.get(messageName) !== handler) { + Cu.reportError( + `Attempting to remove unexpected response handler for ${messageName}` + ); + } + this.handlers.delete(messageName); + } +} + +/** + * Manages mappings of message managers to their corresponding message + * brokers. Brokers are lazily created for each message manager the + * first time they are accessed. In the case of content frame message + * managers, they are also automatically destroyed when the frame + * unload event fires. + */ +class FilteringMessageManagerMap extends Map { + // Unfortunately, we can't use a WeakMap for this, because message + // managers do not support preserved wrappers. + + /** + * @param {string} messageName + * The native message name passed to `FilteringMessageManager` constructors. + * @param {Function} callback + * The message callback function passed to + * `FilteringMessageManager` constructors. + * @param {Function} [constructor = FilteringMessageManager] + * The constructor for the message manager class that we're + * mapping to. + */ + constructor(messageName, callback, constructor = FilteringMessageManager) { + super(); + + this.messageName = messageName; + this.callback = callback; + this._constructor = constructor; + } + + /** + * Returns, and possibly creates, a message broker for the given + * message manager. + * + * @param {nsIMessageListenerManager} target + * The message manager for which to return a broker. + * + * @returns {FilteringMessageManager} + */ + get(target) { + let broker = super.get(target); + if (broker) { + return broker; + } + + broker = new this._constructor(this.messageName, this.callback, target); + this.set(target, broker); + + // XXXbz if target is really known to be a MessageListenerManager, + // do we need this isInstance check? + if (EventTarget.isInstance(target)) { + let onUnload = event => { + target.removeEventListener("unload", onUnload); + this.delete(target); + }; + target.addEventListener("unload", onUnload); + } + + return broker; + } +} + +/** + * Represents a message being sent through a MessageChannel, which may + * or may not have been dispatched yet, and is pending a response. + * + * When a response has been received, or the message has been canceled, + * this class is responsible for settling the response promise as + * appropriate. + * + * @param {number} channelId + * The unique ID for this message. + * @param {any} message + * The message contents. + * @param {object} sender + * An object describing the sender of the message, used by + * `abortResponses` to determine whether the message should be + * aborted. + * @param {ResponseManager} broker + * The response broker on which we're expected to receive a + * reply. + */ +class PendingMessage { + constructor(channelId, message, sender, broker) { + this.channelId = channelId; + this.message = message; + this.sender = sender; + this.broker = broker; + this.deferred = Deferred(); + + MessageChannel.pendingResponses.add(this); + } + + /** + * Cleans up after this message once we've received or aborted a + * response. + */ + cleanup() { + if (this.broker) { + this.broker.removeHandler(this.channelId, this); + MessageChannel.pendingResponses.delete(this); + + this.message = null; + this.broker = null; + } + } + + /** + * Returns the promise which will resolve when we've received or + * aborted a response to this message. + */ + get promise() { + return this.deferred.promise; + } + + /** + * Resolves the message's response promise, and cleans up. + * + * @param {any} value + */ + resolve(value) { + this.cleanup(); + this.deferred.resolve(value); + } + + /** + * Rejects the message's response promise, and cleans up. + * + * @param {any} value + */ + reject(value) { + this.cleanup(); + this.deferred.reject(value); + } + + get messageManager() { + return this.broker.messageManager; + } + + /** + * Returns the contents of the message to be sent over a message + * manager, and registers the response with our response broker. + * + * Returns null if the response has already been canceled, and the + * message should not be sent. + * + * @returns {any} + */ + getMessage() { + let msg = null; + if (this.broker) { + this.broker.addHandler(this.channelId, this); + msg = this.message; + this.message = null; + } + return msg; + } +} + +// Web workers has MessageChannel API, which is unrelated to this. +// eslint-disable-next-line no-global-assign +MessageChannel = { + init() { + Services.obs.addObserver(this, "message-manager-close"); + Services.obs.addObserver(this, "message-manager-disconnect"); + + this.messageManagers = new FilteringMessageManagerMap( + MESSAGE_MESSAGES, + this._handleMessage.bind(this) + ); + + this.responseManagers = new FilteringMessageManagerMap( + MESSAGE_RESPONSE, + this._handleResponse.bind(this), + ResponseManager + ); + + /** + * @property {Set<Deferred>} pendingResponses + * Contains a set of pending responses, either waiting to be + * received or waiting to be sent. + * + * The response object must be a deferred promise with the following + * properties: + * + * promise: + * The promise object which resolves or rejects when the response + * is no longer pending. + * + * reject: + * A function which, when called, causes the `promise` object to be + * rejected. + * + * sender: + * A sender object, as passed to `sendMessage. + * + * messageManager: + * The message manager the response will be sent or received on. + * + * When the promise resolves or rejects, it must be removed from the + * list. + * + * These values are used to clear pending responses when execution + * contexts are destroyed. + */ + this.pendingResponses = new Set(); + + /** + * @property {LimitedSet<string>} abortedResponses + * Contains the message name of a limited number of aborted response + * handlers, the responses for which will be ignored. + */ + this.abortedResponses = new ExtensionUtils.LimitedSet(30); + }, + + RESULT_SUCCESS: 0, + RESULT_DISCONNECTED: 1, + RESULT_NO_HANDLER: 2, + RESULT_MULTIPLE_HANDLERS: 3, + RESULT_ERROR: 4, + RESULT_NO_RESPONSE: 5, + + REASON_DISCONNECTED: { + result: 1, // this.RESULT_DISCONNECTED + message: "Message manager disconnected", + }, + + /** + * Specifies that only a single listener matching the specified + * recipient tag may be listening for the given message, at the other + * end of the target message manager. + * + * If no matching listeners exist, a RESULT_NO_HANDLER error will be + * returned. If multiple matching listeners exist, a + * RESULT_MULTIPLE_HANDLERS error will be returned. + */ + RESPONSE_SINGLE: 0, + + /** + * If multiple message managers matching the specified recipient tag + * are listening for a message, all listeners are notified, but only + * the first response or error is returned. + * + * Only handlers which return a value other than `undefined` are + * considered to have responded. Returning a Promise which evaluates + * to `undefined` is interpreted as an explicit response. + * + * If no matching listeners exist, a RESULT_NO_HANDLER error will be + * returned. If no listeners return a response, a RESULT_NO_RESPONSE + * error will be returned. + */ + RESPONSE_FIRST: 1, + + /** + * If multiple message managers matching the specified recipient tag + * are listening for a message, all listeners are notified, and all + * responses are returned as an array, once all listeners have + * replied. + */ + RESPONSE_ALL: 2, + + /** + * Fire-and-forget: The sender of this message does not expect a reply. + */ + RESPONSE_NONE: 3, + + /** + * Initializes message handlers for the given message managers if needed. + * + * @param {Array<nsIMessageListenerManager>} messageManagers + */ + setupMessageManagers(messageManagers) { + for (let mm of messageManagers) { + // This call initializes a FilteringMessageManager for |mm| if needed. + // The FilteringMessageManager must be created to make sure that senders + // of messages that expect a reply, such as MessageChannel:Message, do + // actually receive a default reply even if there are no explicit message + // handlers. + this.messageManagers.get(mm); + } + }, + + /** + * Returns true if the properties of the `data` object match those in + * the `filter` object. Matching is done on a strict equality basis, + * and the behavior varies depending on the value of the `strict` + * parameter. + * + * @param {object?} filter + * The filter object to match against. + * @param {object} data + * The data object being matched. + * @param {boolean} [strict=true] + * If true, all properties in the `filter` object have a + * corresponding property in `data` with the same value. If + * false, properties present in both objects must have the same + * value. + * @returns {boolean} True if the objects match. + */ + matchesFilter(filter, data, strict = true) { + if (!filter) { + return true; + } + if (strict) { + return Object.keys(filter).every(key => { + return key in data && data[key] === filter[key]; + }); + } + return Object.keys(filter).every(key => { + return !(key in data) || data[key] === filter[key]; + }); + }, + + /** + * Adds a message listener to the given message manager. + * + * @param {nsIMessageListenerManager|Array<nsIMessageListenerManager>} targets + * The message managers on which to listen. + * @param {string|number} messageName + * The name of the message to listen for. + * @param {MessageReceiver} handler + * The handler to dispatch to. Must be an object with the following + * properties: + * + * receiveMessage: + * A method which is called for each message received by the + * listener. The method takes one argument, an object, with the + * following properties: + * + * messageName: + * The internal message name, as passed to `sendMessage`. + * + * target: + * The message manager which received this message. + * + * channelId: + * The internal ID of the transaction, used to map responses to + * the original sender. + * + * sender: + * An object describing the sender, as passed to `sendMessage`. + * + * recipient: + * An object describing the recipient, as passed to + * `sendMessage`. + * + * data: + * The contents of the message, as passed to `sendMessage`. + * + * The method may return any structured-clone-compatible + * object, which will be returned as a response to the message + * sender. It may also instead return a `Promise`, the + * resolution or rejection value of which will likewise be + * returned to the message sender. + * + * messageFilterStrict: + * An object containing arbitrary properties on which to filter + * received messages. Messages will only be dispatched to this + * object if the `recipient` object passed to `sendMessage` + * matches this filter, as determined by `matchesFilter` with + * `strict=true`. + * + * messageFilterPermissive: + * An object containing arbitrary properties on which to filter + * received messages. Messages will only be dispatched to this + * object if the `recipient` object passed to `sendMessage` + * matches this filter, as determined by `matchesFilter` with + * `strict=false`. + * + * filterMessage: + * An optional function that prevents the handler from handling a + * message by returning `false`. See `getHandlers` for the parameters. + */ + addListener(targets, messageName, handler) { + if (!Array.isArray(targets)) { + targets = [targets]; + } + for (let target of targets) { + this.messageManagers.get(target).addHandler(messageName, handler); + } + }, + + /** + * Removes a message listener from the given message manager. + * + * @param {nsIMessageListenerManager|Array<nsIMessageListenerManager>} targets + * The message managers on which to stop listening. + * @param {string|number} messageName + * The name of the message to stop listening for. + * @param {MessageReceiver} handler + * The handler to stop dispatching to. + */ + removeListener(targets, messageName, handler) { + if (!Array.isArray(targets)) { + targets = [targets]; + } + for (let target of targets) { + if (this.messageManagers.has(target)) { + this.messageManagers.get(target).removeHandler(messageName, handler); + } + } + }, + + /** + * Sends a message via the given message manager. Returns a promise which + * resolves or rejects with the return value of the message receiver. + * + * The promise also rejects if there is no matching listener, or the other + * side of the message manager disconnects before the response is received. + * + * @param {nsIMessageSender} target + * The message manager on which to send the message. + * @param {string} messageName + * The name of the message to send, as passed to `addListener`. + * @param {object} data + * A structured-clone-compatible object to send to the message + * recipient. + * @param {object} [options] + * An object containing any of the following properties: + * @param {object} [options.recipient] + * A structured-clone-compatible object to identify the message + * recipient. The object must match the `messageFilterStrict` and + * `messageFilterPermissive` filters defined by recipients in order + * for the message to be received. + * @param {object} [options.sender] + * A structured-clone-compatible object to identify the message + * sender. This object may also be used to avoid delivering the + * message to the sender, and as a filter to prematurely + * abort responses when the sender is being destroyed. + * @see `abortResponses`. + * @param {boolean} [options.lowPriority = false] + * If true, treat this as a low-priority message, and attempt to + * send it in the same chunk as other messages to the same target + * the next time the event queue is idle. This option reduces + * messaging overhead at the expense of adding some latency. + * @param {integer} [options.responseType = RESPONSE_SINGLE] + * Specifies the type of response expected. See the `RESPONSE_*` + * contents for details. + * @returns {Promise} + */ + sendMessage(target, messageName, data, options = {}) { + let sender = options.sender || {}; + let recipient = options.recipient || {}; + let responseType = options.responseType || this.RESPONSE_SINGLE; + + let channelId = ExtensionUtils.getUniqueId(); + let message = { + messageName, + channelId, + sender, + recipient, + data, + responseType, + }; + data = null; + + if (responseType == this.RESPONSE_NONE) { + try { + target.sendAsyncMessage(MESSAGE_MESSAGES, [message]); + } catch (e) { + // Caller is not expecting a reply, so dump the error to the console. + Cu.reportError(e); + return Promise.reject(e); + } + return Promise.resolve(); // Not expecting any reply. + } + + let broker = this.responseManagers.get(target); + let pending = new PendingMessage(channelId, message, recipient, broker); + message = null; + try { + broker.sendMessage(pending, options); + } catch (e) { + pending.reject(e); + } + return pending.promise; + }, + + _callHandlers(handlers, data) { + let responseType = data.responseType; + + // At least one handler is required for all response types but + // RESPONSE_ALL. + if (!handlers.length && responseType != this.RESPONSE_ALL) { + return Promise.reject({ + result: MessageChannel.RESULT_NO_HANDLER, + message: "No matching message handler", + }); + } + + if (responseType == this.RESPONSE_SINGLE) { + if (handlers.length > 1) { + return Promise.reject({ + result: MessageChannel.RESULT_MULTIPLE_HANDLERS, + message: `Multiple matching handlers for ${data.messageName}`, + }); + } + + // Note: We use `new Promise` rather than `Promise.resolve` here + // so that errors from the handler are trapped and converted into + // rejected promises. + return new Promise(resolve => { + resolve(handlers[0].receiveMessage(data)); + }); + } + + let responses = handlers.map((handler, i) => { + try { + return handler.receiveMessage(data, i + 1 == handlers.length); + } catch (e) { + return Promise.reject(e); + } + }); + data = null; + responses = responses.filter(response => response !== undefined); + + switch (responseType) { + case this.RESPONSE_FIRST: + if (!responses.length) { + return Promise.reject({ + result: MessageChannel.RESULT_NO_RESPONSE, + message: "No handler returned a response", + }); + } + + return Promise.race(responses); + + case this.RESPONSE_ALL: + return Promise.all(responses); + } + return Promise.reject({ message: "Invalid response type" }); + }, + + /** + * Handles dispatching message callbacks from the message brokers to their + * appropriate `MessageReceivers`, and routing the responses back to the + * original senders. + * + * Each handler object is a `MessageReceiver` object as passed to + * `addListener`. + * + * @param {Array<MessageHandler>} handlers + * @param {object} data + * @param {nsIMessageSender|{messageManager:nsIMessageSender}} data.target + */ + _handleMessage(handlers, data) { + if (data.responseType == this.RESPONSE_NONE) { + handlers.forEach(handler => { + // The sender expects no reply, so dump any errors to the console. + new Promise(resolve => { + resolve(handler.receiveMessage(data)); + }).catch(e => { + Cu.reportError(e.stack ? `${e}\n${e.stack}` : e.message || e); + }); + }); + data = null; + // Note: Unhandled messages are silently dropped. + return; + } + + let target = getMessageManager(data.target); + + let deferred = { + sender: data.sender, + messageManager: target, + channelId: data.channelId, + respondingSide: true, + }; + + let cleanup = () => { + this.pendingResponses.delete(deferred); + if (target.dispose) { + target.dispose(); + } + }; + this.pendingResponses.add(deferred); + + deferred.promise = new Promise((resolve, reject) => { + deferred.reject = reject; + + this._callHandlers(handlers, data).then(resolve, reject); + data = null; + }) + .then( + value => { + let response = { + result: this.RESULT_SUCCESS, + messageName: deferred.channelId, + recipient: {}, + value, + }; + + if (target.isDisconnected) { + // Target is disconnected. We can't send an error response, so + // don't even try. + return; + } + target.sendAsyncMessage(MESSAGE_RESPONSE, response); + }, + error => { + if (target.isDisconnected) { + // Target is disconnected. We can't send an error response, so + // don't even try. + if ( + error.result !== this.RESULT_DISCONNECTED && + error.result !== this.RESULT_NO_RESPONSE + ) { + Cu.reportError( + Cu.getClassName(error, false) === "Object" + ? error.message + : error + ); + } + return; + } + + let response = { + result: this.RESULT_ERROR, + messageName: deferred.channelId, + recipient: {}, + error: {}, + }; + + if (error && typeof error == "object") { + if (error.result) { + response.result = error.result; + } + // Error objects are not structured-clonable, so just copy + // over the important properties. + for (let key of [ + "fileName", + "filename", + "lineNumber", + "columnNumber", + "message", + "stack", + "result", + "mozWebExtLocation", + ]) { + if (key in error) { + response.error[key] = error[key]; + } + } + } + + target.sendAsyncMessage(MESSAGE_RESPONSE, response); + } + ) + .then(cleanup, e => { + cleanup(); + Cu.reportError(e); + }); + }, + + /** + * Handles message callbacks from the response brokers. + * + * @param {MessageHandler?} handler + * A deferred object created by `sendMessage`, to be resolved + * or rejected based on the contents of the response. + * @param {object} data + * @param {nsIMessageSender|{messageManager:nsIMessageSender}} data.target + */ + _handleResponse(handler, data) { + // If we have an error at this point, we have handler to report it to, + // so just log it. + if (!handler) { + if (this.abortedResponses.has(data.messageName)) { + this.abortedResponses.delete(data.messageName); + Services.console.logStringMessage( + `Ignoring response to aborted listener for ${data.messageName}` + ); + } else { + Cu.reportError( + `No matching message response handler for ${data.messageName}` + ); + } + } else if (data.result === this.RESULT_SUCCESS) { + handler.resolve(data.value); + } else { + handler.reject(data.error); + } + }, + + /** + * Aborts pending message response for the specific channel. + * + * @param {string} channelId + * A string for channelId of the response. + * @param {object} reason + * An object describing the reason the response was aborted. + * Will be passed to the promise rejection handler of the aborted + * response. + */ + abortChannel(channelId, reason) { + for (let response of this.pendingResponses) { + if (channelId === response.channelId && response.respondingSide) { + this.pendingResponses.delete(response); + response.reject(reason); + } + } + }, + + /** + * Aborts any pending message responses to senders matching the given + * filter. + * + * @param {object} sender + * The object on which to filter senders, as determined by + * `matchesFilter`. + * @param {object} [reason] + * An optional object describing the reason the response was aborted. + * Will be passed to the promise rejection handler of all aborted + * responses. + */ + abortResponses(sender, reason = this.REASON_DISCONNECTED) { + for (let response of this.pendingResponses) { + if (this.matchesFilter(sender, response.sender)) { + this.pendingResponses.delete(response); + this.abortedResponses.add(response.channelId); + response.reject(reason); + } + } + }, + + /** + * Aborts any pending message responses to the broker for the given + * message manager. + * + * @param {nsIMessageListenerManager} target + * The message manager for which to abort brokers. + * @param {object} reason + * An object describing the reason the responses were aborted. + * Will be passed to the promise rejection handler of all aborted + * responses. + */ + abortMessageManager(target, reason) { + for (let response of this.pendingResponses) { + if (matches(response.messageManager, target)) { + this.abortedResponses.add(response.channelId); + response.reject(reason); + } + } + }, + + observe(subject, topic, data) { + switch (topic) { + case "message-manager-close": + case "message-manager-disconnect": + try { + if (this.responseManagers.has(subject)) { + this.abortMessageManager(subject, this.REASON_DISCONNECTED); + } + } finally { + this.responseManagers.delete(subject); + this.messageManagers.delete(subject); + } + break; + } + }, +}; + +MessageChannel.init(); diff --git a/toolkit/components/extensions/MessageManagerProxy.sys.mjs b/toolkit/components/extensions/MessageManagerProxy.sys.mjs new file mode 100644 index 0000000000..387b5876e1 --- /dev/null +++ b/toolkit/components/extensions/MessageManagerProxy.sys.mjs @@ -0,0 +1,212 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +// @ts-nocheck TODO: Many references to old types which don't exist anymore. + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { DefaultMap } = ExtensionUtils; + +/** + * Acts as a proxy for a message manager or message manager owner, and + * tracks docShell swaps so that messages are always sent to the same + * receiver, even if it is moved to a different <browser>. + * + * @param {nsIMessageSender|Element} target + * The target message manager on which to send messages, or the + * <browser> element which owns it. + */ +export class MessageManagerProxy { + constructor(target) { + this.listeners = new DefaultMap(() => new Map()); + this.closed = false; + + if (target instanceof Ci.nsIMessageSender) { + this.messageManager = target; + } else { + this.addListeners(target); + } + + Services.obs.addObserver(this, "message-manager-close"); + } + + /** + * Disposes of the proxy object, removes event listeners, and drops + * all references to the underlying message manager. + * + * Must be called before the last reference to the proxy is dropped, + * unless the underlying message manager or <browser> is also being + * destroyed. + */ + dispose() { + if (this.eventTarget) { + this.removeListeners(this.eventTarget); + this.eventTarget = null; + } + this.messageManager = null; + + Services.obs.removeObserver(this, "message-manager-close"); + } + + observe(subject, topic, data) { + if (topic === "message-manager-close") { + if (subject === this.messageManager) { + this.closed = true; + } + } + } + + /** + * Returns true if the given target is the same as, or owns, the given + * message manager. + * + * @param {nsIMessageSender|MessageManagerProxy|Element} target + * The message manager, MessageManagerProxy, or <browser> + * element against which to match. + * @param {nsIMessageSender} messageManager + * The message manager against which to match `target`. + * + * @returns {boolean} + * True if `messageManager` is the same object as `target`, or + * `target` is a MessageManagerProxy or <browser> element that + * is tied to it. + */ + static matches(target, messageManager) { + return ( + target === messageManager || target.messageManager === messageManager + ); + } + + /** + * @property {nsIMessageSender|null} messageManager + * The message manager that is currently being proxied. This + * may change during the life of the proxy object, so should + * not be stored elsewhere. + */ + + /** + * Sends a message on the proxied message manager. + * + * @param {Array} args + * Arguments to be passed verbatim to the underlying + * sendAsyncMessage method. + * @returns {undefined} + */ + sendAsyncMessage(...args) { + if (this.messageManager) { + return this.messageManager.sendAsyncMessage(...args); + } + + Cu.reportError( + `Cannot send message: Other side disconnected: ${uneval(args)}` + ); + } + + get isDisconnected() { + return this.closed || !this.messageManager; + } + + /** + * Adds a message listener to the current message manager, and + * transfers it to the new message manager after a docShell swap. + * + * @param {string} message + * The name of the message to listen for. + * @param {nsIMessageListener} listener + * The listener to add. + * @param {boolean} [listenWhenClosed = false] + * If true, the listener will receive messages which were sent + * after the remote side of the listener began closing. + */ + addMessageListener(message, listener, listenWhenClosed = false) { + this.messageManager.addMessageListener(message, listener, listenWhenClosed); + this.listeners.get(message).set(listener, listenWhenClosed); + } + + /** + * Adds a message listener from the current message manager. + * + * @param {string} message + * The name of the message to stop listening for. + * @param {nsIMessageListener} listener + * The listener to remove. + */ + removeMessageListener(message, listener) { + this.messageManager.removeMessageListener(message, listener); + + let listeners = this.listeners.get(message); + listeners.delete(listener); + if (!listeners.size) { + this.listeners.delete(message); + } + } + + /** + * Iterates over all of the currently registered message listeners. + * + * @private + */ + *iterListeners() { + for (let [message, listeners] of this.listeners) { + for (let [listener, listenWhenClosed] of listeners) { + yield { message, listener, listenWhenClosed }; + } + } + } + + /** + * Adds docShell swap listeners to the message manager owner. + * + * @param {Browser} target + * The target element. + * @private + */ + addListeners(target) { + target.addEventListener("SwapDocShells", this); + + this.eventTarget = target; + this.messageManager = target.messageManager; + + for (let { message, listener, listenWhenClosed } of this.iterListeners()) { + this.messageManager.addMessageListener( + message, + listener, + listenWhenClosed + ); + } + } + + /** + * Removes docShell swap listeners to the message manager owner. + * + * @param {Element} target + * The target element. + * @private + */ + removeListeners(target) { + target.removeEventListener("SwapDocShells", this); + + for (let { message, listener } of this.iterListeners()) { + this.messageManager.removeMessageListener(message, listener); + } + } + + handleEvent(event) { + if (event.type == "SwapDocShells") { + this.removeListeners(this.eventTarget); + // The SwapDocShells event is dispatched for both browsers that are being + // swapped. To avoid double-swapping, register the event handler after + // both SwapDocShells events have fired. + this.eventTarget.addEventListener( + "EndSwapDocShells", + () => { + this.addListeners(event.detail); + }, + { once: true } + ); + } + } +} diff --git a/toolkit/components/extensions/NativeManifests.sys.mjs b/toolkit/components/extensions/NativeManifests.sys.mjs new file mode 100644 index 0000000000..e56df4abc2 --- /dev/null +++ b/toolkit/components/extensions/NativeManifests.sys.mjs @@ -0,0 +1,173 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Schemas: "resource://gre/modules/Schemas.sys.mjs", + WindowsRegistry: "resource://gre/modules/WindowsRegistry.sys.mjs", +}); + +const DASHED = AppConstants.platform === "linux"; + +// Supported native manifest types, with platform-specific slugs. +const TYPES = { + stdio: DASHED ? "native-messaging-hosts" : "NativeMessagingHosts", + storage: DASHED ? "managed-storage" : "ManagedStorage", + pkcs11: DASHED ? "pkcs11-modules" : "PKCS11Modules", +}; + +const NATIVE_MANIFEST_SCHEMA = + "chrome://extensions/content/schemas/native_manifest.json"; + +const REGPATH = "Software\\Mozilla"; + +export var NativeManifests = { + _initializePromise: null, + _lookup: null, + + init() { + if (!this._initializePromise) { + let platform = AppConstants.platform; + if (platform == "win") { + this._lookup = this._winLookup; + } else if (platform == "macosx" || platform == "linux") { + let dirs = [ + Services.dirsvc.get("XREUserNativeManifests", Ci.nsIFile).path, + Services.dirsvc.get("XRESysNativeManifests", Ci.nsIFile).path, + ]; + this._lookup = (type, name, context) => + this._tryPaths(type, name, dirs, context); + } else { + throw new Error( + `Native manifests are not supported on ${AppConstants.platform}` + ); + } + this._initializePromise = lazy.Schemas.load(NATIVE_MANIFEST_SCHEMA); + } + return this._initializePromise; + }, + + async _winLookup(type, name, context) { + const REGISTRY = Ci.nsIWindowsRegKey; + let regPath = `${REGPATH}\\${TYPES[type]}\\${name}`; + let path = lazy.WindowsRegistry.readRegKey( + REGISTRY.ROOT_KEY_CURRENT_USER, + regPath, + "", + REGISTRY.WOW64_64 + ); + if (!path) { + path = lazy.WindowsRegistry.readRegKey( + REGISTRY.ROOT_KEY_LOCAL_MACHINE, + regPath, + "", + REGISTRY.WOW64_32 + ); + } + if (!path) { + path = lazy.WindowsRegistry.readRegKey( + REGISTRY.ROOT_KEY_LOCAL_MACHINE, + regPath, + "", + REGISTRY.WOW64_64 + ); + } + if (!path) { + return null; + } + + // Normalize in case the extension used / instead of \. + path = path.replaceAll("/", "\\"); + + let manifest = await this._tryPath(type, path, name, context, true); + return manifest ? { path, manifest } : null; + }, + + async _tryPath(type, path, name, context, logIfNotFound) { + let manifest; + try { + manifest = await IOUtils.readJSON(path); + } catch (ex) { + if (ex instanceof SyntaxError && ex.message.startsWith("JSON.parse:")) { + Cu.reportError(`Error parsing native manifest ${path}: ${ex.message}`); + return null; + } + if (DOMException.isInstance(ex) && ex.name == "NotFoundError") { + if (logIfNotFound) { + Cu.reportError( + `Error reading native manifest file ${path}: file is referenced in the registry but does not exist` + ); + } + return null; + } + Cu.reportError(ex); + return null; + } + let normalized = lazy.Schemas.normalize( + manifest, + "manifest.NativeManifest", + context + ); + if (normalized.error) { + Cu.reportError(normalized.error); + return null; + } + manifest = normalized.value; + + if (manifest.type !== type) { + Cu.reportError( + `Native manifest ${path} has type property ${manifest.type} (expected ${type})` + ); + return null; + } + if (manifest.name !== name) { + Cu.reportError( + `Native manifest ${path} has name property ${manifest.name} (expected ${name})` + ); + return null; + } + if ( + manifest.allowed_extensions && + !manifest.allowed_extensions.includes(context.extension.id) + ) { + Cu.reportError( + `This extension does not have permission to use native manifest ${path}` + ); + return null; + } + + return manifest; + }, + + async _tryPaths(type, name, dirs, context) { + for (let dir of dirs) { + let path = PathUtils.join(dir, TYPES[type], `${name}.json`); + let manifest = await this._tryPath(type, path, name, context, false); + if (manifest) { + return { path, manifest }; + } + } + return null; + }, + + /** + * Search for a valid native manifest of the given type and name. + * The directories searched and rules for manifest validation are all + * detailed in the Native Manifests documentation. + * + * @param {string} type The type, one of: "pkcs11", "stdio" or "storage". + * @param {string} name The name of the manifest to search for. + * @param {object} context A context object as expected by Schemas.normalize. + * @returns {object} The contents of the validated manifest, or null if + * no valid manifest can be found for this type and name. + */ + lookupManifest(type, name, context) { + return this.init().then(() => this._lookup(type, name, context)); + }, +}; diff --git a/toolkit/components/extensions/NativeMessaging.sys.mjs b/toolkit/components/extensions/NativeMessaging.sys.mjs new file mode 100644 index 0000000000..dcd8fe7807 --- /dev/null +++ b/toolkit/components/extensions/NativeMessaging.sys.mjs @@ -0,0 +1,391 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", + NativeManifests: "resource://gre/modules/NativeManifests.sys.mjs", + Subprocess: "resource://gre/modules/Subprocess.sys.mjs", +}); + +const { ExtensionError, promiseTimeout } = ExtensionUtils; + +// For a graceful shutdown (i.e., when the extension is unloaded or when it +// explicitly calls disconnect() on a native port), how long we give the native +// application to exit before we start trying to kill it. (in milliseconds) +const GRACEFUL_SHUTDOWN_TIME = 3000; + +// Hard limits on maximum message size that can be read/written +// These are defined in the native messaging documentation, note that +// the write limit is imposed by the "wire protocol" in which message +// boundaries are defined by preceding each message with its length as +// 4-byte unsigned integer so this is the largest value that can be +// represented. Good luck generating a serialized message that large, +// the practical write limit is likely to be dictated by available memory. +const MAX_READ = 1024 * 1024; +const MAX_WRITE = 0xffffffff; + +// Preferences that can lower the message size limits above, +// used for testing the limits. +const PREF_MAX_READ = "webextensions.native-messaging.max-input-message-bytes"; +const PREF_MAX_WRITE = + "webextensions.native-messaging.max-output-message-bytes"; + +XPCOMUtils.defineLazyPreferenceGetter(lazy, "maxRead", PREF_MAX_READ, MAX_READ); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "maxWrite", + PREF_MAX_WRITE, + MAX_WRITE +); + +export class NativeApp extends EventEmitter { + /** + * @param {BaseContext} context The context that initiated the native app. + * @param {string} application The identifier of the native app. + */ + constructor(context, application) { + super(); + + this.context = context; + this.name = application; + + // We want a close() notification when the window is destroyed. + this.context.callOnClose(this); + + this.proc = null; + this.readPromise = null; + this.sendQueue = []; + this.writePromise = null; + this.cleanupStarted = false; + + this.startupPromise = lazy.NativeManifests.lookupManifest( + "stdio", + application, + context + ) + .then(hostInfo => { + // Report a generic error to not leak information about whether a native + // application is installed to addons that do not have the right permission. + if (!hostInfo) { + throw new ExtensionError(`No such native application ${application}`); + } + + let command = hostInfo.manifest.path; + if (AppConstants.platform == "win") { + // Normalize in case the extension used / instead of \. + command = command.replaceAll("/", "\\"); + + if (!PathUtils.isAbsolute(command)) { + // Note: hostInfo.path is an absolute path to the manifest. + const parentPath = PathUtils.parent( + hostInfo.path.replaceAll("/", "\\") + ); + // PathUtils.joinRelative cannot be used because it throws for "..". + // but command is allowed to contain ".." to traverse the directory. + command = `${parentPath}\\${command}`; + } + } else if (!PathUtils.isAbsolute(command)) { + // Only windows supports relative paths. + throw new Error( + "NativeApp requires absolute path to command on this platform" + ); + } + + let subprocessOpts = { + command: command, + arguments: [hostInfo.path, context.extension.id], + workdir: PathUtils.parent(command), + stderr: "pipe", + disclaim: true, + }; + + return lazy.Subprocess.call(subprocessOpts); + }) + .then(proc => { + this.startupPromise = null; + this.proc = proc; + this._startRead(); + this._startWrite(); + this._startStderrRead(); + }) + .catch(err => { + this.startupPromise = null; + Cu.reportError(err instanceof Error ? err : err.message); + this._cleanup(err); + }); + } + + /** + * Open a connection to a native messaging host. + * + * @param {number} portId A unique internal ID that identifies the port. + * @param {import("ExtensionParent.sys.mjs").NativeMessenger} port Parent NativeMessenger used to send messages. + * @returns {import("ExtensionParent.sys.mjs").ParentPort} + */ + onConnect(portId, port) { + // eslint-disable-next-line + this.on("message", (_, message) => { + port.sendPortMessage( + portId, + new StructuredCloneHolder( + `NativeMessaging/onConnect/${this.name}`, + null, + message + ) + ); + }); + this.once("disconnect", (_, error) => { + port.sendPortDisconnect(portId, error && new ClonedErrorHolder(error)); + }); + return { + onPortMessage: holder => this.send(holder), + onPortDisconnect: () => this.close(), + }; + } + + /** + * @param {BaseContext} context The scope from where `message` originates. + * @param {*} message A message from the extension, meant for a native app. + * @returns {ArrayBuffer} An ArrayBuffer that can be sent to the native app. + */ + static encodeMessage(context, message) { + message = context.jsonStringify(message); + let buffer = new TextEncoder().encode(message).buffer; + if (buffer.byteLength > lazy.maxWrite) { + throw new context.Error("Write too big"); + } + return buffer; + } + + // A port is definitely "alive" if this.proc is non-null. But we have + // to provide a live port object immediately when connecting so we also + // need to consider a port alive if proc is null but the startupPromise + // is still pending. + get _isDisconnected() { + return !this.proc && !this.startupPromise; + } + + _startRead() { + if (this.readPromise) { + throw new Error("Entered _startRead() while readPromise is non-null"); + } + this.readPromise = this.proc.stdout + .readUint32() + .then(len => { + if (len > lazy.maxRead) { + throw new ExtensionError( + `Native application tried to send a message of ${len} bytes, which exceeds the limit of ${lazy.maxRead} bytes.` + ); + } + return this.proc.stdout.readJSON(len); + }) + .then(msg => { + this.emit("message", msg); + this.readPromise = null; + this._startRead(); + }) + .catch(err => { + if (err.errorCode != lazy.Subprocess.ERROR_END_OF_FILE) { + Cu.reportError(err instanceof Error ? err : err.message); + } + this._cleanup(err); + }); + } + + _startWrite() { + if (!this.sendQueue.length) { + return; + } + + if (this.writePromise) { + throw new Error("Entered _startWrite() while writePromise is non-null"); + } + + let buffer = this.sendQueue.shift(); + let uintArray = Uint32Array.of(buffer.byteLength); + + this.writePromise = Promise.all([ + this.proc.stdin.write(uintArray.buffer), + this.proc.stdin.write(buffer), + ]) + .then(() => { + this.writePromise = null; + this._startWrite(); + }) + .catch(err => { + Cu.reportError(err.message); + this._cleanup(err); + }); + } + + _startStderrRead() { + let proc = this.proc; + let app = this.name; + (async function () { + let partial = ""; + while (true) { + let data = await proc.stderr.readString(); + if (!data.length) { + // We have hit EOF, just stop reading + if (partial) { + Services.console.logStringMessage( + `stderr output from native app ${app}: ${partial}` + ); + } + break; + } + + let lines = data.split(/\r?\n/); + lines[0] = partial + lines[0]; + partial = lines.pop(); + + for (let line of lines) { + Services.console.logStringMessage( + `stderr output from native app ${app}: ${line}` + ); + } + } + })(); + } + + send(holder) { + if (this._isDisconnected) { + throw new ExtensionError("Attempt to postMessage on disconnected port"); + } + let msg = holder.deserialize(globalThis); + if (Cu.getClassName(msg, true) != "ArrayBuffer") { + // This error cannot be triggered by extensions; it indicates an error in + // our implementation. + throw new Error( + "The message to the native messaging host is not an ArrayBuffer" + ); + } + + let buffer = msg; + + if (buffer.byteLength > lazy.maxWrite) { + throw new ExtensionError("Write too big"); + } + + this.sendQueue.push(buffer); + if (!this.startupPromise && !this.writePromise) { + this._startWrite(); + } + } + + // Shut down the native application and (by default) signal to the extension + // that the connect has been disconnected. + async _cleanup(err, fromExtension = false) { + if (this.cleanupStarted) { + return; + } + this.cleanupStarted = true; + this.context.forgetOnClose(this); + + if (!fromExtension) { + if (err && err.errorCode == lazy.Subprocess.ERROR_END_OF_FILE) { + err = null; + } + this.emit("disconnect", err); + } + + await this.startupPromise; + + if (!this.proc) { + // Failed to initialize proc in the constructor. + return; + } + + // To prevent an uncooperative process from blocking shutdown, we take the + // following actions, and wait for GRACEFUL_SHUTDOWN_TIME in between. + // + // 1. Allow exit by closing the stdin pipe. + // 2. Allow exit by a kill signal. + // 3. Allow exit by forced kill signal. + // 4. Give up and unblock shutdown despite the process still being alive. + + // Close the stdin stream and allow the process to exit on its own. + // proc.wait() below will resolve once the process has exited gracefully. + this.proc.stdin.close().catch(err => { + if (err.errorCode != lazy.Subprocess.ERROR_END_OF_FILE) { + Cu.reportError(err); + } + }); + let exitPromise = Promise.race([ + // 1. Allow the process to exit on its own after closing stdin. + this.proc.wait().then(() => { + this.proc = null; + }), + promiseTimeout(GRACEFUL_SHUTDOWN_TIME).then(() => { + if (this.proc) { + // 2. Kill the process gracefully. 3. Force kill after a timeout. + this.proc.kill(GRACEFUL_SHUTDOWN_TIME); + + // 4. If the process is still alive after a kill + timeout followed + // by a forced kill + timeout, give up and just resolve exitPromise. + // + // Note that waiting for just one interval is not enough, because the + // `proc.kill()` is asynchronous, so we need to wait a bit after the + // kill signal has been sent. + return promiseTimeout(2 * GRACEFUL_SHUTDOWN_TIME); + } + }), + ]); + + lazy.AsyncShutdown.profileBeforeChange.addBlocker( + `Native Messaging: Wait for application ${this.name} to exit`, + exitPromise + ); + } + + // Called when the Context or Port is closed. + close() { + this._cleanup(null, true); + } + + sendMessage(holder) { + let responsePromise = new Promise((resolve, reject) => { + this.once("message", (what, msg) => { + resolve(msg); + }); + this.once("disconnect", (what, err) => { + reject(err); + }); + }); + + let result = this.startupPromise.then(() => { + // Skip .send() if _cleanup() has been called already; + // otherwise the error passed to _cleanup/"disconnect" would be hidden by the + // "Attempt to postMessage on disconnected port" error from this.send(). + if (!this.cleanupStarted) { + this.send(holder); + } + return responsePromise; + }); + + result.then( + () => { + this._cleanup(); + }, + () => { + // Prevent the response promise from being reported as an + // unchecked rejection if the startup promise fails. + responsePromise.catch(() => {}); + + this._cleanup(); + } + ); + + return result; + } +} diff --git a/toolkit/components/extensions/PExtensions.ipdl b/toolkit/components/extensions/PExtensions.ipdl new file mode 100644 index 0000000000..ad5a0c993a --- /dev/null +++ b/toolkit/components/extensions/PExtensions.ipdl @@ -0,0 +1,61 @@ +/* -*- Mode: C++; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 8 -*- */ +/* vim: set sw=4 ts=8 et tw=80 ft=cpp : */ +/* 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 protocol PContent; +include protocol PInProcess; +include DOMTypes; +include "mozilla/ipc/URIUtils.h"; + +namespace mozilla { +namespace extensions { + +struct FrameTransitionData +{ + bool clientRedirect; + bool formSubmit; + bool forwardBack; + bool reload; + bool serverRedirect; +}; + +/** + * A generic protocol used by the extension framework for process-level IPC. A + * child instance is created at startup in the parent process and each content + * child process, which can be accessed via + * `mozilla::extensions::ExtensionsChild::Get()`. + */ +protocol PExtensions +{ + manager PContent or PInProcess; + + parent: + async __delete__(); + + async DocumentChange(MaybeDiscardedBrowsingContext bc, + FrameTransitionData transitionData, + nullable nsIURI location); + + async HistoryChange(MaybeDiscardedBrowsingContext bc, + FrameTransitionData transitionData, + nullable nsIURI location, + bool isHistoryStateUpdated, + bool isReferenceFragmentUpdated); + + async StateChange(MaybeDiscardedBrowsingContext bc, + nullable nsIURI requestURI, + nsresult status, + uint32_t stateFlags); + + async CreatedNavigationTarget(MaybeDiscardedBrowsingContext bc, + MaybeDiscardedBrowsingContext sourceBC, + nsCString url); + + async DOMContentLoaded(MaybeDiscardedBrowsingContext bc, + nullable nsIURI documentURI); +}; + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/ProxyChannelFilter.sys.mjs b/toolkit/components/extensions/ProxyChannelFilter.sys.mjs new file mode 100644 index 0000000000..2f7f8cb113 --- /dev/null +++ b/toolkit/components/extensions/ProxyChannelFilter.sys.mjs @@ -0,0 +1,427 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", +}); +XPCOMUtils.defineLazyServiceGetter( + lazy, + "ProxyService", + "@mozilla.org/network/protocol-proxy-service;1", + "nsIProtocolProxyService" +); + +ChromeUtils.defineLazyGetter(lazy, "tabTracker", () => { + return lazy.ExtensionParent.apiManager.global.tabTracker; +}); +ChromeUtils.defineLazyGetter( + lazy, + "getCookieStoreIdForOriginAttributes", + () => { + return lazy.ExtensionParent.apiManager.global + .getCookieStoreIdForOriginAttributes; + } +); + +// DNS is resolved on the SOCKS proxy server. +const { TRANSPARENT_PROXY_RESOLVES_HOST } = Ci.nsIProxyInfo; + +// The length of time (seconds) to wait for a proxy to resolve before ignoring it. +const PROXY_TIMEOUT_SEC = 10; + +const { ExtensionError } = ExtensionUtils; + +const PROXY_TYPES = Object.freeze({ + DIRECT: "direct", + HTTPS: "https", + PROXY: "http", // Synonym for PROXY_TYPES.HTTP + HTTP: "http", + SOCKS: "socks", // SOCKS5 + SOCKS4: "socks4", +}); + +const ProxyInfoData = { + validate(proxyData) { + if (proxyData.type && proxyData.type.toLowerCase() === "direct") { + return { type: proxyData.type }; + } + for (let prop of [ + "type", + "host", + "port", + "username", + "password", + "proxyDNS", + "failoverTimeout", + "proxyAuthorizationHeader", + "connectionIsolationKey", + ]) { + this[prop](proxyData); + } + return proxyData; + }, + + type(proxyData) { + let { type } = proxyData; + if ( + typeof type !== "string" || + !PROXY_TYPES.hasOwnProperty(type.toUpperCase()) + ) { + throw new ExtensionError( + `ProxyInfoData: Invalid proxy server type: "${type}"` + ); + } + proxyData.type = PROXY_TYPES[type.toUpperCase()]; + }, + + host(proxyData) { + let { host } = proxyData; + if (typeof host !== "string" || host.includes(" ")) { + throw new ExtensionError( + `ProxyInfoData: Invalid proxy server host: "${host}"` + ); + } + if (!host.length) { + throw new ExtensionError( + "ProxyInfoData: Proxy server host cannot be empty" + ); + } + proxyData.host = host; + }, + + port(proxyData) { + let port = Number.parseInt(proxyData.port, 10); + if (!Number.isInteger(port)) { + throw new ExtensionError( + `ProxyInfoData: Invalid proxy server port: "${port}"` + ); + } + + if (port < 1 || port > 0xffff) { + throw new ExtensionError( + `ProxyInfoData: Proxy server port ${port} outside range 1 to 65535` + ); + } + proxyData.port = port; + }, + + username(proxyData) { + let { username } = proxyData; + if (username !== undefined && typeof username !== "string") { + throw new ExtensionError( + `ProxyInfoData: Invalid proxy server username: "${username}"` + ); + } + }, + + password(proxyData) { + let { password } = proxyData; + if (password !== undefined && typeof password !== "string") { + throw new ExtensionError( + `ProxyInfoData: Invalid proxy server password: "${password}"` + ); + } + }, + + proxyDNS(proxyData) { + let { proxyDNS, type } = proxyData; + if (proxyDNS !== undefined) { + if (typeof proxyDNS !== "boolean") { + throw new ExtensionError( + `ProxyInfoData: Invalid proxyDNS value: "${proxyDNS}"` + ); + } + if ( + proxyDNS && + type !== PROXY_TYPES.SOCKS && + type !== PROXY_TYPES.SOCKS4 + ) { + throw new ExtensionError( + `ProxyInfoData: proxyDNS can only be true for SOCKS proxy servers` + ); + } + } + }, + + failoverTimeout(proxyData) { + let { failoverTimeout } = proxyData; + if ( + failoverTimeout !== undefined && + (!Number.isInteger(failoverTimeout) || failoverTimeout < 1) + ) { + throw new ExtensionError( + `ProxyInfoData: Invalid failover timeout: "${failoverTimeout}"` + ); + } + }, + + proxyAuthorizationHeader(proxyData) { + let { proxyAuthorizationHeader, type } = proxyData; + if (proxyAuthorizationHeader === undefined) { + return; + } + if (typeof proxyAuthorizationHeader !== "string") { + throw new ExtensionError( + `ProxyInfoData: Invalid proxy server authorization header: "${proxyAuthorizationHeader}"` + ); + } + if (type !== "https") { + throw new ExtensionError( + `ProxyInfoData: ProxyAuthorizationHeader requires type "https"` + ); + } + }, + + connectionIsolationKey(proxyData) { + let { connectionIsolationKey } = proxyData; + if ( + connectionIsolationKey !== undefined && + typeof connectionIsolationKey !== "string" + ) { + throw new ExtensionError( + `ProxyInfoData: Invalid proxy connection isolation key: "${connectionIsolationKey}"` + ); + } + }, + + createProxyInfoFromData( + policy, + proxyDataList, + defaultProxyInfo, + proxyDataListIndex = 0 + ) { + if (proxyDataListIndex >= proxyDataList.length) { + return defaultProxyInfo; + } + let proxyData = proxyDataList[proxyDataListIndex]; + if (proxyData == null) { + return null; + } + let { + type, + host, + port, + username, + password, + proxyDNS, + failoverTimeout, + proxyAuthorizationHeader, + connectionIsolationKey, + } = ProxyInfoData.validate(proxyData); + if (type === PROXY_TYPES.DIRECT && defaultProxyInfo) { + return defaultProxyInfo; + } + let failoverProxy = this.createProxyInfoFromData( + policy, + proxyDataList, + defaultProxyInfo, + proxyDataListIndex + 1 + ); + + let proxyInfo; + if (type === PROXY_TYPES.SOCKS || type === PROXY_TYPES.SOCKS4) { + proxyInfo = lazy.ProxyService.newProxyInfoWithAuth( + type, + host, + port, + username, + password, + proxyAuthorizationHeader, + connectionIsolationKey, + proxyDNS ? TRANSPARENT_PROXY_RESOLVES_HOST : 0, + failoverTimeout ? failoverTimeout : PROXY_TIMEOUT_SEC, + failoverProxy + ); + } else { + proxyInfo = lazy.ProxyService.newProxyInfo( + type, + host, + port, + proxyAuthorizationHeader, + connectionIsolationKey, + proxyDNS ? TRANSPARENT_PROXY_RESOLVES_HOST : 0, + failoverTimeout ? failoverTimeout : PROXY_TIMEOUT_SEC, + failoverProxy + ); + } + proxyInfo.sourceId = policy.id; + return proxyInfo; + }, +}; + +function normalizeFilter(filter) { + if (!filter) { + filter = {}; + } + + return { + urls: filter.urls || null, + types: filter.types || null, + tabId: filter.tabId ?? null, + windowId: filter.windowId ?? null, + incognito: filter.incognito ?? null, + }; +} + +export class ProxyChannelFilter { + constructor(context, extension, listener, filter, extraInfoSpec) { + this.context = context; + this.extension = extension; + this.filter = normalizeFilter(filter); + this.listener = listener; + this.extraInfoSpec = extraInfoSpec || []; + + lazy.ProxyService.registerChannelFilter( + this /* nsIProtocolProxyChannelFilter aFilter */, + 0 /* unsigned long aPosition */ + ); + } + + // Originally duplicated from WebRequest.jsm with small changes. Keep this + // in sync with WebRequest.jsm as well as parent/ext-webRequest.js when + // apropiate. + getRequestData(channel, extraData) { + let originAttributes = channel.loadInfo?.originAttributes; + let data = { + requestId: String(channel.id), + url: channel.finalURL, + method: channel.method, + type: channel.type, + fromCache: !!channel.fromCache, + incognito: originAttributes?.privateBrowsingId > 0, + thirdParty: channel.thirdParty, + + originUrl: channel.originURL || undefined, + documentUrl: channel.documentURL || undefined, + + frameId: channel.frameId, + parentFrameId: channel.parentFrameId, + + frameAncestors: channel.frameAncestors || undefined, + + timeStamp: Date.now(), + + ...extraData, + }; + if (originAttributes) { + data.cookieStoreId = + lazy.getCookieStoreIdForOriginAttributes(originAttributes); + } + if (this.extraInfoSpec.includes("requestHeaders")) { + data.requestHeaders = channel.getRequestHeaders(); + } + if (channel.urlClassification) { + data.urlClassification = { + firstParty: channel.urlClassification.firstParty.filter( + c => !c.startsWith("socialtracking") + ), + thirdParty: channel.urlClassification.thirdParty.filter( + c => !c.startsWith("socialtracking") + ), + }; + } + return data; + } + + /** + * This method (which is required by the nsIProtocolProxyService interface) + * is called to apply proxy filter rules for the given URI and proxy object + * (or list of proxy objects). + * + * @param {nsIChannel} channel The channel for which these proxy settings apply. + * @param {nsIProxyInfo} defaultProxyInfo The proxy (or list of proxies) that + * would be used by default for the given URI. This may be null. + * @param {nsIProxyProtocolFilterResult} proxyFilter + */ + async applyFilter(channel, defaultProxyInfo, proxyFilter) { + let proxyInfo; + try { + let wrapper = ChannelWrapper.get(channel); + + let browserData = { tabId: -1, windowId: -1 }; + if (wrapper.browserElement) { + browserData = lazy.tabTracker.getBrowserData(wrapper.browserElement); + } + + let { filter, extension } = this; + if (filter.tabId != null && browserData.tabId !== filter.tabId) { + return; + } + if (filter.windowId != null && browserData.windowId !== filter.windowId) { + return; + } + if ( + extension.userContextIsolation && + !extension.canAccessContainer( + channel.loadInfo?.originAttributes.userContextId + ) + ) { + return; + } + + let { policy } = this.extension; + if (wrapper.matches(filter, policy, { isProxy: true })) { + let data = this.getRequestData(wrapper, { tabId: browserData.tabId }); + + let ret = await this.listener(data); + if (ret == null) { + // If ret undefined or null, fall through to the `finally` block to apply the proxy result. + proxyInfo = ret; + return; + } + // We only accept proxyInfo objects, not the PAC strings. ProxyInfoData will + // accept either, so we want to enforce the limit here. + if (typeof ret !== "object") { + throw new ExtensionError( + "ProxyInfoData: proxyData must be an object or array of objects" + ); + } + // We allow the call to return either a single proxyInfo or an array of proxyInfo. + if (!Array.isArray(ret)) { + ret = [ret]; + } + proxyInfo = ProxyInfoData.createProxyInfoFromData( + policy, + ret, + defaultProxyInfo + ); + } + } catch (e) { + // We need to normalize errors to dispatch them to the extension handler. If + // we have not started up yet, we'll just log those to the console. + if (!this.context) { + this.extension.logError(`proxy-error before extension startup: ${e}`); + return; + } + let error = this.context.normalizeError(e); + this.extension.emit("proxy-error", { + message: error.message, + fileName: error.fileName, + lineNumber: error.lineNumber, + stack: error.stack, + }); + } finally { + // We must call onProxyFilterResult. proxyInfo may be null or nsIProxyInfo. + // defaultProxyInfo will be null unless a prior proxy handler has set something. + // If proxyInfo is null, that removes any prior proxy config. This allows a + // proxy extension to override higher level (e.g. prefs) config under certain + // circumstances. + proxyFilter.onProxyFilterResult( + proxyInfo !== undefined ? proxyInfo : defaultProxyInfo + ); + } + } + + destroy() { + lazy.ProxyService.unregisterFilter(this); + } +} diff --git a/toolkit/components/extensions/Schemas.sys.mjs b/toolkit/components/extensions/Schemas.sys.mjs new file mode 100644 index 0000000000..9107e6a347 --- /dev/null +++ b/toolkit/components/extensions/Schemas.sys.mjs @@ -0,0 +1,3942 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +var { DefaultMap, DefaultWeakMap } = ExtensionUtils; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", + ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "contentPolicyService", + "@mozilla.org/addons/content-policy;1", + "nsIAddonContentPolicy" +); + +ChromeUtils.defineLazyGetter( + lazy, + "StartupCache", + () => lazy.ExtensionParent.StartupCache +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "treatWarningsAsErrors", + "extensions.webextensions.warnings-as-errors", + false +); + +const KEY_CONTENT_SCHEMAS = "extensions-framework/schemas/content"; +const KEY_PRIVILEGED_SCHEMAS = "extensions-framework/schemas/privileged"; + +const MIN_MANIFEST_VERSION = 2; +const MAX_MANIFEST_VERSION = 3; + +const { DEBUG } = AppConstants; + +const isParentProcess = + Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT; + +function readJSON(url) { + return new Promise((resolve, reject) => { + lazy.NetUtil.asyncFetch( + { uri: url, loadUsingSystemPrincipal: true }, + (inputStream, status) => { + if (!Components.isSuccessCode(status)) { + // Convert status code to a string + let e = Components.Exception("", status); + reject(new Error(`Error while loading '${url}' (${e.name})`)); + return; + } + try { + let text = lazy.NetUtil.readInputStreamToString( + inputStream, + inputStream.available() + ); + + // Chrome JSON files include a license comment that we need to + // strip off for this to be valid JSON. As a hack, we just + // look for the first '[' character, which signals the start + // of the JSON content. + let index = text.indexOf("["); + text = text.slice(index); + + resolve(JSON.parse(text)); + } catch (e) { + reject(e); + } + } + ); + }); +} + +function stripDescriptions(json, stripThis = true) { + if (Array.isArray(json)) { + for (let i = 0; i < json.length; i++) { + if (typeof json[i] === "object" && json[i] !== null) { + json[i] = stripDescriptions(json[i]); + } + } + return json; + } + + let result = {}; + + // Objects are handled much more efficiently, both in terms of memory and + // CPU, if they have the same shape as other objects that serve the same + // purpose. So, normalize the order of properties to increase the chances + // that the majority of schema objects wind up in large shape groups. + for (let key of Object.keys(json).sort()) { + if (stripThis && key === "description" && typeof json[key] === "string") { + continue; + } + + if (typeof json[key] === "object" && json[key] !== null) { + result[key] = stripDescriptions(json[key], key !== "properties"); + } else { + result[key] = json[key]; + } + } + + return result; +} + +function blobbify(json) { + // We don't actually use descriptions at runtime, and they make up about a + // third of the size of our structured clone data, so strip them before + // blobbifying. + json = stripDescriptions(json); + + return new StructuredCloneHolder("Schemas/blobbify", null, json); +} + +async function readJSONAndBlobbify(url) { + let json = await readJSON(url); + + return blobbify(json); +} + +/** + * Defines a lazy getter for the given property on the given object. Any + * security wrappers are waived on the object before the property is + * defined, and the getter and setter methods are wrapped for the target + * scope. + * + * The given getter function is guaranteed to be called only once, even + * if the target scope retrieves the wrapped getter from the property + * descriptor and calls it directly. + * + * @param {object} object + * The object on which to define the getter. + * @param {string | symbol} prop + * The property name for which to define the getter. + * @param {Function} getter + * The function to call in order to generate the final property + * value. + */ +function exportLazyGetter(object, prop, getter) { + object = ChromeUtils.waiveXrays(object); + + let redefine = value => { + if (value === undefined) { + delete object[prop]; + } else { + Object.defineProperty(object, prop, { + enumerable: true, + configurable: true, + writable: true, + value, + }); + } + + getter = null; + + return value; + }; + + Object.defineProperty(object, prop, { + enumerable: true, + configurable: true, + + get: Cu.exportFunction(function () { + return redefine(getter.call(this)); + }, object), + + set: Cu.exportFunction(value => { + redefine(value); + }, object), + }); +} + +/** + * Defines a lazily-instantiated property descriptor on the given + * object. Any security wrappers are waived on the object before the + * property is defined. + * + * The given getter function is guaranteed to be called only once, even + * if the target scope retrieves the wrapped getter from the property + * descriptor and calls it directly. + * + * @param {object} object + * The object on which to define the getter. + * @param {string | symbol} prop + * The property name for which to define the getter. + * @param {Function} getter + * The function to call in order to generate the final property + * descriptor object. This will be called, and the property + * descriptor installed on the object, the first time the + * property is written or read. The function may return + * undefined, which will cause the property to be deleted. + */ +function exportLazyProperty(object, prop, getter) { + object = ChromeUtils.waiveXrays(object); + + let redefine = obj => { + let desc = getter.call(obj); + getter = null; + + delete object[prop]; + if (desc) { + let defaults = { + configurable: true, + enumerable: true, + }; + + if (!desc.set && !desc.get) { + defaults.writable = true; + } + + Object.defineProperty(object, prop, Object.assign(defaults, desc)); + } + }; + + Object.defineProperty(object, prop, { + enumerable: true, + configurable: true, + + get: Cu.exportFunction(function () { + redefine(this); + return object[prop]; + }, object), + + set: Cu.exportFunction(function (value) { + redefine(this); + object[prop] = value; + }, object), + }); +} + +const POSTPROCESSORS = { + convertImageDataToURL(imageData, context) { + let document = context.cloneScope.document; + let canvas = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "canvas" + ); + canvas.width = imageData.width; + canvas.height = imageData.height; + canvas.getContext("2d").putImageData(imageData, 0, 0); + + return canvas.toDataURL("image/png"); + }, + webRequestBlockingPermissionRequired(string, context) { + if (string === "blocking" && !context.hasPermission("webRequestBlocking")) { + throw new context.cloneScope.Error( + "Using webRequest.addListener with the " + + "blocking option requires the 'webRequestBlocking' permission." + ); + } + + return string; + }, + requireBackgroundServiceWorkerEnabled(value, context) { + if (WebExtensionPolicy.backgroundServiceWorkerEnabled) { + return value; + } + + // Add an error to the manifest validations and throw the + // same error. + const msg = "background.service_worker is currently disabled"; + context.logError(context.makeError(msg)); + throw new Error(msg); + }, + + manifestVersionCheck(value, context) { + if ( + value == 2 || + (value == 3 && + Services.prefs.getBoolPref("extensions.manifestV3.enabled", false)) + ) { + return value; + } + const msg = `Unsupported manifest version: ${value}`; + context.logError(context.makeError(msg)); + throw new Error(msg); + }, + + webAccessibleMatching(value, context) { + // Ensure each object has at least one of matches or extension_ids array. + for (let obj of value) { + if (!obj.matches && !obj.extension_ids) { + const msg = `web_accessible_resources requires one of "matches" or "extension_ids"`; + context.logError(context.makeError(msg)); + throw new Error(msg); + } + } + return value; + }, +}; + +// Parses a regular expression, with support for the Python extended +// syntax that allows setting flags by including the string (?im) +function parsePattern(pattern) { + let flags = ""; + let match = /^\(\?([im]*)\)(.*)/.exec(pattern); + if (match) { + [, flags, pattern] = match; + } + return new RegExp(pattern, flags); +} + +function getValueBaseType(value) { + let type = typeof value; + switch (type) { + case "object": + if (value === null) { + return "null"; + } + if (Array.isArray(value)) { + return "array"; + } + break; + + case "number": + if (value % 1 === 0) { + return "integer"; + } + } + return type; +} + +// Methods of Context that are used by Schemas.normalize. These methods can be +// overridden at the construction of Context. +const CONTEXT_FOR_VALIDATION = ["checkLoadURL", "hasPermission", "logError"]; + +// Methods of Context that are used by Schemas.inject. +// Callers of Schemas.inject should implement all of these methods. +const CONTEXT_FOR_INJECTION = [ + ...CONTEXT_FOR_VALIDATION, + "getImplementation", + "isPermissionRevokable", + "shouldInject", +]; + +// If the message is a function, call it and return the result. +// Otherwise, assume it's a string. +function forceString(msg) { + if (typeof msg === "function") { + return msg(); + } + return msg; +} + +/** + * A context for schema validation and error reporting. This class is only used + * internally within Schemas. + */ +class Context { + /** + * @param {object} params Provides the implementation of this class. + * @param {Array<string>} overridableMethods + */ + constructor(params, overridableMethods = CONTEXT_FOR_VALIDATION) { + this.params = params; + + if (typeof params.manifestVersion !== "number") { + throw new Error( + `Unexpected params.manifestVersion value: ${params.manifestVersion}` + ); + } + + this.path = []; + this.preprocessors = { + localize(value, context) { + return value; + }, + ...params.preprocessors, + }; + + this.postprocessors = POSTPROCESSORS; + this.isChromeCompat = params.isChromeCompat ?? false; + this.manifestVersion = params.manifestVersion; + + this.currentChoices = new Set(); + this.choicePathIndex = 0; + + for (let method of overridableMethods) { + if (method in params) { + this[method] = params[method].bind(params); + } + } + } + + get choicePath() { + let path = this.path.slice(this.choicePathIndex); + return path.join("."); + } + + get cloneScope() { + return this.params.cloneScope || undefined; + } + + get url() { + return this.params.url; + } + + get principal() { + return ( + this.params.principal || + Services.scriptSecurityManager.createNullPrincipal({}) + ); + } + + /** + * Checks whether `url` may be loaded by the extension in this context. + * + * @param {string} url The URL that the extension wished to load. + * @returns {boolean} Whether the context may load `url`. + */ + checkLoadURL(url) { + let ssm = Services.scriptSecurityManager; + try { + ssm.checkLoadURIWithPrincipal( + this.principal, + Services.io.newURI(url), + ssm.DISALLOW_INHERIT_PRINCIPAL + ); + } catch (e) { + return false; + } + return true; + } + + /** + * Checks whether this context has the given permission. + * + * @param {string} permission + * The name of the permission to check. + * + * @returns {boolean} True if the context has the given permission. + */ + hasPermission(permission) { + return false; + } + + /** + * Checks whether the given permission can be dynamically revoked or + * granted. + * + * @param {string} permission + * The name of the permission to check. + * + * @returns {boolean} True if the given permission is revokable. + */ + isPermissionRevokable(permission) { + return false; + } + + /** + * Returns an error result object with the given message, for return + * by Type normalization functions. + * + * If the context has a `currentTarget` value, this is prepended to + * the message to indicate the location of the error. + * + * @param {string | Function} errorMessage + * The error message which will be displayed when this is the + * only possible matching schema. If a function is passed, it + * will be evaluated when the error string is first needed, and + * must return a string. + * @param {string | Function} choicesMessage + * The message describing the valid what constitutes a valid + * value for this schema, which will be displayed when multiple + * schema choices are available and none match. + * + * A caller may pass `null` to prevent a choice from being + * added, but this should *only* be done from code processing a + * choices type. + * @param {boolean} [warning = false] + * If true, make message prefixed `Warning`. If false, make message + * prefixed `Error` + * @returns {object} + */ + error(errorMessage, choicesMessage = undefined, warning = false) { + if (choicesMessage !== null) { + let { choicePath } = this; + if (choicePath) { + choicesMessage = `.${choicePath} must ${choicesMessage}`; + } + + this.currentChoices.add(choicesMessage); + } + + if (this.currentTarget) { + let { currentTarget } = this; + return { + error: () => + `${ + warning ? "Warning" : "Error" + } processing ${currentTarget}: ${forceString(errorMessage)}`, + }; + } + return { error: errorMessage }; + } + + /** + * Creates an `Error` object belonging to the current unprivileged + * scope. If there is no unprivileged scope associated with this + * context, the message is returned as a string. + * + * If the context has a `currentTarget` value, this is prepended to + * the message, in the same way as for the `error` method. + * + * @param {string} message + * @param {object} [options] + * @param {boolean} [options.warning = false] + * @returns {Error} + */ + makeError(message, { warning = false } = {}) { + let error = forceString(this.error(message, null, warning).error); + if (this.cloneScope) { + return new this.cloneScope.Error(error); + } + return error; + } + + /** + * Logs the given error to the console. May be overridden to enable + * custom logging. + * + * @param {Error|string} error + */ + logError(error) { + if (this.cloneScope) { + Cu.reportError( + // Error objects logged using Cu.reportError are not associated + // to the related innerWindowID. This results in a leaked docshell + // since consoleService cannot release the error object when the + // extension global is destroyed. + typeof error == "string" ? error : String(error), + // Report the error with the appropriate stack trace when the + // is related to an actual extension global (instead of being + // related to a manifest validation). + this.principal && ChromeUtils.getCallerLocation(this.principal) + ); + } else { + Cu.reportError(error); + } + } + + /** + * Logs a warning. An error might be thrown when we treat warnings as errors. + * + * @param {string} warningMessage + */ + logWarning(warningMessage) { + let error = this.makeError(warningMessage, { warning: true }); + this.logError(error); + + if (lazy.treatWarningsAsErrors) { + // This pref is false by default, and true by default in tests to + // discourage the use of deprecated APIs in our unit tests. + // If a warning is an expected part of a test, temporarily set the pref + // to false, e.g. with the ExtensionTestUtils.failOnSchemaWarnings helper. + Services.console.logStringMessage( + "Treating warning as error because the preference " + + "extensions.webextensions.warnings-as-errors is set to true" + ); + if (typeof error === "string") { + error = new Error(error); + } + throw error; + } + } + + /** + * Returns the name of the value currently being normalized. For a + * nested object, this is usually approximately equivalent to the + * JavaScript property accessor for that property. Given: + * + * { foo: { bar: [{ baz: x }] } } + * + * When processing the value for `x`, the currentTarget is + * 'foo.bar.0.baz' + */ + get currentTarget() { + return this.path.join("."); + } + + /** + * Executes the given callback, and returns an array of choice strings + * passed to {@see #error} during its execution. + * + * @param {Function} callback + * @returns {object} + * An object with a `result` property containing the return + * value of the callback, and a `choice` property containing + * an array of choices. + */ + withChoices(callback) { + let { currentChoices, choicePathIndex } = this; + + let choices = new Set(); + this.currentChoices = choices; + this.choicePathIndex = this.path.length; + + try { + let result = callback(); + + return { result, choices }; + } finally { + this.currentChoices = currentChoices; + this.choicePathIndex = choicePathIndex; + + if (choices.size == 1) { + for (let choice of choices) { + currentChoices.add(choice); + } + } else if (choices.size) { + this.error(null, () => { + let array = Array.from(choices, forceString); + let n = array.length - 1; + array[n] = `or ${array[n]}`; + + return `must either [${array.join(", ")}]`; + }); + } + } + } + + /** + * Appends the given component to the `currentTarget` path to indicate + * that it is being processed, calls the given callback function, and + * then restores the original path. + * + * This is used to identify the path of the property being processed + * when reporting type errors. + * + * @param {string} component + * @param {Function} callback + * @returns {*} + */ + withPath(component, callback) { + this.path.push(component); + try { + return callback(); + } finally { + this.path.pop(); + } + } + + matchManifestVersion(entry) { + let { manifestVersion } = this; + return ( + manifestVersion >= entry.min_manifest_version && + manifestVersion <= entry.max_manifest_version + ); + } +} + +/** + * Represents a schema entry to be injected into an object. Handles the + * injection, revocation, and permissions of said entry. + * + * @param {InjectionContext} context + * The injection context for the entry. + * @param {Entry} entry + * The entry to inject. + * @param {object} parentObject + * The object into which to inject this entry. + * @param {string} name + * The property name at which to inject this entry. + * @param {Array<string>} path + * The full path from the root entry to this entry. + * @param {Entry} parentEntry + * The parent entry for the injected entry. + */ +class InjectionEntry { + constructor(context, entry, parentObj, name, path, parentEntry) { + this.context = context; + this.entry = entry; + this.parentObj = parentObj; + this.name = name; + this.path = path; + this.parentEntry = parentEntry; + + this.injected = null; + this.lazyInjected = null; + } + + /** + * @property {Array<string>} allowedContexts + * The list of allowed contexts into which the entry may be + * injected. + */ + get allowedContexts() { + let { allowedContexts } = this.entry; + if (allowedContexts.length) { + return allowedContexts; + } + return this.parentEntry.defaultContexts; + } + + /** + * @property {boolean} isRevokable + * Returns true if this entry may be dynamically injected or + * revoked based on its permissions. + */ + get isRevokable() { + return ( + this.entry.permissions && + this.entry.permissions.some(perm => + this.context.isPermissionRevokable(perm) + ) + ); + } + + /** + * @property {boolean} hasPermission + * Returns true if the injection context currently has the + * appropriate permissions to access this entry. + */ + get hasPermission() { + return ( + !this.entry.permissions || + this.entry.permissions.some(perm => this.context.hasPermission(perm)) + ); + } + + /** + * @property {boolean} shouldInject + * Returns true if this entry should be injected in the given + * context, without respect to permissions. + */ + get shouldInject() { + return ( + this.context.matchManifestVersion(this.entry) && + this.context.shouldInject( + this.path.join("."), + this.name, + this.allowedContexts + ) + ); + } + + /** + * Revokes this entry, removing its property from its parent object, + * and invalidating its wrappers. + */ + revoke() { + if (this.lazyInjected) { + this.lazyInjected = false; + } else if (this.injected) { + if (this.injected.revoke) { + this.injected.revoke(); + } + + try { + let unwrapped = ChromeUtils.waiveXrays(this.parentObj); + delete unwrapped[this.name]; + } catch (e) { + Cu.reportError(e); + } + + let { value } = this.injected.descriptor; + if (value) { + this.context.revokeChildren(value); + } + + this.injected = null; + } + } + + /** + * Returns a property descriptor object for this entry, if it should + * be injected, or undefined if it should not. + * + * @returns {object?} + * A property descriptor object, or undefined if the property + * should be removed. + */ + getDescriptor() { + this.lazyInjected = false; + + if (this.injected) { + let path = [...this.path, this.name]; + throw new Error( + `Attempting to re-inject already injected entry: ${path.join(".")}` + ); + } + + if (!this.shouldInject) { + return; + } + + if (this.isRevokable) { + this.context.pendingEntries.add(this); + } + + if (!this.hasPermission) { + return; + } + + this.injected = this.entry.getDescriptor(this.path, this.context); + if (!this.injected) { + return undefined; + } + + return this.injected.descriptor; + } + + /** + * Injects a lazy property descriptor into the parent object which + * checks permissions and eligibility for injection the first time it + * is accessed. + */ + lazyInject() { + if (this.lazyInjected || this.injected) { + let path = [...this.path, this.name]; + throw new Error( + `Attempting to re-lazy-inject already injected entry: ${path.join(".")}` + ); + } + + this.lazyInjected = true; + exportLazyProperty(this.parentObj, this.name, () => { + if (this.lazyInjected) { + return this.getDescriptor(); + } + }); + } + + /** + * Injects or revokes this entry if its current state does not match + * the context's current permissions. + */ + permissionsChanged() { + if (this.injected) { + this.maybeRevoke(); + } else { + this.maybeInject(); + } + } + + maybeInject() { + if (!this.injected && !this.lazyInjected) { + this.lazyInject(); + } + } + + maybeRevoke() { + if (this.injected && !this.hasPermission) { + this.revoke(); + } + } +} + +/** + * Holds methods that run the actual implementation of the extension APIs. These + * methods are only called if the extension API invocation matches the signature + * as defined in the schema. Otherwise an error is reported to the context. + */ +class InjectionContext extends Context { + constructor(params, schemaRoot) { + super(params, CONTEXT_FOR_INJECTION); + + this.schemaRoot = schemaRoot; + + this.pendingEntries = new Set(); + this.children = new DefaultWeakMap(() => new Map()); + + this.injectedRoots = new Set(); + + if (params.setPermissionsChangedCallback) { + params.setPermissionsChangedCallback(this.permissionsChanged.bind(this)); + } + } + + /** + * Check whether the API should be injected. + * + * @abstract + * @param {string} namespace The namespace of the API. This may contain dots, + * e.g. in the case of "devtools.inspectedWindow". + * @param {string?} name The name of the property in the namespace. + * `null` if we are checking whether the namespace should be injected. + * @param {Array<string>} allowedContexts A list of additional contexts in + * which this API should be available. May include any of: + * "main" - The main chrome browser process. + * "addon" - An addon process. + * "content" - A content process. + * @returns {boolean} Whether the API should be injected. + */ + shouldInject(namespace, name, allowedContexts) { + throw new Error("Not implemented"); + } + + /** + * Generate the implementation for `namespace`.`name`. + * + * @abstract + * @param {string} namespace The full path to the namespace of the API, minus + * the name of the method or property. E.g. "storage.local". + * @param {string} name The name of the method, property or event. + * @returns {SchemaAPIInterface} The implementation of the API. + */ + getImplementation(namespace, name) { + throw new Error("Not implemented"); + } + + /** + * Updates all injection entries which may need to be updated after a + * permission change, revoking or re-injecting them as necessary. + */ + permissionsChanged() { + for (let entry of this.pendingEntries) { + try { + entry.permissionsChanged(); + } catch (e) { + Cu.reportError(e); + } + } + } + + /** + * Recursively revokes all child injection entries of the given + * object. + * + * @param {object} object + * The object for which to invoke children. + */ + revokeChildren(object) { + if (!this.children.has(object)) { + return; + } + + let children = this.children.get(object); + for (let [name, entry] of children.entries()) { + try { + entry.revoke(); + } catch (e) { + Cu.reportError(e); + } + children.delete(name); + + // When we revoke children for an object, we consider that object + // dead. If the entry is ever reified again, a new object is + // created, with new child entries. + this.pendingEntries.delete(entry); + } + this.children.delete(object); + } + + _getInjectionEntry(entry, dest, name, path, parentEntry) { + let injection = new InjectionEntry( + this, + entry, + dest, + name, + path, + parentEntry + ); + + this.children.get(dest).set(name, injection); + + return injection; + } + + /** + * Returns the property descriptor for the given entry. + * + * @param {Entry} entry + * The entry instance to return a descriptor for. + * @param {object} dest + * The object into which this entry is being injected. + * @param {string} name + * The property name on the destination object where the entry + * will be injected. + * @param {Array<string>} path + * The full path from the root injection object to this entry. + * @param {Partial<Entry>} parentEntry + * The parent entry for this entry. + * + * @returns {object?} + * A property descriptor object, or null if the entry should + * not be injected. + */ + getDescriptor(entry, dest, name, path, parentEntry) { + let injection = this._getInjectionEntry( + entry, + dest, + name, + path, + parentEntry + ); + + return injection.getDescriptor(); + } + + /** + * Lazily injects the given entry into the given object. + * + * @param {Entry} entry + * The entry instance to lazily inject. + * @param {object} dest + * The object into which to inject this entry. + * @param {string} name + * The property name at which to inject the entry. + * @param {Array<string>} path + * The full path from the root injection object to this entry. + * @param {Entry} parentEntry + * The parent entry for this entry. + */ + injectInto(entry, dest, name, path, parentEntry) { + let injection = this._getInjectionEntry( + entry, + dest, + name, + path, + parentEntry + ); + + injection.lazyInject(); + } +} + +/** + * The methods in this singleton represent the "format" specifier for + * JSON Schema string types. + * + * Each method either returns a normalized version of the original + * value, or throws an error if the value is not valid for the given + * format. + */ +const FORMATS = { + hostname(string, context) { + // TODO bug 1797376: Despite the name, this format is NOT a "hostname", + // but hostname + port and may fail with IPv6. Use canonicalDomain instead. + let valid = true; + + try { + valid = new URL(`http://${string}`).host === string; + } catch (e) { + valid = false; + } + + if (!valid) { + throw new Error(`Invalid hostname ${string}`); + } + + return string; + }, + + canonicalDomain(string, context) { + let valid; + + try { + valid = new URL(`http://${string}`).hostname === string; + } catch (e) { + valid = false; + } + + if (!valid) { + // Require the input to be a canonical domain. + // Rejects obvious non-domains such as URLs, + // but also catches non-IDN (punycode) domains. + throw new Error(`Invalid domain ${string}`); + } + + return string; + }, + + url(string, context) { + let url = new URL(string).href; + + if (!context.checkLoadURL(url)) { + throw new Error(`Access denied for URL ${url}`); + } + return url; + }, + + origin(string, context) { + let url; + try { + url = new URL(string); + } catch (e) { + throw new Error(`Invalid origin: ${string}`); + } + if (!/^https?:/.test(url.protocol)) { + throw new Error(`Invalid origin must be http or https for URL ${string}`); + } + // url.origin is punycode so a direct check against string wont work. + // url.href appends a slash even if not in the original string, we we + // additionally check that string does not end in slash. + if (string.endsWith("/") || url.href != new URL(url.origin).href) { + throw new Error( + `Invalid origin for URL ${string}, replace with origin ${url.origin}` + ); + } + if (!context.checkLoadURL(url.origin)) { + throw new Error(`Access denied for URL ${url}`); + } + return url.origin; + }, + + relativeUrl(string, context) { + if (!context.url) { + // If there's no context URL, return relative URLs unresolved, and + // skip security checks for them. + try { + new URL(string); + } catch (e) { + return string; + } + } + + let url = new URL(string, context.url).href; + + if (!context.checkLoadURL(url)) { + throw new Error(`Access denied for URL ${url}`); + } + return url; + }, + + strictRelativeUrl(string, context) { + void FORMATS.unresolvedRelativeUrl(string, context); + return FORMATS.relativeUrl(string, context); + }, + + unresolvedRelativeUrl(string, context) { + if (!string.startsWith("//")) { + try { + new URL(string); + } catch (e) { + return string; + } + } + + throw new SyntaxError( + `String ${JSON.stringify(string)} must be a relative URL` + ); + }, + + homepageUrl(string, context) { + // Pipes are used for separating homepages, but we only allow extensions to + // set a single homepage. Encoding any pipes makes it one URL. + return FORMATS.relativeUrl( + string.replace(new RegExp("\\|", "g"), "%7C"), + context + ); + }, + + imageDataOrStrictRelativeUrl(string, context) { + // Do not accept a string which resolves as an absolute URL, or any + // protocol-relative URL, except PNG or JPG data URLs + if ( + !string.startsWith("data:image/png;base64,") && + !string.startsWith("data:image/jpeg;base64,") + ) { + try { + return FORMATS.strictRelativeUrl(string, context); + } catch (e) { + throw new SyntaxError( + `String ${JSON.stringify( + string + )} must be a relative or PNG or JPG data:image URL` + ); + } + } + return string; + }, + + contentSecurityPolicy(string, context) { + // Manifest V3 extension_pages allows WASM. When sandbox is + // implemented, or any other V3 or later directive, the flags + // logic will need to be updated. + let flags = + context.manifestVersion < 3 + ? Ci.nsIAddonContentPolicy.CSP_ALLOW_ANY + : Ci.nsIAddonContentPolicy.CSP_ALLOW_WASM; + let error = lazy.contentPolicyService.validateAddonCSP(string, flags); + if (error != null) { + // The CSP validation error is not reported as part of the "choices" error message, + // we log the CSP validation error explicitly here to make it easier for the addon developers + // to see and fix the extension CSP. + context.logError(`Error processing ${context.currentTarget}: ${error}`); + return null; + } + return string; + }, + + date(string, context) { + // A valid ISO 8601 timestamp. + const PATTERN = + /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{3})?(Z|([-+]\d{2}:?\d{2})))?$/; + if (!PATTERN.test(string)) { + throw new Error(`Invalid date string ${string}`); + } + // Our pattern just checks the format, we could still have invalid + // values (e.g., month=99 or month=02 and day=31). Let the Date + // constructor do the dirty work of validating. + if (isNaN(Date.parse(string))) { + throw new Error(`Invalid date string ${string}`); + } + return string; + }, + + manifestShortcutKey(string, context) { + if (lazy.ShortcutUtils.validate(string) == lazy.ShortcutUtils.IS_VALID) { + return string; + } + let errorMessage = + `Value "${string}" must consist of ` + + `either a combination of one or two modifiers, including ` + + `a mandatory primary modifier and a key, separated by '+', ` + + `or a media key. For details see: ` + + `https://developer.mozilla.org/en-US/Add-ons/WebExtensions/manifest.json/commands#Key_combinations`; + throw new Error(errorMessage); + }, + + manifestShortcutKeyOrEmpty(string, context) { + return string === "" ? "" : FORMATS.manifestShortcutKey(string, context); + }, + + versionString(string, context) { + const parts = string.split("."); + + if ( + // We accept up to 4 numbers. + parts.length > 4 || + // Non-zero values cannot start with 0 and we allow numbers up to 9 digits. + parts.some(part => !/^(0|[1-9][0-9]{0,8})$/.test(part)) + ) { + context.logWarning( + `version must be a version string consisting of at most 4 integers ` + + `of at most 9 digits without leading zeros, and separated with dots` + ); + } + + // The idea is to only emit a warning when the version string does not + // match the simple format we want to encourage developers to use. Given + // the version is required, we always accept the value as is. + return string; + }, +}; + +// Schema files contain namespaces, and each namespace contains types, +// properties, functions, and events. An Entry is a base class for +// types, properties, functions, and events. +class Entry { + constructor(schema = {}) { + /** + * If set to any value which evaluates as true, this entry is + * deprecated, and any access to it will result in a deprecation + * warning being logged to the browser console. + * + * If the value is a string, it will be appended to the deprecation + * message. If it contains the substring "${value}", it will be + * replaced with a string representation of the value being + * processed. + * + * If the value is any other truthy value, a generic deprecation + * message will be emitted. + */ + this.deprecated = false; + if ("deprecated" in schema) { + this.deprecated = schema.deprecated; + } + + /** + * @property {string} [preprocessor] + * If set to a string value, and a preprocessor of the same is + * defined in the validation context, it will be applied to this + * value prior to any normalization. + */ + this.preprocessor = schema.preprocess || null; + + /** + * @property {string} [postprocessor] + * If set to a string value, and a postprocessor of the same is + * defined in the validation context, it will be applied to this + * value after any normalization. + */ + this.postprocessor = schema.postprocess || null; + + /** + * @property {Array<string>} allowedContexts A list of allowed contexts + * to consider before generating the API. + * These are not parsed by the schema, but passed to `shouldInject`. + */ + this.allowedContexts = schema.allowedContexts || []; + + this.min_manifest_version = + schema.min_manifest_version ?? MIN_MANIFEST_VERSION; + this.max_manifest_version = + schema.max_manifest_version ?? MAX_MANIFEST_VERSION; + } + + /** + * Preprocess the given value with the preprocessor declared in + * `preprocessor`. + * + * @param {*} value + * @param {Context} context + * @returns {*} + */ + preprocess(value, context) { + if (this.preprocessor) { + return context.preprocessors[this.preprocessor](value, context); + } + return value; + } + + /** + * Postprocess the given result with the postprocessor declared in + * `postprocessor`. + * + * @param {object} result + * @param {Context} context + * @returns {object} + */ + postprocess(result, context) { + if (result.error || !this.postprocessor) { + return result; + } + + let value = context.postprocessors[this.postprocessor]( + result.value, + context + ); + return { value }; + } + + /** + * Logs a deprecation warning for this entry, based on the value of + * its `deprecated` property. + * + * @param {Context} context + * @param {any} [value] + */ + logDeprecation(context, value = null) { + let message = "This property is deprecated"; + if (typeof this.deprecated == "string") { + message = this.deprecated; + if (message.includes("${value}")) { + try { + value = JSON.stringify(value); + } catch (e) { + value = String(value); + } + message = message.replace(/\$\{value\}/g, () => value); + } + } + + context.logWarning(message); + } + + /** + * Checks whether the entry is deprecated and, if so, logs a + * deprecation message. + * + * @param {Context} context + * @param {any} [value] + */ + checkDeprecated(context, value = null) { + if (this.deprecated) { + this.logDeprecation(context, value); + } + } + + /** + * Returns an object containing property descriptor for use when + * injecting this entry into an API object. + * + * @param {Array<string>} path The API path, e.g. `["storage", "local"]`. + * @param {InjectionContext} context + * + * @returns {object?} + * An object containing a `descriptor` property, specifying the + * entry's property descriptor, and an optional `revoke` + * method, to be called when the entry is being revoked. + */ + getDescriptor(path, context) { + return undefined; + } +} + +// Corresponds either to a type declared in the "types" section of the +// schema or else to any type object used throughout the schema. +class Type extends Entry { + /** + * @property {Array<string>} EXTRA_PROPERTIES + * An array of extra properties which may be present for + * schemas of this type. + */ + static get EXTRA_PROPERTIES() { + return [ + "description", + "deprecated", + "preprocess", + "postprocess", + "privileged", + "allowedContexts", + "min_manifest_version", + "max_manifest_version", + ]; + } + + /** + * Parses the given schema object and returns an instance of this + * class which corresponds to its properties. + * + * @param {SchemaRoot} root + * The root schema for this type. + * @param {object} schema + * A JSON schema object which corresponds to a definition of + * this type. + * @param {Array<string>} path + * The path to this schema object from the root schema, + * corresponding to the property names and array indices + * traversed during parsing in order to arrive at this schema + * object. + * @param {Array<string>} [extraProperties] + * An array of extra property names which are valid for this + * schema in the current context. + * @returns {Type} + * An instance of this type which corresponds to the given + * schema object. + * @static + */ + static parseSchema(root, schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + + return new this(schema); + } + + /** + * Checks that all of the properties present in the given schema + * object are valid properties for this type, and throws if invalid. + * + * @param {object} schema + * A JSON schema object. + * @param {Array<string>} path + * The path to this schema object from the root schema, + * corresponding to the property names and array indices + * traversed during parsing in order to arrive at this schema + * object. + * @param {Iterable<string>} [extra] + * An array of extra property names which are valid for this + * schema in the current context. + * @throws {Error} + * An error describing the first invalid property found in the + * schema object. + */ + static checkSchemaProperties(schema, path, extra = []) { + if (DEBUG) { + let allowedSet = new Set([...this.EXTRA_PROPERTIES, ...extra]); + + for (let prop of Object.keys(schema)) { + if (!allowedSet.has(prop)) { + throw new Error( + `Internal error: Namespace ${path.join(".")} has ` + + `invalid type property "${prop}" ` + + `in type "${schema.id || JSON.stringify(schema)}"` + ); + } + } + } + } + + // Takes a value, checks that it has the correct type, and returns a + // "normalized" version of the value. The normalized version will + // include "nulls" in place of omitted optional properties. The + // result of this function is either {error: "Some type error"} or + // {value: <normalized-value>}. + normalize(value, context) { + return context.error("invalid type"); + } + + // Unlike normalize, this function does a shallow check to see if + // |baseType| (one of the possible getValueBaseType results) is + // valid for this type. It returns true or false. It's used to fill + // in optional arguments to functions before actually type checking + + checkBaseType(baseType) { + return false; + } + + // Helper method that simply relies on checkBaseType to implement + // normalize. Subclasses can choose to use it or not. + normalizeBase(type, value, context) { + if (this.checkBaseType(getValueBaseType(value))) { + this.checkDeprecated(context, value); + return { value: this.preprocess(value, context) }; + } + + let choice; + if ("aeiou".includes(type[0])) { + choice = `be an ${type} value`; + } else { + choice = `be a ${type} value`; + } + + return context.error( + () => `Expected ${type} instead of ${JSON.stringify(value)}`, + choice + ); + } +} + +// Type that allows any value. +class AnyType extends Type { + normalize(value, context) { + this.checkDeprecated(context, value); + return this.postprocess({ value }, context); + } + + checkBaseType(baseType) { + return true; + } +} + +// An untagged union type. +class ChoiceType extends Type { + static get EXTRA_PROPERTIES() { + return ["choices", ...super.EXTRA_PROPERTIES]; + } + + /** @type {(root, schema, path, extraProperties?: Iterable) => ChoiceType} */ + static parseSchema(root, schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + + let choices = schema.choices.map(t => root.parseSchema(t, path)); + return new this(schema, choices); + } + + constructor(schema, choices) { + super(schema); + this.choices = choices; + } + + extend(type) { + this.choices.push(...type.choices); + + return this; + } + + normalize(value, context) { + this.checkDeprecated(context, value); + + let error; + let { choices, result } = context.withChoices(() => { + for (let choice of this.choices) { + // Ignore a possible choice if it is not supported by + // the manifest version we are normalizing. + if (!context.matchManifestVersion(choice)) { + continue; + } + + let r = choice.normalize(value, context); + if (!r.error) { + return r; + } + + error = r; + } + }); + + if (result) { + return result; + } + if (choices.size <= 1) { + return error; + } + + choices = Array.from(choices, forceString); + let n = choices.length - 1; + choices[n] = `or ${choices[n]}`; + + let message; + if (typeof value === "object") { + message = () => `Value must either: ${choices.join(", ")}`; + } else { + message = () => + `Value ${JSON.stringify(value)} must either: ${choices.join(", ")}`; + } + + return context.error(message, null); + } + + checkBaseType(baseType) { + return this.choices.some(t => t.checkBaseType(baseType)); + } + + getDescriptor(path, context) { + // In StringType.getDescriptor, unlike any other Type, a descriptor is returned if + // it is an enumeration. Since we need versioned choices in some cases, here we + // build a list of valid enumerations that will work for a given manifest version. + if ( + !this.choices.length || + !this.choices.every(t => t.checkBaseType("string") && t.enumeration) + ) { + return; + } + + let obj = Cu.createObjectIn(context.cloneScope); + let descriptor = { value: obj }; + for (let choice of this.choices) { + // Ignore a possible choice if it is not supported by + // the manifest version we are normalizing. + if (!context.matchManifestVersion(choice)) { + continue; + } + let d = choice.getDescriptor(path, context); + if (d) { + Object.assign(obj, d.descriptor.value); + } + } + + return { descriptor }; + } +} + +// This is a reference to another type--essentially a typedef. +class RefType extends Type { + static get EXTRA_PROPERTIES() { + return ["$ref", ...super.EXTRA_PROPERTIES]; + } + + /** @type {(root, schema, path, extraProperties?: Iterable) => RefType} */ + static parseSchema(root, schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + + let ref = schema.$ref; + let ns = path.join("."); + if (ref.includes(".")) { + [, ns, ref] = /^(.*)\.(.*?)$/.exec(ref); + } + return new this(root, schema, ns, ref); + } + + // For a reference to a type named T declared in namespace NS, + // namespaceName will be NS and reference will be T. + constructor(root, schema, namespaceName, reference) { + super(schema); + this.root = root; + this.namespaceName = namespaceName; + this.reference = reference; + } + + get targetType() { + let ns = this.root.getNamespace(this.namespaceName); + let type = ns.get(this.reference); + if (!type) { + throw new Error(`Internal error: Type ${this.reference} not found`); + } + return type; + } + + normalize(value, context) { + this.checkDeprecated(context, value); + return this.targetType.normalize(value, context); + } + + checkBaseType(baseType) { + return this.targetType.checkBaseType(baseType); + } +} + +class StringType extends Type { + static get EXTRA_PROPERTIES() { + return [ + "enum", + "minLength", + "maxLength", + "pattern", + "format", + ...super.EXTRA_PROPERTIES, + ]; + } + + static parseSchema(root, schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + + let enumeration = schema.enum || null; + if (enumeration) { + // The "enum" property is either a list of strings that are + // valid values or else a list of {name, description} objects, + // where the .name values are the valid values. + enumeration = enumeration.map(e => { + if (typeof e == "object") { + return e.name; + } + return e; + }); + } + + let pattern = null; + if (schema.pattern) { + try { + pattern = parsePattern(schema.pattern); + } catch (e) { + throw new Error( + `Internal error: Invalid pattern ${JSON.stringify(schema.pattern)}` + ); + } + } + + let format = null; + if (schema.format) { + if (!(schema.format in FORMATS)) { + throw new Error( + `Internal error: Invalid string format ${schema.format}` + ); + } + format = FORMATS[schema.format]; + } + return new this( + schema, + schema.id || undefined, + enumeration, + schema.minLength || 0, + schema.maxLength || Infinity, + pattern, + format + ); + } + + constructor( + schema, + name, + enumeration, + minLength, + maxLength, + pattern, + format + ) { + super(schema); + this.name = name; + this.enumeration = enumeration; + this.minLength = minLength; + this.maxLength = maxLength; + this.pattern = pattern; + this.format = format; + } + + normalize(value, context) { + let r = this.normalizeBase("string", value, context); + if (r.error) { + return r; + } + value = r.value; + + if (this.enumeration) { + if (this.enumeration.includes(value)) { + return this.postprocess({ value }, context); + } + + let choices = this.enumeration.map(JSON.stringify).join(", "); + + return context.error( + () => `Invalid enumeration value ${JSON.stringify(value)}`, + `be one of [${choices}]` + ); + } + + if (value.length < this.minLength) { + return context.error( + () => + `String ${JSON.stringify(value)} is too short (must be ${ + this.minLength + })`, + `be longer than ${this.minLength}` + ); + } + if (value.length > this.maxLength) { + return context.error( + () => + `String ${JSON.stringify(value)} is too long (must be ${ + this.maxLength + })`, + `be shorter than ${this.maxLength}` + ); + } + + if (this.pattern && !this.pattern.test(value)) { + return context.error( + () => `String ${JSON.stringify(value)} must match ${this.pattern}`, + `match the pattern ${this.pattern.toSource()}` + ); + } + + if (this.format) { + try { + r.value = this.format(r.value, context); + } catch (e) { + return context.error( + String(e), + `match the format "${this.format.name}"` + ); + } + } + + return r; + } + + checkBaseType(baseType) { + return baseType == "string"; + } + + getDescriptor(path, context) { + if (this.enumeration) { + let obj = Cu.createObjectIn(context.cloneScope); + + for (let e of this.enumeration) { + obj[e.toUpperCase()] = e; + } + + return { + descriptor: { value: obj }, + }; + } + } +} + +class NullType extends Type { + normalize(value, context) { + return this.normalizeBase("null", value, context); + } + + checkBaseType(baseType) { + return baseType == "null"; + } +} + +let FunctionEntry; +let Event; +let SubModuleType; + +class ObjectType extends Type { + static get EXTRA_PROPERTIES() { + return [ + "properties", + "patternProperties", + "$import", + ...super.EXTRA_PROPERTIES, + ]; + } + + static parseSchema(root, schema, path, extraProperties = []) { + if ("functions" in schema) { + return SubModuleType.parseSchema(root, schema, path, extraProperties); + } + + if (DEBUG && !("$extend" in schema)) { + // Only allow extending "properties" and "patternProperties". + extraProperties = [ + "additionalProperties", + "isInstanceOf", + ...extraProperties, + ]; + } + this.checkSchemaProperties(schema, path, extraProperties); + + let imported = null; + if ("$import" in schema) { + let importPath = schema.$import; + let idx = importPath.indexOf("."); + if (idx === -1) { + imported = [path[0], importPath]; + } else { + imported = [importPath.slice(0, idx), importPath.slice(idx + 1)]; + } + } + + let parseProperty = (schema, extraProps = []) => { + return { + type: root.parseSchema( + schema, + path, + DEBUG && [ + "unsupported", + "onError", + "permissions", + "default", + ...extraProps, + ] + ), + optional: schema.optional || false, + unsupported: schema.unsupported || false, + onError: schema.onError || null, + default: schema.default === undefined ? null : schema.default, + }; + }; + + // Parse explicit "properties" object. + let properties = Object.create(null); + for (let propName of Object.keys(schema.properties || {})) { + properties[propName] = parseProperty(schema.properties[propName], [ + "optional", + ]); + } + + // Parse regexp properties from "patternProperties" object. + let patternProperties = []; + for (let propName of Object.keys(schema.patternProperties || {})) { + let pattern; + try { + pattern = parsePattern(propName); + } catch (e) { + throw new Error( + `Internal error: Invalid property pattern ${JSON.stringify(propName)}` + ); + } + + patternProperties.push({ + pattern, + type: parseProperty(schema.patternProperties[propName]), + }); + } + + // Parse "additionalProperties" schema. + let additionalProperties = null; + if (schema.additionalProperties) { + let type = schema.additionalProperties; + if (type === true) { + type = { type: "any" }; + } + + additionalProperties = root.parseSchema(type, path); + } + + return new this( + schema, + properties, + additionalProperties, + patternProperties, + schema.isInstanceOf || null, + imported + ); + } + + constructor( + schema, + properties, + additionalProperties, + patternProperties, + isInstanceOf, + imported + ) { + super(schema); + this.properties = properties; + this.additionalProperties = additionalProperties; + this.patternProperties = patternProperties; + this.isInstanceOf = isInstanceOf; + + if (imported) { + let [ns, path] = imported; + ns = Schemas.getNamespace(ns); + let importedType = ns.get(path); + if (!importedType) { + throw new Error(`Internal error: imported type ${path} not found`); + } + + if (DEBUG && !(importedType instanceof ObjectType)) { + throw new Error( + `Internal error: cannot import non-object type ${path}` + ); + } + + this.properties = Object.assign( + {}, + importedType.properties, + this.properties + ); + this.patternProperties = [ + ...importedType.patternProperties, + ...this.patternProperties, + ]; + this.additionalProperties = + importedType.additionalProperties || this.additionalProperties; + } + } + + extend(type) { + for (let key of Object.keys(type.properties)) { + if (key in this.properties) { + throw new Error( + `InternalError: Attempt to extend an object with conflicting property "${key}"` + ); + } + this.properties[key] = type.properties[key]; + } + + this.patternProperties.push(...type.patternProperties); + + return this; + } + + checkBaseType(baseType) { + return baseType == "object"; + } + + /** + * Extracts the enumerable properties of the given object, including + * function properties which would normally be omitted by X-ray + * wrappers. + * + * @param {object} value + * @param {Context} context + * The current parse context. + * @returns {object} + * An object with an `error` or `value` property. + */ + extractProperties(value, context) { + // |value| should be a JS Xray wrapping an object in the + // extension compartment. This works well except when we need to + // access callable properties on |value| since JS Xrays don't + // support those. To work around the problem, we verify that + // |value| is a plain JS object (i.e., not anything scary like a + // Proxy). Then we copy the properties out of it into a normal + // object using a waiver wrapper. + + let klass = ChromeUtils.getClassName(value, true); + if (klass != "Object") { + throw context.error( + `Expected a plain JavaScript object, got a ${klass}`, + `be a plain JavaScript object` + ); + } + + return ChromeUtils.shallowClone(value); + } + + checkProperty(context, prop, propType, result, properties, remainingProps) { + let { type, optional, unsupported, onError } = propType; + let error = null; + + if (!context.matchManifestVersion(type)) { + if (prop in properties) { + error = context.error( + `Property "${prop}" is unsupported in Manifest Version ${context.manifestVersion}`, + `not contain an unsupported "${prop}" property` + ); + + context.logWarning(forceString(error.error)); + if (this.additionalProperties) { + // When `additionalProperties` is set to UnrecognizedProperty, the + // caller (i.e. ObjectType's normalize method) assigns the original + // value to `result[prop]`. Erase the property now to prevent + // `result[prop]` from becoming anything other than `undefined. + // + // A warning was already logged above, so we do not need to also log + // "An unexpected property was found in the WebExtension manifest." + remainingProps.delete(prop); + } + // When `additionalProperties` is not set, ObjectType's normalize method + // will return an error because prop is still in remainingProps. + return; + } + } else if (unsupported) { + if (prop in properties) { + error = context.error( + `Property "${prop}" is unsupported by Firefox`, + `not contain an unsupported "${prop}" property` + ); + } + } else if (prop in properties) { + if ( + optional && + (properties[prop] === null || properties[prop] === undefined) + ) { + result[prop] = propType.default; + } else { + let r = context.withPath(prop, () => + type.normalize(properties[prop], context) + ); + if (r.error) { + error = r; + } else { + result[prop] = r.value; + properties[prop] = r.value; + } + } + remainingProps.delete(prop); + } else if (!optional) { + error = context.error( + `Property "${prop}" is required`, + `contain the required "${prop}" property` + ); + } else if (optional !== "omit-key-if-missing") { + result[prop] = propType.default; + } + + if (error) { + if (onError == "warn") { + context.logWarning(forceString(error.error)); + } else if (onError != "ignore") { + throw error; + } + + result[prop] = propType.default; + } + } + + normalize(value, context) { + try { + let v = this.normalizeBase("object", value, context); + if (v.error) { + return v; + } + value = v.value; + + if (this.isInstanceOf) { + if (DEBUG) { + if ( + Object.keys(this.properties).length || + this.patternProperties.length || + !(this.additionalProperties instanceof AnyType) + ) { + throw new Error( + "InternalError: isInstanceOf can only be used " + + "with objects that are otherwise unrestricted" + ); + } + } + + if ( + ChromeUtils.getClassName(value) !== this.isInstanceOf && + (this.isInstanceOf !== "Element" || value.nodeType !== 1) + ) { + return context.error( + `Object must be an instance of ${this.isInstanceOf}`, + `be an instance of ${this.isInstanceOf}` + ); + } + + // This is kind of a hack, but we can't normalize things that + // aren't JSON, so we just return them. + return this.postprocess({ value }, context); + } + + let properties = this.extractProperties(value, context); + let remainingProps = new Set(Object.keys(properties)); + + let result = {}; + for (let prop of Object.keys(this.properties)) { + this.checkProperty( + context, + prop, + this.properties[prop], + result, + properties, + remainingProps + ); + } + + for (let prop of Object.keys(properties)) { + for (let { pattern, type } of this.patternProperties) { + if (pattern.test(prop)) { + this.checkProperty( + context, + prop, + type, + result, + properties, + remainingProps + ); + } + } + } + + if (this.additionalProperties) { + for (let prop of remainingProps) { + let r = context.withPath(prop, () => + this.additionalProperties.normalize(properties[prop], context) + ); + if (r.error) { + return r; + } + result[prop] = r.value; + } + } else if (remainingProps.size == 1) { + return context.error( + `Unexpected property "${[...remainingProps]}"`, + `not contain an unexpected "${[...remainingProps]}" property` + ); + } else if (remainingProps.size) { + let props = [...remainingProps].sort().join(", "); + return context.error( + `Unexpected properties: ${props}`, + `not contain the unexpected properties [${props}]` + ); + } + + return this.postprocess({ value: result }, context); + } catch (e) { + if (e.error) { + return e; + } + throw e; + } + } +} + +// This type is just a placeholder to be referred to by +// SubModuleProperty. No value is ever expected to have this type. +SubModuleType = class SubModuleType extends Type { + static get EXTRA_PROPERTIES() { + return ["functions", "events", "properties", ...super.EXTRA_PROPERTIES]; + } + + static parseSchema(root, schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + + // The path we pass in here is only used for error messages. + path = [...path, schema.id]; + let functions = schema.functions + .filter(fun => !fun.unsupported) + .map(fun => FunctionEntry.parseSchema(root, fun, path)); + + let events = []; + + if (schema.events) { + events = schema.events + .filter(event => !event.unsupported) + .map(event => Event.parseSchema(root, event, path)); + } + + return new this(schema, functions, events); + } + + constructor(schema, functions, events) { + // schema contains properties such as min/max_manifest_version needed + // in the base class so that the Context class can version compare + // any entries against the manifest version. + super(schema); + this.functions = functions; + this.events = events; + } +}; + +class NumberType extends Type { + normalize(value, context) { + let r = this.normalizeBase("number", value, context); + if (r.error) { + return r; + } + + if (isNaN(r.value) || !Number.isFinite(r.value)) { + return context.error( + "NaN and infinity are not valid", + "be a finite number" + ); + } + + return r; + } + + checkBaseType(baseType) { + return baseType == "number" || baseType == "integer"; + } +} + +class IntegerType extends Type { + static get EXTRA_PROPERTIES() { + return ["minimum", "maximum", ...super.EXTRA_PROPERTIES]; + } + + static parseSchema(root, schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + + let { minimum = -Infinity, maximum = Infinity } = schema; + return new this(schema, minimum, maximum); + } + + constructor(schema, minimum, maximum) { + super(schema); + this.minimum = minimum; + this.maximum = maximum; + } + + normalize(value, context) { + let r = this.normalizeBase("integer", value, context); + if (r.error) { + return r; + } + value = r.value; + + // Ensure it's between -2**31 and 2**31-1 + if (!Number.isSafeInteger(value)) { + return context.error( + "Integer is out of range", + "be a valid 32 bit signed integer" + ); + } + + if (value < this.minimum) { + return context.error( + `Integer ${value} is too small (must be at least ${this.minimum})`, + `be at least ${this.minimum}` + ); + } + if (value > this.maximum) { + return context.error( + `Integer ${value} is too big (must be at most ${this.maximum})`, + `be no greater than ${this.maximum}` + ); + } + + return this.postprocess(r, context); + } + + checkBaseType(baseType) { + return baseType == "integer"; + } +} + +class BooleanType extends Type { + static get EXTRA_PROPERTIES() { + return ["enum", ...super.EXTRA_PROPERTIES]; + } + + static parseSchema(root, schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + let enumeration = schema.enum || null; + return new this(schema, enumeration); + } + + constructor(schema, enumeration) { + super(schema); + this.enumeration = enumeration; + } + + normalize(value, context) { + if (!this.checkBaseType(getValueBaseType(value))) { + return context.error( + () => `Expected boolean instead of ${JSON.stringify(value)}`, + `be a boolean` + ); + } + value = this.preprocess(value, context); + if (this.enumeration && !this.enumeration.includes(value)) { + return context.error( + () => `Invalid value ${JSON.stringify(value)}`, + `be ${this.enumeration}` + ); + } + this.checkDeprecated(context, value); + return { value }; + } + + checkBaseType(baseType) { + return baseType == "boolean"; + } +} + +class ArrayType extends Type { + static get EXTRA_PROPERTIES() { + return ["items", "minItems", "maxItems", ...super.EXTRA_PROPERTIES]; + } + + static parseSchema(root, schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + + let items = root.parseSchema(schema.items, path, ["onError"]); + + return new this( + schema, + items, + schema.minItems || 0, + schema.maxItems || Infinity + ); + } + + constructor(schema, itemType, minItems, maxItems) { + super(schema); + this.itemType = itemType; + this.minItems = minItems; + this.maxItems = maxItems; + this.onError = schema.items.onError || null; + } + + normalize(value, context) { + let v = this.normalizeBase("array", value, context); + if (v.error) { + return v; + } + value = v.value; + + let result = []; + for (let [i, element] of value.entries()) { + element = context.withPath(String(i), () => + this.itemType.normalize(element, context) + ); + if (element.error) { + if (this.onError == "warn") { + context.logWarning(forceString(element.error)); + } else if (this.onError != "ignore") { + return element; + } + continue; + } + result.push(element.value); + } + + if (result.length < this.minItems) { + return context.error( + `Array requires at least ${this.minItems} items; you have ${result.length}`, + `have at least ${this.minItems} items` + ); + } + + if (result.length > this.maxItems) { + return context.error( + `Array requires at most ${this.maxItems} items; you have ${result.length}`, + `have at most ${this.maxItems} items` + ); + } + + return this.postprocess({ value: result }, context); + } + + checkBaseType(baseType) { + return baseType == "array"; + } +} + +class FunctionType extends Type { + static get EXTRA_PROPERTIES() { + return [ + "parameters", + "async", + "returns", + "requireUserInput", + ...super.EXTRA_PROPERTIES, + ]; + } + + static parseSchema(root, schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + + let isAsync = !!schema.async; + let isExpectingCallback = typeof schema.async === "string"; + let parameters = null; + if ("parameters" in schema) { + parameters = []; + for (let param of schema.parameters) { + // Callbacks default to optional for now, because of promise + // handling. + let isCallback = isAsync && param.name == schema.async; + if (isCallback) { + isExpectingCallback = false; + } + + parameters.push({ + type: root.parseSchema(param, path, ["name", "optional", "default"]), + name: param.name, + optional: param.optional == null ? isCallback : param.optional, + default: param.default == undefined ? null : param.default, + }); + } + } + let hasAsyncCallback = false; + if (isAsync) { + hasAsyncCallback = + parameters && + parameters.length && + parameters[parameters.length - 1].name == schema.async; + } + + if (DEBUG) { + if (isExpectingCallback) { + throw new Error( + `Internal error: Expected a callback parameter ` + + `with name ${schema.async}` + ); + } + + if (isAsync && schema.returns) { + throw new Error( + "Internal error: Async functions must not have return values." + ); + } + if ( + isAsync && + schema.allowAmbiguousOptionalArguments && + !hasAsyncCallback + ) { + throw new Error( + "Internal error: Async functions with ambiguous " + + "arguments must declare the callback as the last parameter" + ); + } + } + + return new this( + schema, + parameters, + isAsync, + hasAsyncCallback, + !!schema.requireUserInput + ); + } + + constructor(schema, parameters, isAsync, hasAsyncCallback, requireUserInput) { + super(schema); + this.parameters = parameters; + this.isAsync = isAsync; + this.hasAsyncCallback = hasAsyncCallback; + this.requireUserInput = requireUserInput; + } + + normalize(value, context) { + return this.normalizeBase("function", value, context); + } + + checkBaseType(baseType) { + return baseType == "function"; + } +} + +// Represents a "property" defined in a schema namespace with a +// particular value. Essentially this is a constant. +class ValueProperty extends Entry { + constructor(schema, name, value) { + super(schema); + this.name = name; + this.value = value; + } + + getDescriptor(path, context) { + // Prevent injection if not a supported version. + if (!context.matchManifestVersion(this)) { + return; + } + + return { + descriptor: { value: this.value }, + }; + } +} + +// Represents a "property" defined in a schema namespace that is not a +// constant. +class TypeProperty extends Entry { + unsupported = false; + + constructor(schema, path, name, type, writable, permissions) { + super(schema); + this.path = path; + this.name = name; + this.type = type; + this.writable = writable; + this.permissions = permissions; + } + + throwError(context, msg) { + throw context.makeError(`${msg} for ${this.path.join(".")}.${this.name}.`); + } + + getDescriptor(path, context) { + if (this.unsupported || !context.matchManifestVersion(this)) { + return; + } + + let apiImpl = context.getImplementation(path.join("."), this.name); + + let getStub = () => { + this.checkDeprecated(context); + return apiImpl.getProperty(); + }; + + let descriptor = { + get: Cu.exportFunction(getStub, context.cloneScope), + }; + + if (this.writable) { + let setStub = value => { + let normalized = this.type.normalize(value, context); + if (normalized.error) { + this.throwError(context, forceString(normalized.error)); + } + + apiImpl.setProperty(normalized.value); + }; + + descriptor.set = Cu.exportFunction(setStub, context.cloneScope); + } + + return { + descriptor, + revoke() { + apiImpl.revoke(); + apiImpl = null; + }, + }; + } +} + +class SubModuleProperty extends Entry { + // A SubModuleProperty represents a tree of objects and properties + // to expose to an extension. Currently we support only a limited + // form of sub-module properties, where "$ref" points to a + // SubModuleType containing a list of functions and "properties" is + // a list of additional simple properties. + // + // name: Name of the property stuff is being added to. + // namespaceName: Namespace in which the property lives. + // reference: Name of the type defining the functions to add to the property. + // properties: Additional properties to add to the module (unsupported). + constructor(root, schema, path, name, reference, properties, permissions) { + super(schema); + this.root = root; + this.name = name; + this.path = path; + this.namespaceName = path.join("."); + this.reference = reference; + this.properties = properties; + this.permissions = permissions; + } + + get targetType() { + let ns = this.root.getNamespace(this.namespaceName); + let type = ns.get(this.reference); + if (!type && this.reference.includes(".")) { + let [namespaceName, ref] = this.reference.split("."); + ns = this.root.getNamespace(namespaceName); + type = ns.get(ref); + } + return type; + } + + getDescriptor(path, context) { + let obj = Cu.createObjectIn(context.cloneScope); + + let ns = this.root.getNamespace(this.namespaceName); + let type = this.targetType; + + // Prevent injection if not a supported version. + if (!context.matchManifestVersion(type)) { + return; + } + + if (DEBUG) { + if (!type || !(type instanceof SubModuleType)) { + throw new Error( + `Internal error: ${this.namespaceName}.${this.reference} ` + + `is not a sub-module` + ); + } + } + let subpath = [...path, this.name]; + + let functions = type.functions; + for (let fun of functions) { + context.injectInto(fun, obj, fun.name, subpath, ns); + } + + let events = type.events; + for (let event of events) { + context.injectInto(event, obj, event.name, subpath, ns); + } + + // TODO: Inject this.properties. + + return { + descriptor: { value: obj }, + revoke() { + let unwrapped = ChromeUtils.waiveXrays(obj); + for (let fun of functions) { + try { + delete unwrapped[fun.name]; + } catch (e) { + Cu.reportError(e); + } + } + }, + }; + } +} + +// This class is a base class for FunctionEntrys and Events. It takes +// care of validating parameter lists (i.e., handling of optional +// parameters and parameter type checking). +class CallEntry extends Entry { + hasAsyncCallback = false; + + constructor(schema, path, name, parameters, allowAmbiguousOptionalArguments) { + super(schema); + this.path = path; + this.name = name; + this.parameters = parameters; + this.allowAmbiguousOptionalArguments = allowAmbiguousOptionalArguments; + } + + throwError(context, msg) { + throw context.makeError(`${msg} for ${this.path.join(".")}.${this.name}.`); + } + + checkParameters(args, context) { + let fixedArgs = []; + + // First we create a new array, fixedArgs, that is the same as + // |args| but with default values in place of omitted optional parameters. + let check = (parameterIndex, argIndex) => { + if (parameterIndex == this.parameters.length) { + if (argIndex == args.length) { + return true; + } + return false; + } + + let parameter = this.parameters[parameterIndex]; + if (parameter.optional) { + // Try skipping it. + fixedArgs[parameterIndex] = parameter.default; + if (check(parameterIndex + 1, argIndex)) { + return true; + } + } + + if (argIndex == args.length) { + return false; + } + + let arg = args[argIndex]; + if (!parameter.type.checkBaseType(getValueBaseType(arg))) { + // For Chrome compatibility, use the default value if null or undefined + // is explicitly passed but is not a valid argument in this position. + if (parameter.optional && (arg === null || arg === undefined)) { + fixedArgs[parameterIndex] = Cu.cloneInto(parameter.default, {}); + } else { + return false; + } + } else { + fixedArgs[parameterIndex] = arg; + } + + return check(parameterIndex + 1, argIndex + 1); + }; + + if (this.allowAmbiguousOptionalArguments) { + // When this option is set, it's up to the implementation to + // parse arguments. + // The last argument for asynchronous methods is either a function or null. + // This is specifically done for runtime.sendMessage. + if (this.hasAsyncCallback && typeof args[args.length - 1] != "function") { + args.push(null); + } + return args; + } + let success = check(0, 0); + if (!success) { + this.throwError(context, "Incorrect argument types"); + } + + // Now we normalize (and fully type check) all non-omitted arguments. + fixedArgs = fixedArgs.map((arg, parameterIndex) => { + if (arg === null) { + return null; + } + let parameter = this.parameters[parameterIndex]; + let r = parameter.type.normalize(arg, context); + if (r.error) { + this.throwError( + context, + `Type error for parameter ${parameter.name} (${forceString(r.error)})` + ); + } + return r.value; + }); + + return fixedArgs; + } +} + +// Represents a "function" defined in a schema namespace. +FunctionEntry = class FunctionEntry extends CallEntry { + static parseSchema(root, schema, path) { + // When not in DEBUG mode, we just need to know *if* this returns. + /** @type {boolean|object} */ + let returns = !!schema.returns; + if (DEBUG && "returns" in schema) { + returns = { + type: root.parseSchema(schema.returns, path, ["optional", "name"]), + optional: schema.returns.optional || false, + name: "result", + }; + } + + return new this( + schema, + path, + schema.name, + root.parseSchema(schema, path, [ + "name", + "unsupported", + "returns", + "permissions", + "allowAmbiguousOptionalArguments", + "allowCrossOriginArguments", + ]), + schema.unsupported || false, + schema.allowAmbiguousOptionalArguments || false, + schema.allowCrossOriginArguments || false, + returns, + schema.permissions || null + ); + } + + constructor( + schema, + path, + name, + type, + unsupported, + allowAmbiguousOptionalArguments, + allowCrossOriginArguments, + returns, + permissions + ) { + super(schema, path, name, type.parameters, allowAmbiguousOptionalArguments); + this.unsupported = unsupported; + this.returns = returns; + this.permissions = permissions; + this.allowCrossOriginArguments = allowCrossOriginArguments; + + this.isAsync = type.isAsync; + this.hasAsyncCallback = type.hasAsyncCallback; + this.requireUserInput = type.requireUserInput; + } + + checkValue({ type, optional, name }, value, context) { + if (optional && value == null) { + return; + } + if ( + type.reference === "ExtensionPanel" || + type.reference === "ExtensionSidebarPane" || + type.reference === "Port" + ) { + // TODO: We currently treat objects with functions as SubModuleType, + // which is just wrong, and a bigger yak. Skipping for now. + return; + } + const { error } = type.normalize(value, context); + if (error) { + this.throwError( + context, + `Type error for ${name} value (${forceString(error)})` + ); + } + } + + checkCallback(args, context) { + const callback = this.parameters[this.parameters.length - 1]; + for (const [i, param] of callback.type.parameters.entries()) { + this.checkValue(param, args[i], context); + } + } + + getDescriptor(path, context) { + let apiImpl = context.getImplementation(path.join("."), this.name); + + let stub; + if (this.isAsync) { + stub = (...args) => { + this.checkDeprecated(context); + let actuals = this.checkParameters(args, context); + let callback = null; + if (this.hasAsyncCallback) { + callback = actuals.pop(); + } + if (callback === null && context.isChromeCompat) { + // We pass an empty stub function as a default callback for + // the `chrome` API, so promise objects are not returned, + // and lastError values are reported immediately. + callback = () => {}; + } + if (DEBUG && this.hasAsyncCallback && callback) { + let original = callback; + callback = (...args) => { + this.checkCallback(args, context); + original(...args); + }; + } + let result = apiImpl.callAsyncFunction( + actuals, + callback, + this.requireUserInput + ); + if (DEBUG && this.hasAsyncCallback && !callback) { + return result.then(result => { + this.checkCallback([result], context); + return result; + }); + } + return result; + }; + } else if (!this.returns) { + stub = (...args) => { + this.checkDeprecated(context); + let actuals = this.checkParameters(args, context); + return apiImpl.callFunctionNoReturn(actuals); + }; + } else { + stub = (...args) => { + this.checkDeprecated(context); + let actuals = this.checkParameters(args, context); + let result = apiImpl.callFunction(actuals); + if (DEBUG && this.returns) { + this.checkValue(this.returns, result, context); + } + return result; + }; + } + + return { + descriptor: { + value: Cu.exportFunction(stub, context.cloneScope, { + allowCrossOriginArguments: this.allowCrossOriginArguments, + }), + }, + revoke() { + apiImpl.revoke(); + apiImpl = null; + }, + }; + } +}; + +// Represents an "event" defined in a schema namespace. +// +// TODO Bug 1369722: we should be able to remove the eslint-disable-line that follows +// once Bug 1369722 has been fixed. +// eslint-disable-next-line no-global-assign +Event = class Event extends CallEntry { + static parseSchema(root, event, path) { + let extraParameters = Array.from(event.extraParameters || [], param => ({ + type: root.parseSchema(param, path, ["name", "optional", "default"]), + name: param.name, + optional: param.optional || false, + default: param.default == undefined ? null : param.default, + })); + + let extraProperties = [ + "name", + "unsupported", + "permissions", + "extraParameters", + // We ignore these properties for now. + "returns", + "filters", + ]; + + return new this( + event, + path, + event.name, + root.parseSchema(event, path, extraProperties), + extraParameters, + event.unsupported || false, + event.permissions || null + ); + } + + constructor( + schema, + path, + name, + type, + extraParameters, + unsupported, + permissions + ) { + super(schema, path, name, extraParameters); + this.type = type; + this.unsupported = unsupported; + this.permissions = permissions; + } + + checkListener(listener, context) { + let r = this.type.normalize(listener, context); + if (r.error) { + this.throwError(context, "Invalid listener"); + } + return r.value; + } + + getDescriptor(path, context) { + let apiImpl = context.getImplementation(path.join("."), this.name); + + let addStub = (listener, ...args) => { + listener = this.checkListener(listener, context); + let actuals = this.checkParameters(args, context); + apiImpl.addListener(listener, actuals); + }; + + let removeStub = listener => { + listener = this.checkListener(listener, context); + apiImpl.removeListener(listener); + }; + + let hasStub = listener => { + listener = this.checkListener(listener, context); + return apiImpl.hasListener(listener); + }; + + let obj = Cu.createObjectIn(context.cloneScope); + + Cu.exportFunction(addStub, obj, { defineAs: "addListener" }); + Cu.exportFunction(removeStub, obj, { defineAs: "removeListener" }); + Cu.exportFunction(hasStub, obj, { defineAs: "hasListener" }); + + return { + descriptor: { value: obj }, + revoke() { + apiImpl.revoke(); + apiImpl = null; + + let unwrapped = ChromeUtils.waiveXrays(obj); + delete unwrapped.addListener; + delete unwrapped.removeListener; + delete unwrapped.hasListener; + }, + }; + } +}; + +const TYPES = Object.freeze( + Object.assign(Object.create(null), { + any: AnyType, + array: ArrayType, + boolean: BooleanType, + function: FunctionType, + integer: IntegerType, + null: NullType, + number: NumberType, + object: ObjectType, + string: StringType, + }) +); + +const LOADERS = { + events: "loadEvent", + functions: "loadFunction", + properties: "loadProperty", + types: "loadType", +}; + +class Namespace extends Map { + constructor(root, name, path) { + super(); + + this.root = root; + + this._lazySchemas = []; + this.initialized = false; + + this.name = name; + this.path = name ? [...path, name] : [...path]; + + this.superNamespace = null; + + this.min_manifest_version = MIN_MANIFEST_VERSION; + this.max_manifest_version = MAX_MANIFEST_VERSION; + + this.permissions = null; + this.allowedContexts = []; + this.defaultContexts = []; + } + + /** + * Adds a JSON Schema object to the set of schemas that represent this + * namespace. + * + * @param {object} schema + * A JSON schema object which partially describes this + * namespace. + */ + addSchema(schema) { + this._lazySchemas.push(schema); + + for (let prop of [ + "permissions", + "allowedContexts", + "defaultContexts", + "min_manifest_version", + "max_manifest_version", + ]) { + if (schema[prop]) { + this[prop] = schema[prop]; + } + } + + if (schema.$import) { + this.superNamespace = this.root.getNamespace(schema.$import); + } + } + + /** + * Initializes the keys of this namespace based on the schema objects + * added via previous `addSchema` calls. + */ + init() { + if (this.initialized) { + return; + } + + if (this.superNamespace) { + this._lazySchemas.unshift(...this.superNamespace._lazySchemas); + } + + // Keep in sync with LOADERS above. + this.types = new DefaultMap(() => []); + this.properties = new DefaultMap(() => []); + this.functions = new DefaultMap(() => []); + this.events = new DefaultMap(() => []); + + for (let schema of this._lazySchemas) { + for (let type of schema.types || []) { + if (!type.unsupported) { + this.types.get(type.$extend || type.id).push(type); + } + } + + for (let [name, prop] of Object.entries(schema.properties || {})) { + if (!prop.unsupported) { + this.properties.get(name).push(prop); + } + } + + for (let fun of schema.functions || []) { + if (!fun.unsupported) { + this.functions.get(fun.name).push(fun); + } + } + + for (let event of schema.events || []) { + if (!event.unsupported) { + this.events.get(event.name).push(event); + } + } + } + + // For each type of top-level property in the schema object, iterate + // over all properties of that type, and create a temporary key for + // each property pointing to its type. Those temporary properties + // are later used to instantiate an Entry object based on the actual + // schema object. + for (let type of Object.keys(LOADERS)) { + for (let key of this[type].keys()) { + this.set(key, type); + } + } + + this.initialized = true; + + if (DEBUG) { + for (let key of this.keys()) { + this.get(key); + } + } + } + + /** + * Initializes the value of a given key, by parsing the schema object + * associated with it and replacing its temporary value with an `Entry` + * instance. + * + * @param {string} key + * The name of the property to initialize. + * @param {string} type + * The type of property the key represents. Must have a + * corresponding entry in the `LOADERS` object, pointing to the + * initialization method for that type. + * + * @returns {Entry} + */ + initKey(key, type) { + let loader = LOADERS[type]; + + for (let schema of this[type].get(key)) { + this.set(key, this[loader](key, schema)); + } + + return this.get(key); + } + + loadType(name, type) { + if ("$extend" in type) { + return this.extendType(type); + } + return this.root.parseSchema(type, this.path, ["id"]); + } + + extendType(type) { + let targetType = this.get(type.$extend); + + // Only allow extending object and choices types for now. + if (targetType instanceof ObjectType) { + type.type = "object"; + } else if (DEBUG) { + if (!targetType) { + throw new Error( + `Internal error: Attempt to extend a nonexistent type ${type.$extend}` + ); + } else if (!(targetType instanceof ChoiceType)) { + throw new Error( + `Internal error: Attempt to extend a non-extensible type ${type.$extend}` + ); + } + } + + let parsed = this.root.parseSchema(type, this.path, ["$extend"]); + + if (DEBUG && parsed.constructor !== targetType.constructor) { + throw new Error(`Internal error: Bad attempt to extend ${type.$extend}`); + } + + targetType.extend(parsed); + + return targetType; + } + + loadProperty(name, prop) { + if ("$ref" in prop) { + if (!prop.unsupported) { + return new SubModuleProperty( + this.root, + prop, + this.path, + name, + prop.$ref, + prop.properties || {}, + prop.permissions || null + ); + } + } else if ("value" in prop) { + return new ValueProperty(prop, name, prop.value); + } else { + // We ignore the "optional" attribute on properties since we + // don't inject anything here anyway. + let type = this.root.parseSchema( + prop, + [this.name], + ["optional", "permissions", "writable"] + ); + return new TypeProperty( + prop, + this.path, + name, + type, + prop.writable || false, + prop.permissions || null + ); + } + } + + loadFunction(name, fun) { + return FunctionEntry.parseSchema(this.root, fun, this.path); + } + + loadEvent(name, event) { + return Event.parseSchema(this.root, event, this.path); + } + + /** + * Injects the properties of this namespace into the given object. + * + * @param {object} dest + * The object into which to inject the namespace properties. + * @param {InjectionContext} context + * The injection context with which to inject the properties. + */ + injectInto(dest, context) { + for (let name of this.keys()) { + // If the entry does not match the manifest version do not + // inject the property. This prevents the item from being + // enumerable in the namespace object. We cannot accomplish + // this inside exportLazyProperty, it specifically injects + // an enumerable object. + let entry = this.get(name); + if (!context.matchManifestVersion(entry)) { + continue; + } + exportLazyProperty(dest, name, () => { + let entry = this.get(name); + + return context.getDescriptor(entry, dest, name, this.path, this); + }); + } + } + + getDescriptor(path, context) { + let obj = Cu.createObjectIn(context.cloneScope); + + let ns = context.schemaRoot.getNamespace(this.path.join(".")); + ns.injectInto(obj, context); + + // Only inject the namespace object if it isn't empty. + if (Object.keys(obj).length) { + return { + descriptor: { value: obj }, + }; + } + } + + keys() { + this.init(); + return super.keys(); + } + + /** @returns {Generator<[string, Entry]>} */ + *entries() { + for (let key of this.keys()) { + yield [key, this.get(key)]; + } + } + + get(key) { + this.init(); + let value = super.get(key); + + // The initial values of lazily-initialized schema properties are + // strings, pointing to the type of property, corresponding to one + // of the entries in the `LOADERS` object. + if (typeof value === "string") { + value = this.initKey(key, value); + } + + return value; + } + + /** + * Returns a Namespace object for the given namespace name. If a + * namespace object with this name does not already exist, it is + * created. If the name contains any '.' characters, namespaces are + * recursively created, for each dot-separated component. + * + * @param {string} name + * The name of the sub-namespace to retrieve. + * @param {boolean} [create = true] + * If true, create any intermediate namespaces which don't + * exist. + * + * @returns {Namespace} + */ + getNamespace(name, create = true) { + let subName; + + let idx = name.indexOf("."); + if (idx > 0) { + subName = name.slice(idx + 1); + name = name.slice(0, idx); + } + + let ns = super.get(name); + if (!ns) { + if (!create) { + return null; + } + ns = new Namespace(this.root, name, this.path); + this.set(name, ns); + } + + if (subName) { + return ns.getNamespace(subName); + } + return ns; + } + + getOwnNamespace(name) { + return this.getNamespace(name); + } + + has(key) { + this.init(); + return super.has(key); + } +} + +/** + * A namespace which combines the children of an arbitrary number of + * sub-namespaces. + */ +class Namespaces extends Namespace { + constructor(root, name, path, namespaces) { + super(root, name, path); + + this.namespaces = namespaces; + } + + injectInto(obj, context) { + for (let ns of this.namespaces) { + ns.injectInto(obj, context); + } + } +} + +/** + * A root schema which combines the contents of an arbitrary number of base + * schema roots. + */ +class SchemaRoots extends Namespaces { + constructor(root, bases) { + bases = bases.map(base => base.rootSchema || base); + + super(null, "", [], bases); + + this.root = root; + this.bases = bases; + this._namespaces = new Map(); + } + + _getNamespace(name, create) { + let results = []; + for (let root of this.bases) { + let ns = root.getNamespace(name, create); + if (ns) { + results.push(ns); + } + } + + if (results.length == 1) { + return results[0]; + } + + if (results.length) { + return new Namespaces(this.root, name, name.split("."), results); + } + return null; + } + + getNamespace(name, create) { + let ns = this._namespaces.get(name); + if (!ns) { + ns = this._getNamespace(name, create); + if (ns) { + this._namespaces.set(name, ns); + } + } + return ns; + } + + *getNamespaces(name) { + for (let root of this.bases) { + yield* root.getNamespaces(name); + } + } +} + +/** + * A root schema namespace containing schema data which is isolated from data in + * other schema roots. May extend a base namespace, in which case schemas in + * this root may refer to types in a base, but not vice versa. + * + * @param {SchemaRoot|Array<SchemaRoot>|null} base + * A base schema root (or roots) from which to derive, or null. + * @param {Map<string, Array|StructuredCloneHolder>} schemaJSON + * A map of schema URLs and corresponding JSON blobs from which to + * populate this root namespace. + */ +export class SchemaRoot extends Namespace { + constructor(base, schemaJSON) { + super(null, "", []); + + if (Array.isArray(base)) { + base = new SchemaRoots(this, base); + } + + this.root = this; + this.base = base; + this.schemaJSON = schemaJSON; + } + + *getNamespaces(path) { + let name = path.join("."); + + let ns = this.getNamespace(name, false); + if (ns) { + yield ns; + } + + if (this.base) { + yield* this.base.getNamespaces(name); + } + } + + /** + * Returns the sub-namespace with the given name. If the given namespace + * doesn't already exist, attempts to find it in the base SchemaRoot before + * creating a new empty namespace. + * + * @param {string} name + * The namespace to retrieve. + * @param {boolean} [create = true] + * If true, an empty namespace should be created if one does not + * already exist. + * @returns {Namespace|null} + */ + getNamespace(name, create = true) { + let ns = super.getNamespace(name, false); + if (ns) { + return ns; + } + + ns = this.base && this.base.getNamespace(name, false); + if (ns) { + return ns; + } + return create && super.getNamespace(name, create); + } + + /** + * Like getNamespace, but does not take the base SchemaRoot into account. + * + * @param {string} name + * The namespace to retrieve. + * @returns {Namespace} + */ + getOwnNamespace(name) { + return super.getNamespace(name); + } + + parseSchema(schema, path, extraProperties = []) { + let allowedProperties = DEBUG && new Set(extraProperties); + + if ("choices" in schema) { + return ChoiceType.parseSchema(this, schema, path, allowedProperties); + } else if ("$ref" in schema) { + return RefType.parseSchema(this, schema, path, allowedProperties); + } + + let type = TYPES[schema.type]; + + if (DEBUG) { + allowedProperties.add("type"); + + if (!("type" in schema)) { + throw new Error(`Unexpected value for type: ${JSON.stringify(schema)}`); + } + + if (!type) { + throw new Error(`Unexpected type ${schema.type}`); + } + } + + return type.parseSchema(this, schema, path, allowedProperties); + } + + parseSchemas() { + for (let [key, schema] of this.schemaJSON.entries()) { + try { + if (typeof schema.deserialize === "function") { + schema = schema.deserialize(globalThis, isParentProcess); + + // If we're in the parent process, we need to keep the + // StructuredCloneHolder blob around in order to send to future child + // processes. If we're in a child, we have no further use for it, so + // just store the deserialized schema data in its place. + if (!isParentProcess) { + this.schemaJSON.set(key, schema); + } + } + + this.loadSchema(schema); + } catch (e) { + Cu.reportError(e); + } + } + } + + loadSchema(json) { + for (let namespace of json) { + this.getOwnNamespace(namespace.namespace).addSchema(namespace); + } + } + + /** + * Checks whether a given object has the necessary permissions to + * expose the given namespace. + * + * @param {string} namespace + * The top-level namespace to check permissions for. + * @param {object} wrapperFuncs + * Wrapper functions for the given context. + * @param {Function} wrapperFuncs.hasPermission + * A function which, when given a string argument, returns true + * if the context has the given permission. + * @returns {boolean} + * True if the context has permission for the given namespace. + */ + checkPermissions(namespace, wrapperFuncs) { + let ns = this.getNamespace(namespace); + if (ns && ns.permissions) { + return ns.permissions.some(perm => wrapperFuncs.hasPermission(perm)); + } + return true; + } + + /** + * Inject registered extension APIs into `dest`. + * + * @param {object} dest The root namespace for the APIs. + * This object is usually exposed to extensions as "chrome" or "browser". + * @param {object} wrapperFuncs An implementation of the InjectionContext + * interface, which runs the actual functionality of the generated API. + */ + inject(dest, wrapperFuncs) { + let context = new InjectionContext(wrapperFuncs, this); + + this.injectInto(dest, context); + } + + injectInto(dest, context) { + // For schema graphs where multiple schema roots have the same base, don't + // inject it more than once. + + if (!context.injectedRoots.has(this)) { + context.injectedRoots.add(this); + if (this.base) { + this.base.injectInto(dest, context); + } + super.injectInto(dest, context); + } + } + + /** + * Normalize `obj` according to the loaded schema for `typeName`. + * + * @param {object} obj The object to normalize against the schema. + * @param {string} typeName The name in the format namespace.propertyname + * @param {object} context An implementation of Context. Any validation errors + * are reported to the given context. + * @returns {object} The normalized object. + */ + normalize(obj, typeName, context) { + let [namespaceName, prop] = typeName.split("."); + let ns = this.getNamespace(namespaceName); + let type = ns.get(prop); + + let result = type.normalize(obj, new Context(context)); + if (result.error) { + return { error: forceString(result.error) }; + } + return result; + } +} + +export var Schemas = { + initialized: false, + + REVOKE: Symbol("@@revoke"), + + // Maps a schema URL to the JSON contained in that schema file. This + // is useful for sending the JSON across processes. + schemaJSON: new Map(), + + // A map of schema JSON which should be available in all content processes. + contentSchemaJSON: new Map(), + + // A map of schema JSON which should only be available to extension processes. + privilegedSchemaJSON: new Map(), + + _rootSchema: null, + + // A weakmap for the validation Context class instances given an extension + // context (keyed by the extensin context instance). + // This is used instead of the InjectionContext for webIDL API validation + // and normalization (see Schemas.checkParameters). + paramsValidationContexts: new DefaultWeakMap( + extContext => new Context(extContext) + ), + + get rootSchema() { + if (!this.initialized) { + this.init(); + } + if (!this._rootSchema) { + this._rootSchema = new SchemaRoot(null, this.schemaJSON); + this._rootSchema.parseSchemas(); + } + return this._rootSchema; + }, + + getNamespace(name) { + return this.rootSchema.getNamespace(name); + }, + + init() { + if (this.initialized) { + return; + } + this.initialized = true; + + if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT) { + let addSchemas = schemas => { + for (let [key, value] of schemas.entries()) { + this.schemaJSON.set(key, value); + } + }; + + if (WebExtensionPolicy.isExtensionProcess || DEBUG) { + addSchemas(Services.cpmm.sharedData.get(KEY_PRIVILEGED_SCHEMAS)); + } + + let schemas = Services.cpmm.sharedData.get(KEY_CONTENT_SCHEMAS); + if (schemas) { + addSchemas(schemas); + } + } + }, + + _loadCachedSchemasPromise: null, + loadCachedSchemas() { + if (!this._loadCachedSchemasPromise) { + this._loadCachedSchemasPromise = lazy.StartupCache.schemas + .getAll() + .then(results => { + return results; + }); + } + + return this._loadCachedSchemasPromise; + }, + + addSchema(url, schema, content = false) { + this.schemaJSON.set(url, schema); + + if (content) { + this.contentSchemaJSON.set(url, schema); + } else { + this.privilegedSchemaJSON.set(url, schema); + } + + if (this._rootSchema) { + throw new Error("Schema loaded after root schema populated"); + } + }, + + updateSharedSchemas() { + let { sharedData } = Services.ppmm; + + sharedData.set(KEY_CONTENT_SCHEMAS, this.contentSchemaJSON); + sharedData.set(KEY_PRIVILEGED_SCHEMAS, this.privilegedSchemaJSON); + }, + + fetch(url) { + return readJSONAndBlobbify(url); + }, + + processSchema(json) { + return blobbify(json); + }, + + async load(url, content = false) { + if (!isParentProcess) { + return; + } + + const startTime = Cu.now(); + let schemaCache = await this.loadCachedSchemas(); + const fromCache = schemaCache.has(url); + + let blob = + schemaCache.get(url) || + (await lazy.StartupCache.schemas.get(url, readJSONAndBlobbify)); + + if (!this.schemaJSON.has(url)) { + this.addSchema(url, blob, content); + } + + ChromeUtils.addProfilerMarker( + "ExtensionSchemas", + { startTime }, + `load ${url}, from cache: ${fromCache}` + ); + }, + + /** + * Checks whether a given object has the necessary permissions to + * expose the given namespace. + * + * @param {string} namespace + * The top-level namespace to check permissions for. + * @param {object} wrapperFuncs + * Wrapper functions for the given context. + * @param {Function} wrapperFuncs.hasPermission + * A function which, when given a string argument, returns true + * if the context has the given permission. + * @returns {boolean} + * True if the context has permission for the given namespace. + */ + checkPermissions(namespace, wrapperFuncs) { + return this.rootSchema.checkPermissions(namespace, wrapperFuncs); + }, + + /** + * Returns a sorted array of permission names for the given permission types. + * + * @param {Array} types An array of permission types, defaults to all permissions. + * @returns {Array} sorted array of permission names + */ + getPermissionNames( + types = [ + "Permission", + "OptionalPermission", + "PermissionNoPrompt", + "OptionalPermissionNoPrompt", + "PermissionPrivileged", + ] + ) { + const ns = this.getNamespace("manifest"); + let names = []; + for (let typeName of types) { + for (let choice of ns + .get(typeName) + .choices.filter(choice => choice.enumeration)) { + names = names.concat(choice.enumeration); + } + } + return names.sort(); + }, + + exportLazyGetter, + + /** + * Inject registered extension APIs into `dest`. + * + * @param {object} dest The root namespace for the APIs. + * This object is usually exposed to extensions as "chrome" or "browser". + * @param {object} wrapperFuncs An implementation of the InjectionContext + * interface, which runs the actual functionality of the generated API. + */ + inject(dest, wrapperFuncs) { + this.rootSchema.inject(dest, wrapperFuncs); + }, + + /** + * Normalize `obj` according to the loaded schema for `typeName`. + * + * @param {object} obj The object to normalize against the schema. + * @param {string} typeName The name in the format namespace.propertyname + * @param {object} context An implementation of Context. Any validation errors + * are reported to the given context. + * @returns {object} The normalized object. + */ + normalize(obj, typeName, context) { + return this.rootSchema.normalize(obj, typeName, context); + }, + + /** + * Validate and normalize the arguments for an API request originated + * from the webIDL API bindings. + * + * This provides for calls originating through WebIDL the parameters + * validation and normalization guarantees that the ext-APINAMESPACE.js + * scripts expects (what InjectionContext does for the regular bindings). + * + * @param {object} extContext + * @param {mozIExtensionAPIRequest } apiRequest + * + * @returns {Array<any>} Normalized arguments array. + */ + checkWebIDLRequestParameters(extContext, apiRequest) { + const getSchemaForProperty = (schemaObj, propName, schemaPath) => { + if (schemaObj instanceof Namespace) { + return schemaObj?.get(propName); + } else if (schemaObj instanceof SubModuleProperty) { + for (const fun of schemaObj.targetType.functions) { + if (fun.name === propName) { + return fun; + } + } + + for (const fun of schemaObj.targetType.events) { + if (fun.name === propName) { + return fun; + } + } + } else if (schemaObj instanceof Event) { + return schemaObj; + } + + const schemaPathType = schemaObj?.constructor.name; + throw new Error( + `API Schema for "${propName}" not found in ${schemaPath} (${schemaPath} type is ${schemaPathType})` + ); + }; + const { requestType, apiNamespace, apiName } = apiRequest; + + let [ns, ...rest] = ( + ["addListener", "removeListener"].includes(requestType) + ? `${apiNamespace}.${apiName}.${requestType}` + : `${apiNamespace}.${apiName}` + ).split("."); + let apiSchema = this.getNamespace(ns); + + // Keep track of the current schema path, populated while navigating the nested API schema + // data and then used to include the full path to the API schema that is hitting unexpected + // errors due to schema data not found or an unexpected schema type. + let schemaPath = [ns]; + + while (rest.length) { + // Nested property as namespace (e.g. used for proxy.settings requests). + if (!apiSchema) { + throw new Error(`API Schema not found for ${schemaPath.join(".")}`); + } + + let [propName, ...newRest] = rest; + rest = newRest; + + apiSchema = getSchemaForProperty( + apiSchema, + propName, + schemaPath.join(".") + ); + schemaPath.push(propName); + } + + if (!apiSchema) { + throw new Error(`API Schema not found for ${schemaPath.join(".")}`); + } + + if (!apiSchema.checkParameters) { + throw new Error( + `Unexpected API Schema type for ${schemaPath.join( + "." + )} (${schemaPath.join(".")} type is ${apiSchema.constructor.name})` + ); + } + + return apiSchema.checkParameters( + apiRequest.args, + this.paramsValidationContexts.get(extContext) + ); + }, +}; diff --git a/toolkit/components/extensions/WebExtensionContentScript.h b/toolkit/components/extensions/WebExtensionContentScript.h new file mode 100644 index 0000000000..bf6975df08 --- /dev/null +++ b/toolkit/components/extensions/WebExtensionContentScript.h @@ -0,0 +1,216 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_extensions_WebExtensionContentScript_h +#define mozilla_extensions_WebExtensionContentScript_h + +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/dom/WebExtensionContentScriptBinding.h" + +#include "jspubtd.h" + +#include "mozilla/Maybe.h" +#include "mozilla/Variant.h" +#include "mozilla/extensions/MatchGlob.h" +#include "mozilla/extensions/MatchPattern.h" +#include "nsCOMPtr.h" +#include "nsCycleCollectionParticipant.h" +#include "nsISupports.h" +#include "nsIDocShell.h" +#include "nsPIDOMWindow.h" +#include "nsWrapperCache.h" + +class nsILoadInfo; +class nsPIDOMWindowOuter; + +namespace mozilla { +namespace dom { +class WindowGlobalChild; +} + +namespace extensions { + +using dom::Nullable; +using ContentScriptInit = dom::WebExtensionContentScriptInit; + +class WebExtensionPolicy; + +class MOZ_STACK_CLASS DocInfo final { + public: + DocInfo(const URLInfo& aURL, nsILoadInfo* aLoadInfo); + + MOZ_IMPLICIT DocInfo(nsPIDOMWindowOuter* aWindow); + + const URLInfo& URL() const { return mURL; } + + // The principal of the document, or the expected principal of a request. + // May be null for non-DOMWindow DocInfo objects unless + // URL().InheritsPrincipal() is true. + nsIPrincipal* Principal() const; + + // Returns the URL of the document's principal. Note that this must *only* + // be called for content principals. + const URLInfo& PrincipalURL() const; + + bool IsTopLevel() const; + bool IsSameOriginWithTop() const; + bool ShouldMatchActiveTabPermission() const; + + uint64_t FrameID() const; + + nsPIDOMWindowOuter* GetWindow() const { + if (mObj.is<Window>()) { + return mObj.as<Window>(); + } + return nullptr; + } + + nsILoadInfo* GetLoadInfo() const { + if (mObj.is<LoadInfo>()) { + return mObj.as<LoadInfo>(); + } + return nullptr; + } + + already_AddRefed<nsILoadContext> GetLoadContext() const { + nsCOMPtr<nsILoadContext> loadContext; + if (nsPIDOMWindowOuter* window = GetWindow()) { + nsIDocShell* docShell = window->GetDocShell(); + loadContext = do_QueryInterface(docShell); + } else if (nsILoadInfo* loadInfo = GetLoadInfo()) { + nsCOMPtr<nsISupports> requestingContext = loadInfo->GetLoadingContext(); + loadContext = do_QueryInterface(requestingContext); + } + return loadContext.forget(); + } + + private: + void SetURL(const URLInfo& aURL); + + const URLInfo mURL; + mutable Maybe<const URLInfo> mPrincipalURL; + + mutable Maybe<bool> mIsTopLevel; + + mutable Maybe<nsCOMPtr<nsIPrincipal>> mPrincipal; + mutable Maybe<uint64_t> mFrameID; + + using Window = nsPIDOMWindowOuter*; + using LoadInfo = nsILoadInfo*; + + const Variant<LoadInfo, Window> mObj; +}; + +class MozDocumentMatcher : public nsISupports, public nsWrapperCache { + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(MozDocumentMatcher) + + using MatchGlobArray = nsTArray<RefPtr<MatchGlob>>; + + static already_AddRefed<MozDocumentMatcher> Constructor( + dom::GlobalObject& aGlobal, const dom::MozDocumentMatcherInit& aInit, + ErrorResult& aRv); + + bool Matches(const DocInfo& aDoc, bool aIgnorePermissions) const; + bool Matches(const DocInfo& aDoc) const { return Matches(aDoc, false); } + + bool MatchesURI(const URLInfo& aURL, bool aIgnorePermissions) const; + bool MatchesURI(const URLInfo& aURL) const { return MatchesURI(aURL, false); } + + bool MatchesWindowGlobal(dom::WindowGlobalChild& aWindow, + bool aIgnorePermissions) const; + + WebExtensionPolicy* GetExtension() { return mExtension; } + + WebExtensionPolicy* Extension() { return mExtension; } + const WebExtensionPolicy* Extension() const { return mExtension; } + + bool AllFrames() const { return mAllFrames; } + bool CheckPermissions() const { return mCheckPermissions; } + bool MatchAboutBlank() const { return mMatchAboutBlank; } + + MatchPatternSet* Matches() { return mMatches; } + const MatchPatternSet* GetMatches() const { return mMatches; } + + MatchPatternSet* GetExcludeMatches() { return mExcludeMatches; } + const MatchPatternSet* GetExcludeMatches() const { return mExcludeMatches; } + + Nullable<uint64_t> GetFrameID() const { return mFrameID; } + + void GetOriginAttributesPatterns(JSContext* aCx, + JS::MutableHandle<JS::Value> aVal, + ErrorResult& aError) const; + + WebExtensionPolicy* GetParentObject() const { return mExtension; } + virtual JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + protected: + friend class WebExtensionPolicy; + + virtual ~MozDocumentMatcher() = default; + + MozDocumentMatcher(dom::GlobalObject& aGlobal, + const dom::MozDocumentMatcherInit& aInit, bool aRestricted, + ErrorResult& aRv); + + RefPtr<WebExtensionPolicy> mExtension; + + bool mHasActiveTabPermission; + bool mRestricted; + + RefPtr<MatchPatternSet> mMatches; + RefPtr<MatchPatternSet> mExcludeMatches; + + Nullable<MatchGlobSet> mIncludeGlobs; + Nullable<MatchGlobSet> mExcludeGlobs; + + bool mAllFrames; + bool mCheckPermissions; + Nullable<uint64_t> mFrameID; + bool mMatchAboutBlank; + Nullable<dom::Sequence<OriginAttributesPattern>> mOriginAttributesPatterns; +}; + +class WebExtensionContentScript final : public MozDocumentMatcher { + public: + using RunAtEnum = dom::ContentScriptRunAt; + + static already_AddRefed<WebExtensionContentScript> Constructor( + dom::GlobalObject& aGlobal, WebExtensionPolicy& aExtension, + const ContentScriptInit& aInit, ErrorResult& aRv); + + RunAtEnum RunAt() const { return mRunAt; } + + void GetCssPaths(nsTArray<nsString>& aPaths) const { + aPaths.AppendElements(mCssPaths); + } + void GetJsPaths(nsTArray<nsString>& aPaths) const { + aPaths.AppendElements(mJsPaths); + } + + virtual JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + protected: + friend class WebExtensionPolicy; + + virtual ~WebExtensionContentScript() = default; + + WebExtensionContentScript(dom::GlobalObject& aGlobal, + WebExtensionPolicy& aExtension, + const ContentScriptInit& aInit, ErrorResult& aRv); + + private: + nsTArray<nsString> mCssPaths; + nsTArray<nsString> mJsPaths; + + RunAtEnum mRunAt; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_WebExtensionContentScript_h diff --git a/toolkit/components/extensions/WebExtensionPolicy.cpp b/toolkit/components/extensions/WebExtensionPolicy.cpp new file mode 100644 index 0000000000..1b999ab9a3 --- /dev/null +++ b/toolkit/components/extensions/WebExtensionPolicy.cpp @@ -0,0 +1,1137 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "MainThreadUtils.h" +#include "mozilla/ExtensionPolicyService.h" +#include "mozilla/extensions/DocumentObserver.h" +#include "mozilla/extensions/WebExtensionContentScript.h" +#include "mozilla/extensions/WebExtensionPolicy.h" + +#include "mozilla/AddonManagerWebAPI.h" +#include "mozilla/BasePrincipal.h" +#include "mozilla/dom/WindowGlobalChild.h" +#include "mozilla/ResultExtensions.h" +#include "mozilla/StaticPrefs_extensions.h" +#include "mozilla/Try.h" +#include "nsContentUtils.h" +#include "nsEscape.h" +#include "nsGlobalWindowInner.h" +#include "nsIObserver.h" +#include "nsISubstitutingProtocolHandler.h" +#include "nsLiteralString.h" +#include "nsNetUtil.h" +#include "nsPrintfCString.h" + +namespace mozilla { +namespace extensions { + +using namespace dom; + +static const char kProto[] = "moz-extension"; + +static const char kBackgroundScriptTypeDefault[] = "text/javascript"; + +static const char kBackgroundScriptTypeModule[] = "module"; + +static const char kBackgroundPageHTMLStart[] = + "<!DOCTYPE html>\n\ +<html>\n\ + <head><meta charset=\"utf-8\"></head>\n\ + <body>"; + +static const char kBackgroundPageHTMLScript[] = + "\n\ + <script type=\"%s\" src=\"%s\"></script>"; + +static const char kBackgroundPageHTMLEnd[] = + "\n\ + </body>\n\ +</html>"; + +#define BASE_CSP_PREF_V2 "extensions.webextensions.base-content-security-policy" +#define DEFAULT_BASE_CSP_V2 \ + "script-src 'self' https://* http://localhost:* http://127.0.0.1:* " \ + "moz-extension: blob: filesystem: 'unsafe-eval' 'wasm-unsafe-eval' " \ + "'unsafe-inline';" + +#define BASE_CSP_PREF_V3 \ + "extensions.webextensions.base-content-security-policy.v3" +#define DEFAULT_BASE_CSP_V3 "script-src 'self' 'wasm-unsafe-eval';" + +static inline ExtensionPolicyService& EPS() { + return ExtensionPolicyService::GetSingleton(); +} + +static nsISubstitutingProtocolHandler* Proto() { + static nsCOMPtr<nsISubstitutingProtocolHandler> sHandler; + + if (MOZ_UNLIKELY(!sHandler)) { + nsCOMPtr<nsIIOService> ios = do_GetIOService(); + MOZ_RELEASE_ASSERT(ios); + + nsCOMPtr<nsIProtocolHandler> handler; + ios->GetProtocolHandler(kProto, getter_AddRefs(handler)); + + sHandler = do_QueryInterface(handler); + MOZ_RELEASE_ASSERT(sHandler); + + ClearOnShutdown(&sHandler); + } + + return sHandler; +} + +bool ParseGlobs(GlobalObject& aGlobal, + Sequence<OwningMatchGlobOrUTF8String> aGlobs, + nsTArray<RefPtr<MatchGlobCore>>& aResult, ErrorResult& aRv) { + for (auto& elem : aGlobs) { + if (elem.IsMatchGlob()) { + aResult.AppendElement(elem.GetAsMatchGlob()->Core()); + } else { + RefPtr<MatchGlobCore> glob = + new MatchGlobCore(elem.GetAsUTF8String(), true, false, aRv); + if (aRv.Failed()) { + return false; + } + aResult.AppendElement(glob); + } + } + return true; +} + +enum class ErrorBehavior { + CreateEmptyPattern, + Fail, +}; + +already_AddRefed<MatchPatternSet> ParseMatches( + GlobalObject& aGlobal, + const OwningMatchPatternSetOrStringSequence& aMatches, + const MatchPatternOptions& aOptions, ErrorBehavior aErrorBehavior, + ErrorResult& aRv) { + if (aMatches.IsMatchPatternSet()) { + return do_AddRef(aMatches.GetAsMatchPatternSet().get()); + } + + const auto& strings = aMatches.GetAsStringSequence(); + + nsTArray<OwningStringOrMatchPattern> patterns; + if (!patterns.SetCapacity(strings.Length(), fallible)) { + aRv.Throw(NS_ERROR_OUT_OF_MEMORY); + return nullptr; + } + + for (auto& string : strings) { + OwningStringOrMatchPattern elt; + elt.SetAsString() = string; + patterns.AppendElement(elt); + } + + RefPtr<MatchPatternSet> result = + MatchPatternSet::Constructor(aGlobal, patterns, aOptions, aRv); + + if (aRv.Failed() && aErrorBehavior == ErrorBehavior::CreateEmptyPattern) { + aRv.SuppressException(); + result = MatchPatternSet::Constructor(aGlobal, {}, aOptions, aRv); + } + + return result.forget(); +} + +WebAccessibleResource::WebAccessibleResource( + GlobalObject& aGlobal, const WebAccessibleResourceInit& aInit, + ErrorResult& aRv) { + ParseGlobs(aGlobal, aInit.mResources, mWebAccessiblePaths, aRv); + if (aRv.Failed()) { + return; + } + + if (!aInit.mMatches.IsNull()) { + MatchPatternOptions options; + options.mRestrictSchemes = true; + RefPtr<MatchPatternSet> matches = + ParseMatches(aGlobal, aInit.mMatches.Value(), options, + ErrorBehavior::CreateEmptyPattern, aRv); + MOZ_DIAGNOSTIC_ASSERT(!aRv.Failed()); + mMatches = matches->Core(); + } + + if (!aInit.mExtension_ids.IsNull()) { + mExtensionIDs = new AtomSet(aInit.mExtension_ids.Value()); + } +} + +bool WebAccessibleResource::IsExtensionMatch(const URLInfo& aURI) { + if (!mExtensionIDs) { + return false; + } + RefPtr<WebExtensionPolicyCore> policy = + ExtensionPolicyService::GetCoreByHost(aURI.Host()); + return policy && (mExtensionIDs->Contains(nsGkAtoms::_asterisk) || + mExtensionIDs->Contains(policy->Id())); +} + +/***************************************************************************** + * WebExtensionPolicyCore + *****************************************************************************/ + +WebExtensionPolicyCore::WebExtensionPolicyCore(GlobalObject& aGlobal, + WebExtensionPolicy* aPolicy, + const WebExtensionInit& aInit, + ErrorResult& aRv) + : mPolicy(aPolicy), + mId(NS_AtomizeMainThread(aInit.mId)), + mName(aInit.mName), + mType(NS_AtomizeMainThread(aInit.mType)), + mManifestVersion(aInit.mManifestVersion), + mExtensionPageCSP(aInit.mExtensionPageCSP), + mIsPrivileged(aInit.mIsPrivileged), + mTemporarilyInstalled(aInit.mTemporarilyInstalled), + mBackgroundWorkerScript(aInit.mBackgroundWorkerScript), + mIgnoreQuarantine(aInit.mIsPrivileged || aInit.mIgnoreQuarantine), + mPermissions(new AtomSet(aInit.mPermissions)) { + // In practice this is not necessary, but in tests where the uuid + // passed in is not lowercased various tests can fail. + ToLowerCase(aInit.mMozExtensionHostname, mHostname); + + // Initialize the base CSP and extension page CSP + if (mManifestVersion < 3) { + nsresult rv = Preferences::GetString(BASE_CSP_PREF_V2, mBaseCSP); + if (NS_FAILED(rv)) { + mBaseCSP = NS_LITERAL_STRING_FROM_CSTRING(DEFAULT_BASE_CSP_V2); + } + } else { + nsresult rv = Preferences::GetString(BASE_CSP_PREF_V3, mBaseCSP); + if (NS_FAILED(rv)) { + mBaseCSP = NS_LITERAL_STRING_FROM_CSTRING(DEFAULT_BASE_CSP_V3); + } + } + + if (mExtensionPageCSP.IsVoid()) { + if (mManifestVersion < 3) { + EPS().GetDefaultCSP(mExtensionPageCSP); + } else { + EPS().GetDefaultCSPV3(mExtensionPageCSP); + } + } + + mWebAccessibleResources.SetCapacity(aInit.mWebAccessibleResources.Length()); + for (const auto& resourceInit : aInit.mWebAccessibleResources) { + RefPtr<WebAccessibleResource> resource = + new WebAccessibleResource(aGlobal, resourceInit, aRv); + if (aRv.Failed()) { + return; + } + mWebAccessibleResources.AppendElement(std::move(resource)); + } + + nsresult rv = NS_NewURI(getter_AddRefs(mBaseURI), aInit.mBaseURL); + if (NS_FAILED(rv)) { + aRv.Throw(rv); + } +} + +bool WebExtensionPolicyCore::SourceMayAccessPath( + const URLInfo& aURI, const nsACString& aPath) const { + if (aURI.Scheme() == nsGkAtoms::moz_extension && + MozExtensionHostname().Equals(aURI.Host())) { + // An extension can always access it's own paths. + return true; + } + // Bug 1786564 Static themes need to allow access to theme resources. + if (Type() == nsGkAtoms::theme) { + RefPtr<WebExtensionPolicyCore> policyCore = + ExtensionPolicyService::GetCoreByHost(aURI.Host()); + return policyCore != nullptr; + } + + if (ManifestVersion() < 3) { + return IsWebAccessiblePath(aPath); + } + for (const auto& resource : mWebAccessibleResources) { + if (resource->SourceMayAccessPath(aURI, aPath)) { + return true; + } + } + return false; +} + +bool WebExtensionPolicyCore::CanAccessURI(const URLInfo& aURI, bool aExplicit, + bool aCheckRestricted, + bool aAllowFilePermission) const { + if (aCheckRestricted && WebExtensionPolicy::IsRestrictedURI(aURI)) { + return false; + } + if (aCheckRestricted && QuarantinedFromURI(aURI)) { + return false; + } + if (!aAllowFilePermission && aURI.Scheme() == nsGkAtoms::file) { + return false; + } + + AutoReadLock lock(mLock); + return mHostPermissions && mHostPermissions->Matches(aURI, aExplicit); +} + +bool WebExtensionPolicyCore::QuarantinedFromDoc(const DocInfo& aDoc) const { + return QuarantinedFromURI(aDoc.PrincipalURL()); +} + +bool WebExtensionPolicyCore::QuarantinedFromURI(const URLInfo& aURI) const { + return !IgnoreQuarantine() && WebExtensionPolicy::IsQuarantinedURI(aURI); +} + +/***************************************************************************** + * WebExtensionPolicy + *****************************************************************************/ + +WebExtensionPolicy::WebExtensionPolicy(GlobalObject& aGlobal, + const WebExtensionInit& aInit, + ErrorResult& aRv) + : mCore(new WebExtensionPolicyCore(aGlobal, this, aInit, aRv)), + mLocalizeCallback(aInit.mLocalizeCallback) { + if (aRv.Failed()) { + return; + } + + MatchPatternOptions options; + options.mRestrictSchemes = !HasPermission(nsGkAtoms::mozillaAddons); + + // Set host permissions with SetAllowedOrigins to make sure the copy in core + // and WebExtensionPolicy stay in sync. + RefPtr<MatchPatternSet> hostPermissions = + ParseMatches(aGlobal, aInit.mAllowedOrigins, options, + ErrorBehavior::CreateEmptyPattern, aRv); + if (aRv.Failed()) { + return; + } + SetAllowedOrigins(*hostPermissions); + + if (!aInit.mBackgroundScripts.IsNull()) { + mBackgroundScripts.SetValue().AppendElements( + aInit.mBackgroundScripts.Value()); + } + + mBackgroundTypeModule = aInit.mBackgroundTypeModule; + + mContentScripts.SetCapacity(aInit.mContentScripts.Length()); + for (const auto& scriptInit : aInit.mContentScripts) { + // The activeTab permission is only for dynamically injected scripts, + // it cannot be used for declarative content scripts. + if (scriptInit.mHasActiveTabPermission) { + aRv.Throw(NS_ERROR_INVALID_ARG); + return; + } + + RefPtr<WebExtensionContentScript> contentScript = + new WebExtensionContentScript(aGlobal, *this, scriptInit, aRv); + if (aRv.Failed()) { + return; + } + mContentScripts.AppendElement(std::move(contentScript)); + } + + if (aInit.mReadyPromise.WasPassed()) { + mReadyPromise = &aInit.mReadyPromise.Value(); + } +} + +already_AddRefed<WebExtensionPolicy> WebExtensionPolicy::Constructor( + GlobalObject& aGlobal, const WebExtensionInit& aInit, ErrorResult& aRv) { + RefPtr<WebExtensionPolicy> policy = + new WebExtensionPolicy(aGlobal, aInit, aRv); + if (aRv.Failed()) { + return nullptr; + } + return policy.forget(); +} + +/* static */ +void WebExtensionPolicy::GetActiveExtensions( + dom::GlobalObject& aGlobal, + nsTArray<RefPtr<WebExtensionPolicy>>& aResults) { + EPS().GetAll(aResults); +} + +/* static */ +already_AddRefed<WebExtensionPolicy> WebExtensionPolicy::GetByID( + dom::GlobalObject& aGlobal, const nsAString& aID) { + return do_AddRef(EPS().GetByID(aID)); +} + +/* static */ +already_AddRefed<WebExtensionPolicy> WebExtensionPolicy::GetByHostname( + dom::GlobalObject& aGlobal, const nsACString& aHostname) { + return do_AddRef(EPS().GetByHost(aHostname)); +} + +/* static */ +already_AddRefed<WebExtensionPolicy> WebExtensionPolicy::GetByURI( + dom::GlobalObject& aGlobal, nsIURI* aURI) { + return do_AddRef(EPS().GetByURL(aURI)); +} + +void WebExtensionPolicy::SetActive(bool aActive, ErrorResult& aRv) { + if (aActive == mActive) { + return; + } + + bool ok = aActive ? Enable() : Disable(); + + if (!ok) { + aRv.Throw(NS_ERROR_UNEXPECTED); + } +} + +bool WebExtensionPolicy::Enable() { + MOZ_ASSERT(!mActive); + + if (!EPS().RegisterExtension(*this)) { + return false; + } + + if (XRE_IsParentProcess()) { + // Reserve a BrowsingContextGroup for use by this WebExtensionPolicy. + RefPtr<BrowsingContextGroup> group = BrowsingContextGroup::Create(); + mBrowsingContextGroup = group->MakeKeepAlivePtr(); + } + + Unused << Proto()->SetSubstitution(MozExtensionHostname(), BaseURI()); + + mActive = true; + return true; +} + +bool WebExtensionPolicy::Disable() { + MOZ_ASSERT(mActive); + MOZ_ASSERT(EPS().GetByID(Id()) == this); + + if (!EPS().UnregisterExtension(*this)) { + return false; + } + + if (XRE_IsParentProcess()) { + // Clear our BrowsingContextGroup reference. A new instance will be created + // when the extension is next activated. + mBrowsingContextGroup = nullptr; + } + + Unused << Proto()->SetSubstitution(MozExtensionHostname(), nullptr); + + mActive = false; + return true; +} + +void WebExtensionPolicy::GetURL(const nsAString& aPath, nsAString& aResult, + ErrorResult& aRv) const { + auto result = GetURL(aPath); + if (result.isOk()) { + aResult = result.unwrap(); + } else { + aRv.Throw(result.unwrapErr()); + } +} + +Result<nsString, nsresult> WebExtensionPolicy::GetURL( + const nsAString& aPath) const { + nsPrintfCString spec("%s://%s/", kProto, MozExtensionHostname().get()); + + nsCOMPtr<nsIURI> uri; + MOZ_TRY(NS_NewURI(getter_AddRefs(uri), spec)); + + MOZ_TRY(uri->Resolve(NS_ConvertUTF16toUTF8(aPath), spec)); + + return NS_ConvertUTF8toUTF16(spec); +} + +void WebExtensionPolicy::SetIgnoreQuarantine(bool aIgnore) { + WebExtensionPolicy_Binding::ClearCachedIgnoreQuarantineValue(this); + mCore->SetIgnoreQuarantine(aIgnore); +} + +void WebExtensionPolicy::RegisterContentScript( + WebExtensionContentScript& script, ErrorResult& aRv) { + // Raise an "invalid argument" error if the script is not related to + // the expected extension or if it is already registered. + if (script.mExtension != this || mContentScripts.Contains(&script)) { + aRv.Throw(NS_ERROR_INVALID_ARG); + return; + } + + RefPtr<WebExtensionContentScript> newScript = &script; + + if (!mContentScripts.AppendElement(std::move(newScript), fallible)) { + aRv.Throw(NS_ERROR_OUT_OF_MEMORY); + return; + } + + WebExtensionPolicy_Binding::ClearCachedContentScriptsValue(this); +} + +void WebExtensionPolicy::UnregisterContentScript( + const WebExtensionContentScript& script, ErrorResult& aRv) { + if (script.mExtension != this || !mContentScripts.RemoveElement(&script)) { + aRv.Throw(NS_ERROR_INVALID_ARG); + return; + } + + WebExtensionPolicy_Binding::ClearCachedContentScriptsValue(this); +} + +void WebExtensionPolicy::SetAllowedOrigins(MatchPatternSet& aAllowedOrigins) { + // Make sure to keep the version in `WebExtensionPolicy` (which can be exposed + // back to script using AllowedOrigins()), and the version in + // `WebExtensionPolicyCore` (which is threadsafe) in sync. + AutoWriteLock lock(mCore->mLock); + mHostPermissions = &aAllowedOrigins; + mCore->mHostPermissions = aAllowedOrigins.Core(); +} + +void WebExtensionPolicy::InjectContentScripts(ErrorResult& aRv) { + nsresult rv = EPS().InjectContentScripts(this); + if (NS_FAILED(rv)) { + aRv.Throw(rv); + } +} + +/* static */ +bool WebExtensionPolicy::UseRemoteWebExtensions(GlobalObject& aGlobal) { + return EPS().UseRemoteExtensions(); +} + +/* static */ +bool WebExtensionPolicy::IsExtensionProcess(GlobalObject& aGlobal) { + return EPS().IsExtensionProcess(); +} + +/* static */ +bool WebExtensionPolicy::BackgroundServiceWorkerEnabled(GlobalObject& aGlobal) { + // When MOZ_WEBEXT_WEBIDL_ENABLED is not set at compile time, extension APIs + // are not available to extension service workers. To avoid confusion, the + // extensions.backgroundServiceWorkerEnabled.enabled pref is locked to false + // in modules/libpref/init/all.js when MOZ_WEBEXT_WEBIDL_ENABLED is not set. + return StaticPrefs::extensions_backgroundServiceWorker_enabled_AtStartup(); +} + +/* static */ +bool WebExtensionPolicy::QuarantinedDomainsEnabled(GlobalObject& aGlobal) { + return EPS().GetQuarantinedDomainsEnabled(); +} + +/* static */ +bool WebExtensionPolicy::IsRestrictedDoc(const DocInfo& aDoc) { + // With the exception of top-level about:blank documents with null + // principals, we never match documents that have non-content principals, + // including those with null principals or system principals. + if (aDoc.Principal() && !aDoc.Principal()->GetIsContentPrincipal()) { + return true; + } + + return IsRestrictedURI(aDoc.PrincipalURL()); +} + +/* static */ +bool WebExtensionPolicy::IsRestrictedURI(const URLInfo& aURI) { + RefPtr<AtomSet> restrictedDomains = + ExtensionPolicyService::RestrictedDomains(); + + if (restrictedDomains && restrictedDomains->Contains(aURI.HostAtom())) { + return true; + } + + if (AddonManagerWebAPI::IsValidSite(aURI.URI())) { + return true; + } + + return false; +} + +/* static */ +bool WebExtensionPolicy::IsQuarantinedDoc(const DocInfo& aDoc) { + return IsQuarantinedURI(aDoc.PrincipalURL()); +} + +/* static */ +bool WebExtensionPolicy::IsQuarantinedURI(const URLInfo& aURI) { + // Ensure EPS is initialized before asking it about quarantined domains. + Unused << EPS(); + + RefPtr<AtomSet> quarantinedDomains = + ExtensionPolicyService::QuarantinedDomains(); + + return quarantinedDomains && quarantinedDomains->Contains(aURI.HostAtom()); +} + +nsCString WebExtensionPolicy::BackgroundPageHTML() const { + nsCString result; + + if (mBackgroundScripts.IsNull()) { + result.SetIsVoid(true); + return result; + } + + result.AppendLiteral(kBackgroundPageHTMLStart); + + const char* scriptType = mBackgroundTypeModule ? kBackgroundScriptTypeModule + : kBackgroundScriptTypeDefault; + + for (auto& script : mBackgroundScripts.Value()) { + nsCString escaped; + nsAppendEscapedHTML(NS_ConvertUTF16toUTF8(script), escaped); + result.AppendPrintf(kBackgroundPageHTMLScript, scriptType, escaped.get()); + } + + result.AppendLiteral(kBackgroundPageHTMLEnd); + return result; +} + +void WebExtensionPolicy::Localize(const nsAString& aInput, + nsString& aOutput) const { + RefPtr<WebExtensionLocalizeCallback> callback(mLocalizeCallback); + callback->Call(aInput, aOutput); +} + +JSObject* WebExtensionPolicy::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return WebExtensionPolicy_Binding::Wrap(aCx, this, aGivenProto); +} + +void WebExtensionPolicy::GetContentScripts( + nsTArray<RefPtr<WebExtensionContentScript>>& aScripts) const { + aScripts.AppendElements(mContentScripts); +} + +bool WebExtensionPolicy::PrivateBrowsingAllowed() const { + return HasPermission(nsGkAtoms::privateBrowsingAllowedPermission); +} + +bool WebExtensionPolicy::CanAccessContext(nsILoadContext* aContext) const { + MOZ_ASSERT(aContext); + return PrivateBrowsingAllowed() || !aContext->UsePrivateBrowsing(); +} + +bool WebExtensionPolicy::CanAccessWindow( + const dom::WindowProxyHolder& aWindow) const { + if (PrivateBrowsingAllowed()) { + return true; + } + // match browsing mode with policy + nsIDocShell* docShell = aWindow.get()->GetDocShell(); + nsCOMPtr<nsILoadContext> loadContext = do_QueryInterface(docShell); + return !(loadContext && loadContext->UsePrivateBrowsing()); +} + +void WebExtensionPolicy::GetReadyPromise( + JSContext* aCx, JS::MutableHandle<JSObject*> aResult) const { + if (mReadyPromise) { + aResult.set(mReadyPromise->PromiseObj()); + } else { + aResult.set(nullptr); + } +} + +uint64_t WebExtensionPolicy::GetBrowsingContextGroupId() const { + MOZ_ASSERT(XRE_IsParentProcess() && mActive); + return mBrowsingContextGroup ? mBrowsingContextGroup->Id() : 0; +} + +uint64_t WebExtensionPolicy::GetBrowsingContextGroupId(ErrorResult& aRv) { + if (XRE_IsParentProcess() && mActive) { + return GetBrowsingContextGroupId(); + } + aRv.ThrowInvalidAccessError( + "browsingContextGroupId only available for active policies in the " + "parent process"); + return 0; +} + +WebExtensionPolicy::~WebExtensionPolicy() { mCore->ClearPolicyWeakRef(); } + +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(WebExtensionPolicy) +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(WebExtensionPolicy) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mParent) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mBrowsingContextGroup) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mLocalizeCallback) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mHostPermissions) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mContentScripts) + NS_IMPL_CYCLE_COLLECTION_UNLINK_PRESERVED_WRAPPER + AssertIsOnMainThread(); + tmp->mCore->ClearPolicyWeakRef(); +NS_IMPL_CYCLE_COLLECTION_UNLINK_END +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(WebExtensionPolicy) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mParent) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mBrowsingContextGroup) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mLocalizeCallback) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mHostPermissions) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mContentScripts) +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(WebExtensionPolicy) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(WebExtensionPolicy) +NS_IMPL_CYCLE_COLLECTING_RELEASE(WebExtensionPolicy) + +/***************************************************************************** + * WebExtensionContentScript / MozDocumentMatcher + *****************************************************************************/ + +/* static */ +already_AddRefed<MozDocumentMatcher> MozDocumentMatcher::Constructor( + GlobalObject& aGlobal, const dom::MozDocumentMatcherInit& aInit, + ErrorResult& aRv) { + RefPtr<MozDocumentMatcher> matcher = + new MozDocumentMatcher(aGlobal, aInit, false, aRv); + if (aRv.Failed()) { + return nullptr; + } + return matcher.forget(); +} + +/* static */ +already_AddRefed<WebExtensionContentScript> +WebExtensionContentScript::Constructor(GlobalObject& aGlobal, + WebExtensionPolicy& aExtension, + const ContentScriptInit& aInit, + ErrorResult& aRv) { + RefPtr<WebExtensionContentScript> script = + new WebExtensionContentScript(aGlobal, aExtension, aInit, aRv); + if (aRv.Failed()) { + return nullptr; + } + return script.forget(); +} + +MozDocumentMatcher::MozDocumentMatcher(GlobalObject& aGlobal, + const dom::MozDocumentMatcherInit& aInit, + bool aRestricted, ErrorResult& aRv) + : mHasActiveTabPermission(aInit.mHasActiveTabPermission), + mRestricted(aRestricted), + mAllFrames(aInit.mAllFrames), + mCheckPermissions(aInit.mCheckPermissions), + mFrameID(aInit.mFrameID), + mMatchAboutBlank(aInit.mMatchAboutBlank) { + MatchPatternOptions options; + options.mRestrictSchemes = mRestricted; + + mMatches = ParseMatches(aGlobal, aInit.mMatches, options, + ErrorBehavior::CreateEmptyPattern, aRv); + if (aRv.Failed()) { + return; + } + + if (!aInit.mExcludeMatches.IsNull()) { + mExcludeMatches = + ParseMatches(aGlobal, aInit.mExcludeMatches.Value(), options, + ErrorBehavior::CreateEmptyPattern, aRv); + if (aRv.Failed()) { + return; + } + } + + if (!aInit.mIncludeGlobs.IsNull()) { + if (!ParseGlobs(aGlobal, aInit.mIncludeGlobs.Value(), + mIncludeGlobs.SetValue(), aRv)) { + return; + } + } + + if (!aInit.mExcludeGlobs.IsNull()) { + if (!ParseGlobs(aGlobal, aInit.mExcludeGlobs.Value(), + mExcludeGlobs.SetValue(), aRv)) { + return; + } + } + + if (!aInit.mOriginAttributesPatterns.IsNull()) { + Sequence<OriginAttributesPattern>& arr = + mOriginAttributesPatterns.SetValue(); + for (const auto& pattern : aInit.mOriginAttributesPatterns.Value()) { + if (!arr.AppendElement(OriginAttributesPattern(pattern), fallible)) { + aRv.Throw(NS_ERROR_OUT_OF_MEMORY); + return; + } + } + } +} + +WebExtensionContentScript::WebExtensionContentScript( + GlobalObject& aGlobal, WebExtensionPolicy& aExtension, + const ContentScriptInit& aInit, ErrorResult& aRv) + : MozDocumentMatcher(aGlobal, aInit, + !aExtension.HasPermission(nsGkAtoms::mozillaAddons), + aRv), + mRunAt(aInit.mRunAt) { + mCssPaths.Assign(aInit.mCssPaths); + mJsPaths.Assign(aInit.mJsPaths); + mExtension = &aExtension; + + // Origin permissions are optional in mv3, so always check them at runtime. + if (mExtension->ManifestVersion() >= 3) { + mCheckPermissions = true; + } +} + +bool MozDocumentMatcher::Matches(const DocInfo& aDoc, + bool aIgnorePermissions) const { + if (!mFrameID.IsNull()) { + if (aDoc.FrameID() != mFrameID.Value()) { + return false; + } + } else { + if (!mAllFrames && !aDoc.IsTopLevel()) { + return false; + } + } + + // match browsing mode with policy + nsCOMPtr<nsILoadContext> loadContext = aDoc.GetLoadContext(); + if (loadContext && mExtension && !mExtension->CanAccessContext(loadContext)) { + return false; + } + + if (loadContext && !mOriginAttributesPatterns.IsNull()) { + OriginAttributes docShellAttrs; + loadContext->GetOriginAttributes(docShellAttrs); + bool patternMatch = false; + for (const auto& pattern : mOriginAttributesPatterns.Value()) { + if (pattern.Matches(docShellAttrs)) { + patternMatch = true; + break; + } + } + if (!patternMatch) { + return false; + } + } + + // TODO bug 1411641: we should account for precursorPrincipal if + // match_origin_as_fallback is specified (see also bug 1853411). + if (!mMatchAboutBlank && aDoc.URL().InheritsPrincipal()) { + return false; + } + + // Top-level about:blank is a special case. Unlike about:blank frames/windows + // opened by web pages, these do not have an origin that could be matched by + // a match pattern (they have a null principal instead). To allow extensions + // that intend to run scripts "everywhere", consider the document matched if + // the match pattern describe a very broad pattern (such as "<all_urls>"). + if (mMatchAboutBlank && aDoc.IsTopLevel() && + (aDoc.URL().Spec().EqualsLiteral("about:blank") || + aDoc.URL().Scheme() == nsGkAtoms::data) && + aDoc.Principal() && aDoc.Principal()->GetIsNullPrincipal()) { + if (StaticPrefs::extensions_script_about_blank_without_permission()) { + return true; + } + if (mHasActiveTabPermission) { + return true; + } + if (mMatches->MatchesAllWebUrls() && mIncludeGlobs.IsNull()) { + // When mIncludeGlobs is present, mMatches does not necessarily match + // everything (except possibly if include_globs is just ["*"]). So we + // only match if mMatches is present without mIncludeGlobs. + return true; + } + // Null principal is never going to match, so we may as well return now. + return false; + } + + if (mRestricted && WebExtensionPolicy::IsRestrictedDoc(aDoc)) { + return false; + } + + if (mRestricted && mExtension && mExtension->QuarantinedFromDoc(aDoc)) { + return false; + } + + auto& urlinfo = aDoc.PrincipalURL(); + if (mExtension && mExtension->ManifestVersion() >= 3) { + // In MV3, activeTab only allows access to same-origin iframes. + if (mHasActiveTabPermission && aDoc.IsSameOriginWithTop() && + MatchPattern::MatchesAllURLs(urlinfo)) { + return true; + } + } else { + if (mHasActiveTabPermission && aDoc.ShouldMatchActiveTabPermission() && + MatchPattern::MatchesAllURLs(urlinfo)) { + return true; + } + } + + return MatchesURI(urlinfo, aIgnorePermissions); +} + +bool MozDocumentMatcher::MatchesURI(const URLInfo& aURL, + bool aIgnorePermissions) const { + MOZ_ASSERT((!mRestricted && !mCheckPermissions) || mExtension); + + if (!mMatches->Matches(aURL)) { + return false; + } + + if (mExcludeMatches && mExcludeMatches->Matches(aURL)) { + return false; + } + + if (!mIncludeGlobs.IsNull() && !mIncludeGlobs.Value().Matches(aURL.CSpec())) { + return false; + } + + if (!mExcludeGlobs.IsNull() && mExcludeGlobs.Value().Matches(aURL.CSpec())) { + return false; + } + + if (mRestricted && WebExtensionPolicy::IsRestrictedURI(aURL)) { + return false; + } + + if (mRestricted && mExtension->QuarantinedFromURI(aURL)) { + return false; + } + + if (mCheckPermissions && !aIgnorePermissions && + !mExtension->CanAccessURI(aURL, false, false, true)) { + return false; + } + + return true; +} + +bool MozDocumentMatcher::MatchesWindowGlobal(WindowGlobalChild& aWindow, + bool aIgnorePermissions) const { + if (aWindow.IsClosed() || !aWindow.IsCurrentGlobal()) { + return false; + } + nsGlobalWindowInner* inner = aWindow.GetWindowGlobal(); + if (!inner || !inner->GetDocShell()) { + return false; + } + return Matches(inner->GetOuterWindow(), aIgnorePermissions); +} + +void MozDocumentMatcher::GetOriginAttributesPatterns( + JSContext* aCx, JS::MutableHandle<JS::Value> aVal, + ErrorResult& aError) const { + if (!ToJSValue(aCx, mOriginAttributesPatterns, aVal)) { + aError.NoteJSContextException(aCx); + } +} + +JSObject* MozDocumentMatcher::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return MozDocumentMatcher_Binding::Wrap(aCx, this, aGivenProto); +} + +JSObject* WebExtensionContentScript::WrapObject( + JSContext* aCx, JS::Handle<JSObject*> aGivenProto) { + return WebExtensionContentScript_Binding::Wrap(aCx, this, aGivenProto); +} + +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(MozDocumentMatcher, mMatches, + mExcludeMatches, mExtension) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(MozDocumentMatcher) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(MozDocumentMatcher) +NS_IMPL_CYCLE_COLLECTING_RELEASE(MozDocumentMatcher) + +/***************************************************************************** + * MozDocumentObserver + *****************************************************************************/ + +/* static */ +already_AddRefed<DocumentObserver> DocumentObserver::Constructor( + GlobalObject& aGlobal, dom::MozDocumentCallback& aCallbacks) { + RefPtr<DocumentObserver> matcher = + new DocumentObserver(aGlobal.GetAsSupports(), aCallbacks); + return matcher.forget(); +} + +void DocumentObserver::Observe( + const dom::Sequence<OwningNonNull<MozDocumentMatcher>>& matchers, + ErrorResult& aRv) { + if (!EPS().RegisterObserver(*this)) { + aRv.Throw(NS_ERROR_FAILURE); + return; + } + mMatchers.Clear(); + for (auto& matcher : matchers) { + if (!mMatchers.AppendElement(matcher, fallible)) { + aRv.Throw(NS_ERROR_OUT_OF_MEMORY); + return; + } + } +} + +void DocumentObserver::Disconnect() { + Unused << EPS().UnregisterObserver(*this); +} + +void DocumentObserver::NotifyMatch(MozDocumentMatcher& aMatcher, + nsPIDOMWindowOuter* aWindow) { + IgnoredErrorResult rv; + mCallbacks->OnNewDocument( + aMatcher, WindowProxyHolder(aWindow->GetBrowsingContext()), rv); +} + +void DocumentObserver::NotifyMatch(MozDocumentMatcher& aMatcher, + nsILoadInfo* aLoadInfo) { + IgnoredErrorResult rv; + mCallbacks->OnPreloadDocument(aMatcher, aLoadInfo, rv); +} + +JSObject* DocumentObserver::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return MozDocumentObserver_Binding::Wrap(aCx, this, aGivenProto); +} + +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(DocumentObserver, mCallbacks, mMatchers, + mParent) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(DocumentObserver) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(DocumentObserver) +NS_IMPL_CYCLE_COLLECTING_RELEASE(DocumentObserver) + +/***************************************************************************** + * DocInfo + *****************************************************************************/ + +DocInfo::DocInfo(const URLInfo& aURL, nsILoadInfo* aLoadInfo) + : mURL(aURL), mObj(AsVariant(aLoadInfo)) {} + +DocInfo::DocInfo(nsPIDOMWindowOuter* aWindow) + : mURL(aWindow->GetDocumentURI()), mObj(AsVariant(aWindow)) {} + +bool DocInfo::IsTopLevel() const { + if (mIsTopLevel.isNothing()) { + struct Matcher { + bool operator()(Window aWin) { + return aWin->GetBrowsingContext()->IsTop(); + } + bool operator()(LoadInfo aLoadInfo) { + return aLoadInfo->GetIsTopLevelLoad(); + } + }; + mIsTopLevel.emplace(mObj.match(Matcher())); + } + return mIsTopLevel.ref(); +} + +bool WindowShouldMatchActiveTab(nsPIDOMWindowOuter* aWin) { + WindowContext* wc = aWin->GetCurrentInnerWindow()->GetWindowContext(); + if (wc && wc->SameOriginWithTop()) { + // If the frame is same-origin to top, accept the match regardless of + // whether the frame was populated dynamically. + return true; + } + for (; wc; wc = wc->GetParentWindowContext()) { + BrowsingContext* bc = wc->GetBrowsingContext(); + if (bc->IsTopContent()) { + return true; + } + + if (bc->CreatedDynamically() || !wc->GetIsOriginalFrameSource()) { + return false; + } + } + MOZ_ASSERT_UNREACHABLE("Should reach top content before end of loop"); + return false; +} + +bool DocInfo::ShouldMatchActiveTabPermission() const { + struct Matcher { + bool operator()(Window aWin) { return WindowShouldMatchActiveTab(aWin); } + bool operator()(LoadInfo aLoadInfo) { return false; } + }; + return mObj.match(Matcher()); +} + +bool DocInfo::IsSameOriginWithTop() const { + struct Matcher { + bool operator()(Window aWin) { + WindowContext* wc = aWin->GetCurrentInnerWindow()->GetWindowContext(); + return wc && wc->SameOriginWithTop(); + } + bool operator()(LoadInfo aLoadInfo) { return false; } + }; + return mObj.match(Matcher()); +} + +uint64_t DocInfo::FrameID() const { + if (mFrameID.isNothing()) { + if (IsTopLevel()) { + mFrameID.emplace(0); + } else { + struct Matcher { + uint64_t operator()(Window aWin) { + return aWin->GetBrowsingContext()->Id(); + } + uint64_t operator()(LoadInfo aLoadInfo) { + return aLoadInfo->GetBrowsingContextID(); + } + }; + mFrameID.emplace(mObj.match(Matcher())); + } + } + return mFrameID.ref(); +} + +nsIPrincipal* DocInfo::Principal() const { + if (mPrincipal.isNothing()) { + struct Matcher { + explicit Matcher(const DocInfo& aThis) : mThis(aThis) {} + const DocInfo& mThis; + + nsIPrincipal* operator()(Window aWin) { + RefPtr<Document> doc = aWin->GetDoc(); + return doc->NodePrincipal(); + } + nsIPrincipal* operator()(LoadInfo aLoadInfo) { + if (!(mThis.URL().InheritsPrincipal() || + aLoadInfo->GetForceInheritPrincipal())) { + return nullptr; + } + if (auto principal = aLoadInfo->PrincipalToInherit()) { + return principal; + } + return aLoadInfo->TriggeringPrincipal(); + } + }; + mPrincipal.emplace(mObj.match(Matcher(*this))); + } + return mPrincipal.ref(); +} + +const URLInfo& DocInfo::PrincipalURL() const { + if (!(Principal() && Principal()->GetIsContentPrincipal())) { + return URL(); + } + + if (mPrincipalURL.isNothing()) { + nsIPrincipal* prin = Principal(); + auto* basePrin = BasePrincipal::Cast(prin); + nsCOMPtr<nsIURI> uri; + if (NS_SUCCEEDED(basePrin->GetURI(getter_AddRefs(uri)))) { + MOZ_DIAGNOSTIC_ASSERT(uri); + mPrincipalURL.emplace(uri); + } else { + mPrincipalURL.emplace(URL()); + } + } + + return mPrincipalURL.ref(); +} + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/WebExtensionPolicy.h b/toolkit/components/extensions/WebExtensionPolicy.h new file mode 100644 index 0000000000..e5824f8061 --- /dev/null +++ b/toolkit/components/extensions/WebExtensionPolicy.h @@ -0,0 +1,420 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_extensions_WebExtensionPolicy_h +#define mozilla_extensions_WebExtensionPolicy_h + +#include "MainThreadUtils.h" +#include "mozilla/RWLock.h" +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/dom/BrowsingContextGroup.h" +#include "mozilla/dom/Nullable.h" +#include "mozilla/dom/WebExtensionPolicyBinding.h" +#include "mozilla/dom/WindowProxyHolder.h" +#include "mozilla/extensions/MatchPattern.h" + +#include "jspubtd.h" + +#include "mozilla/Result.h" +#include "mozilla/WeakPtr.h" +#include "nsCOMPtr.h" +#include "nsCycleCollectionParticipant.h" +#include "nsGkAtoms.h" +#include "nsISupports.h" +#include "nsWrapperCache.h" + +namespace mozilla { +namespace dom { +class Promise; +} // namespace dom + +namespace extensions { + +using dom::WebAccessibleResourceInit; +using dom::WebExtensionInit; +using dom::WebExtensionLocalizeCallback; + +class DocInfo; +class WebExtensionContentScript; + +class WebAccessibleResource final { + public: + NS_INLINE_DECL_THREADSAFE_REFCOUNTING(WebAccessibleResource) + + WebAccessibleResource(dom::GlobalObject& aGlobal, + const WebAccessibleResourceInit& aInit, + ErrorResult& aRv); + + bool IsWebAccessiblePath(const nsACString& aPath) const { + return mWebAccessiblePaths.Matches(aPath); + } + + bool SourceMayAccessPath(const URLInfo& aURI, const nsACString& aPath) { + return mWebAccessiblePaths.Matches(aPath) && + (IsHostMatch(aURI) || IsExtensionMatch(aURI)); + } + + bool IsHostMatch(const URLInfo& aURI) { + return mMatches && mMatches->Matches(aURI); + } + + bool IsExtensionMatch(const URLInfo& aURI); + + private: + ~WebAccessibleResource() = default; + + MatchGlobSet mWebAccessiblePaths; + RefPtr<MatchPatternSetCore> mMatches; + RefPtr<AtomSet> mExtensionIDs; +}; + +/// The thread-safe component of the WebExtensionPolicy. +/// +/// Acts as a weak reference to the base WebExtensionPolicy. +class WebExtensionPolicyCore final { + public: + NS_INLINE_DECL_THREADSAFE_REFCOUNTING(WebExtensionPolicyCore) + + nsAtom* Id() const { return mId; } + + const nsCString& MozExtensionHostname() const { return mHostname; } + + nsIURI* BaseURI() const { return mBaseURI; } + + bool IsPrivileged() { return mIsPrivileged; } + + bool TemporarilyInstalled() { return mTemporarilyInstalled; } + + const nsString& Name() const { return mName; } + + nsAtom* Type() const { return mType; } + + uint32_t ManifestVersion() const { return mManifestVersion; } + + const nsString& ExtensionPageCSP() const { return mExtensionPageCSP; } + + const nsString& BaseCSP() const { return mBaseCSP; } + + const nsString& BackgroundWorkerScript() const { + return mBackgroundWorkerScript; + } + + bool IsWebAccessiblePath(const nsACString& aPath) const { + for (const auto& resource : mWebAccessibleResources) { + if (resource->IsWebAccessiblePath(aPath)) { + return true; + } + } + return false; + } + + bool SourceMayAccessPath(const URLInfo& aURI, const nsACString& aPath) const; + + bool HasPermission(const nsAtom* aPermission) const { + AutoReadLock lock(mLock); + return mPermissions->Contains(aPermission); + } + + void GetPermissions(nsTArray<nsString>& aResult) const MOZ_EXCLUDES(mLock) { + AutoReadLock lock(mLock); + return mPermissions->Get(aResult); + } + + void SetPermissions(const nsTArray<nsString>& aPermissions) + MOZ_EXCLUDES(mLock) { + RefPtr<AtomSet> newPermissions = new AtomSet(aPermissions); + AutoWriteLock lock(mLock); + mPermissions = std::move(newPermissions); + } + + bool CanAccessURI(const URLInfo& aURI, bool aExplicit = false, + bool aCheckRestricted = true, + bool aAllowFilePermission = false) const; + + bool IgnoreQuarantine() const MOZ_EXCLUDES(mLock) { + AutoReadLock lock(mLock); + return mIgnoreQuarantine; + } + void SetIgnoreQuarantine(bool aIgnore) MOZ_EXCLUDES(mLock) { + AutoWriteLock lock(mLock); + mIgnoreQuarantine = aIgnore; + } + + bool QuarantinedFromDoc(const DocInfo& aDoc) const; + bool QuarantinedFromURI(const URLInfo& aURI) const MOZ_EXCLUDES(mLock); + + // Try to get a reference to the cycle-collected main-thread-only + // WebExtensionPolicy instance. + // + // Will return nullptr if the policy has already been unlinked or destroyed. + WebExtensionPolicy* GetMainThreadPolicy() const + MOZ_REQUIRES(sMainThreadCapability) { + return mPolicy; + } + + private: + friend class WebExtensionPolicy; + + WebExtensionPolicyCore(dom::GlobalObject& aGlobal, + WebExtensionPolicy* aPolicy, + const WebExtensionInit& aInit, ErrorResult& aRv); + + ~WebExtensionPolicyCore() = default; + + void ClearPolicyWeakRef() MOZ_REQUIRES(sMainThreadCapability) { + mPolicy = nullptr; + } + + // Unless otherwise guarded by a capability, all members on + // WebExtensionPolicyCore should be immutable and threadsafe. + + WebExtensionPolicy* MOZ_NON_OWNING_REF mPolicy + MOZ_GUARDED_BY(sMainThreadCapability); + + const RefPtr<nsAtom> mId; + /* const */ nsCString mHostname; + /* const */ nsCOMPtr<nsIURI> mBaseURI; + + const nsString mName; + const RefPtr<nsAtom> mType; + const uint32_t mManifestVersion; + /* const */ nsString mExtensionPageCSP; + /* const */ nsString mBaseCSP; + + const bool mIsPrivileged; + const bool mTemporarilyInstalled; + + const nsString mBackgroundWorkerScript; + + /* const */ nsTArray<RefPtr<WebAccessibleResource>> mWebAccessibleResources; + + mutable RWLock mLock{"WebExtensionPolicyCore"}; + + bool mIgnoreQuarantine MOZ_GUARDED_BY(mLock); + RefPtr<AtomSet> mPermissions MOZ_GUARDED_BY(mLock); + RefPtr<MatchPatternSetCore> mHostPermissions MOZ_GUARDED_BY(mLock); +}; + +class WebExtensionPolicy final : public nsISupports, public nsWrapperCache { + public: + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(WebExtensionPolicy) + + using ScriptArray = nsTArray<RefPtr<WebExtensionContentScript>>; + + static already_AddRefed<WebExtensionPolicy> Constructor( + dom::GlobalObject& aGlobal, const WebExtensionInit& aInit, + ErrorResult& aRv); + + WebExtensionPolicyCore* Core() const { return mCore; } + + nsAtom* Id() const { return mCore->Id(); } + void GetId(nsAString& aId) const { aId = nsDependentAtomString(Id()); }; + + const nsCString& MozExtensionHostname() const { + return mCore->MozExtensionHostname(); + } + void GetMozExtensionHostname(nsACString& aHostname) const { + aHostname = MozExtensionHostname(); + } + + nsIURI* BaseURI() const { return mCore->BaseURI(); } + void GetBaseURL(nsACString& aBaseURL) const { + MOZ_ALWAYS_SUCCEEDS(mCore->BaseURI()->GetSpec(aBaseURL)); + } + + bool IsPrivileged() { return mCore->IsPrivileged(); } + + bool TemporarilyInstalled() { return mCore->TemporarilyInstalled(); } + + void GetURL(const nsAString& aPath, nsAString& aURL, ErrorResult& aRv) const; + + Result<nsString, nsresult> GetURL(const nsAString& aPath) const; + + void RegisterContentScript(WebExtensionContentScript& script, + ErrorResult& aRv); + + void UnregisterContentScript(const WebExtensionContentScript& script, + ErrorResult& aRv); + + void InjectContentScripts(ErrorResult& aRv); + + bool CanAccessURI(const URLInfo& aURI, bool aExplicit = false, + bool aCheckRestricted = true, + bool aAllowFilePermission = false) const { + return mCore->CanAccessURI(aURI, aExplicit, aCheckRestricted, + aAllowFilePermission); + } + + bool IsWebAccessiblePath(const nsACString& aPath) const { + return mCore->IsWebAccessiblePath(aPath); + } + + bool SourceMayAccessPath(const URLInfo& aURI, const nsACString& aPath) const { + return mCore->SourceMayAccessPath(aURI, aPath); + } + + bool HasPermission(const nsAtom* aPermission) const { + return mCore->HasPermission(aPermission); + } + bool HasPermission(const nsAString& aPermission) const { + RefPtr<nsAtom> atom = NS_AtomizeMainThread(aPermission); + return HasPermission(atom); + } + + static bool IsRestrictedDoc(const DocInfo& aDoc); + static bool IsRestrictedURI(const URLInfo& aURI); + + static bool IsQuarantinedDoc(const DocInfo& aDoc); + static bool IsQuarantinedURI(const URLInfo& aURI); + + bool QuarantinedFromDoc(const DocInfo& aDoc) const { + return mCore->QuarantinedFromDoc(aDoc); + } + + bool QuarantinedFromURI(const URLInfo& aURI) const { + return mCore->QuarantinedFromURI(aURI); + } + + nsCString BackgroundPageHTML() const; + + MOZ_CAN_RUN_SCRIPT + void Localize(const nsAString& aInput, nsString& aResult) const; + + const nsString& Name() const { return mCore->Name(); } + void GetName(nsAString& aName) const { aName = Name(); } + + nsAtom* Type() const { return mCore->Type(); } + void GetType(nsAString& aType) const { + aType = nsDependentAtomString(Type()); + }; + + uint32_t ManifestVersion() const { return mCore->ManifestVersion(); } + + const nsString& ExtensionPageCSP() const { return mCore->ExtensionPageCSP(); } + void GetExtensionPageCSP(nsAString& aCSP) const { aCSP = ExtensionPageCSP(); } + + const nsString& BaseCSP() const { return mCore->BaseCSP(); } + void GetBaseCSP(nsAString& aCSP) const { aCSP = BaseCSP(); } + + already_AddRefed<MatchPatternSet> AllowedOrigins() { + return do_AddRef(mHostPermissions); + } + void SetAllowedOrigins(MatchPatternSet& aAllowedOrigins); + + void GetPermissions(nsTArray<nsString>& aResult) const { + mCore->GetPermissions(aResult); + } + void SetPermissions(const nsTArray<nsString>& aPermissions) { + mCore->SetPermissions(aPermissions); + } + + bool IgnoreQuarantine() const { return mCore->IgnoreQuarantine(); } + void SetIgnoreQuarantine(bool aIgnore); + + void GetContentScripts(ScriptArray& aScripts) const; + const ScriptArray& ContentScripts() const { return mContentScripts; } + + bool Active() const { return mActive; } + void SetActive(bool aActive, ErrorResult& aRv); + + bool PrivateBrowsingAllowed() const; + + bool CanAccessContext(nsILoadContext* aContext) const; + + bool CanAccessWindow(const dom::WindowProxyHolder& aWindow) const; + + void GetReadyPromise(JSContext* aCx, + JS::MutableHandle<JSObject*> aResult) const; + dom::Promise* ReadyPromise() const { return mReadyPromise; } + + const nsString& BackgroundWorkerScript() const { + return mCore->BackgroundWorkerScript(); + } + void GetBackgroundWorker(nsString& aScriptURL) const { + aScriptURL.Assign(BackgroundWorkerScript()); + } + + bool IsManifestBackgroundWorker(const nsAString& aWorkerScriptURL) const { + return BackgroundWorkerScript().Equals(aWorkerScriptURL); + } + + uint64_t GetBrowsingContextGroupId() const; + uint64_t GetBrowsingContextGroupId(ErrorResult& aRv); + + static void GetActiveExtensions( + dom::GlobalObject& aGlobal, + nsTArray<RefPtr<WebExtensionPolicy>>& aResults); + + static already_AddRefed<WebExtensionPolicy> GetByID( + dom::GlobalObject& aGlobal, const nsAString& aID); + + static already_AddRefed<WebExtensionPolicy> GetByHostname( + dom::GlobalObject& aGlobal, const nsACString& aHostname); + + static already_AddRefed<WebExtensionPolicy> GetByURI( + dom::GlobalObject& aGlobal, nsIURI* aURI); + + static bool IsRestrictedURI(dom::GlobalObject& aGlobal, const URLInfo& aURI) { + return IsRestrictedURI(aURI); + } + + static bool IsQuarantinedURI(dom::GlobalObject& aGlobal, + const URLInfo& aURI) { + return IsQuarantinedURI(aURI); + } + + bool QuarantinedFromURI(dom::GlobalObject& aGlobal, + const URLInfo& aURI) const { + return QuarantinedFromURI(aURI); + } + + static bool UseRemoteWebExtensions(dom::GlobalObject& aGlobal); + static bool IsExtensionProcess(dom::GlobalObject& aGlobal); + static bool BackgroundServiceWorkerEnabled(dom::GlobalObject& aGlobal); + static bool QuarantinedDomainsEnabled(dom::GlobalObject& aGlobal); + + nsISupports* GetParentObject() const { return mParent; } + + virtual JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + protected: + ~WebExtensionPolicy(); + + private: + WebExtensionPolicy(dom::GlobalObject& aGlobal, const WebExtensionInit& aInit, + ErrorResult& aRv); + + bool Enable(); + bool Disable(); + + nsCOMPtr<nsISupports> mParent; + + RefPtr<WebExtensionPolicyCore> mCore; + + dom::BrowsingContextGroup::KeepAlivePtr mBrowsingContextGroup; + + bool mActive = false; + + RefPtr<WebExtensionLocalizeCallback> mLocalizeCallback; + + // NOTE: This is a mirror of the object in `mCore`, except with the + // non-threadsafe wrapper. + RefPtr<MatchPatternSet> mHostPermissions; + + dom::Nullable<nsTArray<nsString>> mBackgroundScripts; + + bool mBackgroundTypeModule = false; + + nsTArray<RefPtr<WebExtensionContentScript>> mContentScripts; + + RefPtr<dom::Promise> mReadyPromise; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_WebExtensionPolicy_h diff --git a/toolkit/components/extensions/WebNavigation.sys.mjs b/toolkit/components/extensions/WebNavigation.sys.mjs new file mode 100644 index 0000000000..3de3c58986 --- /dev/null +++ b/toolkit/components/extensions/WebNavigation.sys.mjs @@ -0,0 +1,400 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", + ClickHandlerParent: "resource:///actors/ClickHandlerParent.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", + WebNavigationFrames: "resource://gre/modules/WebNavigationFrames.sys.mjs", +}); + +// Maximum amount of time that can be passed and still consider +// the data recent (similar to how is done in nsNavHistory, +// e.g. nsNavHistory::CheckIsRecentEvent, but with a lower threshold value). +const RECENT_DATA_THRESHOLD = 5 * 1000000; + +function getBrowser(bc) { + return bc.top.embedderElement; +} + +export var WebNavigationManager = { + // Map[string -> Map[listener -> URLFilter]] + listeners: new Map(), + + init() { + // Collect recent tab transition data in a WeakMap: + // browser -> tabTransitionData + this.recentTabTransitionData = new WeakMap(); + + Services.obs.addObserver(this, "urlbar-user-start-navigation", true); + + Services.obs.addObserver(this, "webNavigation-createdNavigationTarget"); + + if (AppConstants.MOZ_BUILD_APP == "browser") { + lazy.ClickHandlerParent.addContentClickListener(this); + } + }, + + uninit() { + // Stop collecting recent tab transition data and reset the WeakMap. + Services.obs.removeObserver(this, "urlbar-user-start-navigation"); + Services.obs.removeObserver(this, "webNavigation-createdNavigationTarget"); + + if (AppConstants.MOZ_BUILD_APP == "browser") { + lazy.ClickHandlerParent.removeContentClickListener(this); + } + + this.recentTabTransitionData = new WeakMap(); + }, + + addListener(type, listener) { + if (this.listeners.size == 0) { + this.init(); + } + + if (!this.listeners.has(type)) { + this.listeners.set(type, new Set()); + } + let listeners = this.listeners.get(type); + listeners.add(listener); + }, + + removeListener(type, listener) { + let listeners = this.listeners.get(type); + if (!listeners) { + return; + } + listeners.delete(listener); + if (listeners.size == 0) { + this.listeners.delete(type); + } + + if (this.listeners.size == 0) { + this.uninit(); + } + }, + + /** + * Support nsIObserver interface to observe the urlbar autocomplete events used + * to keep track of the urlbar user interaction. + */ + QueryInterface: ChromeUtils.generateQI([ + "extIWebNavigation", + "nsIObserver", + "nsISupportsWeakReference", + ]), + + /** + * Observe webNavigation-createdNavigationTarget (to fire the onCreatedNavigationTarget + * related to windows or tabs opened from the main process) topics. + * + * @param {nsIAutoCompleteInput | object} subject + * @param {string} topic + * @param {string | undefined} data + */ + observe: function (subject, topic, data) { + if (topic == "urlbar-user-start-navigation") { + this.onURLBarUserStartNavigation(subject.wrappedJSObject); + } else if (topic == "webNavigation-createdNavigationTarget") { + // The observed notification is coming from privileged JavaScript components running + // in the main process (e.g. when a new tab or window is opened using the context menu + // or Ctrl/Shift + click on a link). + const { createdTabBrowser, url, sourceFrameID, sourceTabBrowser } = + subject.wrappedJSObject; + + this.fire("onCreatedNavigationTarget", createdTabBrowser, null, { + sourceTabBrowser, + sourceFrameId: sourceFrameID, + url, + }); + } + }, + + /** + * Recognize the type of urlbar user interaction (e.g. typing a new url, + * clicking on an url generated from a searchengine or a keyword, or a + * bookmark found by the urlbar autocompletion). + * + * @param {object} acData + * The data for the autocompleted item. + * @param {object} [acData.result] + * The result information associated with the navigation action. + * @param {UrlbarUtils.RESULT_TYPE} [acData.result.type] + * The result type associated with the navigation action. + * @param {UrlbarUtils.RESULT_SOURCE} [acData.result.source] + * The result source associated with the navigation action. + */ + onURLBarUserStartNavigation(acData) { + let tabTransitionData = { + from_address_bar: true, + }; + + if (!acData.result) { + tabTransitionData.typed = true; + } else { + switch (acData.result.type) { + case lazy.UrlbarUtils.RESULT_TYPE.KEYWORD: + tabTransitionData.keyword = true; + break; + case lazy.UrlbarUtils.RESULT_TYPE.SEARCH: + tabTransitionData.generated = true; + break; + case lazy.UrlbarUtils.RESULT_TYPE.URL: + if ( + acData.result.source == lazy.UrlbarUtils.RESULT_SOURCE.BOOKMARKS + ) { + tabTransitionData.auto_bookmark = true; + } else { + tabTransitionData.typed = true; + } + break; + case lazy.UrlbarUtils.RESULT_TYPE.REMOTE_TAB: + // Remote tab are autocomplete results related to + // tab urls from a remote synchronized Firefox. + tabTransitionData.typed = true; + break; + case lazy.UrlbarUtils.RESULT_TYPE.TAB_SWITCH: + // This "switchtab" autocompletion should be ignored, because + // it is not related to a navigation. + // Fall through. + case lazy.UrlbarUtils.RESULT_TYPE.OMNIBOX: + // "Omnibox" should be ignored as the add-on may or may not initiate + // a navigation on the item being selected. + // Fall through. + case lazy.UrlbarUtils.RESULT_TYPE.TIP: + // "Tip" should be ignored since the tip will only initiate navigation + // if there is a valid buttonUrl property, which is optional. + throw new Error( + `Unexpectedly received notification for ${acData.result.type}` + ); + default: + Cu.reportError( + `Received unexpected result type ${acData.result.type}, falling back to typed transition.` + ); + // Fallback on "typed" if the type is unknown. + tabTransitionData.typed = true; + } + } + + this.setRecentTabTransitionData(tabTransitionData); + }, + + /** + * Keep track of a recent user interaction and cache it in a + * map associated to the current selected tab. + * + * @param {object} tabTransitionData + * @param {boolean} [tabTransitionData.auto_bookmark] + * @param {boolean} [tabTransitionData.from_address_bar] + * @param {boolean} [tabTransitionData.generated] + * @param {boolean} [tabTransitionData.keyword] + * @param {boolean} [tabTransitionData.link] + * @param {boolean} [tabTransitionData.typed] + */ + setRecentTabTransitionData(tabTransitionData) { + let window = lazy.BrowserWindowTracker.getTopWindow(); + if ( + window && + window.gBrowser && + window.gBrowser.selectedTab && + window.gBrowser.selectedTab.linkedBrowser + ) { + let browser = window.gBrowser.selectedTab.linkedBrowser; + + // Get recent tab transition data to update if any. + let prevData = this.getAndForgetRecentTabTransitionData(browser); + + let newData = Object.assign( + { time: Date.now() }, + prevData, + tabTransitionData + ); + this.recentTabTransitionData.set(browser, newData); + } + }, + + /** + * Retrieve recent data related to a recent user interaction give a + * given tab's linkedBrowser (only if is is more recent than the + * `RECENT_DATA_THRESHOLD`). + * + * NOTE: this method is used to retrieve the tab transition data + * collected when one of the `onCommitted`, `onHistoryStateUpdated` + * or `onReferenceFragmentUpdated` events has been received. + * + * @param {XULBrowserElement} browser + * @returns {object} + */ + getAndForgetRecentTabTransitionData(browser) { + let data = this.recentTabTransitionData.get(browser); + this.recentTabTransitionData.delete(browser); + + // Return an empty object if there isn't any tab transition data + // or if it's less recent than RECENT_DATA_THRESHOLD. + if (!data || data.time - Date.now() > RECENT_DATA_THRESHOLD) { + return {}; + } + + return data; + }, + + onContentClick(target, data) { + // We are interested only on clicks to links which are not "add to bookmark" commands + if (data.href && !data.bookmark) { + let ownerWin = target.ownerGlobal; + let where = ownerWin.whereToOpenLink(data); + if (where == "current") { + this.setRecentTabTransitionData({ link: true }); + } + } + }, + + onCreatedNavigationTarget(bc, sourceBC, url) { + if (!this.listeners.size) { + return; + } + + let browser = getBrowser(bc); + + this.fire("onCreatedNavigationTarget", browser, null, { + sourceTabBrowser: getBrowser(sourceBC), + sourceFrameId: lazy.WebNavigationFrames.getFrameId(sourceBC), + url, + }); + }, + + onStateChange(bc, requestURI, status, stateFlags) { + if (!this.listeners.size) { + return; + } + + let browser = getBrowser(bc); + + if (stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW) { + let url = requestURI.spec; + if (stateFlags & Ci.nsIWebProgressListener.STATE_START) { + this.fire("onBeforeNavigate", browser, bc, { url }); + } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) { + if (Components.isSuccessCode(status)) { + this.fire("onCompleted", browser, bc, { url }); + } else { + let error = `Error code ${status}`; + this.fire("onErrorOccurred", browser, bc, { error, url }); + } + } + } + }, + + onDocumentChange(bc, frameTransitionData, location) { + if (!this.listeners.size) { + return; + } + + let browser = getBrowser(bc); + + let extra = { + url: location ? location.spec : "", + // Transition data which is coming from the content process. + frameTransitionData, + tabTransitionData: this.getAndForgetRecentTabTransitionData(browser), + }; + + this.fire("onCommitted", browser, bc, extra); + }, + + onHistoryChange( + bc, + frameTransitionData, + location, + isHistoryStateUpdated, + isReferenceFragmentUpdated + ) { + if (!this.listeners.size) { + return; + } + + let browser = getBrowser(bc); + + let extra = { + url: location ? location.spec : "", + // Transition data which is coming from the content process. + frameTransitionData, + tabTransitionData: this.getAndForgetRecentTabTransitionData(browser), + }; + + if (isReferenceFragmentUpdated) { + this.fire("onReferenceFragmentUpdated", browser, bc, extra); + } else if (isHistoryStateUpdated) { + this.fire("onHistoryStateUpdated", browser, bc, extra); + } + }, + + onDOMContentLoaded(bc, documentURI) { + if (!this.listeners.size) { + return; + } + + let browser = getBrowser(bc); + + this.fire("onDOMContentLoaded", browser, bc, { url: documentURI.spec }); + }, + + fire(type, browser, bc, extra) { + if (!browser) { + return; + } + + let listeners = this.listeners.get(type); + if (!listeners) { + return; + } + + let details = { + browser, + }; + + if (bc) { + details.frameId = lazy.WebNavigationFrames.getFrameId(bc); + details.parentFrameId = lazy.WebNavigationFrames.getParentFrameId(bc); + } + + for (let prop in extra) { + details[prop] = extra[prop]; + } + + for (let listener of listeners) { + listener(details); + } + }, +}; + +const EVENTS = [ + "onBeforeNavigate", + "onCommitted", + "onDOMContentLoaded", + "onCompleted", + "onErrorOccurred", + "onReferenceFragmentUpdated", + "onHistoryStateUpdated", + "onCreatedNavigationTarget", +]; + +export var WebNavigation = {}; + +for (let event of EVENTS) { + WebNavigation[event] = { + addListener: WebNavigationManager.addListener.bind( + WebNavigationManager, + event + ), + removeListener: WebNavigationManager.removeListener.bind( + WebNavigationManager, + event + ), + }; +} diff --git a/toolkit/components/extensions/WebNavigationFrames.sys.mjs b/toolkit/components/extensions/WebNavigationFrames.sys.mjs new file mode 100644 index 0000000000..211698a88e --- /dev/null +++ b/toolkit/components/extensions/WebNavigationFrames.sys.mjs @@ -0,0 +1,90 @@ +/* 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/. */ + +/** + * The FrameDetail object which represents a frame in WebExtensions APIs. + * + * @typedef {object} FrameDetail + * @inner + * @property {number} frameId - Represents the numeric id which identify the frame in its tab. + * @property {number} parentFrameId - Represents the numeric id which identify the parent frame. + * @property {string} url - Represents the current location URL loaded in the frame. + * @property {boolean} errorOccurred - Indicates whether an error is occurred during the last load + * happened on this frame (NOT YET SUPPORTED). + */ + +/** + * Returns the frame ID of the given window. If the window is the + * top-level content window, its frame ID is 0. Otherwise, its frame ID + * is its outer window ID. + * + * @param {Window|BrowsingContext} bc - The window to retrieve the frame ID for. + * @returns {number} + */ +function getFrameId(bc) { + if (!BrowsingContext.isInstance(bc)) { + bc = bc.browsingContext; + } + return bc.parent ? bc.id : 0; +} + +/** + * Returns the frame ID of the given window's parent. + * + * @param {Window|BrowsingContext} bc - The window to retrieve the parent frame ID for. + * @returns {number} + */ +function getParentFrameId(bc) { + if (!BrowsingContext.isInstance(bc)) { + bc = bc.browsingContext; + } + return bc.parent ? getFrameId(bc.parent) : -1; +} + +/** + * Convert a BrowsingContext into internal FrameDetail json. + * + * @param {BrowsingContext} bc + * @returns {FrameDetail} + */ +function getFrameDetail(bc) { + return { + frameId: getFrameId(bc), + parentFrameId: getParentFrameId(bc), + url: bc.currentURI?.spec, + }; +} + +export var WebNavigationFrames = { + getFrame(bc, frameId) { + // frameId 0 means the top-level frame; anything else is a child frame. + let frame = BrowsingContext.get(frameId || bc.id); + if (frame && frame.top === bc) { + return getFrameDetail(frame); + } + return null; + }, + + getFrameId, + getParentFrameId, + + getAllFrames(bc) { + let frames = []; + + // Recursively walk the BC tree, find all frames. + function visit(bc) { + frames.push(bc); + bc.children.forEach(visit); + } + visit(bc); + return frames.map(getFrameDetail); + }, + + getFromWindow(target) { + if (Window.isInstance(target)) { + return getFrameId(BrowsingContext.getFromWindow(target)); + } + return -1; + }, +}; diff --git a/toolkit/components/extensions/child/.eslintrc.js b/toolkit/components/extensions/child/.eslintrc.js new file mode 100644 index 0000000000..01f6e45d35 --- /dev/null +++ b/toolkit/components/extensions/child/.eslintrc.js @@ -0,0 +1,11 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +module.exports = { + globals: { + EventManager: true, + }, +}; diff --git a/toolkit/components/extensions/child/ext-backgroundPage.js b/toolkit/components/extensions/child/ext-backgroundPage.js new file mode 100644 index 0000000000..ef5b3dd339 --- /dev/null +++ b/toolkit/components/extensions/child/ext-backgroundPage.js @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.backgroundPage = class extends ExtensionAPI { + getAPI(context) { + function getBackgroundPage() { + for (let view of context.extension.views) { + if ( + // To find the (top-level) background context, this logic relies on + // the order of views, implied by the fact that the top-level context + // is created before child contexts. If this assumption ever becomes + // invalid, add a check for view.isBackgroundContext. + view.viewType == "background" && + context.principal.subsumes(view.principal) + ) { + return view.contentWindow; + } + } + return null; + } + return { + extension: { + getBackgroundPage, + }, + + runtime: { + getBackgroundPage() { + return context.childManager + .callParentAsyncFunction("runtime.internalWakeupBackground", []) + .then(() => { + return context.cloneScope.Promise.resolve(getBackgroundPage()); + }); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-contentScripts.js b/toolkit/components/extensions/child/ext-contentScripts.js new file mode 100644 index 0000000000..338374cde6 --- /dev/null +++ b/toolkit/components/extensions/child/ext-contentScripts.js @@ -0,0 +1,76 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +"use strict"; + +var { ExtensionError } = ExtensionUtils; + +/** + * Represents (in the child extension process) a content script registered + * programmatically (instead of being included in the addon manifest). + * + * @param {ExtensionPageContextChild} context + * The extension context which has registered the content script. + * @param {string} scriptId + * An unique id that represents the registered content script + * (generated and used internally to identify it across the different processes). + */ +class ContentScriptChild { + constructor(context, scriptId) { + this.context = context; + this.scriptId = scriptId; + this.unregistered = false; + } + + async unregister() { + if (this.unregistered) { + throw new ExtensionError("Content script already unregistered"); + } + + this.unregistered = true; + + await this.context.childManager.callParentAsyncFunction( + "contentScripts.unregister", + [this.scriptId] + ); + + this.context = null; + } + + api() { + const { context } = this; + + // TODO(rpl): allow to read the options related to the registered content script? + return { + unregister: () => { + return context.wrapPromise(this.unregister()); + }, + }; + } +} + +this.contentScripts = class extends ExtensionAPI { + getAPI(context) { + return { + contentScripts: { + register(options) { + return context.cloneScope.Promise.resolve().then(async () => { + const scriptId = await context.childManager.callParentAsyncFunction( + "contentScripts.register", + [options] + ); + + const registeredScript = new ContentScriptChild(context, scriptId); + + return Cu.cloneInto(registeredScript.api(), context.cloneScope, { + cloneFunctions: true, + }); + }); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-declarativeNetRequest.js b/toolkit/components/extensions/child/ext-declarativeNetRequest.js new file mode 100644 index 0000000000..82028c6105 --- /dev/null +++ b/toolkit/components/extensions/child/ext-declarativeNetRequest.js @@ -0,0 +1,35 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionDNRLimits: "resource://gre/modules/ExtensionDNRLimits.sys.mjs", +}); + +this.declarativeNetRequest = class extends ExtensionAPI { + getAPI(context) { + return { + declarativeNetRequest: { + get GUARANTEED_MINIMUM_STATIC_RULES() { + return ExtensionDNRLimits.GUARANTEED_MINIMUM_STATIC_RULES; + }, + get MAX_NUMBER_OF_STATIC_RULESETS() { + return ExtensionDNRLimits.MAX_NUMBER_OF_STATIC_RULESETS; + }, + get MAX_NUMBER_OF_ENABLED_STATIC_RULESETS() { + return ExtensionDNRLimits.MAX_NUMBER_OF_ENABLED_STATIC_RULESETS; + }, + get MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES() { + return ExtensionDNRLimits.MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES; + }, + get MAX_NUMBER_OF_REGEX_RULES() { + return ExtensionDNRLimits.MAX_NUMBER_OF_REGEX_RULES; + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-extension.js b/toolkit/components/extensions/child/ext-extension.js new file mode 100644 index 0000000000..f4024086e4 --- /dev/null +++ b/toolkit/components/extensions/child/ext-extension.js @@ -0,0 +1,78 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.extension = class extends ExtensionAPI { + getAPI(context) { + let api = { + getURL(url) { + return context.extension.baseURI.resolve(url); + }, + + get lastError() { + return context.lastError; + }, + + get inIncognitoContext() { + return context.incognito; + }, + }; + + if (context.envType === "addon_child") { + api.getViews = function (fetchProperties) { + let result = Cu.cloneInto([], context.cloneScope); + + for (let view of context.extension.views) { + if (!view.active) { + continue; + } + if (!context.principal.subsumes(view.principal)) { + continue; + } + + if (fetchProperties !== null) { + if ( + fetchProperties.type !== null && + view.viewType != fetchProperties.type + ) { + continue; + } + + if (fetchProperties.windowId !== null) { + let bc = view.contentWindow?.docShell?.browserChild; + let windowId = + view.viewType !== "background" + ? bc?.chromeOuterWindowID ?? -1 + : -1; + if (windowId !== fetchProperties.windowId) { + continue; + } + } + + if ( + fetchProperties.tabId !== null && + view.tabId != fetchProperties.tabId + ) { + continue; + } + } + + // Do not include extension popups contexts while their document + // is blocked on parsing during its preloading state + // (See Bug 1748808). + if (context.extension.hasContextBlockedParsingDocument(view)) { + continue; + } + + result.push(view.contentWindow); + } + + return result; + }; + } + + return { extension: api }; + } +}; diff --git a/toolkit/components/extensions/child/ext-identity.js b/toolkit/components/extensions/child/ext-identity.js new file mode 100644 index 0000000000..9218065322 --- /dev/null +++ b/toolkit/components/extensions/child/ext-identity.js @@ -0,0 +1,84 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +"use strict"; + +var { Constructor: CC } = Components; + +ChromeUtils.defineESModuleGetters(this, { + CommonUtils: "resource://services-common/utils.sys.mjs", +}); +XPCOMUtils.defineLazyPreferenceGetter( + this, + "redirectDomain", + "extensions.webextensions.identity.redirectDomain" +); + +let CryptoHash = CC( + "@mozilla.org/security/hash;1", + "nsICryptoHash", + "initWithString" +); + +XPCOMUtils.defineLazyGlobalGetters(this, ["URL", "TextEncoder"]); + +const computeHash = str => { + let byteArr = new TextEncoder().encode(str); + let hash = new CryptoHash("sha1"); + hash.update(byteArr, byteArr.length); + return CommonUtils.bytesAsHex(hash.finish(false)); +}; + +this.identity = class extends ExtensionAPI { + getAPI(context) { + let { extension } = context; + return { + identity: { + getRedirectURL: function (path = "") { + let hash = computeHash(extension.id); + let url = new URL(`https://${hash}.${redirectDomain}/`); + url.pathname = path; + return url.href; + }, + launchWebAuthFlow: function (details) { + // Validate the url and retreive redirect_uri if it was provided. + let url, redirectURI; + let baseRedirectURL = this.getRedirectURL(); + + // Allow using loopback address for native OAuth flows as some + // providers do not accept the URL provided by getRedirectURL. + // For more context, see bug 1635344. + let loopbackURL = `http://127.0.0.1/mozoauth2/${computeHash( + extension.id + )}`; + try { + url = new URL(details.url); + } catch (e) { + return Promise.reject({ message: "details.url is invalid" }); + } + try { + redirectURI = new URL( + url.searchParams.get("redirect_uri") || baseRedirectURL + ); + if ( + !redirectURI.href.startsWith(baseRedirectURL) && + !redirectURI.href.startsWith(loopbackURL) + ) { + return Promise.reject({ message: "redirect_uri not allowed" }); + } + } catch (e) { + return Promise.reject({ message: "redirect_uri is invalid" }); + } + + return context.childManager.callParentAsyncFunction( + "identity.launchWebAuthFlowInParent", + [details, redirectURI.href] + ); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-runtime.js b/toolkit/components/extensions/child/ext-runtime.js new file mode 100644 index 0000000000..8cf5c445e3 --- /dev/null +++ b/toolkit/components/extensions/child/ext-runtime.js @@ -0,0 +1,143 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + WebNavigationFrames: "resource://gre/modules/WebNavigationFrames.sys.mjs", +}); + +/* eslint-disable jsdoc/check-param-names */ +/** + * With optional arguments on both ends, this case is ambiguous: + * runtime.sendMessage("string", {} or nullish) + * + * Sending a message within the extension is more common than sending + * an empty object to another extension, so we prefer that conclusion. + * + * @param {string?} [extensionId] + * @param {any} message + * @param {object?} [options] + * @param {Function} [callback] + * @returns {{extensionId: string?, message: any, callback: Function?}} + */ +/* eslint-enable jsdoc/check-param-names */ +function parseBonkersArgs(...args) { + let Error = ExtensionUtils.ExtensionError; + let callback = typeof args[args.length - 1] === "function" && args.pop(); + + // We don't support any options anymore, so only an empty object is valid. + function validOptions(v) { + return v == null || (typeof v === "object" && !Object.keys(v).length); + } + + if (args.length === 1 || (args.length === 2 && validOptions(args[1]))) { + // Interpret as passing null for extensionId (message within extension). + args.unshift(null); + } + let [extensionId, message, options] = args; + + if (!args.length) { + throw new Error("runtime.sendMessage's message argument is missing"); + } else if (!validOptions(options)) { + throw new Error("runtime.sendMessage's options argument is invalid"); + } else if (args.length === 4 && args[3] && !callback) { + throw new Error("runtime.sendMessage's last argument is not a function"); + } else if (args[3] != null || args.length > 4) { + throw new Error("runtime.sendMessage received too many arguments"); + } else if (extensionId && typeof extensionId !== "string") { + throw new Error("runtime.sendMessage's extensionId argument is invalid"); + } + return { extensionId, message, callback }; +} + +this.runtime = class extends ExtensionAPI { + getAPI(context) { + let { extension } = context; + + return { + runtime: { + onConnect: context.messenger.onConnect.api(), + onMessage: context.messenger.onMessage.api(), + + onConnectExternal: context.messenger.onConnectEx.api(), + onMessageExternal: context.messenger.onMessageEx.api(), + + connect(extensionId, options) { + let name = options?.name ?? ""; + return context.messenger.connect({ name, extensionId }); + }, + + sendMessage(...args) { + let arg = parseBonkersArgs(...args); + return context.messenger.sendRuntimeMessage(arg); + }, + + connectNative(name) { + return context.messenger.connect({ name, native: true }); + }, + + sendNativeMessage(nativeApp, message) { + return context.messenger.sendNativeMessage(nativeApp, message); + }, + + get lastError() { + return context.lastError; + }, + + getManifest() { + return Cu.cloneInto(extension.manifest, context.cloneScope); + }, + + id: extension.id, + + getURL(url) { + return extension.baseURI.resolve(url); + }, + + getFrameId(target) { + let frameId = WebNavigationFrames.getFromWindow(target); + if (frameId >= 0) { + return frameId; + } + // Not a WindowProxy, perhaps an embedder element? + + let type; + try { + type = Cu.getClassName(target, true); + } catch (e) { + // Not a valid object, will throw below. + } + + const embedderTypes = [ + "HTMLIFrameElement", + "HTMLFrameElement", + "HTMLEmbedElement", + "HTMLObjectElement", + ]; + + if (embedderTypes.includes(type)) { + if (!target.browsingContext) { + return -1; + } + return WebNavigationFrames.getFrameId(target.browsingContext); + } + + throw new ExtensionUtils.ExtensionError("Invalid argument"); + }, + }, + }; + } + + getAPIObjectForRequest(context, request) { + if (request.apiObjectType === "Port") { + const port = context.messenger.getPortById(request.apiObjectId); + if (!port) { + throw new Error(`Port API object not found: ${request}`); + } + return port.api; + } + + throw new Error(`Unexpected apiObjectType: ${request}`); + } +}; diff --git a/toolkit/components/extensions/child/ext-scripting.js b/toolkit/components/extensions/child/ext-scripting.js new file mode 100644 index 0000000000..cce587227f --- /dev/null +++ b/toolkit/components/extensions/child/ext-scripting.js @@ -0,0 +1,49 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +"use strict"; + +var { ExtensionError } = ExtensionUtils; + +this.scripting = class extends ExtensionAPI { + getAPI(context) { + return { + scripting: { + executeScript: async details => { + let { func, args, ...parentDetails } = details; + + if (details.files) { + if (details.args) { + throw new ExtensionError( + "'args' may not be used with file injections." + ); + } + } + // `files` and `func` are mutually exclusive but that is checked in + // the parent (in `execute()`). + if (func) { + try { + const serializedArgs = args + ? JSON.stringify(args).slice(1, -1) + : ""; + // This is a prop that we compute here and pass to the parent. + parentDetails.func = `(${func.toString()})(${serializedArgs});`; + } catch (e) { + throw new ExtensionError("Unserializable arguments."); + } + } else { + parentDetails.func = null; + } + + return context.childManager.callParentAsyncFunction( + "scripting.executeScriptInternal", + [parentDetails] + ); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-storage.js b/toolkit/components/extensions/child/ext-storage.js new file mode 100644 index 0000000000..2d10964d0a --- /dev/null +++ b/toolkit/components/extensions/child/ext-storage.js @@ -0,0 +1,368 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionStorage: "resource://gre/modules/ExtensionStorage.sys.mjs", + ExtensionStorageIDB: "resource://gre/modules/ExtensionStorageIDB.sys.mjs", + ExtensionTelemetry: "resource://gre/modules/ExtensionTelemetry.sys.mjs", +}); + +// Wrap a storage operation in a TelemetryStopWatch. +async function measureOp(telemetryMetric, extension, fn) { + const stopwatchKey = {}; + telemetryMetric.stopwatchStart(extension, stopwatchKey); + try { + let result = await fn(); + telemetryMetric.stopwatchFinish(extension, stopwatchKey); + return result; + } catch (err) { + telemetryMetric.stopwatchCancel(extension, stopwatchKey); + throw err; + } +} + +this.storage = class extends ExtensionAPI { + getLocalFileBackend(context, { deserialize, serialize }) { + return { + get(keys) { + return measureOp( + ExtensionTelemetry.storageLocalGetJson, + context.extension, + () => { + return context.childManager + .callParentAsyncFunction("storage.local.JSONFileBackend.get", [ + serialize(keys), + ]) + .then(deserialize); + } + ); + }, + set(items) { + return measureOp( + ExtensionTelemetry.storageLocalSetJson, + context.extension, + () => { + return context.childManager.callParentAsyncFunction( + "storage.local.JSONFileBackend.set", + [serialize(items)] + ); + } + ); + }, + remove(keys) { + return context.childManager.callParentAsyncFunction( + "storage.local.JSONFileBackend.remove", + [serialize(keys)] + ); + }, + clear() { + return context.childManager.callParentAsyncFunction( + "storage.local.JSONFileBackend.clear", + [] + ); + }, + }; + } + + getLocalIDBBackend(context, { fireOnChanged, serialize, storagePrincipal }) { + let dbPromise; + async function getDB() { + if (dbPromise) { + return dbPromise; + } + + const persisted = context.extension.hasPermission("unlimitedStorage"); + dbPromise = ExtensionStorageIDB.open(storagePrincipal, persisted).catch( + err => { + // Reset the cached promise if it has been rejected, so that the next + // API call is going to retry to open the DB. + dbPromise = null; + throw err; + } + ); + + return dbPromise; + } + + return { + get(keys) { + return measureOp( + ExtensionTelemetry.storageLocalGetIdb, + context.extension, + async () => { + const db = await getDB(); + return db.get(keys); + } + ); + }, + set(items) { + function serialize(name, anonymizedName, value) { + return ExtensionStorage.serialize( + `set/${context.extension.id}/${name}`, + `set/${context.extension.id}/${anonymizedName}`, + value + ); + } + + return measureOp( + ExtensionTelemetry.storageLocalSetIdb, + context.extension, + async () => { + const db = await getDB(); + const changes = await db.set(items, { + serialize, + }); + + if (changes) { + fireOnChanged(changes); + } + } + ); + }, + async remove(keys) { + const db = await getDB(); + const changes = await db.remove(keys); + + if (changes) { + fireOnChanged(changes); + } + }, + async clear() { + const db = await getDB(); + const changes = await db.clear(context.extension); + + if (changes) { + fireOnChanged(changes); + } + }, + }; + } + + getAPI(context) { + const { extension } = context; + const serialize = ExtensionStorage.serializeForContext.bind(null, context); + const deserialize = ExtensionStorage.deserializeForContext.bind( + null, + context + ); + + // onChangedName is "storage.onChanged", "storage.sync.onChanged", etc. + function makeOnChangedEventTarget(onChangedName) { + return new EventManager({ + context, + name: onChangedName, + register: fire => { + let onChanged = (data, area) => { + let changes = new context.cloneScope.Object(); + for (let [key, value] of Object.entries(data)) { + changes[key] = deserialize(value); + } + if (area) { + // storage.onChanged includes the area. + fire.raw(changes, area); + } else { + // StorageArea.onChanged doesn't include the area. + fire.raw(changes); + } + }; + + let parent = context.childManager.getParentEvent(onChangedName); + parent.addListener(onChanged); + return () => { + parent.removeListener(onChanged); + }; + }, + }).api(); + } + + function sanitize(items) { + // The schema validator already takes care of arrays (which are only allowed + // to contain strings). Strings and null are safe values. + if (typeof items != "object" || items === null || Array.isArray(items)) { + return items; + } + // If we got here, then `items` is an object generated by `ObjectType`'s + // `normalize` method from Schemas.jsm. The object returned by `normalize` + // lives in this compartment, while the values live in compartment of + // `context.contentWindow`. The `sanitize` method runs with the principal + // of `context`, so we cannot just use `ExtensionStorage.sanitize` because + // it is not allowed to access properties of `items`. + // So we enumerate all properties and sanitize each value individually. + let sanitized = {}; + for (let [key, value] of Object.entries(items)) { + sanitized[key] = ExtensionStorage.sanitize(value, context); + } + return sanitized; + } + + function fireOnChanged(changes) { + // This call is used (by the storage.local API methods for the IndexedDB backend) to fire a storage.onChanged event, + // it uses the underlying message manager since the child context (or its ProxyContentParent counterpart + // running in the main process) may be gone by the time we call this, and so we can't use the childManager + // abstractions (e.g. callParentAsyncFunction or callParentFunctionNoReturn). + Services.cpmm.sendAsyncMessage( + `Extension:StorageLocalOnChanged:${extension.uuid}`, + changes + ); + } + + // If the selected backend for the extension is not known yet, we have to lazily detect it + // by asking to the main process (as soon as the storage.local API has been accessed for + // the first time). + const getStorageLocalBackend = async () => { + const { backendEnabled, storagePrincipal } = + await ExtensionStorageIDB.selectBackend(context); + + if (!backendEnabled) { + return this.getLocalFileBackend(context, { deserialize, serialize }); + } + + return this.getLocalIDBBackend(context, { + storagePrincipal, + fireOnChanged, + serialize, + }); + }; + + // Synchronously select the backend if it is already known. + let selectedBackend; + + const useStorageIDBBackend = extension.getSharedData("storageIDBBackend"); + if (useStorageIDBBackend === false) { + selectedBackend = this.getLocalFileBackend(context, { + deserialize, + serialize, + }); + } else if (useStorageIDBBackend === true) { + selectedBackend = this.getLocalIDBBackend(context, { + storagePrincipal: extension.getSharedData("storageIDBPrincipal"), + fireOnChanged, + serialize, + }); + } + + let promiseStorageLocalBackend; + + // Generate the backend-agnostic local API wrapped methods. + const local = { + onChanged: makeOnChangedEventTarget("storage.local.onChanged"), + }; + for (let method of ["get", "set", "remove", "clear"]) { + local[method] = async function (...args) { + try { + // Discover the selected backend if it is not known yet. + if (!selectedBackend) { + if (!promiseStorageLocalBackend) { + promiseStorageLocalBackend = getStorageLocalBackend().catch( + err => { + // Clear the cached promise if it has been rejected. + promiseStorageLocalBackend = null; + throw err; + } + ); + } + + // If the storage.local method is not 'get' (which doesn't change any of the stored data), + // fall back to call the method in the parent process, so that it can be completed even + // if this context has been destroyed in the meantime. + if (method !== "get") { + // Let the outer try to catch rejections returned by the backend methods. + try { + const result = + await context.childManager.callParentAsyncFunction( + "storage.local.callMethodInParentProcess", + [method, args] + ); + return result; + } catch (err) { + // Just return the rejection as is, the error has been normalized in the + // parent process by callMethodInParentProcess and the original error + // logged in the browser console. + return Promise.reject(err); + } + } + + // Get the selected backend and cache it for the next API calls from this context. + selectedBackend = await promiseStorageLocalBackend; + } + + // Let the outer try to catch rejections returned by the backend methods. + const result = await selectedBackend[method](...args); + return result; + } catch (err) { + throw ExtensionStorageIDB.normalizeStorageError({ + error: err, + extensionId: extension.id, + storageMethod: method, + }); + } + }; + } + + return { + storage: { + local, + + session: { + async get(keys) { + return deserialize( + await context.childManager.callParentAsyncFunction( + "storage.session.get", + [serialize(keys)] + ) + ); + }, + set(items) { + return context.childManager.callParentAsyncFunction( + "storage.session.set", + [serialize(items)] + ); + }, + onChanged: makeOnChangedEventTarget("storage.session.onChanged"), + }, + + sync: { + get(keys) { + keys = sanitize(keys); + return context.childManager.callParentAsyncFunction( + "storage.sync.get", + [keys] + ); + }, + set(items) { + items = sanitize(items); + return context.childManager.callParentAsyncFunction( + "storage.sync.set", + [items] + ); + }, + onChanged: makeOnChangedEventTarget("storage.sync.onChanged"), + }, + + managed: { + get(keys) { + return context.childManager + .callParentAsyncFunction("storage.managed.get", [serialize(keys)]) + .then(deserialize); + }, + set(items) { + return Promise.reject({ message: "storage.managed is read-only" }); + }, + remove(keys) { + return Promise.reject({ message: "storage.managed is read-only" }); + }, + clear() { + return Promise.reject({ message: "storage.managed is read-only" }); + }, + + onChanged: makeOnChangedEventTarget("storage.managed.onChanged"), + }, + + onChanged: makeOnChangedEventTarget("storage.onChanged"), + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-test.js b/toolkit/components/extensions/child/ext-test.js new file mode 100644 index 0000000000..a4178b63ff --- /dev/null +++ b/toolkit/components/extensions/child/ext-test.js @@ -0,0 +1,371 @@ +/* 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"; + +ChromeUtils.defineLazyGetter(this, "isXpcshell", function () { + return Services.env.exists("XPCSHELL_TEST_PROFILE_DIR"); +}); + +/** + * Checks whether the given error matches the given expectations. + * + * @param {*} error + * The error to check. + * @param {string | RegExp | Function | null} expectedError + * The expectation to check against. If this parameter is: + * + * - a string, the error message must exactly equal the string. + * - a regular expression, it must match the error message. + * - a function, it is called with the error object and its + * return value is returned. + * @param {BaseContext} context + * + * @returns {boolean} + * True if the error matches the expected error. + */ +const errorMatches = (error, expectedError, context) => { + if ( + typeof error === "object" && + error !== null && + !context.principal.subsumes(Cu.getObjectPrincipal(error)) + ) { + Cu.reportError("Error object belongs to the wrong scope."); + return false; + } + + if (typeof expectedError === "function") { + return context.runSafeWithoutClone(expectedError, error); + } + + if ( + typeof error !== "object" || + error == null || + typeof error.message !== "string" + ) { + return false; + } + + if (typeof expectedError === "string") { + return error.message === expectedError; + } + + try { + return expectedError.test(error.message); + } catch (e) { + Cu.reportError(e); + } + + return false; +}; + +// Checks whether |v| should use string serialization instead of JSON. +function useStringInsteadOfJSON(v) { + return ( + // undefined to string, or else it is omitted from object after stringify. + v === undefined || + // Values that would have become null. + (typeof v === "number" && (isNaN(v) || !isFinite(v))) + ); +} + +// A very strict deep equality comparator that throws for unsupported values. +// For context, see https://bugzilla.mozilla.org/show_bug.cgi?id=1782816#c2 +function deepEquals(a, b) { + // Some values don't have a JSON representation. To disambiguate from null or + // regular strings, we prepend this prefix instead. + const NON_JSON_PREFIX = "#NOT_JSON_SERIALIZABLE#"; + + function replacer(key, value) { + if (typeof value == "object" && value !== null && !Array.isArray(value)) { + const cls = ChromeUtils.getClassName(value); + if (cls === "Object") { + // Return plain object with keys sorted in a predictable order. + return Object.fromEntries( + Object.keys(value) + .sort() + .map(k => [k, value[k]]) + ); + } + // Just throw to avoid potentially inaccurate serializations (e.g. {}). + throw new ExtensionUtils.ExtensionError(`Unsupported obj type: ${cls}`); + } + + if (useStringInsteadOfJSON(value)) { + return `${NON_JSON_PREFIX}${value}`; + } + return value; + } + return JSON.stringify(a, replacer) === JSON.stringify(b, replacer); +} + +/** + * Serializes the given value for use in informative assertion messages. + * + * @param {*} value + * @returns {string} + */ +const toSource = value => { + function cannotJSONserialize(v) { + return ( + useStringInsteadOfJSON(v) || + // Not a plain object. E.g. [object X], /regexp/, etc. + (typeof v == "object" && + v !== null && + !Array.isArray(v) && + ChromeUtils.getClassName(v) !== "Object") + ); + } + try { + if (cannotJSONserialize(value)) { + return String(value); + } + + const replacer = (k, v) => (cannotJSONserialize(v) ? String(v) : v); + return JSON.stringify(value, replacer); + } catch (e) { + return "<unknown>"; + } +}; + +this.test = class extends ExtensionAPI { + getAPI(context) { + const { extension } = context; + + function getStack(savedFrame = null) { + if (savedFrame) { + return ChromeUtils.createError("", savedFrame).stack.replace( + /^/gm, + " " + ); + } + return new context.Error().stack.replace(/^/gm, " "); + } + + function assertTrue(value, msg) { + extension.emit( + "test-result", + Boolean(value), + String(msg), + getStack(context.getCaller()) + ); + } + + class TestEventManager extends EventManager { + constructor(...args) { + super(...args); + + // A map to keep track of the listeners wrappers being added in + // addListener (the wrapper will be needed to be able to remove + // the listener from this EventManager instance if the extension + // does call test.onMessage.removeListener). + this._listenerWrappers = new Map(); + context.callOnClose({ + close: () => this._listenerWrappers.clear(), + }); + } + + addListener(callback, ...args) { + const listenerWrapper = function (...args) { + try { + callback.call(this, ...args); + } catch (e) { + assertTrue(false, `${e}\n${e.stack}`); + } + }; + super.addListener(listenerWrapper, ...args); + this._listenerWrappers.set(callback, listenerWrapper); + } + + removeListener(callback) { + if (!this._listenerWrappers.has(callback)) { + return; + } + + super.removeListener(this._listenerWrappers.get(callback)); + this._listenerWrappers.delete(callback); + } + } + + if (!Cu.isInAutomation && !isXpcshell) { + return { test: {} }; + } + + return { + test: { + withHandlingUserInput(callback) { + // TODO(Bug 1598804): remove this once we don't expose anymore the + // entire test API namespace based on an environment variable. + if (!Cu.isInAutomation) { + // This dangerous method should only be available if the + // automation pref is set, which is the case in browser tests. + throw new ExtensionUtils.ExtensionError( + "withHandlingUserInput can only be called in automation" + ); + } + ExtensionCommon.withHandlingUserInput( + context.contentWindow, + callback + ); + }, + + sendMessage(...args) { + extension.emit("test-message", ...args); + }, + + notifyPass(msg) { + extension.emit("test-done", true, msg, getStack(context.getCaller())); + }, + + notifyFail(msg) { + extension.emit( + "test-done", + false, + msg, + getStack(context.getCaller()) + ); + }, + + log(msg) { + extension.emit("test-log", true, msg, getStack(context.getCaller())); + }, + + fail(msg) { + assertTrue(false, msg); + }, + + succeed(msg) { + assertTrue(true, msg); + }, + + assertTrue(value, msg) { + assertTrue(value, msg); + }, + + assertFalse(value, msg) { + assertTrue(!value, msg); + }, + + assertDeepEq(expected, actual, msg) { + // The bindings generated by Schemas.jsm accepts any input, but the + // WebIDL-generated binding expects a structurally cloneable input. + // To ensure consistent behavior regardless of which mechanism was + // used, verify that the inputs are structurally cloneable. + // These will throw if the values cannot be cloned. + function ensureStructurallyCloneable(v) { + if (typeof v == "object" && v !== null) { + // Waive xrays to unhide callable members, so that cloneInto will + // throw if needed. + v = ChromeUtils.waiveXrays(v); + } + new StructuredCloneHolder("test.assertEq", null, v, globalThis); + } + // When WebIDL bindings are used, the objects are already cloned + // structurally, so we don't need to check again. + if (!context.useWebIDLBindings) { + ensureStructurallyCloneable(expected); + ensureStructurallyCloneable(actual); + } + + extension.emit( + "test-eq", + deepEquals(actual, expected), + String(msg), + toSource(expected), + toSource(actual), + getStack(context.getCaller()) + ); + }, + + assertEq(expected, actual, msg) { + let equal = expected === actual; + + expected = String(expected); + actual = String(actual); + + if (!equal && expected === actual) { + actual += " (different)"; + } + extension.emit( + "test-eq", + equal, + String(msg), + expected, + actual, + getStack(context.getCaller()) + ); + }, + + assertRejects(promise, expectedError, msg) { + // Wrap in a native promise for consistency. + promise = Promise.resolve(promise); + + return promise.then( + result => { + let message = `Promise resolved, expected rejection '${toSource( + expectedError + )}'`; + if (msg) { + message += `: ${msg}`; + } + assertTrue(false, message); + }, + error => { + let expected = toSource(expectedError); + let message = `got '${toSource(error)}'`; + if (msg) { + message += `: ${msg}`; + } + + assertTrue( + errorMatches(error, expectedError, context), + `Promise rejected, expecting rejection to match '${expected}', ${message}` + ); + } + ); + }, + + assertThrows(func, expectedError, msg) { + try { + func(); + + let message = `Function did not throw, expected error '${toSource( + expectedError + )}'`; + if (msg) { + message += `: ${msg}`; + } + assertTrue(false, message); + } catch (error) { + let expected = toSource(expectedError); + let message = `got '${toSource(error)}'`; + if (msg) { + message += `: ${msg}`; + } + + assertTrue( + errorMatches(error, expectedError, context), + `Function threw, expecting error to match '${expected}', ${message}` + ); + } + }, + + onMessage: new TestEventManager({ + context, + name: "test.onMessage", + register: fire => { + let handler = (event, ...args) => { + fire.async(...args); + }; + + extension.on("test-harness-message", handler); + return () => { + extension.off("test-harness-message", handler); + }; + }, + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-toolkit.js b/toolkit/components/extensions/child/ext-toolkit.js new file mode 100644 index 0000000000..0786880961 --- /dev/null +++ b/toolkit/components/extensions/child/ext-toolkit.js @@ -0,0 +1,84 @@ +/* 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"; + +global.EventManager = ExtensionCommon.EventManager; + +extensions.registerModules({ + backgroundPage: { + url: "chrome://extensions/content/child/ext-backgroundPage.js", + scopes: ["addon_child"], + manifest: ["background"], + paths: [ + ["extension", "getBackgroundPage"], + ["runtime", "getBackgroundPage"], + ], + }, + contentScripts: { + url: "chrome://extensions/content/child/ext-contentScripts.js", + scopes: ["addon_child"], + paths: [["contentScripts"]], + }, + declarativeNetRequest: { + url: "chrome://extensions/content/child/ext-declarativeNetRequest.js", + scopes: ["addon_child"], + paths: [["declarativeNetRequest"]], + }, + extension: { + url: "chrome://extensions/content/child/ext-extension.js", + scopes: ["addon_child", "content_child", "devtools_child"], + paths: [["extension"]], + }, + i18n: { + url: "chrome://extensions/content/parent/ext-i18n.js", + scopes: ["addon_child", "content_child", "devtools_child"], + paths: [["i18n"]], + }, + runtime: { + url: "chrome://extensions/content/child/ext-runtime.js", + scopes: ["addon_child", "content_child", "devtools_child"], + paths: [["runtime"]], + }, + scripting: { + url: "chrome://extensions/content/child/ext-scripting.js", + scopes: ["addon_child"], + paths: [["scripting"]], + }, + storage: { + url: "chrome://extensions/content/child/ext-storage.js", + scopes: ["addon_child", "content_child", "devtools_child"], + paths: [["storage"]], + }, + test: { + url: "chrome://extensions/content/child/ext-test.js", + scopes: ["addon_child", "content_child", "devtools_child"], + paths: [["test"]], + }, + userScripts: { + url: "chrome://extensions/content/child/ext-userScripts.js", + scopes: ["addon_child"], + paths: [["userScripts"]], + }, + userScriptsContent: { + url: "chrome://extensions/content/child/ext-userScripts-content.js", + scopes: ["content_child"], + paths: [["userScripts", "onBeforeScript"]], + }, + webRequest: { + url: "chrome://extensions/content/child/ext-webRequest.js", + scopes: ["addon_child"], + paths: [["webRequest"]], + }, +}); + +if (AppConstants.MOZ_BUILD_APP === "browser") { + extensions.registerModules({ + identity: { + url: "chrome://extensions/content/child/ext-identity.js", + scopes: ["addon_child"], + paths: [["identity"]], + }, + }); +} diff --git a/toolkit/components/extensions/child/ext-userScripts-content.js b/toolkit/components/extensions/child/ext-userScripts-content.js new file mode 100644 index 0000000000..ee1a1b7a8f --- /dev/null +++ b/toolkit/components/extensions/child/ext-userScripts-content.js @@ -0,0 +1,408 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +"use strict"; + +var USERSCRIPT_PREFNAME = "extensions.webextensions.userScripts.enabled"; +var USERSCRIPT_DISABLED_ERRORMSG = `userScripts APIs are currently experimental and must be enabled with the ${USERSCRIPT_PREFNAME} preference.`; + +ChromeUtils.defineESModuleGetters(this, { + Schemas: "resource://gre/modules/Schemas.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "userScriptsEnabled", + USERSCRIPT_PREFNAME, + false +); + +var { ExtensionError } = ExtensionUtils; + +const TYPEOF_PRIMITIVES = ["bigint", "boolean", "number", "string", "symbol"]; + +/** + * Represents a user script in the child content process. + * + * This class implements the API object that is passed as a parameter to the + * browser.userScripts.onBeforeScript API Event. + * + * @param {object} params + * @param {ContentScriptContextChild} params.context + * The context which has registered the userScripts.onBeforeScript listener. + * @param {PlainJSONValue} params.metadata + * An opaque user script metadata value (as set in userScripts.register). + * @param {Sandbox} params.scriptSandbox + * The Sandbox object of the userScript. + */ +class UserScript { + constructor({ context, metadata, scriptSandbox }) { + this.context = context; + this.extension = context.extension; + this.apiSandbox = context.cloneScope; + this.metadata = metadata; + this.scriptSandbox = scriptSandbox; + + this.ScriptError = scriptSandbox.Error; + this.ScriptPromise = scriptSandbox.Promise; + } + + /** + * Returns the API object provided to the userScripts.onBeforeScript listeners. + * + * @returns {object} + * The API object with the properties and methods to export + * to the extension code. + */ + api() { + return { + metadata: this.metadata, + defineGlobals: sourceObject => this.defineGlobals(sourceObject), + export: value => this.export(value), + }; + } + + /** + * Define all the properties of a given plain object as lazy getters of the + * userScript global object. + * + * @param {object} sourceObject + * A set of objects and methods to export into the userScript scope as globals. + * + * @throws {context.Error} + * Throws an apiScript error when sourceObject is not a plain object. + */ + defineGlobals(sourceObject) { + let className; + try { + className = ChromeUtils.getClassName(sourceObject, true); + } catch (e) { + // sourceObject is not an object; + } + + if (className !== "Object") { + throw new this.context.Error( + "Invalid sourceObject type, plain object expected." + ); + } + + this.exportLazyGetters(sourceObject, this.scriptSandbox); + } + + /** + * Convert a given value to make it accessible to the userScript code. + * + * - any property value that is already accessible to the userScript code is returned unmodified by + * the lazy getter + * - any apiScript's Function is wrapped using the `wrapFunction` method + * - any apiScript's Object is lazily exported (and the same wrappers are lazily applied to its + * properties). + * + * @param {any} valueToExport + * A value to convert into an object accessible to the userScript. + * + * @param {object} privateOptions + * A set of options used when this method is called internally (not exposed in the + * api object exported to the onBeforeScript listeners). + * @param {Error} privateOptions.Error + * The Error constructor to use to report errors (defaults to the apiScript context's Error + * when missing). + * @param {Error} privateOptions.errorMessage + * A custom error message to report exporting error on values not allowed. + * + * @returns {any} + * The resulting userScript object. + * + * @throws {context.Error | privateOptions.Error} + * Throws an error when the value is not allowed and it can't be exported into an allowed one. + */ + export(valueToExport, privateOptions = {}) { + const ExportError = privateOptions.Error || this.context.Error; + + if (this.canAccess(valueToExport, this.scriptSandbox)) { + // Return the value unmodified if the userScript principal is already allowed + // to access it. + return valueToExport; + } + + let className; + + try { + className = ChromeUtils.getClassName(valueToExport, true); + } catch (e) { + // sourceObject is not an object; + } + + if (className === "Function") { + return this.wrapFunction(valueToExport); + } + + if (className === "Object") { + return this.exportLazyGetters(valueToExport); + } + + if (className === "Array") { + return this.exportArray(valueToExport); + } + + let valueType = className || typeof valueToExport; + throw new ExportError( + privateOptions.errorMessage || + `${valueType} cannot be exported to the userScript` + ); + } + + /** + * Export all the elements of the `srcArray` into a newly created userScript array. + * + * @param {Array} srcArray + * The apiScript array to export to the userScript code. + * + * @returns {Array} + * The resulting userScript array. + * + * @throws {UserScriptError} + * Throws an error when the array can't be exported successfully. + */ + exportArray(srcArray) { + const destArray = Cu.cloneInto([], this.scriptSandbox); + + for (let [idx, value] of this.shallowCloneEntries(srcArray)) { + destArray[idx] = this.export(value, { + errorMessage: `Error accessing disallowed element at index "${idx}"`, + Error: this.UserScriptError, + }); + } + + return destArray; + } + + /** + * Export all the properties of the `src` plain object as lazy getters on the `dest` object, + * or in a newly created userScript object if `dest` is `undefined`. + * + * @param {object} src + * A set of properties to define on a `dest` object as lazy getters. + * @param {object} [dest] + * An optional `dest` object (a new userScript object is created by default when not specified). + * + * @returns {object} + * The resulting userScript object. + */ + exportLazyGetters(src, dest = undefined) { + dest = dest || Cu.createObjectIn(this.scriptSandbox); + + for (let [key, value] of this.shallowCloneEntries(src)) { + Schemas.exportLazyGetter(dest, key, () => { + return this.export(value, { + // Lazy properties will raise an error for properties with not allowed + // values to the userScript scope, and so we have to raise an userScript + // Error here. + Error: this.ScriptError, + errorMessage: `Error accessing disallowed property "${key}"`, + }); + }); + } + + return dest; + } + + /** + * Export and wrap an apiScript function to provide the following behaviors: + * - errors throws from an exported function are checked by `handleAPIScriptError` + * - returned apiScript's Promises (not accessible to the userScript) are converted into a + * userScript's Promise + * - check if the returned or resolved value is accessible to the userScript code + * (and raise a userScript error if it is not) + * + * @param {Function} fn + * The apiScript function to wrap + * + * @returns {object} + * The resulting userScript function. + */ + wrapFunction(fn) { + return Cu.exportFunction((...args) => { + let res; + try { + // Checks that all the elements in the `...args` array are allowed to be + // received from the apiScript. + for (let arg of args) { + if (!this.canAccess(arg, this.apiSandbox)) { + throw new this.ScriptError( + `Parameter not accessible to the userScript API` + ); + } + } + + res = fn(...args); + } catch (err) { + this.handleAPIScriptError(err); + } + + // Prevent execution of proxy traps while checking if the return value is a Promise. + if (!Cu.isProxy(res) && res instanceof this.context.Promise) { + return this.ScriptPromise.resolve().then(async () => { + let value; + + try { + value = await res; + } catch (err) { + this.handleAPIScriptError(err); + } + + return this.ensureAccessible(value); + }); + } + + return this.ensureAccessible(res); + }, this.scriptSandbox); + } + + /** + * Shallow clone the source object and iterate over its Object properties (or Array elements), + * which allow us to safely iterate over all its properties (including callable objects that + * would be hidden by the xrays vision, but excluding any property that could be tricky, e.g. + * getters). + * + * @param {object | Array} obj + * The Object or Array object to shallow clone and iterate over. + */ + *shallowCloneEntries(obj) { + const clonedObj = ChromeUtils.shallowClone(obj); + + for (let entry of Object.entries(clonedObj)) { + yield entry; + } + } + + /** + * Check if the given value is accessible to the targetScope. + * + * @param {any} val + * The value to check. + * @param {Sandbox} targetScope + * The targetScope that should be able to access the value. + * + * @returns {boolean} + */ + canAccess(val, targetScope) { + if (val == null || TYPEOF_PRIMITIVES.includes(typeof val)) { + return true; + } + + // Disallow objects that are coming from principals that are not + // subsumed by the targetScope's principal. + try { + const targetPrincipal = Cu.getObjectPrincipal(targetScope); + if (!targetPrincipal.subsumes(Cu.getObjectPrincipal(val))) { + return false; + } + } catch (err) { + Cu.reportError(err); + return false; + } + + return true; + } + + /** + * Check if the value returned (or resolved) from an apiScript method is accessible + * to the userScript code, and throw a userScript Error if it is not allowed. + * + * @param {any} res + * The value to return/resolve. + * + * @returns {any} + * The exported value. + * + * @throws {Error} + * Throws a userScript error when the value is not accessible to the userScript scope. + */ + ensureAccessible(res) { + if (this.canAccess(res, this.scriptSandbox)) { + return res; + } + + throw new this.ScriptError("Return value not accessible to the userScript"); + } + + /** + * Handle the error raised (and rejected promise returned) from apiScript functions exported to the + * userScript. + * + * @param {any} err + * The value to return/resolve. + * + * @throws {any} + * This method is expected to throw: + * - any value that is already accessible to the userScript code is forwarded unmodified + * - any value that is not accessible to the userScript code is logged in the console + * (to make it easier to investigate the underlying issue) and converted into a + * userScript Error (with the generic "An unexpected apiScript error occurred" error + * message accessible to the userScript) + */ + handleAPIScriptError(err) { + if (this.canAccess(err, this.scriptSandbox)) { + throw err; + } + + // Log the actual error on the console and raise a generic userScript Error + // on error objects that can't be accessed by the UserScript principal. + try { + const debugName = this.extension.policy.debugName; + Cu.reportError( + `An unexpected apiScript error occurred for '${debugName}': ${err} :: ${err.stack}` + ); + } catch (e) {} + + throw new this.ScriptError(`An unexpected apiScript error occurred`); + } +} + +this.userScriptsContent = class extends ExtensionAPI { + getAPI(context) { + return { + userScripts: { + onBeforeScript: new EventManager({ + context, + name: "userScripts.onBeforeScript", + register: fire => { + if (!userScriptsEnabled) { + throw new ExtensionError(USERSCRIPT_DISABLED_ERRORMSG); + } + + let handler = (event, metadata, scriptSandbox, eventResult) => { + const us = new UserScript({ + context, + metadata, + scriptSandbox, + }); + + const apiObj = Cu.cloneInto(us.api(), context.cloneScope, { + cloneFunctions: true, + }); + + Object.defineProperty(apiObj, "global", { + value: scriptSandbox, + enumerable: true, + configurable: true, + writable: true, + }); + + fire.raw(apiObj); + }; + + context.userScriptsEvents.on("on-before-script", handler); + return () => { + context.userScriptsEvents.off("on-before-script", handler); + }; + }, + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-userScripts.js b/toolkit/components/extensions/child/ext-userScripts.js new file mode 100644 index 0000000000..66cfeb0906 --- /dev/null +++ b/toolkit/components/extensions/child/ext-userScripts.js @@ -0,0 +1,192 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +"use strict"; + +var USERSCRIPT_PREFNAME = "extensions.webextensions.userScripts.enabled"; +var USERSCRIPT_DISABLED_ERRORMSG = `userScripts APIs are currently experimental and must be enabled with the ${USERSCRIPT_PREFNAME} preference.`; + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "userScriptsEnabled", + USERSCRIPT_PREFNAME, + false +); + +// eslint-disable-next-line mozilla/reject-importGlobalProperties +Cu.importGlobalProperties(["crypto", "TextEncoder"]); + +var { DefaultMap, ExtensionError, getUniqueId } = ExtensionUtils; + +/** + * Represents a registered userScript in the child extension process. + * + * @param {ExtensionPageContextChild} context + * The extension context which has registered the user script. + * @param {string} scriptId + * An unique id that represents the registered user script + * (generated and used internally to identify it across the different processes). + */ +class UserScriptChild { + constructor({ context, scriptId, onScriptUnregister }) { + this.context = context; + this.scriptId = scriptId; + this.onScriptUnregister = onScriptUnregister; + this.unregistered = false; + } + + async unregister() { + if (this.unregistered) { + throw new ExtensionError("User script already unregistered"); + } + + this.unregistered = true; + + await this.context.childManager.callParentAsyncFunction( + "userScripts.unregister", + [this.scriptId] + ); + + this.context = null; + + this.onScriptUnregister(); + } + + api() { + const { context } = this; + + // Returns the RegisteredUserScript API object. + return { + unregister: () => { + return context.wrapPromise(this.unregister()); + }, + }; + } +} + +this.userScripts = class extends ExtensionAPI { + getAPI(context) { + // Cache of the script code already converted into blob urls: + // Map<textHash, blobURLs> + const blobURLsByHash = new Map(); + + // Keep track of the userScript that are sharing the same blob urls, + // so that we can revoke any blob url that is not used by a registered + // userScripts: + // Map<blobURL, Set<scriptId>> + const userScriptsByBlobURL = new DefaultMap(() => new Set()); + + function revokeBlobURLs(scriptId, options) { + let revokedUrls = new Set(); + + for (let url of options.js) { + if (userScriptsByBlobURL.has(url)) { + let scriptIds = userScriptsByBlobURL.get(url); + scriptIds.delete(scriptId); + + if (scriptIds.size === 0) { + revokedUrls.add(url); + userScriptsByBlobURL.delete(url); + context.cloneScope.URL.revokeObjectURL(url); + } + } + } + + // Remove all the removed urls from the map of known computed hashes. + for (let [hash, url] of blobURLsByHash) { + if (revokedUrls.has(url)) { + blobURLsByHash.delete(hash); + } + } + } + + // Convert a script code string into a blob URL (and use a cached one + // if the script hash is already associated to a blob URL). + const getBlobURL = async (text, scriptId) => { + // Compute the hash of the js code string and reuse the blob url if we already have + // for the same hash. + const buffer = await crypto.subtle.digest( + "SHA-1", + new TextEncoder().encode(text) + ); + const hash = String.fromCharCode(...new Uint16Array(buffer)); + + let blobURL = blobURLsByHash.get(hash); + + if (blobURL) { + userScriptsByBlobURL.get(blobURL).add(scriptId); + return blobURL; + } + + const blob = new context.cloneScope.Blob([text], { + type: "text/javascript", + }); + blobURL = context.cloneScope.URL.createObjectURL(blob); + + // Start to track this blob URL. + userScriptsByBlobURL.get(blobURL).add(scriptId); + + blobURLsByHash.set(hash, blobURL); + + return blobURL; + }; + + function convertToAPIObject(scriptId, options) { + const registeredScript = new UserScriptChild({ + context, + scriptId, + onScriptUnregister: () => revokeBlobURLs(scriptId, options), + }); + + const scriptAPI = Cu.cloneInto( + registeredScript.api(), + context.cloneScope, + { cloneFunctions: true } + ); + return scriptAPI; + } + + // Revoke all the created blob urls once the context is destroyed. + context.callOnClose({ + close() { + if (!context.cloneScope) { + return; + } + + for (let blobURL of blobURLsByHash.values()) { + context.cloneScope.URL.revokeObjectURL(blobURL); + } + }, + }); + + return { + userScripts: { + register(options) { + if (!userScriptsEnabled) { + throw new ExtensionError(USERSCRIPT_DISABLED_ERRORMSG); + } + + let scriptId = getUniqueId(); + return context.cloneScope.Promise.resolve().then(async () => { + options.scriptId = scriptId; + options.js = await Promise.all( + options.js.map(js => { + return js.file || getBlobURL(js.code, scriptId); + }) + ); + + await context.childManager.callParentAsyncFunction( + "userScripts.register", + [options] + ); + + return convertToAPIObject(scriptId, options); + }); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-webRequest.js b/toolkit/components/extensions/child/ext-webRequest.js new file mode 100644 index 0000000000..49bdd3f232 --- /dev/null +++ b/toolkit/components/extensions/child/ext-webRequest.js @@ -0,0 +1,119 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +"use strict"; + +var { ExtensionError } = ExtensionUtils; + +this.webRequest = class extends ExtensionAPI { + STREAM_FILTER_INACTIVE_STATUSES = ["closed", "disconnected", "failed"]; + + hasActiveStreamFilter(filtersWeakSet) { + const iter = ChromeUtils.nondeterministicGetWeakSetKeys(filtersWeakSet); + for (let filter of iter) { + if (!this.STREAM_FILTER_INACTIVE_STATUSES.includes(filter.status)) { + return true; + } + } + return false; + } + + watchStreamFilterSuspendCancel({ + context, + filters, + onSuspend, + onSuspendCanceled, + }) { + if ( + !context.isBackgroundContext || + context.extension.persistentBackground !== false + ) { + return; + } + + const { extension } = context; + const cancelSuspendOnActiveStreamFilter = () => + this.hasActiveStreamFilter(filters); + context.callOnClose({ + close() { + extension.off( + "internal:stream-filter-suspend-cancel", + cancelSuspendOnActiveStreamFilter + ); + extension.off("background-script-suspend", onSuspend); + extension.off("background-script-suspend-canceled", onSuspend); + }, + }); + extension.on( + "internal:stream-filter-suspend-cancel", + cancelSuspendOnActiveStreamFilter + ); + extension.on("background-script-suspend", onSuspend); + extension.on("background-script-suspend-canceled", onSuspendCanceled); + } + + getAPI(context) { + let filters = new WeakSet(); + + context.callOnClose({ + close() { + for (let filter of ChromeUtils.nondeterministicGetWeakSetKeys( + filters + )) { + try { + filter.disconnect(); + } catch (e) { + // Ignore. + } + } + }, + }); + + let isSuspending = false; + this.watchStreamFilterSuspendCancel({ + context, + filters, + onSuspend: () => (isSuspending = true), + onSuspendCanceled: () => (isSuspending = false), + }); + + function filterResponseData(requestId) { + if (isSuspending) { + throw new ExtensionError( + "filterResponseData method calls forbidden while background extension global is suspending" + ); + } + requestId = parseInt(requestId, 10); + + let streamFilter = context.cloneScope.StreamFilter.create( + requestId, + context.extension.id + ); + + filters.add(streamFilter); + return streamFilter; + } + + const webRequest = {}; + + // For extensions with manifest_version >= 3, an additional webRequestFilterResponse permission + // is required to get access to the webRequest.filterResponseData API method. + if ( + context.extension.manifestVersion < 3 || + context.extension.hasPermission("webRequestFilterResponse") + ) { + webRequest.filterResponseData = filterResponseData; + } else { + webRequest.filterResponseData = () => { + throw new ExtensionError( + 'Missing required "webRequestFilterResponse" permission' + ); + }; + } + + return { webRequest }; + } +}; diff --git a/toolkit/components/extensions/components.conf b/toolkit/components/extensions/components.conf new file mode 100644 index 0000000000..0b6461f13d --- /dev/null +++ b/toolkit/components/extensions/components.conf @@ -0,0 +1,16 @@ +# -*- 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': '{db82286d-d649-47fb-8599-ba31673a58c5}', + 'contract_ids': ['@mozilla.org/extensions/child;1'], + 'type': 'mozilla::extensions::ExtensionsChild', + 'constructor': 'mozilla::extensions::ExtensionsChild::GetSingleton', + 'headers': ['mozilla/extensions/ExtensionsChild.h'], + 'categories': {'app-startup': 'ExtensionsChild'}, + }, +] diff --git a/toolkit/components/extensions/docs/background.rst b/toolkit/components/extensions/docs/background.rst new file mode 100644 index 0000000000..5d5dcd06b9 --- /dev/null +++ b/toolkit/components/extensions/docs/background.rst @@ -0,0 +1,133 @@ +Background +========== + +WebExtensions run in a sandboxed environment much like regular web content. +The purpose of extensions is to enhance the browser in a way that +regular content cannot -- WebExtensions APIs bridge this gap by exposing +browser features to extensions in a way preserves safety, reliability, +and performance. +The implementation of a WebExtension API runs with +:doc:`chrome privileges </dom/scriptSecurity/index>`. +Browser internals are accessed using +:ref:`XPCOM` +or :doc:`ChromeOnly WebIDL features </dom/webIdlBindings/index>`. + +The rest of this documentation covers how API implementations interact +with the implementation of WebExtensions. +To expose some browser feature to WebExtensions, the first step is +to design the API. Some high-level principles for API design +are documented on the Mozilla wiki: + +- `Vision for WebExtensions <https://wiki.mozilla.org/WebExtensions/Vision>`_ +- `API Policies <https://wiki.mozilla.org/WebExtensions/policy>`_ +- `Process for creating new APIs <https://wiki.mozilla.org/WebExtensions/NewAPIs>`_ + +Javascript APIs +--------------- +Many WebExtension APIs are accessed directly from extensions through +Javascript. Functions are the most common type of object to expose, +though some extensions expose properties of primitive Javascript types +(e.g., constants). +Regardless of the exact method by which something is exposed, +there are a few important considerations when designing part of an API +that is accessible from Javascript: + +- **Namespace**: + Everything provided to extensions is exposed as part of a global object + called ``browser``. For compatibility with Google Chrome, many of these + features are also exposed on a global object called ``chrome``. + Functions and other objects are not exposed directly as properties on + ``browser``, they are organized into *namespaces*, which appear as + properties on ``browser``. For example, the + `tabs API <https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/tabs>`_ + uses a namespace called ``tabs``, so all its functions and other + properties appear on the object ``browser.tabs``. + For a new API that provides features via Javascript, the usual practice + is to create a new namespace with a concise but descriptive name. + +- **Environments**: + There are several different types of Javascript environments in which + extension code can execute: extension pages, content scripts, proxy + scripts, and devtools pages. + Extension pages include the background page, popups, and content pages + accessed via |getURL|_. + When creating a new Javascript feature the designer must choose + in which of these environments the feature will be available. + Most Javascript features are available in extension pages, + other environments have limited sets of API features available. + +.. |getURL| replace:: ``browser.runtime.getURL()`` +.. _getURL: https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/runtime/getURL + +- **Permissions**: + Many Javascript features are only present for extensions that + include an appropriate permission in the manifest. + The guidelines for when an API feature requires a permission are + described in (*citation needed*). + +The specific types of features that can be exposed via Javascript are: + +- **Functions**: + A function callable from Javascript is perhaps the most commonly + used feature in WebExtension APIs. + New API functions are asynchronous, returning a + `Promise <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise>`_. Even functions that do not return a result + use Promises so that errors can be indicated asynchronously + via a rejected Promise as opposed to a synchronously thrown Error. + This is due to the fact that extensions run in a child process and + many API functions require communication with the main process. + If an API function that needs to communicate in this way returned a + synchronous result, then all Javascript execution in the child + process would need to be paused until a response from the main process + was received. Even if a function could be implemented synchronously + within a child process, the standard practice is to make it + asynchronous so as not to constrain the implementation of the underlying + browser feature and make it impossible to move functionality out of the + child process. + Another consequence of functions using inter-process communication is + that the parameters to a function and its return value must all be + simple data types that can be sent between processes using the + `structured clone algorithm <https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm>`_. + +- **Events**: + Events complement functions (which allow an extension to call into + an API) by allowing an event within the browser to invoke a callback + in the extension. + Any time an API requires an extension to pass a callback function that + gets invoked some arbitrary number of times, that API method should be + defined as an event. + +Manifest Keys +------------- +In addition to providing functionality via Javascript, WebExtension APIs +can also take actions based on the contents of particular properties +in an extension's manifest (or even just the presence of a particular +property). +Manifest entries are used for features in which an extension specifies +some static information that is used when an extension is installed or +when it starts up (i.e., before it has the chance to run any code to use +a Javascript API). +An API may handle a manifest key and implement Javascript functionality, +see the +`browser action <https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/browserAction>`_ +API for an example. + +Other Considerations +-------------------- +In addition to the guidelines outlined above, +there are some other considerations when designing and implementing +a WebExtension API: + +- **Cleanup**: A badly written WebExtension should not be able to permanently + leak any resources. In particular, any action from an extension that + causes a resource to be allocated within the browser should be + automatically cleaned up when the extension is disabled or uninstalled. + This is described in more detail in the section on :ref:`lifecycle`. + +- **Performance**: A new WebExtension API should not add any new overhead + to the browser when the API is not used. That is, the implementation + of the API should not be loaded at all unless it is actively used by + an extension. In addition, initialization should be delayed when + possible -- extensions ared started relatively early in the browser + startup process so any unnecessary work done during extension startup + contributes directly to sluggish browser startup. diff --git a/toolkit/components/extensions/docs/basics.rst b/toolkit/components/extensions/docs/basics.rst new file mode 100644 index 0000000000..35d61561e2 --- /dev/null +++ b/toolkit/components/extensions/docs/basics.rst @@ -0,0 +1,275 @@ +.. _basics: + +API Implementation Basics +========================= +This page describes some of the pieces involved when creating +WebExtensions APIs. Detailed documentation about how these pieces work +together to build specific features is in the next section. + +The API Schema +-------------- +As described previously, a WebExtension runs in a sandboxed environment +but the implementation of a WebExtensions API runs with full chrome +privileges. API implementations do not directly interact with +extensions' Javascript environments, that is handled by the WebExtensions +framework. Each API includes a schema that describes all the functions, +events, and other properties that the API might inject into an +extension's Javascript environment. +Among other things, the schema specifies the namespace into which +an API should be injected, what permissions (if any) are required to +use it, and in which contexts (e.g., extension pages, content scripts, etc) +it should be available. The WebExtensions framework reads this schema +and takes care of injecting the right objects into each extension +Javascript environment. + +API schemas are written in JSON and are based on +`JSON Schema <http://json-schema.org/>`_ with some extensions to describe +API functions and events. +The next section describes the format of the schema in detail. + +The ExtensionAPI class +---------------------- +Every WebExtensions API is represented by an instance of the Javascript +`ExtensionAPI <reference.html#extensionapi-class>`_ class. +An instance of its API class is created every time an extension that has +access to the API is enabled. Instances of this class contain the +implementations of functions and events that are exposed to extensions, +and they also contain code for handling manifest keys as well as other +part of the extension lifecycle (e.g., updates, uninstalls, etc.) +The details of this class are covered in a subsequent section, for now the +important point is that this class contains all the actual code that +backs a particular WebExtensions API. + +Built-in versus Experimental APIs +--------------------------------- +A WebExtensions API can be built directly into the browser or it can be +contained in a special type of extension called a privileged extension +that defines a WebExtensions Experiment (i.e. experimental APIs). +The API schema and the ExtensionAPI class are written in the same way +regardless of how the API will be delivered, the rest of this section +explains how to package a new API using these methods. + +Adding a built-in API +--------------------- +Built-in WebExtensions APIs are loaded lazily. That is, the schema and +accompanying code are not actually loaded and interpreted until an +extension that uses the API is activated. +To actually register the API with the WebExtensions framework, an entry +must be added to the list of WebExtensions modules in one of the following +files: + +- ``toolkit/components/extensions/ext-toolkit.json`` +- ``browser/components/extensions/ext-browser.json`` +- ``mobile/android/components/extensions/ext-android.json`` + +Here is a sample fragment for a new API: + +.. code-block:: js + + "myapi": { + "schema": "chrome://extensions/content/schemas/myapi.json", + "url": "chrome://extensions/content/ext-myapi.js", + "paths": [ + ["myapi"], + ["anothernamespace", "subproperty"] + ], + "scopes": ["addon_parent"], + "permissions": ["myapi"], + "manifest": ["myapi_key"], + "events": ["update", "uninstall"] + } + +The ``schema`` and ``url`` properties are simply URLs for the API schema +and the code implementing the API. The ``chrome:`` URLs in the example above +are typically created by adding entries to ``jar.mn`` in the mozilla-central +directory where the API implementation is kept. The standard locations for +API implementations are: + +- ``toolkit/components/extensions``: This is where APIs that work in both + the desktop and mobile versions of Firefox (as well as potentially any + other applications built on Gecko) should go +- ``browser/components/extensions``: APIs that are only supported on + Firefox for the desktop. +- ``mobile/android/components/extensions``: APIs that are only supported + on Firefox for Android. + +Within the appropriate extensions directory, the convention is that the +API schema is in a file called ``schemas/name.json`` (where *name* is +the name of the API, typically the same as its namespace if it has +Javascript visible features). The code for the ExtensionAPI class is put +in a file called ``ext-name.js``. If the API has code that runs in a +child process, that is conventionally put in a file called ``ext-c-name.js``. + +The remaining properties specify when an API should be loaded. +The ``paths``, ``scopes``, and ``permissions`` properties together +cause an API to be loaded when Javascript code in an extension references +something beneath the ``browser`` global object that is part of the API. +The ``paths`` property is an array of paths where each individual path is +also an array of property names. In the example above, the sample API will +be loaded if an extension references either ``browser.myapi`` or +``browser.anothernamespace.subproperty``. + +A reference to a property beneath ``browser`` only causes the API to be +loaded if it occurs within a scope listed in the ``scopes`` property. +A scope corresponds to the combination of a Javascript environment +(e.g., extension pages, content scripts, etc) and the process in which the +API code should run (which is either the main/parent process, or a +content/child process). +Valid ``scopes`` are: + +- ``"addon_parent"``, ``"addon_child``: Extension pages + +- ``"content_parent"``, ``"content_child``: Content scripts + +- ``"devtools_parent"``, ``"devtools_child"``: Devtools pages + +The distinction between the ``_parent`` and ``_child`` scopes will be +explained in further detail in following sections. + +A reference to a property only causes the API to be loaded if the +extension referencing the property also has all the permissions listed +in the ``permissions`` property. + +A WebExtensions API that is controlled by a manifest key can also be loaded +when an extension that includes the relevant manifest key is activated. +This is specified by the ``manifest`` property, which lists any manifest keys +that should cause the API to be loaded. + +Finally, APIs can be loaded based on other events in the WebExtension +lifecycle. These are listed in the ``events`` property and described in +more detail in :ref:`lifecycle`. + +Adding Experimental APIs in Privileged Extensions +------------------------------------------------- + +A new API may also be implemented within a privileged extension. An API +implemented this way is called a WebExtensions Experiment (or simply an +Experimental API). Experiments can be useful when actively developing a +new API, as they do not require building Firefox locally. These extensions +may be installed temporarily via ``about:debugging`` or, on browser that +support it (current Nightly and Developer Edition), by setting the preference +``xpinstall.signatures.required`` to ``false``. You may also set the +preference ``extensions.experiments.enabled`` to ``true`` to install the +addon normally and test across restart. + +.. note:: + Out-of-tree privileged extensions cannot be signed by addons.mozilla.org. + A different pipeline is used to sign them with a privileged certificate. + You'll find more information in the `xpi-manifest repository on GitHub <https://github.com/mozilla-extensions/xpi-manifest>`_. + +Experimental APIs have a few limitations compared with built-in APIs: + +- Experimental APIs can (currently) only be exposed to extension pages, + not to devtools pages or to content scripts. +- Experimental APIs cannot handle manifest keys (since the extension manifest + needs to be parsed and validated before experimental APIs are loaded). +- Experimental APIs cannot use the static ``"update"`` and ``"uninstall"`` + lifecycle events (since in general those may occur when an affected + extension is not active or installed). + +Experimental APIs are declared in the ``experiment_apis`` property in a +WebExtension's ``manifest.json`` file. For example: + +.. code-block:: js + + { + "manifest_version": 2, + "name": "Extension containing an experimental API", + "experiment_apis": { + "apiname": { + "schema": "schema.json", + "parent": { + "scopes": ["addon_parent"], + "paths": [["myapi"]], + "script": "implementation.js" + }, + + "child": { + "scopes": ["addon_child"], + "paths": [["myapi"]], + "script": "child-implementation.js" + } + } + } + } + +This is essentially the same information required for built-in APIs, +just organized differently. The ``schema`` property is a relative path +to a file inside the extension containing the API schema. The actual +implementation details for the parent process and for child processes +are defined in the ``parent`` and ``child`` properties of the API +definition respectively. Inside these sections, the ``scope`` and ``paths`` +properties have the same meaning as those properties in the definition +of a built-in API (though see the note above about limitations; the +only currently valid values for ``scope`` are ``"addon_parent"`` and +``"addon_child"``). The ``script`` property is a relative path to a file +inside the extension containing the implementation of the API. + +The extension that includes an experiment defined in this way automatically +gets access to the experimental API. An extension may also use an +experimental API implemented in a different extension by including the +string ``experiments.name`` in the ``permissions``` property in its +``manifest.json`` file. In this case, the string name must be replace by +the name of the API from the extension that defined it (e.g., ``apiname`` +in the example above. + +Globals available in the API scripts global +------------------------------------------- + +The API scripts aren't loaded as an JSM and so: + +- they are not fully isolated from each other (and they are going to be + lazy loaded when the extension does use them for the first time) and + be executed in a per-process shared global scope) +- the experimental APIs embedded in privileged extensions are executed + in a per-extension global (separate from the one used for the built-in APIs) + +The global scope where the API scripts are executed is pre-populated with +some useful globals: + +- ``AppConstants`` +- ``console`` +- ``CC``, ``Ci``, ``Cr`` and ``Cu`` +- ``ChromeWorker`` +- ``extensions``, ``ExtensionAPI``, ``ExtensionCommon`` and ``ExtensionUtils`` +- ``global`` +- ``MatchGlob``, ``MatchPattern`` and ``MatchPatternSet`` +- ``Services`` +- ``StructuredCloneHolder`` +- ``XPCOMUtils`` + +For a more complete and updated list of the globals available by default in +all API scripts look to the following source: + +- `SchemaAPIManager _createExtGlobal method <https://searchfox.org/mozilla-central/search?q=symbol:SchemaAPIManager%23_createExtGlobal&redirect=false>`_ +- Only available in the parent Firefox process: + `toolkit/components/extensions/parent/ext-toolkit.js <https://searchfox.org/mozilla-central/source/toolkit/components/extensions/parent/ext-toolkit.js>`_ +- Only available in the child Firefox process: + `toolkit/components/extensions/child/ext-toolkit.js <https://searchfox.org/mozilla-central/source/toolkit/components/extensions/child/ext-toolkit.js>`_ +- Only available in the Desktop builds: + `browser/components/extensions/parent/ext-browser.js <https://searchfox.org/mozilla-central/source/browser/components/extensions/parent/ext-browser.js>`_ +- Only available in the Android builds: + `mobile/android/components/extensions/ext-android.js <https://searchfox.org/mozilla-central/source/mobile/android/components/extensions/ext-android.js>`_ + +.. warning:: + The extension API authors should never redefine these globals to avoid introducing potential + conflicts between API scripts (e.g. see `Bug 1697404 comment 3 <https://bugzilla.mozilla.org/show_bug.cgi?id=1697404#c3>`_ + and `Bug 1697404 comment 4 <https://bugzilla.mozilla.org/show_bug.cgi?id=1697404#c4>`_). + +WebIDL Bindings +--------------- + +In ``manifest_version: 3`` the extension will be able to declare a background service worker +instead of a background page, and the existing WebExtensions API bindings can't be injected into this +new extension global, because it lives off the main thread. + +To expose WebExtensions API bindings to the WebExtensions ``background.service_worker`` global +we are in the process of generating new WebIDL bindings for the WebExtensions API. + +An high level view of the architecture and a more in depth details about the architecture process +to create or modify WebIDL bindings for the WebExtensions API can be found here: + +.. toctree:: + :maxdepth: 2 + + webidl_bindings diff --git a/toolkit/components/extensions/docs/events.rst b/toolkit/components/extensions/docs/events.rst new file mode 100644 index 0000000000..d494155ffc --- /dev/null +++ b/toolkit/components/extensions/docs/events.rst @@ -0,0 +1,609 @@ +Implementing an event +===================== +Like a function, an event requires a definition in the schema and +an implementation in Javascript inside an instance of ExtensionAPI. + +Declaring an event in the API schema +------------------------------------ +The definition for a simple event looks like this: + +.. code-block:: json + + [ + { + "namespace": "myapi", + "events": [ + { + "name": "onSomething", + "type": "function", + "description": "Description of the event", + "parameters": [ + { + "name": "param1", + "description": "Description of the first callback parameter", + "type": "number" + } + ] + } + ] + } + ] + +This fragment defines an event that is used from an extension with +code such as: + +.. code-block:: js + + browser.myapi.onSomething.addListener(param1 => { + console.log(`Something happened: ${param1}`); + }); + +Note that the schema syntax looks similar to that for a function, +but for an event, the ``parameters`` property specifies the arguments +that will be passed to a listener. + +Implementing an event +--------------------- +Just like with functions, defining an event in the schema causes +wrappers to be automatically created and exposed to an extensions' +appropriate Javascript contexts. +An event appears to an extension as an object with three standard +function properties: ``addListener()``, ``removeListener()``, +and ``hasListener()``. +Also like functions, if an API defines an event but does not implement +it in a child process, the wrapper in the child process effectively +proxies these calls to the implementation in the main process. + +A helper class called +`EventManager <reference.html#eventmanager-class>`_ makes implementing +events relatively simple. A simple event implementation looks like: + +.. code-block:: js + + this.myapi = class extends ExtensionAPI { + getAPI(context) { + return { + myapi: { + onSomething: new EventManager({ + context, + name: "myapi.onSomething", + register: fire => { + const callback = value => { + fire.async(value); + }; + RegisterSomeInternalCallback(callback); + return () => { + UnregisterInternalCallback(callback); + }; + } + }).api(), + } + } + } + } + +The ``EventManager`` class is usually just used directly as in this example. +The first argument to the constructor is an ``ExtensionContext`` instance, +typically just the object passed to the API's ``getAPI()`` function. +The second argument is a name, it is used only for debugging. +The third argument is the important piece, it is a function that is called +the first time a listener is added for this event. +This function is passed an object (``fire`` in the example) that is used to +invoke the extension's listener whenever the event occurs. The ``fire`` +object has several different methods for invoking listeners, but for +events implemented in the main process, the only valid method is +``async()`` which executes the listener asynchronously. + +The event setup function (the function passed to the ``EventManager`` +constructor) must return a cleanup function, +which will be called when the listener is removed either explicitly +by the extension by calling ``removeListener()`` or implicitly when +the extension Javascript context from which the listener was added is destroyed. + +In this example, ``RegisterSomeInternalCallback()`` and +``UnregisterInternalCallback()`` represent methods for listening for +some internal browser event from chrome privileged code. This is +typically something like adding an observer using ``Services.obs`` or +attaching a listener to an ``EventEmitter``. + +After constructing an instance of ``EventManager``, its ``api()`` method +returns an object with with ``addListener()``, ``removeListener()``, and +``hasListener()`` methods. This is the standard extension event interface, +this object is suitable for returning from the extension's +``getAPI()`` method as in the example above. + +Handling extra arguments to addListener() +----------------------------------------- +The standard ``addListener()`` method for events may accept optional +addition parameters to allow extra information to be passed when registering +an event listener. One common application of this parameter is for filtering, +so that extensions that only care about a small subset of the instances of +some event can avoid the overhead of receiving the ones they don't care about. + +Extra parameters to ``addListener()`` are defined in the schema with the +the ``extraParameters`` property. For example: + +.. code-block:: json + + [ + { + "namespace": "myapi", + "events": [ + { + "name": "onSomething", + "type": "function", + "description": "Description of the event", + "parameters": [ + { + "name": "param1", + "description": "Description of the first callback parameter", + "type": "number" + } + ], + "extraParameters": [ + { + "name": "minValue", + "description": "Only call the listener for values of param1 at least as large as this value.", + "type": "number" + } + ] + } + ] + } + ] + +Extra parameters defined in this way are passed to the event setup +function (the last parameter to the ``EventManager`` constructor. +For example, extending our example above: + +.. code-block:: js + + this.myapi = class extends ExtensionAPI { + getAPI(context) { + return { + myapi: { + onSomething: new EventManager({ + context, + module: "myapi", + event: "onSomething", + register: (fire, minValue) => { + const callback = value => { + if (value >= minValue) { + fire.async(value); + } + }; + RegisterSomeInternalCallback(callback); + return () => { + UnregisterInternalCallback(callback); + }; + } + }).api() + } + } + } + } + +Handling listener return values +------------------------------- +Some event APIs allow extensions to affect event handling in some way +by returning values from event listeners that are processed by the API. +This can be defined in the schema with the ``returns`` property: + +.. code-block:: json + + [ + { + "namespace": "myapi", + "events": [ + { + "name": "onSomething", + "type": "function", + "description": "Description of the event", + "parameters": [ + { + "name": "param1", + "description": "Description of the first callback parameter", + "type": "number" + } + ], + "returns": { + "type": "string", + "description": "Description of how the listener return value is processed." + } + } + ] + } + ] + +And the implementation of the event uses the return value from ``fire.async()`` +which is a Promise that resolves to the listener's return value: + +.. code-block:: js + + this.myapi = class extends ExtensionAPI { + getAPI(context) { + return { + myapi: { + onSomething: new EventManager({ + context, + module: "myapi", + event: "onSomething", + register: fire => { + const callback = async (value) => { + let rv = await fire.async(value); + log(`The onSomething listener returned the string ${rv}`); + }; + RegisterSomeInternalCallback(callback); + return () => { + UnregisterInternalCallback(callback); + }; + } + }).api() + } + } + } + } + +Note that the schema ``returns`` definition is optional and serves only +for documentation. That is, ``fire.async()`` always returns a Promise +that resolves to the listener return value, the implementation of an +event can just ignore this Promise if it doesn't care about the return value. + +Implementing an event in the child process +------------------------------------------ +The reasons for implementing events in the child process are similar to +the reasons for implementing functions in the child process: + +- Listeners for the event return a value that the API implementation must + act on synchronously. + +- Either ``addListener()`` or the listener function has one or more + parameters of a type that cannot be sent between processes. + +- The implementation of the event interacts with code that is only + accessible from a child process. + +- The event can be implemented substantially more efficiently in a + child process. + +The process for implementing an event in the child process is the same +as for functions -- simply implement the event in an ExtensionAPI subclass +that is loaded in a child process. And just as a function in a child +process can call a function in the main process with +`callParentAsyncFunction()`, events in a child process may subscribe to +events implemented in the main process with a similar `getParentEvent()`. +For example, the automatically generated event proxy in a child process +could be written explicitly as: + +.. code-block:: js + + this.myapi = class extends ExtensionAPI { + getAPI(context) { + return { + myapi: { + onSomething: new EventManager( + context, + name: "myapi.onSomething", + register: fire => { + const listener = (value) => { + fire.async(value); + }; + + let parentEvent = context.childManager.getParentEvent("myapi.onSomething"); + parent.addListener(listener); + return () => { + parent.removeListener(listener); + }; + } + }).api() + } + } + } + } + +Events implemented in a child process have some additional methods available +to dispatch listeners: + +- ``fire.sync()`` This runs the listener synchronously and returns the + value returned by the listener + +- ``fire.raw()`` This runs the listener synchronously without cloning + the listener arguments into the extension's Javascript compartment. + This is used as a performance optimization, it should not be used + unless you have a detailed understanding of Javascript compartments + and cross-compartment wrappers. + +Event Listeners Persistence +--------------------------- + +Event listeners are persisted in some circumstances. Persisted event listeners can either +block startup, and/or cause an Event Page or Background Service Worker to be started. + +The event listener must be registered synchronously in the top level scope +of the background. Event listeners registered later, or asynchronously, are +not persisted. + +Currently only WebRequestBlocking and Proxy events are able to block +at startup, causing an addon to start earlier in Firefox startup. Whether +a module can block startup is defined by a ``startupBlocking`` flag in +the module definition files (``ext-toolkit.json`` or ``ext-browser.json``). +As well, these are the only events persisted for persistent background scripts. + +Events implemented only in a child process, without a parent process counterpart, +cannot be persisted. + +Persisted and Primed Event Listeners +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +In terms of terminology: + +- **Persisted Event Listener** is the set of data (in particular API module, API event name + and the parameters passed along with addListener call if any) related to an event listener + that has been registered by an Event Page (or Background Service Worker) in a previous run + and being stored in the StartupCache data + +- **Primed Event Listener** is a "placeholder" event listener created, from the **Persisted Event Listener** + data found in the StartupCache, while the Event Page (or Background Service Worker) is not running + (either not started yet or suspended after the idle timeout was hit) + +ExtensionAPIPersistent and PERSISTENT_EVENTS +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Most of the WebExtensions APIs promise some API events, and it is likely that most of those events are also +expected to be waking up the Event Page (or Background Service Worker) when emitted while the background +extension context has not been started yet (or it was suspended after the idle timeout was hit). + +As part of implementing a WebExtensions API that is meant to persist all or some of its API event +listeners: + +- the WebExtensions API namespace class should extend ``ExtensionAPIPersistent`` (instead of extending + the ``ExtensionAPI`` class) + +- the WebExtensions API namespace should have a ``PERSISTENT_EVENTS`` property, which is expected to be + set to an object defining methods. Each method should be named after the related API event name, which + are going to be called internally: + + - while the extension Event Page (or Background Service Worker) isn't running (either never started yet + or suspended after the idle timeout). These methods are called by the WebExtensions internals to + create placeholder API event listeners in the parent process for each of the API event listeners + persisted for that extension. These placeholder listeners are internally referred to as + ``primed listeners``). + + - while the extension Event Page (or Background Service Worker) is running (as well as for any other + extension context types they may have been created for the extension). These methods are called by the + WebExtensions internals to create the parent process callback that will be responsible for + forwarding the API events to the extension callbacks in the child processes. + +- in the ``getAPI`` method. For all the API namespace properties that represent API events returned by this method, + the ``EventManager`` instances created for each of the API events that is expected to persist its listeners + should include following options: + + - ``module``, to be set to the API module name as listed in ``"ext-toolkit.json"`` / ``"ext-browser.json"`` + / ``"ext-android.json"`` (which, in most cases, is the same as the API namespace name string). + - ``event``, to be set to the API event name string. + - ``extensionApi``, to be set to the ``ExtensionAPIPersistent`` class instance. + +Taking a look to some of the patches landed to introduce API event listener persistency on some of the existing +API as part of introducing support for the Event Page may also be useful: + +- Bug-1748546_ ported the browserAction and pageAction API namespace implementations to + ``ExtensionAPIPersistent`` and, in particular, the changes applied to: + + - ext-browserAction.js: https://hg.mozilla.org/integration/autoland/rev/08a3eaa8bce7 + - ext-pageAction.js: https://hg.mozilla.org/integration/autoland/rev/ed616e2e0abb + +.. _Bug-1748546: https://bugzilla.mozilla.org/show_bug.cgi?id=1748546 + +Follows an example of what has been described previously in a code snippet form: + +.. code-block:: js + + this.myApiName = class extends ExtensionAPIPersistent { + PERSISTENT_EVENTS = { + // @param {object} options + // @param {object} options.fire + // @param {function} options.fire.async + // @param {function} options.fire.sync + // @param {function} options.fire.raw + // For primed listeners `fire.async`/`fire.sync`/`fire.raw` will + // collect the pending events to be send to the background context + // and implicitly wake up the background context (Event Page or + // Background Service Worker), or forward the event right away if + // the background context is running. + // @param {function} [options.fire.wakeup = undefined] + // For primed listeners, the `fire` object also provide a `wakeup` method + // which can be used by the primed listener to explicitly `wakeup` the + // background context (Event Page or Background Service Worker) and wait for + // it to be running (by awaiting on the Promise returned by wakeup to be + // resolved). + // @param {ProxyContextParent} [options.context=undefined] + // This property is expected to be undefined for primed listeners (which + // are created while the background extension context does not exist) and + // to be set to a ProxyContextParent instance (the same got by the getAPI + // method) when the method is called for a listener registered by a + // running extension context. + // + // @param {object} [apiEventsParams=undefined] + // The additional addListener parameter if any (some API events are allowing + // the extensions to pass some parameters along with the extension callback). + onMyEventName({ context, fire }, apiEventParams = undefined) { + const listener = (...) { + // Wake up the EventPage (or Background ServiceWorker). + if (fire.wakeup) { + await fire.wakeup(); + } + + fire.async(...); + } + + // Subscribe a listener to an internal observer or event which will be notified + // when we need to call fire to either send the event to an extension context + // already running or wake up a suspended event page and accumulate the events + // to be fired once the extension context is running again and a callback registered + // back (which will be used to convert the primed listener created while + // the non persistent background extension context was not running yet) + ... + return { + unregister() { + // Unsubscribe a listener from an internal observer or event. + ... + } + convert(fireToExtensionCallback) { + // Convert gets called once the primed API event listener, + // created while the extension background context has been + // suspended, is being converted to a parent process API + // event listener callback that is responsible for forwarding the + // events to the child processes. + // + // The `fireToExtensionCallback` parameter is going to be the + // one that will emit the event to the extension callback (while + // the one got from the API event registrar method may be the one + // that is collecting the events to emit up until the background + // context got started up again). + fire = fireToExtensionCallback; + }, + }; + }, + ... + }; + + getAPI(context) { + ... + return { + myAPIName: { + ... + onMyEventName: new EventManager({ + context, + // NOTE: module is expected to be the API module name as listed in + // ext-toolkit.json / ext-browser.json / ext-android.json. + module: "myAPIName", + event: "onMyEventNAme", + extensionApi: this, + }), + }, + }; + } + }; + +Testing Persisted API Event Listeners +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +- ``extension.terminateBackground()`` / ``extension.terminateBackground({ disableResetIdleForTest: true})``: + + - The wrapper object returned by ``ExtensionTestUtils.loadExtension`` provides + a ``terminateBackground`` method which can be used to simulate an idle timeout, + by explicitly triggering the same idle timeout suspend logic handling the idle timeout. + - This method also accept an optional parameter, if set to ``{ disableResetIdleForTest: true}`` + will forcefully suspend the background extension context and ignore all the + conditions that would reset the idle timeout due to some work still pending + (e.g. a ``NativeMessaging``'s ``Port`` still open, a ``StreamFilter`` instance + still active or a ``Promise`` from an API event listener call not yet resolved) + +- ``ExtensionTestUtils.testAssertions.assertPersistentListeners``: + + - This test assertion helper can be used to more easily assert what should + be the persisted state of a given API event (e.g. assert it to not be + persisted, or to be persisted and/or primed) + +.. code-block:: js + + assertPersistentListeners(extension, "browserAction", "onClicked", { + primed: false, + }); + await extension.terminateBackground(); + assertPersistentListeners(extension, "browserAction", "onClicked", { + primed: true, + }); + +- ``extensions.background.idle.timeout`` preference determines how long to wait + (between API events being notified to the extension event page) before considering + the Event Page in the idle state and suspend it, in some xpcshell test this pref + may be set to 0 to reduce the amount of time the test will have to wait for the + Event Page to be suspended automatically + +- ``extension.eventPage.enabled`` pref is responsible for enabling/disabling + Event Page support for manifest_version 2 extension, technically it is + now set to ``true`` on all channels, but it would still be worth flipping it + to ``true`` explicitly in tests that are meant to cover Event Page behaviors + for manifest_version 2 test extension until the pref is completely removed + (mainly to make sure that if the pref would need to be flipped to false + for any reason, the tests will still be passing) + +Persisted Event listeners internals +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The ``ExtensionAPIPersistent`` class provides a way to quickly introduce API event +listener persistency to a new WebExtensions API, and reduce the number of code +duplication, the following section provide some more details about what the +abstractions are doing internally in practice. + +WebExtensions APIs classes that extend the ``ExtensionAPIPersistent`` base class +are still able to support non persisted listeners along with persisted ones +(e.g. events that are persisting the listeners registered from an event page are +already not persisting listeners registered from other extension contexts) +and can mix persisted and non-persisted events. + +As an example in ``toolkit/components/extensions/parent/ext-runtime.js``` the two +events ``onSuspend`` and ``onSuspendCanceled`` are expected to be never persisted +nor primed (even for an event page) and so their ``EventManager`` instances +receive the following options: + +- a ``register`` callback (instead of the one part of ``PERSISTED_EVENTS``) +- a ``name`` string property (instead of the two separate ``module`` and ``event`` + string properties that are used for ``EventManager`` instances from persisted + ones +- no ``extensionApi`` property (because that is only needed for events that are + expected to persist event page listeners) + +In practice ``ExtensionAPIPersistent`` extends the ``ExtensionAPI`` class to provide +a generic ``primeListeners`` method, which is the method responsible for priming +a persisted listener when the event page has been suspended or not started yet. + +The ``primeListener`` method is expected to return an object with an ``unregister`` +and ``convert`` method, while the ``register`` callback passed to the ``EventManager`` +constructor is expected to return the ``unregister`` method. + +.. code-block:: js + + function somethingListener(fire, minValue) => { + const callback = value => { + if (value >= minValue) { + fire.async(value); + } + }; + RegisterSomeInternalCallback(callback); + return { + unregister() { + UnregisterInternalCallback(callback); + }, + convert(_fire, context) { + fire = _fire; + } + }; + } + + this.myapi = class extends ExtensionAPI { + primeListener(extension, event, fire, params, isInStartup) { + if (event == "onSomething") { + // Note that we return the object with unregister and convert here. + return somethingListener(fire, ...params); + } + // If an event other than onSomething was requested, we are not returning + // anything for it, thus it would not be persistable. + } + getAPI(context) { + return { + myapi: { + onSomething: new EventManager({ + context, + module: "myapi", + event: "onSomething", + register: (fire, minValue) => { + // Note that we return unregister here. + return somethingListener(fire, minValue).unregister; + } + }).api() + } + } + } + } diff --git a/toolkit/components/extensions/docs/functions.rst b/toolkit/components/extensions/docs/functions.rst new file mode 100644 index 0000000000..f1727aceed --- /dev/null +++ b/toolkit/components/extensions/docs/functions.rst @@ -0,0 +1,201 @@ +Implementing a function +======================= +Implementing an API function requires at least two different pieces: +a definition for the function in the schema, and Javascript code that +actually implements the function. + +Declaring a function in the API schema +-------------------------------------- +An API schema definition for a simple function looks like this: + +.. code-block:: json + + [ + { + "namespace": "myapi", + "functions": [ + { + "name": "add", + "type": "function", + "description": "Adds two numbers together.", + "async": true, + "parameters": [ + { + "name": "x", + "type": "number", + "description": "The first number to add." + }, + { + "name": "y", + "type": "number", + "description": "The second number to add." + } + ] + } + ] + } + ] + +The ``type`` and ``description`` properties were described above. +The ``name`` property is the name of the function as it appears in +the given namespace. That is, the fragment above creates a function +callable from an extension as ``browser.myapi.add()``. +The ``parameters`` property describes the parameters the function takes. +Parameters are specified as an array of Javascript types, where each +parameter is a constrained Javascript value as described +in the previous section. + +Each parameter may also contain additional properties ``optional`` +and ``default``. If ``optional`` is present it must be a boolean +(and parameters are not optional by default so this property is typically +only added when it has the value ``true``). +The ``default`` property is only meaningful for optional parameters, +it specifies the value that should be used for an optional parameter +if the function is called without that parameter. +An optional parameter without an explicit ``default`` property will +receive a default value of ``null``. +Although it is legal to create optional parameters at any position +(i.e., optional parameters can come before required parameters), doing so +leads to difficult to use functions and API designers are encouraged to +use object-valued parameters with optional named properties instead, +or if optional parameters must be used, to use them sparingly and put +them at the end of the parameter list. + +.. XXX should we describe allowAmbiguousArguments? + +The boolean-valued ``async`` property specifies whether a function +is asynchronous. +For asynchronous functions, +the WebExtensions framework takes care of automatically generating a +`Promise <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise>`_ and then resolving the Promise when the function +implementation completes (or rejecting the Promise if the implementation +throws an Error). +Since extensions can run in a child process, any API function that is +implemented (either partially or completely) in the parent process must +be asynchronous. + +When a function is declared in the API schema, a wrapper for the function +is automatically created and injected into appropriate extension Javascript +contexts. This wrapper automatically validates arguments passed to the +function against the formal parameters declared in the schema and immediately +throws an Error if invalid arguments are passed. +It also processes optional arguments and inserts default values as needed. +As a result, API implementations generally do not need to write much +boilerplate code to validate and interpret arguments. + +Implementing a function in the main process +------------------------------------------- +If an asynchronous function is not implemented in the child process, +the wrapper generated from the schema automatically marshalls the +function arguments, sends the request to the parent process, +and calls the implementation there. +When that function completes, the return value is sent back to the child process +and the Promise for the function call is resolved with that value. + +Based on this, an implementation of the function we wrote the schema +for above looks like this: + +.. code-block:: js + + this.myapi = class extends ExtensionAPI { + getAPI(context) { + return { + myapi: { + add(x, y) { return x+y; } + } + } + } + } + +The implementations of API functions are contained in a subclass of the +`ExtensionAPI <reference.html#extensionapi-class>`_ class. +Each subclass of ExtensionAPI must implement the ``getAPI()`` method +which returns an object with a structure that mirrors the structure of +functions and events that the API exposes. +The ``context`` object passed to ``getAPI()`` is an instance of +`BaseContext <reference.html#basecontext-class>`_, +which contains a number of useful properties and methods. + +If an API function implementation returns a Promise, its result will +be sent back to the child process when the Promise is settled. +Any other return type will be sent directly back to the child process. +A function implementation may also raise an Error. But by default, +an Error thrown from inside an API implementation function is not +exposed to the extension code that called the function -- it is +converted into generic errors with the message "An unexpected error occurred". +To throw a specific error to extensions, use the ``ExtensionError`` class: + +.. code-block:: js + + this.myapi = class extends ExtensionAPI { + getAPI(context) { + return { + myapi: { + doSomething() { + if (cantDoSomething) { + throw new ExtensionError("Cannot call doSomething at this time"); + } + return something(); + } + } + } + } + } + +The purpose of this step is to avoid bugs in API implementations from +exposing details about the implementation to extensions. When an Error +that is not an instance of ExtensionError is thrown, the original error +is logged to the +`Browser Console <https://developer.mozilla.org/en-US/docs/Tools/Browser_Console>`_, +which can be useful while developing a new API. + +Implementing a function in a child process +------------------------------------------ +Most functions are implemented in the main process, but there are +occasionally reasons to implement a function in a child process, such as: + +- The function has one or more parameters of a type that cannot be automatically + sent to the main process using the structured clone algorithm. + +- The function implementation interacts with some part of the browser + internals that is only accessible from a child process. + +- The function can be implemented substantially more efficiently in + a child process. + +To implement a function in a child process, simply include an ExtensionAPI +subclass that is loaded in the appropriate context +(e.g, ``addon_child``, ``content_child``, etc.) as described in +the section on :ref:`basics`. +Code inside an ExtensionAPI subclass in a child process may call the +implementation of a function in the parent process using a method from +the API context as follows: + +.. code-block:: js + + this.myapi = class extends ExtensionAPI { + getAPI(context) { + return { + myapi: { + async doSomething(arg) { + let result = await context.childManager.callParentAsyncFunction("anothernamespace.functionname", [arg]); + /* do something with result */ + return ...; + } + } + } + } + } + +As you might expect, ``callParentAsyncFunction()`` calls the given function +in the main process with the given arguments, and returns a Promise +that resolves with the result of the function. +This is the same mechanism that is used by the automatically generated +function wrappers for asynchronous functions that do not have a +provided implementation in a child process. + +It is possible to define the same function in both the main process +and a child process and have the implementation in the child process +call the function with the same name in the parent process. +This is a common pattern when the implementation of a particular function +requires some code in both the main process and child process. diff --git a/toolkit/components/extensions/docs/generate_webidl_from_jsonschema.rst b/toolkit/components/extensions/docs/generate_webidl_from_jsonschema.rst new file mode 100644 index 0000000000..f4514bfa25 --- /dev/null +++ b/toolkit/components/extensions/docs/generate_webidl_from_jsonschema.rst @@ -0,0 +1,94 @@ +Generating WebIDL definitions from WebExtensions API JSONSchema +=============================================================== + +In ``toolkit/components/extensions/webidl-api``, a python script named ``GenerateWebIDLBindings.py`` +helps to generation of the WebIDL definitions for the WebExtensions API namespaces based on the existing +JSONSchema data. + +.. figure:: generate_webidl_from_jsonschema_dataflow.drawio.svg + :alt: Diagram of the GenerateWebIDLBindings.py script data flow + +.. + This svg diagram has been created using https://app.diagrams.net, + the svg file also includes the source in the drawio format and so + it can be edited more easily by loading it back into app.diagrams.net + and then re-export from there (and include the updated drawio format + content into the exported svg file). + +Example: how to execute GenerateWebIDLBindings.py +------------------------------------------------- + +As an example, the following shell command generates (or regenerates if one exists) the webidl bindings +for the `runtime` API namespace: + +.. code-block:: bash + + $ export SCRIPT_DIR="toolkit/components/extensions/webidl-api" + $ mach python $SCRIPT_DIR/GenerateWebIDLBindings.py -- runtime + +this command will generates a `.webdil` file named `dom/webidl/ExtensionRuntime.webidl`. + +.. warning:: + This python script uses some python libraries part of mozilla-central ``mach`` command + and so it has to be executed using ``mach python`` and any command line options that has + to the passed to the ``GenerateWebIDLBindings.py`` script should be passed after the ``--`` + one that ends ``mach python`` own command line options. + +* If a webidl file with the same name already exist, the python script will ask confirmation and + offer to print a diff of the changes (or just continue without changing the existing webidl file + if the content is exactly the same): + +.. code-block:: console + + $ mach python $SCRIPT_DIR/GenerateWebIDLBindings.py -- runtime + + Generating webidl definition for 'runtime' => dom/webidl/ExtensionRuntime.webidl + Found existing dom/webidl/ExtensionRuntime.webidl. + + (Run again with --overwrite-existing to allow overwriting it automatically) + + Overwrite dom/webidl/ExtensionRuntime.webidl? (Yes/No/Diff) + D + --- ExtensionRuntime.webidl--existing + +++ ExtensionRuntime.webidl--updated + @@ -24,6 +24,9 @@ + [Exposed=(ServiceWorker), LegacyNoInterfaceObject] + interface ExtensionRuntime { + // API methods. + + + + [Throws, WebExtensionStub="Async"] + + any myNewMethod(boolean aBoolParam, optional Function callback); + + [Throws, WebExtensionStub="Async"] + any openOptionsPage(optional Function callback); + + + Overwrite dom/webidl/ExtensionRuntime.webidl? (Yes/No/Diff) + +* By convention each WebExtensions API WebIDL binding is expected to be paired with C++ files + named ``ExtensionMyNamespace.h`` and ``ExtensionMyNamespace.cpp`` and located in + ``toolkit/components/extensions/webidl-api``: + + * if no files with the expected names is found the python script will generate an initial + boilerplate files and will store them in the expected mozilla-central directory. + * The Firefox developers are responsible to fill this initial boilerplate as needed and + to apply the necessary changes (if any) when the webidl definitions are updated because + of changes to the WebExtensions APIs JSONSchema. + +``ExtensionWebIDL.conf`` config file +------------------------------------ + +TODO: + +* mention the role of the "webidl generation" script config file in handling + special cases (e.g. mapping types and method stubs) + +* notes on desktop-only APIs and API namespaces only partially available on Android + + +``WebExtensionStub`` WebIDL extended attribute +---------------------------------------------- + +TODO: + +* mention the special webidl extended attribute used in the WebIDL definitions diff --git a/toolkit/components/extensions/docs/generate_webidl_from_jsonschema_dataflow.drawio.svg b/toolkit/components/extensions/docs/generate_webidl_from_jsonschema_dataflow.drawio.svg new file mode 100644 index 0000000000..aaa5a4c3e0 --- /dev/null +++ b/toolkit/components/extensions/docs/generate_webidl_from_jsonschema_dataflow.drawio.svg @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- Do not edit this file with editors other than diagrams.net --> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg xmlns="http://www.w3.org/2000/svg" style="background-color: rgb(230, 230, 230);" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="911px" height="701px" viewBox="-0.5 -0.5 911 701" content="<mxfile host="app.diagrams.net" modified="2021-09-22T15:04:40.150Z" agent="5.0 (X11)" etag="wbbyr0k6o51B6i_UoEFs" version="15.2.9" type="device"><diagram id="887lF9N4iZXDEKDicNfW" name="GenerateWebIDLBindings Data Flow">7Vxbc5s4FP41ntl9sIc75DGJk3QzybYz7vTytANGttVgYEGO7f76lUDCSAgMiSFOu9OZFIQ4gnO+cxce6dfr3V3ixqvHyAfBSFP83UifjjRNNW0L/0dG9vmIfaHnA8sE+nTSYWAGfwI6qNDRDfRByk1EURQgGPOD8ygMwRxxY26SRFt+2iIK+FVjdwkqA7O5G1RHv0Ifreioal0cLnwAcLmiSzuanV/w3PnTMok2IV1vpOk3FvmXX167jBZ90XTl+tG2NKTfjPTrJIpQfrTeXYOA8Jax7c79+M/TR9/7MbubaV9m8f1ysx3nxG673FK8YQJCdFrSBqX97AYbwNiQvSzaMwbj947J4SIAu0sisJF+BUKfHk7ngZumcI4HV2gd4AEVHy6iEFGoqAaZv4PoG71Gjr/jY2Vi0rMpAaPCTvbsJETJ/lv5pHQXOT3clp1x930CCVwDBBI66LvpCvj0CVryk/ImjTbJHDTMo3hCbrIETfR0J58IfA7RVFx3IMIPnOzxhO0BxyYF36qEYDaWgMBF8JnXA5eq07IgV6zwKYL4fTWFqr5tUg2him9cKDyJ/MXpXWVkCYQcRSBkCIRyzlQIYQS5+9K0mExIGx7Y4dfRL5TG5xLnmyo3Hx/kT8DOSjI4DGVa1EWjdIlGWQEiGITP+HCJMgTmQx4bWIIQJC4ChHwIEXQDNgU/hSfehsckxJroexEMQBIH2RLtCQumgCD3wfWwB+G03Q3gMiSmAOsT0birZ5AgiE30Jb2whr5PaFwlIIU/XS+jR9SSihwTN69G5lRiOQKy3FVhqq+jICI6HUYhaK/JhZnDzwV2nL5Qt0SfiTPtnE7Su5SJqgh6MzZOoo6awVG1L3gC0WKRAiTg9ySI1c3jPiDjfWY/idS2K4jALHYzq7jFYUWz8e9mbisyqpWFKgjCtKjdKdlPTZEYUFVT6qXDsbczL61a7S90bTZPYIxy6C3gksQ8WDeLaYlEJ71ajcSsQjz3U5RET0DQE7xCIAy1V1qZuHlAvJXELYnEVUci8f4EXpH3zQ6BMIVR+BV4f00fJkTKtTGVH80364xJx5TKyxn+4LGB3njsqBOT1ytbwmVmNcpcdvrist3sU0uctf7dkHg8Q+Q4zSB5iSeoRrzLVYpeZwpFUpUniLXxdh6tY6wZWQRyC5gQyckWeNAPxm4MyVuU9PQV67bw5K+gfkejiRyCVzD0YbhMJ/G+pZ9n8EwRIDCMS8E0GSoF10d9AdwBljTKfEN2Thdum+d0hrRu8WajAGoJ0LolMxu92Q114MTrkGx9L6VhxxKvQ671nUu15IlX2cn4YOFuggO4TpZsMT4dzbaozTiTZMsUcqSifNI12TIFF6ha7ZKtU0WLqvaWwFUagTsIANUzA5Zg20wx+W4NLFb3Y4SUgYElS5zPBFgli6g6VtkmqhMlp9q1IDUIVlmQ9s6MpcHSKBbmi264LaYNXV4BOnVlShMzUaO5MiW+oDC/n8qU2iLPP4mCvbRu+4Ia8TAhh/IutUgTy58v1SJd0CLDHtYz6MqwnuHNmhSDoLltt0I7LzTbtgDCl6LZ0YQuQks0D92t0KwhuhWyaL5TtyIriSib2C91Fo7VMH/pXkNhrU7Qa8DgtDlQnKjXMOZ7DWPT4in02GxoUfcg3I+7Wb3XM9sR82rWDT1WWlbFhOtkNSJNVvas8XvzfQBDHyT6CyrJh40QHzcIk2G1upSqhvnyCr/euVZnVrluD8v1agunKOk/7i8//TXJ68G1oui/pt+dqypf0TckXG7y3adnslNhsh+ti1p7hbfvus3VQVzM+GgtjU9/fa0WZb7cTp8mbH29ATdU3oBL7Lcmq/FXCqinA3kLHg7RWC9keRx/b8gsCa8qgejnvFs3DsBztosxna/A2m29K4b02dPGILVNU2wA+95eYPSqUATWJeZDG9S+1++CIiAetW1u1vZNi2ziK/AK94zvup99/Ht2naNCOS5vWVKSP+CvuN+iO674PEGvWlRZ1NCbU2KPU0LVFKRPKIprLMJvoft8aCdLW4ZVfVmZuSKOx8jDAvnfkFcCdf3MxKm3aMudVyCoigU+iTfUZDzsb1sgI/zmkaBew+TmSHBYZqlVKy8xAWW/j02BghPzDrujldBdgzRjb378YmNyEJt6DmJjV21hD58sIpQllP3F97LCX93O2OsHLEwlilEu3N9vZ2x3iTN1ZBUEpaXA+6sgyNRY6jiGLhRYDseqYsfM0bi2Nyertqj0HjEtbhrnX9dlOy5fgTyjhs/1/JQUE4etHVQjzsI1HOq1ExhWeDpgJNiZraYpmHBVotGsMz9IKNhQGCdsHplX8zge2derkTl9d9x2JjbPbummd20i2fben1moVsnbdGfvYfjDHXtuiu2ApiCwzj77SuVes1WQ8759aWcwCLuzVImDGNiXtvAPZ9E1NdrGmf05A12mNDWW6Fy7pnaNfF7dNe2P6xdtTJXQSF2Rz26wyzi7sk1n/lf6qxMD64atsr9V8UjbrfZE5e7rSVjMnpWE1fU7qF/KQbQXd+f+rDMx7ddL8YtlPj1unY0TPTtPH+Dz+HZ239Ax2wSifw8gG1m7cQxD8tVp1iqhdVcFRfhP/rkYOdvHJF5QGlPvA82mVbK6jILfeBX5bB3W5FcqoUu5qjNDG08esygZLP0svHERSqC3ObL5rN2zsiq0Qn2p8gebXUwu9ATLTPHz5kN+ss4q2qU1i3v+ZO+9jn5iBXDHBOuJm/0MC0wyhKJVc5mj+vx4sCzmk+kjKYoRbuhT83D2Gb+kPiVlTLkaRlh1FkG203aF1RWEFdVs3lo32PfDQmnYkn0IKLPMF92VGJ8efhsm3z53+AEe/eY/</diagram></mxfile>"><defs/><g><path d="M 600.5 305 L 600.5 295 L 655 295 Q 665 295 665 305 L 665 415 L 715.5 415 L 715.5 404.5 L 734.5 420 L 715.5 435.5 L 715.5 425 L 665 425 Q 655 425 655 415 L 655 305 Z" fill="none" stroke="#000000" stroke-linejoin="round" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="all"/><path d="M 715.5 415 L 715.5 404.5 L 734.5 420 L 715.5 435.5 L 715.5 425" fill="none" stroke="#000000" stroke-linejoin="flat" stroke-miterlimit="4" stroke-dasharray="3 3" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 461px; margin-left: 681px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 14px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: nowrap;"><div><b>generate initial</b></div><div><b>boilerplate</b></div></div></div></div></foreignObject><text x="681" y="465" fill="#000000" font-family="Helvetica" font-size="14px" text-anchor="middle">generate initial...</text></switch></g><rect x="30" y="470" width="200" height="120" fill="#ffffff" stroke="#000000" pointer-events="all"/><rect x="30" y="570" width="180" height="20" fill="none" stroke="none" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 178px; height: 1px; padding-top: 580px; margin-left: 31px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 14px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;"><b>Script config file<br /></b></div></div></div></foreignObject><text x="120" y="584" fill="#000000" font-family="Helvetica" font-size="14px" text-anchor="middle">Script config file
</text></switch></g><path d="M 52.5 480 L 187.5 480 L 187.5 548 Q 153.75 526.4 120 548 Q 86.25 569.6 52.5 548 L 52.5 492 Z" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 133px; height: 1px; padding-top: 508px; margin-left: 54px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">ExtensionWebIDL.conf</div></div></div></foreignObject><text x="120" y="512" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">ExtensionWebIDL.conf</text></switch></g><path d="M 240 190 L 580 190 L 600 300 L 580 410 L 240 410 L 260 300 Z" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 358px; height: 1px; padding-top: 300px; margin-left: 241px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 14px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; font-weight: bold; white-space: normal; overflow-wrap: normal;"><div style="font-size: 14px">toolkit/components/extensions/webidl-api/<br style="font-size: 14px" /></div><div style="font-size: 14px">GenerateWebIDLBindings.py</div></div></div></div></foreignObject><text x="420" y="304" fill="#000000" font-family="Helvetica" font-size="14px" text-anchor="middle" font-weight="bold">toolkit/components/extensions/webidl-api/...</text></switch></g><path d="M 415 160.5 L 425 160.5 L 425 170.5 L 435.5 170.5 L 420 189.5 L 404.5 170.5 L 415 170.5 Z" fill="#ffffff" stroke="#000000" stroke-linejoin="round" stroke-miterlimit="10" pointer-events="all"/><path d="M 469 449.5 L 459 449.5 L 459 429.5 L 448.5 429.5 L 464 410.5 L 479.5 429.5 L 469 429.5 Z" fill="#ffffff" stroke="#000000" stroke-linejoin="round" stroke-miterlimit="10" pointer-events="all"/><path d="M 135 469.5 L 125 469.5 L 125 455 Q 125 445 135 445 L 301.99 445 L 301.98 432.59 L 291.48 432.6 L 306.96 413.58 L 322.48 432.56 L 311.98 432.57 L 311.99 444.99 Q 312.01 455 301.99 455 L 135 455 Z" fill="#ffffff" stroke="#000000" stroke-linejoin="round" stroke-miterlimit="10" pointer-events="all"/><path d="M 301.98 432.59 L 291.48 432.6 L 306.96 413.58 L 322.48 432.56 L 311.98 432.57" fill="none" stroke="#000000" stroke-linejoin="flat" stroke-miterlimit="4" pointer-events="all"/><path d="M 220.5 305 L 220.5 295 L 240.5 295 L 240.5 284.5 L 259.5 300 L 240.5 315.5 L 240.5 305 Z" fill="#ffffff" stroke="#000000" stroke-linejoin="round" stroke-miterlimit="10" pointer-events="all"/><path d="M 600.5 305 L 600.5 295 L 655 295 L 655 175 Q 655 165 665 165 L 715.5 165 L 715.5 154.5 L 734.5 170 L 715.5 185.5 L 715.5 175 L 665 175 L 665 295 Q 665 305 655 305 Z" fill="#ffffff" stroke="#000000" stroke-linejoin="round" stroke-miterlimit="10" pointer-events="all"/><path d="M 715.5 165 L 715.5 154.5 L 734.5 170 L 715.5 185.5 L 715.5 175" fill="none" stroke="#000000" stroke-linejoin="flat" stroke-miterlimit="4" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 141px; margin-left: 661px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 14px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: nowrap;"><div><b>generate / update<br /></b></div></div></div></div></foreignObject><text x="661" y="145" fill="#000000" font-family="Helvetica" font-size="14px" text-anchor="middle">generate / update
</text></switch></g><path d="M 735 115 C 735 106.72 773.06 100 820 100 C 842.54 100 864.16 101.58 880.1 104.39 C 896.04 107.21 905 111.02 905 115 L 905 225 C 905 233.28 866.94 240 820 240 C 773.06 240 735 233.28 735 225 Z" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 905 115 C 905 123.28 866.94 130 820 130 C 773.06 130 735 123.28 735 115" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 745 145 L 895 145 L 895 187.5 Q 857.5 174 820 187.5 Q 782.5 201 745 187.5 L 745 152.5 Z" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 148px; height: 1px; padding-top: 163px; margin-left: 746px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: normal; overflow-wrap: normal;">ExtensionMyAPI.webidl</div></div></div></foreignObject><text x="820" y="166" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">ExtensionMyAPI.webidl</text></switch></g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 178px; height: 1px; padding-top: 230px; margin-left: 731px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 14px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: normal; overflow-wrap: normal;">dom/webidl</div></div></div></foreignObject><text x="820" y="234" fill="#000000" font-family="Helvetica" font-size="14px" text-anchor="middle">dom/webidl</text></switch></g><rect x="290" y="0" width="260" height="160" fill="#ffffff" stroke="none" pointer-events="none"/><rect x="290" y="0" width="260" height="160" fill="#ffffff" stroke="#000000" pointer-events="none"/><path d="M 359 30 L 479 30 L 479 72.5 Q 449 59 419 72.5 Q 389 86 359 72.5 L 359 37.5 Z" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 48px; margin-left: 360px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: normal; overflow-wrap: normal;"><div>Toolkit-level schema</div><div>files<br /></div></div></div></div></foreignObject><text x="419" y="51" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">Toolkit-level schema...</text></switch></g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 248px; height: 1px; padding-top: 13px; margin-left: 295px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 14px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: normal; overflow-wrap: normal;"><font style="font-size: 14px"><b>WebExtension JSONSChema files<br /></b></font></div></div></div></foreignObject><text x="419" y="17" fill="#000000" font-family="Helvetica" font-size="14px" text-anchor="middle">WebExtension JSONSChema files
</text></switch></g><path d="M 295 90 L 415 90 L 415 132.5 Q 385 119 355 132.5 Q 325 146 295 132.5 L 295 97.5 Z" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 108px; margin-left: 296px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: normal; overflow-wrap: normal;">Desktop-level schema<div>files<br /></div></div></div></div></foreignObject><text x="355" y="111" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">Desktop-level schema...</text></switch></g><path d="M 425 90 L 545 90 L 545 132.5 Q 515 119 485 132.5 Q 455 146 425 132.5 L 425 97.5 Z" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 108px; margin-left: 426px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: normal; overflow-wrap: normal;"><div>Mobile-level schema</div><div>files<br /></div></div></div></div></foreignObject><text x="485" y="111" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">Mobile-level schema...</text></switch></g><rect x="0" y="240" width="220" height="120" fill="#ffffff" stroke="none" pointer-events="none"/><rect x="0" y="240" width="220" height="120" fill="#ffffff" stroke="#000000" pointer-events="none"/><rect x="17.5" y="270" width="180" height="60" rx="9" ry="9" fill="#ffffff" stroke="#000000" pointer-events="none"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 178px; height: 1px; padding-top: 300px; margin-left: 19px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 14px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: normal; overflow-wrap: normal;"><div>WebExtensions API</div><div> namespace name<br /></div></div></div></div></foreignObject><text x="108" y="304" fill="#000000" font-family="Helvetica" font-size="14px" text-anchor="middle">WebExtensions API...</text></switch></g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 178px; height: 1px; padding-top: 350px; margin-left: 21px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 14px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: normal; overflow-wrap: normal;"><b>Script CLI options<br /></b></div></div></div></foreignObject><text x="110" y="354" fill="#000000" font-family="Helvetica" font-size="14px" text-anchor="middle">Script CLI options
</text></switch></g><rect x="384" y="450" width="160" height="160" fill="#ffffff" stroke="#000000" pointer-events="none"/><path d="M 394.5 460 L 533.5 460 L 533.5 502.5 Q 498.75 489 464 502.5 Q 429.25 516 394.5 502.5 L 394.5 467.5 Z" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 137px; height: 1px; padding-top: 478px; margin-left: 396px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: normal; overflow-wrap: normal;">ExtensionAPI.webidl.in</div></div></div></foreignObject><text x="464" y="481" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">ExtensionAPI.webidl.in</text></switch></g><path d="M 397.75 520 L 530.25 520 L 530.25 562.5 Q 497.13 549 464 562.5 Q 430.88 576 397.75 562.5 L 397.75 527.5 Z" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 131px; height: 1px; padding-top: 538px; margin-left: 399px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: normal; overflow-wrap: normal;">ExtensionAPI.[cpp|h].in</div></div></div></foreignObject><text x="464" y="541" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">ExtensionAPI.[cpp|h].in</text></switch></g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 178px; height: 1px; padding-top: 600px; margin-left: 376px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 14px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: normal; overflow-wrap: normal;"><div><b>Jinja-based templates</b></div></div></div></div></foreignObject><text x="465" y="604" fill="#000000" font-family="Helvetica" font-size="14px" text-anchor="middle">Jinja-based templates</text></switch></g><path d="M 735 355 C 735 346.72 773.06 340 820 340 C 842.54 340 864.16 341.58 880.1 344.39 C 896.04 347.21 905 351.02 905 355 L 905 485 C 905 493.28 866.94 500 820 500 C 773.06 500 735 493.28 735 485 Z" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 905 355 C 905 363.28 866.94 370 820 370 C 773.06 370 735 363.28 735 355" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 745 385.43 L 895 385.43 L 895 434 Q 857.5 418.57 820 434 Q 782.5 449.43 745 434 L 745 394 Z" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 148px; height: 1px; padding-top: 406px; margin-left: 746px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: normal; overflow-wrap: normal;"><div>ExtensionMyAPI.h/cpp</div></div></div></div></foreignObject><text x="820" y="409" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">ExtensionMyAPI.h/cpp</text></switch></g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 178px; height: 1px; padding-top: 474px; margin-left: 731px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 14px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: normal; overflow-wrap: normal;">toolkit/components/extensions/webidl-api</div></div></div></foreignObject><text x="820" y="478" fill="#000000" font-family="Helvetica" font-size="14px" text-anchor="middle">toolkit/components/extensi...</text></switch></g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-start; justify-content: unsafe flex-start; width: 342px; height: 1px; padding-top: 580px; margin-left: 5px;"><div style="box-sizing: border-box; font-size: 0px; text-align: left; max-height: 107px; overflow: hidden;"><div style="display: inline-block; font-size: 14px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: normal; overflow-wrap: normal;"><ul><li>mapping JSONSchema to WebIDL types <br /></li><li>mapping API method to webidl <b>WebExtensionStub</b> extended attribute<br /></li><li>mapping schema group (<i>toolkit, desktop, mobile</i>) to mozilla-central dir paths<br /></li></ul></div></div></div></foreignObject><text x="5" y="594" fill="#000000" font-family="Helvetica" font-size="14px">mapping JSONSchema to WebIDL types...</text></switch></g></g><switch><g requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"/><a transform="translate(0,-5)" xlink:href="https://www.diagrams.net/doc/faq/svg-export-text-problems" target="_blank"><text text-anchor="middle" font-size="10px" x="50%" y="100%">Viewer does not support full SVG 1.1</text></a></switch></svg>
\ No newline at end of file diff --git a/toolkit/components/extensions/docs/incognito.rst b/toolkit/components/extensions/docs/incognito.rst new file mode 100644 index 0000000000..7df71e77c4 --- /dev/null +++ b/toolkit/components/extensions/docs/incognito.rst @@ -0,0 +1,78 @@ +.. _incognito: + +Incognito Implementation +======================== + +This page provides a high level overview of how incognito works in +Firefox, primarily to help in understanding how to test the feature. + +The Implementation +------------------ + +The incognito value in manifest.json supports ``spanning`` and ``not_allowed``. +The other value, ``split``, may be supported in the future. The default +value is ``spanning``, however, by default access to private windows is +not allowed. The user must turn on support, per extension, in ``about:addons``. + +Internally this is handled as a hidden extension permission called +``internal:privateBrowsingAllowed``. This permission is reset when the +extension is disabled or uninstalled. The permission is accessible in +several ways: + +- extension.privateBrowsingAllowed +- context.privateBrowsingAllowed (see BaseContext) +- WebExtensionPolicy.privateBrowsingAllowed +- WebExtensionPolicy.canAccessWindow(DOMWindow) + +Testing +------- + +The goal of testing is to ensure that data from a private browsing session +is not accessible to an extension without permission. + +In Firefox 67, the feature will initially be disabled, however the +intention is to enable the feature on in 67. The pref controlling this +is ``extensions.allowPrivateBrowsingByDefault``. When this pref is +``true``, all extensions have access to private browsing and the manifest +value ``not_allowed`` will produce an error. To enable incognito.not_allowed +for tests you must flip the pref to false. + +Testing EventManager events +--------------------------- + +This is typically most easily handled by running a test with an extension +that has permission, using ``incognitoOverride: spanning`` in the call to +ExtensionTestUtils.loadExtension. You can then use a second extension +without permission to try and catch any events that would typically be passed. + +If the events can happen without calls produced by an extension, you can +also use BrowserTestUtils to open a private window, and use a non-permissioned +extension to run tests against it. + +There are two utility functions in head.js, getIncognitoWindow and +startIncognitoMonitorExtension, which are useful for some basic testing. + +Example: `browser_ext_windows_events.js <https://searchfox.org/mozilla-central/rev/78cd247b5d7a08832f87d786541d3e2204842e8e/browser/components/extensions/test/browser/browser_ext_windows_events.js>`_ + +Testing API Calls +----------------- + +This is easily done using an extension without permission. If you need +an ID of a window or tab, use getIncognitoWindow. In most cases, the +API call should throw an exception when the window is not accessible. +There are some cases where API calls explicitly do not throw. + +Example: `browser_ext_windows_incognito.js <https://searchfox.org/mozilla-central/rev/78cd247b5d7a08832f87d786541d3e2204842e8e/browser/components/extensions/test/browser/browser_ext_windows_incognito.js>`_ + +Privateness of window vs. tab +----------------------------- + +Android does not currently support private windows. When a tab is available, +the test should prefer tab over window. + +- PrivateBrowsingUtils.isBrowserPrivate(tab.linkedBrowser) +- PrivateBrowsingUtils.isContentWindowPrivate(window) + +When WebExtensionPolicy is handy to use, you can directly check window access: + +- policy.canAccessWindow(window) diff --git a/toolkit/components/extensions/docs/index.rst b/toolkit/components/extensions/docs/index.rst new file mode 100644 index 0000000000..63a7c3685c --- /dev/null +++ b/toolkit/components/extensions/docs/index.rst @@ -0,0 +1,33 @@ +WebExtensions API Development +============================= + +This documentation covers the implementation of WebExtensions inside Firefox. +Documentation about existing WebExtension APIs and how to use them +to develop WebExtensions is available +`on MDN <https://developer.mozilla.org/en-US/Add-ons/WebExtensions>`_. + +To use this documentation, you should already be familiar with +WebExtensions, including +`the anatomy of a WebExtension <https://developer.mozilla.org/en-US/Add-ons/WebExtensions/Anatomy_of_a_WebExtension>`_ +and `permissions <https://developer.mozilla.org/en-US/Add-ons/WebExtensions/manifest.json/permissions>`_. +You should also be familiar with concepts from +`Firefox development <https://developer.mozilla.org/en-US/docs/Mozilla/Developer_guide>`_ +including `e10s <https://developer.mozilla.org/en-US/Firefox/Multiprocess_Firefox>`_ +in particular. + +.. toctree:: + :caption: WebExtension API Developers Guide + :maxdepth: 2 + + background + basics + schema + functions + events + manifest + lifecycle + incognito + webidl_bindings + webext-storage + other + reference diff --git a/toolkit/components/extensions/docs/lifecycle.rst b/toolkit/components/extensions/docs/lifecycle.rst new file mode 100644 index 0000000000..8f08b34b68 --- /dev/null +++ b/toolkit/components/extensions/docs/lifecycle.rst @@ -0,0 +1,60 @@ +.. _lifecycle: + +Managing the Extension Lifecycle +================================ +The techniques described in previous pages allow a WebExtension API to +be loaded and instantiated only when an extension that uses the API is +activated. +But there are a few other events in the extension lifecycle that an API +may need to respond to. + +Extension Shutdown +------------------ +APIs that allocate any resources (e.g., adding elements to the browser's +user interface, setting up internal event listeners, etc.) must free +these resources when the extension for which they are allocated is +shut down. An API does this by using the ``callOnClose()`` +method on an `Extension <reference.html#extension-class>`_ object. + +Extension Uninstall and Update +------------------------------ +In addition to resources allocated within an individual browser session, +some APIs make durable changes such as setting preferences or storing +data in the user's profile. +These changes are typically not reverted when an extension is shut down, +but when the extension is completely uninstalled (or stops using the API). +To handle this, extensions can be notified when an extension is uninstalled +or updated. Extension updates are a subtle case -- consider an API that +makes some durable change based on the presence of a manifest property. +If an extension uses the manifest key in one version and then is updated +to a new version that no longer uses the manifest key, +the ``onManifestEntry()`` method for the API is no longer called, +but an API can examine the new manifest after an update to detect that +the key has been removed. + +Handling lifecycle events +------------------------- + +To be notified of update and uninstall events, an extension lists these +events in the API manifest: + +.. code-block:: js + + "myapi": { + "schema": "...", + "url": "...", + "events": ["update", "uninstall"] + } + +If these properties are present, the ``onUpdate()`` and ``onUninstall()`` +methods will be called for the relevant ``ExtensionAPI`` instances when +an extension that uses the API is updated or uninstalled. + +Note that these events can be triggered on extensions that are inactive. +For that reason, these events can only be handled by extension APIs that +are built into the browser. Or, in other words, these events cannot be +handled by APIs that are implemented in WebExtension experiments. If the +implementation of an API relies on these events for correctness, the API +must be built into the browser and not delivered via an experiment. + +.. Should we even document onStartup()? I think no... diff --git a/toolkit/components/extensions/docs/manifest.rst b/toolkit/components/extensions/docs/manifest.rst new file mode 100644 index 0000000000..194dc43a8d --- /dev/null +++ b/toolkit/components/extensions/docs/manifest.rst @@ -0,0 +1,68 @@ +Implementing a manifest property +================================ +Like functions and events, implementing a new manifest key requires +writing a definition in the schema and extending the API's instance +of ``ExtensionAPI``. + +The contents of a WebExtension's ``manifest.json`` are validated using +a type called ``WebExtensionManifest`` defined in the namespace +``manifest``. +The first step when adding a new property is to extend the schema so +that manifests containing the new property pass validation. +This is done with the ``"$extend"`` property as follows: + +.. code-block:: js + + [ + "namespace": "manifest", + "types": [ + { + "$extend": "WebExtensionManifest", + "properties": { + "my_api_property": { + "type": "string", + "optional": true, + ... + } + } + } + ] + ] + +The next step is to inform the WebExtensions framework that this API +should be instantiated and notified when extensions that use the new +manifest key are loaded. +For built-in APIs, this is done with the ``manifest`` property +in the API manifest (e.g., ``ext-toolkit.json``). +Note that this property is an array so an extension can implement +multiple properties: + +.. code-block:: js + + "myapi": { + "schema": "...", + "url": "...", + "manifest": ["my_api_property"] + } + +The final step is to write code to handle the new manifest entry. +The WebExtensions framework processes an extension's manifest when the +extension starts up, this happens for existing extensions when a new +browser session starts up and it can happen in the middle of a session +when an extension is first installed or enabled, or when the extension +is updated. +The JSON fragment above causes the WebExtensions framework to load the +API implementation when it encounters a specific manifest key while +starting an extension, and then call its ``onManifestEntry()`` method +with the name of the property as an argument. +The value of the property is not passed, but the full manifest is +available through ``this.extension.manifest``: + +.. code-block:: js + + this.myapi = class extends ExtensionAPI { + onManifestEntry(name) { + let value = this.extension.manifest.my_api_property; + /* do something with value... */ + } + } diff --git a/toolkit/components/extensions/docs/other.rst b/toolkit/components/extensions/docs/other.rst new file mode 100644 index 0000000000..85a9b6db41 --- /dev/null +++ b/toolkit/components/extensions/docs/other.rst @@ -0,0 +1,140 @@ +Utilities for implementing APIs +=============================== + +This page covers some utility classes that are useful for +implementing WebExtension APIs: + +WindowManager +------------- +This class manages the mapping between the opaque window identifiers used +in the `browser.windows <https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/windows>`__ API. +See the reference docs `here <reference.html#windowmanager-class>`__. + +TabManager +---------- +This class manages the mapping between the opaque tab identifiers used +in the `browser.tabs <https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/tabs>`__ API. +See the reference docs `here <reference.html#tabmanager-class>`__. + +ExtensionSettingsStore +---------------------- +ExtensionSettingsStore (ESS) is used for storing changes to settings that are +requested by extensions, and for finding out what the current value +of a setting should be, based on the precedence chain or a specific selection +made (typically) by the user. + +When multiple extensions request to make a change to a particular +setting, the most recently installed extension will be given +precedence. + +It is also possible to select a specific extension (or no extension, which +infers user-set) to control a setting. This will typically only happen via +ExtensionPreferencesManager described below. When this happens, precedence +control is not used until either a new extension is installed, or the controlling +extension is disabled or uninstalled. If user-set is specifically chosen, +precedence order will only be returned to by installing a new extension that +takes control of the setting. + +ESS will manage what has control over a setting through any +extension state changes (ie. install, uninstall, enable, disable). + +Notifications: +^^^^^^^^^^^^^^ + +"extension-setting-changed": +**************************** + + When a setting changes an event is emitted via the apiManager. It contains + the following: + + * *action*: one of select, remove, enable, disable + + * *id*: the id of the extension for which the setting has changed, may be null + if the setting has returned to default or user set. + + * *type*: The type of setting altered. This is defined by the module using ESS. + If the setting is controlled through the ExtensionPreferencesManager below, + the value will be "prefs". + + * *key*: The name of the setting altered. + + * *item*: The new value, if any that has taken control of the setting. + + +ExtensionPreferencesManager +--------------------------- +ExtensionPreferencesManager (EPM) is used to manage what extensions may control a +setting that results in changing a preference. EPM adds additional logic on top +of ESS to help manage the preference values based on what is in control of a +setting. + +Defining a setting in an API +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +A preference setting is defined in an API module by calling EPM.addSetting. addSetting +allows the API to use callbacks that can handle setting preferences as needed. Since +the setting is defined at runtime, the API module must be loaded as necessary by EPM +to properly manage settings. + +In the api module definition (e.g. ext-toolkit.json), the api must use `"settings": true` +so the management code can discover which API modules to load in order to manage a +setting. See browserSettings[1] as an example. + +Settings that are exposed to the user in about:preferences also require special handling. +We typically show that an extension is in control of the preference, and prevent changes +to the setting. Some settings may allow the user to choose which extension (or none) has +control of the setting. + +Preferences behavior +^^^^^^^^^^^^^^^^^^^^ + +To actually set a setting, the module must call EPM.setSetting. This is typically done +via an extension API, such as browserSettings.settingName.set({ ...value data... }), though +it may be done at other times, such as during extension startup or install in a modules +onManifest handler. + +Preferences are not always changed when an extension uses an API that results in a call +to EPM.setSetting. When setSetting is called, the values are stored by ESS (above), and if +the extension currently has control, or the setting is controllable by the extension, then +the preferences would be updated. + +The preferences would also potentially be updated when installing, enabling, disabling or +uninstalling an extension, or by a user action in about:preferences (or other UI that +allows controlling the preferences). If all extensions that use a preference setting are +disabled or uninstalled, the prior user-set or default values would be returned to. + +An extension may watch for changes using the onChange api (e.g. browserSettings.settingName.onChange). + +[1] https://searchfox.org/mozilla-central/rev/04d8e7629354bab9e6a285183e763410860c5006/toolkit/components/extensions/ext-toolkit.json#19 + +Notifications: +^^^^^^^^^^^^^^ + +"extension-setting-changed:*name*": +*********************************** + + When a setting controlled by EPM changes an event is emitted via the apiManager. It contains + no other data. This is used primarily to implement the onChange API. + +ESS vs. EPM +----------- +An API may use ESS when it needs to allow an extension to store a setting value that +affects how Firefox works, but does not result in setting a preference. An example +is allowing an extension to change the newTab value in the newTab service. + +An API should use EPM when it needs to allow an extension to change a preference. + +Using ESS/EPM with experimental APIs +------------------------------------ + +Properly managing settings values depends on the ability to load any modules that +define a setting. Since experimental APIs are defined inside the extension, there +are situations where settings defined in experimental APIs may not be correctly +managed. The could result in a preference remaining set by the extension after +the extension is disabled or installed, especially when that state is updated during +safe mode. + +Extensions making use of settings in an experimental API should practice caution, +potentially unsetting the values when the extension is shutdown. Values used for +the setting could be stored in the extensions locale storage, and restored into +EPM when the extension is started again. diff --git a/toolkit/components/extensions/docs/reference.rst b/toolkit/components/extensions/docs/reference.rst new file mode 100644 index 0000000000..f88c0b872e --- /dev/null +++ b/toolkit/components/extensions/docs/reference.rst @@ -0,0 +1,35 @@ +WebExtensions Javascript Component Reference +============================================ +This page contains reference documentation for the individual classes +used to implement WebExtensions APIs. This documentation is generated +from jsdoc comments in the source code. + +ExtensionAPI class +------------------ +.. js:autoclass:: ExtensionAPI + :members: + +Extension class +--------------- +.. js:autoclass:: Extension + :members: + +EventManager class +------------------ +.. js:autoclass:: EventManager + :members: + +BaseContext class +----------------- +.. js:autoclass:: BaseContext + :members: + +WindowManager class +------------------- +.. js:autoclass:: WindowManagerBase + :members: + +TabManager class +---------------- +.. js:autoclass:: TabManagerBase + :members: diff --git a/toolkit/components/extensions/docs/schema.rst b/toolkit/components/extensions/docs/schema.rst new file mode 100644 index 0000000000..522328b4ec --- /dev/null +++ b/toolkit/components/extensions/docs/schema.rst @@ -0,0 +1,145 @@ +API Schemas +=========== +Anything that a WebExtension API exposes to extensions via Javascript +is described by the API's schema. The format of API schemas uses some +of the same syntax as `JSON Schema <http://json-schema.org/>`_. +JSON Schema provides a way to specify constraints on JSON documents and +the same method is used by WebExtensions to specify constraints on, +for example, parameters passed to an API function. But the syntax for +describing functions, namespaces, etc. is all ad hoc. This section +describes that syntax. + +An individual API schema consists of structured descriptions of +items in one or more *namespaces* using a structure like this: + +.. code-block:: js + + [ + { + "namespace": "namespace1", + // declarations for namespace 1... + }, + { + "namespace": "namespace2", + // declarations for namespace 2... + }, + // other namespaces... + ] + +Most of the namespaces correspond to objects available to extensions +Javascript code under the ``browser`` global. For example, entries in the +namespace ``example`` are accessible to extension Javascript code as +properties on ``browser.example``. +The namespace ``"manifest"`` is handled specially, it describes the +structure of WebExtension manifests (i.e., ``manifest.json`` files). +Manifest schemas are explained in detail below. + +Declarations within a namespace look like: + +.. code-block:: js + + { + "namespace": "namespace1", + "types": [ + { /* type definition */ }, + ... + ], + "properties": { + "NAME": { /* property definition */ }, + ... + }, + "functions": [ + { /* function definition */ }, + ... + ], + "events": [ + { /* event definition */ }, + ... + ] + } + +The four types of objects that can be defined inside a namespace are: + +- **types**: A type is a reusable schema fragment. A common use of types + is to define in one place an object with a particular set of typed fields + that is used in multiple places in an API. + +- **properties**: A property is a fixed Javascript value available to + extensions via Javascript. Note that the format for defining + properties in a schema is different from the format for types, functions, + and events. The next subsection describes creating properties in detail. + +- **functions** and **events**: + These entries create functions and events respectively, which are + usable from Javascript by extensions. Details on how to implement + them are later in this section. + +Implementing a fixed Javascript property +---------------------------------------- +A static property is made available to extensions via Javascript +entirely from the schema, using a fragment like this one: + +.. code-block:: js + + [ + "namespace": "myapi", + "properties": { + "SOME_PROPERTY": { + "value": 24, + "description": "Description of my property here." + } + } + ] + +If a WebExtension API with this fragment in its schema is loaded for +a particular extension context, that extension will be able to access +``browser.myapi.SOME_PROPERTY`` and read the fixed value 24. +The contents of ``value`` can be any JSON serializable object. + +Schema Items +------------ +Most definitions of individual items in a schema have a common format: + +.. code-block:: js + + { + "type": "SOME TYPE", + /* type-specific parameters... */ + } + +Type-specific parameters will be described in subsequent sections, +but there are some optional properties that can appear in many +different types of items in an API schema: + +- ``description``: This string-valued property serves as documentation + for anybody reading or editing the schema. + +- ``permissions``: This property is an array of strings. + If present, the item in which this property appears is only made + available to extensions that have all the permissions listed in the array. + +- ``unsupported``: This property must be a boolean. + If it is true, the item in which it appears is ignored. + By using this property, a schema can define how a particular API + is intended to work, before it is implemented. + +- ``deprecated``: This property must be a boolean. If it is true, + any uses of the item in which it appears will cause a warning to + be logged to the browser console, to indicate to extension authors + that they are using a feature that is deprecated or otherwise + not fully supported. + + +Describing constrained values +----------------------------- +There are many places where API schemas specify constraints on the type +and possibly contents of some JSON value (e.g., the manifest property +``name`` must be a string) or Javascript value (e.g., the first argument +to ``browser.tabs.get()`` must be a non-negative integer). +These items are defined using `JSON Schema <http://json-schema.org/>`_. +Specifically, these items are specified by using one of the following +values for the ``type`` property: ``boolean``, ``integer``, ``number``, +``string``, ``array``, ``object``, or ``any``. +Refer to the documentation and examples at the +`JSON Schema site <http://json-schema.org/>`_ for details on how these +items are defined in a schema. diff --git a/toolkit/components/extensions/docs/webext-storage.rst b/toolkit/components/extensions/docs/webext-storage.rst new file mode 100644 index 0000000000..9b5f2428d6 --- /dev/null +++ b/toolkit/components/extensions/docs/webext-storage.rst @@ -0,0 +1,227 @@ +======================== +How webext storage works +======================== + +This document describes the implementation of the the `storage.sync` part of the +`WebExtensions Storage APIs +<https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/storage>`_. +The implementation lives in the `toolkit/components/extensions/storage folder <https://searchfox.org/mozilla-central/source/toolkit/components/extensions/storage>`_ + +Ideally you would already know about Rust and XPCOM - `see this doc for more details <../../../../writing-rust-code/index.html>`_ + +At a very high-level, the system looks like: + +.. mermaid:: + + graph LR + A[Extensions API] + A --> B[Storage JS API] + B --> C{magic} + C --> D[app-services component] + +Where "magic" is actually the most interesting part and the primary focus of this document. + + Note: The general mechanism described below is also used for other Rust components from the + app-services team - for example, "dogear" uses a similar mechanism, and the sync engines + too (but with even more complexity) to manage the threads. Unfortunately, at time of writing, + no code is shared and it's not clear how we would, but this might change as more Rust lands. + +The app-services component `lives on github <https://github.com/mozilla/application-services/blob/main/components/webext-storage>`_. +There are docs that describe `how to update/vendor this (and all) external rust code <../../../../build/buildsystem/rust.html>`_ you might be interested in. + +To set the scene, let's look at the parts exposed to WebExtensions first; there are lots of +moving part there too. + +WebExtension API +################ + +The WebExtension API is owned by the addons team. The implementation of this API is quite complex +as it involves multiple processes, but for the sake of this document, we can consider the entry-point +into the WebExtension Storage API as being `parent/ext-storage.js <https://searchfox.org/mozilla-central/source/toolkit/components/extensions/parent/ext-storage.js>`_ + +This entry-point ends up using the implementation in the +`ExtensionStorageSync JS class <https://searchfox.org/mozilla-central/rev/9028b0458cc1f432870d2996b186b0938dda734a/toolkit/components/extensions/ExtensionStorageSync.jsm#84>`_. +This class/module has complexity for things like migration from the earlier Kinto-based backend, +but importantly, code to adapt a callback API into a promise based one. + +Overview of the API +################### + +At a high level, this API is quite simple - there are methods to "get/set/remove" extension +storage data. Note that the "external" API exposed to the addon has subtly changed the parameters +for this "internal" API, so there's an extension ID parameter and the JSON data has already been +converted to a string. +The semantics of the API are beyond this doc but are +`documented on MDN <https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/storage/sync>`_. + +As you will see in those docs, the API is promise-based, but the rust implementation is fully +synchronous and Rust knows nothing about Javascript promises - so this system converts +the callback-based API to a promise-based one. + +xpcom as the interface to Rust +############################## + +xpcom is old Mozilla technology that uses C++ "vtables" to implement "interfaces", which are +described in IDL files. While this traditionally was used to interface +C++ and Javascript, we are leveraging existing support for Rust. The interface we are +exposing is described in `mozIExtensionStorageArea.idl <https://searchfox.org/mozilla-central/source/toolkit/components/extensions/storage/mozIExtensionStorageArea.idl>`_ + +The main interface of interest in this IDL file is `mozIExtensionStorageArea`. +This interface defines the functionality - and is the first layer in the sync to async model. +For example, this interface defines the following method: + +.. code-block:: rust + + interface mozIExtensionStorageArea : nsISupports { + ... + // Sets one or more key-value pairs specified in `json` for the + // `extensionId`... + void set(in AUTF8String extensionId, + in AUTF8String json, + in mozIExtensionStorageCallback callback); + +As you will notice, the 3rd arg is another interface, `mozIExtensionStorageCallback`, also +defined in that IDL file. This is a small, generic interface defined as: + +.. code-block:: cpp + + interface mozIExtensionStorageCallback : nsISupports { + // Called when the operation completes. Operations that return a result, + // like `get`, will pass a `UTF8String` variant. Those that don't return + // anything, like `set` or `remove`, will pass a `null` variant. + void handleSuccess(in nsIVariant result); + + // Called when the operation fails. + void handleError(in nsresult code, in AUTF8String message); + }; + +Note that this delivers all results and errors, so must be capable of handling +every result type, which for some APIs may be problematic - but we are very lucky with this API +that this simple XPCOM callback interface is capable of reasonably representing the return types +from every function in the `mozIExtensionStorageArea` interface. + +(There's another interface, `mozIExtensionStorageListener` which is typically +also implemented by the actual callback to notify the extension about changes, +but that's beyond the scope of this doc.) + +*Note the thread model here is async* - the `set` call will return immediately, and later, on +the main thread, we will call the callback param with the result of the operation. + +So under the hood, what happens is something like: + +.. mermaid:: + + sequenceDiagram + Extension->>ExtensionStorageSync: call `set` and give me a promise + ExtensionStorageSync->>xpcom: call `set`, supplying new data and a callback + ExtensionStorageSync-->>Extension: your promise + xpcom->>xpcom: thread magic in the "bridge" + xpcom-->>ExtensionStorageSync: callback! + ExtensionStorageSync-->>Extension: promise resolved + +So onto the thread magic in the bridge! + +webext_storage_bridge +##################### + +The `webext_storage_bridge <https://searchfox.org/mozilla-central/source/toolkit/components/extensions/storage/webext_storage_bridge>`_ +is a Rust crate which, as implied by the name, is a "bridge" between this Javascript/XPCOM world to +the actual `webext-storage <https://github.com/mozilla/application-services/tree/main/components/webext-storage>`_ crate. + +lib.rs +------ + +Is the entry-point - it defines the xpcom "factory function" - +an `extern "C"` function which is called by xpcom to create the Rust object +implementing `mozIExtensionStorageArea` using existing gecko support. + +area.rs +------- + +This module defines the interface itself. For example, inside that file you will find: + +.. code-block:: rust + + impl StorageSyncArea { + ... + + xpcom_method!( + set => Set( + ext_id: *const ::nsstring::nsACString, + json: *const ::nsstring::nsACString, + callback: *const mozIExtensionStorageCallback + ) + ); + /// Sets one or more key-value pairs. + fn set( + &self, + ext_id: &nsACString, + json: &nsACString, + callback: &mozIExtensionStorageCallback, + ) -> Result<()> { + self.dispatch( + Punt::Set { + ext_id: str::from_utf8(&*ext_id)?.into(), + value: serde_json::from_str(str::from_utf8(&*json)?)?, + }, + callback, + )?; + Ok(()) + } + + +Of interest here: + +* `xpcom_method` is a Rust macro, and part of the existing xpcom integration which already exists + in gecko. It declares the xpcom vtable method described in the IDL. + +* The `set` function is the implementation - it does string conversions and the JSON parsing + on the main thread, then does the work via the supplied callback param, `self.dispatch` and a `Punt`. + +* The `dispatch` method dispatches to another thread, leveraging existing in-tree `moz_task <https://searchfox.org/mozilla-central/source/xpcom/rust/moz_task>`_ support, shifting the `Punt` to another thread and making the callback when done. + +Punt +---- + +`Punt` is a whimsical name somewhat related to a "bridge" - it carries things across and back. + +It is a fairly simple enum in `punt.rs <https://searchfox.org/mozilla-central/source/toolkit/components/extensions/storage/webext_storage_bridge/src/punt.rs>`_. +It's really just a restatement of the API we expose suitable for moving across threads. In short, the `Punt` is created on the main thread, +then sent to the background thread where the actual operation runs via a `PuntTask` and returns a `PuntResult`. + +There's a few dances that go on, but the end result is that `inner_run() <https://searchfox.org/mozilla-central/source/toolkit/components/extensions/storage/webext_storage_bridge/src/punt.rs>`_ +gets executed on the background thread - so for `Set`: + +.. code-block:: rust + + Punt::Set { ext_id, value } => { + PuntResult::with_change(&ext_id, self.store()?.get()?.set(&ext_id, value)?)? + } + +Here, `self.store()` is a wrapper around the actual Rust implementation from app-services with +various initialization and mutex dances involved - see `store.rs`. +ie, this function is calling our Rust implementation and stashing the result in a `PuntResult` + +The `PuntResult` is private to that file but is a simple struct that encapsulates both +the actual result of the function (also a set of changes to send to observers, but that's +beyond this doc). + +Ultimately, the `PuntResult` ends up back on the main thread once the call is complete +and arranges to callback the JS implementation, which in turn resolves the promise created in `ExtensionStorageSync.jsm` + +End result: +----------- + +.. mermaid:: + + sequenceDiagram + Extension->>ExtensionStorageSync: call `set` and give me a promise + ExtensionStorageSync->>xpcom - bridge main thread: call `set`, supplying new data and a callback + ExtensionStorageSync-->>Extension: your promise + xpcom - bridge main thread->>moz_task worker thread: Punt this + moz_task worker thread->>webext-storage: write this data to the database + webext-storage->>webext-storage: done: result/error and observers + webext-storage-->>moz_task worker thread: ... + moz_task worker thread-->>xpcom - bridge main thread: PuntResult + xpcom - bridge main thread-->>ExtensionStorageSync: callback! + ExtensionStorageSync-->>Extension: promise resolved diff --git a/toolkit/components/extensions/docs/webidl_bindings.rst b/toolkit/components/extensions/docs/webidl_bindings.rst new file mode 100644 index 0000000000..be8c63d0a7 --- /dev/null +++ b/toolkit/components/extensions/docs/webidl_bindings.rst @@ -0,0 +1,246 @@ +WebIDL WebExtensions API Bindings +================================= + +While on ``manifest_version: 2`` all the extension globals (extension pages and content scripts) +that lives on the main thread and the WebExtensions API bindings can be injected into the extension +global from the JS privileged code part of the WebExtensions internals (`See Schemas.inject defined in +Schemas.jsm <https://searchfox.org/mozilla-central/search?q=symbol:Schemas%23inject&redirect=false>`_), +in ``manifest_version: 3`` the extension will be able to declare a background service worker +instead of a background page, and the existing WebExtensions API bindings can't be injected into this +new extension global, because it lives off of the main thread. + +To expose WebExtensions API bindings to the WebExtensions ``background.service_worker`` global +we are in the process of generating new WebIDL bindings for the WebExtensions API. + +.. warning:: + + For more general in depth details about WebIDL in Gecko: + + - :doc:`/dom/bindings/webidl/index` + - :doc:`/dom/webIdlBindings/index` + +Review process on changes to webidl definitions +----------------------------------------------- + +.. note:: + + When new webidl definitions are being introduced for a WebExtensions API, or + existing ones need to be updated to stay in sync with changes applied to the + JSONSchema definitions of the same WebExtensions API, the resulting patch + will include a **new or changed WebIDL located at dom/webidl** and that part of the + patch **will require a mandatory review and sign-off from a peer part of the** + webidl_ **phabricator review group**. + +This section includes a brief description about the special setup of the +webidl files related to WebExtensions and other notes useful to the +WebIDL peers that will be reviewing and signing off these webidl files. + +.. _webidl: https://phabricator.services.mozilla.com/tag/webidl/ + +How/Where are these webidl interfaces restricted to the extensions background service workers? +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +All the webidl interfaces related to the extensions API are only visible in +specific extension globals: the WebExtensions background service worker +(a service worker declared in the extension ``manifest.json`` file, through +the ``background.service_worker`` manifest field). + +All webidl interfaces related to the WebExtensions API interfaces are exposed +through the ``ExtensionBrowser`` interface, which gets exposed into the +``ServiceWorkerGlobalScope`` through the ``ExtensionGlobalsMixin`` interface and +restricted to the WebExtensions background service worker through the +``mozilla::extensions::ExtensionAPIAllowed`` helper function. + +See ``ExtensionBrowser`` and ``ExtensionGlobalsMixin`` interfaces defined from +ExtensionBrowser.webidl_ and ``mozilla::extensions::ExtensionAPIAllowed`` defined in +ExtensionBrowser.cpp_. + +.. _ExtensionBrowser.webidl: https://searchfox.org/mozilla-central/source/dom/webidl/ExtensionBrowser.webidl +.. _ExtensionBrowser.cpp: https://searchfox.org/mozilla-central/source/toolkit/components/extensions/webidl-api/ExtensionBrowser.cpp + +Why do all the webidl interfaces for WebExtensions API use LegacyNoInterfaceObject? +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The existing WebExtensions API bindings are not exposing any constructor in the +globals where they are available (e.g. the webidl bindings for the ``browser.alarms`` +API namespace is defined by the ``ExtensionAlarms`` webidl interface, but there +shouldn't be any ``ExtensionAlarms`` constructor available as a global to extension +code running in the background service worker). + +A previous attempt to create W3C specs for the WebExtensions APIs described in WebIDL +syntaxes (https://browserext.github.io/browserext) was also using the same +``NoInterfaceObject`` WebIDL attribute on the definitions of the API namespace +with the same motivations (eg. see ``BrowserExtBrowserRuntime`` as defined here: +https://browserext.github.io/browserext/#webidl-definition-4). + +Bug 1713877_ is tracking a followup to determine a long term replacement for the +``LegacyNoInterfaceObject`` attribute currently being used. + +.. _1713877: https://bugzilla.mozilla.org/1713877 + +Background Service Workers API Request Handling +----------------------------------------------- + +.. figure:: webidl_bindings_backgroundWorker_apiRequestHandling.drawio.svg + :alt: High Level Diagram of the Background Service Worker API Request Handling + +.. + This svg diagram has been created using https://app.diagrams.net, + the svg file also includes the source in the drawio format and so + it can be edited more easily by loading it back into app.diagrams.net + and then re-export from there (and include the updated drawio format + content into the exported svg file). + +Generating WebIDL definitions from WebExtensions API JSONSchema +--------------------------------------------------------------- + +WebIDL definitions for the extension APIs are being generated based on the WebExtensions API JSONSchema +data (the same metadata used to generate the "privilged JS"-based API bindings). + +Most of the API methods in generated WebIDL are meant to be implemented using stub methods shared +between all WebExtensions API classes, a ``WebExtensionStub`` webidl extended attribute specify +which shared stub method should be used when the related API method is called. + +For more in depth details about how to generate or update webidl definition for an Extension API +given its API namespace: + +.. toctree:: + :maxdepth: 2 + + generate_webidl_from_jsonschema + +Wiring up new WebExtensions WebIDL files into mozilla-central +------------------------------------------------------------- + +After a new WebIDL definition has been generated, there are a few more steps to ensure that +the new WebIDL binding is wired up into mozilla-central build system and to be able to +complete successfully a full Gecko build that include the new bindings. + +For more in depth details about these next steps: + +.. toctree:: + :maxdepth: 2 + + wiring_up_new_webidl_bindings + +Testing WebExtensions WebIDL bindings +------------------------------------- + +Once the WebIDL definition for an WebExtensions API namespace has been +implemented and wired up, the following testing strategies are available to +cover them as part of the WebExtensions testing suites: + +``toolkit/components/extensions/test/xpcshell/webidl-api`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The xpcshell tests added to this group of xpcshell tests are meant to provide testing coverage +related to lower level components and behaviors (e.g. when making changes to the shared C++ +helpers defined in ``toolkit/components/extensions/webidl-api``, or adding new ones). + +These tests will often mock part of the internals and use a ``browser.mockExtensionAPI`` +API namespace which is only available in tests and not mapped to any actual API implementation +(instead it is being mocked in the test cases to recreate the scenario that the test case is meant +to cover). + +And so **they are not meant to provide any guarantees in terms of consistency of the behavior +of the two different bindings implementations** (the new WebIDL bindings vs. the current implemented +bindings), instead the other test suites listed in the sections below should be used for that purpose. + +All tests in this directory are skipped in builds where the WebExtensions WebIDL API bindings +are being disabled at build time (e.g. beta and release builds, where otherwise +the test would permafail while riding the train once got on those builds). + + +``toolkit/components/extensions/test/xpcshell/xpcshell-serviceworker.ini`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When a new or existing xpcshell tests added to this xpcshell-test manifest, all test extensions +will be generated with a background service worker instead of a background page. + +.. warning:: + **Unless the background page or scripts are declared as part of the test extension manifest**, + the test file added to this manifest should be explicitly reviewed to make sure all tests + are going to provide the expected test coverage in all modes. + +.. note:: + In a background script that runs in both a background page and a background + service worker it may be necessary to run different code for part of the + test, ``self !== self.window`` is a simple check that can be used to detect if + the script is being executed as a background service worker. + +Test tasks that should be skipped when running in "background service worker mode", but temporarily +until a followup fixes the underlying issue can use the ``ExtensionTestUtils.isInBackgroundServiceWorkerTests()`` in the optional +``add_task``'s ``skip_if`` parameter: + +.. code-block:: js + + add_task( + { + // TODO(Bug TBF): remove this once ... + skip_if: () => ExtensionTestUtils.isInBackgroundServiceWorkerTests(), + }, + async function test_someapi_under_scenario() { + ... + } + ); + +On the contrary if the test tasks is covering a scenario that is specific to a background page, +and it would need to be permanently skipped while the background script is running as a service worker, +it may be more appropriate to split out those tests in a separate test file not included in this +manifest. + +.. warning:: + Make sure that all tests running in multiple modes (in-process, + remote, and "background service worker mode") do not assume that the WebIDL + bindings and Background Service Worker are enabled and to skip them when appropriate, + otherwise the test will become a permafailure once it gets to a channel + where the "Extensions WebIDL API bindings" are disabled by default at build + time (currently on **beta** and **release**). + +While running the test files locally they will be executed once for each manifest file +where they are included, to restrict the run to just the "background service +workers mode" specify the ``sw-webextensions`` tag: + +.. code-block:: bash + + mach xpcshell-test --tag sw-webextensions path/to/test/file.js + +``toolkit/components/extensions/test/mochitest/mochitest-serviceworker.ini`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Same as the xpcshell-serviceworker.ini manifest but for the mochitest-plain tests. + +.. warning:: + The same warnings described in the xpcshell-serviceworker.ini subsection do + also apply to this manifest file. + +Test tasks that should be skipped when not running in "background service worker +mode" can be split into separate test file or skipped inside the ``add_task`` +body, but mochitests' ``add_task`` does not support a ``skip_if`` option and so +that needs to be done manually (and it may be good to also log a message to make +it visible when a test is skipped): + +.. code-block:: js + + add_task(async function test_someapi_in_background_service_worker() { + if (!ExtensionTestUtils.isInBackgroundServiceWorkerTests()) { + is( + ExtensionTestUtils.getBackgroundServiceWorkerEnabled(), + false, + "This test should only be skipped with background service worker disabled" + ) + info("Test intentionally skipped on 'extensions.backgroundServiceWorker.enabled=false'"); + return; + } + + + ... + }); + +While executing the test files locally they will run once for each manifest file +where they are included, to restrict the run to just the "background service +workers mode" specify the ``sw-webextensions`` tag: + +.. code-block:: bash + + mach mochitest --tag sw-webextensions path/to/test/file.js diff --git a/toolkit/components/extensions/docs/webidl_bindings_backgroundWorker_apiRequestHandling.drawio.svg b/toolkit/components/extensions/docs/webidl_bindings_backgroundWorker_apiRequestHandling.drawio.svg new file mode 100644 index 0000000000..ff1fc003ff --- /dev/null +++ b/toolkit/components/extensions/docs/webidl_bindings_backgroundWorker_apiRequestHandling.drawio.svg @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- Do not edit this file with editors other than diagrams.net --> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg xmlns="http://www.w3.org/2000/svg" style="background-color: rgb(230, 230, 230);" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="1051px" height="702px" viewBox="-0.5 -0.5 1051 702" content="<mxfile host="app.diagrams.net" modified="2021-09-23T13:14:59.464Z" agent="5.0 (X11)" etag="SdliZ2VSr7bXlonuACqN" version="15.3.0" type="device"><diagram id="FUdiw6MIiBahmvHCE1FN" name="WebIDL Bindings Background Service Worker API Request handling">7VtZc9o6FP41zPQ+tIM3lkcgtKWThQmZSXJfOgIroMS2qCwTyK+/ki3vAispi7ntZAjWsTafc75zPsmiYQzc9TcClosrbEOnoTftdcO4aOi61rTa7ItLNpGk3W1FgjlBtqiUCiboDcYthTRANvRzFSnGDkXLvHCGPQ/OaE4GCMGv+WpP2MmPugRzWBJMZsApS++RTRdCqjWb6Y3vEM0XYuiOJW5MwexlTnDgifEaujFs8b/otgvivkR9fwFs/JoRGcOGMSAY0+jKXQ+gw3Ubqy1q93XL3WTeBHpUpcFmcGeOvId/f01+Pnr48Uf7Z+/ps25G3ayAE8D4OVoO67BvoxVXsYPmXnij9SvgU+3P2HiQpGV2Neffo/EgbsomEbaObojHp5tY5WxezLqs0Gc6WXLhzMEBm2b/dYEonCzBjAtfmcMx2YK6Ditp7DKejJhC3HVWB0ItK0goXGdEQiffIHYhJRtWRdztGMI+wn+1lii/ZrwhtuEi6whCBoQDzpOuUyOwC2GHd9ikW1aYzVxWFDGhCzzHHnCGqbQf+iHkvTZZKa1zifFSKO8ZUroR+AMBxXnVbtWkjwMygzumG4MWkDmkO+oZUT3+LDvtQqADKFrl4bl3Jcd+ssXvI88drin0fIQ9VrM3HrH/HnChH/rnFlcv9fHpHk5HF5es7Wf2YZNllfr/xBWnpNi0Ajt5Q1fgZQ/waOXRYViK6GgdCh2acV7wMBThoWm1woehgg8Xv40SjIQAUQTFLfwVQJ9+B57thLlEEQxldD2MBzdXAlw/JueDK1OvG7DKTKBoXmG1ku78V+Q6wIP70UzCDGPNdMua0ZsSzSRCmWrEcLeMQgJvzma+dbyEsGUt0ZGNZ+SHAw4jJR6gsM99yS8ZJHnUj9uorYLKWBQ4RYmDYglYomtJKksrbG2yE2EV7W+mz0z/d5ul6phVXNSBT7TMRJORRvbuQFIxOlsksOg0AStofyWZR69o5kOyQjN4j8kLJCPvCasqiMx9xaoeJi7Txhu0e2Gj5qdb1uJ+d/grd8iEZSepCJQMxVTOykNjGH2OdMRU1xNiF9l2lIShj97ANOyIB9klRh4NsWP1G9YF74nlXT9KwbxjnxL8AgfYwYzrX3h4S4TZGc2Uw45m5aKAKQkCbUkQ0A4WjzsqWM/lvyK5/LAlk/XVmdrSLGQQiS3lCeRQnLW51Za7gjRhgyO44rkqCiYD7HGjDRbIsd8J9VTCJoFslqX4g3o2X9PEwSTcUZkHLuSmVAyRCzh74TaExEU+5wn+78WgIrWINwhYKMYBraZmbCKIGY17b9xonIqKsWoPdCWhJ++lD9rh3K11iiUSUyHZPPD2X6y4+Ci6CwsX61xpI0ofX1ppqlsPcWyvydpKU9p8iPcNFFdCYVBgJP0KeGCerqmqV1CQM3xOV5uFLs5nMdVSWUy1jrmYMv4UCJqKEDRqBsGKfe+PQ/ASc740Ho3cpfM7GxsFWGa7PSNgFtbWVkcxOR5u+7D9hwDTUs2NZr2AeZK3H3W2T6dW9kke8ET2yVknNdbp7BNTj5rYJ553LrEVs8ACu9PAr84A+18ttTr5hGBK3raaknxgHIyonTgfHM2fO2dK1JS2wT5C1NLN6L9cLYRdEZpd68RcTT/Jq95tXEA7GDbjFFKda/RaYVMvb2uOCV5vhOtDvpHIpsGPT5zYtzuFDQLTan+ReLc081iHyjyn8e41opFzty1RTIguu059mxc2WUfPtjoCJnRVTLTqhQldhokZ9P0B9uwAUV/s3tcMEdIts+PiwTwtHvQsHjRVPOhHWi/Gbn52OaJVhYdxpI26AULTTg4I2VKuRD4Z9/zMEu5172o4GfcGwy/PvjJxzZ/davrBdOYAf/cbvDpR1uL2Ylv13dvh9v27EpsVo5pn9/jB9UbyHtwG/iJUm6aookpwZxRgSZ4/linHADHCmL/mT/WfnLgS+u9oBb1GYU20SlVb2ZFe7CgKe6WO9nWuyiwz2f+x2bRmp0LdqnYzT2y2ihepkhNjz4FP0dOmfGgsCXqxYNy7HV7fZSLf9B0bA7838u3NYDiZvGfogq+Wj/ZIz988IccpiNSPAMnCez4B7CHCdwunK2S/zuhKkGIeKsCbZX4vSawlkw4f7obXk9HN9busui+HGnwfXbKGzb+OlTpWMQTKDolpR/UspSP3JdtehMf/ovNhH/StLT2zbu8WBAK75v6yd99oF1xDxiqNo7qG0mmFkgGvAPL+TJc4SggpkVaJn+wpN7Fi+kPRiD2lv8Y1hv8B</diagram></mxfile>"><defs/><g><path d="M 820 70 C 796 70 790 90 809.2 94 C 790 102.8 811.6 122 827.2 114 C 838 130 874 130 886 114 C 910 114 910 98 895 90 C 910 74 886 58 865 66 C 850 54 826 54 820 70 Z" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 90px; margin-left: 791px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;"><div align="center">IPC</div></div></div></div></foreignObject><text x="850" y="94" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">IPC</text></switch></g><path d="M 140 270 L 373.63 270" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 378.88 270 L 371.88 273.5 L 373.63 270 L 371.88 266.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><rect x="20" y="240" width="120" height="60" fill="#ffffff" stroke="#000000" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 270px; margin-left: 21px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;"><div>Extension API namespace</div><div>(WebIDL - C++)<br /></div></div></div></div></foreignObject><text x="80" y="274" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">Extension API namesp...</text></switch></g><path d="M 500 270 L 573.63 270" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 578.88 270 L 571.88 273.5 L 573.63 270 L 571.88 266.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><rect x="380" y="240" width="120" height="60" fill="#ffffff" stroke="#000000" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 270px; margin-left: 381px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;"><div>mozIExtensionAPI</div><div>RequestHandler<br /></div><div>(XPCOM - JS)<br /></div></div></div></div></foreignObject><text x="440" y="274" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">mozIExtensionAPI...</text></switch></g><path d="M 170 303 L 170 280 L 370 280 L 370 303" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><path d="M 170 303 L 170 480 L 370 480 L 370 303" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 170 303 L 370 303" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g fill="#000000" font-family="Helvetica" font-weight="bold" pointer-events="none" text-anchor="middle" font-size="12px"><text x="269.5" y="296">mozIExtensionAPIRequest</text></g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe flex-start; width: 1px; height: 1px; padding-top: 400px; margin-left: 187px;"><div style="box-sizing: border-box; font-size: 0px; text-align: left;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: nowrap;"><div><ul><li>apiNamespace</li><li>apiName<br /></li><li>apiObjectType</li><li><div align="left">apiObjectId</div></li><li>callerSavedFrame</li><li>serviceWorkerInfo</li><li>args</li><li>normalizedArgs (R/W)<br /></li></ul></div></div></div></div></foreignObject><text x="187" y="404" fill="#000000" font-family="Helvetica" font-size="12px">apiNamespaceapiName...</text></switch></g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 320px; margin-left: 260px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: nowrap;"><div>(XPCOM - C++)</div></div></div></div></foreignObject><text x="260" y="324" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">(XPCOM - C++)</text></switch></g><path d="M 350 110 L 530 110 L 530 200 L 460 200 L 440 230 L 440 200 L 350 200 Z" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe flex-start; width: 178px; height: 1px; padding-top: 155px; margin-left: 352px;"><div style="box-sizing: border-box; font-size: 0px; text-align: left;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: normal; overflow-wrap: normal;"><ul><li>retrieve WorkerContextChild<br /></li><li>validate and normalize arguments</li><li>check permissions<br /></li></ul></div></div></div></foreignObject><text x="352" y="159" fill="#000000" font-family="Helvetica" font-size="12px">retrieve WorkerContextChild...</text></switch></g><path d="M 660 300 L 660 343.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 660 348.88 L 656.5 341.88 L 660 343.63 L 663.5 341.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><rect x="580" y="240" width="160" height="60" fill="#ffffff" stroke="#000000" pointer-events="none"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 158px; height: 1px; padding-top: 270px; margin-left: 581px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: normal; overflow-wrap: normal;"><div>WebIDL</div><div>ChildAPIManager</div><div>(extends ChildAPIManager)<br /></div></div></div></div></foreignObject><text x="660" y="274" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">WebIDL...</text></switch></g><path d="M 660 530 L 660 603.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 660 608.88 L 656.5 601.88 L 660 603.63 L 663.5 601.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><rect x="570" y="470" width="180" height="60" fill="#ffffff" stroke="#000000" pointer-events="none"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 178px; height: 1px; padding-top: 500px; margin-left: 571px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: normal; overflow-wrap: normal;"><div>WebIDL</div><div>ChildLocalAPIImpl<br /></div><div>(extends ChildLocalAPIImpl)<br /></div></div></div></div></foreignObject><text x="660" y="504" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">WebIDL...</text></switch></g><path d="M 660 380 L 660 463.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 660 468.88 L 656.5 461.88 L 660 463.63 L 663.5 461.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 640 365 L 530 365 L 530 378.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 530 383.88 L 526.5 376.88 L 530 378.63 L 533.5 376.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 680 365 L 773.63 365" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 778.88 365 L 771.88 368.5 L 773.63 365 L 771.88 361.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 660 350 L 680 365 L 660 380 L 640 365 Z" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 530 445 L 530 640 L 563.63 640" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 568.88 640 L 561.88 643.5 L 563.63 640 L 561.88 636.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><rect x="440" y="385" width="180" height="60" fill="#ffffff" stroke="#000000" pointer-events="none"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 178px; height: 1px; padding-top: 415px; margin-left: 441px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: normal; overflow-wrap: normal;"><div>WebIDL</div><div>ChildObjectTypeIImpl<br /></div><div>(extends ChildLocalAPIImpl)<br /></div></div></div></div></foreignObject><text x="530" y="419" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">WebIDL...</text></switch></g><path d="M 850 347.5 L 850 281.37" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 850 276.12 L 853.5 283.12 L 850 281.37 L 846.5 283.12 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><rect x="780" y="347.5" width="140" height="35" fill="#ffffff" stroke="#000000" pointer-events="none"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 138px; height: 1px; padding-top: 365px; margin-left: 781px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: normal; overflow-wrap: normal;">ProxyAPIImplementation</div></div></div></foreignObject><text x="850" y="369" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">ProxyAPIImplementation</text></switch></g><path d="M 885 240 L 885 41.37" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 885 36.12 L 888.5 43.12 L 885 41.37 L 881.5 43.12 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><rect x="780" y="240" width="140" height="35" fill="#ffffff" stroke="#000000" pointer-events="none"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 138px; height: 1px; padding-top: 258px; margin-left: 781px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: normal; overflow-wrap: normal;">ProcessConduitsChild</div></div></div></foreignObject><text x="850" y="261" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">ProcessConduitsChild</text></switch></g><path d="M 815 35 L 815 233.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 815 238.88 L 811.5 231.88 L 815 233.63 L 818.5 231.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><rect x="780" y="0" width="140" height="35" fill="#ffffff" stroke="#000000" pointer-events="none"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 138px; height: 1px; padding-top: 18px; margin-left: 781px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: normal; overflow-wrap: normal;">ProcessConduitsParent</div></div></div></foreignObject><text x="850" y="21" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">ProcessConduitsParent</text></switch></g><rect x="570" y="610" width="180" height="60" fill="#ffffff" stroke="#000000" pointer-events="none"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 178px; height: 1px; padding-top: 640px; margin-left: 571px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: normal; overflow-wrap: normal;"><div>ext-APINAMESPACE.js</div><div>ExtensionAPI subclass<br /></div></div></div></div></foreignObject><text x="660" y="644" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">ext-APINAMESPACE.js...</text></switch></g><path d="M 160 700 L 160 100" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="none"/><path d="M 1040 100 L 0 100" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="none"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 88px; height: 1px; padding-top: 70px; margin-left: 951px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: normal; overflow-wrap: normal;"><div align="justify"><b>PARENT</b></div><div align="justify"><b>PROCESS</b></div></div></div></div></foreignObject><text x="995" y="74" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">PARENT...</text></switch></g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 108px; height: 1px; padding-top: 140px; margin-left: 941px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: normal; overflow-wrap: normal;"><div><b>EXTENSIONS</b></div><div align="justify"><b>CHILD PROCESS</b></div></div></div></div></foreignObject><text x="995" y="144" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">EXTENSIONS...</text></switch></g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 130px; margin-left: 95px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: nowrap;"><div><b>DOM Worker</b></div><div><b> Thread</b></div></div></div></div></foreignObject><text x="95" y="134" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">DOM Worker...</text></switch></g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 88px; height: 1px; padding-top: 130px; margin-left: 161px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: none; white-space: normal; overflow-wrap: normal;"><div><b>Main</b></div><div><b> Thread</b></div></div></div></div></foreignObject><text x="205" y="134" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">Main...</text></switch></g></g><switch><g requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"/><a transform="translate(0,-5)" xlink:href="https://www.diagrams.net/doc/faq/svg-export-text-problems" target="_blank"><text text-anchor="middle" font-size="10px" x="50%" y="100%">Viewer does not support full SVG 1.1</text></a></switch></svg>
\ No newline at end of file diff --git a/toolkit/components/extensions/docs/wiring_up_new_webidl_bindings.rst b/toolkit/components/extensions/docs/wiring_up_new_webidl_bindings.rst new file mode 100644 index 0000000000..01c2498d6d --- /dev/null +++ b/toolkit/components/extensions/docs/wiring_up_new_webidl_bindings.rst @@ -0,0 +1,165 @@ +Wiring up new WebExtensions WebIDL files into mozilla-central +============================================================= + +Add a new entry in ``dom/bindings/Bindings.conf`` +------------------------------------------------- + +New WebIDL bindings should be added as new entries in ``dom/bindings/Bindings.conf``. The new entry should be +added in alphabetic order and nearby the other WebExtensions API bindings already listed in this config file +(look for the ``ExtensionBrowser`` webidl definition and the other existing WebIDL bindings related to the +WebExtensions APIs): + +.. code-block:: text + + # WebExtension API + ... + 'ExtensionRuntime': { + 'headerFile': 'mozilla/extensions/ExtensionRuntime.h', + 'nativeType': 'mozilla::extensions::ExtensionRuntime', + }, + +.. warning:: + + `mach build` will fail if the entries in `dom/bindings/Bindings.conf` are not in alphabetic order, + or if the `headerFile` referenced does not exist yet. + +Add a new entry in ``dom/webidl/moz.build`` +------------------------------------------- + +The new ``.webidl`` file has to be also listed in "dom/webidl/moz.build", it should be added in + +- the existing group of ``WEBIDL_FILES`` entries meant specifically for the WebExtensions API bindings +- or in the group of ``PREPROCESSED_WEBIDL_FILES`` entries meant specifically for the WebExtensions + API bindings, **if the generated `.webidl` includes preprocessing macros** (e.g. when part of an API + is not available in all builds, e.g. subset of APIs that are only available in Desktop builds). + +.. code-block:: text + + # WebExtensions API. + WEBIDL_FILES += [ + ... + "ExtensionRuntime.webidl", + ... + ] + + PREPROCESSED_WEBIDL_FILES += [ + ... + ] + +.. warning:: + + The group of PREPROCESSED_WERBIDL_FILES meant to list WebExtensions APIs ``.webidl`` files + may not exist yet (one will be added right after the existing `WEBIDL_FILES` when the first + preprocessed `.webidl` will be added). + + +Add new entries in ``toolkit/components/extensions/webidl-api/moz.build`` +------------------------------------------------------------------------- + +The new C++ files for the WebExtensions API binding needs to be added to ``toolkit/components/extensions/webidl-api/moz.build`` +to make them part of the build, The new ``.cpp`` file has to be added into the ``UNIFIED_SOURCES`` group +where the other WebIDL bindings are being listed. Similarly, the new ``.h`` counterpart has to be added to +``EXPORTS.mozilla.extensions`` (which ensures that the header file will be placed into the path set earlier +in ``dom/bindings/Bindings.conf``): + +.. code-block:: text + + # WebExtensions API namespaces. + UNIFIED_SOURCES += [ + ... + "ExtensionRuntime.cpp", + ... + ] + + EXPORTS.mozilla.extensions += [ + ... + "ExtensionRuntime.h", + ... + ] + +Wiring up the new API into ``dom/webidl/ExtensionBrowser.webidl`` +----------------------------------------------------------------- + +To make the new WebIDL bindings part of the ``browser`` global, a new attribute has to be added to +``dom/webidl/ExtensionBrowser.webidl``: + +.. code-block:: cpp + + // `browser.runtime` API namespace. + [Replaceable, SameObject, BinaryName="GetExtensionRuntime", + Func="mozilla::extensions::ExtensionRuntime::IsAllowed"] + readonly attribute ExtensionRuntime runtime; + +.. note:: + ``chrome`` is defined as an alias of the ``browser`` global, and so by adding the new attribute + into ``ExtensionBrowser` the same attribute will also be available in the ``chrome`` global. + Unlike the "Privileged JS"-based WebExtensions API, the ``chrome`` and ``browser`` APIs are + exactly the same and a the async methods return a Promise if no callback has been passed + (similarly to Safari versions where the WebExtensions APIs are supported). + +The additional attribute added into ``ExtensionBrowser.webidl`` will require some addition to the ``ExtensionBrowser`` +C++ class as defined in ``toolkit/components/extensions/webidl-api/ExtensionBrowser.h``: + +- the definition of a new corresponding **public method** (by convention named ``GetExtensionMyNamespace``) +- a ``RefPtr`` as a new **private data member named** (by convention named ``mExtensionMyNamespace``) + +.. code-block:: cpp + + ... + namespace extensions { + + ... + class ExtensionRuntime; + ... + + class ExtensionBrowser final : ... { + ... + RefPtr<ExtensionRuntime> mExtensionRuntime; + ... + + public: + ... + ExtensionRuntime* GetExtensionRuntime(); + } + ... + + +And then in its ``toolkit/components/extensions/webidl-api/ExtensionBrowser.cpp`` counterpart: + +- the implementation of the new public method +- the addition of the new private member data ``RefPtr`` in the ``NS_IMPL_CYCLE_COLLECTION_UNLINK`` + and ``NS_IMPL_CYCLE_COLLECTION_TRAVERSE`` macros + +.. code-block:: cpp + + ... + #include "mozilla/extensions/ExtensionRuntime.h" + ... + NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(ExtensionBrowser) + ... + NS_IMPL_CYCLE_COLLECTION_UNLINK(mExtensionRuntime) + ... + NS_IMPL_CYCLE_COLLECTION_UNLINK_END + + NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(ExtensionBrowser) + ... + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mExtensionRuntime) + ... + NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + ... + + ExtensionRuntime* ExtensionBrowser::GetExtensionRuntime() { + if (!mExtensionRuntime) { + mExtensionRuntime = new ExtensionRuntime(mGlobal, this); + } + + return mExtensionRuntime + } + +.. warning:: + + Forgetting to add the new ``RefPtr`` into the cycle collection traverse and unlink macros + will not result in a build error, but it will result into a leak. + + Make sure to don't forget to double-check these macros, especially if some tests are failing + because of detected shutdown leaks. diff --git a/toolkit/components/extensions/dummy.xhtml b/toolkit/components/extensions/dummy.xhtml new file mode 100644 index 0000000000..8b6ada8a2a --- /dev/null +++ b/toolkit/components/extensions/dummy.xhtml @@ -0,0 +1,6 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<window id="documentElement" /> diff --git a/toolkit/components/extensions/ext-browser-content.js b/toolkit/components/extensions/ext-browser-content.js new file mode 100644 index 0000000000..c431b6ccfd --- /dev/null +++ b/toolkit/components/extensions/ext-browser-content.js @@ -0,0 +1,275 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-env mozilla/frame-script */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +// Minimum time between two resizes. +const RESIZE_TIMEOUT = 100; + +const BrowserListener = { + init({ + allowScriptsToClose, + blockParser, + fixedWidth, + maxHeight, + maxWidth, + stylesheets, + isInline, + }) { + this.fixedWidth = fixedWidth; + this.stylesheets = stylesheets || []; + + this.isInline = isInline; + this.maxWidth = maxWidth; + this.maxHeight = maxHeight; + + this.blockParser = blockParser; + this.needsResize = fixedWidth || maxHeight || maxWidth; + + this.oldBackground = null; + + if (allowScriptsToClose) { + content.windowUtils.allowScriptsToClose(); + } + + if (this.blockParser) { + this.blockingPromise = new Promise(resolve => { + this.unblockParser = resolve; + }); + addEventListener("DOMDocElementInserted", this, true); + } + + addEventListener("load", this, true); + addEventListener("DOMWindowCreated", this, true); + addEventListener("DOMContentLoaded", this, true); + addEventListener("MozScrolledAreaChanged", this, true); + }, + + destroy() { + if (this.blockParser) { + removeEventListener("DOMDocElementInserted", this, true); + } + + removeEventListener("load", this, true); + removeEventListener("DOMWindowCreated", this, true); + removeEventListener("DOMContentLoaded", this, true); + removeEventListener("MozScrolledAreaChanged", this, true); + }, + + receiveMessage({ name, data }) { + if (name === "Extension:InitBrowser") { + this.init(data); + } else if (name === "Extension:UnblockParser") { + if (this.unblockParser) { + this.unblockParser(); + this.blockingPromise = null; + } + } else if (name === "Extension:GrabFocus") { + content.window.requestAnimationFrame(() => { + Services.focus.focusedWindow = content.window; + }); + } + }, + + loadStylesheets() { + let { windowUtils } = content; + + for (let url of this.stylesheets) { + windowUtils.addSheet( + ExtensionCommon.stylesheetMap.get(url), + windowUtils.AUTHOR_SHEET + ); + } + }, + + handleEvent(event) { + switch (event.type) { + case "DOMDocElementInserted": + if (this.blockingPromise) { + const doc = event.target; + const policy = doc?.nodePrincipal?.addonPolicy; + event.target.blockParsing(this.blockingPromise).then(() => { + policy?.weakExtension?.get()?.untrackBlockedParsingDocument(doc); + }); + policy?.weakExtension?.get()?.trackBlockedParsingDocument(doc); + } + break; + + case "DOMWindowCreated": + if (event.target === content.document) { + this.loadStylesheets(); + } + break; + + case "DOMContentLoaded": + if (event.target === content.document) { + sendAsyncMessage("Extension:BrowserContentLoaded", { + url: content.location.href, + }); + + if (this.needsResize) { + this.handleDOMChange(true); + } + } + break; + + case "load": + if (event.target.contentWindow === content) { + // For about:addons inline <browser>s, we currently receive a load + // event on the <browser> element, but no load or DOMContentLoaded + // events from the content window. + + // Inline browsers don't receive the "DOMWindowCreated" event, so this + // is a workaround to load the stylesheets. + if (this.isInline) { + this.loadStylesheets(); + } + sendAsyncMessage("Extension:BrowserContentLoaded", { + url: content.location.href, + }); + } else if (event.target !== content.document) { + break; + } + + if (!this.needsResize) { + break; + } + + // We use a capturing listener, so we get this event earlier than any + // load listeners in the content page. Resizing after a timeout ensures + // that we calculate the size after the entire event cycle has completed + // (unless someone spins the event loop, anyway), and hopefully after + // the content has made any modifications. + Promise.resolve().then(() => { + this.handleDOMChange(true); + }); + + // Mutation observer to make sure the panel shrinks when the content does. + new content.MutationObserver(this.handleDOMChange.bind(this)).observe( + content.document.documentElement, + { + attributes: true, + characterData: true, + childList: true, + subtree: true, + } + ); + break; + + case "MozScrolledAreaChanged": + if (this.needsResize) { + this.handleDOMChange(); + } + break; + } + }, + + // Resizes the browser to match the preferred size of the content (debounced). + handleDOMChange(ignoreThrottling = false) { + if (ignoreThrottling && this.resizeTimeout) { + clearTimeout(this.resizeTimeout); + this.resizeTimeout = null; + } + + if (this.resizeTimeout == null) { + this.resizeTimeout = setTimeout(() => { + try { + if (content) { + this._handleDOMChange("delayed"); + } + } finally { + this.resizeTimeout = null; + } + }, RESIZE_TIMEOUT); + + this._handleDOMChange(); + } + }, + + _handleDOMChange(detail) { + let doc = content.document; + + let body = doc.body; + if (!body || doc.compatMode === "BackCompat") { + // In quirks mode, the root element is used as the scroll frame, and the + // body lies about its scroll geometry, and returns the values for the + // root instead. + body = doc.documentElement; + } + + let result; + const zoom = content.browsingContext.fullZoom; + if (this.fixedWidth) { + // If we're in a fixed-width area (namely a slide-in subview of the main + // menu panel), we need to calculate the view height based on the + // preferred height of the content document's root scrollable element at the + // current width, rather than the complete preferred dimensions of the + // content window. + + // Compensate for any offsets (margin, padding, ...) between the scroll + // area of the body and the outer height of the document. + // This calculation is hard to get right for all cases, so take the lower + // number of the combination of all padding and margins of the document + // and body elements, or the difference between their heights. + let getHeight = elem => elem.getBoundingClientRect(elem).height; + let bodyPadding = getHeight(doc.documentElement) - getHeight(body); + + if (body !== doc.documentElement) { + let bs = content.getComputedStyle(body); + let ds = content.getComputedStyle(doc.documentElement); + + let p = + parseFloat(bs.marginTop) + + parseFloat(bs.marginBottom) + + parseFloat(ds.marginTop) + + parseFloat(ds.marginBottom) + + parseFloat(ds.paddingTop) + + parseFloat(ds.paddingBottom); + bodyPadding = Math.min(p, bodyPadding); + } + + let height = Math.ceil((body.scrollHeight + bodyPadding) * zoom); + + result = { height, detail }; + } else { + let background = content.windowUtils.canvasBackgroundColor; + if (background !== this.oldBackground) { + sendAsyncMessage("Extension:BrowserBackgroundChanged", { background }); + } + this.oldBackground = background; + + // Adjust the size of the browser based on its content's preferred size. + let w = {}, + h = {}; + docShell.docViewer.getContentSize( + this.maxWidth, + this.maxHeight, + /* prefWidth = */ 0, + w, + h + ); + + let width = Math.ceil(w.value * zoom); + let height = Math.ceil(h.value * zoom); + result = { width, height, detail }; + } + + sendAsyncMessage("Extension:BrowserResized", result); + }, +}; + +addMessageListener("Extension:InitBrowser", BrowserListener); +addMessageListener("Extension:UnblockParser", BrowserListener); +addMessageListener("Extension:GrabFocus", BrowserListener); + +// This is a temporary hack to prevent regressions (bug 1471327). +void content; diff --git a/toolkit/components/extensions/ext-toolkit.json b/toolkit/components/extensions/ext-toolkit.json new file mode 100644 index 0000000000..ebfb5c5933 --- /dev/null +++ b/toolkit/components/extensions/ext-toolkit.json @@ -0,0 +1,198 @@ +{ + "manifest": { + "schema": "chrome://extensions/content/schemas/extension_types.json", + "scopes": [] + }, + "alarms": { + "url": "chrome://extensions/content/parent/ext-alarms.js", + "schema": "chrome://extensions/content/schemas/alarms.json", + "scopes": ["addon_parent"], + "paths": [["alarms"]] + }, + "backgroundPage": { + "url": "chrome://extensions/content/parent/ext-backgroundPage.js", + "scopes": ["addon_parent"], + "manifest": ["background"] + }, + "browserSettings": { + "url": "chrome://extensions/content/parent/ext-browserSettings.js", + "schema": "chrome://extensions/content/schemas/browser_settings.json", + "scopes": ["addon_parent"], + "settings": true, + "paths": [["browserSettings"]] + }, + "clipboard": { + "url": "chrome://extensions/content/parent/ext-clipboard.js", + "schema": "chrome://extensions/content/schemas/clipboard.json", + "scopes": ["addon_parent"], + "paths": [["clipboard"]] + }, + "contentScripts": { + "url": "chrome://extensions/content/parent/ext-contentScripts.js", + "schema": "chrome://extensions/content/schemas/content_scripts.json", + "scopes": ["addon_parent"], + "paths": [["contentScripts"]] + }, + "contextualIdentities": { + "url": "chrome://extensions/content/parent/ext-contextualIdentities.js", + "schema": "chrome://extensions/content/schemas/contextual_identities.json", + "scopes": ["addon_parent"], + "settings": true, + "events": ["startup"], + "permissions": ["contextualIdentities"], + "paths": [["contextualIdentities"]] + }, + "cookies": { + "url": "chrome://extensions/content/parent/ext-cookies.js", + "schema": "chrome://extensions/content/schemas/cookies.json", + "scopes": ["addon_parent"], + "paths": [["cookies"]] + }, + "declarativeNetRequest": { + "url": "chrome://extensions/content/parent/ext-declarativeNetRequest.js", + "schema": "chrome://extensions/content/schemas/declarative_net_request.json", + "scopes": ["addon_parent"], + "manifest": ["declarative_net_request"], + "paths": [["declarativeNetRequest"]] + }, + "dns": { + "url": "chrome://extensions/content/parent/ext-dns.js", + "schema": "chrome://extensions/content/schemas/dns.json", + "scopes": ["addon_parent"], + "paths": [["dns"]] + }, + "downloads": { + "url": "chrome://extensions/content/parent/ext-downloads.js", + "schema": "chrome://extensions/content/schemas/downloads.json", + "scopes": ["addon_parent"], + "paths": [["downloads"]] + }, + "extension": { + "url": "chrome://extensions/content/parent/ext-extension.js", + "schema": "chrome://extensions/content/schemas/extension.json", + "scopes": ["addon_parent", "content_child"], + "paths": [["extension"]] + }, + "activityLog": { + "url": "chrome://extensions/content/parent/ext-activityLog.js", + "schema": "chrome://extensions/content/schemas/activity_log.json", + "scopes": ["addon_parent"], + "paths": [["activityLog"]] + }, + "i18n": { + "url": "chrome://extensions/content/parent/ext-i18n.js", + "schema": "chrome://extensions/content/schemas/i18n.json", + "scopes": ["addon_parent", "content_child", "devtools_child"], + "paths": [["i18n"]] + }, + "idle": { + "url": "chrome://extensions/content/parent/ext-idle.js", + "schema": "chrome://extensions/content/schemas/idle.json", + "scopes": ["addon_parent"], + "paths": [["idle"]] + }, + "management": { + "url": "chrome://extensions/content/parent/ext-management.js", + "schema": "chrome://extensions/content/schemas/management.json", + "scopes": ["addon_parent"], + "paths": [["management"]] + }, + "networkStatus": { + "url": "chrome://extensions/content/parent/ext-networkStatus.js", + "schema": "chrome://extensions/content/schemas/network_status.json", + "scopes": ["addon_parent"], + "paths": [["networkStatus"]] + }, + "notifications": { + "url": "chrome://extensions/content/parent/ext-notifications.js", + "schema": "chrome://extensions/content/schemas/notifications.json", + "scopes": ["addon_parent"], + "paths": [["notifications"]] + }, + "permissions": { + "url": "chrome://extensions/content/parent/ext-permissions.js", + "schema": "chrome://extensions/content/schemas/permissions.json", + "scopes": ["addon_parent"], + "paths": [["permissions"]] + }, + "privacy": { + "url": "chrome://extensions/content/parent/ext-privacy.js", + "schema": "chrome://extensions/content/schemas/privacy.json", + "scopes": ["addon_parent"], + "settings": true, + "paths": [["privacy"]] + }, + "protocolHandlers": { + "url": "chrome://extensions/content/parent/ext-protocolHandlers.js", + "schema": "chrome://extensions/content/schemas/extension_protocol_handlers.json", + "scopes": ["addon_parent"], + "manifest": ["protocol_handlers"] + }, + "proxy": { + "url": "chrome://extensions/content/parent/ext-proxy.js", + "schema": "chrome://extensions/content/schemas/proxy.json", + "scopes": ["addon_parent"], + "settings": true, + "paths": [["proxy"]], + "startupBlocking": true + }, + "runtime": { + "url": "chrome://extensions/content/parent/ext-runtime.js", + "schema": "chrome://extensions/content/schemas/runtime.json", + "scopes": ["addon_parent", "content_parent", "devtools_parent"], + "paths": [["runtime"]] + }, + "scripting": { + "url": "chrome://extensions/content/parent/ext-scripting.js", + "schema": "chrome://extensions/content/schemas/scripting.json", + "scopes": ["addon_parent"], + "paths": [["scripting"]] + }, + "storage": { + "url": "chrome://extensions/content/parent/ext-storage.js", + "schema": "chrome://extensions/content/schemas/storage.json", + "scopes": ["addon_parent", "content_parent", "devtools_parent"], + "paths": [["storage"]] + }, + "telemetry": { + "url": "chrome://extensions/content/parent/ext-telemetry.js", + "schema": "chrome://extensions/content/schemas/telemetry.json", + "scopes": ["addon_parent"], + "paths": [["telemetry"]] + }, + "test": { + "schema": "chrome://extensions/content/schemas/test.json", + "scopes": ["content_child"] + }, + "theme": { + "url": "chrome://extensions/content/parent/ext-theme.js", + "schema": "chrome://extensions/content/schemas/theme.json", + "scopes": ["addon_parent"], + "manifest": ["theme"], + "paths": [["theme"]] + }, + "userScripts": { + "url": "chrome://extensions/content/parent/ext-userScripts.js", + "schema": "chrome://extensions/content/schemas/user_scripts.json", + "scopes": ["addon_parent"], + "paths": [["userScripts"]] + }, + "userScriptsContent": { + "schema": "chrome://extensions/content/schemas/user_scripts_content.json", + "scopes": ["content_child"], + "paths": [["userScripts", "onBeforeScript"]] + }, + "webNavigation": { + "url": "chrome://extensions/content/parent/ext-webNavigation.js", + "schema": "chrome://extensions/content/schemas/web_navigation.json", + "scopes": ["addon_parent"], + "paths": [["webNavigation"]] + }, + "webRequest": { + "url": "chrome://extensions/content/parent/ext-webRequest.js", + "schema": "chrome://extensions/content/schemas/web_request.json", + "scopes": ["addon_parent"], + "paths": [["webRequest"]], + "startupBlocking": true + } +} diff --git a/toolkit/components/extensions/extIWebNavigation.idl b/toolkit/components/extensions/extIWebNavigation.idl new file mode 100644 index 0000000000..3095d93d9f --- /dev/null +++ b/toolkit/components/extensions/extIWebNavigation.idl @@ -0,0 +1,34 @@ +/* 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" + +webidl BrowsingContext; +interface nsIURI; + +[scriptable, uuid(5cc10dac-cab3-41dd-b4ce-55e27c43cc40)] +interface extIWebNavigation : nsISupports +{ + void onDocumentChange(in BrowsingContext bc, + in jsval transitionData, + in nsIURI location); + + void onHistoryChange(in BrowsingContext bc, + in jsval transitionData, + in nsIURI location, + in bool isHistoryStateUpdated, + in bool isReferenceFragmentUpdated); + + void onStateChange(in BrowsingContext bc, + in nsIURI requestURI, + in nsresult status, + in unsigned long stateFlags); + + void onCreatedNavigationTarget(in BrowsingContext bc, + in BrowsingContext sourceBC, + in ACString url); + + void onDOMContentLoaded(in BrowsingContext bc, + in nsIURI documentURI); +}; diff --git a/toolkit/components/extensions/extensionProcessScriptLoader.js b/toolkit/components/extensions/extensionProcessScriptLoader.js new file mode 100644 index 0000000000..d6fbadf223 --- /dev/null +++ b/toolkit/components/extensions/extensionProcessScriptLoader.js @@ -0,0 +1,11 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-env mozilla/process-script */ + +"use strict"; + +ChromeUtils.importESModule( + "resource://gre/modules/ExtensionProcessScript.sys.mjs" +); diff --git a/toolkit/components/extensions/extensions-toolkit.manifest b/toolkit/components/extensions/extensions-toolkit.manifest new file mode 100644 index 0000000000..1bb8d3182e --- /dev/null +++ b/toolkit/components/extensions/extensions-toolkit.manifest @@ -0,0 +1,13 @@ +# scripts +category webextension-modules toolkit chrome://extensions/content/ext-toolkit.json + +category webextension-scripts a-toolkit chrome://extensions/content/parent/ext-toolkit.js +category webextension-scripts b-tabs-base chrome://extensions/content/parent/ext-tabs-base.js + +category webextension-scripts-content toolkit chrome://extensions/content/child/ext-toolkit.js +category webextension-scripts-devtools toolkit chrome://extensions/content/child/ext-toolkit.js +category webextension-scripts-addon toolkit chrome://extensions/content/child/ext-toolkit.js + +category webextension-schemas events chrome://extensions/content/schemas/events.json +category webextension-schemas native_manifest chrome://extensions/content/schemas/native_manifest.json +category webextension-schemas types chrome://extensions/content/schemas/types.json diff --git a/toolkit/components/extensions/jar.mn b/toolkit/components/extensions/jar.mn new file mode 100644 index 0000000000..3292078590 --- /dev/null +++ b/toolkit/components/extensions/jar.mn @@ -0,0 +1,65 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +toolkit.jar: +% content extensions %content/extensions/ + content/extensions/dummy.xhtml + content/extensions/ext-browser-content.js + content/extensions/ext-toolkit.json + content/extensions/parent/ext-activityLog.js (parent/ext-activityLog.js) + content/extensions/parent/ext-alarms.js (parent/ext-alarms.js) + content/extensions/parent/ext-backgroundPage.js (parent/ext-backgroundPage.js) + content/extensions/parent/ext-browserSettings.js (parent/ext-browserSettings.js) + content/extensions/parent/ext-browsingData.js (parent/ext-browsingData.js) +#ifndef ANDROID + content/extensions/parent/ext-captivePortal.js (parent/ext-captivePortal.js) +#endif + content/extensions/parent/ext-contentScripts.js (parent/ext-contentScripts.js) + content/extensions/parent/ext-contextualIdentities.js (parent/ext-contextualIdentities.js) + content/extensions/parent/ext-clipboard.js (parent/ext-clipboard.js) + content/extensions/parent/ext-cookies.js (parent/ext-cookies.js) + content/extensions/parent/ext-declarativeNetRequest.js (parent/ext-declarativeNetRequest.js) + content/extensions/parent/ext-dns.js (parent/ext-dns.js) + content/extensions/parent/ext-downloads.js (parent/ext-downloads.js) + content/extensions/parent/ext-extension.js (parent/ext-extension.js) +#ifndef ANDROID + content/extensions/parent/ext-geckoProfiler.js (parent/ext-geckoProfiler.js) +#endif + content/extensions/parent/ext-i18n.js (parent/ext-i18n.js) +#ifndef ANDROID + content/extensions/parent/ext-identity.js (parent/ext-identity.js) +#endif + content/extensions/parent/ext-idle.js (parent/ext-idle.js) + content/extensions/parent/ext-management.js (parent/ext-management.js) + content/extensions/parent/ext-networkStatus.js (parent/ext-networkStatus.js) + content/extensions/parent/ext-notifications.js (parent/ext-notifications.js) + content/extensions/parent/ext-permissions.js (parent/ext-permissions.js) + content/extensions/parent/ext-privacy.js (parent/ext-privacy.js) + content/extensions/parent/ext-protocolHandlers.js (parent/ext-protocolHandlers.js) + content/extensions/parent/ext-proxy.js (parent/ext-proxy.js) + content/extensions/parent/ext-runtime.js (parent/ext-runtime.js) + content/extensions/parent/ext-scripting.js (parent/ext-scripting.js) + content/extensions/parent/ext-storage.js (parent/ext-storage.js) + content/extensions/parent/ext-tabs-base.js (parent/ext-tabs-base.js) + content/extensions/parent/ext-telemetry.js (parent/ext-telemetry.js) + content/extensions/parent/ext-theme.js (parent/ext-theme.js) + content/extensions/parent/ext-toolkit.js (parent/ext-toolkit.js) + content/extensions/parent/ext-userScripts.js (parent/ext-userScripts.js) + content/extensions/parent/ext-webRequest.js (parent/ext-webRequest.js) + content/extensions/parent/ext-webNavigation.js (parent/ext-webNavigation.js) + content/extensions/child/ext-backgroundPage.js (child/ext-backgroundPage.js) + content/extensions/child/ext-contentScripts.js (child/ext-contentScripts.js) + content/extensions/child/ext-declarativeNetRequest.js (child/ext-declarativeNetRequest.js) + content/extensions/child/ext-extension.js (child/ext-extension.js) +#ifndef ANDROID + content/extensions/child/ext-identity.js (child/ext-identity.js) +#endif + content/extensions/child/ext-runtime.js (child/ext-runtime.js) + content/extensions/child/ext-scripting.js (child/ext-scripting.js) + content/extensions/child/ext-storage.js (child/ext-storage.js) + content/extensions/child/ext-test.js (child/ext-test.js) + content/extensions/child/ext-toolkit.js (child/ext-toolkit.js) + content/extensions/child/ext-userScripts.js (child/ext-userScripts.js) + content/extensions/child/ext-userScripts-content.js (child/ext-userScripts-content.js) + content/extensions/child/ext-webRequest.js (child/ext-webRequest.js) diff --git a/toolkit/components/extensions/metrics.yaml b/toolkit/components/extensions/metrics.yaml new file mode 100644 index 0000000000..192b12f9f9 --- /dev/null +++ b/toolkit/components/extensions/metrics.yaml @@ -0,0 +1,708 @@ +# 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/. + +# Adding a new metric? We have docs for that! +# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html + +--- +$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0 +$tags: + - 'WebExtensions :: General' + +extensions: + use_remote_pref: + type: boolean + expires: never + lifetime: application + description: > + Corresponds to the value of `extensions.webextensions.remote` pref. + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1850351/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1850351#c2 + data_sensitivity: + - technical + + use_remote_policy: + type: boolean + expires: never + lifetime: application + description: > + Corresponds to the value of `WebExtensionPolicy.useRemoteWebExtensions`. + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1850351/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1850351#c2 + data_sensitivity: + - technical + + startup_cache_load_time: + type: timespan + time_unit: millisecond + expires: never + description: | + Time to load and deserialize the extensions startupCache data. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + - lgreco@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1767336/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1767336#c7 + data_sensitivity: + - technical + telemetry_mirror: EXTENSIONS_STARTUPCACHE_LOAD_TIME + + startup_cache_read_errors: + type: labeled_counter + expires: never + description: | + The number of times an unexpected error has been raised while reading + the extensions StartupCache file. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1767336/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1767336#c7 + data_sensitivity: + - technical + telemetry_mirror: EXTENSIONS_STARTUPCACHE_READ_ERRORS + + startup_cache_write_bytelength: + type: quantity + unit: bytes + expires: never + description: | + The amount of bytes written to the extensions StartupCache file. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1767336/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1767336#c7 + data_sensitivity: + - technical + telemetry_mirror: EXTENSIONS_STARTUPCACHE_WRITE_BYTELENGTH + + process_event: + type: labeled_counter + expires: never + description: | + Counters for how many times the extension process has crashed or been created. + The labels with "_fg" / "_bg" suffixes are only recorded in Android builds, + while the "created" and "crashed" labels are recorded on both Desktop and Android + builds. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1830157/ + - https://bugzilla.mozilla.org/1848223/ + - https://bugzilla.mozilla.org/1850350/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1830157#c7 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848223#c4 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1850350#c2 + data_sensitivity: + - technical + labels: + - crashed_bg + - crashed_fg + - created_bg + - created_fg + - crashed_over_threshold_bg + - crashed_over_threshold_fg + +extensions.apis.dnr: + + startup_cache_read_size: + type: memory_distribution + memory_unit: byte + expires: 126 + description: | + Amount of data read from the DNR startup cache file. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1803363/ + - https://bugzilla.mozilla.org/1850890/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1803363#c11 + data_sensitivity: + - technical + telemetry_mirror: WEBEXT_DNR_STARTUPCACHE_READ_BYTES + + startup_cache_read_time: + type: timing_distribution + time_unit: millisecond + expires: 126 + description: | + Amount of time it takes to read data into the DNR startup cache file. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1803363/ + - https://bugzilla.mozilla.org/1850890/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1803363#c11 + data_sensitivity: + - technical + telemetry_mirror: WEBEXT_DNR_STARTUPCACHE_READ_MS + + startup_cache_write_size: + type: memory_distribution + memory_unit: byte + expires: 126 + description: | + Amount of data written to the DNR startup cache file. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1803363/ + - https://bugzilla.mozilla.org/1850890/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1803363#c11 + data_sensitivity: + - technical + telemetry_mirror: WEBEXT_DNR_STARTUPCACHE_WRITE_BYTES + + startup_cache_write_time: + type: timing_distribution + time_unit: millisecond + expires: 126 + description: | + Amount of time it takes to write data into the DNR startup cache file. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1803363/ + - https://bugzilla.mozilla.org/1850890/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1803363#c11 + data_sensitivity: + - technical + telemetry_mirror: WEBEXT_DNR_STARTUPCACHE_WRITE_MS + + startup_cache_entries: + type: labeled_counter + expires: 126 + description: | + Counters for startup cache data hits or misses on initializating + DNR rules for extensions loaded on application startup. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1803363/ + - https://bugzilla.mozilla.org/1850890/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1803363#c11 + data_sensitivity: + - technical + labels: + - hit + - miss + telemetry_mirror: EXTENSIONS_APIS_DNR_STARTUP_CACHE_ENTRIES + + validate_rules_time: + type: timing_distribution + time_unit: millisecond + expires: 126 + description: | + Amount of time it takes to validate DNR rules of individual ruleset + when dynamic or static rulesets have been loaded from disk. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1803363/ + - https://bugzilla.mozilla.org/1850890/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1803363#c11 + data_sensitivity: + - technical + telemetry_mirror: WEBEXT_DNR_VALIDATE_RULES_MS + + evaluate_rules_time: + type: timing_distribution + time_unit: millisecond + expires: 126 + description: | + Amount of time it takes to evaluate DNR rules for one network request. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1803363/ + - https://bugzilla.mozilla.org/1850890/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1803363#c11 + data_sensitivity: + - technical + telemetry_mirror: WEBEXT_DNR_EVALUATE_RULES_MS + + evaluate_rules_count_max: + type: quantity + unit: rules + expires: 126 + description: | + Max amount of DNR rules being evaluated. + lifetime: ping + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1803363/ + - https://bugzilla.mozilla.org/1850890/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1803363#c11 + data_sensitivity: + - technical + telemetry_mirror: EXTENSIONS_APIS_DNR_EVALUATE_RULES_COUNT_MAX + +extensions.data: + + migrate_result: + type: event + description: | + These events are sent when an extension is migrating its data to the new + IndexedDB backend. + bugs: + - https://bugzilla.mozilla.org/1470213 + - https://bugzilla.mozilla.org/1553297 + - https://bugzilla.mozilla.org/1590736 + - https://bugzilla.mozilla.org/1630596 + - https://bugzilla.mozilla.org/1672570 + - https://bugzilla.mozilla.org/1714251 + - https://bugzilla.mozilla.org/1749878 + - https://bugzilla.mozilla.org/1781974 + - https://bugzilla.mozilla.org/1817100 + - https://bugzilla.mozilla.org/1861295 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1470213#c15 + notification_emails: + - addons-dev-internal@mozilla.com + extra_keys: + addon_id: + description: Id of the addon. + type: string + backend: + description: The selected backend ("JSONFile" / "IndexedDB"). + type: string + data_migrated: + description: The old extension data has been migrated ("y" / "n"). + type: string + error_name: + description: | + A DOMException error name if any ("OtherError" for unknown errors). + The error has been fatal if the `backend` extra key is "JSONFile", + otherwise it is a non fatal error which didn't prevented the + extension from switching to the IndexedDB backend. + type: string + has_jsonfile: + description: The extension has a JSONFile ("y" / "n"). + type: string + has_olddata: + description: Extension had some data stored in JSONFile ("y" / "n"). + type: string + expires: 132 + + storage_local_error: + type: event + description: | + These events are collected when an extension triggers an unexpected error + while running a storage.local API call (e.g. because of some underlying + QuotaManager and/or IndexedDB error). + bugs: + - https://bugzilla.mozilla.org/1606903 + - https://bugzilla.mozilla.org/1649948 + - https://bugzilla.mozilla.org/1689255 + - https://bugzilla.mozilla.org/1730038 + - https://bugzilla.mozilla.org/1763523 + - https://bugzilla.mozilla.org/1811148 + - https://bugzilla.mozilla.org/1861297 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1606903#c3 + notification_emails: + - addons-dev-internal@mozilla.com + extra_keys: + addon_id: + description: Id of the addon. + type: string + method: + description: The storage.local API method name. + type: string + error_name: + description: | + A DOMException error name if any ("OtherError" for unknown errors). + type: string + expires: 132 + +extensions.quarantined_domains: + + listsize: + type: quantity + unit: domains + description: > + Number of domains listed in the quarantined domains list pref for the client during + this session. + lifetime: application + expires: 130 + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1840615/ + - https://bugzilla.mozilla.org/1866199/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1840615 + data_sensitivity: + - technical + telemetry_mirror: EXTENSIONS_QUARANTINEDDOMAINS_LISTSIZE + + listhash: + type: string + description: > + SHA1 cryptographic hash of the quarantined domains string pref. + lifetime: application + expires: 130 + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1841683/ + - https://bugzilla.mozilla.org/1866199/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1840615#c2 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1841683 + data_sensitivity: + - technical + telemetry_mirror: EXTENSIONS_QUARANTINEDDOMAINS_LISTHASH + + remotehash: + type: string + description: > + SHA1 cryptographic hash of the quarantined domains string pref as it was + set based on the value got synced from the RemoteSettings collection. + AMRemoteSettings will be re-processing the entries on the next application + startup and so this metric lifetime can be set to application and expect + it to be always set to the value got from the RemoteSettings collection. + lifetime: application + expires: 130 + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1841683/ + - https://bugzilla.mozilla.org/1866199/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1841683 + data_sensitivity: + - technical + telemetry_mirror: EXTENSIONS_QUARANTINEDDOMAINS_REMOTEHASH + +extensions.counters: + + browser_action_preload_result: + type: labeled_counter + expires: never + description: | + Number of times an event page hit the idle timeout and results in one of the labels. + # Keep these labels in sync with the ones in WEBEXT_BROWSERACTION_POPUP_PRELOAD_RESULT_COUNT + # as defined in Histograms.json + labels: + - popupShown + - clearAfterHover + - clearAfterMousedown + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1297167 + - https://bugzilla.mozilla.org/1513556 + - https://bugzilla.mozilla.org/1578225 + - https://bugzilla.mozilla.org/1623315 + - https://bugzilla.mozilla.org/1666980 + - https://bugzilla.mozilla.org/1706839 + - https://bugzilla.mozilla.org/1745271 + - https://bugzilla.mozilla.org/1777402 + - https://bugzilla.mozilla.org/1811155 + - https://bugzilla.mozilla.org/1861303 + - https://bugzilla.mozilla.org/1820158 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820158#c8 + data_sensitivity: + - technical + + event_page_idle_result: + type: labeled_counter + expires: never + description: | + Number of times an event page hit the idle timeout and results in one of the labels. + # Keep these labels in sync with the ones in WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT + # as defined in Histograms.json + labels: + - suspend + - reset_other + - reset_event + - reset_listeners + - reset_nativeapp + - reset_streamfilter + - reset_parentapicall + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1787940 + - https://bugzilla.mozilla.org/1817103 + - https://bugzilla.mozilla.org/1861303 + - https://bugzilla.mozilla.org/1820158 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820158#c8 + data_sensitivity: + - technical + +extensions.timing: + + background_page_load: + type: timing_distribution + time_unit: millisecond + expires: never + description: | + Amount of time it takes to load a WebExtensions background page, from when the + build function is called to when the page has finished processing the onload event. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1353172 + - https://bugzilla.mozilla.org/1489524 + - https://bugzilla.mozilla.org/1820158 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820158#c8 + data_sensitivity: + - technical + + browser_action_popup_open: + type: timing_distribution + time_unit: millisecond + expires: never + description: | + Amount of time it takes for a BrowserAction popup to open. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1297167 + - https://bugzilla.mozilla.org/1513556 + - https://bugzilla.mozilla.org/1578225 + - https://bugzilla.mozilla.org/1623315 + - https://bugzilla.mozilla.org/1666980 + - https://bugzilla.mozilla.org/1706839 + - https://bugzilla.mozilla.org/1745271 + - https://bugzilla.mozilla.org/1777402 + - https://bugzilla.mozilla.org/1811155 + - https://bugzilla.mozilla.org/1861303 + - https://bugzilla.mozilla.org/1820158 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820158#c8 + data_sensitivity: + - technical + + content_script_injection: + type: timing_distribution + time_unit: millisecond + expires: never + description: | + Amount of time it takes for content scripts from a WebExtension to be injected into a window. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1356323 + - https://bugzilla.mozilla.org/1489524 + - https://bugzilla.mozilla.org/1820158 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820158#c8 + data_sensitivity: + - technical + + event_page_running_time: + type: custom_distribution + unit: ms + range_min: 1 + range_max: 60000 + bucket_count: 100 + histogram_type: exponential + expires: never + description: | + Amount of time (keyed by addon id) that an event page has been running before being suspended, + or the entire addon shutdown. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1787940 + - https://bugzilla.mozilla.org/1820158 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820158#c8 + data_sensitivity: + - technical + + extension_startup: + type: timing_distribution + time_unit: millisecond + expires: never + description: | + Amount of time it takes for a WebExtension to start up, from when the + startup function is called to when the startup promise resolves. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1353171 + - https://bugzilla.mozilla.org/1489524 + - https://bugzilla.mozilla.org/1820158 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820158#c8 + data_sensitivity: + - technical + + page_action_popup_open: + type: timing_distribution + time_unit: millisecond + expires: never + description: | + Amount of time it takes for a PageAction popup to open. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1297167 + - https://bugzilla.mozilla.org/1513556 + - https://bugzilla.mozilla.org/1578225 + - https://bugzilla.mozilla.org/1623315 + - https://bugzilla.mozilla.org/1666980 + - https://bugzilla.mozilla.org/1706839 + - https://bugzilla.mozilla.org/1745271 + - https://bugzilla.mozilla.org/1777402 + - https://bugzilla.mozilla.org/1811155 + - https://bugzilla.mozilla.org/1861303 + - https://bugzilla.mozilla.org/1820158 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820158#c8 + data_sensitivity: + - technical + + storage_local_get_json: + type: timing_distribution + time_unit: millisecond + expires: 128 + description: | + Amount of time it takes to perform a get via storage.local using the JSONFile backend. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1371398 + - https://bugzilla.mozilla.org/1513556 + - https://bugzilla.mozilla.org/1578225 + - https://bugzilla.mozilla.org/1623315 + - https://bugzilla.mozilla.org/1666980 + - https://bugzilla.mozilla.org/1706839 + - https://bugzilla.mozilla.org/1745271 + - https://bugzilla.mozilla.org/1777402 + - https://bugzilla.mozilla.org/1811155 + - https://bugzilla.mozilla.org/1861303 + - https://bugzilla.mozilla.org/1820158 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820158#c8 + data_sensitivity: + - technical + + storage_local_set_json: + type: timing_distribution + time_unit: millisecond + expires: 128 + description: | + Amount of time it takes to perform a set via storage.local using the JSONFile backend. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1371398 + - https://bugzilla.mozilla.org/1513556 + - https://bugzilla.mozilla.org/1578225 + - https://bugzilla.mozilla.org/1623315 + - https://bugzilla.mozilla.org/1666980 + - https://bugzilla.mozilla.org/1706839 + - https://bugzilla.mozilla.org/1745271 + - https://bugzilla.mozilla.org/1777402 + - https://bugzilla.mozilla.org/1811155 + - https://bugzilla.mozilla.org/1861303 + - https://bugzilla.mozilla.org/1820158 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820158#c8 + data_sensitivity: + - technical + + storage_local_get_idb: + type: timing_distribution + time_unit: millisecond + expires: never + description: | + Amount of time it takes to perform a get via storage.local using the IndexedDB backend. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1465120 + - https://bugzilla.mozilla.org/1513556 + - https://bugzilla.mozilla.org/1578225 + - https://bugzilla.mozilla.org/1623315 + - https://bugzilla.mozilla.org/1666980 + - https://bugzilla.mozilla.org/1706839 + - https://bugzilla.mozilla.org/1745271 + - https://bugzilla.mozilla.org/1777402 + - https://bugzilla.mozilla.org/1811155 + - https://bugzilla.mozilla.org/1861303 + - https://bugzilla.mozilla.org/1820158 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820158#c8 + data_sensitivity: + - technical + + storage_local_set_idb: + type: timing_distribution + time_unit: millisecond + expires: never + description: | + Amount of time it takes to perform a set via storage.local using the Indexed backend. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1465120 + - https://bugzilla.mozilla.org/1513556 + - https://bugzilla.mozilla.org/1578225 + - https://bugzilla.mozilla.org/1623315 + - https://bugzilla.mozilla.org/1666980 + - https://bugzilla.mozilla.org/1706839 + - https://bugzilla.mozilla.org/1745271 + - https://bugzilla.mozilla.org/1777402 + - https://bugzilla.mozilla.org/1811155 + - https://bugzilla.mozilla.org/1861303 + - https://bugzilla.mozilla.org/1820158 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820158#c8 + data_sensitivity: + - technical diff --git a/toolkit/components/extensions/moz.build b/toolkit/components/extensions/moz.build new file mode 100644 index 0000000000..8b75567bb3 --- /dev/null +++ b/toolkit/components/extensions/moz.build @@ -0,0 +1,148 @@ +# -*- 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 = ("WebExtensions", "General") + +EXTRA_JS_MODULES += [ + "ConduitsChild.sys.mjs", + "ConduitsParent.sys.mjs", + "Extension.sys.mjs", + "ExtensionActions.sys.mjs", + "ExtensionActivityLog.sys.mjs", + "ExtensionChild.sys.mjs", + "ExtensionChildDevToolsUtils.sys.mjs", + "ExtensionCommon.sys.mjs", + "ExtensionContent.sys.mjs", + "ExtensionDNR.sys.mjs", + "ExtensionDNRLimits.sys.mjs", + "ExtensionDNRStore.sys.mjs", + "ExtensionPageChild.sys.mjs", + "ExtensionParent.sys.mjs", + "ExtensionPermissionMessages.sys.mjs", + "ExtensionPermissions.sys.mjs", + "ExtensionPreferencesManager.sys.mjs", + "ExtensionProcessScript.sys.mjs", + "extensionProcessScriptLoader.js", + "ExtensionScriptingStore.sys.mjs", + "ExtensionSettingsStore.sys.mjs", + "ExtensionShortcuts.sys.mjs", + "ExtensionStorage.sys.mjs", + "ExtensionStorageIDB.sys.mjs", + "ExtensionStorageSync.sys.mjs", + "ExtensionStorageSyncKinto.sys.mjs", + "ExtensionTelemetry.sys.mjs", + "ExtensionUtils.sys.mjs", + "ExtensionWorkerChild.sys.mjs", + "FindContent.sys.mjs", + "MatchURLFilters.sys.mjs", + "MessageManagerProxy.sys.mjs", + "NativeManifests.sys.mjs", + "NativeMessaging.sys.mjs", + "ProxyChannelFilter.sys.mjs", + "Schemas.sys.mjs", + "WebNavigation.sys.mjs", + "WebNavigationFrames.sys.mjs", +] + +EXTRA_COMPONENTS += [ + "extensions-toolkit.manifest", +] + +TESTING_JS_MODULES += [ + "ExtensionTestCommon.sys.mjs", + "ExtensionXPCShellUtils.sys.mjs", + "MessageChannel.sys.mjs", + "test/xpcshell/data/TestWorkerWatcherChild.sys.mjs", + "test/xpcshell/data/TestWorkerWatcherParent.sys.mjs", +] + +DIRS += [ + "schemas", + "storage", + "webidl-api", + "webrequest", +] + +IPDL_SOURCES += [ + "PExtensions.ipdl", +] + +XPIDL_SOURCES += [ + "extIWebNavigation.idl", + "mozIExtensionAPIRequestHandling.idl", + "mozIExtensionProcessScript.idl", +] + +XPIDL_MODULE = "webextensions" + +EXPORTS.mozilla = [ + "ExtensionPolicyService.h", +] + +EXPORTS.mozilla.extensions = [ + "DocumentObserver.h", + "ExtensionsChild.h", + "ExtensionsParent.h", + "MatchGlob.h", + "MatchPattern.h", + "WebExtensionContentScript.h", + "WebExtensionPolicy.h", +] + +UNIFIED_SOURCES += [ + "ExtensionPolicyService.cpp", + "ExtensionsChild.cpp", + "ExtensionsParent.cpp", + "MatchPattern.cpp", + "WebExtensionPolicy.cpp", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +FINAL_LIBRARY = "xul" + + +JAR_MANIFESTS += ["jar.mn"] + +BROWSER_CHROME_MANIFESTS += [ + "test/browser/browser.toml", +] + +MOCHITEST_MANIFESTS += [ + "test/mochitest/mochitest-remote.toml", + "test/mochitest/mochitest.toml", +] +MOCHITEST_CHROME_MANIFESTS += ["test/mochitest/chrome.toml"] +XPCSHELL_TESTS_MANIFESTS += [ + "test/xpcshell/native_messaging.toml", + "test/xpcshell/xpcshell-e10s.toml", + "test/xpcshell/xpcshell-legacy-ep.toml", + "test/xpcshell/xpcshell-remote.toml", + "test/xpcshell/xpcshell.toml", +] + +# Only include tests that requires the WebExtensions WebIDL API bindings +# in builds where they are enabled (currently only on Nightly builds). +if CONFIG["MOZ_WEBEXT_WEBIDL_ENABLED"]: + BROWSER_CHROME_MANIFESTS += ["test/browser/browser-serviceworker.toml"] + MARIONETTE_MANIFESTS += ["test/marionette/manifest-serviceworker.toml"] + XPCSHELL_TESTS_MANIFESTS += [ + "test/xpcshell/webidl-api/xpcshell.toml", + "test/xpcshell/xpcshell-serviceworker.toml", + ] + MOCHITEST_MANIFESTS += ["test/mochitest/mochitest-serviceworker.toml"] + + +SPHINX_TREES["webextensions"] = "docs" + +with Files("docs/**"): + SCHEDULES.exclusive = ["docs"] + +include("/ipc/chromium/chromium-config.mozbuild") diff --git a/toolkit/components/extensions/mozIExtensionAPIRequestHandling.idl b/toolkit/components/extensions/mozIExtensionAPIRequestHandling.idl new file mode 100644 index 0000000000..0a2e3c7a5d --- /dev/null +++ b/toolkit/components/extensions/mozIExtensionAPIRequestHandling.idl @@ -0,0 +1,192 @@ +/* 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 nsIPrincipal; + +[scriptable, builtinclass, uuid(e6862533-8844-4207-a6ab-04748a29d859)] +interface mozIExtensionServiceWorkerInfo : nsISupports +{ + readonly attribute nsIPrincipal principal; + readonly attribute AString scriptURL; + readonly attribute AString clientInfoId; + readonly attribute unsigned long long descriptorId; +}; + +[scriptable, uuid(876d45db-5c1b-4c9b-9148-1c86b33d120b)] +interface mozIExtensionListenerCallOptions : nsISupports +{ + cenum APIObjectType: 8 { + // Default: no api object is prepended to the event listener call arguments. + NONE, + + // A runtime.Port instance is prepended to the event listener call arguments. + RUNTIME_PORT, + }; + + readonly attribute mozIExtensionListenerCallOptions_APIObjectType apiObjectType; + + // An optional javascript object that should provide the attributes expected + // by related apiObjectType webidl dictionary type (e.g. ExtensionPortDescriptor + // if apiObjectType is RUNTIME_PORT). + readonly attribute jsval apiObjectDescriptor; + + // An optional boolean to be set to true if the api object should be + // prepended to the rest of the call arguments (by default it is appended). + readonly attribute bool apiObjectPrepended; + + cenum CallbackType: 8 { + // Default: no callback argument is passed to the call to the event listener. + CALLBACK_NONE, + + // The event listener will be called with a function as the last call parameter + // that behaves like the runtime.onMessage's sendResponse parameter: + // + // - if the event listener already returned false, sendResponse calls are ignored + // (if it has not been called already) + // - if the event listener already returned true, the first sendResponse call + // resolves the promise returned by the mozIExtensionCallback method call + // - if the event listener already returned a Promise, sendResponse calls + // are ignored + CALLBACK_SEND_RESPONSE, + }; + + attribute mozIExtensionListenerCallOptions_CallbackType callbackType; +}; + +[scriptable, builtinclass, uuid(e68e3c19-1b35-4112-8faa-5c5b84086a5b)] +interface mozIExtensionEventListener : nsISupports +{ + [implicit_jscontext, can_run_script] + Promise callListener( + in Array<jsval> args, + [optional] in mozIExtensionListenerCallOptions listenerCallOptions); +}; + +[scriptable, builtinclass, uuid(0fee1c8f-e363-46a6-bd0c-d3c3338e2534)] +interface mozIExtensionAPIRequest : nsISupports +{ + AUTF8String toString(); + + // Determine what the caller and receiver should expect, e.g. what should + // be provided to the API request handler and what to expect it to return. + cenum RequestType: 8 { + CALL_FUNCTION, + CALL_FUNCTION_NO_RETURN, + CALL_FUNCTION_ASYNC, + ADD_LISTENER, + REMOVE_LISTENER, + GET_PROPERTY, + }; + + // The type of the request. + readonly attribute AUTF8String requestType; + + // WebExtension API namespace (e.g. tabs, runtime, webRequest, ...) + readonly attribute AUTF8String apiNamespace; + // method or event name + readonly attribute AUTF8String apiName; + + // API object type (e.g. Port, Panel, ...) and its unique id, this + // properties are used by API requests originated by an API object + // instance (like a runtime Port returned by browser.runtime.connect). + readonly attribute AUTF8String apiObjectType; + readonly attribute AUTF8String apiObjectId; + + // An array of API call arguments. + [implicit_jscontext] readonly attribute jsval args; + + // A property to store on the request objects the arguments normalized + // based on the API jsonschema, so that they are being propagated along + // with the API request object. + // TODO: change this attribute to a readonly attribute if we moved + // the parameters validation and normalization to the C++ layer. + [implicit_jscontext] attribute jsval normalizedArgs; + + // The caller SavedFrame (only set for calls originated off of the main thread + // from a service worker). + [implicit_jscontext] readonly attribute jsval callerSavedFrame; + + // Set for requests coming from an extension service worker. + readonly attribute mozIExtensionServiceWorkerInfo serviceWorkerInfo; + + // Set for `addListener`/`removeListener` API requests. + readonly attribute mozIExtensionEventListener eventListener; +}; + +[scriptable, uuid(59fd4097-d88e-40fd-8664-fedd8ab67ab6)] +interface mozIExtensionAPIRequestResult : nsISupports +{ + cenum ResultType: 8 { + // The result is a value to be returned as a result for the API request. + // The value attribute can be set to: + // - a structured clonable result value (which may be the result of the + // API call itself, or be used by the API method webidl implementation + // to initialize a webidl object to return to the caller, e.g. + // ExtensionPort returned by a call to browser.runtime.connect()) + // - an error object (which also include internally converted to and from + // ClonedErrorHolder to make it structured clonable). + // - a Promise (which can be resolved to a structured clonable value or + // an error object). + RETURN_VALUE, + + // The result is an error object that should be thrown as an extension error + // to the caller on the thread that originated the request. + EXTENSION_ERROR, + }; + + readonly attribute mozIExtensionAPIRequestResult_ResultType type; + readonly attribute jsval value; +}; + +[scriptable, uuid(0c61bd33-0557-43a2-9497-96c449f39e33)] +interface mozIExtensionAPIRequestHandler : nsISupports +{ + /** + * Handle an API request originated from the WebExtensions webidl API + * bindings. + * + * @param extension An instance of the WebExtensionPolicy webidl interface. + * @param apiRequest An instance of the mozIExtensionAPIRequest xpcom interface. + * + * @return mozIExtensionAPIRequestResult + * JS value returned by the API request handler, may contain a value + * (the result of the API call or a WebIDL dictionary that is used to + * initialize WebIDL-based API object, e.g. ExtensionPort) or + * an Error to be throw on the thread that originated the request. + */ + void handleAPIRequest(in nsISupports extension, + in mozIExtensionAPIRequest apiRequest, + [optional, retval] out mozIExtensionAPIRequestResult apiRequestResult); + + /** + * A method called when an extension background service worker is initialized and + * ready to execute its main script. + * + * @param extension An instance of the WebExtensionPolicy webidl interface. + * @param serviceWorkerInfo + */ + void initExtensionWorker(in nsISupports extension, + in mozIExtensionServiceWorkerInfo serviceWorkerInfo); + + /** + * A method called when an extension background service worker has loaded its + * main script. + * + * @param extension An instance of the WebExtensionPolicy webidl interface. + * @param serviceWorkerDescriptorId + */ + void onExtensionWorkerLoaded(in nsISupports extension, + in unsigned long long serviceWorkerDescriptorId); + + /** + * A method called when an extension background service worker is destroyed. + * + * @param extension An instance of the WebExtensionPolicy webidl interface. + * @param serviceWorkerDescriptorId + */ + void onExtensionWorkerDestroyed(in nsISupports extension, + in unsigned long long serviceWorkerDescriptorId); +}; diff --git a/toolkit/components/extensions/mozIExtensionProcessScript.idl b/toolkit/components/extensions/mozIExtensionProcessScript.idl new file mode 100644 index 0000000000..84b33a9d02 --- /dev/null +++ b/toolkit/components/extensions/mozIExtensionProcessScript.idl @@ -0,0 +1,21 @@ +/* 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 mozIDOMWindow; +webidl Document; +webidl WebExtensionContentScript; + +[scriptable, uuid(6b09dc51-6caa-4ca7-9d6d-30c87258a630)] +interface mozIExtensionProcessScript : nsISupports +{ + void preloadContentScript(in nsISupports contentScript); + + Promise loadContentScript(in WebExtensionContentScript contentScript, + in mozIDOMWindow window); + + void initExtensionDocument(in nsISupports extension, in Document doc, + in bool privileged); +}; diff --git a/toolkit/components/extensions/parent/.eslintrc.js b/toolkit/components/extensions/parent/.eslintrc.js new file mode 100644 index 0000000000..2af2a2b34b --- /dev/null +++ b/toolkit/components/extensions/parent/.eslintrc.js @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +module.exports = { + globals: { + CONTAINER_STORE: true, + DEFAULT_STORE: true, + EventEmitter: true, + EventManager: true, + InputEventManager: true, + PRIVATE_STORE: true, + TabBase: true, + TabManagerBase: true, + TabTrackerBase: true, + WindowBase: true, + WindowManagerBase: true, + WindowTrackerBase: true, + getUserContextIdForCookieStoreId: true, + getContainerForCookieStoreId: true, + getCookieStoreIdForContainer: true, + getCookieStoreIdForOriginAttributes: true, + getCookieStoreIdForTab: true, + getOriginAttributesPatternForCookieStoreId: true, + isContainerCookieStoreId: true, + isDefaultCookieStoreId: true, + isPrivateCookieStoreId: true, + isValidCookieStoreId: true, + }, +}; diff --git a/toolkit/components/extensions/parent/ext-activityLog.js b/toolkit/components/extensions/parent/ext-activityLog.js new file mode 100644 index 0000000000..2b0c68614e --- /dev/null +++ b/toolkit/components/extensions/parent/ext-activityLog.js @@ -0,0 +1,38 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionActivityLog: "resource://gre/modules/ExtensionActivityLog.sys.mjs", + ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs", +}); + +this.activityLog = class extends ExtensionAPI { + getAPI(context) { + return { + activityLog: { + onExtensionActivity: new ExtensionCommon.EventManager({ + context, + name: "activityLog.onExtensionActivity", + register: (fire, id) => { + // A logger cannot log itself. + if (id === context.extension.id) { + throw new ExtensionUtils.ExtensionError( + "Extension cannot monitor itself." + ); + } + function handler(details) { + fire.async(details); + } + + ExtensionActivityLog.addListener(id, handler); + return () => { + ExtensionActivityLog.removeListener(id, handler); + }; + }, + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-alarms.js b/toolkit/components/extensions/parent/ext-alarms.js new file mode 100644 index 0000000000..1eea8397e2 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-alarms.js @@ -0,0 +1,161 @@ +/* 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"; + +// The ext-* files are imported into the same scopes. +/* import-globals-from ext-toolkit.js */ + +// Manages an alarm created by the extension (alarms API). +class Alarm { + constructor(api, name, alarmInfo) { + this.api = api; + this.name = name; + this.when = alarmInfo.when; + this.delayInMinutes = alarmInfo.delayInMinutes; + this.periodInMinutes = alarmInfo.periodInMinutes; + this.canceled = false; + + let delay, scheduledTime; + if (this.when) { + scheduledTime = this.when; + delay = this.when - Date.now(); + } else { + if (!this.delayInMinutes) { + this.delayInMinutes = this.periodInMinutes; + } + delay = this.delayInMinutes * 60 * 1000; + scheduledTime = Date.now() + delay; + } + + this.scheduledTime = scheduledTime; + + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + delay = delay > 0 ? delay : 0; + timer.init(this, delay, Ci.nsITimer.TYPE_ONE_SHOT); + this.timer = timer; + } + + clear() { + this.timer.cancel(); + this.api.alarms.delete(this.name); + this.canceled = true; + } + + observe(subject, topic, data) { + if (this.canceled) { + return; + } + + for (let callback of this.api.callbacks) { + callback(this); + } + + if (!this.periodInMinutes) { + this.clear(); + return; + } + + let delay = this.periodInMinutes * 60 * 1000; + this.scheduledTime = Date.now() + delay; + this.timer.init(this, delay, Ci.nsITimer.TYPE_ONE_SHOT); + } + + get data() { + return { + name: this.name, + scheduledTime: this.scheduledTime, + periodInMinutes: this.periodInMinutes, + }; + } +} + +this.alarms = class extends ExtensionAPIPersistent { + constructor(extension) { + super(extension); + + this.alarms = new Map(); + this.callbacks = new Set(); + } + + onShutdown() { + for (let alarm of this.alarms.values()) { + alarm.clear(); + } + } + + PERSISTENT_EVENTS = { + onAlarm({ fire }) { + let callback = alarm => { + fire.sync(alarm.data); + }; + + this.callbacks.add(callback); + + return { + unregister: () => { + this.callbacks.delete(callback); + }, + convert(_fire, context) { + fire = _fire; + }, + }; + }, + }; + + getAPI(context) { + const self = this; + + return { + alarms: { + create: function (name, alarmInfo) { + name = name || ""; + if (self.alarms.has(name)) { + self.alarms.get(name).clear(); + } + let alarm = new Alarm(self, name, alarmInfo); + self.alarms.set(alarm.name, alarm); + }, + + get: function (name) { + name = name || ""; + if (self.alarms.has(name)) { + return Promise.resolve(self.alarms.get(name).data); + } + return Promise.resolve(); + }, + + getAll: function () { + let result = Array.from(self.alarms.values(), alarm => alarm.data); + return Promise.resolve(result); + }, + + clear: function (name) { + name = name || ""; + if (self.alarms.has(name)) { + self.alarms.get(name).clear(); + return Promise.resolve(true); + } + return Promise.resolve(false); + }, + + clearAll: function () { + let cleared = false; + for (let alarm of self.alarms.values()) { + alarm.clear(); + cleared = true; + } + return Promise.resolve(cleared); + }, + + onAlarm: new EventManager({ + context, + module: "alarms", + event: "onAlarm", + extensionApi: self, + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-backgroundPage.js b/toolkit/components/extensions/parent/ext-backgroundPage.js new file mode 100644 index 0000000000..155220c67a --- /dev/null +++ b/toolkit/components/extensions/parent/ext-backgroundPage.js @@ -0,0 +1,1116 @@ +/* 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 { ExtensionParent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" +); +var { + HiddenExtensionPage, + promiseBackgroundViewLoaded, + watchExtensionWorkerContextLoaded, +} = ExtensionParent; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionTelemetry: "resource://gre/modules/ExtensionTelemetry.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(this, "serviceWorkerManager", () => { + return Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "backgroundIdleTimeout", + "extensions.background.idle.timeout", + 30000, + null, + // Minimum 100ms, max 5min + delay => Math.min(Math.max(delay, 100), 5 * 60 * 1000) +); + +// Pref used in tests to assert background page state set to +// stopped on an extension process crash. +XPCOMUtils.defineLazyPreferenceGetter( + this, + "disableRestartPersistentAfterCrash", + "extensions.background.disableRestartPersistentAfterCrash", + false +); + +// eslint-disable-next-line mozilla/reject-importGlobalProperties +Cu.importGlobalProperties(["DOMException"]); + +function notifyBackgroundScriptStatus(addonId, isRunning) { + // Notify devtools when the background scripts is started or stopped + // (used to show the current status in about:debugging). + const subject = { addonId, isRunning }; + Services.obs.notifyObservers(subject, "extension:background-script-status"); +} + +// Same as nsITelemetry msSinceProcessStartExcludingSuspend but returns +// undefined instead of throwing an extension. +function msSinceProcessStartExcludingSuspend() { + let now; + try { + now = Services.telemetry.msSinceProcessStartExcludingSuspend(); + } catch (err) { + Cu.reportError(err); + } + return now; +} + +/** + * Background Page state transitions: + * + * ------> STOPPED <------- + * | | | + * | v | + * | STARTING >------| + * | | | + * | v ^ + * |----< RUNNING ----> SUSPENDING + * ^ v + * |------------| + * + * STARTING: The background is being built. + * RUNNING: The background is running. + * SUSPENDING: The background is suspending, runtime.onSuspend will be called. + * STOPPED: The background is not running. + * + * For persistent backgrounds, SUSPENDING is not used. + * + * See BackgroundContextOwner for the exact relation. + */ +const BACKGROUND_STATE = { + STARTING: "starting", + RUNNING: "running", + SUSPENDING: "suspending", + STOPPED: "stopped", +}; + +// Responsible for the background_page section of the manifest. +class BackgroundPage extends HiddenExtensionPage { + constructor(extension, options) { + super(extension, "background"); + + this.page = options.page || null; + this.isGenerated = !!options.scripts; + + // Last background/event page created time (retrieved using + // Services.telemetry.msSinceProcessStartExcludingSuspend when the + // parent process proxy context has been created). + this.msSinceCreated = null; + + if (this.page) { + this.url = this.extension.baseURI.resolve(this.page); + } else if (this.isGenerated) { + this.url = this.extension.baseURI.resolve( + "_generated_background_page.html" + ); + } + } + + async build() { + const { extension } = this; + ExtensionTelemetry.backgroundPageLoad.stopwatchStart(extension, this); + + let context; + try { + await this.createBrowserElement(); + if (!this.browser) { + throw new Error( + "Extension shut down before the background page was created" + ); + } + extension._backgroundPageFrameLoader = this.browser.frameLoader; + + extensions.emit("extension-browser-inserted", this.browser); + + let contextPromise = promiseBackgroundViewLoaded(this.browser); + this.browser.fixupAndLoadURIString(this.url, { + triggeringPrincipal: extension.principal, + }); + + context = await contextPromise; + // NOTE: context can be null if the load failed. + + this.msSinceCreated = msSinceProcessStartExcludingSuspend(); + + ExtensionTelemetry.backgroundPageLoad.stopwatchFinish(extension, this); + } catch (e) { + // Extension was down before the background page has loaded. + ExtensionTelemetry.backgroundPageLoad.stopwatchCancel(extension, this); + throw e; + } + + return context; + } + + shutdown() { + this.extension._backgroundPageFrameLoader = null; + super.shutdown(); + } +} + +// Responsible for the background.service_worker section of the manifest. +class BackgroundWorker { + constructor(extension, options) { + this.extension = extension; + this.workerScript = options.service_worker; + + if (!this.workerScript) { + throw new Error("Missing mandatory background.service_worker property"); + } + } + + get registrationInfo() { + const { principal } = this.extension; + return serviceWorkerManager.getRegistrationForAddonPrincipal(principal); + } + + getWorkerInfo(descriptorId) { + return this.registrationInfo?.getWorkerByID(descriptorId); + } + + validateWorkerInfoForContext(context) { + const { extension } = this; + if (!this.getWorkerInfo(context.workerDescriptorId)) { + throw new Error( + `ServiceWorkerInfo not found for ${extension.policy.debugName} contextId ${context.contextId}` + ); + } + } + + async build() { + const { extension } = this; + let context; + const contextPromise = new Promise(resolve => { + // TODO bug 1844486: resolve and/or unwatch when startup is interrupted. + let unwatch = watchExtensionWorkerContextLoaded( + { extension, viewType: "background_worker" }, + context => { + unwatch(); + this.validateWorkerInfoForContext(context); + resolve(context); + } + ); + }); + + // TODO(Bug 17228327): follow up to spawn the active worker for a previously installed + // background service worker. + await serviceWorkerManager.registerForAddonPrincipal( + this.extension.principal + ); + + // TODO bug 1844486: Confirm that a shutdown() call during the above or + // below `await` calls can interrupt build() without leaving a stray worker + // registration behind. + + context = await contextPromise; + + await this.waitForActiveWorker(); + return context; + } + + shutdown(isAppShutdown) { + // All service worker registrations related to the extensions will be unregistered + // - when the extension is shutting down if the application is not also shutting down + // shutdown (in which case a previously registered service worker is expected to stay + // active across browser restarts). + // - when the extension has been uninstalled + if (!isAppShutdown) { + this.registrationInfo?.forceShutdown(); + } + } + + waitForActiveWorker() { + const { extension, registrationInfo } = this; + return new Promise((resolve, reject) => { + const resolveOnActive = () => { + if ( + registrationInfo.activeWorker?.state === + Ci.nsIServiceWorkerInfo.STATE_ACTIVATED + ) { + resolve(); + return true; + } + return false; + }; + + const rejectOnUnregistered = () => { + if (registrationInfo.unregistered) { + reject( + new Error( + `Background service worker unregistered for "${extension.policy.debugName}"` + ) + ); + return true; + } + return false; + }; + + if (resolveOnActive() || rejectOnUnregistered()) { + return; + } + + const listener = { + onChange() { + if (resolveOnActive() || rejectOnUnregistered()) { + registrationInfo.removeListener(listener); + } + }, + }; + registrationInfo.addListener(listener); + }); + } +} + +/** + * The BackgroundContextOwner is instantiated at most once per extension and + * tracks the state of the background context. State changes can be triggered + * by explicit calls to methods with the "setBgState" prefix, but also by the + * background context itself, e.g. via an extension process crash. + * + * This class identifies the following stages of interest: + * + * 1. Initially no active background, waiting for a signal to get started. + * - method: none (at constructor and after setBgStateStopped) + * - state: STOPPED + * - context: null + * 2. Parent-triggered background startup + * - method: setBgStateStarting + * - state: STARTING (was STOPPED) + * - context: null + * 3. Background context creation observed in parent + * - method: none (observed by ExtensionParent's recvCreateProxyContext) + * TODO: add method to observe and keep track of it sooner than stage 4. + * - state: STARTING + * - context: ProxyContextParent subclass (was null) + * 4. Parent-observed background startup completion + * - method: setBgStateRunning + * - state: RUNNING (was STARTING) + * - context: ProxyContextParent (was null) + * 5. Background context unloaded for any reason + * - method: setBgStateStopped + * TODO bug 1844217: This is only implemented for process crashes and + * intentionally triggered terminations, not navigations/reloads. + * When unloads happen due to navigations/reloads, context will be + * null but the state will still be RUNNING. + * - state: STOPPED (was STOPPED, STARTING, RUNNING or SUSPENDING) + * - context: null (was ProxyContextParent if stage 4 ran). + * - Continue at stage 1 if the extension has not shut down yet. + */ +class BackgroundContextOwner { + /** + * @property {BackgroundBuilder} backgroundBuilder + * + * The source of parent-triggered background state changes. + */ + backgroundBuilder; + + /** + * @property {Extension} [extension] + * + * The Extension associated with the background. This is always set and + * cleared at extension shutdown. + */ + extension; + + /** + * @property {BackgroundPage|BackgroundWorker} [bgInstance] + * + * The BackgroundClass instance responsible for creating the background + * context. This is set as soon as there is a desire to start a background, + * and cleared as soon as the background context is not wanted any more. + * + * This field is set iff extension.backgroundState is not STOPPED. + */ + bgInstance = null; + + /** + * @property {ExtensionPageContextParent|BackgroundWorkerContextParent} [context] + * + * The parent-side counterpart to a background context in a child. The value + * is a subclass of ProxyContextParent, which manages its own lifetime. The + * class is ultimately instantiated through bgInstance. It can be destroyed by + * bgInstance or externally (e.g. by the context itself or a process crash). + * The reference to the context is cleared as soon as the context is unloaded. + * + * This is currently set when the background has fully loaded. To access the + * background context before that, use |extension.backgroundContext|. + * + * This field is set when extension.backgroundState is RUNNING or SUSPENDING. + */ + context = null; + + /** + * @property {boolean} [canBePrimed] + * + * This property reflects whether persistent listeners can be primed. This + * means that `backgroundState` is `STOPPED` and the listeners haven't been + * primed yet. It is initially `true`, and set to `false` as soon as + * listeners are primed. It can become `true` again if `primeBackground` was + * skipped due to `shouldPrimeBackground` being `false`. + * NOTE: this flag is set for both event pages and persistent background pages. + */ + canBePrimed = true; + + /** + * @property {boolean} [shouldPrimeBackground] + * + * This property controls whether we should prime listeners. Under normal + * conditions, this should always be `true` but when too many crashes have + * occurred, we might have to disable process spawning, which would lead to + * this property being set to `false`. + */ + shouldPrimeBackground = true; + + get #hasEnteredShutdown() { + // This getter is just a small helper to make sure we always check for + // the extension shutdown being already initiated. + // Ordinarily the extension object is expected to be nullified from the + // onShutdown method, but extension.hasShutdown is set earlier and because + // the shutdown goes through some async steps there is a chance for other + // internals to be hit while the hasShutdown flag is set bug onShutdown + // not hit yet. + return this.extension.hasShutdown || Services.startup.shuttingDown; + } + + /** + * @param {BackgroundBuilder} backgroundBuilder + * @param {Extension} extension + */ + constructor(backgroundBuilder, extension) { + this.backgroundBuilder = backgroundBuilder; + this.extension = extension; + this.onExtensionProcessCrashed = this.onExtensionProcessCrashed.bind(this); + this.onApplicationInForeground = this.onApplicationInForeground.bind(this); + this.onExtensionEnableProcessSpawning = + this.onExtensionEnableProcessSpawning.bind(this); + + extension.backgroundState = BACKGROUND_STATE.STOPPED; + + extensions.on("extension-process-crash", this.onExtensionProcessCrashed); + extensions.on( + "extension-enable-process-spawning", + this.onExtensionEnableProcessSpawning + ); + // We only defer handling extension process crashes for persistent + // background context. + if (extension.persistentBackground) { + extensions.on("application-foreground", this.onApplicationInForeground); + } + } + + /** + * setBgStateStarting - right before the background context is initialized. + * + * @param {BackgroundWorker|BackgroundPage} bgInstance + */ + setBgStateStarting(bgInstance) { + if (!this.extension) { + throw new Error(`Cannot start background after extension shutdown.`); + } + if (this.bgInstance) { + throw new Error(`Cannot start multiple background instances`); + } + this.extension.backgroundState = BACKGROUND_STATE.STARTING; + this.bgInstance = bgInstance; + // Often already false, except if we're waking due to a listener that was + // registered with isInStartup=true. + this.canBePrimed = false; + } + + /** + * setBgStateRunning - when the background context has fully loaded. + * + * This method may throw if the background should no longer be active; if that + * is the case, the caller should make sure that the background is cleaned up + * by calling setBgStateStopped. + * + * @param {ExtensionPageContextParent|BackgroundWorkerContextParent} context + */ + setBgStateRunning(context) { + if (!this.extension) { + // Caller should have checked this. + throw new Error(`Extension has shut down before startup completion.`); + } + if (this.context) { + // This can currently not happen - we set the context only once. + // TODO bug 1844217: Handle navigation (bug 1286083). For now, reject. + throw new Error(`Context already set before at startup completion.`); + } + if (!context) { + throw new Error(`Context not found at startup completion.`); + } + if (context.unloaded) { + throw new Error(`Context has unloaded before startup completion.`); + } + this.extension.backgroundState = BACKGROUND_STATE.RUNNING; + this.context = context; + context.callOnClose(this); + + // When the background startup completes successfully, update the set of + // events that should be persisted. + EventManager.clearPrimedListeners(this.extension, true); + + // This notification will be balanced in setBgStateStopped / close. + notifyBackgroundScriptStatus(this.extension.id, true); + + this.extension.emit("background-script-started"); + } + + /** + * setBgStateStopped - when the background context has unloaded or should be + * unloaded. Regardless of the actual state at the entry of this method, upon + * returning the background is considered stopped. + * + * If the context was active at the time of the invocation, the actual unload + * of |this.context| is asynchronous as it may involve a round-trip to the + * child process. + * + * @param {boolean} [isAppShutdown] + */ + setBgStateStopped(isAppShutdown) { + const backgroundState = this.extension.backgroundState; + if (this.context) { + this.context.forgetOnClose(this); + this.context = null; + // This is the counterpart to the notification in setBgStateRunning. + notifyBackgroundScriptStatus(this.extension.id, false); + } + + // We only need to call clearPrimedListeners for states STOPPED and STARTING + // because setBgStateRunning clears all primed listeners when it switches + // from STARTING to RUNNING. Further, the only way to get primed listeners + // is by a primeListeners call, which only happens in the STOPPED state. + if ( + backgroundState === BACKGROUND_STATE.STOPPED || + backgroundState === BACKGROUND_STATE.STARTING + ) { + EventManager.clearPrimedListeners(this.extension, false); + } + + // Ensure there is no backgroundTimer running + this.backgroundBuilder.clearIdleTimer(); + + const bgInstance = this.bgInstance; + if (bgInstance) { + this.bgInstance = null; + isAppShutdown ||= Services.startup.shuttingDown; + // bgInstance.shutdown() unloads the associated context, if any. + bgInstance.shutdown(isAppShutdown); + this.backgroundBuilder.onBgInstanceShutdown(bgInstance); + } + + this.extension.backgroundState = BACKGROUND_STATE.STOPPED; + if (backgroundState === BACKGROUND_STATE.STARTING) { + this.extension.emit("background-script-aborted"); + } + + if (this.extension.hasShutdown) { + this.extension = null; + } else if (this.shouldPrimeBackground) { + // Prime again, so that a stopped background can always be revived when + // needed. + this.backgroundBuilder.primeBackground(false); + } else { + this.canBePrimed = true; + } + } + + // Called by registration via context.callOnClose (if this.context is set). + close() { + // close() is called when: + // - background context unloads (without replacement context). + // - extension process crashes (without replacement context). + // - background context reloads (context likely replaced by new context). + // - background context navigates (context likely replaced by new context). + // + // When the background is gone without replacement, switch to STOPPED. + // TODO bug 1286083: Drop support for navigations. + + // To fully maintain the state, we should call this.setBgStateStopped(); + // But we cannot do that yet because that would close background pages upon + // reload and navigation, which would be a backwards-incompatible change. + // For now, we only do the bare minimum here. + // + // Note that once a navigation or reload starts, that the context is + // untracked. This is a pre-existing issue that we should fix later. + // TODO bug 1844217: Detect context replacement and update this.context. + if (this.context) { + this.context.forgetOnClose(this); + this.context = null; + // This is the counterpart to the notification in setBgStateRunning. + notifyBackgroundScriptStatus(this.extension.id, false); + } + } + + restartPersistentBackgroundAfterCrash() { + const { extension } = this; + if ( + this.#hasEnteredShutdown || + // Ignore if the background state isn't the one expected to be set + // after a crash. + extension.backgroundState !== BACKGROUND_STATE.STOPPED || + // Auto-restart persistent background scripts after crash disabled by prefs. + disableRestartPersistentAfterCrash + ) { + return; + } + + // Persistent background pages are re-primed from setBgStateStopped when we + // are hitting a crash (if the threshold was not exceeded, otherwise they + // are going to be re-primed from onExtensionEnableProcessSpawning). + extension.emit("start-background-script"); + } + + onExtensionEnableProcessSpawning() { + if (this.#hasEnteredShutdown) { + return; + } + + if (!this.canBePrimed) { + return; + } + + // Allow priming again. + this.shouldPrimeBackground = true; + this.backgroundBuilder.primeBackground(false); + + if (this.extension.persistentBackground) { + this.restartPersistentBackgroundAfterCrash(); + } + } + + onApplicationInForeground(eventName, data) { + if ( + this.#hasEnteredShutdown || + // Past the silent crash handling threashold. + data.processSpawningDisabled + ) { + return; + } + + this.restartPersistentBackgroundAfterCrash(); + } + + onExtensionProcessCrashed(eventName, data) { + if (this.#hasEnteredShutdown) { + return; + } + + // data.childID holds the process ID of the crashed extension process. + // For now, assume that there is only one, so clean up unconditionally. + + this.shouldPrimeBackground = !data.processSpawningDisabled; + + // We only need to clean up if a bgInstance has been created. Without it, + // there is only state in the parent process, not the child, and a crashed + // extension process doesn't affect us. + if (this.bgInstance) { + this.setBgStateStopped(); + } + + if (this.extension.persistentBackground) { + // Defer to when back in foreground and/or process spawning is explicitly re-enabled. + if (!data.appInForeground || data.processSpawningDisabled) { + return; + } + + this.restartPersistentBackgroundAfterCrash(); + } + } + + // Called by ExtensionAPI.onShutdown (once). + onShutdown(isAppShutdown) { + // If a background context was active during extension shutdown, then + // close() was called before onShutdown, which clears |this.extension|. + // If the background has not fully started yet, then we have to clear here. + if (this.extension) { + this.setBgStateStopped(isAppShutdown); + } + extensions.off("extension-process-crash", this.onExtensionProcessCrashed); + extensions.off( + "extension-enable-process-spawning", + this.onExtensionEnableProcessSpawning + ); + extensions.off("application-foreground", this.onApplicationInForeground); + } +} + +/** + * BackgroundBuilder manages the creation and parent-triggered termination of + * the background context. Non-parent-triggered terminations are usually due to + * an external cause (e.g. crashes) and detected by BackgroundContextOwner. + * + * Because these external terminations can happen at any time, and the creation + * and suspension of the background context is async, the methods of this + * BackgroundBuilder class necessarily need to check the state of the background + * before proceeding with the operation (and abort + clean up as needed). + * + * The following interruptions are explicitly accounted for: + * - Extension shuts down. + * - Background unloads for any reason. + * - Another background instance starts in the meantime. + */ +class BackgroundBuilder { + constructor(extension) { + this.extension = extension; + this.backgroundContextOwner = new BackgroundContextOwner(this, extension); + } + + async build() { + if (this.backgroundContextOwner.bgInstance) { + return; + } + + let { extension } = this; + let { manifest } = extension; + extension.backgroundState = BACKGROUND_STATE.STARTING; + + this.isWorker = + !!manifest.background.service_worker && + WebExtensionPolicy.backgroundServiceWorkerEnabled; + + let BackgroundClass = this.isWorker ? BackgroundWorker : BackgroundPage; + + const bgInstance = new BackgroundClass(extension, manifest.background); + this.backgroundContextOwner.setBgStateStarting(bgInstance); + let context; + try { + context = await bgInstance.build(); + } catch (e) { + Cu.reportError(e); + // If background startup gets interrupted (e.g. extension shutdown), + // bgInstance.shutdown() is called and backgroundContextOwner.bgInstance + // is cleared. + if (this.backgroundContextOwner.bgInstance === bgInstance) { + this.backgroundContextOwner.setBgStateStopped(); + } + return; + } + + if (context) { + // Wait until all event listeners registered by the script so far + // to be handled. We then set listenerPromises to null, which indicates + // to addListener that the background script has finished loading. + await Promise.all(context.listenerPromises); + context.listenerPromises = null; + } + + if (this.backgroundContextOwner.bgInstance !== bgInstance) { + // Background closed/restarted in the meantime. + return; + } + + try { + this.backgroundContextOwner.setBgStateRunning(context); + } catch (e) { + Cu.reportError(e); + this.backgroundContextOwner.setBgStateStopped(); + } + } + + observe(subject, topic, data) { + if (topic == "timer-callback") { + let { extension } = this; + this.clearIdleTimer(); + extension?.terminateBackground(); + } + } + + clearIdleTimer() { + this.backgroundTimer?.cancel(); + this.backgroundTimer = null; + } + + resetIdleTimer() { + this.clearIdleTimer(); + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.init(this, backgroundIdleTimeout, Ci.nsITimer.TYPE_ONE_SHOT); + this.backgroundTimer = timer; + } + + primeBackground(isInStartup = true) { + let { extension } = this; + + if (this.backgroundContextOwner.bgInstance) { + // This should never happen. The need to prime listeners is mutually + // exclusive with the existence of a background instance. + throw new Error(`bgInstance exists before priming ${extension.id}`); + } + + // Used by runtime messaging to wait for background page listeners. + let bgStartupPromise = new Promise(resolve => { + let done = () => { + extension.off("background-script-started", done); + extension.off("background-script-aborted", done); + extension.off("shutdown", done); + resolve(); + }; + extension.on("background-script-started", done); + extension.on("background-script-aborted", done); + extension.on("shutdown", done); + }); + + extension.promiseBackgroundStarted = () => { + return bgStartupPromise; + }; + + extension.wakeupBackground = () => { + if (extension.hasShutdown) { + return Promise.reject( + new Error( + "wakeupBackground called while the extension was already shutting down" + ) + ); + } + extension.emit("background-script-event"); + // `extension.wakeupBackground` is set back to the original arrow function + // when the background page is terminated and `primeBackground` is called again. + extension.wakeupBackground = () => bgStartupPromise; + return bgStartupPromise; + }; + + let resetBackgroundIdle = (eventName, resetIdleDetails) => { + this.clearIdleTimer(); + if (!this.extension || extension.persistentBackground) { + // Extension was already shut down or is persistent and + // does not idle timout. + return; + } + // TODO remove at an appropriate point in the future prior + // to general availability. There may be some racy conditions + // with idle timeout between an event starting and the event firing + // but we still want testing with an idle timeout. + if ( + !Services.prefs.getBoolPref("extensions.background.idle.enabled", true) + ) { + return; + } + + if ( + extension.backgroundState == BACKGROUND_STATE.SUSPENDING && + // After we begin suspending the background, parent API calls from + // runtime.onSuspend listeners shouldn't cancel the suspension. + resetIdleDetails?.reason !== "parentApiCall" + ) { + extension.backgroundState = BACKGROUND_STATE.RUNNING; + // call runtime.onSuspendCanceled + extension.emit("background-script-suspend-canceled"); + } + + this.resetIdleTimer(); + + if ( + eventName === "background-script-reset-idle" && + // TODO(Bug 1790087): record similar telemetry for background service worker. + !this.isWorker + ) { + // Record the reason for resetting the event page idle timeout + // in a idle result histogram, with the category set based + // on the reason for resetting (defaults to 'reset_other' + // if resetIdleDetails.reason is missing or not mapped into the + // telemetry histogram categories). + // + // Keep this in sync with the categories listed in Histograms.json + // for "WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT". + let category = "reset_other"; + switch (resetIdleDetails?.reason) { + case "event": + category = "reset_event"; + return; // not break; because too frequent, see bug 1868960. + case "hasActiveNativeAppPorts": + category = "reset_nativeapp"; + break; + case "hasActiveStreamFilter": + category = "reset_streamfilter"; + break; + case "pendingListeners": + category = "reset_listeners"; + break; + case "parentApiCall": + category = "reset_parentapicall"; + return; // not break; because too frequent, see bug 1868960. + } + + ExtensionTelemetry.eventPageIdleResult.histogramAdd({ + extension, + category, + }); + } + }; + + // Listen for events from the EventManager + extension.on("background-script-reset-idle", resetBackgroundIdle); + // After the background is started, initiate the first timer + extension.once("background-script-started", resetBackgroundIdle); + + // TODO bug 1844488: terminateBackground should account for externally + // triggered background restarts. It does currently performs various + // backgroundState checks, but it is possible for the background to have + // been crashes or restarted in the meantime. + extension.terminateBackground = async ({ + ignoreDevToolsAttached = false, + disableResetIdleForTest = false, // Disable all reset idle checks for testing purpose. + } = {}) => { + await bgStartupPromise; + if (!this.extension || this.extension.hasShutdown) { + // Extension was already shut down. + return; + } + if (extension.backgroundState != BACKGROUND_STATE.RUNNING) { + return; + } + + if ( + !ignoreDevToolsAttached && + ExtensionParent.DebugUtils.hasDevToolsAttached(extension.id) + ) { + extension.emit("background-script-suspend-ignored"); + return; + } + + // Similar to what happens in recent Chrome version for MV3 extensions, extensions non-persistent + // background scripts with a nativeMessaging port still open or a sendNativeMessage request still + // pending an answer are exempt from being terminated when the idle timeout expires. + // The motivation, as for the similar change that Chrome applies to MV3 extensions, is that using + // the native messaging API have already an higher barrier due to having to specify a native messaging + // host app in their manifest and the user also have to install the native app separately as a native + // application). + if ( + !disableResetIdleForTest && + extension.backgroundContext?.hasActiveNativeAppPorts + ) { + extension.emit("background-script-reset-idle", { + reason: "hasActiveNativeAppPorts", + }); + return; + } + + if ( + !disableResetIdleForTest && + extension.backgroundContext?.pendingRunListenerPromisesCount + ) { + extension.emit("background-script-reset-idle", { + reason: "pendingListeners", + pendingListeners: + extension.backgroundContext.pendingRunListenerPromisesCount, + }); + // Clear the pending promises being tracked when we have reset the idle + // once because some where still pending, so that the pending listeners + // calls can reset the idle timer only once. + extension.backgroundContext.clearPendingRunListenerPromises(); + return; + } + + const childId = extension.backgroundContext?.childId; + if ( + childId !== undefined && + extension.hasPermission("webRequestBlocking") && + (extension.manifestVersion <= 3 || + extension.hasPermission("webRequestFilterResponse")) + ) { + // Ask to the background page context in the child process to check if there are + // StreamFilter instances active (e.g. ones with status "transferringdata" or "suspended", + // see StreamFilterStatus enum defined in StreamFilter.webidl). + // TODO(Bug 1748533): consider additional changes to prevent a StreamFilter that never gets to an + // inactive state from preventing an even page from being ever suspended. + const hasActiveStreamFilter = + await ExtensionParent.ParentAPIManager.queryStreamFilterSuspendCancel( + extension.backgroundContext.childId + ).catch(err => { + // an AbortError raised from the JSWindowActor is expected if the background page was already been + // terminated in the meantime, and so we only log the errors that don't match these particular conditions. + if ( + extension.backgroundState == BACKGROUND_STATE.STOPPED && + DOMException.isInstance(err) && + err.name === "AbortError" + ) { + return false; + } + Cu.reportError(err); + return false; + }); + if (!disableResetIdleForTest && hasActiveStreamFilter) { + extension.emit("background-script-reset-idle", { + reason: "hasActiveStreamFilter", + }); + return; + } + + // Return earlier if extension have started or completed its shutdown in the meantime. + if ( + extension.backgroundState !== BACKGROUND_STATE.RUNNING || + extension.hasShutdown + ) { + return; + } + } + + extension.backgroundState = BACKGROUND_STATE.SUSPENDING; + this.clearIdleTimer(); + // call runtime.onSuspend + await extension.emit("background-script-suspend"); + // If in the meantime another event fired, state will be RUNNING, + // and if it was shutdown it will be STOPPED. + if (extension.backgroundState != BACKGROUND_STATE.SUSPENDING) { + return; + } + extension.off("background-script-reset-idle", resetBackgroundIdle); + + // TODO(Bug 1790087): record similar telemetry for background service worker. + if (!this.isWorker) { + ExtensionTelemetry.eventPageIdleResult.histogramAdd({ + extension, + category: "suspend", + }); + } + + this.backgroundContextOwner.setBgStateStopped(false); + }; + + EventManager.primeListeners(extension, isInStartup); + // Avoid setting the flag to false when called during extension startup. + if (!isInStartup) { + this.backgroundContextOwner.canBePrimed = false; + } + + // TODO: start-background-script and background-script-event should be + // unregistered when build() starts or when the extension shuts down. + extension.once("start-background-script", async () => { + if (!this.extension || this.extension.hasShutdown) { + // Extension was shut down. Don't build the background page. + // Primed listeners have been cleared in onShutdown. + return; + } + await this.build(); + }); + + // There are two ways to start the background page: + // 1. If a primed event fires, then start the background page as + // soon as we have painted a browser window. + // 2. After all windows have been restored on startup (see onManifestEntry). + extension.once("background-script-event", async () => { + await ExtensionParent.browserPaintedPromise; + extension.emit("start-background-script"); + }); + } + + onBgInstanceShutdown(bgInstance) { + const { msSinceCreated } = bgInstance; + const { extension } = this; + + // Emit an event for tests. + extension.emit("shutdown-background-script"); + + if (msSinceCreated) { + const now = msSinceProcessStartExcludingSuspend(); + if ( + now && + // TODO(Bug 1790087): record similar telemetry for background service worker. + !(this.isWorker || extension.persistentBackground) + ) { + ExtensionTelemetry.eventPageRunningTime.histogramAdd({ + extension, + value: now - msSinceCreated, + }); + } + } + } +} + +this.backgroundPage = class extends ExtensionAPI { + async onManifestEntry(entryName) { + let { extension } = this; + + // When in PPB background pages all run in a private context. This check + // simply avoids an extraneous error in the console since the BaseContext + // will throw. + if ( + PrivateBrowsingUtils.permanentPrivateBrowsing && + !extension.privateBrowsingAllowed + ) { + return; + } + + this.backgroundBuilder = new BackgroundBuilder(extension); + + // runtime.onStartup event support. We listen for the first + // background startup then emit a first-run event. + extension.once("background-script-started", () => { + extension.emit("background-first-run"); + }); + + this.backgroundBuilder.primeBackground(); + + // Persistent backgrounds are started immediately except during APP_STARTUP. + // Non-persistent backgrounds must be started immediately for new install or enable + // to initialize the addon and create the persisted listeners. + // updateReason is set when an extension is updated during APP_STARTUP. + if ( + extension.testNoDelayedStartup || + extension.startupReason !== "APP_STARTUP" || + extension.updateReason + ) { + // TODO bug 1543354: Avoid AsyncShutdown timeouts by removing the await + // here, at least for non-test situations. + await this.backgroundBuilder.build(); + + // The task in ExtensionParent.browserPaintedPromise below would be fully + // skipped because of the above build() that sets bgInstance. Return early + // so that it is obvious that the logic is skipped. + return; + } + + ExtensionParent.browserStartupPromise.then(() => { + // Return early if the background has started in the meantime. This can + // happen if a primed listener (isInStartup) has been triggered. + if ( + !this.backgroundBuilder || + this.backgroundBuilder.backgroundContextOwner.bgInstance || + !this.backgroundBuilder.backgroundContextOwner.canBePrimed + ) { + return; + } + + // We either start the background page immediately, or fully prime for + // real. + this.backgroundBuilder.backgroundContextOwner.canBePrimed = false; + + // If there are no listeners for the extension that were persisted, we need to + // start the event page so they can be registered. + if ( + extension.persistentBackground || + !extension.persistentListeners?.size || + // If runtime.onStartup has a listener and this is app_startup, + // start the extension so it will fire the event. + (extension.startupReason == "APP_STARTUP" && + extension.persistentListeners?.get("runtime").has("onStartup")) + ) { + extension.emit("start-background-script"); + } else { + // During startup we only prime startup blocking listeners. At + // this stage we need to prime all listeners for event pages. + EventManager.clearPrimedListeners(extension, false); + // Allow re-priming by deleting existing listeners. + extension.persistentListeners = null; + EventManager.primeListeners(extension, false); + } + }); + } + + onShutdown(isAppShutdown) { + if (this.backgroundBuilder) { + this.backgroundBuilder.backgroundContextOwner.onShutdown(isAppShutdown); + this.backgroundBuilder = null; + } + } +}; diff --git a/toolkit/components/extensions/parent/ext-browserSettings.js b/toolkit/components/extensions/parent/ext-browserSettings.js new file mode 100644 index 0000000000..7b292f76b8 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-browserSettings.js @@ -0,0 +1,592 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs", +}); + +var { ExtensionPreferencesManager } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPreferencesManager.sys.mjs" +); + +var { ExtensionError } = ExtensionUtils; +var { getSettingsAPI, getPrimedSettingsListener } = ExtensionPreferencesManager; + +const HOMEPAGE_URL_PREF = "browser.startup.homepage"; + +const PERM_DENY_ACTION = Services.perms.DENY_ACTION; + +// Add settings objects for supported APIs to the preferences manager. +ExtensionPreferencesManager.addSetting("allowPopupsForUserEvents", { + permission: "browserSettings", + prefNames: ["dom.popup_allowed_events"], + + setCallback(value) { + let returnObj = {}; + // If the value is true, then reset the pref, otherwise set it to "". + returnObj[this.prefNames[0]] = value ? undefined : ""; + return returnObj; + }, + + getCallback() { + return Services.prefs.getCharPref("dom.popup_allowed_events") != ""; + }, +}); + +ExtensionPreferencesManager.addSetting("cacheEnabled", { + permission: "browserSettings", + prefNames: ["browser.cache.disk.enable", "browser.cache.memory.enable"], + + setCallback(value) { + let returnObj = {}; + for (let pref of this.prefNames) { + returnObj[pref] = value; + } + return returnObj; + }, + + getCallback() { + return ( + Services.prefs.getBoolPref("browser.cache.disk.enable") && + Services.prefs.getBoolPref("browser.cache.memory.enable") + ); + }, +}); + +ExtensionPreferencesManager.addSetting("closeTabsByDoubleClick", { + permission: "browserSettings", + prefNames: ["browser.tabs.closeTabByDblclick"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return Services.prefs.getBoolPref("browser.tabs.closeTabByDblclick"); + }, + + validate() { + if (AppConstants.platform == "android") { + throw new ExtensionError( + `android is not a supported platform for the closeTabsByDoubleClick setting.` + ); + } + }, +}); + +ExtensionPreferencesManager.addSetting("colorManagement.mode", { + permission: "browserSettings", + prefNames: ["gfx.color_management.mode"], + + setCallback(value) { + switch (value) { + case "off": + return { [this.prefNames[0]]: 0 }; + case "full": + return { [this.prefNames[0]]: 1 }; + case "tagged_only": + return { [this.prefNames[0]]: 2 }; + } + }, + + getCallback() { + switch (Services.prefs.getIntPref("gfx.color_management.mode")) { + case 0: + return "off"; + case 1: + return "full"; + case 2: + return "tagged_only"; + } + }, +}); + +ExtensionPreferencesManager.addSetting("colorManagement.useNativeSRGB", { + permission: "browserSettings", + prefNames: ["gfx.color_management.native_srgb"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return Services.prefs.getBoolPref("gfx.color_management.native_srgb"); + }, +}); + +ExtensionPreferencesManager.addSetting( + "colorManagement.useWebRenderCompositor", + { + permission: "browserSettings", + prefNames: ["gfx.webrender.compositor"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return Services.prefs.getBoolPref("gfx.webrender.compositor"); + }, + } +); + +ExtensionPreferencesManager.addSetting("contextMenuShowEvent", { + permission: "browserSettings", + prefNames: ["ui.context_menus.after_mouseup"], + + setCallback(value) { + return { [this.prefNames[0]]: value === "mouseup" }; + }, + + getCallback() { + if (AppConstants.platform === "win") { + return "mouseup"; + } + let prefValue = Services.prefs.getBoolPref( + "ui.context_menus.after_mouseup", + null + ); + return prefValue ? "mouseup" : "mousedown"; + }, +}); + +ExtensionPreferencesManager.addSetting("imageAnimationBehavior", { + permission: "browserSettings", + prefNames: ["image.animation_mode"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return Services.prefs.getCharPref("image.animation_mode"); + }, +}); + +ExtensionPreferencesManager.addSetting("newTabPosition", { + permission: "browserSettings", + prefNames: [ + "browser.tabs.insertRelatedAfterCurrent", + "browser.tabs.insertAfterCurrent", + ], + + setCallback(value) { + return { + "browser.tabs.insertAfterCurrent": value === "afterCurrent", + "browser.tabs.insertRelatedAfterCurrent": value === "relatedAfterCurrent", + }; + }, + + getCallback() { + if (Services.prefs.getBoolPref("browser.tabs.insertAfterCurrent")) { + return "afterCurrent"; + } + if (Services.prefs.getBoolPref("browser.tabs.insertRelatedAfterCurrent")) { + return "relatedAfterCurrent"; + } + return "atEnd"; + }, +}); + +ExtensionPreferencesManager.addSetting("openBookmarksInNewTabs", { + permission: "browserSettings", + prefNames: ["browser.tabs.loadBookmarksInTabs"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return Services.prefs.getBoolPref("browser.tabs.loadBookmarksInTabs"); + }, +}); + +ExtensionPreferencesManager.addSetting("openSearchResultsInNewTabs", { + permission: "browserSettings", + prefNames: ["browser.search.openintab"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return Services.prefs.getBoolPref("browser.search.openintab"); + }, +}); + +ExtensionPreferencesManager.addSetting("openUrlbarResultsInNewTabs", { + permission: "browserSettings", + prefNames: ["browser.urlbar.openintab"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return Services.prefs.getBoolPref("browser.urlbar.openintab"); + }, +}); + +ExtensionPreferencesManager.addSetting("webNotificationsDisabled", { + permission: "browserSettings", + prefNames: ["permissions.default.desktop-notification"], + + setCallback(value) { + return { [this.prefNames[0]]: value ? PERM_DENY_ACTION : undefined }; + }, + + getCallback() { + let prefValue = Services.prefs.getIntPref( + "permissions.default.desktop-notification", + null + ); + return prefValue === PERM_DENY_ACTION; + }, +}); + +ExtensionPreferencesManager.addSetting("overrideDocumentColors", { + permission: "browserSettings", + prefNames: ["browser.display.document_color_use"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + let prefValue = Services.prefs.getIntPref( + "browser.display.document_color_use" + ); + if (prefValue === 1) { + return "never"; + } else if (prefValue === 2) { + return "always"; + } + return "high-contrast-only"; + }, +}); + +ExtensionPreferencesManager.addSetting("overrideContentColorScheme", { + permission: "browserSettings", + prefNames: ["layout.css.prefers-color-scheme.content-override"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + let prefValue = Services.prefs.getIntPref( + "layout.css.prefers-color-scheme.content-override" + ); + switch (prefValue) { + case 0: + return "dark"; + case 1: + return "light"; + default: + return "auto"; + } + }, +}); + +ExtensionPreferencesManager.addSetting("useDocumentFonts", { + permission: "browserSettings", + prefNames: ["browser.display.use_document_fonts"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return ( + Services.prefs.getIntPref("browser.display.use_document_fonts") !== 0 + ); + }, +}); + +ExtensionPreferencesManager.addSetting("zoomFullPage", { + permission: "browserSettings", + prefNames: ["browser.zoom.full"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return Services.prefs.getBoolPref("browser.zoom.full"); + }, +}); + +ExtensionPreferencesManager.addSetting("zoomSiteSpecific", { + permission: "browserSettings", + prefNames: ["browser.zoom.siteSpecific"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return Services.prefs.getBoolPref("browser.zoom.siteSpecific"); + }, +}); + +this.browserSettings = class extends ExtensionAPI { + homePageOverrideListener(fire) { + let listener = () => { + fire.async({ + levelOfControl: "not_controllable", + value: Services.prefs.getStringPref(HOMEPAGE_URL_PREF), + }); + }; + Services.prefs.addObserver(HOMEPAGE_URL_PREF, listener); + return { + unregister: () => { + Services.prefs.removeObserver(HOMEPAGE_URL_PREF, listener); + }, + convert(_fire) { + fire = _fire; + }, + }; + } + + newTabOverrideListener(fire) { + let listener = () => { + fire.async({ + levelOfControl: "not_controllable", + value: AboutNewTab.newTabURL, + }); + }; + Services.obs.addObserver(listener, "newtab-url-changed"); + return { + unregister: () => { + Services.obs.removeObserver(listener, "newtab-url-changed"); + }, + convert(_fire) { + fire = _fire; + }, + }; + } + + primeListener(event, fire) { + let { extension } = this; + if (event == "homepageOverride") { + return this.homePageOverrideListener(fire); + } + if (event == "newTabPageOverride") { + return this.newTabOverrideListener(fire); + } + let listener = getPrimedSettingsListener({ + extension, + name: event, + }); + return listener(fire); + } + + getAPI(context) { + let self = this; + let { extension } = context; + + function makeSettingsAPI(name) { + return getSettingsAPI({ + context, + module: "browserSettings", + name, + }); + } + + return { + browserSettings: { + allowPopupsForUserEvents: makeSettingsAPI("allowPopupsForUserEvents"), + cacheEnabled: makeSettingsAPI("cacheEnabled"), + closeTabsByDoubleClick: makeSettingsAPI("closeTabsByDoubleClick"), + contextMenuShowEvent: Object.assign( + makeSettingsAPI("contextMenuShowEvent"), + { + set: details => { + if (!["mouseup", "mousedown"].includes(details.value)) { + throw new ExtensionError( + `${details.value} is not a valid value for contextMenuShowEvent.` + ); + } + if ( + AppConstants.platform === "android" || + (AppConstants.platform === "win" && + details.value === "mousedown") + ) { + return false; + } + return ExtensionPreferencesManager.setSetting( + extension.id, + "contextMenuShowEvent", + details.value + ); + }, + } + ), + ftpProtocolEnabled: getSettingsAPI({ + context, + name: "ftpProtocolEnabled", + readOnly: true, + callback() { + return false; + }, + }), + homepageOverride: getSettingsAPI({ + context, + // Name differs here to preserve this setting properly + name: "homepage_override", + callback() { + return Services.prefs.getStringPref(HOMEPAGE_URL_PREF); + }, + readOnly: true, + onChange: new ExtensionCommon.EventManager({ + context, + module: "browserSettings", + event: "homepageOverride", + name: "homepageOverride.onChange", + register: fire => { + return self.homePageOverrideListener(fire).unregister; + }, + }).api(), + }), + imageAnimationBehavior: makeSettingsAPI("imageAnimationBehavior"), + newTabPosition: makeSettingsAPI("newTabPosition"), + newTabPageOverride: getSettingsAPI({ + context, + // Name differs here to preserve this setting properly + name: "newTabURL", + callback() { + return AboutNewTab.newTabURL; + }, + storeType: "url_overrides", + readOnly: true, + onChange: new ExtensionCommon.EventManager({ + context, + module: "browserSettings", + event: "newTabPageOverride", + name: "newTabPageOverride.onChange", + register: fire => { + return self.newTabOverrideListener(fire).unregister; + }, + }).api(), + }), + openBookmarksInNewTabs: makeSettingsAPI("openBookmarksInNewTabs"), + openSearchResultsInNewTabs: makeSettingsAPI( + "openSearchResultsInNewTabs" + ), + openUrlbarResultsInNewTabs: makeSettingsAPI( + "openUrlbarResultsInNewTabs" + ), + webNotificationsDisabled: makeSettingsAPI("webNotificationsDisabled"), + overrideDocumentColors: Object.assign( + makeSettingsAPI("overrideDocumentColors"), + { + set: details => { + if ( + !["never", "always", "high-contrast-only"].includes( + details.value + ) + ) { + throw new ExtensionError( + `${details.value} is not a valid value for overrideDocumentColors.` + ); + } + let prefValue = 0; // initialize to 0 - auto/high-contrast-only + if (details.value === "never") { + prefValue = 1; + } else if (details.value === "always") { + prefValue = 2; + } + return ExtensionPreferencesManager.setSetting( + extension.id, + "overrideDocumentColors", + prefValue + ); + }, + } + ), + overrideContentColorScheme: Object.assign( + makeSettingsAPI("overrideContentColorScheme"), + { + set: details => { + let value = details.value; + if (value == "system" || value == "browser") { + // Map previous values that used to be different but were + // unified under the "auto" setting. In practice this should + // almost always behave like the extension author expects. + extension.logger.warn( + `The "${value}" value for overrideContentColorScheme has been deprecated. Use "auto" instead` + ); + value = "auto"; + } + let prefValue = ["dark", "light", "auto"].indexOf(value); + if (prefValue === -1) { + throw new ExtensionError( + `${value} is not a valid value for overrideContentColorScheme.` + ); + } + return ExtensionPreferencesManager.setSetting( + extension.id, + "overrideContentColorScheme", + prefValue + ); + }, + } + ), + useDocumentFonts: Object.assign(makeSettingsAPI("useDocumentFonts"), { + set: details => { + if (typeof details.value !== "boolean") { + throw new ExtensionError( + `${details.value} is not a valid value for useDocumentFonts.` + ); + } + return ExtensionPreferencesManager.setSetting( + extension.id, + "useDocumentFonts", + Number(details.value) + ); + }, + }), + zoomFullPage: Object.assign(makeSettingsAPI("zoomFullPage"), { + set: details => { + if (typeof details.value !== "boolean") { + throw new ExtensionError( + `${details.value} is not a valid value for zoomFullPage.` + ); + } + return ExtensionPreferencesManager.setSetting( + extension.id, + "zoomFullPage", + details.value + ); + }, + }), + zoomSiteSpecific: Object.assign(makeSettingsAPI("zoomSiteSpecific"), { + set: details => { + if (typeof details.value !== "boolean") { + throw new ExtensionError( + `${details.value} is not a valid value for zoomSiteSpecific.` + ); + } + return ExtensionPreferencesManager.setSetting( + extension.id, + "zoomSiteSpecific", + details.value + ); + }, + }), + colorManagement: { + mode: makeSettingsAPI("colorManagement.mode"), + useNativeSRGB: makeSettingsAPI("colorManagement.useNativeSRGB"), + useWebRenderCompositor: makeSettingsAPI( + "colorManagement.useWebRenderCompositor" + ), + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-browsingData.js b/toolkit/components/extensions/parent/ext-browsingData.js new file mode 100644 index 0000000000..d06f7a3a1b --- /dev/null +++ b/toolkit/components/extensions/parent/ext-browsingData.js @@ -0,0 +1,405 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + // This helper contains the platform-specific bits of browsingData. + BrowsingDataDelegate: "resource:///modules/ExtensionBrowsingData.sys.mjs", + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + ServiceWorkerCleanUp: "resource://gre/modules/ServiceWorkerCleanUp.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +/** + * A number of iterations after which to yield time back + * to the system. + */ +const YIELD_PERIOD = 10; + +/** + * Convert a Date object to a PRTime (microseconds). + * + * @param {Date} date + * the Date object to convert. + * @returns {integer} microseconds from the epoch. + */ +const toPRTime = date => { + if (typeof date != "number" && date.constructor.name != "Date") { + throw new Error("Invalid value passed to toPRTime"); + } + return date * 1000; +}; + +const makeRange = options => { + return options.since == null + ? null + : [toPRTime(options.since), toPRTime(Date.now())]; +}; +global.makeRange = makeRange; + +// General implementation for clearing data using Services.clearData. +// Currently Sanitizer.items uses this under the hood. +async function clearData(options, flags) { + if (options.hostnames) { + await Promise.all( + options.hostnames.map( + host => + new Promise(resolve => { + // Set aIsUserRequest to true. This means when the ClearDataService + // "Cleaner" implementation doesn't support clearing by host + // it will delete all data instead. + // This is appropriate for cases like |cache|, which doesn't + // support clearing by a time range. + // In future when we use this for other data types, we have to + // evaluate if that behavior is still acceptable. + Services.clearData.deleteDataFromHost(host, true, flags, resolve); + }) + ) + ); + return; + } + + if (options.since) { + const range = makeRange(options); + await new Promise(resolve => { + Services.clearData.deleteDataInTimeRange(...range, true, flags, resolve); + }); + return; + } + + // Don't return the promise here and above to prevent leaking the resolved + // value. + await new Promise(resolve => Services.clearData.deleteData(flags, resolve)); +} + +const clearCache = options => { + return clearData(options, Ci.nsIClearDataService.CLEAR_ALL_CACHES); +}; + +const clearCookies = async function (options) { + let cookieMgr = Services.cookies; + // This code has been borrowed from Sanitizer.jsm. + let yieldCounter = 0; + + if (options.since || options.hostnames || options.cookieStoreId) { + // Iterate through the cookies and delete any created after our cutoff. + let cookies = cookieMgr.cookies; + if ( + !options.cookieStoreId || + isPrivateCookieStoreId(options.cookieStoreId) + ) { + // By default nsICookieManager.cookies doesn't contain private cookies. + const privateCookies = cookieMgr.getCookiesWithOriginAttributes( + JSON.stringify({ + privateBrowsingId: 1, + }) + ); + cookies = cookies.concat(privateCookies); + } + for (const cookie of cookies) { + if ( + (!options.since || cookie.creationTime >= toPRTime(options.since)) && + (!options.hostnames || + options.hostnames.includes(cookie.host.replace(/^\./, ""))) && + (!options.cookieStoreId || + getCookieStoreIdForOriginAttributes(cookie.originAttributes) === + options.cookieStoreId) + ) { + // This cookie was created after our cutoff, clear it. + cookieMgr.remove( + cookie.host, + cookie.name, + cookie.path, + cookie.originAttributes + ); + + if (++yieldCounter % YIELD_PERIOD == 0) { + await new Promise(resolve => setTimeout(resolve, 0)); // Don't block the main thread too long. + } + } + } + } else { + // Remove everything. + cookieMgr.removeAll(); + } +}; + +// Ideally we could reuse the logic in Sanitizer.jsm or nsIClearDataService, +// but this API exposes an ability to wipe data at a much finger granularity +// than those APIs. (See also Bug 1531276) +async function clearQuotaManager(options, dataType) { + // Can not clear localStorage/indexedDB in private browsing mode, + // just ignore. + if (options.cookieStoreId == PRIVATE_STORE) { + return; + } + + let promises = []; + await new Promise((resolve, reject) => { + Services.qms.getUsage(request => { + if (request.resultCode != Cr.NS_OK) { + reject({ message: `Clear ${dataType} failed` }); + return; + } + + for (let item of request.result) { + let principal = + Services.scriptSecurityManager.createContentPrincipalFromOrigin( + item.origin + ); + + // Consistently to removeIndexedDB and the API documentation for + // removeLocalStorage, we should only clear the data stored by + // regular websites, on the contrary we shouldn't clear data stored + // by browser components (like about:newtab) or other extensions. + if (!["http", "https", "file"].includes(principal.scheme)) { + continue; + } + + let host = principal.hostPort; + if ( + (!options.hostnames || options.hostnames.includes(host)) && + (!options.cookieStoreId || + getCookieStoreIdForOriginAttributes(principal.originAttributes) === + options.cookieStoreId) + ) { + promises.push( + new Promise((resolve, reject) => { + let clearRequest; + if (dataType === "indexedDB") { + clearRequest = Services.qms.clearStoragesForPrincipal( + principal, + null, + "idb" + ); + } else { + clearRequest = Services.qms.clearStoragesForPrincipal( + principal, + "default", + "ls" + ); + } + + clearRequest.callback = () => { + if (clearRequest.resultCode == Cr.NS_OK) { + resolve(); + } else { + reject({ message: `Clear ${dataType} failed` }); + } + }; + }) + ); + } + } + + resolve(); + }); + }); + + return Promise.all(promises); +} + +const clearIndexedDB = async function (options) { + return clearQuotaManager(options, "indexedDB"); +}; + +const clearLocalStorage = async function (options) { + if (options.since) { + return Promise.reject({ + message: "Firefox does not support clearing localStorage with 'since'.", + }); + } + + // The legacy LocalStorage implementation that will eventually be removed + // depends on this observer notification. Some other subsystems like + // Reporting headers depend on this too. + // When NextGenLocalStorage is enabled these notifications are ignored. + if (options.hostnames) { + for (let hostname of options.hostnames) { + Services.obs.notifyObservers( + null, + "extension:purge-localStorage", + hostname + ); + } + } else { + Services.obs.notifyObservers(null, "extension:purge-localStorage"); + } + + if (Services.domStorageManager.nextGenLocalStorageEnabled) { + return clearQuotaManager(options, "localStorage"); + } +}; + +const clearPasswords = async function (options) { + let yieldCounter = 0; + + // Iterate through the logins and delete any updated after our cutoff. + for (let login of await LoginHelper.getAllUserFacingLogins()) { + login.QueryInterface(Ci.nsILoginMetaInfo); + if (!options.since || login.timePasswordChanged >= options.since) { + Services.logins.removeLogin(login); + if (++yieldCounter % YIELD_PERIOD == 0) { + await new Promise(resolve => setTimeout(resolve, 0)); // Don't block the main thread too long. + } + } + } +}; + +const clearServiceWorkers = options => { + if (!options.hostnames) { + return ServiceWorkerCleanUp.removeAll(); + } + + return Promise.all( + options.hostnames.map(host => { + return ServiceWorkerCleanUp.removeFromHost(host); + }) + ); +}; + +class BrowsingDataImpl { + constructor(extension) { + this.extension = extension; + // Some APIs cannot implement in a platform-independent way and they are + // delegated to a platform-specific delegate. + this.platformDelegate = new BrowsingDataDelegate(extension); + } + + handleRemoval(dataType, options) { + // First, let's see if the platform implements this + let result = this.platformDelegate.handleRemoval(dataType, options); + if (result !== undefined) { + return result; + } + + // ... if not, run the default behavior. + switch (dataType) { + case "cache": + return clearCache(options); + case "cookies": + return clearCookies(options); + case "indexedDB": + return clearIndexedDB(options); + case "localStorage": + return clearLocalStorage(options); + case "passwords": + return clearPasswords(options); + case "pluginData": + this.extension?.logger.warn( + "pluginData has been deprecated (along with Flash plugin support)" + ); + return Promise.resolve(); + case "serviceWorkers": + return clearServiceWorkers(options); + default: + return undefined; + } + } + + doRemoval(options, dataToRemove) { + if ( + options.originTypes && + (options.originTypes.protectedWeb || options.originTypes.extension) + ) { + return Promise.reject({ + message: + "Firefox does not support protectedWeb or extension as originTypes.", + }); + } + + if (options.cookieStoreId) { + const SUPPORTED_TYPES = ["cookies", "indexedDB"]; + if (Services.domStorageManager.nextGenLocalStorageEnabled) { + // Only the next-gen storage supports removal by cookieStoreId. + SUPPORTED_TYPES.push("localStorage"); + } + + for (let dataType in dataToRemove) { + if (dataToRemove[dataType] && !SUPPORTED_TYPES.includes(dataType)) { + return Promise.reject({ + message: `Firefox does not support clearing ${dataType} with 'cookieStoreId'.`, + }); + } + } + + if ( + !isPrivateCookieStoreId(options.cookieStoreId) && + !isDefaultCookieStoreId(options.cookieStoreId) && + !getContainerForCookieStoreId(options.cookieStoreId) + ) { + return Promise.reject({ + message: `Invalid cookieStoreId: ${options.cookieStoreId}`, + }); + } + } + + let removalPromises = []; + let invalidDataTypes = []; + for (let dataType in dataToRemove) { + if (dataToRemove[dataType]) { + let result = this.handleRemoval(dataType, options); + if (result === undefined) { + invalidDataTypes.push(dataType); + } else { + removalPromises.push(result); + } + } + } + if (invalidDataTypes.length) { + this.extension.logger.warn( + `Firefox does not support dataTypes: ${invalidDataTypes.toString()}.` + ); + } + return Promise.all(removalPromises); + } + + settings() { + return this.platformDelegate.settings(); + } +} + +this.browsingData = class extends ExtensionAPI { + getAPI(context) { + const impl = new BrowsingDataImpl(context.extension); + return { + browsingData: { + settings() { + return impl.settings(); + }, + remove(options, dataToRemove) { + return impl.doRemoval(options, dataToRemove); + }, + removeCache(options) { + return impl.doRemoval(options, { cache: true }); + }, + removeCookies(options) { + return impl.doRemoval(options, { cookies: true }); + }, + removeDownloads(options) { + return impl.doRemoval(options, { downloads: true }); + }, + removeFormData(options) { + return impl.doRemoval(options, { formData: true }); + }, + removeHistory(options) { + return impl.doRemoval(options, { history: true }); + }, + removeIndexedDB(options) { + return impl.doRemoval(options, { indexedDB: true }); + }, + removeLocalStorage(options) { + return impl.doRemoval(options, { localStorage: true }); + }, + removePasswords(options) { + return impl.doRemoval(options, { passwords: true }); + }, + removePluginData(options) { + return impl.doRemoval(options, { pluginData: true }); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-captivePortal.js b/toolkit/components/extensions/parent/ext-captivePortal.js new file mode 100644 index 0000000000..547abaa594 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-captivePortal.js @@ -0,0 +1,158 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ +"use strict"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "gCPS", + "@mozilla.org/network/captive-portal-service;1", + "nsICaptivePortalService" +); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "gCaptivePortalEnabled", + "network.captive-portal-service.enabled", + false +); + +var { ExtensionPreferencesManager } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPreferencesManager.sys.mjs" +); + +var { getSettingsAPI } = ExtensionPreferencesManager; + +const CAPTIVE_URL_PREF = "captivedetect.canonicalURL"; + +var { ExtensionError } = ExtensionUtils; + +this.captivePortal = class extends ExtensionAPIPersistent { + checkCaptivePortalEnabled() { + if (!gCaptivePortalEnabled) { + throw new ExtensionError("Captive Portal detection is not enabled"); + } + } + + nameForCPSState(state) { + switch (state) { + case gCPS.UNKNOWN: + return "unknown"; + case gCPS.NOT_CAPTIVE: + return "not_captive"; + case gCPS.UNLOCKED_PORTAL: + return "unlocked_portal"; + case gCPS.LOCKED_PORTAL: + return "locked_portal"; + default: + return "unknown"; + } + } + + PERSISTENT_EVENTS = { + onStateChanged({ fire }) { + this.checkCaptivePortalEnabled(); + + let observer = (subject, topic) => { + fire.async({ state: this.nameForCPSState(gCPS.state) }); + }; + + Services.obs.addObserver( + observer, + "ipc:network:captive-portal-set-state" + ); + return { + unregister: () => { + Services.obs.removeObserver( + observer, + "ipc:network:captive-portal-set-state" + ); + }, + convert(_fire, context) { + fire = _fire; + }, + }; + }, + onConnectivityAvailable({ fire }) { + this.checkCaptivePortalEnabled(); + + let observer = (subject, topic, data) => { + fire.async({ status: data }); + }; + + Services.obs.addObserver(observer, "network:captive-portal-connectivity"); + return { + unregister: () => { + Services.obs.removeObserver( + observer, + "network:captive-portal-connectivity" + ); + }, + convert(_fire, context) { + fire = _fire; + }, + }; + }, + "captiveURL.onChange": ({ fire }) => { + let listener = (text, id) => { + fire.async({ + levelOfControl: "not_controllable", + value: Services.prefs.getStringPref(CAPTIVE_URL_PREF), + }); + }; + Services.prefs.addObserver(CAPTIVE_URL_PREF, listener); + return { + unregister: () => { + Services.prefs.removeObserver(CAPTIVE_URL_PREF, listener); + }, + convert(_fire, context) { + fire = _fire; + }, + }; + }, + }; + + getAPI(context) { + let self = this; + return { + captivePortal: { + getState() { + self.checkCaptivePortalEnabled(); + return self.nameForCPSState(gCPS.state); + }, + getLastChecked() { + self.checkCaptivePortalEnabled(); + return gCPS.lastChecked; + }, + onStateChanged: new EventManager({ + context, + module: "captivePortal", + event: "onStateChanged", + extensionApi: self, + }).api(), + onConnectivityAvailable: new EventManager({ + context, + module: "captivePortal", + event: "onConnectivityAvailable", + extensionApi: self, + }).api(), + canonicalURL: getSettingsAPI({ + context, + name: "captiveURL", + callback() { + return Services.prefs.getStringPref(CAPTIVE_URL_PREF); + }, + readOnly: true, + onChange: new ExtensionCommon.EventManager({ + context, + module: "captivePortal", + event: "captiveURL.onChange", + extensionApi: self, + }).api(), + }), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-clipboard.js b/toolkit/components/extensions/parent/ext-clipboard.js new file mode 100644 index 0000000000..9916b14be7 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-clipboard.js @@ -0,0 +1,87 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +"use strict"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "imgTools", + "@mozilla.org/image/tools;1", + "imgITools" +); + +const Transferable = Components.Constructor( + "@mozilla.org/widget/transferable;1", + "nsITransferable" +); + +this.clipboard = class extends ExtensionAPI { + getAPI(context) { + return { + clipboard: { + async setImageData(imageData, imageType) { + if (AppConstants.platform == "android") { + return Promise.reject({ + message: + "Writing images to the clipboard is not supported on Android", + }); + } + let img; + try { + img = imgTools.decodeImageFromArrayBuffer( + imageData, + `image/${imageType}` + ); + } catch (e) { + return Promise.reject({ + message: `Data is not a valid ${imageType} image`, + }); + } + + // Other applications can only access the copied image once the data + // is exported via the platform-specific clipboard APIs: + // nsClipboard::SelectionGetEvent (widget/gtk/nsClipboard.cpp) + // nsClipboard::PasteDictFromTransferable (widget/cocoa/nsClipboard.mm) + // nsDataObj::GetDib (widget/windows/nsDataObj.cpp) + // + // The common protocol for exporting a nsITransferable as an image is: + // - Use nsITransferable::GetTransferData to fetch the stored data. + // - QI imgIContainer on the pointer. + // - Convert the image to the native clipboard format. + // + // Below we create a nsITransferable in the above format. + let transferable = new Transferable(); + transferable.init(null); + const kNativeImageMime = "application/x-moz-nativeimage"; + transferable.addDataFlavor(kNativeImageMime); + + // Internal consumers expect the image data to be stored as a + // nsIInputStream. On Linux and Windows, pasted data is directly + // retrieved from the system's native clipboard, and made available + // as a nsIInputStream. + // + // On macOS, nsClipboard::GetNativeClipboardData (nsClipboard.mm) uses + // a cached copy of nsITransferable if available, e.g. when the copy + // was initiated by the same browser instance. To make sure that a + // nsIInputStream is returned instead of the cached imgIContainer, + // the image is exported as as `kNativeImageMime`. Data associated + // with this type is converted to a platform-specific image format + // when written to the clipboard. The type is not used when images + // are read from the clipboard (on all platforms, not just macOS). + // This forces nsClipboard::GetNativeClipboardData to fall back to + // the native clipboard, and return the image as a nsITransferable. + transferable.setTransferData(kNativeImageMime, img); + + Services.clipboard.setData( + transferable, + null, + Services.clipboard.kGlobalClipboard + ); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-contentScripts.js b/toolkit/components/extensions/parent/ext-contentScripts.js new file mode 100644 index 0000000000..068b2c7403 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-contentScripts.js @@ -0,0 +1,232 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +"use strict"; + +var { ExtensionUtils } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionUtils.sys.mjs" +); + +var { ExtensionError, getUniqueId } = ExtensionUtils; + +function getOriginAttributesPatternForCookieStoreId(cookieStoreId) { + if (isDefaultCookieStoreId(cookieStoreId)) { + return { + userContextId: Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID, + privateBrowsingId: + Ci.nsIScriptSecurityManager.DEFAULT_PRIVATE_BROWSING_ID, + }; + } + if (isPrivateCookieStoreId(cookieStoreId)) { + return { + userContextId: Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID, + privateBrowsingId: 1, + }; + } + if (isContainerCookieStoreId(cookieStoreId)) { + let userContextId = getContainerForCookieStoreId(cookieStoreId); + if (userContextId !== null) { + return { userContextId }; + } + } + + throw new ExtensionError("Invalid cookieStoreId"); +} + +/** + * Represents (in the main browser process) a content script registered + * programmatically (instead of being included in the addon manifest). + * + * @param {ProxyContextParent} context + * The parent proxy context related to the extension context which + * has registered the content script. + * @param {RegisteredContentScriptOptions} details + * The options object related to the registered content script + * (which has the properties described in the content_scripts.json + * JSON API schema file). + */ +class ContentScriptParent { + constructor({ context, details }) { + this.context = context; + this.scriptId = getUniqueId(); + this.blobURLs = new Set(); + + this.options = this._convertOptions(details); + + context.callOnClose(this); + } + + close() { + this.destroy(); + } + + destroy() { + if (this.destroyed) { + throw new Error("Unable to destroy ContentScriptParent twice"); + } + + this.destroyed = true; + + this.context.forgetOnClose(this); + + for (const blobURL of this.blobURLs) { + this.context.cloneScope.URL.revokeObjectURL(blobURL); + } + + this.blobURLs.clear(); + + this.context = null; + this.options = null; + } + + _convertOptions(details) { + const { context } = this; + + const options = { + matches: details.matches, + excludeMatches: details.excludeMatches, + includeGlobs: details.includeGlobs, + excludeGlobs: details.excludeGlobs, + allFrames: details.allFrames, + matchAboutBlank: details.matchAboutBlank, + runAt: details.runAt || "document_idle", + jsPaths: [], + cssPaths: [], + originAttributesPatterns: null, + }; + + if (details.cookieStoreId != null) { + const cookieStoreIds = Array.isArray(details.cookieStoreId) + ? details.cookieStoreId + : [details.cookieStoreId]; + options.originAttributesPatterns = cookieStoreIds.map(cookieStoreId => + getOriginAttributesPatternForCookieStoreId(cookieStoreId) + ); + } + + const convertCodeToURL = (data, mime) => { + const blob = new context.cloneScope.Blob(data, { type: mime }); + const blobURL = context.cloneScope.URL.createObjectURL(blob); + + this.blobURLs.add(blobURL); + + return blobURL; + }; + + if (details.js && details.js.length) { + options.jsPaths = details.js.map(data => { + if (data.file) { + return data.file; + } + + return convertCodeToURL([data.code], "text/javascript"); + }); + } + + if (details.css && details.css.length) { + options.cssPaths = details.css.map(data => { + if (data.file) { + return data.file; + } + + return convertCodeToURL([data.code], "text/css"); + }); + } + + return options; + } + + serialize() { + return this.options; + } +} + +this.contentScripts = class extends ExtensionAPI { + getAPI(context) { + const { extension } = context; + + // Map of the content script registered from the extension context. + // + // Map<scriptId -> ContentScriptParent> + const parentScriptsMap = new Map(); + + // Unregister all the scriptId related to a context when it is closed. + context.callOnClose({ + close() { + if (parentScriptsMap.size === 0) { + return; + } + + const scriptIds = Array.from(parentScriptsMap.keys()); + + for (let scriptId of scriptIds) { + extension.registeredContentScripts.delete(scriptId); + } + extension.updateContentScripts(); + + extension.broadcast("Extension:UnregisterContentScripts", { + id: extension.id, + scriptIds, + }); + }, + }); + + return { + contentScripts: { + async register(details) { + for (let origin of details.matches) { + if (!extension.allowedOrigins.subsumes(new MatchPattern(origin))) { + throw new ExtensionError( + `Permission denied to register a content script for ${origin}` + ); + } + } + + const contentScript = new ContentScriptParent({ context, details }); + const { scriptId } = contentScript; + + parentScriptsMap.set(scriptId, contentScript); + + const scriptOptions = contentScript.serialize(); + + extension.registeredContentScripts.set(scriptId, scriptOptions); + extension.updateContentScripts(); + + await extension.broadcast("Extension:RegisterContentScripts", { + id: extension.id, + scripts: [{ scriptId, options: scriptOptions }], + }); + + return scriptId; + }, + + // This method is not available to the extension code, the extension code + // doesn't have access to the internally used scriptId, on the contrary + // the extension code will call script.unregister on the script API object + // that is resolved from the register API method returned promise. + async unregister(scriptId) { + const contentScript = parentScriptsMap.get(scriptId); + if (!contentScript) { + Cu.reportError(new Error(`No such content script ID: ${scriptId}`)); + + return; + } + + parentScriptsMap.delete(scriptId); + extension.registeredContentScripts.delete(scriptId); + extension.updateContentScripts(); + + contentScript.destroy(); + + await extension.broadcast("Extension:UnregisterContentScripts", { + id: extension.id, + scriptIds: [scriptId], + }); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-contextualIdentities.js b/toolkit/components/extensions/parent/ext-contextualIdentities.js new file mode 100644 index 0000000000..c7f28d5e90 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-contextualIdentities.js @@ -0,0 +1,362 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + ContextualIdentityService: + "resource://gre/modules/ContextualIdentityService.sys.mjs", +}); +XPCOMUtils.defineLazyPreferenceGetter( + this, + "containersEnabled", + "privacy.userContext.enabled" +); + +var { ExtensionPreferencesManager } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPreferencesManager.sys.mjs" +); + +var { ExtensionError } = ExtensionUtils; + +const CONTAINER_PREF_INSTALL_DEFAULTS = { + "privacy.userContext.extension": undefined, +}; + +const CONTAINERS_ENABLED_SETTING_NAME = "privacy.containers"; + +const CONTAINER_COLORS = new Map([ + ["blue", "#37adff"], + ["turquoise", "#00c79a"], + ["green", "#51cd00"], + ["yellow", "#ffcb00"], + ["orange", "#ff9f00"], + ["red", "#ff613d"], + ["pink", "#ff4bda"], + ["purple", "#af51f5"], + ["toolbar", "#7c7c7d"], +]); + +const CONTAINER_ICONS = new Set([ + "briefcase", + "cart", + "circle", + "dollar", + "fence", + "fingerprint", + "gift", + "vacation", + "food", + "fruit", + "pet", + "tree", + "chill", +]); + +function getContainerIcon(iconName) { + if (!CONTAINER_ICONS.has(iconName)) { + throw new ExtensionError(`Invalid icon ${iconName} for container`); + } + return `resource://usercontext-content/${iconName}.svg`; +} + +function getContainerColor(colorName) { + if (!CONTAINER_COLORS.has(colorName)) { + throw new ExtensionError(`Invalid color name ${colorName} for container`); + } + return CONTAINER_COLORS.get(colorName); +} + +const convertIdentity = identity => { + let result = { + name: ContextualIdentityService.getUserContextLabel(identity.userContextId), + icon: identity.icon, + iconUrl: getContainerIcon(identity.icon), + color: identity.color, + colorCode: getContainerColor(identity.color), + cookieStoreId: getCookieStoreIdForContainer(identity.userContextId), + }; + + return result; +}; + +const checkAPIEnabled = () => { + if (!containersEnabled) { + throw new ExtensionError("Contextual identities are currently disabled"); + } +}; + +const convertIdentityFromObserver = wrappedIdentity => { + let identity = wrappedIdentity.wrappedJSObject; + let iconUrl, colorCode; + try { + iconUrl = getContainerIcon(identity.icon); + colorCode = getContainerColor(identity.color); + } catch (e) { + return null; + } + + let result = { + name: identity.name, + icon: identity.icon, + iconUrl, + color: identity.color, + colorCode, + cookieStoreId: getCookieStoreIdForContainer(identity.userContextId), + }; + + return result; +}; + +ExtensionPreferencesManager.addSetting(CONTAINERS_ENABLED_SETTING_NAME, { + prefNames: Object.keys(CONTAINER_PREF_INSTALL_DEFAULTS), + + setCallback(value) { + if (value !== true) { + return { + ...CONTAINER_PREF_INSTALL_DEFAULTS, + "privacy.userContext.extension": value, + }; + } + return {}; + }, +}); + +this.contextualIdentities = class extends ExtensionAPIPersistent { + eventRegistrar(eventName) { + return ({ fire }) => { + let observer = (subject, topic) => { + let convertedIdentity = convertIdentityFromObserver(subject); + if (convertedIdentity) { + fire.async({ contextualIdentity: convertedIdentity }); + } + }; + + Services.obs.addObserver(observer, eventName); + return { + unregister() { + Services.obs.removeObserver(observer, eventName); + }, + convert(_fire) { + fire = _fire; + }, + }; + }; + } + + PERSISTENT_EVENTS = { + onCreated: this.eventRegistrar("contextual-identity-created"), + onUpdated: this.eventRegistrar("contextual-identity-updated"), + onRemoved: this.eventRegistrar("contextual-identity-deleted"), + }; + + onStartup() { + let { extension } = this; + + if (extension.hasPermission("contextualIdentities")) { + // Turn on contextual identities, and never turn it off. We handle + // this here to ensure prefs are set when an addon is enabled. + Services.prefs.setBoolPref("privacy.userContext.enabled", true); + Services.prefs.setBoolPref("privacy.userContext.ui.enabled", true); + + ExtensionPreferencesManager.setSetting( + extension.id, + CONTAINERS_ENABLED_SETTING_NAME, + extension.id + ); + } + } + + getAPI(context) { + let self = { + contextualIdentities: { + async get(cookieStoreId) { + checkAPIEnabled(); + let containerId = getContainerForCookieStoreId(cookieStoreId); + if (!containerId) { + throw new ExtensionError( + `Invalid contextual identity: ${cookieStoreId}` + ); + } + + let identity = + ContextualIdentityService.getPublicIdentityFromId(containerId); + return convertIdentity(identity); + }, + + async query(details) { + checkAPIEnabled(); + let identities = []; + ContextualIdentityService.getPublicIdentities().forEach(identity => { + if ( + details.name && + ContextualIdentityService.getUserContextLabel( + identity.userContextId + ) != details.name + ) { + return; + } + + identities.push(convertIdentity(identity)); + }); + + return identities; + }, + + async create(details) { + // Lets prevent making containers that are not valid + getContainerIcon(details.icon); + getContainerColor(details.color); + + let identity = ContextualIdentityService.create( + details.name, + details.icon, + details.color + ); + return convertIdentity(identity); + }, + + async update(cookieStoreId, details) { + checkAPIEnabled(); + let containerId = getContainerForCookieStoreId(cookieStoreId); + if (!containerId) { + throw new ExtensionError( + `Invalid contextual identity: ${cookieStoreId}` + ); + } + + let identity = + ContextualIdentityService.getPublicIdentityFromId(containerId); + if (!identity) { + throw new ExtensionError( + `Invalid contextual identity: ${cookieStoreId}` + ); + } + + if (details.name !== null) { + identity.name = details.name; + } + + if (details.color !== null) { + getContainerColor(details.color); + identity.color = details.color; + } + + if (details.icon !== null) { + getContainerIcon(details.icon); + identity.icon = details.icon; + } + + if ( + !ContextualIdentityService.update( + identity.userContextId, + identity.name, + identity.icon, + identity.color + ) + ) { + throw new ExtensionError( + `Contextual identity failed to update: ${cookieStoreId}` + ); + } + + return convertIdentity(identity); + }, + + async move(cookieStoreIds, position) { + checkAPIEnabled(); + if (!Array.isArray(cookieStoreIds)) { + cookieStoreIds = [cookieStoreIds]; + } + + if (!cookieStoreIds.length) { + return; + } + + const totalIds = + ContextualIdentityService.getPublicIdentities().length; + if (position < -1 || position > totalIds - cookieStoreIds.length) { + throw new ExtensionError(`Moving to invalid position ${position}`); + } + + let userContextIds = []; + cookieStoreIds.forEach((cookieStoreId, index) => { + if (cookieStoreIds.indexOf(cookieStoreId) !== index) { + throw new ExtensionError( + `Duplicate contextual identity: ${cookieStoreId}` + ); + } + + let containerId = getContainerForCookieStoreId(cookieStoreId); + if (!containerId) { + throw new ExtensionError( + `Invalid contextual identity: ${cookieStoreId}` + ); + } + + userContextIds.push(containerId); + }); + + if (!ContextualIdentityService.move(userContextIds, position)) { + throw new ExtensionError( + `Contextual identities failed to move: ${cookieStoreIds}` + ); + } + }, + + async remove(cookieStoreId) { + checkAPIEnabled(); + let containerId = getContainerForCookieStoreId(cookieStoreId); + if (!containerId) { + throw new ExtensionError( + `Invalid contextual identity: ${cookieStoreId}` + ); + } + + let identity = + ContextualIdentityService.getPublicIdentityFromId(containerId); + if (!identity) { + throw new ExtensionError( + `Invalid contextual identity: ${cookieStoreId}` + ); + } + + // We have to create the identity object before removing it. + let convertedIdentity = convertIdentity(identity); + + if (!ContextualIdentityService.remove(identity.userContextId)) { + throw new ExtensionError( + `Contextual identity failed to remove: ${cookieStoreId}` + ); + } + + return convertedIdentity; + }, + + onCreated: new EventManager({ + context, + module: "contextualIdentities", + event: "onCreated", + extensionApi: this, + }).api(), + + onUpdated: new EventManager({ + context, + module: "contextualIdentities", + event: "onUpdated", + extensionApi: this, + }).api(), + + onRemoved: new EventManager({ + context, + module: "contextualIdentities", + event: "onRemoved", + extensionApi: this, + }).api(), + }, + }; + + return self; + } +}; diff --git a/toolkit/components/extensions/parent/ext-cookies.js b/toolkit/components/extensions/parent/ext-cookies.js new file mode 100644 index 0000000000..9308a56cfd --- /dev/null +++ b/toolkit/components/extensions/parent/ext-cookies.js @@ -0,0 +1,696 @@ +/* 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"; + +/* globals DEFAULT_STORE, PRIVATE_STORE */ + +var { ExtensionError } = ExtensionUtils; + +const SAME_SITE_STATUSES = [ + "no_restriction", // Index 0 = Ci.nsICookie.SAMESITE_NONE + "lax", // Index 1 = Ci.nsICookie.SAMESITE_LAX + "strict", // Index 2 = Ci.nsICookie.SAMESITE_STRICT +]; + +const isIPv4 = host => { + let match = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(host); + + if (match) { + return match[1] < 256 && match[2] < 256 && match[3] < 256 && match[4] < 256; + } + return false; +}; +const isIPv6 = host => host.includes(":"); +const addBracketIfIPv6 = host => + isIPv6(host) && !host.startsWith("[") ? `[${host}]` : host; +const dropBracketIfIPv6 = host => + isIPv6(host) && host.startsWith("[") && host.endsWith("]") + ? host.slice(1, -1) + : host; + +// Converts the partitionKey format of the extension API (i.e. PartitionKey) to +// a valid format for the "partitionKey" member of OriginAttributes. +function fromExtPartitionKey(extPartitionKey) { + if (!extPartitionKey) { + // Unpartitioned by default. + return ""; + } + const { topLevelSite } = extPartitionKey; + // TODO: Expand API to force the generation of a partitionKey that differs + // from the default that's specified by privacy.dynamic_firstparty.use_site. + if (topLevelSite) { + // If topLevelSite is set and a non-empty string (a site in a URL format). + try { + return ChromeUtils.getPartitionKeyFromURL(topLevelSite); + } catch (e) { + throw new ExtensionError("Invalid value for 'partitionKey' attribute"); + } + } + // Unpartitioned. + return ""; +} +// Converts an internal partitionKey (format used by OriginAttributes) to the +// string value as exposed through the extension API. +function toExtPartitionKey(partitionKey) { + if (!partitionKey) { + // Canonical representation of an empty partitionKey is null. + // In theory {topLevelSite: ""} also works, but alas. + return null; + } + // Parse partitionKey in order to generate the desired return type (URL). + // OriginAttributes::ParsePartitionKey cannot be used because it assumes that + // the input matches the format of the privacy.dynamic_firstparty.use_site + // pref, which is not necessarily the case for cookies before the pref flip. + if (!partitionKey.startsWith("(")) { + // A partitionKey generated with privacy.dynamic_firstparty.use_site=false. + return { topLevelSite: `https://${partitionKey}` }; + } + // partitionKey starts with "(" and ends with ")". + let [scheme, domain, port] = partitionKey.slice(1, -1).split(","); + let topLevelSite = `${scheme}://${domain}`; + if (port) { + topLevelSite += `:${port}`; + } + return { topLevelSite }; +} + +const convertCookie = ({ cookie, isPrivate }) => { + let result = { + name: cookie.name, + value: cookie.value, + domain: addBracketIfIPv6(cookie.host), + hostOnly: !cookie.isDomain, + path: cookie.path, + secure: cookie.isSecure, + httpOnly: cookie.isHttpOnly, + sameSite: SAME_SITE_STATUSES[cookie.sameSite], + session: cookie.isSession, + firstPartyDomain: cookie.originAttributes.firstPartyDomain || "", + partitionKey: toExtPartitionKey(cookie.originAttributes.partitionKey), + }; + + if (!cookie.isSession) { + result.expirationDate = cookie.expiry; + } + + if (cookie.originAttributes.userContextId) { + result.storeId = getCookieStoreIdForContainer( + cookie.originAttributes.userContextId + ); + } else if (cookie.originAttributes.privateBrowsingId || isPrivate) { + result.storeId = PRIVATE_STORE; + } else { + result.storeId = DEFAULT_STORE; + } + + return result; +}; + +const isSubdomain = (otherDomain, baseDomain) => { + return otherDomain == baseDomain || otherDomain.endsWith("." + baseDomain); +}; + +// Checks that the given extension has permission to set the given cookie for +// the given URI. +const checkSetCookiePermissions = (extension, uri, cookie) => { + // Permission checks: + // + // - If the extension does not have permissions for the specified + // URL, it cannot set cookies for it. + // + // - If the specified URL could not set the given cookie, neither can + // the extension. + // + // Ideally, we would just have the cookie service make the latter + // determination, but that turns out to be quite complicated. At the + // moment, it requires constructing a cookie string and creating a + // dummy channel, both of which can be problematic. It also triggers + // a whole set of additional permission and preference checks, which + // may or may not be desirable. + // + // So instead, we do a similar set of checks here. Exactly what + // cookies a given URL should be able to set is not well-documented, + // and is not standardized in any standard that anyone actually + // follows. So instead, we follow the rules used by the cookie + // service. + // + // See source/netwerk/cookie/CookieService.cpp, in particular + // CheckDomain() and SetCookieInternal(). + + if (uri.scheme != "http" && uri.scheme != "https") { + return false; + } + + if (!extension.allowedOrigins.matches(uri)) { + return false; + } + + if (!cookie.host) { + // If no explicit host is specified, this becomes a host-only cookie. + cookie.host = uri.host; + return true; + } + + // A leading "." is not expected, but is tolerated if it's not the only + // character in the host. If there is one, start by stripping it off. We'll + // add a new one on success. + if (cookie.host.length > 1) { + cookie.host = cookie.host.replace(/^\./, ""); + } + cookie.host = cookie.host.toLowerCase(); + cookie.host = dropBracketIfIPv6(cookie.host); + + if (cookie.host != uri.host) { + // Not an exact match, so check for a valid subdomain. + let baseDomain; + try { + baseDomain = Services.eTLD.getBaseDomain(uri); + } catch (e) { + if ( + e.result == Cr.NS_ERROR_HOST_IS_IP_ADDRESS || + e.result == Cr.NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS + ) { + // The cookie service uses these to determine whether the domain + // requires an exact match. We already know we don't have an exact + // match, so return false. In all other cases, re-raise the error. + return false; + } + throw e; + } + + // The cookie domain must be a subdomain of the base domain. This prevents + // us from setting cookies for domains like ".co.uk". + // The domain of the requesting URL must likewise be a subdomain of the + // cookie domain. This prevents us from setting cookies for entirely + // unrelated domains. + if ( + !isSubdomain(cookie.host, baseDomain) || + !isSubdomain(uri.host, cookie.host) + ) { + return false; + } + + // RFC2109 suggests that we may only add cookies for sub-domains 1-level + // below us, but enforcing that would break the web, so we don't. + } + + // If the host is an IP address, avoid adding a leading ".". + // An IP address is not a domain name, and only supports host-only cookies. + if (isIPv6(cookie.host) || isIPv4(cookie.host)) { + return true; + } + + // An explicit domain was passed, so add a leading "." to make this a + // domain cookie. + cookie.host = "." + cookie.host; + + // We don't do any significant checking of path permissions. RFC2109 + // suggests we only allow sites to add cookies for sub-paths, similar to + // same origin policy enforcement, but no-one implements this. + + return true; +}; + +/** + * Converts the details received from the cookies API to the OriginAttributes + * format, using default values when needed (firstPartyDomain/partitionKey). + * + * If allowPattern is true, an OriginAttributesPattern may be returned instead. + * + * @param {object} details + * The details received from the extension. + * @param {BaseContext} context + * @param {boolean} allowPattern + * Whether to potentially return an OriginAttributesPattern instead of + * OriginAttributes. The get/set/remove cookie methods operate on exact + * OriginAttributes, the getAll method allows a partial pattern and may + * potentially match cookies with distinct origin attributes. + * @returns {object} An object with the following properties: + * - originAttributes {OriginAttributes|OriginAttributesPattern} + * - isPattern {boolean} Whether originAttributes is a pattern. + * - isPrivate {boolean} Whether the cookie belongs to private browsing mode. + * - storeId {string} The storeId of the cookie. + */ +const oaFromDetails = (details, context, allowPattern) => { + // Default values, may be filled in based on details. + let originAttributes = { + userContextId: 0, + privateBrowsingId: 0, + // The following two keys may be deleted if allowPattern=true + firstPartyDomain: details.firstPartyDomain ?? "", + partitionKey: fromExtPartitionKey(details.partitionKey), + }; + + let isPrivate = context.incognito; + let storeId = isPrivate ? PRIVATE_STORE : DEFAULT_STORE; + if (details.storeId) { + storeId = details.storeId; + if (isDefaultCookieStoreId(storeId)) { + isPrivate = false; + } else if (isPrivateCookieStoreId(storeId)) { + isPrivate = true; + } else { + isPrivate = false; + let userContextId = getContainerForCookieStoreId(storeId); + if (!userContextId) { + throw new ExtensionError(`Invalid cookie store id: "${storeId}"`); + } + originAttributes.userContextId = userContextId; + } + } + + if (isPrivate) { + originAttributes.privateBrowsingId = 1; + if (!context.privateBrowsingAllowed) { + throw new ExtensionError( + "Extension disallowed access to the private cookies storeId." + ); + } + } + + // If any of the originAttributes's keys are deleted, this becomes true. + let isPattern = false; + if (allowPattern) { + // firstPartyDomain is unset / void / string. + // If unset, then we default to non-FPI cookies (or if FPI is enabled, + // an error is thrown by validateFirstPartyDomain). We are able to detect + // whether the property is set due to "omit-key-if-missing" in cookies.json. + // If set to a string, we keep the filter. + // If set to void (undefined / null), we drop the FPI filter: + if ("firstPartyDomain" in details && details.firstPartyDomain == null) { + delete originAttributes.firstPartyDomain; + isPattern = true; + } + + // partitionKey is an object or null. + // null implies the default (unpartitioned cookies). + // An object is a filter for partitionKey; currently we require topLevelSite + // to be set to determine the exact partitionKey. Without it, we drop the + // dFPI filter: + if (details.partitionKey && details.partitionKey.topLevelSite == null) { + delete originAttributes.partitionKey; + isPattern = true; + } + } + return { originAttributes, isPattern, isPrivate, storeId }; +}; + +/** + * Query the cookie store for matching cookies. + * + * @param {object} detailsIn + * @param {Array} props Properties the extension is interested in matching against. + * The firstPartyDomain / partitionKey / storeId + * props are always accounted for. + * @param {BaseContext} context The context making the query. + * @param {boolean} allowPattern Whether to allow the query to match distinct + * origin attributes instead of falling back to + * default values. See the oaFromDetails method. + */ +const query = function* (detailsIn, props, context, allowPattern) { + let details = {}; + props.forEach(property => { + if (detailsIn[property] !== null) { + details[property] = detailsIn[property]; + } + }); + + let parsedOA; + try { + parsedOA = oaFromDetails(detailsIn, context, allowPattern); + } catch (e) { + if (e.message.startsWith("Invalid cookie store id")) { + // For backwards-compatibility with previous versions of Firefox, fail + // silently (by not returning any results) instead of throwing an error. + return; + } + throw e; + } + let { originAttributes, isPattern, isPrivate, storeId } = parsedOA; + + if ("domain" in details) { + details.domain = details.domain.toLowerCase().replace(/^\./, ""); + details.domain = dropBracketIfIPv6(details.domain); + } + + // We can use getCookiesFromHost for faster searching. + let cookies; + let host; + let url; + if ("url" in details) { + try { + url = new URL(details.url); + host = dropBracketIfIPv6(url.hostname); + } catch (ex) { + // This often happens for about: URLs + return; + } + } else if ("domain" in details) { + host = details.domain; + } + + if (host && !isPattern) { + // getCookiesFromHost is more efficient than getCookiesWithOriginAttributes + // if the host and all origin attributes are known. + cookies = Services.cookies.getCookiesFromHost(host, originAttributes); + } else { + cookies = Services.cookies.getCookiesWithOriginAttributes( + JSON.stringify(originAttributes), + host + ); + } + + // Based on CookieService::GetCookieStringFromHttp + function matches(cookie) { + function domainMatches(host) { + return ( + cookie.rawHost == host || + (cookie.isDomain && host.endsWith(cookie.host)) + ); + } + + function pathMatches(path) { + let cookiePath = cookie.path.replace(/\/$/, ""); + + if (!path.startsWith(cookiePath)) { + return false; + } + + // path == cookiePath, but without the redundant string compare. + if (path.length == cookiePath.length) { + return true; + } + + // URL path is a substring of the cookie path, so it matches if, and + // only if, the next character is a path delimiter. + return path[cookiePath.length] === "/"; + } + + // "Restricts the retrieved cookies to those that would match the given URL." + if (url) { + if (!domainMatches(host)) { + return false; + } + + if (cookie.isSecure && url.protocol != "https:") { + return false; + } + + if (!pathMatches(url.pathname)) { + return false; + } + } + + if ("name" in details && details.name != cookie.name) { + return false; + } + + // "Restricts the retrieved cookies to those whose domains match or are subdomains of this one." + if ("domain" in details && !isSubdomain(cookie.rawHost, details.domain)) { + return false; + } + + // "Restricts the retrieved cookies to those whose path exactly matches this string."" + if ("path" in details && details.path != cookie.path) { + return false; + } + + if ("secure" in details && details.secure != cookie.isSecure) { + return false; + } + + if ("session" in details && details.session != cookie.isSession) { + return false; + } + + // Check that the extension has permissions for this host. + if (!context.extension.allowedOrigins.matchesCookie(cookie)) { + return false; + } + + return true; + } + + for (const cookie of cookies) { + if (matches(cookie)) { + yield { cookie, isPrivate, storeId }; + } + } +}; + +const validateFirstPartyDomain = details => { + if (details.firstPartyDomain != null) { + return; + } + if (Services.prefs.getBoolPref("privacy.firstparty.isolate")) { + throw new ExtensionError( + "First-Party Isolation is enabled, but the required 'firstPartyDomain' attribute was not set." + ); + } +}; + +this.cookies = class extends ExtensionAPIPersistent { + PERSISTENT_EVENTS = { + onChanged({ fire }) { + let observer = (subject, topic) => { + let notify = (removed, cookie, cause) => { + cookie.QueryInterface(Ci.nsICookie); + + if (this.extension.allowedOrigins.matchesCookie(cookie)) { + fire.async({ + removed, + cookie: convertCookie({ + cookie, + isPrivate: topic == "private-cookie-changed", + }), + cause, + }); + } + }; + + let notification = subject.QueryInterface(Ci.nsICookieNotification); + let { cookie } = notification; + + let { + COOKIE_DELETED, + COOKIE_ADDED, + COOKIE_CHANGED, + COOKIES_BATCH_DELETED, + } = Ci.nsICookieNotification; + + // We do our best effort here to map the incompatible states. + switch (notification.action) { + case COOKIE_DELETED: + notify(true, cookie, "explicit"); + break; + case COOKIE_ADDED: + notify(false, cookie, "explicit"); + break; + case COOKIE_CHANGED: + notify(true, cookie, "overwrite"); + notify(false, cookie, "explicit"); + break; + case COOKIES_BATCH_DELETED: + let cookieArray = notification.batchDeletedCookies.QueryInterface( + Ci.nsIArray + ); + for (let i = 0; i < cookieArray.length; i++) { + let cookie = cookieArray.queryElementAt(i, Ci.nsICookie); + if (!cookie.isSession && cookie.expiry * 1000 <= Date.now()) { + notify(true, cookie, "expired"); + } else { + notify(true, cookie, "evicted"); + } + } + break; + } + }; + + const { privateBrowsingAllowed } = this.extension; + Services.obs.addObserver(observer, "cookie-changed"); + if (privateBrowsingAllowed) { + Services.obs.addObserver(observer, "private-cookie-changed"); + } + return { + unregister() { + Services.obs.removeObserver(observer, "cookie-changed"); + if (privateBrowsingAllowed) { + Services.obs.removeObserver(observer, "private-cookie-changed"); + } + }, + convert(_fire) { + fire = _fire; + }, + }; + }, + }; + getAPI(context) { + let { extension } = context; + let self = { + cookies: { + get: function (details) { + validateFirstPartyDomain(details); + + // TODO bug 1818968: We don't sort by length of path and creation time. + let allowed = ["url", "name"]; + for (let cookie of query(details, allowed, context)) { + return Promise.resolve(convertCookie(cookie)); + } + + // Found no match. + return Promise.resolve(null); + }, + + getAll: function (details) { + if (!("firstPartyDomain" in details)) { + // Check and throw an error if firstPartyDomain is required. + validateFirstPartyDomain(details); + } + + let allowed = ["url", "name", "domain", "path", "secure", "session"]; + let result = Array.from( + query(details, allowed, context, /* allowPattern = */ true), + convertCookie + ); + + return Promise.resolve(result); + }, + + set: function (details) { + validateFirstPartyDomain(details); + if (details.firstPartyDomain && details.partitionKey) { + // FPI and dFPI are mutually exclusive, so it does not make sense + // to accept non-empty (i.e. non-default) values for both. + throw new ExtensionError( + "Partitioned cookies cannot have a 'firstPartyDomain' attribute." + ); + } + + let uri = Services.io.newURI(details.url); + + let path; + if (details.path !== null) { + path = details.path; + } else { + // This interface essentially emulates the behavior of the + // Set-Cookie header. In the case of an omitted path, the cookie + // service uses the directory path of the requesting URL, ignoring + // any filename or query parameters. + path = uri.QueryInterface(Ci.nsIURL).directory; + } + + let name = details.name !== null ? details.name : ""; + let value = details.value !== null ? details.value : ""; + let secure = details.secure !== null ? details.secure : false; + let httpOnly = details.httpOnly !== null ? details.httpOnly : false; + let isSession = details.expirationDate === null; + let expiry = isSession + ? Number.MAX_SAFE_INTEGER + : details.expirationDate; + + let { originAttributes } = oaFromDetails(details, context); + + let cookieAttrs = { + host: details.domain, + path: path, + isSecure: secure, + }; + if (!checkSetCookiePermissions(extension, uri, cookieAttrs)) { + return Promise.reject({ + message: `Permission denied to set cookie ${JSON.stringify( + details + )}`, + }); + } + + let sameSite = SAME_SITE_STATUSES.indexOf(details.sameSite); + + let schemeType = Ci.nsICookie.SCHEME_UNSET; + if (uri.scheme === "https") { + schemeType = Ci.nsICookie.SCHEME_HTTPS; + } else if (uri.scheme === "http") { + schemeType = Ci.nsICookie.SCHEME_HTTP; + } else if (uri.scheme === "file") { + schemeType = Ci.nsICookie.SCHEME_FILE; + } + + // The permission check may have modified the domain, so use + // the new value instead. + Services.cookies.add( + cookieAttrs.host, + path, + name, + value, + secure, + httpOnly, + isSession, + expiry, + originAttributes, + sameSite, + schemeType + ); + + return self.cookies.get(details); + }, + + remove: function (details) { + validateFirstPartyDomain(details); + + let allowed = ["url", "name"]; + for (let { cookie, storeId } of query(details, allowed, context)) { + Services.cookies.remove( + cookie.host, + cookie.name, + cookie.path, + cookie.originAttributes + ); + + // TODO Bug 1387957: could there be multiple per subdomain? + return Promise.resolve({ + url: details.url, + name: details.name, + storeId, + firstPartyDomain: cookie.originAttributes.firstPartyDomain, + partitionKey: toExtPartitionKey( + cookie.originAttributes.partitionKey + ), + }); + } + + return Promise.resolve(null); + }, + + getAllCookieStores: function () { + let data = {}; + for (let tab of extension.tabManager.query()) { + if (!(tab.cookieStoreId in data)) { + data[tab.cookieStoreId] = []; + } + data[tab.cookieStoreId].push(tab.id); + } + + let result = []; + for (let key in data) { + result.push({ + id: key, + tabIds: data[key], + incognito: key == PRIVATE_STORE, + }); + } + return Promise.resolve(result); + }, + + onChanged: new EventManager({ + context, + module: "cookies", + event: "onChanged", + extensionApi: this, + }).api(), + }, + }; + + return self; + } +}; diff --git a/toolkit/components/extensions/parent/ext-declarativeNetRequest.js b/toolkit/components/extensions/parent/ext-declarativeNetRequest.js new file mode 100644 index 0000000000..766a43d98a --- /dev/null +++ b/toolkit/components/extensions/parent/ext-declarativeNetRequest.js @@ -0,0 +1,169 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionDNR: "resource://gre/modules/ExtensionDNR.sys.mjs", +}); + +var { ExtensionError } = ExtensionUtils; + +const PREF_DNR_FEEDBACK = "extensions.dnr.feedback"; +XPCOMUtils.defineLazyPreferenceGetter( + this, + "dnrFeedbackEnabled", + PREF_DNR_FEEDBACK, + false +); + +function ensureDNRFeedbackEnabled(apiName) { + if (!dnrFeedbackEnabled) { + throw new ExtensionError( + `${apiName} is only available when the "${PREF_DNR_FEEDBACK}" preference is set to true.` + ); + } +} + +this.declarativeNetRequest = class extends ExtensionAPI { + onManifestEntry(entryName) { + if (entryName === "declarative_net_request") { + ExtensionDNR.validateManifestEntry(this.extension); + } + } + + onShutdown() { + ExtensionDNR.clearRuleManager(this.extension); + } + + getAPI(context) { + const { extension } = this; + + return { + declarativeNetRequest: { + updateDynamicRules({ removeRuleIds, addRules }) { + return ExtensionDNR.updateDynamicRules(extension, { + removeRuleIds, + addRules, + }); + }, + + updateSessionRules({ removeRuleIds, addRules }) { + const ruleManager = ExtensionDNR.getRuleManager(extension); + let ruleValidator = new ExtensionDNR.RuleValidator( + ruleManager.getSessionRules(), + { isSessionRuleset: true } + ); + if (removeRuleIds) { + ruleValidator.removeRuleIds(removeRuleIds); + } + if (addRules) { + ruleValidator.addRules(addRules); + } + let failures = ruleValidator.getFailures(); + if (failures.length) { + throw new ExtensionError(failures[0].message); + } + let validatedRules = ruleValidator.getValidatedRules(); + let ruleQuotaCounter = new ExtensionDNR.RuleQuotaCounter(); + ruleQuotaCounter.tryAddRules("_session", validatedRules); + ruleManager.setSessionRules(validatedRules); + }, + + async getEnabledRulesets() { + await ExtensionDNR.ensureInitialized(extension); + const ruleManager = ExtensionDNR.getRuleManager(extension); + return ruleManager.enabledStaticRulesetIds; + }, + + async getAvailableStaticRuleCount() { + await ExtensionDNR.ensureInitialized(extension); + const ruleManager = ExtensionDNR.getRuleManager(extension); + return ruleManager.availableStaticRuleCount; + }, + + updateEnabledRulesets({ disableRulesetIds, enableRulesetIds }) { + return ExtensionDNR.updateEnabledStaticRulesets(extension, { + disableRulesetIds, + enableRulesetIds, + }); + }, + + async getDynamicRules() { + await ExtensionDNR.ensureInitialized(extension); + return ExtensionDNR.getRuleManager(extension).getDynamicRules(); + }, + + getSessionRules() { + // ruleManager.getSessionRules() returns an array of Rule instances. + // When these are structurally cloned (to send them to the child), + // the enumerable public fields of the class instances are copied to + // plain objects, as desired. + return ExtensionDNR.getRuleManager(extension).getSessionRules(); + }, + + isRegexSupported(regexOptions) { + const { + regex: regexFilter, + isCaseSensitive: isUrlFilterCaseSensitive, + // requireCapturing: is ignored, as it does not affect validation. + } = regexOptions; + + let ruleValidator = new ExtensionDNR.RuleValidator([]); + ruleValidator.addRules([ + { + id: 1, + condition: { regexFilter, isUrlFilterCaseSensitive }, + action: { type: "allow" }, + }, + ]); + let failures = ruleValidator.getFailures(); + if (failures.length) { + // While the UnsupportedRegexReason enum has more entries than just + // "syntaxError" (e.g. also "memoryLimitExceeded"), our validation + // is currently very permissive, and therefore the only + // distinguishable error is "syntaxError". + return { isSupported: false, reason: "syntaxError" }; + } + return { isSupported: true }; + }, + + async testMatchOutcome(request, options) { + ensureDNRFeedbackEnabled("declarativeNetRequest.testMatchOutcome"); + let { url, initiator, ...req } = request; + req.requestURI = Services.io.newURI(url); + if (initiator) { + req.initiatorURI = Services.io.newURI(initiator); + if (req.initiatorURI.schemeIs("data")) { + // data:-URIs are always opaque, i.e. a null principal. We should + // therefore ignore them here. + // ExtensionDNR's NetworkIntegration.startDNREvaluation does not + // encounter data:-URIs because opaque principals are mapped to a + // null initiatorURI. For consistency, we do the same here. + req.initiatorURI = null; + } + } + const matchedRules = ExtensionDNR.getMatchedRulesForRequest( + req, + options?.includeOtherExtensions ? null : extension + ).map(matchedRule => { + // Converts an internal MatchedRule instance to an object described + // by the "MatchedRule" type in declarative_net_request.json. + const result = { + ruleId: matchedRule.rule.id, + rulesetId: matchedRule.ruleset.id, + }; + if (matchedRule.ruleManager.extension !== extension) { + result.extensionId = matchedRule.ruleManager.extension.id; + } + return result; + }); + return { matchedRules }; + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-dns.js b/toolkit/components/extensions/parent/ext-dns.js new file mode 100644 index 0000000000..f32243c032 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-dns.js @@ -0,0 +1,87 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +"use strict"; + +const dnssFlags = { + allow_name_collisions: Ci.nsIDNSService.RESOLVE_ALLOW_NAME_COLLISION, + bypass_cache: Ci.nsIDNSService.RESOLVE_BYPASS_CACHE, + canonical_name: Ci.nsIDNSService.RESOLVE_CANONICAL_NAME, + disable_ipv4: Ci.nsIDNSService.RESOLVE_DISABLE_IPV4, + disable_ipv6: Ci.nsIDNSService.RESOLVE_DISABLE_IPV6, + disable_trr: Ci.nsIDNSService.RESOLVE_DISABLE_TRR, + offline: Ci.nsIDNSService.RESOLVE_OFFLINE, + priority_low: Ci.nsIDNSService.RESOLVE_PRIORITY_LOW, + priority_medium: Ci.nsIDNSService.RESOLVE_PRIORITY_MEDIUM, + speculate: Ci.nsIDNSService.RESOLVE_SPECULATE, +}; + +function getErrorString(nsresult) { + let e = new Components.Exception("", nsresult); + return e.name; +} + +this.dns = class extends ExtensionAPI { + getAPI(context) { + return { + dns: { + resolve: function (hostname, flags) { + let dnsFlags = flags.reduce( + (mask, flag) => mask | dnssFlags[flag], + 0 + ); + + return new Promise((resolve, reject) => { + let request; + let response = { + addresses: [], + }; + let listener = { + onLookupComplete: function (inRequest, inRecord, inStatus) { + if (inRequest === request) { + if (!Components.isSuccessCode(inStatus)) { + return reject({ message: getErrorString(inStatus) }); + } + inRecord.QueryInterface(Ci.nsIDNSAddrRecord); + if (dnsFlags & Ci.nsIDNSService.RESOLVE_CANONICAL_NAME) { + try { + response.canonicalName = inRecord.canonicalName; + } catch (e) { + // no canonicalName + } + } + response.isTRR = inRecord.IsTRR(); + while (inRecord.hasMore()) { + let addr = inRecord.getNextAddrAsString(); + // Sometimes there are duplicate records with the same ip. + if (!response.addresses.includes(addr)) { + response.addresses.push(addr); + } + } + return resolve(response); + } + }, + }; + try { + request = Services.dns.asyncResolve( + hostname, + Ci.nsIDNSService.RESOLVE_TYPE_DEFAULT, + dnsFlags, + null, // AdditionalInfo + listener, + null, + {} /* defaultOriginAttributes */ + ); + } catch (e) { + // handle exceptions such as offline mode. + return reject({ message: e.name }); + } + }); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-downloads.js b/toolkit/components/extensions/parent/ext-downloads.js new file mode 100644 index 0000000000..9cd96e0d65 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-downloads.js @@ -0,0 +1,1261 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + DownloadLastDir: "resource://gre/modules/DownloadLastDir.sys.mjs", + DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs", + Downloads: "resource://gre/modules/Downloads.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", +}); + +var { EventEmitter, ignoreEvent } = ExtensionCommon; +var { ExtensionError } = ExtensionUtils; + +const DOWNLOAD_ITEM_FIELDS = [ + "id", + "url", + "referrer", + "filename", + "incognito", + "cookieStoreId", + "danger", + "mime", + "startTime", + "endTime", + "estimatedEndTime", + "state", + "paused", + "canResume", + "error", + "bytesReceived", + "totalBytes", + "fileSize", + "exists", + "byExtensionId", + "byExtensionName", +]; + +const DOWNLOAD_DATE_FIELDS = ["startTime", "endTime", "estimatedEndTime"]; + +// Fields that we generate onChanged events for. +const DOWNLOAD_ITEM_CHANGE_FIELDS = [ + "endTime", + "state", + "paused", + "canResume", + "error", + "exists", +]; + +// From https://fetch.spec.whatwg.org/#forbidden-header-name +// Since bug 1367626 we allow extensions to set REFERER. +const FORBIDDEN_HEADERS = [ + "ACCEPT-CHARSET", + "ACCEPT-ENCODING", + "ACCESS-CONTROL-REQUEST-HEADERS", + "ACCESS-CONTROL-REQUEST-METHOD", + "CONNECTION", + "CONTENT-LENGTH", + "COOKIE", + "COOKIE2", + "DATE", + "DNT", + "EXPECT", + "HOST", + "KEEP-ALIVE", + "ORIGIN", + "TE", + "TRAILER", + "TRANSFER-ENCODING", + "UPGRADE", + "VIA", +]; + +const FORBIDDEN_PREFIXES = /^PROXY-|^SEC-/i; + +const PROMPTLESS_DOWNLOAD_PREF = "browser.download.useDownloadDir"; + +// Lists of file extensions for each file picker filter taken from filepicker.properties +const FILTER_HTML_EXTENSIONS = ["html", "htm", "shtml", "xhtml"]; + +const FILTER_TEXT_EXTENSIONS = ["txt", "text"]; + +const FILTER_IMAGES_EXTENSIONS = [ + "jpe", + "jpg", + "jpeg", + "gif", + "png", + "bmp", + "ico", + "svg", + "svgz", + "tif", + "tiff", + "ai", + "drw", + "pct", + "psp", + "xcf", + "psd", + "raw", + "webp", + "heic", +]; + +const FILTER_XML_EXTENSIONS = ["xml"]; + +const FILTER_AUDIO_EXTENSIONS = [ + "aac", + "aif", + "flac", + "iff", + "m4a", + "m4b", + "mid", + "midi", + "mp3", + "mpa", + "mpc", + "oga", + "ogg", + "ra", + "ram", + "snd", + "wav", + "wma", +]; + +const FILTER_VIDEO_EXTENSIONS = [ + "avi", + "divx", + "flv", + "m4v", + "mkv", + "mov", + "mp4", + "mpeg", + "mpg", + "ogm", + "ogv", + "ogx", + "rm", + "rmvb", + "smil", + "webm", + "wmv", + "xvid", +]; + +class DownloadItem { + constructor(id, download, extension) { + this.id = id; + this.download = download; + this.extension = extension; + this.prechange = {}; + this._error = null; + } + + get url() { + return this.download.source.url; + } + + get referrer() { + const uri = this.download.source.referrerInfo?.originalReferrer; + + return uri?.spec; + } + + get filename() { + return this.download.target.path; + } + + get incognito() { + return this.download.source.isPrivate; + } + + get cookieStoreId() { + if (this.download.source.isPrivate) { + return PRIVATE_STORE; + } + if (this.download.source.userContextId) { + return getCookieStoreIdForContainer(this.download.source.userContextId); + } + return DEFAULT_STORE; + } + + get danger() { + // TODO + return "safe"; + } + + get mime() { + return this.download.contentType; + } + + get startTime() { + return this.download.startTime; + } + + get endTime() { + // TODO bug 1256269: implement endTime. + return null; + } + + get estimatedEndTime() { + // Based on the code in summarizeDownloads() in DownloadsCommon.sys.mjs + if (this.download.hasProgress && this.download.speed > 0) { + let sizeLeft = this.download.totalBytes - this.download.currentBytes; + let timeLeftInSeconds = sizeLeft / this.download.speed; + return new Date(Date.now() + timeLeftInSeconds * 1000); + } + } + + get state() { + if (this.download.succeeded) { + return "complete"; + } + if (this.download.canceled || this.error) { + return "interrupted"; + } + return "in_progress"; + } + + get paused() { + return ( + this.download.canceled && + this.download.hasPartialData && + !this.download.error + ); + } + + get canResume() { + return ( + (this.download.stopped || this.download.canceled) && + this.download.hasPartialData && + !this.download.error + ); + } + + get error() { + if (this._error) { + return this._error; + } + if ( + !this.download.startTime || + !this.download.stopped || + this.download.succeeded + ) { + return null; + } + + // TODO store this instead of calculating it + if (this.download.error) { + if (this.download.error.becauseSourceFailed) { + return "NETWORK_FAILED"; // TODO + } + if (this.download.error.becauseTargetFailed) { + return "FILE_FAILED"; // TODO + } + return "CRASH"; + } + return "USER_CANCELED"; + } + + set error(value) { + this._error = value && value.toString(); + } + + get bytesReceived() { + return this.download.currentBytes; + } + + get totalBytes() { + return this.download.hasProgress ? this.download.totalBytes : -1; + } + + get fileSize() { + // todo: this is supposed to be post-compression + return this.download.succeeded ? this.download.target.size : -1; + } + + get exists() { + return this.download.target.exists; + } + + get byExtensionId() { + return this.extension?.id; + } + + get byExtensionName() { + return this.extension?.name; + } + + /** + * Create a cloneable version of this object by pulling all the + * fields into simple properties (instead of getters). + * + * @returns {object} A DownloadItem with flat properties, + * suitable for cloning. + */ + serialize() { + let obj = {}; + for (let field of DOWNLOAD_ITEM_FIELDS) { + obj[field] = this[field]; + } + for (let field of DOWNLOAD_DATE_FIELDS) { + if (obj[field]) { + obj[field] = obj[field].toISOString(); + } + } + return obj; + } + + // When a change event fires, handlers can look at how an individual + // field changed by comparing item.fieldname with item.prechange.fieldname. + // After all handlers have been invoked, this gets called to store the + // current values of all fields ahead of the next event. + _storePrechange() { + for (let field of DOWNLOAD_ITEM_CHANGE_FIELDS) { + this.prechange[field] = this[field]; + } + } +} + +// DownloadMap maps back and forth between the numeric identifiers used in +// the downloads WebExtension API and a Download object from the Downloads sys.mjs. +// TODO Bug 1247794: make id and extension info persistent +const DownloadMap = new (class extends EventEmitter { + constructor() { + super(); + + this.currentId = 0; + this.loadPromise = null; + + // Maps numeric id -> DownloadItem + this.byId = new Map(); + + // Maps Download object -> DownloadItem + this.byDownload = new WeakMap(); + } + + lazyInit() { + if (!this.loadPromise) { + this.loadPromise = (async () => { + const list = await Downloads.getList(Downloads.ALL); + + await list.addView({ + onDownloadAdded: download => { + const item = this.newFromDownload(download, null); + this.emit("create", item); + item._storePrechange(); + }, + onDownloadRemoved: download => { + const item = this.byDownload.get(download); + if (item) { + this.emit("erase", item); + this.byDownload.delete(download); + this.byId.delete(item.id); + } + }, + onDownloadChanged: download => { + const item = this.byDownload.get(download); + if (item) { + this.emit("change", item); + item._storePrechange(); + } else { + Cu.reportError( + "Got onDownloadChanged for unknown download object" + ); + } + }, + }); + + const downloads = await list.getAll(); + + for (let download of downloads) { + this.newFromDownload(download, null); + } + + return list; + })(); + } + + return this.loadPromise; + } + + getDownloadList() { + return this.lazyInit(); + } + + async getAll() { + await this.lazyInit(); + return this.byId.values(); + } + + fromId(id, privateAllowed = true) { + const download = this.byId.get(id); + if (!download || (!privateAllowed && download.incognito)) { + throw new ExtensionError(`Invalid download id ${id}`); + } + return download; + } + + newFromDownload(download, extension) { + if (this.byDownload.has(download)) { + return this.byDownload.get(download); + } + + const id = ++this.currentId; + let item = new DownloadItem(id, download, extension); + this.byId.set(id, item); + this.byDownload.set(download, item); + return item; + } + + async erase(item) { + // TODO Bug 1255507: for now we only work with downloads in the DownloadList + // from getAll() + const list = await this.getDownloadList(); + list.remove(item.download); + } +})(); + +// Create a callable function that filters a DownloadItem based on a +// query object of the type passed to search() or erase(). +const downloadQuery = query => { + let queryTerms = []; + let queryNegativeTerms = []; + if (query.query != null) { + for (let term of query.query) { + if (term[0] == "-") { + queryNegativeTerms.push(term.slice(1).toLowerCase()); + } else { + queryTerms.push(term.toLowerCase()); + } + } + } + + function normalizeDownloadTime(arg, before) { + if (arg == null) { + return before ? Number.MAX_VALUE : 0; + } + return ExtensionCommon.normalizeTime(arg).getTime(); + } + + const startedBefore = normalizeDownloadTime(query.startedBefore, true); + const startedAfter = normalizeDownloadTime(query.startedAfter, false); + + // TODO bug 1727510: Implement endedBefore/endedAfter + // const endedBefore = normalizeDownloadTime(query.endedBefore, true); + // const endedAfter = normalizeDownloadTime(query.endedAfter, false); + + const totalBytesGreater = query.totalBytesGreater ?? -1; + const totalBytesLess = query.totalBytesLess ?? Number.MAX_VALUE; + + // Handle options for which we can have a regular expression and/or + // an explicit value to match. + function makeMatch(regex, value, field) { + if (value == null && regex == null) { + return input => true; + } + + let re; + try { + re = new RegExp(regex || "", "i"); + } catch (err) { + throw new ExtensionError(`Invalid ${field}Regex: ${err.message}`); + } + if (value == null) { + return input => re.test(input); + } + + value = value.toLowerCase(); + if (re.test(value)) { + return input => value == input; + } + return input => false; + } + + const matchFilename = makeMatch( + query.filenameRegex, + query.filename, + "filename" + ); + const matchUrl = makeMatch(query.urlRegex, query.url, "url"); + + return function (item) { + const url = item.url.toLowerCase(); + const filename = item.filename.toLowerCase(); + + if ( + !queryTerms.every(term => url.includes(term) || filename.includes(term)) + ) { + return false; + } + + if ( + queryNegativeTerms.some( + term => url.includes(term) || filename.includes(term) + ) + ) { + return false; + } + + if (!matchFilename(filename) || !matchUrl(url)) { + return false; + } + + if (!item.startTime) { + if (query.startedBefore != null || query.startedAfter != null) { + return false; + } + } else if ( + item.startTime > startedBefore || + item.startTime < startedAfter + ) { + return false; + } + + // todo endedBefore, endedAfter + + if (item.totalBytes == -1) { + if (query.totalBytesGreater !== null || query.totalBytesLess !== null) { + return false; + } + } else if ( + item.totalBytes <= totalBytesGreater || + item.totalBytes >= totalBytesLess + ) { + return false; + } + + // todo: include danger + const SIMPLE_ITEMS = [ + "id", + "mime", + "startTime", + "endTime", + "state", + "paused", + "error", + "incognito", + "cookieStoreId", + "bytesReceived", + "totalBytes", + "fileSize", + "exists", + ]; + for (let field of SIMPLE_ITEMS) { + if (query[field] != null && item[field] != query[field]) { + return false; + } + } + + return true; + }; +}; + +const queryHelper = async query => { + let matchFn = downloadQuery(query); + let compareFn; + + if (query.orderBy) { + const fields = query.orderBy.map(field => + field[0] == "-" + ? { reverse: true, name: field.slice(1) } + : { reverse: false, name: field } + ); + + for (let field of fields) { + if (!DOWNLOAD_ITEM_FIELDS.includes(field.name)) { + throw new ExtensionError(`Invalid orderBy field ${field.name}`); + } + } + + compareFn = (dl1, dl2) => { + for (let field of fields) { + const val1 = dl1[field.name]; + const val2 = dl2[field.name]; + + if (val1 < val2) { + return field.reverse ? 1 : -1; + } else if (val1 > val2) { + return field.reverse ? -1 : 1; + } + } + return 0; + }; + } + + let downloads = await DownloadMap.getAll(); + + if (compareFn) { + downloads = Array.from(downloads); + downloads.sort(compareFn); + } + + let results = []; + for (let download of downloads) { + if (query.limit && results.length >= query.limit) { + break; + } + if (matchFn(download)) { + results.push(download); + } + } + return results; +}; + +this.downloads = class extends ExtensionAPIPersistent { + downloadEventRegistrar(event, listener) { + let { extension } = this; + return ({ fire }) => { + const handler = (what, item) => { + if (extension.privateBrowsingAllowed || !item.incognito) { + listener(fire, what, item); + } + }; + let registerPromise = DownloadMap.getDownloadList().then(() => { + DownloadMap.on(event, handler); + }); + return { + unregister() { + registerPromise.then(() => { + DownloadMap.off(event, handler); + }); + }, + convert(_fire) { + fire = _fire; + }, + }; + }; + } + + PERSISTENT_EVENTS = { + onChanged: this.downloadEventRegistrar("change", (fire, what, item) => { + let changes = {}; + const noundef = val => (val === undefined ? null : val); + DOWNLOAD_ITEM_CHANGE_FIELDS.forEach(fld => { + if (item[fld] != item.prechange[fld]) { + changes[fld] = { + previous: noundef(item.prechange[fld]), + current: noundef(item[fld]), + }; + } + }); + if (Object.keys(changes).length) { + changes.id = item.id; + fire.async(changes); + } + }), + + onCreated: this.downloadEventRegistrar("create", (fire, what, item) => { + fire.async(item.serialize()); + }), + + onErased: this.downloadEventRegistrar("erase", (fire, what, item) => { + fire.async(item.id); + }), + }; + + getAPI(context) { + let { extension } = context; + return { + downloads: { + async download(options) { + const isHandlingUserInput = + context.callContextData?.isHandlingUserInput; + let { filename } = options; + if (filename && AppConstants.platform === "win") { + // cross platform javascript code uses "/" + filename = filename.replace(/\//g, "\\"); + } + + if (filename != null) { + if (!filename.length) { + throw new ExtensionError("filename must not be empty"); + } + + if (PathUtils.isAbsolute(filename)) { + throw new ExtensionError("filename must not be an absolute path"); + } + + const pathComponents = PathUtils.splitRelative(filename, { + allowEmpty: true, + allowCurrentDir: true, + allowParentDir: true, + }); + + if (pathComponents.some(component => component == "..")) { + throw new ExtensionError( + "filename must not contain back-references (..)" + ); + } + + if ( + pathComponents.some(component => { + let sanitized = DownloadPaths.sanitize(component, { + compressWhitespaces: false, + }); + return component != sanitized; + }) + ) { + throw new ExtensionError( + "filename must not contain illegal characters" + ); + } + } + + if (options.incognito && !context.privateBrowsingAllowed) { + throw new ExtensionError("private browsing access not allowed"); + } + + if (options.conflictAction == "prompt") { + // TODO + throw new ExtensionError( + "conflictAction prompt not yet implemented" + ); + } + + if (options.headers) { + for (let { name } of options.headers) { + if ( + FORBIDDEN_HEADERS.includes(name.toUpperCase()) || + name.match(FORBIDDEN_PREFIXES) + ) { + throw new ExtensionError("Forbidden request header name"); + } + } + } + + let userContextId = null; + if (options.cookieStoreId != null) { + userContextId = getUserContextIdForCookieStoreId( + extension, + options.cookieStoreId, + options.incognito + ); + } + + // Handle method, headers and body options. + function adjustChannel(channel) { + if (channel instanceof Ci.nsIHttpChannel) { + const method = options.method || "GET"; + channel.requestMethod = method; + + if (options.headers) { + for (let { name, value } of options.headers) { + if (name.toLowerCase() == "referer") { + // The referer header and referrerInfo object should always + // match. So if we want to set the header from privileged + // context, we should set referrerInfo. The referrer header + // will get set internally. + channel.setNewReferrerInfo( + value, + Ci.nsIReferrerInfo.UNSAFE_URL, + true + ); + } else { + channel.setRequestHeader(name, value, false); + } + } + } + + if (options.body != null) { + const stream = Cc[ + "@mozilla.org/io/string-input-stream;1" + ].createInstance(Ci.nsIStringInputStream); + stream.setData(options.body, options.body.length); + + channel.QueryInterface(Ci.nsIUploadChannel2); + channel.explicitSetUploadStream( + stream, + null, + -1, + method, + false + ); + } + } + return Promise.resolve(); + } + + function allowHttpStatus(download, status) { + const item = DownloadMap.byDownload.get(download); + if (item === null) { + return true; + } + + let error = null; + switch (status) { + case 204: // No Content + case 205: // Reset Content + case 404: // Not Found + error = "SERVER_BAD_CONTENT"; + break; + + case 403: // Forbidden + error = "SERVER_FORBIDDEN"; + break; + + case 402: // Unauthorized + case 407: // Proxy authentication required + error = "SERVER_UNAUTHORIZED"; + break; + + default: + if (status >= 400) { + error = "SERVER_FAILED"; + } + break; + } + + if (error) { + item.error = error; + return false; + } + + // No error, ergo allow the request. + return true; + } + + async function createTarget(downloadsDir) { + if (!filename) { + let uri = Services.io.newURI(options.url); + if (uri instanceof Ci.nsIURL) { + filename = DownloadPaths.sanitize( + Services.textToSubURI.unEscapeURIForUI( + uri.fileName, + /* dontEscape = */ true + ) + ); + } + } + + let target = PathUtils.joinRelative( + downloadsDir, + filename || "download" + ); + + let saveAs; + if (options.saveAs !== null) { + saveAs = options.saveAs; + } else { + // If options.saveAs was not specified, only show the file chooser + // if |browser.download.useDownloadDir == false|. That is to say, + // only show the file chooser if Firefox normally shows it when + // a file is downloaded. + saveAs = !Services.prefs.getBoolPref( + PROMPTLESS_DOWNLOAD_PREF, + true + ); + } + + // Create any needed subdirectories if required by filename. + const dir = PathUtils.parent(target); + await IOUtils.makeDirectory(dir); + + if (await IOUtils.exists(target)) { + // This has a race, something else could come along and create + // the file between this test and them time the download code + // creates the target file. But we can't easily fix it without + // modifying DownloadCore so we live with it for now. + switch (options.conflictAction) { + case "uniquify": + default: + target = DownloadPaths.createNiceUniqueFile( + new FileUtils.File(target) + ).path; + if (saveAs) { + // createNiceUniqueFile actually creates the file, which + // is premature if we need to show a SaveAs dialog. + await IOUtils.remove(target); + } + break; + + case "overwrite": + break; + } + } + + if (!saveAs || AppConstants.platform === "android") { + return target; + } + + if (!("windowTracker" in global)) { + return target; + } + + // At this point we are committed to displaying the file picker. + const downloadLastDir = new DownloadLastDir( + null, + options.incognito + ); + + async function getLastDirectory() { + return downloadLastDir.getFileAsync(extension.baseURI); + } + + function appendFilterForFileExtension(picker, ext) { + if (FILTER_HTML_EXTENSIONS.includes(ext)) { + picker.appendFilters(Ci.nsIFilePicker.filterHTML); + } else if (FILTER_TEXT_EXTENSIONS.includes(ext)) { + picker.appendFilters(Ci.nsIFilePicker.filterText); + } else if (FILTER_IMAGES_EXTENSIONS.includes(ext)) { + picker.appendFilters(Ci.nsIFilePicker.filterImages); + } else if (FILTER_XML_EXTENSIONS.includes(ext)) { + picker.appendFilters(Ci.nsIFilePicker.filterXML); + } else if (FILTER_AUDIO_EXTENSIONS.includes(ext)) { + picker.appendFilters(Ci.nsIFilePicker.filterAudio); + } else if (FILTER_VIDEO_EXTENSIONS.includes(ext)) { + picker.appendFilters(Ci.nsIFilePicker.filterVideo); + } + } + + function saveLastDirectory(lastDir) { + downloadLastDir.setFile(extension.baseURI, lastDir); + } + + // Use windowTracker to find a window, rather than Services.wm, + // so that this doesn't break where navigator:browser isn't the + // main window (e.g. Thunderbird). + const window = global.windowTracker.getTopWindow().window; + const basename = PathUtils.filename(target); + const ext = basename.match(/\.([^.]+)$/)?.[1]; + + // If the filename passed in by the extension is a simple name + // and not a path, we open the file picker so it displays the + // last directory that was chosen by the user. + const pathSep = AppConstants.platform === "win" ? "\\" : "/"; + const lastFilePickerDirectory = + !filename || !filename.includes(pathSep) + ? await getLastDirectory() + : undefined; + + // Setup the file picker Save As dialog. + const picker = Cc["@mozilla.org/filepicker;1"].createInstance( + Ci.nsIFilePicker + ); + picker.init(window, null, Ci.nsIFilePicker.modeSave); + if (lastFilePickerDirectory) { + picker.displayDirectory = lastFilePickerDirectory; + } else { + picker.displayDirectory = new FileUtils.File(dir); + } + picker.defaultString = basename; + if (ext) { + // Configure a default file extension, used as fallback on Windows. + picker.defaultExtension = ext; + appendFilterForFileExtension(picker, ext); + } + picker.appendFilters(Ci.nsIFilePicker.filterAll); + + // Open the dialog and resolve/reject with the result. + return new Promise((resolve, reject) => { + picker.open(result => { + if (result === Ci.nsIFilePicker.returnCancel) { + reject({ message: "Download canceled by the user" }); + } else { + saveLastDirectory(picker.file.parent); + resolve(picker.file.path); + } + }); + }); + } + + const downloadsDir = await Downloads.getPreferredDownloadsDirectory(); + const target = await createTarget(downloadsDir); + const uri = Services.io.newURI(options.url); + const cookieJarSettings = Cc[ + "@mozilla.org/cookieJarSettings;1" + ].createInstance(Ci.nsICookieJarSettings); + cookieJarSettings.initWithURI(uri, options.incognito); + + const source = { + url: options.url, + isPrivate: options.incognito, + // Use the extension's principal to allow extensions to observe + // their own downloads via the webRequest API. + loadingPrincipal: context.principal, + cookieJarSettings, + }; + + if (userContextId) { + source.userContextId = userContextId; + } + + // blob:-URLs can only be loaded by the principal with which they + // are associated. This principal may have origin attributes. + // `context.principal` does sometimes not have these attributes + // due to bug 1653681. If `context.principal` were to be passed, + // the download request would be rejected because of mismatching + // principals (origin attributes). + // TODO bug 1653681: fix context.principal and remove this. + if (options.url.startsWith("blob:")) { + // To make sure that the blob:-URL can be loaded, fall back to + // the default (system) principal instead. + delete source.loadingPrincipal; + } + + // Unless the API user explicitly wants errors ignored, + // set the allowHttpStatus callback, which will instruct + // DownloadCore to cancel downloads on HTTP errors. + if (!options.allowHttpErrors) { + source.allowHttpStatus = allowHttpStatus; + } + + if (options.method || options.headers || options.body) { + source.adjustChannel = adjustChannel; + } + + const download = await Downloads.createDownload({ + // Only open the download panel if the method has been called + // while handling user input (See Bug 1759231). + openDownloadsListOnStart: isHandlingUserInput, + source, + target: { + path: target, + partFilePath: `${target}.part`, + }, + }); + + const list = await DownloadMap.getDownloadList(); + const item = DownloadMap.newFromDownload(download, extension); + list.add(download); + + // This is necessary to make pause/resume work. + download.tryToKeepPartialData = true; + + // Do not handle errors. + // Extensions will use listeners to be informed about errors. + // Just ignore any errors from |start()| to avoid spamming the + // error console. + download.start().catch(err => { + if (err.name !== "DownloadError") { + Cu.reportError(err); + } + }); + + return item.id; + }, + + async removeFile(id) { + await DownloadMap.lazyInit(); + + let item = DownloadMap.fromId(id, context.privateBrowsingAllowed); + + if (item.state !== "complete") { + throw new ExtensionError( + `Cannot remove incomplete download id ${id}` + ); + } + + try { + await IOUtils.remove(item.filename, { ignoreAbsent: false }); + } catch (err) { + if (DOMException.isInstance(err) && err.name === "NotFoundError") { + throw new ExtensionError( + `Could not remove download id ${item.id} because the file doesn't exist` + ); + } + + // Unexpected other error. Throw the original error, so that it + // can bubble up to the global browser console, but keep it + // sanitized (i.e. not wrapped in ExtensionError) to avoid + // inadvertent disclosure of potentially sensitive information. + throw err; + } + }, + + async search(query) { + if (!context.privateBrowsingAllowed) { + query.incognito = false; + } + + const items = await queryHelper(query); + return items.map(item => item.serialize()); + }, + + async pause(id) { + await DownloadMap.lazyInit(); + + let item = DownloadMap.fromId(id, context.privateBrowsingAllowed); + + if (item.state !== "in_progress") { + throw new ExtensionError( + `Download ${id} cannot be paused since it is in state ${item.state}` + ); + } + + return item.download.cancel(); + }, + + async resume(id) { + await DownloadMap.lazyInit(); + + let item = DownloadMap.fromId(id, context.privateBrowsingAllowed); + + if (!item.canResume) { + throw new ExtensionError(`Download ${id} cannot be resumed`); + } + + item.error = null; + return item.download.start(); + }, + + async cancel(id) { + await DownloadMap.lazyInit(); + + let item = DownloadMap.fromId(id, context.privateBrowsingAllowed); + + if (item.download.succeeded) { + throw new ExtensionError(`Download ${id} is already complete`); + } + + return item.download.finalize(true); + }, + + showDefaultFolder() { + Downloads.getPreferredDownloadsDirectory() + .then(dir => { + let dirobj = new FileUtils.File(dir); + if (dirobj.isDirectory()) { + dirobj.launch(); + } else { + throw new Error( + `Download directory ${dirobj.path} is not actually a directory` + ); + } + }) + .catch(Cu.reportError); + }, + + async erase(query) { + if (!context.privateBrowsingAllowed) { + query.incognito = false; + } + + const items = await queryHelper(query); + let results = []; + let promises = []; + + for (let item of items) { + promises.push(DownloadMap.erase(item)); + results.push(item.id); + } + + await Promise.all(promises); + return results; + }, + + async open(downloadId) { + await DownloadMap.lazyInit(); + + let { download } = DownloadMap.fromId( + downloadId, + context.privateBrowsingAllowed + ); + + if (!download.succeeded) { + throw new ExtensionError("Download has not completed."); + } + + return download.launch(); + }, + + async show(downloadId) { + await DownloadMap.lazyInit(); + + const { download } = DownloadMap.fromId( + downloadId, + context.privateBrowsingAllowed + ); + + await download.showContainingDirectory(); + + return true; + }, + + async getFileIcon(downloadId, options) { + await DownloadMap.lazyInit(); + + const size = options?.size || 32; + const { download } = DownloadMap.fromId( + downloadId, + context.privateBrowsingAllowed + ); + + let pathPrefix = ""; + let path; + + if (download.succeeded) { + let file = FileUtils.File(download.target.path); + path = Services.io.newFileURI(file).spec; + } else { + path = PathUtils.filename(download.target.path); + pathPrefix = "//"; + } + + let windowlessBrowser = + Services.appShell.createWindowlessBrowser(true); + let systemPrincipal = + Services.scriptSecurityManager.getSystemPrincipal(); + windowlessBrowser.docShell.createAboutBlankDocumentViewer( + systemPrincipal, + systemPrincipal + ); + + let canvas = windowlessBrowser.document.createElement("canvas"); + let img = new windowlessBrowser.docShell.domWindow.Image(size, size); + + canvas.width = size; + canvas.height = size; + + img.src = `moz-icon:${pathPrefix}${path}?size=${size}`; + + try { + await img.decode(); + + canvas.getContext("2d").drawImage(img, 0, 0, size, size); + + let dataURL = canvas.toDataURL("image/png"); + + return dataURL; + } finally { + windowlessBrowser.close(); + } + }, + + onChanged: new EventManager({ + context, + module: "downloads", + event: "onChanged", + extensionApi: this, + }).api(), + + onCreated: new EventManager({ + context, + module: "downloads", + event: "onCreated", + extensionApi: this, + }).api(), + + onErased: new EventManager({ + context, + module: "downloads", + event: "onErased", + extensionApi: this, + }).api(), + + onDeterminingFilename: ignoreEvent( + context, + "downloads.onDeterminingFilename" + ), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-extension.js b/toolkit/components/extensions/parent/ext-extension.js new file mode 100644 index 0000000000..2f0a168dd4 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-extension.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/. */ + +"use strict"; + +this.extension = class extends ExtensionAPI { + getAPI(context) { + return { + extension: { + get lastError() { + return context.lastError; + }, + + isAllowedIncognitoAccess() { + return context.privateBrowsingAllowed; + }, + + isAllowedFileSchemeAccess() { + return false; + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-geckoProfiler.js b/toolkit/components/extensions/parent/ext-geckoProfiler.js new file mode 100644 index 0000000000..91f2e6e594 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-geckoProfiler.js @@ -0,0 +1,191 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +"use strict"; + +const PREF_ASYNC_STACK = "javascript.options.asyncstack"; + +const ASYNC_STACKS_ENABLED = Services.prefs.getBoolPref( + PREF_ASYNC_STACK, + false +); + +var { ExtensionError } = ExtensionUtils; + +ChromeUtils.defineLazyGetter(this, "symbolicationService", () => { + let { createLocalSymbolicationService } = ChromeUtils.importESModule( + "resource://devtools/client/performance-new/shared/symbolication.sys.mjs" + ); + return createLocalSymbolicationService(Services.profiler.sharedLibraries, []); +}); + +const isRunningObserver = { + _observers: new Set(), + + observe(subject, topic, data) { + switch (topic) { + case "profiler-started": + case "profiler-stopped": + // Call observer(false) or observer(true), but do it through a promise + // so that it's asynchronous. + // We don't want it to be synchronous because of the observer call in + // addObserver, which is asynchronous, and we want to get the ordering + // right. + const isRunningPromise = Promise.resolve(topic === "profiler-started"); + for (let observer of this._observers) { + isRunningPromise.then(observer); + } + break; + } + }, + + _startListening() { + Services.obs.addObserver(this, "profiler-started"); + Services.obs.addObserver(this, "profiler-stopped"); + }, + + _stopListening() { + Services.obs.removeObserver(this, "profiler-started"); + Services.obs.removeObserver(this, "profiler-stopped"); + }, + + addObserver(observer) { + if (this._observers.size === 0) { + this._startListening(); + } + + this._observers.add(observer); + observer(Services.profiler.IsActive()); + }, + + removeObserver(observer) { + if (this._observers.delete(observer) && this._observers.size === 0) { + this._stopListening(); + } + }, +}; + +this.geckoProfiler = class extends ExtensionAPI { + getAPI(context) { + return { + geckoProfiler: { + async start(options) { + const { bufferSize, windowLength, interval, features, threads } = + options; + + Services.prefs.setBoolPref(PREF_ASYNC_STACK, false); + if (threads) { + Services.profiler.StartProfiler( + bufferSize, + interval, + features, + threads, + 0, + windowLength + ); + } else { + Services.profiler.StartProfiler( + bufferSize, + interval, + features, + [], + 0, + windowLength + ); + } + }, + + async stop() { + if (ASYNC_STACKS_ENABLED !== null) { + Services.prefs.setBoolPref(PREF_ASYNC_STACK, ASYNC_STACKS_ENABLED); + } + + Services.profiler.StopProfiler(); + }, + + async pause() { + Services.profiler.Pause(); + }, + + async resume() { + Services.profiler.Resume(); + }, + + async dumpProfileToFile(fileName) { + if (!Services.profiler.IsActive()) { + throw new ExtensionError( + "The profiler is stopped. " + + "You need to start the profiler before you can capture a profile." + ); + } + + if (fileName.includes("\\") || fileName.includes("/")) { + throw new ExtensionError("Path cannot contain a subdirectory."); + } + + let dirPath = PathUtils.join(PathUtils.profileDir, "profiler"); + let filePath = PathUtils.join(dirPath, fileName); + + try { + await IOUtils.makeDirectory(dirPath); + await Services.profiler.dumpProfileToFileAsync(filePath); + } catch (e) { + Cu.reportError(e); + throw new ExtensionError(`Dumping profile to ${filePath} failed.`); + } + }, + + async getProfile() { + if (!Services.profiler.IsActive()) { + throw new ExtensionError( + "The profiler is stopped. " + + "You need to start the profiler before you can capture a profile." + ); + } + + return Services.profiler.getProfileDataAsync(); + }, + + async getProfileAsArrayBuffer() { + if (!Services.profiler.IsActive()) { + throw new ExtensionError( + "The profiler is stopped. " + + "You need to start the profiler before you can capture a profile." + ); + } + + return Services.profiler.getProfileDataAsArrayBuffer(); + }, + + async getProfileAsGzippedArrayBuffer() { + if (!Services.profiler.IsActive()) { + throw new ExtensionError( + "The profiler is stopped. " + + "You need to start the profiler before you can capture a profile." + ); + } + + return Services.profiler.getProfileDataAsGzippedArrayBuffer(); + }, + + async getSymbols(debugName, breakpadId) { + return symbolicationService.getSymbolTable(debugName, breakpadId); + }, + + onRunning: new EventManager({ + context, + name: "geckoProfiler.onRunning", + register: fire => { + isRunningObserver.addObserver(fire.async); + return () => { + isRunningObserver.removeObserver(fire.async); + }; + }, + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-i18n.js b/toolkit/components/extensions/parent/ext-i18n.js new file mode 100644 index 0000000000..167a1d16c2 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-i18n.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/. */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + LanguageDetector: + "resource://gre/modules/translation/LanguageDetector.sys.mjs", +}); + +this.i18n = class extends ExtensionAPI { + getAPI(context) { + let { extension } = context; + return { + i18n: { + getMessage: function (messageName, substitutions) { + return extension.localizeMessage(messageName, substitutions, { + cloneScope: context.cloneScope, + }); + }, + + getAcceptLanguages: function () { + let result = extension.localeData.acceptLanguages; + return Promise.resolve(result); + }, + + getUILanguage: function () { + return extension.localeData.uiLocale; + }, + + detectLanguage: function (text) { + return LanguageDetector.detectLanguage(text).then(result => ({ + isReliable: result.confident, + languages: result.languages.map(lang => { + return { + language: lang.languageCode, + percentage: lang.percent, + }; + }), + })); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-identity.js b/toolkit/components/extensions/parent/ext-identity.js new file mode 100644 index 0000000000..5bc643811a --- /dev/null +++ b/toolkit/components/extensions/parent/ext-identity.js @@ -0,0 +1,152 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +"use strict"; + +XPCOMUtils.defineLazyGlobalGetters(this, ["XMLHttpRequest", "ChannelWrapper"]); + +var { promiseDocumentLoaded } = ExtensionUtils; + +const checkRedirected = (url, redirectURI) => { + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + xhr.open("GET", url); + // We expect this if the user has not authenticated. + xhr.onload = () => { + reject(0); + }; + // An unexpected error happened, log for extension authors. + xhr.onerror = () => { + reject(xhr.status); + }; + // Catch redirect to our redirect_uri before a new request is made. + xhr.channel.notificationCallbacks = { + QueryInterface: ChromeUtils.generateQI([ + "nsIInterfaceRequestor", + "nsIChannelEventSync", + ]), + + getInterface: ChromeUtils.generateQI(["nsIChannelEventSink"]), + + asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) { + let responseURL = newChannel.URI.spec; + if (responseURL.startsWith(redirectURI)) { + resolve(responseURL); + // Cancel the redirect. + callback.onRedirectVerifyCallback(Cr.NS_BINDING_ABORTED); + return; + } + callback.onRedirectVerifyCallback(Cr.NS_OK); + }, + }; + xhr.send(); + }); +}; + +const openOAuthWindow = (details, redirectURI) => { + let args = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray); + let supportsStringPrefURL = Cc[ + "@mozilla.org/supports-string;1" + ].createInstance(Ci.nsISupportsString); + supportsStringPrefURL.data = details.url; + args.appendElement(supportsStringPrefURL); + + let window = Services.ww.openWindow( + null, + AppConstants.BROWSER_CHROME_URL, + "launchWebAuthFlow_dialog", + "chrome,location=yes,centerscreen,dialog=no,resizable=yes,scrollbars=yes", + args + ); + + return new Promise((resolve, reject) => { + let httpActivityDistributor = Cc[ + "@mozilla.org/network/http-activity-distributor;1" + ].getService(Ci.nsIHttpActivityDistributor); + + let unloadListener; + let httpObserver; + + const resolveIfRedirectURI = channel => { + const url = channel.URI && channel.URI.spec; + if (!url || !url.startsWith(redirectURI)) { + return; + } + + // Early exit if channel isn't related to the oauth dialog. + let wrapper = ChannelWrapper.get(channel); + if ( + !wrapper.browserElement && + wrapper.browserElement !== window.gBrowser.selectedBrowser + ) { + return; + } + + wrapper.cancel(Cr.NS_ERROR_ABORT, Ci.nsILoadInfo.BLOCKING_REASON_NONE); + window.gBrowser.webNavigation.stop(Ci.nsIWebNavigation.STOP_ALL); + window.removeEventListener("unload", unloadListener); + httpActivityDistributor.removeObserver(httpObserver); + window.close(); + resolve(url); + }; + + httpObserver = { + observeActivity(channel, type, subtype, timestamp, sizeData, stringData) { + try { + channel.QueryInterface(Ci.nsIChannel); + } catch { + // Ignore activities for channels that doesn't implement nsIChannel + // (e.g. a NullHttpChannel). + return; + } + + resolveIfRedirectURI(channel); + }, + }; + + httpActivityDistributor.addObserver(httpObserver); + + // If the user just closes the window we need to reject + unloadListener = () => { + window.removeEventListener("unload", unloadListener); + httpActivityDistributor.removeObserver(httpObserver); + reject({ message: "User cancelled or denied access." }); + }; + + promiseDocumentLoaded(window.document).then(() => { + window.addEventListener("unload", unloadListener); + }); + }); +}; + +this.identity = class extends ExtensionAPI { + getAPI(context) { + return { + identity: { + launchWebAuthFlowInParent: function (details, redirectURI) { + // If the request is automatically redirected the user has already + // authorized and we do not want to show the window. + return checkRedirected(details.url, redirectURI).catch( + requestError => { + // requestError is zero or xhr.status + if (requestError !== 0) { + Cu.reportError( + `browser.identity auth check failed with ${requestError}` + ); + return Promise.reject({ message: "Invalid request" }); + } + if (!details.interactive) { + return Promise.reject({ message: `Requires user interaction` }); + } + + return openOAuthWindow(details, redirectURI); + } + ); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-idle.js b/toolkit/components/extensions/parent/ext-idle.js new file mode 100644 index 0000000000..f68ea293d7 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-idle.js @@ -0,0 +1,113 @@ +/* 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"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "idleService", + "@mozilla.org/widget/useridleservice;1", + "nsIUserIdleService" +); + +var { DefaultWeakMap } = ExtensionUtils; + +// WeakMap[Extension -> Object] +const idleObserversMap = new DefaultWeakMap(() => { + return { + observer: null, + detectionInterval: 60, + }; +}); + +const getIdleObserver = extension => { + let observerInfo = idleObserversMap.get(extension); + let { observer, detectionInterval } = observerInfo; + let interval = + extension.startupData?.idleDetectionInterval || detectionInterval; + + if (!observer) { + observer = new (class extends ExtensionCommon.EventEmitter { + observe(subject, topic, data) { + if (topic == "idle" || topic == "active") { + this.emit("stateChanged", topic); + } + } + })(); + idleService.addIdleObserver(observer, interval); + observerInfo.observer = observer; + observerInfo.detectionInterval = interval; + } + return observer; +}; + +this.idle = class extends ExtensionAPIPersistent { + PERSISTENT_EVENTS = { + onStateChanged({ fire }) { + let { extension } = this; + let listener = (event, data) => { + fire.sync(data); + }; + + getIdleObserver(extension).on("stateChanged", listener); + return { + async unregister() { + let observerInfo = idleObserversMap.get(extension); + let { observer, detectionInterval } = observerInfo; + if (observer) { + observer.off("stateChanged", listener); + if (!observer.has("stateChanged")) { + idleService.removeIdleObserver(observer, detectionInterval); + observerInfo.observer = null; + } + } + }, + convert(_fire) { + fire = _fire; + }, + }; + }, + }; + + getAPI(context) { + let { extension } = context; + let self = this; + + return { + idle: { + queryState(detectionIntervalInSeconds) { + if (idleService.idleTime < detectionIntervalInSeconds * 1000) { + return "active"; + } + return "idle"; + }, + setDetectionInterval(detectionIntervalInSeconds) { + let observerInfo = idleObserversMap.get(extension); + let { observer, detectionInterval } = observerInfo; + if (detectionInterval == detectionIntervalInSeconds) { + return; + } + if (observer) { + idleService.removeIdleObserver(observer, detectionInterval); + idleService.addIdleObserver(observer, detectionIntervalInSeconds); + } + observerInfo.detectionInterval = detectionIntervalInSeconds; + // There is no great way to modify a persistent listener param, but we + // need to keep this for the startup listener. + if (!extension.persistentBackground) { + extension.startupData.idleDetectionInterval = + detectionIntervalInSeconds; + extension.saveStartupData(); + } + }, + onStateChanged: new EventManager({ + context, + module: "idle", + event: "onStateChanged", + extensionApi: self, + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-management.js b/toolkit/components/extensions/parent/ext-management.js new file mode 100644 index 0000000000..e0834d378f --- /dev/null +++ b/toolkit/components/extensions/parent/ext-management.js @@ -0,0 +1,354 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +"use strict"; + +ChromeUtils.defineLazyGetter(this, "strBundle", function () { + return Services.strings.createBundle( + "chrome://global/locale/extensions.properties" + ); +}); +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", +}); + +// We can't use Services.prompt here at the moment, as tests need to mock +// the prompt service. We could use sinon, but that didn't seem to work +// with Android builds. +// eslint-disable-next-line mozilla/use-services +XPCOMUtils.defineLazyServiceGetter( + this, + "promptService", + "@mozilla.org/prompter;1", + "nsIPromptService" +); + +var { ExtensionError } = ExtensionUtils; + +const _ = (key, ...args) => { + if (args.length) { + return strBundle.formatStringFromName(key, args); + } + return strBundle.GetStringFromName(key); +}; + +const installType = addon => { + if (addon.temporarilyInstalled) { + return "development"; + } else if (addon.foreignInstall) { + return "sideload"; + } else if (addon.isSystem) { + return "other"; + } + return "normal"; +}; + +const getExtensionInfoForAddon = (extension, addon) => { + let extInfo = { + id: addon.id, + name: addon.name, + description: addon.description || "", + version: addon.version, + mayDisable: !!(addon.permissions & AddonManager.PERM_CAN_DISABLE), + enabled: addon.isActive, + optionsUrl: addon.optionsURL || "", + installType: installType(addon), + type: addon.type, + }; + + if (extension) { + let m = extension.manifest; + + let hostPerms = extension.allowedOrigins.patterns.map( + matcher => matcher.pattern + ); + + extInfo.permissions = Array.from(extension.permissions).filter(perm => { + return !hostPerms.includes(perm); + }); + extInfo.hostPermissions = hostPerms; + + extInfo.shortName = m.short_name || ""; + if (m.icons) { + extInfo.icons = Object.keys(m.icons).map(key => { + return { size: Number(key), url: m.icons[key] }; + }); + } + } + + if (!addon.isActive) { + extInfo.disabledReason = "unknown"; + } + if (addon.homepageURL) { + extInfo.homepageUrl = addon.homepageURL; + } + if (addon.updateURL) { + extInfo.updateUrl = addon.updateURL; + } + return extInfo; +}; + +// Some management APIs are intentionally limited. +const allowedTypes = ["theme", "extension"]; + +function checkAllowedAddon(addon) { + if (addon.isSystem || addon.isAPIExtension) { + return false; + } + if (addon.type == "extension" && !addon.isWebExtension) { + return false; + } + return allowedTypes.includes(addon.type); +} + +class ManagementAddonListener extends ExtensionCommon.EventEmitter { + eventNames = ["onEnabled", "onDisabled", "onInstalled", "onUninstalled"]; + + hasAnyListeners() { + for (let event of this.eventNames) { + if (this.has(event)) { + return true; + } + } + return false; + } + + on(event, listener) { + if (!this.eventNames.includes(event)) { + throw new Error("unsupported event"); + } + if (!this.hasAnyListeners()) { + AddonManager.addAddonListener(this); + } + super.on(event, listener); + } + + off(event, listener) { + if (!this.eventNames.includes(event)) { + throw new Error("unsupported event"); + } + super.off(event, listener); + if (!this.hasAnyListeners()) { + AddonManager.removeAddonListener(this); + } + } + + getExtensionInfo(addon) { + let ext = WebExtensionPolicy.getByID(addon.id)?.extension; + return getExtensionInfoForAddon(ext, addon); + } + + onEnabled(addon) { + if (!checkAllowedAddon(addon)) { + return; + } + this.emit("onEnabled", this.getExtensionInfo(addon)); + } + + onDisabled(addon) { + if (!checkAllowedAddon(addon)) { + return; + } + this.emit("onDisabled", this.getExtensionInfo(addon)); + } + + onInstalled(addon) { + if (!checkAllowedAddon(addon)) { + return; + } + this.emit("onInstalled", this.getExtensionInfo(addon)); + } + + onUninstalled(addon) { + if (!checkAllowedAddon(addon)) { + return; + } + this.emit("onUninstalled", this.getExtensionInfo(addon)); + } +} + +this.management = class extends ExtensionAPIPersistent { + addonListener = new ManagementAddonListener(); + + onShutdown() { + AddonManager.removeAddonListener(this.addonListener); + } + + eventRegistrar(eventName) { + return ({ fire }) => { + let listener = (event, data) => { + fire.async(data); + }; + + this.addonListener.on(eventName, listener); + return { + unregister: () => { + this.addonListener.off(eventName, listener); + }, + convert(_fire, context) { + fire = _fire; + }, + }; + }; + } + + PERSISTENT_EVENTS = { + onDisabled: this.eventRegistrar("onDisabled"), + onEnabled: this.eventRegistrar("onEnabled"), + onInstalled: this.eventRegistrar("onInstalled"), + onUninstalled: this.eventRegistrar("onUninstalled"), + }; + + getAPI(context) { + let { extension } = context; + + return { + management: { + async get(id) { + let addon = await AddonManager.getAddonByID(id); + if (!addon) { + throw new ExtensionError(`No such addon ${id}`); + } + if (!checkAllowedAddon(addon)) { + throw new ExtensionError("get not allowed for this addon"); + } + // If the extension is enabled get it and use it for more data. + let ext = WebExtensionPolicy.getByID(addon.id)?.extension; + return getExtensionInfoForAddon(ext, addon); + }, + + async getAll() { + let addons = await AddonManager.getAddonsByTypes(allowedTypes); + return addons.filter(checkAllowedAddon).map(addon => { + // If the extension is enabled get it and use it for more data. + let ext = WebExtensionPolicy.getByID(addon.id)?.extension; + return getExtensionInfoForAddon(ext, addon); + }); + }, + + async install({ url, hash }) { + let listener = { + onDownloadEnded(install) { + if (install.addon.appDisabled || install.addon.type !== "theme") { + install.cancel(); + return false; + } + }, + }; + + let telemetryInfo = { + source: "extension", + method: "management-webext-api", + }; + let install = await AddonManager.getInstallForURL(url, { + hash, + telemetryInfo, + triggeringPrincipal: extension.principal, + }); + install.addListener(listener); + try { + await install.install(); + } catch (e) { + Cu.reportError(e); + throw new ExtensionError("Incompatible addon"); + } + await install.addon.enable(); + return { id: install.addon.id }; + }, + + async getSelf() { + let addon = await AddonManager.getAddonByID(extension.id); + return getExtensionInfoForAddon(extension, addon); + }, + + async uninstallSelf(options) { + if (options && options.showConfirmDialog) { + let message = _("uninstall.confirmation.message", extension.name); + if (options.dialogMessage) { + message = `${options.dialogMessage}\n${message}`; + } + let title = _("uninstall.confirmation.title", extension.name); + let buttonFlags = + Ci.nsIPrompt.BUTTON_POS_0 * Ci.nsIPrompt.BUTTON_TITLE_IS_STRING + + Ci.nsIPrompt.BUTTON_POS_1 * Ci.nsIPrompt.BUTTON_TITLE_IS_STRING; + let button0Title = _("uninstall.confirmation.button-0.label"); + let button1Title = _("uninstall.confirmation.button-1.label"); + let response = promptService.confirmEx( + null, + title, + message, + buttonFlags, + button0Title, + button1Title, + null, + null, + { value: 0 } + ); + if (response == 1) { + throw new ExtensionError("User cancelled uninstall of extension"); + } + } + let addon = await AddonManager.getAddonByID(extension.id); + let canUninstall = Boolean( + addon.permissions & AddonManager.PERM_CAN_UNINSTALL + ); + if (!canUninstall) { + throw new ExtensionError("The add-on cannot be uninstalled"); + } + addon.uninstall(); + }, + + async setEnabled(id, enabled) { + let addon = await AddonManager.getAddonByID(id); + if (!addon) { + throw new ExtensionError(`No such addon ${id}`); + } + if (addon.type !== "theme") { + throw new ExtensionError("setEnabled applies only to theme addons"); + } + if (addon.isSystem) { + throw new ExtensionError( + "setEnabled cannot be used with a system addon" + ); + } + if (enabled) { + await addon.enable(); + } else { + await addon.disable(); + } + }, + + onDisabled: new EventManager({ + context, + module: "management", + event: "onDisabled", + extensionApi: this, + }).api(), + + onEnabled: new EventManager({ + context, + module: "management", + event: "onEnabled", + extensionApi: this, + }).api(), + + onInstalled: new EventManager({ + context, + module: "management", + event: "onInstalled", + extensionApi: this, + }).api(), + + onUninstalled: new EventManager({ + context, + module: "management", + event: "onUninstalled", + extensionApi: this, + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-networkStatus.js b/toolkit/components/extensions/parent/ext-networkStatus.js new file mode 100644 index 0000000000..7379d746f5 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-networkStatus.js @@ -0,0 +1,85 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ +"use strict"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "gNetworkLinkService", + "@mozilla.org/network/network-link-service;1", + "nsINetworkLinkService" +); + +function getLinkType() { + switch (gNetworkLinkService.linkType) { + case gNetworkLinkService.LINK_TYPE_UNKNOWN: + return "unknown"; + case gNetworkLinkService.LINK_TYPE_ETHERNET: + return "ethernet"; + case gNetworkLinkService.LINK_TYPE_USB: + return "usb"; + case gNetworkLinkService.LINK_TYPE_WIFI: + return "wifi"; + case gNetworkLinkService.LINK_TYPE_WIMAX: + return "wimax"; + case gNetworkLinkService.LINK_TYPE_MOBILE: + return "mobile"; + default: + return "unknown"; + } +} + +function getLinkStatus() { + if (!gNetworkLinkService.linkStatusKnown) { + return "unknown"; + } + return gNetworkLinkService.isLinkUp ? "up" : "down"; +} + +function getLinkInfo() { + return { + id: gNetworkLinkService.networkID || undefined, + status: getLinkStatus(), + type: getLinkType(), + }; +} + +this.networkStatus = class extends ExtensionAPI { + getAPI(context) { + return { + networkStatus: { + getLinkInfo, + onConnectionChanged: new EventManager({ + context, + name: "networkStatus.onConnectionChanged", + register: fire => { + let observerStatus = (subject, topic, data) => { + fire.async(getLinkInfo()); + }; + + Services.obs.addObserver( + observerStatus, + "network:link-status-changed" + ); + Services.obs.addObserver( + observerStatus, + "network:link-type-changed" + ); + return () => { + Services.obs.removeObserver( + observerStatus, + "network:link-status-changed" + ); + Services.obs.removeObserver( + observerStatus, + "network:link-type-changed" + ); + }; + }, + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-notifications.js b/toolkit/components/extensions/parent/ext-notifications.js new file mode 100644 index 0000000000..5b42e6c936 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-notifications.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/. */ + +"use strict"; + +const ToolkitModules = {}; + +ChromeUtils.defineESModuleGetters(ToolkitModules, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", +}); + +var { ignoreEvent } = ExtensionCommon; + +// Manages a notification popup (notifications API) created by the extension. +function Notification(context, notificationsMap, id, options) { + this.notificationsMap = notificationsMap; + this.id = id; + this.options = options; + + let imageURL; + if (options.iconUrl) { + imageURL = context.extension.baseURI.resolve(options.iconUrl); + } + + // Set before calling into nsIAlertsService, because the notification may be + // closed during the call. + notificationsMap.set(id, this); + + try { + let svc = Cc["@mozilla.org/alerts-service;1"].getService( + Ci.nsIAlertsService + ); + svc.showAlertNotification( + imageURL, + options.title, + options.message, + true, // textClickable + this.id, + this, + this.id, + undefined, + undefined, + undefined, + // Principal is not set because doing so reveals buttons to control + // notification preferences, which are currently not implemented for + // notifications triggered via this extension API (bug 1589693). + undefined, + context.incognito + ); + } catch (e) { + // This will fail if alerts aren't available on the system. + + this.observe(null, "alertfinished", id); + } +} + +Notification.prototype = { + clear() { + try { + let svc = Cc["@mozilla.org/alerts-service;1"].getService( + Ci.nsIAlertsService + ); + svc.closeAlert(this.id); + } catch (e) { + // This will fail if the OS doesn't support this function. + } + this.notificationsMap.delete(this.id); + }, + + observe(subject, topic, data) { + switch (topic) { + case "alertclickcallback": + this.notificationsMap.emit("clicked", data); + break; + case "alertfinished": + this.notificationsMap.emit("closed", data); + this.notificationsMap.delete(this.id); + break; + case "alertshow": + this.notificationsMap.emit("shown", data); + break; + } + }, +}; + +this.notifications = class extends ExtensionAPI { + constructor(extension) { + super(extension); + + this.nextId = 0; + this.notificationsMap = new Map(); + ToolkitModules.EventEmitter.decorate(this.notificationsMap); + } + + onShutdown() { + for (let notification of this.notificationsMap.values()) { + notification.clear(); + } + } + + getAPI(context) { + let notificationsMap = this.notificationsMap; + + return { + notifications: { + create: (notificationId, options) => { + if (!notificationId) { + notificationId = String(this.nextId++); + } + + if (notificationsMap.has(notificationId)) { + notificationsMap.get(notificationId).clear(); + } + + new Notification(context, notificationsMap, notificationId, options); + + return Promise.resolve(notificationId); + }, + + clear: function (notificationId) { + if (notificationsMap.has(notificationId)) { + notificationsMap.get(notificationId).clear(); + return Promise.resolve(true); + } + return Promise.resolve(false); + }, + + getAll: function () { + let result = {}; + notificationsMap.forEach((value, key) => { + result[key] = value.options; + }); + return Promise.resolve(result); + }, + + onClosed: new EventManager({ + context, + name: "notifications.onClosed", + register: fire => { + let listener = (event, notificationId) => { + // TODO Bug 1413188, Support the byUser argument. + fire.async(notificationId, true); + }; + + notificationsMap.on("closed", listener); + return () => { + notificationsMap.off("closed", listener); + }; + }, + }).api(), + + onClicked: new EventManager({ + context, + name: "notifications.onClicked", + register: fire => { + let listener = (event, notificationId) => { + fire.async(notificationId); + }; + + notificationsMap.on("clicked", listener); + return () => { + notificationsMap.off("clicked", listener); + }; + }, + }).api(), + + onShown: new EventManager({ + context, + name: "notifications.onShown", + register: fire => { + let listener = (event, notificationId) => { + fire.async(notificationId); + }; + + notificationsMap.on("shown", listener); + return () => { + notificationsMap.off("shown", listener); + }; + }, + }).api(), + + // TODO Bug 1190681, implement button support. + onButtonClicked: ignoreEvent(context, "notifications.onButtonClicked"), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-permissions.js b/toolkit/components/extensions/parent/ext-permissions.js new file mode 100644 index 0000000000..8639381de7 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-permissions.js @@ -0,0 +1,191 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs", +}); + +var { ExtensionError } = ExtensionUtils; + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "promptsEnabled", + "extensions.webextOptionalPermissionPrompts" +); + +function normalizePermissions(perms) { + perms = { ...perms }; + perms.permissions = perms.permissions.filter( + perm => !perm.startsWith("internal:") + ); + return perms; +} + +this.permissions = class extends ExtensionAPIPersistent { + PERSISTENT_EVENTS = { + onAdded({ fire }) { + let { extension } = this; + let callback = (event, change) => { + if (change.extensionId == extension.id && change.added) { + let perms = normalizePermissions(change.added); + if (perms.permissions.length || perms.origins.length) { + fire.async(perms); + } + } + }; + + extensions.on("change-permissions", callback); + return { + unregister() { + extensions.off("change-permissions", callback); + }, + convert(_fire) { + fire = _fire; + }, + }; + }, + onRemoved({ fire }) { + let { extension } = this; + let callback = (event, change) => { + if (change.extensionId == extension.id && change.removed) { + let perms = normalizePermissions(change.removed); + if (perms.permissions.length || perms.origins.length) { + fire.async(perms); + } + } + }; + + extensions.on("change-permissions", callback); + return { + unregister() { + extensions.off("change-permissions", callback); + }, + convert(_fire) { + fire = _fire; + }, + }; + }, + }; + + getAPI(context) { + let { extension } = context; + + return { + permissions: { + async request(perms) { + let { permissions, origins } = perms; + + let { optionalPermissions } = context.extension; + for (let perm of permissions) { + if (!optionalPermissions.includes(perm)) { + throw new ExtensionError( + `Cannot request permission ${perm} since it was not declared in optional_permissions` + ); + } + } + + let optionalOrigins = context.extension.optionalOrigins; + for (let origin of origins) { + if (!optionalOrigins.subsumes(new MatchPattern(origin))) { + throw new ExtensionError( + `Cannot request origin permission for ${origin} since it was not declared in the manifest` + ); + } + } + + if (promptsEnabled) { + permissions = permissions.filter( + perm => !context.extension.hasPermission(perm) + ); + origins = origins.filter( + origin => + !context.extension.allowedOrigins.subsumes( + new MatchPattern(origin) + ) + ); + + if (!permissions.length && !origins.length) { + return true; + } + + let browser = context.pendingEventBrowser || context.xulBrowser; + let allow = await new Promise(resolve => { + let subject = { + wrappedJSObject: { + browser, + name: context.extension.name, + id: context.extension.id, + icon: context.extension.getPreferredIcon(32), + permissions: { permissions, origins }, + resolve, + }, + }; + Services.obs.notifyObservers( + subject, + "webextension-optional-permission-prompt" + ); + }); + if (!allow) { + return false; + } + } + + await ExtensionPermissions.add(extension.id, perms, extension); + return true; + }, + + async getAll() { + let perms = normalizePermissions(context.extension.activePermissions); + delete perms.apis; + return perms; + }, + + async contains(permissions) { + for (let perm of permissions.permissions) { + if (!context.extension.hasPermission(perm)) { + return false; + } + } + + for (let origin of permissions.origins) { + if ( + !context.extension.allowedOrigins.subsumes( + new MatchPattern(origin) + ) + ) { + return false; + } + } + + return true; + }, + + async remove(permissions) { + await ExtensionPermissions.remove( + extension.id, + permissions, + extension + ); + return true; + }, + + onAdded: new EventManager({ + context, + module: "permissions", + event: "onAdded", + extensionApi: this, + }).api(), + + onRemoved: new EventManager({ + context, + module: "permissions", + event: "onRemoved", + extensionApi: this, + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-privacy.js b/toolkit/components/extensions/parent/ext-privacy.js new file mode 100644 index 0000000000..1c4bf05ff1 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-privacy.js @@ -0,0 +1,516 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +"use strict"; + +var { ExtensionPreferencesManager } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPreferencesManager.sys.mjs" +); + +var { ExtensionError } = ExtensionUtils; +var { getSettingsAPI, getPrimedSettingsListener } = ExtensionPreferencesManager; + +const cookieSvc = Ci.nsICookieService; + +const getIntPref = p => Services.prefs.getIntPref(p, undefined); +const getBoolPref = p => Services.prefs.getBoolPref(p, undefined); + +const TLS_MIN_PREF = "security.tls.version.min"; +const TLS_MAX_PREF = "security.tls.version.max"; + +const cookieBehaviorValues = new Map([ + ["allow_all", cookieSvc.BEHAVIOR_ACCEPT], + ["reject_third_party", cookieSvc.BEHAVIOR_REJECT_FOREIGN], + ["reject_all", cookieSvc.BEHAVIOR_REJECT], + ["allow_visited", cookieSvc.BEHAVIOR_LIMIT_FOREIGN], + ["reject_trackers", cookieSvc.BEHAVIOR_REJECT_TRACKER], + [ + "reject_trackers_and_partition_foreign", + cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN, + ], +]); + +function isTLSMinVersionLowerOrEQThan(version) { + return ( + Services.prefs.getDefaultBranch("").getIntPref(TLS_MIN_PREF) <= version + ); +} + +const TLS_VERSIONS = [ + { version: 1, name: "TLSv1", settable: isTLSMinVersionLowerOrEQThan(1) }, + { version: 2, name: "TLSv1.1", settable: isTLSMinVersionLowerOrEQThan(2) }, + { version: 3, name: "TLSv1.2", settable: true }, + { version: 4, name: "TLSv1.3", settable: true }, +]; + +// Add settings objects for supported APIs to the preferences manager. +ExtensionPreferencesManager.addSetting("network.networkPredictionEnabled", { + permission: "privacy", + prefNames: [ + "network.predictor.enabled", + "network.prefetch-next", + "network.http.speculative-parallel-limit", + "network.dns.disablePrefetch", + ], + + setCallback(value) { + return { + "network.http.speculative-parallel-limit": value ? undefined : 0, + "network.dns.disablePrefetch": !value, + "network.predictor.enabled": value, + "network.prefetch-next": value, + }; + }, + + getCallback() { + return ( + getBoolPref("network.predictor.enabled") && + getBoolPref("network.prefetch-next") && + getIntPref("network.http.speculative-parallel-limit") > 0 && + !getBoolPref("network.dns.disablePrefetch") + ); + }, +}); + +ExtensionPreferencesManager.addSetting("network.globalPrivacyControl", { + permission: "privacy", + prefNames: ["privacy.globalprivacycontrol.enabled"], + readOnly: true, + + setCallback(value) { + return { + "privacy.globalprivacycontrol.enabled": value, + }; + }, + + getCallback() { + return getBoolPref("privacy.globalprivacycontrol.enabled"); + }, +}); + +ExtensionPreferencesManager.addSetting("network.httpsOnlyMode", { + permission: "privacy", + prefNames: [ + "dom.security.https_only_mode", + "dom.security.https_only_mode_pbm", + ], + readOnly: true, + + setCallback(value) { + let prefs = { + "dom.security.https_only_mode": false, + "dom.security.https_only_mode_pbm": false, + }; + + switch (value) { + case "always": + prefs["dom.security.https_only_mode"] = true; + break; + + case "private_browsing": + prefs["dom.security.https_only_mode_pbm"] = true; + break; + + case "never": + break; + } + + return prefs; + }, + + getCallback() { + if (getBoolPref("dom.security.https_only_mode")) { + return "always"; + } + if (getBoolPref("dom.security.https_only_mode_pbm")) { + return "private_browsing"; + } + return "never"; + }, +}); + +ExtensionPreferencesManager.addSetting("network.peerConnectionEnabled", { + permission: "privacy", + prefNames: ["media.peerconnection.enabled"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return getBoolPref("media.peerconnection.enabled"); + }, +}); + +ExtensionPreferencesManager.addSetting("network.webRTCIPHandlingPolicy", { + permission: "privacy", + prefNames: [ + "media.peerconnection.ice.default_address_only", + "media.peerconnection.ice.no_host", + "media.peerconnection.ice.proxy_only_if_behind_proxy", + "media.peerconnection.ice.proxy_only", + ], + + setCallback(value) { + let prefs = {}; + switch (value) { + case "default": + // All prefs are already set to be reset. + break; + + case "default_public_and_private_interfaces": + prefs["media.peerconnection.ice.default_address_only"] = true; + break; + + case "default_public_interface_only": + prefs["media.peerconnection.ice.default_address_only"] = true; + prefs["media.peerconnection.ice.no_host"] = true; + break; + + case "disable_non_proxied_udp": + prefs["media.peerconnection.ice.default_address_only"] = true; + prefs["media.peerconnection.ice.no_host"] = true; + prefs["media.peerconnection.ice.proxy_only_if_behind_proxy"] = true; + break; + + case "proxy_only": + prefs["media.peerconnection.ice.proxy_only"] = true; + break; + } + return prefs; + }, + + getCallback() { + if (getBoolPref("media.peerconnection.ice.proxy_only")) { + return "proxy_only"; + } + + let default_address_only = getBoolPref( + "media.peerconnection.ice.default_address_only" + ); + if (default_address_only) { + let no_host = getBoolPref("media.peerconnection.ice.no_host"); + if (no_host) { + if ( + getBoolPref("media.peerconnection.ice.proxy_only_if_behind_proxy") + ) { + return "disable_non_proxied_udp"; + } + return "default_public_interface_only"; + } + return "default_public_and_private_interfaces"; + } + + return "default"; + }, +}); + +ExtensionPreferencesManager.addSetting("services.passwordSavingEnabled", { + permission: "privacy", + prefNames: ["signon.rememberSignons"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return getBoolPref("signon.rememberSignons"); + }, +}); + +ExtensionPreferencesManager.addSetting("websites.cookieConfig", { + permission: "privacy", + prefNames: ["network.cookie.cookieBehavior"], + + setCallback(value) { + const cookieBehavior = cookieBehaviorValues.get(value.behavior); + + // Intentionally use Preferences.get("network.cookie.cookieBehavior") here + // to read the "real" preference value. + const needUpdate = + cookieBehavior !== getIntPref("network.cookie.cookieBehavior"); + if ( + needUpdate && + getBoolPref("privacy.firstparty.isolate") && + cookieBehavior === cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN + ) { + throw new ExtensionError( + `Invalid cookieConfig '${value.behavior}' when firstPartyIsolate is enabled` + ); + } + + if (typeof value.nonPersistentCookies === "boolean") { + Cu.reportError( + "'nonPersistentCookies' has been deprecated and it has no effect anymore." + ); + } + + return { + "network.cookie.cookieBehavior": cookieBehavior, + }; + }, + + getCallback() { + let prefValue = getIntPref("network.cookie.cookieBehavior"); + return { + behavior: Array.from(cookieBehaviorValues.entries()).find( + entry => entry[1] === prefValue + )[0], + // Bug 1754924 - this property is now deprecated. + nonPersistentCookies: false, + }; + }, +}); + +ExtensionPreferencesManager.addSetting("websites.firstPartyIsolate", { + permission: "privacy", + prefNames: ["privacy.firstparty.isolate"], + + setCallback(value) { + // Intentionally use Preferences.get("network.cookie.cookieBehavior") here + // to read the "real" preference value. + const cookieBehavior = getIntPref("network.cookie.cookieBehavior"); + + const needUpdate = value !== getBoolPref("privacy.firstparty.isolate"); + if ( + needUpdate && + value && + cookieBehavior === cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN + ) { + const behavior = Array.from(cookieBehaviorValues.entries()).find( + entry => entry[1] === cookieBehavior + )[0]; + throw new ExtensionError( + `Can't enable firstPartyIsolate when cookieBehavior is '${behavior}'` + ); + } + + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return getBoolPref("privacy.firstparty.isolate"); + }, +}); + +ExtensionPreferencesManager.addSetting("websites.hyperlinkAuditingEnabled", { + permission: "privacy", + prefNames: ["browser.send_pings"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return getBoolPref("browser.send_pings"); + }, +}); + +ExtensionPreferencesManager.addSetting("websites.referrersEnabled", { + permission: "privacy", + prefNames: ["network.http.sendRefererHeader"], + + // Values for network.http.sendRefererHeader: + // 0=don't send any, 1=send only on clicks, 2=send on image requests as well + // http://searchfox.org/mozilla-central/rev/61054508641ee76f9c49bcf7303ef3cfb6b410d2/modules/libpref/init/all.js#1585 + setCallback(value) { + return { [this.prefNames[0]]: value ? 2 : 0 }; + }, + + getCallback() { + return getIntPref("network.http.sendRefererHeader") !== 0; + }, +}); + +ExtensionPreferencesManager.addSetting("websites.resistFingerprinting", { + permission: "privacy", + prefNames: ["privacy.resistFingerprinting"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return getBoolPref("privacy.resistFingerprinting"); + }, +}); + +ExtensionPreferencesManager.addSetting("websites.trackingProtectionMode", { + permission: "privacy", + prefNames: [ + "privacy.trackingprotection.enabled", + "privacy.trackingprotection.pbmode.enabled", + ], + + setCallback(value) { + // Default to private browsing. + let prefs = { + "privacy.trackingprotection.enabled": false, + "privacy.trackingprotection.pbmode.enabled": true, + }; + + switch (value) { + case "private_browsing": + break; + + case "always": + prefs["privacy.trackingprotection.enabled"] = true; + break; + + case "never": + prefs["privacy.trackingprotection.pbmode.enabled"] = false; + break; + } + + return prefs; + }, + + getCallback() { + if (getBoolPref("privacy.trackingprotection.enabled")) { + return "always"; + } else if (getBoolPref("privacy.trackingprotection.pbmode.enabled")) { + return "private_browsing"; + } + return "never"; + }, +}); + +ExtensionPreferencesManager.addSetting("network.tlsVersionRestriction", { + permission: "privacy", + prefNames: [TLS_MIN_PREF, TLS_MAX_PREF], + + setCallback(value) { + function tlsStringToVersion(string) { + const version = TLS_VERSIONS.find(a => a.name === string); + if (version && version.settable) { + return version.version; + } + + throw new ExtensionError( + `Setting TLS version ${string} is not allowed for security reasons.` + ); + } + + const prefs = {}; + + if (value.minimum) { + prefs[TLS_MIN_PREF] = tlsStringToVersion(value.minimum); + } + + if (value.maximum) { + prefs[TLS_MAX_PREF] = tlsStringToVersion(value.maximum); + } + + // If minimum has passed and it's greater than the max value. + if (prefs[TLS_MIN_PREF]) { + const max = prefs[TLS_MAX_PREF] || getIntPref(TLS_MAX_PREF); + if (max < prefs[TLS_MIN_PREF]) { + throw new ExtensionError( + `Setting TLS min version grater than the max version is not allowed.` + ); + } + } + + // If maximum has passed and it's lower than the min value. + else if (prefs[TLS_MAX_PREF]) { + const min = getIntPref(TLS_MIN_PREF); + if (min > prefs[TLS_MAX_PREF]) { + throw new ExtensionError( + `Setting TLS max version lower than the min version is not allowed.` + ); + } + } + + return prefs; + }, + + getCallback() { + function tlsVersionToString(pref) { + const value = getIntPref(pref); + const version = TLS_VERSIONS.find(a => a.version === value); + if (version) { + return version.name; + } + return "unknown"; + } + + return { + minimum: tlsVersionToString(TLS_MIN_PREF), + maximum: tlsVersionToString(TLS_MAX_PREF), + }; + }, + + validate(extension) { + if (!extension.isPrivileged) { + throw new ExtensionError( + "tlsVersionRestriction can be set by privileged extensions only." + ); + } + }, +}); + +this.privacy = class extends ExtensionAPI { + primeListener(event, fire) { + let { extension } = this; + let listener = getPrimedSettingsListener({ + extension, + name: event, + }); + return listener(fire); + } + + getAPI(context) { + function makeSettingsAPI(name) { + return getSettingsAPI({ + context, + module: "privacy", + name, + }); + } + + return { + privacy: { + network: { + networkPredictionEnabled: makeSettingsAPI( + "network.networkPredictionEnabled" + ), + globalPrivacyControl: makeSettingsAPI("network.globalPrivacyControl"), + httpsOnlyMode: makeSettingsAPI("network.httpsOnlyMode"), + peerConnectionEnabled: makeSettingsAPI( + "network.peerConnectionEnabled" + ), + webRTCIPHandlingPolicy: makeSettingsAPI( + "network.webRTCIPHandlingPolicy" + ), + tlsVersionRestriction: makeSettingsAPI( + "network.tlsVersionRestriction" + ), + }, + + services: { + passwordSavingEnabled: makeSettingsAPI( + "services.passwordSavingEnabled" + ), + }, + + websites: { + cookieConfig: makeSettingsAPI("websites.cookieConfig"), + firstPartyIsolate: makeSettingsAPI("websites.firstPartyIsolate"), + hyperlinkAuditingEnabled: makeSettingsAPI( + "websites.hyperlinkAuditingEnabled" + ), + referrersEnabled: makeSettingsAPI("websites.referrersEnabled"), + resistFingerprinting: makeSettingsAPI( + "websites.resistFingerprinting" + ), + trackingProtectionMode: makeSettingsAPI( + "websites.trackingProtectionMode" + ), + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-protocolHandlers.js b/toolkit/components/extensions/parent/ext-protocolHandlers.js new file mode 100644 index 0000000000..36cdf25d42 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-protocolHandlers.js @@ -0,0 +1,100 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +"use strict"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "handlerService", + "@mozilla.org/uriloader/handler-service;1", + "nsIHandlerService" +); +XPCOMUtils.defineLazyServiceGetter( + this, + "protocolService", + "@mozilla.org/uriloader/external-protocol-service;1", + "nsIExternalProtocolService" +); + +const hasHandlerApp = handlerConfig => { + let protoInfo = protocolService.getProtocolHandlerInfo( + handlerConfig.protocol + ); + let appHandlers = protoInfo.possibleApplicationHandlers; + for (let i = 0; i < appHandlers.length; i++) { + let handler = appHandlers.queryElementAt(i, Ci.nsISupports); + if ( + handler instanceof Ci.nsIWebHandlerApp && + handler.uriTemplate === handlerConfig.uriTemplate + ) { + return true; + } + } + return false; +}; + +this.protocolHandlers = class extends ExtensionAPI { + onManifestEntry(entryName) { + let { extension } = this; + let { manifest } = extension; + + for (let handlerConfig of manifest.protocol_handlers) { + if (hasHandlerApp(handlerConfig)) { + continue; + } + + let handler = Cc[ + "@mozilla.org/uriloader/web-handler-app;1" + ].createInstance(Ci.nsIWebHandlerApp); + handler.name = handlerConfig.name; + handler.uriTemplate = handlerConfig.uriTemplate; + + let protoInfo = protocolService.getProtocolHandlerInfo( + handlerConfig.protocol + ); + let handlers = protoInfo.possibleApplicationHandlers; + if (protoInfo.preferredApplicationHandler || handlers.length) { + protoInfo.alwaysAskBeforeHandling = true; + } else { + protoInfo.preferredApplicationHandler = handler; + protoInfo.alwaysAskBeforeHandling = false; + } + handlers.appendElement(handler); + handlerService.store(protoInfo); + } + } + + onShutdown(isAppShutdown) { + let { extension } = this; + let { manifest } = extension; + + if (isAppShutdown) { + return; + } + + for (let handlerConfig of manifest.protocol_handlers) { + let protoInfo = protocolService.getProtocolHandlerInfo( + handlerConfig.protocol + ); + let appHandlers = protoInfo.possibleApplicationHandlers; + for (let i = 0; i < appHandlers.length; i++) { + let handler = appHandlers.queryElementAt(i, Ci.nsISupports); + if ( + handler instanceof Ci.nsIWebHandlerApp && + handler.uriTemplate === handlerConfig.uriTemplate + ) { + appHandlers.removeElementAt(i); + if (protoInfo.preferredApplicationHandler === handler) { + protoInfo.preferredApplicationHandler = null; + protoInfo.alwaysAskBeforeHandling = true; + } + handlerService.store(protoInfo); + break; + } + } + } + } +}; diff --git a/toolkit/components/extensions/parent/ext-proxy.js b/toolkit/components/extensions/parent/ext-proxy.js new file mode 100644 index 0000000000..86505f9423 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-proxy.js @@ -0,0 +1,335 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ProxyChannelFilter: "resource://gre/modules/ProxyChannelFilter.sys.mjs", +}); + +// Delayed wakeup is tied to ExtensionParent.browserPaintedPromise, which is +// when the first browser window has been painted. On Android, parts of the +// browser can trigger requests without browser "window" (geckoview.xhtml). +// Therefore we allow such proxy events to trigger wakeup. +// On desktop, we do not wake up early, to minimize the amount of work before +// a browser window is painted. +XPCOMUtils.defineLazyPreferenceGetter( + this, + "isEarlyWakeupOnRequestEnabled", + "extensions.webextensions.early_background_wakeup_on_request", + false +); +var { ExtensionPreferencesManager } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPreferencesManager.sys.mjs" +); + +var { ExtensionError } = ExtensionUtils; +var { getSettingsAPI } = ExtensionPreferencesManager; + +const proxySvc = Ci.nsIProtocolProxyService; + +const PROXY_TYPES_MAP = new Map([ + ["none", proxySvc.PROXYCONFIG_DIRECT], + ["autoDetect", proxySvc.PROXYCONFIG_WPAD], + ["system", proxySvc.PROXYCONFIG_SYSTEM], + ["manual", proxySvc.PROXYCONFIG_MANUAL], + ["autoConfig", proxySvc.PROXYCONFIG_PAC], +]); + +const DEFAULT_PORTS = new Map([ + ["http", 80], + ["ssl", 443], + ["socks", 1080], +]); + +ExtensionPreferencesManager.addSetting("proxy.settings", { + permission: "proxy", + prefNames: [ + "network.proxy.type", + "network.proxy.http", + "network.proxy.http_port", + "network.proxy.share_proxy_settings", + "network.proxy.ssl", + "network.proxy.ssl_port", + "network.proxy.socks", + "network.proxy.socks_port", + "network.proxy.socks_version", + "network.proxy.socks_remote_dns", + "network.proxy.no_proxies_on", + "network.proxy.autoconfig_url", + "signon.autologin.proxy", + "network.http.proxy.respect-be-conservative", + ], + + setCallback(value) { + let prefs = { + "network.proxy.type": PROXY_TYPES_MAP.get(value.proxyType), + "signon.autologin.proxy": value.autoLogin, + "network.proxy.socks_remote_dns": value.proxyDNS, + "network.proxy.autoconfig_url": value.autoConfigUrl, + "network.proxy.share_proxy_settings": value.httpProxyAll, + "network.proxy.socks_version": value.socksVersion, + "network.proxy.no_proxies_on": value.passthrough, + "network.http.proxy.respect-be-conservative": value.respectBeConservative, + }; + + for (let prop of ["http", "ssl", "socks"]) { + if (value[prop]) { + let url = new URL(`http://${value[prop]}`); + prefs[`network.proxy.${prop}`] = url.hostname; + // Only fall back to defaults if no port provided. + let [, rawPort] = value[prop].split(":"); + let port = parseInt(rawPort, 10) || DEFAULT_PORTS.get(prop); + prefs[`network.proxy.${prop}_port`] = port; + } + } + + return prefs; + }, +}); + +function registerProxyFilterEvent( + context, + extension, + fire, + filterProps, + extraInfoSpec = [] +) { + let listener = data => { + if (isEarlyWakeupOnRequestEnabled && fire.wakeup) { + // Starts the background script if it has not started, no-op otherwise. + extension.emit("start-background-script"); + } + return fire.sync(data); + }; + + let filter = { ...filterProps }; + if (filter.urls) { + let perms = new MatchPatternSet([ + ...extension.allowedOrigins.patterns, + ...extension.optionalOrigins.patterns, + ]); + filter.urls = new MatchPatternSet(filter.urls); + + if (!perms.overlapsAll(filter.urls)) { + Cu.reportError( + "The proxy.onRequest filter doesn't overlap with host permissions." + ); + } + } + + let proxyFilter = new ProxyChannelFilter( + context, + extension, + listener, + filter, + extraInfoSpec + ); + return { + unregister: () => { + proxyFilter.destroy(); + }, + convert(_fire, _context) { + fire = _fire; + proxyFilter.context = _context; + }, + }; +} + +this.proxy = class extends ExtensionAPIPersistent { + PERSISTENT_EVENTS = { + onRequest({ fire, context }, params) { + return registerProxyFilterEvent(context, this.extension, fire, ...params); + }, + }; + + getAPI(context) { + let { extension } = context; + let self = this; + + return { + proxy: { + onRequest: new EventManager({ + context, + module: "proxy", + event: "onRequest", + extensionApi: self, + }).api(), + + // Leaving as non-persistent. By itself it's not useful since proxy-error + // is emitted from the proxy filter. + onError: new EventManager({ + context, + name: "proxy.onError", + register: fire => { + let listener = (name, error) => { + fire.async(error); + }; + extension.on("proxy-error", listener); + return () => { + extension.off("proxy-error", listener); + }; + }, + }).api(), + + settings: Object.assign( + getSettingsAPI({ + context, + name: "proxy.settings", + callback() { + let prefValue = Services.prefs.getIntPref("network.proxy.type"); + let proxyConfig = { + proxyType: Array.from(PROXY_TYPES_MAP.entries()).find( + entry => entry[1] === prefValue + )[0], + autoConfigUrl: Services.prefs.getCharPref( + "network.proxy.autoconfig_url" + ), + autoLogin: Services.prefs.getBoolPref("signon.autologin.proxy"), + proxyDNS: Services.prefs.getBoolPref( + "network.proxy.socks_remote_dns" + ), + httpProxyAll: Services.prefs.getBoolPref( + "network.proxy.share_proxy_settings" + ), + socksVersion: Services.prefs.getIntPref( + "network.proxy.socks_version" + ), + passthrough: Services.prefs.getCharPref( + "network.proxy.no_proxies_on" + ), + }; + + if (extension.isPrivileged) { + proxyConfig.respectBeConservative = Services.prefs.getBoolPref( + "network.http.proxy.respect-be-conservative" + ); + } + + for (let prop of ["http", "ssl", "socks"]) { + let host = Services.prefs.getCharPref(`network.proxy.${prop}`); + let port = Services.prefs.getIntPref( + `network.proxy.${prop}_port` + ); + proxyConfig[prop] = port ? `${host}:${port}` : host; + } + + return proxyConfig; + }, + // proxy.settings is unsupported on android. + validate() { + if (AppConstants.platform == "android") { + throw new ExtensionError( + `proxy.settings is not supported on android.` + ); + } + }, + }), + { + set: details => { + if (AppConstants.platform === "android") { + throw new ExtensionError( + "proxy.settings is not supported on android." + ); + } + + if (!extension.privateBrowsingAllowed) { + throw new ExtensionError( + "proxy.settings requires private browsing permission." + ); + } + + if (!Services.policies.isAllowed("changeProxySettings")) { + throw new ExtensionError( + "Proxy settings are being managed by the Policies manager." + ); + } + + let value = details.value; + + // proxyType is optional and it should default to "system" when missing. + if (value.proxyType == null) { + value.proxyType = "system"; + } + + if (!PROXY_TYPES_MAP.has(value.proxyType)) { + throw new ExtensionError( + `${value.proxyType} is not a valid value for proxyType.` + ); + } + + if (value.httpProxyAll) { + // Match what about:preferences does with proxy settings + // since the proxy service does not check the value + // of share_proxy_settings. + value.ssl = value.http; + } + + for (let prop of ["http", "ssl", "socks"]) { + let host = value[prop]; + if (host) { + try { + // Fixup in case a full url is passed. + if (host.includes("://")) { + value[prop] = new URL(host).host; + } else { + // Validate the host value. + new URL(`http://${host}`); + } + } catch (e) { + throw new ExtensionError( + `${value[prop]} is not a valid value for ${prop}.` + ); + } + } + } + + if (value.proxyType === "autoConfig" || value.autoConfigUrl) { + try { + new URL(value.autoConfigUrl); + } catch (e) { + throw new ExtensionError( + `${value.autoConfigUrl} is not a valid value for autoConfigUrl.` + ); + } + } + + if (value.socksVersion !== undefined) { + if ( + !Number.isInteger(value.socksVersion) || + value.socksVersion < 4 || + value.socksVersion > 5 + ) { + throw new ExtensionError( + `${value.socksVersion} is not a valid value for socksVersion.` + ); + } + } + + if ( + value.respectBeConservative !== undefined && + !extension.isPrivileged && + Services.prefs.getBoolPref( + "network.http.proxy.respect-be-conservative" + ) != value.respectBeConservative + ) { + throw new ExtensionError( + `respectBeConservative can be set by privileged extensions only.` + ); + } + + return ExtensionPreferencesManager.setSetting( + extension.id, + "proxy.settings", + value + ); + }, + } + ), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-runtime.js b/toolkit/components/extensions/parent/ext-runtime.js new file mode 100644 index 0000000000..f4f9ea6616 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-runtime.js @@ -0,0 +1,310 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// This file expects tabTracker to be defined in the global scope (e.g. +// by ext-browser.js or ext-android.js). +/* global tabTracker */ + +var { ExtensionParent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs", + DevToolsShim: "chrome://devtools-startup/content/DevToolsShim.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "gRuntimeTimeout", + "extensions.webextensions.runtime.timeout", + 5000 +); + +this.runtime = class extends ExtensionAPIPersistent { + PERSISTENT_EVENTS = { + // Despite not being part of PERSISTENT_EVENTS, the following events are + // still triggered (after waking up the background context if needed): + // - runtime.onConnect + // - runtime.onConnectExternal + // - runtime.onMessage + // - runtime.onMessageExternal + // For details, see bug 1852317 and test_ext_eventpage_messaging_wakeup.js. + + onInstalled({ fire }) { + let { extension } = this; + let temporary = !!extension.addonData.temporarilyInstalled; + + let listener = () => { + switch (extension.startupReason) { + case "APP_STARTUP": + if (AddonManagerPrivate.browserUpdated) { + fire.sync({ reason: "browser_update", temporary }); + } + break; + case "ADDON_INSTALL": + fire.sync({ reason: "install", temporary }); + break; + case "ADDON_UPGRADE": + fire.sync({ + reason: "update", + previousVersion: extension.addonData.oldVersion, + temporary, + }); + break; + } + }; + extension.on("background-first-run", listener); + return { + unregister() { + extension.off("background-first-run", listener); + }, + convert(_fire) { + fire = _fire; + }, + }; + }, + onUpdateAvailable({ fire }) { + let { extension } = this; + let instanceID = extension.addonData.instanceID; + AddonManager.addUpgradeListener(instanceID, upgrade => { + extension.upgrade = upgrade; + let details = { + version: upgrade.version, + }; + fire.sync(details); + }); + return { + unregister() { + AddonManager.removeUpgradeListener(instanceID); + }, + convert(_fire) { + fire = _fire; + }, + }; + }, + onPerformanceWarning({ fire }) { + let { extension } = this; + + let observer = (subject, topic) => { + let report = subject.QueryInterface(Ci.nsIHangReport); + + if (report?.addonId !== extension.id) { + return; + } + + const performanceWarningEventDetails = { + category: "content_script", + severity: "high", + description: + "Slow extension content script caused a page hang, user was warned.", + }; + + let scriptBrowser = report.scriptBrowser; + let nativeTab = + scriptBrowser?.ownerGlobal.gBrowser?.getTabForBrowser(scriptBrowser); + if (nativeTab) { + performanceWarningEventDetails.tabId = tabTracker.getId(nativeTab); + } + + fire.async(performanceWarningEventDetails); + }; + + Services.obs.addObserver(observer, "process-hang-report"); + return { + unregister: () => { + Services.obs.removeObserver(observer, "process-hang-report"); + }, + convert(_fire, context) { + fire = _fire; + }, + }; + }, + }; + + getAPI(context) { + let { extension } = context; + return { + runtime: { + // onStartup is special-cased in ext-backgroundPages to cause + // an immediate startup. We do not prime onStartup. + onStartup: new EventManager({ + context, + module: "runtime", + event: "onStartup", + register: fire => { + if (context.incognito || extension.startupReason != "APP_STARTUP") { + // This event should not fire if we are operating in a private profile. + return () => {}; + } + let listener = () => { + return fire.sync(); + }; + + extension.on("background-first-run", listener); + + return () => { + extension.off("background-first-run", listener); + }; + }, + }).api(), + + onInstalled: new EventManager({ + context, + module: "runtime", + event: "onInstalled", + extensionApi: this, + }).api(), + + onUpdateAvailable: new EventManager({ + context, + module: "runtime", + event: "onUpdateAvailable", + extensionApi: this, + }).api(), + + onSuspend: new EventManager({ + context, + name: "runtime.onSuspend", + resetIdleOnEvent: false, + register: fire => { + let listener = async () => { + let timedOut = false; + async function promiseFire() { + try { + await fire.async(); + } catch (e) {} + } + await Promise.race([ + promiseFire(), + ExtensionUtils.promiseTimeout(gRuntimeTimeout).then(() => { + timedOut = true; + }), + ]); + if (timedOut) { + Cu.reportError( + `runtime.onSuspend in ${extension.id} took too long` + ); + } + }; + extension.on("background-script-suspend", listener); + return () => { + extension.off("background-script-suspend", listener); + }; + }, + }).api(), + + onSuspendCanceled: new EventManager({ + context, + name: "runtime.onSuspendCanceled", + register: fire => { + let listener = () => { + fire.async(); + }; + extension.on("background-script-suspend-canceled", listener); + return () => { + extension.off("background-script-suspend-canceled", listener); + }; + }, + }).api(), + + onPerformanceWarning: new EventManager({ + context, + module: "runtime", + event: "onPerformanceWarning", + extensionApi: this, + }).api(), + + reload: async () => { + if (extension.upgrade) { + // If there is a pending update, install it now. + extension.upgrade.install(); + } else { + // Otherwise, reload the current extension. + let addon = await AddonManager.getAddonByID(extension.id); + addon.reload(); + } + }, + + get lastError() { + // TODO(robwu): Figure out how to make sure that errors in the parent + // process are propagated to the child process. + // lastError should not be accessed from the parent. + return context.lastError; + }, + + getBrowserInfo: function () { + const { name, vendor, version, appBuildID } = Services.appinfo; + const info = { name, vendor, version, buildID: appBuildID }; + return Promise.resolve(info); + }, + + getPlatformInfo: function () { + return Promise.resolve(ExtensionParent.PlatformInfo); + }, + + openOptionsPage: function () { + if (!extension.manifest.options_ui) { + return Promise.reject({ message: "No `options_ui` declared" }); + } + + // This expects openOptionsPage to be defined in the file using this, + // e.g. the browser/ version of ext-runtime.js + /* global openOptionsPage:false */ + return openOptionsPage(extension).then(() => {}); + }, + + setUninstallURL: function (url) { + if (url === null || url.length === 0) { + extension.uninstallURL = null; + return Promise.resolve(); + } + + let uri; + try { + uri = new URL(url); + } catch (e) { + return Promise.reject({ + message: `Invalid URL: ${JSON.stringify(url)}`, + }); + } + + if (uri.protocol != "http:" && uri.protocol != "https:") { + return Promise.reject({ + message: "url must have the scheme http or https", + }); + } + + extension.uninstallURL = url; + return Promise.resolve(); + }, + + // This function is not exposed to the extension js code and it is only + // used by the alert function redefined into the background pages to be + // able to open the BrowserConsole from the main process. + openBrowserConsole() { + if (AppConstants.platform !== "android") { + DevToolsShim.openBrowserConsole(); + } + }, + + async internalWakeupBackground() { + const { background } = extension.manifest; + if ( + background && + (background.page || background.scripts) && + // Note: if background.service_worker is specified, it takes + // precedence over page/scripts, and persistentBackground is false. + !extension.persistentBackground + ) { + await extension.wakeupBackground(); + } + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-scripting.js b/toolkit/components/extensions/parent/ext-scripting.js new file mode 100644 index 0000000000..baa05f3aad --- /dev/null +++ b/toolkit/components/extensions/parent/ext-scripting.js @@ -0,0 +1,365 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +"use strict"; + +const { + ExtensionScriptingStore, + makeInternalContentScript, + makePublicContentScript, +} = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionScriptingStore.sys.mjs" +); + +var { ExtensionError, parseMatchPatterns } = ExtensionUtils; + +// Map<Extension, Map<string, number>> - For each extension, we keep a map +// where the key is a user-provided script ID, the value is an internal +// generated integer. +const gScriptIdsMap = new Map(); + +/** + * Inserts a script or style in the given tab, and returns a promise which + * resolves when the operation has completed. + * + * @param {BaseContext} context + * The extension context for which to perform the injection. + * @param {object} details + * The details object, specifying what to inject, where, and when. + * Derived from the ScriptInjection or CSSInjection types. + * @param {string} kind + * The kind of data being injected. Possible choices: "js" or "css". + * @param {string} method + * The name of the method which was called to trigger the injection. + * Used to generate appropriate error messages on failure. + * + * @returns {Promise} + * Resolves to the result of the execution, once it has completed. + */ +const execute = (context, details, kind, method) => { + const { tabManager } = context.extension; + + let options = { + jsPaths: [], + cssPaths: [], + removeCSS: method == "removeCSS", + extensionId: context.extension.id, + }; + + const { tabId, frameIds, allFrames } = details.target; + const tab = tabManager.get(tabId); + + options.hasActiveTabPermission = tab.hasActiveTabPermission; + options.matches = tab.extension.allowedOrigins.patterns.map( + host => host.pattern + ); + + const codeKey = kind === "js" ? "func" : "css"; + if ((details.files === null) == (details[codeKey] === null)) { + throw new ExtensionError( + `Exactly one of files and ${codeKey} must be specified.` + ); + } + + if (details[codeKey]) { + options[`${kind}Code`] = details[codeKey]; + } + + if (details.files) { + for (const file of details.files) { + let url = context.uri.resolve(file); + if (!tab.extension.isExtensionURL(url)) { + throw new ExtensionError( + "Files to be injected must be within the extension" + ); + } + options[`${kind}Paths`].push(url); + } + } + + if (allFrames && frameIds) { + throw new ExtensionError("Cannot specify both 'allFrames' and 'frameIds'."); + } + + if (allFrames) { + options.allFrames = allFrames; + } else if (frameIds) { + options.frameIds = frameIds; + } else { + options.frameIds = [0]; + } + + options.runAt = details.injectImmediately + ? "document_start" + : "document_idle"; + options.matchAboutBlank = true; + options.wantReturnValue = true; + // With this option set to `true`, we'll receive executeScript() results with + // `frameId/result` properties and an `error` property will also be returned + // in case of an error. + options.returnResultsWithFrameIds = kind === "js"; + + if (details.origin) { + options.cssOrigin = details.origin.toLowerCase(); + } else { + options.cssOrigin = "author"; + } + + // There is no need to execute anything when we have an empty list of frame + // IDs because (1) it isn't invalid and (2) nothing will get executed. + if (options.frameIds && options.frameIds.length === 0) { + return []; + } + + // This function is derived from `_execute()` in `parent/ext-tabs-base.js`, + // make sure to keep both in sync when relevant. + return tab.queryContent("Execute", options); +}; + +const ensureValidScriptId = id => { + if (!id.length || id.startsWith("_")) { + throw new ExtensionError("Invalid content script id."); + } +}; + +const ensureValidScriptParams = (extension, script) => { + if (!script.js?.length && !script.css?.length) { + throw new ExtensionError("At least one js or css must be specified."); + } + + if (!script.matches?.length) { + throw new ExtensionError("matches must be specified."); + } + + // This will throw if a match pattern is invalid. + parseMatchPatterns(script.matches, { + // This only works with MV2, not MV3. See Bug 1780507 for more information. + restrictSchemes: extension.restrictSchemes, + }); + + if (script.excludeMatches) { + // This will throw if a match pattern is invalid. + parseMatchPatterns(script.excludeMatches, { + // This only works with MV2, not MV3. See Bug 1780507 for more information. + restrictSchemes: extension.restrictSchemes, + }); + } +}; + +this.scripting = class extends ExtensionAPI { + constructor(extension) { + super(extension); + + // We initialize the scriptIdsMap for the extension with the scriptIds of + // the store because this store initializes the extension before we + // construct the scripting API here (and we need those IDs for some of the + // API methods below). + gScriptIdsMap.set( + extension, + ExtensionScriptingStore.getInitialScriptIdsMap(extension) + ); + } + + onShutdown() { + // When the extension is unloaded, the following happens: + // + // 1. The shared memory is cleared in the parent, see [1] + // 2. The policy is marked as invalid, see [2] + // + // The following are not explicitly cleaned up: + // + // - `extension.registeredContentScripts + // - `ExtensionProcessScript.registeredContentScripts` + + // `policy.contentScripts` (via `policy.unregisterContentScripts`) + // + // This means the script won't run again, but there is still potential for + // memory leaks if there is a reference to `extension` or `policy` + // somewhere. + // + // [1]: https://searchfox.org/mozilla-central/rev/211649f071259c4c733b4cafa94c44481c5caacc/toolkit/components/extensions/Extension.jsm#2974-2976 + // [2]: https://searchfox.org/mozilla-central/rev/211649f071259c4c733b4cafa94c44481c5caacc/toolkit/components/extensions/ExtensionProcessScript.jsm#239 + + gScriptIdsMap.delete(this.extension); + } + + getAPI(context) { + const { extension } = context; + + return { + scripting: { + executeScriptInternal: async details => { + return execute(context, details, "js", "executeScript"); + }, + + insertCSS: async details => { + return execute(context, details, "css", "insertCSS").then(() => {}); + }, + + removeCSS: async details => { + return execute(context, details, "css", "removeCSS").then(() => {}); + }, + + registerContentScripts: async scripts => { + // Map<string, number> + const scriptIdsMap = gScriptIdsMap.get(extension); + // Map<string, { scriptId: number, options: Object }> + const scriptsToRegister = new Map(); + + for (const script of scripts) { + ensureValidScriptId(script.id); + + if (scriptIdsMap.has(script.id)) { + throw new ExtensionError( + `Content script with id "${script.id}" is already registered.` + ); + } + + if (scriptsToRegister.has(script.id)) { + throw new ExtensionError( + `Script ID "${script.id}" found more than once in 'scripts' array.` + ); + } + + ensureValidScriptParams(extension, script); + + scriptsToRegister.set( + script.id, + makeInternalContentScript(extension, script) + ); + } + + for (const [id, { scriptId, options }] of scriptsToRegister) { + scriptIdsMap.set(id, scriptId); + extension.registeredContentScripts.set(scriptId, options); + } + extension.updateContentScripts(); + + ExtensionScriptingStore.persistAll(extension); + + await extension.broadcast("Extension:RegisterContentScripts", { + id: extension.id, + scripts: Array.from(scriptsToRegister.values()), + }); + }, + + getRegisteredContentScripts: async details => { + // Map<string, number> + const scriptIdsMap = gScriptIdsMap.get(extension); + + return Array.from(scriptIdsMap.entries()) + .filter( + ([id, scriptId]) => !details?.ids || details.ids.includes(id) + ) + .map(([id, scriptId]) => { + const options = extension.registeredContentScripts.get(scriptId); + + return makePublicContentScript(extension, options); + }); + }, + + unregisterContentScripts: async details => { + // Map<string, number> + const scriptIdsMap = gScriptIdsMap.get(extension); + + let ids = []; + + if (details?.ids) { + for (const id of details.ids) { + ensureValidScriptId(id); + + if (!scriptIdsMap.has(id)) { + throw new ExtensionError( + `Content script with id "${id}" does not exist.` + ); + } + } + + ids = details.ids; + } else { + ids = Array.from(scriptIdsMap.keys()); + } + + if (ids.length === 0) { + return; + } + + const scriptIds = []; + for (const id of ids) { + const scriptId = scriptIdsMap.get(id); + + extension.registeredContentScripts.delete(scriptId); + scriptIdsMap.delete(id); + scriptIds.push(scriptId); + } + extension.updateContentScripts(); + + ExtensionScriptingStore.persistAll(extension); + + await extension.broadcast("Extension:UnregisterContentScripts", { + id: extension.id, + scriptIds, + }); + }, + + updateContentScripts: async scripts => { + // Map<string, number> + const scriptIdsMap = gScriptIdsMap.get(extension); + // Map<string, { scriptId: number, options: Object }> + const scriptsToUpdate = new Map(); + + for (const script of scripts) { + ensureValidScriptId(script.id); + + if (!scriptIdsMap.has(script.id)) { + throw new ExtensionError( + `Content script with id "${script.id}" does not exist.` + ); + } + + if (scriptsToUpdate.has(script.id)) { + throw new ExtensionError( + `Script ID "${script.id}" found more than once in 'scripts' array.` + ); + } + + // Retrieve the existing script options. + const scriptId = scriptIdsMap.get(script.id); + const options = extension.registeredContentScripts.get(scriptId); + + // Use existing values if not specified in the update. + script.allFrames ??= options.allFrames; + script.css ??= options.cssPaths; + script.excludeMatches ??= options.excludeMatches; + script.js ??= options.jsPaths; + script.matches ??= options.matches; + script.runAt ??= options.runAt; + script.persistAcrossSessions ??= options.persistAcrossSessions; + + ensureValidScriptParams(extension, script); + + scriptsToUpdate.set(script.id, { + ...makeInternalContentScript(extension, script), + // Re-use internal script ID. + scriptId, + }); + } + + for (const { scriptId, options } of scriptsToUpdate.values()) { + extension.registeredContentScripts.set(scriptId, options); + } + extension.updateContentScripts(); + + ExtensionScriptingStore.persistAll(extension); + + await extension.broadcast("Extension:UpdateContentScripts", { + id: extension.id, + scripts: Array.from(scriptsToUpdate.values()), + }); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-storage.js b/toolkit/components/extensions/parent/ext-storage.js new file mode 100644 index 0000000000..350ca0acfa --- /dev/null +++ b/toolkit/components/extensions/parent/ext-storage.js @@ -0,0 +1,366 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs", + ExtensionStorage: "resource://gre/modules/ExtensionStorage.sys.mjs", + ExtensionStorageIDB: "resource://gre/modules/ExtensionStorageIDB.sys.mjs", + NativeManifests: "resource://gre/modules/NativeManifests.sys.mjs", + extensionStorageSession: "resource://gre/modules/ExtensionStorage.sys.mjs", +}); + +var { ExtensionError } = ExtensionUtils; +var { ignoreEvent } = ExtensionCommon; + +ChromeUtils.defineLazyGetter(this, "extensionStorageSync", () => { + // TODO bug 1637465: Remove Kinto-based implementation. + if (Services.prefs.getBoolPref("webextensions.storage.sync.kinto")) { + const { extensionStorageSyncKinto } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionStorageSyncKinto.sys.mjs" + ); + return extensionStorageSyncKinto; + } + + const { extensionStorageSync } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionStorageSync.sys.mjs" + ); + return extensionStorageSync; +}); + +const enforceNoTemporaryAddon = extensionId => { + const EXCEPTION_MESSAGE = + "The storage API will not work with a temporary addon ID. " + + "Please add an explicit addon ID to your manifest. " + + "For more information see https://mzl.la/3lPk1aE."; + if (AddonManagerPrivate.isTemporaryInstallID(extensionId)) { + throw new ExtensionError(EXCEPTION_MESSAGE); + } +}; + +// WeakMap[extension -> Promise<SerializableMap?>] +const managedStorage = new WeakMap(); + +const lookupManagedStorage = async (extensionId, context) => { + if (Services.policies) { + let extensionPolicy = Services.policies.getExtensionPolicy(extensionId); + if (extensionPolicy) { + return ExtensionStorage._serializableMap(extensionPolicy); + } + } + let info = await NativeManifests.lookupManifest( + "storage", + extensionId, + context + ); + if (info) { + return ExtensionStorage._serializableMap(info.manifest.data); + } + return null; +}; + +this.storage = class extends ExtensionAPIPersistent { + constructor(extension) { + super(extension); + + const messageName = `Extension:StorageLocalOnChanged:${extension.uuid}`; + Services.ppmm.addMessageListener(messageName, this); + this.clearStorageChangedListener = () => { + Services.ppmm.removeMessageListener(messageName, this); + }; + } + + PERSISTENT_EVENTS = { + onChanged({ context, fire }) { + let unregisterLocal = this.registerLocalChangedListener(changes => { + // |changes| is already serialized. Send the raw value, so that it can + // be deserialized by the onChanged handler in child/ext-storage.js. + fire.raw(changes, "local"); + }); + + // Session storage is not exposed to content scripts, and `context` does + // not exist while setting up persistent listeners for an event page. + let unregisterSession; + if ( + !context || + context.envType === "addon_parent" || + context.envType === "devtools_parent" + ) { + unregisterSession = extensionStorageSession.registerListener( + this.extension, + changes => fire.async(changes, "session") + ); + } + + let unregisterSync = this.registerSyncChangedListener(changes => { + fire.async(changes, "sync"); + }); + + return { + unregister() { + unregisterLocal(); + unregisterSession?.(); + unregisterSync(); + }, + convert(_fire) { + fire = _fire; + }, + }; + }, + "local.onChanged"({ fire }) { + let unregister = this.registerLocalChangedListener(changes => { + // |changes| is already serialized. Send the raw value, so that it can + // be deserialized by the onChanged handler in child/ext-storage.js. + fire.raw(changes); + }); + return { + unregister, + convert(_fire) { + fire = _fire; + }, + }; + }, + "session.onChanged"({ fire }) { + let unregister = extensionStorageSession.registerListener( + this.extension, + changes => fire.async(changes) + ); + + return { + unregister, + convert(_fire) { + fire = _fire; + }, + }; + }, + "sync.onChanged"({ fire }) { + let unregister = this.registerSyncChangedListener(changes => { + fire.async(changes); + }); + return { + unregister, + convert(_fire) { + fire = _fire; + }, + }; + }, + }; + + registerLocalChangedListener(onStorageLocalChanged) { + const extensionId = this.extension.id; + ExtensionStorage.addOnChangedListener(extensionId, onStorageLocalChanged); + ExtensionStorageIDB.addOnChangedListener( + extensionId, + onStorageLocalChanged + ); + return () => { + ExtensionStorage.removeOnChangedListener( + extensionId, + onStorageLocalChanged + ); + ExtensionStorageIDB.removeOnChangedListener( + extensionId, + onStorageLocalChanged + ); + }; + } + + registerSyncChangedListener(onStorageSyncChanged) { + const { extension } = this; + let closeCallback; + // The ExtensionStorageSyncKinto implementation of addOnChangedListener + // relies on context.callOnClose (via ExtensionStorageSync.registerInUse) + // to keep track of active users of the storage. We don't need to pass a + // real BaseContext instance, a dummy object with the callOnClose method + // works too. This enables us to register a primed listener before any + // context is available. + // TODO bug 1637465: Remove this when the Kinto backend is dropped. + let dummyContextForKinto = { + callOnClose({ close }) { + closeCallback = close; + }, + }; + extensionStorageSync.addOnChangedListener( + extension, + onStorageSyncChanged, + dummyContextForKinto + ); + return () => { + extensionStorageSync.removeOnChangedListener( + extension, + onStorageSyncChanged + ); + // May be void if ExtensionStorageSyncKinto.jsm was not used. + // ExtensionStorageSync.jsm does not use the context. + closeCallback?.(); + }; + } + + onShutdown() { + const { clearStorageChangedListener } = this; + this.clearStorageChangedListener = null; + + if (clearStorageChangedListener) { + clearStorageChangedListener(); + } + } + + receiveMessage({ name, data }) { + if (name !== `Extension:StorageLocalOnChanged:${this.extension.uuid}`) { + return; + } + + ExtensionStorageIDB.notifyListeners(this.extension.id, data); + } + + getAPI(context) { + let { extension } = context; + + return { + storage: { + local: { + async callMethodInParentProcess(method, args) { + const res = await ExtensionStorageIDB.selectBackend({ extension }); + if (!res.backendEnabled) { + return ExtensionStorage[method](extension.id, ...args); + } + + const persisted = extension.hasPermission("unlimitedStorage"); + const db = await ExtensionStorageIDB.open( + res.storagePrincipal.deserialize(this, true), + persisted + ); + try { + const changes = await db[method](...args); + if (changes) { + ExtensionStorageIDB.notifyListeners(extension.id, changes); + } + return changes; + } catch (err) { + const normalizedError = ExtensionStorageIDB.normalizeStorageError( + { + error: err, + extensionId: extension.id, + storageMethod: method, + } + ).message; + return Promise.reject({ + message: String(normalizedError), + }); + } + }, + // Private storage.local JSONFile backend methods (used internally by the child + // ext-storage.js module). + JSONFileBackend: { + get(spec) { + return ExtensionStorage.get(extension.id, spec); + }, + set(items) { + return ExtensionStorage.set(extension.id, items); + }, + remove(keys) { + return ExtensionStorage.remove(extension.id, keys); + }, + clear() { + return ExtensionStorage.clear(extension.id); + }, + }, + // Private storage.local IDB backend methods (used internally by the child ext-storage.js + // module). + IDBBackend: { + selectBackend() { + return ExtensionStorageIDB.selectBackend(context); + }, + }, + onChanged: new EventManager({ + context, + module: "storage", + event: "local.onChanged", + extensionApi: this, + }).api(), + }, + + session: { + get(items) { + return extensionStorageSession.get(extension, items); + }, + set(items) { + extensionStorageSession.set(extension, items); + }, + remove(keys) { + extensionStorageSession.remove(extension, keys); + }, + clear() { + extensionStorageSession.clear(extension); + }, + onChanged: new EventManager({ + context, + module: "storage", + event: "session.onChanged", + extensionApi: this, + }).api(), + }, + + sync: { + get(spec) { + enforceNoTemporaryAddon(extension.id); + return extensionStorageSync.get(extension, spec, context); + }, + set(items) { + enforceNoTemporaryAddon(extension.id); + return extensionStorageSync.set(extension, items, context); + }, + remove(keys) { + enforceNoTemporaryAddon(extension.id); + return extensionStorageSync.remove(extension, keys, context); + }, + clear() { + enforceNoTemporaryAddon(extension.id); + return extensionStorageSync.clear(extension, context); + }, + getBytesInUse(keys) { + enforceNoTemporaryAddon(extension.id); + return extensionStorageSync.getBytesInUse(extension, keys, context); + }, + onChanged: new EventManager({ + context, + module: "storage", + event: "sync.onChanged", + extensionApi: this, + }).api(), + }, + + managed: { + async get(keys) { + enforceNoTemporaryAddon(extension.id); + let lookup = managedStorage.get(extension); + + if (!lookup) { + lookup = lookupManagedStorage(extension.id, context); + managedStorage.set(extension, lookup); + } + + let data = await lookup; + if (!data) { + return Promise.reject({ + message: "Managed storage manifest not found", + }); + } + return ExtensionStorage._filterProperties(extension.id, data, keys); + }, + // managed storage is currently initialized once. + onChanged: ignoreEvent(context, "storage.managed.onChanged"), + }, + + onChanged: new EventManager({ + context, + module: "storage", + event: "onChanged", + extensionApi: this, + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-tabs-base.js b/toolkit/components/extensions/parent/ext-tabs-base.js new file mode 100644 index 0000000000..64ca9c0627 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-tabs-base.js @@ -0,0 +1,2377 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ +"use strict"; + +/* globals EventEmitter */ + +ChromeUtils.defineESModuleGetters(this, { + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "containersEnabled", + "privacy.userContext.enabled" +); + +var { DefaultMap, DefaultWeakMap, ExtensionError, parseMatchPatterns } = + ExtensionUtils; + +var { defineLazyGetter } = ExtensionCommon; + +/** + * The platform-specific type of native tab objects, which are wrapped by + * TabBase instances. + * + * @typedef {object | XULElement} NativeTab + */ + +/** + * @typedef {object} MutedInfo + * @property {boolean} muted + * True if the tab is currently muted, false otherwise. + * @property {string} [reason] + * The reason the tab is muted. Either "user", if the tab was muted by a + * user, or "extension", if it was muted by an extension. + * @property {string} [extensionId] + * If the tab was muted by an extension, contains the internal ID of that + * extension. + */ + +/** + * A platform-independent base class for extension-specific wrappers around + * native tab objects. + * + * @param {Extension} extension + * The extension object for which this wrapper is being created. Used to + * determine permissions for access to certain properties and + * functionality. + * @param {NativeTab} nativeTab + * The native tab object which is being wrapped. The type of this object + * varies by platform. + * @param {integer} id + * The numeric ID of this tab object. This ID should be the same for + * every extension, and for the lifetime of the tab. + */ +class TabBase { + constructor(extension, nativeTab, id) { + this.extension = extension; + this.tabManager = extension.tabManager; + this.id = id; + this.nativeTab = nativeTab; + this.activeTabWindowID = null; + + if (!extension.privateBrowsingAllowed && this._incognito) { + throw new ExtensionError(`Invalid tab ID: ${id}`); + } + } + + /** + * Capture the visible area of this tab, and return the result as a data: URI. + * + * @param {BaseContext} context + * The extension context for which to perform the capture. + * @param {number} zoom + * The current zoom for the page. + * @param {object} [options] + * The options with which to perform the capture. + * @param {string} [options.format = "png"] + * The image format in which to encode the captured data. May be one of + * "png" or "jpeg". + * @param {integer} [options.quality = 92] + * The quality at which to encode the captured image data, ranging from + * 0 to 100. Has no effect for the "png" format. + * @param {DOMRectInit} [options.rect] + * Area of the document to render, in CSS pixels, relative to the page. + * If null, the currently visible viewport is rendered. + * @param {number} [options.scale] + * The scale to render at, defaults to devicePixelRatio. + * @returns {Promise<string>} + */ + async capture(context, zoom, options) { + let win = this.browser.ownerGlobal; + let scale = options?.scale || win.devicePixelRatio; + let rect = options?.rect && win.DOMRect.fromRect(options.rect); + + // We only allow mozilla addons to use the resetScrollPosition option, + // since it's not standardized. + let resetScrollPosition = false; + if (!context.extension.restrictSchemes) { + resetScrollPosition = !!options?.resetScrollPosition; + } + + let wgp = this.browsingContext.currentWindowGlobal; + let image = await wgp.drawSnapshot( + rect, + scale * zoom, + "white", + resetScrollPosition + ); + + let doc = Services.appShell.hiddenDOMWindow.document; + let canvas = doc.createElement("canvas"); + canvas.width = image.width; + canvas.height = image.height; + + let ctx = canvas.getContext("2d", { alpha: false }); + ctx.drawImage(image, 0, 0); + image.close(); + + return canvas.toDataURL(`image/${options?.format}`, options?.quality / 100); + } + + /** + * @property {integer | null} innerWindowID + * The last known innerWindowID loaded into this tab's docShell. This + * property must remain in sync with the last known values of + * properties such as `url` and `title`. Any operations on the content + * of an out-of-process tab will automatically fail if the + * innerWindowID of the tab when the message is received does not match + * the value of this property when the message was sent. + * @readonly + */ + get innerWindowID() { + return this.browser.innerWindowID; + } + + /** + * @property {boolean} hasTabPermission + * Returns true if the extension has permission to access restricted + * properties of this tab, such as `url`, `title`, and `favIconUrl`. + * @readonly + */ + get hasTabPermission() { + return ( + this.extension.hasPermission("tabs") || + this.hasActiveTabPermission || + this.matchesHostPermission + ); + } + + /** + * @property {boolean} hasActiveTabPermission + * Returns true if the extension has the "activeTab" permission, and + * has been granted access to this tab due to a user executing an + * extension action. + * + * If true, the extension may load scripts and CSS into this tab, and + * access restricted properties, such as its `url`. + * @readonly + */ + get hasActiveTabPermission() { + return ( + (this.extension.originControls || + this.extension.hasPermission("activeTab")) && + this.activeTabWindowID != null && + this.activeTabWindowID === this.innerWindowID + ); + } + + /** + * @property {boolean} matchesHostPermission + * Returns true if the extensions host permissions match the current tab url. + * @readonly + */ + get matchesHostPermission() { + return this.extension.allowedOrigins.matches(this._uri); + } + + /** + * @property {boolean} incognito + * Returns true if this is a private browsing tab, false otherwise. + * @readonly + */ + get _incognito() { + return PrivateBrowsingUtils.isBrowserPrivate(this.browser); + } + + /** + * @property {string} _url + * Returns the current URL of this tab. Does not do any permission + * checks. + * @readonly + */ + get _url() { + return this.browser.currentURI.spec; + } + + /** + * @property {string | null} url + * Returns the current URL of this tab if the extension has permission + * to read it, or null otherwise. + * @readonly + */ + get url() { + if (this.hasTabPermission) { + return this._url; + } + } + + /** + * @property {nsIURI} _uri + * Returns the current URI of this tab. + * @readonly + */ + get _uri() { + return this.browser.currentURI; + } + + /** + * @property {string} _title + * Returns the current title of this tab. Does not do any permission + * checks. + * @readonly + */ + get _title() { + return this.browser.contentTitle || this.nativeTab.label; + } + + /** + * @property {nsIURI | null} title + * Returns the current title of this tab if the extension has permission + * to read it, or null otherwise. + * @readonly + */ + get title() { + if (this.hasTabPermission) { + return this._title; + } + } + + /** + * @property {string} _favIconUrl + * Returns the current favicon URL of this tab. Does not do any permission + * checks. + * @readonly + * @abstract + */ + get _favIconUrl() { + throw new Error("Not implemented"); + } + + /** + * @property {nsIURI | null} faviconUrl + * Returns the current faviron URL of this tab if the extension has permission + * to read it, or null otherwise. + * @readonly + */ + get favIconUrl() { + if (this.hasTabPermission) { + return this._favIconUrl; + } + } + + /** + * @property {integer} lastAccessed + * Returns the last time the tab was accessed as the number of + * milliseconds since epoch. + * @readonly + * @abstract + */ + get lastAccessed() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} audible + * Returns true if the tab is currently playing audio, false otherwise. + * @readonly + * @abstract + */ + get audible() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} autoDiscardable + * Returns true if the tab can be discarded on memory pressure, false otherwise. + * @readonly + * @abstract + */ + get autoDiscardable() { + throw new Error("Not implemented"); + } + + /** + * @property {XULElement} browser + * Returns the XUL browser for the given tab. + * @readonly + * @abstract + */ + get browser() { + throw new Error("Not implemented"); + } + + /** + * @property {BrowsingContext} browsingContext + * Returns the BrowsingContext for the given tab. + * @readonly + */ + get browsingContext() { + return this.browser?.browsingContext; + } + + /** + * @property {FrameLoader} frameLoader + * Returns the frameloader for the given tab. + * @readonly + */ + get frameLoader() { + return this.browser && this.browser.frameLoader; + } + + /** + * @property {string} cookieStoreId + * Returns the cookie store identifier for the given tab. + * @readonly + * @abstract + */ + get cookieStoreId() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} openerTabId + * Returns the ID of the tab which opened this one. + * @readonly + */ + get openerTabId() { + return null; + } + + /** + * @property {integer} discarded + * Returns true if the tab is discarded. + * @readonly + * @abstract + */ + get discarded() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} height + * Returns the pixel height of the visible area of the tab. + * @readonly + * @abstract + */ + get height() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} hidden + * Returns true if the tab is hidden. + * @readonly + * @abstract + */ + get hidden() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} index + * Returns the index of the tab in its window's tab list. + * @readonly + * @abstract + */ + get index() { + throw new Error("Not implemented"); + } + + /** + * @property {MutedInfo} mutedInfo + * Returns information about the tab's current audio muting status. + * @readonly + * @abstract + */ + get mutedInfo() { + throw new Error("Not implemented"); + } + + /** + * @property {SharingState} sharingState + * Returns object with tab sharingState. + * @readonly + * @abstract + */ + get sharingState() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} pinned + * Returns true if the tab is pinned, false otherwise. + * @readonly + * @abstract + */ + get pinned() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} active + * Returns true if the tab is the currently-selected tab, false + * otherwise. + * @readonly + * @abstract + */ + get active() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} highlighted + * Returns true if the tab is highlighted. + * @readonly + * @abstract + */ + get highlighted() { + throw new Error("Not implemented"); + } + + /** + * @property {string} status + * Returns the current loading status of the tab. May be either + * "loading" or "complete". + * @readonly + * @abstract + */ + get status() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} height + * Returns the pixel height of the visible area of the tab. + * @readonly + * @abstract + */ + get width() { + throw new Error("Not implemented"); + } + + /** + * @property {DOMWindow} window + * Returns the browser window to which the tab belongs. + * @readonly + * @abstract + */ + get window() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} window + * Returns the numeric ID of the browser window to which the tab belongs. + * @readonly + * @abstract + */ + get windowId() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} attention + * Returns true if the tab is drawing attention. + * @readonly + * @abstract + */ + get attention() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} isArticle + * Returns true if the document in the tab can be rendered in reader + * mode. + * @readonly + * @abstract + */ + get isArticle() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} isInReaderMode + * Returns true if the document in the tab is being rendered in reader + * mode. + * @readonly + * @abstract + */ + get isInReaderMode() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} successorTabId + * @readonly + * @abstract + */ + get successorTabId() { + throw new Error("Not implemented"); + } + + /** + * Returns true if this tab matches the the given query info object. Omitted + * or null have no effect on the match. + * + * @param {object} queryInfo + * The query info against which to match. + * @param {boolean} [queryInfo.active] + * Matches against the exact value of the tab's `active` attribute. + * @param {boolean} [queryInfo.audible] + * Matches against the exact value of the tab's `audible` attribute. + * @param {boolean} [queryInfo.autoDiscardable] + * Matches against the exact value of the tab's `autoDiscardable` attribute. + * @param {string} [queryInfo.cookieStoreId] + * Matches against the exact value of the tab's `cookieStoreId` attribute. + * @param {boolean} [queryInfo.discarded] + * Matches against the exact value of the tab's `discarded` attribute. + * @param {boolean} [queryInfo.hidden] + * Matches against the exact value of the tab's `hidden` attribute. + * @param {boolean} [queryInfo.highlighted] + * Matches against the exact value of the tab's `highlighted` attribute. + * @param {integer} [queryInfo.index] + * Matches against the exact value of the tab's `index` attribute. + * @param {boolean} [queryInfo.muted] + * Matches against the exact value of the tab's `mutedInfo.muted` attribute. + * @param {boolean} [queryInfo.pinned] + * Matches against the exact value of the tab's `pinned` attribute. + * @param {string} [queryInfo.status] + * Matches against the exact value of the tab's `status` attribute. + * @param {string} [queryInfo.title] + * Matches against the exact value of the tab's `title` attribute. + * @param {string|boolean } [queryInfo.screen] + * Matches against the exact value of the tab's `sharingState.screen` attribute, or use true to match any screen sharing tab. + * @param {boolean} [queryInfo.camera] + * Matches against the exact value of the tab's `sharingState.camera` attribute. + * @param {boolean} [queryInfo.microphone] + * Matches against the exact value of the tab's `sharingState.microphone` attribute. + * + * Note: Per specification, this should perform a pattern match, rather + * than an exact value match, and will do so in the future. + * @param {MatchPattern} [queryInfo.url] + * Requires the tab's URL to match the given MatchPattern object. + * + * @returns {boolean} + * True if the tab matches the query. + */ + matches(queryInfo) { + const PROPS = [ + "active", + "audible", + "autoDiscardable", + "discarded", + "hidden", + "highlighted", + "index", + "openerTabId", + "pinned", + "status", + ]; + + function checkProperty(prop, obj) { + return queryInfo[prop] != null && queryInfo[prop] !== obj[prop]; + } + + if (PROPS.some(prop => checkProperty(prop, this))) { + return false; + } + + if (checkProperty("muted", this.mutedInfo)) { + return false; + } + + let state = this.sharingState; + if (["camera", "microphone"].some(prop => checkProperty(prop, state))) { + return false; + } + // query for screen can be boolean (ie. any) or string (ie. specific). + if (queryInfo.screen !== null) { + let match = + typeof queryInfo.screen == "boolean" + ? queryInfo.screen === !!state.screen + : queryInfo.screen === state.screen; + if (!match) { + return false; + } + } + + if (queryInfo.cookieStoreId) { + if (!queryInfo.cookieStoreId.includes(this.cookieStoreId)) { + return false; + } + } + + if (queryInfo.url || queryInfo.title) { + if (!this.hasTabPermission) { + return false; + } + // Using _uri and _title instead of url/title to avoid repeated permission checks. + if (queryInfo.url && !queryInfo.url.matches(this._uri)) { + return false; + } + if (queryInfo.title && !queryInfo.title.matches(this._title)) { + return false; + } + } + + return true; + } + + /** + * Converts this tab object to a JSON-compatible object containing the values + * of its properties which the extension is permitted to access, in the format + * required to be returned by WebExtension APIs. + * + * @param {object} [fallbackTabSize] + * A geometry data if the lazy geometry data for this tab hasn't been + * initialized yet. + * @returns {object} + */ + convert(fallbackTabSize = null) { + let result = { + id: this.id, + index: this.index, + windowId: this.windowId, + highlighted: this.highlighted, + active: this.active, + attention: this.attention, + pinned: this.pinned, + status: this.status, + hidden: this.hidden, + discarded: this.discarded, + incognito: this.incognito, + width: this.width, + height: this.height, + lastAccessed: this.lastAccessed, + audible: this.audible, + autoDiscardable: this.autoDiscardable, + mutedInfo: this.mutedInfo, + isArticle: this.isArticle, + isInReaderMode: this.isInReaderMode, + sharingState: this.sharingState, + successorTabId: this.successorTabId, + cookieStoreId: this.cookieStoreId, + }; + + // If the tab has not been fully layed-out yet, fallback to the geometry + // from a different tab (usually the currently active tab). + if (fallbackTabSize && (!result.width || !result.height)) { + result.width = fallbackTabSize.width; + result.height = fallbackTabSize.height; + } + + let opener = this.openerTabId; + if (opener) { + result.openerTabId = opener; + } + + if (this.hasTabPermission) { + for (let prop of ["url", "title", "favIconUrl"]) { + // We use the underscored variants here to avoid the redundant + // permissions checks imposed on the public properties. + let val = this[`_${prop}`]; + if (val) { + result[prop] = val; + } + } + } + + return result; + } + + /** + * Query each content process hosting subframes of the tab, return results. + * + * @param {string} message + * @param {object} options + * These options are also sent to the message handler in the + * `ExtensionContentChild`. + * @param {number[]} options.frameIds + * When omitted, all frames will be queried. + * @param {boolean} options.returnResultsWithFrameIds + * @returns {Promise[]} + */ + async queryContent(message, options) { + let { frameIds } = options; + + /** @type {Map<nsIDOMProcessParent, innerWindowId[]>} */ + let byProcess = new DefaultMap(() => []); + // We use this set to know which frame IDs are potentially invalid (as in + // not found when visiting the tab's BC tree below) when frameIds is a + // non-empty list of frame IDs. + let frameIdsSet = new Set(frameIds); + + // Recursively walk the tab's BC tree, find all frames, group by process. + function visit(bc) { + let win = bc.currentWindowGlobal; + let frameId = bc.parent ? bc.id : 0; + + if (win?.domProcess && (!frameIds || frameIdsSet.has(frameId))) { + byProcess.get(win.domProcess).push(win.innerWindowId); + frameIdsSet.delete(frameId); + } + + if (!frameIds || frameIdsSet.size > 0) { + bc.children.forEach(visit); + } + } + visit(this.browsingContext); + + if (frameIdsSet.size > 0) { + throw new ExtensionError( + `Invalid frame IDs: [${Array.from(frameIdsSet).join(", ")}].` + ); + } + + let promises = Array.from(byProcess.entries(), ([proc, windows]) => + proc.getActor("ExtensionContent").sendQuery(message, { windows, options }) + ); + + let results = await Promise.all(promises).catch(err => { + if (err.name === "DataCloneError") { + let fileName = options.jsPaths.slice(-1)[0] || "<anonymous code>"; + let message = `Script '${fileName}' result is non-structured-clonable data`; + return Promise.reject({ message, fileName }); + } + throw err; + }); + results = results.flat(); + + if (!results.length) { + let errorMessage = "Missing host permission for the tab"; + if (!frameIds || frameIds.length > 1 || frameIds[0] !== 0) { + errorMessage += " or frames"; + } + + throw new ExtensionError(errorMessage); + } + + if (frameIds && frameIds.length === 1 && results.length > 1) { + throw new ExtensionError("Internal error: multiple windows matched"); + } + + return results; + } + + /** + * Inserts a script or stylesheet in the given tab, and returns a promise + * which resolves when the operation has completed. + * + * @param {BaseContext} context + * The extension context for which to perform the injection. + * @param {InjectDetails} details + * The InjectDetails object, specifying what to inject, where, and + * when. + * @param {string} kind + * The kind of data being injected. Either "script" or "css". + * @param {string} method + * The name of the method which was called to trigger the injection. + * Used to generate appropriate error messages on failure. + * + * @returns {Promise} + * Resolves to the result of the execution, once it has completed. + * @private + */ + _execute(context, details, kind, method) { + let options = { + jsPaths: [], + cssPaths: [], + removeCSS: method == "removeCSS", + extensionId: context.extension.id, + }; + + // We require a `code` or a `file` property, but we can't accept both. + if ((details.code === null) == (details.file === null)) { + return Promise.reject({ + message: `${method} requires either a 'code' or a 'file' property, but not both`, + }); + } + + if (details.frameId !== null && details.allFrames) { + return Promise.reject({ + message: `'frameId' and 'allFrames' are mutually exclusive`, + }); + } + + options.hasActiveTabPermission = this.hasActiveTabPermission; + options.matches = this.extension.allowedOrigins.patterns.map( + host => host.pattern + ); + + if (details.code !== null) { + options[`${kind}Code`] = details.code; + } + if (details.file !== null) { + let url = context.uri.resolve(details.file); + if (!this.extension.isExtensionURL(url)) { + return Promise.reject({ + message: "Files to be injected must be within the extension", + }); + } + options[`${kind}Paths`].push(url); + } + + if (details.allFrames) { + options.allFrames = true; + } else if (details.frameId !== null) { + options.frameIds = [details.frameId]; + } else if (!details.allFrames) { + options.frameIds = [0]; + } + + if (details.matchAboutBlank) { + options.matchAboutBlank = details.matchAboutBlank; + } + if (details.runAt !== null) { + options.runAt = details.runAt; + } else { + options.runAt = "document_idle"; + } + if (details.cssOrigin !== null) { + options.cssOrigin = details.cssOrigin; + } else { + options.cssOrigin = "author"; + } + + options.wantReturnValue = true; + + // The scripting API (defined in `parent/ext-scripting.js`) has its own + // `execute()` function that calls `queryContent()` as well. Make sure to + // keep both in sync when relevant. + return this.queryContent("Execute", options); + } + + /** + * Executes a script in the tab's content window, and returns a Promise which + * resolves to the result of the evaluation, or rejects to the value of any + * error the injection generates. + * + * @param {BaseContext} context + * The extension context for which to inject the script. + * @param {InjectDetails} details + * The InjectDetails object, specifying what to inject, where, and + * when. + * + * @returns {Promise} + * Resolves to the result of the evaluation of the given script, once + * it has completed, or rejects with any error the evaluation + * generates. + */ + executeScript(context, details) { + return this._execute(context, details, "js", "executeScript"); + } + + /** + * Injects CSS into the tab's content window, and returns a Promise which + * resolves when the injection is complete. + * + * @param {BaseContext} context + * The extension context for which to inject the script. + * @param {InjectDetails} details + * The InjectDetails object, specifying what to inject, and where. + * + * @returns {Promise} + * Resolves when the injection has completed. + */ + insertCSS(context, details) { + return this._execute(context, details, "css", "insertCSS").then(() => {}); + } + + /** + * Removes CSS which was previously into the tab's content window via + * `insertCSS`, and returns a Promise which resolves when the operation is + * complete. + * + * @param {BaseContext} context + * The extension context for which to remove the CSS. + * @param {InjectDetails} details + * The InjectDetails object, specifying what to remove, and from where. + * + * @returns {Promise} + * Resolves when the operation has completed. + */ + removeCSS(context, details) { + return this._execute(context, details, "css", "removeCSS").then(() => {}); + } +} + +defineLazyGetter(TabBase.prototype, "incognito", function () { + return this._incognito; +}); + +// Note: These must match the values in windows.json. +const WINDOW_ID_NONE = -1; +const WINDOW_ID_CURRENT = -2; + +/** + * A platform-independent base class for extension-specific wrappers around + * native browser windows + * + * @param {Extension} extension + * The extension object for which this wrapper is being created. + * @param {DOMWindow} window + * The browser DOM window which is being wrapped. + * @param {integer} id + * The numeric ID of this DOM window object. This ID should be the same for + * every extension, and for the lifetime of the window. + */ +class WindowBase { + constructor(extension, window, id) { + if (!extension.canAccessWindow(window)) { + throw new ExtensionError("extension cannot access window"); + } + this.extension = extension; + this.window = window; + this.id = id; + } + + /** + * @property {nsIAppWindow} appWindow + * The nsIAppWindow object for this browser window. + * @readonly + */ + get appWindow() { + return this.window.docShell.treeOwner + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow); + } + + /** + * Returns true if this window is the current window for the given extension + * context, false otherwise. + * + * @param {BaseContext} context + * The extension context for which to perform the check. + * + * @returns {boolean} + */ + isCurrentFor(context) { + if (context && context.currentWindow) { + return this.window === context.currentWindow; + } + return this.isLastFocused; + } + + /** + * @property {string} type + * The type of the window, as defined by the WebExtension API. May be + * either "normal" or "popup". + * @readonly + */ + get type() { + let { chromeFlags } = this.appWindow; + + if (chromeFlags & Ci.nsIWebBrowserChrome.CHROME_OPENAS_DIALOG) { + return "popup"; + } + + return "normal"; + } + + /** + * Converts this window object to a JSON-compatible object which may be + * returned to an extension, in the format required to be returned by + * WebExtension APIs. + * + * @param {object} [getInfo] + * An optional object, the properties of which determine what data is + * available on the result object. + * @param {boolean} [getInfo.populate] + * Of true, the result object will contain a `tabs` property, + * containing an array of converted Tab objects, one for each tab in + * the window. + * + * @returns {object} + */ + convert(getInfo) { + let result = { + id: this.id, + focused: this.focused, + top: this.top, + left: this.left, + width: this.width, + height: this.height, + incognito: this.incognito, + type: this.type, + state: this.state, + alwaysOnTop: this.alwaysOnTop, + title: this.title, + }; + + if (getInfo && getInfo.populate) { + result.tabs = Array.from(this.getTabs(), tab => tab.convert()); + } + + return result; + } + + /** + * Returns true if this window matches the the given query info object. Omitted + * or null have no effect on the match. + * + * @param {object} queryInfo + * The query info against which to match. + * @param {boolean} [queryInfo.currentWindow] + * Matches against against the return value of `isCurrentFor()` for the + * given context. + * @param {boolean} [queryInfo.lastFocusedWindow] + * Matches against the exact value of the window's `isLastFocused` attribute. + * @param {boolean} [queryInfo.windowId] + * Matches against the exact value of the window's ID, taking into + * account the special WINDOW_ID_CURRENT value. + * @param {string} [queryInfo.windowType] + * Matches against the exact value of the window's `type` attribute. + * @param {BaseContext} context + * The extension context for which the matching is being performed. + * Used to determine the current window for relevant properties. + * + * @returns {boolean} + * True if the window matches the query. + */ + matches(queryInfo, context) { + if ( + queryInfo.lastFocusedWindow !== null && + queryInfo.lastFocusedWindow !== this.isLastFocused + ) { + return false; + } + + if (queryInfo.windowType !== null && queryInfo.windowType !== this.type) { + return false; + } + + if (queryInfo.windowId !== null) { + if (queryInfo.windowId === WINDOW_ID_CURRENT) { + if (!this.isCurrentFor(context)) { + return false; + } + } else if (queryInfo.windowId !== this.id) { + return false; + } + } + + if ( + queryInfo.currentWindow !== null && + queryInfo.currentWindow !== this.isCurrentFor(context) + ) { + return false; + } + + return true; + } + + /** + * @property {boolean} focused + * Returns true if the browser window is currently focused. + * @readonly + * @abstract + */ + get focused() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} top + * Returns the pixel offset of the top of the window from the top of + * the screen. + * @readonly + * @abstract + */ + get top() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} left + * Returns the pixel offset of the left of the window from the left of + * the screen. + * @readonly + * @abstract + */ + get left() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} width + * Returns the pixel width of the window. + * @readonly + * @abstract + */ + get width() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} height + * Returns the pixel height of the window. + * @readonly + * @abstract + */ + get height() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} incognito + * Returns true if this is a private browsing window, false otherwise. + * @readonly + * @abstract + */ + get incognito() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} alwaysOnTop + * Returns true if this window is constrained to always remain above + * other windows. + * @readonly + * @abstract + */ + get alwaysOnTop() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} isLastFocused + * Returns true if this is the browser window which most recently had + * focus. + * @readonly + * @abstract + */ + get isLastFocused() { + throw new Error("Not implemented"); + } + + /** + * @property {string} state + * Returns or sets the current state of this window, as determined by + * `getState()`. + * @abstract + */ + get state() { + throw new Error("Not implemented"); + } + + set state(state) { + throw new Error("Not implemented"); + } + + /** + * @property {nsIURI | null} title + * Returns the current title of this window if the extension has permission + * to read it, or null otherwise. + * @readonly + */ + get title() { + // activeTab may be null when a new window is adopting an existing tab as its first tab + // (See Bug 1458918 for rationale). + if (this.activeTab && this.activeTab.hasTabPermission) { + return this._title; + } + } + + // The JSDoc validator does not support @returns tags in abstract functions or + // star functions without return statements. + /* eslint-disable valid-jsdoc */ + /** + * Returns the window state of the given window. + * + * @param {DOMWindow} window + * The window for which to return a state. + * + * @returns {string} + * The window's state. One of "normal", "minimized", "maximized", + * "fullscreen", or "docked". + * @static + * @abstract + */ + static getState(window) { + throw new Error("Not implemented"); + } + + /** + * Returns an iterator of TabBase objects for each tab in this window. + * + * @returns {Iterator<TabBase>} + */ + getTabs() { + throw new Error("Not implemented"); + } + + /** + * Returns an iterator of TabBase objects for each highlighted tab in this window. + * + * @returns {Iterator<TabBase>} + */ + getHighlightedTabs() { + throw new Error("Not implemented"); + } + + /** + * @property {TabBase} The window's currently active tab. + */ + get activeTab() { + throw new Error("Not implemented"); + } + + /** + * Returns the window's tab at the specified index. + * + * @param {integer} index + * The index of the desired tab. + * + * @returns {TabBase|undefined} + */ + getTabAtIndex(index) { + throw new Error("Not implemented"); + } + /* eslint-enable valid-jsdoc */ +} + +Object.assign(WindowBase, { WINDOW_ID_NONE, WINDOW_ID_CURRENT }); + +/** + * The parameter type of "tab-attached" events, which are emitted when a + * pre-existing tab is attached to a new window. + * + * @typedef {object} TabAttachedEvent + * @property {NativeTab} tab + * The native tab object in the window to which the tab is being + * attached. This may be a different object than was used to represent + * the tab in the old window. + * @property {integer} tabId + * The ID of the tab being attached. + * @property {integer} newWindowId + * The ID of the window to which the tab is being attached. + * @property {integer} newPosition + * The position of the tab in the tab list of the new window. + */ + +/** + * The parameter type of "tab-detached" events, which are emitted when a + * pre-existing tab is detached from a window, in order to be attached to a new + * window. + * + * @typedef {object} TabDetachedEvent + * @property {NativeTab} tab + * The native tab object in the window from which the tab is being + * detached. This may be a different object than will be used to + * represent the tab in the new window. + * @property {NativeTab} adoptedBy + * The native tab object in the window to which the tab will be attached, + * and is adopting the contents of this tab. This may be a different + * object than the tab in the previous window. + * @property {integer} tabId + * The ID of the tab being detached. + * @property {integer} oldWindowId + * The ID of the window from which the tab is being detached. + * @property {integer} oldPosition + * The position of the tab in the tab list of the window from which it is + * being detached. + */ + +/** + * The parameter type of "tab-created" events, which are emitted when a + * new tab is created. + * + * @typedef {object} TabCreatedEvent + * @property {NativeTab} tab + * The native tab object for the tab which is being created. + */ + +/** + * The parameter type of "tab-removed" events, which are emitted when a + * tab is removed and destroyed. + * + * @typedef {object} TabRemovedEvent + * @property {NativeTab} tab + * The native tab object for the tab which is being removed. + * @property {integer} tabId + * The ID of the tab being removed. + * @property {integer} windowId + * The ID of the window from which the tab is being removed. + * @property {boolean} isWindowClosing + * True if the tab is being removed because the window is closing. + */ + +/** + * An object containing basic, extension-independent information about the window + * and tab that a XUL <browser> belongs to. + * + * @typedef {object} BrowserData + * @property {integer} tabId + * The numeric ID of the tab that a <browser> belongs to, or -1 if it + * does not belong to a tab. + * @property {integer} windowId + * The numeric ID of the browser window that a <browser> belongs to, or -1 + * if it does not belong to a browser window. + */ + +/** + * A platform-independent base class for the platform-specific TabTracker + * classes, which track the opening and closing of tabs, and manage the mapping + * of them between numeric IDs and native tab objects. + * + * Instances of this class are EventEmitters which emit the following events, + * each with an argument of the given type: + * + * - "tab-attached" {@link TabAttacheEvent} + * - "tab-detached" {@link TabDetachedEvent} + * - "tab-created" {@link TabCreatedEvent} + * - "tab-removed" {@link TabRemovedEvent} + */ +class TabTrackerBase extends EventEmitter { + on(...args) { + if (!this.initialized) { + this.init(); + } + + return super.on(...args); // eslint-disable-line mozilla/balanced-listeners + } + + /** + * Called to initialize the tab tracking listeners the first time that an + * event listener is added. + * + * @protected + * @abstract + */ + init() { + throw new Error("Not implemented"); + } + + // The JSDoc validator does not support @returns tags in abstract functions or + // star functions without return statements. + /* eslint-disable valid-jsdoc */ + /** + * Returns the numeric ID for the given native tab. + * + * @param {NativeTab} nativeTab + * The native tab for which to return an ID. + * + * @returns {integer} + * The tab's numeric ID. + * @abstract + */ + getId(nativeTab) { + throw new Error("Not implemented"); + } + + /** + * Returns the native tab with the given numeric ID. + * + * @param {integer} tabId + * The numeric ID of the tab to return. + * @param {*} default_ + * The value to return if no tab exists with the given ID. + * + * @returns {NativeTab} + * @throws {ExtensionError} + * If no tab exists with the given ID and a default return value is not + * provided. + * @abstract + */ + getTab(tabId, default_ = undefined) { + throw new Error("Not implemented"); + } + + /** + * Returns basic information about the tab and window that the given browser + * belongs to. + * + * @param {XULElement} browser + * The XUL browser element for which to return data. + * + * @returns {BrowserData} + * @abstract + */ + /* eslint-enable valid-jsdoc */ + getBrowserData(browser) { + throw new Error("Not implemented"); + } + + /** + * @property {NativeTab} activeTab + * Returns the native tab object for the active tab in the + * most-recently focused window, or null if no live tabs currently + * exist. + * @abstract + */ + get activeTab() { + throw new Error("Not implemented"); + } +} + +/** + * A browser progress listener instance which calls a given listener function + * whenever the status of the given browser changes. + * + * @param {function(object): void} listener + * A function to be called whenever the status of a tab's top-level + * browser. It is passed an object with a `browser` property pointing to + * the XUL browser, and a `status` property with a string description of + * the browser's status. + * @private + */ +class StatusListener { + constructor(listener) { + this.listener = listener; + } + + onStateChange(browser, webProgress, request, stateFlags, statusCode) { + if (!webProgress.isTopLevel) { + return; + } + + let status; + if (stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW) { + if (stateFlags & Ci.nsIWebProgressListener.STATE_START) { + status = "loading"; + } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) { + status = "complete"; + } + } else if ( + stateFlags & Ci.nsIWebProgressListener.STATE_STOP && + statusCode == Cr.NS_BINDING_ABORTED + ) { + status = "complete"; + } + + if (status) { + this.listener({ browser, status }); + } + } + + onLocationChange(browser, webProgress, request, locationURI, flags) { + if (webProgress.isTopLevel) { + let status = webProgress.isLoadingDocument ? "loading" : "complete"; + this.listener({ browser, status, url: locationURI.spec }); + } + } +} + +/** + * A platform-independent base class for the platform-specific WindowTracker + * classes, which track the opening and closing of windows, and manage the + * mapping of them between numeric IDs and native tab objects. + */ +class WindowTrackerBase extends EventEmitter { + constructor() { + super(); + + this._handleWindowOpened = this._handleWindowOpened.bind(this); + + this._openListeners = new Set(); + this._closeListeners = new Set(); + + this._listeners = new DefaultMap(() => new Set()); + + this._statusListeners = new DefaultWeakMap(listener => { + return new StatusListener(listener); + }); + + this._windowIds = new DefaultWeakMap(window => { + return window.docShell.outerWindowID; + }); + } + + isBrowserWindow(window) { + let { documentElement } = window.document; + + return documentElement.getAttribute("windowtype") === "navigator:browser"; + } + + // The JSDoc validator does not support @returns tags in abstract functions or + // star functions without return statements. + /* eslint-disable valid-jsdoc */ + /** + * Returns an iterator for all currently active browser windows. + * + * @param {boolean} [includeInomplete = false] + * If true, include browser windows which are not yet fully loaded. + * Otherwise, only include windows which are. + * + * @returns {Iterator<DOMWindow>} + */ + /* eslint-enable valid-jsdoc */ + *browserWindows(includeIncomplete = false) { + // The window type parameter is only available once the window's document + // element has been created. This means that, when looking for incomplete + // browser windows, we need to ignore the type entirely for windows which + // haven't finished loading, since we would otherwise skip browser windows + // in their early loading stages. + // This is particularly important given that the "domwindowcreated" event + // fires for browser windows when they're in that in-between state, and just + // before we register our own "domwindowcreated" listener. + + for (let window of Services.wm.getEnumerator("")) { + let ok = includeIncomplete; + if (window.document.readyState === "complete") { + ok = this.isBrowserWindow(window); + } + + if (ok) { + yield window; + } + } + } + + /** + * @property {DOMWindow|null} topWindow + * The currently active, or topmost, browser window, or null if no + * browser window is currently open. + * @readonly + */ + get topWindow() { + return Services.wm.getMostRecentWindow("navigator:browser"); + } + + /** + * @property {DOMWindow|null} topWindow + * The currently active, or topmost, browser window that is not + * private browsing, or null if no browser window is currently open. + * @readonly + */ + get topNonPBWindow() { + return Services.wm.getMostRecentNonPBWindow("navigator:browser"); + } + + /** + * Returns the top window accessible by the extension. + * + * @param {BaseContext} context + * The extension context for which to return the current window. + * + * @returns {DOMWindow|null} + */ + getTopWindow(context) { + if (context && !context.privateBrowsingAllowed) { + return this.topNonPBWindow; + } + return this.topWindow; + } + + /** + * Returns the numeric ID for the given browser window. + * + * @param {DOMWindow} window + * The DOM window for which to return an ID. + * + * @returns {integer} + * The window's numeric ID. + */ + getId(window) { + return this._windowIds.get(window); + } + + /** + * Returns the browser window to which the given context belongs, or the top + * browser window if the context does not belong to a browser window. + * + * @param {BaseContext} context + * The extension context for which to return the current window. + * + * @returns {DOMWindow|null} + */ + getCurrentWindow(context) { + return (context && context.currentWindow) || this.getTopWindow(context); + } + + /** + * Returns the browser window with the given ID. + * + * @param {integer} id + * The ID of the window to return. + * @param {BaseContext} context + * The extension context for which the matching is being performed. + * Used to determine the current window for relevant properties. + * @param {boolean} [strict = true] + * If false, undefined will be returned instead of throwing an error + * in case no window exists with the given ID. + * + * @returns {DOMWindow|undefined} + * @throws {ExtensionError} + * If no window exists with the given ID and `strict` is true. + */ + getWindow(id, context, strict = true) { + if (id === WINDOW_ID_CURRENT) { + return this.getCurrentWindow(context); + } + + let window = Services.wm.getOuterWindowWithId(id); + if ( + window && + !window.closed && + (window.document.readyState !== "complete" || + this.isBrowserWindow(window)) + ) { + if (!context || context.canAccessWindow(window)) { + // Tolerate incomplete windows because isBrowserWindow is only reliable + // once the window is fully loaded. + return window; + } + } + + if (strict) { + throw new ExtensionError(`Invalid window ID: ${id}`); + } + } + + /** + * @property {boolean} _haveListeners + * Returns true if any window open or close listeners are currently + * registered. + * @private + */ + get _haveListeners() { + return this._openListeners.size > 0 || this._closeListeners.size > 0; + } + + /** + * Register the given listener function to be called whenever a new browser + * window is opened. + * + * @param {function(DOMWindow): void} listener + * The listener function to register. + */ + addOpenListener(listener) { + if (!this._haveListeners) { + Services.ww.registerNotification(this); + } + + this._openListeners.add(listener); + + for (let window of this.browserWindows(true)) { + if (window.document.readyState !== "complete") { + window.addEventListener("load", this); + } + } + } + + /** + * Unregister a listener function registered in a previous addOpenListener + * call. + * + * @param {function(DOMWindow): void} listener + * The listener function to unregister. + */ + removeOpenListener(listener) { + this._openListeners.delete(listener); + + if (!this._haveListeners) { + Services.ww.unregisterNotification(this); + } + } + + /** + * Register the given listener function to be called whenever a browser + * window is closed. + * + * @param {function(DOMWindow): void} listener + * The listener function to register. + */ + addCloseListener(listener) { + if (!this._haveListeners) { + Services.ww.registerNotification(this); + } + + this._closeListeners.add(listener); + } + + /** + * Unregister a listener function registered in a previous addCloseListener + * call. + * + * @param {function(DOMWindow): void} listener + * The listener function to unregister. + */ + removeCloseListener(listener) { + this._closeListeners.delete(listener); + + if (!this._haveListeners) { + Services.ww.unregisterNotification(this); + } + } + + /** + * Handles load events for recently-opened windows, and adds additional + * listeners which may only be safely added when the window is fully loaded. + * + * @param {Event} event + * A DOM event to handle. + * @private + */ + handleEvent(event) { + if (event.type === "load") { + event.currentTarget.removeEventListener(event.type, this); + + let window = event.target.defaultView; + if (!this.isBrowserWindow(window)) { + return; + } + + for (let listener of this._openListeners) { + try { + listener(window); + } catch (e) { + Cu.reportError(e); + } + } + } + } + + /** + * Observes "domwindowopened" and "domwindowclosed" events, notifies the + * appropriate listeners, and adds necessary additional listeners to the new + * windows. + * + * @param {DOMWindow} window + * A DOM window. + * @param {string} topic + * The topic being observed. + * @private + */ + observe(window, topic) { + if (topic === "domwindowclosed") { + if (!this.isBrowserWindow(window)) { + return; + } + + window.removeEventListener("load", this); + for (let listener of this._closeListeners) { + try { + listener(window); + } catch (e) { + Cu.reportError(e); + } + } + } else if (topic === "domwindowopened") { + window.addEventListener("load", this); + } + } + + /** + * Add an event listener to be called whenever the given DOM event is received + * at the top level of any browser window. + * + * @param {string} type + * The type of event to listen for. May be any valid DOM event name, or + * one of the following special cases: + * + * - "progress": Adds a tab progress listener to every browser window. + * - "status": Adds a StatusListener to every tab of every browser + * window. + * - "domwindowopened": Acts as an alias for addOpenListener. + * - "domwindowclosed": Acts as an alias for addCloseListener. + * @param {Function | object} listener + * The listener to invoke in response to the given events. + * + * @returns {undefined} + */ + addListener(type, listener) { + if (type === "domwindowopened") { + return this.addOpenListener(listener); + } else if (type === "domwindowclosed") { + return this.addCloseListener(listener); + } + + if (this._listeners.size === 0) { + this.addOpenListener(this._handleWindowOpened); + } + + if (type === "status") { + listener = this._statusListeners.get(listener); + type = "progress"; + } + + this._listeners.get(type).add(listener); + + // Register listener on all existing windows. + for (let window of this.browserWindows()) { + this._addWindowListener(window, type, listener); + } + } + + /** + * Removes an event listener previously registered via an addListener call. + * + * @param {string} type + * The type of event to stop listening for. + * @param {Function | object} listener + * The listener to remove. + * + * @returns {undefined} + */ + removeListener(type, listener) { + if (type === "domwindowopened") { + return this.removeOpenListener(listener); + } else if (type === "domwindowclosed") { + return this.removeCloseListener(listener); + } + + if (type === "status") { + listener = this._statusListeners.get(listener); + type = "progress"; + } + + let listeners = this._listeners.get(type); + listeners.delete(listener); + + if (listeners.size === 0) { + this._listeners.delete(type); + if (this._listeners.size === 0) { + this.removeOpenListener(this._handleWindowOpened); + } + } + + // Unregister listener from all existing windows. + let useCapture = type === "focus" || type === "blur"; + for (let window of this.browserWindows()) { + if (type === "progress") { + this.removeProgressListener(window, listener); + } else { + window.removeEventListener(type, listener, useCapture); + } + } + } + + /** + * Adds a listener for the given event to the given window. + * + * @param {DOMWindow} window + * The browser window to which to add the listener. + * @param {string} eventType + * The type of DOM event to listen for, or "progress" to add a tab + * progress listener. + * @param {Function | object} listener + * The listener to add. + * @private + */ + _addWindowListener(window, eventType, listener) { + let useCapture = eventType === "focus" || eventType === "blur"; + + if (eventType === "progress") { + this.addProgressListener(window, listener); + } else { + window.addEventListener(eventType, listener, useCapture); + } + } + + /** + * A private method which is called whenever a new browser window is opened, + * and adds the necessary listeners to it. + * + * @param {DOMWindow} window + * The window being opened. + * @private + */ + _handleWindowOpened(window) { + for (let [eventType, listeners] of this._listeners) { + for (let listener of listeners) { + this._addWindowListener(window, eventType, listener); + } + } + } + + /** + * Adds a tab progress listener to the given browser window. + * + * @param {DOMWindow} window + * The browser window to which to add the listener. + * @param {object} listener + * The tab progress listener to add. + * @abstract + */ + addProgressListener(window, listener) { + throw new Error("Not implemented"); + } + + /** + * Removes a tab progress listener from the given browser window. + * + * @param {DOMWindow} window + * The browser window from which to remove the listener. + * @param {object} listener + * The tab progress listener to remove. + * @abstract + */ + removeProgressListener(window, listener) { + throw new Error("Not implemented"); + } +} + +/** + * Manages native tabs, their wrappers, and their dynamic permissions for a + * particular extension. + * + * @param {Extension} extension + * The extension for which to manage tabs. + */ +class TabManagerBase { + constructor(extension) { + this.extension = extension; + + this._tabs = new DefaultWeakMap(tab => this.wrapTab(tab)); + } + + /** + * If the extension has requested activeTab permission, grant it those + * permissions for the current inner window in the given native tab. + * + * @param {NativeTab} nativeTab + * The native tab for which to grant permissions. + */ + addActiveTabPermission(nativeTab) { + let tab = this.getWrapper(nativeTab); + if ( + this.extension.hasPermission("activeTab") || + (this.extension.originControls && + this.extension.optionalOrigins.matches(tab._uri)) + ) { + // Note that, unlike Chrome, we don't currently clear this permission with + // the tab navigates. If the inner window is revived from BFCache before + // we've granted this permission to a new inner window, the extension + // maintains its permissions for it. + tab.activeTabWindowID = tab.innerWindowID; + } + } + + /** + * Revoke the extension's activeTab permissions for the current inner window + * of the given native tab. + * + * @param {NativeTab} nativeTab + * The native tab for which to revoke permissions. + */ + revokeActiveTabPermission(nativeTab) { + this.getWrapper(nativeTab).activeTabWindowID = null; + } + + /** + * Returns true if the extension has requested activeTab permission, and has + * been granted permissions for the current inner window if this tab. + * + * @param {NativeTab} nativeTab + * The native tab for which to check permissions. + * @returns {boolean} + * True if the extension has activeTab permissions for this tab. + */ + hasActiveTabPermission(nativeTab) { + return this.getWrapper(nativeTab).hasActiveTabPermission; + } + + /** + * Activate MV3 content scripts if the extension has activeTab or an + * (ungranted) host permission. + * + * @param {NativeTab} nativeTab + */ + activateScripts(nativeTab) { + let tab = this.getWrapper(nativeTab); + if ( + this.extension.originControls && + !tab.matchesHostPermission && + (this.extension.optionalOrigins.matches(tab._uri) || + this.extension.hasPermission("activeTab")) && + (this.extension.contentScripts.length || + this.extension.registeredContentScripts.size) + ) { + tab.queryContent("ActivateScripts", { id: this.extension.id }); + } + } + + /** + * Returns true if the extension has permissions to access restricted + * properties of the given native tab. In practice, this means that it has + * either requested the "tabs" permission or has activeTab permissions for the + * given tab. + * + * NOTE: Never use this method on an object that is not a native tab + * for the current platform: this method implicitly generates a wrapper + * for the passed nativeTab parameter and the platform-specific tabTracker + * instance is likely to store it in a map which is cleared only when the + * tab is closed (and so, if nativeTab is not a real native tab, it will + * never be cleared from the platform-specific tabTracker instance), + * See Bug 1458918 for a rationale. + * + * @param {NativeTab} nativeTab + * The native tab for which to check permissions. + * @returns {boolean} + * True if the extension has permissions for this tab. + */ + hasTabPermission(nativeTab) { + return this.getWrapper(nativeTab).hasTabPermission; + } + + /** + * Returns this extension's TabBase wrapper for the given native tab. This + * method will always return the same wrapper object for any given native tab. + * + * @param {NativeTab} nativeTab + * The tab for which to return a wrapper. + * + * @returns {TabBase|undefined} + * The wrapper for this tab. + */ + getWrapper(nativeTab) { + if (this.canAccessTab(nativeTab)) { + return this._tabs.get(nativeTab); + } + } + + /** + * Determines access using extension context. + * + * @param {NativeTab} nativeTab + * The tab to check access on. + * @returns {boolean} + * True if the extension has permissions for this tab. + * @protected + * @abstract + */ + canAccessTab(nativeTab) { + throw new Error("Not implemented"); + } + + /** + * Converts the given native tab to a JSON-compatible object, in the format + * required to be returned by WebExtension APIs, which may be safely passed to + * extension code. + * + * @param {NativeTab} nativeTab + * The native tab to convert. + * @param {object} [fallbackTabSize] + * A geometry data if the lazy geometry data for this tab hasn't been + * initialized yet. + * + * @returns {object} + */ + convert(nativeTab, fallbackTabSize = null) { + return this.getWrapper(nativeTab).convert(fallbackTabSize); + } + + // The JSDoc validator does not support @returns tags in abstract functions or + // star functions without return statements. + /* eslint-disable valid-jsdoc */ + /** + * Returns an iterator of TabBase objects which match the given query info. + * + * @param {object | null} [queryInfo = null] + * An object containing properties on which to filter. May contain any + * properties which are recognized by {@link TabBase#matches} or + * {@link WindowBase#matches}. Unknown properties will be ignored. + * @param {BaseContext|null} [context = null] + * The extension context for which the matching is being performed. + * Used to determine the current window for relevant properties. + * + * @returns {Iterator<TabBase>} + */ + *query(queryInfo = null, context = null) { + if (queryInfo) { + if (queryInfo.url !== null) { + queryInfo.url = parseMatchPatterns([].concat(queryInfo.url), { + restrictSchemes: false, + }); + } + + if (queryInfo.cookieStoreId !== null) { + queryInfo.cookieStoreId = [].concat(queryInfo.cookieStoreId); + } + + if (queryInfo.title !== null) { + try { + queryInfo.title = new MatchGlob(queryInfo.title); + } catch (e) { + throw new ExtensionError(`Invalid title: ${queryInfo.title}`); + } + } + } + function* candidates(windowWrapper) { + if (queryInfo) { + let { active, highlighted, index } = queryInfo; + if (active === true) { + let { activeTab } = windowWrapper; + if (activeTab) { + yield activeTab; + } + return; + } + if (index != null) { + let tabWrapper = windowWrapper.getTabAtIndex(index); + if (tabWrapper) { + yield tabWrapper; + } + return; + } + if (highlighted === true) { + yield* windowWrapper.getHighlightedTabs(); + return; + } + } + yield* windowWrapper.getTabs(); + } + let windowWrappers = this.extension.windowManager.query(queryInfo, context); + for (let windowWrapper of windowWrappers) { + for (let tabWrapper of candidates(windowWrapper)) { + if (!queryInfo || tabWrapper.matches(queryInfo)) { + yield tabWrapper; + } + } + } + } + + /** + * Returns a TabBase wrapper for the tab with the given ID. + * + * @param {integer} tabId + * The ID of the tab for which to return a wrapper. + * + * @returns {TabBase} + * @throws {ExtensionError} + * If no tab exists with the given ID. + * @abstract + */ + get(tabId) { + throw new Error("Not implemented"); + } + + /** + * Returns a new TabBase instance wrapping the given native tab. + * + * @param {NativeTab} nativeTab + * The native tab for which to return a wrapper. + * + * @returns {TabBase} + * @protected + * @abstract + */ + /* eslint-enable valid-jsdoc */ + wrapTab(nativeTab) { + throw new Error("Not implemented"); + } +} + +/** + * Manages native browser windows and their wrappers for a particular extension. + * + * @param {Extension} extension + * The extension for which to manage windows. + */ +class WindowManagerBase { + constructor(extension) { + this.extension = extension; + + this._windows = new DefaultWeakMap(window => this.wrapWindow(window)); + } + + /** + * Converts the given browser window to a JSON-compatible object, in the + * format required to be returned by WebExtension APIs, which may be safely + * passed to extension code. + * + * @param {DOMWindow} window + * The browser window to convert. + * @param {*} args + * Additional arguments to be passed to {@link WindowBase#convert}. + * + * @returns {object} + */ + convert(window, ...args) { + return this.getWrapper(window).convert(...args); + } + + /** + * Returns this extension's WindowBase wrapper for the given browser window. + * This method will always return the same wrapper object for any given + * browser window. + * + * @param {DOMWindow} window + * The browser window for which to return a wrapper. + * + * @returns {WindowBase|undefined} + * The wrapper for this tab. + */ + getWrapper(window) { + if (this.extension.canAccessWindow(window)) { + return this._windows.get(window); + } + } + + /** + * Returns whether this window can be accessed by the extension in the given + * context. + * + * @param {DOMWindow} window + * The browser window that is being tested + * @param {BaseContext|null} context + * The extension context for which this test is being performed. + * @returns {boolean} + */ + canAccessWindow(window, context) { + return ( + (context && context.canAccessWindow(window)) || + this.extension.canAccessWindow(window) + ); + } + + // The JSDoc validator does not support @returns tags in abstract functions or + // star functions without return statements. + /* eslint-disable valid-jsdoc */ + /** + * Returns an iterator of WindowBase objects which match the given query info. + * + * @param {object | null} [queryInfo = null] + * An object containing properties on which to filter. May contain any + * properties which are recognized by {@link WindowBase#matches}. + * Unknown properties will be ignored. + * @param {BaseContext|null} [context = null] + * The extension context for which the matching is being performed. + * Used to determine the current window for relevant properties. + * + * @returns {Iterator<WindowBase>} + */ + *query(queryInfo = null, context = null) { + function* candidates(windowManager) { + if (queryInfo) { + let { currentWindow, windowId, lastFocusedWindow } = queryInfo; + if (currentWindow === true && windowId == null) { + windowId = WINDOW_ID_CURRENT; + } + if (windowId != null) { + let window = global.windowTracker.getWindow(windowId, context, false); + if (window) { + yield windowManager.getWrapper(window); + } + return; + } + if (lastFocusedWindow === true) { + let window = global.windowTracker.getTopWindow(context); + if (window) { + yield windowManager.getWrapper(window); + } + return; + } + } + yield* windowManager.getAll(context); + } + for (let windowWrapper of candidates(this)) { + if (!queryInfo || windowWrapper.matches(queryInfo, context)) { + yield windowWrapper; + } + } + } + + /** + * Returns a WindowBase wrapper for the browser window with the given ID. + * + * @param {integer} windowId + * The ID of the browser window for which to return a wrapper. + * @param {BaseContext} context + * The extension context for which the matching is being performed. + * Used to determine the current window for relevant properties. + * + * @returns {WindowBase} + * @throws {ExtensionError} + * If no window exists with the given ID. + * @abstract + */ + get(windowId, context) { + throw new Error("Not implemented"); + } + + /** + * Returns an iterator of WindowBase wrappers for each currently existing + * browser window. + * + * @returns {Iterator<WindowBase>} + * @abstract + */ + getAll() { + throw new Error("Not implemented"); + } + + /** + * Returns a new WindowBase instance wrapping the given browser window. + * + * @param {DOMWindow} window + * The browser window for which to return a wrapper. + * + * @returns {WindowBase} + * @protected + * @abstract + */ + wrapWindow(window) { + throw new Error("Not implemented"); + } + /* eslint-enable valid-jsdoc */ +} + +function getUserContextIdForCookieStoreId( + extension, + cookieStoreId, + isPrivateBrowsing +) { + if (!extension.hasPermission("cookies")) { + throw new ExtensionError( + `No permission for cookieStoreId: ${cookieStoreId}` + ); + } + + if (!isValidCookieStoreId(cookieStoreId)) { + throw new ExtensionError(`Illegal cookieStoreId: ${cookieStoreId}`); + } + + if (isPrivateBrowsing && !isPrivateCookieStoreId(cookieStoreId)) { + throw new ExtensionError( + `Illegal to set non-private cookieStoreId in a private window` + ); + } + + if (!isPrivateBrowsing && isPrivateCookieStoreId(cookieStoreId)) { + throw new ExtensionError( + `Illegal to set private cookieStoreId in a non-private window` + ); + } + + if (isContainerCookieStoreId(cookieStoreId)) { + if (PrivateBrowsingUtils.permanentPrivateBrowsing) { + // Container tabs are not supported in perma-private browsing mode - bug 1320757 + throw new ExtensionError( + `Contextual identities are unavailable in permanent private browsing mode` + ); + } + if (!containersEnabled) { + throw new ExtensionError(`Contextual identities are currently disabled`); + } + let userContextId = getContainerForCookieStoreId(cookieStoreId); + if (!userContextId) { + throw new ExtensionError( + `No cookie store exists with ID ${cookieStoreId}` + ); + } + if (!extension.canAccessContainer(userContextId)) { + throw new ExtensionError(`Cannot access ${cookieStoreId}`); + } + return userContextId; + } + + return Services.scriptSecurityManager.DEFAULT_USER_CONTEXT_ID; +} + +Object.assign(global, { + TabTrackerBase, + TabManagerBase, + TabBase, + WindowTrackerBase, + WindowManagerBase, + WindowBase, + getUserContextIdForCookieStoreId, +}); diff --git a/toolkit/components/extensions/parent/ext-telemetry.js b/toolkit/components/extensions/parent/ext-telemetry.js new file mode 100644 index 0000000000..cff568a038 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-telemetry.js @@ -0,0 +1,195 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + TelemetryController: "resource://gre/modules/TelemetryController.sys.mjs", + TelemetryUtils: "resource://gre/modules/TelemetryUtils.sys.mjs", +}); + +const SCALAR_TYPES = { + count: Ci.nsITelemetry.SCALAR_TYPE_COUNT, + string: Ci.nsITelemetry.SCALAR_TYPE_STRING, + boolean: Ci.nsITelemetry.SCALAR_TYPE_BOOLEAN, +}; + +// Currently unsupported on Android: blocked on 1220177. +// See 1280234 c67 for discussion. +function desktopCheck() { + if (AppConstants.MOZ_BUILD_APP !== "browser") { + throw new ExtensionUtils.ExtensionError( + "This API is only supported on desktop" + ); + } +} + +this.telemetry = class extends ExtensionAPI { + getAPI(context) { + let { extension } = context; + return { + telemetry: { + submitPing(type, payload, options) { + desktopCheck(); + const manifest = extension.manifest; + if (manifest.telemetry) { + throw new ExtensionUtils.ExtensionError( + "Encryption settings are defined, use submitEncryptedPing instead." + ); + } + + try { + TelemetryController.submitExternalPing(type, payload, options); + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + submitEncryptedPing(payload, options) { + desktopCheck(); + + const manifest = extension.manifest; + if (!manifest.telemetry) { + throw new ExtensionUtils.ExtensionError( + "Encrypted telemetry pings require ping_type and public_key to be set in manifest." + ); + } + + if (!(options.schemaName && options.schemaVersion)) { + throw new ExtensionUtils.ExtensionError( + "Encrypted telemetry pings require schema name and version to be set in options object." + ); + } + + try { + const type = manifest.telemetry.ping_type; + + // Optional manifest entries. + if (manifest.telemetry.study_name) { + options.studyName = manifest.telemetry.study_name; + } + options.addPioneerId = manifest.telemetry.pioneer_id === true; + + // Required manifest entries. + options.useEncryption = true; + options.publicKey = manifest.telemetry.public_key.key; + options.encryptionKeyId = manifest.telemetry.public_key.id; + options.schemaNamespace = manifest.telemetry.schemaNamespace; + + TelemetryController.submitExternalPing(type, payload, options); + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + canUpload() { + desktopCheck(); + // Note: remove the ternary and direct pref check when + // TelemetryController.canUpload() is implemented (bug 1440089). + try { + const result = + "canUpload" in TelemetryController + ? TelemetryController.canUpload() + : Services.prefs.getBoolPref( + TelemetryUtils.Preferences.FhrUploadEnabled, + false + ); + return result; + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + scalarAdd(name, value) { + desktopCheck(); + try { + Services.telemetry.scalarAdd(name, value); + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + scalarSet(name, value) { + desktopCheck(); + try { + Services.telemetry.scalarSet(name, value); + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + scalarSetMaximum(name, value) { + desktopCheck(); + try { + Services.telemetry.scalarSetMaximum(name, value); + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + keyedScalarAdd(name, key, value) { + desktopCheck(); + try { + Services.telemetry.keyedScalarAdd(name, key, value); + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + keyedScalarSet(name, key, value) { + desktopCheck(); + try { + Services.telemetry.keyedScalarSet(name, key, value); + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + keyedScalarSetMaximum(name, key, value) { + desktopCheck(); + try { + Services.telemetry.keyedScalarSetMaximum(name, key, value); + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + recordEvent(category, method, object, value, extra) { + desktopCheck(); + try { + Services.telemetry.recordEvent( + category, + method, + object, + value, + extra + ); + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + registerScalars(category, data) { + desktopCheck(); + try { + // For each scalar in `data`, replace scalar.kind with + // the appropriate nsITelemetry constant. + Object.keys(data).forEach(scalar => { + data[scalar].kind = SCALAR_TYPES[data[scalar].kind]; + }); + Services.telemetry.registerScalars(category, data); + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + setEventRecordingEnabled(category, enabled) { + desktopCheck(); + try { + Services.telemetry.setEventRecordingEnabled(category, enabled); + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + registerEvents(category, data) { + desktopCheck(); + try { + Services.telemetry.registerEvents(category, data); + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-theme.js b/toolkit/components/extensions/parent/ext-theme.js new file mode 100644 index 0000000000..1280563dd0 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-theme.js @@ -0,0 +1,529 @@ +/* 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"; + +/* global windowTracker, EventManager, EventEmitter */ + +/* eslint-disable complexity */ + +ChromeUtils.defineESModuleGetters(this, { + LightweightThemeManager: + "resource://gre/modules/LightweightThemeManager.sys.mjs", +}); + +const onUpdatedEmitter = new EventEmitter(); + +// Represents an empty theme for convenience of use +const emptyTheme = { + details: { colors: null, images: null, properties: null }, +}; + +let defaultTheme = emptyTheme; +// Map[windowId -> Theme instance] +let windowOverrides = new Map(); + +/** + * Class representing either a global theme affecting all windows or an override on a specific window. + * Any extension updating the theme with a new global theme will replace the singleton defaultTheme. + */ +class Theme { + /** + * Creates a theme instance. + * + * @param {object} options + * @param {string} options.extension Extension that created the theme. + * @param {Integer} options.windowId The windowId where the theme is applied. + * @param {object} options.details + * @param {object} options.darkDetails + * @param {object} options.experiment + * @param {object} options.startupData + */ + constructor({ + extension, + details, + darkDetails, + windowId, + experiment, + startupData, + }) { + this.extension = extension; + this.details = details; + this.darkDetails = darkDetails; + this.windowId = windowId; + + if (startupData && startupData.lwtData) { + Object.assign(this, startupData); + } else { + // TODO(ntim): clean this in bug 1550090 + this.lwtStyles = {}; + this.lwtDarkStyles = null; + if (darkDetails) { + this.lwtDarkStyles = {}; + } + + if (experiment) { + if (extension.canUseThemeExperiment()) { + this.lwtStyles.experimental = { + colors: {}, + images: {}, + properties: {}, + }; + if (this.lwtDarkStyles) { + this.lwtDarkStyles.experimental = { + colors: {}, + images: {}, + properties: {}, + }; + } + const { baseURI } = this.extension; + if (experiment.stylesheet) { + experiment.stylesheet = baseURI.resolve(experiment.stylesheet); + } + this.experiment = experiment; + } else { + const { logger } = this.extension; + logger.warn("This extension is not allowed to run theme experiments"); + return; + } + } + } + this.load(); + } + + /** + * Loads a theme by reading the properties from the extension's manifest. + * This method will override any currently applied theme. + */ + load() { + if (!this.lwtData) { + this.loadDetails(this.details, this.lwtStyles); + if (this.darkDetails) { + this.loadDetails(this.darkDetails, this.lwtDarkStyles); + } + + this.lwtData = { + theme: this.lwtStyles, + darkTheme: this.lwtDarkStyles, + }; + + if (this.experiment) { + this.lwtData.experiment = this.experiment; + } + + this.extension.startupData = { + lwtData: this.lwtData, + lwtStyles: this.lwtStyles, + lwtDarkStyles: this.lwtDarkStyles, + experiment: this.experiment, + }; + this.extension.saveStartupData(); + } + + if (this.windowId) { + this.lwtData.window = windowTracker.getWindow( + this.windowId + ).docShell.outerWindowID; + windowOverrides.set(this.windowId, this); + } else { + windowOverrides.clear(); + defaultTheme = this; + LightweightThemeManager.fallbackThemeData = this.lwtData; + } + onUpdatedEmitter.emit("theme-updated", this.details, this.windowId); + + Services.obs.notifyObservers( + this.lwtData, + "lightweight-theme-styling-update" + ); + } + + /** + * @param {object} details Details + * @param {object} styles Styles object in which to store the colors. + */ + loadDetails(details, styles) { + if (details.colors) { + this.loadColors(details.colors, styles); + } + + if (details.images) { + this.loadImages(details.images, styles); + } + + if (details.properties) { + this.loadProperties(details.properties, styles); + } + + this.loadMetadata(this.extension, styles); + } + + /** + * Helper method for loading colors found in the extension's manifest. + * + * @param {object} colors Dictionary mapping color properties to values. + * @param {object} styles Styles object in which to store the colors. + */ + loadColors(colors, styles) { + for (let color of Object.keys(colors)) { + let val = colors[color]; + + if (!val) { + continue; + } + + let cssColor = val; + if (Array.isArray(val)) { + cssColor = + "rgb" + (val.length > 3 ? "a" : "") + "(" + val.join(",") + ")"; + } + + switch (color) { + case "frame": + styles.accentcolor = cssColor; + break; + case "frame_inactive": + styles.accentcolorInactive = cssColor; + break; + case "tab_background_text": + styles.textcolor = cssColor; + break; + case "toolbar": + styles.toolbarColor = cssColor; + break; + case "toolbar_text": + case "bookmark_text": + styles.toolbar_text = cssColor; + break; + case "icons": + styles.icon_color = cssColor; + break; + case "icons_attention": + styles.icon_attention_color = cssColor; + break; + case "tab_background_separator": + case "tab_loading": + case "tab_text": + case "tab_line": + case "tab_selected": + case "toolbar_field": + case "toolbar_field_text": + case "toolbar_field_border": + case "toolbar_field_focus": + case "toolbar_field_text_focus": + case "toolbar_field_border_focus": + case "toolbar_top_separator": + case "toolbar_bottom_separator": + case "toolbar_vertical_separator": + case "button_background_hover": + case "button_background_active": + case "popup": + case "popup_text": + case "popup_border": + case "popup_highlight": + case "popup_highlight_text": + case "ntp_background": + case "ntp_card_background": + case "ntp_text": + case "sidebar": + case "sidebar_border": + case "sidebar_text": + case "sidebar_highlight": + case "sidebar_highlight_text": + case "toolbar_field_highlight": + case "toolbar_field_highlight_text": + styles[color] = cssColor; + break; + default: + if ( + this.experiment && + this.experiment.colors && + color in this.experiment.colors + ) { + styles.experimental.colors[color] = cssColor; + } else { + const { logger } = this.extension; + logger.warn(`Unrecognized theme property found: colors.${color}`); + } + break; + } + } + } + + /** + * Helper method for loading images found in the extension's manifest. + * + * @param {object} images Dictionary mapping image properties to values. + * @param {object} styles Styles object in which to store the colors. + */ + loadImages(images, styles) { + const { baseURI, logger } = this.extension; + + for (let image of Object.keys(images)) { + let val = images[image]; + + if (!val) { + continue; + } + + switch (image) { + case "additional_backgrounds": { + let backgroundImages = val.map(img => baseURI.resolve(img)); + styles.additionalBackgrounds = backgroundImages; + break; + } + case "theme_frame": { + let resolvedURL = baseURI.resolve(val); + styles.headerURL = resolvedURL; + break; + } + default: { + if ( + this.experiment && + this.experiment.images && + image in this.experiment.images + ) { + styles.experimental.images[image] = baseURI.resolve(val); + } else { + logger.warn(`Unrecognized theme property found: images.${image}`); + } + break; + } + } + } + } + + /** + * Helper method for preparing properties found in the extension's manifest. + * Properties are commonly used to specify more advanced behavior of colors, + * images or icons. + * + * @param {object} properties Dictionary mapping properties to values. + * @param {object} styles Styles object in which to store the colors. + */ + loadProperties(properties, styles) { + let additionalBackgroundsCount = + (styles.additionalBackgrounds && styles.additionalBackgrounds.length) || + 0; + const assertValidAdditionalBackgrounds = (property, valueCount) => { + const { logger } = this.extension; + if (!additionalBackgroundsCount) { + logger.warn( + `The '${property}' property takes effect only when one ` + + `or more additional background images are specified using the 'additional_backgrounds' property.` + ); + return false; + } + if (additionalBackgroundsCount !== valueCount) { + logger.warn( + `The amount of values specified for '${property}' ` + + `(${valueCount}) is not equal to the amount of additional background ` + + `images (${additionalBackgroundsCount}), which may lead to unexpected results.` + ); + } + return true; + }; + + for (let property of Object.getOwnPropertyNames(properties)) { + let val = properties[property]; + + if (!val) { + continue; + } + + switch (property) { + case "additional_backgrounds_alignment": { + if (!assertValidAdditionalBackgrounds(property, val.length)) { + break; + } + + styles.backgroundsAlignment = val.join(","); + break; + } + case "additional_backgrounds_tiling": { + if (!assertValidAdditionalBackgrounds(property, val.length)) { + break; + } + + let tiling = []; + for (let i = 0, l = styles.additionalBackgrounds.length; i < l; ++i) { + tiling.push(val[i] || "no-repeat"); + } + styles.backgroundsTiling = tiling.join(","); + break; + } + case "color_scheme": + case "content_color_scheme": { + styles[property] = val; + break; + } + default: { + if ( + this.experiment && + this.experiment.properties && + property in this.experiment.properties + ) { + styles.experimental.properties[property] = val; + } else { + const { logger } = this.extension; + logger.warn( + `Unrecognized theme property found: properties.${property}` + ); + } + break; + } + } + } + } + + /** + * Helper method for loading extension metadata required by downstream + * consumers. + * + * @param {object} extension Extension object. + * @param {object} styles Styles object in which to store the colors. + */ + loadMetadata(extension, styles) { + styles.id = extension.id; + styles.version = extension.version; + } + + static unload(windowId) { + let lwtData = { + theme: null, + }; + + if (windowId) { + lwtData.window = windowTracker.getWindow(windowId).docShell.outerWindowID; + windowOverrides.delete(windowId); + } else { + windowOverrides.clear(); + defaultTheme = emptyTheme; + LightweightThemeManager.fallbackThemeData = null; + } + onUpdatedEmitter.emit("theme-updated", {}, windowId); + + Services.obs.notifyObservers(lwtData, "lightweight-theme-styling-update"); + } +} + +this.theme = class extends ExtensionAPIPersistent { + PERSISTENT_EVENTS = { + onUpdated({ fire, context }) { + let callback = (event, theme, windowId) => { + if (windowId) { + // Force access validation for incognito mode by getting the window. + if (windowTracker.getWindow(windowId, context, false)) { + fire.async({ theme, windowId }); + } + } else { + fire.async({ theme }); + } + }; + + onUpdatedEmitter.on("theme-updated", callback); + return { + unregister() { + onUpdatedEmitter.off("theme-updated", callback); + }, + convert(_fire, _context) { + fire = _fire; + context = _context; + }, + }; + }, + }; + + onManifestEntry(entryName) { + let { extension } = this; + let { manifest } = extension; + + defaultTheme = new Theme({ + extension, + details: manifest.theme, + darkDetails: manifest.dark_theme, + experiment: manifest.theme_experiment, + startupData: extension.startupData, + }); + } + + onShutdown(isAppShutdown) { + if (isAppShutdown) { + return; + } + + let { extension } = this; + for (let [windowId, theme] of windowOverrides) { + if (theme.extension === extension) { + Theme.unload(windowId); + } + } + + if (defaultTheme.extension === extension) { + Theme.unload(); + } + } + + getAPI(context) { + let { extension } = context; + + return { + theme: { + getCurrent: windowId => { + // Take last focused window when no ID is supplied. + if (!windowId) { + windowId = windowTracker.getId(windowTracker.topWindow); + } + // Force access validation for incognito mode by getting the window. + if (!windowTracker.getWindow(windowId, context)) { + return Promise.reject(`Invalid window ID: ${windowId}`); + } + + if (windowOverrides.has(windowId)) { + return Promise.resolve(windowOverrides.get(windowId).details); + } + return Promise.resolve(defaultTheme.details); + }, + update: (windowId, details) => { + if (windowId) { + const browserWindow = windowTracker.getWindow(windowId, context); + if (!browserWindow) { + return Promise.reject(`Invalid window ID: ${windowId}`); + } + } + + new Theme({ + extension, + details, + windowId, + experiment: this.extension.manifest.theme_experiment, + }); + }, + reset: windowId => { + if (windowId) { + const browserWindow = windowTracker.getWindow(windowId, context); + if (!browserWindow) { + return Promise.reject(`Invalid window ID: ${windowId}`); + } + + let theme = windowOverrides.get(windowId) || defaultTheme; + if (theme.extension !== extension) { + return; + } + } else if (defaultTheme.extension !== extension) { + return; + } + + Theme.unload(windowId); + }, + onUpdated: new EventManager({ + context, + module: "theme", + event: "onUpdated", + extensionApi: this, + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-toolkit.js b/toolkit/components/extensions/parent/ext-toolkit.js new file mode 100644 index 0000000000..c672cb96c0 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-toolkit.js @@ -0,0 +1,130 @@ +/* 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"; + +// These are defined on "global" which is used for the same scopes as the other +// ext-*.js files. +/* exported getCookieStoreIdForTab, getCookieStoreIdForContainer, + getContainerForCookieStoreId, + isValidCookieStoreId, isContainerCookieStoreId, + EventManager, URL */ +/* global getCookieStoreIdForTab:false, + getCookieStoreIdForContainer:false, + getContainerForCookieStoreId: false, + isValidCookieStoreId:false, isContainerCookieStoreId:false, + isDefaultCookieStoreId: false, isPrivateCookieStoreId:false, + EventManager: false */ + +ChromeUtils.defineESModuleGetters(this, { + ContextualIdentityService: + "resource://gre/modules/ContextualIdentityService.sys.mjs", +}); + +XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]); + +var { ExtensionCommon } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionCommon.sys.mjs" +); + +var { ExtensionError } = ExtensionUtils; + +global.EventEmitter = ExtensionCommon.EventEmitter; +global.EventManager = ExtensionCommon.EventManager; + +/* globals DEFAULT_STORE, PRIVATE_STORE, CONTAINER_STORE */ + +global.DEFAULT_STORE = "firefox-default"; +global.PRIVATE_STORE = "firefox-private"; +global.CONTAINER_STORE = "firefox-container-"; + +global.getCookieStoreIdForTab = function (data, tab) { + if (data.incognito) { + return PRIVATE_STORE; + } + + if (tab.userContextId) { + return getCookieStoreIdForContainer(tab.userContextId); + } + + return DEFAULT_STORE; +}; + +global.getCookieStoreIdForOriginAttributes = function (originAttributes) { + if (originAttributes.privateBrowsingId) { + return PRIVATE_STORE; + } + + if (originAttributes.userContextId) { + return getCookieStoreIdForContainer(originAttributes.userContextId); + } + + return DEFAULT_STORE; +}; + +global.isPrivateCookieStoreId = function (storeId) { + return storeId == PRIVATE_STORE; +}; + +global.isDefaultCookieStoreId = function (storeId) { + return storeId == DEFAULT_STORE; +}; + +global.isContainerCookieStoreId = function (storeId) { + return storeId !== null && storeId.startsWith(CONTAINER_STORE); +}; + +global.getCookieStoreIdForContainer = function (containerId) { + return CONTAINER_STORE + containerId; +}; + +global.getContainerForCookieStoreId = function (storeId) { + if (!isContainerCookieStoreId(storeId)) { + return null; + } + + let containerId = storeId.substring(CONTAINER_STORE.length); + + if (AppConstants.platform === "android") { + return parseInt(containerId, 10); + } // TODO: Bug 1643740, support ContextualIdentityService on Android + + if (ContextualIdentityService.getPublicIdentityFromId(containerId)) { + return parseInt(containerId, 10); + } + + return null; +}; + +global.isValidCookieStoreId = function (storeId) { + return ( + isDefaultCookieStoreId(storeId) || + isPrivateCookieStoreId(storeId) || + isContainerCookieStoreId(storeId) + ); +}; + +global.getOriginAttributesPatternForCookieStoreId = function (cookieStoreId) { + if (isDefaultCookieStoreId(cookieStoreId)) { + return { + userContextId: Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID, + privateBrowsingId: + Ci.nsIScriptSecurityManager.DEFAULT_PRIVATE_BROWSING_ID, + }; + } + if (isPrivateCookieStoreId(cookieStoreId)) { + return { + userContextId: Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID, + privateBrowsingId: 1, + }; + } + if (isContainerCookieStoreId(cookieStoreId)) { + let userContextId = getContainerForCookieStoreId(cookieStoreId); + if (userContextId !== null) { + return { userContextId }; + } + } + + throw new ExtensionError("Invalid cookieStoreId"); +}; diff --git a/toolkit/components/extensions/parent/ext-userScripts.js b/toolkit/components/extensions/parent/ext-userScripts.js new file mode 100644 index 0000000000..9c008a4e8d --- /dev/null +++ b/toolkit/components/extensions/parent/ext-userScripts.js @@ -0,0 +1,158 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 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/. */ + +"use strict"; + +var { ExtensionUtils } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionUtils.sys.mjs" +); + +var { ExtensionError } = ExtensionUtils; + +/** + * Represents (in the main browser process) a user script. + * + * @param {UserScriptOptions} details + * The options object related to the user script + * (which has the properties described in the user_scripts.json + * JSON API schema file). + */ +class UserScriptParent { + constructor(details) { + this.scriptId = details.scriptId; + this.options = this._convertOptions(details); + } + + destroy() { + if (this.destroyed) { + throw new Error("Unable to destroy UserScriptParent twice"); + } + + this.destroyed = true; + this.options = null; + } + + _convertOptions(details) { + const options = { + matches: details.matches, + excludeMatches: details.excludeMatches, + includeGlobs: details.includeGlobs, + excludeGlobs: details.excludeGlobs, + allFrames: details.allFrames, + matchAboutBlank: details.matchAboutBlank, + runAt: details.runAt || "document_idle", + jsPaths: details.js, + userScriptOptions: { + scriptMetadata: details.scriptMetadata, + }, + originAttributesPatterns: null, + }; + + if (details.cookieStoreId != null) { + const cookieStoreIds = Array.isArray(details.cookieStoreId) + ? details.cookieStoreId + : [details.cookieStoreId]; + options.originAttributesPatterns = cookieStoreIds.map(cookieStoreId => + getOriginAttributesPatternForCookieStoreId(cookieStoreId) + ); + } + + return options; + } + + serialize() { + return this.options; + } +} + +this.userScripts = class extends ExtensionAPI { + constructor(...args) { + super(...args); + + // Map<scriptId -> UserScriptParent> + this.userScriptsMap = new Map(); + } + + getAPI(context) { + const { extension } = context; + + // Set of the scriptIds registered from this context. + const registeredScriptIds = new Set(); + + const unregisterContentScripts = scriptIds => { + if (scriptIds.length === 0) { + return Promise.resolve(); + } + + for (let scriptId of scriptIds) { + registeredScriptIds.delete(scriptId); + extension.registeredContentScripts.delete(scriptId); + this.userScriptsMap.delete(scriptId); + } + extension.updateContentScripts(); + + return context.extension.broadcast("Extension:UnregisterContentScripts", { + id: context.extension.id, + scriptIds, + }); + }; + + // Unregister all the scriptId related to a context when it is closed, + // and revoke all the created blob urls once the context is destroyed. + context.callOnClose({ + close() { + unregisterContentScripts(Array.from(registeredScriptIds)); + }, + }); + + return { + userScripts: { + register: async details => { + for (let origin of details.matches) { + if (!extension.allowedOrigins.subsumes(new MatchPattern(origin))) { + throw new ExtensionError( + `Permission denied to register a user script for ${origin}` + ); + } + } + + const userScript = new UserScriptParent(details); + const { scriptId } = userScript; + + this.userScriptsMap.set(scriptId, userScript); + registeredScriptIds.add(scriptId); + + const scriptOptions = userScript.serialize(); + + extension.registeredContentScripts.set(scriptId, scriptOptions); + extension.updateContentScripts(); + + await extension.broadcast("Extension:RegisterContentScripts", { + id: extension.id, + scripts: [{ scriptId, options: scriptOptions }], + }); + + return scriptId; + }, + + // This method is not available to the extension code, the extension code + // doesn't have access to the internally used scriptId, on the contrary + // the extension code will call script.unregister on the script API object + // that is resolved from the register API method returned promise. + unregister: async scriptId => { + const userScript = this.userScriptsMap.get(scriptId); + if (!userScript) { + throw new Error(`No such user script ID: ${scriptId}`); + } + + userScript.destroy(); + + await unregisterContentScripts([scriptId]); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-webNavigation.js b/toolkit/components/extensions/parent/ext-webNavigation.js new file mode 100644 index 0000000000..c65b61041b --- /dev/null +++ b/toolkit/components/extensions/parent/ext-webNavigation.js @@ -0,0 +1,276 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// This file expects tabTracker to be defined in the global scope (e.g. +// by ext-browser.js or ext-android.js). +/* global tabTracker */ + +ChromeUtils.defineESModuleGetters(this, { + MatchURLFilters: "resource://gre/modules/MatchURLFilters.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + WebNavigation: "resource://gre/modules/WebNavigation.sys.mjs", + WebNavigationFrames: "resource://gre/modules/WebNavigationFrames.sys.mjs", +}); + +var { ExtensionError } = ExtensionUtils; + +const defaultTransitionTypes = { + topFrame: "link", + subFrame: "auto_subframe", +}; + +const frameTransitions = { + anyFrame: { + qualifiers: ["server_redirect", "client_redirect", "forward_back"], + }, + topFrame: { + types: ["reload", "form_submit"], + }, +}; + +const tabTransitions = { + topFrame: { + qualifiers: ["from_address_bar"], + types: ["auto_bookmark", "typed", "keyword", "generated", "link"], + }, + subFrame: { + types: ["manual_subframe"], + }, +}; + +const isTopLevelFrame = ({ frameId, parentFrameId }) => { + return frameId == 0 && parentFrameId == -1; +}; + +const fillTransitionProperties = (eventName, src, dst) => { + if ( + eventName == "onCommitted" || + eventName == "onHistoryStateUpdated" || + eventName == "onReferenceFragmentUpdated" + ) { + let frameTransitionData = src.frameTransitionData || {}; + let tabTransitionData = src.tabTransitionData || {}; + + let transitionType, + transitionQualifiers = []; + + // Fill transition properties for any frame. + for (let qualifier of frameTransitions.anyFrame.qualifiers) { + if (frameTransitionData[qualifier]) { + transitionQualifiers.push(qualifier); + } + } + + if (isTopLevelFrame(dst)) { + for (let type of frameTransitions.topFrame.types) { + if (frameTransitionData[type]) { + transitionType = type; + } + } + + for (let qualifier of tabTransitions.topFrame.qualifiers) { + if (tabTransitionData[qualifier]) { + transitionQualifiers.push(qualifier); + } + } + + for (let type of tabTransitions.topFrame.types) { + if (tabTransitionData[type]) { + transitionType = type; + } + } + + // If transitionType is not defined, defaults it to "link". + if (!transitionType) { + transitionType = defaultTransitionTypes.topFrame; + } + } else { + // If it is sub-frame, transitionType defaults it to "auto_subframe", + // "manual_subframe" is set only in case of a recent user interaction. + transitionType = tabTransitionData.link + ? "manual_subframe" + : defaultTransitionTypes.subFrame; + } + + // Fill the transition properties in the webNavigation event object. + dst.transitionType = transitionType; + dst.transitionQualifiers = transitionQualifiers; + } +}; + +this.webNavigation = class extends ExtensionAPIPersistent { + makeEventHandler(event) { + let { extension } = this; + let { tabManager } = extension; + return ({ fire }, params) => { + // Don't create a MatchURLFilters instance if the listener does not include any filter. + let [urlFilters] = params; + let filters = urlFilters ? new MatchURLFilters(urlFilters.url) : null; + + let listener = data => { + if (!data.browser) { + return; + } + if ( + !extension.privateBrowsingAllowed && + PrivateBrowsingUtils.isBrowserPrivate(data.browser) + ) { + return; + } + if (filters && !filters.matches(data.url)) { + return; + } + + let data2 = { + url: data.url, + timeStamp: Date.now(), + }; + + if (event == "onErrorOccurred") { + data2.error = data.error; + } + + if (data.frameId != undefined) { + data2.frameId = data.frameId; + data2.parentFrameId = data.parentFrameId; + } + + if (data.sourceFrameId != undefined) { + data2.sourceFrameId = data.sourceFrameId; + } + + // Do not send a webNavigation event when the data.browser is related to a tab from a + // new window opened to adopt an existent tab (See Bug 1443221 for a rationale). + const chromeWin = data.browser.ownerGlobal; + + if ( + chromeWin && + chromeWin.gBrowser && + chromeWin.gBrowserInit && + chromeWin.gBrowserInit.isAdoptingTab() && + chromeWin.gBrowser.selectedBrowser === data.browser + ) { + return; + } + + // Fills in tabId typically. + Object.assign(data2, tabTracker.getBrowserData(data.browser)); + if (data2.tabId < 0) { + return; + } + let tab = tabTracker.getTab(data2.tabId); + if (!tabManager.canAccessTab(tab)) { + return; + } + + if (data.sourceTabBrowser) { + data2.sourceTabId = tabTracker.getBrowserData( + data.sourceTabBrowser + ).tabId; + } + + fillTransitionProperties(event, data, data2); + + fire.async(data2); + }; + + WebNavigation[event].addListener(listener); + return { + unregister() { + WebNavigation[event].removeListener(listener); + }, + convert(_fire) { + fire = _fire; + }, + }; + }; + } + + makeEventManagerAPI(event, context) { + let self = this; + return new EventManager({ + context, + module: "webNavigation", + event, + register(fire, ...params) { + let fn = self.makeEventHandler(event); + return fn({ fire }, params).unregister; + }, + }).api(); + } + + PERSISTENT_EVENTS = { + onBeforeNavigate: this.makeEventHandler("onBeforeNavigate"), + onCommitted: this.makeEventHandler("onCommitted"), + onDOMContentLoaded: this.makeEventHandler("onDOMContentLoaded"), + onCompleted: this.makeEventHandler("onCompleted"), + onErrorOccurred: this.makeEventHandler("onErrorOccurred"), + onReferenceFragmentUpdated: this.makeEventHandler( + "onReferenceFragmentUpdated" + ), + onHistoryStateUpdated: this.makeEventHandler("onHistoryStateUpdated"), + onCreatedNavigationTarget: this.makeEventHandler( + "onCreatedNavigationTarget" + ), + }; + + getAPI(context) { + let { extension } = context; + let { tabManager } = extension; + + return { + webNavigation: { + // onTabReplaced does nothing, it exists for compat. + onTabReplaced: new EventManager({ + context, + name: "webNavigation.onTabReplaced", + register: fire => { + return () => {}; + }, + }).api(), + onBeforeNavigate: this.makeEventManagerAPI("onBeforeNavigate", context), + onCommitted: this.makeEventManagerAPI("onCommitted", context), + onDOMContentLoaded: this.makeEventManagerAPI( + "onDOMContentLoaded", + context + ), + onCompleted: this.makeEventManagerAPI("onCompleted", context), + onErrorOccurred: this.makeEventManagerAPI("onErrorOccurred", context), + onReferenceFragmentUpdated: this.makeEventManagerAPI( + "onReferenceFragmentUpdated", + context + ), + onHistoryStateUpdated: this.makeEventManagerAPI( + "onHistoryStateUpdated", + context + ), + onCreatedNavigationTarget: this.makeEventManagerAPI( + "onCreatedNavigationTarget", + context + ), + getAllFrames({ tabId }) { + let tab = tabManager.get(tabId); + if (tab.discarded) { + return null; + } + let frames = WebNavigationFrames.getAllFrames(tab.browsingContext); + return frames.map(fd => ({ tabId, ...fd })); + }, + getFrame({ tabId, frameId }) { + let tab = tabManager.get(tabId); + if (tab.discarded) { + return null; + } + let fd = WebNavigationFrames.getFrame(tab.browsingContext, frameId); + if (!fd) { + throw new ExtensionError(`No frame found with frameId: ${frameId}`); + } + return { tabId, ...fd }; + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-webRequest.js b/toolkit/components/extensions/parent/ext-webRequest.js new file mode 100644 index 0000000000..4f0ea90abd --- /dev/null +++ b/toolkit/components/extensions/parent/ext-webRequest.js @@ -0,0 +1,206 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + WebRequest: "resource://gre/modules/WebRequest.sys.mjs", +}); + +var { parseMatchPatterns } = ExtensionUtils; + +// The guts of a WebRequest event handler. Takes care of converting +// |details| parameter when invoking listeners. +function registerEvent( + extension, + eventName, + fire, + filter, + info, + remoteTab = null +) { + let listener = async data => { + let event = data.serialize(eventName); + if (data.registerTraceableChannel) { + // If this is a primed listener, no tabParent was passed in here, + // but the convert() callback later in this function will be called + // when the background page is started. Force that to happen here + // after which we'll have a valid tabParent. + if (fire.wakeup) { + await fire.wakeup(); + } + data.registerTraceableChannel(extension.policy, remoteTab); + } + + return fire.sync(event); + }; + + let filter2 = {}; + if (filter.urls) { + let perms = new MatchPatternSet([ + ...extension.allowedOrigins.patterns, + ...extension.optionalOrigins.patterns, + ]); + + filter2.urls = parseMatchPatterns(filter.urls); + + if (!perms.overlapsAll(filter2.urls)) { + Cu.reportError( + "The webRequest.addListener filter doesn't overlap with host permissions." + ); + } + } + if (filter.types) { + filter2.types = filter.types; + } + if (filter.tabId !== undefined) { + filter2.tabId = filter.tabId; + } + if (filter.windowId !== undefined) { + filter2.windowId = filter.windowId; + } + if (filter.incognito !== undefined) { + filter2.incognito = filter.incognito; + } + + let blockingAllowed = extension.hasPermission("webRequestBlocking"); + + let info2 = []; + if (info) { + for (let desc of info) { + if (desc == "blocking" && !blockingAllowed) { + // This is usually checked in the child process (based on the API schemas, where these options + // should be checked with the "webRequestBlockingPermissionRequired" postprocess property), + // but it is worth to also check it here just in case a new webRequest has been added and + // it has not yet using the expected postprocess property). + Cu.reportError( + "Using webRequest.addListener with the blocking option " + + "requires the 'webRequestBlocking' permission." + ); + } else { + info2.push(desc); + } + } + } + + let listenerDetails = { + addonId: extension.id, + policy: extension.policy, + blockingAllowed, + }; + WebRequest[eventName].addListener(listener, filter2, info2, listenerDetails); + + return { + unregister: () => { + WebRequest[eventName].removeListener(listener); + }, + convert(_fire, context) { + fire = _fire; + remoteTab = context.xulBrowser.frameLoader.remoteTab; + }, + }; +} + +function makeWebRequestEventAPI(context, event, extensionApi) { + return new EventManager({ + context, + module: "webRequest", + event, + extensionApi, + }).api(); +} + +function makeWebRequestEventRegistrar(event) { + return function ({ fire, context }, params) { + // ExtensionAPIPersistent makes sure this function will be bound + // to the ExtensionAPIPersistent instance. + const { extension } = this; + + const [filter, info] = params; + + // When we are registering the real listener coming from the extension context, + // we should get the additional remoteTab parameter value from the extension context + // (which is then used by the registerTraceableChannel helper to register stream + // filters to the channel and associate them to the extension context that has + // created it and will be handling the filter onstart/ondata/onend events). + let remoteTab; + if (context) { + remoteTab = context.xulBrowser.frameLoader.remoteTab; + } + + return registerEvent(extension, event, fire, filter, info, remoteTab); + }; +} + +this.webRequest = class extends ExtensionAPIPersistent { + primeListener(event, fire, params, isInStartup) { + // During early startup if the listener does not use blocking we do not prime it. + if (!isInStartup || params[1]?.includes("blocking")) { + return super.primeListener(event, fire, params, isInStartup); + } + } + + PERSISTENT_EVENTS = { + onBeforeRequest: makeWebRequestEventRegistrar("onBeforeRequest"), + onBeforeSendHeaders: makeWebRequestEventRegistrar("onBeforeSendHeaders"), + onSendHeaders: makeWebRequestEventRegistrar("onSendHeaders"), + onHeadersReceived: makeWebRequestEventRegistrar("onHeadersReceived"), + onAuthRequired: makeWebRequestEventRegistrar("onAuthRequired"), + onBeforeRedirect: makeWebRequestEventRegistrar("onBeforeRedirect"), + onResponseStarted: makeWebRequestEventRegistrar("onResponseStarted"), + onErrorOccurred: makeWebRequestEventRegistrar("onErrorOccurred"), + onCompleted: makeWebRequestEventRegistrar("onCompleted"), + }; + + getAPI(context) { + return { + webRequest: { + onBeforeRequest: makeWebRequestEventAPI( + context, + "onBeforeRequest", + this + ), + onBeforeSendHeaders: makeWebRequestEventAPI( + context, + "onBeforeSendHeaders", + this + ), + onSendHeaders: makeWebRequestEventAPI(context, "onSendHeaders", this), + onHeadersReceived: makeWebRequestEventAPI( + context, + "onHeadersReceived", + this + ), + onAuthRequired: makeWebRequestEventAPI(context, "onAuthRequired", this), + onBeforeRedirect: makeWebRequestEventAPI( + context, + "onBeforeRedirect", + this + ), + onResponseStarted: makeWebRequestEventAPI( + context, + "onResponseStarted", + this + ), + onErrorOccurred: makeWebRequestEventAPI( + context, + "onErrorOccurred", + this + ), + onCompleted: makeWebRequestEventAPI(context, "onCompleted", this), + getSecurityInfo: function (requestId, options = {}) { + return WebRequest.getSecurityInfo({ + id: requestId, + policy: context.extension.policy, + remoteTab: context.xulBrowser.frameLoader.remoteTab, + options, + }); + }, + handlerBehaviorChanged: function () { + // TODO: Flush all caches. + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/schemas/LICENSE-CHROMIUM b/toolkit/components/extensions/schemas/LICENSE-CHROMIUM new file mode 100644 index 0000000000..9314092fdc --- /dev/null +++ b/toolkit/components/extensions/schemas/LICENSE-CHROMIUM @@ -0,0 +1,27 @@ +// Copyright (c) 2006-2008 The Chromium Authors. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/toolkit/components/extensions/schemas/README.md b/toolkit/components/extensions/schemas/README.md new file mode 100644 index 0000000000..790fcd648e --- /dev/null +++ b/toolkit/components/extensions/schemas/README.md @@ -0,0 +1,13 @@ +This source code is available under the [Mozilla Public License 2.0](/LICENSE). + +Additionally, parts of the schema files originated from Chromium source code: + +> Copyright (c) 2012 The Chromium Authors. All rights reserved. +> Use of this source code is governed by a BSD-style license that can be +> found in the [LICENSE-CHROMIUM](LICENSE-CHROMIUM) file. + +You are not granted rights or licenses to the trademarks of the +Mozilla Foundation or any party, including without limitation the +Firefox name or logo. + +For more information, see: https://www.mozilla.org/foundation/licensing.html diff --git a/toolkit/components/extensions/schemas/activity_log.json b/toolkit/components/extensions/schemas/activity_log.json new file mode 100644 index 0000000000..7f60817539 --- /dev/null +++ b/toolkit/components/extensions/schemas/activity_log.json @@ -0,0 +1,101 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "PermissionPrivileged", + "choices": [ + { + "type": "string", + "enum": ["activityLog"] + } + ] + } + ] + }, + { + "namespace": "activityLog", + "description": "Monitor extension activity", + "permissions": ["activityLog"], + "events": [ + { + "name": "onExtensionActivity", + "description": "Receives an activityItem for each logging event.", + "type": "function", + "parameters": [ + { + "name": "details", + "type": "object", + "properties": { + "timeStamp": { + "$ref": "extensionTypes.Date", + "description": "The date string when this call is triggered." + }, + "type": { + "type": "string", + "enum": [ + "api_call", + "api_event", + "content_script", + "user_script" + ], + "description": "The type of log entry. api_call is a function call made by the extension and api_event is an event callback to the extension. content_script is logged when a content script is injected." + }, + "viewType": { + "type": "string", + "optional": true, + "enum": [ + "background", + "popup", + "sidebar", + "tab", + "devtools_page", + "devtools_panel" + ], + "description": "The type of view where the activity occurred. Content scripts will not have a viewType." + }, + "name": { + "type": "string", + "description": "The name of the api call or event, or the script url if this is a content or user script event." + }, + "data": { + "type": "object", + "properties": { + "args": { + "type": "array", + "optional": true, + "items": { + "type": "any" + }, + "description": "A list of arguments passed to the call." + }, + "result": { + "type": "object", + "optional": true, + "description": "The result of the call." + }, + "tabId": { + "type": "integer", + "optional": true, + "description": "The tab associated with this event if it is a tab or content script." + }, + "url": { + "type": "string", + "optional": true, + "description": "If the type is content_script, this is the url of the script that was injected." + } + } + } + } + } + ], + "extraParameters": [ + { + "name": "id", + "type": "string" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/alarms.json b/toolkit/components/extensions/schemas/alarms.json new file mode 100644 index 0000000000..852001aa6c --- /dev/null +++ b/toolkit/components/extensions/schemas/alarms.json @@ -0,0 +1,166 @@ +[ + { + "namespace": "alarms", + "permissions": ["alarms"], + "types": [ + { + "id": "Alarm", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of this alarm." + }, + "scheduledTime": { + "type": "number", + "description": "Time when the alarm is scheduled to fire, in milliseconds past the epoch." + }, + "periodInMinutes": { + "type": "number", + "optional": true, + "description": "When present, signals that the alarm triggers periodically after so many minutes." + } + } + } + ], + "functions": [ + { + "name": "create", + "type": "function", + "description": "Creates an alarm. After the delay is expired, the onAlarm event is fired. If there is another alarm with the same name (or no name if none is specified), it will be cancelled and replaced by this alarm.", + "parameters": [ + { + "type": "string", + "name": "name", + "optional": true, + "description": "Optional name to identify this alarm. Defaults to the empty string." + }, + { + "type": "object", + "name": "alarmInfo", + "description": "Details about the alarm. The alarm first fires either at 'when' milliseconds past the epoch (if 'when' is provided), after 'delayInMinutes' minutes from the current time (if 'delayInMinutes' is provided instead), or after 'periodInMinutes' minutes from the current time (if only 'periodInMinutes' is provided). Users should never provide both 'when' and 'delayInMinutes'. If 'periodInMinutes' is provided, then the alarm recurs repeatedly after that many minutes.", + "properties": { + "when": { + "type": "number", + "optional": true, + "description": "Time when the alarm is scheduled to first fire, in milliseconds past the epoch." + }, + "delayInMinutes": { + "type": "number", + "optional": true, + "description": "Number of minutes from the current time after which the alarm should first fire." + }, + "periodInMinutes": { + "type": "number", + "optional": true, + "description": "Number of minutes after which the alarm should recur repeatedly." + } + } + } + ] + }, + { + "name": "get", + "type": "function", + "description": "Retrieves details about the specified alarm.", + "async": "callback", + "parameters": [ + { + "type": "string", + "name": "name", + "optional": true, + "description": "The name of the alarm to get. Defaults to the empty string." + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "alarm", + "$ref": "Alarm", + "optional": true + } + ] + } + ] + }, + { + "name": "getAll", + "type": "function", + "description": "Gets an array of all the alarms.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "alarms", + "type": "array", + "items": { "$ref": "Alarm" } + } + ] + } + ] + }, + { + "name": "clear", + "type": "function", + "description": "Clears the alarm with the given name.", + "async": "callback", + "parameters": [ + { + "type": "string", + "name": "name", + "optional": true, + "description": "The name of the alarm to clear. Defaults to the empty string." + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "wasCleared", + "type": "boolean", + "description": "Whether an alarm of the given name was found to clear." + } + ] + } + ] + }, + { + "name": "clearAll", + "type": "function", + "description": "Clears all alarms.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "wasCleared", + "type": "boolean", + "description": "Whether any alarm was found to clear." + } + ] + } + ] + } + ], + "events": [ + { + "name": "onAlarm", + "type": "function", + "description": "Fired when an alarm has expired. Useful for transient background pages.", + "parameters": [ + { + "name": "name", + "$ref": "Alarm", + "description": "The alarm that has expired." + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/browser_action.json b/toolkit/components/extensions/schemas/browser_action.json new file mode 100644 index 0000000000..9813d930f7 --- /dev/null +++ b/toolkit/components/extensions/schemas/browser_action.json @@ -0,0 +1,530 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "id": "ActionManifest", + "type": "object", + "additionalProperties": { "$ref": "UnrecognizedProperty" }, + "properties": { + "default_title": { + "type": "string", + "optional": true, + "preprocess": "localize" + }, + "default_icon": { + "$ref": "IconPath", + "optional": true + }, + "theme_icons": { + "type": "array", + "optional": true, + "minItems": 1, + "items": { "$ref": "ThemeIcons" }, + "description": "Specifies icons to use for dark and light themes" + }, + "default_popup": { + "type": "string", + "format": "relativeUrl", + "optional": true, + "preprocess": "localize" + }, + "browser_style": { + "type": "boolean", + "optional": true, + "description": "Deprecated in Manifest V3." + }, + "default_area": { + "description": "Defines the location the browserAction will appear by default. The default location is navbar.", + "type": "string", + "enum": ["navbar", "menupanel", "tabstrip", "personaltoolbar"], + "optional": true + } + } + }, + { + "$extend": "WebExtensionManifest", + "properties": { + "action": { + "min_manifest_version": 3, + "$ref": "ActionManifest", + "optional": true + } + } + }, + { + "$extend": "WebExtensionManifest", + "properties": { + "browser_action": { + "max_manifest_version": 2, + "$ref": "ActionManifest", + "optional": true + } + } + } + ] + }, + { + "namespace": "action", + "description": "Use browser actions to put icons in the main browser toolbar, to the right of the address bar. In addition to its icon, a browser action can also have a tooltip, a badge, and a popup.", + "permissions": ["manifest:action", "manifest:browser_action"], + "min_manifest_version": 3, + "types": [ + { + "id": "Details", + "type": "object", + "description": "Specifies to which tab or window the value should be set, or from which one it should be retrieved. If no tab nor window is specified, the global value is set or retrieved.", + "properties": { + "tabId": { + "type": "integer", + "optional": true, + "minimum": 0, + "description": "When setting a value, it will be specific to the specified tab, and will automatically reset when the tab navigates. When getting, specifies the tab to get the value from; if there is no tab-specific value, the window one will be inherited." + }, + "windowId": { + "type": "integer", + "optional": true, + "minimum": -2, + "description": "When setting a value, it will be specific to the specified window. When getting, specifies the window to get the value from; if there is no window-specific value, the global one will be inherited." + } + } + }, + { + "id": "ColorArray", + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 4, + "maxItems": 4 + }, + { + "id": "ImageDataType", + "type": "object", + "isInstanceOf": "ImageData", + "additionalProperties": { "type": "any" }, + "postprocess": "convertImageDataToURL", + "description": "Pixel data for an image. Must be an ImageData object (for example, from a <code>canvas</code> element)." + }, + { + "id": "ColorValue", + "description": "An array of four integers in the range [0,255] that make up the RGBA color of the badge. For example, opaque red is <code>[255, 0, 0, 255]</code>. Can also be a string with a CSS value, with opaque red being <code>#FF0000</code> or <code>#F00</code>.", + "choices": [ + { "type": "string" }, + { "$ref": "ColorArray" }, + { "type": "null" } + ] + }, + { + "id": "OnClickData", + "type": "object", + "description": "Information sent when a browser action is clicked.", + "properties": { + "modifiers": { + "type": "array", + "items": { + "type": "string", + "enum": ["Shift", "Alt", "Command", "Ctrl", "MacCtrl"] + }, + "description": "An array of keyboard modifiers that were held while the menu item was clicked." + }, + "button": { + "type": "integer", + "optional": true, + "description": "An integer value of button by which menu item was clicked." + } + } + } + ], + "functions": [ + { + "name": "setTitle", + "type": "function", + "description": "Sets the title of the browser action. This shows up in the tooltip.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "$import": "Details", + "properties": { + "title": { + "choices": [{ "type": "string" }, { "type": "null" }], + "description": "The string the browser action should display when moused over." + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "getTitle", + "type": "function", + "description": "Gets the title of the browser action.", + "async": "callback", + "parameters": [ + { + "name": "details", + "$ref": "Details" + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "result", + "type": "string" + } + ] + } + ] + }, + { + "name": "getUserSettings", + "type": "function", + "description": "Returns the user-specified settings relating to an extension's action.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "userSettings", + "type": "object", + "properties": { + "isOnToolbar": { + "type": "boolean", + "optional": true, + "description": "Whether the extension's action icon is visible on browser windows' top-level toolbar (i.e., whether the extension has been 'pinned' by the user)." + } + }, + "description": "The collection of user-specified settings relating to an extension's action." + } + ] + } + ] + }, + { + "name": "setIcon", + "type": "function", + "description": "Sets the icon for the browser action. The icon can be specified either as the path to an image file or as the pixel data from a canvas element, or as dictionary of either one of those. Either the <b>path</b> or the <b>imageData</b> property must be specified.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "$import": "Details", + "properties": { + "imageData": { + "choices": [ + { "$ref": "ImageDataType" }, + { + "type": "object", + "patternProperties": { + "^[1-9]\\d*$": { "$ref": "ImageDataType" } + } + } + ], + "optional": true, + "description": "Either an ImageData object or a dictionary {size -> ImageData} representing icon to be set. If the icon is specified as a dictionary, the actual image to be used is chosen depending on screen's pixel density. If the number of image pixels that fit into one screen space unit equals <code>scale</code>, then image with size <code>scale</code> * 19 will be selected. Initially only scales 1 and 2 will be supported. At least one image must be specified. Note that 'details.imageData = foo' is equivalent to 'details.imageData = {'19': foo}'" + }, + "path": { + "choices": [ + { "type": "string" }, + { + "type": "object", + "patternProperties": { + "^[1-9]\\d*$": { "type": "string" } + } + } + ], + "optional": true, + "description": "Either a relative image path or a dictionary {size -> relative image path} pointing to icon to be set. If the icon is specified as a dictionary, the actual image to be used is chosen depending on screen's pixel density. If the number of image pixels that fit into one screen space unit equals <code>scale</code>, then image with size <code>scale</code> * 19 will be selected. Initially only scales 1 and 2 will be supported. At least one image must be specified. Note that 'details.path = foo' is equivalent to 'details.imageData = {'19': foo}'" + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "setPopup", + "type": "function", + "description": "Sets the html document to be opened as a popup when the user clicks on the browser action's icon.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "$import": "Details", + "properties": { + "popup": { + "choices": [{ "type": "string" }, { "type": "null" }], + "description": "The html file to show in a popup. If set to the empty string (''), no popup is shown." + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "getPopup", + "type": "function", + "description": "Gets the html document set as the popup for this browser action.", + "async": "callback", + "parameters": [ + { + "name": "details", + "$ref": "Details" + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "result", + "type": "string" + } + ] + } + ] + }, + { + "name": "setBadgeText", + "type": "function", + "description": "Sets the badge text for the browser action. The badge is displayed on top of the icon.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "$import": "Details", + "properties": { + "text": { + "choices": [{ "type": "string" }, { "type": "null" }], + "description": "Any number of characters can be passed, but only about four can fit in the space." + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "getBadgeText", + "type": "function", + "description": "Gets the badge text of the browser action. If no tab nor window is specified is specified, the global badge text is returned.", + "async": "callback", + "parameters": [ + { + "name": "details", + "$ref": "Details" + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "result", + "type": "string" + } + ] + } + ] + }, + { + "name": "setBadgeBackgroundColor", + "type": "function", + "description": "Sets the background color for the badge.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "$import": "Details", + "properties": { + "color": { "$ref": "ColorValue" } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "getBadgeBackgroundColor", + "type": "function", + "description": "Gets the background color of the browser action badge.", + "async": "callback", + "parameters": [ + { + "name": "details", + "$ref": "Details" + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "result", + "$ref": "ColorArray" + } + ] + } + ] + }, + { + "name": "setBadgeTextColor", + "type": "function", + "description": "Sets the text color for the badge.", + "async": true, + "parameters": [ + { + "name": "details", + "type": "object", + "$import": "Details", + "properties": { + "color": { "$ref": "ColorValue" } + } + } + ] + }, + { + "name": "getBadgeTextColor", + "type": "function", + "description": "Gets the text color of the browser action badge.", + "async": true, + "parameters": [ + { + "name": "details", + "$ref": "Details" + } + ] + }, + { + "name": "enable", + "type": "function", + "description": "Enables the browser action for a tab. By default, browser actions are enabled.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "optional": true, + "name": "tabId", + "minimum": 0, + "description": "The id of the tab for which you want to modify the browser action." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "disable", + "type": "function", + "description": "Disables the browser action for a tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "optional": true, + "name": "tabId", + "minimum": 0, + "description": "The id of the tab for which you want to modify the browser action." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "isEnabled", + "type": "function", + "description": "Checks whether the browser action is enabled.", + "async": true, + "parameters": [ + { + "name": "details", + "$ref": "Details" + } + ] + }, + { + "name": "openPopup", + "type": "function", + "description": "Opens the extension popup window in the specified window.", + "async": true, + "parameters": [ + { + "name": "options", + "optional": true, + "type": "object", + "description": "An object with information about the popup to open.", + "properties": { + "windowId": { + "type": "integer", + "minimum": -2, + "optional": true, + "description": "Defaults to the $(topic:current-window)[current window]." + } + } + } + ] + } + ], + "events": [ + { + "name": "onClicked", + "type": "function", + "description": "Fired when a browser action icon is clicked. This event will not fire if the browser action has a popup.", + "parameters": [ + { + "name": "tab", + "$ref": "tabs.Tab" + }, + { + "name": "info", + "$ref": "OnClickData", + "optional": true + } + ] + } + ] + }, + { + "namespace": "browserAction", + "permissions": ["manifest:action", "manifest:browser_action"], + "max_manifest_version": 2, + "$import": "action" + } +] diff --git a/toolkit/components/extensions/schemas/browser_settings.json b/toolkit/components/extensions/schemas/browser_settings.json new file mode 100644 index 0000000000..0a584d5c3b --- /dev/null +++ b/toolkit/components/extensions/schemas/browser_settings.json @@ -0,0 +1,135 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermission", + "choices": [ + { + "type": "string", + "enum": ["browserSettings"] + } + ] + } + ] + }, + { + "namespace": "browserSettings", + "description": "Use the <code>browser.browserSettings</code> API to control global settings of the browser.", + "permissions": ["browserSettings"], + "types": [ + { + "id": "ImageAnimationBehavior", + "type": "string", + "enum": ["normal", "none", "once"], + "description": "How images should be animated in the browser." + }, + { + "id": "ContextMenuMouseEvent", + "type": "string", + "enum": ["mouseup", "mousedown"], + "description": "After which mouse event context menus should popup." + }, + { + "id": "ColorManagementMode", + "type": "string", + "enum": ["off", "full", "tagged_only"], + "description": "Color management mode." + } + ], + "properties": { + "allowPopupsForUserEvents": { + "$ref": "types.Setting", + "description": "Allows or disallows pop-up windows from opening in response to user events." + }, + "cacheEnabled": { + "$ref": "types.Setting", + "description": "Enables or disables the browser cache." + }, + "closeTabsByDoubleClick": { + "$ref": "types.Setting", + "description": "This boolean setting controls whether the selected tab can be closed with a double click." + }, + "contextMenuShowEvent": { + "$ref": "types.Setting", + "description": "Controls after which mouse event context menus popup. This setting's value is of type ContextMenuMouseEvent, which has possible values of <code>mouseup</code> and <code>mousedown</code>." + }, + "ftpProtocolEnabled": { + "$ref": "types.Setting", + "description": "Returns whether the FTP protocol is enabled. Read-only.", + "deprecated": "FTP support was removed from Firefox in bug 1574475" + }, + "homepageOverride": { + "$ref": "types.Setting", + "description": "Returns the value of the overridden home page. Read-only." + }, + "imageAnimationBehavior": { + "$ref": "types.Setting", + "description": "Controls the behaviour of image animation in the browser. This setting's value is of type ImageAnimationBehavior, defaulting to <code>normal</code>." + }, + "newTabPageOverride": { + "$ref": "types.Setting", + "description": "Returns the value of the overridden new tab page. Read-only." + }, + "newTabPosition": { + "$ref": "types.Setting", + "description": "Controls where new tabs are opened. `afterCurrent` will open all new tabs next to the current tab, `relatedAfterCurrent` will open only related tabs next to the current tab, and `atEnd` will open all tabs at the end of the tab strip. The default is `relatedAfterCurrent`." + }, + "openBookmarksInNewTabs": { + "$ref": "types.Setting", + "description": "This boolean setting controls whether bookmarks are opened in the current tab or in a new tab." + }, + "openSearchResultsInNewTabs": { + "$ref": "types.Setting", + "description": "This boolean setting controls whether search results are opened in the current tab or in a new tab." + }, + "openUrlbarResultsInNewTabs": { + "$ref": "types.Setting", + "description": "This boolean setting controls whether urlbar results are opened in the current tab or in a new tab." + }, + "webNotificationsDisabled": { + "$ref": "types.Setting", + "description": "Disables webAPI notifications." + }, + "overrideDocumentColors": { + "$ref": "types.Setting", + "description": "This setting controls whether the user-chosen colors override the page's colors." + }, + "overrideContentColorScheme": { + "$ref": "types.Setting", + "description": "This setting controls whether a light or dark color scheme overrides the page's preferred color scheme." + }, + "useDocumentFonts": { + "$ref": "types.Setting", + "description": "This setting controls whether the document's fonts are used." + }, + "zoomFullPage": { + "$ref": "types.Setting", + "description": "This boolean setting controls whether zoom is applied to the full page or to text only." + }, + "zoomSiteSpecific": { + "$ref": "types.Setting", + "description": "This boolean setting controls whether zoom is applied on a per-site basis or to the current tab only. If privacy.resistFingerprinting is true, this setting has no effect and zoom is applied to the current tab only." + } + } + }, + { + "namespace": "browserSettings.colorManagement", + "description": "Use the <code>browserSettings.colorManagement</code> API to query and set items related to color management.", + "permissions": ["browserSettings"], + "properties": { + "mode": { + "$ref": "types.Setting", + "description": "This setting controls the mode used for color management and must be a string from $(ref:browserSettings.ColorManagementMode)" + }, + "useNativeSRGB": { + "$ref": "types.Setting", + "description": "This boolean setting controls whether or not native sRGB color management is used." + }, + "useWebRenderCompositor": { + "$ref": "types.Setting", + "description": "This boolean setting controls whether or not the WebRender compositor is used." + } + } + } +] diff --git a/toolkit/components/extensions/schemas/browsing_data.json b/toolkit/components/extensions/schemas/browsing_data.json new file mode 100644 index 0000000000..ca6754aec3 --- /dev/null +++ b/toolkit/components/extensions/schemas/browsing_data.json @@ -0,0 +1,419 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermission", + "choices": [ + { + "type": "string", + "enum": ["browsingData"] + } + ] + } + ] + }, + { + "namespace": "browsingData", + "description": "Use the <code>chrome.browsingData</code> API to remove browsing data from a user's local profile.", + "permissions": ["browsingData"], + "types": [ + { + "id": "RemovalOptions", + "type": "object", + "description": "Options that determine exactly what data will be removed.", + "properties": { + "since": { + "$ref": "extensionTypes.Date", + "optional": true, + "description": "Remove data accumulated on or after this date, represented in milliseconds since the epoch (accessible via the <code>getTime</code> method of the JavaScript <code>Date</code> object). If absent, defaults to 0 (which would remove all browsing data)." + }, + "hostnames": { + "type": "array", + "items": { "type": "string", "format": "hostname" }, + "optional": true, + "description": "Only remove data associated with these hostnames (only applies to cookies and localStorage)." + }, + "cookieStoreId": { + "type": "string", + "description": "Only remove data associated with this specific cookieStoreId.", + "optional": true + }, + "originTypes": { + "type": "object", + "optional": true, + "description": "An object whose properties specify which origin types ought to be cleared. If this object isn't specified, it defaults to clearing only \"unprotected\" origins. Please ensure that you <em>really</em> want to remove application data before adding 'protectedWeb' or 'extensions'.", + "properties": { + "unprotectedWeb": { + "type": "boolean", + "optional": true, + "description": "Normal websites." + }, + "protectedWeb": { + "type": "boolean", + "optional": true, + "description": "Websites that have been installed as hosted applications (be careful!)." + }, + "extension": { + "type": "boolean", + "optional": true, + "description": "Extensions and packaged applications a user has installed (be _really_ careful!)." + } + } + } + } + }, + { + "id": "DataTypeSet", + "type": "object", + "description": "A set of data types. Missing data types are interpreted as <code>false</code>.", + "properties": { + "cache": { + "type": "boolean", + "optional": true, + "description": "The browser's cache. Note: when removing data, this clears the <em>entire</em> cache: it is not limited to the range you specify." + }, + "cookies": { + "type": "boolean", + "optional": true, + "description": "The browser's cookies." + }, + "downloads": { + "type": "boolean", + "optional": true, + "description": "The browser's download list." + }, + "formData": { + "type": "boolean", + "optional": true, + "description": "The browser's stored form data." + }, + "history": { + "type": "boolean", + "optional": true, + "description": "The browser's history." + }, + "indexedDB": { + "type": "boolean", + "optional": true, + "description": "Websites' IndexedDB data." + }, + "localStorage": { + "type": "boolean", + "optional": true, + "description": "Websites' local storage data." + }, + "serverBoundCertificates": { + "type": "boolean", + "optional": true, + "description": "Server-bound certificates." + }, + "passwords": { + "type": "boolean", + "optional": true, + "description": "Stored passwords." + }, + "pluginData": { + "type": "boolean", + "optional": true, + "description": "Plugins' data." + }, + "serviceWorkers": { + "type": "boolean", + "optional": true, + "description": "Service Workers." + } + } + } + ], + "functions": [ + { + "name": "settings", + "description": "Reports which types of data are currently selected in the 'Clear browsing data' settings UI. Note: some of the data types included in this API are not available in the settings UI, and some UI settings control more than one data type listed here.", + "type": "function", + "async": "callback", + "parameters": [ + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "result", + "type": "object", + "properties": { + "options": { + "$ref": "RemovalOptions" + }, + "dataToRemove": { + "$ref": "DataTypeSet", + "description": "All of the types will be present in the result, with values of <code>true</code> if they are both selected to be removed and permitted to be removed, otherwise <code>false</code>." + }, + "dataRemovalPermitted": { + "$ref": "DataTypeSet", + "description": "All of the types will be present in the result, with values of <code>true</code> if they are permitted to be removed (e.g., by enterprise policy) and <code>false</code> if not." + } + } + } + ] + } + ] + }, + { + "name": "remove", + "description": "Clears various types of browsing data stored in a user's profile.", + "type": "function", + "async": "callback", + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "dataToRemove", + "$ref": "DataTypeSet", + "description": "The set of data types to remove." + }, + { + "name": "callback", + "type": "function", + "description": "Called when deletion has completed.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeAppcache", + "description": "Clears websites' appcache data.", + "type": "function", + "async": "callback", + "unsupported": true, + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when websites' appcache data has been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeCache", + "description": "Clears the browser's cache.", + "type": "function", + "async": "callback", + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when the browser's cache has been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeCookies", + "description": "Clears the browser's cookies and server-bound certificates modified within a particular timeframe.", + "type": "function", + "async": "callback", + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when the browser's cookies and server-bound certificates have been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeDownloads", + "description": "Clears the browser's list of downloaded files (<em>not</em> the downloaded files themselves).", + "type": "function", + "async": "callback", + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when the browser's list of downloaded files has been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeFileSystems", + "description": "Clears websites' file system data.", + "type": "function", + "async": "callback", + "unsupported": true, + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when websites' file systems have been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeFormData", + "description": "Clears the browser's stored form data (autofill).", + "type": "function", + "async": "callback", + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when the browser's form data has been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeHistory", + "description": "Clears the browser's history.", + "type": "function", + "async": "callback", + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when the browser's history has cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeIndexedDB", + "description": "Clears websites' IndexedDB data.", + "type": "function", + "async": "callback", + "unsupported": true, + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when websites' IndexedDB data has been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeLocalStorage", + "description": "Clears websites' local storage data.", + "type": "function", + "async": "callback", + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when websites' local storage has been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removePluginData", + "description": "Clears plugins' data.", + "type": "function", + "async": "callback", + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when plugins' data has been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removePasswords", + "description": "Clears the browser's stored passwords.", + "type": "function", + "async": "callback", + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when the browser's passwords have been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeWebSQL", + "description": "Clears websites' WebSQL data.", + "type": "function", + "async": "callback", + "unsupported": true, + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when websites' WebSQL databases have been cleared.", + "optional": true, + "parameters": [] + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/captive_portal.json b/toolkit/components/extensions/schemas/captive_portal.json new file mode 100644 index 0000000000..fd697c66ae --- /dev/null +++ b/toolkit/components/extensions/schemas/captive_portal.json @@ -0,0 +1,80 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "PermissionNoPrompt", + "choices": [ + { + "type": "string", + "enum": ["captivePortal"] + } + ] + } + ] + }, + { + "namespace": "captivePortal", + "description": "This API provides the ability detect the captive portal state of the users connection.", + "permissions": ["captivePortal"], + "properties": { + "canonicalURL": { + "$ref": "types.Setting", + "description": "Return the canonical captive-portal detection URL. Read-only." + } + }, + "functions": [ + { + "name": "getState", + "type": "function", + "description": "Returns the current portal state, one of `unknown`, `not_captive`, `unlocked_portal`, `locked_portal`.", + "async": true, + "parameters": [] + }, + { + "name": "getLastChecked", + "type": "function", + "description": "Returns the time difference between NOW and the last time a request was completed in milliseconds.", + "async": true, + "parameters": [] + } + ], + "events": [ + { + "name": "onStateChanged", + "type": "function", + "description": "Fired when the captive portal state changes.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "state": { + "type": "string", + "enum": [ + "unknown", + "not_captive", + "unlocked_portal", + "locked_portal" + ], + "description": "The current captive portal state." + } + } + } + ] + }, + { + "name": "onConnectivityAvailable", + "type": "function", + "description": "This notification will be emitted when the captive portal service has determined that we can connect to the internet. The service will pass either `captive` if there is an unlocked captive portal present, or `clear` if no captive portal was detected.", + "parameters": [ + { + "name": "status", + "enum": ["captive", "clear"], + "type": "string" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/clipboard.json b/toolkit/components/extensions/schemas/clipboard.json new file mode 100644 index 0000000000..010a003c4c --- /dev/null +++ b/toolkit/components/extensions/schemas/clipboard.json @@ -0,0 +1,30 @@ +[ + { + "namespace": "clipboard", + "description": "Offers the ability to write to the clipboard. Reading is not supported because the clipboard can already be read through the standard web platform APIs.", + "permissions": ["clipboardWrite"], + "functions": [ + { + "name": "setImageData", + "type": "function", + "description": "Copy an image to the clipboard. The image is re-encoded before it is written to the clipboard. If the image is invalid, the clipboard is not modified.", + "async": true, + "parameters": [ + { + "type": "object", + "isInstanceOf": "ArrayBuffer", + "additionalProperties": true, + "name": "imageData", + "description": "The image data to be copied." + }, + { + "type": "string", + "name": "imageType", + "enum": ["jpeg", "png"], + "description": "The type of imageData." + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/content_scripts.json b/toolkit/components/extensions/schemas/content_scripts.json new file mode 100644 index 0000000000..a45dc30918 --- /dev/null +++ b/toolkit/components/extensions/schemas/content_scripts.json @@ -0,0 +1,106 @@ +[ + { + "namespace": "contentScripts", + "max_manifest_version": 2, + "types": [ + { + "id": "RegisteredContentScriptOptions", + "type": "object", + "description": "Details of a content script registered programmatically", + "properties": { + "matches": { + "type": "array", + "optional": false, + "minItems": 1, + "items": { "$ref": "manifest.MatchPattern" } + }, + "excludeMatches": { + "type": "array", + "optional": true, + "minItems": 1, + "items": { "$ref": "manifest.MatchPattern" } + }, + "includeGlobs": { + "type": "array", + "optional": true, + "items": { "type": "string" } + }, + "excludeGlobs": { + "type": "array", + "optional": true, + "items": { "type": "string" } + }, + "css": { + "type": "array", + "optional": true, + "description": "The list of CSS files to inject", + "items": { "$ref": "extensionTypes.ExtensionFileOrCode" } + }, + "js": { + "type": "array", + "optional": true, + "description": "The list of JS files to inject", + "items": { "$ref": "extensionTypes.ExtensionFileOrCode" } + }, + "allFrames": { + "type": "boolean", + "optional": true, + "description": "If allFrames is <code>true</code>, implies that the JavaScript or CSS should be injected into all frames of current page. By default, it's <code>false</code> and is only injected into the top frame." + }, + "matchAboutBlank": { + "type": "boolean", + "optional": true, + "description": "If matchAboutBlank is true, then the code is also injected in about:blank and about:srcdoc frames if your extension has access to its parent document. Code cannot be inserted in top-level about:-frames. By default it is <code>false</code>." + }, + "runAt": { + "$ref": "extensionTypes.RunAt", + "optional": true, + "description": "The soonest that the JavaScript or CSS will be injected into the tab. Defaults to \"document_idle\"." + }, + "cookieStoreId": { + "choices": [ + { + "type": "array", + "minItems": 1, + "items": { "type": "string" } + }, + { + "type": "string" + } + ], + "optional": true, + "description": "limit the set of matched tabs to those that belong to the given cookie store id" + } + } + }, + { + "id": "RegisteredContentScript", + "type": "object", + "description": "An object that represents a content script registered programmatically", + "functions": [ + { + "name": "unregister", + "type": "function", + "description": "Unregister a content script registered programmatically", + "async": true, + "parameters": [] + } + ] + } + ], + "functions": [ + { + "name": "register", + "type": "function", + "description": "Register a content script programmatically", + "async": true, + "parameters": [ + { + "name": "contentScriptOptions", + "$ref": "RegisteredContentScriptOptions" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/contextual_identities.json b/toolkit/components/extensions/schemas/contextual_identities.json new file mode 100644 index 0000000000..315270ef20 --- /dev/null +++ b/toolkit/components/extensions/schemas/contextual_identities.json @@ -0,0 +1,241 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "PermissionNoPrompt", + "choices": [ + { + "type": "string", + "enum": ["contextualIdentities"] + } + ] + } + ] + }, + { + "namespace": "contextualIdentities", + "description": "Use the <code>browser.contextualIdentities</code> API to query and modify contextual identity, also called as containers.", + "permissions": ["contextualIdentities"], + "types": [ + { + "id": "ContextualIdentity", + "type": "object", + "description": "Represents information about a contextual identity.", + "properties": { + "name": { + "type": "string", + "description": "The name of the contextual identity." + }, + "icon": { + "type": "string", + "description": "The icon name of the contextual identity." + }, + "iconUrl": { + "type": "string", + "description": "The icon url of the contextual identity." + }, + "color": { + "type": "string", + "description": "The color name of the contextual identity." + }, + "colorCode": { + "type": "string", + "description": "The color hash of the contextual identity." + }, + "cookieStoreId": { + "type": "string", + "description": "The cookie store ID of the contextual identity." + } + } + } + ], + "functions": [ + { + "name": "get", + "type": "function", + "description": "Retrieves information about a single contextual identity.", + "async": true, + "parameters": [ + { + "type": "string", + "name": "cookieStoreId", + "description": "The ID of the contextual identity cookie store. " + } + ] + }, + { + "name": "query", + "type": "function", + "description": "Retrieves all contextual identities", + "async": true, + "parameters": [ + { + "type": "object", + "name": "details", + "description": "Information to filter the contextual identities being retrieved.", + "properties": { + "name": { + "type": "string", + "optional": true, + "description": "Filters the contextual identity by name." + } + } + } + ] + }, + { + "name": "create", + "type": "function", + "description": "Creates a contextual identity with the given data.", + "async": true, + "parameters": [ + { + "type": "object", + "name": "details", + "description": "Details about the contextual identity being created.", + "properties": { + "name": { + "type": "string", + "optional": false, + "description": "The name of the contextual identity." + }, + "color": { + "type": "string", + "optional": false, + "description": "The color of the contextual identity." + }, + "icon": { + "type": "string", + "optional": false, + "description": "The icon of the contextual identity." + } + } + } + ] + }, + { + "name": "update", + "type": "function", + "description": "Updates a contextual identity with the given data.", + "async": true, + "parameters": [ + { + "type": "string", + "name": "cookieStoreId", + "description": "The ID of the contextual identity cookie store. " + }, + { + "type": "object", + "name": "details", + "description": "Details about the contextual identity being created.", + "properties": { + "name": { + "type": "string", + "optional": true, + "description": "The name of the contextual identity." + }, + "color": { + "type": "string", + "optional": true, + "description": "The color of the contextual identity." + }, + "icon": { + "type": "string", + "optional": true, + "description": "The icon of the contextual identity." + } + } + } + ] + }, + { + "name": "move", + "type": "function", + "description": "Reorder one or more contextual identities by their cookieStoreIDs to a given position.", + "async": true, + "parameters": [ + { + "name": "cookieStoreIds", + "description": "The ID or list of IDs of the contextual identity cookie stores. ", + "choices": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ] + }, + { + "type": "integer", + "name": "position", + "description": "The position the contextual identity should move to." + } + ] + }, + { + "name": "remove", + "type": "function", + "description": "Deletes a contextual identity by its cookie Store ID.", + "async": true, + "parameters": [ + { + "type": "string", + "name": "cookieStoreId", + "description": "The ID of the contextual identity cookie store. " + } + ] + } + ], + "events": [ + { + "name": "onUpdated", + "type": "function", + "description": "Fired when a container is updated.", + "parameters": [ + { + "type": "object", + "name": "changeInfo", + "properties": { + "contextualIdentity": { + "$ref": "ContextualIdentity", + "description": "Contextual identity that has been updated" + } + } + } + ] + }, + { + "name": "onCreated", + "type": "function", + "description": "Fired when a new container is created.", + "parameters": [ + { + "type": "object", + "name": "changeInfo", + "properties": { + "contextualIdentity": { + "$ref": "ContextualIdentity", + "description": "Contextual identity that has been created" + } + } + } + ] + }, + { + "name": "onRemoved", + "type": "function", + "description": "Fired when a container is removed.", + "parameters": [ + { + "type": "object", + "name": "changeInfo", + "properties": { + "contextualIdentity": { + "$ref": "ContextualIdentity", + "description": "Contextual identity that has been removed" + } + } + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/cookies.json b/toolkit/components/extensions/schemas/cookies.json new file mode 100644 index 0000000000..2706cbde3d --- /dev/null +++ b/toolkit/components/extensions/schemas/cookies.json @@ -0,0 +1,467 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermissionNoPrompt", + "choices": [ + { + "type": "string", + "enum": ["cookies"] + } + ] + } + ] + }, + { + "namespace": "cookies", + "description": "Use the <code>browser.cookies</code> API to query and modify cookies, and to be notified when they change.", + "permissions": ["cookies"], + "types": [ + { + "id": "SameSiteStatus", + "type": "string", + "enum": ["no_restriction", "lax", "strict"], + "description": "A cookie's 'SameSite' state (https://tools.ietf.org/html/draft-west-first-party-cookies). 'no_restriction' corresponds to a cookie set without a 'SameSite' attribute, 'lax' to 'SameSite=Lax', and 'strict' to 'SameSite=Strict'." + }, + { + "id": "PartitionKey", + "type": "object", + "description": "The description of the storage partition of a cookie. This object may be omitted (null) if a cookie is not partitioned.", + "properties": { + "topLevelSite": { + "type": "string", + "optional": true, + "description": "The first-party URL of the cookie, if the cookie is in storage partitioned by the top-level site." + } + } + }, + { + "id": "Cookie", + "type": "object", + "description": "Represents information about an HTTP cookie.", + "properties": { + "name": { + "type": "string", + "description": "The name of the cookie." + }, + "value": { + "type": "string", + "description": "The value of the cookie." + }, + "domain": { + "type": "string", + "description": "The domain of the cookie (e.g. \"www.google.com\", \"example.com\")." + }, + "hostOnly": { + "type": "boolean", + "description": "True if the cookie is a host-only cookie (i.e. a request's host must exactly match the domain of the cookie)." + }, + "path": { + "type": "string", + "description": "The path of the cookie." + }, + "secure": { + "type": "boolean", + "description": "True if the cookie is marked as Secure (i.e. its scope is limited to secure channels, typically HTTPS)." + }, + "httpOnly": { + "type": "boolean", + "description": "True if the cookie is marked as HttpOnly (i.e. the cookie is inaccessible to client-side scripts)." + }, + "sameSite": { + "$ref": "SameSiteStatus", + "description": "The cookie's same-site status (i.e. whether the cookie is sent with cross-site requests)." + }, + "session": { + "type": "boolean", + "description": "True if the cookie is a session cookie, as opposed to a persistent cookie with an expiration date." + }, + "expirationDate": { + "type": "number", + "optional": true, + "description": "The expiration date of the cookie as the number of seconds since the UNIX epoch. Not provided for session cookies." + }, + "storeId": { + "type": "string", + "description": "The ID of the cookie store containing this cookie, as provided in getAllCookieStores()." + }, + "firstPartyDomain": { + "type": "string", + "description": "The first-party domain of the cookie." + }, + "partitionKey": { + "$ref": "PartitionKey", + "optional": true, + "description": "The cookie's storage partition, if any. null if not partitioned." + } + } + }, + { + "id": "CookieStore", + "type": "object", + "description": "Represents a cookie store in the browser. An incognito mode window, for instance, uses a separate cookie store from a non-incognito window.", + "properties": { + "id": { + "type": "string", + "description": "The unique identifier for the cookie store." + }, + "tabIds": { + "type": "array", + "items": { "type": "integer" }, + "description": "Identifiers of all the browser tabs that share this cookie store." + }, + "incognito": { + "type": "boolean", + "description": "Indicates if this is an incognito cookie store" + } + } + }, + { + "id": "OnChangedCause", + "type": "string", + "enum": [ + "evicted", + "expired", + "explicit", + "expired_overwrite", + "overwrite" + ], + "description": "The underlying reason behind the cookie's change. If a cookie was inserted, or removed via an explicit call to $(ref:cookies.remove), \"cause\" will be \"explicit\". If a cookie was automatically removed due to expiry, \"cause\" will be \"expired\". If a cookie was removed due to being overwritten with an already-expired expiration date, \"cause\" will be set to \"expired_overwrite\". If a cookie was automatically removed due to garbage collection, \"cause\" will be \"evicted\". If a cookie was automatically removed due to a \"set\" call that overwrote it, \"cause\" will be \"overwrite\". Plan your response accordingly." + } + ], + "functions": [ + { + "name": "get", + "type": "function", + "description": "Retrieves information about a single cookie. If more than one cookie of the same name exists for the given URL, the one with the longest path will be returned. For cookies with the same path length, the cookie with the earliest creation time will be returned.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "details", + "description": "Details to identify the cookie being retrieved.", + "properties": { + "url": { + "type": "string", + "description": "The URL with which the cookie to retrieve is associated. This argument may be a full URL, in which case any data following the URL path (e.g. the query string) is simply ignored. If host permissions for this URL are not specified in the manifest file, the API call will fail." + }, + "name": { + "type": "string", + "description": "The name of the cookie to retrieve." + }, + "storeId": { + "type": "string", + "optional": true, + "description": "The ID of the cookie store in which to look for the cookie. By default, the current execution context's cookie store will be used." + }, + "firstPartyDomain": { + "type": "string", + "optional": true, + "description": "The first-party domain which the cookie to retrieve is associated. This attribute is required if First-Party Isolation is enabled." + }, + "partitionKey": { + "$ref": "PartitionKey", + "optional": true, + "description": "The storage partition, if the cookie is part of partitioned storage. By default, only non-partitioned cookies are returned." + } + } + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "cookie", + "$ref": "Cookie", + "optional": true, + "description": "Contains details about the cookie. This parameter is null if no such cookie was found." + } + ] + } + ] + }, + { + "name": "getAll", + "type": "function", + "description": "Retrieves all cookies from a single cookie store that match the given information. The cookies returned will be sorted, with those with the longest path first. If multiple cookies have the same path length, those with the earliest creation time will be first.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "details", + "description": "Information to filter the cookies being retrieved.", + "properties": { + "url": { + "type": "string", + "optional": true, + "description": "Restricts the retrieved cookies to those that would match the given URL." + }, + "name": { + "type": "string", + "optional": true, + "description": "Filters the cookies by name." + }, + "domain": { + "type": "string", + "optional": true, + "description": "Restricts the retrieved cookies to those whose domains match or are subdomains of this one." + }, + "path": { + "type": "string", + "optional": true, + "description": "Restricts the retrieved cookies to those whose path exactly matches this string." + }, + "secure": { + "type": "boolean", + "optional": true, + "description": "Filters the cookies by their Secure property." + }, + "session": { + "type": "boolean", + "optional": true, + "description": "Filters out session vs. persistent cookies." + }, + "storeId": { + "type": "string", + "optional": true, + "description": "The cookie store to retrieve cookies from. If omitted, the current execution context's cookie store will be used." + }, + "firstPartyDomain": { + "type": "string", + "optional": "omit-key-if-missing", + "description": "Restricts the retrieved cookies to those whose first-party domains match this one. This attribute is required if First-Party Isolation is enabled. To not filter by a specific first-party domain, use `null` or `undefined`." + }, + "partitionKey": { + "$ref": "PartitionKey", + "optional": true, + "description": "Selects a specific storage partition to look up cookies. Defaults to null, in which case only non-partitioned cookies are retrieved. If an object iis passed, partitioned cookies are also included, and filtered based on the keys present in the given PartitionKey description. An empty object ({}) returns all cookies (partitioned + unpartitioned), a non-empty object (e.g. {topLevelSite: '...'}) only returns cookies whose partition match all given attributes." + } + } + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "cookies", + "type": "array", + "items": { "$ref": "Cookie" }, + "description": "All the existing, unexpired cookies that match the given cookie info." + } + ] + } + ] + }, + { + "name": "set", + "type": "function", + "description": "Sets a cookie with the given cookie data; may overwrite equivalent cookies if they exist.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "details", + "description": "Details about the cookie being set.", + "properties": { + "url": { + "type": "string", + "description": "The request-URI to associate with the setting of the cookie. This value can affect the default domain and path values of the created cookie. If host permissions for this URL are not specified in the manifest file, the API call will fail." + }, + "name": { + "type": "string", + "optional": true, + "description": "The name of the cookie. Empty by default if omitted." + }, + "value": { + "type": "string", + "optional": true, + "description": "The value of the cookie. Empty by default if omitted." + }, + "domain": { + "type": "string", + "optional": true, + "description": "The domain of the cookie. If omitted, the cookie becomes a host-only cookie." + }, + "path": { + "type": "string", + "optional": true, + "description": "The path of the cookie. Defaults to the path portion of the url parameter." + }, + "secure": { + "type": "boolean", + "optional": true, + "description": "Whether the cookie should be marked as Secure. Defaults to false." + }, + "httpOnly": { + "type": "boolean", + "optional": true, + "description": "Whether the cookie should be marked as HttpOnly. Defaults to false." + }, + "sameSite": { + "$ref": "SameSiteStatus", + "optional": true, + "description": "The cookie's same-site status.", + "default": "no_restriction" + }, + "expirationDate": { + "type": "number", + "optional": true, + "description": "The expiration date of the cookie as the number of seconds since the UNIX epoch. If omitted, the cookie becomes a session cookie." + }, + "storeId": { + "type": "string", + "optional": true, + "description": "The ID of the cookie store in which to set the cookie. By default, the cookie is set in the current execution context's cookie store." + }, + "firstPartyDomain": { + "type": "string", + "optional": true, + "description": "The first-party domain of the cookie. This attribute is required if First-Party Isolation is enabled." + }, + "partitionKey": { + "$ref": "PartitionKey", + "optional": true, + "description": "The storage partition, if the cookie is part of partitioned storage. By default, non-partitioned storage is used." + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "cookie", + "$ref": "Cookie", + "optional": true, + "description": "Contains details about the cookie that's been set. If setting failed for any reason, this will be \"null\", and $(ref:runtime.lastError) will be set." + } + ] + } + ] + }, + { + "name": "remove", + "type": "function", + "description": "Deletes a cookie by name.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "details", + "description": "Information to identify the cookie to remove.", + "properties": { + "url": { + "type": "string", + "description": "The URL associated with the cookie. If host permissions for this URL are not specified in the manifest file, the API call will fail." + }, + "name": { + "type": "string", + "description": "The name of the cookie to remove." + }, + "storeId": { + "type": "string", + "optional": true, + "description": "The ID of the cookie store to look in for the cookie. If unspecified, the cookie is looked for by default in the current execution context's cookie store." + }, + "firstPartyDomain": { + "type": "string", + "optional": true, + "description": "The first-party domain associated with the cookie. This attribute is required if First-Party Isolation is enabled." + }, + "partitionKey": { + "$ref": "PartitionKey", + "optional": true, + "description": "The storage partition, if the cookie is part of partitioned storage. By default, non-partitioned storage is used." + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "details", + "type": "object", + "description": "Contains details about the cookie that's been removed. If removal failed for any reason, this will be \"null\", and $(ref:runtime.lastError) will be set.", + "optional": true, + "properties": { + "url": { + "type": "string", + "description": "The URL associated with the cookie that's been removed." + }, + "name": { + "type": "string", + "description": "The name of the cookie that's been removed." + }, + "storeId": { + "type": "string", + "description": "The ID of the cookie store from which the cookie was removed." + }, + "firstPartyDomain": { + "type": "string", + "description": "The first-party domain associated with the cookie that's been removed." + }, + "partitionKey": { + "$ref": "PartitionKey", + "optional": true, + "description": "The storage partition, if the cookie is part of partitioned storage. null if not partitioned." + } + } + } + ] + } + ] + }, + { + "name": "getAllCookieStores", + "type": "function", + "description": "Lists all existing cookie stores.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "cookieStores", + "type": "array", + "items": { "$ref": "CookieStore" }, + "description": "All the existing cookie stores." + } + ] + } + ] + } + ], + "events": [ + { + "name": "onChanged", + "type": "function", + "description": "Fired when a cookie is set or removed. As a special case, note that updating a cookie's properties is implemented as a two step process: the cookie to be updated is first removed entirely, generating a notification with \"cause\" of \"overwrite\" . Afterwards, a new cookie is written with the updated values, generating a second notification with \"cause\" \"explicit\".", + "parameters": [ + { + "type": "object", + "name": "changeInfo", + "properties": { + "removed": { + "type": "boolean", + "description": "True if a cookie was removed." + }, + "cookie": { + "$ref": "Cookie", + "description": "Information about the cookie that was set or removed." + }, + "cause": { + "$ref": "OnChangedCause", + "description": "The underlying reason behind the cookie's change." + } + } + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/declarative_net_request.json b/toolkit/components/extensions/schemas/declarative_net_request.json new file mode 100644 index 0000000000..e7bdc02041 --- /dev/null +++ b/toolkit/components/extensions/schemas/declarative_net_request.json @@ -0,0 +1,785 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "Permission", + "choices": [ + { + "type": "string", + "enum": ["declarativeNetRequest"] + } + ] + }, + { + "$extend": "OptionalPermission", + "choices": [ + { + "type": "string", + "enum": ["declarativeNetRequestFeedback"] + } + ] + }, + { + "$extend": "PermissionNoPrompt", + "choices": [ + { + "type": "string", + "enum": ["declarativeNetRequestWithHostAccess"] + } + ] + }, + { + "$extend": "WebExtensionManifest", + "properties": { + "declarative_net_request": { + "type": "object", + "optional": true, + "additionalProperties": { "$ref": "UnrecognizedProperty" }, + "properties": { + "rule_resources": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": { "$ref": "UnrecognizedProperty" }, + "properties": { + "id": { + "type": "string", + "pattern": "^[^_]", + "description": "A non-empty string uniquely identifying the ruleset. IDs beginning with '_' are reserved for internal use." + }, + "enabled": { + "type": "boolean", + "description": "Whether the ruleset is enabled by default." + }, + "path": { + "$ref": "manifest.ExtensionURL", + "description": "The path of the JSON ruleset relative to the extension directory." + } + } + } + } + } + } + } + } + ] + }, + { + "namespace": "declarativeNetRequest", + "description": "Use the declarativeNetRequest API to block or modify network requests by specifying declarative rules.", + "permissions": [ + "declarativeNetRequest", + "declarativeNetRequestWithHostAccess" + ], + "types": [ + { + "id": "ResourceType", + "type": "string", + "description": "How the requested resource will be used. Comparable to the webRequest.ResourceType type.", + "enum": [ + "main_frame", + "sub_frame", + "stylesheet", + "script", + "image", + "object", + "object_subrequest", + "xmlhttprequest", + "xslt", + "ping", + "beacon", + "xml_dtd", + "font", + "media", + "websocket", + "csp_report", + "imageset", + "web_manifest", + "speculative", + "other" + ] + }, + { + "id": "UnsupportedRegexReason", + "type": "string", + "description": "Describes the reason why a given regular expression isn't supported.", + "enum": ["syntaxError", "memoryLimitExceeded"] + }, + { + "id": "MatchedRule", + "type": "object", + "properties": { + "ruleId": { + "type": "integer", + "description": "A matching rule's ID." + }, + "rulesetId": { + "type": "string", + "description": "ID of the Ruleset this rule belongs to." + }, + "extensionId": { + "type": "string", + "description": "ID of the extension, if this rule belongs to a different extension.", + "optional": true + } + } + }, + { + "id": "URLTransform", + "type": "object", + "description": "Describes the type of the Rule.action.redirect.transform property.", + "properties": { + "scheme": { + "type": "string", + "optional": true, + "description": "The new scheme for the request.", + "enum": ["http", "https", "moz-extension"] + }, + "username": { + "type": "string", + "optional": true, + "description": "The new username for the request." + }, + "password": { + "type": "string", + "optional": true, + "description": "The new password for the request." + }, + "host": { + "type": "string", + "optional": true, + "description": "The new host name for the request." + }, + "port": { + "type": "string", + "optional": true, + "description": "The new port for the request. If empty, the existing port is cleared." + }, + "path": { + "type": "string", + "optional": true, + "description": "The new path for the request. If empty, the existing path is cleared." + }, + "query": { + "type": "string", + "optional": true, + "description": "The new query for the request. Should be either empty, in which case the existing query is cleared; or should begin with '?'. Cannot be specified if 'queryTransform' is specified." + }, + "queryTransform": { + "type": "object", + "optional": true, + "description": "Add, remove or replace query key-value pairs. Cannot be specified if 'query' is specified.", + "properties": { + "removeParams": { + "type": "array", + "optional": true, + "description": "The list of query keys to be removed.", + "items": { + "type": "string" + } + }, + "addOrReplaceParams": { + "type": "array", + "optional": true, + "description": "The list of query key-value pairs to be added or replaced.", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + }, + "replaceOnly": { + "type": "boolean", + "optional": true, + "description": "If true, the query key is replaced only if it's already present. Otherwise, the key is also added if it's missing.", + "default": false + } + } + } + } + } + }, + "fragment": { + "type": "string", + "optional": true, + "description": "The new fragment for the request. Should be either empty, in which case the existing fragment is cleared; or should begin with '#'." + } + } + }, + { + "id": "Rule", + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "An id which uniquely identifies a rule. Mandatory and should be >= 1.", + "minimum": 1 + }, + "priority": { + "type": "integer", + "optional": true, + "description": "Rule priority. Defaults to 1. When specified, should be >= 1", + "minimum": 1, + "default": 1 + }, + "condition": { + "type": "object", + "description": "The condition under which this rule is triggered.", + "properties": { + "urlFilter": { + "type": "string", + "optional": true, + "description": "TODO: link to doc explaining supported pattern. The pattern which is matched against the network request url. Only one of 'urlFilter' or 'regexFilter' can be specified." + }, + "regexFilter": { + "type": "string", + "optional": true, + "description": "Regular expression to match against the network request url. Only one of 'urlFilter' or 'regexFilter' can be specified." + }, + "isUrlFilterCaseSensitive": { + "type": "boolean", + "optional": true, + "description": "Whether 'urlFilter' or 'regexFilter' is case-sensitive." + }, + "initiatorDomains": { + "type": "array", + "optional": true, + "description": "The rule will only match network requests originating from the list of 'initiatorDomains'. If the list is omitted, the rule is applied to requests from all domains.", + "minItems": 1, + "items": { + "type": "string", + "format": "canonicalDomain" + } + }, + "excludedInitiatorDomains": { + "type": "array", + "optional": true, + "description": "The rule will not match network requests originating from the list of 'initiatorDomains'. If the list is empty or omitted, no domains are excluded. This takes precedence over 'initiatorDomains'.", + "items": { + "type": "string", + "format": "canonicalDomain" + } + }, + "requestDomains": { + "type": "array", + "optional": true, + "description": "The rule will only match network requests when the domain matches one from the list of 'requestDomains'. If the list is omitted, the rule is applied to requests from all domains.", + "minItems": 1, + "items": { + "type": "string", + "format": "canonicalDomain" + } + }, + "excludedRequestDomains": { + "type": "array", + "optional": true, + "description": "The rule will not match network requests when the domains matches one from the list of 'excludedRequestDomains'. If the list is empty or omitted, no domains are excluded. This takes precedence over 'requestDomains'.", + "items": { + "type": "string", + "format": "canonicalDomain" + } + }, + "resourceTypes": { + "type": "array", + "optional": true, + "description": "List of resource types which the rule can match. When the rule action is 'allowAllRequests', this must be specified and may only contain 'main_frame' or 'sub_frame'. Cannot be specified if 'excludedResourceTypes' is specified. If neither of them is specified, all resource types except 'main_frame' are matched.", + "minItems": 1, + "items": { + "$ref": "ResourceType" + } + }, + "excludedResourceTypes": { + "type": "array", + "optional": true, + "description": "List of resource types which the rule won't match. Cannot be specified if 'resourceTypes' is specified. If neither of them is specified, all resource types except 'main_frame' are matched.", + "items": { + "$ref": "ResourceType" + } + }, + "requestMethods": { + "type": "array", + "optional": true, + "description": "List of HTTP request methods which the rule can match. Should be a lower-case method such as 'connect', 'delete', 'get', 'head', 'options', 'patch', 'post', 'put'.'", + "minItems": 1, + "items": { + "type": "string" + } + }, + "excludedRequestMethods": { + "type": "array", + "optional": true, + "description": "List of request methods which the rule won't match. Cannot be specified if 'requestMethods' is specified. If neither of them is specified, all request methods are matched.", + "items": { + "type": "string" + } + }, + "domainType": { + "type": "string", + "optional": true, + "description": "Specifies whether the network request is first-party or third-party to the domain from which it originated. If omitted, all requests are matched.", + "enum": ["firstParty", "thirdParty"] + }, + "tabIds": { + "type": "array", + "optional": true, + "description": "List of tabIds which the rule should match. An ID of -1 matches requests which don't originate from a tab. Only supported for session-scoped rules.", + "minItems": 1, + "items": { + "type": "integer" + } + }, + "excludedTabIds": { + "type": "array", + "optional": true, + "description": "List of tabIds which the rule should not match. An ID of -1 excludes requests which don't originate from a tab. Only supported for session-scoped rules.", + "items": { + "type": "integer" + } + } + } + }, + "action": { + "type": "object", + "description": "The action to take if this rule is matched.", + "properties": { + "type": { + "type": "string", + "enum": [ + "block", + "redirect", + "allow", + "upgradeScheme", + "modifyHeaders", + "allowAllRequests" + ] + }, + "redirect": { + "type": "object", + "optional": true, + "description": "Describes how the redirect should be performed. Only valid when type is 'redirect'.", + "properties": { + "extensionPath": { + "type": "string", + "optional": true, + "description": "Path relative to the extension directory. Should start with '/'." + }, + "transform": { + "$ref": "URLTransform", + "optional": true, + "description": "Url transformations to perform." + }, + "url": { + "type": "string", + "format": "url", + "optional": true, + "description": "The redirect url. Redirects to JavaScript urls are not allowed." + }, + "regexSubstitution": { + "type": "string", + "optional": true, + "description": "Substitution pattern for rules which specify a 'regexFilter'. The first match of regexFilter within the url will be replaced with this pattern. Within regexSubstitution, backslash-escaped digits (\\1 to \\9) can be used to insert the corresponding capture groups. \\0 refers to the entire matching text." + } + } + }, + "requestHeaders": { + "type": "array", + "optional": true, + "description": "The request headers to modify for the request. Only valid when type is 'modifyHeaders'.", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "header": { + "type": "string", + "description": "The name of the request header to be modified." + }, + "operation": { + "type": "string", + "description": "The operation to be performed on a header.", + "enum": ["append", "set", "remove"] + }, + "value": { + "type": "string", + "optional": true, + "description": "The new value for the header. Must be specified for the 'append' and 'set' operations." + } + } + } + }, + "responseHeaders": { + "type": "array", + "optional": true, + "description": "The response headers to modify for the request. Only valid when type is 'modifyHeaders'.", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "header": { + "type": "string", + "description": "The name of the response header to be modified." + }, + "operation": { + "type": "string", + "description": "The operation to be performed on a header.", + "enum": ["append", "set", "remove"] + }, + "value": { + "type": "string", + "optional": true, + "description": "The new value for the header. Must be specified for the 'append' and 'set' operations." + } + } + } + } + } + } + } + } + ], + "functions": [ + { + "name": "updateDynamicRules", + "type": "function", + "description": "Modifies the current set of dynamic rules for the extension. The rules with IDs listed in options.removeRuleIds are first removed, and then the rules given in options.addRules are added. These rules are persisted across browser sessions and extension updates.", + "async": "callback", + "parameters": [ + { + "name": "options", + "type": "object", + "properties": { + "removeRuleIds": { + "type": "array", + "optional": true, + "description": "IDs of the rules to remove. Any invalid IDs will be ignored.", + "items": { + "type": "integer" + } + }, + "addRules": { + "type": "array", + "optional": true, + "description": "Rules to add.", + "items": { + "$ref": "Rule" + } + } + } + }, + { + "name": "callback", + "type": "function", + "description": "Called when the dynamic rules have been updated", + "parameters": [] + } + ] + }, + { + "name": "updateSessionRules", + "type": "function", + "description": "Modifies the current set of session scoped rules for the extension. The rules with IDs listed in options.removeRuleIds are first removed, and then the rules given in options.addRules are added. These rules are not persisted across sessions and are backed in memory.", + "async": "callback", + "parameters": [ + { + "name": "options", + "type": "object", + "properties": { + "removeRuleIds": { + "type": "array", + "optional": true, + "description": "IDs of the rules to remove. Any invalid IDs will be ignored.", + "items": { + "type": "integer" + } + }, + "addRules": { + "type": "array", + "optional": true, + "description": "Rules to add.", + "items": { + "$ref": "Rule" + } + } + } + }, + { + "name": "callback", + "type": "function", + "description": "Called when the session rules have been updated", + "parameters": [] + } + ] + }, + { + "name": "getEnabledRulesets", + "type": "function", + "description": "Returns the ids for the current set of enabled static rulesets.", + "async": "callback", + "parameters": [ + { + "name": "callback", + "type": "function", + "parameters": [ + { + "type": "array", + "name": "rulesetIds", + "items": { "type": "string" } + } + ] + } + ] + }, + { + "name": "updateEnabledRulesets", + "type": "function", + "description": "Returns the ids for the current set of enabled static rulesets.", + "async": "callback", + "parameters": [ + { + "name": "updateRulesetOptions", + "type": "object", + "properties": { + "disableRulesetIds": { + "type": "array", + "items": { "type": "string" }, + "optional": true, + "default": [] + }, + "enableRulesetIds": { + "type": "array", + "items": { "type": "string" }, + "optional": true, + "default": [] + } + } + }, + { + "name": "callback", + "type": "function", + "parameters": [] + } + ] + }, + { + "name": "getAvailableStaticRuleCount", + "type": "function", + "description": "Returns the remaining number of static rules an extension can enable", + "async": "callback", + "parameters": [ + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "count", + "type": "integer" + } + ] + } + ] + }, + { + "name": "getDynamicRules", + "type": "function", + "description": "Returns the current set of dynamic rules for the extension.", + "async": "callback", + "parameters": [ + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "dynamicRules", + "type": "array", + "items": { + "$ref": "Rule" + } + } + ] + } + ] + }, + { + "name": "getSessionRules", + "type": "function", + "description": "Returns the current set of session scoped rules for the extension.", + "async": "callback", + "parameters": [ + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "sessionRules", + "type": "array", + "items": { + "$ref": "Rule" + } + } + ] + } + ] + }, + { + "name": "isRegexSupported", + "type": "function", + "description": "Checks if the given regular expression will be supported as a 'regexFilter' rule condition.", + "async": "callback", + "parameters": [ + { + "name": "regexOptions", + "type": "object", + "properties": { + "regex": { + "type": "string", + "description": "The regular expresson to check." + }, + "isCaseSensitive": { + "type": "boolean", + "optional": true, + "description": "Whether the 'regex' specified is case sensitive.", + "default": false + }, + "requireCapturing": { + "type": "boolean", + "optional": true, + "description": "Whether the 'regex' specified requires capturing. Capturing is only required for redirect rules which specify a 'regexSubstition' action.", + "default": false + } + } + }, + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "result", + "type": "object", + "properties": { + "isSupported": { + "type": "boolean", + "description": "Whether the given regex is supported" + }, + "reason": { + "$ref": "UnsupportedRegexReason", + "optional": true, + "description": "Specifies the reason why the regular expression is not supported. Only provided if 'isSupported' is false." + } + } + } + ] + } + ] + }, + { + "name": "testMatchOutcome", + "type": "function", + "description": "Checks if any of the extension's declarativeNetRequest rules would match a hypothetical request.", + "permissions": ["declarativeNetRequestFeedback"], + "async": "callback", + "parameters": [ + { + "name": "request", + "type": "object", + "description": "The details of the request to test.", + "properties": { + "url": { + "type": "string", + "description": "The URL of the hypothetical request." + }, + "initiator": { + "type": "string", + "description": "The initiator URL (if any) for the hypothetical request.", + "optional": true + }, + "method": { + "type": "string", + "description": "Standard HTTP method of the hypothetical request.", + "optional": true, + "default": "get" + }, + "type": { + "$ref": "ResourceType", + "description": "The resource type of the hypothetical request." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the hypothetical request takes place. Does not need to correspond to a real tab ID. Default is -1, meaning that the request isn't related to a tab.", + "optional": true, + "default": -1 + } + } + }, + { + "name": "options", + "type": "object", + "optional": true, + "properties": { + "includeOtherExtensions": { + "type": "boolean", + "description": "Whether to account for rules from other installed extensions during rule evaluation.", + "optional": true + } + } + }, + { + "name": "callback", + "type": "function", + "description": "Called with the details of matched rules.", + "parameters": [ + { + "name": "result", + "type": "object", + "properties": { + "matchedRules": { + "type": "array", + "description": "The rules (if any) that match the hypothetical request.", + "items": { + "$ref": "MatchedRule" + } + } + } + } + ] + } + ] + } + ], + "properties": { + "DYNAMIC_RULESET_ID": { + "type": "string", + "value": "_dynamic", + "description": "Ruleset ID for the dynamic rules added by the extension." + }, + "GUARANTEED_MINIMUM_STATIC_RULES": { + "type": "number", + "description": "The minimum number of static rules guaranteed to an extension across its enabled static rulesets. Any rules above this limit will count towards the global static rule limit." + }, + "MAX_NUMBER_OF_STATIC_RULESETS": { + "type": "number", + "description": "The maximum number of static Rulesets an extension can specify as part of the rule_resources manifest key." + }, + "MAX_NUMBER_OF_ENABLED_STATIC_RULESETS": { + "type": "number", + "description": "The maximum number of static Rulesets an extension can enable at any one time." + }, + "MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES": { + "type": "number", + "description": "The maximum number of dynamic and session rules an extension can add. NOTE: in the Firefox we are enforcing this limit to the session and dynamic rules count separately, instead of enforcing it to the rules count for both combined as the Chrome implementation does." + }, + "MAX_NUMBER_OF_REGEX_RULES": { + "type": "number", + "description": "The maximum number of regular expression rules that an extension can add. This limit is evaluated separately for the set of session rules, dynamic rules and those specified in the rule_resources file." + }, + "SESSION_RULESET_ID": { + "type": "string", + "value": "_session", + "description": "Ruleset ID for the session-scoped rules added by the extension." + } + } + } +] diff --git a/toolkit/components/extensions/schemas/dns.json b/toolkit/components/extensions/schemas/dns.json new file mode 100644 index 0000000000..415849c6de --- /dev/null +++ b/toolkit/components/extensions/schemas/dns.json @@ -0,0 +1,82 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "PermissionNoPrompt", + "choices": [ + { + "type": "string", + "enum": ["dns"] + } + ] + } + ] + }, + { + "namespace": "dns", + "description": "Asynchronous DNS API", + "permissions": ["dns"], + "types": [ + { + "id": "DNSRecord", + "type": "object", + "description": "An object encapsulating a DNS Record.", + "properties": { + "canonicalName": { + "type": "string", + "optional": true, + "description": "The canonical hostname for this record. this value is empty if the record was not fetched with the 'canonical_name' flag." + }, + "isTRR": { + "type": "string", + "description": "Record retreived with TRR." + }, + "addresses": { + "type": "array", + "items": { "type": "string" } + } + } + }, + { + "id": "ResolveFlags", + "type": "array", + "items": { + "type": "string", + "enum": [ + "allow_name_collisions", + "bypass_cache", + "canonical_name", + "disable_ipv4", + "disable_ipv6", + "disable_trr", + "offline", + "priority_low", + "priority_medium", + "speculate" + ] + } + } + ], + "functions": [ + { + "name": "resolve", + "type": "function", + "description": "Resolves a hostname to a DNS record.", + "async": true, + "parameters": [ + { + "name": "hostname", + "type": "string" + }, + { + "name": "flags", + "optional": true, + "default": [], + "$ref": "ResolveFlags" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/downloads.json b/toolkit/components/extensions/schemas/downloads.json new file mode 100644 index 0000000000..ed3c1002e0 --- /dev/null +++ b/toolkit/components/extensions/schemas/downloads.json @@ -0,0 +1,810 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermission", + "choices": [ + { + "type": "string", + "enum": ["downloads", "downloads.open"] + } + ] + } + ] + }, + { + "namespace": "downloads", + "permissions": ["downloads"], + "types": [ + { + "id": "FilenameConflictAction", + "type": "string", + "enum": ["uniquify", "overwrite", "prompt"] + }, + { + "id": "InterruptReason", + "type": "string", + "enum": [ + "FILE_FAILED", + "FILE_ACCESS_DENIED", + "FILE_NO_SPACE", + "FILE_NAME_TOO_LONG", + "FILE_TOO_LARGE", + "FILE_VIRUS_INFECTED", + "FILE_TRANSIENT_ERROR", + "FILE_BLOCKED", + "FILE_SECURITY_CHECK_FAILED", + "FILE_TOO_SHORT", + "NETWORK_FAILED", + "NETWORK_TIMEOUT", + "NETWORK_DISCONNECTED", + "NETWORK_SERVER_DOWN", + "NETWORK_INVALID_REQUEST", + "SERVER_FAILED", + "SERVER_NO_RANGE", + "SERVER_BAD_CONTENT", + "SERVER_UNAUTHORIZED", + "SERVER_CERT_PROBLEM", + "SERVER_FORBIDDEN", + "USER_CANCELED", + "USER_SHUTDOWN", + "CRASH" + ] + }, + { + "id": "DangerType", + "type": "string", + "enum": [ + "file", + "url", + "content", + "uncommon", + "host", + "unwanted", + "safe", + "accepted" + ], + "description": "<dl><dt>file</dt><dd>The download's filename is suspicious.</dd><dt>url</dt><dd>The download's URL is known to be malicious.</dd><dt>content</dt><dd>The downloaded file is known to be malicious.</dd><dt>uncommon</dt><dd>The download's URL is not commonly downloaded and could be dangerous.</dd><dt>safe</dt><dd>The download presents no known danger to the user's computer.</dd></dl>These string constants will never change, however the set of DangerTypes may change." + }, + { + "id": "State", + "type": "string", + "enum": ["in_progress", "interrupted", "complete"], + "description": "<dl><dt>in_progress</dt><dd>The download is currently receiving data from the server.</dd><dt>interrupted</dt><dd>An error broke the connection with the file host.</dd><dt>complete</dt><dd>The download completed successfully.</dd></dl>These string constants will never change, however the set of States may change." + }, + { + "id": "DownloadItem", + "type": "object", + "properties": { + "id": { + "description": "An identifier that is persistent across browser sessions.", + "type": "integer" + }, + "url": { + "description": "Absolute URL.", + "type": "string" + }, + "referrer": { + "type": "string", + "optional": true + }, + "filename": { + "description": "Absolute local path.", + "type": "string" + }, + "incognito": { + "description": "False if this download is recorded in the history, true if it is not recorded.", + "type": "boolean" + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, + "danger": { + "$ref": "DangerType", + "description": "Indication of whether this download is thought to be safe or known to be suspicious." + }, + "mime": { + "description": "The file's MIME type.", + "type": "string", + "optional": true + }, + "startTime": { + "description": "Number of milliseconds between the unix epoch and when this download began.", + "type": "string" + }, + "endTime": { + "description": "Number of milliseconds between the unix epoch and when this download ended.", + "optional": true, + "type": "string" + }, + "estimatedEndTime": { + "type": "string", + "optional": true + }, + "state": { + "$ref": "State", + "description": "Indicates whether the download is progressing, interrupted, or complete." + }, + "paused": { + "description": "True if the download has stopped reading data from the host, but kept the connection open.", + "type": "boolean" + }, + "canResume": { + "type": "boolean" + }, + "error": { + "description": "Number indicating why a download was interrupted.", + "optional": true, + "$ref": "InterruptReason" + }, + "bytesReceived": { + "description": "Number of bytes received so far from the host, without considering file compression.", + "type": "number" + }, + "totalBytes": { + "description": "Number of bytes in the whole file, without considering file compression, or -1 if unknown.", + "type": "number" + }, + "fileSize": { + "description": "Number of bytes in the whole file post-decompression, or -1 if unknown.", + "type": "number" + }, + "exists": { + "type": "boolean" + }, + "byExtensionId": { + "type": "string", + "optional": true + }, + "byExtensionName": { + "type": "string", + "optional": true + } + } + }, + { + "id": "StringDelta", + "type": "object", + "properties": { + "current": { + "optional": true, + "type": "string" + }, + "previous": { + "optional": true, + "type": "string" + } + } + }, + { + "id": "DoubleDelta", + "type": "object", + "properties": { + "current": { + "optional": true, + "type": "number" + }, + "previous": { + "optional": true, + "type": "number" + } + } + }, + { + "id": "BooleanDelta", + "type": "object", + "properties": { + "current": { + "optional": true, + "type": "boolean" + }, + "previous": { + "optional": true, + "type": "boolean" + } + } + }, + { + "id": "DownloadTime", + "description": "A time specified as a Date object, a number or string representing milliseconds since the epoch, or an ISO 8601 string", + "choices": [ + { + "type": "string", + "pattern": "^[1-9]\\d*$" + }, + { + "$ref": "extensionTypes.Date" + } + ] + }, + { + "id": "DownloadQuery", + "description": "Parameters that combine to specify a predicate that can be used to select a set of downloads. Used for example in search() and erase()", + "type": "object", + "properties": { + "query": { + "description": "This array of search terms limits results to <a href='#type-DownloadItem'>DownloadItems</a> whose <code>filename</code> or <code>url</code> contain all of the search terms that do not begin with a dash '-' and none of the search terms that do begin with a dash.", + "optional": true, + "type": "array", + "items": { "type": "string" } + }, + "startedBefore": { + "description": "Limits results to downloads that started before the given ms since the epoch.", + "optional": true, + "$ref": "DownloadTime" + }, + "startedAfter": { + "description": "Limits results to downloads that started after the given ms since the epoch.", + "optional": true, + "$ref": "DownloadTime" + }, + "endedBefore": { + "description": "Limits results to downloads that ended before the given ms since the epoch.", + "optional": true, + "$ref": "DownloadTime" + }, + "endedAfter": { + "description": "Limits results to downloads that ended after the given ms since the epoch.", + "optional": true, + "$ref": "DownloadTime" + }, + "totalBytesGreater": { + "description": "Limits results to downloads whose totalBytes is greater than the given integer.", + "optional": true, + "type": "number" + }, + "totalBytesLess": { + "description": "Limits results to downloads whose totalBytes is less than the given integer.", + "optional": true, + "type": "number" + }, + "filenameRegex": { + "description": "Limits results to <a href='#type-DownloadItem'>DownloadItems</a> whose <code>filename</code> matches the given regular expression.", + "optional": true, + "type": "string" + }, + "urlRegex": { + "description": "Limits results to <a href='#type-DownloadItem'>DownloadItems</a> whose <code>url</code> matches the given regular expression.", + "optional": true, + "type": "string" + }, + "limit": { + "description": "Setting this integer limits the number of results. Otherwise, all matching <a href='#type-DownloadItem'>DownloadItems</a> will be returned.", + "optional": true, + "type": "integer" + }, + "orderBy": { + "description": "Setting elements of this array to <a href='#type-DownloadItem'>DownloadItem</a> properties in order to sort the search results. For example, setting <code>orderBy='startTime'</code> sorts the <a href='#type-DownloadItem'>DownloadItems</a> by their start time in ascending order. To specify descending order, prefix <code>orderBy</code> with a hyphen: '-startTime'.", + "optional": true, + "type": "array", + "items": { "type": "string" } + }, + "id": { + "type": "integer", + "optional": true + }, + "url": { + "description": "Absolute URL.", + "optional": true, + "type": "string" + }, + "filename": { + "description": "Absolute local path.", + "optional": true, + "type": "string" + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, + "danger": { + "$ref": "DangerType", + "description": "Indication of whether this download is thought to be safe or known to be suspicious.", + "optional": true + }, + "mime": { + "description": "The file's MIME type.", + "optional": true, + "type": "string" + }, + "startTime": { + "optional": true, + "type": "string" + }, + "endTime": { + "optional": true, + "type": "string" + }, + "state": { + "$ref": "State", + "description": "Indicates whether the download is progressing, interrupted, or complete.", + "optional": true + }, + "paused": { + "description": "True if the download has stopped reading data from the host, but kept the connection open.", + "optional": true, + "type": "boolean" + }, + "error": { + "description": "Why a download was interrupted.", + "optional": true, + "$ref": "InterruptReason" + }, + "bytesReceived": { + "description": "Number of bytes received so far from the host, without considering file compression.", + "optional": true, + "type": "number" + }, + "totalBytes": { + "description": "Number of bytes in the whole file, without considering file compression, or -1 if unknown.", + "optional": true, + "type": "number" + }, + "fileSize": { + "description": "Number of bytes in the whole file post-decompression, or -1 if unknown.", + "optional": true, + "type": "number" + }, + "exists": { + "type": "boolean", + "optional": true + } + } + } + ], + "functions": [ + { + "name": "download", + "type": "function", + "async": "callback", + "description": "Download a URL. If the URL uses the HTTP[S] protocol, then the request will include all cookies currently set for its hostname. If both <code>filename</code> and <code>saveAs</code> are specified, then the Save As dialog will be displayed, pre-populated with the specified <code>filename</code>. If the download started successfully, <code>callback</code> will be called with the new <a href='#type-DownloadItem'>DownloadItem</a>'s <code>downloadId</code>. If there was an error starting the download, then <code>callback</code> will be called with <code>downloadId=undefined</code> and <a href='extension.html#property-lastError'>chrome.extension.lastError</a> will contain a descriptive string. The error strings are not guaranteed to remain backwards compatible between releases. You must not parse it.", + "parameters": [ + { + "description": "What to download and how.", + "name": "options", + "type": "object", + "properties": { + "url": { + "description": "The URL to download.", + "type": "string", + "format": "url" + }, + "filename": { + "description": "A file path relative to the Downloads directory to contain the downloaded file.", + "optional": true, + "type": "string" + }, + "incognito": { + "description": "Whether to associate the download with a private browsing session.", + "optional": true, + "default": false, + "type": "boolean" + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity; requires \"cookies\" permission." + }, + "conflictAction": { + "$ref": "FilenameConflictAction", + "optional": true + }, + "saveAs": { + "description": "Use a file-chooser to allow the user to select a filename. If the option is not specified, the file chooser will be shown only if the Firefox \"Always ask you where to save files\" option is enabled (i.e. the pref <code>browser.download.useDownloadDir</code> is set to <code>false</code>).", + "optional": true, + "type": "boolean" + }, + "method": { + "description": "The HTTP method to use if the URL uses the HTTP[S] protocol.", + "enum": ["GET", "POST"], + "optional": true, + "type": "string" + }, + "headers": { + "optional": true, + "type": "array", + "description": "Extra HTTP headers to send with the request if the URL uses the HTTP[s] protocol. Each header is represented as a dictionary containing the keys <code>name</code> and either <code>value</code> or <code>binaryValue</code>, restricted to those allowed by XMLHttpRequest.", + "items": { + "type": "object", + "properties": { + "name": { + "description": "Name of the HTTP header.", + "type": "string" + }, + "value": { + "description": "Value of the HTTP header.", + "type": "string" + } + } + } + }, + "body": { + "description": "Post body.", + "optional": true, + "type": "string" + }, + "allowHttpErrors": { + "description": "When this flag is set to <code>true</code>, then the browser will allow downloads to proceed after encountering HTTP errors such as <code>404 Not Found</code>.", + "optional": true, + "default": false, + "type": "boolean" + } + } + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [ + { + "name": "downloadId", + "type": "integer" + } + ] + } + ] + }, + { + "name": "search", + "type": "function", + "async": "callback", + "description": "Find <a href='#type-DownloadItem'>DownloadItems</a>. Set <code>query</code> to the empty object to get all <a href='#type-DownloadItem'>DownloadItems</a>. To get a specific <a href='#type-DownloadItem'>DownloadItem</a>, set only the <code>id</code> field.", + "parameters": [ + { + "name": "query", + "$ref": "DownloadQuery" + }, + { + "name": "callback", + "type": "function", + "parameters": [ + { + "items": { + "$ref": "DownloadItem" + }, + "name": "results", + "type": "array" + } + ] + } + ] + }, + { + "name": "pause", + "type": "function", + "async": "callback", + "description": "Pause the download. If the request was successful the download is in a paused state. Otherwise <a href='extension.html#property-lastError'>chrome.extension.lastError</a> contains an error message. The request will fail if the download is not active.", + "parameters": [ + { + "description": "The id of the download to pause.", + "name": "downloadId", + "type": "integer" + }, + { + "name": "callback", + "optional": true, + "parameters": [], + "type": "function" + } + ] + }, + { + "name": "resume", + "type": "function", + "async": "callback", + "description": "Resume a paused download. If the request was successful the download is in progress and unpaused. Otherwise <a href='extension.html#property-lastError'>chrome.extension.lastError</a> contains an error message. The request will fail if the download is not active.", + "parameters": [ + { + "description": "The id of the download to resume.", + "name": "downloadId", + "type": "integer" + }, + { + "name": "callback", + "optional": true, + "parameters": [], + "type": "function" + } + ] + }, + { + "name": "cancel", + "type": "function", + "async": "callback", + "description": "Cancel a download. When <code>callback</code> is run, the download is cancelled, completed, interrupted or doesn't exist anymore.", + "parameters": [ + { + "description": "The id of the download to cancel.", + "name": "downloadId", + "type": "integer" + }, + { + "name": "callback", + "optional": true, + "parameters": [], + "type": "function" + } + ] + }, + { + "name": "getFileIcon", + "type": "function", + "async": "callback", + "description": "Retrieve an icon for the specified download. For new downloads, file icons are available after the <a href='#event-onCreated'>onCreated</a> event has been received. The image returned by this function while a download is in progress may be different from the image returned after the download is complete. Icon retrieval is done by querying the underlying operating system or toolkit depending on the platform. The icon that is returned will therefore depend on a number of factors including state of the download, platform, registered file types and visual theme. If a file icon cannot be determined, <a href='extension.html#property-lastError'>chrome.extension.lastError</a> will contain an error message.", + "parameters": [ + { + "description": "The identifier for the download.", + "name": "downloadId", + "type": "integer" + }, + { + "name": "options", + "optional": true, + "properties": { + "size": { + "description": "The size of the icon. The returned icon will be square with dimensions size * size pixels. The default size for the icon is 32x32 pixels.", + "optional": true, + "minimum": 1, + "maximum": 127, + "type": "integer" + } + }, + "type": "object" + }, + { + "name": "callback", + "parameters": [ + { + "name": "iconURL", + "optional": true, + "type": "string" + } + ], + "type": "function" + } + ] + }, + { + "name": "open", + "type": "function", + "async": "callback", + "requireUserInput": true, + "description": "Open the downloaded file.", + "permissions": ["downloads.open"], + "parameters": [ + { + "name": "downloadId", + "type": "integer" + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "show", + "type": "function", + "description": "Show the downloaded file in its folder in a file manager.", + "async": "callback", + "parameters": [ + { + "name": "downloadId", + "type": "integer" + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [ + { + "name": "success", + "type": "boolean" + } + ] + } + ] + }, + { + "name": "showDefaultFolder", + "type": "function", + "parameters": [] + }, + { + "name": "erase", + "type": "function", + "async": "callback", + "description": "Erase matching <a href='#type-DownloadItem'>DownloadItems</a> from history", + "parameters": [ + { + "name": "query", + "$ref": "DownloadQuery" + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [ + { + "items": { + "type": "integer" + }, + "name": "erasedIds", + "type": "array" + } + ] + } + ] + }, + { + "name": "removeFile", + "async": "callback", + "type": "function", + "parameters": [ + { + "name": "downloadId", + "type": "integer" + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [] + } + ] + }, + { + "description": "Prompt the user to either accept or cancel a dangerous download. <code>acceptDanger()</code> does not automatically accept dangerous downloads.", + "name": "acceptDanger", + "unsupported": true, + "parameters": [ + { + "name": "downloadId", + "type": "integer" + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [] + } + ], + "type": "function" + }, + { + "description": "Initiate dragging the file to another application.", + "name": "drag", + "unsupported": true, + "parameters": [ + { + "name": "downloadId", + "type": "integer" + } + ], + "type": "function" + }, + { + "name": "setShelfEnabled", + "type": "function", + "unsupported": true, + "parameters": [ + { + "name": "enabled", + "type": "boolean" + } + ] + } + ], + "events": [ + { + "description": "This event fires with the <a href='#type-DownloadItem'>DownloadItem</a> object when a download begins.", + "name": "onCreated", + "parameters": [ + { + "$ref": "DownloadItem", + "name": "downloadItem" + } + ], + "type": "function" + }, + { + "description": "Fires with the <code>downloadId</code> when a download is erased from history.", + "name": "onErased", + "parameters": [ + { + "name": "downloadId", + "description": "The <code>id</code> of the <a href='#type-DownloadItem'>DownloadItem</a> that was erased.", + "type": "integer" + } + ], + "type": "function" + }, + { + "name": "onChanged", + "description": "When any of a <a href='#type-DownloadItem'>DownloadItem</a>'s properties except <code>bytesReceived</code> changes, this event fires with the <code>downloadId</code> and an object containing the properties that changed.", + "parameters": [ + { + "name": "downloadDelta", + "type": "object", + "properties": { + "id": { + "description": "The <code>id</code> of the <a href='#type-DownloadItem'>DownloadItem</a> that changed.", + "type": "integer" + }, + "url": { + "description": "Describes a change in a <a href='#type-DownloadItem'>DownloadItem</a>'s <code>url</code>.", + "optional": true, + "$ref": "StringDelta" + }, + "filename": { + "description": "Describes a change in a <a href='#type-DownloadItem'>DownloadItem</a>'s <code>filename</code>.", + "optional": true, + "$ref": "StringDelta" + }, + "danger": { + "description": "Describes a change in a <a href='#type-DownloadItem'>DownloadItem</a>'s <code>danger</code>.", + "optional": true, + "$ref": "StringDelta" + }, + "mime": { + "description": "Describes a change in a <a href='#type-DownloadItem'>DownloadItem</a>'s <code>mime</code>.", + "optional": true, + "$ref": "StringDelta" + }, + "startTime": { + "description": "Describes a change in a <a href='#type-DownloadItem'>DownloadItem</a>'s <code>startTime</code>.", + "optional": true, + "$ref": "StringDelta" + }, + "endTime": { + "description": "Describes a change in a <a href='#type-DownloadItem'>DownloadItem</a>'s <code>endTime</code>.", + "optional": true, + "$ref": "StringDelta" + }, + "state": { + "description": "Describes a change in a <a href='#type-DownloadItem'>DownloadItem</a>'s <code>state</code>.", + "optional": true, + "$ref": "StringDelta" + }, + "canResume": { + "optional": true, + "$ref": "BooleanDelta" + }, + "paused": { + "description": "Describes a change in a <a href='#type-DownloadItem'>DownloadItem</a>'s <code>paused</code>.", + "optional": true, + "$ref": "BooleanDelta" + }, + "error": { + "description": "Describes a change in a <a href='#type-DownloadItem'>DownloadItem</a>'s <code>error</code>.", + "optional": true, + "$ref": "StringDelta" + }, + "totalBytes": { + "description": "Describes a change in a <a href='#type-DownloadItem'>DownloadItem</a>'s <code>totalBytes</code>.", + "optional": true, + "$ref": "DoubleDelta" + }, + "fileSize": { + "description": "Describes a change in a <a href='#type-DownloadItem'>DownloadItem</a>'s <code>fileSize</code>.", + "optional": true, + "$ref": "DoubleDelta" + }, + "exists": { + "optional": true, + "$ref": "BooleanDelta" + } + } + } + ], + "type": "function" + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/events.json b/toolkit/components/extensions/schemas/events.json new file mode 100644 index 0000000000..b8dad2c6a5 --- /dev/null +++ b/toolkit/components/extensions/schemas/events.json @@ -0,0 +1,324 @@ +[ + { + "namespace": "events", + "description": "The <code>chrome.events</code> namespace contains common types used by APIs dispatching events to notify you when something interesting happens.", + "types": [ + { + "id": "Rule", + "type": "object", + "description": "Description of a declarative rule for handling events.", + "properties": { + "id": { + "type": "string", + "optional": true, + "description": "Optional identifier that allows referencing this rule." + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "optional": true, + "description": "Tags can be used to annotate rules and perform operations on sets of rules." + }, + "conditions": { + "type": "array", + "items": { "type": "any" }, + "description": "List of conditions that can trigger the actions." + }, + "actions": { + "type": "array", + "items": { "type": "any" }, + "description": "List of actions that are triggered if one of the condtions is fulfilled." + }, + "priority": { + "type": "integer", + "optional": true, + "description": "Optional priority of this rule. Defaults to 100." + } + } + }, + { + "id": "Event", + "type": "object", + "description": "An object which allows the addition and removal of listeners for a Chrome event.", + "functions": [ + { + "name": "addListener", + "type": "function", + "description": "Registers an event listener <em>callback</em> to an event.", + "parameters": [ + { + "name": "callback", + "type": "function", + "description": "Called when an event occurs. The parameters of this function depend on the type of event." + } + ] + }, + { + "name": "removeListener", + "type": "function", + "description": "Deregisters an event listener <em>callback</em> from an event.", + "parameters": [ + { + "name": "callback", + "type": "function", + "description": "Listener that shall be unregistered." + } + ] + }, + { + "name": "hasListener", + "type": "function", + "parameters": [ + { + "name": "callback", + "type": "function", + "description": "Listener whose registration status shall be tested." + } + ], + "returns": { + "type": "boolean", + "description": "True if <em>callback</em> is registered to the event." + } + }, + { + "name": "hasListeners", + "type": "function", + "parameters": [], + "returns": { + "type": "boolean", + "description": "True if any event listeners are registered to the event." + } + }, + { + "name": "addRules", + "unsupported": true, + "type": "function", + "description": "Registers rules to handle events.", + "parameters": [ + { + "name": "eventName", + "type": "string", + "description": "Name of the event this function affects." + }, + { + "name": "webViewInstanceId", + "type": "integer", + "description": "If provided, this is an integer that uniquely identfies the <webview> associated with this function call." + }, + { + "name": "rules", + "type": "array", + "items": { "$ref": "Rule" }, + "description": "Rules to be registered. These do not replace previously registered rules." + }, + { + "name": "callback", + "optional": true, + "type": "function", + "parameters": [ + { + "name": "rules", + "type": "array", + "items": { "$ref": "Rule" }, + "description": "Rules that were registered, the optional parameters are filled with values." + } + ], + "description": "Called with registered rules." + } + ] + }, + { + "name": "getRules", + "unsupported": true, + "type": "function", + "description": "Returns currently registered rules.", + "parameters": [ + { + "name": "eventName", + "type": "string", + "description": "Name of the event this function affects." + }, + { + "name": "webViewInstanceId", + "type": "integer", + "description": "If provided, this is an integer that uniquely identfies the <webview> associated with this function call." + }, + { + "name": "ruleIdentifiers", + "optional": true, + "type": "array", + "items": { "type": "string" }, + "description": "If an array is passed, only rules with identifiers contained in this array are returned." + }, + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "rules", + "type": "array", + "items": { "$ref": "Rule" }, + "description": "Rules that were registered, the optional parameters are filled with values." + } + ], + "description": "Called with registered rules." + } + ] + }, + { + "name": "removeRules", + "unsupported": true, + "type": "function", + "description": "Unregisters currently registered rules.", + "parameters": [ + { + "name": "eventName", + "type": "string", + "description": "Name of the event this function affects." + }, + { + "name": "webViewInstanceId", + "type": "integer", + "description": "If provided, this is an integer that uniquely identfies the <webview> associated with this function call." + }, + { + "name": "ruleIdentifiers", + "optional": true, + "type": "array", + "items": { "type": "string" }, + "description": "If an array is passed, only rules with identifiers contained in this array are unregistered." + }, + { + "name": "callback", + "optional": true, + "type": "function", + "parameters": [], + "description": "Called when rules were unregistered." + } + ] + } + ] + }, + { + "id": "UrlFilter", + "type": "object", + "description": "Filters URLs for various criteria. See <a href='events#filtered'>event filtering</a>. All criteria are case sensitive.", + "properties": { + "hostContains": { + "type": "string", + "description": "Matches if the host name of the URL contains a specified string. To test whether a host name component has a prefix 'foo', use hostContains: '.foo'. This matches 'www.foobar.com' and 'foo.com', because an implicit dot is added at the beginning of the host name. Similarly, hostContains can be used to match against component suffix ('foo.') and to exactly match against components ('.foo.'). Suffix- and exact-matching for the last components need to be done separately using hostSuffix, because no implicit dot is added at the end of the host name.", + "optional": true + }, + "hostEquals": { + "type": "string", + "description": "Matches if the host name of the URL is equal to a specified string.", + "optional": true + }, + "hostPrefix": { + "type": "string", + "description": "Matches if the host name of the URL starts with a specified string.", + "optional": true + }, + "hostSuffix": { + "type": "string", + "description": "Matches if the host name of the URL ends with a specified string.", + "optional": true + }, + "pathContains": { + "type": "string", + "description": "Matches if the path segment of the URL contains a specified string.", + "optional": true + }, + "pathEquals": { + "type": "string", + "description": "Matches if the path segment of the URL is equal to a specified string.", + "optional": true + }, + "pathPrefix": { + "type": "string", + "description": "Matches if the path segment of the URL starts with a specified string.", + "optional": true + }, + "pathSuffix": { + "type": "string", + "description": "Matches if the path segment of the URL ends with a specified string.", + "optional": true + }, + "queryContains": { + "type": "string", + "description": "Matches if the query segment of the URL contains a specified string.", + "optional": true + }, + "queryEquals": { + "type": "string", + "description": "Matches if the query segment of the URL is equal to a specified string.", + "optional": true + }, + "queryPrefix": { + "type": "string", + "description": "Matches if the query segment of the URL starts with a specified string.", + "optional": true + }, + "querySuffix": { + "type": "string", + "description": "Matches if the query segment of the URL ends with a specified string.", + "optional": true + }, + "urlContains": { + "type": "string", + "description": "Matches if the URL (without fragment identifier) contains a specified string. Port numbers are stripped from the URL if they match the default port number.", + "optional": true + }, + "urlEquals": { + "type": "string", + "description": "Matches if the URL (without fragment identifier) is equal to a specified string. Port numbers are stripped from the URL if they match the default port number.", + "optional": true + }, + "urlMatches": { + "type": "string", + "description": "Matches if the URL (without fragment identifier) matches a specified regular expression. Port numbers are stripped from the URL if they match the default port number. The regular expressions use the <a href=\"https://github.com/google/re2/blob/master/doc/syntax.txt\">RE2 syntax</a>.", + "optional": true + }, + "originAndPathMatches": { + "type": "string", + "description": "Matches if the URL without query segment and fragment identifier matches a specified regular expression. Port numbers are stripped from the URL if they match the default port number. The regular expressions use the <a href=\"https://github.com/google/re2/blob/master/doc/syntax.txt\">RE2 syntax</a>.", + "optional": true + }, + "urlPrefix": { + "type": "string", + "description": "Matches if the URL (without fragment identifier) starts with a specified string. Port numbers are stripped from the URL if they match the default port number.", + "optional": true + }, + "urlSuffix": { + "type": "string", + "description": "Matches if the URL (without fragment identifier) ends with a specified string. Port numbers are stripped from the URL if they match the default port number.", + "optional": true + }, + "schemes": { + "type": "array", + "description": "Matches if the scheme of the URL is equal to any of the schemes specified in the array.", + "optional": true, + "items": { "type": "string" } + }, + "ports": { + "type": "array", + "description": "Matches if the port of the URL is contained in any of the specified port lists. For example <code>[80, 443, [1000, 1200]]</code> matches all requests on port 80, 443 and in the range 1000-1200.", + "optional": true, + "items": { + "choices": [ + { "type": "integer", "description": "A specific port." }, + { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { "type": "integer" }, + "description": "A pair of integers identiying the start and end (both inclusive) of a port range." + } + ] + } + } + } + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/experiments.json b/toolkit/components/extensions/schemas/experiments.json new file mode 100644 index 0000000000..78f23cd8f2 --- /dev/null +++ b/toolkit/components/extensions/schemas/experiments.json @@ -0,0 +1,119 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "Permission", + "choices": [ + { + "type": "string", + "pattern": "^experiments(\\.\\w+)+$" + } + ] + }, + { + "$extend": "WebExtensionManifest", + "properties": { + "experiment_apis": { + "type": "object", + "additionalProperties": { "$ref": "experiments.ExperimentAPI" }, + "optional": true, + "privileged": true + } + } + } + ] + }, + { + "namespace": "experiments", + "types": [ + { + "id": "ExperimentAPI", + "type": "object", + "properties": { + "schema": { "$ref": "ExperimentURL" }, + + "parent": { + "type": "object", + "properties": { + "events": { + "$ref": "APIEvents", + "optional": true, + "default": [] + }, + + "paths": { + "$ref": "APIPaths", + "optional": true, + "default": [] + }, + + "script": { "$ref": "ExperimentURL" }, + + "scopes": { + "type": "array", + "items": { "$ref": "APIParentScope", "onError": "warn" }, + "optional": true, + "default": [] + } + }, + "optional": true + }, + + "child": { + "type": "object", + "properties": { + "paths": { "$ref": "APIPaths" }, + + "script": { "$ref": "ExperimentURL" }, + + "scopes": { + "type": "array", + "minItems": 1, + "items": { "$ref": "APIChildScope", "onError": "warn" } + } + }, + "optional": true + } + } + }, + { + "id": "ExperimentURL", + "type": "string", + "format": "unresolvedRelativeUrl" + }, + { + "id": "APIPaths", + "type": "array", + "items": { "$ref": "APIPath" }, + "minItems": 1 + }, + { + "id": "APIPath", + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + { + "id": "APIEvents", + "type": "array", + "items": { "$ref": "APIEvent", "onError": "warn" } + }, + { + "id": "APIEvent", + "type": "string", + "enum": ["startup"] + }, + { + "id": "APIParentScope", + "type": "string", + "enum": ["addon_parent", "content_parent", "devtools_parent"] + }, + { + "id": "APIChildScope", + "type": "string", + "enum": ["addon_child", "content_child", "devtools_child"] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/extension.json b/toolkit/components/extensions/schemas/extension.json new file mode 100644 index 0000000000..389b33026b --- /dev/null +++ b/toolkit/components/extensions/schemas/extension.json @@ -0,0 +1,200 @@ +[ + { + "namespace": "extension", + "allowedContexts": ["content", "devtools"], + "description": "The <code>browser.extension</code> API has utilities that can be used by any extension page. It includes support for exchanging messages between an extension and its content scripts or between extensions, as described in detail in $(topic:messaging)[Message Passing].", + "properties": { + "lastError": { + "type": "object", + "optional": true, + "max_manifest_version": 2, + "deprecated": "Please use $(ref:runtime.lastError).", + "allowedContexts": ["content", "devtools"], + "description": "Set for the lifetime of a callback if an ansychronous extension api has resulted in an error. If no error has occured lastError will be <var>undefined</var>.", + "properties": { + "message": { + "type": "string", + "description": "Description of the error that has taken place." + } + }, + "additionalProperties": { + "type": "any" + } + }, + "inIncognitoContext": { + "type": "boolean", + "optional": true, + "allowedContexts": ["content", "devtools"], + "description": "True for content scripts running inside incognito tabs, and for extension pages running inside an incognito process. The latter only applies to extensions with 'split' incognito_behavior." + } + }, + "types": [ + { + "id": "ViewType", + "type": "string", + "enum": ["tab", "popup", "sidebar"], + "description": "The type of extension view." + } + ], + "functions": [ + { + "name": "getURL", + "type": "function", + "deprecated": "Please use $(ref:runtime.getURL).", + "max_manifest_version": 2, + "allowedContexts": ["content", "devtools"], + "description": "Converts a relative path within an extension install directory to a fully-qualified URL.", + "parameters": [ + { + "type": "string", + "name": "path", + "description": "A path to a resource within an extension expressed relative to its install directory." + } + ], + "returns": { + "type": "string", + "description": "The fully-qualified URL to the resource." + } + }, + { + "name": "getViews", + "type": "function", + "description": "Returns an array of the JavaScript 'window' objects for each of the pages running inside the current extension.", + "parameters": [ + { + "type": "object", + "name": "fetchProperties", + "optional": true, + "properties": { + "type": { + "$ref": "ViewType", + "optional": true, + "description": "The type of view to get. If omitted, returns all views (including background pages and tabs). Valid values: 'tab', 'popup', 'sidebar'." + }, + "windowId": { + "type": "integer", + "optional": true, + "description": "The window to restrict the search to. If omitted, returns all views." + }, + "tabId": { + "type": "integer", + "optional": true, + "description": "Find a view according to a tab id. If this field is omitted, returns all views." + } + } + } + ], + "returns": { + "type": "array", + "description": "Array of global objects", + "items": { + "type": "object", + "isInstanceOf": "Window", + "additionalProperties": { "type": "any" } + } + } + }, + { + "name": "getBackgroundPage", + "type": "function", + "description": "Returns the JavaScript 'window' object for the background page running inside the current extension. Returns null if the extension has no background page.", + "parameters": [], + "returns": { + "type": "object", + "optional": true, + "isInstanceOf": "Window", + "additionalProperties": { "type": "any" } + } + }, + { + "name": "isAllowedIncognitoAccess", + "type": "function", + "description": "Retrieves the state of the extension's access to Incognito-mode (as determined by the user-controlled 'Allowed in Incognito' checkbox.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "isAllowedAccess", + "type": "boolean", + "description": "True if the extension has access to Incognito mode, false otherwise." + } + ] + } + ] + }, + { + "name": "isAllowedFileSchemeAccess", + "type": "function", + "description": "Retrieves the state of the extension's access to the 'file://' scheme (as determined by the user-controlled 'Allow access to File URLs' checkbox.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "isAllowedAccess", + "type": "boolean", + "description": "True if the extension can access the 'file://' scheme, false otherwise." + } + ] + } + ] + }, + { + "name": "setUpdateUrlData", + "unsupported": true, + "type": "function", + "description": "Sets the value of the ap CGI parameter used in the extension's update URL. This value is ignored for extensions that are hosted in the browser vendor's store.", + "parameters": [{ "type": "string", "name": "data", "maxLength": 1024 }] + } + ], + "events": [ + { + "name": "onRequest", + "unsupported": true, + "deprecated": "Please use $(ref:runtime.onMessage).", + "type": "function", + "description": "Fired when a request is sent from either an extension process or a content script.", + "parameters": [ + { + "name": "request", + "type": "any", + "optional": true, + "description": "The request sent by the calling script." + }, + { "name": "sender", "$ref": "runtime.MessageSender" }, + { + "name": "sendResponse", + "type": "function", + "description": "Function to call (at most once) when you have a response. The argument should be any JSON-ifiable object, or undefined if there is no response. If you have more than one <code>onRequest</code> listener in the same document, then only one may send a response." + } + ] + }, + { + "name": "onRequestExternal", + "unsupported": true, + "deprecated": "Please use $(ref:runtime.onMessageExternal).", + "type": "function", + "description": "Fired when a request is sent from another extension.", + "parameters": [ + { + "name": "request", + "type": "any", + "optional": true, + "description": "The request sent by the calling script." + }, + { "name": "sender", "$ref": "runtime.MessageSender" }, + { + "name": "sendResponse", + "type": "function", + "description": "Function to call when you have a response. The argument should be any JSON-ifiable object, or undefined if there is no response." + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/extension_protocol_handlers.json b/toolkit/components/extensions/schemas/extension_protocol_handlers.json new file mode 100644 index 0000000000..b77e1e7426 --- /dev/null +++ b/toolkit/components/extensions/schemas/extension_protocol_handlers.json @@ -0,0 +1,75 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "id": "ProtocolHandler", + "type": "object", + "description": "Represents a protocol handler definition.", + "properties": { + "name": { + "description": "A user-readable title string for the protocol handler. This will be displayed to the user in interface objects as needed.", + "type": "string" + }, + "protocol": { + "description": "The protocol the site wishes to handle, specified as a string. For example, you can register to handle SMS text message links by registering to handle the \"sms\" scheme.", + "choices": [ + { + "type": "string", + "enum": [ + "bitcoin", + "dat", + "dweb", + "ftp", + "geo", + "gopher", + "im", + "ipfs", + "ipns", + "irc", + "ircs", + "magnet", + "mailto", + "matrix", + "mms", + "news", + "nntp", + "sip", + "sms", + "smsto", + "ssb", + "ssh", + "tel", + "urn", + "webcal", + "wtai", + "xmpp" + ] + }, + { + "type": "string", + "pattern": "^(ext|web)\\+[a-z0-9.+-]+$" + } + ] + }, + "uriTemplate": { + "description": "The URL of the handler, as a string. This string should include \"%s\" as a placeholder which will be replaced with the escaped URL of the document to be handled. This URL might be a true URL, or it could be a phone number, email address, or so forth.", + "preprocess": "localize", + "choices": [{ "$ref": "ExtensionURL" }, { "$ref": "HttpURL" }] + } + } + }, + { + "$extend": "WebExtensionManifest", + "properties": { + "protocol_handlers": { + "description": "A list of protocol handler definitions.", + "optional": true, + "type": "array", + "items": { "$ref": "ProtocolHandler" } + } + } + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/extension_types.json b/toolkit/components/extensions/schemas/extension_types.json new file mode 100644 index 0000000000..fd83cc494a --- /dev/null +++ b/toolkit/components/extensions/schemas/extension_types.json @@ -0,0 +1,164 @@ +[ + { + "namespace": "extensionTypes", + "description": "The <code>browser.extensionTypes</code> API contains type declarations for WebExtensions.", + "types": [ + { + "id": "ImageFormat", + "type": "string", + "enum": ["jpeg", "png"], + "description": "The format of an image." + }, + { + "id": "ImageDetails", + "type": "object", + "description": "Details about the format, quality, area and scale of the capture.", + "properties": { + "format": { + "$ref": "ImageFormat", + "optional": true, + "description": "The format of the resulting image. Default is <code>\"jpeg\"</code>." + }, + "quality": { + "type": "integer", + "optional": true, + "minimum": 0, + "maximum": 100, + "description": "When format is <code>\"jpeg\"</code>, controls the quality of the resulting image. This value is ignored for PNG images. As quality is decreased, the resulting image will have more visual artifacts, and the number of bytes needed to store it will decrease." + }, + "rect": { + "type": "object", + "optional": true, + "description": "The area of the document to capture, in CSS pixels, relative to the page. If omitted, capture the visible viewport.", + "properties": { + "x": { "type": "number" }, + "y": { "type": "number" }, + "width": { "type": "number" }, + "height": { "type": "number" } + } + }, + "scale": { + "type": "number", + "optional": true, + "description": "The scale of the resulting image. Defaults to <code>devicePixelRatio</code>." + }, + "resetScrollPosition": { + "type": "boolean", + "optional": true, + "description": "If true, temporarily resets the scroll position of the document to 0. Only takes effect if rect is also specified." + } + } + }, + { + "id": "RunAt", + "type": "string", + "enum": ["document_start", "document_end", "document_idle"], + "description": "The soonest that the JavaScript or CSS will be injected into the tab." + }, + { + "id": "CSSOrigin", + "type": "string", + "enum": ["user", "author"], + "description": "The origin of the CSS to inject, this affects the cascading order (priority) of the stylesheet." + }, + { + "id": "InjectDetails", + "type": "object", + "description": "Details of the script or CSS to inject. Either the code or the file property must be set, but both may not be set at the same time.", + "properties": { + "code": { + "type": "string", + "optional": true, + "description": "JavaScript or CSS code to inject.<br><br><b>Warning:</b><br>Be careful using the <code>code</code> parameter. Incorrect use of it may open your extension to <a href=\"https://en.wikipedia.org/wiki/Cross-site_scripting\">cross site scripting</a> attacks." + }, + "file": { + "type": "string", + "optional": true, + "description": "JavaScript or CSS file to inject." + }, + "allFrames": { + "type": "boolean", + "optional": true, + "description": "If allFrames is <code>true</code>, implies that the JavaScript or CSS should be injected into all frames of current page. By default, it's <code>false</code> and is only injected into the top frame." + }, + "matchAboutBlank": { + "type": "boolean", + "optional": true, + "description": "If matchAboutBlank is true, then the code is also injected in about:blank and about:srcdoc frames if your extension has access to its parent document. Code cannot be inserted in top-level about:-frames. By default it is <code>false</code>." + }, + "frameId": { + "type": "integer", + "minimum": 0, + "optional": true, + "description": "The ID of the frame to inject the script into. This may not be used in combination with <code>allFrames</code>." + }, + "runAt": { + "$ref": "RunAt", + "optional": true, + "default": "document_idle", + "description": "The soonest that the JavaScript or CSS will be injected into the tab. Defaults to \"document_idle\"." + }, + "cssOrigin": { + "$ref": "CSSOrigin", + "optional": true, + "description": "The css origin of the stylesheet to inject. Defaults to \"author\"." + } + } + }, + { + "id": "Date", + "choices": [ + { + "type": "string", + "format": "date" + }, + { + "type": "integer", + "minimum": 0 + }, + { + "type": "object", + "isInstanceOf": "Date", + "additionalProperties": { "type": "any" } + } + ] + }, + { + "id": "ExtensionFileOrCode", + "choices": [ + { + "type": "object", + "properties": { + "file": { + "$ref": "manifest.ExtensionURL" + } + } + }, + { + "type": "object", + "properties": { + "code": { + "type": "string" + } + } + } + ] + }, + { + "id": "PlainJSONValue", + "description": "A plain JSON value", + "choices": [ + { "type": "null" }, + { "type": "number" }, + { "type": "string" }, + { "type": "boolean" }, + { "type": "array", "items": { "$ref": "PlainJSONValue" } }, + { + "type": "object", + "additionalProperties": { "$ref": "PlainJSONValue" } + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/geckoProfiler.json b/toolkit/components/extensions/schemas/geckoProfiler.json new file mode 100644 index 0000000000..f2c6bec4cd --- /dev/null +++ b/toolkit/components/extensions/schemas/geckoProfiler.json @@ -0,0 +1,192 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "PermissionNoPrompt", + "choices": [ + { + "type": "string", + "enum": ["geckoProfiler"] + } + ] + } + ] + }, + { + "namespace": "geckoProfiler", + "description": "Exposes the browser's profiler.", + + "permissions": ["geckoProfiler"], + "types": [ + { + "id": "ProfilerFeature", + "type": "string", + "enum": [ + "java", + "js", + "mainthreadio", + "fileio", + "fileioall", + "nomarkerstacks", + "screenshots", + "seqstyle", + "stackwalk", + "jsallocations", + "nostacksampling", + "nativeallocations", + "ipcmessages", + "audiocallbacktracing", + "cpu", + "notimerresolutionchange", + "cpuallthreads", + "samplingallthreads", + "markersallthreads", + "unregisteredthreads", + "processcpu", + "power", + "responsiveness", + "cpufreq", + "bandwidth" + ] + }, + { + "id": "supports", + "type": "string", + "enum": ["windowLength"] + } + ], + "functions": [ + { + "name": "start", + "type": "function", + "description": "Starts the profiler with the specified settings.", + "async": true, + "parameters": [ + { + "name": "settings", + "type": "object", + "properties": { + "bufferSize": { + "type": "integer", + "minimum": 0, + "description": "The maximum size in bytes of the buffer used to store profiling data. A larger value allows capturing a profile that covers a greater amount of time." + }, + "windowLength": { + "type": "number", + "optional": true, + "description": "The length of the window of time that's kept in the buffer. Any collected samples are discarded as soon as they are older than the number of seconds specified in this setting. Zero means no duration restriction." + }, + "interval": { + "type": "number", + "description": "Interval in milliseconds between samples of profiling data. A smaller value will increase the detail of the profiles captured." + }, + "features": { + "type": "array", + "description": "A list of active features for the profiler.", + "items": { + "$ref": "ProfilerFeature" + } + }, + "threads": { + "type": "array", + "description": "A list of thread names for which to capture profiles.", + "optional": true, + "items": { + "type": "string" + } + } + } + } + ] + }, + { + "name": "stop", + "type": "function", + "description": "Stops the profiler and discards any captured profile data.", + "async": true, + "parameters": [] + }, + { + "name": "pause", + "type": "function", + "description": "Pauses the profiler, keeping any profile data that is already written.", + "async": true, + "parameters": [] + }, + { + "name": "resume", + "type": "function", + "description": "Resumes the profiler with the settings that were initially used to start it.", + "async": true, + "parameters": [] + }, + { + "name": "dumpProfileToFile", + "type": "function", + "description": "Gathers the profile data from the current profiling session, and writes it to disk. The returned promise resolves to a path that locates the created file.", + "async": true, + "parameters": [ + { + "type": "string", + "name": "fileName", + "description": "The name of the file inside the profile/profiler directory" + } + ] + }, + { + "name": "getProfile", + "type": "function", + "description": "Gathers the profile data from the current profiling session.", + "async": true, + "parameters": [] + }, + { + "name": "getProfileAsArrayBuffer", + "type": "function", + "description": "Gathers the profile data from the current profiling session. The returned promise resolves to an array buffer that contains a JSON string.", + "async": true, + "parameters": [] + }, + { + "name": "getProfileAsGzippedArrayBuffer", + "type": "function", + "description": "Gathers the profile data from the current profiling session. The returned promise resolves to an array buffer that contains a gzipped JSON string.", + "async": true, + "parameters": [] + }, + { + "name": "getSymbols", + "type": "function", + "description": "Gets the debug symbols for a particular library.", + "async": true, + "parameters": [ + { + "type": "string", + "name": "debugName", + "description": "The name of the library's debug file. For example, 'xul.pdb" + }, + { + "type": "string", + "name": "breakpadId", + "description": "The Breakpad ID of the library" + } + ] + } + ], + "events": [ + { + "name": "onRunning", + "type": "function", + "description": "Fires when the profiler starts/stops running.", + "parameters": [ + { + "name": "isRunning", + "type": "boolean", + "description": "Whether the profiler is running or not. Pausing the profiler will not affect this value." + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/i18n.json b/toolkit/components/extensions/schemas/i18n.json new file mode 100644 index 0000000000..f86efb98c0 --- /dev/null +++ b/toolkit/components/extensions/schemas/i18n.json @@ -0,0 +1,139 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "WebExtensionManifest", + "properties": { + "default_locale": { + "type": "string", + "optional": "true" + }, + "l10n_resources": { + "type": "array", + "items": { + "type": "string" + }, + "optional": true, + "privileged": true + } + } + } + ] + }, + { + "namespace": "i18n", + "allowedContexts": ["content", "devtools"], + "defaultContexts": ["content", "devtools"], + "description": "Use the <code>browser.i18n</code> infrastructure to implement internationalization across your whole app or extension.", + "types": [ + { + "id": "LanguageCode", + "type": "string", + "description": "An ISO language code such as <code>en</code> or <code>fr</code>. For a complete list of languages supported by this method, see <a href='http://src.chromium.org/viewvc/chrome/trunk/src/third_party/cld/languages/internal/languages.cc'>kLanguageInfoTable</a>. For an unknown language, <code>und</code> will be returned, which means that [percentage] of the text is unknown to CLD" + } + ], + "functions": [ + { + "name": "getAcceptLanguages", + "type": "function", + "description": "Gets the accept-languages of the browser. This is different from the locale used by the browser; to get the locale, use $(ref:i18n.getUILanguage).", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "languages", + "type": "array", + "items": { "$ref": "LanguageCode" }, + "description": "Array of LanguageCode" + } + ] + } + ] + }, + { + "name": "getMessage", + "type": "function", + "description": "Gets the localized string for the specified message. If the message is missing, this method returns an empty string (''). If the format of the <code>getMessage()</code> call is wrong — for example, <em>messageName</em> is not a string or the <em>substitutions</em> array has more than 9 elements — this method returns <code>undefined</code>.", + "parameters": [ + { + "type": "string", + "name": "messageName", + "description": "The name of the message, as specified in the <code>$(topic:i18n-messages)[messages.json]</code> file." + }, + { + "type": "any", + "name": "substitutions", + "optional": true, + "description": "Substitution strings, if the message requires any." + } + ], + "returns": { + "type": "string", + "description": "Message localized for current locale." + } + }, + { + "name": "getUILanguage", + "type": "function", + "description": "Gets the browser UI language of the browser. This is different from $(ref:i18n.getAcceptLanguages) which returns the preferred user languages.", + "parameters": [], + "returns": { + "type": "string", + "description": "The browser UI language code such as en-US or fr-FR." + } + }, + { + "name": "detectLanguage", + "type": "function", + "description": "Detects the language of the provided text using CLD.", + "async": "callback", + "parameters": [ + { + "type": "string", + "name": "text", + "description": "User input string to be translated." + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "type": "object", + "name": "result", + "description": "LanguageDetectionResult object that holds detected langugae reliability and array of DetectedLanguage", + "properties": { + "isReliable": { + "type": "boolean", + "description": "CLD detected language reliability" + }, + "languages": { + "type": "array", + "description": "array of detectedLanguage", + "items": { + "type": "object", + "description": "DetectedLanguage object that holds detected ISO language code and its percentage in the input string", + "properties": { + "language": { + "$ref": "LanguageCode" + }, + "percentage": { + "type": "integer", + "description": "The percentage of the detected language" + } + } + } + } + } + } + ] + } + ] + } + ], + "events": [] + } +] diff --git a/toolkit/components/extensions/schemas/identity.json b/toolkit/components/extensions/schemas/identity.json new file mode 100644 index 0000000000..947630aa8c --- /dev/null +++ b/toolkit/components/extensions/schemas/identity.json @@ -0,0 +1,219 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "PermissionNoPrompt", + "choices": [ + { + "type": "string", + "enum": ["identity"] + } + ] + } + ] + }, + { + "namespace": "identity", + "description": "Use the chrome.identity API to get OAuth2 access tokens. ", + "permissions": ["identity"], + "types": [ + { + "id": "AccountInfo", + "type": "object", + "description": "An object encapsulating an OAuth account id.", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for the account. This ID will not change for the lifetime of the account. " + } + } + } + ], + "functions": [ + { + "name": "getAccounts", + "type": "function", + "unsupported": true, + "description": "Retrieves a list of AccountInfo objects describing the accounts present on the profile.", + "async": "callback", + "parameters": [ + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "results", + "type": "array", + "items": { + "$ref": "AccountInfo" + } + } + ] + } + ] + }, + { + "name": "getAuthToken", + "type": "function", + "unsupported": true, + "description": "Gets an OAuth2 access token using the client ID and scopes specified in the oauth2 section of manifest.json.", + "async": "callback", + "parameters": [ + { + "name": "details", + "optional": true, + "type": "object", + "properties": { + "interactive": { + "optional": true, + "type": "boolean" + }, + "account": { + "optional": true, + "$ref": "AccountInfo" + }, + "scopes": { + "optional": true, + "type": "array", + "items": { + "type": "string" + } + } + } + }, + { + "name": "callback", + "optional": true, + "type": "function", + "parameters": [ + { + "name": "results", + "type": "array", + "items": { + "$ref": "AccountInfo" + } + } + ] + } + ] + }, + { + "name": "getProfileUserInfo", + "type": "function", + "unsupported": true, + "description": "Retrieves email address and obfuscated gaia id of the user signed into a profile.", + "async": "callback", + "parameters": [ + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "userinfo", + "type": "object", + "properties": { + "email": { "type": "string" }, + "id": { "type": "string" } + } + } + ] + } + ] + }, + { + "name": "removeCachedAuthToken", + "type": "function", + "unsupported": true, + "description": "Removes an OAuth2 access token from the Identity API's token cache.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "properties": { + "token": { "type": "string" } + } + }, + { + "name": "callback", + "optional": true, + "type": "function", + "parameters": [ + { + "name": "userinfo", + "type": "object", + "properties": { + "email": { "type": "string" }, + "id": { "type": "string" } + } + } + ] + } + ] + }, + { + "name": "launchWebAuthFlow", + "type": "function", + "description": "Starts an auth flow at the specified URL.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "properties": { + "url": { "$ref": "manifest.HttpURL" }, + "interactive": { "type": "boolean", "optional": true } + } + }, + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": " responseUrl", + "type": "string", + "optional": true + } + ] + } + ] + }, + { + "name": "getRedirectURL", + "type": "function", + "description": "Generates a redirect URL to be used in |launchWebAuthFlow|.", + "parameters": [ + { + "name": "path", + "type": "string", + "default": "", + "optional": true, + "description": "The path appended to the end of the generated URL. " + } + ], + "returns": { + "type": "string" + } + } + ], + "events": [ + { + "name": "onSignInChanged", + "unsupported": true, + "type": "function", + "description": "Fired when signin state changes for an account on the user's profile.", + "parameters": [ + { + "name": "account", + "$ref": "AccountInfo" + }, + { + "name": "signedIn", + "type": "boolean" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/idle.json b/toolkit/components/extensions/schemas/idle.json new file mode 100644 index 0000000000..69dd063058 --- /dev/null +++ b/toolkit/components/extensions/schemas/idle.json @@ -0,0 +1,66 @@ +[ + { + "namespace": "idle", + "description": "Use the <code>browser.idle</code> API to detect when the machine's idle state changes.", + "permissions": ["idle"], + "types": [ + { + "id": "IdleState", + "type": "string", + "enum": ["active", "idle"] + } + ], + "functions": [ + { + "name": "queryState", + "type": "function", + "description": "Returns \"idle\" if the user has not generated any input for a specified number of seconds, or \"active\" otherwise.", + "async": "callback", + "parameters": [ + { + "name": "detectionIntervalInSeconds", + "type": "integer", + "minimum": 15, + "description": "The system is considered idle if detectionIntervalInSeconds seconds have elapsed since the last user input detected." + }, + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "newState", + "$ref": "IdleState" + } + ] + } + ] + }, + { + "name": "setDetectionInterval", + "type": "function", + "description": "Sets the interval, in seconds, used to determine when the system is in an idle state for onStateChanged events. The default interval is 60 seconds.", + "parameters": [ + { + "name": "intervalInSeconds", + "type": "integer", + "minimum": 15, + "description": "Threshold, in seconds, used to determine when the system is in an idle state." + } + ] + } + ], + "events": [ + { + "name": "onStateChanged", + "type": "function", + "description": "Fired when the system changes to an active or idle state. The event fires with \"idle\" if the the user has not generated any input for a specified number of seconds, and \"active\" when the user generates input on an idle system.", + "parameters": [ + { + "name": "newState", + "$ref": "IdleState" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/jar.mn b/toolkit/components/extensions/schemas/jar.mn new file mode 100644 index 0000000000..59e2332ebe --- /dev/null +++ b/toolkit/components/extensions/schemas/jar.mn @@ -0,0 +1,54 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +toolkit.jar: +% content extensions %content/extensions/ + content/extensions/schemas/activity_log.json + content/extensions/schemas/alarms.json + content/extensions/schemas/browser_action.json + content/extensions/schemas/browser_settings.json + content/extensions/schemas/browsing_data.json +#ifndef ANDROID + content/extensions/schemas/captive_portal.json +#endif + content/extensions/schemas/clipboard.json + content/extensions/schemas/content_scripts.json + content/extensions/schemas/contextual_identities.json + content/extensions/schemas/cookies.json + content/extensions/schemas/declarative_net_request.json + content/extensions/schemas/dns.json + content/extensions/schemas/downloads.json + content/extensions/schemas/events.json + content/extensions/schemas/experiments.json + content/extensions/schemas/extension.json + content/extensions/schemas/extension_types.json + content/extensions/schemas/extension_protocol_handlers.json +#ifndef ANDROID + content/extensions/schemas/geckoProfiler.json +#endif + content/extensions/schemas/i18n.json +#ifndef ANDROID + content/extensions/schemas/identity.json +#endif + content/extensions/schemas/idle.json + content/extensions/schemas/management.json + content/extensions/schemas/manifest.json + content/extensions/schemas/native_manifest.json + content/extensions/schemas/network_status.json + content/extensions/schemas/notifications.json + content/extensions/schemas/page_action.json + content/extensions/schemas/permissions.json + content/extensions/schemas/proxy.json + content/extensions/schemas/privacy.json + content/extensions/schemas/runtime.json + content/extensions/schemas/scripting.json + content/extensions/schemas/storage.json + content/extensions/schemas/telemetry.json + content/extensions/schemas/test.json + content/extensions/schemas/theme.json + content/extensions/schemas/types.json + content/extensions/schemas/user_scripts.json + content/extensions/schemas/user_scripts_content.json + content/extensions/schemas/web_navigation.json + content/extensions/schemas/web_request.json diff --git a/toolkit/components/extensions/schemas/management.json b/toolkit/components/extensions/schemas/management.json new file mode 100644 index 0000000000..b75c0e2b4c --- /dev/null +++ b/toolkit/components/extensions/schemas/management.json @@ -0,0 +1,364 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermission", + "choices": [ + { + "type": "string", + "enum": ["management"] + } + ] + } + ] + }, + { + "namespace": "management", + "description": "The <code>browser.management</code> API provides ways to manage the list of extensions that are installed and running.", + "types": [ + { + "id": "IconInfo", + "description": "Information about an icon belonging to an extension.", + "type": "object", + "properties": { + "size": { + "type": "integer", + "description": "A number representing the width and height of the icon. Likely values include (but are not limited to) 128, 48, 24, and 16." + }, + "url": { + "type": "string", + "description": "The URL for this icon image. To display a grayscale version of the icon (to indicate that an extension is disabled, for example), append <code>?grayscale=true</code> to the URL." + } + } + }, + { + "id": "ExtensionDisabledReason", + "description": "A reason the item is disabled.", + "type": "string", + "enum": ["unknown", "permissions_increase"] + }, + { + "id": "ExtensionType", + "description": "The type of this extension, 'extension' or 'theme'.", + "type": "string", + "enum": ["extension", "theme"] + }, + { + "id": "ExtensionInstallType", + "description": "How the extension was installed. One of<br><var>development</var>: The extension was loaded unpacked in developer mode,<br><var>normal</var>: The extension was installed normally via an .xpi file,<br><var>sideload</var>: The extension was installed by other software on the machine,<br><var>other</var>: The extension was installed by other means.", + "type": "string", + "enum": ["development", "normal", "sideload", "other"] + }, + { + "id": "ExtensionInfo", + "description": "Information about an installed extension.", + "type": "object", + "properties": { + "id": { + "description": "The extension's unique identifier.", + "type": "string" + }, + "name": { + "description": "The name of this extension.", + "type": "string" + }, + "shortName": { + "description": "A short version of the name of this extension.", + "type": "string", + "optional": true + }, + "description": { + "description": "The description of this extension.", + "type": "string" + }, + "version": { + "description": "The <a href='manifest/version'>version</a> of this extension.", + "type": "string" + }, + "versionName": { + "description": "The <a href='manifest/version#version_name'>version name</a> of this extension if the manifest specified one.", + "type": "string", + "optional": true + }, + "mayDisable": { + "description": "Whether this extension can be disabled or uninstalled by the user.", + "type": "boolean" + }, + "enabled": { + "description": "Whether it is currently enabled or disabled.", + "type": "boolean" + }, + "disabledReason": { + "description": "A reason the item is disabled.", + "$ref": "ExtensionDisabledReason", + "optional": true + }, + "type": { + "description": "The type of this extension, 'extension' or 'theme'.", + "$ref": "ExtensionType" + }, + "homepageUrl": { + "description": "The URL of the homepage of this extension.", + "type": "string", + "optional": true + }, + "updateUrl": { + "description": "The update URL of this extension.", + "type": "string", + "optional": true + }, + "optionsUrl": { + "description": "The url for the item's options page, if it has one.", + "type": "string" + }, + "icons": { + "description": "A list of icon information. Note that this just reflects what was declared in the manifest, and the actual image at that url may be larger or smaller than what was declared, so you might consider using explicit width and height attributes on img tags referencing these images. See the <a href='manifest/icons'>manifest documentation on icons</a> for more details.", + "type": "array", + "optional": true, + "items": { + "$ref": "IconInfo" + } + }, + "permissions": { + "description": "Returns a list of API based permissions.", + "type": "array", + "optional": true, + "items": { + "type": "string" + } + }, + "hostPermissions": { + "description": "Returns a list of host based permissions.", + "type": "array", + "optional": true, + "items": { + "type": "string" + } + }, + "installType": { + "description": "How the extension was installed.", + "$ref": "ExtensionInstallType" + } + } + } + ], + "functions": [ + { + "name": "getAll", + "type": "function", + "permissions": ["management"], + "description": "Returns a list of information about installed extensions.", + "async": "callback", + "parameters": [ + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [ + { + "type": "array", + "name": "result", + "items": { + "$ref": "ExtensionInfo" + } + } + ] + } + ] + }, + { + "name": "get", + "type": "function", + "permissions": ["management"], + "description": "Returns information about the installed extension that has the given ID.", + "async": "callback", + "parameters": [ + { + "name": "id", + "$ref": "manifest.ExtensionID", + "description": "The ID from an item of $(ref:management.ExtensionInfo)." + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [ + { + "name": "result", + "$ref": "ExtensionInfo" + } + ] + } + ] + }, + { + "name": "install", + "type": "function", + "requireUserInput": true, + "permissions": ["management"], + "description": "Installs and enables a theme extension from the given url.", + "async": "callback", + "parameters": [ + { + "name": "options", + "type": "object", + "properties": { + "url": { + "$ref": "manifest.HttpURL", + "description": "URL pointing to the XPI file on addons.mozilla.org or similar." + }, + "hash": { + "type": "string", + "optional": true, + "pattern": "^(sha256|sha512):[0-9a-fA-F]{64,128}$", + "description": "A hash of the XPI file, using sha256 or stronger." + } + } + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [ + { + "name": "result", + "type": "object", + "properties": { + "id": { + "$ref": "manifest.ExtensionID" + } + } + } + ] + } + ] + }, + { + "name": "getSelf", + "type": "function", + "description": "Returns information about the calling extension. Note: This function can be used without requesting the 'management' permission in the manifest.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "result", + "$ref": "ExtensionInfo" + } + ] + } + ] + }, + { + "name": "uninstallSelf", + "type": "function", + "description": "Uninstalls the calling extension. Note: This function can be used without requesting the 'management' permission in the manifest.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "options", + "optional": true, + "properties": { + "showConfirmDialog": { + "type": "boolean", + "optional": true, + "description": "Whether or not a confirm-uninstall dialog should prompt the user. Defaults to false." + }, + "dialogMessage": { + "type": "string", + "optional": true, + "description": "The message to display to a user when being asked to confirm removal of the extension." + } + } + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "setEnabled", + "type": "function", + "permissions": ["management"], + "description": "Enables or disables the given add-on.", + "async": "callback", + "parameters": [ + { + "name": "id", + "type": "string", + "description": "ID of the add-on to enable/disable." + }, + { + "name": "enabled", + "type": "boolean", + "description": "Whether to enable or disable the add-on." + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [] + } + ] + } + ], + "events": [ + { + "name": "onDisabled", + "type": "function", + "permissions": ["management"], + "description": "Fired when an addon has been disabled.", + "parameters": [ + { + "name": "info", + "$ref": "ExtensionInfo" + } + ] + }, + { + "name": "onEnabled", + "type": "function", + "permissions": ["management"], + "description": "Fired when an addon has been enabled.", + "parameters": [ + { + "name": "info", + "$ref": "ExtensionInfo" + } + ] + }, + { + "name": "onInstalled", + "type": "function", + "permissions": ["management"], + "description": "Fired when an addon has been installed.", + "parameters": [ + { + "name": "info", + "$ref": "ExtensionInfo" + } + ] + }, + { + "name": "onUninstalled", + "type": "function", + "permissions": ["management"], + "description": "Fired when an addon has been uninstalled.", + "parameters": [ + { + "name": "info", + "$ref": "ExtensionInfo" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/manifest.json b/toolkit/components/extensions/schemas/manifest.json new file mode 100644 index 0000000000..14f78ba564 --- /dev/null +++ b/toolkit/components/extensions/schemas/manifest.json @@ -0,0 +1,782 @@ +[ + { + "namespace": "manifest", + "permissions": [], + "types": [ + { + "id": "ManifestBase", + "type": "object", + "description": "Common properties for all manifest.json files", + "properties": { + "manifest_version": { + "type": "integer", + "minimum": 2, + "maximum": 3, + "postprocess": "manifestVersionCheck" + }, + + "applications": { + "$ref": "DeprecatedApplications", + "description": "The applications property is deprecated, please use 'browser_specific_settings'", + "optional": true, + "max_manifest_version": 2 + }, + + "browser_specific_settings": { + "$ref": "BrowserSpecificSettings", + "optional": true + }, + + "name": { + "type": "string", + "optional": false, + "preprocess": "localize" + }, + + "short_name": { + "type": "string", + "optional": true, + "preprocess": "localize" + }, + + "description": { + "type": "string", + "optional": true, + "preprocess": "localize" + }, + + "author": { + "type": "string", + "optional": true, + "preprocess": "localize", + "onError": "warn" + }, + + "version": { + "type": "string", + "optional": false, + "format": "versionString" + }, + + "homepage_url": { + "type": "string", + "format": "url", + "optional": true, + "preprocess": "localize" + }, + + "install_origins": { + "type": "array", + "optional": true, + "items": { + "type": "string", + "format": "origin" + } + }, + + "developer": { + "type": "object", + "optional": true, + "properties": { + "name": { + "type": "string", + "optional": true, + "preprocess": "localize" + }, + "url": { + "type": "string", + "format": "url", + "optional": true, + "preprocess": "localize", + "onError": "warn" + } + } + } + } + }, + { + "id": "WebExtensionManifest", + "type": "object", + "description": "Represents a WebExtension manifest.json file", + + "$import": "ManifestBase", + "properties": { + "minimum_chrome_version": { + "type": "string", + "optional": true + }, + + "minimum_opera_version": { + "type": "string", + "optional": true + }, + + "icons": { + "type": "object", + "optional": true, + "patternProperties": { + "^[1-9]\\d*$": { "$ref": "ExtensionFileUrl" } + } + }, + + "incognito": { + "type": "string", + "enum": ["not_allowed", "spanning"], + "default": "spanning", + "optional": true + }, + + "background": { + "choices": [ + { + "type": "object", + "properties": { + "page": { "$ref": "ExtensionURL" }, + "persistent": { + "optional": true, + "type": "boolean", + "max_manifest_version": 2, + "default": true + } + }, + "additionalProperties": { "$ref": "UnrecognizedProperty" } + }, + { + "type": "object", + "properties": { + "scripts": { + "type": "array", + "items": { "$ref": "ExtensionURL" } + }, + "type": { + "optional": true, + "type": "string", + "enum": ["module", "classic"] + }, + "persistent": { + "optional": true, + "type": "boolean", + "max_manifest_version": 2, + "default": true + } + }, + "additionalProperties": { "$ref": "UnrecognizedProperty" } + }, + { + "type": "object", + "properties": { + "service_worker": { "$ref": "ExtensionURL" } + }, + "postprocess": "requireBackgroundServiceWorkerEnabled" + } + ], + "optional": true + }, + + "options_ui": { + "type": "object", + + "optional": true, + + "properties": { + "page": { "$ref": "ExtensionURL" }, + "browser_style": { + "type": "boolean", + "optional": true, + "description": "Defaults to true in Manifest V2; Deprecated in Manifest V3." + }, + "chrome_style": { + "type": "boolean", + "optional": true, + "max_manifest_version": 2, + "description": "chrome_style is ignored in Firefox. Its replacement (browser_style) has been deprecated." + }, + "open_in_tab": { + "type": "boolean", + "optional": true + } + }, + "additionalProperties": { "$ref": "UnrecognizedProperty" } + }, + + "content_scripts": { + "type": "array", + "optional": true, + "items": { "$ref": "ContentScript" } + }, + + "content_security_policy": { + "optional": true, + "onError": "warn", + "choices": [ + { + "max_manifest_version": 2, + "type": "string", + "format": "contentSecurityPolicy" + }, + { + "min_manifest_version": 3, + "type": "object", + "additionalProperties": { + "$ref": "UnrecognizedProperty" + }, + "properties": { + "extension_pages": { + "type": "string", + "optional": true, + "format": "contentSecurityPolicy", + "description": "The Content Security Policy used for extension pages." + } + } + } + ] + }, + + "permissions": { + "default": [], + "optional": true, + "choices": [ + { + "max_manifest_version": 2, + "type": "array", + "items": { + "$ref": "PermissionOrOrigin", + "onError": "warn" + } + }, + { + "min_manifest_version": 3, + "type": "array", + "items": { + "$ref": "Permission", + "onError": "warn" + } + } + ] + }, + + "granted_host_permissions": { + "type": "boolean", + "optional": true, + "default": false + }, + + "host_permissions": { + "min_manifest_version": 3, + "type": "array", + "items": { + "$ref": "MatchPattern", + "onError": "warn" + }, + "optional": true, + "default": [] + }, + + "optional_permissions": { + "type": "array", + "items": { + "$ref": "OptionalPermissionOrOrigin", + "onError": "warn" + }, + "optional": true, + "default": [] + }, + + "web_accessible_resources": { + "optional": true, + "choices": [ + { + "max_manifest_version": 2, + "type": "array", + "items": { "type": "string" } + }, + { + "min_manifest_version": 3, + "type": "array", + "postprocess": "webAccessibleMatching", + "items": { + "type": "object", + "properties": { + "resources": { + "type": "array", + "items": { "type": "string" } + }, + "matches": { + "optional": true, + "type": "array", + "items": { "$ref": "MatchPattern" } + }, + "extension_ids": { + "optional": true, + "type": "array", + "items": { + "choices": [ + { "$ref": "ExtensionID" }, + { "type": "string", "enum": ["*"] } + ] + } + } + }, + "additionalProperties": { "$ref": "UnrecognizedProperty" } + } + } + ] + }, + + "hidden": { + "type": "boolean", + "optional": true, + "default": false + } + }, + + "additionalProperties": { "$ref": "UnrecognizedProperty" } + }, + { + "id": "WebExtensionLangpackManifest", + "type": "object", + "description": "Represents a WebExtension language pack manifest.json file", + + "$import": "ManifestBase", + "properties": { + "langpack_id": { + "type": "string", + "pattern": "^[a-zA-Z][a-zA-Z-]+$" + }, + + "languages": { + "type": "object", + "patternProperties": { + "^[a-z]{2}[a-zA-Z-]*$": { + "type": "object", + "properties": { + "chrome_resources": { + "type": "object", + "patternProperties": { + "^[a-zA-Z-.]+$": { + "choices": [ + { + "$ref": "ExtensionURL" + }, + { + "type": "object", + "patternProperties": { + "^[a-z]+$": { + "$ref": "ExtensionURL" + } + } + } + ] + } + } + }, + "version": { + "type": "string" + } + } + } + } + }, + "sources": { + "type": "object", + "optional": true, + "patternProperties": { + "^[a-z]+$": { + "type": "object", + "properties": { + "base_path": { + "$ref": "ExtensionURL" + }, + "paths": { + "type": "array", + "items": { + "type": "string", + "format": "strictRelativeUrl" + }, + "optional": true + } + } + } + } + } + } + }, + { + "id": "WebExtensionDictionaryManifest", + "type": "object", + "description": "Represents a WebExtension dictionary manifest.json file", + + "$import": "ManifestBase", + "properties": { + "dictionaries": { + "type": "object", + "patternProperties": { + "^[a-z]{2}[a-zA-Z-]*$": { + "type": "string", + "format": "strictRelativeUrl", + "pattern": "\\.dic$" + } + } + } + } + }, + { + "id": "WebExtensionSitePermissionsManifest", + "type": "object", + "description": "Represents a WebExtension site permissions manifest.json file", + + "$import": "ManifestBase", + "properties": { + "site_permissions": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "SitePermission" + } + }, + "install_origins": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "type": "string", + "format": "origin" + } + } + } + }, + { + "id": "ThemeIcons", + "type": "object", + "properties": { + "light": { + "$ref": "ExtensionURL", + "description": "A light icon to use for dark themes" + }, + "dark": { + "$ref": "ExtensionURL", + "description": "The dark icon to use for light themes" + }, + "size": { + "type": "integer", + "description": "The size of the icons" + } + }, + "additionalProperties": { "$ref": "UnrecognizedProperty" } + }, + { + "id": "OptionalPermissionNoPrompt", + "choices": [ + { + "type": "string", + "enum": ["idle"] + } + ] + }, + { + "id": "OptionalPermission", + "choices": [ + { "$ref": "OptionalPermissionNoPrompt" }, + { + "type": "string", + "enum": [ + "clipboardRead", + "clipboardWrite", + "geolocation", + "notifications" + ] + } + ] + }, + { + "id": "OptionalPermissionOrOrigin", + "choices": [ + { "$ref": "OptionalPermission" }, + { "$ref": "MatchPattern" } + ] + }, + { + "id": "PermissionPrivileged", + "choices": [ + { + "type": "string", + "enum": ["mozillaAddons"] + } + ] + }, + { + "id": "PermissionNoPrompt", + "choices": [ + { "$ref": "OptionalPermissionNoPrompt" }, + { "$ref": "PermissionPrivileged" }, + { + "type": "string", + "enum": ["alarms", "storage", "unlimitedStorage"] + } + ] + }, + { + "id": "Permission", + "choices": [ + { "$ref": "PermissionNoPrompt" }, + { "$ref": "OptionalPermission" } + ] + }, + { + "id": "PermissionOrOrigin", + "choices": [{ "$ref": "Permission" }, { "$ref": "MatchPattern" }] + }, + { + "id": "SitePermission", + "choices": [ + { + "type": "string", + "enum": ["midi", "midi-sysex"] + } + ] + }, + { + "id": "HttpURL", + "type": "string", + "format": "url", + "pattern": "^https?://.*$" + }, + { + "id": "ExtensionURL", + "type": "string", + "format": "strictRelativeUrl" + }, + { + "id": "ExtensionFileUrl", + "type": "string", + "format": "strictRelativeUrl", + "pattern": "\\S", + "preprocess": "localize" + }, + { + "id": "ImageDataOrExtensionURL", + "type": "string", + "format": "imageDataOrStrictRelativeUrl" + }, + { + "id": "ExtensionID", + "choices": [ + { + "type": "string", + "pattern": "(?i)^\\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\}$" + }, + { + "type": "string", + "pattern": "(?i)^[a-z0-9-._]*@[a-z0-9-._]+$" + } + ] + }, + { + "id": "FirefoxSpecificProperties", + "type": "object", + "properties": { + "id": { + "$ref": "ExtensionID", + "optional": true + }, + + "update_url": { + "type": "string", + "format": "url", + "optional": true + }, + + "strict_min_version": { + "type": "string", + "optional": true + }, + + "strict_max_version": { + "type": "string", + "optional": true + } + } + }, + { + "id": "GeckoAndroidSpecificProperties", + "type": "object", + "properties": { + "strict_min_version": { + "type": "string", + "optional": true + }, + "strict_max_version": { + "type": "string", + "optional": true + } + }, + "additionalProperties": { "type": "any" } + }, + { + "id": "DeprecatedApplications", + "type": "object", + "properties": { + "gecko": { + "$ref": "FirefoxSpecificProperties", + "optional": true + }, + "gecko_android": { + "$ref": "GeckoAndroidSpecificProperties", + "optional": true, + "unsupported": true + } + }, + "additionalProperties": { "type": "any" } + }, + { + "id": "BrowserSpecificSettings", + "type": "object", + "properties": { + "gecko": { + "$ref": "FirefoxSpecificProperties", + "optional": true + }, + "gecko_android": { + "$ref": "GeckoAndroidSpecificProperties", + "optional": true + } + }, + "additionalProperties": { "type": "any" } + }, + { + "id": "MatchPattern", + "choices": [ + { + "type": "string", + "enum": ["<all_urls>"] + }, + { + "$ref": "MatchPatternRestricted" + }, + { + "$ref": "MatchPatternUnestricted" + } + ] + }, + { + "id": "MatchPatternRestricted", + "description": "Same as MatchPattern above, but excludes <all_urls>", + "choices": [ + { + "type": "string", + "pattern": "^(https?|wss?|file|ftp|\\*)://(\\*|\\*\\.[^*/]+|[^*/]+)/.*$" + }, + { + "type": "string", + "pattern": "^file:///.*$" + } + ] + }, + { + "id": "MatchPatternUnestricted", + "description": "Mostly unrestricted match patterns for privileged add-ons. This should technically be rejected for unprivileged add-ons, but, reasons. The MatchPattern class will still refuse privileged schemes for those extensions.", + "choices": [ + { + "type": "string", + "pattern": "^resource://(\\*|\\*\\.[^*/]+|[^*/]+)/.*$|^about:" + } + ] + }, + { + "id": "ContentScript", + "type": "object", + "description": "Details of the script or CSS to inject. Either the code or the file property must be set, but both may not be set at the same time. Based on InjectDetails, but using underscore rather than camel case naming conventions.", + "additionalProperties": { "$ref": "UnrecognizedProperty" }, + "properties": { + "matches": { + "type": "array", + "optional": false, + "minItems": 1, + "items": { "$ref": "MatchPattern" } + }, + "exclude_matches": { + "type": "array", + "optional": true, + "minItems": 1, + "items": { "$ref": "MatchPattern" } + }, + "include_globs": { + "type": "array", + "optional": true, + "items": { "type": "string" } + }, + "exclude_globs": { + "type": "array", + "optional": true, + "items": { "type": "string" } + }, + "css": { + "type": "array", + "optional": true, + "description": "The list of CSS files to inject", + "items": { "$ref": "ExtensionURL" } + }, + "js": { + "type": "array", + "optional": true, + "description": "The list of JS files to inject", + "items": { "$ref": "ExtensionURL" } + }, + "all_frames": { + "type": "boolean", + "optional": true, + "description": "If allFrames is <code>true</code>, implies that the JavaScript or CSS should be injected into all frames of current page. By default, it's <code>false</code> and is only injected into the top frame." + }, + "match_about_blank": { + "type": "boolean", + "optional": true, + "description": "If matchAboutBlank is true, then the code is also injected in about:blank and about:srcdoc frames if your extension has access to its parent document. Code cannot be inserted in top-level about:-frames. By default it is <code>false</code>." + }, + "run_at": { + "$ref": "extensionTypes.RunAt", + "optional": true, + "default": "document_idle", + "description": "The soonest that the JavaScript or CSS will be injected into the tab. Defaults to \"document_idle\"." + } + } + }, + { + "id": "IconPath", + "choices": [ + { + "type": "object", + "patternProperties": { + "^[1-9]\\d*$": { "$ref": "ExtensionFileUrl" } + }, + "additionalProperties": false + }, + { "$ref": "ExtensionFileUrl" } + ] + }, + { + "id": "IconImageData", + "choices": [ + { + "type": "object", + "patternProperties": { + "^[1-9]\\d*$": { "$ref": "ImageData" } + }, + "additionalProperties": false + }, + { "$ref": "ImageData" } + ] + }, + { + "id": "ImageData", + "type": "object", + "isInstanceOf": "ImageData", + "postprocess": "convertImageDataToURL" + }, + { + "id": "UnrecognizedProperty", + "type": "any", + "deprecated": "An unexpected property was found in the WebExtension manifest." + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/moz.build b/toolkit/components/extensions/schemas/moz.build new file mode 100644 index 0000000000..d988c0ff9b --- /dev/null +++ b/toolkit/components/extensions/schemas/moz.build @@ -0,0 +1,7 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +JAR_MANIFESTS += ["jar.mn"] diff --git a/toolkit/components/extensions/schemas/native_manifest.json b/toolkit/components/extensions/schemas/native_manifest.json new file mode 100644 index 0000000000..b08262e59b --- /dev/null +++ b/toolkit/components/extensions/schemas/native_manifest.json @@ -0,0 +1,60 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "id": "NativeManifest", + "description": "Represents a native manifest file", + "choices": [ + { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^\\w+(\\.\\w+)*$" + }, + "description": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["pkcs11", "stdio"] + }, + "allowed_extensions": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "manifest.ExtensionID" + } + } + } + }, + { + "type": "object", + "properties": { + "name": { + "$ref": "manifest.ExtensionID" + }, + "description": { + "type": "string" + }, + "data": { + "type": "object", + "additionalProperties": { + "type": "any" + } + }, + "type": { + "type": "string", + "enum": ["storage"] + } + } + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/network_status.json b/toolkit/components/extensions/schemas/network_status.json new file mode 100644 index 0000000000..bf2c9cb494 --- /dev/null +++ b/toolkit/components/extensions/schemas/network_status.json @@ -0,0 +1,66 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "PermissionPrivileged", + "choices": [ + { + "type": "string", + "enum": ["networkStatus"] + } + ] + } + ] + }, + { + "namespace": "networkStatus", + "description": "This API provides the ability to determine the status of and detect changes in the network connection. This API can only be used in privileged extensions.", + "permissions": ["networkStatus"], + "types": [ + { + "id": "NetworkLinkInfo", + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["unknown", "up", "down"], + "description": "Status of the network link, if \"unknown\" then link is usually assumed to be \"up\"" + }, + "type": { + "type": "string", + "enum": ["unknown", "ethernet", "usb", "wifi", "wimax", "mobile"], + "description": "If known, the type of network connection that is avialable." + }, + "id": { + "type": "string", + "optional": true, + "description": "If known, the network id or name." + } + } + } + ], + "functions": [ + { + "name": "getLinkInfo", + "type": "function", + "description": "Returns the $(ref:NetworkLinkInfo} of the current network connection.", + "async": true, + "parameters": [] + } + ], + "events": [ + { + "name": "onConnectionChanged", + "type": "function", + "description": "Fired when the network connection state changes.", + "parameters": [ + { + "name": "details", + "$ref": "NetworkLinkInfo" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/notifications.json b/toolkit/components/extensions/schemas/notifications.json new file mode 100644 index 0000000000..79a5497997 --- /dev/null +++ b/toolkit/components/extensions/schemas/notifications.json @@ -0,0 +1,416 @@ +[ + { + "namespace": "notifications", + "permissions": ["notifications"], + "types": [ + { + "id": "TemplateType", + "type": "string", + "enum": ["basic", "image", "list", "progress"] + }, + { + "id": "PermissionLevel", + "type": "string", + "enum": ["granted", "denied"] + }, + { + "id": "NotificationItem", + "type": "object", + "properties": { + "title": { + "description": "Title of one item of a list notification.", + "type": "string" + }, + "message": { + "description": "Additional details about this item.", + "type": "string" + } + } + }, + { + "id": "CreateNotificationOptions", + "type": "object", + "properties": { + "type": { + "description": "Which type of notification to display.", + "$ref": "TemplateType" + }, + "iconUrl": { + "optional": true, + "description": "A URL to the sender's avatar, app icon, or a thumbnail for image notifications.", + "type": "string" + }, + "appIconMaskUrl": { + "optional": true, + "description": "A URL to the app icon mask.", + "type": "string" + }, + "title": { + "description": "Title of the notification (e.g. sender name for email).", + "type": "string" + }, + "message": { + "description": "Main notification content.", + "type": "string" + }, + "contextMessage": { + "optional": true, + "description": "Alternate notification content with a lower-weight font.", + "type": "string" + }, + "priority": { + "optional": true, + "description": "Priority ranges from -2 to 2. -2 is lowest priority. 2 is highest. Zero is default.", + "type": "integer", + "minimum": -2, + "maximum": 2 + }, + "eventTime": { + "optional": true, + "description": "A timestamp associated with the notification, in milliseconds past the epoch.", + "type": "number" + }, + "buttons": { + "unsupported": true, + "optional": true, + "description": "Text and icons for up to two notification action buttons.", + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "iconUrl": { + "optional": true, + "type": "string" + } + } + } + }, + "imageUrl": { + "optional": true, + "description": "A URL to the image thumbnail for image-type notifications.", + "type": "string" + }, + "items": { + "optional": true, + "description": "Items for multi-item notifications.", + "type": "array", + "items": { "$ref": "NotificationItem" } + }, + "progress": { + "optional": true, + "description": "Current progress ranges from 0 to 100.", + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "isClickable": { + "optional": true, + "description": "Whether to show UI indicating that the app will visibly respond to clicks on the body of a notification.", + "type": "boolean" + } + } + }, + { + "id": "UpdateNotificationOptions", + "type": "object", + "properties": { + "type": { + "optional": true, + "description": "Which type of notification to display.", + "$ref": "TemplateType" + }, + "iconUrl": { + "optional": true, + "description": "A URL to the sender's avatar, app icon, or a thumbnail for image notifications.", + "type": "string" + }, + "appIconMaskUrl": { + "optional": true, + "description": "A URL to the app icon mask.", + "type": "string" + }, + "title": { + "optional": true, + "description": "Title of the notification (e.g. sender name for email).", + "type": "string" + }, + "message": { + "optional": true, + "description": "Main notification content.", + "type": "string" + }, + "contextMessage": { + "optional": true, + "description": "Alternate notification content with a lower-weight font.", + "type": "string" + }, + "priority": { + "optional": true, + "description": "Priority ranges from -2 to 2. -2 is lowest priority. 2 is highest. Zero is default.", + "type": "integer", + "minimum": -2, + "maximum": 2 + }, + "eventTime": { + "optional": true, + "description": "A timestamp associated with the notification, in milliseconds past the epoch.", + "type": "number" + }, + "buttons": { + "unsupported": true, + "optional": true, + "description": "Text and icons for up to two notification action buttons.", + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "iconUrl": { + "optional": true, + "type": "string" + } + } + } + }, + "imageUrl": { + "optional": true, + "description": "A URL to the image thumbnail for image-type notifications.", + "type": "string" + }, + "items": { + "optional": true, + "description": "Items for multi-item notifications.", + "type": "array", + "items": { "$ref": "NotificationItem" } + }, + "progress": { + "optional": true, + "description": "Current progress ranges from 0 to 100.", + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "isClickable": { + "optional": true, + "description": "Whether to show UI indicating that the app will visibly respond to clicks on the body of a notification.", + "type": "boolean" + } + } + } + ], + "functions": [ + { + "name": "create", + "type": "function", + "description": "Creates and displays a notification.", + "async": "callback", + "parameters": [ + { + "optional": true, + "type": "string", + "name": "notificationId", + "description": "Identifier of the notification. If it is empty, this method generates an id. If it matches an existing notification, this method first clears that notification before proceeding with the create operation." + }, + { + "$ref": "CreateNotificationOptions", + "name": "options", + "description": "Contents of the notification." + }, + { + "optional": true, + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "notificationId", + "type": "string", + "description": "The notification id (either supplied or generated) that represents the created notification." + } + ] + } + ] + }, + { + "name": "update", + "unsupported": true, + "type": "function", + "description": "Updates an existing notification.", + "async": "callback", + "parameters": [ + { + "type": "string", + "name": "notificationId", + "description": "The id of the notification to be updated." + }, + { + "$ref": "UpdateNotificationOptions", + "name": "options", + "description": "Contents of the notification to update to." + }, + { + "optional": true, + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "wasUpdated", + "type": "boolean", + "description": "Indicates whether a matching notification existed." + } + ] + } + ] + }, + { + "name": "clear", + "type": "function", + "description": "Clears an existing notification.", + "async": "callback", + "parameters": [ + { + "type": "string", + "name": "notificationId", + "description": "The id of the notification to be updated." + }, + { + "optional": true, + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "wasCleared", + "type": "boolean", + "description": "Indicates whether a matching notification existed." + } + ] + } + ] + }, + { + "name": "getAll", + "type": "function", + "description": "Retrieves all the notifications.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "notifications", + "type": "object", + "additionalProperties": { "$ref": "CreateNotificationOptions" }, + "description": "The set of notifications currently in the system." + } + ] + } + ] + }, + { + "name": "getPermissionLevel", + "unsupported": true, + "type": "function", + "description": "Retrieves whether the user has enabled notifications from this app or extension.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "level", + "$ref": "PermissionLevel", + "description": "The current permission level." + } + ] + } + ] + } + ], + "events": [ + { + "name": "onClosed", + "type": "function", + "description": "Fired when the notification closed, either by the system or by user action.", + "parameters": [ + { + "type": "string", + "name": "notificationId", + "description": "The notificationId of the closed notification." + }, + { + "type": "boolean", + "name": "byUser", + "description": "True if the notification was closed by the user." + } + ] + }, + { + "name": "onClicked", + "type": "function", + "description": "Fired when the user clicked in a non-button area of the notification.", + "parameters": [ + { + "type": "string", + "name": "notificationId", + "description": "The notificationId of the clicked notification." + } + ] + }, + { + "name": "onButtonClicked", + "type": "function", + "description": "Fired when the user pressed a button in the notification.", + "parameters": [ + { + "type": "string", + "name": "notificationId", + "description": "The notificationId of the clicked notification." + }, + { + "type": "number", + "name": "buttonIndex", + "description": "The index of the button clicked by the user." + } + ] + }, + { + "name": "onPermissionLevelChanged", + "unsupported": true, + "type": "function", + "description": "Fired when the user changes the permission level.", + "parameters": [ + { + "$ref": "PermissionLevel", + "name": "level", + "description": "The new permission level." + } + ] + }, + { + "name": "onShowSettings", + "unsupported": true, + "type": "function", + "description": "Fired when the user clicked on a link for the app's notification settings.", + "parameters": [] + }, + { + "name": "onShown", + "type": "function", + "description": "Fired when the notification is shown.", + "parameters": [ + { + "type": "string", + "name": "notificationId", + "description": "The notificationId of the shown notification." + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/page_action.json b/toolkit/components/extensions/schemas/page_action.json new file mode 100644 index 0000000000..e064f87d44 --- /dev/null +++ b/toolkit/components/extensions/schemas/page_action.json @@ -0,0 +1,329 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "WebExtensionManifest", + "properties": { + "page_action": { + "type": "object", + "additionalProperties": { "$ref": "UnrecognizedProperty" }, + "properties": { + "default_title": { + "type": "string", + "optional": true, + "preprocess": "localize" + }, + "default_icon": { + "$ref": "IconPath", + "optional": true + }, + "default_popup": { + "type": "string", + "format": "relativeUrl", + "optional": true, + "preprocess": "localize" + }, + "browser_style": { + "type": "boolean", + "optional": true, + "description": "Deprecated in Manifest V3." + }, + "show_matches": { + "type": "array", + "optional": true, + "minItems": 1, + "items": { "$ref": "MatchPattern" } + }, + "hide_matches": { + "type": "array", + "optional": true, + "minItems": 1, + "items": { "$ref": "MatchPatternRestricted" } + }, + "pinned": { + "type": "boolean", + "optional": true, + "default": true + } + }, + "optional": true + } + } + } + ] + }, + { + "namespace": "pageAction", + "description": "Use the <code>browser.pageAction</code> API to put icons inside the address bar. Page actions represent actions that can be taken on the current page, but that aren't applicable to all pages.", + "permissions": ["manifest:page_action"], + "types": [ + { + "id": "ImageDataType", + "type": "object", + "isInstanceOf": "ImageData", + "additionalProperties": { "type": "any" }, + "postprocess": "convertImageDataToURL", + "description": "Pixel data for an image. Must be an ImageData object (for example, from a <code>canvas</code> element)." + }, + { + "id": "OnClickData", + "type": "object", + "description": "Information sent when a page action is clicked.", + "properties": { + "modifiers": { + "type": "array", + "items": { + "type": "string", + "enum": ["Shift", "Alt", "Command", "Ctrl", "MacCtrl"] + }, + "description": "An array of keyboard modifiers that were held while the menu item was clicked." + }, + "button": { + "type": "integer", + "optional": true, + "description": "An integer value of button by which menu item was clicked." + } + } + } + ], + "functions": [ + { + "name": "show", + "type": "function", + "async": "callback", + "description": "Shows the page action. The page action is shown whenever the tab is selected.", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "description": "The id of the tab for which you want to modify the page action." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "hide", + "type": "function", + "async": "callback", + "description": "Hides the page action.", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "description": "The id of the tab for which you want to modify the page action." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "isShown", + "type": "function", + "description": "Checks whether the page action is shown.", + "async": true, + "parameters": [ + { + "name": "details", + "type": "object", + "properties": { + "tabId": { + "type": "integer", + "description": "Specify the tab to get the shownness from." + } + } + } + ] + }, + { + "name": "setTitle", + "type": "function", + "description": "Sets the title of the page action. This is displayed in a tooltip over the page action.", + "parameters": [ + { + "name": "details", + "type": "object", + "properties": { + "tabId": { + "type": "integer", + "minimum": 0, + "description": "The id of the tab for which you want to modify the page action." + }, + "title": { + "choices": [{ "type": "string" }, { "type": "null" }], + "description": "The tooltip string." + } + } + } + ] + }, + { + "name": "getTitle", + "type": "function", + "description": "Gets the title of the page action.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "properties": { + "tabId": { + "type": "integer", + "description": "Specify the tab to get the title from." + } + } + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "result", + "type": "string" + } + ] + } + ] + }, + { + "name": "setIcon", + "type": "function", + "description": "Sets the icon for the page action. The icon can be specified either as the path to an image file or as the pixel data from a canvas element, or as dictionary of either one of those. Either the <b>path</b> or the <b>imageData</b> property must be specified.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "properties": { + "tabId": { + "type": "integer", + "minimum": 0, + "description": "The id of the tab for which you want to modify the page action." + }, + "imageData": { + "choices": [ + { "$ref": "ImageDataType" }, + { + "type": "object", + "patternProperties": { + "^[1-9]\\d*$": { "$ref": "ImageDataType" } + } + } + ], + "optional": true, + "description": "Either an ImageData object or a dictionary {size -> ImageData} representing icon to be set. If the icon is specified as a dictionary, the actual image to be used is chosen depending on screen's pixel density. If the number of image pixels that fit into one screen space unit equals <code>scale</code>, then image with size <code>scale</code> * 19 will be selected. Initially only scales 1 and 2 will be supported. At least one image must be specified. Note that 'details.imageData = foo' is equivalent to 'details.imageData = {'19': foo}'" + }, + "path": { + "choices": [ + { "type": "string" }, + { + "type": "object", + "patternProperties": { + "^[1-9]\\d*$": { "type": "string" } + } + } + ], + "optional": true, + "description": "Either a relative image path or a dictionary {size -> relative image path} pointing to icon to be set. If the icon is specified as a dictionary, the actual image to be used is chosen depending on screen's pixel density. If the number of image pixels that fit into one screen space unit equals <code>scale</code>, then image with size <code>scale</code> * 19 will be selected. Initially only scales 1 and 2 will be supported. At least one image must be specified. Note that 'details.path = foo' is equivalent to 'details.imageData = {'19': foo}'" + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "setPopup", + "type": "function", + "async": true, + "description": "Sets the html document to be opened as a popup when the user clicks on the page action's icon.", + "parameters": [ + { + "name": "details", + "type": "object", + "properties": { + "tabId": { + "type": "integer", + "minimum": 0, + "description": "The id of the tab for which you want to modify the page action." + }, + "popup": { + "choices": [{ "type": "string" }, { "type": "null" }], + "description": "The html file to show in a popup. If set to the empty string (''), no popup is shown." + } + } + } + ] + }, + { + "name": "getPopup", + "type": "function", + "description": "Gets the html document set as the popup for this page action.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "properties": { + "tabId": { + "type": "integer", + "description": "Specify the tab to get the popup from." + } + } + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "result", + "type": "string" + } + ] + } + ] + }, + { + "name": "openPopup", + "type": "function", + "requireUserInput": true, + "description": "Opens the extension page action in the active window.", + "async": true, + "parameters": [] + } + ], + "events": [ + { + "name": "onClicked", + "type": "function", + "description": "Fired when a page action icon is clicked. This event will not fire if the page action has a popup.", + "parameters": [ + { + "name": "tab", + "$ref": "tabs.Tab" + }, + { + "name": "info", + "$ref": "OnClickData", + "optional": true + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/permissions.json b/toolkit/components/extensions/schemas/permissions.json new file mode 100644 index 0000000000..eb66fa7c64 --- /dev/null +++ b/toolkit/components/extensions/schemas/permissions.json @@ -0,0 +1,150 @@ +[ + { + "namespace": "permissions", + "types": [ + { + "id": "Permissions", + "type": "object", + "properties": { + "permissions": { + "type": "array", + "items": { "$ref": "manifest.OptionalPermission" }, + "optional": true, + "default": [] + }, + "origins": { + "type": "array", + "items": { "$ref": "manifest.MatchPattern" }, + "optional": true, + "default": [] + } + } + }, + { + "id": "AnyPermissions", + "type": "object", + "properties": { + "permissions": { + "type": "array", + "items": { "$ref": "manifest.Permission" }, + "optional": true, + "default": [] + }, + "origins": { + "type": "array", + "items": { "$ref": "manifest.MatchPattern" }, + "optional": true, + "default": [] + } + } + } + ], + "functions": [ + { + "name": "getAll", + "type": "function", + "async": "callback", + "description": "Get a list of all the extension's permissions.", + "parameters": [ + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "permissions", + "$ref": "AnyPermissions" + } + ] + } + ] + }, + { + "name": "contains", + "type": "function", + "async": "callback", + "description": "Check if the extension has the given permissions.", + "parameters": [ + { + "name": "permissions", + "$ref": "AnyPermissions" + }, + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "result", + "type": "boolean" + } + ] + } + ] + }, + { + "name": "request", + "type": "function", + "allowedContexts": ["content"], + "async": "callback", + "requireUserInput": true, + "description": "Request the given permissions.", + "parameters": [ + { + "name": "permissions", + "$ref": "Permissions" + }, + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "granted", + "type": "boolean" + } + ] + } + ] + }, + { + "name": "remove", + "type": "function", + "async": "callback", + "description": "Relinquish the given permissions.", + "parameters": [ + { + "name": "permissions", + "$ref": "Permissions" + }, + { + "name": "callback", + "type": "function", + "parameters": [] + } + ] + } + ], + "events": [ + { + "name": "onAdded", + "type": "function", + "description": "Fired when the extension acquires new permissions.", + "parameters": [ + { + "name": "permissions", + "$ref": "Permissions" + } + ] + }, + { + "name": "onRemoved", + "type": "function", + "description": "Fired when permissions are removed from the extension.", + "parameters": [ + { + "name": "permissions", + "$ref": "Permissions" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/privacy.json b/toolkit/components/extensions/schemas/privacy.json new file mode 100644 index 0000000000..54c78a4c83 --- /dev/null +++ b/toolkit/components/extensions/schemas/privacy.json @@ -0,0 +1,177 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermission", + "choices": [ + { + "type": "string", + "enum": ["privacy"] + } + ] + } + ] + }, + { + "namespace": "privacy", + "permissions": ["privacy"] + }, + { + "namespace": "privacy.network", + "description": "Use the <code>browser.privacy</code> API to control usage of the features in the browser that can affect a user's privacy.", + "permissions": ["privacy"], + "types": [ + { + "id": "IPHandlingPolicy", + "type": "string", + "enum": [ + "default", + "default_public_and_private_interfaces", + "default_public_interface_only", + "disable_non_proxied_udp", + "proxy_only" + ], + "description": "The IP handling policy of WebRTC." + }, + { + "id": "tlsVersionRestrictionConfig", + "type": "object", + "description": "An object which describes TLS minimum and maximum versions.", + "properties": { + "minimum": { + "type": "string", + "enum": ["TLSv1", "TLSv1.1", "TLSv1.2", "TLSv1.3", "unknown"], + "optional": true, + "description": "The minimum TLS version supported." + }, + "maximum": { + "type": "string", + "enum": ["TLSv1", "TLSv1.1", "TLSv1.2", "TLSv1.3", "unknown"], + "optional": true, + "description": "The maximum TLS version supported." + } + } + }, + { + "id": "HTTPSOnlyModeOption", + "type": "string", + "enum": ["always", "private_browsing", "never"], + "description": "The mode for https-only mode." + } + ], + "properties": { + "networkPredictionEnabled": { + "$ref": "types.Setting", + "description": "If enabled, the browser attempts to speed up your web browsing experience by pre-resolving DNS entries, prerendering sites (<code><link rel='prefetch' ...></code>), and preemptively opening TCP and SSL connections to servers. This preference's value is a boolean, defaulting to <code>true</code>." + }, + "peerConnectionEnabled": { + "$ref": "types.Setting", + "description": "Allow users to enable and disable RTCPeerConnections (aka WebRTC)." + }, + "webRTCIPHandlingPolicy": { + "$ref": "types.Setting", + "description": "Allow users to specify the media performance/privacy tradeoffs which impacts how WebRTC traffic will be routed and how much local address information is exposed. This preference's value is of type IPHandlingPolicy, defaulting to <code>default</code>." + }, + "tlsVersionRestriction": { + "$ref": "types.Setting", + "description": "This property controls the minimum and maximum TLS versions. This setting's value is an object of $(ref:tlsVersionRestrictionConfig)." + }, + "httpsOnlyMode": { + "$ref": "types.Setting", + "description": "Allow users to query the mode for 'HTTPS-Only Mode'. This setting's value is of type HTTPSOnlyModeOption, defaulting to <code>never</code>." + }, + "globalPrivacyControl": { + "$ref": "types.Setting", + "description": "Allow users to query the status of 'Global Privacy Control'. This setting's value is of type boolean, defaulting to <code>false</code>." + } + } + }, + { + "namespace": "privacy.services", + "description": "Use the <code>browser.privacy</code> API to control usage of the features in the browser that can affect a user's privacy.", + "permissions": ["privacy"], + "properties": { + "passwordSavingEnabled": { + "$ref": "types.Setting", + "description": "If enabled, the password manager will ask if you want to save passwords. This preference's value is a boolean, defaulting to <code>true</code>." + } + } + }, + { + "namespace": "privacy.websites", + "description": "Use the <code>browser.privacy</code> API to control usage of the features in the browser that can affect a user's privacy.", + "permissions": ["privacy"], + "types": [ + { + "id": "TrackingProtectionModeOption", + "type": "string", + "enum": ["always", "never", "private_browsing"], + "description": "The mode for tracking protection." + }, + { + "id": "CookieConfig", + "type": "object", + "description": "The settings for cookies.", + "properties": { + "behavior": { + "type": "string", + "optional": true, + "enum": [ + "allow_all", + "reject_all", + "reject_third_party", + "allow_visited", + "reject_trackers", + "reject_trackers_and_partition_foreign" + ], + "description": "The type of cookies to allow." + }, + "nonPersistentCookies": { + "type": "boolean", + "optional": true, + "default": false, + "description": "Whether to create all cookies as nonPersistent (i.e., session) cookies.", + "deprecated": "This property has no effect anymore and its value is always <code>false<code>." + } + } + } + ], + "properties": { + "thirdPartyCookiesAllowed": { + "$ref": "types.Setting", + "description": "If disabled, the browser blocks third-party sites from setting cookies. The value of this preference is of type boolean, and the default value is <code>true</code>.", + "unsupported": true + }, + "hyperlinkAuditingEnabled": { + "$ref": "types.Setting", + "description": "If enabled, the browser sends auditing pings when requested by a website (<code><a ping></code>). The value of this preference is of type boolean, and the default value is <code>true</code>." + }, + "referrersEnabled": { + "$ref": "types.Setting", + "description": "If enabled, the browser sends <code>referer</code> headers with your requests. Yes, the name of this preference doesn't match the misspelled header. No, we're not going to change it. The value of this preference is of type boolean, and the default value is <code>true</code>." + }, + "resistFingerprinting": { + "$ref": "types.Setting", + "description": "If enabled, the browser attempts to appear similar to other users by reporting generic information to websites. This can prevent websites from uniquely identifying users. Examples of data that is spoofed include number of CPU cores, precision of JavaScript timers, the local timezone, and disabling features such as GamePad support, and the WebSpeech and Navigator APIs. The value of this preference is of type boolean, and the default value is <code>false</code>." + }, + "firstPartyIsolate": { + "$ref": "types.Setting", + "description": "If enabled, the browser will associate all data (including cookies, HSTS data, cached images, and more) for any third party domains with the domain in the address bar. This prevents third party trackers from using directly stored information to identify you across different websites, but may break websites where you login with a third party account (such as a Facebook or Google login.) The value of this preference is of type boolean, and the default value is <code>false</code>." + }, + "protectedContentEnabled": { + "$ref": "types.Setting", + "description": "<strong>Available on Windows and ChromeOS only</strong>: If enabled, the browser provides a unique ID to plugins in order to run protected content. The value of this preference is of type boolean, and the default value is <code>true</code>.", + "unsupported": true + }, + "trackingProtectionMode": { + "$ref": "types.Setting", + "description": "Allow users to specify the mode for tracking protection. This setting's value is of type TrackingProtectionModeOption, defaulting to <code>private_browsing_only</code>." + }, + "cookieConfig": { + "$ref": "types.Setting", + "description": "Allow users to specify the default settings for allowing cookies, as well as whether all cookies should be created as non-persistent cookies. This setting's value is of type CookieConfig." + } + } + } +] diff --git a/toolkit/components/extensions/schemas/proxy.json b/toolkit/components/extensions/schemas/proxy.json new file mode 100644 index 0000000000..78617c2137 --- /dev/null +++ b/toolkit/components/extensions/schemas/proxy.json @@ -0,0 +1,210 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermission", + "choices": [ + { + "type": "string", + "enum": ["proxy"] + } + ] + } + ] + }, + { + "namespace": "proxy", + "description": "Provides access to global proxy settings for Firefox and proxy event listeners to handle dynamic proxy implementations.", + "permissions": ["proxy"], + "types": [ + { + "id": "ProxyConfig", + "type": "object", + "description": "An object which describes proxy settings.", + "properties": { + "proxyType": { + "type": "string", + "optional": true, + "enum": ["none", "autoDetect", "system", "manual", "autoConfig"], + "description": "The type of proxy to use." + }, + "http": { + "type": "string", + "optional": true, + "description": "The address of the http proxy, can include a port." + }, + "httpProxyAll": { + "type": "boolean", + "optional": true, + "description": "Use the http proxy server for all protocols." + }, + "ftp": { + "type": "string", + "optional": true, + "deprecated": true, + "description": "The address of the ftp proxy, can include a port. Deprecated since Firefox 88." + }, + "ssl": { + "type": "string", + "optional": true, + "description": "The address of the ssl proxy, can include a port." + }, + "socks": { + "type": "string", + "optional": true, + "description": "The address of the socks proxy, can include a port." + }, + "socksVersion": { + "type": "integer", + "optional": true, + "description": "The version of the socks proxy.", + "minimum": 4, + "maximum": 5 + }, + "passthrough": { + "type": "string", + "optional": true, + "description": "A list of hosts which should not be proxied." + }, + "autoConfigUrl": { + "type": "string", + "optional": true, + "description": "A URL to use to configure the proxy." + }, + "autoLogin": { + "type": "boolean", + "optional": true, + "description": "Do not prompt for authentication if password is saved." + }, + "proxyDNS": { + "type": "boolean", + "optional": true, + "description": "Proxy DNS when using SOCKS v5." + }, + "respectBeConservative": { + "type": "boolean", + "optional": true, + "default": true, + "description": " If true (the default value), do not use newer TLS protocol features that might have interoperability problems on the Internet. This is intended only for use with critical infrastructure like the updates, and is only available to privileged addons." + } + } + } + ], + "properties": { + "settings": { + "$ref": "types.Setting", + "description": "Configures proxy settings. This setting's value is an object of type ProxyConfig." + } + }, + "events": [ + { + "name": "onRequest", + "type": "function", + "description": "Fired when proxy data is needed for a request.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": { + "type": "string", + "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request." + }, + "url": { "type": "string" }, + "method": { + "type": "string", + "description": "Standard HTTP method." + }, + "frameId": { + "type": "integer", + "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (<code>type</code> is <code>main_frame</code> or <code>sub_frame</code>), <code>frameId</code> indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists." + }, + "incognito": { + "type": "boolean", + "optional": true, + "description": "True for private browsing requests." + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, + "originUrl": { + "type": "string", + "optional": true, + "description": "URL of the resource that triggered this request." + }, + "documentUrl": { + "type": "string", + "optional": true, + "description": "URL of the page into which the requested resource will be loaded." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab." + }, + "type": { + "$ref": "webRequest.ResourceType", + "description": "How the requested resource will be used." + }, + "timeStamp": { + "type": "number", + "description": "The time when this signal is triggered, in milliseconds since the epoch." + }, + "fromCache": { + "type": "boolean", + "description": "Indicates if this response was fetched from disk cache." + }, + "requestHeaders": { + "$ref": "webRequest.HttpHeaders", + "optional": true, + "description": "The HTTP request headers that are going to be sent out with this request." + }, + "urlClassification": { + "$ref": "webRequest.UrlClassification", + "description": "Url classification if the request has been classified." + }, + "thirdParty": { + "type": "boolean", + "description": "Indicates if this request and its content window hierarchy is third party." + } + } + } + ], + "extraParameters": [ + { + "$ref": "webRequest.RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "type": "string", + "enum": ["requestHeaders"] + } + } + ] + }, + { + "name": "onError", + "type": "function", + "description": "Notifies about errors caused by the invalid use of the proxy API.", + "parameters": [ + { + "name": "error", + "type": "object" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/runtime.json b/toolkit/components/extensions/schemas/runtime.json new file mode 100644 index 0000000000..75ff341393 --- /dev/null +++ b/toolkit/components/extensions/schemas/runtime.json @@ -0,0 +1,721 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermission", + "choices": [ + { + "type": "string", + "enum": ["nativeMessaging"] + } + ] + } + ] + }, + { + "namespace": "runtime", + "allowedContexts": ["content", "devtools"], + "description": "Use the <code>browser.runtime</code> API to retrieve the background page, return details about the manifest, and listen for and respond to events in the app or extension lifecycle. You can also use this API to convert the relative path of URLs to fully-qualified URLs.", + "types": [ + { + "id": "Port", + "type": "object", + "allowedContexts": ["content", "devtools"], + "description": "An object which allows two way communication with other pages.", + "properties": { + "name": { "type": "string" }, + "disconnect": { "type": "function" }, + "onDisconnect": { "$ref": "events.Event" }, + "onMessage": { "$ref": "events.Event" }, + "postMessage": { "type": "function" }, + "sender": { + "$ref": "MessageSender", + "optional": true, + "description": "This property will <b>only</b> be present on ports passed to onConnect/onConnectExternal listeners." + } + }, + "additionalProperties": { "type": "any" } + }, + { + "id": "MessageSender", + "type": "object", + "allowedContexts": ["content", "devtools"], + "description": "An object containing information about the script context that sent a message or request.", + "properties": { + "tab": { + "$ref": "tabs.Tab", + "optional": true, + "description": "The $(ref:tabs.Tab) which opened the connection, if any. This property will <strong>only</strong> be present when the connection was opened from a tab (including content scripts), and <strong>only</strong> if the receiver is an extension, not an app." + }, + "frameId": { + "type": "integer", + "optional": true, + "description": "The $(topic:frame_ids)[frame] that opened the connection. 0 for top-level frames, positive for child frames. This will only be set when <code>tab</code> is set." + }, + "id": { + "type": "string", + "optional": true, + "description": "The ID of the extension or app that opened the connection, if any." + }, + "url": { + "type": "string", + "optional": true, + "description": "The URL of the page or frame that opened the connection. If the sender is in an iframe, it will be iframe's URL not the URL of the page which hosts it." + }, + "tlsChannelId": { + "unsupported": true, + "type": "string", + "optional": true, + "description": "The TLS channel ID of the page or frame that opened the connection, if requested by the extension or app, and if available." + } + } + }, + { + "id": "PlatformOs", + "type": "string", + "allowedContexts": ["content", "devtools"], + "description": "The operating system the browser is running on.", + "enum": ["mac", "win", "android", "cros", "linux", "openbsd"] + }, + { + "id": "PlatformArch", + "type": "string", + "enum": [ + "aarch64", + "arm", + "ppc64", + "s390x", + "sparc64", + "x86-32", + "x86-64", + "noarch" + ], + "allowedContexts": ["content", "devtools"], + "description": "The machine's processor architecture." + }, + { + "id": "PlatformInfo", + "type": "object", + "allowedContexts": ["content", "devtools"], + "description": "An object containing information about the current platform.", + "properties": { + "os": { + "$ref": "PlatformOs", + "description": "The operating system the browser is running on." + }, + "arch": { + "$ref": "PlatformArch", + "description": "The machine's processor architecture." + }, + "nacl_arch": { + "unsupported": true, + "description": "The native client architecture. This may be different from arch on some platforms.", + "$ref": "PlatformNaclArch" + } + } + }, + { + "id": "BrowserInfo", + "type": "object", + "description": "An object containing information about the current browser.", + "properties": { + "name": { + "type": "string", + "description": "The name of the browser, for example 'Firefox'." + }, + "vendor": { + "type": "string", + "description": "The name of the browser vendor, for example 'Mozilla'." + }, + "version": { + "type": "string", + "description": "The browser's version, for example '42.0.0' or '0.8.1pre'." + }, + "buildID": { + "type": "string", + "description": "The browser's build ID/date, for example '20160101'." + } + } + }, + { + "id": "RequestUpdateCheckStatus", + "type": "string", + "enum": ["throttled", "no_update", "update_available"], + "allowedContexts": ["content", "devtools"], + "description": "Result of the update check." + }, + { + "id": "OnInstalledReason", + "type": "string", + "enum": ["install", "update", "browser_update"], + "allowedContexts": ["content", "devtools"], + "description": "The reason that this event is being dispatched." + }, + { + "id": "OnRestartRequiredReason", + "type": "string", + "allowedContexts": ["content", "devtools"], + "description": "The reason that the event is being dispatched. 'app_update' is used when the restart is needed because the application is updated to a newer version. 'os_update' is used when the restart is needed because the browser/OS is updated to a newer version. 'periodic' is used when the system runs for more than the permitted uptime set in the enterprise policy.", + "enum": ["app_update", "os_update", "periodic"] + }, + { + "id": "OnPerformanceWarningCategory", + "type": "string", + "enum": ["content_script"], + "description": "The performance warning event category, e.g. 'content_script'." + }, + { + "id": "OnPerformanceWarningSeverity", + "type": "string", + "enum": ["low", "medium", "high"], + "description": "The performance warning event severity. Will be 'high' for serious and user-visible issues." + } + ], + "properties": { + "lastError": { + "type": "object", + "optional": true, + "allowedContexts": ["content", "devtools"], + "description": "This will be defined during an API method callback if there was an error", + "properties": { + "message": { + "optional": true, + "type": "string", + "description": "Details about the error which occurred." + } + }, + "additionalProperties": { + "type": "any" + } + }, + "id": { + "type": "string", + "allowedContexts": ["content", "devtools"], + "description": "The ID of the extension/app." + } + }, + "functions": [ + { + "name": "getBackgroundPage", + "type": "function", + "description": "Retrieves the JavaScript 'window' object for the background page running inside the current extension/app. If the background page is an event page, the system will ensure it is loaded before calling the callback. If there is no background page, an error is set.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "backgroundPage", + "optional": true, + "type": "object", + "isInstanceOf": "Window", + "additionalProperties": { "type": "any" }, + "description": "The JavaScript 'window' object for the background page." + } + ] + } + ] + }, + { + "name": "openOptionsPage", + "type": "function", + "description": "<p>Open your Extension's options page, if possible.</p><p>The precise behavior may depend on your manifest's <code>$(topic:optionsV2)[options_ui]</code> or <code>$(topic:options)[options_page]</code> key, or what the browser happens to support at the time.</p><p>If your Extension does not declare an options page, or the browser failed to create one for some other reason, the callback will set $(ref:lastError).</p>", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [], + "optional": true + } + ] + }, + { + "name": "getManifest", + "allowedContexts": ["content", "devtools"], + "description": "Returns details about the app or extension from the manifest. The object returned is a serialization of the full $(topic:manifest)[manifest file].", + "type": "function", + "parameters": [], + "returns": { + "type": "object", + "properties": {}, + "additionalProperties": { "type": "any" }, + "description": "The manifest details." + } + }, + { + "name": "getURL", + "type": "function", + "allowedContexts": ["content", "devtools"], + "description": "Converts a relative path within an app/extension install directory to a fully-qualified URL.", + "parameters": [ + { + "type": "string", + "name": "path", + "description": "A path to a resource within an app/extension expressed relative to its install directory." + } + ], + "returns": { + "type": "string", + "description": "The fully-qualified URL to the resource." + } + }, + { + "name": "getFrameId", + "type": "function", + "allowedContexts": ["content", "devtools"], + "description": "Get the frameId of any window global or frame element.", + "parameters": [ + { + "type": "any", + "name": "target", + "description": "A WindowProxy or a Browsing Context container element (IFrame, Frame, Embed, Object) for the target frame." + } + ], + "allowCrossOriginArguments": true, + "returns": { + "type": "number", + "description": "The frameId of the target frame, or -1 if it doesn't exist." + } + }, + { + "name": "setUninstallURL", + "type": "function", + "description": "Sets the URL to be visited upon uninstallation. This may be used to clean up server-side data, do analytics, and implement surveys. Maximum 1023 characters.", + "async": "callback", + "parameters": [ + { + "type": "string", + "name": "url", + "optional": true, + "maxLength": 1023, + "description": "URL to be opened after the extension is uninstalled. This URL must have an http: or https: scheme. Set an empty string to not open a new tab upon uninstallation." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "description": "Called when the uninstall URL is set. If the given URL is invalid, $(ref:runtime.lastError) will be set.", + "parameters": [] + } + ] + }, + { + "name": "reload", + "description": "Reloads the app or extension.", + "type": "function", + "parameters": [] + }, + { + "name": "requestUpdateCheck", + "unsupported": true, + "type": "function", + "description": "Requests an update check for this app/extension.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "status", + "$ref": "RequestUpdateCheckStatus", + "description": "Result of the update check." + }, + { + "name": "details", + "type": "object", + "optional": true, + "properties": { + "version": { + "type": "string", + "description": "The version of the available update." + } + }, + "description": "If an update is available, this contains more information about the available update." + } + ] + } + ] + }, + { + "name": "restart", + "unsupported": true, + "description": "Restart the device when the app runs in kiosk mode. Otherwise, it's no-op.", + "type": "function", + "parameters": [] + }, + { + "name": "connect", + "type": "function", + "allowedContexts": ["content", "devtools"], + "description": "Attempts to connect to connect listeners within an extension/app (such as the background page), or other extensions/apps. This is useful for content scripts connecting to their extension processes, inter-app/extension communication, and $(topic:manifest/externally_connectable)[web messaging]. Note that this does not connect to any listeners in a content script. Extensions may connect to content scripts embedded in tabs via $(ref:tabs.connect).", + "parameters": [ + { + "type": "string", + "name": "extensionId", + "optional": true, + "description": "The ID of the extension or app to connect to. If omitted, a connection will be attempted with your own extension. Required if sending messages from a web page for $(topic:manifest/externally_connectable)[web messaging]." + }, + { + "type": "object", + "name": "connectInfo", + "properties": { + "name": { + "type": "string", + "optional": true, + "description": "Will be passed into onConnect for processes that are listening for the connection event." + }, + "includeTlsChannelId": { + "type": "boolean", + "optional": true, + "description": "Whether the TLS channel ID will be passed into onConnectExternal for processes that are listening for the connection event." + } + }, + "optional": true + } + ], + "returns": { + "$ref": "Port", + "description": "Port through which messages can be sent and received. The port's $(ref:runtime.Port onDisconnect) event is fired if the extension/app does not exist. " + } + }, + { + "name": "connectNative", + "type": "function", + "description": "Connects to a native application in the host machine.", + "allowedContexts": ["content"], + "permissions": ["nativeMessaging"], + "parameters": [ + { + "type": "string", + "pattern": "^\\w+(\\.\\w+)*$", + "name": "application", + "description": "The name of the registered application to connect to." + } + ], + "returns": { + "$ref": "Port", + "description": "Port through which messages can be sent and received with the application" + } + }, + { + "name": "sendMessage", + "type": "function", + "allowAmbiguousOptionalArguments": true, + "allowedContexts": ["content", "devtools"], + "description": "Sends a single message to event listeners within your extension/app or a different extension/app. Similar to $(ref:runtime.connect) but only sends a single message, with an optional response. If sending to your extension, the $(ref:runtime.onMessage) event will be fired in each page, or $(ref:runtime.onMessageExternal), if a different extension. Note that extensions cannot send messages to content scripts using this method. To send messages to content scripts, use $(ref:tabs.sendMessage).", + "async": "responseCallback", + "parameters": [ + { + "type": "string", + "name": "extensionId", + "optional": true, + "description": "The ID of the extension/app to send the message to. If omitted, the message will be sent to your own extension/app. Required if sending messages from a web page for $(topic:manifest/externally_connectable)[web messaging]." + }, + { "type": "any", "name": "message" }, + { + "type": "object", + "name": "options", + "properties": { + "includeTlsChannelId": { + "type": "boolean", + "optional": true, + "unsupported": true, + "description": "Whether the TLS channel ID will be passed into onMessageExternal for processes that are listening for the connection event." + } + }, + "optional": true + }, + { + "type": "function", + "name": "responseCallback", + "optional": true, + "parameters": [ + { + "name": "response", + "type": "any", + "description": "The JSON response object sent by the handler of the message. If an error occurs while connecting to the extension, the callback will be called with no arguments and $(ref:runtime.lastError) will be set to the error message." + } + ] + } + ] + }, + { + "name": "sendNativeMessage", + "type": "function", + "description": "Send a single message to a native application.", + "allowedContexts": ["content"], + "permissions": ["nativeMessaging"], + "async": "responseCallback", + "parameters": [ + { + "name": "application", + "description": "The name of the native messaging host.", + "type": "string", + "pattern": "^\\w+(\\.\\w+)*$" + }, + { + "name": "message", + "description": "The message that will be passed to the native messaging host.", + "type": "any" + }, + { + "type": "function", + "name": "responseCallback", + "optional": true, + "parameters": [ + { + "name": "response", + "type": "any", + "description": "The response message sent by the native messaging host. If an error occurs while connecting to the native messaging host, the callback will be called with no arguments and $(ref:runtime.lastError) will be set to the error message." + } + ] + } + ] + }, + { + "name": "getBrowserInfo", + "type": "function", + "description": "Returns information about the current browser.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "description": "Called with results", + "parameters": [ + { + "name": "browserInfo", + "$ref": "BrowserInfo" + } + ] + } + ] + }, + { + "name": "getPlatformInfo", + "type": "function", + "description": "Returns information about the current platform.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "description": "Called with results", + "parameters": [ + { + "name": "platformInfo", + "$ref": "PlatformInfo" + } + ] + } + ] + }, + { + "name": "getPackageDirectoryEntry", + "unsupported": true, + "type": "function", + "description": "Returns a DirectoryEntry for the package directory.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "directoryEntry", + "type": "object", + "additionalProperties": { "type": "any" }, + "isInstanceOf": "DirectoryEntry" + } + ] + } + ] + } + ], + "events": [ + { + "name": "onStartup", + "type": "function", + "description": "Fired when a profile that has this extension installed first starts up. This event is not fired for incognito profiles." + }, + { + "name": "onInstalled", + "type": "function", + "description": "Fired when the extension is first installed, when the extension is updated to a new version, and when the browser is updated to a new version.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "reason": { + "$ref": "OnInstalledReason", + "description": "The reason that this event is being dispatched." + }, + "previousVersion": { + "type": "string", + "optional": true, + "description": "Indicates the previous version of the extension, which has just been updated. This is present only if 'reason' is 'update'." + }, + "temporary": { + "type": "boolean", + "description": "Indicates whether the addon is installed as a temporary extension." + }, + "id": { + "type": "string", + "optional": true, + "unsupported": true, + "description": "Indicates the ID of the imported shared module extension which updated. This is present only if 'reason' is 'shared_module_update'." + } + } + } + ] + }, + { + "name": "onSuspend", + "type": "function", + "description": "Sent to the event page just before it is unloaded. This gives the extension opportunity to do some clean up. Note that since the page is unloading, any asynchronous operations started while handling this event are not guaranteed to complete. If more activity for the event page occurs before it gets unloaded the onSuspendCanceled event will be sent and the page won't be unloaded. " + }, + { + "name": "onSuspendCanceled", + "type": "function", + "description": "Sent after onSuspend to indicate that the app won't be unloaded after all." + }, + { + "name": "onUpdateAvailable", + "type": "function", + "description": "Fired when an update is available, but isn't installed immediately because the app is currently running. If you do nothing, the update will be installed the next time the background page gets unloaded, if you want it to be installed sooner you can explicitly call $(ref:runtime.reload). If your extension is using a persistent background page, the background page of course never gets unloaded, so unless you call $(ref:runtime.reload) manually in response to this event the update will not get installed until the next time the browser itself restarts. If no handlers are listening for this event, and your extension has a persistent background page, it behaves as if $(ref:runtime.reload) is called in response to this event.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "version": { + "type": "string", + "description": "The version number of the available update." + } + }, + "additionalProperties": { "type": "any" }, + "description": "The manifest details of the available update." + } + ] + }, + { + "name": "onBrowserUpdateAvailable", + "unsupported": true, + "type": "function", + "description": "Fired when an update for the browser is available, but isn't installed immediately because a browser restart is required.", + "deprecated": "Please use $(ref:runtime.onRestartRequired).", + "parameters": [] + }, + { + "name": "onConnect", + "type": "function", + "allowedContexts": ["content", "devtools"], + "description": "Fired when a connection is made from either an extension process or a content script.", + "parameters": [{ "$ref": "Port", "name": "port" }] + }, + { + "name": "onConnectExternal", + "type": "function", + "description": "Fired when a connection is made from another extension.", + "parameters": [{ "$ref": "Port", "name": "port" }] + }, + { + "name": "onMessage", + "type": "function", + "allowedContexts": ["content", "devtools"], + "description": "Fired when a message is sent from either an extension process or a content script.", + "parameters": [ + { + "name": "message", + "type": "any", + "optional": true, + "description": "The message sent by the calling script." + }, + { "name": "sender", "$ref": "MessageSender" }, + { + "name": "sendResponse", + "type": "function", + "description": "Function to call (at most once) when you have a response. The argument should be any JSON-ifiable object. If you have more than one <code>onMessage</code> listener in the same document, then only one may send a response. This function becomes invalid when the event listener returns, unless you return true from the event listener to indicate you wish to send a response asynchronously (this will keep the message channel open to the other end until <code>sendResponse</code> is called)." + } + ], + "returns": { + "type": "boolean", + "optional": true, + "description": "Return true from the event listener if you wish to call <code>sendResponse</code> after the event listener returns." + } + }, + { + "name": "onMessageExternal", + "type": "function", + "description": "Fired when a message is sent from another extension/app. Cannot be used in a content script.", + "parameters": [ + { + "name": "message", + "type": "any", + "optional": true, + "description": "The message sent by the calling script." + }, + { "name": "sender", "$ref": "MessageSender" }, + { + "name": "sendResponse", + "type": "function", + "description": "Function to call (at most once) when you have a response. The argument should be any JSON-ifiable object. If you have more than one <code>onMessage</code> listener in the same document, then only one may send a response. This function becomes invalid when the event listener returns, unless you return true from the event listener to indicate you wish to send a response asynchronously (this will keep the message channel open to the other end until <code>sendResponse</code> is called)." + } + ], + "returns": { + "type": "boolean", + "optional": true, + "description": "Return true from the event listener if you wish to call <code>sendResponse</code> after the event listener returns." + } + }, + { + "name": "onRestartRequired", + "unsupported": true, + "type": "function", + "description": "Fired when an app or the device that it runs on needs to be restarted. The app should close all its windows at its earliest convenient time to let the restart to happen. If the app does nothing, a restart will be enforced after a 24-hour grace period has passed. Currently, this event is only fired for Chrome OS kiosk apps.", + "parameters": [ + { + "$ref": "OnRestartRequiredReason", + "name": "reason", + "description": "The reason that the event is being dispatched." + } + ] + }, + { + "name": "onPerformanceWarning", + "type": "function", + "description": "Fired when a runtime performance issue is detected with the extension. Observe this event to be proactively notified of runtime performance problems with the extension.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "category": { + "$ref": "OnPerformanceWarningCategory", + "description": "The performance warning event category, e.g. 'content_script'." + }, + "severity": { + "$ref": "OnPerformanceWarningSeverity", + "description": "The performance warning event severity, e.g. 'high'." + }, + "tabId": { + "type": "integer", + "optional": true, + "description": "The $(ref:tabs.Tab) that the performance warning relates to, if any." + }, + "description": { + "type": "string", + "description": "An explanation of what the warning means, and hopefully how to address it." + } + } + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/scripting.json b/toolkit/components/extensions/schemas/scripting.json new file mode 100644 index 0000000000..75003620cc --- /dev/null +++ b/toolkit/components/extensions/schemas/scripting.json @@ -0,0 +1,361 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermissionNoPrompt", + "choices": [ + { + "type": "string", + "enum": ["scripting"] + } + ] + } + ] + }, + { + "namespace": "scripting", + "description": "Use the scripting API to execute script in different contexts.", + "permissions": ["scripting"], + "types": [ + { + "id": "ScriptInjection", + "type": "object", + "description": "Details of a script injection", + "properties": { + "args": { + "type": "array", + "optional": true, + "description": "The arguments to curry into a provided function. This is only valid if the <code>func</code> parameter is specified. These arguments must be JSON-serializable.", + "items": { "type": "any" } + }, + "files": { + "type": "array", + "optional": true, + "description": "The path of the JS files to inject, relative to the extension's root directory. Exactly one of <code>files</code> and <code>func</code> must be specified.", + "minItems": 1, + "items": { "type": "string" } + }, + "func": { + "type": "function", + "optional": true, + "description": "A JavaScript function to inject. This function will be serialized, and then deserialized for injection. This means that any bound parameters and execution context will be lost. Exactly one of <code>files</code> and <code>func</code> must be specified." + }, + "target": { + "$ref": "InjectionTarget", + "description": "Details specifying the target into which to inject the script." + }, + "world": { + "$ref": "ExecutionWorld", + "optional": true + }, + "injectImmediately": { + "type": "boolean", + "optional": true, + "description": "Whether the injection should be triggered in the target as soon as possible (but not necessarily prior to page load)." + } + } + }, + { + "id": "InjectionResult", + "type": "object", + "description": "Result of a script injection.", + "properties": { + "frameId": { + "type": "integer", + "description": "The frame ID associated with the injection." + }, + "result": { + "type": "any", + "optional": true, + "description": "The result of the script execution." + }, + "error": { + "type": "any", + "optional": true, + "description": "The error property is set when the script execution failed. The value is typically an (Error) object with a message property, but could be any value (including primitives and undefined) if the script threw or rejected with such a value." + } + } + }, + { + "id": "InjectionTarget", + "type": "object", + "properties": { + "frameIds": { + "type": "array", + "optional": true, + "description": "The IDs of specific frames to inject into.", + "items": { "type": "number" } + }, + "allFrames": { + "type": "boolean", + "optional": true, + "description": "Whether the script should inject into all frames within the tab. Defaults to false. This must not be true if <code>frameIds</code> is specified." + }, + "tabId": { + "type": "number", + "description": "The ID of the tab into which to inject." + } + } + }, + { + "id": "CSSInjection", + "type": "object", + "properties": { + "css": { + "type": "string", + "optional": true, + "description": "A string containing the CSS to inject. Exactly one of <code>files</code> and <code>css</code> must be specified." + }, + "files": { + "type": "array", + "optional": true, + "description": "The path of the CSS files to inject, relative to the extension's root directory. Exactly one of <code>files</code> and <code>css</code> must be specified.", + "minItems": 1, + "items": { "type": "string" } + }, + "origin": { + "type": "string", + "optional": true, + "enum": ["USER", "AUTHOR"], + "default": "AUTHOR", + "description": "The style origin for the injection. Defaults to <code>'AUTHOR'</code>." + }, + "target": { + "$ref": "InjectionTarget", + "description": "Details specifying the target into which to inject the CSS." + } + } + }, + { + "id": "ContentScriptFilter", + "type": "object", + "properties": { + "ids": { + "type": "array", + "optional": true, + "description": "The IDs of specific scripts to retrieve with <code>getRegisteredContentScripts()</code> or to unregister with <code>unregisterContentScripts()</code>.", + "items": { "type": "string" } + } + } + }, + { + "id": "ExecutionWorld", + "type": "string", + "enum": ["ISOLATED"], + "description": "The JavaScript world for a script to execute within. We currently only support the <code>'ISOLATED'</code> world." + }, + { + "id": "RegisteredContentScript", + "type": "object", + "properties": { + "allFrames": { + "type": "boolean", + "optional": true, + "description": "If specified true, it will inject into all frames, even if the frame is not the top-most frame in the tab. Each frame is checked independently for URL requirements; it will not inject into child frames if the URL requirements are not met. Defaults to false, meaning that only the top frame is matched." + }, + "excludeMatches": { + "type": "array", + "optional": true, + "description": "Excludes pages that this content script would otherwise be injected into.", + "items": { "type": "string" } + }, + "id": { + "type": "string", + "description": "The id of the content script, specified in the API call." + }, + "js": { + "type": "array", + "optional": true, + "description": "The list of JavaScript files to be injected into matching pages. These are injected in the order they appear in this array.", + "items": { "$ref": "manifest.ExtensionURL" } + }, + "matches": { + "type": "array", + "optional": true, + "description": "Specifies which pages this content script will be injected into. Must be specified for <code>registerContentScripts()</code>.", + "items": { "type": "string" } + }, + "runAt": { + "$ref": "extensionTypes.RunAt", + "optional": true, + "description": "Specifies when JavaScript files are injected into the web page. The preferred and default value is <code>document_idle</code>." + }, + "persistAcrossSessions": { + "type": "boolean", + "optional": true, + "default": true, + "description": "Specifies if this content script will persist into future sessions. Defaults to true." + }, + "css": { + "type": "array", + "optional": true, + "description": "The list of CSS files to be injected into matching pages. These are injected in the order they appear in this array.", + "items": { "$ref": "manifest.ExtensionURL" } + } + } + } + ], + "functions": [ + { + "name": "executeScript", + "type": "function", + "description": "Injects a script into a target context. The script will be run at <code>document_idle</code>.", + "async": "callback", + "parameters": [ + { + "name": "injection", + "$ref": "ScriptInjection", + "description": "The details of the script which to inject." + }, + { + "name": "callback", + "type": "function", + "description": "Invoked upon completion of the injection. The resulting array contains the result of execution for each frame where the injection succeeded.", + "parameters": [ + { + "name": "results", + "type": "array", + "items": { "$ref": "InjectionResult" } + } + ] + } + ] + }, + { + "name": "insertCSS", + "type": "function", + "description": "Inserts a CSS stylesheet into a target context. If multiple frames are specified, unsuccessful injections are ignored.", + "async": "callback", + "parameters": [ + { + "name": "injection", + "$ref": "CSSInjection", + "description": "The details of the styles to insert." + }, + { + "name": "callback", + "type": "function", + "description": "Invoked upon completion of the injection.", + "parameters": [] + } + ] + }, + { + "name": "removeCSS", + "type": "function", + "description": "Removes a CSS stylesheet that was previously inserted by this extension from a target context.", + "async": "callback", + "parameters": [ + { + "name": "injection", + "$ref": "CSSInjection", + "description": "The details of the styles to remove. Note that the <code>css</code>, <code>files</code>, and <code>origin</code> properties must exactly match the stylesheet inserted through <code>insertCSS</code>. Attempting to remove a non-existent stylesheet is a no-op." + }, + { + "name": "callback", + "type": "function", + "description": "Invoked upon completion of the injection.", + "parameters": [] + } + ] + }, + { + "name": "registerContentScripts", + "type": "function", + "description": "Registers one or more content scripts for this extension.", + "async": "callback", + "parameters": [ + { + "name": "scripts", + "type": "array", + "description": "Contains a list of scripts to be registered. If there are errors during script parsing/file validation, or if the IDs specified already exist, then no scripts are registered.", + "items": { "$ref": "RegisteredContentScript" } + }, + { + "name": "callback", + "type": "function", + "description": "Invoked upon completion of the registration.", + "parameters": [] + } + ] + }, + { + "name": "getRegisteredContentScripts", + "type": "function", + "description": "Returns all dynamically registered content scripts for this extension that match the given filter.", + "async": "callback", + "parameters": [ + { + "name": "filter", + "$ref": "ContentScriptFilter", + "optional": true, + "description": "An object to filter the extension's dynamically registered scripts." + }, + { + "name": "callback", + "type": "function", + "description": "The resulting array contains the registered content scripts.", + "parameters": [ + { + "name": "scripts", + "type": "array", + "items": { "$ref": "RegisteredContentScript" } + } + ] + } + ] + }, + { + "name": "unregisterContentScripts", + "type": "function", + "description": "Unregisters one or more content scripts for this extension.", + "async": "callback", + "parameters": [ + { + "name": "filter", + "$ref": "ContentScriptFilter", + "optional": true, + "description": "If specified, only unregisters dynamic content scripts which match the filter. Otherwise, all of the extension's dynamic content scripts are unregistered." + }, + { + "name": "callback", + "type": "function", + "description": "Invoked upon completion of the unregistration.", + "parameters": [] + } + ] + }, + { + "name": "updateContentScripts", + "type": "function", + "description": "Updates one or more content scripts for this extension.", + "async": "callback", + "parameters": [ + { + "name": "scripts", + "type": "array", + "description": "Contains a list of scripts to be updated. If there are errors during script parsing/file validation, or if the IDs specified do not already exist, then no scripts are updated.", + "items": { + "type": "object", + "$import": "RegisteredContentScript", + "properties": { + "persistAcrossSessions": { + "type": "boolean", + "optional": true, + "description": "Specifies if this content script will persist into future sessions." + } + } + } + }, + { + "name": "callback", + "type": "function", + "description": "Invoked when scripts have been updated.", + "parameters": [] + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/storage.json b/toolkit/components/extensions/schemas/storage.json new file mode 100644 index 0000000000..56649fbbc6 --- /dev/null +++ b/toolkit/components/extensions/schemas/storage.json @@ -0,0 +1,394 @@ +[ + { + "namespace": "storage", + "allowedContexts": ["content", "devtools"], + "defaultContexts": ["content", "devtools"], + "description": "Use the <code>browser.storage</code> API to store, retrieve, and track changes to user data.", + "permissions": ["storage"], + "types": [ + { + "id": "StorageChange", + "type": "object", + "properties": { + "oldValue": { + "type": "any", + "description": "The old value of the item, if there was an old value.", + "optional": true + }, + "newValue": { + "type": "any", + "description": "The new value of the item, if there is a new value.", + "optional": true + } + } + }, + { + "id": "StorageArea", + "type": "object", + "functions": [ + { + "name": "get", + "type": "function", + "description": "Gets one or more items from storage.", + "async": "callback", + "parameters": [ + { + "name": "keys", + "choices": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } }, + { + "type": "object", + "description": "Storage items to return in the callback, where the values are replaced with those from storage if they exist.", + "additionalProperties": { "type": "any" } + } + ], + "description": "A single key to get, list of keys to get, or a dictionary specifying default values (see description of the object). An empty list or object will return an empty result object. Pass in <code>null</code> to get the entire contents of storage.", + "optional": true + }, + { + "name": "callback", + "type": "function", + "description": "Callback with storage items, or on failure (in which case $(ref:runtime.lastError) will be set).", + "parameters": [ + { + "name": "items", + "type": "object", + "additionalProperties": { "type": "any" }, + "description": "Object with items in their key-value mappings." + } + ] + } + ] + }, + { + "name": "getBytesInUse", + "unsupported": true, + "type": "function", + "description": "Gets the amount of space (in bytes) being used by one or more items.", + "async": "callback", + "parameters": [ + { + "name": "keys", + "choices": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ], + "description": "A single key or list of keys to get the total usage for. An empty list will return 0. Pass in <code>null</code> to get the total usage of all of storage.", + "optional": true + }, + { + "name": "callback", + "type": "function", + "description": "Callback with the amount of space being used by storage, or on failure (in which case $(ref:runtime.lastError) will be set).", + "parameters": [ + { + "name": "bytesInUse", + "type": "integer", + "description": "Amount of space being used in storage, in bytes." + } + ] + } + ] + }, + { + "name": "set", + "type": "function", + "description": "Sets multiple items.", + "async": "callback", + "parameters": [ + { + "name": "items", + "type": "object", + "additionalProperties": { "type": "any" }, + "description": "<p>An object which gives each key/value pair to update storage with. Any other key/value pairs in storage will not be affected.</p><p>Primitive values such as numbers will serialize as expected. Values with a <code>typeof</code> <code>\"object\"</code> and <code>\"function\"</code> will typically serialize to <code>{}</code>, with the exception of <code>Array</code> (serializes as expected), <code>Date</code>, and <code>Regex</code> (serialize using their <code>String</code> representation).</p>" + }, + { + "name": "callback", + "type": "function", + "description": "Callback on success, or on failure (in which case $(ref:runtime.lastError) will be set).", + "parameters": [], + "optional": true + } + ] + }, + { + "name": "remove", + "type": "function", + "description": "Removes one or more items from storage.", + "async": "callback", + "parameters": [ + { + "name": "keys", + "choices": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ], + "description": "A single key or a list of keys for items to remove." + }, + { + "name": "callback", + "type": "function", + "description": "Callback on success, or on failure (in which case $(ref:runtime.lastError) will be set).", + "parameters": [], + "optional": true + } + ] + }, + { + "name": "clear", + "type": "function", + "description": "Removes all items from storage.", + "async": "callback", + "parameters": [ + { + "name": "callback", + "type": "function", + "description": "Callback on success, or on failure (in which case $(ref:runtime.lastError) will be set).", + "parameters": [], + "optional": true + } + ] + } + ], + "events": [ + { + "name": "onChanged", + "type": "function", + "description": "Fired when one or more items change.", + "parameters": [ + { + "name": "changes", + "type": "object", + "additionalProperties": { "$ref": "StorageChange" }, + "description": "Object mapping each key that changed to its corresponding $(ref:storage.StorageChange) for that item." + } + ] + } + ] + }, + { + "id": "StorageAreaSync", + "type": "object", + "functions": [ + { + "name": "get", + "type": "function", + "description": "Gets one or more items from storage.", + "async": "callback", + "parameters": [ + { + "name": "keys", + "choices": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } }, + { + "type": "object", + "description": "Storage items to return in the callback, where the values are replaced with those from storage if they exist.", + "additionalProperties": { "type": "any" } + } + ], + "description": "A single key to get, list of keys to get, or a dictionary specifying default values (see description of the object). An empty list or object will return an empty result object. Pass in <code>null</code> to get the entire contents of storage.", + "optional": true + }, + { + "name": "callback", + "type": "function", + "description": "Callback with storage items, or on failure (in which case $(ref:runtime.lastError) will be set).", + "parameters": [ + { + "name": "items", + "type": "object", + "additionalProperties": { "type": "any" }, + "description": "Object with items in their key-value mappings." + } + ] + } + ] + }, + { + "name": "getBytesInUse", + "type": "function", + "description": "Gets the amount of space (in bytes) being used by one or more items.", + "async": "callback", + "parameters": [ + { + "name": "keys", + "choices": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ], + "description": "A single key or list of keys to get the total usage for. An empty list will return 0. Pass in <code>null</code> to get the total usage of all of storage.", + "optional": true + }, + { + "name": "callback", + "type": "function", + "description": "Callback with the amount of space being used by storage, or on failure (in which case $(ref:runtime.lastError) will be set).", + "parameters": [ + { + "name": "bytesInUse", + "type": "integer", + "description": "Amount of space being used in storage, in bytes." + } + ] + } + ] + }, + { + "name": "set", + "type": "function", + "description": "Sets multiple items.", + "async": "callback", + "parameters": [ + { + "name": "items", + "type": "object", + "additionalProperties": { "type": "any" }, + "description": "<p>An object which gives each key/value pair to update storage with. Any other key/value pairs in storage will not be affected.</p><p>Primitive values such as numbers will serialize as expected. Values with a <code>typeof</code> <code>\"object\"</code> and <code>\"function\"</code> will typically serialize to <code>{}</code>, with the exception of <code>Array</code> (serializes as expected), <code>Date</code>, and <code>Regex</code> (serialize using their <code>String</code> representation).</p>" + }, + { + "name": "callback", + "type": "function", + "description": "Callback on success, or on failure (in which case $(ref:runtime.lastError) will be set).", + "parameters": [], + "optional": true + } + ] + }, + { + "name": "remove", + "type": "function", + "description": "Removes one or more items from storage.", + "async": "callback", + "parameters": [ + { + "name": "keys", + "choices": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ], + "description": "A single key or a list of keys for items to remove." + }, + { + "name": "callback", + "type": "function", + "description": "Callback on success, or on failure (in which case $(ref:runtime.lastError) will be set).", + "parameters": [], + "optional": true + } + ] + }, + { + "name": "clear", + "type": "function", + "description": "Removes all items from storage.", + "async": "callback", + "parameters": [ + { + "name": "callback", + "type": "function", + "description": "Callback on success, or on failure (in which case $(ref:runtime.lastError) will be set).", + "parameters": [], + "optional": true + } + ] + } + ], + "events": [ + { + "name": "onChanged", + "type": "function", + "description": "Fired when one or more items change.", + "parameters": [ + { + "name": "changes", + "type": "object", + "additionalProperties": { "$ref": "StorageChange" }, + "description": "Object mapping each key that changed to its corresponding $(ref:storage.StorageChange) for that item." + } + ] + } + ] + } + ], + "events": [ + { + "name": "onChanged", + "type": "function", + "description": "Fired when one or more items change.", + "parameters": [ + { + "name": "changes", + "type": "object", + "additionalProperties": { "$ref": "StorageChange" }, + "description": "Object mapping each key that changed to its corresponding $(ref:storage.StorageChange) for that item." + }, + { + "name": "areaName", + "type": "string", + "description": "The name of the storage area (<code>\"sync\"</code>, <code>\"local\"</code> or <code>\"managed\"</code>) the changes are for." + } + ] + } + ], + "properties": { + "sync": { + "$ref": "StorageAreaSync", + "description": "Items in the <code>sync</code> storage area are synced by the browser.", + "properties": { + "QUOTA_BYTES": { + "value": 102400, + "description": "The maximum total amount (in bytes) of data that can be stored in sync storage, as measured by the JSON stringification of every value plus every key's length. Updates that would cause this limit to be exceeded fail immediately and set $(ref:runtime.lastError)." + }, + "QUOTA_BYTES_PER_ITEM": { + "value": 8192, + "description": "The maximum size (in bytes) of each individual item in sync storage, as measured by the JSON stringification of its value plus its key length. Updates containing items larger than this limit will fail immediately and set $(ref:runtime.lastError)." + }, + "MAX_ITEMS": { + "value": 512, + "description": "The maximum number of items that can be stored in sync storage. Updates that would cause this limit to be exceeded will fail immediately and set $(ref:runtime.lastError)." + }, + "MAX_WRITE_OPERATIONS_PER_HOUR": { + "value": 1800, + "description": "<p>The maximum number of <code>set</code>, <code>remove</code>, or <code>clear</code> operations that can be performed each hour. This is 1 every 2 seconds, a lower ceiling than the short term higher writes-per-minute limit.</p><p>Updates that would cause this limit to be exceeded fail immediately and set $(ref:runtime.lastError).</p>" + }, + "MAX_WRITE_OPERATIONS_PER_MINUTE": { + "value": 120, + "description": "<p>The maximum number of <code>set</code>, <code>remove</code>, or <code>clear</code> operations that can be performed each minute. This is 2 per second, providing higher throughput than writes-per-hour over a shorter period of time.</p><p>Updates that would cause this limit to be exceeded fail immediately and set $(ref:runtime.lastError).</p>" + }, + "MAX_SUSTAINED_WRITE_OPERATIONS_PER_MINUTE": { + "value": 1000000, + "deprecated": "The storage.sync API no longer has a sustained write operation quota.", + "description": "" + } + } + }, + "local": { + "$ref": "StorageArea", + "description": "Items in the <code>local</code> storage area are local to each machine.", + "properties": { + "QUOTA_BYTES": { + "value": 5242880, + "description": "The maximum amount (in bytes) of data that can be stored in local storage, as measured by the JSON stringification of every value plus every key's length. This value will be ignored if the extension has the <code>unlimitedStorage</code> permission. Updates that would cause this limit to be exceeded fail immediately and set $(ref:runtime.lastError)." + } + } + }, + "managed": { + "$ref": "StorageArea", + "description": "Items in the <code>managed</code> storage area are set by administrators or native applications, and are read-only for the extension; trying to modify this namespace results in an error.", + "properties": { + "QUOTA_BYTES": { + "value": 5242880, + "description": "The maximum size (in bytes) of the managed storage JSON manifest file. Files larger than this limit will fail to load." + } + } + }, + "session": { + "allowedContexts": ["devtools"], + "$ref": "StorageArea", + "description": "Items in the <code>session</code> storage area are kept in memory, and only until the either browser or extension is closed or reloaded." + } + } + } +] diff --git a/toolkit/components/extensions/schemas/telemetry.json b/toolkit/components/extensions/schemas/telemetry.json new file mode 100644 index 0000000000..da8587f7e7 --- /dev/null +++ b/toolkit/components/extensions/schemas/telemetry.json @@ -0,0 +1,469 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "WebExtensionManifest", + "properties": { + "telemetry": { + "type": "object", + "optional": true, + "additionalProperties": { "$ref": "UnrecognizedProperty" }, + "properties": { + "ping_type": { + "type": "string" + }, + "schemaNamespace": { + "type": "string" + }, + "public_key": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "key": { + "type": "object", + "properties": { + "crv": { + "type": "string", + "optional": "false" + }, + "kty": { + "type": "string", + "optional": "false" + }, + "x": { + "type": "string", + "optional": "false" + }, + "y": { + "type": "string", + "optional": "false" + } + } + } + } + }, + "study_name": { + "type": "string", + "optional": true + }, + "pioneer_id": { + "type": "boolean", + "optional": true, + "default": false + } + } + } + } + }, + { + "$extend": "PermissionPrivileged", + "choices": [ + { + "type": "string", + "enum": ["telemetry"] + } + ] + } + ] + }, + { + "namespace": "telemetry", + "description": "Use the <code>browser.telemetry</code> API to send telemetry data to the Mozilla Telemetry service. Restricted to Mozilla privileged webextensions.", + "types": [ + { + "id": "ScalarType", + "type": "string", + "enum": ["count", "string", "boolean"], + "description": "Type of scalar: 'count' for numeric values, 'string' for string values, 'boolean' for boolean values. Maps to <code>nsITelemetry.SCALAR_TYPE_*</code>." + }, + { + "id": "ScalarData", + "type": "object", + "description": "Represents registration data for a Telemetry scalar.", + "properties": { + "kind": { + "$ref": "ScalarType" + }, + "keyed": { + "type": "boolean", + "optional": true, + "default": false, + "description": "True if this is a keyed scalar." + }, + "record_on_release": { + "type": "boolean", + "optional": true, + "default": false, + "description": "True if this data should be recorded on release." + }, + "expired": { + "type": "boolean", + "optional": true, + "default": false, + "description": "True if this scalar entry is expired. This allows recording it without error, but it will be discarded." + } + } + }, + { + "id": "EventData", + "type": "object", + "description": "Represents registration data for a Telemetry event.", + "properties": { + "methods": { + "type": "array", + "items": { "type": "string" }, + "description": "List of methods for this event entry." + }, + "objects": { + "type": "array", + "items": { "type": "string" }, + "description": "List of objects for this event entry." + }, + "extra_keys": { + "type": "array", + "items": { "type": "string" }, + "description": "List of allowed extra keys for this event entry." + }, + "record_on_release": { + "type": "boolean", + "optional": true, + "default": false, + "description": "True if this data should be recorded on release." + }, + "expired": { + "type": "boolean", + "optional": true, + "default": false, + "description": "True if this event entry is expired. This allows recording it without error, but it will be discarded." + } + } + } + ], + "permissions": ["telemetry"], + "functions": [ + { + "name": "submitPing", + "type": "function", + "description": "Submits a custom ping to the Telemetry back-end. See <code>submitExternalPing</code> inside TelemetryController.sys.mjs for more details.", + "async": true, + "parameters": [ + { + "name": "type", + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]+[a-z0-9]$", + "description": "The type of the ping." + }, + { + "name": "message", + "type": "object", + "additionalProperties": { "type": "any" }, + "description": "The data payload for the ping." + }, + { + "description": "Options object.", + "name": "options", + "type": "object", + "properties": { + "addClientId": { + "type": "boolean", + "optional": true, + "default": false, + "description": "True if the ping should contain the client id." + }, + "addEnvironment": { + "type": "boolean", + "optional": true, + "default": false, + "description": "True if the ping should contain the environment data." + }, + "overrideEnvironment": { + "type": "object", + "additionalProperties": { "type": "any" }, + "optional": true, + "default": false, + "description": "Set to override the environment data." + }, + "usePingSender": { + "type": "boolean", + "optional": true, + "default": false, + "description": "If true, send the ping using the PingSender." + } + } + } + ] + }, + { + "name": "submitEncryptedPing", + "type": "function", + "description": "Submits a custom ping to the Telemetry back-end, with an encrypted payload. Requires a telemetry entry in the manifest to be used.", + "parameters": [ + { + "name": "message", + "type": "object", + "additionalProperties": { "type": "any" }, + "description": "The data payload for the ping, which will be encrypted." + }, + { + "description": "Options object.", + "name": "options", + "type": "object", + "properties": { + "schemaName": { + "type": "string", + "optional": false, + "description": "Schema name used for payload." + }, + "schemaVersion": { + "type": "integer", + "optional": false, + "description": "Schema version used for payload." + } + } + } + ], + "async": true + }, + { + "name": "canUpload", + "type": "function", + "description": "Checks if Telemetry upload is enabled.", + "parameters": [], + "async": true + }, + { + "name": "scalarAdd", + "type": "function", + "description": "Adds the value to the given scalar.", + "async": true, + "parameters": [ + { + "name": "name", + "type": "string", + "description": "The scalar name." + }, + { + "name": "value", + "type": "integer", + "minimum": 1, + "description": "The numeric value to add to the scalar. Only unsigned integers supported." + } + ] + }, + { + "name": "scalarSet", + "type": "function", + "description": "Sets the named scalar to the given value. Throws if the value type doesn't match the scalar type.", + "async": true, + "parameters": [ + { + "name": "name", + "type": "string", + "description": "The scalar name" + }, + { + "name": "value", + "description": "The value to set the scalar to", + "choices": [ + { "type": "string" }, + { "type": "boolean" }, + { "type": "integer" }, + { "type": "object", "additionalProperties": { "type": "any" } } + ] + } + ] + }, + { + "name": "scalarSetMaximum", + "type": "function", + "description": "Sets the scalar to the maximum of the current and the passed value", + "async": true, + "parameters": [ + { + "name": "name", + "type": "string", + "description": "The scalar name." + }, + { + "name": "value", + "type": "integer", + "minimum": 0, + "description": "The numeric value to set the scalar to. Only unsigned integers supported." + } + ] + }, + { + "name": "keyedScalarAdd", + "type": "function", + "description": "Adds the value to the given keyed scalar.", + "async": true, + "parameters": [ + { + "name": "name", + "type": "string", + "description": "The scalar name" + }, + { + "name": "key", + "type": "string", + "description": "The key name" + }, + { + "name": "value", + "type": "integer", + "minimum": 1, + "description": "The numeric value to add to the scalar. Only unsigned integers supported." + } + ] + }, + { + "name": "keyedScalarSet", + "type": "function", + "description": "Sets the keyed scalar to the given value. Throws if the value type doesn't match the scalar type.", + "async": true, + "parameters": [ + { + "name": "name", + "type": "string", + "description": "The scalar name." + }, + { + "name": "key", + "type": "string", + "description": "The key name." + }, + { + "name": "value", + "description": "The value to set the scalar to.", + "choices": [ + { "type": "string" }, + { "type": "boolean" }, + { "type": "integer" }, + { "type": "object", "additionalProperties": { "type": "any" } } + ] + } + ] + }, + { + "name": "keyedScalarSetMaximum", + "type": "function", + "description": "Sets the keyed scalar to the maximum of the current and the passed value", + "async": true, + "parameters": [ + { + "name": "name", + "type": "string", + "description": "The scalar name." + }, + { + "name": "key", + "type": "string", + "description": "The key name." + }, + { + "name": "value", + "type": "integer", + "minimum": 0, + "description": "The numeric value to set the scalar to. Only unsigned integers supported." + } + ] + }, + { + "name": "recordEvent", + "type": "function", + "description": "Record an event in Telemetry. Throws when trying to record an unknown event.", + "async": true, + "parameters": [ + { + "name": "category", + "type": "string", + "description": "The category name." + }, + { + "name": "method", + "type": "string", + "description": "The method name." + }, + { + "name": "object", + "type": "string", + "description": "The object name." + }, + { + "name": "value", + "type": "string", + "optional": true, + "description": "An optional string value to record." + }, + { + "name": "extra", + "type": "object", + "optional": true, + "description": "An optional object of the form (string -> string). It should only contain registered extra keys.", + "additionalProperties": { "type": "string" } + } + ] + }, + + { + "name": "registerScalars", + "type": "function", + "description": "Register new scalars to record them from addons. See nsITelemetry.idl for more details.", + "async": true, + "parameters": [ + { + "name": "category", + "type": "string", + "description": "The unique category the scalars are registered in." + }, + { + "name": "data", + "type": "object", + "additionalProperties": { "$ref": "ScalarData" }, + "description": "An object that contains registration data for multiple scalars. Each property name is the scalar name, and the corresponding property value is an object of ScalarData type." + } + ] + }, + { + "name": "registerEvents", + "type": "function", + "description": "Register new events to record them from addons. See nsITelemetry.idl for more details.", + "async": true, + "parameters": [ + { + "name": "category", + "type": "string", + "description": "The unique category the events are registered in." + }, + { + "name": "data", + "type": "object", + "additionalProperties": { "$ref": "EventData" }, + "description": "An object that contains registration data for 1+ events. Each property name is the category name, and the corresponding property value is an object of EventData type." + } + ] + }, + { + "name": "setEventRecordingEnabled", + "type": "function", + "description": "Enable recording of events in a category. Events default to recording disabled. This allows to toggle recording for all events in the specified category.", + "async": true, + "parameters": [ + { + "name": "category", + "type": "string", + "description": "The category name." + }, + { + "name": "enabled", + "type": "boolean", + "description": "Whether recording is enabled for events in that category." + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/test.json b/toolkit/components/extensions/schemas/test.json new file mode 100644 index 0000000000..d0327a9e63 --- /dev/null +++ b/toolkit/components/extensions/schemas/test.json @@ -0,0 +1,206 @@ +[ + { + "namespace": "test", + "allowedContexts": ["content", "devtools"], + "defaultContexts": ["content", "devtools"], + "description": "none", + "functions": [ + { + "name": "withHandlingUserInput", + "type": "function", + "description": "Calls the callback function wrapped with user input set. This is only used for internal unit testing.", + "parameters": [{ "type": "function", "name": "callback" }] + }, + { + "name": "notifyFail", + "type": "function", + "description": "Notifies the browser process that test code running in the extension failed. This is only used for internal unit testing.", + "parameters": [{ "type": "string", "name": "message" }] + }, + { + "name": "notifyPass", + "type": "function", + "description": "Notifies the browser process that test code running in the extension passed. This is only used for internal unit testing.", + "parameters": [ + { "type": "string", "name": "message", "optional": true } + ] + }, + { + "name": "log", + "type": "function", + "description": "Logs a message during internal unit testing.", + "parameters": [{ "type": "string", "name": "message" }] + }, + { + "name": "sendMessage", + "type": "function", + "description": "Sends a string message to the browser process, generating a Notification that C++ test code can wait for.", + "allowAmbiguousOptionalArguments": true, + "parameters": [ + { "type": "any", "name": "arg1", "optional": true }, + { "type": "any", "name": "arg2", "optional": true } + ] + }, + { + "name": "fail", + "type": "function", + "parameters": [{ "type": "any", "name": "message", "optional": true }] + }, + { + "name": "succeed", + "type": "function", + "parameters": [{ "type": "any", "name": "message", "optional": true }] + }, + { + "name": "assertTrue", + "type": "function", + "allowAmbiguousOptionalArguments": true, + "parameters": [ + { "name": "test", "type": "any", "optional": true }, + { "type": "string", "name": "message", "optional": true } + ] + }, + { + "name": "assertFalse", + "type": "function", + "allowAmbiguousOptionalArguments": true, + "parameters": [ + { "name": "test", "type": "any", "optional": true }, + { "type": "string", "name": "message", "optional": true } + ] + }, + { + "name": "assertBool", + "type": "function", + "unsupported": true, + "parameters": [ + { + "name": "test", + "choices": [{ "type": "string" }, { "type": "boolean" }] + }, + { "type": "boolean", "name": "expected" }, + { "type": "string", "name": "message", "optional": true } + ] + }, + { + "name": "assertDeepEq", + "type": "function", + "allowAmbiguousOptionalArguments": true, + "parameters": [ + { "type": "any", "name": "expected" }, + { "type": "any", "name": "actual" }, + { "type": "string", "name": "message", "optional": true } + ] + }, + { + "name": "assertEq", + "type": "function", + "allowAmbiguousOptionalArguments": true, + "parameters": [ + { "type": "any", "name": "expected", "optional": true }, + { "type": "any", "name": "actual", "optional": true }, + { "type": "string", "name": "message", "optional": true } + ] + }, + { + "name": "assertNoLastError", + "type": "function", + "unsupported": true, + "parameters": [] + }, + { + "name": "assertLastError", + "type": "function", + "unsupported": true, + "parameters": [{ "type": "string", "name": "expectedError" }] + }, + { + "name": "assertRejects", + "type": "function", + "async": true, + "parameters": [ + { + "name": "promise", + "$ref": "Promise" + }, + { + "name": "expectedError", + "$ref": "ExpectedError" + }, + { + "name": "message", + "type": "string", + "optional": true + } + ] + }, + { + "name": "assertThrows", + "type": "function", + "parameters": [ + { + "name": "func", + "type": "function" + }, + { + "name": "expectedError", + "$ref": "ExpectedError" + }, + { + "name": "message", + "type": "string", + "optional": true + } + ] + } + ], + "types": [ + { + "id": "ExpectedError", + "choices": [ + { "type": "string" }, + { + "type": "object", + "isInstanceOf": "RegExp", + "additionalProperties": true + }, + { "type": "function" } + ] + }, + { + "id": "Promise", + "choices": [ + { + "type": "object", + "properties": { + "then": { "type": "function" } + }, + "additionalProperties": true + }, + { + "type": "object", + "isInstanceOf": "Promise", + "additionalProperties": true + } + ] + } + ], + "events": [ + { + "name": "onMessage", + "type": "function", + "description": "Used to test sending messages to extensions.", + "parameters": [ + { + "type": "string", + "name": "message" + }, + { + "type": "any", + "name": "argument" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/theme.json b/toolkit/components/extensions/schemas/theme.json new file mode 100644 index 0000000000..4cdd70aa19 --- /dev/null +++ b/toolkit/components/extensions/schemas/theme.json @@ -0,0 +1,457 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "PermissionNoPrompt", + "choices": [ + { + "type": "string", + "enum": ["theme"] + } + ] + }, + { + "id": "ThemeColor", + "choices": [ + { + "type": "string" + }, + { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + } + }, + { + "type": "array", + "minItems": 4, + "maxItems": 4, + "items": { + "type": "number" + } + } + ] + }, + { + "id": "ThemeExperiment", + "type": "object", + "properties": { + "stylesheet": { + "optional": true, + "$ref": "ExtensionURL" + }, + "images": { + "type": "object", + "optional": true, + "additionalProperties": { + "type": "string" + } + }, + "colors": { + "type": "object", + "optional": true, + "additionalProperties": { + "type": "string" + } + }, + "properties": { + "type": "object", + "optional": true, + "additionalProperties": { + "type": "string" + } + } + } + }, + { + "id": "ThemeType", + "type": "object", + "properties": { + "images": { + "type": "object", + "optional": true, + "properties": { + "additional_backgrounds": { + "type": "array", + "items": { "$ref": "ImageDataOrExtensionURL" }, + "maxItems": 15, + "optional": true + }, + "headerURL": { + "$ref": "ImageDataOrExtensionURL", + "optional": true, + "deprecated": "Unsupported images property, use 'theme.images.theme_frame', this alias is ignored in Firefox >= 70." + }, + "theme_frame": { + "$ref": "ImageDataOrExtensionURL", + "optional": true + } + }, + "additionalProperties": { "$ref": "ImageDataOrExtensionURL" } + }, + "colors": { + "type": "object", + "optional": true, + "properties": { + "tab_selected": { + "$ref": "ThemeColor", + "optional": true + }, + "accentcolor": { + "$ref": "ThemeColor", + "optional": true, + "deprecated": "Unsupported colors property, use 'theme.colors.frame', this alias is ignored in Firefox >= 70." + }, + "frame": { + "$ref": "ThemeColor", + "optional": true + }, + "frame_inactive": { + "$ref": "ThemeColor", + "optional": true + }, + "textcolor": { + "$ref": "ThemeColor", + "optional": true, + "deprecated": "Unsupported color property, use 'theme.colors.tab_background_text', this alias is ignored in Firefox >= 70." + }, + "tab_background_text": { + "$ref": "ThemeColor", + "optional": true + }, + "tab_background_separator": { + "$ref": "ThemeColor", + "optional": true + }, + "tab_loading": { + "$ref": "ThemeColor", + "optional": true + }, + "tab_text": { + "$ref": "ThemeColor", + "optional": true + }, + "tab_line": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar_text": { + "$ref": "ThemeColor", + "optional": true, + "description": "This color property is an alias of 'bookmark_text'." + }, + "bookmark_text": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar_field": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar_field_text": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar_field_border": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar_field_separator": { + "$ref": "ThemeColor", + "optional": true, + "deprecated": "This color property is ignored in Firefox >= 89." + }, + "toolbar_top_separator": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar_bottom_separator": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar_vertical_separator": { + "$ref": "ThemeColor", + "optional": true + }, + "icons": { + "$ref": "ThemeColor", + "optional": true + }, + "icons_attention": { + "$ref": "ThemeColor", + "optional": true + }, + "button_background_hover": { + "$ref": "ThemeColor", + "optional": true + }, + "button_background_active": { + "$ref": "ThemeColor", + "optional": true + }, + "popup": { + "$ref": "ThemeColor", + "optional": true + }, + "popup_text": { + "$ref": "ThemeColor", + "optional": true + }, + "popup_border": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar_field_focus": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar_field_text_focus": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar_field_border_focus": { + "$ref": "ThemeColor", + "optional": true + }, + "popup_highlight": { + "$ref": "ThemeColor", + "optional": true + }, + "popup_highlight_text": { + "$ref": "ThemeColor", + "optional": true + }, + "ntp_background": { + "$ref": "ThemeColor", + "optional": true + }, + "ntp_card_background": { + "$ref": "ThemeColor", + "optional": true + }, + "ntp_text": { + "$ref": "ThemeColor", + "optional": true + }, + "sidebar": { + "$ref": "ThemeColor", + "optional": true + }, + "sidebar_border": { + "$ref": "ThemeColor", + "optional": true + }, + "sidebar_text": { + "$ref": "ThemeColor", + "optional": true + }, + "sidebar_highlight": { + "$ref": "ThemeColor", + "optional": true + }, + "sidebar_highlight_text": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar_field_highlight": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar_field_highlight_text": { + "$ref": "ThemeColor", + "optional": true + } + }, + "additionalProperties": { "$ref": "ThemeColor" } + }, + "properties": { + "type": "object", + "optional": true, + "properties": { + "additional_backgrounds_alignment": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "bottom", + "center", + "left", + "right", + "top", + "center bottom", + "center center", + "center top", + "left bottom", + "left center", + "left top", + "right bottom", + "right center", + "right top" + ] + }, + "maxItems": 15, + "optional": true + }, + "additional_backgrounds_tiling": { + "type": "array", + "items": { + "type": "string", + "enum": ["no-repeat", "repeat", "repeat-x", "repeat-y"] + }, + "maxItems": 15, + "optional": true + }, + "color_scheme": { + "optional": true, + "type": "string", + "enum": ["auto", "light", "dark", "system"] + }, + "content_color_scheme": { + "optional": true, + "type": "string", + "enum": ["auto", "light", "dark", "system"] + } + }, + "additionalProperties": { "type": "string" } + } + }, + "additionalProperties": { "$ref": "UnrecognizedProperty" } + }, + { + "id": "ThemeManifest", + "type": "object", + "description": "Contents of manifest.json for a static theme", + "$import": "manifest.ManifestBase", + "properties": { + "theme": { + "$ref": "ThemeType" + }, + "dark_theme": { + "$ref": "ThemeType", + "optional": true + }, + "default_locale": { + "type": "string", + "optional": true + }, + "theme_experiment": { + "$ref": "ThemeExperiment", + "optional": true + }, + "icons": { + "type": "object", + "optional": true, + "patternProperties": { + "^[1-9]\\d*$": { "type": "string" } + } + } + } + }, + { + "$extend": "WebExtensionManifest", + "properties": { + "theme_experiment": { + "$ref": "ThemeExperiment", + "optional": true + } + } + } + ] + }, + { + "namespace": "theme", + "description": "The theme API allows customizing of visual elements of the browser.", + "types": [ + { + "id": "ThemeUpdateInfo", + "type": "object", + "description": "Info provided in the onUpdated listener.", + "properties": { + "theme": { + "type": "object", + "description": "The new theme after update" + }, + "windowId": { + "type": "integer", + "description": "The id of the window the theme has been applied to", + "optional": true + } + } + } + ], + "events": [ + { + "name": "onUpdated", + "type": "function", + "description": "Fired when a new theme has been applied", + "parameters": [ + { + "$ref": "ThemeUpdateInfo", + "name": "updateInfo", + "description": "Details of the theme update" + } + ] + } + ], + "functions": [ + { + "name": "getCurrent", + "type": "function", + "async": true, + "description": "Returns the current theme for the specified window or the last focused window.", + "parameters": [ + { + "type": "integer", + "name": "windowId", + "optional": true, + "description": "The window for which we want the theme." + } + ] + }, + { + "name": "update", + "type": "function", + "async": true, + "description": "Make complete updates to the theme. Resolves when the update has completed.", + "permissions": ["theme"], + "parameters": [ + { + "type": "integer", + "name": "windowId", + "optional": true, + "description": "The id of the window to update. No id updates all windows." + }, + { + "name": "details", + "$ref": "manifest.ThemeType", + "description": "The properties of the theme to update." + } + ] + }, + { + "name": "reset", + "type": "function", + "async": true, + "description": "Removes the updates made to the theme.", + "permissions": ["theme"], + "parameters": [ + { + "type": "integer", + "name": "windowId", + "optional": true, + "description": "The id of the window to reset. No id resets all windows." + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/types.json b/toolkit/components/extensions/schemas/types.json new file mode 100644 index 0000000000..c0dded5ad0 --- /dev/null +++ b/toolkit/components/extensions/schemas/types.json @@ -0,0 +1,168 @@ +[ + { + "namespace": "types", + "description": "Contains types used by other schemas.", + "types": [ + { + "id": "SettingScope", + "type": "string", + "enum": [ + "regular", + "regular_only", + "incognito_persistent", + "incognito_session_only" + ], + "description": "The scope of the Setting. One of<ul><li><var>regular</var>: setting for the regular profile (which is inherited by the incognito profile if not overridden elsewhere),</li><li><var>regular_only</var>: setting for the regular profile only (not inherited by the incognito profile),</li><li><var>incognito_persistent</var>: setting for the incognito profile that survives browser restarts (overrides regular preferences),</li><li><var>incognito_session_only</var>: setting for the incognito profile that can only be set during an incognito session and is deleted when the incognito session ends (overrides regular and incognito_persistent preferences).</li></ul> Only <var>regular</var> is supported by Firefox at this time." + }, + { + "id": "LevelOfControl", + "type": "string", + "enum": [ + "not_controllable", + "controlled_by_other_extensions", + "controllable_by_this_extension", + "controlled_by_this_extension" + ], + "description": "One of<ul><li><var>not_controllable</var>: cannot be controlled by any extension</li><li><var>controlled_by_other_extensions</var>: controlled by extensions with higher precedence</li><li><var>controllable_by_this_extension</var>: can be controlled by this extension</li><li><var>controlled_by_this_extension</var>: controlled by this extension</li></ul>" + }, + { + "id": "Setting", + "type": "object", + "functions": [ + { + "name": "get", + "type": "function", + "description": "Gets the value of a setting.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "description": "Which setting to consider.", + "properties": { + "incognito": { + "type": "boolean", + "optional": true, + "description": "Whether to return the value that applies to the incognito session (default false)." + } + } + }, + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "details", + "type": "object", + "description": "Details of the currently effective value.", + "properties": { + "value": { + "description": "The value of the setting.", + "type": "any" + }, + "levelOfControl": { + "$ref": "types.LevelOfControl", + "description": "The level of control of the setting." + }, + "incognitoSpecific": { + "description": "Whether the effective value is specific to the incognito session.<br/>This property will <em>only</em> be present if the <var>incognito</var> property in the <var>details</var> parameter of <code>get()</code> was true.", + "type": "boolean", + "optional": true + } + } + } + ] + } + ] + }, + { + "name": "set", + "type": "function", + "description": "Sets the value of a setting.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "description": "Which setting to change.", + "properties": { + "value": { + "description": "The value of the setting. <br/>Note that every setting has a specific value type, which is described together with the setting. An extension should <em>not</em> set a value of a different type.", + "type": "any" + }, + "scope": { + "$ref": "types.SettingScope", + "optional": true, + "description": "Where to set the setting (default: regular)." + } + } + }, + { + "name": "callback", + "type": "function", + "description": "Called at the completion of the set operation.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "clear", + "type": "function", + "description": "Clears the setting, restoring any default value.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "description": "Which setting to clear.", + "properties": { + "scope": { + "$ref": "types.SettingScope", + "optional": true, + "description": "Where to clear the setting (default: regular)." + } + } + }, + { + "name": "callback", + "type": "function", + "description": "Called at the completion of the clear operation.", + "optional": true, + "parameters": [] + } + ] + } + ], + "events": [ + { + "name": "onChange", + "type": "function", + "description": "Fired after the setting changes.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "value": { + "description": "The value of the setting after the change.", + "type": "any" + }, + "levelOfControl": { + "$ref": "types.LevelOfControl", + "description": "The level of control of the setting." + }, + "incognitoSpecific": { + "description": "Whether the value that has changed is specific to the incognito session.<br/>This property will <em>only</em> be present if the user has enabled the extension in incognito mode.", + "type": "boolean", + "optional": true + } + } + } + ] + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/user_scripts.json b/toolkit/components/extensions/schemas/user_scripts.json new file mode 100644 index 0000000000..35a66e53ed --- /dev/null +++ b/toolkit/components/extensions/schemas/user_scripts.json @@ -0,0 +1,132 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "WebExtensionManifest", + "properties": { + "user_scripts": { + "type": "object", + "max_manifest_version": 2, + "optional": true, + "properties": { + "api_script": { + "optional": true, + "$ref": "manifest.ExtensionURL" + } + }, + "additionalProperties": { "$ref": "UnrecognizedProperty" } + } + } + } + ] + }, + { + "namespace": "userScripts", + "max_manifest_version": 2, + "permissions": ["manifest:user_scripts"], + "types": [ + { + "id": "UserScriptOptions", + "type": "object", + "description": "Details of a user script", + "properties": { + "js": { + "type": "array", + "optional": false, + "description": "The list of JS files to inject", + "minItems": 1, + "items": { "$ref": "extensionTypes.ExtensionFileOrCode" } + }, + "scriptMetadata": { + "description": "An opaque user script metadata value", + "$ref": "extensionTypes.PlainJSONValue", + "optional": true + }, + "matches": { + "type": "array", + "optional": false, + "minItems": 1, + "items": { "$ref": "manifest.MatchPattern" } + }, + "excludeMatches": { + "type": "array", + "optional": true, + "minItems": 1, + "items": { "$ref": "manifest.MatchPattern" } + }, + "includeGlobs": { + "type": "array", + "optional": true, + "items": { "type": "string" } + }, + "excludeGlobs": { + "type": "array", + "optional": true, + "items": { "type": "string" } + }, + "allFrames": { + "type": "boolean", + "default": false, + "optional": true, + "description": "If allFrames is <code>true</code>, implies that the JavaScript should be injected into all frames of current page. By default, it's <code>false</code> and is only injected into the top frame." + }, + "matchAboutBlank": { + "type": "boolean", + "default": false, + "optional": true, + "description": "If matchAboutBlank is true, then the code is also injected in about:blank and about:srcdoc frames if your extension has access to its parent document. Code cannot be inserted in top-level about:-frames. By default it is <code>false</code>." + }, + "runAt": { + "$ref": "extensionTypes.RunAt", + "default": "document_idle", + "optional": true, + "description": "The soonest that the JavaScript will be injected into the tab. Defaults to \"document_idle\"." + }, + "cookieStoreId": { + "choices": [ + { + "type": "array", + "minItems": 1, + "items": { "type": "string" } + }, + { + "type": "string" + } + ], + "optional": true, + "description": "limit the set of matched tabs to those that belong to the given cookie store id" + } + } + }, + { + "id": "RegisteredUserScript", + "type": "object", + "description": "An object that represents a user script registered programmatically", + "functions": [ + { + "name": "unregister", + "type": "function", + "description": "Unregister a user script registered programmatically", + "async": true, + "parameters": [] + } + ] + } + ], + "functions": [ + { + "name": "register", + "type": "function", + "description": "Register a user script programmatically given its $(ref:userScripts.UserScriptOptions), and resolves to a $(ref:userScripts.RegisteredUserScript) instance", + "async": true, + "parameters": [ + { + "name": "userScriptOptions", + "$ref": "UserScriptOptions" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/user_scripts_content.json b/toolkit/components/extensions/schemas/user_scripts_content.json new file mode 100644 index 0000000000..6e6581ed2e --- /dev/null +++ b/toolkit/components/extensions/schemas/user_scripts_content.json @@ -0,0 +1,58 @@ +[ + { + "namespace": "userScripts", + "max_manifest_version": 2, + "permissions": ["manifest:user_scripts"], + "allowedContexts": ["content"], + "events": [ + { + "name": "onBeforeScript", + "permissions": ["manifest:user_scripts.api_script"], + "allowedContexts": ["content", "content_only"], + "type": "function", + "description": "Event called when a new userScript global has been created", + "parameters": [ + { + "type": "object", + "name": "userScript", + "properties": { + "metadata": { + "type": "any", + "description": "The userScript metadata (as set in userScripts.register)" + }, + "global": { + "type": "any", + "description": "The userScript global" + }, + "defineGlobals": { + "type": "function", + "description": "Exports all the properties of a given plain object as userScript globals", + "parameters": [ + { + "type": "object", + "name": "sourceObject", + "description": "A plain object whose properties are exported as userScript globals" + } + ] + }, + "export": { + "type": "function", + "description": "Convert a given value to make it accessible to the userScript code", + "parameters": [ + { + "type": "any", + "name": "value", + "description": "A value to convert into an object accessible to the userScript" + } + ], + "returns": { + "type": "any" + } + } + } + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/web_navigation.json b/toolkit/components/extensions/schemas/web_navigation.json new file mode 100644 index 0000000000..c614a064bd --- /dev/null +++ b/toolkit/components/extensions/schemas/web_navigation.json @@ -0,0 +1,573 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermission", + "choices": [ + { + "type": "string", + "enum": ["webNavigation"] + } + ] + } + ] + }, + { + "namespace": "webNavigation", + "description": "Use the <code>browser.webNavigation</code> API to receive notifications about the status of navigation requests in-flight.", + "permissions": ["webNavigation"], + "types": [ + { + "id": "TransitionType", + "type": "string", + "enum": [ + "link", + "typed", + "auto_bookmark", + "auto_subframe", + "manual_subframe", + "generated", + "start_page", + "form_submit", + "reload", + "keyword", + "keyword_generated" + ], + "description": "Cause of the navigation. The same transition types as defined in the history API are used. These are the same transition types as defined in the $(topic:transition_types)[history API] except with <code>\"start_page\"</code> in place of <code>\"auto_toplevel\"</code> (for backwards compatibility)." + }, + { + "id": "TransitionQualifier", + "type": "string", + "enum": [ + "client_redirect", + "server_redirect", + "forward_back", + "from_address_bar" + ] + }, + { + "id": "EventUrlFilters", + "type": "object", + "properties": { + "url": { + "type": "array", + "minItems": 1, + "items": { "$ref": "events.UrlFilter" } + } + } + } + ], + "functions": [ + { + "name": "getFrame", + "type": "function", + "description": "Retrieves information about the given frame. A frame refers to an <iframe> or a <frame> of a web page and is identified by a tab ID and a frame ID.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "details", + "description": "Information about the frame to retrieve information about.", + "properties": { + "tabId": { + "type": "integer", + "minimum": 0, + "description": "The ID of the tab in which the frame is." + }, + "processId": { + "optional": true, + "type": "integer", + "description": "The ID of the process runs the renderer for this tab." + }, + "frameId": { + "type": "integer", + "minimum": 0, + "description": "The ID of the frame in the given tab." + } + } + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "type": "object", + "name": "details", + "optional": true, + "description": "Information about the requested frame, null if the specified frame ID and/or tab ID are invalid.", + "properties": { + "errorOccurred": { + "optional": true, + "type": "boolean", + "description": "True if the last navigation in this frame was interrupted by an error, i.e. the onErrorOccurred event fired." + }, + "url": { + "type": "string", + "description": "The URL currently associated with this frame, if the frame identified by the frameId existed at one point in the given tab. The fact that an URL is associated with a given frameId does not imply that the corresponding frame still exists." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the frame is." + }, + "frameId": { + "type": "integer", + "description": "The ID of the frame. 0 indicates that this is the main frame; a positive value indicates the ID of a subframe." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame. Set to -1 of no parent frame exists." + } + } + } + ] + } + ] + }, + { + "name": "getAllFrames", + "type": "function", + "description": "Retrieves information about all frames of a given tab.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "details", + "description": "Information about the tab to retrieve all frames from.", + "properties": { + "tabId": { + "type": "integer", + "minimum": 0, + "description": "The ID of the tab." + } + } + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "details", + "type": "array", + "description": "A list of frames in the given tab, null if the specified tab ID is invalid.", + "optional": true, + "items": { + "type": "object", + "properties": { + "errorOccurred": { + "optional": true, + "type": "boolean", + "description": "True if the last navigation in this frame was interrupted by an error, i.e. the onErrorOccurred event fired." + }, + "processId": { + "unsupported": true, + "type": "integer", + "description": "The ID of the process runs the renderer for this tab." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the frame is." + }, + "frameId": { + "type": "integer", + "description": "The ID of the frame. 0 indicates that this is the main frame; a positive value indicates the ID of a subframe." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame. Set to -1 of no parent frame exists." + }, + "url": { + "type": "string", + "description": "The URL currently associated with this frame." + } + } + } + } + ] + } + ] + } + ], + "events": [ + { + "name": "onBeforeNavigate", + "type": "function", + "description": "Fired when a navigation is about to occur.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the navigation is about to occur." + }, + "url": { "type": "string" }, + "processId": { + "unsupported": true, + "type": "integer", + "description": "The ID of the process runs the renderer for this tab." + }, + "frameId": { + "type": "integer", + "description": "0 indicates the navigation happens in the tab content window; a positive value indicates navigation in a subframe. Frame IDs are unique for a given tab and process." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame. Set to -1 of no parent frame exists." + }, + "timeStamp": { + "type": "number", + "description": "The time when the browser was about to start the navigation, in milliseconds since the epoch." + } + } + } + ], + "extraParameters": [ + { + "name": "filters", + "optional": true, + "$ref": "EventUrlFilters", + "description": "Conditions that the URL being navigated to must satisfy. The 'schemes' and 'ports' fields of UrlFilter are ignored for this event." + } + ] + }, + { + "name": "onCommitted", + "type": "function", + "description": "Fired when a navigation is committed. The document (and the resources it refers to, such as images and subframes) might still be downloading, but at least part of the document has been received from the server and the browser has decided to switch to the new document.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the navigation occurs." + }, + "url": { "type": "string" }, + "processId": { + "unsupported": true, + "type": "integer", + "description": "The ID of the process runs the renderer for this tab." + }, + "frameId": { + "type": "integer", + "description": "0 indicates the navigation happens in the tab content window; a positive value indicates navigation in a subframe. Frame IDs are unique within a tab." + }, + "transitionType": { + "$ref": "TransitionType", + "description": "Cause of the navigation." + }, + "transitionQualifiers": { + "type": "array", + "description": "A list of transition qualifiers.", + "items": { "$ref": "TransitionQualifier" } + }, + "timeStamp": { + "type": "number", + "description": "The time when the navigation was committed, in milliseconds since the epoch." + } + } + } + ], + "extraParameters": [ + { + "name": "filters", + "optional": true, + "$ref": "EventUrlFilters", + "description": "Conditions that the URL being navigated to must satisfy. The 'schemes' and 'ports' fields of UrlFilter are ignored for this event." + } + ] + }, + { + "name": "onDOMContentLoaded", + "type": "function", + "description": "Fired when the page's DOM is fully constructed, but the referenced resources may not finish loading.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the navigation occurs." + }, + "url": { "type": "string" }, + "processId": { + "unsupported": true, + "type": "integer", + "description": "The ID of the process runs the renderer for this tab." + }, + "frameId": { + "type": "integer", + "description": "0 indicates the navigation happens in the tab content window; a positive value indicates navigation in a subframe. Frame IDs are unique within a tab." + }, + "timeStamp": { + "type": "number", + "description": "The time when the page's DOM was fully constructed, in milliseconds since the epoch." + } + } + } + ], + "extraParameters": [ + { + "name": "filters", + "optional": true, + "$ref": "EventUrlFilters", + "description": "Conditions that the URL being navigated to must satisfy. The 'schemes' and 'ports' fields of UrlFilter are ignored for this event." + } + ] + }, + { + "name": "onCompleted", + "type": "function", + "description": "Fired when a document, including the resources it refers to, is completely loaded and initialized.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the navigation occurs." + }, + "url": { "type": "string" }, + "processId": { + "unsupported": true, + "type": "integer", + "description": "The ID of the process runs the renderer for this tab." + }, + "frameId": { + "type": "integer", + "description": "0 indicates the navigation happens in the tab content window; a positive value indicates navigation in a subframe. Frame IDs are unique within a tab." + }, + "timeStamp": { + "type": "number", + "description": "The time when the document finished loading, in milliseconds since the epoch." + } + } + } + ], + "extraParameters": [ + { + "name": "filters", + "optional": true, + "$ref": "EventUrlFilters", + "description": "Conditions that the URL being navigated to must satisfy. The 'schemes' and 'ports' fields of UrlFilter are ignored for this event." + } + ] + }, + { + "name": "onErrorOccurred", + "type": "function", + "description": "Fired when an error occurs and the navigation is aborted. This can happen if either a network error occurred, or the user aborted the navigation.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the navigation occurs." + }, + "url": { "type": "string" }, + "processId": { + "unsupported": true, + "type": "integer", + "description": "The ID of the process runs the renderer for this tab." + }, + "frameId": { + "type": "integer", + "description": "0 indicates the navigation happens in the tab content window; a positive value indicates navigation in a subframe. Frame IDs are unique within a tab." + }, + "error": { + "unsupported": true, + "type": "string", + "description": "The error description." + }, + "timeStamp": { + "type": "number", + "description": "The time when the error occurred, in milliseconds since the epoch." + } + } + } + ], + "extraParameters": [ + { + "name": "filters", + "optional": true, + "$ref": "EventUrlFilters", + "description": "Conditions that the URL being navigated to must satisfy. The 'schemes' and 'ports' fields of UrlFilter are ignored for this event." + } + ] + }, + { + "name": "onCreatedNavigationTarget", + "type": "function", + "description": "Fired when a new window, or a new tab in an existing window, is created to host a navigation.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "sourceTabId": { + "type": "integer", + "description": "The ID of the tab in which the navigation is triggered." + }, + "sourceProcessId": { + "type": "integer", + "description": "The ID of the process runs the renderer for the source tab." + }, + "sourceFrameId": { + "type": "integer", + "description": "The ID of the frame with sourceTabId in which the navigation is triggered. 0 indicates the main frame." + }, + "url": { + "type": "string", + "description": "The URL to be opened in the new window." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the url is opened" + }, + "timeStamp": { + "type": "number", + "description": "The time when the browser was about to create a new view, in milliseconds since the epoch." + } + } + } + ], + "extraParameters": [ + { + "name": "filters", + "optional": true, + "$ref": "EventUrlFilters", + "description": "Conditions that the URL being navigated to must satisfy. The 'schemes' and 'ports' fields of UrlFilter are ignored for this event." + } + ] + }, + { + "name": "onReferenceFragmentUpdated", + "type": "function", + "description": "Fired when the reference fragment of a frame was updated. All future events for that frame will use the updated URL.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the navigation occurs." + }, + "url": { "type": "string" }, + "processId": { + "unsupported": true, + "type": "integer", + "description": "The ID of the process runs the renderer for this tab." + }, + "frameId": { + "type": "integer", + "description": "0 indicates the navigation happens in the tab content window; a positive value indicates navigation in a subframe. Frame IDs are unique within a tab." + }, + "transitionType": { + "$ref": "TransitionType", + "description": "Cause of the navigation." + }, + "transitionQualifiers": { + "type": "array", + "description": "A list of transition qualifiers.", + "items": { "$ref": "TransitionQualifier" } + }, + "timeStamp": { + "type": "number", + "description": "The time when the navigation was committed, in milliseconds since the epoch." + } + } + } + ], + "extraParameters": [ + { + "name": "filters", + "optional": true, + "$ref": "EventUrlFilters", + "description": "Conditions that the URL being navigated to must satisfy. The 'schemes' and 'ports' fields of UrlFilter are ignored for this event." + } + ] + }, + { + "name": "onTabReplaced", + "type": "function", + "description": "Fired when the contents of the tab is replaced by a different (usually previously pre-rendered) tab.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "replacedTabId": { + "type": "integer", + "description": "The ID of the tab that was replaced." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab that replaced the old tab." + }, + "timeStamp": { + "type": "number", + "description": "The time when the replacement happened, in milliseconds since the epoch." + } + } + } + ] + }, + { + "name": "onHistoryStateUpdated", + "type": "function", + "description": "Fired when the frame's history was updated to a new URL. All future events for that frame will use the updated URL.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the navigation occurs." + }, + "url": { "type": "string" }, + "processId": { + "unsupported": true, + "type": "integer", + "description": "The ID of the process runs the renderer for this tab." + }, + "frameId": { + "type": "integer", + "description": "0 indicates the navigation happens in the tab content window; a positive value indicates navigation in a subframe. Frame IDs are unique within a tab." + }, + "transitionType": { + "$ref": "TransitionType", + "description": "Cause of the navigation." + }, + "transitionQualifiers": { + "type": "array", + "description": "A list of transition qualifiers.", + "items": { "$ref": "TransitionQualifier" } + }, + "timeStamp": { + "type": "number", + "description": "The time when the navigation was committed, in milliseconds since the epoch." + } + } + } + ], + "extraParameters": [ + { + "name": "filters", + "optional": true, + "$ref": "EventUrlFilters", + "description": "Conditions that the URL being navigated to must satisfy. The 'schemes' and 'ports' fields of UrlFilter are ignored for this event." + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/web_request.json b/toolkit/components/extensions/schemas/web_request.json new file mode 100644 index 0000000000..e4405f24c3 --- /dev/null +++ b/toolkit/components/extensions/schemas/web_request.json @@ -0,0 +1,1475 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermissionNoPrompt", + "choices": [ + { + "type": "string", + "enum": [ + "webRequest", + "webRequestBlocking", + "webRequestFilterResponse", + "webRequestFilterResponse.serviceWorkerScript" + ] + } + ] + } + ] + }, + { + "namespace": "webRequest", + "description": "Use the <code>browser.webRequest</code> API to observe and analyze traffic and to intercept, block, or modify requests in-flight.", + "permissions": ["webRequest"], + "properties": { + "MAX_HANDLER_BEHAVIOR_CHANGED_CALLS_PER_10_MINUTES": { + "value": 20, + "description": "The maximum number of times that <code>handlerBehaviorChanged</code> can be called per 10 minute sustained interval. <code>handlerBehaviorChanged</code> is an expensive function call that shouldn't be called often." + } + }, + "types": [ + { + "id": "ResourceType", + "type": "string", + "enum": [ + "main_frame", + "sub_frame", + "stylesheet", + "script", + "image", + "object", + "object_subrequest", + "xmlhttprequest", + "xslt", + "ping", + "beacon", + "xml_dtd", + "font", + "media", + "websocket", + "csp_report", + "imageset", + "web_manifest", + "speculative", + "other" + ] + }, + { + "id": "OnBeforeRequestOptions", + "type": "string", + "enum": ["blocking", "requestBody"], + "postprocess": "webRequestBlockingPermissionRequired" + }, + { + "id": "OnBeforeSendHeadersOptions", + "type": "string", + "enum": ["requestHeaders", "blocking"], + "postprocess": "webRequestBlockingPermissionRequired" + }, + { + "id": "OnSendHeadersOptions", + "type": "string", + "enum": ["requestHeaders"] + }, + { + "id": "OnHeadersReceivedOptions", + "type": "string", + "enum": ["blocking", "responseHeaders"], + "postprocess": "webRequestBlockingPermissionRequired" + }, + { + "id": "OnAuthRequiredOptions", + "type": "string", + "enum": ["responseHeaders", "blocking", "asyncBlocking"], + "postprocess": "webRequestBlockingPermissionRequired" + }, + { + "id": "OnResponseStartedOptions", + "type": "string", + "enum": ["responseHeaders"] + }, + { + "id": "OnBeforeRedirectOptions", + "type": "string", + "enum": ["responseHeaders"] + }, + { + "id": "OnCompletedOptions", + "type": "string", + "enum": ["responseHeaders"] + }, + { + "id": "RequestFilter", + "type": "object", + "description": "An object describing filters to apply to webRequest events.", + "properties": { + "urls": { + "type": "array", + "description": "A list of URLs or URL patterns. Requests that cannot match any of the URLs will be filtered out.", + "items": { "type": "string" }, + "minItems": 1 + }, + "types": { + "type": "array", + "optional": true, + "description": "A list of request types. Requests that cannot match any of the types will be filtered out.", + "items": { "$ref": "ResourceType", "onError": "warn" }, + "minItems": 1 + }, + "tabId": { "type": "integer", "optional": true }, + "windowId": { "type": "integer", "optional": true }, + "incognito": { + "type": "boolean", + "optional": true, + "description": "If provided, requests that do not match the incognito state will be filtered out." + } + } + }, + { + "id": "HttpHeaders", + "type": "array", + "description": "An array of HTTP headers. Each header is represented as a dictionary containing the keys <code>name</code> and either <code>value</code> or <code>binaryValue</code>.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the HTTP header." + }, + "value": { + "type": "string", + "optional": true, + "description": "Value of the HTTP header if it can be represented by UTF-8." + }, + "binaryValue": { + "type": "array", + "optional": true, + "description": "Value of the HTTP header if it cannot be represented by UTF-8, stored as individual byte values (0..255).", + "items": { "type": "integer" } + } + } + } + }, + { + "id": "BlockingResponse", + "type": "object", + "description": "Returns value for event handlers that have the 'blocking' extraInfoSpec applied. Allows the event handler to modify network requests.", + "properties": { + "cancel": { + "type": "boolean", + "optional": true, + "description": "If true, the request is cancelled. Used in onBeforeRequest, this prevents the request from being sent." + }, + "redirectUrl": { + "type": "string", + "optional": true, + "description": "Only used as a response to the onBeforeRequest and onHeadersReceived events. If set, the original request is prevented from being sent/completed and is instead redirected to the given URL. Redirections to non-HTTP schemes such as data: are allowed. Redirects initiated by a redirect action use the original request method for the redirect, with one exception: If the redirect is initiated at the onHeadersReceived stage, then the redirect will be issued using the GET method." + }, + "upgradeToSecure": { + "type": "boolean", + "optional": true, + "description": "Only used as a response to the onBeforeRequest event. If set, the original request is prevented from being sent/completed and is instead upgraded to a secure request. If any extension returns <code>redirectUrl</code> during onBeforeRequest, <code>upgradeToSecure</code> will have no affect." + }, + "requestHeaders": { + "$ref": "HttpHeaders", + "optional": true, + "description": "Only used as a response to the onBeforeSendHeaders event. If set, the request is made with these request headers instead." + }, + "responseHeaders": { + "$ref": "HttpHeaders", + "optional": true, + "description": "Only used as a response to the onHeadersReceived event. If set, the server is assumed to have responded with these response headers instead. Only return <code>responseHeaders</code> if you really want to modify the headers in order to limit the number of conflicts (only one extension may modify <code>responseHeaders</code> for each request)." + }, + "authCredentials": { + "type": "object", + "description": "Only used as a response to the onAuthRequired event. If set, the request is made using the supplied credentials.", + "optional": true, + "properties": { + "username": { "type": "string" }, + "password": { "type": "string" } + } + } + } + }, + { + "id": "CertificateInfo", + "type": "object", + "description": "Contains the certificate properties of the request if it is a secure request.", + "properties": { + "subject": { + "type": "string" + }, + "issuer": { + "type": "string" + }, + "validity": { + "type": "object", + "description": "Contains start and end timestamps.", + "properties": { + "start": { "type": "integer" }, + "end": { "type": "integer" } + } + }, + "fingerprint": { + "type": "object", + "properties": { + "sha1": { "type": "string" }, + "sha256": { "type": "string" } + } + }, + "serialNumber": { + "type": "string" + }, + "isBuiltInRoot": { + "type": "boolean" + }, + "subjectPublicKeyInfoDigest": { + "type": "object", + "properties": { + "sha256": { "type": "string" } + } + }, + "rawDER": { + "optional": true, + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + { + "id": "CertificateTransparencyStatus", + "type": "string", + "enum": [ + "not_applicable", + "policy_compliant", + "policy_not_enough_scts", + "policy_not_diverse_scts" + ] + }, + { + "id": "TransportWeaknessReasons", + "type": "string", + "enum": ["cipher"] + }, + { + "id": "SecurityInfo", + "type": "object", + "description": "Contains the security properties of the request (ie. SSL/TLS information).", + "properties": { + "state": { + "type": "string", + "enum": ["insecure", "weak", "broken", "secure"] + }, + "errorMessage": { + "type": "string", + "description": "Error message if state is \"broken\"", + "optional": true + }, + "protocolVersion": { + "type": "string", + "description": "Protocol version if state is \"secure\"", + "enum": ["TLSv1", "TLSv1.1", "TLSv1.2", "TLSv1.3", "unknown"], + "optional": true + }, + "cipherSuite": { + "type": "string", + "description": "The cipher suite used in this request if state is \"secure\".", + "optional": true + }, + "keaGroupName": { + "type": "string", + "description": "The key exchange algorithm used in this request if state is \"secure\".", + "optional": true + }, + "secretKeyLength": { + "type": "number", + "description": "The length (in bits) of the secret key.", + "optional": true + }, + "signatureSchemeName": { + "type": "string", + "description": "The signature scheme used in this request if state is \"secure\".", + "optional": true + }, + "certificates": { + "description": "Certificate data if state is \"secure\". Will only contain one entry unless <code>certificateChain</code> is passed as an option.", + "type": "array", + "items": { "$ref": "CertificateInfo" } + }, + "overridableErrorCategory": { + "description": "The type of certificate error that was overridden for this connection, if any.", + "type": "string", + "enum": [ + "trust_error", + "domain_mismatch", + "expired_or_not_yet_valid" + ], + "optional": true + }, + "isDomainMismatch": { + "description": "The domain name does not match the certificate domain.", + "type": "boolean", + "optional": true, + "deprecated": "Please use $(ref:SecurityInfo.overridableErrorCategory)." + }, + "isNotValidAtThisTime": { + "description": "The certificate is either expired or is not yet valid. See <code>CertificateInfo.validity</code> for start and end dates.", + "type": "boolean", + "optional": true, + "deprecated": "Please use $(ref:SecurityInfo.overridableErrorCategory)." + }, + "isUntrusted": { + "type": "boolean", + "optional": true, + "deprecated": "Please use $(ref:SecurityInfo.overridableErrorCategory)." + }, + "isExtendedValidation": { + "type": "boolean", + "optional": true + }, + "certificateTransparencyStatus": { + "description": "Certificate transparency compliance per RFC 6962. See <code>https://www.certificate-transparency.org/what-is-ct</code> for more information.", + "$ref": "CertificateTransparencyStatus", + "optional": true + }, + "hsts": { + "type": "boolean", + "description": "True if host uses Strict Transport Security and state is \"secure\".", + "optional": true + }, + "hpkp": { + "type": "string", + "description": "True if host uses Public Key Pinning and state is \"secure\".", + "optional": true + }, + "weaknessReasons": { + "type": "array", + "items": { "$ref": "TransportWeaknessReasons" }, + "description": "list of reasons that cause the request to be considered weak, if state is \"weak\"", + "optional": true + }, + "usedEch": { + "type": "boolean", + "description": "True if the TLS connection used Encrypted Client Hello.", + "optional": true + }, + "usedDelegatedCredentials": { + "type": "boolean", + "description": "True if the TLS connection used Delegated Credentials.", + "optional": true + }, + "usedOcsp": { + "type": "boolean", + "description": "True if the TLS connection made OCSP requests.", + "optional": true + }, + "usedPrivateDns": { + "type": "boolean", + "description": "True if the TLS connection used a privacy-preserving DNS transport like DNS-over-HTTPS.", + "optional": true + } + } + }, + { + "id": "UploadData", + "type": "object", + "properties": { + "bytes": { + "type": "any", + "optional": true, + "description": "An ArrayBuffer with a copy of the data." + }, + "file": { + "type": "string", + "optional": true, + "description": "A string with the file's path and name." + } + }, + "description": "Contains data uploaded in a URL request." + }, + { + "id": "UrlClassificationFlags", + "type": "string", + "enum": [ + "fingerprinting", + "fingerprinting_content", + "cryptomining", + "cryptomining_content", + "emailtracking", + "emailtracking_content", + "tracking", + "tracking_ad", + "tracking_analytics", + "tracking_social", + "tracking_content", + "any_basic_tracking", + "any_strict_tracking", + "any_social_tracking" + ], + "description": "Tracking flags that match our internal tracking classification" + }, + { + "id": "UrlClassificationParty", + "type": "array", + "items": { "$ref": "UrlClassificationFlags" }, + "description": "If the request has been classified this is an array of $(ref:UrlClassificationFlags)." + }, + { + "id": "UrlClassification", + "type": "object", + "properties": { + "firstParty": { + "$ref": "UrlClassificationParty", + "description": "Classification flags if the request has been classified and it is first party." + }, + "thirdParty": { + "$ref": "UrlClassificationParty", + "description": "Classification flags if the request has been classified and it or its window hierarchy is third party." + } + } + } + ], + "functions": [ + { + "name": "handlerBehaviorChanged", + "type": "function", + "description": "Needs to be called when the behavior of the webRequest handlers has changed to prevent incorrect handling due to caching. This function call is expensive. Don't call it often.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "filterResponseData", + "permissions": ["webRequestBlocking"], + "type": "function", + "description": "...", + "parameters": [ + { + "name": "requestId", + "type": "string" + } + ], + "returns": { + "type": "object", + "additionalProperties": { "type": "any" }, + "isInstanceOf": "StreamFilter" + } + }, + { + "name": "getSecurityInfo", + "type": "function", + "async": true, + "description": "Retrieves the security information for the request. Returns a promise that will resolve to a SecurityInfo object.", + "parameters": [ + { + "name": "requestId", + "type": "string" + }, + { + "name": "options", + "optional": true, + "type": "object", + "properties": { + "certificateChain": { + "type": "boolean", + "description": "Include the entire certificate chain.", + "optional": true + }, + "rawDER": { + "type": "boolean", + "description": "Include raw certificate data for processing by the extension.", + "optional": true + } + } + } + ] + } + ], + "events": [ + { + "name": "onBeforeRequest", + "type": "function", + "description": "Fired when a request is about to occur.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": { + "type": "string", + "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request." + }, + "url": { "type": "string" }, + "method": { + "type": "string", + "description": "Standard HTTP method." + }, + "frameId": { + "type": "integer", + "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (<code>type</code> is <code>main_frame</code> or <code>sub_frame</code>), <code>frameId</code> indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists." + }, + "incognito": { + "type": "boolean", + "optional": true, + "description": "True for private browsing requests." + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, + "originUrl": { + "type": "string", + "optional": true, + "description": "URL of the resource that triggered this request." + }, + "documentUrl": { + "type": "string", + "optional": true, + "description": "URL of the page into which the requested resource will be loaded." + }, + "requestBody": { + "type": "object", + "optional": true, + "description": "Contains the HTTP request body data. Only provided if extraInfoSpec contains 'requestBody'.", + "properties": { + "error": { + "type": "string", + "optional": true, + "description": "Errors when obtaining request body data." + }, + "formData": { + "type": "object", + "optional": true, + "description": "If the request method is POST and the body is a sequence of key-value pairs encoded in UTF8, encoded as either multipart/form-data, or application/x-www-form-urlencoded, this dictionary is present and for each key contains the list of all values for that key. If the data is of another media type, or if it is malformed, the dictionary is not present. An example value of this dictionary is {'key': ['value1', 'value2']}.", + "properties": {}, + "additionalProperties": { + "type": "array", + "items": { "type": "string" } + } + }, + "raw": { + "type": "array", + "optional": true, + "items": { "$ref": "UploadData" }, + "description": "If the request method is PUT or POST, and the body is not already parsed in formData, then the unparsed request body elements are contained in this array." + } + } + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab." + }, + "type": { + "$ref": "ResourceType", + "description": "How the requested resource will be used." + }, + "timeStamp": { + "type": "number", + "description": "The time when this signal is triggered, in milliseconds since the epoch." + }, + "urlClassification": { + "$ref": "UrlClassification", + "optional": true, + "description": "Tracking classification if the request has been classified." + }, + "thirdParty": { + "type": "boolean", + "description": "Indicates if this request and its content window hierarchy is third party." + } + } + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "$ref": "OnBeforeRequestOptions" + } + } + ], + "returns": { + "$ref": "BlockingResponse", + "description": "If \"blocking\" is specified in the \"extraInfoSpec\" parameter, the event listener should return an object of this type.", + "optional": true + } + }, + { + "name": "onBeforeSendHeaders", + "type": "function", + "description": "Fired before sending an HTTP request, once the request headers are available. This may occur after a TCP connection is made to the server, but before any HTTP data is sent. ", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": { + "type": "string", + "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request." + }, + "url": { "type": "string" }, + "method": { + "type": "string", + "description": "Standard HTTP method." + }, + "frameId": { + "type": "integer", + "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (<code>type</code> is <code>main_frame</code> or <code>sub_frame</code>), <code>frameId</code> indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists." + }, + "incognito": { + "type": "boolean", + "optional": true, + "description": "True for private browsing requests." + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, + "originUrl": { + "type": "string", + "optional": true, + "description": "URL of the resource that triggered this request." + }, + "documentUrl": { + "type": "string", + "optional": true, + "description": "URL of the page into which the requested resource will be loaded." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab." + }, + "type": { + "$ref": "ResourceType", + "description": "How the requested resource will be used." + }, + "timeStamp": { + "type": "number", + "description": "The time when this signal is triggered, in milliseconds since the epoch." + }, + "requestHeaders": { + "$ref": "HttpHeaders", + "optional": true, + "description": "The HTTP request headers that are going to be sent out with this request." + }, + "urlClassification": { + "$ref": "UrlClassification", + "optional": true, + "description": "Tracking classification if the request has been classified." + }, + "thirdParty": { + "type": "boolean", + "description": "Indicates if this request and its content window hierarchy is third party." + } + } + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "$ref": "OnBeforeSendHeadersOptions" + } + } + ], + "returns": { + "$ref": "BlockingResponse", + "description": "If \"blocking\" is specified in the \"extraInfoSpec\" parameter, the event listener should return an object of this type.", + "optional": true + } + }, + { + "name": "onSendHeaders", + "type": "function", + "description": "Fired just before a request is going to be sent to the server (modifications of previous onBeforeSendHeaders callbacks are visible by the time onSendHeaders is fired).", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": { + "type": "string", + "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request." + }, + "url": { "type": "string" }, + "method": { + "type": "string", + "description": "Standard HTTP method." + }, + "frameId": { + "type": "integer", + "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (<code>type</code> is <code>main_frame</code> or <code>sub_frame</code>), <code>frameId</code> indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists." + }, + "incognito": { + "type": "boolean", + "optional": true, + "description": "True for private browsing requests." + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, + "originUrl": { + "type": "string", + "optional": true, + "description": "URL of the resource that triggered this request." + }, + "documentUrl": { + "type": "string", + "optional": true, + "description": "URL of the page into which the requested resource will be loaded." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab." + }, + "type": { + "$ref": "ResourceType", + "description": "How the requested resource will be used." + }, + "timeStamp": { + "type": "number", + "description": "The time when this signal is triggered, in milliseconds since the epoch." + }, + "requestHeaders": { + "$ref": "HttpHeaders", + "optional": true, + "description": "The HTTP request headers that have been sent out with this request." + }, + "urlClassification": { + "$ref": "UrlClassification", + "optional": true, + "description": "Tracking classification if the request has been classified." + }, + "thirdParty": { + "type": "boolean", + "description": "Indicates if this request and its content window hierarchy is third party." + } + } + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "$ref": "OnSendHeadersOptions" + } + } + ] + }, + { + "name": "onHeadersReceived", + "type": "function", + "description": "Fired when HTTP response headers of a request have been received.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": { + "type": "string", + "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request." + }, + "url": { "type": "string" }, + "method": { + "type": "string", + "description": "Standard HTTP method." + }, + "frameId": { + "type": "integer", + "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (<code>type</code> is <code>main_frame</code> or <code>sub_frame</code>), <code>frameId</code> indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists." + }, + "incognito": { + "type": "boolean", + "optional": true, + "description": "True for private browsing requests." + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, + "originUrl": { + "type": "string", + "optional": true, + "description": "URL of the resource that triggered this request." + }, + "documentUrl": { + "type": "string", + "optional": true, + "description": "URL of the page into which the requested resource will be loaded." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab." + }, + "type": { + "$ref": "ResourceType", + "description": "How the requested resource will be used." + }, + "timeStamp": { + "type": "number", + "description": "The time when this signal is triggered, in milliseconds since the epoch." + }, + "statusLine": { + "type": "string", + "description": "HTTP status line of the response or the 'HTTP/0.9 200 OK' string for HTTP/0.9 responses (i.e., responses that lack a status line)." + }, + "responseHeaders": { + "$ref": "HttpHeaders", + "optional": true, + "description": "The HTTP response headers that have been received with this response." + }, + "statusCode": { + "type": "integer", + "description": "Standard HTTP status code returned by the server." + }, + "urlClassification": { + "$ref": "UrlClassification", + "optional": true, + "description": "Tracking classification if the request has been classified." + }, + "thirdParty": { + "type": "boolean", + "description": "Indicates if this request and its content window hierarchy is third party." + } + } + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "$ref": "OnHeadersReceivedOptions" + } + } + ], + "returns": { + "$ref": "BlockingResponse", + "description": "If \"blocking\" is specified in the \"extraInfoSpec\" parameter, the event listener should return an object of this type.", + "optional": true + } + }, + { + "name": "onAuthRequired", + "type": "function", + "description": "Fired when an authentication failure is received. The listener has three options: it can provide authentication credentials, it can cancel the request and display the error page, or it can take no action on the challenge. If bad user credentials are provided, this may be called multiple times for the same request.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": { + "type": "string", + "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request." + }, + "url": { "type": "string" }, + "method": { + "type": "string", + "description": "Standard HTTP method." + }, + "frameId": { + "type": "integer", + "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (<code>type</code> is <code>main_frame</code> or <code>sub_frame</code>), <code>frameId</code> indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists." + }, + "incognito": { + "type": "boolean", + "optional": true, + "description": "True for private browsing requests." + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, + "originUrl": { + "type": "string", + "optional": true, + "description": "URL of the resource that triggered this request." + }, + "documentUrl": { + "type": "string", + "optional": true, + "description": "URL of the page into which the requested resource will be loaded." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab." + }, + "type": { + "$ref": "ResourceType", + "description": "How the requested resource will be used." + }, + "timeStamp": { + "type": "number", + "description": "The time when this signal is triggered, in milliseconds since the epoch." + }, + "scheme": { + "type": "string", + "description": "The authentication scheme, e.g. Basic or Digest." + }, + "realm": { + "type": "string", + "description": "The authentication realm provided by the server, if there is one.", + "optional": true + }, + "challenger": { + "type": "object", + "description": "The server requesting authentication.", + "properties": { + "host": { "type": "string" }, + "port": { "type": "integer" } + } + }, + "isProxy": { + "type": "boolean", + "description": "True for Proxy-Authenticate, false for WWW-Authenticate." + }, + "responseHeaders": { + "$ref": "HttpHeaders", + "optional": true, + "description": "The HTTP response headers that were received along with this response." + }, + "statusLine": { + "type": "string", + "description": "HTTP status line of the response or the 'HTTP/0.9 200 OK' string for HTTP/0.9 responses (i.e., responses that lack a status line) or an empty string if there are no headers." + }, + "statusCode": { + "type": "integer", + "description": "Standard HTTP status code returned by the server." + }, + "urlClassification": { + "$ref": "UrlClassification", + "optional": true, + "description": "Tracking classification if the request has been classified." + }, + "thirdParty": { + "type": "boolean", + "description": "Indicates if this request and its content window hierarchy is third party." + } + } + }, + { + "type": "function", + "optional": true, + "name": "callback", + "parameters": [{ "name": "response", "$ref": "BlockingResponse" }] + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "$ref": "OnAuthRequiredOptions" + } + } + ], + "returns": { + "$ref": "BlockingResponse", + "description": "If \"blocking\" is specified in the \"extraInfoSpec\" parameter, the event listener should return an object of this type.", + "optional": true + } + }, + { + "name": "onResponseStarted", + "type": "function", + "description": "Fired when the first byte of the response body is received. For HTTP requests, this means that the status line and response headers are available.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": { + "type": "string", + "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request." + }, + "url": { "type": "string" }, + "method": { + "type": "string", + "description": "Standard HTTP method." + }, + "frameId": { + "type": "integer", + "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (<code>type</code> is <code>main_frame</code> or <code>sub_frame</code>), <code>frameId</code> indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists." + }, + "incognito": { + "type": "boolean", + "optional": true, + "description": "True for private browsing requests." + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, + "originUrl": { + "type": "string", + "optional": true, + "description": "URL of the resource that triggered this request." + }, + "documentUrl": { + "type": "string", + "optional": true, + "description": "URL of the page into which the requested resource will be loaded." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab." + }, + "type": { + "$ref": "ResourceType", + "description": "How the requested resource will be used." + }, + "timeStamp": { + "type": "number", + "description": "The time when this signal is triggered, in milliseconds since the epoch." + }, + "ip": { + "type": "string", + "optional": true, + "description": "The server IP address that the request was actually sent to. Note that it may be a literal IPv6 address." + }, + "fromCache": { + "type": "boolean", + "description": "Indicates if this response was fetched from disk cache." + }, + "statusCode": { + "type": "integer", + "description": "Standard HTTP status code returned by the server." + }, + "responseHeaders": { + "$ref": "HttpHeaders", + "optional": true, + "description": "The HTTP response headers that were received along with this response." + }, + "statusLine": { + "type": "string", + "description": "HTTP status line of the response or the 'HTTP/0.9 200 OK' string for HTTP/0.9 responses (i.e., responses that lack a status line) or an empty string if there are no headers." + }, + "urlClassification": { + "$ref": "UrlClassification", + "optional": true, + "description": "Tracking classification if the request has been classified." + }, + "thirdParty": { + "type": "boolean", + "description": "Indicates if this request and its content window hierarchy is third party." + } + } + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "$ref": "OnResponseStartedOptions" + } + } + ] + }, + { + "name": "onBeforeRedirect", + "type": "function", + "description": "Fired when a server-initiated redirect is about to occur.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": { + "type": "string", + "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request." + }, + "url": { "type": "string" }, + "method": { + "type": "string", + "description": "Standard HTTP method." + }, + "frameId": { + "type": "integer", + "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (<code>type</code> is <code>main_frame</code> or <code>sub_frame</code>), <code>frameId</code> indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists." + }, + "incognito": { + "type": "boolean", + "optional": true, + "description": "True for private browsing requests." + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, + "originUrl": { + "type": "string", + "optional": true, + "description": "URL of the resource that triggered this request." + }, + "documentUrl": { + "type": "string", + "optional": true, + "description": "URL of the page into which the requested resource will be loaded." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab." + }, + "type": { + "$ref": "ResourceType", + "description": "How the requested resource will be used." + }, + "timeStamp": { + "type": "number", + "description": "The time when this signal is triggered, in milliseconds since the epoch." + }, + "ip": { + "type": "string", + "optional": true, + "description": "The server IP address that the request was actually sent to. Note that it may be a literal IPv6 address." + }, + "fromCache": { + "type": "boolean", + "description": "Indicates if this response was fetched from disk cache." + }, + "statusCode": { + "type": "integer", + "description": "Standard HTTP status code returned by the server." + }, + "redirectUrl": { + "type": "string", + "description": "The new URL." + }, + "responseHeaders": { + "$ref": "HttpHeaders", + "optional": true, + "description": "The HTTP response headers that were received along with this redirect." + }, + "statusLine": { + "type": "string", + "description": "HTTP status line of the response or the 'HTTP/0.9 200 OK' string for HTTP/0.9 responses (i.e., responses that lack a status line) or an empty string if there are no headers." + }, + "urlClassification": { + "$ref": "UrlClassification", + "optional": true, + "description": "Tracking classification if the request has been classified." + }, + "thirdParty": { + "type": "boolean", + "description": "Indicates if this request and its content window hierarchy is third party." + } + } + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "$ref": "OnBeforeRedirectOptions" + } + } + ] + }, + { + "name": "onCompleted", + "type": "function", + "description": "Fired when a request is completed.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": { + "type": "string", + "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request." + }, + "url": { "type": "string" }, + "method": { + "type": "string", + "description": "Standard HTTP method." + }, + "frameId": { + "type": "integer", + "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (<code>type</code> is <code>main_frame</code> or <code>sub_frame</code>), <code>frameId</code> indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists." + }, + "incognito": { + "type": "boolean", + "optional": true, + "description": "True for private browsing requests." + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, + "originUrl": { + "type": "string", + "optional": true, + "description": "URL of the resource that triggered this request." + }, + "documentUrl": { + "type": "string", + "optional": true, + "description": "URL of the page into which the requested resource will be loaded." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab." + }, + "type": { + "$ref": "ResourceType", + "description": "How the requested resource will be used." + }, + "timeStamp": { + "type": "number", + "description": "The time when this signal is triggered, in milliseconds since the epoch." + }, + "ip": { + "type": "string", + "optional": true, + "description": "The server IP address that the request was actually sent to. Note that it may be a literal IPv6 address." + }, + "fromCache": { + "type": "boolean", + "description": "Indicates if this response was fetched from disk cache." + }, + "statusCode": { + "type": "integer", + "description": "Standard HTTP status code returned by the server." + }, + "responseHeaders": { + "$ref": "HttpHeaders", + "optional": true, + "description": "The HTTP response headers that were received along with this response." + }, + "statusLine": { + "type": "string", + "description": "HTTP status line of the response or the 'HTTP/0.9 200 OK' string for HTTP/0.9 responses (i.e., responses that lack a status line) or an empty string if there are no headers." + }, + "urlClassification": { + "$ref": "UrlClassification", + "description": "Tracking classification if the request has been classified." + }, + "thirdParty": { + "type": "boolean", + "description": "Indicates if this request and its content window hierarchy is third party." + }, + "requestSize": { + "type": "integer", + "description": "For http requests, the bytes transferred in the request. Only available in onCompleted." + }, + "responseSize": { + "type": "integer", + "description": "For http requests, the bytes received in the request. Only available in onCompleted." + } + } + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "$ref": "OnCompletedOptions" + } + } + ] + }, + { + "name": "onErrorOccurred", + "type": "function", + "description": "Fired when an error occurs.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": { + "type": "string", + "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request." + }, + "url": { "type": "string" }, + "method": { + "type": "string", + "description": "Standard HTTP method." + }, + "frameId": { + "type": "integer", + "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (<code>type</code> is <code>main_frame</code> or <code>sub_frame</code>), <code>frameId</code> indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists." + }, + "incognito": { + "type": "boolean", + "optional": true, + "description": "True for private browsing requests." + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, + "originUrl": { + "type": "string", + "optional": true, + "description": "URL of the resource that triggered this request." + }, + "documentUrl": { + "type": "string", + "optional": true, + "description": "URL of the page into which the requested resource will be loaded." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab." + }, + "type": { + "$ref": "ResourceType", + "description": "How the requested resource will be used." + }, + "timeStamp": { + "type": "number", + "description": "The time when this signal is triggered, in milliseconds since the epoch." + }, + "ip": { + "type": "string", + "optional": true, + "description": "The server IP address that the request was actually sent to. Note that it may be a literal IPv6 address." + }, + "fromCache": { + "type": "boolean", + "description": "Indicates if this response was fetched from disk cache." + }, + "error": { + "type": "string", + "description": "The error description. This string is <em>not</em> guaranteed to remain backwards compatible between releases. You must not parse and act based upon its content." + }, + "urlClassification": { + "$ref": "UrlClassification", + "optional": true, + "description": "Tracking classification if the request has been classified." + }, + "thirdParty": { + "type": "boolean", + "description": "Indicates if this request and its content window hierarchy is third party." + } + } + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/storage/ExtensionStorageComponents.h b/toolkit/components/extensions/storage/ExtensionStorageComponents.h new file mode 100644 index 0000000000..53af177432 --- /dev/null +++ b/toolkit/components/extensions/storage/ExtensionStorageComponents.h @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_extensions_storage_ExtensionStorageComponents_h_ +#define mozilla_extensions_storage_ExtensionStorageComponents_h_ + +#include "mozIExtensionStorageArea.h" +#include "nsCOMPtr.h" + +extern "C" { + +// Implemented in Rust, in the `webext_storage_bridge` crate. +nsresult NS_NewExtensionStorageSyncArea(mozIExtensionStorageArea** aResult); + +} // extern "C" + +namespace mozilla { +namespace extensions { +namespace storage { + +// The C++ constructor for a `storage.sync` area. This wrapper exists because +// `components.conf` requires a component class constructor to return an +// `already_AddRefed<T>`, but Rust doesn't have such a type. So we call the +// Rust constructor using a `nsCOMPtr` (which is compatible with Rust's +// `xpcom::RefPtr`) out param, and return that. +already_AddRefed<mozIExtensionStorageArea> NewSyncArea() { + nsCOMPtr<mozIExtensionStorageArea> storage; + nsresult rv = NS_NewExtensionStorageSyncArea(getter_AddRefs(storage)); + if (NS_WARN_IF(NS_FAILED(rv))) { + return nullptr; + } + return storage.forget(); +} + +} // namespace storage +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_storage_ExtensionStorageComponents_h_ diff --git a/toolkit/components/extensions/storage/ExtensionStorageComponents.sys.mjs b/toolkit/components/extensions/storage/ExtensionStorageComponents.sys.mjs new file mode 100644 index 0000000000..4de62005a1 --- /dev/null +++ b/toolkit/components/extensions/storage/ExtensionStorageComponents.sys.mjs @@ -0,0 +1,118 @@ +/* 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, { + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", +}); + +const StorageSyncArea = Components.Constructor( + "@mozilla.org/extensions/storage/internal/sync-area;1", + "mozIConfigurableExtensionStorageArea", + "configure" +); + +/** + * An XPCOM service for the WebExtension `storage.sync` API. The service manages + * a storage area for storing and syncing extension data. + * + * The service configures its storage area with the database path, and hands + * out references to the configured area via `getInterface`. It also registers + * a shutdown blocker to automatically tear down the area. + * + * ## What's the difference between `storage/internal/storage-sync-area;1` and + * `storage/sync;1`? + * + * `components.conf` has two classes: + * `@mozilla.org/extensions/storage/internal/sync-area;1` and + * `@mozilla.org/extensions/storage/sync;1`. + * + * The `storage/internal/sync-area;1` class is implemented in Rust, and can be + * instantiated using `createInstance` and `Components.Constructor`. It's not + * a singleton, so creating a new instance will create a new `storage.sync` + * area, with its own database connection. It's useful for testing, but not + * meant to be used outside of this module. + * + * The `storage/sync;1` class is implemented in this file. It's a singleton, + * ensuring there's only one `storage.sync` area, with one database connection. + * The service implements `nsIInterfaceRequestor`, so callers can access the + * storage interface like this: + * + * let storageSyncArea = Cc["@mozilla.org/extensions/storage/sync;1"] + * .getService(Ci.nsIInterfaceRequestor) + * .getInterface(Ci.mozIExtensionStorageArea); + * + * ...And the Sync interface like this: + * + * let extensionStorageEngine = Cc["@mozilla.org/extensions/storage/sync;1"] + * .getService(Ci.nsIInterfaceRequestor) + * .getInterface(Ci.mozIBridgedSyncEngine); + * + * @class + */ +export function StorageSyncService() { + if (StorageSyncService._singleton) { + return StorageSyncService._singleton; + } + + let file = new lazy.FileUtils.File( + PathUtils.join(PathUtils.profileDir, "storage-sync-v2.sqlite") + ); + let kintoFile = new lazy.FileUtils.File( + PathUtils.join(PathUtils.profileDir, "storage-sync.sqlite") + ); + this._storageArea = new StorageSyncArea(file, kintoFile); + + // Register a blocker to close the storage connection on shutdown. + this._shutdownBound = () => this._shutdown(); + lazy.AsyncShutdown.profileChangeTeardown.addBlocker( + "StorageSyncService: shutdown", + this._shutdownBound + ); + + StorageSyncService._singleton = this; +} + +StorageSyncService._singleton = null; + +StorageSyncService.prototype = { + QueryInterface: ChromeUtils.generateQI(["nsIInterfaceRequestor"]), + + // Returns the storage and syncing interfaces. This just hands out a + // reference to the underlying storage area, with a quick check to make sure + // that callers are asking for the right interfaces. + getInterface(iid) { + if ( + iid.equals(Ci.mozIExtensionStorageArea) || + iid.equals(Ci.mozIBridgedSyncEngine) + ) { + return this._storageArea.QueryInterface(iid); + } + throw Components.Exception( + "This interface isn't implemented", + Cr.NS_ERROR_NO_INTERFACE + ); + }, + + // Tears down the storage area and lifts the blocker so that shutdown can + // continue. + async _shutdown() { + try { + await new Promise((resolve, reject) => { + this._storageArea.teardown({ + handleSuccess: resolve, + handleError(code, message) { + reject(Components.Exception(message, code)); + }, + }); + }); + } finally { + lazy.AsyncShutdown.profileChangeTeardown.removeBlocker( + this._shutdownBound + ); + } + }, +}; diff --git a/toolkit/components/extensions/storage/components.conf b/toolkit/components/extensions/storage/components.conf new file mode 100644 index 0000000000..a1d54fa542 --- /dev/null +++ b/toolkit/components/extensions/storage/components.conf @@ -0,0 +1,22 @@ +# -*- 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': '{f1e424f2-67fe-4f69-a8f8-3993a71f44fa}', + 'contract_ids': ['@mozilla.org/extensions/storage/internal/sync-area;1'], + 'type': 'mozIExtensionStorageArea', + 'headers': ['mozilla/extensions/storage/ExtensionStorageComponents.h'], + 'constructor': 'mozilla::extensions::storage::NewSyncArea', + }, + { + 'cid': '{5b7047b4-fe17-4661-8e13-871402bc2023}', + 'contract_ids': ['@mozilla.org/extensions/storage/sync;1'], + 'esModule': 'resource://gre/modules/ExtensionStorageComponents.sys.mjs', + 'constructor': 'StorageSyncService', + 'singleton': True, + }, +] diff --git a/toolkit/components/extensions/storage/moz.build b/toolkit/components/extensions/storage/moz.build new file mode 100644 index 0000000000..85f52cdadb --- /dev/null +++ b/toolkit/components/extensions/storage/moz.build @@ -0,0 +1,33 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +with Files("**"): + BUG_COMPONENT = ("WebExtensions", "Storage") + +XPIDL_MODULE = "webextensions-storage" + +XPIDL_SOURCES += [ + "mozIExtensionStorageArea.idl", +] + +# Don't build the Rust `storage.sync` bridge for GeckoView, as it will expose +# a delegate for consumers to use instead. Android Components can then provide +# an implementation of the delegate that's backed by the Rust component. For +# details, please see bug 1626506, comment 4. +if CONFIG["MOZ_WIDGET_TOOLKIT"] != "android": + EXPORTS.mozilla.extensions.storage += [ + "ExtensionStorageComponents.h", + ] + + EXTRA_JS_MODULES += [ + "ExtensionStorageComponents.sys.mjs", + ] + + XPCOM_MANIFESTS += [ + "components.conf", + ] + +FINAL_LIBRARY = "xul" diff --git a/toolkit/components/extensions/storage/mozIExtensionStorageArea.idl b/toolkit/components/extensions/storage/mozIExtensionStorageArea.idl new file mode 100644 index 0000000000..b3dcaa2479 --- /dev/null +++ b/toolkit/components/extensions/storage/mozIExtensionStorageArea.idl @@ -0,0 +1,127 @@ +/* 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 mozIExtensionStorageCallback; +interface nsIFile; +interface nsIVariant; + +// Implements the operations needed to support the `StorageArea` WebExtension +// API. +[scriptable, uuid(d8eb3ff1-9b4b-435a-99ca-5b8cbaba2420)] +interface mozIExtensionStorageArea : nsISupports { + // These constants are exposed by the rust crate, but it's not worth the + // effort of jumping through the hoops to get them exposed to the JS + // code in a sane way - so we just duplicate them here. We should consider a + // test that checks they match the rust code. + // This interface is agnostic WRT the area, so we prefix the constants with + // the area - it's the consumer of this interface which knows what to use. + const unsigned long SYNC_QUOTA_BYTES = 102400; + const unsigned long SYNC_QUOTA_BYTES_PER_ITEM = 8192; + const unsigned long SYNC_MAX_ITEMS = 512; + + // Sets one or more key-value pairs specified in `json` for the + // `extensionId`. If the `callback` implements + // `mozIExtensionStorageListener`, its `onChange` + // method will be called with the new and old values. + void set(in AUTF8String extensionId, + in AUTF8String json, + in mozIExtensionStorageCallback callback); + + // Returns the value for the `key` in the storage area for the + // `extensionId`. `key` must be a JSON string containing either `null`, + // an array of string key names, a single string key name, or an object + // where the properties are the key names, and the values are the defaults + // if the key name doesn't exist in the storage area. + // + // If `get()` fails due to the quota being exceeded, the exception will + // have a result code of NS_ERROR_DOM_QUOTA_EXCEEDED_ERR (==0x80530016) + void get(in AUTF8String extensionId, + in AUTF8String key, + in mozIExtensionStorageCallback callback); + + // Removes the `key` from the storage area for the `extensionId`. If `key` + // exists and the `callback` implements `mozIExtensionStorageListener`, its + // `onChanged` method will be called with the removed key-value pair. + void remove(in AUTF8String extensionId, + in AUTF8String key, + in mozIExtensionStorageCallback callback); + + // Removes all keys from the storage area for the `extensionId`. If + // `callback` implements `mozIExtensionStorageListener`, its `onChange` + // method will be called with all removed key-value pairs. + void clear(in AUTF8String extensionId, + in mozIExtensionStorageCallback callback); + + // Gets the number of bytes in use for the specified keys. + void getBytesInUse(in AUTF8String extensionId, + in AUTF8String keys, + in mozIExtensionStorageCallback callback); + + // Gets and clears the information about the migration from the kinto + // database into the rust one. As "and clears" indicates, this will + // only produce a non-empty the first time it's called after a + // migration (which, hopefully, should only happen once). + void takeMigrationInfo(in mozIExtensionStorageCallback callback); +}; + +// Implements additional methods for setting up and tearing down the underlying +// database connection for a storage area. This is a separate interface because +// these methods are not part of the `StorageArea` API, and have restrictions on +// when they can be called. +[scriptable, uuid(2b008295-1bcc-4610-84f1-ad4cab2fa9ee)] +interface mozIConfigurableExtensionStorageArea : nsISupports { + // Sets up the storage area. An area can only be configured once; calling + // `configure` multiple times will throw. `configure` must also be called + // before any of the `mozIExtensionStorageArea` methods, or they'll fail + // with errors. + // The second param is the path to the kinto database file from which we + // should migrate. This should always be specified even when there's a + // chance the file doesn't exist. + void configure(in nsIFile databaseFile, in nsIFile kintoFile); + + // Tears down the storage area, closing the backing database connection. + // This is called automatically when Firefox shuts down. Once a storage area + // has been shut down, all its methods will fail with errors. If `configure` + // hasn't been called for this area yet, `teardown` is a no-op. + void teardown(in mozIExtensionStorageCallback callback); +}; + +// Implements additional methods for syncing a storage area. This is a separate +// interface because these methods are not part of the `StorageArea` API, and +// have restrictions on when they can be called. +[scriptable, uuid(6dac82c9-1d8a-4893-8c0f-6e626aef802c)] +interface mozISyncedExtensionStorageArea : nsISupports { + // If a sync is in progress, this method fetches pending change + // notifications for all extensions whose storage areas were updated. + // `callback` should implement `mozIExtensionStorageListener` to forward + // the records to `storage.onChanged` listeners. This method should only + // be called by Sync, after `mozIBridgedSyncEngine.apply` and before + // `syncFinished`. It fetches nothing if called at any other time. + void fetchPendingSyncChanges(in mozIExtensionStorageCallback callback); +}; + +// A listener for storage area notifications. +[scriptable, uuid(8cb3c7e4-d0ca-4353-bccd-2673b4e11510)] +interface mozIExtensionStorageListener : nsISupports { + // Notifies that an operation has data to pass to `storage.onChanged` + // listeners for the given `extensionId`. `json` is a JSON array of listener + // infos. If an operation affects multiple extensions, this method will be + // called multiple times, once per extension. + void onChanged(in AUTF8String extensionId, in AUTF8String json); +}; + +// A generic callback for a storage operation. Either `handleSuccess` or +// `handleError` is guaranteed to be called once. +[scriptable, uuid(870dca40-6602-4748-8493-c4253eb7f322)] +interface mozIExtensionStorageCallback : nsISupports { + // Called when the operation completes. Operations that return a result, + // like `get`, will pass a `UTF8String` variant. Those that don't return + // anything, like `set` or `remove`, will pass a `null` variant. + void handleSuccess(in nsIVariant result); + + // Called when the operation fails. + void handleError(in nsresult code, in AUTF8String message); +}; diff --git a/toolkit/components/extensions/storage/webext_storage_bridge/Cargo.toml b/toolkit/components/extensions/storage/webext_storage_bridge/Cargo.toml new file mode 100644 index 0000000000..39c5bf92c6 --- /dev/null +++ b/toolkit/components/extensions/storage/webext_storage_bridge/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "webext_storage_bridge" +description = "The WebExtension `storage.sync` bindings for Firefox" +version = "0.1.0" +authors = ["The Firefox Sync Developers <sync-team@mozilla.com>"] +edition = "2018" +license = "MPL-2.0" + +[dependencies] +anyhow = "1.0" +atomic_refcell = "0.1" +cstr = "0.2" +golden_gate = { path = "../../../../../services/sync/golden_gate" } +interrupt-support = "0.1" +moz_task = { path = "../../../../../xpcom/rust/moz_task" } +nserror = { path = "../../../../../xpcom/rust/nserror" } +nsstring = { path = "../../../../../xpcom/rust/nsstring" } +once_cell = "1" +thin-vec = { version = "0.2.1", features = ["gecko-ffi"] } +xpcom = { path = "../../../../../xpcom/rust/xpcom" } +serde = "1" +serde_json = "1" +storage_variant = { path = "../../../../../storage/variant" } +sql-support = "0.1" +webext-storage = "0.1" diff --git a/toolkit/components/extensions/storage/webext_storage_bridge/src/area.rs b/toolkit/components/extensions/storage/webext_storage_bridge/src/area.rs new file mode 100644 index 0000000000..1418ccca29 --- /dev/null +++ b/toolkit/components/extensions/storage/webext_storage_bridge/src/area.rs @@ -0,0 +1,484 @@ +/* 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 std::{ + cell::{Ref, RefCell}, + convert::TryInto, + ffi::OsString, + mem, + path::PathBuf, + str, + sync::Arc, +}; + +use golden_gate::{ApplyTask, BridgedEngine, FerryTask}; +use moz_task::{self, DispatchOptions, TaskRunnable}; +use nserror::{nsresult, NS_OK}; +use nsstring::{nsACString, nsCString, nsString}; +use thin_vec::ThinVec; +use webext_storage::STORAGE_VERSION; +use xpcom::{ + interfaces::{ + mozIBridgedSyncEngineApplyCallback, mozIBridgedSyncEngineCallback, + mozIExtensionStorageCallback, mozIServicesLogSink, nsIFile, nsISerialEventTarget, + }, + RefPtr, +}; + +use crate::error::{Error, Result}; +use crate::punt::{Punt, PuntTask, TeardownTask}; +use crate::store::{LazyStore, LazyStoreConfig}; + +fn path_from_nsifile(file: &nsIFile) -> Result<PathBuf> { + let mut raw_path = nsString::new(); + // `nsIFile::GetPath` gives us a UTF-16-encoded version of its + // native path, which we must turn back into a platform-native + // string. We can't use `nsIFile::nativePath()` here because + // it's marked as `nostdcall`, which Rust doesn't support. + unsafe { file.GetPath(&mut *raw_path) }.to_result()?; + let native_path = { + // On Windows, we can create a native string directly from the + // encoded path. + #[cfg(windows)] + { + use std::os::windows::prelude::*; + OsString::from_wide(&raw_path) + } + // On other platforms, we must first decode the raw path from + // UTF-16, and then create our native string. + #[cfg(not(windows))] + OsString::from(String::from_utf16(&raw_path)?) + }; + Ok(native_path.into()) +} + +/// An XPCOM component class for the Rust extension storage API. This class +/// implements the interfaces needed for syncing and storage. +/// +/// This class can be created on any thread, but must not be shared between +/// threads. In Rust terms, it's `Send`, but not `Sync`. +#[xpcom( + implement( + mozIExtensionStorageArea, + mozIConfigurableExtensionStorageArea, + mozISyncedExtensionStorageArea, + mozIInterruptible, + mozIBridgedSyncEngine + ), + nonatomic +)] +pub struct StorageSyncArea { + /// A background task queue, used to run all our storage operations on a + /// thread pool. Using a serial event target here means that all operations + /// will execute sequentially. + queue: RefPtr<nsISerialEventTarget>, + /// The store is lazily initialized on the task queue the first time it's + /// used. + store: RefCell<Option<Arc<LazyStore>>>, +} + +/// `mozIExtensionStorageArea` implementation. +impl StorageSyncArea { + /// Creates a storage area and its task queue. + pub fn new() -> Result<RefPtr<StorageSyncArea>> { + let queue = moz_task::create_background_task_queue(cstr!("StorageSyncArea"))?; + Ok(StorageSyncArea::allocate(InitStorageSyncArea { + queue, + store: RefCell::new(Some(Arc::default())), + })) + } + + /// Returns the store for this area, or an error if it's been torn down. + fn store(&self) -> Result<Ref<'_, Arc<LazyStore>>> { + let maybe_store = self.store.borrow(); + if maybe_store.is_some() { + Ok(Ref::map(maybe_store, |s| s.as_ref().unwrap())) + } else { + Err(Error::AlreadyTornDown) + } + } + + /// Dispatches a task for a storage operation to the task queue. + fn dispatch(&self, punt: Punt, callback: &mozIExtensionStorageCallback) -> Result<()> { + let name = punt.name(); + let task = PuntTask::new(Arc::downgrade(&*self.store()?), punt, callback)?; + let runnable = TaskRunnable::new(name, Box::new(task))?; + // `may_block` schedules the runnable on a dedicated I/O pool. + TaskRunnable::dispatch_with_options( + runnable, + self.queue.coerce(), + DispatchOptions::new().may_block(true), + )?; + Ok(()) + } + + xpcom_method!( + configure => Configure( + database_file: *const nsIFile, + kinto_file: *const nsIFile + ) + ); + /// Sets up the storage area. + fn configure(&self, database_file: &nsIFile, kinto_file: &nsIFile) -> Result<()> { + self.store()?.configure(LazyStoreConfig { + path: path_from_nsifile(database_file)?, + kinto_path: path_from_nsifile(kinto_file)?, + })?; + Ok(()) + } + + xpcom_method!( + set => Set( + ext_id: *const ::nsstring::nsACString, + json: *const ::nsstring::nsACString, + callback: *const mozIExtensionStorageCallback + ) + ); + /// Sets one or more key-value pairs. + fn set( + &self, + ext_id: &nsACString, + json: &nsACString, + callback: &mozIExtensionStorageCallback, + ) -> Result<()> { + self.dispatch( + Punt::Set { + ext_id: str::from_utf8(ext_id)?.into(), + value: serde_json::from_str(str::from_utf8(json)?)?, + }, + callback, + )?; + Ok(()) + } + + xpcom_method!( + get => Get( + ext_id: *const ::nsstring::nsACString, + json: *const ::nsstring::nsACString, + callback: *const mozIExtensionStorageCallback + ) + ); + /// Gets values for one or more keys. + fn get( + &self, + ext_id: &nsACString, + json: &nsACString, + callback: &mozIExtensionStorageCallback, + ) -> Result<()> { + self.dispatch( + Punt::Get { + ext_id: str::from_utf8(ext_id)?.into(), + keys: serde_json::from_str(str::from_utf8(json)?)?, + }, + callback, + ) + } + + xpcom_method!( + remove => Remove( + ext_id: *const ::nsstring::nsACString, + json: *const ::nsstring::nsACString, + callback: *const mozIExtensionStorageCallback + ) + ); + /// Removes one or more keys and their values. + fn remove( + &self, + ext_id: &nsACString, + json: &nsACString, + callback: &mozIExtensionStorageCallback, + ) -> Result<()> { + self.dispatch( + Punt::Remove { + ext_id: str::from_utf8(ext_id)?.into(), + keys: serde_json::from_str(str::from_utf8(json)?)?, + }, + callback, + ) + } + + xpcom_method!( + clear => Clear( + ext_id: *const ::nsstring::nsACString, + callback: *const mozIExtensionStorageCallback + ) + ); + /// Removes all keys and values for the specified extension. + fn clear(&self, ext_id: &nsACString, callback: &mozIExtensionStorageCallback) -> Result<()> { + self.dispatch( + Punt::Clear { + ext_id: str::from_utf8(ext_id)?.into(), + }, + callback, + ) + } + + xpcom_method!( + getBytesInUse => GetBytesInUse( + ext_id: *const ::nsstring::nsACString, + keys: *const ::nsstring::nsACString, + callback: *const mozIExtensionStorageCallback + ) + ); + /// Obtains the count of bytes in use for the specified key or for all keys. + fn getBytesInUse( + &self, + ext_id: &nsACString, + keys: &nsACString, + callback: &mozIExtensionStorageCallback, + ) -> Result<()> { + self.dispatch( + Punt::GetBytesInUse { + ext_id: str::from_utf8(ext_id)?.into(), + keys: serde_json::from_str(str::from_utf8(keys)?)?, + }, + callback, + ) + } + + xpcom_method!(teardown => Teardown(callback: *const mozIExtensionStorageCallback)); + /// Tears down the storage area, closing the backing database connection. + fn teardown(&self, callback: &mozIExtensionStorageCallback) -> Result<()> { + // Each storage task holds a `Weak` reference to the store, which it + // upgrades to an `Arc` (strong reference) when the task runs on the + // background queue. The strong reference is dropped when the task + // finishes. When we tear down the storage area, we relinquish our one + // owned strong reference to the `TeardownTask`. Because we're using a + // task queue, when the `TeardownTask` runs, it should have the only + // strong reference to the store, since all other tasks that called + // `Weak::upgrade` will have already finished. The `TeardownTask` can + // then consume the `Arc` and destroy the store. + let mut maybe_store = self.store.borrow_mut(); + match mem::take(&mut *maybe_store) { + Some(store) => { + // Interrupt any currently-running statements. + store.interrupt(); + // If dispatching the runnable fails, we'll leak the store + // without closing its database connection. + teardown(&self.queue, store, callback)?; + } + None => return Err(Error::AlreadyTornDown), + } + Ok(()) + } + + xpcom_method!(takeMigrationInfo => TakeMigrationInfo(callback: *const mozIExtensionStorageCallback)); + + /// Fetch-and-delete (e.g. `take`) information about the migration from the + /// kinto-based extension-storage to the rust-based storage. + fn takeMigrationInfo(&self, callback: &mozIExtensionStorageCallback) -> Result<()> { + self.dispatch(Punt::TakeMigrationInfo, callback) + } +} + +fn teardown( + queue: &nsISerialEventTarget, + store: Arc<LazyStore>, + callback: &mozIExtensionStorageCallback, +) -> Result<()> { + let task = TeardownTask::new(store, callback)?; + let runnable = TaskRunnable::new(TeardownTask::name(), Box::new(task))?; + TaskRunnable::dispatch_with_options( + runnable, + queue.coerce(), + DispatchOptions::new().may_block(true), + )?; + Ok(()) +} + +/// `mozISyncedExtensionStorageArea` implementation. +impl StorageSyncArea { + xpcom_method!( + fetch_pending_sync_changes => FetchPendingSyncChanges(callback: *const mozIExtensionStorageCallback) + ); + fn fetch_pending_sync_changes(&self, callback: &mozIExtensionStorageCallback) -> Result<()> { + self.dispatch(Punt::FetchPendingSyncChanges, callback) + } +} + +/// `mozIInterruptible` implementation. +impl StorageSyncArea { + xpcom_method!( + interrupt => Interrupt() + ); + /// Interrupts any operations currently running on the background task + /// queue. + fn interrupt(&self) -> Result<()> { + self.store()?.interrupt(); + Ok(()) + } +} + +/// `mozIBridgedSyncEngine` implementation. +impl StorageSyncArea { + xpcom_method!(get_logger => GetLogger() -> *const mozIServicesLogSink); + fn get_logger(&self) -> Result<RefPtr<mozIServicesLogSink>> { + Err(NS_OK)? + } + + xpcom_method!(set_logger => SetLogger(logger: *const mozIServicesLogSink)); + fn set_logger(&self, _logger: Option<&mozIServicesLogSink>) -> Result<()> { + Ok(()) + } + + xpcom_method!(get_storage_version => GetStorageVersion() -> i32); + fn get_storage_version(&self) -> Result<i32> { + Ok(STORAGE_VERSION.try_into().unwrap()) + } + + // It's possible that migration, or even merging, will result in records + // too large for the server. We tolerate that (and hope that the addons do + // too :) + xpcom_method!(get_allow_skipped_record => GetAllowSkippedRecord() -> bool); + fn get_allow_skipped_record(&self) -> Result<bool> { + Ok(true) + } + + xpcom_method!( + get_last_sync => GetLastSync( + callback: *const mozIBridgedSyncEngineCallback + ) + ); + fn get_last_sync(&self, callback: &mozIBridgedSyncEngineCallback) -> Result<()> { + Ok(FerryTask::for_last_sync(self.new_bridge()?, callback)?.dispatch(&self.queue)?) + } + + xpcom_method!( + set_last_sync => SetLastSync( + last_sync_millis: i64, + callback: *const mozIBridgedSyncEngineCallback + ) + ); + fn set_last_sync( + &self, + last_sync_millis: i64, + callback: &mozIBridgedSyncEngineCallback, + ) -> Result<()> { + Ok( + FerryTask::for_set_last_sync(self.new_bridge()?, last_sync_millis, callback)? + .dispatch(&self.queue)?, + ) + } + + xpcom_method!( + get_sync_id => GetSyncId( + callback: *const mozIBridgedSyncEngineCallback + ) + ); + fn get_sync_id(&self, callback: &mozIBridgedSyncEngineCallback) -> Result<()> { + Ok(FerryTask::for_sync_id(self.new_bridge()?, callback)?.dispatch(&self.queue)?) + } + + xpcom_method!( + reset_sync_id => ResetSyncId( + callback: *const mozIBridgedSyncEngineCallback + ) + ); + fn reset_sync_id(&self, callback: &mozIBridgedSyncEngineCallback) -> Result<()> { + Ok(FerryTask::for_reset_sync_id(self.new_bridge()?, callback)?.dispatch(&self.queue)?) + } + + xpcom_method!( + ensure_current_sync_id => EnsureCurrentSyncId( + new_sync_id: *const nsACString, + callback: *const mozIBridgedSyncEngineCallback + ) + ); + fn ensure_current_sync_id( + &self, + new_sync_id: &nsACString, + callback: &mozIBridgedSyncEngineCallback, + ) -> Result<()> { + Ok( + FerryTask::for_ensure_current_sync_id(self.new_bridge()?, new_sync_id, callback)? + .dispatch(&self.queue)?, + ) + } + + xpcom_method!( + sync_started => SyncStarted( + callback: *const mozIBridgedSyncEngineCallback + ) + ); + fn sync_started(&self, callback: &mozIBridgedSyncEngineCallback) -> Result<()> { + Ok(FerryTask::for_sync_started(self.new_bridge()?, callback)?.dispatch(&self.queue)?) + } + + xpcom_method!( + store_incoming => StoreIncoming( + incoming_envelopes_json: *const ThinVec<::nsstring::nsCString>, + callback: *const mozIBridgedSyncEngineCallback + ) + ); + fn store_incoming( + &self, + incoming_envelopes_json: Option<&ThinVec<nsCString>>, + callback: &mozIBridgedSyncEngineCallback, + ) -> Result<()> { + Ok(FerryTask::for_store_incoming( + self.new_bridge()?, + incoming_envelopes_json.map(|v| v.as_slice()).unwrap_or(&[]), + callback, + )? + .dispatch(&self.queue)?) + } + + xpcom_method!(apply => Apply(callback: *const mozIBridgedSyncEngineApplyCallback)); + fn apply(&self, callback: &mozIBridgedSyncEngineApplyCallback) -> Result<()> { + Ok(ApplyTask::new(self.new_bridge()?, callback)?.dispatch(&self.queue)?) + } + + xpcom_method!( + set_uploaded => SetUploaded( + server_modified_millis: i64, + uploaded_ids: *const ThinVec<::nsstring::nsCString>, + callback: *const mozIBridgedSyncEngineCallback + ) + ); + fn set_uploaded( + &self, + server_modified_millis: i64, + uploaded_ids: Option<&ThinVec<nsCString>>, + callback: &mozIBridgedSyncEngineCallback, + ) -> Result<()> { + Ok(FerryTask::for_set_uploaded( + self.new_bridge()?, + server_modified_millis, + uploaded_ids.map(|v| v.as_slice()).unwrap_or(&[]), + callback, + )? + .dispatch(&self.queue)?) + } + + xpcom_method!( + sync_finished => SyncFinished( + callback: *const mozIBridgedSyncEngineCallback + ) + ); + fn sync_finished(&self, callback: &mozIBridgedSyncEngineCallback) -> Result<()> { + Ok(FerryTask::for_sync_finished(self.new_bridge()?, callback)?.dispatch(&self.queue)?) + } + + xpcom_method!( + reset => Reset( + callback: *const mozIBridgedSyncEngineCallback + ) + ); + fn reset(&self, callback: &mozIBridgedSyncEngineCallback) -> Result<()> { + Ok(FerryTask::for_reset(self.new_bridge()?, callback)?.dispatch(&self.queue)?) + } + + xpcom_method!( + wipe => Wipe( + callback: *const mozIBridgedSyncEngineCallback + ) + ); + fn wipe(&self, callback: &mozIBridgedSyncEngineCallback) -> Result<()> { + Ok(FerryTask::for_wipe(self.new_bridge()?, callback)?.dispatch(&self.queue)?) + } + + fn new_bridge(&self) -> Result<Box<dyn BridgedEngine>> { + Ok(Box::new(self.store()?.get()?.bridged_engine())) + } +} diff --git a/toolkit/components/extensions/storage/webext_storage_bridge/src/error.rs b/toolkit/components/extensions/storage/webext_storage_bridge/src/error.rs new file mode 100644 index 0000000000..877b2b21a8 --- /dev/null +++ b/toolkit/components/extensions/storage/webext_storage_bridge/src/error.rs @@ -0,0 +1,124 @@ +/* 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 std::{error, fmt, result, str::Utf8Error, string::FromUtf16Error}; + +use golden_gate::Error as GoldenGateError; +use nserror::{ + nsresult, NS_ERROR_ALREADY_INITIALIZED, NS_ERROR_CANNOT_CONVERT_DATA, + NS_ERROR_DOM_QUOTA_EXCEEDED_ERR, NS_ERROR_FAILURE, NS_ERROR_INVALID_ARG, + NS_ERROR_NOT_IMPLEMENTED, NS_ERROR_NOT_INITIALIZED, NS_ERROR_UNEXPECTED, +}; +use serde_json::error::Error as JsonError; +use webext_storage::error::Error as WebextStorageError; + +/// A specialized `Result` type for extension storage operations. +pub type Result<T> = result::Result<T, Error>; + +/// The error type for extension storage operations. Errors can be converted +/// into `nsresult` codes, and include more detailed messages that can be passed +/// to callbacks. +#[derive(Debug)] +pub enum Error { + Nsresult(nsresult), + WebextStorage(WebextStorageError), + MigrationFailed(WebextStorageError), + GoldenGate(GoldenGateError), + MalformedString(Box<dyn error::Error + Send + Sync + 'static>), + AlreadyConfigured, + NotConfigured, + AlreadyRan(&'static str), + DidNotRun(&'static str), + AlreadyTornDown, + NotImplemented, +} + +impl error::Error for Error { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + match self { + Error::MalformedString(error) => Some(error.as_ref()), + _ => None, + } + } +} + +impl From<nsresult> for Error { + fn from(result: nsresult) -> Error { + Error::Nsresult(result) + } +} + +impl From<WebextStorageError> for Error { + fn from(error: WebextStorageError) -> Error { + Error::WebextStorage(error) + } +} + +impl From<GoldenGateError> for Error { + fn from(error: GoldenGateError) -> Error { + Error::GoldenGate(error) + } +} + +impl From<Utf8Error> for Error { + fn from(error: Utf8Error) -> Error { + Error::MalformedString(error.into()) + } +} + +impl From<FromUtf16Error> for Error { + fn from(error: FromUtf16Error) -> Error { + Error::MalformedString(error.into()) + } +} + +impl From<JsonError> for Error { + fn from(error: JsonError) -> Error { + Error::MalformedString(error.into()) + } +} + +impl From<Error> for nsresult { + fn from(error: Error) -> nsresult { + match error { + Error::Nsresult(result) => result, + Error::WebextStorage(e) => match e { + WebextStorageError::QuotaError(_) => NS_ERROR_DOM_QUOTA_EXCEEDED_ERR, + _ => NS_ERROR_FAILURE, + }, + Error::MigrationFailed(_) => NS_ERROR_CANNOT_CONVERT_DATA, + Error::GoldenGate(error) => error.into(), + Error::MalformedString(_) => NS_ERROR_INVALID_ARG, + Error::AlreadyConfigured => NS_ERROR_ALREADY_INITIALIZED, + Error::NotConfigured => NS_ERROR_NOT_INITIALIZED, + Error::AlreadyRan(_) => NS_ERROR_UNEXPECTED, + Error::DidNotRun(_) => NS_ERROR_UNEXPECTED, + Error::AlreadyTornDown => NS_ERROR_UNEXPECTED, + Error::NotImplemented => NS_ERROR_NOT_IMPLEMENTED, + } + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Error::Nsresult(result) => write!(f, "Operation failed with {result}"), + Error::WebextStorage(error) => error.fmt(f), + Error::MigrationFailed(error) => write!(f, "Migration failed with {error}"), + Error::GoldenGate(error) => error.fmt(f), + Error::MalformedString(error) => error.fmt(f), + Error::AlreadyConfigured => write!(f, "The storage area is already configured"), + Error::NotConfigured => write!( + f, + "The storage area must be configured by calling `configure` first" + ), + Error::AlreadyRan(what) => write!(f, "`{what}` already ran on the background thread"), + Error::DidNotRun(what) => write!(f, "`{what}` didn't run on the background thread"), + Error::AlreadyTornDown => { + write!(f, "Can't use a storage area that's already torn down") + } + Error::NotImplemented => write!(f, "Operation not implemented"), + } + } +} diff --git a/toolkit/components/extensions/storage/webext_storage_bridge/src/lib.rs b/toolkit/components/extensions/storage/webext_storage_bridge/src/lib.rs new file mode 100644 index 0000000000..94133ef1e9 --- /dev/null +++ b/toolkit/components/extensions/storage/webext_storage_bridge/src/lib.rs @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#![allow(non_snake_case)] + +//! This crate bridges the WebExtension storage area interfaces in Firefox +//! Desktop to the extension storage Rust component in Application Services. +//! +//! ## How are the WebExtension storage APIs implemented in Firefox? +//! +//! There are three storage APIs available for WebExtensions: +//! `storage.local`, which is stored locally in an IndexedDB database and never +//! synced to other devices, `storage.sync`, which is stored in a local SQLite +//! database and synced to all devices signed in to the same Firefox Account, +//! and `storage.managed`, which is provisioned in a native manifest and +//! read-only. +//! +//! * `storage.local` is implemented in `ExtensionStorageIDB.jsm`. +//! * `storage.sync` is implemented in a Rust component, `webext_storage`. This +//! Rust component is vendored in m-c, and exposed to JavaScript via an XPCOM +//! API in `webext_storage_bridge` (this crate). Eventually, we'll change +//! `ExtensionStorageSync.jsm` to call the XPCOM API instead of using the +//! old Kinto storage adapter. +//! * `storage.managed` is implemented directly in `parent/ext-storage.js`. +//! +//! `webext_storage_bridge` implements the `mozIExtensionStorageArea` +//! (and, eventually, `mozIBridgedSyncEngine`) interface for `storage.sync`. The +//! implementation is in `area::StorageSyncArea`, and is backed by the +//! `webext_storage` component. + +#[macro_use] +extern crate cstr; +#[macro_use] +extern crate xpcom; + +mod area; +mod error; +mod punt; +mod store; + +use nserror::{nsresult, NS_OK}; +use xpcom::{interfaces::mozIExtensionStorageArea, RefPtr}; + +use crate::area::StorageSyncArea; + +/// The constructor for a `storage.sync` area. This uses C linkage so that it +/// can be called from C++. See `ExtensionStorageComponents.h` for the C++ +/// constructor that's passed to the component manager. +/// +/// # Safety +/// +/// This function is unsafe because it dereferences `result`. +#[no_mangle] +pub unsafe extern "C" fn NS_NewExtensionStorageSyncArea( + result: *mut *const mozIExtensionStorageArea, +) -> nsresult { + match StorageSyncArea::new() { + Ok(bridge) => { + RefPtr::new(bridge.coerce::<mozIExtensionStorageArea>()).forget(&mut *result); + NS_OK + } + Err(err) => err.into(), + } +} diff --git a/toolkit/components/extensions/storage/webext_storage_bridge/src/punt.rs b/toolkit/components/extensions/storage/webext_storage_bridge/src/punt.rs new file mode 100644 index 0000000000..4740237942 --- /dev/null +++ b/toolkit/components/extensions/storage/webext_storage_bridge/src/punt.rs @@ -0,0 +1,321 @@ +/* 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 std::{ + borrow::Borrow, + fmt::Write, + mem, result, str, + sync::{Arc, Weak}, +}; + +use atomic_refcell::AtomicRefCell; +use moz_task::{Task, ThreadPtrHandle, ThreadPtrHolder}; +use nserror::nsresult; +use nsstring::nsCString; +use serde::Serialize; +use serde_json::Value as JsonValue; +use storage_variant::VariantType; +use xpcom::{ + interfaces::{mozIExtensionStorageCallback, mozIExtensionStorageListener}, + RefPtr, XpCom, +}; + +use crate::error::{Error, Result}; +use crate::store::LazyStore; + +/// A storage operation that's punted from the main thread to the background +/// task queue. +pub enum Punt { + /// Get the values of the keys for an extension. + Get { ext_id: String, keys: JsonValue }, + /// Set a key-value pair for an extension. + Set { ext_id: String, value: JsonValue }, + /// Remove one or more keys for an extension. + Remove { ext_id: String, keys: JsonValue }, + /// Clear all keys and values for an extension. + Clear { ext_id: String }, + /// Returns the bytes in use for the specified, or all, keys. + GetBytesInUse { ext_id: String, keys: JsonValue }, + /// Fetches all pending Sync change notifications to pass to + /// `storage.onChanged` listeners. + FetchPendingSyncChanges, + /// Fetch-and-delete (e.g. `take`) information about the migration from the + /// kinto-based extension-storage to the rust-based storage. + /// + /// This data is stored in the database instead of just being returned by + /// the call to `migrate`, as we may migrate prior to telemetry being ready. + TakeMigrationInfo, +} + +impl Punt { + /// Returns the operation name, used to label the task runnable and report + /// errors. + pub fn name(&self) -> &'static str { + match self { + Punt::Get { .. } => "webext_storage::get", + Punt::Set { .. } => "webext_storage::set", + Punt::Remove { .. } => "webext_storage::remove", + Punt::Clear { .. } => "webext_storage::clear", + Punt::GetBytesInUse { .. } => "webext_storage::get_bytes_in_use", + Punt::FetchPendingSyncChanges => "webext_storage::fetch_pending_sync_changes", + Punt::TakeMigrationInfo => "webext_storage::take_migration_info", + } + } +} + +/// A storage operation result, punted from the background queue back to the +/// main thread. +#[derive(Default)] +struct PuntResult { + changes: Vec<Change>, + value: Option<String>, +} + +/// A change record for an extension. +struct Change { + ext_id: String, + json: String, +} + +impl PuntResult { + /// Creates a result with a single change to pass to `onChanged`, and no + /// return value for `handleSuccess`. The `Borrow` bound lets this method + /// take either a borrowed reference or an owned value. + fn with_change<T: Borrow<S>, S: Serialize>(ext_id: &str, changes: T) -> Result<Self> { + Ok(PuntResult { + changes: vec![Change { + ext_id: ext_id.into(), + json: serde_json::to_string(changes.borrow())?, + }], + value: None, + }) + } + + /// Creates a result with changes for multiple extensions to pass to + /// `onChanged`, and no return value for `handleSuccess`. + fn with_changes(changes: Vec<Change>) -> Self { + PuntResult { + changes, + value: None, + } + } + + /// Creates a result with no changes to pass to `onChanged`, and a return + /// value for `handleSuccess`. + fn with_value<T: Borrow<S>, S: Serialize>(value: T) -> Result<Self> { + Ok(PuntResult { + changes: Vec::new(), + value: Some(serde_json::to_string(value.borrow())?), + }) + } +} + +/// A generic task used for all storage operations. Punts the operation to the +/// background task queue, receives a result back on the main thread, and calls +/// the callback with it. +pub struct PuntTask { + name: &'static str, + /// Storage tasks hold weak references to the store, which they upgrade + /// to strong references when running on the background queue. This + /// ensures that pending storage tasks don't block teardown (for example, + /// if a consumer calls `get` and then `teardown`, without waiting for + /// `get` to finish). + store: Weak<LazyStore>, + punt: AtomicRefCell<Option<Punt>>, + callback: ThreadPtrHandle<mozIExtensionStorageCallback>, + result: AtomicRefCell<Result<PuntResult>>, +} + +impl PuntTask { + /// Creates a storage task that punts an operation to the background queue. + /// Returns an error if the task couldn't be created because the thread + /// manager is shutting down. + pub fn new( + store: Weak<LazyStore>, + punt: Punt, + callback: &mozIExtensionStorageCallback, + ) -> Result<Self> { + let name = punt.name(); + Ok(Self { + name, + store, + punt: AtomicRefCell::new(Some(punt)), + callback: ThreadPtrHolder::new( + cstr!("mozIExtensionStorageCallback"), + RefPtr::new(callback), + )?, + result: AtomicRefCell::new(Err(Error::DidNotRun(name))), + }) + } + + /// Upgrades the task's weak `LazyStore` reference to a strong one. Returns + /// an error if the store has been torn down. + /// + /// It's important that this is called on the background queue, after the + /// task has been dispatched. Storage tasks shouldn't hold strong references + /// to the store on the main thread, because then they might block teardown. + fn store(&self) -> Result<Arc<LazyStore>> { + match self.store.upgrade() { + Some(store) => Ok(store), + None => Err(Error::AlreadyTornDown), + } + } + + /// Runs this task's storage operation on the background queue. + fn inner_run(&self, punt: Punt) -> Result<PuntResult> { + Ok(match punt { + Punt::Set { ext_id, value } => { + PuntResult::with_change(&ext_id, self.store()?.get()?.set(&ext_id, value)?)? + } + Punt::Get { ext_id, keys } => { + PuntResult::with_value(self.store()?.get()?.get(&ext_id, keys)?)? + } + Punt::Remove { ext_id, keys } => { + PuntResult::with_change(&ext_id, self.store()?.get()?.remove(&ext_id, keys)?)? + } + Punt::Clear { ext_id } => { + PuntResult::with_change(&ext_id, self.store()?.get()?.clear(&ext_id)?)? + } + Punt::GetBytesInUse { ext_id, keys } => { + PuntResult::with_value(self.store()?.get()?.get_bytes_in_use(&ext_id, keys)?)? + } + Punt::FetchPendingSyncChanges => PuntResult::with_changes( + self.store()? + .get()? + .get_synced_changes()? + .into_iter() + .map(|info| Change { + ext_id: info.ext_id, + json: info.changes, + }) + .collect(), + ), + Punt::TakeMigrationInfo => { + PuntResult::with_value(self.store()?.get()?.take_migration_info()?)? + } + }) + } +} + +impl Task for PuntTask { + fn run(&self) { + *self.result.borrow_mut() = match self.punt.borrow_mut().take() { + Some(punt) => self.inner_run(punt), + // A task should never run on the background queue twice, but we + // return an error just in case. + None => Err(Error::AlreadyRan(self.name)), + }; + } + + fn done(&self) -> result::Result<(), nsresult> { + let callback = self.callback.get().unwrap(); + // As above, `done` should never be called multiple times, but we handle + // that by returning an error. + match mem::replace( + &mut *self.result.borrow_mut(), + Err(Error::AlreadyRan(self.name)), + ) { + Ok(PuntResult { changes, value }) => { + // If we have change data, and the callback implements the + // listener interface, notify about it first. + if let Some(listener) = callback.query_interface::<mozIExtensionStorageListener>() { + for Change { ext_id, json } in changes { + // Ignore errors. + let _ = unsafe { + listener.OnChanged(&*nsCString::from(ext_id), &*nsCString::from(json)) + }; + } + } + let result = value.map(nsCString::from).into_variant(); + unsafe { callback.HandleSuccess(result.coerce()) } + } + Err(err) => { + let mut message = nsCString::new(); + write!(message, "{err}").unwrap(); + unsafe { callback.HandleError(err.into(), &*message) } + } + } + .to_result() + } +} + +/// A task to tear down the store on the background task queue. +pub struct TeardownTask { + /// Unlike storage tasks, the teardown task holds a strong reference to + /// the store, which it drops on the background queue. This is the only + /// task that should do that. + store: AtomicRefCell<Option<Arc<LazyStore>>>, + callback: ThreadPtrHandle<mozIExtensionStorageCallback>, + result: AtomicRefCell<Result<()>>, +} + +impl TeardownTask { + /// Creates a teardown task. This should only be created and dispatched + /// once, to clean up the store at shutdown. Returns an error if the task + /// couldn't be created because the thread manager is shutting down. + pub fn new(store: Arc<LazyStore>, callback: &mozIExtensionStorageCallback) -> Result<Self> { + Ok(Self { + store: AtomicRefCell::new(Some(store)), + callback: ThreadPtrHolder::new( + cstr!("mozIExtensionStorageCallback"), + RefPtr::new(callback), + )?, + result: AtomicRefCell::new(Err(Error::DidNotRun(Self::name()))), + }) + } + + /// Returns the task name, used to label its runnable and report errors. + pub fn name() -> &'static str { + "webext_storage::teardown" + } + + /// Tears down and drops the store on the background queue. + fn inner_run(&self, store: Arc<LazyStore>) -> Result<()> { + // At this point, we should be holding the only strong reference + // to the store, since 1) `StorageSyncArea` gave its one strong + // reference to our task, and 2) we're running on a background + // task queue, which runs all tasks sequentially...so no other + // `PuntTask`s should be running and trying to upgrade their + // weak references. So we can unwrap the `Arc` and take ownership + // of the store. + match Arc::try_unwrap(store) { + Ok(store) => store.teardown(), + Err(_) => { + // If unwrapping the `Arc` fails, someone else must have + // a strong reference to the store. We could sleep and + // try again, but this is so unexpected that it's easier + // to just leak the store, and return an error to the + // callback. Except in tests, we only call `teardown` at + // shutdown, so the resources will get reclaimed soon, + // anyway. + Err(Error::DidNotRun(Self::name())) + } + } + } +} + +impl Task for TeardownTask { + fn run(&self) { + *self.result.borrow_mut() = match self.store.borrow_mut().take() { + Some(store) => self.inner_run(store), + None => Err(Error::AlreadyRan(Self::name())), + }; + } + + fn done(&self) -> result::Result<(), nsresult> { + let callback = self.callback.get().unwrap(); + match mem::replace( + &mut *self.result.borrow_mut(), + Err(Error::AlreadyRan(Self::name())), + ) { + Ok(()) => unsafe { callback.HandleSuccess(().into_variant().coerce()) }, + Err(err) => { + let mut message = nsCString::new(); + write!(message, "{err}").unwrap(); + unsafe { callback.HandleError(err.into(), &*message) } + } + } + .to_result() + } +} diff --git a/toolkit/components/extensions/storage/webext_storage_bridge/src/store.rs b/toolkit/components/extensions/storage/webext_storage_bridge/src/store.rs new file mode 100644 index 0000000000..cb1ce07784 --- /dev/null +++ b/toolkit/components/extensions/storage/webext_storage_bridge/src/store.rs @@ -0,0 +1,136 @@ +/* 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 std::{fs::remove_file, path::PathBuf, sync::Arc}; + +use interrupt_support::SqlInterruptHandle; +use once_cell::sync::OnceCell; +use webext_storage::store::WebExtStorageStore as Store; + +use crate::error::{self, Error}; + +/// Options for an extension storage area. +pub struct LazyStoreConfig { + /// The path to the database file for this storage area. + pub path: PathBuf, + /// The path to the old kinto database. If it exists, we should attempt to + /// migrate from this database as soon as we open our DB. It's not Option<> + /// because the caller will not have checked whether it exists or not, so + /// will assume it might. + pub kinto_path: PathBuf, +} + +/// A lazy store is automatically initialized on a background thread with its +/// configuration the first time it's used. +#[derive(Default)] +pub struct LazyStore { + store: OnceCell<InterruptStore>, + config: OnceCell<LazyStoreConfig>, +} + +/// An `InterruptStore` wraps an inner extension store, and its interrupt +/// handle. +struct InterruptStore { + inner: Store, + handle: Arc<SqlInterruptHandle>, +} + +impl LazyStore { + /// Configures the lazy store. Returns an error if the store has already + /// been configured. This method should be called from the main thread. + pub fn configure(&self, config: LazyStoreConfig) -> error::Result<()> { + self.config + .set(config) + .map_err(|_| Error::AlreadyConfigured) + } + + /// Interrupts all pending operations on the store. If a database statement + /// is currently running, this will interrupt that statement. If the + /// statement is a write inside an active transaction, the entire + /// transaction will be rolled back. This method should be called from the + /// main thread. + pub fn interrupt(&self) { + if let Some(outer) = self.store.get() { + outer.handle.interrupt(); + } + } + + /// Returns the underlying store, initializing it if needed. This method + /// should only be called from a background thread or task queue, since + /// opening the database does I/O. + pub fn get(&self) -> error::Result<&Store> { + Ok(&self + .store + .get_or_try_init(|| match self.config.get() { + Some(config) => { + let store = init_store(config)?; + let handle = store.interrupt_handle(); + Ok(InterruptStore { + inner: store, + handle, + }) + } + None => Err(Error::NotConfigured), + })? + .inner) + } + + /// Tears down the store. If the store wasn't initialized, this is a no-op. + /// This should only be called from a background thread or task queue, + /// because closing the database also does I/O. + pub fn teardown(self) -> error::Result<()> { + if let Some(store) = self.store.into_inner() { + store.inner.close()?; + } + Ok(()) + } +} + +// Initialize the store, performing a migration if necessary. +// The requirements for migration are, roughly: +// * If kinto_path doesn't exist, we don't try to migrate. +// * If our DB path exists, we assume we've already migrated and don't try again +// * If the migration fails, we close our store and delete the DB, then return +// a special error code which tells our caller about the failure. It's then +// expected to fallback to the "old" kinto store and we'll try next time. +// Note that the migrate() method on the store is written such that is should +// ignore all "read" errors from the source, but propagate "write" errors on our +// DB - the intention is that things like corrupted source databases never fail, +// but disk-space failures on our database does. +fn init_store(config: &LazyStoreConfig) -> error::Result<Store> { + let should_migrate = config.kinto_path.exists() && !config.path.exists(); + let store = Store::new(&config.path)?; + if should_migrate { + match store.migrate(&config.kinto_path) { + // It's likely to be too early for us to stick the MigrationInfo + // into the sync telemetry, a separate call to `take_migration_info` + // must be made to the store (this is done by telemetry after it's + // ready to submit the data). + Ok(()) => { + // need logging, but for now let's print to stdout. + println!("extension-storage: migration complete"); + Ok(store) + } + Err(e) => { + println!("extension-storage: migration failure: {e}"); + if let Err(e) = store.close() { + // welp, this probably isn't going to end well... + println!( + "extension-storage: failed to close the store after migration failure: {e}" + ); + } + if let Err(e) = remove_file(&config.path) { + // this is bad - if it happens regularly it will defeat + // out entire migration strategy - we'll assume it + // worked. + // So it's desirable to make noise if this happens. + println!("Failed to remove file after failed migration: {e}"); + } + Err(Error::MigrationFailed(e)) + } + } + } else { + Ok(store) + } +} diff --git a/toolkit/components/extensions/test/browser/.eslintrc.js b/toolkit/components/extensions/test/browser/.eslintrc.js new file mode 100644 index 0000000000..ef228570e3 --- /dev/null +++ b/toolkit/components/extensions/test/browser/.eslintrc.js @@ -0,0 +1,11 @@ +"use strict"; + +module.exports = { + env: { + webextensions: true, + }, + + rules: { + "no-shadow": "off", + }, +}; diff --git a/toolkit/components/extensions/test/browser/browser-serviceworker.toml b/toolkit/components/extensions/test/browser/browser-serviceworker.toml new file mode 100644 index 0000000000..1d7c7c3ffe --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser-serviceworker.toml @@ -0,0 +1,9 @@ +[DEFAULT] +support-files = [ + "head_serviceworker.js", + "data/**", +] + +prefs = ["extensions.backgroundServiceWorker.enabled=true"] + +["browser_ext_background_serviceworker.js"] diff --git a/toolkit/components/extensions/test/browser/browser.toml b/toolkit/components/extensions/test/browser/browser.toml new file mode 100644 index 0000000000..33d54bddc2 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser.toml @@ -0,0 +1,103 @@ +[DEFAULT] +support-files = [ + "head.js", + "data/**" +] + +["browser_ext_background_serviceworker_pref_disabled.js"] + +["browser_ext_downloads_filters.js"] + +["browser_ext_downloads_referrer.js"] +https_first_disabled = true + +["browser_ext_eventpage_disableResetIdleForTest.js"] + +["browser_ext_extension_page_tab_navigated.js"] + +["browser_ext_management_themes.js"] +skip-if = ["verify"] + +["browser_ext_process_crash_handling.js"] +skip-if = ["!crashreporter"] + +["browser_ext_test_mock.js"] + +["browser_ext_themes_additional_backgrounds_alignment.js"] + +["browser_ext_themes_alpha_accentcolor.js"] + +["browser_ext_themes_arrowpanels.js"] + +["browser_ext_themes_autocomplete_popup.js"] + +["browser_ext_themes_chromeparity.js"] + +["browser_ext_themes_dynamic_getCurrent.js"] + +["browser_ext_themes_dynamic_onUpdated.js"] + +["browser_ext_themes_dynamic_updates.js"] + +["browser_ext_themes_experiment.js"] + +["browser_ext_themes_findbar.js"] + +["browser_ext_themes_getCurrent_differentExt.js"] + +["browser_ext_themes_highlight.js"] + +["browser_ext_themes_incognito.js"] + +["browser_ext_themes_lwtsupport.js"] + +["browser_ext_themes_multiple_backgrounds.js"] + +["browser_ext_themes_ntp_colors.js"] + +["browser_ext_themes_ntp_colors_perwindow.js"] + +["browser_ext_themes_pbm.js"] + +["browser_ext_themes_persistence.js"] + +["browser_ext_themes_reset.js"] + +["browser_ext_themes_sanitization.js"] + +["browser_ext_themes_separators.js"] + +["browser_ext_themes_sidebars.js"] + +["browser_ext_themes_static_onUpdated.js"] + +["browser_ext_themes_tab_line.js"] + +["browser_ext_themes_tab_loading.js"] + +["browser_ext_themes_tab_selected.js"] + +["browser_ext_themes_tab_text.js"] + +["browser_ext_themes_theme_transition.js"] + +["browser_ext_themes_toolbar_fields.js"] + +["browser_ext_themes_toolbar_fields_focus.js"] + +["browser_ext_themes_toolbarbutton_colors.js"] + +["browser_ext_themes_toolbarbutton_icons.js"] + +["browser_ext_themes_toolbars.js"] + +["browser_ext_themes_warnings.js"] + +["browser_ext_thumbnails_bg_extension.js"] +support-files = ["!/toolkit/components/thumbnails/test/head.js"] + +["browser_ext_webNavigation_eventpage.js"] + +["browser_ext_webRequest_redirect_mozextension.js"] + +["browser_ext_windows_popup_title.js"] diff --git a/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker.js b/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker.js new file mode 100644 index 0000000000..153818f4de --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker.js @@ -0,0 +1,285 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* globals getBackgroundServiceWorkerRegistration, waitForServiceWorkerTerminated */ + +Services.scriptloader.loadSubScript( + new URL("head_serviceworker.js", gTestPath).href, + this +); + +add_task(assert_background_serviceworker_pref_enabled); + +add_task(async function test_serviceWorker_register_guarded_by_pref() { + // Test with backgroundServiceWorkeEnable set to true and the + // extensions.serviceWorkerRegist.allowed pref set to false. + // NOTE: the scenario with backgroundServiceWorkeEnable set to false + // is part of "browser_ext_background_serviceworker_pref_disabled.js". + + await SpecialPowers.pushPrefEnv({ + set: [["extensions.serviceWorkerRegister.allowed", false]], + }); + + let extensionData = { + files: { + "page.html": "<!DOCTYPE html><script src='page.js'></script>", + "page.js": async function () { + browser.test.assertEq( + undefined, + navigator.serviceWorker, + "navigator.serviceWorker should be undefined" + ); + browser.test.sendMessage("test-serviceWorker-register-disallowed"); + }, + "sw.js": "", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + // Verify that an extension page can't register a moz-extension url + // as a service worker. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: `moz-extension://${extension.uuid}/page.html`, + }, + async () => { + await extension.awaitMessage("test-serviceWorker-register-disallowed"); + } + ); + + await extension.unload(); + + await SpecialPowers.popPrefEnv(); + + // Test again with the pref set to true. + + await SpecialPowers.pushPrefEnv({ + set: [["extensions.serviceWorkerRegister.allowed", true]], + }); + + extension = ExtensionTestUtils.loadExtension({ + files: { + ...extensionData.files, + "page.js": async function () { + try { + await navigator.serviceWorker.register("sw.js"); + } catch (err) { + browser.test.fail( + `Unexpected error on registering a service worker: ${err}` + ); + throw err; + } finally { + browser.test.sendMessage("test-serviceworker-register-allowed"); + } + }, + }, + }); + await extension.startup(); + + // Verify that an extension page can register a moz-extension url + // as a service worker if enabled by the related pref. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: `moz-extension://${extension.uuid}/page.html`, + }, + async () => { + await extension.awaitMessage("test-serviceworker-register-allowed"); + } + ); + + await extension.unload(); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_cache_api_allowed() { + // Verify that Cache API support for moz-extension url availability is + // conditioned only by the extensions.backgroundServiceWorker.enabled pref. + // NOTE: the scenario with backgroundServiceWorkeEnable set to false + // is part of "browser_ext_background_serviceworker_pref_disabled.js". + const extension = ExtensionTestUtils.loadExtension({ + async background() { + try { + let cache = await window.caches.open("test-cache-api"); + browser.test.assertTrue( + await window.caches.has("test-cache-api"), + "CacheStorage.has should resolve to true" + ); + + // Test that adding and requesting cached moz-extension urls + // works as well. + let url = browser.runtime.getURL("file.txt"); + await cache.add(url); + const content = await cache.match(url).then(res => res.text()); + browser.test.assertEq( + "file content", + content, + "Got the expected content from the cached moz-extension url" + ); + + // Test that deleting the cache storage works as expected. + browser.test.assertTrue( + await window.caches.delete("test-cache-api"), + "Cache deleted successfully" + ); + browser.test.assertTrue( + !(await window.caches.has("test-cache-api")), + "CacheStorage.has should resolve to false" + ); + } catch (err) { + browser.test.fail(`Unexpected error on using Cache API: ${err}`); + throw err; + } finally { + browser.test.sendMessage("test-cache-api-allowed"); + } + }, + files: { + "file.txt": "file content", + }, + }); + + await extension.startup(); + await extension.awaitMessage("test-cache-api-allowed"); + await extension.unload(); +}); + +function createTestSWScript({ postMessageReply }) { + return ` + self.onmessage = msg => { + dump("Background ServiceWorker - onmessage handler\\n"); + msg.ports[0].postMessage("${postMessageReply}"); + dump("Background ServiceWorker - postMessage\\n"); + }; + dump("Background ServiceWorker - executed\\n"); + `; +} + +async function testServiceWorker({ extension, expectMessageReply }) { + // Verify that the WebExtensions framework has successfully registered the + // background service worker declared in the extension manifest. + const swRegInfo = getBackgroundServiceWorkerRegistration(extension); + + // Activate the background service worker by exchanging a message + // with it. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: `moz-extension://${extension.uuid}/page.html`, + }, + async browser => { + let msgFromV1 = await SpecialPowers.spawn( + browser, + [swRegInfo.scriptURL], + async url => { + const { active } = await content.navigator.serviceWorker.ready; + const { port1, port2 } = new content.MessageChannel(); + + return new Promise(resolve => { + port1.onmessage = msg => resolve(msg.data); + active.postMessage("test", [port2]); + }); + } + ); + + Assert.deepEqual( + msgFromV1, + expectMessageReply, + "Got the expected reply from the extension service worker" + ); + } + ); +} + +function loadTestExtension({ version }) { + const postMessageReply = `reply:sw-v${version}`; + + return ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + version, + background: { + service_worker: "sw.js", + }, + browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } }, + }, + files: { + "page.html": "<!DOCTYPE html><body></body>", + "sw.js": createTestSWScript({ postMessageReply }), + }, + }); +} + +async function assertWorkerIsRunningInExtensionProcess(extension) { + // Activate the background service worker by exchanging a message + // with it. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: `moz-extension://${extension.uuid}/page.html`, + }, + async browser => { + const workerScriptURL = `moz-extension://${extension.uuid}/sw.js`; + const workerDebuggerURLs = await SpecialPowers.spawn( + browser, + [workerScriptURL], + async url => { + await content.navigator.serviceWorker.ready; + const wdm = Cc[ + "@mozilla.org/dom/workers/workerdebuggermanager;1" + ].getService(Ci.nsIWorkerDebuggerManager); + + return Array.from(wdm.getWorkerDebuggerEnumerator()) + .map(wd => { + return wd.url; + }) + .filter(swURL => swURL == url); + } + ); + + Assert.deepEqual( + workerDebuggerURLs, + [workerScriptURL], + "The worker should be running in the extension child process" + ); + } + ); +} + +add_task(async function test_background_serviceworker_with_no_ext_apis() { + const extensionV1 = loadTestExtension({ version: "1" }); + await extensionV1.startup(); + + const swRegInfo = getBackgroundServiceWorkerRegistration(extensionV1); + const { uuid } = extensionV1; + + await assertWorkerIsRunningInExtensionProcess(extensionV1); + await testServiceWorker({ + extension: extensionV1, + expectMessageReply: "reply:sw-v1", + }); + + // Load a new version of the same addon and verify that the + // expected worker script is being executed. + const extensionV2 = loadTestExtension({ version: "2" }); + await extensionV2.startup(); + is(extensionV2.uuid, uuid, "The extension uuid did not change as expected"); + + await testServiceWorker({ + extension: extensionV2, + expectMessageReply: "reply:sw-v2", + }); + + await Promise.all([ + extensionV2.unload(), + // test extension v1 wrapper has to be unloaded explicitly, otherwise + // will be detected as a failure by the test harness. + extensionV1.unload(), + ]); + await waitForServiceWorkerTerminated(swRegInfo); + await waitForServiceWorkerRegistrationsRemoved(extensionV2); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker_pref_disabled.js b/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker_pref_disabled.js new file mode 100644 index 0000000000..0194cd237f --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker_pref_disabled.js @@ -0,0 +1,126 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function assert_background_serviceworker_pref_disabled() { + is( + WebExtensionPolicy.backgroundServiceWorkerEnabled, + false, + "Expect extensions.backgroundServiceWorker.enabled to be false" + ); +}); + +add_task(async function test_background_serviceworker_disallowed() { + const id = "test-disallowed-worker@test"; + + const extensionData = { + manifest: { + background: { + service_worker: "sw.js", + }, + applicantions: { gecko: { id } }, + useAddonManager: "temporary", + }, + }; + + SimpleTest.waitForExplicitFinish(); + let waitForConsole = new Promise(resolve => { + SimpleTest.monitorConsole(resolve, [ + { + message: + /Reading manifest: Error processing background: background.service_worker is currently disabled/, + }, + ]); + }); + + const extension = ExtensionTestUtils.loadExtension(extensionData); + await Assert.rejects( + extension.startup(), + /startup failed/, + "Startup failed with background.service_worker while disabled by pref" + ); + + SimpleTest.endMonitorConsole(); + await waitForConsole; +}); + +add_task(async function test_serviceWorker_register_disallowed() { + // Verify that setting extensions.serviceWorkerRegist.allowed pref to false + // doesn't allow serviceWorker.register if backgroundServiceWorkeEnable is + // set to false + + await SpecialPowers.pushPrefEnv({ + set: [["extensions.serviceWorkerRegister.allowed", true]], + }); + + let extensionData = { + files: { + "page.html": "<!DOCTYPE html><script src='page.js'></script>", + "page.js": async function () { + try { + await navigator.serviceWorker.register("sw.js"); + browser.test.fail( + `An extension page should not be able to register a serviceworker successfully` + ); + } catch (err) { + browser.test.assertEq( + String(err), + "SecurityError: The operation is insecure.", + "Got the expected error on registering a service worker from a script" + ); + } + browser.test.sendMessage("test-serviceWorker-register-disallowed"); + }, + "sw.js": "", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + // Verify that an extension page can't register a moz-extension url + // as a service worker. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: `moz-extension://${extension.uuid}/page.html`, + }, + async () => { + await extension.awaitMessage("test-serviceWorker-register-disallowed"); + } + ); + + await extension.unload(); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_cache_api_disallowed() { + // Verify that Cache API support for moz-extension url availability is also + // conditioned by the extensions.backgroundServiceWorker.enabled pref. + const extension = ExtensionTestUtils.loadExtension({ + async background() { + try { + const cache = await window.caches.open("test-cache-api"); + let url = browser.runtime.getURL("file.txt"); + await browser.test.assertRejects( + cache.add(url), + new RegExp(`Cache.add: Request URL ${url} must be either`), + "Got the expected rejections on calling cache.add with a moz-extension:// url" + ); + } catch (err) { + browser.test.fail(`Unexpected error on using Cache API: ${err}`); + throw err; + } finally { + browser.test.sendMessage("test-cache-api-disallowed"); + } + }, + files: { + "file.txt": "file content", + }, + }); + + await extension.startup(); + await extension.awaitMessage("test-cache-api-disallowed"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_downloads_filters.js b/toolkit/components/extensions/test/browser/browser_ext_downloads_filters.js new file mode 100644 index 0000000000..48dd6b88ee --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_downloads_filters.js @@ -0,0 +1,139 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +async function testAppliedFilters(ext, expectedFilter, expectedFilterCount) { + let tempDir = FileUtils.getDir("TmpD", [`testDownloadDir-${Math.random()}`]); + tempDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + + let filterCount = 0; + + let MockFilePicker = SpecialPowers.MockFilePicker; + MockFilePicker.init(window); + MockFilePicker.displayDirectory = tempDir; + MockFilePicker.returnValue = MockFilePicker.returnCancel; + MockFilePicker.appendFiltersCallback = function (fp, val) { + const hexstr = "0x" + ("000" + val.toString(16)).substr(-3); + filterCount++; + if (filterCount < expectedFilterCount) { + is(val, expectedFilter, "Got expected filter: " + hexstr); + } else if (filterCount == expectedFilterCount) { + is(val, MockFilePicker.filterAll, "Got all files filter: " + hexstr); + } else { + is(val, null, "Got unexpected filter: " + hexstr); + } + }; + MockFilePicker.showCallback = function (fp) { + const filename = fp.defaultString; + info("MockFilePicker - save as: " + filename); + }; + + let manifest = { + description: ext, + permissions: ["downloads"], + }; + + const extension = ExtensionTestUtils.loadExtension({ + manifest: manifest, + + background: async function () { + let ext = chrome.runtime.getManifest().description; + await browser.test.assertRejects( + browser.downloads.download({ + url: "http://any-origin/any-path/any-resource", + filename: "any-file" + ext, + saveAs: true, + }), + "Download canceled by the user", + "expected request to be canceled" + ); + browser.test.sendMessage("canceled"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("canceled"); + await extension.unload(); + + is( + filterCount, + expectedFilterCount, + "Got correct number of filters: " + filterCount + ); + + MockFilePicker.cleanup(); + + tempDir.remove(true); +} + +// Missing extension +add_task(async function testDownload_missing_All() { + await testAppliedFilters("", null, 1); +}); + +// Unrecognized extension +add_task(async function testDownload_unrecognized_All() { + await testAppliedFilters(".xxx", null, 1); +}); + +// Recognized extensions +add_task(async function testDownload_html_HTML() { + await testAppliedFilters(".html", Ci.nsIFilePicker.filterHTML, 2); +}); + +add_task(async function testDownload_xhtml_HTML() { + await testAppliedFilters(".xhtml", Ci.nsIFilePicker.filterHTML, 2); +}); + +add_task(async function testDownload_txt_Text() { + await testAppliedFilters(".txt", Ci.nsIFilePicker.filterText, 2); +}); + +add_task(async function testDownload_text_Text() { + await testAppliedFilters(".text", Ci.nsIFilePicker.filterText, 2); +}); + +add_task(async function testDownload_jpe_Images() { + await testAppliedFilters(".jpe", Ci.nsIFilePicker.filterImages, 2); +}); + +add_task(async function testDownload_tif_Images() { + await testAppliedFilters(".tif", Ci.nsIFilePicker.filterImages, 2); +}); + +add_task(async function testDownload_webp_Images() { + await testAppliedFilters(".webp", Ci.nsIFilePicker.filterImages, 2); +}); + +add_task(async function testDownload_heic_Images() { + await testAppliedFilters(".heic", Ci.nsIFilePicker.filterImages, 2); +}); + +add_task(async function testDownload_xml_XML() { + await testAppliedFilters(".xml", Ci.nsIFilePicker.filterXML, 2); +}); + +add_task(async function testDownload_aac_Audio() { + await testAppliedFilters(".aac", Ci.nsIFilePicker.filterAudio, 2); +}); + +add_task(async function testDownload_mp3_Audio() { + await testAppliedFilters(".mp3", Ci.nsIFilePicker.filterAudio, 2); +}); + +add_task(async function testDownload_wma_Audio() { + await testAppliedFilters(".wma", Ci.nsIFilePicker.filterAudio, 2); +}); + +add_task(async function testDownload_avi_Video() { + await testAppliedFilters(".avi", Ci.nsIFilePicker.filterVideo, 2); +}); + +add_task(async function testDownload_mp4_Video() { + await testAppliedFilters(".mp4", Ci.nsIFilePicker.filterVideo, 2); +}); + +add_task(async function testDownload_xvid_Video() { + await testAppliedFilters(".xvid", Ci.nsIFilePicker.filterVideo, 2); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_downloads_referrer.js b/toolkit/components/extensions/test/browser/browser_ext_downloads_referrer.js new file mode 100644 index 0000000000..9690df6376 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_downloads_referrer.js @@ -0,0 +1,91 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +const URL_PATH = "browser/toolkit/components/extensions/test/browser/data"; +const TEST_URL = `http://example.com/${URL_PATH}/test_downloads_referrer.html`; +const DOWNLOAD_URL = `http://example.com/${URL_PATH}/test-download.txt`; + +async function triggerSaveAs({ selector }) { + const contextMenu = window.document.getElementById("contentAreaContextMenu"); + const popupshown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + await BrowserTestUtils.synthesizeMouseAtCenter( + selector, + { type: "contextmenu", button: 2 }, + gBrowser.selectedBrowser + ); + await popupshown; + let saveLinkCommand = window.document.getElementById("context-savelink"); + contextMenu.activateItem(saveLinkCommand); +} + +add_setup(() => { + const tempDir = Services.dirsvc.get("TmpD", Ci.nsIFile); + tempDir.append("test-download-dir"); + if (!tempDir.exists()) { + tempDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755); + } + + let MockFilePicker = SpecialPowers.MockFilePicker; + MockFilePicker.init(window); + registerCleanupFunction(function () { + MockFilePicker.cleanup(); + + if (tempDir.exists()) { + tempDir.remove(true); + } + }); + + MockFilePicker.displayDirectory = tempDir; + MockFilePicker.showCallback = function (fp) { + info("MockFilePicker: shown"); + const filename = fp.defaultString; + info("MockFilePicker: save as " + filename); + const destFile = tempDir.clone(); + destFile.append(filename); + MockFilePicker.setFiles([destFile]); + info("MockFilePicker: showCallback done"); + }; +}); + +add_task(async function test_download_item_referrer_info() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["downloads"], + }, + async background() { + browser.downloads.onCreated.addListener(async downloadInfo => { + browser.test.sendMessage("download-on-created", downloadInfo); + }); + browser.downloads.onChanged.addListener(async downloadInfo => { + // Wait download to be completed. + if (downloadInfo.state?.current !== "complete") { + return; + } + browser.test.sendMessage("download-completed"); + }); + + // Call an API method implemented in the parent process to make sure + // registering the downloas.onCreated event listener has been completed. + await browser.runtime.getBrowserInfo(); + + browser.test.sendMessage("bg-page:ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("bg-page:ready"); + + await BrowserTestUtils.withNewTab({ gBrowser, url: TEST_URL }, async () => { + await triggerSaveAs({ selector: "a.test-link" }); + const downloadInfo = await extension.awaitMessage("download-on-created"); + is(downloadInfo.url, DOWNLOAD_URL, "Got the expected download url"); + is(downloadInfo.referrer, TEST_URL, "Got the expected referrer"); + }); + + // Wait for the download to have been completed and removed. + await extension.awaitMessage("download-completed"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_eventpage_disableResetIdleForTest.js b/toolkit/components/extensions/test/browser/browser_ext_eventpage_disableResetIdleForTest.js new file mode 100644 index 0000000000..8178411e80 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_eventpage_disableResetIdleForTest.js @@ -0,0 +1,83 @@ +"use strict"; + +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); + +const { AppUiTestDelegate } = ChromeUtils.importESModule( + "resource://testing-common/AppUiTestDelegate.sys.mjs" +); + +// Ignore error "Actor 'Conduits' destroyed before query 'RunListener' was resolved" +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Actor 'Conduits' destroyed before query 'RunListener'/ +); + +async function run_test_disableResetIdleForTest(options) { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + action: {}, + }, + background() { + browser.action.onClicked.addListener(async () => { + browser.test.notifyPass("action-clicked"); + // Deliberately keep this listener active to simulate a still active listener + // callback, while calling extension.terminateBackground(). + await new Promise(() => {}); + }); + + browser.test.sendMessage("background-ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + // After startup, the listener should be persistent but not primed. + assertPersistentListeners(extension, "browserAction", "onClicked", { + primed: false, + }); + + // Terminating the background should prime the persistent listener. + await extension.terminateBackground(); + assertPersistentListeners(extension, "browserAction", "onClicked", { + primed: true, + }); + + // Wake up the background, and verify the listener is no longer primed. + await AppUiTestDelegate.clickBrowserAction(window, extension.id); + await extension.awaitFinish("action-clicked"); + await AppUiTestDelegate.closeBrowserAction(window, extension.id); + await extension.awaitMessage("background-ready"); + assertPersistentListeners(extension, "browserAction", "onClicked", { + primed: false, + }); + + // Terminate the background again, while the onClicked listener is still + // being executed. + // With options.disableResetIdleForTest = true, the termination should NOT + // be skipped and the listener should become primed again. + // With options.disableResetIdleForTest = false or unset, the termination + // should be skipped and the listener should not become primed. + await extension.terminateBackground(options); + assertPersistentListeners(extension, "browserAction", "onClicked", { + primed: !!options?.disableResetIdleForTest, + }); + + await extension.unload(); +} + +// Verify default behaviour when terminating a background while a +// listener is still running: The background should not be terminated +// and the listener should not become primed. Not specifyiny a value +// for disableResetIdleForTest defauls to disableResetIdleForTest:false. +add_task(async function test_disableResetIdleForTest_default() { + await run_test_disableResetIdleForTest(); +}); + +// Verify that disableResetIdleForTest:true is honoured and terminating +// a background while a listener is still running is enforced: The +// background should be terminated and the listener should become primed. +add_task(async function test_disableResetIdleForTest_true() { + await run_test_disableResetIdleForTest({ disableResetIdleForTest: true }); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_extension_page_tab_navigated.js b/toolkit/components/extensions/test/browser/browser_ext_extension_page_tab_navigated.js new file mode 100644 index 0000000000..9742d42b2e --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_extension_page_tab_navigated.js @@ -0,0 +1,226 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +// The test tasks in this test file tends to trigger an intermittent +// exception raised from JSActor::AfterDestroy, because of a race between +// when the WebExtensions API event is being emitted from the parent process +// and the navigation triggered on the test extension pages. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Actor 'Conduits' destroyed before query 'RunListener' was resolved/ +); + +AddonTestUtils.initMochitest(this); + +const server = AddonTestUtils.createHttpServer({ + hosts: ["example.com", "anotherwebpage.org"], +}); + +server.registerPathHandler("/", (request, response) => { + response.write(`<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <title>test webpage</title> + </head> + </html> + `); +}); + +function createTestExtPage({ script }) { + return `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="${script}"></script> + </head> + </html> + `; +} + +function createTestExtPageScript(name) { + return `(${function (pageName) { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.log( + `Extension page "${pageName}" got a webRequest event: ${details.url}` + ); + browser.test.sendMessage(`event-received:${pageName}`); + }, + { types: ["main_frame"], urls: ["http://example.com/*"] } + ); + /* eslint-disable mozilla/balanced-listeners */ + window.addEventListener("pageshow", () => { + browser.test.log(`Extension page "${pageName}" got a pageshow event`); + browser.test.sendMessage(`pageshow:${pageName}`); + }); + window.addEventListener("pagehide", () => { + browser.test.log(`Extension page "${pageName}" got a pagehide event`); + browser.test.sendMessage(`pagehide:${pageName}`); + }); + /* eslint-enable mozilla/balanced-listeners */ + }})("${name}");`; +} + +// Triggers a WebRequest listener registered by the test extensions by +// opening a tab on the given web page URL and then closing it after +// it did load. +async function triggerWebRequestListener(webPageURL, pause) { + let webPageTab = await BrowserTestUtils.openNewForegroundTab( + { + gBrowser, + url: webPageURL, + }, + true /* waitForLoad */, + true /* waitForStop */ + ); + BrowserTestUtils.removeTab(webPageTab); +} + +// The following tests tasks are testing the expected behaviors related to same-process and cross-process +// navigations for an extension page, similarly to test_ext_extension_page_navigated.js, but unlike its +// xpcshell counterpart this tests are only testing that after navigating back to an extension page +// previously stored in the BFCache the WebExtensions events subscribed are being received as expected. + +add_task(async function test_extension_page_sameprocess_navigation() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "http://example.com/*"], + }, + files: { + "extpage1.html": createTestExtPage({ script: "extpage1.js" }), + "extpage1.js": createTestExtPageScript("extpage1"), + "extpage2.html": createTestExtPage({ script: "extpage2.js" }), + "extpage2.js": createTestExtPageScript("extpage2"), + }, + }); + + await extension.startup(); + + const policy = WebExtensionPolicy.getByID(extension.id); + + const extPageURL1 = policy.extension.baseURI.resolve("extpage1.html"); + const extPageURL2 = policy.extension.baseURI.resolve("extpage2.html"); + + info("Opening extension page in a new tab"); + const extPageTab = await BrowserTestUtils.addTab(gBrowser, extPageURL1); + let browser = gBrowser.getBrowserForTab(extPageTab); + info("Wait for the extension page to be loaded"); + await extension.awaitMessage("pageshow:extpage1"); + + await triggerWebRequestListener("http://example.com"); + await extension.awaitMessage("event-received:extpage1"); + ok(true, "extpage1 got a webRequest event as expected"); + + info("Load a second extension page in the same tab"); + BrowserTestUtils.startLoadingURIString(browser, extPageURL2); + + info("Wait extpage1 to receive a pagehide event"); + await extension.awaitMessage("pagehide:extpage1"); + info("Wait extpage2 to receive a pageshow event"); + await extension.awaitMessage("pageshow:extpage2"); + + info( + "Trigger a web request event and expect extpage2 to be the only one receiving it" + ); + await triggerWebRequestListener("http://example.com"); + await extension.awaitMessage("event-received:extpage2"); + ok(true, "extpage2 got a webRequest event as expected"); + + info( + "Navigating back to extpage1 and expect extpage2 to be the only one receiving the webRequest event" + ); + + browser.goBack(); + info("Wait for extpage1 to receive a pageshow event"); + await extension.awaitMessage("pageshow:extpage1"); + info("Wait for extpage2 to receive a pagehide event"); + await extension.awaitMessage("pagehide:extpage2"); + + // We only expect extpage1 to be able to receive API events. + await triggerWebRequestListener("http://example.com"); + await extension.awaitMessage("event-received:extpage1"); + ok(true, "extpage1 got a webRequest event as expected"); + + BrowserTestUtils.removeTab(extPageTab); + await extension.awaitMessage("pagehide:extpage1"); + + await extension.unload(); +}); + +add_task(async function test_extension_page_context_navigated_to_web_page() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "http://example.com/*"], + }, + files: { + "extpage.html": createTestExtPage({ script: "extpage.js" }), + "extpage.js": createTestExtPageScript("extpage"), + }, + }); + + await extension.startup(); + + const policy = WebExtensionPolicy.getByID(extension.id); + + const extPageURL = policy.extension.baseURI.resolve("extpage.html"); + // NOTE: this test will navigate the extension page to a webpage url that + // isn't matching the match pattern the test extension is going to use + // in its webRequest event listener, otherwise the extension page being + // navigated will be intermittently able to receive an event before it + // is navigated to the webpage url (and moved into the BFCache or destroyed) + // and trigger an intermittent failure of this test. + const webPageURL = "http://anotherwebpage.org/"; + const triggerWebRequestURL = "http://example.com/"; + + info("Opening extension page in a new tab"); + const extPageTab1 = await BrowserTestUtils.addTab(gBrowser, extPageURL); + let browserForTab1 = gBrowser.getBrowserForTab(extPageTab1); + info("Wait for the extension page to be loaded"); + await extension.awaitMessage("pageshow:extpage"); + + info("Navigate the tab from the extension page to a web page"); + let promiseLoaded = BrowserTestUtils.browserLoaded( + browserForTab1, + false, + webPageURL + ); + BrowserTestUtils.startLoadingURIString(browserForTab1, webPageURL); + info("Wait the tab to have loaded the new webpage url"); + await promiseLoaded; + info("Wait the extension page to receive a pagehide event"); + await extension.awaitMessage("pagehide:extpage"); + + // Trigger a webRequest listener, the extension page is expected to + // not be active, if that isn't the case a test message will be queued + // and will trigger an explicit test failure. + await triggerWebRequestListener(triggerWebRequestURL); + + info("Navigate back to the extension page"); + browserForTab1.goBack(); + info("Wait for extension page to receive a pageshow event"); + await extension.awaitMessage("pageshow:extpage"); + + await triggerWebRequestListener(triggerWebRequestURL); + await extension.awaitMessage("event-received:extpage"); + ok( + true, + "extpage got a webRequest event as expected after being restored from BFCache" + ); + + info("Cleanup and exit test"); + BrowserTestUtils.removeTab(extPageTab1); + + info("Wait the extension page to receive a pagehide event"); + await extension.awaitMessage("pagehide:extpage"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_management_themes.js b/toolkit/components/extensions/test/browser/browser_ext_management_themes.js new file mode 100644 index 0000000000..d3cfa536b8 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_management_themes.js @@ -0,0 +1,177 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +const { BuiltInThemes } = ChromeUtils.importESModule( + "resource:///modules/BuiltInThemes.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Message manager disconnected/ +); + +add_task(async function test_management_themes() { + await BuiltInThemes.ensureBuiltInThemes(); + + const TEST_ID = "test_management_themes@tests.mozilla.com"; + + let theme = ExtensionTestUtils.loadExtension({ + manifest: { + name: "Simple theme test", + version: "1.0", + description: "test theme", + theme: { + images: { + theme_frame: "image1.png", + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + useAddonManager: "temporary", + }); + + async function background(TEST_ID) { + browser.management.onInstalled.addListener(info => { + if (info.name == TEST_ID) { + return; + } + browser.test.log(`${info.name} was installed`); + browser.test.assertEq(info.type, "theme", "addon is theme"); + browser.test.sendMessage("onInstalled", info.name); + }); + browser.management.onDisabled.addListener(info => { + browser.test.log(`${info.name} was disabled`); + browser.test.assertEq(info.type, "theme", "addon is theme"); + browser.test.sendMessage("onDisabled", info.name); + }); + browser.management.onEnabled.addListener(info => { + browser.test.log(`${info.name} was enabled`); + browser.test.assertEq(info.type, "theme", "addon is theme"); + browser.test.sendMessage("onEnabled", info.name); + }); + browser.management.onUninstalled.addListener(info => { + browser.test.log(`${info.name} was uninstalled`); + browser.test.assertEq(info.type, "theme", "addon is theme"); + browser.test.sendMessage("onUninstalled", info.name); + }); + + async function getAddon(type) { + let addons = await browser.management.getAll(); + let themes = addons.filter(addon => addon.type === "theme"); + const STANDARD_BUILTIN_THEME_IDS = [ + "default-theme@mozilla.org", + "firefox-compact-light@mozilla.org", + "firefox-compact-dark@mozilla.org", + "firefox-alpenglow@mozilla.org", + ]; + // Check that management.getAll returns the built-in themes and our test + // extension. + for (let id of [...STANDARD_BUILTIN_THEME_IDS, TEST_ID]) { + let builtInExtension = addons.find(addon => { + return addon.id === id; + }); + browser.test.assertTrue( + !!builtInExtension, + `The extension with id ${id} was returned by getAll.` + ); + } + let found; + for (let addon of themes) { + browser.test.assertEq(addon.type, "theme", "addon is theme"); + if (type == "theme" && addon.id.includes("temporary-addon")) { + found = addon; + } else if (type == "enabled" && addon.enabled) { + found = addon; + } + } + return found; + } + + browser.test.onMessage.addListener(async msg => { + let theme = await getAddon("theme"); + browser.test.assertEq( + theme.description, + "test theme", + "description is correct" + ); + browser.test.assertTrue(theme.enabled, "theme is enabled"); + await browser.management.setEnabled(theme.id, false); + + theme = await getAddon("theme"); + + browser.test.assertTrue(!theme.enabled, "theme is disabled"); + let addon = getAddon("enabled"); + browser.test.assertTrue(addon, "another theme was enabled"); + + await browser.management.setEnabled(theme.id, true); + theme = await getAddon("theme"); + addon = await getAddon("enabled"); + browser.test.assertEq(theme.id, addon.id, "theme is enabled"); + + browser.test.sendMessage("done"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: TEST_ID, + }, + }, + name: TEST_ID, + permissions: ["management"], + }, + background: `(${background})("${TEST_ID}")`, + useAddonManager: "temporary", + }); + await extension.startup(); + + await theme.startup(); + is( + await extension.awaitMessage("onInstalled"), + "Simple theme test", + "webextension theme installed" + ); + is( + await extension.awaitMessage("onDisabled"), + "System theme — auto", + "default disabled" + ); + + extension.sendMessage("test"); + is( + await extension.awaitMessage("onEnabled"), + "System theme — auto", + "default enabled" + ); + is( + await extension.awaitMessage("onDisabled"), + "Simple theme test", + "addon disabled" + ); + is( + await extension.awaitMessage("onEnabled"), + "Simple theme test", + "addon enabled" + ); + is( + await extension.awaitMessage("onDisabled"), + "System theme — auto", + "default disabled" + ); + await extension.awaitMessage("done"); + + await Promise.all([theme.unload(), extension.awaitMessage("onUninstalled")]); + + is( + await extension.awaitMessage("onEnabled"), + "System theme — auto", + "default enabled" + ); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_process_crash_handling.js b/toolkit/components/extensions/test/browser/browser_ext_process_crash_handling.js new file mode 100644 index 0000000000..c33727b96b --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_process_crash_handling.js @@ -0,0 +1,180 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +const { ExtensionProcessCrashObserver, Management } = + ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + +AddonTestUtils.initMochitest(this); + +add_task(async function test_ExtensionProcessCrashObserver() { + await SpecialPowers.pushPrefEnv({ + // This test triggers a crash and so it will be restarting all builtin + // extensions persistent background pages as a side effect (and that would + // be make this test to hit failures due to the builtin background pages + // still in the process of being restarted being detected as shutdown leaks). + set: [["extensions.background.disableRestartPersistentAfterCrash", true]], + }); + let mv2Extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + manifest_version: 2, + }, + background() { + browser.test.sendMessage("background_running"); + }, + }); + + await mv2Extension.startup(); + await mv2Extension.awaitMessage("background_running"); + + let { + currentProcessChildID, + lastCrashedProcessChildID, + processSpawningDisabled, + lastCrashTimestamps, + } = ExtensionProcessCrashObserver; + + Assert.notEqual( + currentProcessChildID, + undefined, + "Expect ExtensionProcessCrashObserver.currentProcessChildID to be set" + ); + + Assert.equal( + ChromeUtils.getAllDOMProcesses().find( + pp => pp.childID == currentProcessChildID + )?.remoteType, + "extension", + "Expect a child process with remoteType extension to be found for the process childID set" + ); + + Assert.notEqual( + lastCrashedProcessChildID, + currentProcessChildID, + "Expect lastCrashedProcessChildID to not be set to the same value that currentProcessChildID is set" + ); + + Assert.equal( + processSpawningDisabled, + false, + "Expect process spawning to be enabled" + ); + + Assert.deepEqual(lastCrashTimestamps, [], "Expect no crash timestamps"); + + let mv3Extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + manifest_version: 3, + }, + background() { + browser.test.sendMessage("background_running"); + }, + }); + + const waitForExtensionBrowserInserted = () => + new Promise(resolve => { + const listener = (_eventName, browser) => { + if (!browser.getAttribute("webextension-view-type") === "background") { + return; + } + Management.off("extension-browser-inserted", listener); + resolve(browser); + }; + Management.on("extension-browser-inserted", listener); + }); + + const waitForExtensionProcessCrashNotified = () => + new Promise(resolve => { + Management.once("extension-process-crash", (_evt, data) => resolve(data)); + }); + + const promiseBackgroundBrowser = waitForExtensionBrowserInserted(); + + const promiseExtensionProcessCrashNotified = + waitForExtensionProcessCrashNotified(); + + await mv3Extension.startup(); + await mv3Extension.awaitMessage("background_running"); + const bgPageBrowser = await promiseBackgroundBrowser; + + Assert.ok( + Glean.extensions.processEvent.created_fg.testGetValue() > 0, + "Expect glean processEvent.created_fg to be set." + ); + Assert.equal( + undefined, + Glean.extensions.processEvent.created_bg.testGetValue(), + "Creating in the background is not expected on desktop." + ); + + info("Force extension process crash"); + // Clear any existing telemetry data, so that we can be sure we can + // assert the glean process_event metric labels values to be strictly + // equal to 1 after the extension process crashed. + Services.fog.testResetFOG(); + // NOTE: shouldShowTabCrashPage option needs to be set to false + // to make sure crashFrame method resolves without waiting for a + // tab crash page (which is not going to be shown for a background + // page browser element). + await BrowserTestUtils.crashFrame( + bgPageBrowser, + /* shouldShowTabCrashPage */ false + ); + + info("Verify ExtensionProcessCrashObserver after extension process crash"); + Assert.equal( + ExtensionProcessCrashObserver.lastCrashedProcessChildID, + currentProcessChildID, + "Expect ExtensionProcessCrashObserver.lastCrashedProcessChildID to be set to the expected childID" + ); + + Assert.equal( + ExtensionProcessCrashObserver.processSpawningDisabled, + false, + "Expect process spawning to still be enabled" + ); + Assert.equal( + ExtensionProcessCrashObserver.lastCrashTimestamps.length, + 1, + "Expect a crash timestamp" + ); + + info("Expect the same childID to have been notified as a Management event"); + Assert.deepEqual( + await promiseExtensionProcessCrashNotified, + { + childID: currentProcessChildID, + processSpawningDisabled: false, + // This boolean flag is expected to be always true on Desktop builds. + appInForeground: true, + }, + "Got the expected childID notified as part of the extension-process-crash Management event" + ); + + Assert.ok( + Glean.extensions.processEvent.crashed_fg.testGetValue() > 0, + "Expect glean processEvent.crashed_fg to be set" + ); + Assert.equal( + undefined, + Glean.extensions.processEvent.crashed_bg.testGetValue(), + "Crashing in the background is not expected on desktop." + ); + + info("Wait for mv3 extension shutdown"); + await mv3Extension.unload(); + info("Wait for mv2 extension shutdown"); + await mv2Extension.unload(); + + // Reset this array to prevent TV failures. + ExtensionProcessCrashObserver.lastCrashTimestamps = []; + + await SpecialPowers.popPrefEnv(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_test_mock.js b/toolkit/components/extensions/test/browser/browser_ext_test_mock.js new file mode 100644 index 0000000000..de496b7631 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_test_mock.js @@ -0,0 +1,47 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// This test verifies that the extension mocks behave consistently, regardless +// of test type (xpcshell vs browser test). +// See also toolkit/components/extensions/test/xpcshell/test_ext_test_mock.js + +// Check the state of the extension object. This should be consistent between +// browser tests and xpcshell tests. +async function checkExtensionStartupAndUnload(ext) { + await ext.startup(); + Assert.ok(ext.id, "Extension ID should be available"); + Assert.ok(ext.uuid, "Extension UUID should be available"); + await ext.unload(); + // Once set nothing clears the UUID. + Assert.ok(ext.uuid, "Extension UUID exists after unload"); +} + +add_task(async function test_MockExtension() { + // When "useAddonManager" is set, a MockExtension is created in the main + // process, which does not necessarily behave identically to an Extension. + let ext = ExtensionTestUtils.loadExtension({ + // xpcshell/test_ext_test_mock.js tests "temporary", so here we use + // "permanent" to have even more test coverage. + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: "@permanent-mock-extension" } }, + }, + }); + + Assert.ok(!ext.id, "Extension ID is initially unavailable"); + Assert.ok(!ext.uuid, "Extension UUID is initially unavailable"); + await checkExtensionStartupAndUnload(ext); + Assert.ok(ext.id, "Extension ID exists after unload"); +}); + +add_task(async function test_generated_Extension() { + let ext = ExtensionTestUtils.loadExtension({ + manifest: {}, + }); + + Assert.ok(!ext.id, "Extension ID is initially unavailable"); + Assert.ok(!ext.uuid, "Extension UUID is initially unavailable"); + await checkExtensionStartupAndUnload(ext); + Assert.ok(ext.id, "Extension ID exists after unload"); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_additional_backgrounds_alignment.js b/toolkit/components/extensions/test/browser/browser_ext_themes_additional_backgrounds_alignment.js new file mode 100644 index 0000000000..b02e552ecb --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_additional_backgrounds_alignment.js @@ -0,0 +1,88 @@ +"use strict"; + +// Case 1 - When there is a theme_frame image and additional_backgrounds_alignment is not specified. +// So background-position should default to "right top" +add_task(async function test_default_additional_backgrounds_alignment() { + const RIGHT_TOP = "100% 0%"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + additional_backgrounds: ["image1.png", "image1.png"], + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let toolbox = document.querySelector("#navigator-toolbox"); + let toolboxCS = window.getComputedStyle(toolbox); + /** + * We expect duplicate background-position values because we apply `right top` + * once for theme_frame, and again as the default value of + * --lwt-background-alignment. + */ + Assert.equal( + toolboxCS.getPropertyValue("background-position"), + `${RIGHT_TOP}, ${RIGHT_TOP}`, + toolbox.id + + " contains theme_frame and default lwt-background-alignment properties" + ); + + await extension.unload(); +}); + +// Case 2 - When there is a theme_frame image and additional_backgrounds_alignment is specified. +add_task(async function test_additional_backgrounds_alignment() { + const LEFT_BOTTOM = "0% 100%"; + const CENTER_CENTER = "50% 50%"; + const RIGHT_TOP = "100% 0%"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + additional_backgrounds: ["image1.png", "image1.png", "image1.png"], + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + }, + properties: { + additional_backgrounds_alignment: [ + "left bottom", + "center center", + "right top", + ], + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let toolbox = document.querySelector("#navigator-toolbox"); + let toolboxCS = window.getComputedStyle(toolbox); + Assert.equal( + toolboxCS.getPropertyValue("background-position"), + RIGHT_TOP + ", " + LEFT_BOTTOM + ", " + CENTER_CENTER + ", " + RIGHT_TOP, + toolbox.id + + " contains theme_frame and additional_backgrounds alignment properties" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_alpha_accentcolor.js b/toolkit/components/extensions/test/browser/browser_ext_themes_alpha_accentcolor.js new file mode 100644 index 0000000000..761e89561f --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_alpha_accentcolor.js @@ -0,0 +1,30 @@ +"use strict"; + +add_task(async function test_alpha_frame_color() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: "rgba(230, 128, 0, 0.1)", + tab_background_text: TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + Assert.equal( + getToolboxBackgroundColor(), + "rgb(230, 128, 0)", + "Window background color should be opaque" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_arrowpanels.js b/toolkit/components/extensions/test/browser/browser_ext_themes_arrowpanels.js new file mode 100644 index 0000000000..6665fb3092 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_arrowpanels.js @@ -0,0 +1,82 @@ +"use strict"; + +function openIdentityPopup() { + let promise = BrowserTestUtils.waitForEvent( + window, + "popupshown", + true, + event => event.target == gIdentityHandler._identityPopup + ); + gIdentityHandler._identityIconBox.click(); + return promise; +} + +function closeIdentityPopup() { + let promise = BrowserTestUtils.waitForEvent( + gIdentityHandler._identityPopup, + "popuphidden" + ); + gIdentityHandler._identityPopup.hidePopup(); + return promise; +} + +// This test checks applied WebExtension themes that attempt to change +// popup properties + +add_task(async function test_popup_styling(browser, accDoc) { + const POPUP_BACKGROUND_COLOR = "#FF0000"; + const POPUP_TEXT_COLOR = "#008000"; + const POPUP_BORDER_COLOR = "#0000FF"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + popup: POPUP_BACKGROUND_COLOR, + popup_text: POPUP_TEXT_COLOR, + popup_border: POPUP_BORDER_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: "https://example.com" }, + async function (browser) { + await extension.startup(); + + // Open the information arrow panel + await openIdentityPopup(); + + let arrowContent = gIdentityHandler._identityPopup.panelContent; + let arrowContentComputedStyle = window.getComputedStyle(arrowContent); + // Ensure popup background color was set properly + Assert.equal( + arrowContentComputedStyle.getPropertyValue("background-color"), + `rgb(${hexToRGB(POPUP_BACKGROUND_COLOR).join(", ")})`, + "Popup background color should have been themed" + ); + + // Ensure popup text color was set properly + Assert.equal( + arrowContentComputedStyle.getPropertyValue("color"), + `rgb(${hexToRGB(POPUP_TEXT_COLOR).join(", ")})`, + "Popup text color should have been themed" + ); + + // Ensure popup border color was set properly + testBorderColor(arrowContent, POPUP_BORDER_COLOR); + + await closeIdentityPopup(); + await extension.unload(); + } + ); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_autocomplete_popup.js b/toolkit/components/extensions/test/browser/browser_ext_themes_autocomplete_popup.js new file mode 100644 index 0000000000..d2baf6157b --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_autocomplete_popup.js @@ -0,0 +1,173 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// popup properties are applied correctly to the autocomplete bar. +const POPUP_COLOR_DARK = "#00A400"; +const POPUP_COLOR_BRIGHT = "#85A4FF"; +const POPUP_TEXT_COLOR_DARK = "#000000"; +const POPUP_TEXT_COLOR_BRIGHT = "#ffffff"; +const POPUP_SELECTED_COLOR = "#9400ff"; +const POPUP_SELECTED_TEXT_COLOR = "#09b9a6"; + +const POPUP_URL_COLOR_DARK = "#0061e0"; +const POPUP_ACTION_COLOR_DARK = "#5b5b66"; +const POPUP_URL_COLOR_BRIGHT = "#00ddff"; +const POPUP_ACTION_COLOR_BRIGHT = "#bfbfc9"; + +const SEARCH_TERM = "urlbar-reflows-" + Date.now(); + +ChromeUtils.defineESModuleGetters(this, { + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", +}); + +add_setup(async function () { + await PlacesUtils.history.clear(); + const NUM_VISITS = 10; + let visits = []; + + for (let i = 0; i < NUM_VISITS; ++i) { + visits.push({ + uri: `http://example.com/urlbar-reflows-${i}`, + title: `Reflow test for URL bar entry #${i} - ${SEARCH_TERM}`, + }); + } + + await PlacesTestUtils.addVisits(visits); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function test_popup_url() { + // Load a manifest with popup_text being dark (bright background). Test for + // dark text properties. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_field_focus: POPUP_COLOR_BRIGHT, + toolbar_field_text_focus: POPUP_TEXT_COLOR_DARK, + popup_highlight: POPUP_SELECTED_COLOR, + popup_highlight_text: POPUP_SELECTED_TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let maxResults = Services.prefs.getIntPref("browser.urlbar.maxRichResults"); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:mozilla" + ); + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + await BrowserTestUtils.removeTab(tab); + }); + + let visits = []; + + for (let i = 0; i < maxResults; i++) { + visits.push({ uri: makeURI("http://example.com/autocomplete/?" + i) }); + } + + await PlacesTestUtils.addVisits(visits); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: "example.com/autocomplete", + }); + await UrlbarTestUtils.waitForAutocompleteResultAt(window, maxResults - 1); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + maxResults, + "Should get maxResults=" + maxResults + " results" + ); + + // Set the selected attribute to true to test the highlight popup properties + UrlbarTestUtils.setSelectedRowIndex(window, 1); + let actionResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + let urlResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + let resultCS = window.getComputedStyle(urlResult.element.row); + + Assert.equal( + resultCS.backgroundColor, + `rgb(${hexToRGB(POPUP_SELECTED_COLOR).join(", ")})`, + `Popup highlight background color should be set to ${POPUP_SELECTED_COLOR}` + ); + + Assert.equal( + resultCS.color, + `rgb(${hexToRGB(POPUP_SELECTED_TEXT_COLOR).join(", ")})`, + `Popup highlight color should be set to ${POPUP_SELECTED_TEXT_COLOR}` + ); + + // Now set the index to somewhere not on the first two, so that we can test both + // url and action text colors. + UrlbarTestUtils.setSelectedRowIndex(window, 2); + + Assert.equal( + window.getComputedStyle(urlResult.element.url).color, + `rgb(${hexToRGB(POPUP_URL_COLOR_DARK).join(", ")})`, + `Urlbar popup url color should be set to ${POPUP_URL_COLOR_DARK}` + ); + + Assert.equal( + window.getComputedStyle(actionResult.element.action).color, + `rgb(${hexToRGB(POPUP_ACTION_COLOR_DARK).join(", ")})`, + `Urlbar popup action color should be set to ${POPUP_ACTION_COLOR_DARK}` + ); + + await extension.unload(); + + // Load a manifest with popup_text being bright (dark background). Test for + // bright text properties. + extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_field_focus: POPUP_COLOR_DARK, + toolbar_field_text_focus: POPUP_TEXT_COLOR_BRIGHT, + popup_highlight: POPUP_SELECTED_COLOR, + popup_highlight_text: POPUP_SELECTED_TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + Assert.equal( + window.getComputedStyle(urlResult.element.url).color, + `rgb(${hexToRGB(POPUP_URL_COLOR_BRIGHT).join(", ")})`, + `Urlbar popup url color should be set to ${POPUP_URL_COLOR_BRIGHT}` + ); + + Assert.equal( + window.getComputedStyle(actionResult.element.action).color, + `rgb(${hexToRGB(POPUP_ACTION_COLOR_BRIGHT).join(", ")})`, + `Urlbar popup action color should be set to ${POPUP_ACTION_COLOR_BRIGHT}` + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_chromeparity.js b/toolkit/components/extensions/test/browser/browser_ext_themes_chromeparity.js new file mode 100644 index 0000000000..086ea39653 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_chromeparity.js @@ -0,0 +1,159 @@ +"use strict"; + +add_task(async function test_support_theme_frame() { + const FRAME_COLOR = [71, 105, 91]; + const TAB_TEXT_COLOR = [0, 0, 0]; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "face.png", + }, + colors: { + frame: FRAME_COLOR, + tab_background_text: TAB_TEXT_COLOR, + }, + }, + }, + files: { + "face.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + + Assert.ok( + docEl.hasAttribute("lwtheme-image"), + "LWT image attribute should be set" + ); + + Assert.equal( + docEl.getAttribute("lwtheme-brighttext"), + null, + "LWT text color attribute should not be set" + ); + + let toolbox = document.querySelector("#navigator-toolbox"); + let toolboxCS = window.getComputedStyle(toolbox); + + Assert.ok( + toolboxCS.backgroundImage.includes("face.png"), + `The backgroundImage should use face.png. Actual value is: ${toolboxCS.backgroundImage}` + ); + Assert.equal( + getToolboxBackgroundColor(), + "rgb(" + FRAME_COLOR.join(", ") + ")", + "Expected correct background color" + ); + Assert.equal( + toolboxCS.color, + "rgb(" + TAB_TEXT_COLOR.join(", ") + ")", + "Expected correct text color" + ); + + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); + + Assert.ok( + !docEl.hasAttribute("lwtheme-image"), + "LWT image attribute should not be set" + ); + + Assert.ok( + !docEl.hasAttribute("lwtheme-brighttext"), + "LWT text color attribute should not be set" + ); +}); + +add_task(async function test_support_theme_frame_inactive() { + const FRAME_COLOR = [71, 105, 91]; + const FRAME_COLOR_INACTIVE = [255, 0, 0]; + const TAB_TEXT_COLOR = [207, 221, 192]; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: FRAME_COLOR, + frame_inactive: FRAME_COLOR_INACTIVE, + tab_background_text: TAB_TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + + Assert.equal( + getToolboxBackgroundColor(), + "rgb(" + FRAME_COLOR.join(", ") + ")", + "Window background is set to the colors.frame property" + ); + + // Now we'll open a new window to see if the inactive browser accent color changed + let window2 = await BrowserTestUtils.openNewBrowserWindow(); + Assert.equal( + getToolboxBackgroundColor(), + "rgb(" + FRAME_COLOR_INACTIVE.join(", ") + ")", + `Inactive window background color should be ${FRAME_COLOR_INACTIVE}` + ); + + await BrowserTestUtils.closeWindow(window2); + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); + +add_task(async function test_lack_of_theme_frame_inactive() { + const FRAME_COLOR = [71, 105, 91]; + const TAB_TEXT_COLOR = [207, 221, 192]; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: FRAME_COLOR, + tab_background_text: TAB_TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + Assert.equal( + getToolboxBackgroundColor(), + "rgb(" + FRAME_COLOR.join(", ") + ")", + "Window background is set to the colors.frame property" + ); + + // Now we'll open a new window to make sure the inactive browser accent color stayed the same + let window2 = await BrowserTestUtils.openNewBrowserWindow(); + Assert.equal( + getToolboxBackgroundColor(), + "rgb(" + FRAME_COLOR.join(", ") + ")", + "Inactive window background should not change if colors.frame_inactive isn't set" + ); + + await BrowserTestUtils.closeWindow(window2); + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_getCurrent.js b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_getCurrent.js new file mode 100644 index 0000000000..4a379edfbf --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_getCurrent.js @@ -0,0 +1,203 @@ +"use strict"; + +// This test checks whether browser.theme.getCurrent() works correctly in different +// configurations and with different parameter. + +// PNG image data for a simple red dot. +const BACKGROUND_1 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; +// PNG image data for the Mozilla dino head. +const BACKGROUND_2 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="; + +add_task(async function test_get_current() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + const ACCENT_COLOR_1 = "#a14040"; + const TEXT_COLOR_1 = "#fac96e"; + + const ACCENT_COLOR_2 = "#03fe03"; + const TEXT_COLOR_2 = "#0ef325"; + + const theme1 = { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR_1, + tab_background_text: TEXT_COLOR_1, + }, + }; + + const theme2 = { + images: { + theme_frame: "image2.png", + }, + colors: { + frame: ACCENT_COLOR_2, + tab_background_text: TEXT_COLOR_2, + }, + }; + + function ensureWindowFocused(winId) { + browser.test.log("Waiting for focused window to be " + winId); + // eslint-disable-next-line no-async-promise-executor + return new Promise(async resolve => { + let listener = windowId => { + if (windowId === winId) { + browser.windows.onFocusChanged.removeListener(listener); + resolve(); + } + }; + // We first add a listener and then check whether the window is + // focused using .get(), because the .get() Promise resolving + // could race with the listener running, in which case we'd + // never be notified. + browser.windows.onFocusChanged.addListener(listener); + let { focused } = await browser.windows.get(winId); + if (focused) { + browser.windows.onFocusChanged.removeListener(listener); + resolve(); + } + }); + } + + function testTheme1(returnedTheme) { + browser.test.assertTrue( + returnedTheme.images.theme_frame.includes("image1.png"), + "Theme 1 theme_frame image should be applied" + ); + browser.test.assertEq( + ACCENT_COLOR_1, + returnedTheme.colors.frame, + "Theme 1 frame color should be applied" + ); + browser.test.assertEq( + TEXT_COLOR_1, + returnedTheme.colors.tab_background_text, + "Theme 1 tab_background_text color should be applied" + ); + } + + function testTheme2(returnedTheme) { + browser.test.assertTrue( + returnedTheme.images.theme_frame.includes("image2.png"), + "Theme 2 theme_frame image should be applied" + ); + browser.test.assertEq( + ACCENT_COLOR_2, + returnedTheme.colors.frame, + "Theme 2 frame color should be applied" + ); + browser.test.assertEq( + TEXT_COLOR_2, + returnedTheme.colors.tab_background_text, + "Theme 2 tab_background_text color should be applied" + ); + } + + function testEmptyTheme(returnedTheme) { + browser.test.assertEq( + JSON.stringify({ colors: null, images: null, properties: null }), + JSON.stringify(returnedTheme), + JSON.stringify(returnedTheme, null, 2) + ); + } + + browser.test.log("Testing getCurrent() with initial unthemed window"); + const firstWin = await browser.windows.getCurrent(); + testEmptyTheme(await browser.theme.getCurrent()); + testEmptyTheme(await browser.theme.getCurrent(firstWin.id)); + + browser.test.log("Testing getCurrent() with after theme.update()"); + await browser.theme.update(theme1); + testTheme1(await browser.theme.getCurrent()); + testTheme1(await browser.theme.getCurrent(firstWin.id)); + + browser.test.log( + "Testing getCurrent() with after theme.update(windowId)" + ); + const secondWin = await browser.windows.create(); + await ensureWindowFocused(secondWin.id); + await browser.theme.update(secondWin.id, theme2); + testTheme2(await browser.theme.getCurrent()); + testTheme1(await browser.theme.getCurrent(firstWin.id)); + testTheme2(await browser.theme.getCurrent(secondWin.id)); + + browser.test.log("Testing getCurrent() after window focus change"); + let focusChanged = ensureWindowFocused(firstWin.id); + await browser.windows.update(firstWin.id, { focused: true }); + await focusChanged; + testTheme1(await browser.theme.getCurrent()); + testTheme1(await browser.theme.getCurrent(firstWin.id)); + testTheme2(await browser.theme.getCurrent(secondWin.id)); + + browser.test.log( + "Testing getCurrent() after another window focus change" + ); + focusChanged = ensureWindowFocused(secondWin.id); + await browser.windows.update(secondWin.id, { focused: true }); + await focusChanged; + testTheme2(await browser.theme.getCurrent()); + testTheme1(await browser.theme.getCurrent(firstWin.id)); + testTheme2(await browser.theme.getCurrent(secondWin.id)); + + browser.test.log("Testing getCurrent() after theme.reset(windowId)"); + await browser.theme.reset(firstWin.id); + testTheme2(await browser.theme.getCurrent()); + testTheme1(await browser.theme.getCurrent(firstWin.id)); + testTheme2(await browser.theme.getCurrent(secondWin.id)); + + browser.test.log( + "Testing getCurrent() after reset and window focus change" + ); + focusChanged = ensureWindowFocused(firstWin.id); + await browser.windows.update(firstWin.id, { focused: true }); + await focusChanged; + testTheme1(await browser.theme.getCurrent()); + testTheme1(await browser.theme.getCurrent(firstWin.id)); + testTheme2(await browser.theme.getCurrent(secondWin.id)); + + browser.test.log("Testing getCurrent() after theme.update(windowId)"); + await browser.theme.update(firstWin.id, theme1); + testTheme1(await browser.theme.getCurrent()); + testTheme1(await browser.theme.getCurrent(firstWin.id)); + testTheme2(await browser.theme.getCurrent(secondWin.id)); + + browser.test.log("Testing getCurrent() after theme.reset()"); + await browser.theme.reset(); + testEmptyTheme(await browser.theme.getCurrent()); + testEmptyTheme(await browser.theme.getCurrent(firstWin.id)); + testEmptyTheme(await browser.theme.getCurrent(secondWin.id)); + + browser.test.log("Testing getCurrent() after closing a window"); + await browser.windows.remove(secondWin.id); + testEmptyTheme(await browser.theme.getCurrent()); + testEmptyTheme(await browser.theme.getCurrent(firstWin.id)); + + browser.test.log("Testing update calls with invalid window ID"); + await browser.test.assertRejects( + browser.theme.reset(secondWin.id), + /Invalid window/, + "Invalid window should throw" + ); + await browser.test.assertRejects( + browser.theme.update(secondWin.id, theme2), + /Invalid window/, + "Invalid window should throw" + ); + browser.test.notifyPass("get_current"); + }, + manifest: { + permissions: ["theme"], + }, + files: { + "image1.png": BACKGROUND_1, + "image2.png": BACKGROUND_2, + }, + }); + + await extension.startup(); + await extension.awaitFinish("get_current"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_onUpdated.js b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_onUpdated.js new file mode 100644 index 0000000000..34c7162810 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_onUpdated.js @@ -0,0 +1,154 @@ +"use strict"; + +// This test checks whether browser.theme.onUpdated works correctly with different +// types of dynamic theme updates. + +// PNG image data for a simple red dot. +const BACKGROUND_1 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; +// PNG image data for the Mozilla dino head. +const BACKGROUND_2 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="; + +add_task(async function test_on_updated() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + const ACCENT_COLOR_1 = "#a14040"; + const TEXT_COLOR_1 = "#fac96e"; + + const ACCENT_COLOR_2 = "#03fe03"; + const TEXT_COLOR_2 = "#0ef325"; + + const theme1 = { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR_1, + tab_background_text: TEXT_COLOR_1, + }, + }; + + const theme2 = { + images: { + theme_frame: "image2.png", + }, + colors: { + frame: ACCENT_COLOR_2, + tab_background_text: TEXT_COLOR_2, + }, + }; + + function testTheme1(returnedTheme) { + browser.test.assertTrue( + returnedTheme.images.theme_frame.includes("image1.png"), + "Theme 1 theme_frame image should be applied" + ); + browser.test.assertEq( + ACCENT_COLOR_1, + returnedTheme.colors.frame, + "Theme 1 frame color should be applied" + ); + browser.test.assertEq( + TEXT_COLOR_1, + returnedTheme.colors.tab_background_text, + "Theme 1 tab_background_text color should be applied" + ); + } + + function testTheme2(returnedTheme) { + browser.test.assertTrue( + returnedTheme.images.theme_frame.includes("image2.png"), + "Theme 2 theme_frame image should be applied" + ); + browser.test.assertEq( + ACCENT_COLOR_2, + returnedTheme.colors.frame, + "Theme 2 frame color should be applied" + ); + browser.test.assertEq( + TEXT_COLOR_2, + returnedTheme.colors.tab_background_text, + "Theme 2 tab_background_text color should be applied" + ); + } + + const firstWin = await browser.windows.getCurrent(); + const secondWin = await browser.windows.create(); + + const onceThemeUpdated = () => + new Promise(resolve => { + const listener = updateInfo => { + browser.theme.onUpdated.removeListener(listener); + resolve(updateInfo); + }; + browser.theme.onUpdated.addListener(listener); + }); + + browser.test.log("Testing update with no windowId parameter"); + let updateInfo1 = onceThemeUpdated(); + await browser.theme.update(theme1); + updateInfo1 = await updateInfo1; + testTheme1(updateInfo1.theme); + browser.test.assertTrue( + !updateInfo1.windowId, + "No window id on first update" + ); + + browser.test.log("Testing update with windowId parameter"); + let updateInfo2 = onceThemeUpdated(); + await browser.theme.update(secondWin.id, theme2); + updateInfo2 = await updateInfo2; + testTheme2(updateInfo2.theme); + browser.test.assertEq( + secondWin.id, + updateInfo2.windowId, + "window id on second update" + ); + + browser.test.log("Testing reset with windowId parameter"); + let updateInfo3 = onceThemeUpdated(); + await browser.theme.reset(firstWin.id); + updateInfo3 = await updateInfo3; + browser.test.assertEq( + 0, + Object.keys(updateInfo3.theme).length, + "Empty theme given on reset" + ); + browser.test.assertEq( + firstWin.id, + updateInfo3.windowId, + "window id on third update" + ); + + browser.test.log("Testing reset with no windowId parameter"); + let updateInfo4 = onceThemeUpdated(); + await browser.theme.reset(); + updateInfo4 = await updateInfo4; + browser.test.assertEq( + 0, + Object.keys(updateInfo4.theme).length, + "Empty theme given on reset" + ); + browser.test.assertTrue( + !updateInfo4.windowId, + "no window id on fourth update" + ); + + browser.test.log("Cleaning up test"); + await browser.windows.remove(secondWin.id); + browser.test.notifyPass("onUpdated"); + }, + manifest: { + permissions: ["theme"], + }, + files: { + "image1.png": BACKGROUND_1, + "image2.png": BACKGROUND_2, + }, + }); + + await extension.startup(); + await extension.awaitFinish("onUpdated"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_updates.js b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_updates.js new file mode 100644 index 0000000000..5fae5150b6 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_updates.js @@ -0,0 +1,199 @@ +"use strict"; + +// PNG image data for a simple red dot. +const BACKGROUND_1 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; +const ACCENT_COLOR_1 = "#a14040"; +const TEXT_COLOR_1 = "#fac96e"; + +// PNG image data for the Mozilla dino head. +const BACKGROUND_2 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="; +const ACCENT_COLOR_2 = "#03fe03"; +const TEXT_COLOR_2 = "#0ef325"; + +function hexToRGB(hex) { + hex = parseInt(hex.indexOf("#") > -1 ? hex.substring(1) : hex, 16); + return ( + "rgb(" + [hex >> 16, (hex & 0x00ff00) >> 8, hex & 0x0000ff].join(", ") + ")" + ); +} + +function validateTheme(backgroundImage, accentColor, textColor, isLWT) { + let docEl = window.document.documentElement; + let rootCS = window.getComputedStyle(docEl); + + let toolbox = document.querySelector("#navigator-toolbox"); + let toolboxCS = window.getComputedStyle(toolbox); + + if (isLWT) { + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal( + docEl.getAttribute("lwtheme-brighttext"), + "true", + "LWT text color attribute should be set" + ); + } + + if (accentColor.startsWith("#")) { + accentColor = hexToRGB(accentColor); + } + if (textColor.startsWith("#")) { + textColor = hexToRGB(textColor); + } + Assert.ok( + toolboxCS.backgroundImage.includes(backgroundImage), + "Expected correct background image" + ); + Assert.equal( + getToolboxBackgroundColor(), + accentColor, + "Expected correct accent color" + ); + + Assert.equal(rootCS.color, textColor, "Expected correct text color"); +} + +add_task(async function test_dynamic_theme_updates() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["theme"], + }, + files: { + "image1.png": BACKGROUND_1, + "image2.png": BACKGROUND_2, + }, + background() { + browser.test.onMessage.addListener((msg, details) => { + if (msg === "update-theme") { + browser.theme.update(details).then(() => { + browser.test.sendMessage("theme-updated"); + }); + } else { + browser.theme.reset().then(() => { + browser.test.sendMessage("theme-reset"); + }); + } + }); + }, + }); + + let rootCS = window.getComputedStyle(window.document.documentElement); + let toolboxCS = window.getComputedStyle( + window.document.documentElement.querySelector("#navigator-toolbox") + ); + await extension.startup(); + + extension.sendMessage("update-theme", { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR_1, + tab_background_text: TEXT_COLOR_1, + }, + }); + + await extension.awaitMessage("theme-updated"); + + validateTheme("image1.png", ACCENT_COLOR_1, TEXT_COLOR_1, true); + + // Check with the LWT aliases (to update on Firefox 69, because the + // LWT aliases are going to be removed). + extension.sendMessage("update-theme", { + images: { + theme_frame: "image2.png", + }, + colors: { + frame: ACCENT_COLOR_2, + tab_background_text: TEXT_COLOR_2, + }, + }); + + await extension.awaitMessage("theme-updated"); + + validateTheme("image2.png", ACCENT_COLOR_2, TEXT_COLOR_2, true); + + extension.sendMessage("reset-theme"); + + await extension.awaitMessage("theme-reset"); + + let { color } = rootCS; + let backgroundImage = toolboxCS.backgroundImage; + let backgroundColor = getToolboxBackgroundColor(); + validateTheme(backgroundImage, backgroundColor, color, false); + + await extension.unload(); + + let docEl = window.document.documentElement; + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); + +add_task(async function test_dynamic_theme_updates_with_data_url() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["theme"], + }, + background() { + browser.test.onMessage.addListener((msg, details) => { + if (msg === "update-theme") { + browser.theme.update(details).then(() => { + browser.test.sendMessage("theme-updated"); + }); + } else { + browser.theme.reset().then(() => { + browser.test.sendMessage("theme-reset"); + }); + } + }); + }, + }); + + let rootCS = window.getComputedStyle(window.document.documentElement); + let toolboxCS = window.getComputedStyle( + window.document.documentElement.querySelector("#navigator-toolbox") + ); + await extension.startup(); + + extension.sendMessage("update-theme", { + images: { + theme_frame: BACKGROUND_1, + }, + colors: { + frame: ACCENT_COLOR_1, + tab_background_text: TEXT_COLOR_1, + }, + }); + + await extension.awaitMessage("theme-updated"); + + validateTheme(BACKGROUND_1, ACCENT_COLOR_1, TEXT_COLOR_1, true); + + extension.sendMessage("update-theme", { + images: { + theme_frame: BACKGROUND_2, + }, + colors: { + frame: ACCENT_COLOR_2, + tab_background_text: TEXT_COLOR_2, + }, + }); + + await extension.awaitMessage("theme-updated"); + + validateTheme(BACKGROUND_2, ACCENT_COLOR_2, TEXT_COLOR_2, true); + + extension.sendMessage("reset-theme"); + + await extension.awaitMessage("theme-reset"); + + let { color } = rootCS; + let backgroundImage = toolboxCS.backgroundImage; + let backgroundColor = getToolboxBackgroundColor(); + validateTheme(backgroundImage, backgroundColor, color, false); + + await extension.unload(); + + let docEl = window.document.documentElement; + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_experiment.js b/toolkit/components/extensions/test/browser/browser_ext_themes_experiment.js new file mode 100644 index 0000000000..02156b6cd8 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_experiment.js @@ -0,0 +1,450 @@ +"use strict"; + +const { AddonSettings } = ChromeUtils.importESModule( + "resource://gre/modules/addons/AddonSettings.sys.mjs" +); + +// This test checks whether the theme experiments work +add_task(async function test_experiment_static_theme() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + theme: { + colors: { + some_color_property: "#ff00ff", + }, + images: { + some_image_property: "background.jpg", + }, + properties: { + some_random_property: "no-repeat", + }, + }, + theme_experiment: { + colors: { + some_color_property: "--some-color-property", + }, + images: { + some_image_property: "--some-image-property", + }, + properties: { + some_random_property: "--some-random-property", + }, + }, + }, + }); + + const root = window.document.documentElement; + + is( + root.style.getPropertyValue("--some-color-property"), + "", + "Color property should be unset" + ); + is( + root.style.getPropertyValue("--some-image-property"), + "", + "Image property should be unset" + ); + is( + root.style.getPropertyValue("--some-random-property"), + "", + "Generic Property should be unset." + ); + + await extension.startup(); + + const testExperimentApplied = rootEl => { + if (AddonSettings.EXPERIMENTS_ENABLED) { + is( + rootEl.style.getPropertyValue("--some-color-property"), + hexToCSS("#ff00ff"), + "Color property should be parsed and set." + ); + ok( + rootEl.style + .getPropertyValue("--some-image-property") + .startsWith("url("), + "Image property should be parsed." + ); + ok( + rootEl.style + .getPropertyValue("--some-image-property") + .endsWith("background.jpg)"), + "Image property should be set." + ); + is( + rootEl.style.getPropertyValue("--some-random-property"), + "no-repeat", + "Generic Property should be set." + ); + } else { + is( + rootEl.style.getPropertyValue("--some-color-property"), + "", + "Color property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-image-property"), + "", + "Image property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-random-property"), + "", + "Generic Property should be unset." + ); + } + }; + + info("Testing that current window updated with the experiment applied"); + testExperimentApplied(root); + + info("Testing that new window initialized with the experiment applied"); + const newWindow = await BrowserTestUtils.openNewBrowserWindow(); + const newWindowRoot = newWindow.document.documentElement; + testExperimentApplied(newWindowRoot); + + await extension.unload(); + + info("Testing that both windows unapplied the experiment"); + for (const rootEl of [root, newWindowRoot]) { + is( + rootEl.style.getPropertyValue("--some-color-property"), + "", + "Color property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-image-property"), + "", + "Image property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-random-property"), + "", + "Generic Property should be unset." + ); + } + await BrowserTestUtils.closeWindow(newWindow); +}); + +add_task(async function test_experiment_dynamic_theme() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + permissions: ["theme"], + theme_experiment: { + colors: { + some_color_property: "--some-color-property", + }, + images: { + some_image_property: "--some-image-property", + }, + properties: { + some_random_property: "--some-random-property", + }, + }, + }, + background() { + const theme = { + colors: { + some_color_property: "#ff00ff", + }, + images: { + some_image_property: "background.jpg", + }, + properties: { + some_random_property: "no-repeat", + }, + }; + browser.test.onMessage.addListener(msg => { + if (msg === "update-theme") { + browser.theme.update(theme).then(() => { + browser.test.sendMessage("theme-updated"); + }); + } else { + browser.theme.reset().then(() => { + browser.test.sendMessage("theme-reset"); + }); + } + }); + }, + }); + + await extension.startup(); + + const root = window.document.documentElement; + + is( + root.style.getPropertyValue("--some-color-property"), + "", + "Color property should be unset" + ); + is( + root.style.getPropertyValue("--some-image-property"), + "", + "Image property should be unset" + ); + is( + root.style.getPropertyValue("--some-random-property"), + "", + "Generic Property should be unset." + ); + + extension.sendMessage("update-theme"); + await extension.awaitMessage("theme-updated"); + + const testExperimentApplied = rootEl => { + if (AddonSettings.EXPERIMENTS_ENABLED) { + is( + rootEl.style.getPropertyValue("--some-color-property"), + hexToCSS("#ff00ff"), + "Color property should be parsed and set." + ); + ok( + rootEl.style + .getPropertyValue("--some-image-property") + .startsWith("url("), + "Image property should be parsed." + ); + ok( + rootEl.style + .getPropertyValue("--some-image-property") + .endsWith("background.jpg)"), + "Image property should be set." + ); + is( + rootEl.style.getPropertyValue("--some-random-property"), + "no-repeat", + "Generic Property should be set." + ); + } else { + is( + rootEl.style.getPropertyValue("--some-color-property"), + "", + "Color property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-image-property"), + "", + "Image property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-random-property"), + "", + "Generic Property should be unset." + ); + } + }; + testExperimentApplied(root); + + const newWindow = await BrowserTestUtils.openNewBrowserWindow(); + const newWindowRoot = newWindow.document.documentElement; + + testExperimentApplied(newWindowRoot); + + extension.sendMessage("reset-theme"); + await extension.awaitMessage("theme-reset"); + + for (const rootEl of [root, newWindowRoot]) { + is( + rootEl.style.getPropertyValue("--some-color-property"), + "", + "Color property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-image-property"), + "", + "Image property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-random-property"), + "", + "Generic Property should be unset." + ); + } + + extension.sendMessage("update-theme"); + await extension.awaitMessage("theme-updated"); + + testExperimentApplied(root); + testExperimentApplied(newWindowRoot); + + await extension.unload(); + + for (const rootEl of [root, newWindowRoot]) { + is( + rootEl.style.getPropertyValue("--some-color-property"), + "", + "Color property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-image-property"), + "", + "Image property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-random-property"), + "", + "Generic Property should be unset." + ); + } + + await BrowserTestUtils.closeWindow(newWindow); +}); + +add_task(async function test_experiment_stylesheet() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + theme: { + colors: { + menu_button_background: "#ff00ff", + }, + }, + theme_experiment: { + stylesheet: "experiment.css", + colors: { + menu_button_background: "--menu-button-background", + }, + }, + }, + files: { + "experiment.css": `#PanelUI-menu-button { + background-color: var(--menu-button-background); + fill: white; + }`, + }, + }); + + const root = window.document.documentElement; + const menuButton = document.getElementById("PanelUI-menu-button"); + const computedStyle = window.getComputedStyle(menuButton); + const expectedColor = hexToCSS("#ff00ff"); + const expectedFill = hexToCSS("#ffffff"); + + is( + root.style.getPropertyValue("--menu-button-background"), + "", + "Variable should be unset" + ); + isnot( + computedStyle.backgroundColor, + expectedColor, + "Menu button should not have custom background" + ); + isnot( + computedStyle.fill, + expectedFill, + "Menu button should not have stylesheet fill" + ); + + await extension.startup(); + + if (AddonSettings.EXPERIMENTS_ENABLED) { + // Wait for stylesheet load. + await BrowserTestUtils.waitForCondition( + () => computedStyle.fill === expectedFill + ); + + is( + root.style.getPropertyValue("--menu-button-background"), + expectedColor, + "Variable should be parsed and set." + ); + is( + computedStyle.backgroundColor, + expectedColor, + "Menu button should be have correct background" + ); + is( + computedStyle.fill, + expectedFill, + "Menu button should be have correct fill" + ); + } else { + is( + root.style.getPropertyValue("--menu-button-background"), + "", + "Variable should be unset" + ); + isnot( + computedStyle.backgroundColor, + expectedColor, + "Menu button should not have custom background" + ); + isnot( + computedStyle.fill, + expectedFill, + "Menu button should not have stylesheet fill" + ); + } + + await extension.unload(); + + is( + root.style.getPropertyValue("--menu-button-background"), + "", + "Variable should be unset" + ); + isnot( + computedStyle.backgroundColor, + expectedColor, + "Menu button should not have custom background" + ); + isnot( + computedStyle.fill, + expectedFill, + "Menu button should not have stylesheet fill" + ); +}); + +// This test checks whether the theme experiments are allowed for non privileged +// theme installed non-temporarily if AddonSettings.EXPERIMENTS_ENABLED is true. +add_task(async function test_experiment_installed_non_temporarily() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.experiments.enabled", true]], + }); + + if (!AddonSettings.EXPERIMENTS_ENABLED) { + info( + "Skipping test case on build where AddonSettings.EXPERIMENTS_ENABLED is false" + ); + return; + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + some_color_property: "#ff00ff", + }, + }, + theme_experiment: { + colors: { + some_color_property: "--some-color-property", + }, + }, + }, + }); + + const root = window.document.documentElement; + + is( + root.style.getPropertyValue("--some-color-property"), + "", + "Color property should be unset" + ); + + await extension.startup(); + + is( + root.style.getPropertyValue("--some-color-property"), + hexToCSS("#ff00ff"), + "Color property should be parsed and set." + ); + + await extension.unload(); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_findbar.js b/toolkit/components/extensions/test/browser/browser_ext_themes_findbar.js new file mode 100644 index 0000000000..a24c90615b --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_findbar.js @@ -0,0 +1,227 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// the toolbar and toolbar_field properties also theme the findbar. + +function assertHasNoBorders(element) { + let cs = window.getComputedStyle(element); + Assert.equal(cs.borderTopWidth, "0px", "should have no top border"); + Assert.equal(cs.borderRightWidth, "0px", "should have no right border"); + Assert.equal(cs.borderBottomWidth, "0px", "should have no bottom border"); + Assert.equal(cs.borderLeftWidth, "0px", "should have no left border"); +} + +add_task(async function test_support_toolbar_properties_on_findbar() { + const TOOLBAR_COLOR = "#ff00ff"; + const TOOLBAR_TEXT_COLOR = "#9400ff"; + const ACCENT_COLOR_INACTIVE = "#ffff00"; + // The TabContextMenu initializes its strings only on a focus or mouseover event. + // Calls focus event on the TabContextMenu early in the test. + gBrowser.selectedTab.focus(); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + frame_inactive: ACCENT_COLOR_INACTIVE, + tab_background_text: TEXT_COLOR, + toolbar: TOOLBAR_COLOR, + bookmark_text: TOOLBAR_TEXT_COLOR, + }, + }, + }, + }); + + await extension.startup(); + await gBrowser.getFindBar(); + + let findbar_button = gFindBar.getElement("highlight"); + + info("Checking findbar background is set as toolbar color"); + Assert.equal( + window.getComputedStyle(gFindBar).backgroundColor, + hexToCSS(ACCENT_COLOR), + "Findbar background color should be the same as toolbar background color." + ); + + info("Checking findbar and checkbox text color use toolbar text color"); + const rgbColor = hexToCSS(TOOLBAR_TEXT_COLOR); + Assert.equal( + window.getComputedStyle(gFindBar).color, + rgbColor, + "Findbar text color should be the same as toolbar text color." + ); + Assert.equal( + window.getComputedStyle(findbar_button).color, + rgbColor, + "Findbar checkbox text color should be toolbar text color." + ); + + // Open a new window to check frame_inactive + let window2 = await BrowserTestUtils.openNewBrowserWindow(); + Assert.equal( + window.getComputedStyle(gFindBar).backgroundColor, + hexToCSS(ACCENT_COLOR_INACTIVE), + "Findbar background changed in inactive window." + ); + await BrowserTestUtils.closeWindow(window2); + + await extension.unload(); +}); + +add_task(async function test_support_toolbar_field_properties_on_findbar() { + let findbar_prev_button = gFindBar.getElement("find-previous"); + let findbar_next_button = gFindBar.getElement("find-next"); + + assertHasNoBorders(findbar_prev_button); + assertHasNoBorders(findbar_next_button); + + const TOOLBAR_FIELD_COLOR = "#ff00ff"; + const TOOLBAR_FIELD_TEXT_COLOR = "#9400ff"; + const TOOLBAR_FIELD_BORDER_COLOR = "#ffffff"; + // The TabContextMenu initializes its strings only on a focus or mouseover event. + // Calls focus event on the TabContextMenu early in the test. + gBrowser.selectedTab.focus(); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_field: TOOLBAR_FIELD_COLOR, + toolbar_field_text: TOOLBAR_FIELD_TEXT_COLOR, + toolbar_field_border: TOOLBAR_FIELD_BORDER_COLOR, + }, + }, + }, + }); + + await extension.startup(); + await gBrowser.getFindBar(); + + let findbar_textbox = gFindBar.getElement("findbar-textbox"); + + info( + "Checking findbar textbox background is set as toolbar field background color" + ); + Assert.equal( + window.getComputedStyle(findbar_textbox).backgroundColor, + hexToCSS(TOOLBAR_FIELD_COLOR), + "Findbar textbox background color should be the same as toolbar field color." + ); + + info("Checking findbar textbox color is set as toolbar field text color"); + Assert.equal( + window.getComputedStyle(findbar_textbox).color, + hexToCSS(TOOLBAR_FIELD_TEXT_COLOR), + "Findbar textbox text color should be the same as toolbar field text color." + ); + testBorderColor(findbar_textbox, TOOLBAR_FIELD_BORDER_COLOR); + + assertHasNoBorders(findbar_prev_button); + assertHasNoBorders(findbar_next_button); + + await extension.unload(); +}); + +// Test that theme properties are applied with a theme_frame +add_task(async function test_toolbar_properties_on_findbar_with_theme_frame() { + const TOOLBAR_COLOR = "#ff00ff"; + const TOOLBAR_TEXT_COLOR = "#9400ff"; + // The TabContextMenu initializes its strings only on a focus or mouseover event. + // Calls focus event on the TabContextMenu early in the test. + gBrowser.selectedTab.focus(); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar: TOOLBAR_COLOR, + bookmark_text: TOOLBAR_TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + await gBrowser.getFindBar(); + + let findbar_button = gFindBar.getElement("highlight"); + + info("Checking findbar background is set as toolbar color"); + Assert.equal( + window.getComputedStyle(gFindBar).backgroundColor, + hexToCSS(ACCENT_COLOR), + "Findbar background color should be set by theme." + ); + + info("Checking findbar and button text color is set as toolbar text color"); + Assert.equal( + window.getComputedStyle(gFindBar).color, + hexToCSS(TOOLBAR_TEXT_COLOR), + "Findbar text color should be set by theme." + ); + Assert.equal( + window.getComputedStyle(findbar_button).color, + hexToCSS(TOOLBAR_TEXT_COLOR), + "Findbar button text color should be set by theme." + ); + + await extension.unload(); +}); + +add_task( + async function test_toolbar_field_properties_on_findbar_with_theme_frame() { + const TOOLBAR_FIELD_COLOR = "#ff00ff"; + const TOOLBAR_FIELD_TEXT_COLOR = "#9400ff"; + const TOOLBAR_FIELD_BORDER_COLOR = "#ffffff"; + // The TabContextMenu initializes its strings only on a focus or mouseover event. + // Calls focus event on the TabContextMenu early in the test. + gBrowser.selectedTab.focus(); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_field: TOOLBAR_FIELD_COLOR, + toolbar_field_text: TOOLBAR_FIELD_TEXT_COLOR, + toolbar_field_border: TOOLBAR_FIELD_BORDER_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + await gBrowser.getFindBar(); + + let findbar_textbox = gFindBar.getElement("findbar-textbox"); + + Assert.equal( + window.getComputedStyle(findbar_textbox).backgroundColor, + hexToCSS(TOOLBAR_FIELD_COLOR), + "Findbar textbox background color should be set by theme." + ); + + Assert.equal( + window.getComputedStyle(findbar_textbox).color, + hexToCSS(TOOLBAR_FIELD_TEXT_COLOR), + "Findbar textbox text color should be set by theme." + ); + + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_getCurrent_differentExt.js b/toolkit/components/extensions/test/browser/browser_ext_themes_getCurrent_differentExt.js new file mode 100644 index 0000000000..587c5d4efe --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_getCurrent_differentExt.js @@ -0,0 +1,151 @@ +"use strict"; + +// This test checks whether browser.theme.getCurrent() works correctly when theme +// does not originate from extension querying the theme. +const THEME = { + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, +}; + +add_task(async function test_getcurrent() { + const theme = ExtensionTestUtils.loadExtension(THEME); + const extension = ExtensionTestUtils.loadExtension({ + background() { + browser.theme.onUpdated.addListener(() => { + browser.theme.getCurrent().then(theme => { + browser.test.sendMessage("theme-updated", theme); + if (!theme?.images) { + return; + } + + // Try to access the theme_frame image + fetch(theme.images.theme_frame) + .then(() => { + browser.test.sendMessage("theme-image", { success: true }); + }) + .catch(e => { + browser.test.sendMessage("theme-image", { + success: false, + error: e.message, + }); + }); + }); + }); + }, + }); + + await extension.startup(); + + info("Testing getCurrent after static theme startup"); + let updatedPromise = extension.awaitMessage("theme-updated"); + let imageLoaded = extension.awaitMessage("theme-image"); + await theme.startup(); + let receivedTheme = await updatedPromise; + Assert.ok( + receivedTheme.images.theme_frame.includes("image1.png"), + "getCurrent returns correct theme_frame image" + ); + Assert.equal( + receivedTheme.colors.frame, + ACCENT_COLOR, + "getCurrent returns correct frame color" + ); + Assert.equal( + receivedTheme.colors.tab_background_text, + TEXT_COLOR, + "getCurrent returns correct tab_background_text color" + ); + Assert.deepEqual(await imageLoaded, { success: true }, "theme image loaded"); + + info("Testing getCurrent after static theme unload"); + updatedPromise = extension.awaitMessage("theme-updated"); + await theme.unload(); + receivedTheme = await updatedPromise; + Assert.equal( + JSON.stringify({ colors: null, images: null, properties: null }), + JSON.stringify(receivedTheme), + "getCurrent returns empty theme" + ); + + await extension.unload(); +}); + +add_task(async function test_getcurrent_privateBrowsing() { + const theme = ExtensionTestUtils.loadExtension(THEME); + + const extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + // We don't want the sidebar to automatically open on extension startup. + startupReason: "APP_STARTUP", + files: { + "sidebar.html": `<!DOCTYPE html> + <html> + <body> + Test Extension Sidebar + <script src="sidebar.js"></script> + </body> + </html> + `, + "sidebar.js": function () { + browser.theme.getCurrent().then(theme => { + if (!theme?.images) { + browser.test.fail( + `Missing expected images from theme.getCurrent result` + ); + return; + } + + // Try to access the theme_frame image + fetch(theme.images.theme_frame) + .then(() => { + browser.test.sendMessage("theme-image", { success: true }); + }) + .catch(e => { + browser.test.sendMessage("theme-image", { + success: false, + error: e.message, + }); + }); + }); + }, + }, + }); + + await extension.startup(); + await theme.startup(); + + const privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + const { ExtensionCommon } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionCommon.sys.mjs" + ); + const { makeWidgetId } = ExtensionCommon; + privateWin.SidebarUI.show(`${makeWidgetId(extension.id)}-sidebar-action`); + + let imageLoaded = extension.awaitMessage("theme-image"); + Assert.deepEqual(await imageLoaded, { success: true }, "theme image loaded"); + + await extension.unload(); + await theme.unload(); + + await BrowserTestUtils.closeWindow(privateWin); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_highlight.js b/toolkit/components/extensions/test/browser/browser_ext_themes_highlight.js new file mode 100644 index 0000000000..5a0d1c7a8d --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_highlight.js @@ -0,0 +1,63 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// the color of the font and background in a selection are applied properly. +const { CustomizableUITestUtils } = ChromeUtils.importESModule( + "resource://testing-common/CustomizableUITestUtils.sys.mjs" +); +let gCUITestUtils = new CustomizableUITestUtils(window); +add_setup(async function () { + await gCUITestUtils.addSearchBar(); + await gFindBarPromise; + registerCleanupFunction(() => { + gFindBar.close(); + gCUITestUtils.removeSearchBar(); + }); +}); + +add_task(async function test_support_selection() { + const HIGHLIGHT_TEXT_COLOR = "#9400FF"; + const HIGHLIGHT_COLOR = "#F89919"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + toolbar_field_highlight: HIGHLIGHT_COLOR, + toolbar_field_highlight_text: HIGHLIGHT_TEXT_COLOR, + }, + }, + }, + }); + await extension.startup(); + + let fields = [ + gURLBar.inputField, + document.querySelector("#searchbar .searchbar-textbox"), + document.querySelector(".findbar-textbox"), + ].filter(field => { + let bounds = field.getBoundingClientRect(); + return bounds.width > 0 && bounds.height > 0; + }); + + Assert.equal(fields.length, 3, "Should be testing three elements"); + + info( + `Checking background colors and colors for ${fields.length} toolbar input fields.` + ); + for (let field of fields) { + info(`Testing ${field.id || field.className}`); + field.focus(); + Assert.equal( + window.getComputedStyle(field, "::selection").backgroundColor, + hexToCSS(HIGHLIGHT_COLOR), + "Input selection background should be set." + ); + Assert.equal( + window.getComputedStyle(field, "::selection").color, + hexToCSS(HIGHLIGHT_TEXT_COLOR), + "Input selection color should be set." + ); + } + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_incognito.js b/toolkit/components/extensions/test/browser/browser_ext_themes_incognito.js new file mode 100644 index 0000000000..d9beb0f9a8 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_incognito.js @@ -0,0 +1,77 @@ +"use strict"; + +add_task(async function test_theme_incognito_not_allowed() { + let windowExtension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + async background() { + const theme = { + colors: { + frame: "black", + tab_background_text: "black", + }, + }; + let window = await browser.windows.create({ incognito: true }); + browser.test.onMessage.addListener(async message => { + if (message == "update") { + browser.theme.update(window.id, theme); + return; + } + await browser.windows.remove(window.id); + browser.test.sendMessage("done"); + }); + browser.test.sendMessage("ready", window.id); + }, + manifest: { + permissions: ["theme"], + }, + }); + await windowExtension.startup(); + let wId = await windowExtension.awaitMessage("ready"); + + async function background(windowId) { + const theme = { + colors: { + frame: "black", + tab_background_text: "black", + }, + }; + + browser.theme.onUpdated.addListener(info => { + browser.test.log("got theme onChanged"); + browser.test.fail("theme"); + }); + await browser.test.assertRejects( + browser.theme.getCurrent(windowId), + /Invalid window ID/, + "API should reject getting window theme" + ); + await browser.test.assertRejects( + browser.theme.update(windowId, theme), + /Invalid window ID/, + "API should reject updating theme" + ); + await browser.test.assertRejects( + browser.theme.reset(windowId), + /Invalid window ID/, + "API should reject reseting theme on window" + ); + + browser.test.sendMessage("start"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${background})(${wId})`, + manifest: { + permissions: ["theme"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("start"); + windowExtension.sendMessage("update"); + + windowExtension.sendMessage("close"); + await windowExtension.awaitMessage("done"); + await windowExtension.unload(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_lwtsupport.js b/toolkit/components/extensions/test/browser/browser_ext_themes_lwtsupport.js new file mode 100644 index 0000000000..0458558ee4 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_lwtsupport.js @@ -0,0 +1,56 @@ +"use strict"; + +const DEFAULT_THEME_BG_COLOR = "rgb(255, 255, 255)"; +const DEFAULT_THEME_TEXT_COLOR = "rgb(0, 0, 0)"; + +add_task(async function test_deprecated_LWT_properties_ignored() { + // This test uses deprecated theme properties, so warnings are expected. + ExtensionTestUtils.failOnSchemaWarnings(false); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + headerURL: "image1.png", + }, + colors: { + accentcolor: ACCENT_COLOR, + textcolor: TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + let docStyle = window.getComputedStyle(docEl); + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.ok( + !docEl.hasAttribute("lwtheme-image"), + "LWT image attribute should not be set on deprecated headerURL alias" + ); + Assert.ok( + !docEl.getAttribute("lwtheme-brighttext"), + "LWT text color attribute should not be set on deprecated textcolor alias" + ); + + Assert.equal( + getToolboxBackgroundColor(), + DEFAULT_THEME_BG_COLOR, + "Expected default theme background color" + ); + + Assert.equal( + docStyle.color, + DEFAULT_THEME_TEXT_COLOR, + "Expected default theme text color" + ); + + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_multiple_backgrounds.js b/toolkit/components/extensions/test/browser/browser_ext_themes_multiple_backgrounds.js new file mode 100644 index 0000000000..2115d8d23c --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_multiple_backgrounds.js @@ -0,0 +1,202 @@ +"use strict"; + +add_task(async function test_support_backgrounds_position() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "face1.png", + additional_backgrounds: ["face2.png", "face2.png", "face2.png"], + }, + colors: { + frame: `rgb(${FRAME_COLOR.join(",")})`, + tab_background_text: `rgb(${TAB_BACKGROUND_TEXT_COLOR.join(",")})`, + }, + properties: { + additional_backgrounds_alignment: [ + "left top", + "center top", + "right bottom", + ], + }, + }, + }, + files: { + "face1.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + "face2.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + let toolbox = document.querySelector("#navigator-toolbox"); + + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal( + docEl.getAttribute("lwtheme-brighttext"), + "true", + "LWT text color attribute should be set" + ); + + let toolboxCS = window.getComputedStyle(toolbox); + let toolboxBgImage = toolboxCS.backgroundImage.split(",")[0].trim(); + Assert.equal( + toolboxCS.backgroundImage, + [1, 2, 2, 2] + .map(num => toolboxBgImage.replace(/face[\d]*/, `face${num}`)) + .join(", "), + "The backgroundImage should use face1.png once and face2.png three times." + ); + Assert.equal( + toolboxCS.backgroundPosition, + "100% 0%, 0% 0%, 50% 0%, 100% 100%", + "The backgroundPosition should use the three values provided, preceded by the default for theme_frame." + ); + /** + * We expect duplicate background-repeat values because we apply `no-repeat` + * once for theme_frame, and again as the default value of + * --lwt-background-tiling. + */ + Assert.equal( + toolboxCS.backgroundRepeat, + "no-repeat, no-repeat", + "The backgroundPosition should use the default value." + ); + + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); + toolboxCS = window.getComputedStyle(toolbox); + + // Styles should've reverted to their initial values. + Assert.equal(toolboxCS.backgroundImage, "none"); + Assert.equal(toolboxCS.backgroundPosition, "0% 0%"); + Assert.equal(toolboxCS.backgroundRepeat, "repeat"); +}); + +add_task(async function test_support_backgrounds_repeat() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "face0.png", + additional_backgrounds: ["face1.png", "face2.png", "face3.png"], + }, + colors: { + frame: FRAME_COLOR, + tab_background_text: TAB_BACKGROUND_TEXT_COLOR, + }, + properties: { + additional_backgrounds_tiling: ["repeat-x", "repeat-y", "repeat"], + }, + }, + }, + files: { + "face0.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + "face1.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + "face2.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + "face3.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + let toolbox = document.querySelector("#navigator-toolbox"); + + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal( + docEl.getAttribute("lwtheme-brighttext"), + "true", + "LWT text color attribute should be set" + ); + + let toolboxCS = window.getComputedStyle(toolbox); + let toolboxImage = toolboxCS.backgroundImage.split(",")[0].trim(); + Assert.equal( + [0, 1, 2, 3] + .map(num => toolboxImage.replace(/face[\d]*/, `face${num}`)) + .join(", "), + toolboxCS.backgroundImage, + "The backgroundImage should use face.png four times." + ); + /** + * We expect duplicate background-position values because we apply `right top` + * once for theme_frame, and again as the default value of + * --lwt-background-alignment. + */ + Assert.equal( + toolboxCS.backgroundPosition, + "100% 0%, 100% 0%", + "The backgroundPosition should use the default value for navigator-toolbox." + ); + Assert.equal( + toolboxCS.backgroundRepeat, + "no-repeat, repeat-x, repeat-y, repeat", + "The backgroundRepeat should use the three values provided for --lwt-background-tiling, preceeded by the default for theme_frame." + ); + + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); + +add_task(async function test_additional_images_check() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "face.png", + }, + colors: { + frame: FRAME_COLOR, + tab_background_text: TAB_BACKGROUND_TEXT_COLOR, + }, + properties: { + additional_backgrounds_tiling: ["repeat-x", "repeat-y", "repeat"], + }, + }, + }, + files: { + "face.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + let toolbox = document.querySelector("#navigator-toolbox"); + + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal( + docEl.getAttribute("lwtheme-brighttext"), + "true", + "LWT text color attribute should be set" + ); + + let toolboxCS = window.getComputedStyle(toolbox); + let bgImage = toolboxCS.backgroundImage.split(",")[0].trim(); + Assert.ok( + bgImage.includes("face.png"), + `The backgroundImage should use face.png. Actual value is: ${bgImage}` + ); + Assert.ok( + bgImage.includes("face.png"), + `The backgroundImage should use face.png. Actual value is: ${bgImage}` + ); + Assert.equal( + toolboxCS.backgroundPosition, + "100% 0%, 100% 0%", + "The backgroundPosition should use the default value." + ); + Assert.equal( + toolboxCS.backgroundRepeat, + "no-repeat, no-repeat", + "The backgroundRepeat should use the default value." + ); + + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors.js b/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors.js new file mode 100644 index 0000000000..8e2f5446c9 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors.js @@ -0,0 +1,203 @@ +"use strict"; +// This test checks whether the new tab page color properties work. + +function waitForAboutNewTabReady(browser, url) { + // Stop-gap fix for https://bugzilla.mozilla.org/show_bug.cgi?id=1697196#c24 + return SpecialPowers.spawn(browser, [url], async url => { + let doc = content.document; + await ContentTaskUtils.waitForCondition( + () => doc.querySelector(".outer-wrapper"), + `Waiting for page wrapper to be initialized at ${url}` + ); + }); +} + +/** + * Test whether the selected browser has the new tab page theme applied + * + * @param {object} theme that is applied + * @param {boolean} isBrightText whether the brighttext attribute should be set + */ +async function test_ntp_theme(theme, isBrightText) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme, + }, + }); + + let browser = gBrowser.selectedBrowser; + + let { originalBackground, originalCardBackground, originalColor } = + await SpecialPowers.spawn(browser, [], function () { + let doc = content.document; + ok( + !doc.documentElement.hasAttribute("lwt-newtab"), + "New tab page should not have lwt-newtab attribute" + ); + ok( + !doc.documentElement.hasAttribute("lwt-newtab-brighttext"), + `New tab page should not have lwt-newtab-brighttext attribute` + ); + + return { + originalBackground: content.getComputedStyle(doc.body).backgroundColor, + originalCardBackground: content.getComputedStyle( + doc.querySelector(".top-site-outer .tile") + ).backgroundColor, + originalColor: content.getComputedStyle( + doc.querySelector(".outer-wrapper") + ).color, + // We check the value of --newtab-link-primary-color directly because the + // elements on which it is applied are hard to test. It is most visible in + // the "learn more" link in the Pocket section. We cannot show the Pocket + // section since it hits the network, and the usual workarounds to change + // its backend only work in browser/. This variable is also used in + // the Edit Top Site modal, but showing/hiding that is very verbose and + // would make this test almost unreadable. + originalLinks: content + .getComputedStyle(doc.documentElement) + .getPropertyValue("--newtab-link-primary-color"), + }; + }); + + await extension.startup(); + + Services.ppmm.sharedData.flush(); + + await SpecialPowers.spawn( + browser, + [ + { + isBrightText, + background: hexToCSS(theme.colors.ntp_background), + card_background: hexToCSS(theme.colors.ntp_card_background), + color: hexToCSS(theme.colors.ntp_text), + }, + ], + async function ({ isBrightText, background, card_background, color }) { + let doc = content.document; + ok( + doc.documentElement.hasAttribute("lwt-newtab"), + "New tab page should have lwt-newtab attribute" + ); + is( + doc.documentElement.hasAttribute("lwt-newtab-brighttext"), + isBrightText, + `New tab page should${ + !isBrightText ? " not" : "" + } have lwt-newtab-brighttext attribute` + ); + + is( + content.getComputedStyle(doc.body).backgroundColor, + background, + "New tab page background should be set." + ); + is( + content.getComputedStyle(doc.querySelector(".top-site-outer .tile")) + .backgroundColor, + card_background, + "New tab page card background should be set." + ); + is( + content.getComputedStyle(doc.querySelector(".outer-wrapper")).color, + color, + "New tab page text color should be set." + ); + } + ); + + await extension.unload(); + + Services.ppmm.sharedData.flush(); + + await SpecialPowers.spawn( + browser, + [ + { + originalBackground, + originalCardBackground, + originalColor, + }, + ], + function ({ originalBackground, originalCardBackground, originalColor }) { + let doc = content.document; + ok( + !doc.documentElement.hasAttribute("lwt-newtab"), + "New tab page should not have lwt-newtab attribute" + ); + ok( + !doc.documentElement.hasAttribute("lwt-newtab-brighttext"), + `New tab page should not have lwt-newtab-brighttext attribute` + ); + + is( + content.getComputedStyle(doc.body).backgroundColor, + originalBackground, + "New tab page background should be reset." + ); + is( + content.getComputedStyle(doc.querySelector(".top-site-outer .tile")) + .backgroundColor, + originalCardBackground, + "New tab page card background should be reset." + ); + is( + content.getComputedStyle(doc.querySelector(".outer-wrapper")).color, + originalColor, + "New tab page text color should be reset." + ); + } + ); +} + +add_task(async function test_support_ntp_colors() { + await SpecialPowers.pushPrefEnv({ + set: [ + // BrowserTestUtils.withNewTab waits for about:newtab to load + // so we disable preloading before running the test. + ["browser.newtab.preload", false], + // Force prefers-color-scheme to "light", as otherwise it might be + // derived from the theme, but we hard-code the light styles on this + // test. + ["layout.css.prefers-color-scheme.content-override", 1], + // Override the system color scheme to light so this test passes on + // machines with dark system color scheme. + ["ui.systemUsesDarkTheme", 0], + ], + }); + NewTabPagePreloading.removePreloadedBrowser(window); + for (let url of ["about:newtab", "about:home"]) { + info("Opening url: " + url); + await BrowserTestUtils.withNewTab({ gBrowser, url }, async browser => { + await waitForAboutNewTabReady(browser, url); + await test_ntp_theme( + { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + ntp_background: "#add8e6", + ntp_card_background: "#ffffff", + ntp_text: "#00008b", + }, + }, + false, + url + ); + + await test_ntp_theme( + { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + ntp_background: "#00008b", + ntp_card_background: "#000000", + ntp_text: "#add8e6", + }, + }, + true, + url + ); + }); + } +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors_perwindow.js b/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors_perwindow.js new file mode 100644 index 0000000000..9d28cf50c8 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors_perwindow.js @@ -0,0 +1,240 @@ +"use strict"; + +// This test checks whether the new tab page color properties work per-window. + +function waitForAboutNewTabReady(browser, url) { + // Stop-gap fix for https://bugzilla.mozilla.org/show_bug.cgi?id=1697196#c24 + return SpecialPowers.spawn(browser, [url], async url => { + let doc = content.document; + await ContentTaskUtils.waitForCondition( + () => doc.querySelector(".outer-wrapper"), + `Waiting for page wrapper to be initialized at ${url}` + ); + }); +} + +/** + * Test whether a given browser has the new tab page theme applied + * + * @param {object} browser to test against + * @param {object} theme that is applied + * @param {boolean} isBrightText whether the brighttext attribute should be set + * @returns {Promise} The task as a promise + */ +function test_ntp_theme(browser, theme, isBrightText) { + Services.ppmm.sharedData.flush(); + return SpecialPowers.spawn( + browser, + [ + { + isBrightText, + background: hexToCSS(theme.colors.ntp_background), + card_background: hexToCSS(theme.colors.ntp_card_background), + color: hexToCSS(theme.colors.ntp_text), + }, + ], + function ({ isBrightText, background, card_background, color }) { + let doc = content.document; + ok( + doc.documentElement.hasAttribute("lwt-newtab"), + "New tab page should have lwt-newtab attribute" + ); + is( + doc.documentElement.hasAttribute("lwt-newtab-brighttext"), + isBrightText, + `New tab page should${ + !isBrightText ? " not" : "" + } have lwt-newtab-brighttext attribute` + ); + + is( + content.getComputedStyle(doc.body).backgroundColor, + background, + "New tab page background should be set." + ); + is( + content.getComputedStyle(doc.querySelector(".top-site-outer .tile")) + .backgroundColor, + card_background, + "New tab page card background should be set." + ); + is( + content.getComputedStyle(doc.querySelector(".outer-wrapper")).color, + color, + "New tab page text color should be set." + ); + } + ); +} + +/** + * Test whether a given browser has the default theme applied + * + * @param {object} browser to test against + * @param {string} url being tested + * @returns {Promise} The task as a promise + */ +function test_ntp_default_theme(browser, url) { + Services.ppmm.sharedData.flush(); + return SpecialPowers.spawn( + browser, + [ + { + background: hexToCSS("#F9F9FB"), + color: hexToCSS("#15141A"), + }, + ], + function ({ background, color }) { + let doc = content.document; + ok( + !doc.documentElement.hasAttribute("lwt-newtab"), + "New tab page should not have lwt-newtab attribute" + ); + ok( + !doc.documentElement.hasAttribute("lwt-newtab-brighttext"), + `New tab page should not have lwt-newtab-brighttext attribute` + ); + + is( + content.getComputedStyle(doc.body).backgroundColor, + background, + "New tab page background should be reset." + ); + is( + content.getComputedStyle(doc.querySelector(".outer-wrapper")).color, + color, + "New tab page text color should be reset." + ); + } + ); +} + +add_task(async function test_per_window_ntp_theme() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["theme"], + }, + async background() { + function promiseWindowChecked() { + return new Promise(resolve => { + let listener = msg => { + if (msg == "checked-window") { + browser.test.onMessage.removeListener(listener); + resolve(); + } + }; + browser.test.onMessage.addListener(listener); + }); + } + + function removeWindow(winId) { + return new Promise(resolve => { + let listener = removedWinId => { + if (removedWinId == winId) { + browser.windows.onRemoved.removeListener(listener); + resolve(); + } + }; + browser.windows.onRemoved.addListener(listener); + browser.windows.remove(winId); + }); + } + + async function checkWindow(theme, isBrightText, winId) { + let windowChecked = promiseWindowChecked(); + browser.test.sendMessage("check-window", { + theme, + isBrightText, + winId, + }); + await windowChecked; + } + + const darkTextTheme = { + colors: { + frame: "#add8e6", + tab_background_text: "#000", + ntp_background: "#add8e6", + ntp_card_background: "#ff0000", + ntp_text: "#000", + }, + }; + + const brightTextTheme = { + colors: { + frame: "#00008b", + tab_background_text: "#add8e6", + ntp_background: "#00008b", + ntp_card_background: "#00ff00", + ntp_text: "#add8e6", + }, + }; + + let { id: winId } = await browser.windows.getCurrent(); + // We are opening about:blank instead of the default homepage, + // because using the default homepage results in intermittent + // test failures on debug builds due to browser window leaks. + // A side effect of testing on about:blank is that + // test_ntp_default_theme cannot test properties used only on + // about:newtab, like ntp_card_background. + let { id: secondWinId } = await browser.windows.create({ + url: "about:blank", + }); + + browser.test.log("Test that single window update works"); + await browser.theme.update(winId, darkTextTheme); + await checkWindow(darkTextTheme, false, winId); + await checkWindow(null, false, secondWinId); + + browser.test.log("Test that applying different themes on both windows"); + await browser.theme.update(secondWinId, brightTextTheme); + await checkWindow(darkTextTheme, false, winId); + await checkWindow(brightTextTheme, true, secondWinId); + + browser.test.log("Test resetting the theme on one window"); + await browser.theme.reset(winId); + await checkWindow(null, false, winId); + await checkWindow(brightTextTheme, true, secondWinId); + + await removeWindow(secondWinId); + await checkWindow(null, false, winId); + browser.test.notifyPass("perwindow-ntp-theme"); + }, + }); + + extension.onMessage( + "check-window", + async ({ theme, isBrightText, winId }) => { + let win = Services.wm.getOuterWindowWithId(winId); + win.NewTabPagePreloading.removePreloadedBrowser(win); + // These pages were initially chosen because LightweightThemeChild.sys.mjs + // treats them specially. + for (let url of ["about:newtab", "about:home"]) { + info("Opening url: " + url); + await BrowserTestUtils.withNewTab( + { gBrowser: win.gBrowser, url }, + async browser => { + await waitForAboutNewTabReady(browser, url); + if (theme) { + await test_ntp_theme(browser, theme, isBrightText); + } else { + await test_ntp_default_theme(browser, url); + } + } + ); + } + extension.sendMessage("checked-window"); + } + ); + + // BrowserTestUtils.withNewTab waits for about:newtab to load + // so we disable preloading before running the test. + await SpecialPowers.setBoolPref("browser.newtab.preload", false); + registerCleanupFunction(() => { + SpecialPowers.clearUserPref("browser.newtab.preload"); + }); + + await extension.startup(); + await extension.awaitFinish("perwindow-ntp-theme"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_pbm.js b/toolkit/components/extensions/test/browser/browser_ext_themes_pbm.js new file mode 100644 index 0000000000..3b36a256d0 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_pbm.js @@ -0,0 +1,422 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/** + * Tests that we apply dark theme variants to PBM windows where applicable. + */ + +const { BuiltInThemes } = ChromeUtils.importESModule( + "resource:///modules/BuiltInThemes.sys.mjs" +); +const { PromptTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromptTestUtils.sys.mjs" +); + +const LIGHT_THEME_ID = "firefox-compact-light@mozilla.org"; +const DARK_THEME_ID = "firefox-compact-dark@mozilla.org"; + +// This tests opens many chrome windows which is slow on debug builds. +requestLongerTimeout(2); + +async function testIsDark(win, expectDark) { + let mql = win.matchMedia("(prefers-color-scheme: dark)"); + if (mql.matches != expectDark) { + // The color scheme change might not have been processed yet, since that + // happens on a refresh driver tick. + await new Promise(r => mql.addEventListener("change", r, { once: true })); + } + is( + mql.matches, + expectDark, + `Window should${expectDark ? "" : " not"} be dark.` + ); +} + +/** + * Test a window's theme color scheme. + * + * @param {*} options - Test options. + * @param {Window} options.win - Window object to test. + * @param {boolean} options.colorScheme - Whether expected chrome color scheme + * is dark (true) or light (false). + * @param {boolean} options.expectLWTAttributes - Whether the window should + * have the LWT attributes set matching the color scheme. + */ +async function testWindowColorScheme({ win, expectDark, expectLWTAttributes }) { + let docEl = win.document.documentElement; + + await testIsDark(win, expectDark); + + if (expectLWTAttributes) { + ok(docEl.hasAttribute("lwtheme"), "Window should have LWT attribute."); + is( + docEl.getAttribute("lwtheme-brighttext"), + expectDark ? "true" : null, + "LWT text color attribute should be set." + ); + } else { + ok(!docEl.hasAttribute("lwtheme"), "Window should not have LWT attribute."); + ok( + !docEl.hasAttribute("lwtheme-brighttext"), + "LWT text color attribute should not be set." + ); + } +} + +/** + * Match the prefers-color-scheme media query and return the results. + * + * @param {object} options + * @param {Window} options.win - If chrome=true, window to test, otherwise + * parent window of the content window to test. + * @param {boolean} options.chrome - If true the media queries will be matched + * against the supplied chrome window. Otherwise they will be matched against + * the content window. + * @returns {Promise<{light: boolean, dark: boolean}>} - Resolves with an + * object of the media query results. + */ +function getPrefersColorSchemeInfo({ win, chrome = false }) { + let fn = async windowObj => { + // If called in the parent, we use the supplied win object. Otherwise use + // the content window global. + let win = windowObj || content; + + // LookAndFeel updates are async. + await new Promise(resolve => { + win.requestAnimationFrame(() => win.requestAnimationFrame(resolve)); + }); + return { + light: win.matchMedia("(prefers-color-scheme: light)").matches, + dark: win.matchMedia("(prefers-color-scheme: dark)").matches, + }; + }; + + if (chrome) { + return fn(win); + } + + return SpecialPowers.spawn(win.gBrowser.selectedBrowser, [], fn); +} + +add_setup(async function () { + // Set system theme to light to ensure consistency across test machines. + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.theme.dark-private-windows", true], + ["ui.systemUsesDarkTheme", 0], + ], + }); + // Ensure the built-in themes are initialized. + await BuiltInThemes.ensureBuiltInThemes(); + + // The previous test, browser_ext_themes_ntp_colors.js has side effects. + // Switch to a theme, then switch back to the default theme to reach a + // consistent themeData state. Without this, themeData in + // LightWeightConsumer#_update does not contain darkTheme data and PBM windows + // don't get themed correctly. + let lightTheme = await AddonManager.getAddonByID(LIGHT_THEME_ID); + await lightTheme.enable(); + await lightTheme.disable(); +}); + +// For the default theme with light color scheme, private browsing windows +// should be themed dark. +// The PBM window's content should not be themed dark. +add_task(async function test_default_theme_light() { + info("Normal browsing window should not be in dark mode."); + await testWindowColorScheme({ + win: window, + expectDark: false, + expectLWTAttributes: false, + }); + + let windowB = await BrowserTestUtils.openNewBrowserWindow(); + + info("Additional normal browsing window should not be in dark mode."); + await testWindowColorScheme({ + win: windowB, + expectDark: false, + expectLWTAttributes: false, + }); + + let pbmWindowA = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + info("Private browsing window should be in dark mode."); + await testWindowColorScheme({ + win: pbmWindowA, + expectDark: true, + expectLWTAttributes: true, + }); + + let prefersColorScheme = await getPrefersColorSchemeInfo({ win: pbmWindowA }); + ok( + prefersColorScheme.light && !prefersColorScheme.dark, + "Content of dark themed PBM window should still be themed light" + ); + + let pbmWindowB = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + info("Additional private browsing window should be in dark mode."); + await testWindowColorScheme({ + win: pbmWindowB, + expectDark: true, + expectLWTAttributes: true, + }); + + await BrowserTestUtils.closeWindow(windowB); + await BrowserTestUtils.closeWindow(pbmWindowA); + await BrowserTestUtils.closeWindow(pbmWindowB); +}); + +// For the default theme with dark color scheme, normal and private browsing +// windows should be themed dark. +add_task(async function test_default_theme_dark() { + // Set the system theme to dark. The default theme will follow this color + // scheme. + await SpecialPowers.pushPrefEnv({ set: [["ui.systemUsesDarkTheme", 1]] }); + + info("Normal browsing window should be in dark mode."); + await testWindowColorScheme({ + win: window, + expectDark: true, + expectLWTAttributes: false, + }); + + let pbmWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + info("Private browsing window should be in dark mode."); + await testWindowColorScheme({ + win: pbmWindow, + expectDark: true, + expectLWTAttributes: false, + }); + + await BrowserTestUtils.closeWindow(pbmWindow); + + await SpecialPowers.popPrefEnv(); +}); + +// For the light theme both normal and private browsing windows should have a +// bright color scheme applied. +add_task(async function test_light_theme_builtin() { + let lightTheme = await AddonManager.getAddonByID(LIGHT_THEME_ID); + await lightTheme.enable(); + + info("Normal browsing window should not be in dark mode."); + await testWindowColorScheme({ + win: window, + expectDark: false, + expectLWTAttributes: true, + }); + + let pbmWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + info("Private browsing window should not be in dark mode."); + await testWindowColorScheme({ + win: pbmWindow, + expectDark: false, + expectLWTAttributes: true, + }); + + await BrowserTestUtils.closeWindow(pbmWindow); + await lightTheme.disable(); +}); + +// For the dark theme both normal and private browsing should have a dark color +// scheme applied. +add_task(async function test_dark_theme_builtin() { + let darkTheme = await AddonManager.getAddonByID(DARK_THEME_ID); + await darkTheme.enable(); + + info("Normal browsing window should be in dark mode."); + await testWindowColorScheme({ + win: window, + expectDark: true, + expectLWTAttributes: true, + }); + + let pbmWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + info("Private browsing window should be in dark mode."); + await testWindowColorScheme({ + win: pbmWindow, + expectDark: true, + expectLWTAttributes: true, + }); + + await BrowserTestUtils.closeWindow(pbmWindow); + await darkTheme.disable(); +}); + +// When switching between default, light and dark theme the private browsing +// window color scheme should update accordingly. +add_task(async function test_theme_switch_updates_existing_pbm_win() { + let windowB = await BrowserTestUtils.openNewBrowserWindow(); + let pbmWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + info("Normal browsing window should not be in dark mode."); + await testWindowColorScheme({ + win: window, + expectDark: false, + expectLWTAttributes: false, + }); + + info("Private browsing window should be in dark mode."); + await testWindowColorScheme({ + win: pbmWindow, + expectDark: true, + expectLWTAttributes: true, + }); + + info("Enabling light theme."); + let lightTheme = await AddonManager.getAddonByID(LIGHT_THEME_ID); + await lightTheme.enable(); + + info("Normal browsing window should not be in dark mode."); + await testWindowColorScheme({ + win: window, + expectDark: false, + expectLWTAttributes: true, + }); + + info("Private browsing window should not be in dark mode."); + await testWindowColorScheme({ + win: pbmWindow, + expectDark: false, + expectLWTAttributes: true, + }); + + await lightTheme.disable(); + + info("Enabling dark theme."); + let darkTheme = await AddonManager.getAddonByID(DARK_THEME_ID); + await darkTheme.enable(); + + info("Normal browsing window should be in dark mode."); + await testWindowColorScheme({ + win: window, + expectDark: true, + expectLWTAttributes: true, + }); + + info("Private browsing window should be in dark mode."); + await testWindowColorScheme({ + win: pbmWindow, + expectDark: true, + expectLWTAttributes: true, + }); + + await darkTheme.disable(); + + await BrowserTestUtils.closeWindow(windowB); + await BrowserTestUtils.closeWindow(pbmWindow); +}); + +// pageInfo windows should inherit the PBM window dark theme. +add_task(async function test_pbm_dark_page_info() { + for (let isPBM of [false, true]) { + let win = await BrowserTestUtils.openNewBrowserWindow({ + private: isPBM, + }); + let windowTypeStr = isPBM ? "private" : "normal"; + + info(`Opening pageInfo from ${windowTypeStr} browsing.`); + + await BrowserTestUtils.withNewTab( + { gBrowser: win.gBrowser, url: "https://example.com" }, + async () => { + let pageInfo = win.BrowserPageInfo(null, "securityTab"); + await BrowserTestUtils.waitForEvent(pageInfo, "page-info-init"); + + let prefersColorScheme = await getPrefersColorSchemeInfo({ + win: pageInfo, + chrome: true, + }); + if (isPBM) { + ok( + !prefersColorScheme.light && prefersColorScheme.dark, + "pageInfo from private window should be themed dark." + ); + } else { + ok( + prefersColorScheme.light && !prefersColorScheme.dark, + "pageInfo from normal window should be themed light." + ); + } + + pageInfo.close(); + } + ); + + await BrowserTestUtils.closeWindow(win); + } +}); + +// Prompts should inherit the PBM window dark theme. +add_task(async function test_pbm_dark_prompts() { + const { MODAL_TYPE_TAB, MODAL_TYPE_CONTENT } = Services.prompt; + + for (let isPBM of [false, true]) { + let win = await BrowserTestUtils.openNewBrowserWindow({ + private: isPBM, + }); + + // TODO: Once Bug 1751953 has been fixed, we can also test MODAL_TYPE_WINDOW + // here. + for (let modalType of [MODAL_TYPE_TAB, MODAL_TYPE_CONTENT]) { + let windowTypeStr = isPBM ? "private" : "normal"; + let modalTypeStr = modalType == MODAL_TYPE_TAB ? "tab" : "content"; + + info(`Opening ${modalTypeStr} prompt from ${windowTypeStr} browsing.`); + + let openPromise = PromptTestUtils.waitForPrompt( + win.gBrowser.selectedBrowser, + { + modalType, + promptType: "alert", + } + ); + let promptPromise = Services.prompt.asyncAlert( + win.gBrowser.selectedBrowser.browsingContext, + modalType, + "Hello", + "Hello, world!" + ); + + let dialog = await openPromise; + + let prefersColorScheme = await getPrefersColorSchemeInfo({ + win: dialog.ui.prompt, + chrome: true, + }); + + if (isPBM) { + ok( + !prefersColorScheme.light && prefersColorScheme.dark, + "Prompt from private window should be themed dark." + ); + } else { + ok( + prefersColorScheme.light && !prefersColorScheme.dark, + "Prompt from normal window should be themed light." + ); + } + + await PromptTestUtils.handlePrompt(dialog); + await promptPromise; + } + + await BrowserTestUtils.closeWindow(win); + } +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_persistence.js b/toolkit/components/extensions/test/browser/browser_ext_themes_persistence.js new file mode 100644 index 0000000000..b71ff572cc --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_persistence.js @@ -0,0 +1,60 @@ +"use strict"; + +// This test checks whether applied WebExtension themes are persisted and applied +// on newly opened windows. + +add_task(async function test_multiple_windows() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + let toolbox = document.querySelector("#navigator-toolbox"); + let computedStyle = window.getComputedStyle(toolbox); + + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal( + docEl.getAttribute("lwtheme-brighttext"), + "true", + "LWT text color attribute should be set" + ); + Assert.ok( + computedStyle.backgroundImage.includes("image1.png"), + "Expected background image" + ); + + // Now we'll open a new window to see if the theme is also applied there. + let window2 = await BrowserTestUtils.openNewBrowserWindow(); + docEl = window2.document.documentElement; + toolbox = window2.document.querySelector("#navigator-toolbox"); + computedStyle = window.getComputedStyle(toolbox); + + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal( + docEl.getAttribute("lwtheme-brighttext"), + "true", + "LWT text color attribute should be set" + ); + Assert.ok( + computedStyle.backgroundImage.includes("image1.png"), + "Expected background image" + ); + + await BrowserTestUtils.closeWindow(window2); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_reset.js b/toolkit/components/extensions/test/browser/browser_ext_themes_reset.js new file mode 100644 index 0000000000..d8b3b14073 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_reset.js @@ -0,0 +1,112 @@ +"use strict"; + +add_task(async function theme_reset_global_static_theme() { + let global_theme_extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: "#123456", + tab_background_text: "#fedcba", + }, + }, + }, + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["theme"], + }, + async background() { + await browser.theme.reset(); + let theme_after_reset = await browser.theme.getCurrent(); + + browser.test.assertEq( + "#123456", + theme_after_reset.colors.frame, + "Theme from other extension should not be cleared upon reset()" + ); + + let theme = { + colors: { + frame: "#CF723F", + }, + }; + + await browser.theme.update(theme); + await browser.theme.reset(); + let final_reset_theme = await browser.theme.getCurrent(); + + browser.test.assertEq( + JSON.stringify({ colors: null, images: null, properties: null }), + JSON.stringify(final_reset_theme), + "Should reset when extension had replaced the global theme" + ); + browser.test.sendMessage("done"); + }, + }); + await global_theme_extension.startup(); + await extension.startup(); + await extension.awaitMessage("done"); + + await global_theme_extension.unload(); + await extension.unload(); +}); + +add_task(async function theme_reset_by_windowId() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["theme"], + }, + async background() { + let theme = { + colors: { + frame: "#CF723F", + }, + }; + + let { id: winId } = await browser.windows.getCurrent(); + await browser.theme.update(winId, theme); + let update_theme = await browser.theme.getCurrent(winId); + + browser.test.onMessage.addListener(async () => { + let current_theme = await browser.theme.getCurrent(winId); + browser.test.assertEq( + update_theme.colors.frame, + current_theme.colors.frame, + "Should not be reset by a reset(windowId) call from another extension" + ); + + browser.test.sendMessage("done"); + }); + + browser.test.sendMessage("ready", winId); + }, + }); + + let anotherExtension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["theme"], + }, + background() { + browser.test.onMessage.addListener(async winId => { + await browser.theme.reset(winId); + browser.test.sendMessage("done"); + }); + }, + }); + + await extension.startup(); + let winId = await extension.awaitMessage("ready"); + + await anotherExtension.startup(); + + // theme.reset should be ignored if the theme was set by another extension. + anotherExtension.sendMessage(winId); + await anotherExtension.awaitMessage("done"); + + extension.sendMessage(); + await extension.awaitMessage("done"); + + await anotherExtension.unload(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_sanitization.js b/toolkit/components/extensions/test/browser/browser_ext_themes_sanitization.js new file mode 100644 index 0000000000..89ebd3ae68 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_sanitization.js @@ -0,0 +1,174 @@ +"use strict"; + +// This test checks color sanitization in various situations + +add_task(async function test_sanitization_invalid() { + // This test checks that invalid values are sanitized + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + bookmark_text: "ntimsfavoriteblue", + }, + }, + }, + }); + + await extension.startup(); + + let navbar = document.querySelector("#nav-bar"); + Assert.equal( + window.getComputedStyle(navbar).color, + "rgb(0, 0, 0)", + "All invalid values should always compute to black." + ); + + await extension.unload(); +}); + +add_task(async function test_sanitization_css_variables() { + // This test checks that CSS variables are sanitized + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + bookmark_text: "var(--arrowpanel-dimmed)", + }, + }, + }, + }); + + await extension.startup(); + + let navbar = document.querySelector("#nav-bar"); + Assert.equal( + window.getComputedStyle(navbar).color, + "rgb(0, 0, 0)", + "All CSS variables should always compute to black." + ); + + await extension.unload(); +}); + +add_task(async function test_sanitization_important() { + // This test checks that the sanitizer cannot be fooled with !important + let stylesheetAttr = `href="data:text/css,*{color:red!important}" type="text/css"`; + let stylesheet = document.createProcessingInstruction( + "xml-stylesheet", + stylesheetAttr + ); + let load = BrowserTestUtils.waitForEvent(stylesheet, "load"); + document.insertBefore(stylesheet, document.documentElement); + await load; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + bookmark_text: "green", + }, + }, + }, + }); + + await extension.startup(); + + let navbar = document.querySelector("#nav-bar"); + Assert.equal( + window.getComputedStyle(navbar).color, + "rgb(255, 0, 0)", + "Sheet applies" + ); + + stylesheet.remove(); + + Assert.equal( + window.getComputedStyle(navbar).color, + "rgb(0, 128, 0)", + "Shouldn't be able to fool the color sanitizer with !important" + ); + + await extension.unload(); +}); + +add_task(async function test_sanitization_transparent() { + // This test checks whether transparent values are applied properly + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_top_separator: "transparent", + }, + }, + }, + }); + + await extension.startup(); + + let navbar = document.querySelector("#nav-bar"); + Assert.ok( + window.getComputedStyle(navbar).boxShadow.includes("rgba(0, 0, 0, 0)"), + "Top separator should be transparent" + ); + + await extension.unload(); +}); + +add_task(async function test_sanitization_transparent_frame_color() { + // This test checks whether transparent frame color falls back to white. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: "transparent", + tab_background_text: TEXT_COLOR, + }, + }, + }, + }); + + await extension.startup(); + + Assert.equal( + getToolboxBackgroundColor(), + "rgb(255, 255, 255)", + "Accent color should be white" + ); + + await extension.unload(); +}); + +add_task( + async function test_sanitization_transparent_tab_background_text_color() { + // This test checks whether transparent textcolor falls back to black. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: "transparent", + }, + }, + }, + }); + + await extension.startup(); + + let docEl = document.documentElement; + Assert.equal( + window.getComputedStyle(docEl).color, + "rgb(0, 0, 0)", + "Text color should be black" + ); + + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_separators.js b/toolkit/components/extensions/test/browser/browser_ext_themes_separators.js new file mode 100644 index 0000000000..4da4927ccf --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_separators.js @@ -0,0 +1,76 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// the separator colors are applied properly. + +add_task(async function test_support_separator_properties() { + const SEPARATOR_TOP_COLOR = "#ff00ff"; + const SEPARATOR_VERTICAL_COLOR = "#f0000f"; + const SEPARATOR_FIELD_COLOR = "#9400ff"; + const SEPARATOR_BOTTOM_COLOR = "#3366cc"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_top_separator: SEPARATOR_TOP_COLOR, + toolbar_vertical_separator: SEPARATOR_VERTICAL_COLOR, + // This property is deprecated, but left in to check it doesn't + // unexpectedly break the theme installation. + toolbar_field_separator: SEPARATOR_FIELD_COLOR, + toolbar_bottom_separator: SEPARATOR_BOTTOM_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + // Test the deprecated color property. + let deprecatedMessagePromise = new Promise(resolve => { + Services.console.registerListener(function listener(msg) { + if (msg.message.includes("toolbar_field_separator")) { + resolve(); + Services.console.unregisterListener(listener); + } + }); + }); + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + info("Wait for property deprecation message"); + await deprecatedMessagePromise; + + let navbar = document.querySelector("#nav-bar"); + Assert.ok( + window + .getComputedStyle(navbar) + .boxShadow.includes(`rgb(${hexToRGB(SEPARATOR_TOP_COLOR).join(", ")})`), + "Top separator color properly set" + ); + + let panelUIButton = document.querySelector("#PanelUI-button"); + // Bug 1712334: This should test bookmark item toolbar separators instead + Assert.equal( + window + .getComputedStyle(panelUIButton) + .getPropertyValue("border-image-source"), + "none", + "No vertical separator on app menu" + ); + + let toolbox = document.querySelector("#navigator-toolbox"); + Assert.equal( + window.getComputedStyle(toolbox).borderBottomColor, + `rgb(${hexToRGB(SEPARATOR_BOTTOM_COLOR).join(", ")})`, + "Bottom separator color properly set" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_sidebars.js b/toolkit/components/extensions/test/browser/browser_ext_themes_sidebars.js new file mode 100644 index 0000000000..0d2e69716d --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_sidebars.js @@ -0,0 +1,278 @@ +"use strict"; + +// This test checks whether the sidebar color properties work. + +/** + * Test whether the selected browser has the sidebar theme applied + * + * @param {object} theme that is applied + * @param {boolean} isBrightText whether the text color is light + */ +async function test_sidebar_theme(theme, isBrightText) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme, + }, + }); + + const sidebarBox = document.getElementById("sidebar-box"); + const browserRoot = document.documentElement; + const content = SidebarUI.browser.contentWindow; + const root = content.document.documentElement; + + ok( + !browserRoot.hasAttribute("lwt-sidebar"), + "Browser should not have lwt-sidebar attribute" + ); + ok( + !root.hasAttribute("lwt-sidebar"), + "Root should not have lwt-sidebar attribute" + ); + ok( + !browserRoot.hasAttribute("lwt-sidebar-highlight"), + "Browser should not have lwt-sidebar-brighttext attribute" + ); + ok( + !root.hasAttribute("lwt-sidebar-highlight"), + "Sidebar should not have lwt-sidebar-highlight attribute" + ); + + const rootCS = content.getComputedStyle(root); + const originalBackground = rootCS.backgroundColor; + const originalColor = rootCS.color; + + // ::-moz-tree-row(selected, focus) computed style can't be accessed, so we create a fake one. + const highlightCS = { + get backgroundColor() { + // Standardize to rgb like other computed style. + let color = rootCS.getPropertyValue( + "--lwt-sidebar-highlight-background-color" + ); + let [r, g, b] = color + .replace("rgba(", "") + .split(",") + .map(channel => parseInt(channel, 10)); + return `rgb(${r}, ${g}, ${b})`; + }, + + get color() { + let color = rootCS.getPropertyValue("--lwt-sidebar-highlight-text-color"); + let [r, g, b] = color + .replace("rgba(", "") + .split(",") + .map(channel => parseInt(channel, 10)); + return `rgb(${r}, ${g}, ${b})`; + }, + }; + const originalHighlightBackground = highlightCS.backgroundColor; + const originalHighlightColor = highlightCS.color; + + await extension.startup(); + + Services.ppmm.sharedData.flush(); + + const actualBackground = hexToCSS(theme.colors.sidebar) || originalBackground; + const actualColor = hexToCSS(theme.colors.sidebar_text) || originalColor; + const actualHighlightBackground = + hexToCSS(theme.colors.sidebar_highlight) || originalHighlightBackground; + const actualHighlightColor = + hexToCSS(theme.colors.sidebar_highlight_text) || originalHighlightColor; + const isCustomHighlight = !!theme.colors.sidebar_highlight_text; + const isCustomSidebar = !!theme.colors.sidebar_text; + + is( + browserRoot.hasAttribute("lwt-sidebar"), + isCustomSidebar, + `Browser should${!isCustomSidebar ? " not" : ""} have lwt-sidebar attribute` + ); + is( + root.hasAttribute("lwt-sidebar"), + isCustomSidebar, + `Sidebar should${!isCustomSidebar ? " not" : ""} have lwt-sidebar attribute` + ); + if (isCustomSidebar) { + // Quite confusingly, getAttribute() on XUL elements for attributes that + // are not present has different behavior to HTML (empty string vs. null). + is( + root.getAttribute("lwt-sidebar"), + browserRoot.getAttribute("lwt-sidebar"), + `Sidebar lwt-sidebar attribute should match browser` + ); + } + is( + browserRoot.getAttribute("lwt-sidebar") == "dark", + isBrightText, + `Browser should${ + !isBrightText ? " not" : "" + } have lwt-sidebar="dark" attribute` + ); + is( + root.hasAttribute("lwt-sidebar-highlight"), + isCustomHighlight, + `Sidebar should${ + !isCustomHighlight ? " not" : "" + } have lwt-sidebar-highlight attribute` + ); + + if (isCustomSidebar) { + const sidebarBoxCS = window.getComputedStyle(sidebarBox); + is( + sidebarBoxCS.backgroundColor, + actualBackground, + "Sidebar box background should be set." + ); + is( + sidebarBoxCS.color, + actualColor, + "Sidebar box text color should be set." + ); + } + + is( + rootCS.backgroundColor, + actualBackground, + "Sidebar background should be set." + ); + is(rootCS.color, actualColor, "Sidebar text color should be set."); + is( + highlightCS.backgroundColor, + actualHighlightBackground, + "Sidebar highlight background color should be set." + ); + is( + highlightCS.color, + actualHighlightColor, + "Sidebar highlight text color should be set." + ); + + await extension.unload(); + + Services.ppmm.sharedData.flush(); + + ok( + !browserRoot.hasAttribute("lwt-sidebar"), + "Browser should not have lwt-sidebar attribute" + ); + ok( + !root.hasAttribute("lwt-sidebar"), + "Sidebar should not have lwt-sidebar attribute" + ); + ok( + !root.hasAttribute("lwt-sidebar-highlight"), + "Sidebar should not have lwt-sidebar-highlight attribute" + ); + + is( + rootCS.backgroundColor, + originalBackground, + "Sidebar background should be reset." + ); + is(rootCS.color, originalColor, "Sidebar text color should be reset."); + is( + highlightCS.backgroundColor, + originalHighlightBackground, + "Sidebar highlight background color should be reset." + ); + is( + highlightCS.color, + originalHighlightColor, + "Sidebar highlight text color should be reset." + ); +} + +add_task(async function test_support_sidebar_colors() { + for (let command of ["viewBookmarksSidebar", "viewHistorySidebar"]) { + info("Executing command: " + command); + + await SidebarUI.show(command); + + await test_sidebar_theme( + { + colors: { + sidebar: "#fafad2", // lightgoldenrodyellow + sidebar_text: "#2f4f4f", // darkslategrey + }, + }, + false + ); + + await test_sidebar_theme( + { + colors: { + sidebar: "#8b4513", // saddlebrown + sidebar_text: "#ffa07a", // lightsalmon + }, + }, + true + ); + + await test_sidebar_theme( + { + colors: { + sidebar: "#fffafa", // snow + sidebar_text: "#663399", // rebeccapurple + sidebar_highlight: "#7cfc00", // lawngreen + sidebar_highlight_text: "#ffefd5", // papayawhip + }, + }, + false + ); + + await test_sidebar_theme( + { + colors: { + sidebar_highlight: "#a0522d", // sienna + sidebar_highlight_text: "#fff5ee", // seashell + }, + }, + false + ); + } +}); + +add_task(async function test_support_sidebar_border_color() { + const LIGHT_SALMON = "#ffa07a"; + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + sidebar_border: LIGHT_SALMON, + }, + }, + }, + }); + + await extension.startup(); + + const sidebarHeader = document.getElementById("sidebar-header"); + const sidebarHeaderCS = window.getComputedStyle(sidebarHeader); + + is( + sidebarHeaderCS.borderBottomColor, + hexToCSS(LIGHT_SALMON), + "Sidebar header border should be colored properly" + ); + + if (AppConstants.platform !== "linux") { + const sidebarSplitter = document.getElementById("sidebar-splitter"); + const sidebarSplitterCS = window.getComputedStyle(sidebarSplitter); + + is( + sidebarSplitterCS.borderInlineEndColor, + hexToCSS(LIGHT_SALMON), + "Sidebar splitter should be colored properly" + ); + + SidebarUI.reversePosition(); + + is( + sidebarSplitterCS.borderInlineStartColor, + hexToCSS(LIGHT_SALMON), + "Sidebar splitter should be colored properly after switching sides" + ); + + SidebarUI.reversePosition(); + } + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_static_onUpdated.js b/toolkit/components/extensions/test/browser/browser_ext_themes_static_onUpdated.js new file mode 100644 index 0000000000..4a6d9a92f6 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_static_onUpdated.js @@ -0,0 +1,126 @@ +"use strict"; + +// This test checks whether browser.theme.onUpdated works +// when a static theme is applied + +add_task(async function test_on_updated() { + const theme = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + const extension = ExtensionTestUtils.loadExtension({ + background() { + browser.theme.onUpdated.addListener(updateInfo => { + browser.test.sendMessage("theme-updated", updateInfo); + }); + }, + }); + + await extension.startup(); + + info("Testing update event on static theme startup"); + let updatedPromise = extension.awaitMessage("theme-updated"); + await theme.startup(); + const { theme: receivedTheme, windowId } = await updatedPromise; + Assert.ok(!windowId, "No window id in static theme update event"); + Assert.ok( + receivedTheme.images.theme_frame.includes("image1.png"), + "Theme theme_frame image should be applied" + ); + Assert.equal( + receivedTheme.colors.frame, + ACCENT_COLOR, + "Theme frame color should be applied" + ); + Assert.equal( + receivedTheme.colors.tab_background_text, + TEXT_COLOR, + "Theme tab_background_text color should be applied" + ); + + info("Testing update event on static theme unload"); + updatedPromise = extension.awaitMessage("theme-updated"); + await theme.unload(); + const updateInfo = await updatedPromise; + Assert.ok(!windowId, "No window id in static theme update event on unload"); + Assert.equal( + Object.keys(updateInfo.theme), + 0, + "unloading theme sends empty theme in update event" + ); + + await extension.unload(); +}); + +add_task(async function test_on_updated_eventpage() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.eventPages.enabled", true]], + }); + const theme = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: "watcher@mochitest" } }, + background: { persistent: false }, + }, + background() { + browser.theme.onUpdated.addListener(updateInfo => { + browser.test.sendMessage("theme-updated", updateInfo); + }); + }, + }); + + await extension.startup(); + assertPersistentListeners(extension, "theme", "onUpdated", { + primed: false, + }); + + await extension.terminateBackground(); + assertPersistentListeners(extension, "theme", "onUpdated", { + primed: true, + }); + + info("Testing update event on static theme startup"); + let updatedPromise = extension.awaitMessage("theme-updated"); + await theme.startup(); + const { theme: receivedTheme, windowId } = await updatedPromise; + Assert.ok(!windowId, "No window id in static theme update event"); + Assert.ok( + receivedTheme.images.theme_frame.includes("image1.png"), + "Theme theme_frame image should be applied" + ); + await theme.unload(); + await extension.awaitMessage("theme-updated"); + + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_tab_line.js b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_line.js new file mode 100644 index 0000000000..e4bd8cb99b --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_line.js @@ -0,0 +1,39 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// the color of the tab line are applied properly. + +add_task(async function test_support_tab_line() { + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + + const TAB_LINE_COLOR = "#ff0000"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: "#000", + tab_line: TAB_LINE_COLOR, + }, + }, + }, + }); + + await extension.startup(); + + info("Checking selected tab line color"); + let selectedTab = newWin.document.querySelector(".tabbrowser-tab[selected]"); + let tab = selectedTab.querySelector(".tab-background"); + let element = tab; + let property = "outline-color"; + let computedValue = newWin.getComputedStyle(element)[property]; + let expectedColor = `rgb(${hexToRGB(TAB_LINE_COLOR).join(", ")})`; + + Assert.ok( + computedValue.includes(expectedColor), + `Tab line should be displayed in the box shadow of the tab: ${computedValue}` + ); + + await extension.unload(); + await BrowserTestUtils.closeWindow(newWin); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_tab_loading.js b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_loading.js new file mode 100644 index 0000000000..10ce77db11 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_loading.js @@ -0,0 +1,49 @@ +"use strict"; + +add_task(async function test_support_tab_loading_filling() { + const TAB_LOADING_COLOR = "#FF0000"; + + // Make sure we use the animating loading icon + await SpecialPowers.pushPrefEnv({ + set: [["ui.prefersReducedMotion", 0]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: "#000", + toolbar: "#124455", + tab_background_text: "#9400ff", + tab_loading: TAB_LOADING_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + info("Checking selected tab loading indicator colors"); + + let selectedTab = document.querySelector(".tabbrowser-tab[visuallyselected]"); + + selectedTab.setAttribute("busy", "true"); + selectedTab.setAttribute("progress", "true"); + + let throbber = selectedTab.throbber; + Assert.equal( + window.getComputedStyle(throbber, "::before").fill, + `rgb(${hexToRGB(TAB_LOADING_COLOR).join(", ")})`, + "Throbber is filled with theme color" + ); + + selectedTab.removeAttribute("busy"); + selectedTab.removeAttribute("progress"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_tab_selected.js b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_selected.js new file mode 100644 index 0000000000..3dd77ac92c --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_selected.js @@ -0,0 +1,49 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// the background color of selected tab are applied correctly. + +add_task(async function test_tab_background_color_property() { + const TAB_BACKGROUND_COLOR = "#9400ff"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + tab_selected: TAB_BACKGROUND_COLOR, + }, + }, + }, + }); + + await extension.startup(); + + info("Checking selected tab color"); + + let openTab = document.querySelector(".tabbrowser-tab[visuallyselected]"); + let openTabBackground = openTab.querySelector(".tab-background"); + + let selectedTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + let selectedTabBackground = selectedTab.querySelector(".tab-background"); + + let openTabColor = window + .getComputedStyle(openTabBackground) + .getPropertyValue("background-color"); + let selectedTabColor = window + .getComputedStyle(selectedTabBackground) + .getPropertyValue("background-color"); + + Assert.equal( + selectedTabColor, + "rgb(" + hexToRGB(TAB_BACKGROUND_COLOR).join(", ") + ")", + "Selected tab background color should be set." + ); + Assert.notEqual(openTabColor, selectedTabColor); + + gBrowser.removeTab(selectedTab); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_tab_text.js b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_text.js new file mode 100644 index 0000000000..d819f3a5f1 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_text.js @@ -0,0 +1,70 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// the text color of the selected tab are applied properly. + +add_task(async function test_support_tab_text_property_css_color() { + const TAB_TEXT_COLOR = "#9400ff"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + tab_text: TAB_TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + info("Checking selected tab colors"); + let selectedTab = document.querySelector(".tabbrowser-tab[selected]"); + Assert.equal( + window.getComputedStyle(selectedTab).color, + "rgb(" + hexToRGB(TAB_TEXT_COLOR).join(", ") + ")", + "Selected tab text color should be set." + ); + + await extension.unload(); +}); + +add_task(async function test_support_tab_text_chrome_array() { + const TAB_TEXT_COLOR = [148, 0, 255]; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: FRAME_COLOR, + tab_background_text: TAB_BACKGROUND_TEXT_COLOR, + tab_text: TAB_TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + info("Checking selected tab colors"); + let selectedTab = document.querySelector(".tabbrowser-tab[selected]"); + Assert.equal( + window.getComputedStyle(selectedTab).color, + "rgb(" + TAB_TEXT_COLOR.join(", ") + ")", + "Selected tab text color should be set." + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_theme_transition.js b/toolkit/components/extensions/test/browser/browser_ext_themes_theme_transition.js new file mode 100644 index 0000000000..39934200ac --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_theme_transition.js @@ -0,0 +1,48 @@ +"use strict"; + +// This test checks whether the applied theme transition effects are applied +// correctly. + +add_task(async function test_theme_transition_effects() { + const TOOLBAR = "#f27489"; + const TEXT_COLOR = "#000000"; + const TRANSITION_PROPERTY = "background-color"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + tab_background_text: TEXT_COLOR, + toolbar: TOOLBAR, + bookmark_text: TEXT_COLOR, + }, + }, + }, + }); + + await extension.startup(); + + // check transition effect for toolbars + let navbar = document.querySelector("#nav-bar"); + let navbarCS = window.getComputedStyle(navbar); + + Assert.ok( + navbarCS + .getPropertyValue("transition-property") + .includes(TRANSITION_PROPERTY), + "Transition property set for #nav-bar" + ); + + let bookmarksBar = document.querySelector("#PersonalToolbar"); + setToolbarVisibility(bookmarksBar, true, false, true); + let bookmarksBarCS = window.getComputedStyle(bookmarksBar); + + Assert.ok( + bookmarksBarCS + .getPropertyValue("transition-property") + .includes(TRANSITION_PROPERTY), + "Transition property set for #PersonalToolbar" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields.js b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields.js new file mode 100644 index 0000000000..3fd7899cdc --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields.js @@ -0,0 +1,212 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// the background color and the color of the navbar text fields are applied properly. + +const { CustomizableUITestUtils } = ChromeUtils.importESModule( + "resource://testing-common/CustomizableUITestUtils.sys.mjs" +); +let gCUITestUtils = new CustomizableUITestUtils(window); + +add_setup(async function () { + await gCUITestUtils.addSearchBar(); + registerCleanupFunction(() => { + gCUITestUtils.removeSearchBar(); + }); +}); + +add_task(async function test_support_toolbar_field_properties() { + const TOOLBAR_FIELD_BACKGROUND = "#ff00ff"; + const TOOLBAR_FIELD_COLOR = "#00ff00"; + const TOOLBAR_FIELD_BORDER = "#aaaaff"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_field: TOOLBAR_FIELD_BACKGROUND, + toolbar_field_text: TOOLBAR_FIELD_COLOR, + toolbar_field_border: TOOLBAR_FIELD_BORDER, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let root = document.documentElement; + // Remove the `remotecontrol` attribute since it interferes with the urlbar styling. + root.removeAttribute("remotecontrol"); + registerCleanupFunction(() => { + root.setAttribute("remotecontrol", "true"); + }); + + let fields = [ + document.querySelector("#urlbar-background"), + BrowserSearch.searchBar, + ].filter(field => { + let bounds = field.getBoundingClientRect(); + return bounds.width > 0 && bounds.height > 0; + }); + + Assert.equal(fields.length, 2, "Should be testing two elements"); + + info( + `Checking toolbar background colors and colors for ${fields.length} toolbar fields.` + ); + for (let field of fields) { + info(`Testing ${field.id || field.className}`); + Assert.equal( + window.getComputedStyle(field).backgroundColor, + hexToCSS(TOOLBAR_FIELD_BACKGROUND), + "Field background should be set." + ); + Assert.equal( + window.getComputedStyle(field).color, + hexToCSS(TOOLBAR_FIELD_COLOR), + "Field color should be set." + ); + testBorderColor(field, TOOLBAR_FIELD_BORDER); + } + + await extension.unload(); +}); + +add_task(async function test_support_toolbar_field_brighttext() { + let root = document.documentElement; + // Remove the `remotecontrol` attribute since it interferes with the urlbar styling. + root.removeAttribute("remotecontrol"); + registerCleanupFunction(() => { + root.setAttribute("remotecontrol", "true"); + }); + let urlbar = gURLBar.textbox; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_field: "#fff", + toolbar_field_text: "#000", + }, + }, + }, + }); + + await extension.startup(); + + Assert.equal( + window.getComputedStyle(urlbar).color, + hexToCSS("#000000"), + "Color has been set" + ); + Assert.equal( + root.getAttribute("lwt-toolbar-field"), + "light", + "Should be light" + ); + + await extension.unload(); + + extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_field: "#000", + toolbar_field_text: "#fff", + }, + }, + }, + }); + + await extension.startup(); + + Assert.equal( + window.getComputedStyle(urlbar).color, + hexToCSS("#ffffff"), + "Color has been set" + ); + Assert.equal( + root.getAttribute("lwt-toolbar-field"), + "dark", + "Should be dark" + ); + + await extension.unload(); +}); + +// Verifies that we apply the lwt-toolbar-field="dark" attribute when +// toolbar fields are dark text on a dark background. +add_task(async function test_support_toolbar_field_brighttext_dark_on_dark() { + let root = document.documentElement; + // Remove the `remotecontrol` attribute since it interferes with the urlbar styling. + root.removeAttribute("remotecontrol"); + registerCleanupFunction(() => { + root.setAttribute("remotecontrol", "true"); + }); + let urlbar = gURLBar.textbox; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_field: "#000", + toolbar_field_text: "#111111", + }, + }, + }, + }); + + await extension.startup(); + + Assert.equal( + window.getComputedStyle(urlbar).color, + hexToCSS("#111111"), + "Color has been set" + ); + Assert.equal( + root.getAttribute("lwt-toolbar-field"), + "dark", + "toolbar-field color-scheme should be dark" + ); + + await extension.unload(); +}); + +add_task(async function test_no_explicit_toolbar_field_on_dark_toolbar() { + let root = document.documentElement; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: "#000", + tab_background_text: "#fff", + // Explicitly unset toolbar fields, but they default to light. + }, + }, + }, + }); + + await extension.startup(); + + Assert.equal( + root.getAttribute("lwt-toolbar-field"), + "light", + "toolbar-field color-scheme should be set and light" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields_focus.js b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields_focus.js new file mode 100644 index 0000000000..ff6af3ade7 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields_focus.js @@ -0,0 +1,107 @@ +"use strict"; + +add_setup(async function () { + // Remove the `remotecontrol` attribute since it interferes with the urlbar styling. + document.documentElement.removeAttribute("remotecontrol"); + registerCleanupFunction(() => { + document.documentElement.setAttribute("remotecontrol", "true"); + }); +}); + +add_task(async function test_toolbar_field_focus() { + const TOOLBAR_FIELD_BACKGROUND = "#FF00FF"; + const TOOLBAR_FIELD_COLOR = "#00FF00"; + const TOOLBAR_FOCUS_BACKGROUND = "#FF0000"; + const TOOLBAR_FOCUS_TEXT = "#9400FF"; + const TOOLBAR_FOCUS_BORDER = "#FFFFFF"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: "#FF0000", + tab_background_color: "#ffffff", + toolbar_field: TOOLBAR_FIELD_BACKGROUND, + toolbar_field_text: TOOLBAR_FIELD_COLOR, + toolbar_field_focus: TOOLBAR_FOCUS_BACKGROUND, + toolbar_field_text_focus: TOOLBAR_FOCUS_TEXT, + toolbar_field_border_focus: TOOLBAR_FOCUS_BORDER, + }, + }, + }, + }); + + await extension.startup(); + + info("Checking toolbar field's focus color"); + + let urlBar = document.querySelector("#urlbar-background"); + gURLBar.textbox.setAttribute("focused", "true"); + let style = window.getComputedStyle(urlBar); + + Assert.equal( + style.backgroundColor, + `rgb(${hexToRGB(TOOLBAR_FOCUS_BACKGROUND).join(", ")})`, + "Background Color is changed" + ); + Assert.equal( + style.color, + `rgb(${hexToRGB(TOOLBAR_FOCUS_TEXT).join(", ")})`, + "Text Color is changed" + ); + Assert.equal( + style.outlineColor, + `rgb(${hexToRGB(TOOLBAR_FOCUS_BORDER).join(", ")})`, + "Focus ring color" + ); + + gURLBar.textbox.removeAttribute("focused"); + + Assert.equal( + style.backgroundColor, + `rgb(${hexToRGB(TOOLBAR_FIELD_BACKGROUND).join(", ")})`, + "Background Color is set back to initial" + ); + Assert.equal( + style.color, + `rgb(${hexToRGB(TOOLBAR_FIELD_COLOR).join(", ")})`, + "Text Color is set back to initial" + ); + await extension.unload(); +}); + +add_task(async function test_toolbar_field_focus_low_alpha() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: "#FF0000", + tab_background_color: "#ffffff", + toolbar_field: "#FF00FF", + toolbar_field_text: "#00FF00", + toolbar_field_focus: "rgba(0, 0, 255, 0.4)", + toolbar_field_text_focus: "red", + toolbar_field_border_focus: "#FFFFFF", + }, + }, + }, + }); + + await extension.startup(); + gURLBar.textbox.setAttribute("focused", "true"); + + let urlBar = document.querySelector("#urlbar-background"); + Assert.equal( + window.getComputedStyle(urlBar).backgroundColor, + `rgba(0, 0, 255, 0.9)`, + "Background color has minimum opacity enforced" + ); + Assert.equal( + window.getComputedStyle(urlBar).color, + `rgb(255, 255, 255)`, + "Text color has been overridden to match background" + ); + + gURLBar.textbox.removeAttribute("focused"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_colors.js b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_colors.js new file mode 100644 index 0000000000..37c082b36f --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_colors.js @@ -0,0 +1,63 @@ +"use strict"; + +/* globals InspectorUtils */ + +// This test checks whether applied WebExtension themes that attempt to change +// the button background color properties are applied correctly. + +add_task(async function setup_home_button() { + CustomizableUI.addWidgetToArea("home-button", "nav-bar"); + registerCleanupFunction(() => + CustomizableUI.removeWidgetFromArea("home-button") + ); +}); + +add_task(async function test_button_background_properties() { + const BUTTON_BACKGROUND_ACTIVE = "#FFFFFF"; + const BUTTON_BACKGROUND_HOVER = "#59CBE8"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + button_background_active: BUTTON_BACKGROUND_ACTIVE, + button_background_hover: BUTTON_BACKGROUND_HOVER, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let toolbarButton = document.querySelector("#home-button"); + let toolbarButtonIcon = toolbarButton.icon; + let toolbarButtonIconCS = window.getComputedStyle(toolbarButtonIcon); + + InspectorUtils.addPseudoClassLock(toolbarButton, ":hover"); + + Assert.equal( + toolbarButtonIconCS.getPropertyValue("background-color"), + `rgb(${hexToRGB(BUTTON_BACKGROUND_HOVER).join(", ")})`, + "Toolbar button hover background is set." + ); + + InspectorUtils.addPseudoClassLock(toolbarButton, ":active"); + + Assert.equal( + toolbarButtonIconCS.getPropertyValue("background-color"), + `rgb(${hexToRGB(BUTTON_BACKGROUND_ACTIVE).join(", ")})`, + "Toolbar button active background is set!" + ); + + InspectorUtils.clearPseudoClassLocks(toolbarButton); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_icons.js b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_icons.js new file mode 100644 index 0000000000..2802c6ac33 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_icons.js @@ -0,0 +1,109 @@ +"use strict"; + +// This test checks applied WebExtension themes that attempt to change +// icon color properties + +add_task(async function setup_home_button() { + CustomizableUI.addWidgetToArea("home-button", "nav-bar"); + registerCleanupFunction(() => + CustomizableUI.removeWidgetFromArea("home-button") + ); +}); + +add_task(async function test_icons_properties() { + const ICONS_COLOR = "#001b47"; + const ICONS_ATTENTION_COLOR = "#44ba77"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + icons: ICONS_COLOR, + icons_attention: ICONS_ATTENTION_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let toolbarbutton = document.querySelector("#home-button"); + Assert.equal( + window.getComputedStyle(toolbarbutton).getPropertyValue("fill"), + `rgb(${hexToRGB(ICONS_COLOR).join(", ")})`, + "Buttons fill color set!" + ); + + let starButton = document.querySelector("#star-button"); + starButton.setAttribute("starred", "true"); + + let starComputedStyle = window.getComputedStyle(starButton); + Assert.equal( + starComputedStyle.getPropertyValue( + "--lwt-toolbarbutton-icon-fill-attention" + ), + `rgb(${hexToRGB(ICONS_ATTENTION_COLOR).join(", ")})`, + "Variable is properly set" + ); + Assert.equal( + starComputedStyle.getPropertyValue("fill"), + `rgb(${hexToRGB(ICONS_ATTENTION_COLOR).join(", ")})`, + "Starred icon fill is properly set" + ); + + starButton.removeAttribute("starred"); + + await extension.unload(); +}); + +add_task(async function test_no_icons_properties() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let toolbarbutton = document.querySelector("#home-button"); + let toolbarbuttonCS = window.getComputedStyle(toolbarbutton); + let currentColor = toolbarbuttonCS.getPropertyValue("color"); + Assert.equal( + window.getComputedStyle(toolbarbutton).getPropertyValue("fill"), + currentColor, + "Button fill color should be currentColor when no icon color specified." + ); + + let starButton = document.querySelector("#star-button"); + starButton.setAttribute("starred", "true"); + let starComputedStyle = window.getComputedStyle(starButton); + Assert.equal( + starComputedStyle.getPropertyValue( + "--lwt-toolbarbutton-icon-fill-attention" + ), + "", + "Icon attention fill should not be set when the value is not specified in the manifest." + ); + starButton.removeAttribute("starred"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_toolbars.js b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbars.js new file mode 100644 index 0000000000..ee31d80888 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbars.js @@ -0,0 +1,105 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// the background color of toolbars are applied properly. + +add_task(async function test_support_toolbar_property() { + const TOOLBAR_COLOR = "#ff00ff"; + const TOOLBAR_TEXT_COLOR = "#9400ff"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar: TOOLBAR_COLOR, + toolbar_text: TOOLBAR_TEXT_COLOR, + }, + }, + }, + }); + + let toolbox = document.querySelector("#navigator-toolbox"); + let toolbars = [ + ...toolbox.querySelectorAll("toolbar:not(#TabsToolbar)"), + ].filter(toolbar => { + let bounds = toolbar.getBoundingClientRect(); + return bounds.width > 0 && bounds.height > 0; + }); + + let transitionPromise = waitForTransition(toolbars[0], "background-color"); + await extension.startup(); + await transitionPromise; + + info(`Checking toolbar colors for ${toolbars.length} toolbars.`); + for (let toolbar of toolbars) { + info(`Testing ${toolbar.id}`); + Assert.equal( + window.getComputedStyle(toolbar).backgroundColor, + hexToCSS(TOOLBAR_COLOR), + "Toolbar background color should be set." + ); + Assert.equal( + window.getComputedStyle(toolbar).color, + hexToCSS(TOOLBAR_TEXT_COLOR), + "Toolbar text color should be set." + ); + } + + info("Checking selected tab colors"); + let selectedTab = document.querySelector(".tabbrowser-tab[selected]"); + Assert.equal( + window.getComputedStyle(selectedTab).color, + hexToCSS(TOOLBAR_TEXT_COLOR), + "Selected tab text color should be set." + ); + + await extension.unload(); +}); + +add_task(async function test_bookmark_text_property() { + const TOOLBAR_COLOR = [255, 0, 255]; + const TOOLBAR_TEXT_COLOR = [48, 0, 255]; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar: TOOLBAR_COLOR, + bookmark_text: TOOLBAR_TEXT_COLOR, + }, + }, + }, + }); + + await extension.startup(); + + let toolbox = document.querySelector("#navigator-toolbox"); + let toolbars = [ + ...toolbox.querySelectorAll("toolbar:not(#TabsToolbar)"), + ].filter(toolbar => { + let bounds = toolbar.getBoundingClientRect(); + return bounds.width > 0 && bounds.height > 0; + }); + + info(`Checking toolbar colors for ${toolbars.length} toolbars.`); + for (let toolbar of toolbars) { + info(`Testing ${toolbar.id}`); + Assert.equal( + window.getComputedStyle(toolbar).color, + rgbToCSS(TOOLBAR_TEXT_COLOR), + "bookmark_text should be an alias for toolbar_text" + ); + } + + info("Checking selected tab colors"); + let selectedTab = document.querySelector(".tabbrowser-tab[selected]"); + Assert.equal( + window.getComputedStyle(selectedTab).color, + rgbToCSS(TOOLBAR_TEXT_COLOR), + "Selected tab text color should be set." + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_warnings.js b/toolkit/components/extensions/test/browser/browser_ext_themes_warnings.js new file mode 100644 index 0000000000..025a4073dd --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_warnings.js @@ -0,0 +1,144 @@ +"use strict"; + +const { AddonSettings } = ChromeUtils.importESModule( + "resource://gre/modules/addons/AddonSettings.sys.mjs" +); + +// This test checks that theme warnings are properly emitted. + +function waitForConsole(task, message) { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async resolve => { + SimpleTest.monitorConsole(resolve, [ + { + message: new RegExp(message), + }, + ]); + await task(); + SimpleTest.endMonitorConsole(); + }); +} + +add_setup(async function () { + SimpleTest.waitForExplicitFinish(); +}); + +add_task(async function test_static_theme() { + for (const property of ["colors", "images", "properties"]) { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + [property]: { + such_property: "much_wow", + }, + }, + }, + }); + await waitForConsole( + extension.startup, + `Unrecognized theme property found: ${property}.such_property` + ); + await extension.unload(); + } +}); + +add_task(async function test_dynamic_theme() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["theme"], + }, + background() { + browser.test.onMessage.addListener((msg, details) => { + if (msg === "update-theme") { + browser.theme.update(details).then(() => { + browser.test.sendMessage("theme-updated"); + }); + } else { + browser.theme.reset().then(() => { + browser.test.sendMessage("theme-reset"); + }); + } + }); + }, + }); + + await extension.startup(); + + for (const property of ["colors", "images", "properties"]) { + extension.sendMessage("update-theme", { + [property]: { + such_property: "much_wow", + }, + }); + await waitForConsole( + () => extension.awaitMessage("theme-updated"), + `Unrecognized theme property found: ${property}.such_property` + ); + } + + await extension.unload(); +}); + +add_task(async function test_experiments_enabled() { + info("Testing that experiments are handled correctly on nightly and deved"); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + theme: { + properties: { + such_property: "much_wow", + unknown_property: "very_unknown", + }, + }, + theme_experiment: { + properties: { + such_property: "--such-property", + }, + }, + }, + }); + if (!AddonSettings.EXPERIMENTS_ENABLED) { + await waitForConsole( + extension.startup, + "This extension is not allowed to run theme experiments" + ); + } else { + await waitForConsole( + extension.startup, + "Unrecognized theme property found: properties.unknown_property" + ); + } + await extension.unload(); +}); + +add_task(async function test_experiments_disabled() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.experiments.enabled", false]], + }); + + info( + "Testing that experiments are handled correctly when experiements pref is disabled" + ); + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + properties: { + such_property: "much_wow", + }, + }, + theme_experiment: { + properties: { + such_property: "--such-property", + }, + }, + }, + }); + await waitForConsole( + extension.startup, + "This extension is not allowed to run theme experiments" + ); + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_thumbnails_bg_extension.js b/toolkit/components/extensions/test/browser/browser_ext_thumbnails_bg_extension.js new file mode 100644 index 0000000000..96a2216067 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_thumbnails_bg_extension.js @@ -0,0 +1,94 @@ +"use strict"; + +/* import-globals-from ../../../thumbnails/test/head.js */ +loadTestSubscript("../../../thumbnails/test/head.js"); + +// The service that creates thumbnails of webpages in the background loads a +// web page in the background (with several features disabled). Extensions +// should be able to observe requests, but not run content scripts. +add_task(async function test_thumbnails_background_visibility_to_extensions() { + const iframeUrl = "http://example.com/?iframe"; + const testPageUrl = bgTestPageURL({ iframe: iframeUrl }); + // ^ testPageUrl is http://mochi.test:8888/.../thumbnails_background.sjs?... + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + // ":8888" omitted due to bug 1362809. + matches: [ + "http://mochi.test/*/thumbnails_background.sjs*", + "http://example.com/?iframe*", + ], + js: ["contentscript.js"], + run_at: "document_start", + all_frames: true, + }, + ], + permissions: [ + "webRequest", + "webRequestBlocking", + "http://example.com/*", + "http://mochi.test/*", + ], + }, + files: { + "contentscript.js": () => { + // Content scripts are not expected to be run in the page of the + // thumbnail service, so this should never execute. + new Image().src = "http://example.com/?unexpected-content-script"; + browser.test.fail("Content script ran in thumbs, unexpectedly."); + }, + }, + background() { + let requests = []; + browser.webRequest.onBeforeRequest.addListener( + ({ url, tabId, frameId, type }) => { + browser.test.assertEq(-1, tabId, "Thumb page is not a tab"); + // We want to know if frameId is 0 or non-negative (or possibly -1). + if (type === "sub_frame") { + browser.test.assertTrue(frameId > 0, `frame ${frameId} for ${url}`); + } else { + browser.test.assertEq(0, frameId, `frameId for ${type} ${url}`); + } + requests.push({ type, url }); + }, + { + types: ["main_frame", "sub_frame", "image"], + urls: ["*://*/*"], + }, + ["blocking"] + ); + browser.test.onMessage.addListener(msg => { + browser.test.assertEq("get-results", msg, "expected message"); + browser.test.sendMessage("webRequest-results", requests); + }); + }, + }); + + await extension.startup(); + + ok(!thumbnailExists(testPageUrl), "Thumbnail should not be cached yet."); + + await bgCapture(testPageUrl); + ok(thumbnailExists(testPageUrl), "Thumbnail should be cached after capture"); + removeThumbnail(testPageUrl); + + extension.sendMessage("get-results"); + Assert.deepEqual( + await extension.awaitMessage("webRequest-results"), + [ + { + type: "main_frame", + url: testPageUrl, + }, + { + type: "sub_frame", + url: iframeUrl, + }, + ], + "Expected requests via webRequest" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_webNavigation_eventpage.js b/toolkit/components/extensions/test/browser/browser_ext_webNavigation_eventpage.js new file mode 100644 index 0000000000..d898cb96a4 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_webNavigation_eventpage.js @@ -0,0 +1,72 @@ +"use strict"; + +add_task(async function webnav_test_eventpage() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.eventPages.enabled", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webNavigation", "*://mochi.test/*"], + background: { persistent: false }, + }, + background() { + const EVENTS = [ + "onTabReplaced", + "onBeforeNavigate", + "onCommitted", + "onDOMContentLoaded", + "onCompleted", + "onErrorOccurred", + "onReferenceFragmentUpdated", + "onHistoryStateUpdated", + ]; + + for (let event of EVENTS) { + browser.webNavigation[event].addListener(() => {}); + } + browser.test.sendMessage("ready"); + }, + }); + + // onTabReplaced is never persisted, it is an empty event handler. + const EVENTS = [ + "onBeforeNavigate", + "onCommitted", + "onDOMContentLoaded", + "onCompleted", + "onErrorOccurred", + "onReferenceFragmentUpdated", + "onHistoryStateUpdated", + ]; + + await extension.startup(); + await extension.awaitMessage("ready"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "webNavigation", event, { + primed: false, + }); + } + + await extension.terminateBackground(); + for (let event of EVENTS) { + assertPersistentListeners(extension, "webNavigation", event, { + primed: true, + }); + } + + // wake up the background, we don't really care which event does it, + // we're just verifying the state after. + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + await extension.awaitMessage("ready"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "webNavigation", event, { + primed: false, + }); + } + + await BrowserTestUtils.closeWindow(newWin); + + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_webRequest_redirect_mozextension.js b/toolkit/components/extensions/test/browser/browser_ext_webRequest_redirect_mozextension.js new file mode 100644 index 0000000000..674a10a5ef --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_webRequest_redirect_mozextension.js @@ -0,0 +1,48 @@ +"use strict"; + +// See: https://bugzilla.mozilla.org/show_bug.cgi?id=1573456 +add_task(async function test_mozextension_page_loaded_in_extension_process() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "https://example.com/*", + ], + web_accessible_resources: ["test.html"], + }, + files: { + "test.html": '<!DOCTYPE html><script src="test.js"></script>', + "test.js": () => { + browser.test.assertTrue( + browser.webRequest, + "webRequest API should be available" + ); + + browser.test.sendMessage("test_done"); + }, + }, + background: () => { + browser.webRequest.onBeforeRequest.addListener( + () => { + return { + redirectUrl: browser.runtime.getURL("test.html"), + }; + }, + { urls: ["*://*/redir"] }, + ["blocking"] + ); + }, + }); + await extension.startup(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/redir" + ); + + await extension.awaitMessage("test_done"); + + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_windows_popup_title.js b/toolkit/components/extensions/test/browser/browser_ext_windows_popup_title.js new file mode 100644 index 0000000000..666d4f324f --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_windows_popup_title.js @@ -0,0 +1,133 @@ +"use strict"; + +// Check that extension popup windows contain the name of the extension +// as well as the title of the loaded document, but not the URL. +add_task(async function test_popup_title() { + const name = "custom_title_number_9_please"; + const docTitle = "popup-test-title"; + + const extensionWithImplicitHostPermission = ExtensionTestUtils.loadExtension({ + manifest: { + name, + }, + async background() { + let popup; + + // Called after the popup loads + browser.runtime.onMessage.addListener(async ({ docTitle }) => { + const name = browser.runtime.getManifest().name; + const { id } = await popup; + const { title } = await browser.windows.get(id); + + browser.test.assertTrue( + title.includes(name), + "popup title must include extension name" + ); + browser.test.assertTrue( + title.includes(docTitle), + "popup title must include extension document title" + ); + browser.test.assertFalse( + title.includes("moz-extension:"), + "popup title must not include extension URL" + ); + + // share window data with other extensions + browser.test.sendMessage("windowData", { + id: id, + fullTitle: title, + }); + + browser.test.onMessage.addListener(async message => { + if (message === "cleanup") { + await browser.windows.remove(id); + browser.test.sendMessage("finishedCleanup"); + } + }); + + browser.test.sendMessage("done"); + }); + + popup = browser.windows.create({ + url: "/index.html", + type: "popup", + }); + }, + files: { + "index.html": `<!doctype html> + <meta charset="utf-8"> + <title>${docTitle}</title>, + <script src="index.js"></script> + `, + "index.js": `addEventListener( + "load", + () => browser.runtime.sendMessage({docTitle: document.title}) + );`, + }, + }); + + const extensionWithoutPermissions = ExtensionTestUtils.loadExtension({ + async background() { + const { id } = await new Promise(resolve => { + browser.test.onMessage.addListener(message => { + resolve(message); + }); + }); + + const { title } = await browser.windows.get(id); + + browser.test.assertEq( + title, + undefined, + "popup window must not include title" + ); + + browser.test.sendMessage("done"); + }, + }); + + const extensionWithTabsPermission = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + async background() { + const { id, fullTitle } = await new Promise(resolve => { + browser.test.onMessage.addListener(message => { + resolve(message); + }); + }); + + const { title } = await browser.windows.get(id); + + browser.test.assertEq( + title, + fullTitle, + "popup title equals expected title" + ); + + browser.test.sendMessage("done"); + }, + }); + + await extensionWithoutPermissions.startup(); + await extensionWithTabsPermission.startup(); + await extensionWithImplicitHostPermission.startup(); + + const windowData = await extensionWithImplicitHostPermission.awaitMessage( + "windowData" + ); + + extensionWithoutPermissions.sendMessage(windowData); + extensionWithTabsPermission.sendMessage(windowData); + + await extensionWithoutPermissions.awaitMessage("done"); + await extensionWithTabsPermission.awaitMessage("done"); + await extensionWithImplicitHostPermission.awaitMessage("done"); + + extensionWithImplicitHostPermission.sendMessage("cleanup"); + await extensionWithImplicitHostPermission.awaitMessage("finishedCleanup"); + + await extensionWithoutPermissions.unload(); + await extensionWithTabsPermission.unload(); + await extensionWithImplicitHostPermission.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/data/test-download.txt b/toolkit/components/extensions/test/browser/data/test-download.txt new file mode 100644 index 0000000000..f416e0e291 --- /dev/null +++ b/toolkit/components/extensions/test/browser/data/test-download.txt @@ -0,0 +1 @@ +test download content diff --git a/toolkit/components/extensions/test/browser/data/test_downloads_referrer.html b/toolkit/components/extensions/test/browser/data/test_downloads_referrer.html new file mode 100644 index 0000000000..85410abfcd --- /dev/null +++ b/toolkit/components/extensions/test/browser/data/test_downloads_referrer.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <title>Test downloads referrer</title> + </head> + <body> + <a href="test-download.txt" class="test-link">test link</a> + </body> +</html> diff --git a/toolkit/components/extensions/test/browser/head.js b/toolkit/components/extensions/test/browser/head.js new file mode 100644 index 0000000000..fc17bd5a51 --- /dev/null +++ b/toolkit/components/extensions/test/browser/head.js @@ -0,0 +1,115 @@ +/* exported ACCENT_COLOR, BACKGROUND, ENCODED_IMAGE_DATA, FRAME_COLOR, TAB_TEXT_COLOR, + TEXT_COLOR, TAB_BACKGROUND_TEXT_COLOR, imageBufferFromDataURI, hexToCSS, hexToRGB, testBorderColor, + waitForTransition, loadTestSubscript, assertPersistentListeners, getToolboxBackgroundColor */ + +"use strict"; + +const BACKGROUND = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0" + + "DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; +const ENCODED_IMAGE_DATA = + "iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAgY0h" + + "STQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAAdhwAAHYcBj+XxZQAAB5dJREFUSMd" + + "91vmTlEcZB/Bvd7/vO+/ce83O3gfLDUsC4VgIghBUEo2GM9GCFTaQBEISA1qIEVNQ4aggJDGIgAGTlFUKKcqKQpVHaQyny7FrCMiywp4ze+/Mzs67M/P" + + "O+3a3v5jdWo32H/B86vv0U083weecV3+0C8lkEh6PhzS3tuLkieMSAKo3fW9Mb1eoUtM0jemerukLllzrbGlKheovUpeqkmt113hPfx/27tyFF7+/bbg" + + "e+U9g20s7kEwmMXXGNLrp2fWi4V5z/tFjJ3fWX726INbfU2xx0yelkJAKdJf3Xl5+2QcPTpv2U0JZR+u92+xvly5ygKDm20/hlX17/jvB6VNnIKXEOyd" + + "O0iFh4PLVy0XV1U83Vk54QI7JK+bl+UE5vjRfTCzJ5eWBTFEayBLjisvljKmzwmtWrVkEAPNmVrEZkyfh+fU1n59k//7X4Fbz8MK2DRSAWLNq/Yc36y9" + + "+3UVMsyAYVPMy/MTvdBKvriJhphDq6xa9vf0i1GMwPVhM5s9bsLw/EvtN2kywwnw/nzBuLDZs2z4auXGjHuvWbmBQdT5v7qytn165fLCyyGtXTR6j5GV" + + "kIsvlBCwTVNgQhMKCRDQ2iIbmJv7BpU+Ykl02UFOzdt6gkbzTEQ5Rl2KL3W8eGUE+/ssFXK+rJQ8vWigLgjk5z9ZsvpOniJzVi+ZKTUhCuATTKCjhoLA" + + "hhQAsjrSZBJcm7rZ22O+ev6mMmTLj55eu1T+jU8GOH/kJf2TZCiifIQsXfwEbN2yktxoaeYbf93DKSORMnTOZE0aZaVlQGYVKJCgjEJSCcgLB0xDERjI" + + "NFBUEaXmuB20t95eEutr0xrufpo4eepMAkMPIxx+dx9at25EWQNXsh77q0Bzwen0ShEF32HCrCpjksAWHFAKqokFhgEJt2DKJeFoQv8eDuz3duaseXZY" + + "dixthaQ+NRlRCcKO+FgCweP68wswMF/yZWcTkNpLJFAZEGi6XC07NCUIIoqaNSLQfFALCEpCSEL/bK/wuw+12sKlDQzKs6k5yZt+rI+2aNKUSNdUbSSQ" + + "Wh2mJP46rGPeYrjtkY0M7jFgciUQCiqqgrCAfBTle3G9rR1NHN3SnDq9Lg49QlBQEcbfbQCKZlhQEDkXBih27RpDOrmacfP8YB4CfHT7uNXrCMFM2FdD" + + "BVQ5TE/A5HbDSJoSpQXAbXm8A4b5+gKrwulU4KKEBnwuzHpiQu+n1jQoQsM+9cYQMT9fvf/FLBYTaDqdzbfgft95PKzbPyQqwnlAXGkJtGIgNYnJpMfw" + + "OghLG0GJE0ZdiaOnsQ16OD6XZLkiRROdAgud5sxk8ridsy/pQU1VlOIkZN6QtAGnx0FA0AtXvIA4C5OX4kOWbiLRhQBDApTmgJuLwEonMgBvjgpmgjIE" + + "hhX7DAIVKNeqE05/dJbgEgRy5eOJ1ieXr1gJA7ZNLTrVVlAZLyopLJAUlHsrAMrwwrRQ4t6E5VHgSBExjcGpO0JQNizCE05a41dhOi+cXXVm144e1AHD" + + "1vXfFMOLy+KSHEDoEJLZ8s+ZWKpUusWwpFKiMUQ4jbiAaj8Hp9oExBsMCUpEIfD6JLKZjKJVGV3RIZGdm0qxA5qmz+/cgMhBVuuMRewRRGF7fe4BYHMg" + + "N5LxdV3vhy1EjrrjA5GAyTuKpFHricfS0dSDNCQRPoSyQgSSPI+UBEtwShiWUQEHw5mMvbz4JRcXvDr3B3dBG1sq5X53GlMcX4JWVTyvRQcOumDD2vfK" + + "cjOqiQDZPGBF2ryUEnjRhJlP4d6/BiQ1TABPKiyQhgtzvjPCJlQ/OGRwauqESSUPX68U3Vi4fGeH83Hwc3bYHBWUV0m0k4HB6z7aGu6sznDos00R3exg" + + "l5ZMwc+FMaJoKKxHFnbo6DMYiELBlqLOXDBq8dsvuPTfKALpwdbX42iMLsHjLd0Zv4RNvvY1wZxdZunyVDGZm6D/47sv12RqbmOPVhG5LGnAH4S8sgu7" + + "1oK/pn2BWAoYw0dDbaTd19iqlZROejwzEjqgMSuXUifak8jF49JnNI0kAoGrBfET7+uXOrS+y5ta21JzZsw7faW45XJaXxSvyAtTpkOi483fwtAWP1wt" + + "vrhvd/VFx+26zojr9Les2PnfaTNu4cuGvvKe9BVv3/RgARiNTpk/Hod17MWikxcqzzfhK/+1jL2xc+YQAX1ISDHLV7WTpQQaLcASzPEiB41ZrmEeHkrT" + + "Q49uz/aXn+iilLKXq/MmlS0e/jFcuX4SmaQAAKSXlnIvVy1aQ6EBMFgRyCznDpfGFwdKqirF2tu5SdIeGrkiP+KS5yb7dHtIKsnI++kP9rS8RQvjmxxe" + + "jePxD2HHwwP9FdCllurGhUbx14CAbiMc4Y2qVJqwLbo0qfpdLSilILB4Xg0mT6h7vnSWzZn9RoaynobWF3K6rk1NmzMWZ83/+37+V4a1cVg5JACYF45b" + + "FGVVWOFS2V1HUCjOdBqW0Q9fYb7N9/tcSptnldjpott8rFEXBO+f+NKrWMHL9Wu1nSUAIAaUUa59aAyE43E4X3bD8W6K5K6x1h1snRaMDJDuQf7+vrzf" + + "eG+mgfrcLHh3C79bx6wttGEqERiH/AjPohWMouv2ZAAAAAElFTkSuQmCC"; +const ACCENT_COLOR = "#a14040"; +const TEXT_COLOR = "#fac96e"; +// For testing aliases of the colors above: +const FRAME_COLOR = [71, 105, 91]; +const TAB_BACKGROUND_TEXT_COLOR = [207, 221, 192, 0.9]; + +function hexToRGB(hex) { + if (!hex) { + return null; + } + hex = parseInt(hex.indexOf("#") > -1 ? hex.substring(1) : hex, 16); + return [hex >> 16, (hex & 0x00ff00) >> 8, hex & 0x0000ff]; +} + +function rgbToCSS(rgb) { + return `rgb(${rgb.join(", ")})`; +} + +function hexToCSS(hex) { + if (!hex) { + return null; + } + return rgbToCSS(hexToRGB(hex)); +} + +function imageBufferFromDataURI(encodedImageData) { + let decodedImageData = atob(encodedImageData); + return Uint8Array.from(decodedImageData, byte => byte.charCodeAt(0)).buffer; +} + +function waitForTransition(element, propertyName) { + return BrowserTestUtils.waitForEvent( + element, + "transitionend", + false, + event => { + return event.target == element && event.propertyName == propertyName; + } + ); +} + +function getToolboxBackgroundColor() { + let toolbox = document.getElementById("navigator-toolbox"); + // Ignore any potentially ongoing transition. + toolbox.style.transitionProperty = "none"; + let color = window.getComputedStyle(toolbox).backgroundColor; + toolbox.style.transitionProperty = ""; + return color; +} + +function testBorderColor(element, expected) { + let computedStyle = window.getComputedStyle(element); + Assert.equal( + computedStyle.borderLeftColor, + hexToCSS(expected), + "Element left border color should be set." + ); + Assert.equal( + computedStyle.borderRightColor, + hexToCSS(expected), + "Element right border color should be set." + ); + Assert.equal( + computedStyle.borderTopColor, + hexToCSS(expected), + "Element top border color should be set." + ); + Assert.equal( + computedStyle.borderBottomColor, + hexToCSS(expected), + "Element bottom border color should be set." + ); +} + +function loadTestSubscript(filePath) { + Services.scriptloader.loadSubScript(new URL(filePath, gTestPath).href, this); +} + +// Persistent Listener test functionality +const { assertPersistentListeners } = ExtensionTestUtils.testAssertions; diff --git a/toolkit/components/extensions/test/browser/head_serviceworker.js b/toolkit/components/extensions/test/browser/head_serviceworker.js new file mode 100644 index 0000000000..b2f9512e5a --- /dev/null +++ b/toolkit/components/extensions/test/browser/head_serviceworker.js @@ -0,0 +1,119 @@ +"use strict"; + +/* exported assert_background_serviceworker_pref_enabled, + * getBackgroundServiceWorkerRegistration, + * getServiceWorkerInfo, getServiceWorkerState, + * waitForServiceWorkerRegistrationsRemoved, waitForServiceWorkerTerminated + */ + +async function assert_background_serviceworker_pref_enabled() { + is( + WebExtensionPolicy.backgroundServiceWorkerEnabled, + true, + "Expect extensions.backgroundServiceWorker.enabled to be true" + ); +} + +// Return the name of the enum corresponding to the worker's state (ex: "STATE_ACTIVATED") +// because nsIServiceWorkerInfo doesn't currently provide a comparable string-returning getter. +function getServiceWorkerState(workerInfo) { + const map = Object.keys(workerInfo) + .filter(k => k.startsWith("STATE_")) + .reduce((map, name) => { + map.set(workerInfo[name], name); + return map; + }, new Map()); + return map.has(workerInfo.state) + ? map.get(workerInfo.state) + : "state: ${workerInfo.state}"; +} + +function getServiceWorkerInfo(swRegInfo) { + const { evaluatingWorker, installingWorker, waitingWorker, activeWorker } = + swRegInfo; + return evaluatingWorker || installingWorker || waitingWorker || activeWorker; +} + +async function waitForServiceWorkerTerminated(swRegInfo) { + info(`Wait all ${swRegInfo.scope} workers to be terminated`); + + try { + await BrowserTestUtils.waitForCondition( + () => !getServiceWorkerInfo(swRegInfo) + ); + } catch (err) { + const workerInfo = getServiceWorkerInfo(swRegInfo); + if (workerInfo) { + ok( + false, + `Error while waiting for workers for scope ${swRegInfo.scope} to be terminated. ` + + `Found a worker in state: ${getServiceWorkerState(workerInfo)}` + ); + return; + } + + throw err; + } +} + +function getBackgroundServiceWorkerRegistration(extension) { + const policy = WebExtensionPolicy.getByHostname(extension.uuid); + const expectedSWScope = policy.getURL("/"); + const expectedScriptURL = policy.extension.backgroundWorkerScript || ""; + + ok( + expectedScriptURL.startsWith(expectedSWScope), + `Extension does include a valid background.service_worker: ${expectedScriptURL}` + ); + + const swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + + let swReg; + let regs = swm.getAllRegistrations(); + + for (let i = 0; i < regs.length; i++) { + let reg = regs.queryElementAt(i, Ci.nsIServiceWorkerRegistrationInfo); + if (reg.scriptSpec === expectedScriptURL) { + swReg = reg; + break; + } + } + + ok(swReg, `Found service worker registration for ${expectedScriptURL}`); + + is( + swReg.scope, + expectedSWScope, + "The extension background worker registration has the expected scope URL" + ); + + return swReg; +} + +async function waitForServiceWorkerRegistrationsRemoved(extension) { + info(`Wait ${extension.id} service worker registration to be deleted`); + const swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + let baseURI = Services.io.newURI(`moz-extension://${extension.uuid}/`); + let principal = Services.scriptSecurityManager.createContentPrincipal( + baseURI, + {} + ); + + await BrowserTestUtils.waitForCondition(() => { + let regs = swm.getAllRegistrations(); + + for (let i = 0; i < regs.length; i++) { + let reg = regs.queryElementAt(i, Ci.nsIServiceWorkerRegistrationInfo); + if (principal.equals(reg.principal)) { + return false; + } + } + + info(`All ${extension.id} service worker registrations are gone`); + return true; + }, `All ${extension.id} service worker registrations should be deleted`); +} diff --git a/toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/manifest.json b/toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/manifest.json new file mode 100644 index 0000000000..38a5c3f027 --- /dev/null +++ b/toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/manifest.json @@ -0,0 +1,11 @@ +{ + "manifest_version": 2, + "name": "Test Extension with Background Service Worker", + "version": "1", + "browser_specific_settings": { + "gecko": { "id": "extension-with-bg-sw@test" } + }, + "background": { + "service_worker": "sw.js" + } +} diff --git a/toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/sw.js b/toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/sw.js new file mode 100644 index 0000000000..2282e6a64b --- /dev/null +++ b/toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/sw.js @@ -0,0 +1,3 @@ +"use strict"; + +dump("extension-with-bg-sw: sw.js loaded"); diff --git a/toolkit/components/extensions/test/marionette/manifest-serviceworker.toml b/toolkit/components/extensions/test/marionette/manifest-serviceworker.toml new file mode 100644 index 0000000000..c8035f80c2 --- /dev/null +++ b/toolkit/components/extensions/test/marionette/manifest-serviceworker.toml @@ -0,0 +1,4 @@ +[DEFAULT] + +["test_extension_serviceworkers_purged_on_pref_disabled.py"] +["test_temporary_extension_serviceworkers_not_persisted.py"] diff --git a/toolkit/components/extensions/test/marionette/service_worker_testutils.py b/toolkit/components/extensions/test/marionette/service_worker_testutils.py new file mode 100644 index 0000000000..b1fda926c0 --- /dev/null +++ b/toolkit/components/extensions/test/marionette/service_worker_testutils.py @@ -0,0 +1,48 @@ +# 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/. + +from marionette_harness import MarionetteTestCase + +EXT_ID = "extension-with-bg-sw@test" +EXT_DIR_PATH = "extension-with-bg-sw" +PREF_BG_SW_ENABLED = "extensions.backgroundServiceWorker.enabled" +PREF_PERSIST_TEMP_ADDONS = ( + "dom.serviceWorkers.testing.persistTemporarilyInstalledAddons" +) + + +class MarionetteServiceWorkerTestCase(MarionetteTestCase): + def get_extension_url(self, path="/"): + with self.marionette.using_context("chrome"): + return self.marionette.execute_script( + """ + let policy = WebExtensionPolicy.getByID(arguments[0]); + return policy.getURL(arguments[1]) + """, + script_args=(self.test_extension_id, path), + ) + + @property + def is_extension_service_worker_registered(self): + with self.marionette.using_context("chrome"): + return self.marionette.execute_script( + """ + let serviceWorkerManager = Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + + let serviceWorkers = serviceWorkerManager.getAllRegistrations(); + for (let i = 0; i < serviceWorkers.length; i++) { + let sw = serviceWorkers.queryElementAt( + i, + Ci.nsIServiceWorkerRegistrationInfo + ); + if (sw.scope == arguments[0]) { + return true; + } + } + return false; + """, + script_args=(self.test_extension_base_url,), + ) diff --git a/toolkit/components/extensions/test/marionette/test_extension_serviceworkers_purged_on_pref_disabled.py b/toolkit/components/extensions/test/marionette/test_extension_serviceworkers_purged_on_pref_disabled.py new file mode 100644 index 0000000000..ff2184c692 --- /dev/null +++ b/toolkit/components/extensions/test/marionette/test_extension_serviceworkers_purged_on_pref_disabled.py @@ -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/. + +import os +import sys + +from marionette_driver import Wait +from marionette_driver.addons import Addons + +# Add this directory to the import path. +sys.path.append(os.path.dirname(__file__)) + +from service_worker_testutils import ( + EXT_DIR_PATH, + EXT_ID, + PREF_BG_SW_ENABLED, + PREF_PERSIST_TEMP_ADDONS, + MarionetteServiceWorkerTestCase, +) + + +class PurgeExtensionServiceWorkersOnPrefDisabled(MarionetteServiceWorkerTestCase): + def setUp(self): + super(PurgeExtensionServiceWorkersOnPrefDisabled, self).setUp() + self.test_extension_id = EXT_ID + # Flip the "mirror: once" pref and restart Firefox to be able + # to run the extension successfully. + self.marionette.set_pref(PREF_BG_SW_ENABLED, True) + self.marionette.set_pref(PREF_PERSIST_TEMP_ADDONS, True) + self.marionette.restart(in_app=True) + + def tearDown(self): + self.marionette.restart(in_app=False, clean=True) + super(PurgeExtensionServiceWorkersOnPrefDisabled, self).tearDown() + + def test_unregistering_service_worker_when_clearing_data(self): + self.install_extension_with_service_worker() + + # Flip the pref to false and restart again to verify that the + # service worker registration has been removed as expected. + self.marionette.set_pref(PREF_BG_SW_ENABLED, False) + self.marionette.restart(in_app=True) + self.assertFalse(self.is_extension_service_worker_registered) + + def install_extension_with_service_worker(self): + addons = Addons(self.marionette) + test_extension_path = os.path.join( + os.path.dirname(self.filepath), "data", EXT_DIR_PATH + ) + addons.install(test_extension_path, temp=True) + self.test_extension_base_url = self.get_extension_url() + Wait(self.marionette).until( + lambda _: self.is_extension_service_worker_registered, + message="Wait the extension service worker to be registered", + ) diff --git a/toolkit/components/extensions/test/marionette/test_temporary_extension_serviceworkers_not_persisted.py b/toolkit/components/extensions/test/marionette/test_temporary_extension_serviceworkers_not_persisted.py new file mode 100644 index 0000000000..57c0696385 --- /dev/null +++ b/toolkit/components/extensions/test/marionette/test_temporary_extension_serviceworkers_not_persisted.py @@ -0,0 +1,54 @@ +# 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 os +import sys + +from marionette_driver import Wait +from marionette_driver.addons import Addons + +# Add this directory to the import path. +sys.path.append(os.path.dirname(__file__)) + +from service_worker_testutils import ( + EXT_DIR_PATH, + EXT_ID, + PREF_BG_SW_ENABLED, + MarionetteServiceWorkerTestCase, +) + + +class TemporarilyInstalledAddonServiceWorkerNotPersisted( + MarionetteServiceWorkerTestCase +): + def setUp(self): + super(TemporarilyInstalledAddonServiceWorkerNotPersisted, self).setUp() + self.test_extension_id = EXT_ID + # Flip the "mirror: once" pref and restart Firefox to be able + # to run the extension successfully. + self.marionette.set_pref(PREF_BG_SW_ENABLED, True) + self.marionette.restart(in_app=True) + + def tearDown(self): + self.marionette.restart(in_app=False, clean=True) + super(TemporarilyInstalledAddonServiceWorkerNotPersisted, self).tearDown() + + def test_temporarily_installed_addon_serviceWorkers_not_persisted(self): + self.install_temporary_extension_with_service_worker() + # Make sure the extension worker registration is persisted + # across restarts when the pref stays set to true. + self.marionette.restart(in_app=True) + self.assertFalse(self.is_extension_service_worker_registered) + + def install_temporary_extension_with_service_worker(self): + addons = Addons(self.marionette) + test_extension_path = os.path.join( + os.path.dirname(self.filepath), "data", EXT_DIR_PATH + ) + addons.install(test_extension_path, temp=True) + self.test_extension_base_url = self.get_extension_url() + Wait(self.marionette).until( + lambda _: self.is_extension_service_worker_registered, + message="Wait the extension service worker to be registered", + ) diff --git a/toolkit/components/extensions/test/mochitest/.eslintrc.js b/toolkit/components/extensions/test/mochitest/.eslintrc.js new file mode 100644 index 0000000000..a776405c9d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/.eslintrc.js @@ -0,0 +1,12 @@ +"use strict"; + +module.exports = { + env: { + browser: true, + webextensions: true, + }, + + rules: { + "no-shadow": 0, + }, +}; diff --git a/toolkit/components/extensions/test/mochitest/chrome.toml b/toolkit/components/extensions/test/mochitest/chrome.toml new file mode 100644 index 0000000000..0fbd044f53 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/chrome.toml @@ -0,0 +1,55 @@ +[DEFAULT] +support-files = [ + "chrome_cleanup_script.js", + "head.js", + "head_cookies.js", + "file_image_good.png", + "file_image_great.png", + "file_sample.html", + "file_with_images.html", + "webrequest_chromeworker.js", + "webrequest_test.sys.mjs", +] +prefs = ["security.mixed_content.upgrade_display_content=false"] +tags = "webextensions in-process-webextensions" + +# NO NEW TESTS. mochitest-chrome does not run under e10s, avoid adding new +# tests here unless absolutely necessary. + +["test_chrome_ext_contentscript_data_uri.html"] + +["test_chrome_ext_contentscript_telemetry.html"] + +["test_chrome_ext_contentscript_unrecognizedprop_warning.html"] + +["test_chrome_ext_downloads_open.html"] + +["test_chrome_ext_downloads_saveAs.html"] +skip-if = [ + "verify && !debug && os == 'win'", + "os == 'android'", + "win10_2009", # Bug 1695612 +] + +["test_chrome_ext_downloads_uniquify.html"] +skip-if = ["win10_2009"] # Bug 1695612 + +["test_chrome_ext_permissions.html"] +skip-if = ["os == 'android'"] # Bug 1350559 + +["test_chrome_ext_svg_context_fill.html"] + +["test_chrome_ext_trackingprotection.html"] + +["test_chrome_ext_webnavigation_resolved_urls.html"] + +["test_chrome_ext_webrequest_background_events.html"] + +["test_chrome_ext_webrequest_host_permissions.html"] +skip-if = ["verify"] + +["test_chrome_ext_webrequest_mozextension.html"] +skip-if = ["true"] # Bug 1404172 + +["test_chrome_native_messaging_paths.html"] +skip-if = ["os != 'mac' && os != 'linux'"] diff --git a/toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js b/toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js new file mode 100644 index 0000000000..9afa95f302 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js @@ -0,0 +1,65 @@ +/* eslint-env mozilla/chrome-script */ + +"use strict"; + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +let listener = msg => { + void (msg instanceof Ci.nsIConsoleMessage); + dump(`Console message: ${msg}\n`); +}; + +Services.console.registerListener(listener); + +let getBrowserApp, getTabBrowser; +if (AppConstants.MOZ_BUILD_APP === "mobile/android") { + getBrowserApp = win => win.BrowserApp; + getTabBrowser = tab => tab.browser; +} else { + getBrowserApp = win => win.gBrowser; + getTabBrowser = tab => tab.linkedBrowser; +} + +function* iterBrowserWindows() { + for (let win of Services.wm.getEnumerator("navigator:browser")) { + if (!win.closed && getBrowserApp(win)) { + yield win; + } + } +} + +let initialTabs = new Map(); +for (let win of iterBrowserWindows()) { + initialTabs.set(win, new Set(getBrowserApp(win).tabs)); +} + +addMessageListener("check-cleanup", extensionId => { + Services.console.unregisterListener(listener); + + let results = { + extraWindows: [], + extraTabs: [], + }; + + for (let win of iterBrowserWindows()) { + if (initialTabs.has(win)) { + let tabs = initialTabs.get(win); + + for (let tab of getBrowserApp(win).tabs) { + if (!tabs.has(tab)) { + results.extraTabs.push(getTabBrowser(tab).currentURI.spec); + } + } + } else { + results.extraWindows.push( + Array.from(win.gBrowser.tabs, tab => getTabBrowser(tab).currentURI.spec) + ); + } + } + + initialTabs = null; + + sendAsyncMessage("cleanup-results", results); +}); diff --git a/toolkit/components/extensions/test/mochitest/chrome_head.js b/toolkit/components/extensions/test/mochitest/chrome_head.js new file mode 100644 index 0000000000..3918c74e44 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/chrome_head.js @@ -0,0 +1 @@ +"use strict"; diff --git a/toolkit/components/extensions/test/mochitest/file_WebNavigation_page1.html b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page1.html new file mode 100644 index 0000000000..663ebc6112 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page1.html @@ -0,0 +1,12 @@ +<!DOCTYPE HTML> + +<html> +<body> + +<iframe src="file_WebNavigation_page2.html" width="200" height="200"></iframe> + +<form> +</form> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_WebNavigation_page2.html b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page2.html new file mode 100644 index 0000000000..cc1acc83d6 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page2.html @@ -0,0 +1,7 @@ +<!DOCTYPE HTML> + +<html> +<body> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_WebNavigation_page3.html b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page3.html new file mode 100644 index 0000000000..a0a26a2e9d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page3.html @@ -0,0 +1,9 @@ +<!DOCTYPE HTML> + +<html> +<body> + +<a id="elt" href="file_WebNavigation_page3.html#ref">click me</a> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_WebRequest_page3.html b/toolkit/components/extensions/test/mochitest/file_WebRequest_page3.html new file mode 100644 index 0000000000..24c7a42986 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_WebRequest_page3.html @@ -0,0 +1,10 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +<script> +"use strict"; +</script> +</head> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_contains_iframe.html b/toolkit/components/extensions/test/mochitest/file_contains_iframe.html new file mode 100644 index 0000000000..e905b5a224 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_contains_iframe.html @@ -0,0 +1,13 @@ +<!DOCTYPE HTML> +<html> +<head> +<meta charset="utf-8"> +<title>file contains iframe</title> +</head> +<body> + +<iframe src="//example.org/tests/toolkit/components/extensions/test/mochitest/file_contains_img.html"> +</iframe> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_contains_img.html b/toolkit/components/extensions/test/mochitest/file_contains_img.html new file mode 100644 index 0000000000..2b0c3137d6 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_contains_img.html @@ -0,0 +1,12 @@ +<!DOCTYPE HTML> +<html> +<head> +<meta charset="utf-8"> +<title>file contains img</title> +</head> +<body> + +<img src="file_image_good.png"/> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html b/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html new file mode 100644 index 0000000000..6c1675cb47 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html @@ -0,0 +1,11 @@ +<!DOCTYPE HTML> + +<html> +<head> + <meta charset="utf-8"> +</head> +<body> + <iframe id="emptyframe"></iframe> + <iframe id="regularframe" src="http://test1.example.com/"></iframe> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab2.html b/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab2.html new file mode 100644 index 0000000000..3b102b3d67 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab2.html @@ -0,0 +1,10 @@ +<!DOCTYPE HTML> + +<html> +<head> + <meta charset="utf-8"> +</head> +<body> + <iframe srcdoc="<iframe src='http://test1.example.com/'></iframe>"></iframe> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_contentscript_iframe.html b/toolkit/components/extensions/test/mochitest/file_contentscript_iframe.html new file mode 100644 index 0000000000..670bad1360 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_contentscript_iframe.html @@ -0,0 +1,10 @@ +<!DOCTYPE HTML> + +<html> +<head> + <meta charset="utf-8"> +</head> +<body> + <iframe id="frame" src="https://test2.example.com/"></iframe> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_green.html b/toolkit/components/extensions/test/mochitest/file_green.html new file mode 100644 index 0000000000..20755c5b56 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_green.html @@ -0,0 +1,3 @@ +<meta charset=utf-8> +<title>Super green test page</title> +<body style="background: #0f0"> diff --git a/toolkit/components/extensions/test/mochitest/file_green_blue.html b/toolkit/components/extensions/test/mochitest/file_green_blue.html new file mode 100644 index 0000000000..9266b637ba --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_green_blue.html @@ -0,0 +1,16 @@ +<meta charset=utf-8> +<title>Upper square green, rest blue</title> +<style> + div { + position: absolute; + width: 50vw; + height: 50vh; + top: 0; + left: 0; + background-color: lime; + } + :root { + background-color: blue; + } +</style> +<div></div> diff --git a/toolkit/components/extensions/test/mochitest/file_image_bad.png b/toolkit/components/extensions/test/mochitest/file_image_bad.png Binary files differnew file mode 100644 index 0000000000..4c3be50847 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_image_bad.png diff --git a/toolkit/components/extensions/test/mochitest/file_image_good.png b/toolkit/components/extensions/test/mochitest/file_image_good.png Binary files differnew file mode 100644 index 0000000000..769c636340 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_image_good.png diff --git a/toolkit/components/extensions/test/mochitest/file_image_great.png b/toolkit/components/extensions/test/mochitest/file_image_great.png Binary files differnew file mode 100644 index 0000000000..769c636340 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_image_great.png diff --git a/toolkit/components/extensions/test/mochitest/file_image_redirect.png b/toolkit/components/extensions/test/mochitest/file_image_redirect.png Binary files differnew file mode 100644 index 0000000000..4c3be50847 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_image_redirect.png diff --git a/toolkit/components/extensions/test/mochitest/file_indexedDB.html b/toolkit/components/extensions/test/mochitest/file_indexedDB.html new file mode 100644 index 0000000000..65b7e0ad2f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_indexedDB.html @@ -0,0 +1,28 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <script> +"use strict"; + +const objectStoreName = "Objects"; + +let test = {key: 0, value: "test"}; + +let request = indexedDB.open("WebExtensionTest", 1); +request.onupgradeneeded = event => { + let db = event.target.result; + let objectStore = db.createObjectStore(objectStoreName, + {autoIncrement: 0}); + request = objectStore.add(test.value, test.key); + request.onsuccess = event => { + db.close(); + window.postMessage("indexedDBCreated", "*"); + }; +}; + </script> + </head> + <body> + This is a test page. + </body> +<html> diff --git a/toolkit/components/extensions/test/mochitest/file_language_fr_en.html b/toolkit/components/extensions/test/mochitest/file_language_fr_en.html new file mode 100644 index 0000000000..5e3c7b3b08 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_language_fr_en.html @@ -0,0 +1,14 @@ +<!DOCTYPE html> +<html lang="fr"> +<head> + <meta charset="UTF-8"> + <title></title> +</head> +<body> + France is the largest country in Western Europe and the third-largest in Europe as a whole. + A accès aux chiens et aux frontaux qui lui ont été il peut consulter et modifier ses collections et exporter + Cet article concerne le pays européen aujourd’hui appelé République française. Pour d’autres usages du nom France, + Pour une aide rapide et effective, veuiller trouver votre aide dans le menu ci-dessus. + Motoring events began soon after the construction of the first successful gasoline-fueled automobiles. The quick brown fox jumps over the lazy dog. +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_language_ja.html b/toolkit/components/extensions/test/mochitest/file_language_ja.html new file mode 100644 index 0000000000..ed07ba70e5 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_language_ja.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html lang="ja"> +<head> + <meta charset="UTF-8"> + <title></title> +</head> +<body> + このペ ジでは アカウントに指定された予算の履歴を一覧にしています それぞれの項目には 予算額と特定期間のステ タスが表示されます 現在または今後の予算を設定するには +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_language_tlh.html b/toolkit/components/extensions/test/mochitest/file_language_tlh.html new file mode 100644 index 0000000000..dd7da7bdbf --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_language_tlh.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html lang="tlh"> +<head> + <meta charset="UTF-8"> + <title></title> +</head> +<body> + tlhIngan maH! + Hab SoSlI' Quch! + Heghlu'meH QaQ jajvam +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_mixed.html b/toolkit/components/extensions/test/mochitest/file_mixed.html new file mode 100644 index 0000000000..f3c7dda580 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_mixed.html @@ -0,0 +1,13 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<div id="test">Sample text</div> +<img id="bad-image" src="http://example.com/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png" /> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_redirect_cors_bypass.html b/toolkit/components/extensions/test/mochitest/file_redirect_cors_bypass.html new file mode 100644 index 0000000000..b8fda2369a --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_redirect_cors_bypass.html @@ -0,0 +1,30 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>1450965 Skip Cors Check for Early WebExtention Redirects</title> +</head> +<body> + <pre id="c"> + Fetching ... + </pre> + <script> + "use strict"; + let c = document.querySelector("#c"); + const channel = new BroadcastChannel("test_bus"); + function l(t) { c.innerText += `${t}\n`; } + + fetch("https://example.org/tests/toolkit/components/extensions/test/mochitest/file_cors_blocked.txt") + .then(r => r.text()) + .then(t => { + // This Request should have been redirected to /file_sample.txt in + // onBeforeRequest. So the text should be 'Sample' + l(`Loaded: ${t}`); + channel.postMessage(t); + }).catch(e => { + // The Redirect Failed, most likly due to a CORS Error + l(`e`); + channel.postMessage(e.toString()); + }); + </script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_redirect_data_uri.html b/toolkit/components/extensions/test/mochitest/file_redirect_data_uri.html new file mode 100644 index 0000000000..fe8e5bea44 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_redirect_data_uri.html @@ -0,0 +1,9 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Bug 1434357: Allow Web Request API to redirect to data: URI</title> +</head> +<body> + <div id="testdiv">foo</div> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_remote_frame.html b/toolkit/components/extensions/test/mochitest/file_remote_frame.html new file mode 100644 index 0000000000..f1b9240092 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_remote_frame.html @@ -0,0 +1,20 @@ +<!DOCTYPE> +<html> + <head> + <meta charset="utf-8"> + <script> + "use strict"; + var response = { + tabs: false, + cookie: document.cookie, + }; + try { + browser.tabs.create({url: "file_sample.html"}); + response.tabs = true; + } catch (e) { + // ok + } + window.parent.postMessage(response, "*"); + </script> + </head> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_sample.html b/toolkit/components/extensions/test/mochitest/file_sample.html new file mode 100644 index 0000000000..aa1ef6e6f4 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_sample.html @@ -0,0 +1,13 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +<title>file sample</title> +</head> +<body> + +<div id="test">Sample text</div> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_sample.txt b/toolkit/components/extensions/test/mochitest/file_sample.txt new file mode 100644 index 0000000000..c02cd532b1 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_sample.txt @@ -0,0 +1 @@ +Sample
\ No newline at end of file diff --git a/toolkit/components/extensions/test/mochitest/file_sample.txt^headers^ b/toolkit/components/extensions/test/mochitest/file_sample.txt^headers^ new file mode 100644 index 0000000000..cb762eff80 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_sample.txt^headers^ @@ -0,0 +1 @@ +Access-Control-Allow-Origin: * diff --git a/toolkit/components/extensions/test/mochitest/file_script_bad.js b/toolkit/components/extensions/test/mochitest/file_script_bad.js new file mode 100644 index 0000000000..c425122c71 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_script_bad.js @@ -0,0 +1,3 @@ +"use strict"; + +window.failure = true; diff --git a/toolkit/components/extensions/test/mochitest/file_script_good.js b/toolkit/components/extensions/test/mochitest/file_script_good.js new file mode 100644 index 0000000000..14e959aa5c --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_script_good.js @@ -0,0 +1,12 @@ +"use strict"; + +window.success = window.success ? window.success + 1 : 1; + +{ + let scripts = document.getElementsByTagName("script"); + let url = new URL(scripts[scripts.length - 1].src); + let flag = url.searchParams.get("q"); + if (flag) { + window.postMessage(flag, "*"); + } +} diff --git a/toolkit/components/extensions/test/mochitest/file_script_redirect.js b/toolkit/components/extensions/test/mochitest/file_script_redirect.js new file mode 100644 index 0000000000..c425122c71 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_script_redirect.js @@ -0,0 +1,3 @@ +"use strict"; + +window.failure = true; diff --git a/toolkit/components/extensions/test/mochitest/file_script_xhr.js b/toolkit/components/extensions/test/mochitest/file_script_xhr.js new file mode 100644 index 0000000000..ad01f74253 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_script_xhr.js @@ -0,0 +1,9 @@ +"use strict"; + +var request = new XMLHttpRequest(); +request.open( + "get", + "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/xhr_resource", + false +); +request.send(); diff --git a/toolkit/components/extensions/test/mochitest/file_serviceWorker.html b/toolkit/components/extensions/test/mochitest/file_serviceWorker.html new file mode 100644 index 0000000000..d2b99769cc --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_serviceWorker.html @@ -0,0 +1,16 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <script> + "use strict"; + + navigator.serviceWorker.register("serviceWorker.js").then(() => { + window.postMessage("serviceWorkerRegistered", "*"); + }); + </script> + </head> + <body> + This is a test page. + </body> +<html> diff --git a/toolkit/components/extensions/test/mochitest/file_simple_iframe_worker.html b/toolkit/components/extensions/test/mochitest/file_simple_iframe_worker.html new file mode 100644 index 0000000000..2ecc24e648 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_simple_iframe_worker.html @@ -0,0 +1,26 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<script type="application/javascript"> +"use strict"; + +fetch("file_simple_iframe.txt"); +const worker = new Worker("file_simple_worker.js?iniframe=true"); +worker.onmessage = (msg) => { + worker.postMessage("file_simple_iframe_worker.txt"); +} + +const sharedworker = new SharedWorker("file_simple_sharedworker.js?iniframe=true"); +sharedworker.port.onmessage = (msg) => { + sharedworker.port.postMessage("file_simple_iframe_sharedworker.txt"); +} +sharedworker.port.start(); + +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_simple_sandboxed_frame.html b/toolkit/components/extensions/test/mochitest/file_simple_sandboxed_frame.html new file mode 100644 index 0000000000..909a1f9e36 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_simple_sandboxed_frame.html @@ -0,0 +1,23 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<script> +"use strict"; + +let req = new XMLHttpRequest(); +req.open("GET", "/xhr_sandboxed"); +req.send(); + +let sandbox = document.createElement("iframe"); +sandbox.setAttribute("sandbox", "allow-scripts"); +sandbox.setAttribute("src", "file_simple_sandboxed_subframe.html"); +document.documentElement.appendChild(sandbox); +</script> +<img src="file_image_great.png"/> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_simple_sandboxed_subframe.html b/toolkit/components/extensions/test/mochitest/file_simple_sandboxed_subframe.html new file mode 100644 index 0000000000..a0a437d0eb --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_simple_sandboxed_subframe.html @@ -0,0 +1,10 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_simple_sharedworker.js b/toolkit/components/extensions/test/mochitest/file_simple_sharedworker.js new file mode 100644 index 0000000000..e8776216f1 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_simple_sharedworker.js @@ -0,0 +1,11 @@ +"use strict"; + +self.onconnect = async evt => { + const port = evt.ports[0]; + port.onmessage = async message => { + await fetch(message.data); + self.close(); + }; + port.start(); + port.postMessage("loaded"); +}; diff --git a/toolkit/components/extensions/test/mochitest/file_simple_webrequest_worker.html b/toolkit/components/extensions/test/mochitest/file_simple_webrequest_worker.html new file mode 100644 index 0000000000..a90c4509be --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_simple_webrequest_worker.html @@ -0,0 +1,28 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<script type="application/javascript"> +"use strict"; + +fetch("file_simple_toplevel.txt"); +const worker = new Worker("file_simple_worker.js"); +worker.onmessage = (msg) => { + worker.postMessage("file_simple_worker.txt"); +} + +const sharedworker = new SharedWorker("file_simple_sharedworker.js"); +sharedworker.port.onmessage = (msg) => { + dump(`postMessage to sharedworker\n`); + sharedworker.port.postMessage("file_simple_sharedworker.txt"); +} +sharedworker.port.start(); + +</script> +<iframe src="file_simple_iframe_worker.html"></iframe> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_simple_worker.js b/toolkit/components/extensions/test/mochitest/file_simple_worker.js new file mode 100644 index 0000000000..9638a8e9c2 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_simple_worker.js @@ -0,0 +1,8 @@ +"use strict"; + +self.onmessage = async message => { + await fetch(message.data); + self.close(); +}; + +self.postMessage("loaded"); diff --git a/toolkit/components/extensions/test/mochitest/file_simple_xhr.html b/toolkit/components/extensions/test/mochitest/file_simple_xhr.html new file mode 100644 index 0000000000..1b43f804d9 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_simple_xhr.html @@ -0,0 +1,19 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<script> +"use strict"; + +let req = new XMLHttpRequest(); +req.open("GET", "https://example.org/example.txt"); +req.send(); +</script> +<img src="file_image_good.png"/> +<iframe src="file_simple_xhr_frame.html"/> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_simple_xhr_frame.html b/toolkit/components/extensions/test/mochitest/file_simple_xhr_frame.html new file mode 100644 index 0000000000..7f38247ac0 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_simple_xhr_frame.html @@ -0,0 +1,19 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<script> +"use strict"; + +let req = new XMLHttpRequest(); +req.open("GET", "/xhr_resource"); +req.send(); +</script> +<img src="file_image_bad.png"/> +<iframe src="file_simple_xhr_frame2.html"/> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_simple_xhr_frame2.html b/toolkit/components/extensions/test/mochitest/file_simple_xhr_frame2.html new file mode 100644 index 0000000000..6174a0b402 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_simple_xhr_frame2.html @@ -0,0 +1,23 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<script> +"use strict"; + +let req = new XMLHttpRequest(); +req.open("GET", "/xhr_resource_2"); +req.send(); + +let sandbox = document.createElement("iframe"); +sandbox.setAttribute("sandbox", "allow-scripts"); +sandbox.setAttribute("src", "file_simple_sandboxed_frame.html"); +document.documentElement.appendChild(sandbox); +</script> +<img src="file_image_redirect.png"/> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_slowed_document.sjs b/toolkit/components/extensions/test/mochitest/file_slowed_document.sjs new file mode 100644 index 0000000000..8c42fcc966 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_slowed_document.sjs @@ -0,0 +1,49 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80 ft=javascript: */ +"use strict"; + +// This script slows the load of an HTML document so that we can reliably test +// all phases of the load cycle supported by the extension API. + +/* eslint-disable no-unused-vars */ + +const URL = "file_slowed_document.sjs"; + +const DELAY = 2 * 1000; // Delay two seconds before completing the request. + +let nsTimer = Components.Constructor( + "@mozilla.org/timer;1", + "nsITimer", + "initWithCallback" +); + +let timer; + +function handleRequest(request, response) { + response.processAsync(); + + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Cache-Control", "no-cache", false); + response.write(`<!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="UTF-8"> + <title></title> + </head> + <body> + `); + + // Note: We need to store a reference to the timer to prevent it from being + // canceled when it's GCed. + timer = new nsTimer( + () => { + if (request.queryString.includes("with-iframe")) { + response.write(`<iframe src="${URL}?r=${Math.random()}"></iframe>`); + } + response.write(`</body></html>`); + response.finish(); + }, + DELAY, + Ci.nsITimer.TYPE_ONE_SHOT + ); +} diff --git a/toolkit/components/extensions/test/mochitest/file_streamfilter.txt b/toolkit/components/extensions/test/mochitest/file_streamfilter.txt new file mode 100644 index 0000000000..56cdd85e1d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_streamfilter.txt @@ -0,0 +1 @@ +Middle diff --git a/toolkit/components/extensions/test/mochitest/file_style_bad.css b/toolkit/components/extensions/test/mochitest/file_style_bad.css new file mode 100644 index 0000000000..8dbc8dc7a4 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_style_bad.css @@ -0,0 +1,3 @@ +#test { + color: green !important; +} diff --git a/toolkit/components/extensions/test/mochitest/file_style_good.css b/toolkit/components/extensions/test/mochitest/file_style_good.css new file mode 100644 index 0000000000..46f9774b5f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_style_good.css @@ -0,0 +1,3 @@ +#test { + color: red; +} diff --git a/toolkit/components/extensions/test/mochitest/file_style_redirect.css b/toolkit/components/extensions/test/mochitest/file_style_redirect.css new file mode 100644 index 0000000000..8dbc8dc7a4 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_style_redirect.css @@ -0,0 +1,3 @@ +#test { + color: green !important; +} diff --git a/toolkit/components/extensions/test/mochitest/file_tabs_permission_page1.html b/toolkit/components/extensions/test/mochitest/file_tabs_permission_page1.html new file mode 100644 index 0000000000..63f503ad3c --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_tabs_permission_page1.html @@ -0,0 +1,10 @@ +<!DOCTYPE HTML> + +<html> +<head> + <title>The Title</title> +</head> +<body> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_tabs_permission_page2.html b/toolkit/components/extensions/test/mochitest/file_tabs_permission_page2.html new file mode 100644 index 0000000000..87ac7a2f64 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_tabs_permission_page2.html @@ -0,0 +1,11 @@ +<!DOCTYPE HTML> + +<html> +<head> + <title>Another Title</title> + <link href="file_image_great.png" rel="icon" type="image/png" /> +</head> +<body> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_third_party.html b/toolkit/components/extensions/test/mochitest/file_third_party.html new file mode 100644 index 0000000000..fc5a326297 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_third_party.html @@ -0,0 +1,21 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<script> + +"use strict" + +let url = new URL(location); +let img = new Image(); +img.src = `http://${url.searchParams.get("domain")}/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png`; +document.body.appendChild(img); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_to_drawWindow.html b/toolkit/components/extensions/test/mochitest/file_to_drawWindow.html new file mode 100644 index 0000000000..6ebd54d9a3 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_to_drawWindow.html @@ -0,0 +1,9 @@ +<!doctype html> +<html> +<head> + <meta charset="utf-8"> +</head> +<body style="background: #ff9"> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect.html new file mode 100644 index 0000000000..cba3043f71 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect.html @@ -0,0 +1,9 @@ +<!DOCTYPE HTML> + +<html> + <head> + <meta http-equiv="refresh" content="1;dummy_page.html"> + </head> + <body> + </body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html new file mode 100644 index 0000000000..c5b436979f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html @@ -0,0 +1,8 @@ +<!DOCTYPE HTML> + +<html> + <head> + </head> + <body> + </body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html^headers^ b/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html^headers^ new file mode 100644 index 0000000000..574a392a15 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html^headers^ @@ -0,0 +1 @@ +Refresh: 1;url=dummy_page.html diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_frameClientRedirect.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_frameClientRedirect.html new file mode 100644 index 0000000000..d360bcbb13 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_frameClientRedirect.html @@ -0,0 +1,12 @@ +<!DOCTYPE HTML> + +<html> +<body> + +<iframe src="file_webNavigation_clientRedirect.html" width="200" height="200"></iframe> + +<form> +</form> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_frameRedirect.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_frameRedirect.html new file mode 100644 index 0000000000..06dbd43741 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_frameRedirect.html @@ -0,0 +1,12 @@ +<!DOCTYPE HTML> + +<html> +<body> + +<iframe src="redirection.sjs" width="200" height="200"></iframe> + +<form> +</form> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe.html new file mode 100644 index 0000000000..307990714b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe.html @@ -0,0 +1,12 @@ +<!DOCTYPE HTML> + +<html> +<body> + +<iframe src="file_webNavigation_manualSubframe_page1.html" width="200" height="200"></iframe> + +<form> +</form> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page1.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page1.html new file mode 100644 index 0000000000..55bb7aa6ae --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page1.html @@ -0,0 +1,8 @@ +<!DOCTYPE html> + +<html> + <body> + <h1>page1</h1> + <a href="file_webNavigation_manualSubframe_page2.html">page2</a> + </body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page2.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page2.html new file mode 100644 index 0000000000..8f589f8bbd --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page2.html @@ -0,0 +1,7 @@ +<!DOCTYPE html> + +<html> + <body> + <h1>page2</h1> + </body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_with_about_blank.html b/toolkit/components/extensions/test/mochitest/file_with_about_blank.html new file mode 100644 index 0000000000..af51c2e52a --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_with_about_blank.html @@ -0,0 +1,10 @@ +<!doctype html> +<html> +<head> + <meta charset="utf-8"> +</head> +<body> + <iframe id="a_b" src="about:blank"></iframe> + <iframe srcdoc="galactica actual" src="adama"></iframe> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_with_images.html b/toolkit/components/extensions/test/mochitest/file_with_images.html new file mode 100644 index 0000000000..6a3c090be2 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_with_images.html @@ -0,0 +1,10 @@ +<!doctype html> +<html> +<head> + <meta charset="utf-8"> +</head> +<body> + <img src="https://example.com/chrome/toolkit/components/extensions/test/mochitest/file_image_good.png"> + <img src="http://mochi.test:8888/chrome/toolkit/components/extensions/test/mochitest/file_image_great.png"> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_with_subframes_and_embed.html b/toolkit/components/extensions/test/mochitest/file_with_subframes_and_embed.html new file mode 100644 index 0000000000..348c51f16c --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_with_subframes_and_embed.html @@ -0,0 +1,21 @@ +<!DOCTYPE HTML> +<meta charset="utf-8"> + +Load a bunch of iframes with subframes. +<p> +<iframe src="file_contains_iframe.html"></iframe> +<iframe src="file_WebNavigation_page1.html"></iframe> +<iframe src="file_with_xorigin_frame.html"></iframe> + +<p> +Load an embed frame. +<p> +<embed type="text/html" src="file_sample.html"></embed> + +<p> +And an object. +<p> +<object type="text/html" data="file_contains_img.html"></embed> + +<p> +Done. diff --git a/toolkit/components/extensions/test/mochitest/file_with_xorigin_frame.html b/toolkit/components/extensions/test/mochitest/file_with_xorigin_frame.html new file mode 100644 index 0000000000..25c60df078 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_with_xorigin_frame.html @@ -0,0 +1,6 @@ +<!DOCTYPE HTML> +<meta charset="utf-8"> + +<img src="file_image_great.png"/> +Load a cross-origin iframe from example.net <p> +<iframe src="https://example.net/tests/toolkit/components/extensions/test/mochitest/file_sample.html"></iframe> diff --git a/toolkit/components/extensions/test/mochitest/head.js b/toolkit/components/extensions/test/mochitest/head.js new file mode 100644 index 0000000000..48ed27a1ab --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/head.js @@ -0,0 +1,155 @@ +"use strict"; + +/* exported AppConstants, Assert, AppTestDelegate */ + +var { AppConstants } = SpecialPowers.ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { AppTestDelegate } = SpecialPowers.ChromeUtils.importESModule( + "resource://specialpowers/AppTestDelegate.sys.mjs" +); + +{ + let chromeScript = SpecialPowers.loadChromeScript( + SimpleTest.getTestFileURL("chrome_cleanup_script.js") + ); + + SimpleTest.registerCleanupFunction(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + + chromeScript.sendAsyncMessage("check-cleanup"); + + let results = await chromeScript.promiseOneMessage("cleanup-results"); + chromeScript.destroy(); + + if (results.extraWindows.length || results.extraTabs.length) { + ok( + false, + `Test left extra windows or tabs: ${JSON.stringify(results)}\n` + ); + } + }); +} + +let Assert = { + // Cut-down version based on Assert.sys.mjs. Only supports regexp and objects as + // the expected variables. + rejects(promise, expected, msg) { + return promise.then( + () => { + ok(false, msg); + }, + actual => { + let matched = false; + if (Object.prototype.toString.call(expected) == "[object RegExp]") { + if (expected.test(actual)) { + matched = true; + } + } else if (actual instanceof expected) { + matched = true; + } + + if (matched) { + ok(true, msg); + } else { + ok(false, `Unexpected exception for "${msg}": ${actual}`); + } + } + ); + }, +}; + +/* exported waitForLoad */ + +function waitForLoad(win) { + return new Promise(resolve => { + win.addEventListener( + "load", + function () { + resolve(); + }, + { capture: true, once: true } + ); + }); +} + +/* exported loadChromeScript */ +function loadChromeScript(fn) { + let wrapper = ` +(${fn.toString()})();`; + + return SpecialPowers.loadChromeScript(new Function(wrapper)); +} + +/* exported consoleMonitor */ +let consoleMonitor = { + start(messages) { + this.chromeScript = SpecialPowers.loadChromeScript( + SimpleTest.getTestFileURL("mochitest_console.js") + ); + this.chromeScript.sendAsyncMessage("consoleStart", messages); + }, + + async finished() { + let done = this.chromeScript.promiseOneMessage("consoleDone").then(done => { + this.chromeScript.destroy(); + return done; + }); + this.chromeScript.sendAsyncMessage("waitForConsole"); + let test = await done; + ok(test.ok, test.message); + }, +}; +/* exported waitForState */ + +function waitForState(sw, state) { + return new Promise(resolve => { + if (sw.state === state) { + return resolve(); + } + sw.addEventListener("statechange", function onStateChange() { + if (sw.state === state) { + sw.removeEventListener("statechange", onStateChange); + resolve(); + } + }); + }); +} + +/* exported assertPersistentListeners */ +async function assertPersistentListeners( + extWrapper, + apiNs, + apiEvents, + expected +) { + const stringErr = await SpecialPowers.spawnChrome( + [extWrapper.id, apiNs, apiEvents, expected], + async (id, apiNs, apiEvents, expected) => { + try { + const { ExtensionTestCommon } = ChromeUtils.importESModule( + "resource://testing-common/ExtensionTestCommon.sys.mjs" + ); + const ext = { id }; + for (const event of apiEvents) { + ExtensionTestCommon.testAssertions.assertPersistentListeners( + ext, + apiNs, + event, + { + primed: expected.primed, + persisted: expected.persisted, + primedListenersCount: expected.primedListenersCount, + } + ); + } + } catch (err) { + return String(err); + } + } + ); + ok( + stringErr == undefined, + stringErr ? stringErr : `Found expected primed and persistent listeners` + ); +} diff --git a/toolkit/components/extensions/test/mochitest/head_cookies.js b/toolkit/components/extensions/test/mochitest/head_cookies.js new file mode 100644 index 0000000000..610c800c94 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/head_cookies.js @@ -0,0 +1,287 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported testCookies */ +/* import-globals-from head.js */ + +async function testCookies(options) { + // Changing the options object is a bit of a hack, but it allows us to easily + // pass an expiration date to the background script. + options.expiry = Date.now() / 1000 + 3600; + + async function background(backgroundOptions) { + // Ask the parent scope to change some cookies we may or may not have + // permission for. + let awaitChanges = new Promise(resolve => { + browser.test.onMessage.addListener(msg => { + browser.test.assertEq("cookies-changed", msg, "browser.test.onMessage"); + resolve(); + }); + }); + + let changed = []; + browser.cookies.onChanged.addListener(event => { + changed.push(`${event.cookie.name}:${event.cause}`); + }); + browser.test.sendMessage("change-cookies"); + + // Try to access some cookies in various ways. + let { url, domain, secure } = backgroundOptions; + + let failures = 0; + let tallyFailure = error => { + failures++; + }; + + try { + await awaitChanges; + + let cookie = await browser.cookies.get({ url, name: "foo" }); + browser.test.assertEq( + backgroundOptions.shouldPass, + cookie != null, + "should pass == get cookie" + ); + + let cookies = await browser.cookies.getAll({ domain }); + if (backgroundOptions.shouldPass) { + browser.test.assertEq(2, cookies.length, "expected number of cookies"); + } else { + browser.test.assertEq(0, cookies.length, "expected number of cookies"); + } + + await Promise.all([ + browser.cookies + .set({ + url, + domain, + secure, + name: "foo", + value: "baz", + expirationDate: backgroundOptions.expiry, + }) + .catch(tallyFailure), + browser.cookies + .set({ + url, + domain, + secure, + name: "bar", + value: "quux", + expirationDate: backgroundOptions.expiry, + }) + .catch(tallyFailure), + browser.cookies.remove({ url, name: "deleted" }), + ]); + + if (backgroundOptions.shouldPass) { + // The order of eviction events isn't guaranteed, so just check that + // it's there somewhere. + let evicted = changed.indexOf("evicted:evicted"); + if (evicted < 0) { + browser.test.fail("got no eviction event"); + } else { + browser.test.succeed("got eviction event"); + changed.splice(evicted, 1); + } + + browser.test.assertEq( + "x:explicit,x:overwrite,x:explicit,x:explicit,foo:overwrite,foo:explicit,bar:explicit,deleted:explicit", + changed.join(","), + "expected changes" + ); + } else { + browser.test.assertEq("", changed.join(","), "expected no changes"); + } + + if (!(backgroundOptions.shouldPass || backgroundOptions.shouldWrite)) { + browser.test.assertEq(2, failures, "Expected failures"); + } else { + browser.test.assertEq(0, failures, "Expected no failures"); + } + + browser.test.notifyPass("cookie-permissions"); + } catch (error) { + browser.test.fail(`Error: ${error} :: ${error.stack}`); + browser.test.notifyFail("cookie-permissions"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: options.permissions, + }, + + background: `(${background})(${JSON.stringify(options)})`, + }); + + let stepOne = loadChromeScript(() => { + const { addMessageListener, sendAsyncMessage } = this; + addMessageListener("options", options => { + let domain = options.domain.replace(/^\.?/, "."); + // This will be evicted after we add a fourth cookie. + Services.cookies.add( + domain, + "/", + "evicted", + "bar", + options.secure, + false, + false, + options.expiry, + {}, + Ci.nsICookie.SAMESITE_NONE, + options.url.startsWith("https") + ? Ci.nsICookie.SCHEME_HTTPS + : Ci.nsICookie.SCHEME_HTTP + ); + // This will be modified by the background script. + Services.cookies.add( + domain, + "/", + "foo", + "bar", + options.secure, + false, + false, + options.expiry, + {}, + Ci.nsICookie.SAMESITE_NONE, + options.url.startsWith("https") + ? Ci.nsICookie.SCHEME_HTTPS + : Ci.nsICookie.SCHEME_HTTP + ); + // This will be deleted by the background script. + Services.cookies.add( + domain, + "/", + "deleted", + "bar", + options.secure, + false, + false, + options.expiry, + {}, + Ci.nsICookie.SAMESITE_NONE, + options.url.startsWith("https") + ? Ci.nsICookie.SCHEME_HTTPS + : Ci.nsICookie.SCHEME_HTTP + ); + sendAsyncMessage("done"); + }); + }); + stepOne.sendAsyncMessage("options", options); + await stepOne.promiseOneMessage("done"); + stepOne.destroy(); + + await extension.startup(); + + await extension.awaitMessage("change-cookies"); + + let stepTwo = loadChromeScript(() => { + const { addMessageListener, sendAsyncMessage } = this; + addMessageListener("options", options => { + let domain = options.domain.replace(/^\.?/, "."); + + Services.cookies.add( + domain, + "/", + "x", + "y", + options.secure, + false, + false, + options.expiry, + {}, + Ci.nsICookie.SAMESITE_NONE, + options.url.startsWith("https") + ? Ci.nsICookie.SCHEME_HTTPS + : Ci.nsICookie.SCHEME_HTTP + ); + Services.cookies.add( + domain, + "/", + "x", + "z", + options.secure, + false, + false, + options.expiry, + {}, + Ci.nsICookie.SAMESITE_NONE, + options.url.startsWith("https") + ? Ci.nsICookie.SCHEME_HTTPS + : Ci.nsICookie.SCHEME_HTTP + ); + Services.cookies.remove(domain, "x", "/", {}); + sendAsyncMessage("done"); + }); + }); + stepTwo.sendAsyncMessage("options", options); + await stepTwo.promiseOneMessage("done"); + stepTwo.destroy(); + + extension.sendMessage("cookies-changed"); + + await extension.awaitFinish("cookie-permissions"); + await extension.unload(); + + let stepThree = loadChromeScript(() => { + const { addMessageListener, sendAsyncMessage, assert } = this; + let cookieSvc = Services.cookies; + + function getCookies(host) { + let cookies = []; + for (let cookie of cookieSvc.getCookiesFromHost(host, {})) { + cookies.push(cookie); + } + return cookies.sort((a, b) => a.name.localeCompare(b.name)); + } + + addMessageListener("options", options => { + let cookies = getCookies(options.domain); + + if (options.shouldPass) { + assert.equal(cookies.length, 2, "expected two cookies for host"); + + assert.equal(cookies[0].name, "bar", "correct cookie name"); + assert.equal(cookies[0].value, "quux", "correct cookie value"); + + assert.equal(cookies[1].name, "foo", "correct cookie name"); + assert.equal(cookies[1].value, "baz", "correct cookie value"); + } else if (options.shouldWrite) { + // Note: |shouldWrite| applies only when |shouldPass| is false. + // This is necessary because, unfortunately, websites (and therefore web + // extensions) are allowed to write some cookies which they're not allowed + // to read. + assert.equal(cookies.length, 3, "expected three cookies for host"); + + assert.equal(cookies[0].name, "bar", "correct cookie name"); + assert.equal(cookies[0].value, "quux", "correct cookie value"); + + assert.equal(cookies[1].name, "deleted", "correct cookie name"); + + assert.equal(cookies[2].name, "foo", "correct cookie name"); + assert.equal(cookies[2].value, "baz", "correct cookie value"); + } else { + assert.equal(cookies.length, 2, "expected two cookies for host"); + + assert.equal(cookies[0].name, "deleted", "correct second cookie name"); + + assert.equal(cookies[1].name, "foo", "correct cookie name"); + assert.equal(cookies[1].value, "bar", "correct cookie value"); + } + + for (let cookie of cookies) { + cookieSvc.remove(cookie.host, cookie.name, "/", {}); + } + // Make sure we don't silently poison subsequent tests if something goes wrong. + assert.equal(getCookies(options.domain).length, 0, "cookies cleared"); + sendAsyncMessage("done"); + }); + }); + stepThree.sendAsyncMessage("options", options); + await stepThree.promiseOneMessage("done"); + stepThree.destroy(); +} diff --git a/toolkit/components/extensions/test/mochitest/head_notifications.js b/toolkit/components/extensions/test/mochitest/head_notifications.js new file mode 100644 index 0000000000..bba3f59d49 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/head_notifications.js @@ -0,0 +1,171 @@ +"use strict"; + +/* exported MockAlertsService */ + +function mockServicesChromeScript() { + /* eslint-env mozilla/chrome-script */ + + const MOCK_ALERTS_CID = Components.ID( + "{48068bc2-40ab-4904-8afd-4cdfb3a385f3}" + ); + const ALERTS_SERVICE_CONTRACT_ID = "@mozilla.org/alerts-service;1"; + + const { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" + ); + const registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar); + + let activeNotifications = Object.create(null); + + const mockAlertsService = { + showPersistentNotification: function ( + persistentData, + alert, + alertListener + ) { + this.showAlert(alert, alertListener); + }, + + showAlert: function (alert, listener) { + activeNotifications[alert.name] = { + listener: listener, + cookie: alert.cookie, + title: alert.title, + }; + + // fake async alert show event + if (listener) { + setTimeout(function () { + listener.observe(null, "alertshow", alert.cookie); + }, 100); + } + }, + + showAlertNotification: function ( + imageUrl, + title, + text, + textClickable, + cookie, + alertListener, + name + ) { + this.showAlert( + { + name: name, + cookie: cookie, + title: title, + }, + alertListener + ); + }, + + closeAlert: function (name) { + let alertNotification = activeNotifications[name]; + if (alertNotification) { + if (alertNotification.listener) { + alertNotification.listener.observe( + null, + "alertfinished", + alertNotification.cookie + ); + } + delete activeNotifications[name]; + } + }, + + QueryInterface: ChromeUtils.generateQI(["nsIAlertsService"]), + + createInstance: function (iid) { + return this.QueryInterface(iid); + }, + }; + + registrar.registerFactory( + MOCK_ALERTS_CID, + "alerts service", + ALERTS_SERVICE_CONTRACT_ID, + mockAlertsService + ); + + function clickNotifications(doClose) { + // Until we need to close a specific notification, just click them all. + for (let [name, notification] of Object.entries(activeNotifications)) { + let { listener, cookie } = notification; + listener.observe(null, "alertclickcallback", cookie); + if (doClose) { + mockAlertsService.closeAlert(name); + } + } + } + + function closeAllNotifications() { + for (let alertName of Object.keys(activeNotifications)) { + mockAlertsService.closeAlert(alertName); + } + } + + const { addMessageListener, sendAsyncMessage } = this; + + addMessageListener("mock-alert-service:unregister", () => { + closeAllNotifications(); + activeNotifications = null; + registrar.unregisterFactory(MOCK_ALERTS_CID, mockAlertsService); + sendAsyncMessage("mock-alert-service:unregistered"); + }); + + addMessageListener( + "mock-alert-service:click-notifications", + clickNotifications + ); + + addMessageListener( + "mock-alert-service:close-notifications", + closeAllNotifications + ); + + sendAsyncMessage("mock-alert-service:registered"); +} + +const MockAlertsService = { + async register() { + if (this._chromeScript) { + throw new Error("MockAlertsService already registered"); + } + this._chromeScript = SpecialPowers.loadChromeScript( + mockServicesChromeScript + ); + await this._chromeScript.promiseOneMessage("mock-alert-service:registered"); + }, + async unregister() { + if (!this._chromeScript) { + throw new Error("MockAlertsService not registered"); + } + this._chromeScript.sendAsyncMessage("mock-alert-service:unregister"); + return this._chromeScript + .promiseOneMessage("mock-alert-service:unregistered") + .then(() => { + this._chromeScript.destroy(); + this._chromeScript = null; + }); + }, + async clickNotifications() { + // Most implementations of the nsIAlertsService automatically close upon click. + await this._chromeScript.sendAsyncMessage( + "mock-alert-service:click-notifications", + true + ); + }, + async clickNotificationsWithoutClose() { + // The implementation on macOS does not automatically close the notification. + await this._chromeScript.sendAsyncMessage( + "mock-alert-service:click-notifications", + false + ); + }, + async closeNotifications() { + await this._chromeScript.sendAsyncMessage( + "mock-alert-service:close-notifications" + ); + }, +}; diff --git a/toolkit/components/extensions/test/mochitest/head_unlimitedStorage.js b/toolkit/components/extensions/test/mochitest/head_unlimitedStorage.js new file mode 100644 index 0000000000..2194e156dd --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/head_unlimitedStorage.js @@ -0,0 +1,45 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported checkSitePermissions */ + +const { Services } = SpecialPowers; +const { NetUtil } = SpecialPowers.ChromeUtils.importESModule( + "resource://gre/modules/NetUtil.sys.mjs" +); + +function checkSitePermissions(uuid, expectedPermAction, assertMessage) { + if (!uuid) { + throw new Error( + "checkSitePermissions should not be called with an undefined uuid" + ); + } + + const baseURI = NetUtil.newURI(`moz-extension://${uuid}/`); + const principal = Services.scriptSecurityManager.createContentPrincipal( + baseURI, + {} + ); + + const sitePermissions = { + webextUnlimitedStorage: Services.perms.testPermissionFromPrincipal( + principal, + "WebExtensions-unlimitedStorage" + ), + persistentStorage: Services.perms.testPermissionFromPrincipal( + principal, + "persistent-storage" + ), + }; + + for (const [sitePermissionName, actualPermAction] of Object.entries( + sitePermissions + )) { + is( + actualPermAction, + expectedPermAction, + `The extension "${sitePermissionName}" SitePermission ${assertMessage} as expected` + ); + } +} diff --git a/toolkit/components/extensions/test/mochitest/head_webrequest.js b/toolkit/components/extensions/test/mochitest/head_webrequest.js new file mode 100644 index 0000000000..9e6b5cc910 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/head_webrequest.js @@ -0,0 +1,481 @@ +"use strict"; + +let commonEvents = { + onBeforeRequest: [{ urls: ["<all_urls>"] }, ["blocking"]], + onBeforeSendHeaders: [ + { urls: ["<all_urls>"] }, + ["blocking", "requestHeaders"], + ], + onSendHeaders: [{ urls: ["<all_urls>"] }, ["requestHeaders"]], + onBeforeRedirect: [{ urls: ["<all_urls>"] }], + onHeadersReceived: [ + { urls: ["<all_urls>"] }, + ["blocking", "responseHeaders"], + ], + // Auth tests will need to set their own events object + // "onAuthRequired": [{urls: ["<all_urls>"]}, ["blocking", "responseHeaders"]], + onResponseStarted: [{ urls: ["<all_urls>"] }], + onCompleted: [{ urls: ["<all_urls>"] }, ["responseHeaders"]], + onErrorOccurred: [{ urls: ["<all_urls>"] }], +}; + +function background(events) { + const IP_PATTERN = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; + + let expect; + let ignore; + let defaultOrigin; + let watchAuth = Object.keys(events).includes("onAuthRequired"); + let expectedIp = null; + + browser.test.onMessage.addListener((msg, expected) => { + if (msg !== "set-expected") { + return; + } + expect = expected.expect; + defaultOrigin = expected.origin; + ignore = expected.ignore; + let promises = []; + // Initialize some stuff we'll need in the tests. + for (let entry of Object.values(expect)) { + // a place for the test infrastructure to store some state. + entry.test = {}; + // Each entry in expected gets a Promise that will be resolved in the + // last event for that entry. This will either be onCompleted, or the + // last entry if an events list was provided. + promises.push( + new Promise(resolve => { + entry.test.resolve = resolve; + }) + ); + // If events was left undefined, we're expecting all normal events we're + // listening for, exclude onBeforeRedirect and onErrorOccurred + if (entry.events === undefined) { + entry.events = Object.keys(events).filter( + name => name != "onErrorOccurred" && name != "onBeforeRedirect" + ); + } + if (entry.optional_events === undefined) { + entry.optional_events = []; + } + } + // When every expected entry has finished our test is done. + Promise.all(promises).then(() => { + browser.test.sendMessage("done"); + }); + browser.test.sendMessage("continue"); + }); + + // Retrieve the per-file/test expected values. + function getExpected(details) { + let url = new URL(details.url); + let filename = url.pathname.split("/").pop(); + if (ignore && ignore.includes(filename)) { + return; + } + let expected = expect[filename]; + if (!expected) { + browser.test.fail(`unexpected request ${filename}`); + return; + } + // Save filename for redirect verification. + expected.test.filename = filename; + return expected; + } + + // Process any test header modifications that can happen in request or response phases. + // If a test includes headers, it needs a complete header object, no undefined + // objects even if empty: + // request: { + // add: {"HeaderName": "value",}, + // modify: {"HeaderName": "value",}, + // remove: ["HeaderName",], + // }, + // response: { + // add: {"HeaderName": "value",}, + // modify: {"HeaderName": "value",}, + // remove: ["HeaderName",], + // }, + function processHeaders(phase, expected, details) { + // This should only happen once per phase [request|response]. + browser.test.assertFalse( + !!expected.test[phase], + `First processing of headers for ${phase}` + ); + expected.test[phase] = true; + + let headers = details[`${phase}Headers`]; + browser.test.assertTrue( + Array.isArray(headers), + `${phase}Headers array present` + ); + + let { add, modify, remove } = expected.headers[phase]; + + for (let name in add) { + browser.test.assertTrue( + !headers.find(h => h.name === name), + `header ${name} to be added not present yet in ${phase}Headers` + ); + let header = { name: name }; + if (name.endsWith("-binary")) { + header.binaryValue = Array.from(add[name], c => c.charCodeAt(0)); + } else { + header.value = add[name]; + } + headers.push(header); + } + + let modifiedAny = false; + for (let header of headers) { + if (header.name.toLowerCase() in modify) { + header.value = modify[header.name.toLowerCase()]; + modifiedAny = true; + } + } + browser.test.assertTrue( + modifiedAny, + `at least one ${phase}Headers element to modify` + ); + + let deletedAny = false; + for (let j = headers.length; j-- > 0; ) { + if (remove.includes(headers[j].name.toLowerCase())) { + headers.splice(j, 1); + deletedAny = true; + } + } + browser.test.assertTrue( + deletedAny, + `at least one ${phase}Headers element to delete` + ); + + return headers; + } + + // phase is request or response. + function checkHeaders(phase, expected, details) { + if (!/^https?:/.test(details.url)) { + return; + } + + let headers = details[`${phase}Headers`]; + browser.test.assertTrue( + Array.isArray(headers), + `valid ${phase}Headers array` + ); + + let { add, modify, remove } = expected.headers[phase]; + for (let name in add) { + let value = headers.find( + h => h.name.toLowerCase() === name.toLowerCase() + ).value; + browser.test.assertEq( + value, + add[name], + `header ${name} correctly injected in ${phase}Headers` + ); + } + + for (let name in modify) { + let value = headers.find( + h => h.name.toLowerCase() === name.toLowerCase() + ).value; + browser.test.assertEq( + value, + modify[name], + `header ${name} matches modified value` + ); + } + + for (let name of remove) { + let found = headers.find( + h => h.name.toLowerCase() === name.toLowerCase() + ); + browser.test.assertFalse( + !!found, + `deleted header ${name} still found in ${phase}Headers` + ); + } + } + + let listeners = { + onBeforeRequest(expected, details, result) { + // Save some values to test request consistency in later events. + browser.test.assertTrue( + details.tabId !== undefined, + `tabId ${details.tabId}` + ); + browser.test.assertTrue( + details.requestId !== undefined, + `requestId ${details.requestId}` + ); + // Validate requestId if it's already set, this happens with redirects. + if (expected.test.requestId !== undefined) { + browser.test.assertEq( + "string", + typeof expected.test.requestId, + `requestid ${expected.test.requestId} is string` + ); + browser.test.assertEq( + "string", + typeof details.requestId, + `requestid ${details.requestId} is string` + ); + browser.test.assertEq( + "number", + typeof parseInt(details.requestId, 10), + "parsed requestid is number" + ); + browser.test.assertEq( + expected.test.requestId, + details.requestId, + "redirects will keep the same requestId" + ); + } else { + // Save any values we want to validate in later events. + expected.test.requestId = details.requestId; + expected.test.tabId = details.tabId; + } + // Tests we don't need to do every event. + browser.test.assertTrue( + details.type.toUpperCase() in browser.webRequest.ResourceType, + `valid resource type ${details.type}` + ); + if (details.type == "main_frame") { + browser.test.assertEq( + 0, + details.frameId, + "frameId is zero when type is main_frame, see bug 1329299" + ); + } + }, + onBeforeSendHeaders(expected, details, result) { + if (expected.headers && expected.headers.request) { + result.requestHeaders = processHeaders("request", expected, details); + } + if (expected.redirect) { + browser.test.log(`${name} redirect request`); + result.redirectUrl = details.url.replace( + expected.test.filename, + expected.redirect + ); + } + }, + onBeforeRedirect() {}, + onSendHeaders(expected, details, result) { + if (expected.headers && expected.headers.request) { + checkHeaders("request", expected, details); + } + }, + onResponseStarted() {}, + onHeadersReceived(expected, details, result) { + let expectedStatus = expected.status || 200; + // If authentication is being requested we don't fail on the status code. + if (watchAuth && [401, 407].includes(details.statusCode)) { + expectedStatus = details.statusCode; + } + browser.test.assertEq( + expectedStatus, + details.statusCode, + `expected HTTP status received for ${details.url} ${details.statusLine}` + ); + if (expected.headers && expected.headers.response) { + result.responseHeaders = processHeaders("response", expected, details); + } + }, + onAuthRequired(expected, details, result) { + result.authCredentials = expected.authInfo; + }, + onCompleted(expected, details, result) { + // If we have already completed a GET request for this url, + // and it was found, we expect for the response to come fromCache. + // expected.cached may be undefined, force boolean. + if (typeof expected.cached === "boolean") { + let expectCached = + expected.cached && + details.method === "GET" && + details.statusCode != 404; + browser.test.assertEq( + expectCached, + details.fromCache, + "fromCache is correct" + ); + } + // We can only tell IPs for non-cached HTTP requests. + if (!details.fromCache && /^https?:/.test(details.url)) { + browser.test.assertTrue( + IP_PATTERN.test(details.ip), + `IP for ${details.url} looks IP-ish: ${details.ip}` + ); + + // We can't easily predict the IP ahead of time, so just make + // sure they're all consistent. + expectedIp = expectedIp || details.ip; + browser.test.assertEq( + expectedIp, + details.ip, + `correct ip for ${details.url}` + ); + } + if (expected.headers && expected.headers.response) { + checkHeaders("response", expected, details); + } + }, + onErrorOccurred(expected, details, result) { + if (expected.error) { + if (Array.isArray(expected.error)) { + browser.test.assertTrue( + expected.error.includes(details.error), + "expected error message received in onErrorOccurred" + ); + } else { + browser.test.assertEq( + expected.error, + details.error, + "expected error message received in onErrorOccurred" + ); + } + } + }, + }; + + function getListener(name) { + return details => { + let result = {}; + browser.test.log(`${name} ${details.requestId} ${details.url}`); + let expected = getExpected(details); + if (!expected) { + return result; + } + let expectedEvent = expected.events[0] == name; + if (expectedEvent) { + expected.events.shift(); + } else { + // e10s vs. non-e10s errors can end with either onCompleted or onErrorOccurred + expectedEvent = expected.optional_events.includes(name); + } + browser.test.assertTrue(expectedEvent, `received ${name}`); + browser.test.assertEq( + expected.type, + details.type, + "resource type is correct" + ); + browser.test.assertEq( + expected.origin || defaultOrigin, + details.originUrl, + "origin is correct" + ); + + if (name != "onBeforeRequest") { + // On events after onBeforeRequest, check the previous values. + browser.test.assertEq( + expected.test.requestId, + details.requestId, + "correct requestId" + ); + browser.test.assertEq( + expected.test.tabId, + details.tabId, + "correct tabId" + ); + } + try { + listeners[name](expected, details, result); + } catch (e) { + browser.test.fail(`unexpected webrequest failure ${name} ${e}`); + } + + if (expected.cancel && expected.cancel == name) { + browser.test.log(`${name} cancel request`); + browser.test.sendMessage("cancelled"); + result.cancel = true; + } + // If we've used up all the events for this test, resolve the promise. + // If something wrong happens and more events come through, there will be + // failures. + if (expected.events.length <= 0) { + expected.test.resolve(); + } + return result; + }; + } + + for (let [name, args] of Object.entries(events)) { + browser.test.log(`adding listener for ${name}`); + try { + browser.webRequest[name].addListener(getListener(name), ...args); + } catch (e) { + browser.test.assertTrue( + /\brequestBody\b/.test(e.message), + "Request body is unsupported" + ); + + // RequestBody is disabled in release builds. + if (!/\brequestBody\b/.test(e.message)) { + throw e; + } + + args.splice(args.indexOf("requestBody"), 1); + browser.webRequest[name].addListener(getListener(name), ...args); + } + } +} + +/* exported makeExtension */ + +function makeExtension(events = commonEvents) { + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + background: `(${background})(${JSON.stringify(events)})`, + }); +} + +/* exported addStylesheet */ + +function addStylesheet(file) { + let link = document.createElement("link"); + link.setAttribute("rel", "stylesheet"); + link.setAttribute("href", file); + document.body.appendChild(link); +} + +/* exported addLink */ + +function addLink(file) { + let a = document.createElement("a"); + a.setAttribute("href", file); + a.setAttribute("target", "_blank"); + a.setAttribute("rel", "opener"); + document.body.appendChild(a); + return a; +} + +/* exported addImage */ + +function addImage(file) { + let img = document.createElement("img"); + img.setAttribute("src", file); + document.body.appendChild(img); +} + +/* exported addScript */ + +function addScript(file) { + let script = document.createElement("script"); + script.setAttribute("type", "text/javascript"); + script.setAttribute("src", file); + document.getElementsByTagName("head").item(0).appendChild(script); +} + +/* exported addFrame */ + +function addFrame(file) { + let frame = document.createElement("iframe"); + frame.setAttribute("width", "200"); + frame.setAttribute("height", "200"); + frame.setAttribute("src", file); + document.body.appendChild(frame); +} diff --git a/toolkit/components/extensions/test/mochitest/hsts.sjs b/toolkit/components/extensions/test/mochitest/hsts.sjs new file mode 100644 index 0000000000..52b9dd340b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/hsts.sjs @@ -0,0 +1,10 @@ +"use strict"; + +function handleRequest(request, response) { + let page = "<!DOCTYPE html><html><body><p>HSTS page</p></body></html>"; + response.setStatusLine(request.httpVersion, "200", "OK"); + response.setHeader("Strict-Transport-Security", "max-age=60"); + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Content-Length", page.length + "", false); + response.write(page); +} diff --git a/toolkit/components/extensions/test/mochitest/mochitest-common.toml b/toolkit/components/extensions/test/mochitest/mochitest-common.toml new file mode 100644 index 0000000000..51a851a74b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/mochitest-common.toml @@ -0,0 +1,594 @@ +[DEFAULT] +tags = "condprof" +support-files = [ + "chrome_cleanup_script.js", + "file_WebNavigation_page1.html", + "file_WebNavigation_page2.html", + "file_WebNavigation_page3.html", + "file_WebRequest_page3.html", + "file_contains_img.html", + "file_contains_iframe.html", + "file_green.html", + "file_green_blue.html", + "file_contentscript_activeTab.html", + "file_contentscript_activeTab2.html", + "file_contentscript_iframe.html", + "file_image_bad.png", + "file_image_good.png", + "file_image_great.png", + "file_image_redirect.png", + "file_indexedDB.html", + "file_mixed.html", + "file_remote_frame.html", + "file_sample.html", + "file_sample.txt", + "file_sample.txt^headers^", + "file_script_bad.js", + "file_script_good.js", + "file_script_redirect.js", + "file_script_xhr.js", + "file_serviceWorker.html", + "file_simple_iframe_worker.html", + "file_simple_sandboxed_frame.html", + "file_simple_sandboxed_subframe.html", + "file_simple_xhr.html", + "file_simple_xhr_frame.html", + "file_simple_xhr_frame2.html", + "file_simple_sharedworker.js", + "file_simple_webrequest_worker.html", + "file_simple_worker.js", + "file_slowed_document.sjs", + "file_streamfilter.txt", + "file_style_bad.css", + "file_style_good.css", + "file_style_redirect.css", + "file_third_party.html", + "file_to_drawWindow.html", + "file_webNavigation_clientRedirect.html", + "file_webNavigation_clientRedirect_httpHeaders.html", + "file_webNavigation_clientRedirect_httpHeaders.html^headers^", + "file_webNavigation_frameClientRedirect.html", + "file_webNavigation_frameRedirect.html", + "file_webNavigation_manualSubframe.html", + "file_webNavigation_manualSubframe_page1.html", + "file_webNavigation_manualSubframe_page2.html", + "file_with_about_blank.html", + "file_with_subframes_and_embed.html", + "file_with_xorigin_frame.html", + "head.js", + "head_cookies.js", + "head_notifications.js", + "head_unlimitedStorage.js", + "head_webrequest.js", + "hsts.sjs", + "mochitest_console.js", + "oauth.html", + "redirect_auto.sjs", + "redirection.sjs", + "return_headers.sjs", + "serviceWorker.js", + "slow_response.sjs", + "webrequest_worker.js", + "!/dom/tests/mochitest/geolocation/network_geolocation.sjs", + "!/toolkit/components/passwordmgr/test/authenticate.sjs", + "file_redirect_data_uri.html", + "file_redirect_cors_bypass.html", + "file_tabs_permission_page1.html", + "file_tabs_permission_page2.html", + "file_language_fr_en.html", + "file_language_ja.html", + "file_language_tlh.html", +] +prefs = [ + "security.mixed_content.upgrade_display_content=false", + "browser.chrome.guess_favicon=true", +] + +["test_check_startupcache.html"] + +["test_ext_action.html"] + +["test_ext_activityLog.html"] +skip-if = [ + "os == 'android'", # Bug 1845604: test case uses tabHide permission which is not available on Android + "tsan", # Times out on TSan, bug 1612707 + "xorigin", # Inconsistent pass/fail in opt and debug + "http3", + "http2", +] + +["test_ext_async_clipboard.html"] +skip-if = [ + "os == 'android'", # Bug 1845607 + "tsan", # Bug 1612707: times out on TSan + "display == 'wayland' && os_version == '22.04'", # Bug 1857067 +] + +["test_ext_background_canvas.html"] + +["test_ext_background_page.html"] +skip-if = ["os == 'android'"] # test case covering desktop-only expected behavior (android doesn't have devtools) + +["test_ext_background_page_dpi.html"] + +["test_ext_browserAction_getUserSettings.html"] + +["test_ext_browserAction_onClicked.html"] + +["test_ext_browserAction_openPopup.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_browserAction_openPopup_incognito_window.html"] +skip-if = ["os == 'android'"] # cannot open private windows - bug 1372178 + +["test_ext_browserAction_openPopup_windowId.html"] +skip-if = ["os == 'android'"] # only the current window is supported - bug 1795956 + +["test_ext_browserAction_openPopup_without_pref.html"] + +["test_ext_browserSettings_overrideDocumentColors.html"] + +["test_ext_browsingData_indexedDB.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_browsingData_localStorage.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_browsingData_pluginData.html"] + +["test_ext_browsingData_serviceWorkers.html"] +skip-if = [ + "condprof", # "Wait for 2 service workers to be registered - timed out after 50 tries." + "http3", + "http2", +] + +["test_ext_browsingData_settings.html"] + +["test_ext_canvas_resistFingerprinting.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_clipboard.html"] +skip-if = [ + "os == 'android'", # Bug 1845607 + "http3", + "http2", +] + +["test_ext_clipboard_image.html"] +skip-if = ["headless"] # Bug 1405872 + +["test_ext_contentscript_about_blank.html"] + +["test_ext_contentscript_activeTab.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_contentscript_cache.html"] +skip-if = [ + "os == 'linux' && debug", + "os == 'android' && debug", # bug 1348241 +] +fail-if = ["xorigin"] # TypeError: can't access property "staticScripts", ext is undefined - Should not throw any errors + +["test_ext_contentscript_canvas.html"] +skip-if = [ + "os == 'android'", # Bug 1617062 + "verify && debug && os == 'linux'", +] + +["test_ext_contentscript_devtools_metadata.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_contentscript_fission_frame.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_contentscript_getFrameId.html"] + +["test_ext_contentscript_incognito.html"] +skip-if = [ + "os == 'android'", # Bug 1513544 (GeckoView is missing the windows API and ability to open private tabs) + "http3", + "http2", +] + +["test_ext_contentscript_permission.html"] +skip-if = ["tsan"] # Times out on TSan, bug 1612707 + +["test_ext_contentscript_securecontext.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_cookies.html"] +skip-if = [ + "os == 'android'", # Bug 1845615 + "tsan", # Times out on TSan intermittently, bug 1615184; + "condprof", #: "one tabId returned for store - Expected: 1, Actual: 3" + "http3", + "http2", +] + +["test_ext_cookies_containers.html"] + +["test_ext_cookies_expiry.html"] + +["test_ext_cookies_first_party.html"] + +["test_ext_cookies_incognito.html"] +skip-if = ["os == 'android'"] # Bug 1513544 (GeckoView is missing the windows API and ability to open private tabs) + +["test_ext_cookies_permissions_bad.html"] + +["test_ext_cookies_permissions_good.html"] + +["test_ext_dnr_other_extensions.html"] + +["test_ext_dnr_tabIds.html"] + +["test_ext_dnr_upgradeScheme.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_downloads_download.html"] + +["test_ext_embeddedimg_iframe_frameAncestors.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_exclude_include_globs.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_extension_iframe_messaging.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_external_messaging.html"] + +["test_ext_generate.html"] + +["test_ext_geolocation.html"] +skip-if = ["os == 'android'"] # Bug 1336194 (GeckoView doesn't yet account for geolocation to be granted for extensions requesting it from their manifest) + +["test_ext_identity.html"] +skip-if = [ + "win11_2009 && !debug && socketprocess_networking", # Bug 1777016 + "os == 'android'", # Bug 1475887 (API not supported on android yet) + "tsan", # Bug 1612707 +] + +["test_ext_idle.html"] +skip-if = ["tsan"] # Times out on TSan, bug 1612707 + +["test_ext_inIncognitoContext_window.html"] +skip-if = ["os == 'android'"] # Bug 1513544 (GeckoView is missing the windows API and ability to open private tabs) + +["test_ext_listener_proxies.html"] + +["test_ext_new_tab_processType.html"] +skip-if = [ + "verify && debug && (os == 'linux' || os == 'mac')", + "condprof", #: Page URL should match - got "https://example.com/tests/toolkit/components/extensions/test/mochitest/file_serviceWorker.html", expected "https://example.com/" + "http3", + "http2", +] + +["test_ext_notifications.html"] +skip-if = ["os == 'android'"] # Bug 1845617 + +["test_ext_optional_permissions.html"] + +["test_ext_pageAction_onClicked.html"] + +["test_ext_protocolHandlers.html"] +skip-if = ["os == 'android'"] # Bug 1342577: not implemented on GeckoView yet + +["test_ext_redirect_jar.html"] +skip-if = ["os == 'win' && (debug || asan)"] # Bug 1563440 + +["test_ext_request_urlClassification.html"] +skip-if = [ + "os == 'android'", # Bug 1615427 + "http3", + "http2", +] + +["test_ext_runtime_connect.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_runtime_connect2.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_runtime_connect_iframe.html"] + +["test_ext_runtime_connect_twoway.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_runtime_disconnect.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_script_filenames.html"] + +["test_ext_scripting_contentScripts.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_scripting_executeScript.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_scripting_executeScript_activeTab.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_scripting_executeScript_injectImmediately.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_scripting_insertCSS.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_scripting_permissions.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_scripting_removeCSS.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_sendmessage_doublereply.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_sendmessage_frameId.html"] + +["test_ext_sendmessage_no_receiver.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_sendmessage_reply.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_sendmessage_reply2.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_storage_manager_capabilities.html"] +skip-if = [ + "xorigin", # JavaScript Error: "SecurityError: Permission denied to access property "wrappedJSObject" on cross-origin object" {file: "https://example.com/tests/SimpleTest/TestRunner.js" line: 157} + "http3", + "http2", +] +scheme = "https" + +["test_ext_storage_smoke_test.html"] + +["test_ext_streamfilter_multiple.html"] +skip-if = [ + "!debug", # Bug 1628642 + "os == 'linux'", # Bug 1628642 +] + +["test_ext_streamfilter_processswitch.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_subframes_privileges.html"] +skip-if = [ + "os == 'android'", # Bug 1845918 + "verify", # Bug 1489771 + "http3", + "http2", +] + +["test_ext_tabs_captureTab.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_tabs_create_cookieStoreId.html"] + +["test_ext_tabs_detectLanguage.html"] + +["test_ext_tabs_executeScript_good.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_tabs_permissions.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_tabs_query_popup.html"] + +["test_ext_tabs_sendMessage.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_test.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_unlimitedStorage.html"] + +["test_ext_web_accessible_incognito.html"] +skip-if = ["os == 'android'"] # bug 1397615, bug 1513544 + +["test_ext_web_accessible_resources.html"] +skip-if = [ + "os == 'android' && debug", # bug 1397615 + "os == 'linux' && bits == 64", # bug 1618231 +] + +["test_ext_webnavigation.html"] +skip-if = [ + "os == 'android' && debug", # bug 1397615 + "http3", + "http2", +] + +["test_ext_webnavigation_filters.html"] +skip-if = [ + "os == 'android' && debug", + "verify && (os == 'linux' || os == 'mac')", # bug 1397615 + "http3", + "http2", +] + +["test_ext_webnavigation_incognito.html"] +skip-if = [ + "os == 'android'", # Bug 1513544 (GeckoView is missing the windows API and ability to open private tabs) + "http3", + "http2", +] + +["test_ext_webrequest_and_proxy_filter.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_webrequest_auth.html"] +skip-if = [ + "os == 'android'", # Bug 1845906 (skip-if added for Fennec) + "http3", + "http2", +] + +["test_ext_webrequest_background_events.html"] + +["test_ext_webrequest_basic.html"] +skip-if = [ + "os == 'android' && debug", # bug 1397615 + "tsan", # bug 1612707 + "xorigin", # JavaScript Error: "SecurityError: Permission denied to access property "wrappedJSObject" on cross-origin object" {file: "http://mochi.false-test:8888/tests/SimpleTest/TestRunner.js" line: 157}] + "os == 'linux' && bits == 64 && !debug && asan", # Bug 1633189 + "http3", + "http2", +] + +["test_ext_webrequest_errors.html"] +skip-if = [ + "tsan", + "http3", + "http2", +] + +["test_ext_webrequest_filter.html"] +skip-if = [ + "os == 'android' && debug", # bug 1452348 + "tsan", # tsan: bug 1612707 + "os == 'linux' && bits == 64 && !debug && xorigin", # Bug 1756023 +] + +["test_ext_webrequest_frameId.html"] + +["test_ext_webrequest_getSecurityInfo.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_webrequest_hsts.html"] +https_first_disabled = true +skip-if = [ + "http3", + "http2", +] + +["test_ext_webrequest_redirect_bypass_cors.html"] + +["test_ext_webrequest_redirect_data_uri.html"] + +["test_ext_webrequest_upgrade.html"] +https_first_disabled = true + +["test_ext_webrequest_upload.html"] +skip-if = ["os == 'android'"] # Bug 1845906 (skip-if added for Fennec) + +["test_ext_webrequest_worker.html"] + +["test_ext_window_postMessage.html"] +skip-if = [ + "http3", + "http2", +] + +["test_startup_canary.html"] +# test_startup_canary.html is at the bottom to minimize the time spent waiting in the test. diff --git a/toolkit/components/extensions/test/mochitest/mochitest-remote.toml b/toolkit/components/extensions/test/mochitest/mochitest-remote.toml new file mode 100644 index 0000000000..4c7effd77d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/mochitest-remote.toml @@ -0,0 +1,12 @@ +[DEFAULT] +tags = "webextensions remote-webextensions" +prefs = [ + "extensions.webextensions.remote=true", + # We don't want to reset this at the end of the test, so that we don't have + # to spawn a new extension child process for each test unit. + "dom.ipc.keepProcessesAlive.extension=1", +] + +["include:mochitest-common.toml"] + +["test_verify_remote_mode.html"] diff --git a/toolkit/components/extensions/test/mochitest/mochitest-serviceworker.toml b/toolkit/components/extensions/test/mochitest/mochitest-serviceworker.toml new file mode 100644 index 0000000000..0e9e58307d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/mochitest-serviceworker.toml @@ -0,0 +1,34 @@ +[DEFAULT] +tags = "webextensions sw-webextensions condprof" +skip-if = [ + "!e10s", # Thunderbird does still run in non e10s mode (and so also with in-process-webextensions mode) + "http3", + "http2", +] + +prefs = [ + "extensions.webextensions.remote=true", + # We don't want to reset this at the end of the test, so that we don't have + # to spawn a new extension child process for each test unit. + "dom.ipc.keepProcessesAlive.extension=1", + "extensions.backgroundServiceWorker.enabled=true", + "extensions.backgroundServiceWorker.forceInTestExtension=true", +] +dupe-manifest = true + +["test_verify_sw_mode.html"] +# `test_verify_sw_mode.html` should be the first one, even if it breaks the +# alphabetical order. + +["test_ext_scripting_contentScripts.html"] + +["test_ext_scripting_executeScript.html"] +skip-if = ["true"] # Bug 1748315 - Add WebIDL bindings for `scripting.executeScript()` + +["test_ext_scripting_insertCSS.html"] +skip-if = ["true"] # Bug 1748318 - Add WebIDL bindings for `tabs` + +["test_ext_scripting_removeCSS.html"] +skip-if = ["true"] # Bug 1748318 - Add WebIDL bindings for `tabs` + +["test_ext_test.html"] diff --git a/toolkit/components/extensions/test/mochitest/mochitest.toml b/toolkit/components/extensions/test/mochitest/mochitest.toml new file mode 100644 index 0000000000..2a19953acb --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/mochitest.toml @@ -0,0 +1,15 @@ +[DEFAULT] +tags = "webextensions in-process-webextensions" +prefs = [ + "extensions.webextensions.remote=false", + "javascript.options.asyncstack_capture_debuggee_only=false", +] +dupe-manifest = true + +["include:mochitest-common.toml"] +skip-if = ["os == 'win'"] # Windows WebExtensions always run OOP + +["test_ext_storage_cleanup.html"] +# Bug 1426514 storage_cleanup: clearing localStorage fails with oop + +["test_verify_non_remote_mode.html"] diff --git a/toolkit/components/extensions/test/mochitest/mochitest_console.js b/toolkit/components/extensions/test/mochitest/mochitest_console.js new file mode 100644 index 0000000000..582e12b48f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/mochitest_console.js @@ -0,0 +1,54 @@ +/* eslint-env mozilla/chrome-script */ + +"use strict"; + +const { addMessageListener, sendAsyncMessage } = this; + +// Much of the console monitoring code is copied from TestUtils but simplified +// to our needs. +function monitorConsole(msgs) { + function msgMatches(msg, pat) { + for (let k in pat) { + if (!(k in msg)) { + return false; + } + if (pat[k] instanceof RegExp && typeof msg[k] === "string") { + if (!pat[k].test(msg[k])) { + return false; + } + } else if (msg[k] !== pat[k]) { + return false; + } + } + return true; + } + + let counter = 0; + function listener(msg) { + if (msgMatches(msg, msgs[counter])) { + counter++; + } + } + addMessageListener("waitForConsole", () => { + sendAsyncMessage("consoleDone", { + ok: counter >= msgs.length, + message: `monitorConsole | messages left expected at least ${msgs.length} got ${counter}`, + }); + Services.console.unregisterListener(listener); + }); + + Services.console.registerListener(listener); +} + +addMessageListener("consoleStart", messages => { + for (let msg of messages) { + // Message might be a RegExp object from a different compartment, but + // instanceof RegExp will fail. If we have an object, lets just make + // sure. + let message = msg.message; + if (typeof message == "object" && !(message instanceof RegExp)) { + msg.message = new RegExp(message); + } + } + monitorConsole(messages); +}); diff --git a/toolkit/components/extensions/test/mochitest/oauth.html b/toolkit/components/extensions/test/mochitest/oauth.html new file mode 100644 index 0000000000..8b9b1d65ec --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/oauth.html @@ -0,0 +1,26 @@ +<!DOCTYPE html> +<html> +<head> + <script> + "use strict"; + + onload = () => { + let url = new URL(location); + if (url.searchParams.get("post")) { + let server_redirect = `${url.searchParams.get("server_uri")}?redirect_uri=${encodeURIComponent(url.searchParams.get("redirect_uri"))}`; + let form = document.forms.testform; + form.setAttribute("action", server_redirect); + form.submit(); + } else { + let end = new URL(url.searchParams.get("redirect_uri")); + end.searchParams.set("access_token", "here ya go"); + location.href = end.href; + } + }; + </script> +</head> +<body> + <form name="testform" action="" method="POST"> + </form> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/redirect_auto.sjs b/toolkit/components/extensions/test/mochitest/redirect_auto.sjs new file mode 100644 index 0000000000..bf7af2556b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/redirect_auto.sjs @@ -0,0 +1,24 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ +"use strict"; +Cu.importGlobalProperties(["URLSearchParams", "URL"]); + +function handleRequest(request, response) { + let params = new URLSearchParams(request.queryString); + if (params.has("no_redirect")) { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); + } else { + if (request.method == "POST") { + response.setStatusLine(request.httpVersion, 303, "Redirected"); + } else { + response.setStatusLine(request.httpVersion, 302, "Moved Temporarily"); + } + let url = new URL( + params.get("redirect_uri") || params.get("default_redirect") + ); + url.searchParams.set("access_token", "here ya go"); + response.setHeader("Location", url.href); + } +} diff --git a/toolkit/components/extensions/test/mochitest/redirection.sjs b/toolkit/components/extensions/test/mochitest/redirection.sjs new file mode 100644 index 0000000000..873a3d41ba --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/redirection.sjs @@ -0,0 +1,6 @@ +"use strict"; + +function handleRequest(request, response) { + response.setStatusLine(request.httpVersion, 302); + response.setHeader("Location", "./dummy_page.html"); +} diff --git a/toolkit/components/extensions/test/mochitest/return_headers.sjs b/toolkit/components/extensions/test/mochitest/return_headers.sjs new file mode 100644 index 0000000000..46beab8185 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/return_headers.sjs @@ -0,0 +1,19 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported handleRequest */ + +function handleRequest(request, response) { + response.setHeader("Content-Type", "text/plain", false); + + let headers = {}; + // Why on earth is this a nsISimpleEnumerator... + let enumerator = request.headers; + while (enumerator.hasMoreElements()) { + let header = enumerator.getNext().data; + headers[header.toLowerCase()] = request.getHeader(header); + } + + response.write(JSON.stringify(headers)); +} diff --git a/toolkit/components/extensions/test/mochitest/serviceWorker.js b/toolkit/components/extensions/test/mochitest/serviceWorker.js new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/serviceWorker.js diff --git a/toolkit/components/extensions/test/mochitest/slow_response.sjs b/toolkit/components/extensions/test/mochitest/slow_response.sjs new file mode 100644 index 0000000000..d39c4c0bf0 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/slow_response.sjs @@ -0,0 +1,60 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80 ft=javascript: */ +"use strict"; + +/* eslint-disable no-unused-vars */ + +let { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +const DELAY = AppConstants.DEBUG ? 4000 : 800; + +let nsTimer = Components.Constructor( + "@mozilla.org/timer;1", + "nsITimer", + "initWithCallback" +); + +let timer; +function delay() { + return new Promise(resolve => { + timer = nsTimer(resolve, DELAY, Ci.nsITimer.TYPE_ONE_SHOT); + }); +} + +const PARTS = [ + `<!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="UTF-8"> + <title></title> + </head> + <body>`, + "Lorem ipsum dolor sit amet, <br>", + "consectetur adipiscing elit, <br>", + "sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. <br>", + "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. <br>", + "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <br>", + "Excepteur sint occaecat cupidatat non proident, <br>", + "sunt in culpa qui officia deserunt mollit anim id est laborum.<br>", + ` + </body> + </html>`, +]; + +async function handleRequest(request, response) { + response.processAsync(); + + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Cache-Control", "no-cache", false); + + await delay(); + + for (let part of PARTS) { + response.write(`${part}\n`); + await delay(); + } + + response.finish(); +} diff --git a/toolkit/components/extensions/test/mochitest/test_check_startupcache.html b/toolkit/components/extensions/test/mochitest/test_check_startupcache.html new file mode 100644 index 0000000000..8cb529d18d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_check_startupcache.html @@ -0,0 +1,63 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Check StartupCache</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function check_ExtensionParent_StartupCache_is_non_empty() { + // This test aims to verify that the StartupCache of extensions is populated. + // Ideally, we would load an extension, restart the browser and confirm the + // existence of the StartupCache. That is not possible in a mochitest. + // So we will just read the contents of the StartupCache and verify that it + // populated and assume that it carries over to the next startup. + // The latter is checked in test_startup_canary.html + + const { WebExtensionPolicy } = SpecialPowers.Cu.getGlobalForObject(SpecialPowers.Services); + // The Mochikit extension is part of the mochitests framework, so the fact + // that this test runs implies that the extension should have been started. + ok( + WebExtensionPolicy.getByID("mochikit@mozilla.org"), + "This test expects the Mochikit extension to be running" + ); + + let chromeScript = loadChromeScript(() => { + const { + ExtensionParent, + } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" + ); + const { StartupCache } = ExtensionParent; + this.sendAsyncMessage("StartupCache_data", StartupCache._data); + }); + + let map = await chromeScript.promiseOneMessage("StartupCache_data"); + chromeScript.destroy(); + + // "manifests" is populated by Extension's parseManifest in Extension.jsm. + const keys = ["manifests", "mochikit@mozilla.org", "2.0", "en-US"]; + for (let key of keys) { + map = map.get(key); + ok(map, `StartupCache data map contains ${key}`); + } + + // At this point `map` is expected to be the return value of + // ExtensionData's parseManifest. + + is( + map?.manifest?.applications?.gecko?.id, + "mochikit@mozilla.org", + "StartupCache.manifests contains a parsed manifest" + ); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_data_uri.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_data_uri.html new file mode 100644 index 0000000000..42950c50ec --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_data_uri.html @@ -0,0 +1,104 @@ +<!DOCTYPE html> +<html> +<head> + <title>Test content script matching a data: URI</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" href="chrome://mochikit/contents/tests/SimpleTest/test.css"/> +</head> +<body> + +<script> +"use strict"; + +add_task(async function test_contentscript_data_uri() { + const target = ExtensionTestUtils.loadExtension({ + files: { + "page.html": `<!DOCTYPE html> + <meta charset="utf-8"> + <iframe id="inherited" src="data:text/html;charset=utf-8,inherited"></iframe> + `, + }, + background() { + browser.test.sendMessage("page", browser.runtime.getURL("page.html")); + }, + }); + + const scripts = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webNavigation"], + content_scripts: [{ + all_frames: true, + matches: ["<all_urls>"], + run_at: "document_start", + css: ["all_urls.css"], + js: ["all_urls.js"], + }], + }, + files: { + "all_urls.css": ` + body { background: yellow; } + `, + "all_urls.js": function() { + document.body.style.color = "red"; + browser.test.assertTrue(location.protocol !== "data:", + `Matched document not a data URI: ${location.href}`); + }, + }, + background() { + browser.webNavigation.onCompleted.addListener(({url, frameId}) => { + browser.test.log(`Document loading complete: ${url}`); + if (frameId === 0) { + browser.test.sendMessage("tab-ready", url); + } + }); + }, + }); + + await target.startup(); + await scripts.startup(); + + // Test extension page with a data: iframe. + const page = await target.awaitMessage("page"); + + // Hold on to the tab by the browser, as extension loads are COOP loads, and + // will break WindowProxy references. + let win = window.open(); + const browserFrame = win.browsingContext.embedderElement; + win.location.href = page; + + await scripts.awaitMessage("tab-ready"); + win = browserFrame.contentWindow; + is(win.location.href, page, "Extension page loaded into a tab"); + is(win.document.readyState, "complete", "Page finished loading"); + + const iframe = win.document.getElementById("inherited").contentWindow; + is(iframe.document.readyState, "complete", "iframe finished loading"); + + const style1 = iframe.getComputedStyle(iframe.document.body); + is(style1.color, "rgb(0, 0, 0)", "iframe text color is unmodified"); + is(style1.backgroundColor, "rgba(0, 0, 0, 0)", "iframe background unmodified"); + + // Test extension tab navigated to a data: URI. + const data = "data:text/html;charset=utf-8,also-inherits"; + win.location.href = data; + + await scripts.awaitMessage("tab-ready"); + win = browserFrame.contentWindow; + is(win.location.href, data, "Extension tab navigated to a data: URI"); + is(win.document.readyState, "complete", "Tab finished loading"); + + const style2 = win.getComputedStyle(win.document.body); + is(style2.color, "rgb(0, 0, 0)", "Tab text color is unmodified"); + is(style2.backgroundColor, "rgba(0, 0, 0, 0)", "Tab background unmodified"); + + win.close(); + await target.unload(); + await scripts.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_telemetry.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_telemetry.html new file mode 100644 index 0000000000..d2bb66d507 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_telemetry.html @@ -0,0 +1,112 @@ +<!DOCTYPE html> +<html> +<head> + <title>Test for telemetry for content script injection</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" href="chrome://mochikit/contents/tests/SimpleTest/test.css"/> +</head> +<body> + +<script> +"use strict"; + +const HISTOGRAM = "WEBEXT_CONTENT_SCRIPT_INJECTION_MS"; +const GLEAN_METRIC_ID = "contentScriptInjection"; + +function assertHistogramSamplesCount(histogram, expectedSamplesCount, msg) { + return is( + Object.values(histogram.snapshot().values).reduce((a, b) => a + b, 0), + expectedSamplesCount, + msg + ); +} + +add_task(async function test_contentscript_telemetry() { + // Turn on telemetry and reset it to the previous state once the test is completed. + const telemetryCanRecordBase = SpecialPowers.Services.telemetry.canRecordBase; + SpecialPowers.Services.telemetry.canRecordBase = true; + SimpleTest.registerCleanupFunction(() => { + SpecialPowers.Services.telemetry.canRecordBase = telemetryCanRecordBase; + }); + + function background() { + browser.test.onMessage.addListener(() => { + browser.tabs.executeScript({code: 'browser.test.sendMessage("content-script-run");'}); + }); + } + + let extensionData = { + manifest: { + permissions: ["<all_urls>"], + }, + background, + }; + + let tab = await AppTestDelegate.openNewForegroundTab( + window, + "https://example.com", + true + ); + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + + is( + Glean.extensionsTiming[GLEAN_METRIC_ID].testGetValue(), + null, + `No data recorded for Glean metric extensionsTiming.${GLEAN_METRIC_ID}` + ); + + let histogram = SpecialPowers.Services.telemetry.getHistogramById(HISTOGRAM); + histogram.clear(); + assertHistogramSamplesCount( + histogram, + 0, + `No data recorded for histogram: ${HISTOGRAM}.` + ); + + await extension.startup(); + + assertHistogramSamplesCount( + histogram, + 0, + `No data recorded for histogram: ${HISTOGRAM}.` + ); + + await Services.fog.testFlushAllChildren(); + is( + Glean.extensionsTiming[GLEAN_METRIC_ID].testGetValue(), + null, + `No data recorded for Glean metric extensionsTiming.${GLEAN_METRIC_ID}` + ); + + extension.sendMessage(); + await extension.awaitMessage("content-script-run"); + + await Services.fog.testFlushAllChildren(); + + ok( + Glean.extensionsTiming[GLEAN_METRIC_ID].testGetValue()?.sum > 0, + `Data recorded for first extension on Glean metric extensionsTiming.${GLEAN_METRIC_ID}` + ); + + // Asserting the number of samples is the expected one (the histogram sum value + // is intermittently set to 0 due to a sample being intermittently recorded for + // the bucket 0 and would trigger an intermittent failure, see Bug 1864213). + assertHistogramSamplesCount( + histogram, + 1, + `Data recorded for first extension for histogram: ${HISTOGRAM}.` + ); + + await AppTestDelegate.removeTab(window, tab); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_unrecognizedprop_warning.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_unrecognizedprop_warning.html new file mode 100644 index 0000000000..40403dea2b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_unrecognizedprop_warning.html @@ -0,0 +1,80 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for content script unrecognized property on manifest</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +const BASE = "http://mochi.test:8888/chrome/toolkit/components/extensions/test/mochitest"; + +add_task(async function test_contentscript() { + function background() { + browser.runtime.onMessage.addListener(async (msg) => { + if (msg == "loaded") { + // NOTE: we're removing the tab from here because doing a win.close() + // from the chrome test code is raising a "TypeError: can't access + // dead object" exception. + let tabs = await browser.tabs.query({active: true, currentWindow: true}); + await browser.tabs.remove(tabs[0].id); + + browser.test.notifyPass("content-script-loaded"); + } + }); + } + + function contentScript() { + chrome.runtime.sendMessage("loaded"); + } + + let extensionData = { + manifest: { + content_scripts: [ + { + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + "run_at": "document_idle", + "unrecognized_property": "with-a-random-value", + }, + ], + }, + background, + + files: { + "content_script.js": contentScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + SimpleTest.waitForExplicitFinish(); + let waitForConsole = new Promise(resolve => { + SimpleTest.monitorConsole(resolve, [{ + message: /Reading manifest: Warning processing content_scripts.*.unrecognized_property: An unexpected property was found/, + }]); + }); + + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + + window.open(`${BASE}/file_sample.html`); + + await Promise.all([extension.awaitFinish("content-script-loaded")]); + info("test page loaded"); + + await extension.unload(); + + SimpleTest.endMonitorConsole(); + await waitForConsole; +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_open.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_open.html new file mode 100644 index 0000000000..530937c1ac --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_open.html @@ -0,0 +1,114 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for permissions</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_downloads_open_permission() { + function backgroundScript() { + browser.test.assertEq(browser.downloads.open, undefined, + "`downloads.open` permission is required."); + browser.test.notifyPass("downloads tests"); + } + + let extensionData = { + background: backgroundScript, + manifest: { + permissions: ["downloads"], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("downloads tests"); + await extension.unload(); +}); + +add_task(async function test_downloads_open_requires_user_interaction() { + async function backgroundScript() { + await browser.test.assertRejects( + browser.downloads.open(10), + "downloads.open may only be called from a user input handler", + "The error is informative."); + + browser.test.notifyPass("downloads tests"); + } + + let extensionData = { + background: backgroundScript, + manifest: { + permissions: ["downloads", "downloads.open"], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("downloads tests"); + await extension.unload(); +}); + +add_task(async function downloads_open_invalid_id() { + async function pageScript() { + window.addEventListener("keypress", async function handler() { + try { + await browser.downloads.open(10); + browser.test.sendMessage("download-open.result", {success: true}); + } catch (e) { + browser.test.sendMessage("download-open.result", { + success: false, + error: e.message, + }); + } + window.removeEventListener("keypress", handler); + }); + + browser.test.sendMessage("page-ready"); + } + + let extensionData = { + background() { + browser.test.sendMessage("ready", browser.runtime.getURL("page.html")); + }, + files: { + "foo.txt": "It's the file called foo.txt.", + "page.html": `<html><head> + <script src="page.js"><\/script> + </head></html>`, + "page.js": pageScript, + }, + manifest: { + permissions: ["downloads", "downloads.open"], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let url = await extension.awaitMessage("ready"); + let win = window.open(); + let browserFrame = win.browsingContext.embedderElement; + win.location.href = url; + await extension.awaitMessage("page-ready"); + + synthesizeKey("a", {}, browserFrame.contentWindow); + let result = await extension.awaitMessage("download-open.result"); + + is(result.success, false, "Opening download fails."); + is(result.error, "Invalid download id 10", "The error is informative."); + + + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_saveAs.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_saveAs.html new file mode 100644 index 0000000000..4b5d90814c --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_saveAs.html @@ -0,0 +1,259 @@ +<!doctype html> +<html> +<head> + <title>Test downloads.download() saveAs option</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +const {FileUtils} = ChromeUtils.importESModule( + "resource://gre/modules/FileUtils.sys.mjs" +); + +const PROMPTLESS_DOWNLOAD_PREF = "browser.download.useDownloadDir"; + +const DOWNLOAD_FILENAME = "file_download.nonext.txt"; +const DEFAULT_SUBDIR = "subdir"; + +// We need to be able to distinguish files downloaded by the file picker from +// files downloaded without it. +let pickerDir; +let pbPickerDir; // for incognito downloads +let defaultDir; + +add_task(async function setup() { + // Reset DownloadLastDir preferences in case other tests set them. + SpecialPowers.Services.obs.notifyObservers( + null, + "browser:purge-session-history" + ); + + // Set up temporary directories. + let downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + pickerDir = downloadDir.clone(); + pickerDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + info(`Using file picker download directory ${pickerDir.path}`); + pbPickerDir = downloadDir.clone(); + pbPickerDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + info(`Using private browsing file picker download directory ${pbPickerDir.path}`); + defaultDir = downloadDir.clone(); + defaultDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + info(`Using default download directory ${defaultDir.path}`); + let subDir = defaultDir.clone(); + subDir.append(DEFAULT_SUBDIR); + subDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + + isnot(pickerDir.path, defaultDir.path, + "Should be able to distinguish between files saved with or without the file picker"); + isnot(pickerDir.path, pbPickerDir.path, + "Should be able to distinguish between files saved in and out of private browsing mode"); + + await SpecialPowers.pushPrefEnv({"set": [ + ["browser.download.folderList", 2], + ["browser.download.dir", defaultDir.path], + ]}); + + SimpleTest.registerCleanupFunction(async () => { + await SpecialPowers.popPrefEnv(); + pickerDir.remove(true); + pbPickerDir.remove(true); + defaultDir.remove(true); // This also removes DEFAULT_SUBDIR. + }); +}); + +add_task(async function test_downloads_saveAs() { + const pickerFile = pickerDir.clone(); + pickerFile.append(DOWNLOAD_FILENAME); + + const pbPickerFile = pbPickerDir.clone(); + pbPickerFile.append(DOWNLOAD_FILENAME); + + const defaultFile = defaultDir.clone(); + defaultFile.append(DOWNLOAD_FILENAME); + + const {MockFilePicker} = SpecialPowers; + MockFilePicker.init(window); + + function mockFilePickerCallback(expectedStartingDir, pickedFile) { + return fp => { + // Assert that the downloads API correctly sets the starting directory. + ok(fp.displayDirectory.equals(expectedStartingDir), "Got the expected FilePicker displayDirectory"); + + // Assert that the downloads API configures both default properties. + is(fp.defaultString, DOWNLOAD_FILENAME, "Got the expected FilePicker defaultString"); + is(fp.defaultExtension, "txt", "Got the expected FilePicker defaultExtension"); + + MockFilePicker.setFiles([pickedFile]); + }; + } + + function background() { + const url = URL.createObjectURL(new Blob(["file content"])); + browser.test.onMessage.addListener(async (filename, saveAs, isPrivate) => { + try { + let options = { + url, + filename, + incognito: isPrivate, + }; + // Only define the saveAs option if the argument was actually set + if (saveAs !== undefined) { + options.saveAs = saveAs; + } + let id = await browser.downloads.download(options); + browser.downloads.onChanged.addListener(delta => { + if (delta.id == id && delta.state.current === "complete") { + browser.test.sendMessage("done", {ok: true, id}); + } + }); + } catch ({message}) { + browser.test.sendMessage("done", {ok: false, message}); + } + }); + browser.test.sendMessage("ready"); + } + + const manifest = { + background, + incognitoOverride: "spanning", + manifest: {permissions: ["downloads"]}, + }; + const extension = ExtensionTestUtils.loadExtension(manifest); + + await extension.startup(); + await extension.awaitMessage("ready"); + + // options should have the following properties: + // saveAs (Boolean or undefined) + // isPrivate (Boolean) + // fileName (string) + // expectedStartingDir (nsIFile) + // destinationFile (nsIFile) + async function testExpectFilePicker(options) { + ok(!options.destinationFile.exists(), "the file should have been cleaned up properly previously"); + + MockFilePicker.showCallback = mockFilePickerCallback( + options.expectedStartingDir, + options.destinationFile + ); + MockFilePicker.returnValue = MockFilePicker.returnOK; + + extension.sendMessage(options.fileName, options.saveAs, options.isPrivate); + let result = await extension.awaitMessage("done"); + ok(result.ok, `downloads.download() works with saveAs=${options.saveAs}`); + + ok(options.destinationFile.exists(), "the file exists."); + is(options.destinationFile.fileSize, 12, "downloaded file is the correct size"); + options.destinationFile.remove(false); + MockFilePicker.reset(); + + // Test the user canceling the save dialog. + MockFilePicker.returnValue = MockFilePicker.returnCancel; + + extension.sendMessage(options.fileName, options.saveAs, options.isPrivate); + result = await extension.awaitMessage("done"); + + ok(!result.ok, "download rejected if the user cancels the dialog"); + is(result.message, "Download canceled by the user", "with the correct message"); + ok(!options.destinationFile.exists(), "file was not downloaded"); + MockFilePicker.reset(); + } + + async function testNoFilePicker(saveAs) { + ok(!defaultFile.exists(), "the file should have been cleaned up properly previously"); + + extension.sendMessage(DOWNLOAD_FILENAME, saveAs, false); + let result = await extension.awaitMessage("done"); + ok(result.ok, `downloads.download() works with saveAs=${saveAs}`); + + ok(defaultFile.exists(), "the file exists."); + is(defaultFile.fileSize, 12, "downloaded file is the correct size"); + defaultFile.remove(false); + } + + info("Testing that saveAs=true uses the file picker as expected"); + let expectedStartingDir = defaultDir; + let fpOptions = { + saveAs: true, + isPrivate: false, + fileName: DOWNLOAD_FILENAME, + expectedStartingDir: expectedStartingDir, + destinationFile: pickerFile, + }; + await testExpectFilePicker(fpOptions); + + info("Testing that saveas=true reuses last file picker directory"); + fpOptions.expectedStartingDir = pickerDir; + await testExpectFilePicker(fpOptions); + + info("Testing that saveAs=true in PB reuses last directory"); + let nonPBStartingDir = fpOptions.expectedStartingDir; + fpOptions.isPrivate = true; + fpOptions.destinationFile = pbPickerFile; + await testExpectFilePicker(fpOptions); + + info("Testing that saveAs=true in PB uses a separate last directory"); + fpOptions.expectedStartingDir = pbPickerDir; + await testExpectFilePicker(fpOptions); + + info("Testing that saveAs=true in Permanent PB mode ignores the incognito option"); + await SpecialPowers.pushPrefEnv({ + set: [["browser.privatebrowsing.autostart", true]], + }); + fpOptions.isPrivate = false; + fpOptions.expectedStartingDir = pbPickerDir; + await testExpectFilePicker(fpOptions); + + info("Testing that saveas=true reuses the non-PB last directory after private download"); + await SpecialPowers.popPrefEnv(); + fpOptions.isPrivate = false; + fpOptions.expectedStartingDir = nonPBStartingDir; + fpOptions.destinationFile = pickerFile; + await testExpectFilePicker(fpOptions); + + info("Testing that saveAs=true does not reuse last directory when filename contains a path separator"); + fpOptions.fileName = DEFAULT_SUBDIR + "/" + DOWNLOAD_FILENAME; + let destinationFile = defaultDir.clone(); + destinationFile.append(DEFAULT_SUBDIR); + fpOptions.expectedStartingDir = destinationFile.clone(); + destinationFile.append(DOWNLOAD_FILENAME); + fpOptions.destinationFile = destinationFile; + await testExpectFilePicker(fpOptions); + + info("Testing that saveAs=false does not use the file picker"); + fpOptions.saveAs = false; + await testNoFilePicker(fpOptions.saveAs); + + // When saveAs is not set, the behavior should be determined by the Firefox + // pref that normally determines whether the "Save As" prompt should be + // displayed. + info(`Testing that the file picker is used when saveAs is not specified ` + + `but ${PROMPTLESS_DOWNLOAD_PREF} is disabled`); + fpOptions.saveAs = undefined; + await SpecialPowers.pushPrefEnv({"set": [ + [PROMPTLESS_DOWNLOAD_PREF, false], + ]}); + await testExpectFilePicker(fpOptions); + + info(`Testing that the file picker is NOT used when saveAs is not ` + + `specified but ${PROMPTLESS_DOWNLOAD_PREF} is enabled`); + await SpecialPowers.popPrefEnv(); + await SpecialPowers.pushPrefEnv({"set": [ + [PROMPTLESS_DOWNLOAD_PREF, true], + ]}); + await testNoFilePicker(fpOptions.saveAs); + + await extension.unload(); + MockFilePicker.cleanup(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_uniquify.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_uniquify.html new file mode 100644 index 0000000000..99a6c48500 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_uniquify.html @@ -0,0 +1,118 @@ +<!doctype html> +<html> +<head> + <title>Test downloads.download() uniquify option</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +const {FileUtils} = ChromeUtils.importESModule( + "resource://gre/modules/FileUtils.sys.mjs" +); + +let directory; + +add_task(async function setup() { + directory = FileUtils.getDir("TmpD", ["downloads"]); + directory.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + info(`Using download directory ${directory.path}`); + + await SpecialPowers.pushPrefEnv({"set": [ + ["browser.download.folderList", 2], + ["browser.download.dir", directory.path], + ]}); + + SimpleTest.registerCleanupFunction(async () => { + await SpecialPowers.popPrefEnv(); + directory.remove(true); + }); +}); + +add_task(async function test_downloads_uniquify() { + const file = directory.clone(); + file.append("file_download.txt"); + + const unique = directory.clone(); + unique.append("file_download(1).txt"); + + const {MockFilePicker} = SpecialPowers; + MockFilePicker.init(window); + MockFilePicker.returnValue = MockFilePicker.returnOK; + + MockFilePicker.showCallback = fp => { + let file = directory.clone(); + file.append(fp.defaultString); + MockFilePicker.setFiles([file]); + }; + + function background() { + const url = URL.createObjectURL(new Blob(["file content"])); + browser.test.onMessage.addListener(async (filename, saveAs) => { + try { + let id = await browser.downloads.download({ + url, + filename, + saveAs, + conflictAction: "uniquify", + }); + browser.downloads.onChanged.addListener(delta => { + if (delta.id == id && delta.state.current === "complete") { + browser.test.sendMessage("done", {ok: true, id}); + } + }); + } catch ({message}) { + browser.test.sendMessage("done", {ok: false, message}); + } + }); + browser.test.sendMessage("ready"); + } + + const manifest = {background, manifest: {permissions: ["downloads"]}}; + const extension = ExtensionTestUtils.loadExtension(manifest); + + await extension.startup(); + await extension.awaitMessage("ready"); + + async function testUniquify(saveAs) { + info(`Testing conflictAction:"uniquify" with saveAs=${saveAs}`); + + ok(!file.exists(), "downloaded file should have been cleaned up before test ran"); + ok(!unique.exists(), "uniquified file should have been cleaned up before test ran"); + + // Test download without uniquify and create a conflicting file so we can + // test with uniquify. + extension.sendMessage("file_download.txt", saveAs); + let result = await extension.awaitMessage("done"); + ok(result.ok, "downloads.download() works with saveAs"); + + ok(file.exists(), "the file exists."); + is(file.fileSize, 12, "downloaded file is the correct size"); + + // Now that a conflicting file exists, test the uniquify behavior + extension.sendMessage("file_download.txt", saveAs); + result = await extension.awaitMessage("done"); + ok(result.ok, "downloads.download() works with saveAs and uniquify"); + + ok(unique.exists(), "the file exists."); + is(unique.fileSize, 12, "downloaded file is the correct size"); + + file.remove(false); + unique.remove(false); + } + await testUniquify(true); + await testUniquify(false); + + await extension.unload(); + MockFilePicker.cleanup(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_permissions.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_permissions.html new file mode 100644 index 0000000000..65bf0a50d0 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_permissions.html @@ -0,0 +1,172 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for permissions</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +function makeTest(manifestPermissions, optionalPermissions, checkFetch = true) { + return async function() { + function pageScript() { + /* global PERMISSIONS */ + browser.test.onMessage.addListener(async msg => { + if (msg == "set-cookie") { + try { + await browser.cookies.set({ + url: "http://example.com/", + name: "COOKIE", + value: "NOM NOM", + }); + browser.test.sendMessage("set-cookie.result", {success: true}); + } catch (err) { + dump(`set cookie failed with ${err.message}\n`); + browser.test.sendMessage("set-cookie.result", + {success: false, message: err.message}); + } + } else if (msg == "remove") { + browser.permissions.remove(PERMISSIONS).then(result => { + browser.test.sendMessage("remove.result", result); + }); + } else if (msg == "request") { + browser.test.withHandlingUserInput(() => { + browser.permissions.request(PERMISSIONS).then(result => { + browser.test.sendMessage("request.result", result); + }); + }); + } + }); + + browser.test.sendMessage("page-ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("ready", browser.runtime.getURL("page.html")); + }, + + manifest: { + permissions: manifestPermissions, + optional_permissions: [...(optionalPermissions.permissions || []), + ...(optionalPermissions.origins || [])], + + content_scripts: [{ + matches: ["http://mochi.test/*/file_sample.html"], + js: ["content_script.js"], + }], + }, + + files: { + "content_script.js": async () => { + let url = new URL(window.location.pathname, "http://example.com/"); + fetch(url, {}).then(response => { + browser.test.sendMessage("fetch.result", response.ok); + }).catch(err => { + browser.test.sendMessage("fetch.result", false); + }); + }, + + "page.html": `<html><head> + <script src="page.js"><\/script> + </head></html>`, + + "page.js": `const PERMISSIONS = ${JSON.stringify(optionalPermissions)}; (${pageScript})();`, + }, + }); + + await extension.startup(); + + function call(method) { + extension.sendMessage(method); + return extension.awaitMessage(`${method}.result`); + } + + let base = window.location.href.replace(/^chrome:\/\/mochitests\/content/, + "http://mochi.test:8888"); + let file = new URL("file_sample.html", base); + + async function testContentScript() { + let win = window.open(file); + let result = await extension.awaitMessage("fetch.result"); + win.close(); + return result; + } + + let url = await extension.awaitMessage("ready"); + let win = window.open(); + win.location.href = url; + await extension.awaitMessage("page-ready"); + + // Using the cookies API from an extension page should fail + let result = await call("set-cookie"); + is(result.success, false, "setting cookie failed"); + if (manifestPermissions.includes("cookies")) { + ok(/^Permission denied/.test(result.message), + "setting cookie failed with an appropriate error due to missing host permission"); + } else { + ok(/browser\.cookies is undefined/.test(result.message), + "setting cookie failed since cookies API is not present"); + } + + // Making a cross-origin request from a content script should fail + if (checkFetch) { + result = await testContentScript(); + is(result, false, "fetch() failed from content script due to lack of host permission"); + } + + result = await call("request"); + is(result, true, "permissions.request() succeeded"); + + // Using the cookies API from an extension page should succeed + result = await call("set-cookie"); + is(result.success, true, "setting cookie succeeded"); + + // Making a cross-origin request from a content script should succeed + if (checkFetch) { + result = await testContentScript(); + is(result, true, "fetch() succeeded from content script due to lack of host permission"); + } + + // Now revoke our permissions + result = await call("remove"); + + // The cookies API should once again fail + result = await call("set-cookie"); + is(result.success, false, "setting cookie failed"); + + // As should the cross-origin request from a content script + if (checkFetch) { + result = await testContentScript(); + is(result, false, "fetch() failed from content script due to lack of host permission"); + } + + await extension.unload(); + }; +} + +add_task(function setup() { + // Don't bother with prompts in this test. + return SpecialPowers.pushPrefEnv({ + set: [["extensions.webextOptionalPermissionPrompts", false]], + }); +}); + +const ORIGIN = "*://example.com/"; +add_task(makeTest([], { + permissions: ["cookies"], + origins: [ORIGIN], +})); + +add_task(makeTest(["cookies"], {origins: [ORIGIN]})); +add_task(makeTest([ORIGIN], {permissions: ["cookies"]}, false)); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_svg_context_fill.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_svg_context_fill.html new file mode 100644 index 0000000000..c15ae9adf7 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_svg_context_fill.html @@ -0,0 +1,204 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for permissions</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> +</head> +<body> + <style> + img { + -moz-context-properties: fill; + fill: green; + } + + img, div.ref { + width: 100px; + height: 100px; + } + + div#green { + background: green; + } + + div#red { + background: red; + } + </style> + <h3>Testing on: <span id="test-params"></span></h3> + <table> + <thead> + <tr> + <th>webext image</th> + <th>allowed ref</th> + <th>disallowed ref</th> + </tr> + </thead> + <tbody> + <tr> + <td> + <img id="actual"> + </td> + <td> + <div id="green" class="ref"></div> + </td> + <td> + <div id="red" class="ref"></div> + </td> + </tr> + </tbody> + </table> + +<script type="text/javascript"> +"use strict"; + +const { TestUtils } = SpecialPowers.ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +function screenshotPage(win, elementSelector) { + const el = win.document.querySelector(elementSelector); + return TestUtils.screenshotArea(el, win); +} + +async function test_moz_extension_svg_context_fill({ + addonId, + isPrivileged, + expectAllowed, +}) { + // Include current test params in the rendered html page (to be included in failure + // screenshots). + document.querySelector("#test-params").textContent = JSON.stringify({ + addonId, + isPrivileged, + expectAllowed, + }); + + let extDefinition = { + manifest: { + browser_specific_settings: { gecko: { id: addonId } }, + }, + background() { + browser.test.sendMessage("svg-url", browser.runtime.getURL("context-fill-fallback-red.svg")); + }, + files: { + "context-fill-fallback-red.svg": ` + <svg xmlns="http://www.w3.org/2000/svg" version="1.1" + xmlns:xlink="http://www.w3.org/1999/xlink"> + <rect height="100%" width="100%" fill="context-fill red" /> + </svg> + `, + }, + } + + if (isPrivileged) { + // isPrivileged is unused when useAddonManager is set (see ExtensionTestCommon.generate), + // the internal permission being tested is only added when the extension has a startupReason + // related to new installations and upgrades/downgrades and so the `startupReason` is set here + // to be able to mock the startupReason expected when useAddonManager can't be used. + extDefinition = { + ...extDefinition, + isPrivileged, + startupReason: "ADDON_INSTALL", + }; + } else { + // useAddonManager temporary is instead used to explicitly test the other cases when the extension + // is not expected to be privileged. + extDefinition = { + ...extDefinition, + useAddonManager: "temporary", + }; + } + + const extension = ExtensionTestUtils.loadExtension(extDefinition); + + await extension.startup(); + + // Set the extension url on the img element part of the + // comparison table defined in the html part of this test file. + const svgURL = await extension.awaitMessage("svg-url"); + document.querySelector("#actual").src = svgURL; + + let screenshots; + + // Wait until the svg context fill has been applied + // (unfortunately waiting for a document reflow does + // not seem to be enough). + const expectedColor = expectAllowed ? "green" : "red"; + await TestUtils.waitForCondition( + async () => { + const result = await screenshotPage(window, "#actual"); + const reference = await screenshotPage(window, `#${expectedColor}`); + screenshots = {result, reference}; + return result == reference; + }, + `Context-fill should be ${ + expectAllowed ? "allowed" : "disallowed" + } (resulting in ${expectedColor}) on "${addonId}" extension` + ); + + // At least an assertion is required to prevent the test from + // failing. + is( + screenshots.result, + screenshots.reference, + "svg context-fill test completed, result does match reference" + ); + + await extension.unload(); +} + +// This test file verify that the non-standard svg context-fill feature is allowed +// on extensions svg files coming from Mozilla-owned extensions. +// +// NOTE: line extension permission to use context fill is tested in test_recommendations.js + +add_task(async function test_allowed_on_privileged_ext() { + await test_moz_extension_svg_context_fill({ + addonId: "privileged-addon@mochi.test", + isPrivileged: true, + expectAllowed: true, + }); +}); + +add_task(async function test_disallowed_on_non_privileged_ext() { + await test_moz_extension_svg_context_fill({ + addonId: "non-privileged-arbitrary-addon-id@mochi.test", + isPrivileged: false, + expectAllowed: false, + }); +}); + +add_task(async function test_allowed_on_privileged_ext_with_mozilla_id() { + await test_moz_extension_svg_context_fill({ + addonId: "privileged-addon@mozilla.org", + isPrivileged: true, + expectAllowed: true, + }); + + await test_moz_extension_svg_context_fill({ + addonId: "privileged-addon@mozilla.com", + isPrivileged: true, + expectAllowed: true, + }); +}); + +add_task(async function test_allowed_on_non_privileged_ext_with_mozilla_id() { + await test_moz_extension_svg_context_fill({ + addonId: "non-privileged-addon@mozilla.org", + isPrivileged: false, + expectAllowed: true, + }); + + await test_moz_extension_svg_context_fill({ + addonId: "non-privileged-addon@mozilla.com", + isPrivileged: false, + expectAllowed: true, + }); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_trackingprotection.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_trackingprotection.html new file mode 100644 index 0000000000..438fb06706 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_trackingprotection.html @@ -0,0 +1,100 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for simple WebExtension</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +var {UrlClassifierTestUtils} = ChromeUtils.importESModule( + "resource://testing-common/UrlClassifierTestUtils.sys.mjs" +); + +function tp_background(expectFail = true) { + fetch("https://tracking.example.com/example.txt").then(() => { + browser.test.assertTrue(!expectFail, "fetch received"); + browser.test.sendMessage("done"); + }, () => { + browser.test.assertTrue(expectFail, "fetch failure"); + browser.test.sendMessage("done"); + }); +} + +async function test_permission(permissions, expectFail) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions, + }, + background: `(${tp_background})(${expectFail})`, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +} + +add_task(async function setup() { + await UrlClassifierTestUtils.addTestTrackers(); + await SpecialPowers.pushPrefEnv({ + set: [["privacy.trackingprotection.enabled", true]], + }); +}); + +// Fetch would be blocked with these tests +add_task(async function() { await test_permission([], true); }); +add_task(async function() { await test_permission(["http://*/"], true); }); +add_task(async function() { await test_permission(["http://*.example.com/"], true); }); +add_task(async function() { await test_permission(["http://localhost/*"], true); }); +// Fetch will not be blocked if the extension has host permissions. +add_task(async function() { await test_permission(["<all_urls>"], false); }); +add_task(async function() { await test_permission(["*://tracking.example.com/*"], false); }); + +add_task(async function test_contentscript() { + function contentScript() { + fetch("https://tracking.example.com/example.txt").then(() => { + browser.test.notifyPass("fetch received"); + }, () => { + browser.test.notifyFail("fetch failure"); + }); + } + + let extensionData = { + manifest: { + permissions: ["*://tracking.example.com/*"], + content_scripts: [ + { + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + "run_at": "document_start", + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }; + const url = "http://mochi.test:8888/chrome/toolkit/components/extensions/test/mochitest/file_sample.html"; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + let win = window.open(url); + await extension.awaitFinish(); + win.close(); + await extension.unload(); +}); + +add_task(async function teardown() { + UrlClassifierTestUtils.cleanupTestTrackers(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_webnavigation_resolved_urls.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webnavigation_resolved_urls.html new file mode 100644 index 0000000000..7e876694a0 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webnavigation_resolved_urls.html @@ -0,0 +1,81 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for simple WebExtension</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function webnav_unresolved_uri_on_expected_URI_scheme() { + function background() { + let checkURLs; + + browser.webNavigation.onCompleted.addListener(async msg => { + if (checkURLs.length) { + let expectedURL = checkURLs.shift(); + browser.test.assertEq(expectedURL, msg.url, "Got the expected URL"); + await browser.tabs.remove(msg.tabId); + browser.test.sendMessage("next"); + } + }); + + browser.test.onMessage.addListener((name, urls) => { + if (name == "checkURLs") { + checkURLs = urls; + } + }); + + browser.test.sendMessage("ready", browser.runtime.getURL("/tab.html")); + } + + let extensionData = { + manifest: { + permissions: [ + "webNavigation", + ], + }, + background, + files: { + "tab.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + </html> + `, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + let checkURLs = [ + "resource://gre/modules/XPCOMUtils.sys.mjs", + "chrome://mochikit/content/tests/SimpleTest/SimpleTest.js", + "about:mozilla", + ]; + + let tabURL = await extension.awaitMessage("ready"); + checkURLs.push(tabURL); + + extension.sendMessage("checkURLs", checkURLs); + + for (let url of checkURLs) { + window.open(url); + await extension.awaitMessage("next"); + } + + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_background_events.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_background_events.html new file mode 100644 index 0000000000..4caa4d2464 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_background_events.html @@ -0,0 +1,96 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for simple WebExtension</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +const {webrequest_test} = ChromeUtils.importESModule( + SimpleTest.getTestFileURL("webrequest_test.sys.mjs") +); +let {testFetch, testXHR} = webrequest_test; + +// Here we test that any requests originating from a system principal are not +// accessible through WebRequest. text_ext_webrequest_background_events tests +// non-system principal requests. + +let testExtension = { + manifest: { + permissions: [ + "webRequest", + "<all_urls>", + ], + }, + background() { + let eventNames = [ + "onBeforeRequest", + "onBeforeSendHeaders", + "onSendHeaders", + "onHeadersReceived", + "onResponseStarted", + "onCompleted", + ]; + + function listener(name, details) { + // If we get anything, we failed. Removing the system principal check + // in ext-webrequest triggers this failure. + browser.test.fail(`received ${name}`); + } + + for (let name of eventNames) { + browser.webRequest[name].addListener( + listener.bind(null, name), + {urls: ["https://example.com/*"]} + ); + } + }, +}; + +add_task(async function test_webRequest_chromeworker_events() { + let extension = ExtensionTestUtils.loadExtension(testExtension); + await extension.startup(); + await new Promise(resolve => { + let worker = new ChromeWorker("webrequest_chromeworker.js"); + worker.onmessage = event => { + ok("chrome worker fetch finished"); + resolve(); + }; + worker.postMessage("go"); + }); + await extension.unload(); +}); + +add_task(async function test_webRequest_chromepage_events() { + let extension = ExtensionTestUtils.loadExtension(testExtension); + await extension.startup(); + await new Promise(resolve => { + fetch("https://example.com/example.txt").then(() => { + ok("test page loaded"); + resolve(); + }); + }); + await extension.unload(); +}); + +add_task(async function test_webRequest_jsm_events() { + let extension = ExtensionTestUtils.loadExtension(testExtension); + await extension.startup(); + await testFetch("https://example.com/example.txt").then(() => { + ok("fetch page loaded"); + }); + await testXHR("https://example.com/example.txt").then(() => { + ok("xhr page loaded"); + }); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_host_permissions.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_host_permissions.html new file mode 100644 index 0000000000..19c812f59f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_host_permissions.html @@ -0,0 +1,89 @@ +<!doctype html> +<html> +<head> + <title>Test webRequest checks host permissions</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> +</head> +<body> + + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_webRequest_host_permissions() { + function background() { + function png(details) { + browser.test.sendMessage("png", details.url); + } + browser.webRequest.onBeforeRequest.addListener(png, {urls: ["*://*/*.png"]}); + browser.test.sendMessage("ready"); + } + + const all = ExtensionTestUtils.loadExtension({background, manifest: {permissions: ["webRequest", "<all_urls>"]}}); + const example = ExtensionTestUtils.loadExtension({background, manifest: {permissions: ["webRequest", "https://example.com/"]}}); + const mochi_test = ExtensionTestUtils.loadExtension({background, manifest: {permissions: ["webRequest", "http://mochi.test/"]}}); + + await all.startup(); + await example.startup(); + await mochi_test.startup(); + + await all.awaitMessage("ready"); + await example.awaitMessage("ready"); + await mochi_test.awaitMessage("ready"); + + const win1 = window.open("https://example.com/chrome/toolkit/components/extensions/test/mochitest/file_with_images.html"); + let urls = [await all.awaitMessage("png"), + await all.awaitMessage("png")]; + ok(urls.some(url => url.endsWith("good.png")), "<all_urls> permission gets to see good.png"); + ok((await example.awaitMessage("png")).endsWith("good.png"), "example permission sees same-origin example.com image"); + ok(urls.some(url => url.endsWith("great.png")), "<all_urls> permission also sees great.png"); + + // Clear the in-memory image cache, it can prevent listeners from receiving events. + const imgTools = SpecialPowers.Cc["@mozilla.org/image/tools;1"].getService(SpecialPowers.Ci.imgITools); + imgTools.getImgCacheForDocument(win1.document).clearCache(false); + win1.close(); + + const win2 = window.open("http://mochi.test:8888/chrome/toolkit/components/extensions/test/mochitest/file_with_images.html"); + urls = [await all.awaitMessage("png"), + await all.awaitMessage("png")]; + ok(urls.some(url => url.endsWith("good.png")), "<all_urls> permission gets to see good.png"); + ok((await mochi_test.awaitMessage("png")).endsWith("great.png"), "mochi.test permission sees same-origin mochi.test image"); + ok(urls.some(url => url.endsWith("great.png")), "<all_urls> permission also sees great.png"); + win2.close(); + + await all.unload(); + await example.unload(); + await mochi_test.unload(); +}); + +add_task(async function test_webRequest_filter_permissions_warning() { + const manifest = { + permissions: ["webRequest", "http://example.com/"], + }; + + async function background() { + await browser.webRequest.onBeforeRequest.addListener(() => {}, {urls: ["http://example.org/"]}); + browser.test.notifyPass(); + } + + const extension = ExtensionTestUtils.loadExtension({manifest, background}); + + const warning = new Promise(resolve => { + SimpleTest.monitorConsole(resolve, [{message: /filter doesn't overlap with host permissions/}]); + }); + + await extension.startup(); + await extension.awaitFinish(); + + SimpleTest.endMonitorConsole(); + await warning; + + await extension.unload(); +}); + +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_mozextension.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_mozextension.html new file mode 100644 index 0000000000..6a41b9cf08 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_mozextension.html @@ -0,0 +1,193 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test moz-extension protocol use</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +let peakAchu; +add_task(async function setup() { + peakAchu = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "<all_urls>", + ], + }, + background() { + // ID for the extension in the tests. Try to observe it to ensure we cannot. + browser.webRequest.onBeforeRequest.addListener(details => { + browser.test.notifyFail(`PeakAchu onBeforeRequest ${details.url}`); + }, {urls: ["<all_urls>", "moz-extension://*/*"]}); + + browser.test.onMessage.addListener((msg, extensionUrl) => { + browser.test.log(`spying for ${extensionUrl}`); + browser.webRequest.onBeforeRequest.addListener(details => { + browser.test.notifyFail(`PeakAchu onBeforeRequest ${details.url}`); + }, {urls: [extensionUrl]}); + }); + }, + }); + await peakAchu.startup(); +}); + +add_task(async function test_webRequest_no_mozextension_permission() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "tabs", + "moz-extension://c9e007e0-e518-ed4c-8202-83849981dd21/*", + "moz-extension://*/*", + ], + }, + background() { + browser.test.notifyPass("loaded"); + }, + }); + + let messages = [ + {message: /processing permissions\.2: Value "moz-extension:\/\/c9e007e0-e518-ed4c-8202-83849981dd21\/\*"/}, + {message: /processing permissions\.3: Value "moz-extension:\/\/\*\/\*"/}, + ]; + + let waitForConsole = new Promise(resolve => { + SimpleTest.monitorConsole(resolve, messages); + }); + + await extension.startup(); + await extension.awaitFinish("loaded"); + await extension.unload(); + + SimpleTest.endMonitorConsole(); + await waitForConsole; +}); + +add_task(async function test_webRequest_mozextension_fetch() { + function background() { + let page = browser.runtime.getURL("fetched.html"); + browser.webRequest.onBeforeRequest.addListener(details => { + browser.test.assertEq(details.url, page, "got correct url in onBeforeRequest"); + browser.test.sendMessage("request-started"); + }, {urls: [browser.runtime.getURL("*")]}, ["blocking"]); + browser.webRequest.onCompleted.addListener(details => { + browser.test.assertEq(details.url, page, "got correct url in onCompleted"); + browser.test.sendMessage("request-complete"); + }, {urls: [browser.runtime.getURL("*")]}); + + browser.test.onMessage.addListener((msg, data) => { + fetch(page).then(() => { + browser.test.notifyPass("fetch success"); + browser.test.sendMessage("done"); + }, () => { + browser.test.fail("fetch failed"); + browser.test.sendMessage("done"); + }); + }); + browser.test.sendMessage("extensionUrl", browser.runtime.getURL("*")); + } + + // Use webrequest to monitor moz-extension:// requests + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "tabs", + "<all_urls>", + ], + }, + files: { + "fetched.html": ` + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>moz-extension file</h1> + </body> + </html> + `.trim(), + }, + background, + }); + + await extension.startup(); + // send the url for this extension to the monitoring extension + peakAchu.sendMessage("extensionUrl", await extension.awaitMessage("extensionUrl")); + + extension.sendMessage("testFetch"); + await extension.awaitMessage("request-started"); + await extension.awaitMessage("request-complete"); + await extension.awaitMessage("done"); + + await extension.unload(); +}); + +add_task(async function test_webRequest_mozextension_tab_query() { + function background() { + browser.test.sendMessage("extensionUrl", browser.runtime.getURL("*")); + let page = browser.runtime.getURL("tab.html"); + + async function onUpdated(tabId, tabInfo, tab) { + if (tabInfo.status !== "complete") { + return; + } + browser.test.log(`tab created ${tabId} ${JSON.stringify(tabInfo)} ${tab.url}`); + let tabs = await browser.tabs.query({url: browser.runtime.getURL("*")}); + browser.test.assertEq(1, tabs.length, "got one tab"); + browser.test.assertEq(tabs.length && tabs[0].id, tab.id, "got the correct tab"); + browser.test.assertEq(tabs.length && tabs[0].url, page, "got correct url in tab"); + browser.tabs.remove(tabId); + browser.tabs.onUpdated.removeListener(onUpdated); + browser.test.sendMessage("tabs-done"); + } + browser.tabs.onUpdated.addListener(onUpdated); + browser.tabs.create({url: page}); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "tabs", + "<all_urls>", + ], + }, + files: { + "tab.html": ` + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>moz-extension file</h1> + </body> + </html> + `.trim(), + }, + background, + }); + + await extension.startup(); + peakAchu.sendMessage("extensionUrl", await extension.awaitMessage("extensionUrl")); + await extension.awaitMessage("tabs-done"); + await extension.unload(); +}); + +add_task(async function teardown() { + await peakAchu.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_native_messaging_paths.html b/toolkit/components/extensions/test/mochitest/test_chrome_native_messaging_paths.html new file mode 100644 index 0000000000..c29b6286d9 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_native_messaging_paths.html @@ -0,0 +1,58 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +// Test that the default paths searched for native host manifests +// are the ones we expect. +add_task(async function test_default_paths() { + let expectUser, expectGlobal; + switch (AppConstants.platform) { + case "macosx": { + expectUser = PathUtils.joinRelative( + Services.dirsvc.get("Home", Ci.nsIFile).path, + "Library/Application Support/Mozilla" + ); + expectGlobal = "/Library/Application Support/Mozilla"; + + break; + } + + case "linux": { + expectUser = PathUtils.join( + Services.dirsvc.get("Home", Ci.nsIFile).path, + ".mozilla" + ); + + const libdir = AppConstants.HAVE_USR_LIB64_DIR ? "lib64" : "lib"; + expectGlobal = PathUtils.join("/usr", libdir, "mozilla"); + break; + } + + default: + // Fixed filesystem paths are only defined for MacOS and Linux, + // there's nothing to test on other platforms. + ok(false, `This test does not apply on ${AppConstants.platform}`); + break; + } + + let userDir = Services.dirsvc.get("XREUserNativeManifests", Ci.nsIFile).path; + is(userDir, expectUser, "user-specific native messaging directory is correct"); + + let globalDir = Services.dirsvc.get("XRESysNativeManifests", Ci.nsIFile).path; + is(globalDir, expectGlobal, "system-wide native messaing directory is correct"); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_action.html b/toolkit/components/extensions/test/mochitest/test_ext_action.html new file mode 100644 index 0000000000..16826d06f8 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_action.html @@ -0,0 +1,50 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Action with MV3</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.manifestV3.enabled", true]], + }); +}); + +add_task(async function test_action_onClicked() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + action: {}, + }, + background() { + browser.action.onClicked.addListener(async () => { + browser.test.notifyPass("action-clicked"); + }); + + browser.test.sendMessage("background-ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + await AppTestDelegate.clickBrowserAction(window, extension); + await extension.awaitFinish("action-clicked"); + await AppTestDelegate.closeBrowserAction(window, extension); + + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_activityLog.html b/toolkit/components/extensions/test/mochitest/test_ext_activityLog.html new file mode 100644 index 0000000000..c426913373 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_activityLog.html @@ -0,0 +1,390 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension activityLog test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_api() { + let URL = + "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_sample.html"; + + // Test that an unspecified extension is not logged by the watcher extension. + let unlogged = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + manifest: { + browser_specific_settings: { gecko: { id: "unlogged@tests.mozilla.org" } }, + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + background() { + // This privileged test extension should not affect the webRequest + // data received by non-privileged extensions (See Bug 1576272). + browser.webRequest.onBeforeRequest.addListener( + details => { + return { cancel: false }; + }, + { urls: ["http://mochi.test/*/file_sample.html"] }, + ["blocking"] + ); + }, + }); + await unlogged.startup(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: "watched@tests.mozilla.org" } }, + permissions: [ + "tabs", + "tabHide", + "storage", + "webRequest", + "webRequestBlocking", + "<all_urls>", + ], + content_scripts: [ + { + matches: ["http://mochi.test/*/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + }, + files: { + "content_script.js": () => { + browser.test.sendMessage("content_script"); + }, + "registered_script.js": () => { + browser.test.sendMessage("registered_script"); + }, + }, + async background() { + let listen = () => {}; + async function runTest() { + // Test activity for a child function call. + browser.test.assertEq( + undefined, + browser.activityLog, + "activityLog requires permission" + ); + + // Test a child event manager. + browser.storage.onChanged.addListener(listen); + browser.storage.onChanged.removeListener(listen); + + // Test a parent event manager. + let webRequestListener = details => { + browser.webRequest.onBeforeRequest.removeListener(webRequestListener); + return { cancel: false }; + }; + browser.webRequest.onBeforeRequest.addListener( + webRequestListener, + { urls: ["http://mochi.test/*/file_sample.html"] }, + ["blocking"] + ); + + // A manifest based content script is already + // registered, we do a dynamic registration here. + await browser.contentScripts.register({ + js: [{ file: "registered_script.js" }], + matches: ["http://mochi.test/*/file_sample.html"], + runAt: "document_start", + }); + browser.test.sendMessage("ready"); + } + browser.test.onMessage.addListener((msg, data) => { + // Logging has started here so this listener is logged, but the + // call adding it was not. We do an additional onMessage.addListener + // call in the test function to validate child based event managers. + if (msg == "runtest") { + browser.test.assertTrue(true, msg); + runTest(); + } + if (msg == "hideTab") { + browser.tabs.hide(data); + } + }); + browser.test.sendMessage("url", browser.runtime.getURL("")); + }, + }); + + async function backgroundScript(expectedUrl, extensionUrl) { + let expecting = [ + // Test child-only api_call. + { + type: "api_call", + name: "test.assertTrue", + data: { args: [true, "runtest"] }, + }, + + // Test child-only api_call. + { + type: "api_call", + name: "test.assertEq", + data: { + args: [undefined, undefined, "activityLog requires permission"], + }, + }, + // Test child addListener calls. + { + type: "api_call", + name: "storage.onChanged.addListener", + data: { + args: [], + }, + }, + { + type: "api_call", + name: "storage.onChanged.removeListener", + data: { + args: [], + }, + }, + // Test parent addListener calls. + { + type: "api_call", + name: "webRequest.onBeforeRequest.addListener", + data: { + args: [ + { + incognito: null, + tabId: null, + types: null, + urls: ["http://mochi.test/*/file_sample.html"], + windowId: null, + }, + ["blocking"], + ], + }, + }, + // Test an api that makes use of callParentAsyncFunction. + { + type: "api_call", + name: "contentScripts.register", + data: { + args: [ + { + allFrames: null, + css: null, + excludeGlobs: null, + excludeMatches: null, + includeGlobs: null, + js: [ + { + file: `${extensionUrl}registered_script.js`, + }, + ], + matchAboutBlank: null, + matches: ["http://mochi.test/*/file_sample.html"], + runAt: "document_start", + }, + ], + }, + }, + // Test child api_event calls. + { + type: "api_event", + name: "test.onMessage", + data: { args: ["runtest"] }, + }, + { + type: "api_call", + name: "test.sendMessage", + data: { args: ["ready"] }, + }, + // Test parent api_event calls. + { + type: "api_call", + name: "webRequest.onBeforeRequest.removeListener", + data: { + args: [], + }, + }, + { + type: "api_event", + name: "webRequest.onBeforeRequest", + data: { + args: [ + { + url: expectedUrl, + method: "GET", + type: "main_frame", + frameId: 0, + parentFrameId: -1, + incognito: false, + thirdParty: false, + ip: null, + frameAncestors: [], + urlClassification: { firstParty: [], thirdParty: [] }, + requestSize: 0, + responseSize: 0, + }, + ], + result: { + cancel: false, + }, + }, + }, + // Test manifest based content script. + { + type: "content_script", + name: "content_script.js", + data: { url: expectedUrl, tabId: 1 }, + }, + // registered script test + { + type: "content_script", + name: `${extensionUrl}registered_script.js`, + data: { url: expectedUrl, tabId: 1 }, + }, + { + type: "api_call", + name: "test.sendMessage", + data: { args: ["registered_script"], tabId: 1 }, + }, + { + type: "api_call", + name: "test.sendMessage", + data: { args: ["content_script"], tabId: 1 }, + }, + // Child api call + { + type: "api_call", + name: "tabs.hide", + data: { args: ["__TAB_ID"] }, + }, + { + type: "api_event", + name: "test.onMessage", + data: { args: ["hideTab", "__TAB_ID"] }, + }, + ]; + browser.test.assertTrue(browser.activityLog, "activityLog is privileged"); + + // Slightly less than a normal deep equal, we want to know that the values + // in our expected data are the same in the actual data, but we don't care + // if actual data has additional data or if data is in the same order in objects. + // This allows us to ignore keys that may be variable, or that are set in + // the api with an undefined value. + function deepEquivalent(a, b) { + if (a === b) { + return true; + } + if ( + typeof a != "object" || + typeof b != "object" || + a === null || + b === null + ) { + return false; + } + for (let k in a) { + if (!deepEquivalent(a[k], b[k])) { + return false; + } + } + return true; + } + + let tab; + let handler = async details => { + browser.test.log(`onExtensionActivity ${JSON.stringify(details)}`); + let test = expecting.shift(); + if (!test) { + browser.test.notifyFail(`no test for ${details.name}`); + } + + // On multiple runs, tabId will be different. Set the current + // tabId where we need it. + if (test.data.tabId !== undefined) { + test.data.tabId = tab.id; + } + if (test.data.args !== undefined) { + test.data.args = test.data.args.map(value => + value === "__TAB_ID" ? tab.id : value + ); + } + + browser.test.assertEq(test.type, details.type, "type matches"); + if (test.type == "content_script") { + browser.test.assertTrue( + details.name.includes(test.name), + "content script name matches" + ); + } else { + browser.test.assertEq(test.name, details.name, "name matches"); + } + + browser.test.assertTrue( + deepEquivalent(test.data, details.data), + `expected ${JSON.stringify( + test.data + )} included in actual ${JSON.stringify(details.data)}` + ); + if (!expecting.length) { + await browser.tabs.remove(tab.id); + browser.test.notifyPass("activity"); + } + }; + browser.activityLog.onExtensionActivity.addListener( + handler, + "watched@tests.mozilla.org" + ); + + browser.test.onMessage.addListener(async msg => { + if (msg === "opentab") { + tab = await browser.tabs.create({ url: expectedUrl }); + browser.test.sendMessage("tabid", tab.id); + } + if (msg === "done") { + browser.activityLog.onExtensionActivity.removeListener( + handler, + "watched@tests.mozilla.org" + ); + } + }); + } + + await extension.startup(); + let extensionUrl = await extension.awaitMessage("url"); + + let logger = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + manifest: { + browser_specific_settings: { gecko: { id: "watcher@tests.mozilla.org" } }, + permissions: ["activityLog"], + }, + background: `(${backgroundScript})("${URL}", "${extensionUrl}")`, + }); + await logger.startup(); + extension.sendMessage("runtest"); + await extension.awaitMessage("ready"); + logger.sendMessage("opentab"); + let id = await logger.awaitMessage("tabid"); + + await Promise.all([ + extension.awaitMessage("content_script"), + extension.awaitMessage("registered_script"), + ]); + + extension.sendMessage("hideTab", id); + await logger.awaitFinish("activity"); + + // Stop watching because we get extra calls on extension shutdown + // such as listener removal. + logger.sendMessage("done"); + + await extension.unload(); + await unlogged.unload(); + await logger.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js b/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js new file mode 100644 index 0000000000..95ac9af50d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js @@ -0,0 +1,248 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Tests whether not too many APIs are visible by default. +// This file is used by test_ext_all_apis.html in browser/ and mobile/android/, +// which may modify the following variables to add or remove expected APIs. +/* globals expectedContentApisTargetSpecific */ +/* globals expectedBackgroundApisTargetSpecific */ + +// Generates a list of expectations. +function generateExpectations(list) { + return list + .reduce((allApis, path) => { + return allApis.concat(`browser.${path}`, `chrome.${path}`); + }, []) + .sort(); +} + +let expectedCommonApis = [ + "extension.getURL", + "extension.inIncognitoContext", + "extension.lastError", + "i18n.detectLanguage", + "i18n.getAcceptLanguages", + "i18n.getMessage", + "i18n.getUILanguage", + "runtime.OnInstalledReason", + "runtime.OnRestartRequiredReason", + "runtime.PlatformArch", + "runtime.PlatformOs", + "runtime.RequestUpdateCheckStatus", + "runtime.getManifest", + "runtime.connect", + "runtime.getFrameId", + "runtime.getURL", + "runtime.id", + "runtime.lastError", + "runtime.onConnect", + "runtime.onMessage", + "runtime.sendMessage", + // browser.test is only available in xpcshell or when + // Cu.isInAutomation is true. + "test.assertDeepEq", + "test.assertEq", + "test.assertFalse", + "test.assertRejects", + "test.assertThrows", + "test.assertTrue", + "test.fail", + "test.log", + "test.notifyFail", + "test.notifyPass", + "test.onMessage", + "test.sendMessage", + "test.succeed", + "test.withHandlingUserInput", +]; + +let expectedContentApis = [ + ...expectedCommonApis, + ...expectedContentApisTargetSpecific, +]; + +let expectedBackgroundApis = [ + ...expectedCommonApis, + ...expectedBackgroundApisTargetSpecific, + "contentScripts.register", + "experiments.APIChildScope", + "experiments.APIEvent", + "experiments.APIParentScope", + "extension.ViewType", + "extension.getBackgroundPage", + "extension.getViews", + "extension.isAllowedFileSchemeAccess", + "extension.isAllowedIncognitoAccess", + // Note: extensionTypes is not visible in Chrome. + "extensionTypes.CSSOrigin", + "extensionTypes.ImageFormat", + "extensionTypes.RunAt", + "management.ExtensionDisabledReason", + "management.ExtensionInstallType", + "management.ExtensionType", + "management.getSelf", + "management.uninstallSelf", + "permissions.getAll", + "permissions.contains", + "permissions.request", + "permissions.remove", + "permissions.onAdded", + "permissions.onRemoved", + "runtime.getBackgroundPage", + "runtime.getBrowserInfo", + "runtime.getPlatformInfo", + "runtime.onConnectExternal", + "runtime.onInstalled", + "runtime.onMessageExternal", + "runtime.onPerformanceWarning", + "runtime.onStartup", + "runtime.onSuspend", + "runtime.onSuspendCanceled", + "runtime.onUpdateAvailable", + "runtime.openOptionsPage", + "runtime.reload", + "runtime.setUninstallURL", + "runtime.OnPerformanceWarningCategory", + "runtime.OnPerformanceWarningSeverity", + "theme.getCurrent", + "theme.onUpdated", + "types.LevelOfControl", + "types.SettingScope", +]; + +// APIs that are exposed to MV2 by default, but not to MV3. +const mv2onlyBackgroundApis = new Set([ + "extension.getURL", + "extension.lastError", + "contentScripts.register", + "tabs.executeScript", + "tabs.insertCSS", + "tabs.removeCSS", +]); +let expectedBackgroundApisMV3 = expectedBackgroundApis.filter( + path => !mv2onlyBackgroundApis.has(path) +); + +function sendAllApis() { + function isEvent(key, val) { + if (!/^on[A-Z]/.test(key)) { + return false; + } + let eventKeys = []; + for (let prop in val) { + eventKeys.push(prop); + } + eventKeys = eventKeys.sort().join(); + return eventKeys === "addListener,hasListener,removeListener"; + } + // Some items are removed from the namespaces in the lazy getters after the first get. This + // in one case, the events namespace, leaves a namespace that is empty. Make sure we don't + // consider those as a part of our testing. + function isEmptyObject(val) { + return val !== null && typeof val == "object" && !Object.keys(val).length; + } + function mayRecurse(key, val) { + if (Object.keys(val).filter(k => !/^[A-Z\-0-9_]+$/.test(k)).length === 0) { + // Don't recurse on constants and empty objects. + return false; + } + return !isEvent(key, val); + } + + let results = []; + function diveDeeper(path, obj) { + for (const [key, val] of Object.entries(obj)) { + if (typeof val == "object" && val !== null && mayRecurse(key, val)) { + diveDeeper(`${path}.${key}`, val); + } else if (val !== undefined && !isEmptyObject(val)) { + results.push(`${path}.${key}`); + } + } + } + diveDeeper("browser", browser); + diveDeeper("chrome", chrome); + browser.test.sendMessage("allApis", results.sort()); + browser.test.sendMessage("namespaces", browser === chrome); +} + +add_task(async function setup() { + // This test enumerates all APIs and may access a deprecated API. Just log a + // warning instead of throwing. + await ExtensionTestUtils.failOnSchemaWarnings(false); +}); + +add_task(async function test_enumerate_content_script_apis() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://mochi.test/*/file_sample.html"], + js: ["contentscript.js"], + run_at: "document_start", + }, + ], + }, + files: { + "contentscript.js": sendAllApis, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let win = window.open("file_sample.html"); + let actualApis = await extension.awaitMessage("allApis"); + win.close(); + let expectedApis = generateExpectations(expectedContentApis); + isDeeply(actualApis, expectedApis, "content script APIs"); + + let sameness = await extension.awaitMessage("namespaces"); + ok(sameness, "namespaces are same object"); + + await extension.unload(); +}); + +add_task(async function test_enumerate_background_script_apis() { + let extensionData = { + background: sendAllApis, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let actualApis = await extension.awaitMessage("allApis"); + let expectedApis = generateExpectations(expectedBackgroundApis); + isDeeply(actualApis, expectedApis, "background script APIs"); + + let sameness = await extension.awaitMessage("namespaces"); + ok(!sameness, "namespaces are different objects"); + + await extension.unload(); +}); + +add_task(async function test_enumerate_background_script_apis_mv3() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.manifestV3.enabled", true]], + }); + let extensionData = { + background: sendAllApis, + manifest: { + manifest_version: 3, + + // Features that expose APIs in MV2, but should not do anything with MV3. + browser_action: {}, + user_scripts: {}, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let actualApis = await extension.awaitMessage("allApis"); + let expectedApis = generateExpectations(expectedBackgroundApisMV3); + isDeeply(actualApis, expectedApis, "background script APIs in MV3"); + + let sameness = await extension.awaitMessage("namespaces"); + ok(sameness, "namespaces are same object"); + + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/toolkit/components/extensions/test/mochitest/test_ext_async_clipboard.html b/toolkit/components/extensions/test/mochitest/test_ext_async_clipboard.html new file mode 100644 index 0000000000..4bd8339357 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_async_clipboard.html @@ -0,0 +1,401 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Async Clipboard permissions tests</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"> +</head> +<body> + +<script> +"use strict"; + +// Bug 1479956 - On android-debug verify this test times out +SimpleTest.requestLongerTimeout(2); + +/* globals ClipboardItem, clipboardWriteText, clipboardWrite, clipboardReadText, clipboardRead */ +function shared() { + this.clipboardWriteText = function(txt) { + return navigator.clipboard.writeText(txt); + }; + + this.clipboardWrite = function(items) { + return navigator.clipboard.write(items); + }; + + this.clipboardReadText = function() { + return navigator.clipboard.readText(); + }; + + this.clipboardRead = function() { + return navigator.clipboard.read(); + }; +} + +/** + * Clear the clipboard. + * + * This is needed because Services.clipboard.emptyClipboard() does not clear the actual system clipboard. + */ +function clearClipboard() { + if (AppConstants.platform == "android") { + // On android, this clears the actual system clipboard + SpecialPowers.Services.clipboard.emptyClipboard(SpecialPowers.Services.clipboard.kGlobalClipboard); + return; + } + // Need to do this hack on other platforms to clear the actual system clipboard + let transf = SpecialPowers.Cc["@mozilla.org/widget/transferable;1"] + .createInstance(SpecialPowers.Ci.nsITransferable); + transf.init(null); + // Empty transferables may cause crashes, so just add an unknown type. + const TYPE = "text/x-moz-place-empty"; + transf.addDataFlavor(TYPE); + transf.setTransferData(TYPE, {}); + SpecialPowers.Services.clipboard.setData(transf, null, SpecialPowers.Services.clipboard.kGlobalClipboard); +} + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({"set": [ + ["dom.events.asyncClipboard.clipboardItem", true], + ]}); +}); + +// Test that without enough permissions, we are NOT allowed to use writeText, write, read or readText in background script +add_task(async function test_background_async_clipboard_no_permissions() { + function backgroundScript() { + const item = new ClipboardItem({ + "text/plain": new Blob(["HI"], {type: "text/plain"}) + }); + browser.test.assertRejects( + clipboardRead(), + "Clipboard read request was blocked due to lack of user activation.", + "Read should be denied without permission" + ); + browser.test.assertRejects( + clipboardWrite([item]), + "Clipboard write was blocked due to lack of user activation.", + "Write should be denied without permission" + ); + browser.test.assertRejects( + clipboardWriteText("blabla"), + "Clipboard write was blocked due to lack of user activation.", + "WriteText should be denied without permission" + ); + browser.test.assertRejects( + clipboardReadText(), + "Clipboard read request was blocked due to lack of user activation.", + "ReadText should be denied without permission" + ); + browser.test.sendMessage("ready"); + } + let extensionData = { + background: [shared, backgroundScript], + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("ready"); + await extension.unload(); +}); + +// Test that without enough permissions, we are NOT allowed to use writeText, write, read or readText in content script +add_task(async function test_contentscript_async_clipboard_no_permission() { + function contentScript() { + const item = new ClipboardItem({ + "text/plain": new Blob(["HI"], {type: "text/plain"}) + }); + browser.test.assertRejects( + clipboardRead(), + "Clipboard read request was blocked due to lack of user activation.", + "Read should be denied without permission" + ); + browser.test.assertRejects( + clipboardWrite([item]), + "Clipboard write was blocked due to lack of user activation.", + "Write should be denied without permission" + ); + browser.test.assertRejects( + clipboardWriteText("blabla"), + "Clipboard write was blocked due to lack of user activation.", + "WriteText should be denied without permission" + ); + browser.test.assertRejects( + clipboardReadText(), + "Clipboard read request was blocked due to lack of user activation.", + "ReadText should be denied without permission" + ); + browser.test.sendMessage("ready"); + } + let extensionData = { + manifest: { + content_scripts: [{ + js: ["shared.js", "contentscript.js"], + matches: ["https://example.com/*/file_sample.html"], + }], + }, + files: { + "shared.js": shared, + "contentscript.js": contentScript, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html"); + await extension.awaitMessage("ready"); + win.close(); + await extension.unload(); +}); + +// Test that with enough permissions, we are allowed to use writeText in content script +add_task(async function test_contentscript_clipboard_permission_writetext() { + function contentScript() { + let str = "HI"; + clipboardWriteText(str).then(function() { + // nothing here + browser.test.sendMessage("ready"); + }, function(err) { + browser.test.fail("WriteText promise rejected"); + browser.test.sendMessage("ready"); + }); // clipboardWriteText + } + let extensionData = { + manifest: { + content_scripts: [{ + js: ["shared.js", "contentscript.js"], + matches: ["https://example.com/*/file_sample.html"], + }], + permissions: [ + "clipboardWrite", + "clipboardRead", + ], + }, + files: { + "shared.js": shared, + "contentscript.js": contentScript, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html"); + await extension.awaitMessage("ready"); + const actual = SpecialPowers.getClipboardData("text/plain"); + is(actual, "HI", "right string copied by write"); + win.close(); + await extension.unload(); +}); + +// Test that with enough permissions, we are allowed to use readText in content script +add_task(async function test_contentscript_clipboard_permission_readtext() { + function contentScript() { + let str = "HI"; + clipboardReadText().then(function(strData) { + if (strData == str) { + browser.test.succeed("Successfully read from clipboard"); + } else { + browser.test.fail("ReadText read the wrong thing from clipboard:" + strData); + } + browser.test.sendMessage("ready"); + }, function(err) { + browser.test.fail("ReadText promise rejected"); + browser.test.sendMessage("ready"); + }); // clipboardReadText + } + let extensionData = { + manifest: { + content_scripts: [{ + js: ["shared.js", "contentscript.js"], + matches: ["https://example.com/*/file_sample.html"], + }], + permissions: [ + "clipboardWrite", + "clipboardRead", + ], + }, + files: { + "shared.js": shared, + "contentscript.js": contentScript, + }, + }; + await SimpleTest.promiseClipboardChange("HI", () => { + SpecialPowers.clipboardCopyString("HI"); + }, "text/plain"); + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html"); + await extension.awaitMessage("ready"); + win.close(); + await extension.unload(); +}); + +// Test that with enough permissions, we are allowed to use write in content script +add_task(async function test_contentscript_clipboard_permission_write() { + function contentScript() { + const item = new ClipboardItem({ + "text/plain": new Blob(["HI"], {type: "text/plain"}) + }); + clipboardWrite([item]).then(function() { + // nothing here + browser.test.sendMessage("ready"); + }, function(err) { // clipboardWrite promise error function + browser.test.fail("Write promise rejected"); + browser.test.sendMessage("ready"); + }); // clipboard write + } + let extensionData = { + manifest: { + content_scripts: [{ + js: ["shared.js", "contentscript.js"], + matches: ["https://example.com/*/file_sample.html"], + }], + permissions: [ + "clipboardWrite", + "clipboardRead", + ], + }, + files: { + "shared.js": shared, + "contentscript.js": contentScript, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html"); + await extension.awaitMessage("ready"); + const actual = SpecialPowers.getClipboardData("text/plain"); + is(actual, "HI", "right string copied by write"); + win.close(); + await extension.unload(); +}); + +// Test that with enough permissions, we are allowed to use read in content script +add_task(async function test_contentscript_clipboard_permission_read() { + function contentScript() { + clipboardRead().then(async function(items) { + let blob = await items[0].getType("text/plain"); + let s = await blob.text(); + if (s == "HELLO") { + browser.test.succeed("Read promise successfully read the right thing"); + } else { + browser.test.fail("Read read the wrong string from clipboard:" + s); + } + browser.test.sendMessage("ready"); + }, function(err) { // clipboardRead promise error function + browser.test.fail("Read promise rejected"); + browser.test.sendMessage("ready"); + }); // clipboard read + } + let extensionData = { + manifest: { + content_scripts: [{ + js: ["shared.js", "contentscript.js"], + matches: ["https://example.com/*/file_sample.html"], + }], + permissions: [ + "clipboardWrite", + "clipboardRead", + ], + }, + files: { + "shared.js": shared, + "contentscript.js": contentScript, + }, + }; + await SimpleTest.promiseClipboardChange("HELLO", () => { + SpecialPowers.clipboardCopyString("HELLO"); + }, "text/plain"); + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html"); + await extension.awaitMessage("ready"); + win.close(); + await extension.unload(); +}); + +// Test that performing readText(...) when the clipboard is empty returns an empty string +add_task(async function test_contentscript_clipboard_nocontents_readtext() { + function contentScript() { + clipboardReadText().then(function(strData) { + if (strData == "") { + browser.test.succeed("ReadText successfully read correct thing from an empty clipboard"); + } else { + browser.test.fail("ReadText should have read an empty string, but read:" + strData); + } + browser.test.sendMessage("ready"); + }, function(err) { + browser.test.fail("ReadText promise rejected: " + err); + browser.test.sendMessage("ready"); + }); + } + let extensionData = { + manifest: { + content_scripts: [{ + js: ["shared.js", "contentscript.js"], + matches: ["https://example.com/*/file_sample.html"], + }], + permissions: [ + "clipboardRead", + ], + }, + files: { + "shared.js": shared, + "contentscript.js": contentScript, + }, + }; + + await SimpleTest.promiseClipboardChange("", () => { + clearClipboard(); + }, "text/x-moz-place-empty"); + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html"); + await extension.awaitMessage("ready"); + win.close(); + await extension.unload(); +}); + +// Test that performing read(...) when the clipboard is empty returns an empty ClipboardItem +add_task(async function test_contentscript_clipboard_nocontents_read() { + function contentScript() { + clipboardRead().then(function(items) { + if (items[0].types.length) { + browser.test.fail("Read read the wrong thing from clipboard, " + + "ClipboardItem has this many entries: " + items[0].types.length); + } else { + browser.test.succeed("Read promise successfully resolved"); + } + browser.test.sendMessage("ready"); + }, function(err) { + browser.test.fail("Read promise rejected: " + err); + browser.test.sendMessage("ready"); + }); + } + let extensionData = { + manifest: { + content_scripts: [{ + js: ["shared.js", "contentscript.js"], + matches: ["https://example.com/*/file_sample.html"], + }], + permissions: [ + "clipboardRead", + ], + }, + files: { + "shared.js": shared, + "contentscript.js": contentScript, + }, + }; + + await SimpleTest.promiseClipboardChange("", () => { + clearClipboard(); + }, "text/x-moz-place-empty"); + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html"); + await extension.awaitMessage("ready"); + win.close(); + await extension.unload(); +}); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_background_canvas.html b/toolkit/components/extensions/test/mochitest/test_ext_background_canvas.html new file mode 100644 index 0000000000..e7745f08c5 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_background_canvas.html @@ -0,0 +1,42 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for background page canvas rendering</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_background_canvas() { + function background() { + try { + let canvas = document.createElement("canvas"); + + let context = canvas.getContext("2d"); + + // This ensures that we have a working PresShell, and can successfully + // calculate font metrics. + context.font = "8pt fixed"; + + browser.test.notifyPass("background-canvas"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("background-canvas"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ background }); + + await extension.startup(); + await extension.awaitFinish("background-canvas"); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_background_page.html b/toolkit/components/extensions/test/mochitest/test_ext_background_page.html new file mode 100644 index 0000000000..2f4fe3b96c --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_background_page.html @@ -0,0 +1,84 @@ +<!DOCTYPE HTML> +<html> + <head> + <title>WebExtension test</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js" type="text/javascript"></script> + <link href="/tests/SimpleTest/test.css" rel="stylesheet"/> + </head> + <body> + + <script type="text/javascript"> + "use strict"; + + /* eslint-disable mozilla/balanced-listeners */ + + add_task(async function testAlertNotShownInBackgroundWindow() { + let extension = ExtensionTestUtils.loadExtension({ + background: function () { + alert("I am an alert in the background."); + + browser.test.notifyPass("alertCalled"); + } + }); + + let consoleOpened = loadChromeScript(() => { + const {sendAsyncMessage, assert} = this; + assert.ok(!Services.wm.getEnumerator("alert:alert").hasMoreElements(), "Alerts should not be present at the start of the test."); + + Services.obs.addObserver(function observer() { + sendAsyncMessage("web-console-created"); + Services.obs.removeObserver(observer, "web-console-created"); + }, "web-console-created"); + }); + let opened = consoleOpened.promiseOneMessage("web-console-created"); + + consoleMonitor.start([ + { + message: /alert\(\) is not supported in background windows/ + }, { + message: /I am an alert in the background/ + } + ]); + + await extension.startup(); + await extension.awaitFinish("alertCalled"); + + let chromeScript = loadChromeScript(async () => { + const {assert} = this; + assert.ok(!Services.wm.getEnumerator("alert:alert").hasMoreElements(), "Alerts should not be present after calling alert()."); + }); + chromeScript.destroy(); + + await consoleMonitor.finished(); + + await opened; + consoleOpened.destroy(); + + chromeScript = loadChromeScript(async () => { + const {sendAsyncMessage} = this; + let {require} = ChromeUtils.importESModule("resource://devtools/shared/loader/Loader.sys.mjs"); + require("devtools/client/framework/devtools-browser"); + let {BrowserConsoleManager} = require("devtools/client/webconsole/browser-console-manager"); + + // And then double check that we have an actual browser console. + let haveConsole = !!BrowserConsoleManager.getBrowserConsole(); + + if (haveConsole) { + await BrowserConsoleManager.toggleBrowserConsole(); + } + sendAsyncMessage("done", haveConsole); + }); + + let consoleShown = await chromeScript.promiseOneMessage("done"); + ok(consoleShown, "console was shown"); + chromeScript.destroy(); + + await extension.unload(); + }); + </script> + + </body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_background_page_dpi.html b/toolkit/components/extensions/test/mochitest/test_ext_background_page_dpi.html new file mode 100644 index 0000000000..40772402b1 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_background_page_dpi.html @@ -0,0 +1,46 @@ +<!DOCTYPE HTML> +<meta charset="utf-8"> +<title>DPI of background page</title> +<script src="/tests/SimpleTest/SimpleTest.js"></script> +<script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> +<script src="head.js"></script> +<link rel="stylesheet" href="/tests/SimpleTest/test.css"> +<script> +"use strict"; + +async function testDPIMatches(description) { + let extension = ExtensionTestUtils.loadExtension({ + background: function() { + browser.test.sendMessage("dpi", window.devicePixelRatio); + }, + }); + await extension.startup(); + let dpi = await extension.awaitMessage("dpi"); + await extension.unload(); + + // This assumes that the window is loaded in a device DPI. + is( + dpi, + window.devicePixelRatio, + `DPI in a background page should match DPI in primary chrome page ${description}` + ); +} + +add_task(async function test_dpi_simple() { + await testDPIMatches("by default"); +}); + +add_task(async function test_dpi_devPixelsPerPx() { + await SpecialPowers.pushPrefEnv({ + set: [["layout.css.devPixelsPerPx", 1.5]], + }); + await testDPIMatches("with devPixelsPerPx"); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_dpi_os_zoom() { + await SpecialPowers.pushPrefEnv({ set: [["ui.textScaleFactor", 200]] }); + await testDPIMatches("with OS zoom"); + await SpecialPowers.popPrefEnv(); +}); +</script> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browserAction_getUserSettings.html b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_getUserSettings.html new file mode 100644 index 0000000000..6e3d9391bc --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_getUserSettings.html @@ -0,0 +1,50 @@ +<!DOCTYPE HTML> +<html> + +<head> + <title>action.getUserSettings Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css" /> +</head> + +<body> + <script type="text/javascript"> + "use strict"; + async function background() { + let { manifest_version } = await browser.runtime.getManifest(); + let action = browser.browserAction; + if (manifest_version === 3) { + action = browser.action; + } + let userSettings = await action.getUserSettings(); + + if (navigator.userAgent.includes("Android")) { + browser.test.assertDeepEq({}, userSettings, "userSettings should return an empty object on Android") + } else { + browser.test.assertFalse( + userSettings.isOnToolbar, + "isOnToolbar should be false when no default_area is specified in manifest.json" + ); + } + await browser.test.notifyPass("getUserSettings"); + } + add_task(async function browserAction_getUserSettings() { + let manifest = { manifest: { manifest_version: 2, browser_action: {} }, background } + let extension = ExtensionTestUtils.loadExtension(manifest); + await extension.startup(); + await extension.awaitFinish("getUserSettings"); + await extension.unload(); + }); + add_task(async function action_getUserSettings() { + let manifest = { manifest: { manifest_version: 3, action: {} }, background } + let extension = ExtensionTestUtils.loadExtension(manifest); + await extension.startup(); + await extension.awaitFinish("getUserSettings"); + await extension.unload(); + }); + </script> +</body> + +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browserAction_onClicked.html b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_onClicked.html new file mode 100644 index 0000000000..48db7b0e92 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_onClicked.html @@ -0,0 +1,100 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>browserAction.onClicked test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +function createExtension(background = {}) { + return ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: {}, + background, + }, + async background() { + async function checkIsHandlingUserInput() { + try { + // permissions.request is declared with requireUserInput, + // so it would reject if inputHandling is false. + let granted = await browser.permissions.request({}); + // We haven't requested any permissions, so the API call grants the + // requested permissions without actually prompting the user. + browser.test.assertTrue(granted, "empty permissions granted"); + return true; + } catch (e) { + browser.test.assertEq( + e?.message, + "permissions.request may only be called from a user input handler", + "Expected error when permissions.request rejects" + ); + return false; + } + } + browser.browserAction.onClicked.addListener(async () => { + browser.test.assertTrue( + await checkIsHandlingUserInput(), + "browserAction.onClicked is handling user input" + ); + browser.test.notifyPass("action-clicked"); + }); + + browser.test.assertFalse( + await checkIsHandlingUserInput(), + "not handling user input by default" + ); + browser.test.sendMessage("background-ready"); + }, + }); +} + +add_task(async function test_browserAction_onClicked_and_inputHandling() { + const extension = createExtension(); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + await AppTestDelegate.clickBrowserAction(window, extension); + await extension.awaitFinish("action-clicked"); + await AppTestDelegate.closeBrowserAction(window, extension); + + await extension.unload(); +}); + +add_task(async function test_browserAction_onClicked_persistent_event() { + const extension = createExtension({ persistent: false }); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + assertPersistentListeners(extension, "browserAction", ["onClicked"], { + primed: false, + }); + + await extension.terminateBackground(); + assertPersistentListeners(extension, "browserAction", ["onClicked"], { + primed: true, + }); + + await AppTestDelegate.clickBrowserAction(window, extension); + + // Background script will run again. + await extension.awaitMessage("background-ready"); + await extension.awaitFinish("action-clicked"); + + await AppTestDelegate.closeBrowserAction(window, extension); + + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup.html b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup.html new file mode 100644 index 0000000000..0d038da897 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup.html @@ -0,0 +1,183 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>action.openPopup Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +let extensionData = { + manifest: { + browser_specific_settings: { + gecko: { + id: "open-popup@tests.mozilla.org", + } + }, + browser_action: { + default_popup: "popup.html", + }, + permissions: ["activeTab"] + }, + + useAddonManager: "android-only", +}; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + "set": [ + ["extensions.openPopupWithoutUserGesture.enabled", true], + ], + }); +}); + +async function testActiveTabPermissions(withHandlingUserInput) { + const background = async function(withHandlingUserInput) { + let tabPromise; + let tabLoadedPromise = new Promise(resolve => { + // Wait for the tab to actually finish loading (bug 1589734) + browser.tabs.onUpdated.addListener(async (id, { status }) => { + if (id === (await tabPromise).id && status === "complete") { + resolve(); + } + }); + }); + tabPromise = browser.tabs.create({ url: "https://www.example.com" }); + tabLoadedPromise.then(() => { + // Once the popup opens, check if we have activeTab permission + browser.runtime.onMessage.addListener(async msg => { + if (msg === "popup-open") { + let tabs = await browser.tabs.query({}); + + browser.test.assertEq( + withHandlingUserInput ? 1 : 0, + tabs.filter((t) => typeof t.url !== "undefined").length, + "active tab permission only granted with user input" + ); + + await browser.tabs.remove((await tabPromise).id); + browser.test.sendMessage("activeTabsChecked"); + } + }); + + if (withHandlingUserInput) { + browser.test.withHandlingUserInput(() => { + browser.browserAction.openPopup(); + }); + } else { + browser.browserAction.openPopup(); + } + }) + }; + + let extension = ExtensionTestUtils.loadExtension({ + ...extensionData, + + background: `(${background})(${withHandlingUserInput})`, + + files: { + "popup.html": `<!DOCTYPE html><meta charset="utf-8"><script src="popup.js"><\/script>`, + async "popup.js"() { + browser.runtime.sendMessage("popup-open"); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("activeTabsChecked"); + await extension.unload(); +} + +add_task(async function test_browserAction_openPopup_activeTab() { + await testActiveTabPermissions(true); +}); + +add_task(async function test_browserAction_openPopup_non_activeTab() { + await testActiveTabPermissions(false); +}); + +add_task(async function test_browserAction_openPopup_invalid_states() { + let extension = ExtensionTestUtils.loadExtension({ + ...extensionData, + + background: async function() { + await browser.browserAction.setPopup({ popup: "" }) + await browser.test.assertRejects( + browser.browserAction.openPopup(), + "No popup URL is set", + "Should throw when no URL is set" + ); + + await browser.browserAction.disable() + await browser.test.assertRejects( + browser.browserAction.openPopup(), + "Popup is disabled", + "Should throw when disabled" + ); + + browser.test.notifyPass("invalidStates"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("invalidStates"); + await extension.unload(); +}); + +add_task(async function test_browserAction_openPopup_no_click_event() { + let extension = ExtensionTestUtils.loadExtension({ + ...extensionData, + + background: async function() { + let clicks = 0; + + browser.browserAction.onClicked.addListener(() => { + clicks++; + }); + + // Test with popup set + await browser.browserAction.openPopup(); + browser.test.sendMessage("close-popup"); + + browser.test.onMessage.addListener(async (msg) => { + if (msg === "popup-closed") { + // Test without popup + await browser.browserAction.setPopup({ popup: "" }); + + await browser.test.assertRejects( + browser.browserAction.openPopup(), + "No popup URL is set", + "Should throw when no URL is set" + ); + + // We expect the last call to be a no-op, so there isn't really anything + // to wait on. Instead, check that no clicks are registered after waiting + // for a sufficient amount of time. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(() => { + browser.test.assertEq(0, clicks, "onClicked should not be called"); + browser.test.notifyPass("noClick"); + }, 1000); + } + }); + }, + }); + + extension.onMessage("close-popup", async () => { + await AppTestDelegate.closeBrowserAction(window, extension); + extension.sendMessage("popup-closed"); + }); + + await extension.startup(); + await extension.awaitFinish("noClick"); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_incognito_window.html b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_incognito_window.html new file mode 100644 index 0000000000..8036d97398 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_incognito_window.html @@ -0,0 +1,151 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>action.openPopup Incognito Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +let extensionData = { + manifest: { + browser_specific_settings: { + gecko: { + id: "open-popup@tests.mozilla.org", + } + }, + browser_action: { + default_popup: "popup.html", + }, + permissions: ["activeTab"] + }, + + useAddonManager: "android-only", +}; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + "set": [ + ["extensions.openPopupWithoutUserGesture.enabled", true], + ], + }); +}); + +async function getIncognitoWindow() { + // Since events will be limited based on incognito, we need a + // spanning extension to get the tab id so we can test access failure. + + let windowWatcher = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background: function() { + browser.windows.create({ incognito: true }).then(({ id: windowId }) => { + browser.test.onMessage.addListener(async data => { + if (data === "close") { + await browser.windows.remove(windowId); + browser.test.sendMessage("window-closed"); + } + }); + + browser.test.sendMessage("window-id", windowId); + }); + }, + incognitoOverride: "spanning", + }); + + await windowWatcher.startup(); + let windowId = await windowWatcher.awaitMessage("window-id"); + + return { + windowId, + close: async () => { + windowWatcher.sendMessage("close"); + await windowWatcher.awaitMessage("window-closed"); + await windowWatcher.unload(); + }, + }; +} + +async function testWithIncognitoOverride(incognitoOverride) { + let extension = ExtensionTestUtils.loadExtension({ + ...extensionData, + + incognitoOverride, + + background: async function() { + browser.test.onMessage.addListener(async ({ windowId, incognitoOverride }) => { + const openPromise = browser.browserAction.openPopup({ windowId }); + + if (incognitoOverride === "not_allowed") { + await browser.test.assertRejects( + openPromise, + /Invalid window ID/, + "Should prevent open popup call for incognito window" + ); + } else { + try { + browser.test.assertEq(await openPromise, undefined, "openPopup resolved"); + } catch (e) { + browser.test.fail(`Unexpected error: ${e}`); + } + } + + browser.test.sendMessage("incognitoWindow"); + }); + }, + + files: { + "popup.html": `<!DOCTYPE html><meta charset="utf-8"><script src="popup.js"><\/script>`, + "popup.js"() { + browser.test.sendMessage("popup"); + }, + }, + }); + + await extension.startup(); + + let incognitoWindow = await getIncognitoWindow(); + await extension.sendMessage({ windowId: incognitoWindow.windowId, incognitoOverride }); + + await extension.awaitMessage("incognitoWindow"); + + // Wait for the popup to open - bug 1800100 + if (incognitoOverride === "spanning") { + await extension.awaitMessage("popup"); + } + + await extension.unload(); + + await incognitoWindow.close(); +} + +add_task(async function test_browserAction_openPopup_incognito_window_spanning() { + if (AppConstants.platform == "android") { + // TODO bug 1372178: Cannot open private windows from an extension. + todo(false, "Cannot open private windows on Android"); + return; + } + + await testWithIncognitoOverride("spanning"); +}); + +add_task(async function test_browserAction_openPopup_incognito_window_not_allowed() { + if (AppConstants.platform == "android") { + // TODO bug 1372178: Cannot open private windows from an extension. + todo(false, "Cannot open private windows on Android"); + return; + } + + + await testWithIncognitoOverride("not_allowed"); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_windowId.html b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_windowId.html new file mode 100644 index 0000000000..c528028901 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_windowId.html @@ -0,0 +1,162 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>action.openPopup Window ID Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +let extensionData = { + manifest: { + browser_specific_settings: { + gecko: { + id: "open-popup@tests.mozilla.org", + } + }, + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + }, + permissions: ["activeTab"] + }, + + useAddonManager: "android-only", +}; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + "set": [ + ["extensions.openPopupWithoutUserGesture.enabled", true], + ], + }); +}); + +async function testWithWindowState(state) { + const background = async function(state) { + const originalWindow = await browser.windows.getCurrent(); + + let newWindowPromise; + const tabLoadedPromise = new Promise(resolve => { + browser.tabs.onUpdated.addListener(async (id, { status }, tab) => { + if (tab.windowId === (await newWindowPromise).id && status === "complete") { + resolve(); + } + }); + }); + + newWindowPromise = browser.windows.create({ url: "tab.html" }); + + browser.test.onMessage.addListener(async (msg) => { + if (msg === "close-window") { + await browser.windows.remove((await newWindowPromise).id); + browser.test.sendMessage("window-closed"); + } + }); + + tabLoadedPromise.then(async () => { + const windowId = (await newWindowPromise).id; + + switch (state) { + case "inactive": + const focusChangePromise = new Promise(resolve => { + browser.windows.onFocusChanged.addListener((focusedWindowId) => { + if (focusedWindowId === originalWindow.id) { + resolve(); + } + }) + }); + await browser.windows.update(originalWindow.id, { focused: true }); + await focusChangePromise; + break; + case "minimized": + await browser.windows.update(windowId, { state: "minimized" }); + break; + default: + throw new Error(`Invalid state: ${state}`); + } + + await browser.browserAction.openPopup({ windowId }); + }); + }; + + let extension = ExtensionTestUtils.loadExtension({ + ...extensionData, + + background: `(${background})(${JSON.stringify(state)})`, + + files: { + "tab.html": "<!DOCTYPE html>", + "popup.html": `<!DOCTYPE html><meta charset="utf-8"><script src="popup.js"><\/script>`, + "popup.js"() { + // Small timeout to ensure the popup doesn't immediately close, which can + // happen when focus moves between windows + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(async () => { + let windows = await browser.windows.getAll(); + let highestWindowIdIsFocused = Math.max(...windows.map((w) => w.id)) + === windows.find((w) => w.focused).id; + + browser.test.assertEq(true, highestWindowIdIsFocused, "new window is focused"); + + await browser.test.sendMessage("popup-open"); + + // Bug 1800100: Window leaks if not explicitly closed + window.close(); + }, 1000); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("popup-open"); + await extension.sendMessage("close-window"); + await extension.awaitMessage("window-closed"); + await extension.unload(); +} + +add_task(async function test_browserAction_openPopup_window_inactive() { + if (AppConstants.platform == "linux") { + // TODO bug 1798334: Currently unreliable on linux + todo(false, "Unreliable on linux"); + return; + } + await testWithWindowState("inactive"); +}); + +add_task(async function test_browserAction_openPopup_window_minimized() { + if (AppConstants.platform == "linux") { + // TODO bug 1798334: Currently unreliable on linux + todo(false, "Unreliable on linux"); + return; + } + await testWithWindowState("minimized"); +}); + +add_task(async function test_browserAction_openPopup_invalid_window() { + let extension = ExtensionTestUtils.loadExtension({ + ...extensionData, + + background: async function() { + await browser.test.assertRejects( + browser.browserAction.openPopup({ windowId: Number.MAX_SAFE_INTEGER }), + /Invalid window ID/, + "Should throw for invalid window ID" + ); + browser.test.notifyPass("invalidWindow"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("invalidWindow"); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_without_pref.html b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_without_pref.html new file mode 100644 index 0000000000..aa7285d5f5 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_without_pref.html @@ -0,0 +1,58 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>action.openPopup Preference Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +let extensionData = { + manifest: { + browser_specific_settings: { + gecko: { + id: "open-popup@tests.mozilla.org", + } + }, + browser_action: { + default_popup: "popup.html", + } + }, + + useAddonManager: "android-only", +}; + +add_task(async function test_browserAction_openPopup_without_pref() { + await SpecialPowers.pushPrefEnv({ + "set": [ + ["extensions.openPopupWithoutUserGesture.enabled", false], + ], + }); + + let extension = ExtensionTestUtils.loadExtension({ + ...extensionData, + + background: async function() { + await browser.test.assertRejects( + browser.browserAction.openPopup(), + "openPopup requires a user gesture", + "Should throw when preference is unset" + ); + + browser.test.notifyPass("withoutPref"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("withoutPref"); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browserSettings_overrideDocumentColors.html b/toolkit/components/extensions/test/mochitest/test_ext_browserSettings_overrideDocumentColors.html new file mode 100644 index 0000000000..6486216376 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browserSettings_overrideDocumentColors.html @@ -0,0 +1,175 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>browserSettings.overrideDocumentColors</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"> +</head> +<body> +<script> +"use strict"; + +add_setup(async () => { + // Set some defaults to make it easier to test the baseline behavior. + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.display.background_color", "#010203"], // rgb(1, 2, 3) + ["browser.display.foreground_color", "#fefd00"], // rgb(254, 253, 00) + ["browser.display.use_system_colors", false], + ], + }); +}); + +async function with_overrideDocumentColors(value, callbackAfterOverride) { + async function background(value) { + await browser.browserSettings.overrideDocumentColors.set({ value }); + browser.test.sendMessage("settings_set"); + } + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + permissions: ["browserSettings"], + }, + background: `(${background})(${JSON.stringify(value)})`, + }); + await extension.startup(); + await extension.awaitMessage("settings_set"); + + await callbackAfterOverride(); + + await extension.unload(); +} + +async function testGetExtensionColors({ useCssLayers, useImportant } = {}) { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + // Note: using manifest_version: 2, because browser_style has been dropped + // from manifest_version: 3, which would result in all tests passing + // trivially because MV3 does not have style overrides. + browser_action: { + default_popup: "test.html", + browser_style: true, + }, + }, + files: { + "test.html": `<!DOCTYPE html> + <style id="extStyle"> + ${useCssLayers ? "@layer {" : ""} + body { + background-color: rgb(12, 34, 56)${useImportant ? " !important": ""}; + color: rgb(98, 76, 54) ${useImportant ? " !important": ""}; + } + ${useCssLayers ? "}" : ""} + </style> + <body><script src="test.js"><\/script>`, + "test.js": () => { + let { + backgroundColor: bgColor, + color: fgColor, + } = window.getComputedStyle(document.body); + + document.getElementById("extStyle").disabled = true; + + let { color: defaultFgColor } = window.getComputedStyle(document.body); + + browser.test.sendMessage("test_result", { + bgColor, + fgColor, + defaultFgColor, + }); + }, + }, + }); + await extension.startup(); + await AppTestDelegate.clickBrowserAction(window, extension); + let result = await extension.awaitMessage("test_result"); + await AppTestDelegate.closeBrowserAction(window, extension); + await extension.unload(); + return result; +} + +add_task(async function overrideDocumentColors_always() { + let result; + await with_overrideDocumentColors("always", async () => { + result = await testGetExtensionColors(); + }); + // Note: these colors are from add_setup. The colors from test.html should + // be ignored. + is(result.bgColor, "rgb(1, 2, 3)", "Bg color from user"); + is(result.fgColor, "rgb(254, 253, 0)", "Fg color from user"); + is(result.defaultFgColor, "rgb(254, 253, 0)", "Default fg color from user"); +}); + +add_task(async function overrideDocumentColors_never() { + let result; + await with_overrideDocumentColors("never", async () => { + result = await testGetExtensionColors(); + }); + // Note: these colors are from test.html in testGetExtensionColors + is(result.bgColor, "rgb(12, 34, 56)", "Bg color from extension"); + is(result.fgColor, "rgb(98, 76, 54)", "Fg color from extension"); + if (AppConstants.platform === "android") { + is( + result.defaultFgColor, + "rgb(0, 0, 0)", // = default font color without extension.css + "Default fg color is from the UA (extension.css unavailable)" + ); + } else { + is( + result.defaultFgColor, + "rgb(34, 36, 38)", // = "color: #222426;" from extension.css + "Default fg color is from browser_style's extension.css" + ); + } +}); + +add_task(async function with_css_cascade_layers() { + if (AppConstants.platform === "android") { + // extension.css does not exist on Android, so overrideDocumentColors_never + // already covers this case. + info("Skipping - already covered by overrideDocumentColors_never"); + return; + } + let result; + await with_overrideDocumentColors("never", async () => { + result = await testGetExtensionColors({ useCssLayers: true }); + }); + // Ideally, the results are equivalent to overrideDocumentColors_never, + // because we tried really hard to ensure that extension.css has the least + // precedence and CSS specificity. The implementation has one limitation: + // Layered selectors from extension themselves are always lower precedence + // than the browser_style stylesheet. This is not ideal but has minimal impact + // due to the minimal use of @layer in the wild: + // https://bugzilla.mozilla.org/show_bug.cgi?id=1873024#c4 + // Besides, the same observation is true in Chrome: @layer in extensions have + // lower precedence than the chrome_style stylesheet. + // + // Extensions have the following work-arounds (covered by tests): + // - Do not use @layer (overrideDocumentColors_never) + // - Use !important (with_css_cascade_layers_and_important) + // - disable browser_style (browser_ext_optionsPage_browser_style.js) + is( + result.fgColor, + "rgb(34, 36, 38)", // = "color: #222426;" from extension.css + "Fg color from extension.css takes precedence over @layer from extension" + ); +}); + +add_task(async function with_css_cascade_layers_and_important() { + let result; + await with_overrideDocumentColors("never", async () => { + result = await testGetExtensionColors({ + useCssLayers: true, + useImportant: true, + }); + }); + is(result.fgColor, "rgb(98, 76, 54)", "important color from extension"); +}); + +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browsingData_indexedDB.html b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_indexedDB.html new file mode 100644 index 0000000000..f8ea41ddab --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_indexedDB.html @@ -0,0 +1,159 @@ +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +<!DOCTYPE HTML> +<html> +<head> + <title>Test browsingData.remove indexedDB</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function testIndexedDB() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", true]], + }); + + async function background() { + const PAGE = + "/tests/toolkit/components/extensions/test/mochitest/file_indexedDB.html"; + + let tabs = []; + + browser.test.onMessage.addListener(async msg => { + if (msg == "cleanup") { + await Promise.all(tabs.map(tabId => browser.tabs.remove(tabId))); + browser.test.sendMessage("done"); + return; + } + + await browser.browsingData.remove(msg, { indexedDB: true }); + browser.test.sendMessage("indexedDBRemoved"); + }); + + // Create two tabs. + let tab = await browser.tabs.create({ url: `https://example.org${PAGE}` }); + tabs.push(tab.id); + + tab = await browser.tabs.create({ url: `https://example.com${PAGE}` }); + tabs.push(tab.id); + + // Create tab with cookieStoreId "firefox-container-1" + tab = await browser.tabs.create({ url: `https://example.net${PAGE}`, cookieStoreId: 'firefox-container-1' }); + tabs.push(tab.id); + } + + function contentScript() { + // eslint-disable-next-line mozilla/balanced-listeners + window.addEventListener( + "message", + msg => { + browser.test.sendMessage("indexedDBCreated"); + }, + true + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData", "tabs", "cookies"], + content_scripts: [ + { + matches: [ + "https://example.org/*/file_indexedDB.html", + "https://example.com/*/file_indexedDB.html", + "https://example.net/*/file_indexedDB.html", + ], + js: ["script.js"], + run_at: "document_start", + }, + ], + }, + files: { + "script.js": contentScript, + }, + }); + + await extension.startup(); + + await extension.awaitMessage("indexedDBCreated"); + await extension.awaitMessage("indexedDBCreated"); + await extension.awaitMessage("indexedDBCreated"); + + function getUsage() { + return new Promise(resolve => { + let qms = SpecialPowers.Services.qms; + let cb = SpecialPowers.wrapCallback(request => resolve(request.result)); + qms.getUsage(cb); + }); + } + + async function getOrigins() { + let origins = []; + let result = await getUsage(); + for (let i = 0; i < result.length; ++i) { + if (result[i].usage === 0) { + continue; + } + if ( + result[i].origin.startsWith("https://example.org") || + result[i].origin.startsWith("https://example.com") || + result[i].origin.startsWith("https://example.net") + ) { + origins.push(result[i].origin); + } + } + return origins.sort(); + } + + let origins = await getOrigins(); + is(origins.length, 3, "IndexedDB databases have been populated."); + + // Deleting private browsing mode data is silently ignored. + extension.sendMessage({ cookieStoreId: "firefox-private" }); + await extension.awaitMessage("indexedDBRemoved"); + + origins = await getOrigins(); + is(origins.length, 3, "All indexedDB remains after clearing firefox-private"); + + // Delete by hostname + extension.sendMessage({ hostnames: ["example.com"] }); + await extension.awaitMessage("indexedDBRemoved"); + + origins = await getOrigins(); + is(origins.length, 2, "IndexedDB data only for only two domains left"); + ok(origins[0].startsWith("https://example.net"), "example.net not deleted"); + ok(origins[1].startsWith("https://example.org"), "example.org not deleted"); + + // TODO: Bug 1643740 + if (AppConstants.platform != "android") { + // Delete by cookieStoreId + extension.sendMessage({ cookieStoreId: "firefox-container-1" }); + await extension.awaitMessage("indexedDBRemoved"); + + origins = await getOrigins(); + is(origins.length, 1, "IndexedDB data only for only one domain"); + ok(origins[0].startsWith("https://example.org"), "example.org not deleted"); + } + + // Delete all + extension.sendMessage({}); + await extension.awaitMessage("indexedDBRemoved"); + + origins = await getOrigins(); + is(origins.length, 0, "All IndexedDB data has been removed."); + + await extension.sendMessage("cleanup"); + await extension.awaitMessage("done"); + + await extension.unload(); +}); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browsingData_localStorage.html b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_localStorage.html new file mode 100644 index 0000000000..2fd608f125 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_localStorage.html @@ -0,0 +1,323 @@ +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +<!DOCTYPE HTML> +<html> +<head> + <title>Test browsingData.remove indexedDB</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function setup() { + // make sure userContext is enabled. + return SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", true]], + }); +}); + +add_task(async function testLocalStorage() { + async function background() { + function waitForTabs() { + return new Promise(resolve => { + let tabs = {}; + + let listener = async (msg, { tab }) => { + if (msg !== "content-script-ready") { + return; + } + + tabs[tab.url] = tab; + if (Object.keys(tabs).length == 3) { + browser.runtime.onMessage.removeListener(listener); + resolve(tabs); + } + }; + browser.runtime.onMessage.addListener(listener); + }); + } + + function sendMessageToTabs(tabs, message) { + return Promise.all( + Object.values(tabs).map(tab => { + return browser.tabs.sendMessage(tab.id, message); + }) + ); + } + + let tabs = await waitForTabs(); + + browser.test.assertRejects( + browser.browsingData.removeLocalStorage({ since: Date.now() }), + "Firefox does not support clearing localStorage with 'since'.", + "Expected error received when using unimplemented parameter 'since'." + ); + + await sendMessageToTabs(tabs, "resetLocalStorage"); + await browser.browsingData.removeLocalStorage({ + hostnames: ["example.com"], + }); + await browser.tabs.sendMessage(tabs["https://example.com/"].id, "checkLocalStorageCleared"); + await browser.tabs.sendMessage(tabs["https://example.net/"].id, "checkLocalStorageSet"); + + if ( + SpecialPowers.Services.domStorageManager.nextGenLocalStorageEnabled === + false + ) { + // This assertion fails when localStorage is using the legacy + // implementation (See Bug 1595431). + browser.test.log("Skipped assertion on nextGenLocalStorageEnabled=false"); + } else { + await browser.tabs.sendMessage(tabs["https://test1.example.com/"].id, "checkLocalStorageSet"); + } + + await sendMessageToTabs(tabs, "resetLocalStorage"); + await sendMessageToTabs(tabs, "checkLocalStorageSet"); + await browser.browsingData.removeLocalStorage({}); + await sendMessageToTabs(tabs, "checkLocalStorageCleared"); + + await sendMessageToTabs(tabs, "resetLocalStorage"); + await sendMessageToTabs(tabs, "checkLocalStorageSet"); + await browser.browsingData.remove({}, { localStorage: true }); + await sendMessageToTabs(tabs, "checkLocalStorageCleared"); + + // Can only delete cookieStoreId with LSNG enabled. + if (SpecialPowers.Services.domStorageManager.nextGenLocalStorageEnabled) { + await sendMessageToTabs(tabs, "resetLocalStorage"); + await sendMessageToTabs(tabs, "checkLocalStorageSet"); + await browser.browsingData.removeLocalStorage({ + cookieStoreId: "firefox-container-1", + }); + await browser.tabs.sendMessage(tabs["https://example.com/"].id, "checkLocalStorageSet"); + await browser.tabs.sendMessage(tabs["https://example.net/"].id, "checkLocalStorageSet"); + + // TODO: containers support is lacking on GeckoView (Bug 1643740) + if (!navigator.userAgent.includes("Android")) { + await browser.tabs.sendMessage(tabs["https://test1.example.com/"].id, "checkLocalStorageCleared"); + } + + await sendMessageToTabs(tabs, "resetLocalStorage"); + await sendMessageToTabs(tabs, "checkLocalStorageSet"); + // Hostname doesn't match, so nothing cleared. + await browser.browsingData.removeLocalStorage({ + cookieStoreId: "firefox-container-1", + hostnames: ["example.net"], + }); + await sendMessageToTabs(tabs, "checkLocalStorageSet"); + + await sendMessageToTabs(tabs, "resetLocalStorage"); + await sendMessageToTabs(tabs, "checkLocalStorageSet"); + // Deleting private browsing mode data is silently ignored. + await browser.browsingData.removeLocalStorage({ + cookieStoreId: "firefox-private", + }); + await sendMessageToTabs(tabs, "checkLocalStorageSet"); + } else { + await browser.test.assertRejects( + browser.browsingData.removeLocalStorage({ + cookieStoreId: "firefox-container-1", + }), + "Firefox does not support clearing localStorage with 'cookieStoreId'.", + "removeLocalStorage with cookieStoreId requires LSNG" + ); + } + + // Cleanup (checkLocalStorageCleared creates empty LS databases). + await browser.browsingData.removeLocalStorage({}); + + browser.test.notifyPass("done"); + } + + function contentScript() { + browser.runtime.onMessage.addListener(msg => { + if (msg === "resetLocalStorage") { + localStorage.clear(); + localStorage.setItem("test", "test"); + } else if (msg === "checkLocalStorageSet") { + browser.test.assertEq( + "test", + localStorage.getItem("test"), + `checkLocalStorageSet: ${location.href}` + ); + } else if (msg === "checkLocalStorageCleared") { + browser.test.assertEq( + null, + localStorage.getItem("test"), + `checkLocalStorageCleared: ${location.href}` + ); + } + }); + browser.runtime.sendMessage("content-script-ready"); + } + + // This extension is responsible for opening tabs with a specified + // cookieStoreId, we use a separate extension to make sure that browsingData + // works without the cookies permission. + let openTabsExtension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + name: "Open tabs", + browser_specific_settings: { gecko: { id: "open-tabs@tests.mozilla.org" }, }, + permissions: ["cookies"], + }, + async background() { + const TABS = [ + { url: "https://example.com" }, + { url: "https://example.net" }, + { + url: "https://test1.example.com", + cookieStoreId: 'firefox-container-1', + }, + ]; + + function awaitLoad(tabId) { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener(tabId_, changed, tab) { + if (tabId == tabId_ && changed.status == "complete") { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + } + + let tabs = []; + let loaded = []; + for (let options of TABS) { + let tab = await browser.tabs.create(options); + loaded.push(awaitLoad(tab.id)); + tabs.push(tab); + } + + await Promise.all(loaded); + + browser.test.onMessage.addListener(async msg => { + if (msg === "cleanup") { + const tabIds = tabs.map(tab => tab.id); + let removedTabs = 0; + browser.tabs.onRemoved.addListener(tabId => { + browser.test.log(`Removing tab ${tabId}.`); + if (tabIds.includes(tabId)) { + removedTabs++; + if (removedTabs == tabIds.length) { + browser.test.sendMessage("done"); + } + } + }); + await browser.tabs.remove(tabIds); + } + }); + } + }); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background, + manifest: { + name: "Test Extension", + browser_specific_settings: { gecko: { id: "localStorage@tests.mozilla.org" } }, + permissions: ["browsingData", "tabs"], + content_scripts: [ + { + matches: [ + "https://example.com/", + "https://example.net/", + "https://test1.example.com/", + ], + js: ["content-script.js"], + run_at: "document_end", + }, + ], + }, + files: { + "content-script.js": contentScript, + }, + }); + + await openTabsExtension.startup(); + + await extension.startup(); + await extension.awaitFinish("done"); + await extension.unload(); + + await openTabsExtension.sendMessage("cleanup"); + await openTabsExtension.awaitMessage("done"); + await openTabsExtension.unload(); +}); + +// Verify that browsingData.removeLocalStorage doesn't break on data stored +// in about:newtab or file principals. +add_task(async function test_browserData_on_aboutnewtab_and_file_data() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + async background() { + await browser.browsingData.removeLocalStorage({}).catch(err => { + browser.test.fail(`${err} :: ${err.stack}`); + }); + browser.test.sendMessage("done"); + }, + manifest: { + browser_specific_settings: { gecko: { id: "indexed-db-file@test.mozilla.org" } }, + permissions: ["browsingData"], + }, + }); + + await new Promise(resolve => { + const chromeScript = SpecialPowers.loadChromeScript(async () => { + /* eslint-env mozilla/chrome-script */ + const { SiteDataTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/SiteDataTestUtils.sys.mjs" + ); + await SiteDataTestUtils.addToIndexedDB("about:newtab"); + await SiteDataTestUtils.addToIndexedDB("file:///fake/file"); + sendAsyncMessage("done"); + }); + + chromeScript.addMessageListener("done", () => { + chromeScript.destroy(); + resolve(); + }); + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function test_browserData_should_not_remove_extension_data() { + if (SpecialPowers.getBoolPref("dom.storage.enable_unsupported_legacy_implementation")) { + // When LSNG isn't enabled, the browsingData API does still clear + // all the extensions localStorage if called without a list of specific + // origins to clear. + info("Test skipped because LSNG is currently disabled"); + return; + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + async background() { + window.localStorage.setItem("key", "value"); + await browser.browsingData.removeLocalStorage({}).catch(err => { + browser.test.fail(`${err} :: ${err.stack}`); + }); + browser.test.sendMessage("done", window.localStorage.getItem("key")); + }, + manifest: { + browser_specific_settings: { gecko: { id: "extension-data@tests.mozilla.org" } }, + permissions: ["browsingData"], + }, + }); + + await extension.startup(); + const lsValue = await extension.awaitMessage("done"); + is(lsValue, "value", "Got the expected localStorage data"); + await extension.unload(); +}); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browsingData_pluginData.html b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_pluginData.html new file mode 100644 index 0000000000..bf4bd8fe80 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_pluginData.html @@ -0,0 +1,69 @@ +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +<!DOCTYPE HTML> +<html> +<head> + <title>Test browsingData.remove indexedDB</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +// NB: Since plugins are disabled, there is never any data to clear. +// We are really testing that these operations are no-ops. + +add_task(async function testPluginData() { + async function background() { + const REFERENCE_DATE = Date.now(); + const TEST_CASES = [ + // Clear plugin data with no since value. + {}, + // Clear pluginData with recent since value. + { since: REFERENCE_DATE - 20000 }, + // Clear pluginData with old since value. + { since: REFERENCE_DATE - 1000000 }, + // Clear pluginData for specific hosts. + { hostnames: ["bar.com", "baz.com"] }, + // Clear pluginData for no hosts. + { hostnames: [] }, + ]; + + for (let method of ["removePluginData", "remove"]) { + for (let options of TEST_CASES) { + browser.test.log(`Testing ${method} with ${JSON.stringify(options)}`); + if (method == "removePluginData") { + await browser.browsingData.removePluginData(options); + } else { + await browser.browsingData.remove(options, { pluginData: true }); + } + } + } + + browser.test.sendMessage("done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["tabs", "browsingData"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + + // This test has no assertions because it's only meant to check that we don't + // throw when calling removePluginData and remove with pluginData: true. + ok(true, "dummy check"); + + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browsingData_serviceWorkers.html b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_serviceWorkers.html new file mode 100644 index 0000000000..d8ebd8e225 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_serviceWorkers.html @@ -0,0 +1,141 @@ +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +<!DOCTYPE HTML> +<html> +<head> + <title>Test browsingData.remove indexedDB</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +const { TestUtils } = SpecialPowers.ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["dom.serviceWorkers.exemptFromPerDomainMax", true], + ["dom.serviceWorkers.enabled", true], + ["dom.serviceWorkers.testing.enabled", true], + ], + }); +}); + +add_task(async function testServiceWorkers() { + async function background() { + const PAGE = + "/tests/toolkit/components/extensions/test/mochitest/file_serviceWorker.html"; + + browser.runtime.onMessage.addListener(msg => { + browser.test.sendMessage("serviceWorkerRegistered"); + }); + + let tabs = []; + + browser.test.onMessage.addListener(async msg => { + if (msg == "cleanup") { + await browser.tabs.remove(tabs.map(tab => tab.id)); + browser.test.sendMessage("done"); + return; + } + + await browser.browsingData.remove( + { hostnames: msg.hostnames }, + { serviceWorkers: true } + ); + browser.test.sendMessage("serviceWorkersRemoved"); + }); + + // Create two serviceWorkers. + let tab = await browser.tabs.create({ url: `http://mochi.test:8888${PAGE}` }); + tabs.push(tab); + + tab = await browser.tabs.create({ url: `https://example.com${PAGE}` }); + tabs.push(tab); + } + + function contentScript() { + // eslint-disable-next-line mozilla/balanced-listeners + window.addEventListener( + "message", + msg => { + if (msg.data == "serviceWorkerRegistered") { + browser.runtime.sendMessage("serviceWorkerRegistered"); + } + }, + true + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData", "tabs"], + content_scripts: [ + { + matches: [ + "http://mochi.test/*/file_serviceWorker.html", + "https://example.com/*/file_serviceWorker.html", + ], + js: ["script.js"], + run_at: "document_start", + }, + ], + }, + files: { + "script.js": contentScript, + }, + }); + + await extension.startup(); + await extension.awaitMessage("serviceWorkerRegistered"); + await extension.awaitMessage("serviceWorkerRegistered"); + + // Even though we await the registrations by waiting for the messages, + // sometimes the serviceWorkers are still not registered at this point. + async function getRegistrations(count) { + await TestUtils.waitForCondition( + async () => (await SpecialPowers.registeredServiceWorkers()).length === count, + `Wait for ${count} service workers to be registered` + ); + return SpecialPowers.registeredServiceWorkers(); + } + + let serviceWorkers = await getRegistrations(2); + is(serviceWorkers.length, 2, "ServiceWorkers have been registered."); + + extension.sendMessage({ hostnames: ["example.com"] }); + await extension.awaitMessage("serviceWorkersRemoved"); + + serviceWorkers = await getRegistrations(1); + is( + serviceWorkers.length, + 1, + "ServiceWorkers for example.com have been removed." + ); + + let { scriptSpec } = serviceWorkers[0]; + dump(`Service worker spec: ${scriptSpec}`); + ok(scriptSpec.startsWith("http://mochi.test:8888/"), + "ServiceWorkers for example.com have been removed."); + + extension.sendMessage({}); + await extension.awaitMessage("serviceWorkersRemoved"); + + serviceWorkers = await getRegistrations(0); + is(serviceWorkers.length, 0, "All ServiceWorkers have been removed."); + + extension.sendMessage("cleanup"); + await extension.awaitMessage("done"); + await extension.unload(); +}); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browsingData_settings.html b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_settings.html new file mode 100644 index 0000000000..11c690e5bf --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_settings.html @@ -0,0 +1,65 @@ +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +<!DOCTYPE HTML> +<html> +<head> + <title>Test browsingData.settings</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +const SETTINGS_LIST = [ + "cache", + "cookies", + "history", + "formData", + "downloads", +].sort(); + +add_task(async function testSettings() { + async function background() { + browser.browsingData.settings().then(settings => { + browser.test.sendMessage("settings", settings); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + await extension.startup(); + let settings = await extension.awaitMessage("settings"); + + // Verify that we get the keys back we expect. + isDeeply( + Object.entries(settings.dataToRemove) + .filter(([key, value]) => value) + .map(([key, value]) => key) + .sort(), + SETTINGS_LIST, + "dataToRemove contains expected properties." + ); + isDeeply( + Object.entries(settings.dataRemovalPermitted) + .filter(([key, value]) => value) + .map(([key, value]) => key) + .sort(), + SETTINGS_LIST, + "dataToRemove contains expected properties." + ); + is("since" in settings.options, true, "options contains |since|"); + + await extension.unload(); +}); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_canvas_resistFingerprinting.html b/toolkit/components/extensions/test/mochitest/test_ext_canvas_resistFingerprinting.html new file mode 100644 index 0000000000..f60f335dc5 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_canvas_resistFingerprinting.html @@ -0,0 +1,65 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <script type="text/javascript" src="head_cookies.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.resistFingerprinting", true]], + }); +}); + +add_task(async function test_contentscript() { + function contentScript() { + let canvas = document.createElement("canvas"); + canvas.width = canvas.height = "100"; + + let ctx = canvas.getContext("2d"); + ctx.fillStyle = "green"; + ctx.fillRect(0, 0, 100, 100); + let data = ctx.getImageData(50, 50, 1, 1); + + browser.test.sendMessage("data-color", data.data[1]); + } + + let extensionData = { + manifest: { + content_scripts: [ + { + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + "run_at": "document_start", + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }; + const url = "http://mochi.test:8888/chrome/toolkit/components/extensions/test/mochitest/file_sample.html"; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + let win = window.open(url); + let color = await extension.awaitMessage("data-color"); + const expected = 128; + is(color, expected, "Got correct pixel data for green"); + win.close(); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_clipboard.html b/toolkit/components/extensions/test/mochitest/test_ext_clipboard.html new file mode 100644 index 0000000000..77ac767391 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_clipboard.html @@ -0,0 +1,210 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Clipboard permissions tests</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"> +</head> +<body> + +<script> +"use strict"; + +/* globals doCopy, doPaste */ +function shared() { + let field = document.createElement("textarea"); + document.body.appendChild(field); + field.contentEditable = true; + + this.doCopy = function(txt) { + field.value = txt; + field.select(); + return document.execCommand("copy"); + }; + + this.doPaste = function() { + field.select(); + return document.execCommand("paste") && field.value; + }; +} + +add_task(async function test_background_clipboard_permissions() { + function backgroundScript() { + browser.test.assertEq(false, doCopy("whatever"), + "copy should be denied without permission"); + browser.test.assertEq(false, doPaste(), + "paste should be denied without permission"); + browser.test.sendMessage("ready"); + } + let extensionData = { + background: [shared, backgroundScript], + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitMessage("ready"); + + await extension.unload(); +}); + +add_task(async function test_background_clipboard_copy() { + function backgroundScript() { + browser.test.onMessage.addListener(txt => { + browser.test.assertEq(true, doCopy(txt), + "copy should be allowed with permission"); + }); + browser.test.sendMessage("ready"); + } + let extensionData = { + background: `(${shared})();(${backgroundScript})();`, + manifest: { + permissions: [ + "clipboardWrite", + ], + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("ready"); + + const DUMMY_STR = "dummy string to copy"; + await new Promise(resolve => { + SimpleTest.waitForClipboard(DUMMY_STR, () => { + extension.sendMessage(DUMMY_STR); + }, resolve, resolve); + }); + + await extension.unload(); +}); + +add_task(async function test_contentscript_clipboard_permissions() { + function contentScript() { + browser.test.assertEq(false, doCopy("whatever"), + "copy should be denied without permission"); + browser.test.assertEq(false, doPaste(), + "paste should be denied without permission"); + browser.test.sendMessage("ready"); + } + let extensionData = { + manifest: { + content_scripts: [{ + js: ["shared.js", "contentscript.js"], + matches: ["http://mochi.test/*/file_sample.html"], + }], + }, + files: { + "shared.js": shared, + "contentscript.js": contentScript, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let win = window.open("file_sample.html"); + await extension.awaitMessage("ready"); + win.close(); + + await extension.unload(); +}); + +add_task(async function test_contentscript_clipboard_copy() { + function contentScript() { + browser.test.onMessage.addListener(txt => { + browser.test.assertEq(true, doCopy(txt), + "copy should be allowed with permission"); + }); + browser.test.sendMessage("ready"); + } + let extensionData = { + manifest: { + content_scripts: [{ + js: ["shared.js", "contentscript.js"], + matches: ["http://mochi.test/*/file_sample.html"], + }], + permissions: [ + "clipboardWrite", + ], + }, + files: { + "shared.js": shared, + "contentscript.js": contentScript, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let win = window.open("file_sample.html"); + await extension.awaitMessage("ready"); + + const DUMMY_STR = "dummy string to copy in content script"; + await new Promise(resolve => { + SimpleTest.waitForClipboard(DUMMY_STR, () => { + extension.sendMessage(DUMMY_STR); + }, resolve, resolve); + }); + + win.close(); + + await extension.unload(); +}); + +add_task(async function test_contentscript_clipboard_paste() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "clipboardRead", + ], + content_scripts: [{ + matches: ["http://mochi.test/*/file_sample.html"], + js: ["shared.js", "content_script.js"], + }], + }, + files: { + "shared.js": shared, + "content_script.js": () => { + browser.test.sendMessage("paste", doPaste()); + }, + }, + }); + + const STRANGE = "A Strange Thing"; + SpecialPowers.clipboardCopyString(STRANGE); + + await extension.startup(); + const win = window.open("file_sample.html"); + + const paste = await extension.awaitMessage("paste"); + is(paste, STRANGE, "the correct string was pasted"); + + win.close(); + await extension.unload(); +}); + +add_task(async function test_background_clipboard_paste() { + function background() { + browser.test.sendMessage("paste", doPaste()); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["clipboardRead"], + }, + background: [shared, background], + }); + + const STRANGE = "Stranger Things"; + SpecialPowers.clipboardCopyString(STRANGE); + + await extension.startup(); + + const paste = await extension.awaitMessage("paste"); + is(paste, STRANGE, "the correct string was pasted"); + + await extension.unload(); +}); + +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_clipboard_image.html b/toolkit/components/extensions/test/mochitest/test_ext_clipboard_image.html new file mode 100644 index 0000000000..b5d5f6764a --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_clipboard_image.html @@ -0,0 +1,262 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Clipboard permissions tests</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"> +</head> +<body> + +<script> +"use strict"; +/** + * This cannot be a xpcshell test, because: + * - On Android, copyString of nsIClipboardHelper segfaults because + * widget/android/nsClipboard.cpp calls java::Clipboard::SetText, which is + * unavailable in xpcshell. + * - On Windows, the clipboard is unavailable to xpcshell. + */ + +function resetClipboard() { + SpecialPowers.clipboardCopyString( + "This is the default value of the clipboard in the test."); +} + +async function checkClipboardHasTestImage(imageType) { + async function backgroundScript(imageType) { + async function verifyImage(img) { + // Checks whether the image is a 1x1 red image. + browser.test.assertEq(1, img.naturalWidth, "image width should match"); + browser.test.assertEq(1, img.naturalHeight, "image height should match"); + + let canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + let ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0); // Draw without scaling. + let [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data; + let expectedColor; + if (imageType === "png") { + expectedColor = [255, 0, 0]; + } else if (imageType === "jpeg") { + expectedColor = [254, 0, 0]; + } + let {os} = await browser.runtime.getPlatformInfo(); + if (os === "mac") { + // Due to https://bugzil.la/1396587, the pasted image differs from the + // original/expected image. + // Once that bug is fixed, this whole macOS-only branch can be removed. + if (imageType === "png") { + expectedColor = [255, 38, 0]; + } else if (imageType === "jpeg") { + expectedColor = [255, 38, 0]; + } + } + browser.test.assertEq(expectedColor[0], r, "pixel should be red"); + browser.test.assertEq(expectedColor[1], g, "pixel should not contain green"); + browser.test.assertEq(expectedColor[2], b, "pixel should not contain blue"); + browser.test.assertEq(255, a, "pixel should be opaque"); + } + + let editable = document.body; + editable.contentEditable = true; + let file; + await new Promise(resolve => { + document.addEventListener("paste", function(event) { + browser.test.assertEq(1, event.clipboardData.types.length, "expected one type"); + browser.test.assertEq("Files", event.clipboardData.types[0], "expected type"); + browser.test.assertEq(1, event.clipboardData.files.length, "expected one file"); + + // After returning from the paste event, event.clipboardData is cleaned, so we + // have to store the file in a separate variable. + file = event.clipboardData.files[0]; + resolve(); + }, {once: true}); + + document.execCommand("paste"); // requires clipboardWrite permission. + }); + + // When image data is copied, its first frame is decoded and exported to the + // clipboard. The pasted result is always an unanimated PNG file, regardless + // of the input. + browser.test.assertEq("image/png", file.type, "expected file.type"); + + // event.files[0] should be an accurate representation of the input image. + { + let img = new Image(); + await new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = () => reject(new Error(`Failed to load image ${img.src} of size ${file.size}`)); + img.src = URL.createObjectURL(file); + }); + + await verifyImage(img); + } + + // This confirms that an image was put on the clipboard. + // In contrast, when document.execCommand('copy') + clipboardData.setData + // is used, then the 'paste' event will also have the image data (as tested + // above), but the contentEditable area will be empty. + { + let imgs = editable.querySelectorAll("img"); + browser.test.assertEq(1, imgs.length, "should have pasted one image"); + await verifyImage(imgs[0]); + } + browser.test.sendMessage("tested image on clipboard"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})("${imageType}");`, + manifest: { + permissions: ["clipboardRead"], + }, + }); + await extension.startup(); + await extension.awaitMessage("tested image on clipboard"); + await extension.unload(); +} + +add_task(async function test_without_clipboard_permission() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.assertEq(undefined, browser.clipboard, + "clipboard API requires the clipboardWrite permission."); + browser.test.notifyPass(); + }, + manifest: { + permissions: ["clipboardRead"], + }, + }); + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task(async function test_copy_png() { + if (AppConstants.platform === "android") { + return; // Android does not support images on the clipboard. + } + let extension = ExtensionTestUtils.loadExtension({ + async background() { + // A 1x1 red PNG image. + let b64data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAABlBMVEX/AAD///9BHTQRAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAACklEQVQImWNgAAAAAgAB9HFkpgAAAABJRU5ErkJggg=="; + let imageData = Uint8Array.from(atob(b64data), c => c.charCodeAt(0)).buffer; + await browser.clipboard.setImageData(imageData, "png"); + browser.test.sendMessage("Called setImageData with PNG"); + }, + manifest: { + permissions: ["clipboardWrite"], + }, + }); + + resetClipboard(); + + await extension.startup(); + await extension.awaitMessage("Called setImageData with PNG"); + await extension.unload(); + + await checkClipboardHasTestImage("png"); +}); + +add_task(async function test_copy_jpeg() { + if (AppConstants.platform === "android") { + return; // Android does not support images on the clipboard. + } + let extension = ExtensionTestUtils.loadExtension({ + async background() { + // A 1x1 red JPEG image, created using: convert xc:red red.jpg. + // JPEG is lossy, and the red pixel value is actually #FE0000 instead of + // #FF0000 (also seen using: convert red.jpg text:-). + let b64data = "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACP/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAVAQEBAAAAAAAAAAAAAAAAAAAHCf/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/ADoDFU3/2Q=="; + let imageData = Uint8Array.from(atob(b64data), c => c.charCodeAt(0)).buffer; + await browser.clipboard.setImageData(imageData, "jpeg"); + browser.test.sendMessage("Called setImageData with JPEG"); + }, + manifest: { + permissions: ["clipboardWrite"], + }, + }); + + resetClipboard(); + + await extension.startup(); + await extension.awaitMessage("Called setImageData with JPEG"); + await extension.unload(); + + await checkClipboardHasTestImage("jpeg"); +}); + +add_task(async function test_copy_invalid_image() { + if (AppConstants.platform === "android") { + // Android does not support images on the clipboard. + return; + } + let extension = ExtensionTestUtils.loadExtension({ + async background() { + // This is a PNG image. + let b64data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAABlBMVEX/AAD///9BHTQRAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAACklEQVQImWNgAAAAAgAB9HFkpgAAAABJRU5ErkJggg=="; + let pngImageData = Uint8Array.from(atob(b64data), c => c.charCodeAt(0)).buffer; + await browser.test.assertRejects( + browser.clipboard.setImageData(pngImageData, "jpeg"), + "Data is not a valid jpeg image", + "Image data that is not valid for the given type should be rejected."); + browser.test.sendMessage("finished invalid image"); + }, + manifest: { + permissions: ["clipboardWrite"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("finished invalid image"); + await extension.unload(); +}); + +add_task(async function test_copy_invalid_image_type() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + // setImageData expects "png" or "jpeg", but we pass "image/png" here. + browser.test.assertThrows( + () => { browser.clipboard.setImageData(new ArrayBuffer(0), "image/png"); }, + "Type error for parameter imageType (Invalid enumeration value \"image/png\") for clipboard.setImageData.", + "An invalid type for setImageData should be rejected."); + browser.test.sendMessage("finished invalid type"); + }, + manifest: { + permissions: ["clipboardWrite"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("finished invalid type"); + await extension.unload(); +}); + +if (AppConstants.platform === "android") { + add_task(async function test_setImageData_unsupported_on_android() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + // Android does not support images on the clipboard, + // so it should not try to decode an image but fail immediately. + await browser.test.assertRejects( + browser.clipboard.setImageData(new ArrayBuffer(0), "png"), + "Writing images to the clipboard is not supported on Android", + "Should get an error when setImageData is called on Android."); + browser.test.sendMessage("finished unsupported setImageData"); + }, + manifest: { + permissions: ["clipboardWrite"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("finished unsupported setImageData"); + await extension.unload(); + }); +} + +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_about_blank.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_about_blank.html new file mode 100644 index 0000000000..1ca3cd619a --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_about_blank.html @@ -0,0 +1,335 @@ +<!doctype html> +<html> +<head> + <title>Test content script match_about_blank option</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_contentscript_about_blank() { + const manifest = { + content_scripts: [ + { + match_about_blank: true, + matches: [ + "*://mochi.test/*/file_with_about_blank.html", + "https://example.com/*/file_with_about_blank.html", + ], + all_frames: true, + css: ["all.css"], + js: ["all.js"], + }, { + matches: ["*://mochi.test/*/file_with_about_blank.html"], + css: ["mochi_without.css"], + js: ["mochi_without.js"], + all_frames: true, + }, { + match_about_blank: true, + matches: ["*://mochi.test/*/file_with_about_blank.html"], + css: ["mochi_with.css"], + js: ["mochi_with.js"], + all_frames: true, + }, + ], + }; + + const files = { + "all.js": function() { + browser.runtime.sendMessage("all"); + }, + "all.css": ` + body { color: red; } + `, + "mochi_without.js": function() { + browser.runtime.sendMessage("mochi_without"); + }, + "mochi_without.css": ` + body { background: yellow; } + `, + "mochi_with.js": function() { + browser.runtime.sendMessage("mochi_with"); + }, + "mochi_with.css": ` + body { text-align: right; } + `, + }; + + function background() { + browser.runtime.onMessage.addListener((script, {url}) => { + const kind = url.startsWith("about:") ? url : "top"; + browser.test.sendMessage("script", [script, kind, url]); + browser.test.sendMessage(`${script}:${kind}`); + }); + } + + const PATH = "tests/toolkit/components/extensions/test/mochitest/file_with_about_blank.html"; + const extension = ExtensionTestUtils.loadExtension({manifest, files, background}); + await extension.startup(); + + let count = 0; + extension.onMessage("script", script => { + info(`script ran: ${script}`); + count++; + }); + + let win = window.open("https://example.com/" + PATH); + await Promise.all([ + extension.awaitMessage("all:top"), + extension.awaitMessage("all:about:blank"), + extension.awaitMessage("all:about:srcdoc"), + ]); + is(count, 3, "exactly 3 scripts ran"); + win.close(); + + win = window.open("http://mochi.test:8888/" + PATH); + await Promise.all([ + extension.awaitMessage("all:top"), + extension.awaitMessage("all:about:blank"), + extension.awaitMessage("all:about:srcdoc"), + extension.awaitMessage("mochi_without:top"), + extension.awaitMessage("mochi_with:top"), + extension.awaitMessage("mochi_with:about:blank"), + extension.awaitMessage("mochi_with:about:srcdoc"), + ]); + + let style = win.getComputedStyle(win.document.body); + is(style.color, "rgb(255, 0, 0)", "top window text color is red"); + is(style.backgroundColor, "rgb(255, 255, 0)", "top window background is yellow"); + is(style.textAlign, "right", "top window text is right-aligned"); + + let a_b = win.document.getElementById("a_b"); + style = a_b.contentWindow.getComputedStyle(a_b.contentDocument.body); + is(style.color, "rgb(255, 0, 0)", "about:blank iframe text color is red"); + is(style.backgroundColor, "rgba(0, 0, 0, 0)", "about:blank iframe background is transparent"); + is(style.textAlign, "right", "about:blank text is right-aligned"); + + is(count, 10, "exactly 7 more scripts ran"); + win.close(); + + await extension.unload(); +}); + +async function top_level_about_blank({ legacyBehavior = false } = {}) { + const content_scripts = [ + { + match_about_blank: true, + matches: ["*://*/*"], + js: ["1.matches_any_url_and_blank.js"], + run_at: "document_end", + }, + { + // Note: interestingly, if one only wants to match top-level about:blank + // in Firefox, this would be a way to do so: + match_about_blank: true, + matches: ["*://*/*"], + exclude_matches: ["<all_urls>"], + exclude_globs: ["*"], + js: ["2.does_not_care_about_exclude_matches_globs.js"], + run_at: "document_end", + }, + { + match_about_blank: true, + matches: ["*://*/*"], + include_globs: ["*"], + js: ["3.should_not_run_because_include_globs_is_set.js"], + run_at: "document_end", + }, + { + matches: ["*://*/*"], + js: ["4.should_not_run_because_no_matchAboutBlank.js"], + run_at: "document_end", + }, + { + match_about_blank: true, + matches: ["*://non.matching.example/*"], + js: ["5.should_not_run_because_matches_does_not_match_all_urls.js"], + run_at: "document_end", + }, + ]; + const files = { + "get_seenScripts.js": () => { + globalThis.seenScripts ??= []; + globalThis.seenScripts.push("get_seenScripts.js"); + return globalThis.seenScripts; + }, + }; + function makeJsFile(filename) { + files[filename] = ` + dump("Running ${filename} at " + location + ", origin " + origin + "\\n"); + globalThis.seenScripts ??= []; + globalThis.seenScripts.push("${filename}"); + `; + } + for (let { js } of content_scripts) { + for (let filename of js) { + makeJsFile(filename); + } + } + + // Send an explicit test message in the last script which is expected to only + // be injected if the legacy behavior pref is set to true, if this is sent + // when running the test for the new expected behavior then hitting this message + // will trigger an additional explicit test failure due to the non handled + // "legacy-matching-script:executed" test message, on the contrary when + // the test is executed for the legacy matchAboutBlank behavior the test + // will await for that message explicitly to avoid intermittent failures. + files["5.should_not_run_because_matches_does_not_match_all_urls.js"] += ` + browser.test.sendMessage("legacy-matching-script:executed"); + `; + + function background() { + let tabId; + browser.test.onMessage.addListener(async (msg, expected, description) => { + if (msg === "openAboutBlankTab") { + const tab = await browser.tabs.create({ url: "about:blank" }); + tabId = tab.id; + browser.test.sendMessage("openAboutBlankTab_done"); + return; + } + if (msg === "closeAboutBlankTab") { + await browser.tabs.remove(tabId); + browser.test.sendMessage("closeAboutBlankTab_done"); + return; + } + browser.test.assertEq("seenScripts_check", msg, "Checking seenScripts"); + try { + browser.test.log("Checking seen content scripts"); + let [ seenScripts ] = await browser.tabs.executeScript( + tabId, + { + matchAboutBlank: true, + file: "get_seenScripts.js", + } + ); + browser.test.assertDeepEq(expected, seenScripts.sort(), description); + } catch (e) { + browser.test.assertDeepEq(expected, { error: e.message }, description); + } + browser.test.sendMessage("seenScripts_check_done"); + }); + browser.browserAction.onClicked.addListener(tab => { + browser.test.assertTrue(tab.active, "Active tab should be clicked"); + browser.test.assertEq(tabId, tab.id, "tabId should match"); + // Upon click, activeTab should be granted, so just return control. + browser.test.sendMessage("got_activeTab"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts, + browser_action: {}, + permissions: ["activeTab"], + }, + files, + background, + }); + + async function openAboutBlankTab() { + extension.sendMessage("openAboutBlankTab"); + await extension.awaitMessage("openAboutBlankTab_done"); + } + async function closeAboutBlankTab() { + extension.sendMessage("closeAboutBlankTab"); + await extension.awaitMessage("closeAboutBlankTab_done"); + } + async function checkSeenScripts(expected, description) { + extension.sendMessage("seenScripts_check", expected, description); + await extension.awaitMessage("seenScripts_check_done"); + } + + // TODO bug 1856071: Remove this pref setter when the pref is removed. + // This pref should be set to false by default, but we still explicitly + // set pref value to: + // (1) cover the legacy behavior + // (2) in case that is not the default anymore (e.g. if we had to uplift a + // patch to temporarily bring back the legacy behavior before have remove pref + // and support for the legacy behavior). + await SpecialPowers.pushPrefEnv({ + set: [["extensions.script_about_blank_without_permission", legacyBehavior]], + }); + + // TODO bug 1856071: Remove this entire block when the pref is removed. + if (legacyBehavior) { + // Start of "Testing legacy behavior". + await extension.startup(); + await openAboutBlankTab(); + + // Await explicitly for the last content script that we expect to be + // matching the top level about:blank page while running on legacy + // behavior, otherwise we hit an intermittent failure due to the + // get_seenScript.js being executed before any of the other + // content scripts. + await extension.awaitMessage("legacy-matching-script:executed"); + + // Legacy behavior: tabs.executeScript works in top-level about:blank + // without granted activeTab. + await checkSeenScripts( + [ + "1.matches_any_url_and_blank.js", + "2.does_not_care_about_exclude_matches_globs.js", + // While the standard behavior is to only run scripts on top-level + // null-principal about:blank when matches[] matches "all urls", the + // legacy behavior was to do so for any script that declared + // match_about_blank:true, independent of matches[]. + "3.should_not_run_because_include_globs_is_set.js", + "5.should_not_run_because_matches_does_not_match_all_urls.js", + "get_seenScripts.js", + ], + "Any content script with match_about_blank:true should (legacy behavior)" + ); + + await closeAboutBlankTab(); + await extension.unload(); + await SpecialPowers.popPrefEnv(); + return; + // End of "Testing legacy behavior". + } + + await extension.startup(); + await openAboutBlankTab(); + + info("Testing tabs.executeScript without activeTab/host permissions"); + await checkSeenScripts( + { error: "Missing host permission for the tab" }, + "tabs.executeScript without activeTab shouldn't match top-level about:blank" + ); + + info("Unlocking activeTab permission"); + await AppTestDelegate.clickBrowserAction(window, extension); + await extension.awaitMessage("got_activeTab"); + + info("Retrieving result with tabs.executeScript with activeTab"); + await checkSeenScripts( + [ + "1.matches_any_url_and_blank.js", + "2.does_not_care_about_exclude_matches_globs.js", + "get_seenScripts.js", + ], + "Only content content scripts that match all URLs and matchAboutBlank should run" + ); + + await closeAboutBlankTab(); + await extension.unload(); + // TODO bug 1856071: Remove this popPrefEnv call when the pref is removed. + await SpecialPowers.popPrefEnv(); +} + +add_task(async function test_toplevel_aboutblank_match_with_permissions() { + await top_level_about_blank({ legacyBehavior: false }); +}); + +add_task(async function test_toplevel_aboutblank_match_without_permissions() { + await top_level_about_blank({ legacyBehavior: true }); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_activeTab.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_activeTab.html new file mode 100644 index 0000000000..076c177dfa --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_activeTab.html @@ -0,0 +1,703 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for content script</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.manifestV3.enabled", true]], + }); +}); + +// Create a test extension with the provided function as the background +// script. The background script will have a few helpful functions +// available. +/* global awaitLoad, gatherFrameSources */ +function makeExtension({ + background, + useScriptingAPI = false, + manifest_version = 2, + host_permissions, +}) { + // Wait for a webNavigation.onCompleted event where the details for the + // loaded page match the attributes of `filter`. + function awaitLoad(filter) { + return new Promise(resolve => { + const listener = details => { + if (Object.keys(filter).every(key => details[key] === filter[key])) { + browser.webNavigation.onCompleted.removeListener(listener); + resolve(); + } + }; + browser.webNavigation.onCompleted.addListener(listener); + }); + } + + // Return a string with a (sorted) list of the source of all frames + // in the given tab into which this extension can inject scripts + // (ie all frames for which it has the activeTab permission). + // Source is the hostname for frames in http sources, or the full + // location href in other documents (eg about: pages) + const gatherFrameSources = useScriptingAPI ? + async function gatherFrameSources(tabid) { + let results = await browser.scripting.executeScript({ + target: { tabId: tabid, allFrames: true }, + func: () => window.location.hostname || window.location.href, + }); + // Adjust `result` so that it looks like the one returned by + // `tabs.executeScript()`. + let result = results.map(res => res.result); + + return String(result.sort()); + } : async function gatherFrameSources(tabid) { + let result = await browser.tabs.executeScript(tabid, { + allFrames: true, + matchAboutBlank: true, + code: "window.location.hostname || window.location.href;", + }); + + return String(result.sort()); + }; + + const permissions = ["webNavigation"]; + if (useScriptingAPI) { + permissions.push("scripting"); + } + + // When host_permissions is passed, test "automatic activeTab" for ungranted + // host_permissions in mv3, else test with the normal activeTab permission. + if (!host_permissions) { + permissions.push("activeTab"); + } + + return ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version, + permissions, + host_permissions, + }, + background: [ + `const useScriptingAPI = ${useScriptingAPI};`, + `const manifest_version = ${manifest_version};`, + `${awaitLoad}`, + `${gatherFrameSources}`, + `${ExtensionTestCommon.serializeScript(background)}`, + ].join("\n") + }); +} + +// Helper function to verify that executeScript() fails without the activeTab +// permission (or any specific origin permissions). +const verifyNoActiveTab = async ({ useScriptingAPI, manifest_version, host_permissions }) => { + let extension = makeExtension({ + async background() { + const URL = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html"; + + let [tab] = await Promise.all([ + browser.tabs.create({url: URL}), + awaitLoad({frameId: 0}), + ]); + + await browser.test.assertRejects( + gatherFrameSources(tab.id), + /^Missing host permission/, + "executeScript should fail without activeTab permission" + ); + + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("no-active-tab"); + }, + useScriptingAPI, + manifest_version, + host_permissions, + }); + + await extension.startup(); + await extension.awaitFinish("no-active-tab"); + await extension.unload(); +}; + +add_task(async function test_no_activeTab_tabs() { + await verifyNoActiveTab({ useScriptingAPI: false }); +}); + +add_task(async function test_no_activeTab_scripting() { + await verifyNoActiveTab({ useScriptingAPI: true }); +}); + +add_task(async function test_no_activeTab_scripting_mv3() { + await verifyNoActiveTab({ + useScriptingAPI: true, + manifest_version: 3, + host_permissions: null, + }); +}); + +add_task(async function test_no_activeTab_scripting_mv3_autoActiveTab() { + await verifyNoActiveTab({ + useScriptingAPI: true, + manifest_version: 3, + host_permissions: ["http://mochi.test/"], + }); +}); + +// Test helper to verify that dynamically created iframes do not get the +// activeTab permission, unless same-origin with the top page. +const verifyDynamicFrames = async ({ useScriptingAPI, manifest_version, host_permissions }) => { + let extension = makeExtension({ + async background() { + const BASE_HOST = "www.example.com"; + + let [tab] = await Promise.all([ + browser.tabs.create({url: `https://${BASE_HOST}/`}), + awaitLoad({frameId: 0}), + ]); + + function inject() { + let nframes = 4; + function frameLoaded() { + nframes--; + if (nframes == 0) { + browser.runtime.sendMessage("frames-loaded"); + } + } + + // about:blank + let frame = document.createElement("iframe"); + frame.addEventListener("load", frameLoaded, {once: true}); + document.body.appendChild(frame); + + let div = document.createElement("div"); + div.innerHTML = "<iframe src='https://test1.example.com/'></iframe>"; + let framelist = div.getElementsByTagName("iframe"); + browser.test.assertEq(1, framelist.length, "Found 1 frame inside div"); + framelist[0].addEventListener("load", frameLoaded, {once: true}); + document.body.appendChild(div); + + // about:srcdoc containing cross-origin frame. + let div2 = document.createElement("div"); + div2.innerHTML = "<iframe srcdoc=\"<iframe src='https://test2.example.com/'></iframe>\"></iframe>"; + framelist = div2.getElementsByTagName("iframe"); + browser.test.assertEq(1, framelist.length, "Found 1 frame inside div"); + framelist[0].addEventListener("load", frameLoaded, {once: true}); + document.body.appendChild(div2); + + // Note: URL's host is BASE_HOST (same as top). + const URL = "https://www.example.com/tests/toolkit/components/extensions/test/mochitest/file_contentscript_iframe.html"; + + let xhr = new XMLHttpRequest(); + xhr.open("GET", URL); + xhr.responseType = "document"; + xhr.overrideMimeType("text/html"); + + xhr.addEventListener("load", () => { + if (xhr.readyState != 4) { + return; + } + if (xhr.status != 200) { + browser.runtime.sendMessage("error"); + } + + let frame = xhr.response.getElementById("frame"); + browser.test.assertEq( + "https://test2.example.com/", + frame?.src, + "Found frame in response document with cross-origin URL" + ); + frame.addEventListener("load", frameLoaded, {once: true}); + document.body.appendChild(frame); + }, {once: true}); + xhr.addEventListener("error", () => { + browser.runtime.sendMessage("error"); + }, {once: true}); + xhr.send(); + } + + browser.test.onMessage.addListener(async msg => { + if (msg !== "go") { + browser.test.fail(`unexpected message received: ${msg}`); + return; + } + + let loadedPromise = new Promise((resolve, reject) => { + let listener = msg => { + let unlisten = () => browser.runtime.onMessage.removeListener(listener); + if (msg == "frames-loaded") { + unlisten(); + resolve(); + } else if (msg == "error") { + unlisten(); + reject(); + } + }; + browser.runtime.onMessage.addListener(listener); + }); + + if (useScriptingAPI) { + await browser.scripting.executeScript({ + target: { tabId: tab.id }, + func: inject, + }); + } else { + await browser.tabs.executeScript(tab.id, { + code: `(${inject})();`, + }); + } + + await loadedPromise; + + let result = await gatherFrameSources(tab.id); + + browser.test.assertEq( + String(["about:blank", "about:srcdoc", BASE_HOST]), + result, + `Script injected only into (same origin) about:blank-ish dynamically created frames` + ); + + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("dynamic-frames"); + }); + + browser.test.sendMessage("ready", tab.id); + }, + useScriptingAPI, + manifest_version, + host_permissions, + }); + + await extension.startup(); + + let tabId = await extension.awaitMessage("ready"); + extension.grantActiveTab(tabId); + + extension.sendMessage("go"); + await extension.awaitFinish("dynamic-frames"); + + await extension.unload(); +}; + +add_task(async function test_dynamic_frames_tabs() { + await verifyDynamicFrames({ useScriptingAPI: false }); +}); + +add_task(async function test_dynamic_frames_scripting() { + await verifyDynamicFrames({ useScriptingAPI: true }); +}); + +add_task(async function test_dynamic_frames_scripting_mv3() { + await verifyDynamicFrames({ + useScriptingAPI: true, + manifest_version: 3, + host_permissions: null, + }); +}); + +add_task(async function test_dynamic_frames_scripting_mv3_autoActiveTab() { + await verifyDynamicFrames({ + useScriptingAPI: true, + manifest_version: 3, + host_permissions: ["https://www.example.com/"], + }); +}); + +// Test helper to verify that an iframe created from an <iframe srcdoc> gets +// the activeTab permission. +const verifySrcdoc = async ({ useScriptingAPI, manifest_version, host_permissions }) => { + let extension = makeExtension({ + async background() { + const URL = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab2.html"; + const OUTER_SOURCE = "about:srcdoc"; + const PAGE_SOURCE = "mochi.test"; + const FRAME_SOURCE = "test1.example.com"; + + let [tab] = await Promise.all([ + browser.tabs.create({url: URL}), + awaitLoad({frameId: 0}), + ]); + + browser.test.onMessage.addListener(async msg => { + if (msg !== "go") { + browser.test.fail(`unexpected message received: ${msg}`); + return; + } + + let result = await gatherFrameSources(tab.id); + + if (manifest_version < 3) { + browser.test.assertEq( + String([OUTER_SOURCE, PAGE_SOURCE, FRAME_SOURCE]), + result, + "Script is injected into frame created from <iframe srcdoc>" + ); + } else { + browser.test.assertEq( + String([OUTER_SOURCE, PAGE_SOURCE]), + result, + "Script is not injected into cross-origin frame created from <iframe srcdoc>" + ); + } + + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("srcdoc"); + }); + + browser.test.sendMessage("ready", tab.id); + }, + useScriptingAPI, + manifest_version, + host_permissions, + }); + + await extension.startup(); + + let tabId = await extension.awaitMessage("ready"); + extension.grantActiveTab(tabId); + + extension.sendMessage("go"); + await extension.awaitFinish("srcdoc"); + + await extension.unload(); +}; + +add_task(async function test_srcdoc_tabs() { + await verifySrcdoc({ useScriptingAPI: false }); +}); + +add_task(async function test_srcdoc_scripting() { + await verifySrcdoc({ useScriptingAPI: true }); +}); + +add_task(async function test_srcdoc_scripting_mv3() { + await verifySrcdoc({ + useScriptingAPI: true, + manifest_version: 3, + host_permissions: null, + }); +}); + +add_task(async function test_srcdoc_scripting_mv3_autoActiveTab() { + await verifySrcdoc({ + useScriptingAPI: true, + manifest_version: 3, + host_permissions: ["http://mochi.test/"], + }); +}); + +// Test helper to verify that navigating frames by setting the src attribute +// from the parent page does not grant the activeTab permission to the frame, +// unless the frame is same-origin to the top page. +const verifyNavigateBySrc = async ({ useScriptingAPI, manifest_version, host_permissions }) => { + let extension = makeExtension({ + async background() { + const URL = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html"; + const PAGE_SOURCE = "mochi.test"; + const EMPTY_SOURCE = "about:blank"; + const FRAME_SOURCE = "test1.example.com"; + + let [tab] = await Promise.all([ + browser.tabs.create({url: URL}), + awaitLoad({frameId: 0}), + ]); + + browser.test.onMessage.addListener(async msg => { + if (msg !== "go") { + browser.test.fail(`unexpected message received: ${msg}`); + return; + } + + let result = await gatherFrameSources(tab.id); + if (manifest_version < 3) { + browser.test.assertEq( + String([EMPTY_SOURCE, PAGE_SOURCE, FRAME_SOURCE]), + result, + "In original page, script is injected into base page and original frames" + ); + } else { + browser.test.assertEq( + String([EMPTY_SOURCE, PAGE_SOURCE]), + result, + "In original page, script is injected into same-origin frames" + ); + } + + let loadedPromise = awaitLoad({tabId: tab.id}); + + let func = () => { + document.getElementById('emptyframe').src = 'http://test2.example.com/'; + }; + + if (useScriptingAPI) { + await browser.scripting.executeScript({ + target: { tabId: tab.id }, + func, + }); + } else { + await browser.tabs.executeScript(tab.id, { code: `(${func})();` }); + } + + await loadedPromise; + + + result = await gatherFrameSources(tab.id); + if (manifest_version < 3) { + browser.test.assertEq( + String([PAGE_SOURCE, FRAME_SOURCE]), + result, + "Script is not injected into initially empty frame after cross-origin navigation" + ); + } else { + browser.test.assertEq( + String([PAGE_SOURCE]), + result, + "Script is not injected into initially empty frame after cross-origin navigation" + ); + } + + loadedPromise = awaitLoad({tabId: tab.id}); + + func = () => { + document.getElementById('regularframe').src = 'http://mochi.test:8888/'; + }; + + if (useScriptingAPI) { + await browser.scripting.executeScript({ + target: { tabId: tab.id }, + func, + }); + } else { + await browser.tabs.executeScript(tab.id, { code: `(${func})();` }); + } + + await loadedPromise; + + result = await gatherFrameSources(tab.id); + + browser.test.assertEq( + String([PAGE_SOURCE, PAGE_SOURCE]), + result, + "Script injected into frame after navigating to same-origin" + ); + + await browser.tabs.remove(tab.id); + browser.test.notifyPass("test-scripts"); + }); + + browser.test.sendMessage("ready", tab.id); + }, + useScriptingAPI, + manifest_version, + host_permissions, + }); + + await extension.startup(); + + let tabId = await extension.awaitMessage("ready"); + extension.grantActiveTab(tabId); + + extension.sendMessage("go"); + await extension.awaitFinish("test-scripts"); + + await extension.unload(); +}; + +add_task(async function test_navigate_by_src_tabs() { + await verifyNavigateBySrc({ useScriptingAPI: false }); +}); + +add_task(async function test_navigate_by_src_scripting() { + await verifyNavigateBySrc({ useScriptingAPI: true }); +}); + +add_task(async function test_navigate_by_src_scripting_mv3() { + await verifyNavigateBySrc({ + useScriptingAPI: true, + manifest_version: 3, + host_permissions: null, + }); +}); + +add_task(async function test_navigate_by_src_scripting_mv3_autoActiveTab() { + await verifyNavigateBySrc({ + useScriptingAPI: true, + manifest_version: 3, + host_permissions: ["http://mochi.test/"], + }); +}); + +// Test helper to verify that navigating frames by setting window.location from +// inside the frame revokes the activeTab permission. +const verifyNavigateByWindowLocation = async ({ useScriptingAPI, manifest_version, host_permissions }) => { + let extension = makeExtension({ + async background() { + const URL = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html"; + const PAGE_SOURCE = "mochi.test"; + const EMPTY_SOURCE = "about:blank"; + const FRAME_SOURCE = "test1.example.com"; + + let [tab] = await Promise.all([ + browser.tabs.create({url: URL}), + awaitLoad({frameId: 0}), + ]); + + browser.test.onMessage.addListener(async msg => { + if (msg !== "go") { + browser.test.fail(`unexpected message received: ${msg}`); + return; + } + + let result = await gatherFrameSources(tab.id); + + if (manifest_version < 3) { + browser.test.assertEq( + String([EMPTY_SOURCE, PAGE_SOURCE, FRAME_SOURCE]), + result, + "Script initially injected into all frames" + ); + } else { + browser.test.assertEq( + String([EMPTY_SOURCE, PAGE_SOURCE]), + result, + "Script initially injected into all same-origin frames" + ); + } + + let nframes = 0; + let frames = await browser.webNavigation.getAllFrames({tabId: tab.id}); + for (let frame of frames) { + if (frame.parentFrameId == -1) { + continue; + } + + if (manifest_version >= 3 && frame.url.includes(FRAME_SOURCE)) { + // In MV3, can't access cross-origin iframes from the start. + + let invalidPromise = browser.scripting.executeScript({ + target: { tabId: tab.id, frameIds: [frame.frameId] }, + func: () => window.location.hostname, + }); + await browser.test.assertRejects( + invalidPromise, + /^Missing host permission for the tab or frames/, + "executeScript should fail on cross-origin frame" + ); + + continue; + } + + let loadPromise = awaitLoad({ + tabId: tab.id, + frameId: frame.frameId, + }); + + let func = () => { + window.location.href = 'https://test2.example.com/'; + }; + + if (useScriptingAPI) { + await browser.scripting.executeScript({ + target: { tabId: tab.id, frameIds: [frame.frameId] }, + func, + }); + } else { + await browser.tabs.executeScript(tab.id, { + frameId: frame.frameId, + matchAboutBlank: true, + code: `(${func})();`, + }); + } + + await loadPromise; + + let executePromise; + func = () => window.location.hostname; + + if (useScriptingAPI) { + executePromise = browser.scripting.executeScript({ + target: { tabId: tab.id, frameIds: [frame.frameId] }, + func, + }); + } else { + executePromise = browser.tabs.executeScript(tab.id, { + frameId: frame.frameId, + matchAboutBlank: true, + code: `(${func})();`, + }); + } + + await browser.test.assertRejects( + executePromise, + /^Missing host permission for the tab or frames/, + "executeScript should have failed on navigated frame" + ); + + nframes++; + } + + if (manifest_version < 3) { + browser.test.assertEq(2, nframes, "Found 2 frames"); + } else { + browser.test.assertEq(1, nframes, "Found 1 frame"); + } + + await browser.tabs.remove(tab.id); + browser.test.notifyPass("scripted-navigation"); + }); + + browser.test.sendMessage("ready", tab.id); + }, + useScriptingAPI, + manifest_version, + host_permissions, + }); + + await extension.startup(); + + let tabId = await extension.awaitMessage("ready"); + extension.grantActiveTab(tabId); + + extension.sendMessage("go"); + await extension.awaitFinish("scripted-navigation"); + + await extension.unload(); +}; + +add_task(async function test_navigate_by_window_location_tabs() { + await verifyNavigateByWindowLocation({ useScriptingAPI: false }); +}); + +add_task(async function test_navigate_by_window_location_scripting() { + await verifyNavigateByWindowLocation({ useScriptingAPI: true }); +}); + +add_task(async function test_navigate_by_window_location_scripting_mv3() { + await verifyNavigateByWindowLocation({ + useScriptingAPI: true, + manifest_version: 3, + host_permissions: null, + }); +}); + +add_task(async function test_navigate_by_window_location_scripting_mv3_autoActiveTab() { + await verifyNavigateByWindowLocation({ + useScriptingAPI: true, + manifest_version: 3, + host_permissions: ["http://mochi.test/"], + }); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_cache.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_cache.html new file mode 100644 index 0000000000..5caab9129d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_cache.html @@ -0,0 +1,117 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for content script caching</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +// This file defines content scripts. + +const BASE = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest"; + +add_task(async function test_contentscript_cache() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [{ + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + "run_at": "document_start", + }], + + permissions: ["<all_urls>", "tabs"], + }, + + async background() { + // Force our extension instance to be initialized for the current content process. + await browser.tabs.insertCSS({code: ""}); + + browser.test.sendMessage("origin", location.origin); + }, + + files: { + "content_script.js": function() { + browser.test.sendMessage("content-script-loaded"); + }, + }, + }); + + await extension.startup(); + + let origin = await extension.awaitMessage("origin"); + let scriptUrl = `${origin}/content_script.js`; + + const { ExtensionProcessScript } = SpecialPowers.ChromeUtils.importESModule( + "resource://gre/modules/ExtensionProcessScript.sys.mjs" + ); + let ext = ExtensionProcessScript.getExtensionChild(extension.id); + + ext.staticScripts.expiryTimeout = 3000; + is(ext.staticScripts.size, 0, "Should have no cached scripts"); + + let win = window.open(`${BASE}/file_sample.html`); + await extension.awaitMessage("content-script-loaded"); + + if (AppConstants.platform !== "android") { + is(ext.staticScripts.size, 1, "Should have one cached script"); + ok(ext.staticScripts.has(scriptUrl), "Script cache should contain script URL"); + } + + let chromeScript, chromeScriptDone; + let { appinfo } = SpecialPowers.Services; + if (appinfo.processType === appinfo.PROCESS_TYPE_CONTENT) { + /* globals addMessageListener, assert */ + chromeScript = SpecialPowers.loadChromeScript(() => { + /* eslint-env mozilla/chrome-script */ + addMessageListener("check-script-cache", extensionId => { + const { ExtensionProcessScript } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionProcessScript.sys.mjs" + ); + let ext = ExtensionProcessScript.getExtensionChild(extensionId); + + if (ext && ext.staticScripts) { + assert.equal(ext.staticScripts.size, 0, "Should have no cached scripts in the parent process"); + } + + sendAsyncMessage("done"); + }); + }); + chromeScript.sendAsyncMessage("check-script-cache", extension.id); + chromeScriptDone = chromeScript.promiseOneMessage("done"); + } + + SimpleTest.requestFlakyTimeout("Required to test expiry timeout"); + await new Promise(resolve => setTimeout(resolve, 3000)); + is(ext.staticScripts.size, 0, "Should have no cached scripts"); + + if (chromeScript) { + await chromeScriptDone; + chromeScript.destroy(); + } + + win.close(); + + win = window.open(`${BASE}/file_sample.html`); + await extension.awaitMessage("content-script-loaded"); + + is(ext.staticScripts.size, 1, "Should have one cached script"); + ok(ext.staticScripts.has(scriptUrl)); + + SpecialPowers.Services.obs.notifyObservers(null, "memory-pressure", "heap-minimize"); + + is(ext.staticScripts.size, 0, "Should have no cached scripts after heap-minimize"); + + win.close(); + + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_canvas.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_canvas.html new file mode 100644 index 0000000000..8659d8c409 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_canvas.html @@ -0,0 +1,134 @@ +<!doctype html> +<html> +<head> + <title>Test content script access to canvas drawWindow()</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<script> +"use strict"; + +add_task(async function test_drawWindow() { + const permissions = [ + "<all_urls>", + ]; + + const content_scripts = [{ + matches: ["https://example.org/*"], + js: ["content_script.js"], + }]; + + const files = { + "content_script.js": () => { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + try { + ctx.drawWindow(window, 0, 0, 10, 10, "red"); + const {data} = ctx.getImageData(0, 0, 10, 10); + browser.test.sendMessage("success", data.slice(0, 3).join()); + } catch (e) { + browser.test.sendMessage("error", e.message); + } + }, + }; + + const first = ExtensionTestUtils.loadExtension({ + manifest: { + permissions, + content_scripts + }, + files + }); + const second = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts + }, + files + }); + + consoleMonitor.start([{ message: /Use of drawWindow [\w\s]+ is deprecated. Use tabs.captureTab/ }]); + + await first.startup(); + await second.startup(); + + const win = window.open("https://example.org/tests/toolkit/components/extensions/test/mochitest/file_to_drawWindow.html"); + + const colour = await first.awaitMessage("success"); + is(colour, "255,255,153", "drawWindow() call was successful: #ff9 == rgb(255,255,153)"); + + const error = await second.awaitMessage("error"); + is(error, "ctx.drawWindow is not a function", "drawWindow() method not awailable without permission"); + + win.close(); + await first.unload(); + await second.unload(); + await consoleMonitor.finished(); +}); + +add_task(async function test_tainted_canvas() { + const permissions = [ + "<all_urls>", + ]; + + const content_scripts = [{ + matches: ["https://example.org/*"], + js: ["content_script.js"], + }]; + + const files = { + "content_script.js": () => { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + const img = new Image(); + + img.onload = function() { + ctx.drawImage(img, 0, 0); + try { + const png = canvas.toDataURL(); + const {data} = ctx.getImageData(0, 0, 10, 10); + browser.test.sendMessage("success", {png, colour: data.slice(0, 4).join()}); + } catch (e) { + browser.test.log(`Exception: ${e.message}`); + browser.test.sendMessage("error", e.message); + } + }; + + // Cross-origin image from example.com. + img.src = "https://example.com/tests/toolkit/components/extensions/test/mochitest/file_image_good.png"; + }, + }; + + const first = ExtensionTestUtils.loadExtension({ + manifest: { + permissions, + content_scripts + }, + files + }); + const second = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts + }, + files + }); + + await first.startup(); + await second.startup(); + + const win = window.open("https://example.org/tests/toolkit/components/extensions/test/mochitest/file_to_drawWindow.html"); + + const {png, colour} = await first.awaitMessage("success"); + ok(png.startsWith("data:image/png;base64,"), "toDataURL() call was successful."); + is(colour, "0,0,0,0", "getImageData() returned the correct colour (transparent)."); + + const error = await second.awaitMessage("error"); + is(error, "The operation is insecure.", "toDataURL() throws without permission."); + + win.close(); + await first.unload(); + await second.unload(); +}); + +</script> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_devtools_metadata.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_devtools_metadata.html new file mode 100644 index 0000000000..6b67be6b5b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_devtools_metadata.html @@ -0,0 +1,77 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for Sandbox metadata on WebExtensions ContentScripts</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_contentscript_devtools_sandbox_metadata() { + function contentScript() { + browser.runtime.sendMessage("contentScript.executed"); + } + + function background() { + browser.runtime.onMessage.addListener((msg) => { + if (msg == "contentScript.executed") { + browser.test.notifyPass("contentScript.executed"); + } + }); + } + + let extensionData = { + manifest: { + content_scripts: [ + { + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + "run_at": "document_idle", + }, + ], + }, + + background, + files: { + "content_script.js": contentScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + let win = window.open("file_sample.html"); + + let innerWindowID = SpecialPowers.wrap(win).windowGlobalChild.innerWindowId; + + await extension.awaitFinish("contentScript.executed"); + + const { ExtensionContent } = SpecialPowers.ChromeUtils.importESModule( + "resource://gre/modules/ExtensionContent.sys.mjs" + ); + + let res = ExtensionContent.getContentScriptGlobals(win); + is(res.length, 1, "Got the expected array of globals"); + let metadata = SpecialPowers.Cu.getSandboxMetadata(res[0]) || {}; + + is(metadata.addonId, extension.id, "Got the expected addonId"); + is(metadata["inner-window-id"], innerWindowID, "Got the expected inner-window-id"); + + await extension.unload(); + info("extension unloaded"); + + res = ExtensionContent.getContentScriptGlobals(win); + is(res.length, 0, "No content scripts globals found once the extension is unloaded"); + + win.close(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_fission_frame.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_fission_frame.html new file mode 100644 index 0000000000..6e03c3e9cd --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_fission_frame.html @@ -0,0 +1,109 @@ +<!doctype html> +<head> + <title>Test content script in cross-origin frame</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<script> +"use strict"; + +add_task(async function test_content_script_cross_origin_frame() { + + const extensionData = { + manifest: { + content_scripts: [{ + matches: ["https://example.net/*/file_sample.html"], + all_frames: true, + js: ["cs.js"], + }], + permissions: ["https://example.net/"], + }, + + background() { + browser.runtime.onConnect.addListener(port => { + port.onMessage.addListener(async num => { + let { tab, url, frameId } = port.sender; + + browser.test.assertTrue(frameId > 0, "sender frameId is ok"); + browser.test.assertTrue(url.endsWith("file_sample.html"), "url is ok"); + + let shared = await browser.tabs.executeScript(tab.id, { + allFrames: true, + code: `window.sharedVal`, + }); + browser.test.assertEq(shared[0], 357, "CS runs in a shared Sandbox"); + + let code = "does.not.exist"; + await browser.test.assertRejects( + browser.tabs.executeScript(tab.id, { allFrames: true, code }), + /does is not defined/, + "Got the expected rejection from tabs.executeScript" + ); + + code = "() => {}"; + await browser.test.assertRejects( + browser.tabs.executeScript(tab.id, { allFrames: true, code }), + /Script .* result is non-structured-clonable data/, + "Got the expected rejection from tabs.executeScript" + ); + + let result = await browser.tabs.sendMessage(tab.id, num); + port.postMessage(result); + port.disconnect(); + }); + }); + }, + + files: { + "cs.js"() { + let text = document.getElementById("test").textContent; + browser.test.assertEq(text, "Sample text", "CS can access page DOM"); + + let manifest = browser.runtime.getManifest(); + browser.test.assertEq(manifest.version, "1.0"); + browser.test.assertEq(manifest.name, "Generated extension"); + + browser.runtime.onMessage.addListener(async num => { + browser.test.log("content script received tabs.sendMessage"); + return num * 3; + }) + + let response; + window.sharedVal = 357; + + let port = browser.runtime.connect(); + port.onMessage.addListener(num => { + response = num; + }); + port.onDisconnect.addListener(() => { + browser.test.assertEq(response, 21, "Got correct response"); + browser.test.notifyPass(); + }); + port.postMessage(7); + }, + }, + }; + + info("Load first extension"); + let ext1 = ExtensionTestUtils.loadExtension(extensionData); + await ext1.startup(); + + info("Load a page, test content scripts in new frame with extension loaded"); + let base = "https://example.org/tests/toolkit/components/extensions/test"; + let win = window.open(`${base}/mochitest/file_with_xorigin_frame.html`); + + await ext1.awaitFinish(); + await ext1.unload(); + + info("Load second extension, test content scripts in existing frame"); + let ext2 = ExtensionTestUtils.loadExtension(extensionData); + await ext2.startup(); + await ext2.awaitFinish(); + + win.close(); + await ext2.unload(); +}); + +</script> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_getFrameId.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_getFrameId.html new file mode 100644 index 0000000000..d679634030 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_getFrameId.html @@ -0,0 +1,189 @@ +<!doctype html> +<head> + <title>Test content script runtime.getFrameId</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<script> +"use strict"; + +add_task(async function test_runtime_getFrameId_invalid() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + let proxy = new Proxy(window, {}); + let proto = Object.create(window); + + class FakeFrame extends HTMLIFrameElement { + constructor() { + super(); + console.log("FakeFrame ctor"); // eslint-disable-line + } + } + customElements.define('fake-frame', FakeFrame, { extends: 'iframe' }); + let custom = document.createElement("fake-frame"); + + let invalid = [null, 13, "blah", document.body, proxy, proto, custom]; + + for (let value of invalid) { + browser.test.assertThrows( + () => browser.runtime.getFrameId(value), + /Invalid argument/, + "Correct exception thrown." + ); + } + + let detached = document.createElement("iframe"); + let id = browser.runtime.getFrameId(detached); + browser.test.assertEq(id, -1, "Detached iframe has no frameId."); + + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function test_contentscript_runtime_getFrameId() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webNavigation", "tabs"], + host_permissions: ["https://example.org/"], + }, + + files: { + "cs.js"() { + browser.test.log(`Content script loaded on: ${location.href}`); + let parents = {}; + + // Recursivelly walk descendant frames and get parent frameIds. + function visit(win) { + let frameId = browser.runtime.getFrameId(win); + let parentId = browser.runtime.getFrameId(win.parent); + parents[frameId] = (win.parent != win) ? parentId : -1; + + try { + let frameEl = browser.runtime.getFrameId(win.frameElement); + browser.test.assertEq(frameId, frameEl, "frameElement id correct"); + } catch (e) { + // Can't access a cross-origin .frameElement. + } + + for (let i = 0; i < win.frames.length; i++) { + visit(win.frames[i]); + } + } + visit(window); + + // Add the <embed> frame if it exists. + let embed = document.querySelector("embed"); + if (embed) { + let id = browser.runtime.getFrameId(embed); + parents[id] = 0; + } + + // Add the <object> frame if it exists. + let object = document.querySelector("object"); + if (object) { + let id = browser.runtime.getFrameId(object); + parents[id] = 0; + } + + browser.test.log(`Parents tree: ${JSON.stringify(parents)}`); + return parents; + }, + + async "closedPopup.js"() { + let popup = window.open("https://example.org/?popup"); + popup.close(); + for (let i = 0; i < 100; i++) { + await new Promise(r => setTimeout(r, 50)); + try { + popup.blur(); + } catch(e) { + if (e.message === "can't access dead object") { + browser.test.assertThrows( + () => browser.runtime.getFrameId(popup), + /An exception was thrown/, + "Passing a dead object throws." + ); + browser.test.sendMessage("done"); + return; + } + } + } + browser.test.fail("Timed out while waiting for popup to close."); + }, + "closedPopup.html": ` + <!doctype html> + <meta charset="utf-8"> + <script src="closedPopup.js"><\/script> + `, + }, + + async background() { + let base = "https://example.org/tests/toolkit/components/extensions/test/mochitest"; + let files = { + "file_contains_iframe.html": 2, + "file_WebNavigation_page1.html": 2, + "file_with_xorigin_frame.html": 2, + // Contains all of the above. + "file_with_subframes_and_embed.html": 9, + }; + + for (let [file, count] of Object.entries(files)) { + let tab; + let completed = new Promise(resolve => { + browser.webNavigation.onCompleted.addListener(function cb(details) { + browser.test.log(`onCompleted: ${JSON.stringify(details)}`); + + if (details.tabId === tab?.id && details.frameId === 0) { + browser.webNavigation.onCompleted.removeListener(cb); + resolve(); + } + }); + }); + + browser.test.log(`Load a test page: ${file}`); + tab = await browser.tabs.create({ url: `${base}/${file}` }); + await completed; + + let [parents] = await browser.tabs.executeScript(tab.id, { + file: "cs.js" + }); + + let all = await browser.webNavigation.getAllFrames({ tabId: tab.id }); + browser.test.log(`getAllFrames: ${JSON.stringify(all)}`); + + browser.test.assertEq(all.length, count, "All frames accounted for."); + + browser.test.assertEq( + Object.keys(parents).length, + count, + "All frames accounted for from content script." + ); + + for (let frame of all) { + browser.test.assertEq( + frame.parentFrameId, + parents[frame.frameId], + "Correct frame ancestor info." + ); + } + + await browser.tabs.remove(tab.id); + } + + browser.tabs.create({ url: browser.runtime.getURL("closedPopup.html" )}); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +</script> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_incognito.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_incognito.html new file mode 100644 index 0000000000..de2a2571a9 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_incognito.html @@ -0,0 +1,101 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for content script private browsing ID</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ChromeTask.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +async function test_contentscript_incognito() { + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + content_scripts: [ + { + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + }, + ], + }, + + background() { + let windowId; + + browser.test.onMessage.addListener(([msg, url]) => { + if (msg === "open-window") { + browser.windows.create({url, incognito: true}).then(window => { + windowId = window.id; + }); + } else if (msg === "close-window") { + browser.windows.remove(windowId).then(() => { + browser.test.sendMessage("done"); + }); + } + }); + }, + + files: { + "content_script.js": async () => { + const COOKIE = "foo=florgheralzps"; + document.cookie = COOKIE; + + let url = new URL("return_headers.sjs", location.href); + + let responses = [ + new Promise(resolve => { + let xhr = new XMLHttpRequest(); + xhr.open("GET", url); + xhr.onload = () => resolve(JSON.parse(xhr.responseText)); + xhr.send(); + }), + + fetch(url, {credentials: "include"}).then(body => body.json()), + ]; + + try { + for (let response of await Promise.all(responses)) { + browser.test.assertEq(COOKIE, response.cookie, "Got expected cookie header"); + } + browser.test.notifyPass("cookies"); + } catch (e) { + browser.test.fail(`Error: ${e}`); + browser.test.notifyFail("cookies"); + } + }, + }, + }); + + await extension.startup(); + + extension.sendMessage(["open-window", SimpleTest.getTestFileURL("file_sample.html")]); + + await extension.awaitFinish("cookies"); + + extension.sendMessage(["close-window"]); + await extension.awaitMessage("done"); + + await extension.unload(); +} + +add_task(async function() { + await test_contentscript_incognito(); +}); + +add_task(async function() { + await SpecialPowers.pushPrefEnv({set: [ + ["network.cookie.cookieBehavior", 3], + ]}); + await test_contentscript_incognito(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_permission.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_permission.html new file mode 100644 index 0000000000..8ab4b1fb28 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_permission.html @@ -0,0 +1,59 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for content script</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_contentscript() { + function background() { + browser.test.onMessage.addListener(async url => { + let tab = await browser.tabs.create({url}); + + let executed = true; + try { + await browser.tabs.executeScript(tab.id, {code: "true;"}); + } catch (e) { + executed = false; + } + + await browser.tabs.remove([tab.id]); + browser.test.sendMessage("executed", executed); + }); + } + + let extensionData = { + manifest: { + permissions: ["<all_urls>"], + }, + background, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + extension.sendMessage("https://example.com"); + let result = await extension.awaitMessage("executed"); + is(result, true, "Content script can be run in a page without mozAddonManager"); + + await SpecialPowers.pushPrefEnv({ + set: [["extensions.webapi.testing", true]], + }); + + extension.sendMessage("https://example.com"); + result = await extension.awaitMessage("executed"); + is(result, false, "Content script cannot be run in a page with mozAddonManager"); + + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_securecontext.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_securecontext.html new file mode 100644 index 0000000000..093c26898f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_securecontext.html @@ -0,0 +1,163 @@ +<!doctype html> + +<head> + <title>Test content script accessing certain [SecureContext] interfaces in non-secure contexts</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css" /> +</head> +<script> + "use strict"; + + add_setup(async function setup() { + await SpecialPowers.pushPrefEnv({ + "set": [ + ["dom.w3c_pointer_events.getcoalescedevents_only_in_securecontext", true], + ] + }); + }); + + add_task(async function test_contentscript_getCoalescedEvents_in_non_secure_context() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + "matches": ["http://example.org/"], + "js": ["content_script.js"] + }, + ] + }, + files: { + "content_script.js"() { + // Make sure we're testing a non-secure context + browser.test.assertEq(window.isSecureContext, false, "window.isSecureContext === false") + + // Make sure our content script can access getCoalescedEvents in non-secure context + browser.test.assertEq(typeof PointerEvent.prototype.getCoalescedEvents, "function", "Content script can access getCoalescedEvents in non-secure context") + + // Make sure the page can't access getCoalescedEvents in non-secure context + browser.test.assertEq(typeof window.wrappedJSObject.PointerEvent.prototype.getCoalescedEvents, "undefined", "Page can't access getCoalescedEvents in non-secure context") + + browser.test.sendMessage("done"); + }, + }, + }); + await extension.startup(); + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + const win = window.open("http://example.org/"); + await extension.awaitMessage("done"); + win.close(); + await extension.unload(); + }); + + add_task(async function test_iframe_getCoalescedEvents_in_non_secure_context() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + "matches": ["http://example.org/"], + "js": ["content_script.js"] + }, + ] + }, + files: { + "iframe_script.js"() { + // Make sure we're testing a non-secure context + browser.test.assertEq(window.isSecureContext, false, "window.isSecureContext === false") + + // Make sure our iframe script can access getCoalescedEvents in non-secure context + browser.test.assertEq(typeof PointerEvent.prototype.getCoalescedEvents, "function", "iframe script can access getCoalescedEvents in non-secure context") + + browser.test.sendMessage("done"); + }, + "content_script.js"() { + let iframe = document.createElement("iframe"); + iframe.src = browser.runtime.getURL("iframe.html"); + document.body.append(iframe); + }, + "iframe.html": "<!DOCTYPE html><html><head><script src=\"./iframe_script.js\"><\/script></head><body></body></html>", + } + }); + await extension.startup(); + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + const win = window.open("http://example.org/"); + await extension.awaitMessage("done"); + win.close(); + await extension.unload(); + }); + + add_task(async function test_contentscript_crypto_in_non_secure_context() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + "matches": ["http://example.org/"], + "js": ["content_script.js"] + }, + ] + }, + files: { + "content_script.js"() { + // Make sure we're testing a non-secure context + browser.test.assertEq(window.isSecureContext, false, "window.isSecureContext === false") + + // Make sure our content script can't access window.crypto.randomUUID in non-secure context + browser.test.assertEq(typeof window.crypto.randomUUID, "undefined", "Content script can't access window.crypto.randomUUID in non-secure context") + + // Make sure the page can't access window.crypto.randomUUID in non-secure context + browser.test.assertEq(typeof window.wrappedJSObject.crypto.randomUUID, "undefined", "Page can't access window.crypto.randomUUID in non-secure context") + + browser.test.sendMessage("done"); + }, + }, + }); + await extension.startup(); + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + const win = window.open("http://example.org/"); + await extension.awaitMessage("done"); + win.close(); + await extension.unload(); + }); + + add_task(async function test_iframe_crypto_in_non_secure_context() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + "matches": ["http://example.org/"], + "js": ["content_script.js"] + }, + ] + }, + files: { + "iframe_script.js"() { + // Make sure we're testing a non-secure context + browser.test.assertEq(window.isSecureContext, false, "window.isSecureContext === false") + + // Make sure our iframe script can't access window.crypto.randomUUID in non-secure context + browser.test.assertEq(typeof window.crypto.randomUUID, "undefined", "iframe script can't access window.crypto.randomUUID in non-secure context") + + browser.test.sendMessage("done"); + }, + "content_script.js"() { + let iframe = document.createElement("iframe"); + iframe.src = browser.runtime.getURL("iframe.html"); + document.body.append(iframe); + }, + "iframe.html": "<!DOCTYPE html><html><head><script src=\"./iframe_script.js\"><\/script></head><body></body></html>", + } + }); + await extension.startup(); + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + const win = window.open("http://example.org/"); + await extension.awaitMessage("done"); + win.close(); + await extension.unload(); + }); + +</script> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies.html new file mode 100644 index 0000000000..cdec628975 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies.html @@ -0,0 +1,367 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_cookies() { + await SpecialPowers.pushPrefEnv({set: [ + ["dom.security.https_first_pbm", false], + ["dom.security.https_first", false], + ]}); + + async function background() { + function assertExpected(expected, cookie) { + for (let key of Object.keys(cookie)) { + browser.test.assertTrue(key in expected, `found property ${key}`); + browser.test.assertEq(expected[key], cookie[key], `property value for ${key} is correct`); + } + browser.test.assertEq(Object.keys(expected).length, Object.keys(cookie).length, "all expected properties found"); + } + + async function getDocumentCookie(tabId) { + let results = await browser.tabs.executeScript(tabId, { + code: "document.cookie", + }); + browser.test.assertEq(1, results.length, "executeScript returns one result"); + return results[0]; + } + + async function testIpCookie(ipAddress, setHostOnly) { + const IP_TEST_HOST = ipAddress; + const IP_TEST_URL = `http://${IP_TEST_HOST}/`; + const IP_THE_FUTURE = Date.now() + 5 * 60; + const IP_STORE_ID = "firefox-default"; + + let expectedCookie = { + name: "name1", + value: "value1", + domain: IP_TEST_HOST, + hostOnly: true, + path: "/", + secure: false, + httpOnly: false, + sameSite: "no_restriction", + session: false, + expirationDate: IP_THE_FUTURE, + storeId: IP_STORE_ID, + firstPartyDomain: "", + partitionKey: null, + }; + + await browser.browsingData.removeCookies({}); + let ip_cookie = await browser.cookies.set({ + url: IP_TEST_URL, + domain: setHostOnly ? ipAddress : undefined, + name: "name1", + value: "value1", + expirationDate: IP_THE_FUTURE, + }); + assertExpected(expectedCookie, ip_cookie); + + let ip_cookies = await browser.cookies.getAll({name: "name1"}); + browser.test.assertEq(1, ip_cookies.length, "ip cookie can be added"); + assertExpected(expectedCookie, ip_cookies[0]); + + ip_cookies = await browser.cookies.getAll({domain: IP_TEST_HOST, name: "name1"}); + browser.test.assertEq(1, ip_cookies.length, "can get ip cookie by host"); + assertExpected(expectedCookie, ip_cookies[0]); + + let ip_details = await browser.cookies.remove({url: IP_TEST_URL, name: "name1"}); + assertExpected({url: IP_TEST_URL, name: "name1", storeId: IP_STORE_ID, firstPartyDomain: "", partitionKey: null}, ip_details); + + ip_cookies = await browser.cookies.getAll({name: "name1"}); + browser.test.assertEq(0, ip_cookies.length, "ip cookie can be removed"); + } + + async function openPrivateWindowAndTab(TEST_URL) { + // Add some random suffix to make sure that we select the right tab. + const PRIVATE_TEST_URL = TEST_URL + "?random" + Math.random(); + + let tabReadyPromise = new Promise((resolve) => { + browser.webNavigation.onDOMContentLoaded.addListener(function listener({tabId}) { + browser.webNavigation.onDOMContentLoaded.removeListener(listener); + resolve(tabId); + }, { + url: [{ + urlPrefix: PRIVATE_TEST_URL, + }], + }); + }); + // This tab is opened for two purposes: + // 1. To allow tests to run content scripts in the context of a tab, + // for fetching the value of document.cookie. + // 2. TODO Bug 1309637 To work around cookies in incognito windows, + // based on the analysis in comment 8. + let {id: windowId} = await browser.windows.create({ + incognito: true, + url: PRIVATE_TEST_URL, + }); + let tabId = await tabReadyPromise; + return {windowId, tabId}; + } + + function changePort(href, port) { + let url = new URL(href); + url.port = port; + return url.href; + } + + await testIpCookie("[2a03:4000:6:310e:216:3eff:fe53:99b]", false); + await testIpCookie("[2a03:4000:6:310e:216:3eff:fe53:99b]", true); + await testIpCookie("192.168.1.1", false); + await testIpCookie("192.168.1.1", true); + + const TEST_URL = "http://example.org/"; + const TEST_SECURE_URL = "https://example.org/"; + const THE_FUTURE = Date.now() + 5 * 60; + const TEST_PATH = "set_path"; + const TEST_URL_WITH_PATH = TEST_URL + TEST_PATH; + const TEST_COOKIE_PATH = `/${TEST_PATH}`; + const STORE_ID = "firefox-default"; + const PRIVATE_STORE_ID = "firefox-private"; + + let expected = { + name: "name1", + value: "value1", + domain: "example.org", + hostOnly: true, + path: "/", + secure: false, + httpOnly: false, + sameSite: "no_restriction", + session: false, + expirationDate: THE_FUTURE, + storeId: STORE_ID, + firstPartyDomain: "", + partitionKey: null, + }; + + // Remove all cookies before starting the test. + await browser.browsingData.removeCookies({}); + + let cookie = await browser.cookies.set({url: TEST_URL, name: "name1", value: "value1", expirationDate: THE_FUTURE}); + assertExpected(expected, cookie); + + cookie = await browser.cookies.get({url: TEST_URL, name: "name1"}); + assertExpected(expected, cookie); + + let cookies = await browser.cookies.getAll({name: "name1"}); + browser.test.assertEq(1, cookies.length, "one cookie found for matching name"); + assertExpected(expected, cookies[0]); + + cookies = await browser.cookies.getAll({domain: "example.org"}); + browser.test.assertEq(1, cookies.length, "one cookie found for matching domain"); + assertExpected(expected, cookies[0]); + + cookies = await browser.cookies.getAll({domain: "example.net"}); + browser.test.assertEq(0, cookies.length, "no cookies found for non-matching domain"); + + cookies = await browser.cookies.getAll({secure: false}); + browser.test.assertEq(1, cookies.length, "one non-secure cookie found"); + assertExpected(expected, cookies[0]); + + cookies = await browser.cookies.getAll({secure: true}); + browser.test.assertEq(0, cookies.length, "no secure cookies found"); + + cookies = await browser.cookies.getAll({storeId: STORE_ID}); + browser.test.assertEq(1, cookies.length, "one cookie found for valid storeId"); + assertExpected(expected, cookies[0]); + + cookies = await browser.cookies.getAll({storeId: "invalid_id"}); + browser.test.assertEq(0, cookies.length, "no cookies found for invalid storeId"); + + let details = await browser.cookies.remove({url: TEST_URL, name: "name1"}); + assertExpected({url: TEST_URL, name: "name1", storeId: STORE_ID, firstPartyDomain: "", partitionKey: null}, details); + + cookie = await browser.cookies.get({url: TEST_URL, name: "name1"}); + browser.test.assertEq(null, cookie, "removed cookie not found"); + + // Ports in cookie URLs should be ignored. Every API call uses a different port number for better coverage. + cookie = await browser.cookies.set({url: changePort(TEST_URL, 1234), name: "name1", value: "value1", expirationDate: THE_FUTURE}); + assertExpected(expected, cookie); + + cookie = await browser.cookies.get({url: changePort(TEST_URL, 65535), name: "name1"}); + assertExpected(expected, cookie); + + cookies = await browser.cookies.getAll({url: TEST_URL}); + browser.test.assertEq(cookies.length, 1, "Found cookie using getAll without port"); + assertExpected(expected, cookies[0]); + + cookies = await browser.cookies.getAll({url: changePort(TEST_URL, 1)}); + browser.test.assertEq(cookies.length, 1, "Found cookie using getAll with port"); + assertExpected(expected, cookies[0]); + + // .remove should return the URL of the API call, so the port is included in the return value. + const TEST_URL_TO_REMOVE = changePort(TEST_URL, 1023); + details = await browser.cookies.remove({url: TEST_URL_TO_REMOVE, name: "name1"}); + assertExpected({url: TEST_URL_TO_REMOVE, name: "name1", storeId: STORE_ID, firstPartyDomain: "", partitionKey: null}, details); + + cookie = await browser.cookies.get({url: TEST_URL, name: "name1"}); + browser.test.assertEq(null, cookie, "removed cookie not found"); + + let stores = await browser.cookies.getAllCookieStores(); + browser.test.assertEq(1, stores.length, "expected number of stores returned"); + browser.test.assertEq(STORE_ID, stores[0].id, "expected store id returned"); + browser.test.assertEq(1, stores[0].tabIds.length, "one tabId returned for store"); + browser.test.assertEq("number", typeof stores[0].tabIds[0], "tabId is a number"); + + // TODO bug 1372178: Opening private windows/tabs is not supported on Android + if (browser.windows) { + let {windowId} = await openPrivateWindowAndTab(TEST_URL); + let stores = await browser.cookies.getAllCookieStores(); + + browser.test.assertEq(2, stores.length, "expected number of stores returned"); + browser.test.assertEq(STORE_ID, stores[0].id, "expected store id returned"); + browser.test.assertEq(1, stores[0].tabIds.length, "one tab returned for store"); + browser.test.assertEq(PRIVATE_STORE_ID, stores[1].id, "expected private store id returned"); + browser.test.assertEq(1, stores[0].tabIds.length, "one tab returned for private store"); + + await browser.windows.remove(windowId); + } + + cookie = await browser.cookies.set({url: TEST_URL, name: "name2", domain: ".example.org", expirationDate: THE_FUTURE}); + browser.test.assertEq(false, cookie.hostOnly, "cookie is not a hostOnly cookie"); + + details = await browser.cookies.remove({url: TEST_URL, name: "name2"}); + assertExpected({url: TEST_URL, name: "name2", storeId: STORE_ID, firstPartyDomain: "", partitionKey: null}, details); + + // Create a session cookie. + cookie = await browser.cookies.set({url: TEST_URL, name: "name1", value: "value1"}); + browser.test.assertEq(true, cookie.session, "session cookie set"); + + cookie = await browser.cookies.get({url: TEST_URL, name: "name1"}); + browser.test.assertEq(true, cookie.session, "got session cookie"); + + cookies = await browser.cookies.getAll({session: true}); + browser.test.assertEq(1, cookies.length, "one session cookie found"); + browser.test.assertEq(true, cookies[0].session, "found session cookie"); + + cookies = await browser.cookies.getAll({session: false}); + browser.test.assertEq(0, cookies.length, "no non-session cookies found"); + + details = await browser.cookies.remove({url: TEST_URL, name: "name1"}); + assertExpected({url: TEST_URL, name: "name1", storeId: STORE_ID, firstPartyDomain: "", partitionKey: null}, details); + + cookie = await browser.cookies.get({url: TEST_URL, name: "name1"}); + browser.test.assertEq(null, cookie, "removed cookie not found"); + + cookie = await browser.cookies.set({url: TEST_SECURE_URL, name: "name1", value: "value1", secure: true}); + browser.test.assertEq(true, cookie.secure, "secure cookie set"); + + cookie = await browser.cookies.get({url: TEST_SECURE_URL, name: "name1"}); + browser.test.assertEq(true, cookie.session, "got secure cookie"); + + cookies = await browser.cookies.getAll({secure: true}); + browser.test.assertEq(1, cookies.length, "one secure cookie found"); + browser.test.assertEq(true, cookies[0].secure, "found secure cookie"); + + cookies = await browser.cookies.getAll({secure: false}); + browser.test.assertEq(0, cookies.length, "no non-secure cookies found"); + + details = await browser.cookies.remove({url: TEST_SECURE_URL, name: "name1"}); + assertExpected({url: TEST_SECURE_URL, name: "name1", storeId: STORE_ID, firstPartyDomain: "", partitionKey: null}, details); + + cookie = await browser.cookies.get({url: TEST_SECURE_URL, name: "name1"}); + browser.test.assertEq(null, cookie, "removed cookie not found"); + + cookie = await browser.cookies.set({url: TEST_URL_WITH_PATH, path: TEST_COOKIE_PATH, name: "name1", value: "value1", expirationDate: THE_FUTURE}); + browser.test.assertEq(TEST_COOKIE_PATH, cookie.path, "created cookie with path"); + + cookie = await browser.cookies.get({url: TEST_URL_WITH_PATH, name: "name1"}); + browser.test.assertEq(TEST_COOKIE_PATH, cookie.path, "got cookie with path"); + + cookies = await browser.cookies.getAll({path: TEST_COOKIE_PATH}); + browser.test.assertEq(1, cookies.length, "one cookie with path found"); + browser.test.assertEq(TEST_COOKIE_PATH, cookies[0].path, "found cookie with path"); + + cookie = await browser.cookies.get({url: TEST_URL + "invalid_path", name: "name1"}); + browser.test.assertEq(null, cookie, "get with invalid path returns null"); + + cookies = await browser.cookies.getAll({path: "/invalid_path"}); + browser.test.assertEq(0, cookies.length, "getAll with invalid path returns 0 cookies"); + + details = await browser.cookies.remove({url: TEST_URL_WITH_PATH, name: "name1"}); + assertExpected({url: TEST_URL_WITH_PATH, name: "name1", storeId: STORE_ID, firstPartyDomain: "", partitionKey: null}, details); + + cookie = await browser.cookies.set({url: TEST_URL, name: "name1", value: "value1", httpOnly: true}); + browser.test.assertEq(true, cookie.httpOnly, "httpOnly cookie set"); + + cookie = await browser.cookies.set({url: TEST_URL, name: "name1", value: "value1", httpOnly: false}); + browser.test.assertEq(false, cookie.httpOnly, "non-httpOnly cookie set"); + + details = await browser.cookies.remove({url: TEST_URL, name: "name1"}); + assertExpected({url: TEST_URL, name: "name1", storeId: STORE_ID, firstPartyDomain: "", partitionKey: null}, details); + + cookie = await browser.cookies.set({url: TEST_URL}); + browser.test.assertEq("", cookie.name, "default name set"); + browser.test.assertEq("", cookie.value, "default value set"); + browser.test.assertEq(true, cookie.session, "no expiry date created session cookie"); + + // TODO bug 1372178: Opening private windows/tabs is not supported on Android + if (browser.windows) { + let {tabId, windowId} = await openPrivateWindowAndTab(TEST_URL); + + browser.test.assertEq("", await getDocumentCookie(tabId), "initially no cookie"); + + let cookie = await browser.cookies.set({url: TEST_URL, name: "store", value: "private", expirationDate: THE_FUTURE, storeId: PRIVATE_STORE_ID}); + browser.test.assertEq("private", cookie.value, "set the private cookie"); + + cookie = await browser.cookies.set({url: TEST_URL, name: "store", value: "default", expirationDate: THE_FUTURE, storeId: STORE_ID}); + browser.test.assertEq("default", cookie.value, "set the default cookie"); + + cookie = await browser.cookies.get({url: TEST_URL, name: "store", storeId: PRIVATE_STORE_ID}); + browser.test.assertEq("private", cookie.value, "get the private cookie"); + browser.test.assertEq(PRIVATE_STORE_ID, cookie.storeId, "get the private cookie storeId"); + + cookie = await browser.cookies.get({url: TEST_URL, name: "store", storeId: STORE_ID}); + browser.test.assertEq("default", cookie.value, "get the default cookie"); + browser.test.assertEq(STORE_ID, cookie.storeId, "get the default cookie storeId"); + + browser.test.assertEq("store=private", await getDocumentCookie(tabId), "private document.cookie should be set"); + + let details = await browser.cookies.remove({url: TEST_URL, name: "store", storeId: STORE_ID}); + assertExpected({url: TEST_URL, name: "store", storeId: STORE_ID, firstPartyDomain: "", partitionKey: null}, details); + + cookie = await browser.cookies.get({url: TEST_URL, name: "store", storeId: STORE_ID}); + browser.test.assertEq(null, cookie, "deleted the default cookie"); + + details = await browser.cookies.remove({url: TEST_URL, name: "store", storeId: PRIVATE_STORE_ID}); + assertExpected({url: TEST_URL, name: "store", storeId: PRIVATE_STORE_ID, firstPartyDomain: "", partitionKey: null}, details); + + cookie = await browser.cookies.get({url: TEST_URL, name: "store", storeId: PRIVATE_STORE_ID}); + browser.test.assertEq(null, cookie, "deleted the private cookie"); + + browser.test.assertEq("", await getDocumentCookie(tabId), "private document.cookie should be removed"); + + await browser.windows.remove(windowId); + } + + browser.test.notifyPass("cookies"); + } + + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + background, + manifest: { + permissions: ["cookies", "*://example.org/", "*://[2a03:4000:6:310e:216:3eff:fe53:99b]/", "*://192.168.1.1/", "webNavigation", "browsingData"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("cookies"); + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_containers.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_containers.html new file mode 100644 index 0000000000..d4bbd61177 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_containers.html @@ -0,0 +1,98 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function setup() { + // make sure userContext is enabled. + await SpecialPowers.pushPrefEnv({"set": [ + ["privacy.userContext.enabled", true], + ]}); +}); + +add_task(async function test_cookie_containers() { + async function background() { + // Sometimes there is a cookie without name/value when running tests. + let cookiesAtStart = await browser.cookies.getAll({storeId: "firefox-default"}); + + function assertExpected(expected, cookie) { + for (let key of Object.keys(cookie)) { + browser.test.assertTrue(key in expected, `found property ${key}`); + browser.test.assertEq(expected[key], cookie[key], `property value for ${key} is correct`); + } + browser.test.assertEq(Object.keys(expected).length, Object.keys(cookie).length, "all expected properties found"); + } + + const TEST_URL = "http://example.org/"; + const THE_FUTURE = Date.now() + 5 * 60; + + let expected = { + name: "name1", + value: "value1", + domain: "example.org", + hostOnly: true, + path: "/", + secure: false, + httpOnly: false, + sameSite: "no_restriction", + session: false, + expirationDate: THE_FUTURE, + storeId: "firefox-container-1", + firstPartyDomain: "", + partitionKey: null, + }; + + let cookie = await browser.cookies.set({ + url: TEST_URL, name: "name1", value: "value1", + expirationDate: THE_FUTURE, storeId: "firefox-container-1", + }); + browser.test.assertEq("firefox-container-1", cookie.storeId, "the cookie has the correct storeId"); + + cookie = await browser.cookies.get({url: TEST_URL, name: "name1"}); + browser.test.assertEq(null, cookie, "get() without storeId returns null"); + + cookie = await browser.cookies.get({url: TEST_URL, name: "name1", storeId: "firefox-container-1"}); + assertExpected(expected, cookie); + + let cookies = await browser.cookies.getAll({storeId: "firefox-default"}); + browser.test.assertEq(0, cookiesAtStart.length - cookies.length, "getAll() with default storeId hasn't added cookies"); + + cookies = await browser.cookies.getAll({storeId: "firefox-container-1"}); + browser.test.assertEq(1, cookies.length, "one cookie found for matching domain"); + assertExpected(expected, cookies[0]); + + let details = await browser.cookies.remove({url: TEST_URL, name: "name1", storeId: "firefox-container-1"}); + assertExpected({url: TEST_URL, name: "name1", storeId: "firefox-container-1", firstPartyDomain: "", partitionKey: null}, details); + + cookie = await browser.cookies.get({url: TEST_URL, name: "name1", storeId: "firefox-container-1"}); + browser.test.assertEq(null, cookie, "removed cookie not found"); + + browser.test.notifyPass("cookies"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["cookies", "*://example.org/"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("cookies"); + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_expiry.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_expiry.html new file mode 100644 index 0000000000..fa118f5271 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_expiry.html @@ -0,0 +1,72 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension cookies test</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_cookies_expiry() { + function background() { + let expectedEvents = []; + + browser.cookies.onChanged.addListener(event => { + expectedEvents.push(`${event.removed}:${event.cause}`); + if (expectedEvents.length === 1) { + browser.test.assertEq("true:expired", expectedEvents[0], "expired cookie removed"); + browser.test.assertEq("first", event.cookie.name, "expired cookie has the expected name"); + browser.test.assertEq("one", event.cookie.value, "expired cookie has the expected value"); + } else { + browser.test.assertEq("false:explicit", expectedEvents[1], "new cookie added"); + browser.test.assertEq("first", event.cookie.name, "new cookie has the expected name"); + browser.test.assertEq("one-again", event.cookie.value, "new cookie has the expected value"); + browser.test.notifyPass("cookie-expiry"); + } + }); + + setTimeout(() => { + browser.test.sendMessage("change-cookies"); + }, 1000); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["http://example.com/", "cookies"], + }, + background, + }); + + let chromeScript = loadChromeScript(() => { + const {sendAsyncMessage} = this; + Services.cookies.add(".example.com", "/", "first", "one", false, false, false, Date.now() / 1000 + 1, {}, Ci.nsICookie.SAMESITE_NONE, Ci.nsICookie.SCHEME_HTTP); + sendAsyncMessage("done"); + }); + await chromeScript.promiseOneMessage("done"); + chromeScript.destroy(); + + await extension.startup(); + await extension.awaitMessage("change-cookies"); + + chromeScript = loadChromeScript(() => { + const {sendAsyncMessage} = this; + Services.cookies.add(".example.com", "/", "first", "one-again", false, false, false, Date.now() / 1000 + 10, {}, Ci.nsICookie.SAMESITE_NONE, Ci.nsICookie.SCHEME_HTTP); + sendAsyncMessage("done"); + }); + await chromeScript.promiseOneMessage("done"); + chromeScript.destroy(); + + await extension.awaitFinish("cookie-expiry"); + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_first_party.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_first_party.html new file mode 100644 index 0000000000..7e33f4731d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_first_party.html @@ -0,0 +1,316 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<script src="/tests/SimpleTest/SimpleTest.js"></script> +<script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> +<script src="head.js"></script> +<script> +"use strict"; + +async function background() { + const url = "http://ext-cookie-first-party.mochi.test/"; + const firstPartyDomain = "ext-cookie-first-party.mochi.test"; + // A first party domain with invalid characters for the file system, which just happens to be a IPv6 address. + const firstPartyDomainInvalidChars = "[2606:4700:4700::1111]"; + const expectedError = "First-Party Isolation is enabled, but the required 'firstPartyDomain' attribute was not set."; + + const assertExpectedCookies = (expected, cookies, message) => { + let matches = (cookie, expected) => { + if (!cookie || !expected) { + return cookie === expected; // true if both are null. + } + for (let key of Object.keys(expected)) { + if (cookie[key] !== expected[key]) { + return false; + } + } + return true; + }; + browser.test.assertEq(expected.length, cookies.length, `Got expected number of cookies - ${message}`); + if (cookies.length !== expected.length) { + return; + } + for (let expect of expected) { + let foundCookies = cookies.filter(cookie => matches(cookie, expect)); + browser.test.assertEq(1, foundCookies.length, + `Expected cookie ${JSON.stringify(expect)} found - ${message}`); + } + }; + + // Test when FPI is disabled. + const test_fpi_disabled = async () => { + let cookie, cookies; + + // set + cookie = await browser.cookies.set({url, name: "foo1", value: "bar1"}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + ], [cookie], "set: FPI off, w/ empty firstPartyDomain, non-FP cookie"); + cookie = await browser.cookies.set({url, name: "foo2", value: "bar2", firstPartyDomain}); + assertExpectedCookies([ + {name: "foo2", value: "bar2", firstPartyDomain}, + ], [cookie], "set: FPI off, w/ firstPartyDomain, FP cookie"); + + // get + // When FPI is disabled, missing key/null/undefined is equivalent to "". + cookie = await browser.cookies.get({url, name: "foo1"}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + ], [cookie], "get: FPI off, w/o firstPartyDomain, non-FP cookie"); + cookie = await browser.cookies.get({url, name: "foo1", firstPartyDomain: ""}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + ], [cookie], "get: FPI off, w/ empty firstPartyDomain, non-FP cookie"); + cookie = await browser.cookies.get({url, name: "foo1", firstPartyDomain: null}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + ], [cookie], "get: FPI off, w/ null firstPartyDomain, non-FP cookie"); + cookie = await browser.cookies.get({url, name: "foo1", firstPartyDomain: undefined}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + ], [cookie], "get: FPI off, w/ undefined firstPartyDomain, non-FP cookie"); + + cookie = await browser.cookies.get({url, name: "foo2", firstPartyDomain}); + assertExpectedCookies([ + {name: "foo2", value: "bar2", firstPartyDomain}, + ], [cookie], "get: FPI off, w/ firstPartyDomain, FP cookie"); + // There is no match for non-FP cookies with name "foo2". + cookie = await browser.cookies.get({url, name: "foo2"}); + assertExpectedCookies([null], [cookie], "get: FPI off, w/o firstPartyDomain, no cookie"); + cookie = await browser.cookies.get({url, name: "foo2", firstPartyDomain: ""}); + assertExpectedCookies([null], [cookie], "get: FPI off, w/ empty firstPartyDomain, no cookie"); + cookie = await browser.cookies.get({url, name: "foo2", firstPartyDomain: null}); + assertExpectedCookies([null], [cookie], "get: FPI off, w/ null firstPartyDomain, no cookie"); + cookie = await browser.cookies.get({url, name: "foo2", firstPartyDomain: undefined}); + assertExpectedCookies([null], [cookie], "get: FPI off, w/ undefined firstPartyDomain, no cookie"); + + // getAll + for (let extra of [{}, {url}, {domain: firstPartyDomain}]) { + const prefix = `getAll(${JSON.stringify(extra)})`; + cookies = await browser.cookies.getAll({...extra}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + ], cookies, `${prefix}: FPI off, w/o firstPartyDomain, non-FP cookies`); + cookies = await browser.cookies.getAll({...extra, firstPartyDomain: ""}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + ], cookies, `${prefix}: FPI off, w/ empty firstPartyDomain, non-FP cookies`); + cookies = await browser.cookies.getAll({...extra, firstPartyDomain: null}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + {name: "foo2", value: "bar2", firstPartyDomain}, + ], cookies, `${prefix}: FPI off, w/ null firstPartyDomain, all cookies`); + cookies = await browser.cookies.getAll({...extra, firstPartyDomain: undefined}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + {name: "foo2", value: "bar2", firstPartyDomain}, + ], cookies, `${prefix}: FPI off, w/ undefined firstPartyDomain, all cookies`); + cookies = await browser.cookies.getAll({...extra, firstPartyDomain}); + assertExpectedCookies([ + {name: "foo2", value: "bar2", firstPartyDomain}, + ], cookies, `${prefix}: FPI off, w/ firstPartyDomain, FP cookies`); + } + + // remove + cookie = await browser.cookies.remove({url, name: "foo1"}); + assertExpectedCookies([ + {url, name: "foo1", firstPartyDomain: ""}, + ], [cookie], "remove: FPI off, w/ empty firstPartyDomain, non-FP cookie"); + cookie = await browser.cookies.remove({url, name: "foo2", firstPartyDomain}); + assertExpectedCookies([ + {url, name: "foo2", firstPartyDomain}, + ], [cookie], "remove: FPI off, w/ firstPartyDomain, FP cookie"); + + // Test if FP cookies set when FPI off can be accessed when FPI on. + await browser.cookies.set({url, name: "foo1", value: "bar1"}); + await browser.cookies.set({url, name: "foo2", value: "bar2", firstPartyDomain}); + + browser.test.sendMessage("test_fpi_disabled"); + }; + + // Test when FPI is enabled. + const test_fpi_enabled = async () => { + let cookie, cookies; + + // set + await browser.test.assertRejects( + browser.cookies.set({url, name: "foo3", value: "bar3"}), + expectedError, + "set: FPI on, w/o firstPartyDomain, rejection"); + cookie = await browser.cookies.set({url, name: "foo4", value: "bar4", firstPartyDomain}); + assertExpectedCookies([ + {name: "foo4", value: "bar4", firstPartyDomain}, + ], [cookie], "set: FPI on, w/ firstPartyDomain, FP cookie"); + + // get + await browser.test.assertRejects( + browser.cookies.get({url, name: "foo3"}), + expectedError, + "get: FPI on, w/o firstPartyDomain, rejection"); + await browser.test.assertRejects( + browser.cookies.get({url, name: "foo3", firstPartyDomain: null}), + expectedError, + "get: FPI on, w/ null firstPartyDomain, rejection"); + await browser.test.assertRejects( + browser.cookies.get({url, name: "foo3", firstPartyDomain: undefined}), + expectedError, + "get: FPI on, w/ undefined firstPartyDomain, rejection"); + cookie = await browser.cookies.get({url, name: "foo1", firstPartyDomain: ""}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + ], [cookie], "get: FPI on, w/ empty firstPartyDomain, non-FP cookie"); + cookie = await browser.cookies.get({url, name: "foo4", firstPartyDomain}); + assertExpectedCookies([ + {name: "foo4", value: "bar4", firstPartyDomain}, + ], [cookie], "get: FPI on, w/ firstPartyDomain, FP cookie"); + cookie = await browser.cookies.get({url, name: "foo2", firstPartyDomain}); + assertExpectedCookies([ + {name: "foo2", value: "bar2", firstPartyDomain}, + ], [cookie], "get: FPI on, w/ firstPartyDomain, FP cookie (set when FPI off)"); + + // getAll + for (let extra of [{}, {url}, {domain: firstPartyDomain}]) { + const prefix = `getAll(${JSON.stringify(extra)})`; + await browser.test.assertRejects( + browser.cookies.getAll({...extra}), + expectedError, + `${prefix}: FPI on, w/o firstPartyDomain, rejection`); + cookies = await browser.cookies.getAll({...extra, firstPartyDomain: ""}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + ], cookies, `${prefix}: FPI on, w/ empty firstPartyDomain, non-FP cookies`); + cookies = await browser.cookies.getAll({...extra, firstPartyDomain: null}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + {name: "foo2", value: "bar2", firstPartyDomain}, + {name: "foo4", value: "bar4", firstPartyDomain}, + ], cookies, `${prefix}: FPI on, w/ null firstPartyDomain, all cookies`); + cookies = await browser.cookies.getAll({...extra, firstPartyDomain: undefined}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + {name: "foo2", value: "bar2", firstPartyDomain}, + {name: "foo4", value: "bar4", firstPartyDomain}, + ], cookies, `${prefix}: FPI on, w/ undefined firstPartyDomain, all cookies`); + cookies = await browser.cookies.getAll({...extra, firstPartyDomain}); + assertExpectedCookies([ + {name: "foo2", value: "bar2", firstPartyDomain}, + {name: "foo4", value: "bar4", firstPartyDomain}, + ], cookies, `${prefix}: FPI on, w/ firstPartyDomain, FP cookies`); + } + + // remove + await browser.test.assertRejects( + browser.cookies.remove({url, name: "foo3"}), + expectedError, + "remove: FPI on, w/o firstPartyDomain, rejection"); + cookie = await browser.cookies.remove({url, name: "foo4", firstPartyDomain}); + assertExpectedCookies([ + {url, name: "foo4", firstPartyDomain}, + ], [cookie], "remove: FPI on, w/ firstPartyDomain, FP cookie"); + cookie = await browser.cookies.remove({url, name: "foo2", firstPartyDomain}); + assertExpectedCookies([ + {url, name: "foo2", firstPartyDomain}, + ], [cookie], "remove: FPI on, w/ firstPartyDomain, FP cookie (set when FPI off)"); + + // Test if FP cookies set when FPI on can be accessed when FPI off. + await browser.cookies.set({url, name: "foo4", value: "bar4", firstPartyDomain}); + + browser.test.sendMessage("test_fpi_enabled"); + }; + + // Test FPI with a first party domain with invalid characters for + // the file system. + const test_fpi_with_invalid_characters = async () => { + let cookie; + + // Test setting a cookie with a first party domain with invalid characters + // for the file system. + cookie = await browser.cookies.set({url, name: "foo5", value: "bar5", + firstPartyDomain: firstPartyDomainInvalidChars}); + assertExpectedCookies([ + {name: "foo5", value: "bar5", firstPartyDomain: firstPartyDomainInvalidChars}, + ], [cookie], "set: FPI on, w/ firstPartyDomain with invalid characters, FP cookie"); + + // Test getting a cookie with a first party domain with invalid characters + // for the file system. + cookie = await browser.cookies.get({url, name: "foo5", + firstPartyDomain: firstPartyDomainInvalidChars}); + assertExpectedCookies([ + {name: "foo5", value: "bar5", firstPartyDomain: firstPartyDomainInvalidChars}, + ], [cookie], "get: FPI on, w/ firstPartyDomain with invalid characters, FP cookie"); + + // Test removing a cookie with a first party domain with invalid characters + // for the file system. + cookie = await browser.cookies.remove({url, name: "foo5", + firstPartyDomain: firstPartyDomainInvalidChars}); + assertExpectedCookies([ + {url, name: "foo5", firstPartyDomain: firstPartyDomainInvalidChars}, + ], [cookie], "remove: FPI on, w/ firstPartyDomain with invalid characters, FP cookie"); + + browser.test.sendMessage("test_fpi_with_invalid_characters"); + }; + + // Test when FPI is disabled again, accessing FP cookies set when FPI is enabled. + const test_fpd_cookies_on_fpi_disabled = async () => { + let cookie, cookies; + cookie = await browser.cookies.get({url, name: "foo4", firstPartyDomain}); + assertExpectedCookies([ + {name: "foo4", value: "bar4", firstPartyDomain}, + ], [cookie], "get: FPI off, w/ firstPartyDomain, FP cookie (set when FPI on)"); + cookie = await browser.cookies.remove({url, name: "foo4", firstPartyDomain}); + assertExpectedCookies([ + {url, name: "foo4", firstPartyDomain}, + ], [cookie], "remove: FPI off, w/ firstPartyDomain, FP cookie (set when FPI on)"); + + // Clean up. + await browser.cookies.remove({url, name: "foo1"}); + + cookies = await browser.cookies.getAll({firstPartyDomain: null}); + assertExpectedCookies([], cookies, "Test is finishing, all cookies removed"); + + browser.test.sendMessage("test_fpd_cookies_on_fpi_disabled"); + }; + + browser.test.onMessage.addListener((message) => { + switch (message) { + case "test_fpi_disabled": return test_fpi_disabled(); + case "test_fpi_enabled": return test_fpi_enabled(); + case "test_fpi_with_invalid_characters": return test_fpi_with_invalid_characters(); + case "test_fpd_cookies_on_fpi_disabled": return test_fpd_cookies_on_fpi_disabled(); + default: return browser.test.notifyFail("unknown-message"); + } + }); +} + +function enableFirstPartyIsolation() { + return SpecialPowers.pushPrefEnv({ + set: [ + ["privacy.firstparty.isolate", true], + ], + }); +} + +function disableFirstPartyIsolation() { + return SpecialPowers.popPrefEnv(); +} + +add_task(async () => { + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["cookies", "*://ext-cookie-first-party.mochi.test/"], + }, + }); + await extension.startup(); + extension.sendMessage("test_fpi_disabled"); + await extension.awaitMessage("test_fpi_disabled"); + await enableFirstPartyIsolation(); + extension.sendMessage("test_fpi_enabled"); + await extension.awaitMessage("test_fpi_enabled"); + extension.sendMessage("test_fpi_with_invalid_characters"); + await extension.awaitMessage("test_fpi_with_invalid_characters"); + await disableFirstPartyIsolation(); + extension.sendMessage("test_fpd_cookies_on_fpi_disabled"); + await extension.awaitMessage("test_fpd_cookies_on_fpi_disabled"); + await extension.unload(); +}); +</script> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_incognito.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_incognito.html new file mode 100644 index 0000000000..b33ceecf06 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_incognito.html @@ -0,0 +1,107 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_cookies_incognito_not_allowed() { + let privateExtension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + async background() { + let window = await browser.windows.create({incognito: true}); + browser.test.onMessage.addListener(async () => { + await browser.windows.remove(window.id); + browser.test.sendMessage("done"); + }); + browser.test.sendMessage("ready"); + }, + manifest: { + permissions: ["cookies", "*://example.org/"], + }, + }); + await privateExtension.startup(); + await privateExtension.awaitMessage("ready"); + + async function background() { + const storeId = "firefox-private"; + const url = "http://example.org/"; + + // Getting the wrong storeId will fail, otherwise we should finish the test fine. + browser.cookies.onChanged.addListener(changeInfo => { + let {cookie} = changeInfo; + browser.test.assertTrue(cookie.storeId != storeId, "cookie store is correct"); + }); + + browser.test.onMessage.addListener(async () => { + let stores = await browser.cookies.getAllCookieStores(); + let store = stores.find(s => s.incognito); + browser.test.assertTrue(!store, "incognito cookie store should not be available"); + browser.test.notifyPass("cookies"); + }); + + await browser.test.assertRejects( + browser.cookies.set({url, name: "test", storeId}), + /Extension disallowed access/, + "API should reject setting cookie"); + await browser.test.assertRejects( + browser.cookies.get({url, name: "test", storeId}), + /Extension disallowed access/, + "API should reject getting cookie"); + await browser.test.assertRejects( + browser.cookies.getAll({url, storeId}), + /Extension disallowed access/, + "API should reject getting cookie"); + await browser.test.assertRejects( + browser.cookies.remove({url, name: "test", storeId}), + /Extension disallowed access/, + "API should reject getting cookie"); + await browser.test.assertRejects( + browser.cookies.getAll({url, storeId}), + /Extension disallowed access/, + "API should reject getting cookie"); + + browser.test.sendMessage("set-cookies"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["cookies", "*://example.org/"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("set-cookies"); + + let chromeScript = SpecialPowers.loadChromeScript(() => { + /* eslint-env mozilla/chrome-script */ + Services.cookies.add("example.org", "/", "public", `foo${Math.random()}`, + false, false, false, Number.MAX_SAFE_INTEGER, {}, + Ci.nsICookie.SAMESITE_NONE); + Services.cookies.add("example.org", "/", "private", `foo${Math.random()}`, + false, false, false, Number.MAX_SAFE_INTEGER, {privateBrowsingId: 1}, + Ci.nsICookie.SAMESITE_NONE); + }); + extension.sendMessage("test-cookie-store"); + await extension.awaitFinish("cookies"); + + await extension.unload(); + privateExtension.sendMessage("close"); + await privateExtension.awaitMessage("done"); + await privateExtension.unload(); + chromeScript.destroy(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_bad.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_bad.html new file mode 100644 index 0000000000..0bd2852075 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_bad.html @@ -0,0 +1,115 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <script type="text/javascript" src="head_cookies.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function init() { + // We need to trigger a cookie eviction in order to test our batch delete + // observer. + + // Set quotaPerHost to maxPerHost - 1, so there is only one cookie + // will be evicted everytime. + SpecialPowers.setIntPref("network.cookie.quotaPerHost", 2); + SpecialPowers.setIntPref("network.cookie.maxPerHost", 3); + SimpleTest.registerCleanupFunction(() => { + SpecialPowers.clearUserPref("network.cookie.quotaPerHost"); + SpecialPowers.clearUserPref("network.cookie.maxPerHost"); + }); +}); + +add_task(async function test_bad_cookie_permissions() { + info("Test non-matching, non-secure domain with non-secure cookie"); + await testCookies({ + permissions: ["http://example.com/", "cookies"], + url: "http://example.net/", + domain: "example.net", + secure: false, + shouldPass: false, + shouldWrite: false, + }); + + info("Test non-matching, secure domain with non-secure cookie"); + await testCookies({ + permissions: ["https://example.com/", "cookies"], + url: "https://example.net/", + domain: "example.net", + secure: false, + shouldPass: false, + shouldWrite: false, + }); + + info("Test non-matching, secure domain with secure cookie"); + await testCookies({ + permissions: ["https://example.com/", "cookies"], + url: "https://example.net/", + domain: "example.net", + secure: false, + shouldPass: false, + shouldWrite: false, + }); + + info("Test matching subdomain with superdomain privileges, secure cookie (http)"); + await testCookies({ + permissions: ["http://foo.bar.example.com/", "cookies"], + url: "http://foo.bar.example.com/", + domain: ".example.com", + secure: true, + shouldPass: false, + shouldWrite: true, + }); + + info("Test matching, non-secure domain with secure cookie"); + await testCookies({ + permissions: ["http://example.com/", "cookies"], + url: "http://example.com/", + domain: "example.com", + secure: true, + shouldPass: false, + shouldWrite: true, + }); + + info("Test matching, non-secure host, secure URL"); + await testCookies({ + permissions: ["http://example.com/", "cookies"], + url: "https://example.com/", + domain: "example.com", + secure: true, + shouldPass: false, + shouldWrite: false, + }); + + info("Test non-matching domain"); + await testCookies({ + permissions: ["http://example.com/", "cookies"], + url: "http://example.com/", + domain: "example.net", + secure: false, + shouldPass: false, + shouldWrite: false, + }); + + info("Test invalid scheme"); + await testCookies({ + permissions: ["ftp://example.com/", "cookies"], + url: "ftp://example.com/", + domain: "example.com", + secure: false, + shouldPass: false, + shouldWrite: false, + }); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_good.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_good.html new file mode 100644 index 0000000000..bd76f2b9c0 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_good.html @@ -0,0 +1,89 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <script type="text/javascript" src="head_cookies.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function init() { + // We need to trigger a cookie eviction in order to test our batch delete + // observer. + + // Set quotaPerHost to maxPerHost - 1, so there is only one cookie + // will be evicted everytime. + SpecialPowers.setIntPref("network.cookie.quotaPerHost", 2); + SpecialPowers.setIntPref("network.cookie.maxPerHost", 3); + SimpleTest.registerCleanupFunction(() => { + SpecialPowers.clearUserPref("network.cookie.quotaPerHost"); + SpecialPowers.clearUserPref("network.cookie.maxPerHost"); + }); +}); + +add_task(async function test_good_cookie_permissions() { + info("Test matching, non-secure domain with non-secure cookie"); + await testCookies({ + permissions: ["http://example.com/", "cookies"], + url: "http://example.com/", + domain: "example.com", + secure: false, + shouldPass: true, + }); + + info("Test matching, secure domain with non-secure cookie"); + await testCookies({ + permissions: ["https://example.com/", "cookies"], + url: "https://example.com/", + domain: "example.com", + secure: false, + shouldPass: true, + }); + + info("Test matching, secure domain with secure cookie"); + await testCookies({ + permissions: ["https://example.com/", "cookies"], + url: "https://example.com/", + domain: "example.com", + secure: true, + shouldPass: true, + }); + + info("Test matching subdomain with superdomain privileges, secure cookie (https)"); + await testCookies({ + permissions: ["https://foo.bar.example.com/", "cookies"], + url: "https://foo.bar.example.com/", + domain: ".example.com", + secure: true, + shouldPass: true, + }); + + info("Test matching subdomain with superdomain privileges, non-secure cookie (https)"); + await testCookies({ + permissions: ["https://foo.bar.example.com/", "cookies"], + url: "https://foo.bar.example.com/", + domain: ".example.com", + secure: false, + shouldPass: true, + }); + + info("Test matching subdomain with superdomain privileges, non-secure cookie (http)"); + await testCookies({ + permissions: ["http://foo.bar.example.com/", "cookies"], + url: "http://foo.bar.example.com/", + domain: ".example.com", + secure: false, + shouldPass: true, + }); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_dnr_other_extensions.html b/toolkit/components/extensions/test/mochitest/test_ext_dnr_other_extensions.html new file mode 100644 index 0000000000..d3074b3dec --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_dnr_other_extensions.html @@ -0,0 +1,113 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>DNR and tabs.create from other extension</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"> +</head> +<body> +<script> +"use strict"; + + +// While most DNR tests are xpcshell tests, this one is a mochitest because the +// tabs.create API does not work in a xpcshell test. + +add_setup(async () => { + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.manifestV3.enabled", true], + ["extensions.dnr.enabled", true], + ], + }); +}); + + +add_task(async function tabs_create_can_be_redirected_by_other_dnr_extension() { + let dnrExtension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["declarativeNetRequestWithHostAccess"], + // redirect action requires host permissions: + host_permissions: ["*://example.com/*"], + }, + async background() { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { + resourceTypes: ["main_frame"], + urlFilter: "?dnr_redir_me_pls", + }, + action: { + type: "redirect", + redirect: { + transform: { + query: "?dnr_redir_target" + }, + }, + }, + }, + ], + }); + browser.test.sendMessage("dnr_registered"); + }, + }); + await dnrExtension.startup(); + await dnrExtension.awaitMessage("dnr_registered"); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webNavigation"], + }, + async background() { + async function createTabAndGetFinalUrl(url) { + let navigationDonePromise = new Promise(resolve => { + browser.webNavigation.onDOMContentLoaded.addListener( + function listener(details) { + browser.webNavigation.onDOMContentLoaded.removeListener(listener); + resolve(details); + }, + // All input URLs and redirection targets match this URL filter: + { url: [{ queryPrefix: "dnr_redir_" }] } + ); + }); + const tab = await browser.tabs.create({ url }); + browser.test.log(`Waiting for navigation done, starting from ${url}`); + const result = await navigationDonePromise; + browser.test.assertEq( + tab.id, + result.tabId, + `Observed load completion for navigation tab with initial URL ${url}` + ); + await browser.tabs.remove(tab.id); + return result.url; + } + + browser.test.assertEq( + "https://example.com/?dnr_redir_target", + await createTabAndGetFinalUrl("https://example.com/?dnr_redir_me_pls"), + "DNR rule from other extension should have redirected the navigation" + ); + + browser.test.assertEq( + "https://example.org/?dnr_redir_me_pls", + await createTabAndGetFinalUrl("https://example.org/?dnr_redir_me_pls"), + "DNR redirect ignored for URLs without host permission" + ); + browser.test.sendMessage("done"); + } + }); + await extension.startup(); + await extension.awaitMessage("done"); + + await dnrExtension.unload(); + await extension.unload(); +}); + +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_dnr_tabIds.html b/toolkit/components/extensions/test/mochitest/test_ext_dnr_tabIds.html new file mode 100644 index 0000000000..0278a8ccc8 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_dnr_tabIds.html @@ -0,0 +1,137 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>DNR with tabIds condition</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"> +</head> +<body> +<script> +"use strict"; + +// While most DNR tests are xpcshell tests, this one is a mochitest because it +// is not possible to create a tab and get a tabId in a xpcshell test. + +// toolkit/components/extensions/test/xpcshell/test_ext_dnr_tabIds.js does +// exist, as an isolated xpcshell is needed to verify that the internals are +// working as expected. A mochitest is not a good fit for that because it has +// built-in add-ons that may affect the observed behavior. + +add_setup(async () => { + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.manifestV3.enabled", true], + ["extensions.dnr.enabled", true], + ], + }); +}); + +add_task(async function match_by_tabIds() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + async function createTabAndPort() { + let portPromise = new Promise(resolve => { + browser.runtime.onConnect.addListener(function listener(port) { + browser.runtime.onConnect.removeListener(listener); + browser.test.assertEq("port_from_tab", port.name, "Got port"); + resolve(port); + }); + }); + const tab = await browser.tabs.create({ url: "tab.html" }); + const port = await portPromise; + browser.test.assertEq(tab.id, port.sender.tab.id, "Got port from tab"); + browser.test.assertTrue(tab.id > 0, `tabId must be valid: ${tab.id}`); + tab.port = port; + return tab; + } + async function getFinalUrlForFetchInTab(tabWithPort, url) { + const port = tabWithPort.port; // from createTabAndPort. + return new Promise(resolve => { + port.onMessage.addListener(function listener(responseUrl) { + port.onMessage.removeListener(listener); + resolve(responseUrl); + }); + port.postMessage(url); + }); + } + let tab1 = await createTabAndPort(); + let tab2 = await createTabAndPort(); + + const URL_PREFIX = "https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.txt"; + + function makeRedirect(id, condition, url) { + return { + id, + // The test sends a request to example.net and expects a redirect to + // URL_PREFIX (example.com). + condition: { requestDomains: ["example.net"], ...condition }, + action: { type: "redirect", redirect: { url }}, + }; + } + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + makeRedirect(1, { tabIds: [-1] }, `${URL_PREFIX}?tabId/-1`), + makeRedirect(2, { tabIds: [tab1.id] }, `${URL_PREFIX}?tabId/tab1`), + makeRedirect( + 3, + { excludedTabIds: [-1, tab1.id] }, + `${URL_PREFIX}?tabId/not-1,not-tab1` + ), + ], + }); + + browser.test.assertEq( + `${URL_PREFIX}?tabId/-1`, + (await fetch("https://example.net/?pre-redirect-bg")).url, + "Request from background should match tabIds: [-1]" + ); + browser.test.assertEq( + `${URL_PREFIX}?tabId/tab1`, + await getFinalUrlForFetchInTab(tab1, "https://example.net/?pre-tab1"), + "Request from tab1 should match tabIds: [tab1]" + ); + browser.test.assertEq( + `${URL_PREFIX}?tabId/not-1,not-tab1`, + await getFinalUrlForFetchInTab(tab2, "https://example.net/?pre-tab2"), + "Request from tab2 should match excludedTabIds: [-1, tab1]" + ); + + await browser.tabs.remove(tab1.id); + await browser.tabs.remove(tab2.id); + + browser.test.sendMessage("done"); + }, + temporarilyInstalled: true, // Needed for granted_host_permissions + manifest: { + manifest_version: 3, + host_permissions: ["*://example.com/*", "*://example.net/*"], + permissions: ["declarativeNetRequest"], + granted_host_permissions: true, + }, + files: { + "tab.html": `<!DOCTYPE html><script src="tab.js"><\/script>`, + "tab.js": () => { + let port = browser.runtime.connect({ name: "port_from_tab" }); + port.onMessage.addListener(async url => { + try { + let res = await fetch(url); + port.postMessage(res.url); + } catch (e) { + port.postMessage(e.message); + } + }); + }, + }, + }); + await extension.startup(); + await extension.awaitMessage("done"); + + await extension.unload(); +}); + +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_dnr_upgradeScheme.html b/toolkit/components/extensions/test/mochitest/test_ext_dnr_upgradeScheme.html new file mode 100644 index 0000000000..43bc8a5a00 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_dnr_upgradeScheme.html @@ -0,0 +1,137 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>DNR with upgradeScheme action</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"> +</head> +<body> +<script> +"use strict"; + +// This test is not a xpcshell test, because we want to test upgrades to https, +// and HttpServer helper does not support https (bug 1742061). + +add_setup(async () => { + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.manifestV3.enabled", true], + ["extensions.dnr.enabled", true], + ["extensions.dnr.match_requests_from_other_extensions", true], + ], + }); +}); + +// Tests that the upgradeScheme action works as expected: +// - http should be upgraded to https +// - after the https upgrade the request should happen instead of being stuck +// in a upgrade redirect loop. +add_task(async function upgradeScheme_with_dnr() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [{ id: 1, condition: { requestDomains: ["example.com"] }, action: { type: "upgradeScheme" } }], + }); + + let sanityCheckResponse = await fetch( + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + "http://example.net/tests/toolkit/components/extensions/test/mochitest/file_sample.txt" + ); + browser.test.assertEq( + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + "http://example.net/tests/toolkit/components/extensions/test/mochitest/file_sample.txt", + sanityCheckResponse.url, + "non-matching request should not be upgraded" + ); + + let res = await fetch( + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + "http://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.txt" + ); + browser.test.assertEq( + "https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.txt", + res.url, + "upgradeScheme should have upgraded to https" + ); + // Server adds "Access-Control-Allow-Origin: *" to file_sample.txt, so + // we should be able to read the response despite no host_permissions. + browser.test.assertEq("Sample", await res.text(), "read body with CORS"); + + browser.test.sendMessage("dnr_registered"); + }, + manifest: { + manifest_version: 3, + // Note: host_permissions missing. upgradeScheme should not need it. + permissions: ["declarativeNetRequest"], + }, + allowInsecureRequests: true, + }); + await extension.startup(); + await extension.awaitMessage("dnr_registered"); + + // The request made by otherExtension is affected by the DNR rule from the + // extension because extensions.dnr.match_requests_from_other_extensions was + // set to true. A realistic alternative would have been to trigger the fetch + // requests from a content page instead of the extension. + let otherExtension = ExtensionTestUtils.loadExtension({ + async background() { + let firstRequestPromise = new Promise(resolve => { + let count = 0; + browser.webRequest.onBeforeRequest.addListener( + ({ url }) => { + ++count; + browser.test.assertTrue( + count <= 2, + `Expected at most two requests; got ${count} to ${url}` + ); + resolve(url); + }, + { urls: ["*://example.com/?test_dnr_upgradeScheme"] } + ); + }); + // Round-trip through ext-webRequest.js implementation to ensure that the + // listener has been registered (workaround for bug 1300234). + await browser.webRequest.handlerBehaviorChanged(); + + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + const insecureInitialUrl = "http://example.com/?test_dnr_upgradeScheme"; + browser.test.log(`Requesting insecure URL: ${insecureInitialUrl}`); + + let req = await fetch(insecureInitialUrl); + browser.test.assertEq( + "https://example.com/?test_dnr_upgradeScheme", + req.url, + "upgradeScheme action upgraded http to https" + ); + browser.test.assertEq(200, req.status, "Correct HTTP status"); + + await req.text(); // Verify that the body can be read, just in case. + + // Sanity check that the test did not pass trivially due to an automatic + // https upgrade of the extension / test environment. + browser.test.assertEq( + insecureInitialUrl, + await firstRequestPromise, + "Initial URL should be http" + ); + + browser.test.sendMessage("tested_dnr_upgradeScheme"); + }, + manifest: { + host_permissions: ["*://example.com/*"], + permissions: ["webRequest"], + }, + }); + await otherExtension.startup(); + await otherExtension.awaitMessage("tested_dnr_upgradeScheme"); + await otherExtension.unload(); + + await extension.unload(); +}); + +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_downloads_download.html b/toolkit/components/extensions/test/mochitest/test_ext_downloads_download.html new file mode 100644 index 0000000000..23058c35ec --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_downloads_download.html @@ -0,0 +1,94 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Downloads Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +async function background() { + const url = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_page.html"; + + browser.test.assertThrows( + () => browser.downloads.download(), + /Incorrect argument types for downloads.download/, + "Should fail without options" + ); + + browser.test.assertThrows( + () => browser.downloads.download({url: "invalid url"}), + /invalid url is not a valid URL/, + "Should fail on invalid URL" + ); + + browser.test.assertThrows( + () => browser.downloads.download({}), + /Property "url" is required/, + "Should fail with no URL" + ); + + browser.test.assertThrows( + () => browser.downloads.download({url, method: "DELETE"}), + /Invalid enumeration value "DELETE"/, + "Should fail with invalid method" + ); + + await browser.test.assertRejects( + browser.downloads.download({url, headers: [{name: "Host", value: "Banana"}]}), + /Forbidden request header name/, + "Should fail with a forbidden header" + ); + + const absoluteFilename = SpecialPowers.Services.appinfo.OS === "WINNT" + ? "C:\\tmp\\file.gif" + : "/tmp/file.gif"; + + await browser.test.assertRejects( + browser.downloads.download({url, filename: absoluteFilename}), + /filename must not be an absolute path/, + "Should fail with an absolute file path" + ); + + await browser.test.assertRejects( + browser.downloads.download({url, filename: ""}), + /filename must not be empty/, + "Should fail with an empty file path" + ); + + await browser.test.assertRejects( + browser.downloads.download({url, filename: "file."}), + /filename must not contain illegal characters/, + "Should fail with a dot in the filename" + ); + + await browser.test.assertRejects( + browser.downloads.download({url, filename: "../file.gif"}), + /filename must not contain back-references/, + "Should fail with a file path that contains back-references" + ); + + browser.test.notifyPass("download.done"); +} + +add_task(async function test_invalid_download_parameters() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: {permissions: ["downloads"]}, + background, + }); + await extension.startup(); + + await extension.awaitFinish("download.done"); + + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_embeddedimg_iframe_frameAncestors.html b/toolkit/components/extensions/test/mochitest/test_ext_embeddedimg_iframe_frameAncestors.html new file mode 100644 index 0000000000..d6702da4d3 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_embeddedimg_iframe_frameAncestors.html @@ -0,0 +1,94 @@ +<!DOCTYPE html> +<html> +<head> + <title>Test checking webRequest.onBeforeRequest details object</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +let expected = { + "file_contains_iframe.html": { + type: "main_frame", + frameAncestor_length: 0, + }, + "file_contains_img.html": { + type: "sub_frame", + frameAncestor_length: 1, + }, + "file_image_good.png": { + type: "image", + frameAncestor_length: 1, + } +}; + +function checkDetails(details) { + let url = new URL(details.url); + let filename = url.pathname.split("/").pop(); + ok(expected.hasOwnProperty(filename), `Should be expecting a request for ${filename}`); + let expect = expected[filename]; + is(expect.type, details.type, `${details.type} type matches`); + is(expect.frameAncestor_length, details.frameAncestors.length, "incorrect frameAncestors length"); + if (filename == "file_contains_img.html") { + is(details.frameAncestors[0].frameId, details.parentFrameId, + "frameAncestors[0] should match parentFrameId"); + expected["file_image_good.png"].frameId = details.frameId; + } else if (filename == "file_image_good.png") { + is(details.frameAncestors[0].frameId, details.parentFrameId, + "frameAncestors[0] should match parentFrameId"); + is(details.frameId, expect.frameId, + "frameId for image and iframe should match"); + } +} + +add_task(async () => { + // Clear the image cache, since it gets in the way otherwise. + let imgTools = SpecialPowers.Cc["@mozilla.org/image/tools;1"].getService(SpecialPowers.Ci.imgITools); + let cache = imgTools.getImgCacheForDocument(document); + cache.clearCache(false); + await SpecialPowers.spawnChrome([], async () => { + Services.cache2.clear(); + }); + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "<all_urls>"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("onBeforeRequest", details); + }, + { + urls: [ + "http://example.org/*/file_contains_img.html", + "http://mochi.test/*/file_contains_iframe.html", + "*://*/*.png", + ], + } + ); + }, + }); + + await extension.startup(); + const FILE_URL = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html"; + let win = window.open(FILE_URL); + await new Promise(resolve => win.addEventListener("load", () => resolve(), {once: true})); + + for (let i = 0; i < Object.keys(expected).length; i++) { + checkDetails(await extension.awaitMessage("onBeforeRequest")); + } + + win.close(); + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_exclude_include_globs.html b/toolkit/components/extensions/test/mochitest/test_ext_exclude_include_globs.html new file mode 100644 index 0000000000..f87b5620d6 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_exclude_include_globs.html @@ -0,0 +1,91 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for content script</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_contentscript() { + function background() { + browser.runtime.onMessage.addListener(([script], sender) => { + browser.test.sendMessage("run", {script}); + browser.test.sendMessage("run-" + script); + }); + browser.test.sendMessage("running"); + } + + function contentScriptAll() { + browser.runtime.sendMessage(["all"]); + } + function contentScriptIncludesTest1() { + browser.runtime.sendMessage(["includes-test1"]); + } + function contentScriptExcludesTest1() { + browser.runtime.sendMessage(["excludes-test1"]); + } + + let extensionData = { + manifest: { + content_scripts: [ + { + "matches": ["https://example.org/", "https://*.example.org/"], + "exclude_globs": [], + "include_globs": ["*"], + "js": ["content_script_all.js"], + }, + { + "matches": ["https://example.org/", "https://*.example.org/"], + "include_globs": ["*test1*"], + "js": ["content_script_includes_test1.js"], + }, + { + "matches": ["https://example.org/", "https://*.example.org/"], + "exclude_globs": ["*test1*"], + "js": ["content_script_excludes_test1.js"], + }, + ], + }, + background, + + files: { + "content_script_all.js": contentScriptAll, + "content_script_includes_test1.js": contentScriptIncludesTest1, + "content_script_excludes_test1.js": contentScriptExcludesTest1, + }, + + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + let ran = 0; + extension.onMessage("run", ({script}) => { + ran++; + }); + + await Promise.all([extension.startup(), extension.awaitMessage("running")]); + info("extension loaded"); + + let win = window.open("https://example.org/"); + await Promise.all([extension.awaitMessage("run-all"), extension.awaitMessage("run-excludes-test1")]); + win.close(); + is(ran, 2); + + win = window.open("https://test1.example.org/"); + await Promise.all([extension.awaitMessage("run-all"), extension.awaitMessage("run-includes-test1")]); + win.close(); + is(ran, 4); + + await extension.unload(); + info("extension unloaded"); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_extension_iframe_messaging.html b/toolkit/components/extensions/test/mochitest/test_ext_extension_iframe_messaging.html new file mode 100644 index 0000000000..403782ab7d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_extension_iframe_messaging.html @@ -0,0 +1,124 @@ +<!DOCTYPE html> +<html> +<head> + <title>Test moz-extension iframe messaging</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"> +</head> +<body> +<script> +"use strict"; + +add_task(async function test_moz_extension_iframe_messaging() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.content_web_accessible.enabled", true], + ], + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [{ + js: ["cs.js"], + matches: ["http://mochi.test/*/file_sample.html"], + }], + web_accessible_resources: ["iframe.html"], + permissions: ["tabs"], + }, + files: { + "cs.js"() { + let iframe = document.createElement("iframe"); + iframe.src = browser.runtime.getURL("iframe.html"); + document.body.append(iframe); + }, + + "iframe.html": `<!doctype html><script src=iframe.js><\/script>`, + async "iframe.js"() { + browser.runtime.onMessage.addListener(async msg => { + browser.test.assertEq(msg, "from-background", "Correct message."); + return "iframe-response"; + }); + + browser.runtime.onConnect.addListener(async port => { + port.postMessage("port-message"); + }); + + await browser.test.assertRejects( + browser.runtime.sendMessage("from-iframe"), + "Could not establish connection. Receiving end does not exist.", + "No onMessage listener in the background." + ); + + await new Promise(resolve => { + let port = browser.runtime.connect(); + port.onDisconnect.addListener(() => { + browser.test.assertEq( + port.error.message, + "Could not establish connection. Receiving end does not exist.", + "No onConnect listener in the background." + ); + resolve(); + }) + }); + + // TODO: If/when the tabs API is available from extension iframes, test + // that it won't send a message to itself via browser.tabs.sendMessage() + browser.test.assertEq(browser.tabs, undefined, "No tabs API"); + + browser.test.sendMessage("iframe-done"); + }, + }, + background() { + browser.test.onMessage.addListener(async msg => { + + await browser.test.assertRejects( + browser.runtime.sendMessage("from-background"), + "Could not establish connection. Receiving end does not exist.", + "No onMessage listener in another extension page." + ); + + await new Promise(resolve => { + let port = browser.runtime.connect(); + port.onDisconnect.addListener(() => { + browser.test.assertEq( + port.error.message, + "Could not establish connection. Receiving end does not exist.", + "No onConnect listener in another extension page." + ); + resolve(); + }) + }); + + let [tab] = await browser.tabs.query({ + url: "http://mochi.test/*/file_sample.html", + }); + let res = await browser.tabs.sendMessage(tab.id, "from-background"); + browser.test.assertEq(res, "iframe-response", "Correct response."); + + let port = browser.tabs.connect(tab.id); + port.onMessage.addListener(msg => { + browser.test.assertEq(msg, "port-message", "Correct port message."); + browser.test.notifyPass("done"); + }); + }) + } + }); + + await extension.startup(); + + let win = window.open("file_sample.html"); + await extension.awaitMessage("iframe-done"); + + extension.sendMessage("run-background"); + await extension.awaitFinish("done"); + win.close(); + + await extension.unload(); +}); + +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_external_messaging.html b/toolkit/components/extensions/test/mochitest/test_ext_external_messaging.html new file mode 100644 index 0000000000..639cacef28 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_external_messaging.html @@ -0,0 +1,110 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension external messaging</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +function backgroundScript(id, otherId) { + browser.runtime.onMessage.addListener((msg, sender) => { + browser.test.fail(`Got unexpected message: ${uneval(msg)} ${uneval(sender)}`); + }); + + browser.runtime.onConnect.addListener(port => { + browser.test.fail(`Got unexpected connection: ${uneval(port.sender)}`); + }); + + browser.runtime.onMessageExternal.addListener((msg, sender) => { + browser.test.assertEq(otherId, sender.id, `${id}: Got expected external sender ID`); + browser.test.assertEq(`helo-${id}`, msg, "Got expected message"); + + browser.test.sendMessage("onMessage-done"); + + return Promise.resolve(`ehlo-${otherId}`); + }); + + browser.runtime.onConnectExternal.addListener(port => { + browser.test.assertEq(otherId, port.sender.id, `${id}: Got expected external connecter ID`); + + port.onMessage.addListener(msg => { + browser.test.assertEq(`helo-${id}`, msg, "Got expected port message"); + + port.postMessage(`ehlo-${otherId}`); + + browser.test.sendMessage("onConnect-done"); + }); + }); + + browser.test.onMessage.addListener(msg => { + if (msg === "go") { + browser.runtime.sendMessage(otherId, `helo-${otherId}`).then(result => { + browser.test.assertEq(`ehlo-${id}`, result, "Got expected reply"); + browser.test.sendMessage("sendMessage-done"); + }); + + let port = browser.runtime.connect(otherId); + port.postMessage(`helo-${otherId}`); + + port.onMessage.addListener(msg => { + port.disconnect(); + + browser.test.assertEq(msg, `ehlo-${id}`, "Got expected port reply"); + browser.test.sendMessage("connect-done"); + }); + } + }); +} + +function makeExtension(id, otherId) { + let args = `${JSON.stringify(id)}, ${JSON.stringify(otherId)}`; + + let extensionData = { + background: `(${backgroundScript})(${args})`, + manifest: { + browser_specific_settings: {gecko: {id}}, + }, + }; + + return ExtensionTestUtils.loadExtension(extensionData); +} + +add_task(async function test_contentscript() { + const ID1 = "foo-message@mochitest.mozilla.org"; + const ID2 = "bar-message@mochitest.mozilla.org"; + + let extension1 = makeExtension(ID1, ID2); + let extension2 = makeExtension(ID2, ID1); + + await Promise.all([extension1.startup(), extension2.startup()]); + + extension1.sendMessage("go"); + extension2.sendMessage("go"); + + await Promise.all([ + extension1.awaitMessage("sendMessage-done"), + extension2.awaitMessage("sendMessage-done"), + + extension1.awaitMessage("onMessage-done"), + extension2.awaitMessage("onMessage-done"), + + extension1.awaitMessage("connect-done"), + extension2.awaitMessage("connect-done"), + + extension1.awaitMessage("onConnect-done"), + extension2.awaitMessage("onConnect-done"), + ]); + + await extension1.unload(); + await extension2.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_generate.html b/toolkit/components/extensions/test/mochitest/test_ext_generate.html new file mode 100644 index 0000000000..ba88d16ca3 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_generate.html @@ -0,0 +1,48 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for generating WebExtensions</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +function background() { + browser.test.log("running background script"); + + browser.test.onMessage.addListener((x, y) => { + browser.test.assertEq(x, 10, "x is 10"); + browser.test.assertEq(y, 20, "y is 20"); + + browser.test.notifyPass("background test passed"); + }); + + browser.test.sendMessage("running", 1); +} + +let extensionData = { + background, +}; + +add_task(async function test_background() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + info("load complete"); + let [, x] = await Promise.all([extension.startup(), extension.awaitMessage("running")]); + is(x, 1, "got correct value from extension"); + info("startup complete"); + extension.sendMessage(10, 20); + await extension.awaitFinish(); + info("test complete"); + await extension.unload(); + info("extension unloaded successfully"); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_geolocation.html b/toolkit/components/extensions/test/mochitest/test_ext_geolocation.html new file mode 100644 index 0000000000..9f326372bb --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_geolocation.html @@ -0,0 +1,86 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +<script> +"use strict"; + +add_task(async function test_geolocation_nopermission() { + let GEO_URL = "http://mochi.test:8888/tests/dom/tests/mochitest/geolocation/network_geolocation.sjs"; + await SpecialPowers.pushPrefEnv({"set": [["geo.provider.network.url", GEO_URL]]}); +}); + +add_task(async function test_geolocation() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "geolocation", + ], + }, + background() { + navigator.geolocation.getCurrentPosition(() => { + browser.test.notifyPass("success geolocation call"); + }, (error) => { + browser.test.notifyFail(`geolocation call ${error}`); + }); + }, + }); + + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task(async function test_geolocation_nopermission() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + navigator.geolocation.getCurrentPosition(() => { + browser.test.notifyFail("success geolocation call"); + }, (error) => { + browser.test.notifyPass(`geolocation call ${error}`); + }); + }, + }); + + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task(async function test_geolocation_prompt() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.tabs.create({url: "tab.html"}); + }, + files: { + "tab.html": `<html><head> + <meta charset="utf-8"> + <script src="tab.js"><\/script> + </head></html>`, + "tab.js": () => { + navigator.geolocation.getCurrentPosition(() => { + browser.test.notifyPass("success geolocation call"); + }, (error) => { + browser.test.notifyFail(`geolocation call ${error}`); + }); + }, + }, + }); + + // Bypass the actual prompt, but the prompt result is to allow access. + await SpecialPowers.pushPrefEnv({"set": [["geo.prompt.testing", true], ["geo.prompt.testing.allow", true]]}); + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); +</script> +</head> +<body> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_identity.html b/toolkit/components/extensions/test/mochitest/test_ext_identity.html new file mode 100644 index 0000000000..7aa590ec22 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_identity.html @@ -0,0 +1,390 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for WebExtension Identity</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.webextensions.identity.redirectDomain", "example.com"], + // Disable the network cache first-party partition during this + // test (TODO: look more closely to how that is affecting the intermittency + // of this test on MacOS, see Bug 1626482). + ["privacy.partition.network_state", false], + ], + }); +}); + +add_task(async function test_noPermission() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.assertEq( + undefined, + browser.identity, + "No identity api without permission" + ); + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function test_getRedirectURL() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "identity@mozilla.org", + }, + }, + permissions: ["identity", "https://example.com/"], + }, + async background() { + let redirect_base = + "https://35b64b676900f491c00e7f618d43f7040e88422e.example.com/"; + await browser.test.assertEq( + redirect_base, + browser.identity.getRedirectURL(), + "redirect url ok" + ); + await browser.test.assertEq( + redirect_base, + browser.identity.getRedirectURL(""), + "redirect url ok" + ); + await browser.test.assertEq( + redirect_base + "foobar", + browser.identity.getRedirectURL("foobar"), + "redirect url ok" + ); + await browser.test.assertEq( + redirect_base + "callback", + browser.identity.getRedirectURL("/callback"), + "redirect url ok" + ); + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function test_badAuthURI() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["identity", "https://example.com/"], + }, + async background() { + for (let url of [ + "foobar", + "about:addons", + "about:blank", + "ftp://example.com/test", + ]) { + await browser.test.assertThrows( + () => { + browser.identity.launchWebAuthFlow({ interactive: true, url }); + }, + /Type error for parameter details/, + "details.url is invalid" + ); + } + + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function test_badRequestURI() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["identity", "https://example.com/"], + }, + async background() { + let base_uri = + "https://example.com/tests/toolkit/components/extensions/test/mochitest/"; + let url = `${base_uri}?redirect_uri=badrobot}`; + await browser.test.assertRejects( + browser.identity.launchWebAuthFlow({ interactive: true, url }), + "redirect_uri is invalid", + "invalid redirect url" + ); + url = `${base_uri}?redirect_uri=https://somesite.com`; + await browser.test.assertRejects( + browser.identity.launchWebAuthFlow({ interactive: true, url }), + "redirect_uri not allowed", + "invalid redirect url" + ); + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function background_launchWebAuthFlow_requires_interaction() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["identity", "https://example.com/"], + }, + async background() { + let base_uri = + "https://example.com/tests/toolkit/components/extensions/test/mochitest/"; + let url = `${base_uri}?redirect_uri=${browser.identity.getRedirectURL( + "redirect" + )}`; + await browser.test.assertRejects( + browser.identity.launchWebAuthFlow({ interactive: false, url }), + "Requires user interaction", + "Rejects on required user interaction" + ); + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +function background_launchWebAuthFlow({ + interactive = false, + path = "redirect_auto.sjs", + params = {}, + redirect = true, + useRedirectUri = true, +} = {}) { + let uri_path = useRedirectUri ? "identity_cb" : ""; + let expected_redirect = `https://35b64b676900f491c00e7f618d43f7040e88422e.example.com/${uri_path}`; + let base_uri = + "https://example.com/tests/toolkit/components/extensions/test/mochitest/"; + let redirect_uri = browser.identity.getRedirectURL( + useRedirectUri ? uri_path : undefined + ); + browser.test.assertEq( + expected_redirect, + redirect_uri, + "expected redirect uri matches hash" + ); + let url = `${base_uri}${path}`; + if (useRedirectUri) { + params.redirect_uri = redirect_uri; + } else { + // We kind of fake it with the redirect url that would normally be configured + // in the oauth service. This does still test that the identity service falls back + // to the extensions redirect url. + params.default_redirect = expected_redirect; + } + if (!redirect) { + params.no_redirect = 1; + } + let query = []; + for (let [param, value] of Object.entries(params)) { + query.push(`${param}=${encodeURIComponent(value)}`); + } + url = `${url}?${query.join("&")}`; + + // Ensure we do not start the actual request for the redirect url. In the case + // of a 303 POST redirect we are getting a request started. + let watchRedirectRequest = () => {}; + if (params.post !== 303) { + watchRedirectRequest = details => { + if (details.url.startsWith(expected_redirect)) { + browser.test.fail(`onBeforeRequest called for redirect url: ${JSON.stringify(details)}`); + } + }; + + browser.webRequest.onBeforeRequest.addListener( + watchRedirectRequest, + { + urls: [ + "https://35b64b676900f491c00e7f618d43f7040e88422e.example.com/*", + ], + } + ); + } + + browser.identity + .launchWebAuthFlow({ interactive, url }) + .then(redirectURL => { + browser.test.assertTrue( + redirectURL.startsWith(redirect_uri), + `correct redirect url ${redirectURL}` + ); + if (redirect) { + let url = new URL(redirectURL); + browser.test.assertEq( + "here ya go", + url.searchParams.get("access_token"), + "Handled auto redirection" + ); + } + }) + .catch(error => { + if (redirect) { + browser.test.fail(error.message); + } else { + browser.test.assertEq( + "Requires user interaction", + error.message, + "Auth page loaded, interaction required." + ); + } + }).then(() => { + browser.webRequest.onBeforeRequest.removeListener(watchRedirectRequest); + browser.test.sendMessage("done"); + }); +} + +// Tests the situation where the oauth provider has already granted access and +// simply redirects the oauth client to provide the access key or code. +add_task(async function test_autoRedirect() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "identity@mozilla.org", + }, + }, + permissions: ["webRequest", "identity", "https://*.example.com/*"], + }, + background: `(${background_launchWebAuthFlow})()`, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function test_autoRedirect_noRedirectURI() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "identity@mozilla.org", + }, + }, + permissions: ["webRequest", "identity", "https://*.example.com/*"], + }, + background: `(${background_launchWebAuthFlow})({useRedirectUri: false})`, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +// Tests the situation where the oauth provider has not granted access and interactive=false +add_task(async function test_noRedirect() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "identity@mozilla.org", + }, + }, + permissions: ["webRequest", "identity", "https://*.example.com/*"], + }, + background: `(${background_launchWebAuthFlow})({redirect: false})`, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +// Tests the situation where the oauth provider must show a window where +// presumably the user interacts, then the redirect occurs and access key or +// code is provided. We bypass any real interaction, but want the window to +// open and result in a redirect. +add_task(async function test_interaction() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "identity@mozilla.org", + }, + }, + permissions: ["webRequest", "identity", "https://*.example.com/*"], + }, + background: `(${background_launchWebAuthFlow})({interactive: true, path: "oauth.html"})`, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +// Tests the situation where the oauth provider redirects with a 303. +add_task(async function test_auto303Redirect() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "identity@mozilla.org", + }, + }, + permissions: ["webRequest", "identity", "https://*.example.com/*"], + }, + background: `(${background_launchWebAuthFlow})({interactive: true, path: "oauth.html", params: {post: 303, server_uri: "redirect_auto.sjs"}})`, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function test_loopbackRedirectURI() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "identity@mozilla.org", + }, + }, + permissions: ["identity"], + }, + async background() { + let redirectURL = "http://127.0.0.1/mozoauth2/35b64b676900f491c00e7f618d43f7040e88422e"; + let actualRedirect = await browser.identity.launchWebAuthFlow({ + interactive: true, + url: `https://example.com/tests/toolkit/components/extensions/test/mochitest/oauth.html?redirect_uri=${encodeURIComponent(redirectURL)}` + }).catch(error => { + browser.test.fail(error.message) + }); + browser.test.assertTrue( + actualRedirect.startsWith(redirectURL), + "Expected redirect url to be loopback address" + ) + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_idle.html b/toolkit/components/extensions/test/mochitest/test_ext_idle.html new file mode 100644 index 0000000000..381687ee38 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_idle.html @@ -0,0 +1,68 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function testWithRealIdleService() { + function background() { + browser.test.onMessage.addListener(async (msg, ...args) => { + let detectionInterval = args[0]; + if (msg == "addListener") { + let status = await browser.idle.queryState(detectionInterval); + browser.test.assertEq("active", status, "Idle status is active"); + browser.idle.setDetectionInterval(detectionInterval); + browser.idle.onStateChanged.addListener(newState => { + browser.test.assertEq("idle", newState, "listener fired with the expected state"); + browser.test.sendMessage("listenerFired"); + }); + browser.test.sendMessage("listenerAdded"); + } else if (msg == "checkState") { + let status = await browser.idle.queryState(detectionInterval); + browser.test.assertEq("idle", status, "Idle status is idle"); + browser.test.notifyPass("idle"); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + await extension.startup(); + + let chromeScript = loadChromeScript(() => { + const {sendAsyncMessage} = this; + const idleService = Cc["@mozilla.org/widget/useridleservice;1"].getService(Ci.nsIUserIdleService); + let idleTime = idleService.idleTime; + sendAsyncMessage("detectionInterval", Math.max(Math.ceil(idleTime / 1000) + 10, 15)); + }); + let detectionInterval = await chromeScript.promiseOneMessage("detectionInterval"); + chromeScript.destroy(); + + info(`Setting interval to ${detectionInterval}`); + extension.sendMessage("addListener", detectionInterval); + await extension.awaitMessage("listenerAdded"); + info("Listener added"); + await extension.awaitMessage("listenerFired"); + info("Listener fired"); + extension.sendMessage("checkState", detectionInterval); + await extension.awaitFinish("idle"); + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_inIncognitoContext_window.html b/toolkit/components/extensions/test/mochitest/test_ext_inIncognitoContext_window.html new file mode 100644 index 0000000000..5b36902581 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_inIncognitoContext_window.html @@ -0,0 +1,49 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_in_incognito_context_true() { + function background() { + browser.runtime.onMessage.addListener(msg => { + browser.test.assertEq(true, msg, "inIncognitoContext is true"); + browser.test.notifyPass("inIncognitoContext"); + }); + + browser.windows.create({url: browser.runtime.getURL("/tab.html"), incognito: true}); + } + + function tabScript() { + browser.runtime.sendMessage(browser.extension.inIncognitoContext); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + files: { + "tab.js": tabScript, + "tab.html": `<!DOCTYPE html><html><head> + <meta charset="utf-8"> + <script src="tab.js"><\/script> + </head></html>`, + }, + incognitoOverride: "spanning", + }); + + await extension.startup(); + await extension.awaitFinish("inIncognitoContext"); + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_listener_proxies.html b/toolkit/components/extensions/test/mochitest/test_ext_listener_proxies.html new file mode 100644 index 0000000000..cc161f735f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_listener_proxies.html @@ -0,0 +1,62 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for content script</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_listener_proxies() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + + manifest: { + "permissions": ["storage"], + }, + + async background() { + // Test that adding multiple listeners for the same event works as + // expected. + + let awaitChanged = () => new Promise(resolve => { + browser.storage.onChanged.addListener(function listener() { + browser.storage.onChanged.removeListener(listener); + resolve(); + }); + }); + + let promises = [ + awaitChanged(), + awaitChanged(), + ]; + + function removedListener() {} + browser.storage.onChanged.addListener(removedListener); + browser.storage.onChanged.removeListener(removedListener); + + promises.push(awaitChanged(), awaitChanged()); + + browser.storage.local.set({foo: "bar"}); + + await Promise.all(promises); + + browser.test.notifyPass("onchanged-listeners"); + }, + }); + + await extension.startup(); + + await extension.awaitFinish("onchanged-listeners"); + + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_new_tab_processType.html b/toolkit/components/extensions/test/mochitest/test_ext_new_tab_processType.html new file mode 100644 index 0000000000..2c5ae1c0ba --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_new_tab_processType.html @@ -0,0 +1,168 @@ +<!DOCTYPE html> +<html> +<head> + <title>Test for opening links in new tabs from extension frames</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +function promiseObserved(topic, check) { + return new Promise(resolve => { + let obs = SpecialPowers.Services.obs; + + function observer(subject, topic, data) { + subject = SpecialPowers.wrap(subject); + if (check(subject, data)) { + obs.removeObserver(observer, topic); + resolve({subject, data}); + } + } + obs.addObserver(observer, topic); + }); +} + +add_task(async function test_target_blank_link_no_opener_from_privileged() { + const linkURL = "https://example.com/"; + + function extension_tab() { + document.getElementById("link").click(); + } + + function content_script() { + browser.runtime.sendMessage("content_page_loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [{ + js: ["content_script.js"], + matches: ["https://example.com/*"], + run_at: "document_idle", + }], + permissions: ["tabs"], + }, + files: { + "page.html": `<!DOCTYPE html> + <html> + <head><meta charset="utf-8"></html> + <body> + <a href="${linkURL}" target="_blank" id="link">link</a> + <script src="extension_tab.js"><\/script> + </body> + </html>`, + "extension_tab.js": extension_tab, + "content_script.js": content_script, + }, + async background() { + let pageTab; + browser.test.onMessage.addListener(async (msg) => { + if (msg !== "close_tab") { + browser.test.fail("Unexpected test message: " + msg); + return; + } + if (!pageTab) { + browser.test.fail("Unexpected close-tab test message received when there is no pageTab"); + return; + } + await browser.tabs.remove(pageTab.id); + browser.test.sendMessage("close_tab_done"); + }); + browser.runtime.onMessage.addListener(async (msg, sender) => { + if (sender.tab) { + await browser.tabs.remove(sender.tab.id); + browser.test.sendMessage(msg, sender.tab.url); + } + }); + pageTab = await browser.tabs.create({ url: browser.runtime.getURL("page.html") }); + browser.test.sendMessage("tab_created"); + }, + }); + + await extension.startup(); + + await extension.awaitMessage("tab_created"); + + // Make sure page is loaded correctly + const url = await extension.awaitMessage("content_page_loaded"); + is(url, linkURL, "Page URL should match"); + + // Clean up opened tab. + extension.sendMessage("close_tab"); + await extension.awaitMessage("close_tab_done"); + + await extension.unload(); +}); + +add_task(async function test_target_blank_link() { + const linkURL = "http://mochi.test:8888/tests/toolkit/"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_security_policy: "script-src 'self' 'unsafe-eval'; object-src 'self';", + + web_accessible_resources: ["iframe.html"], + }, + files: { + "iframe.html": `<!DOCTYPE html> + <html> + <head><meta charset="utf-8"></html> + <body> + <a href="${linkURL}" target="_blank" id="link" rel="opener">link</a> + </body> + </html>`, + }, + background() { + browser.test.sendMessage("frame_url", browser.runtime.getURL("iframe.html")); + }, + }); + + await extension.startup(); + + let url = await extension.awaitMessage("frame_url"); + + let iframe = document.createElement("iframe"); + iframe.src = url; + document.body.appendChild(iframe); + await new Promise(resolve => iframe.addEventListener("load", () => setTimeout(resolve, 0), {once: true})); + + let win = SpecialPowers.wrap(iframe).contentWindow; + + { + // Flush layout so that synthesizeMouseAtCenter on a cross-origin iframe + // works as expected. + document.body.getBoundingClientRect(); + + let promise = promiseObserved("document-element-inserted", doc => doc.documentURI === linkURL); + + await SpecialPowers.spawn(iframe, [], async () => { + this.content.document.getElementById("link").click(); + }); + + let {subject: doc} = await promise; + info("Link opened"); + doc.defaultView.close(); + info("Window closed"); + } + + { + let promise = promiseObserved("document-element-inserted", doc => doc.documentURI === linkURL); + + let res = win.eval(`window.open("${linkURL}")`); + let {subject: doc} = await promise; + is(SpecialPowers.unwrap(res), SpecialPowers.unwrap(doc.defaultView), "window.open worked as expected"); + + doc.defaultView.close(); + } + + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_notifications.html b/toolkit/components/extensions/test/mochitest/test_ext_notifications.html new file mode 100644 index 0000000000..7a91320373 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_notifications.html @@ -0,0 +1,340 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for notifications</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head_notifications.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +// A 1x1 PNG image. +// Source: https://commons.wikimedia.org/wiki/File:1x1.png (Public Domain) +let image = atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" + + "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII="); +const IMAGE_ARRAYBUFFER = Uint8Array.from(image, byte => byte.charCodeAt(0)).buffer; + +add_task(async function setup_mock_alert_service() { + await MockAlertsService.register(); +}); + +add_task(async function test_notification() { + async function background() { + let opts = { + type: "basic", + title: "Testing Notification", + message: "Carry on", + }; + + let id = await browser.notifications.create(opts); + + browser.test.sendMessage("running", id); + browser.test.notifyPass("background test passed"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["notifications"], + }, + background, + }); + await extension.startup(); + let x = await extension.awaitMessage("running"); + is(x, "0", "got correct id from notifications.create"); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task(async function test_notification_events() { + async function background() { + let opts = { + type: "basic", + title: "Testing Notification", + message: "Carry on", + }; + + let createdId = "98"; + + // Test an ignored listener. + browser.notifications.onButtonClicked.addListener(function() {}); + + // We cannot test onClicked listener without a mock + // but we can attempt to add a listener. + browser.notifications.onClicked.addListener(async function(id) { + browser.test.assertEq(createdId, id, "onClicked has the expected ID"); + browser.test.sendMessage("notification-event", "clicked"); + }); + + browser.notifications.onShown.addListener(async function listener(id) { + browser.test.assertEq(createdId, id, "onShown has the expected ID"); + browser.test.sendMessage("notification-event", "shown"); + }); + + browser.test.onMessage.addListener(async function(msg, expectedCount) { + if (msg === "create-again") { + let newId = await browser.notifications.create(createdId, opts); + browser.test.assertEq(createdId, newId, "create returned the expected id."); + browser.test.sendMessage("notification-created-twice"); + } else if (msg === "check-count") { + let notifications = await browser.notifications.getAll(); + let ids = Object.keys(notifications); + browser.test.assertEq(expectedCount, ids.length, `getAll() = ${ids}`); + browser.test.sendMessage("check-count-result"); + } + }); + + // Test onClosed listener. + browser.notifications.onClosed.addListener(function listener(id) { + browser.test.assertEq(createdId, id, "onClosed received the expected id."); + browser.test.sendMessage("notification-event", "closed"); + }); + + await browser.notifications.create(createdId, opts); + + browser.test.sendMessage("notification-created-once"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["notifications"], + }, + background, + }); + + await extension.startup(); + + async function waitForNotificationEvent(name) { + info(`Waiting for notification event: ${name}`); + is(name, await extension.awaitMessage("notification-event"), + "Expected notification event"); + } + async function checkNotificationCount(expectedCount) { + extension.sendMessage("check-count", expectedCount); + await extension.awaitMessage("check-count-result"); + } + + await extension.awaitMessage("notification-created-once"); + await waitForNotificationEvent("shown"); + await checkNotificationCount(1); + + // On most platforms, clicking the notification closes it. + // But on macOS, the notification can repeatedly be clicked without closing. + await MockAlertsService.clickNotificationsWithoutClose(); + await waitForNotificationEvent("clicked"); + await checkNotificationCount(1); + await MockAlertsService.clickNotificationsWithoutClose(); + await waitForNotificationEvent("clicked"); + await checkNotificationCount(1); + await MockAlertsService.clickNotifications(); + await waitForNotificationEvent("clicked"); + await waitForNotificationEvent("closed"); + await checkNotificationCount(0); + + extension.sendMessage("create-again"); + await extension.awaitMessage("notification-created-twice"); + await waitForNotificationEvent("shown"); + await checkNotificationCount(1); + + await MockAlertsService.closeNotifications(); + await waitForNotificationEvent("closed"); + await checkNotificationCount(0); + + await extension.unload(); +}); + +add_task(async function test_notification_clear() { + function background() { + let opts = { + type: "basic", + title: "Testing Notification", + message: "Carry on", + }; + + let createdId = "99"; + + browser.notifications.onShown.addListener(async id => { + browser.test.assertEq(createdId, id, "onShown received the expected id."); + let wasCleared = await browser.notifications.clear(id); + browser.test.assertTrue(wasCleared, "notifications.clear returned true."); + }); + + browser.notifications.onClosed.addListener(id => { + browser.test.assertEq(createdId, id, "onClosed received the expected id."); + browser.test.notifyPass("background test passed"); + }); + + browser.notifications.create(createdId, opts); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["notifications"], + }, + background, + }); + + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task(async function test_notifications_empty_getAll() { + async function background() { + let notifications = await browser.notifications.getAll(); + + browser.test.assertEq("object", typeof notifications, "getAll() returned an object"); + browser.test.assertEq(0, Object.keys(notifications).length, "the object has no properties"); + browser.test.notifyPass("getAll empty"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["notifications"], + }, + background, + }); + await extension.startup(); + await extension.awaitFinish("getAll empty"); + await extension.unload(); +}); + +add_task(async function test_notifications_populated_getAll() { + async function background() { + let opts = { + type: "basic", + iconUrl: "a.png", + title: "Testing Notification", + message: "Carry on", + }; + + await browser.notifications.create("p1", opts); + await browser.notifications.create("p2", opts); + let notifications = await browser.notifications.getAll(); + + browser.test.assertEq("object", typeof notifications, "getAll() returned an object"); + browser.test.assertEq(2, Object.keys(notifications).length, "the object has 2 properties"); + + for (let notificationId of ["p1", "p2"]) { + for (let key of Object.keys(opts)) { + browser.test.assertEq( + opts[key], + notifications[notificationId][key], + `the notification has the expected value for option: ${key}` + ); + } + } + + browser.test.notifyPass("getAll populated"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["notifications"], + }, + background, + files: { + "a.png": IMAGE_ARRAYBUFFER, + }, + }); + await extension.startup(); + await extension.awaitFinish("getAll populated"); + await extension.unload(); +}); + +add_task(async function test_buttons_unsupported() { + function background() { + let opts = { + type: "basic", + title: "Testing Notification", + message: "Carry on", + buttons: [{title: "Button title"}], + }; + + let exception = {}; + try { + browser.notifications.create(opts); + } catch (e) { + exception = e; + } + + browser.test.assertTrue( + String(exception).includes('Property "buttons" is unsupported by Firefox'), + "notifications.create with buttons option threw an expected exception" + ); + browser.test.notifyPass("buttons-unsupported"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["notifications"], + }, + background, + }); + await extension.startup(); + await extension.awaitFinish("buttons-unsupported"); + await extension.unload(); +}); + +add_task(async function test_notifications_different_contexts() { + async function background() { + let opts = { + type: "basic", + title: "Testing Notification", + message: "Carry on", + }; + + let id = await browser.notifications.create(opts); + + browser.runtime.onMessage.addListener(async (message, sender) => { + await browser.tabs.remove(sender.tab.id); + + // We should be able to clear the notification after creating and + // destroying the tab.html page. + let wasCleared = await browser.notifications.clear(id); + browser.test.assertTrue(wasCleared, "The notification was cleared."); + browser.test.notifyPass("notifications"); + }); + + browser.tabs.create({url: browser.runtime.getURL("/tab.html")}); + } + + async function tabScript() { + // We should be able to see the notification created in the background page + // in this page. + let notifications = await browser.notifications.getAll(); + browser.test.assertEq(1, Object.keys(notifications).length, + "One notification found."); + browser.runtime.sendMessage("continue-test"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["notifications"], + }, + background, + files: { + "tab.js": tabScript, + "tab.html": `<!DOCTYPE html><html><head> + <meta charset="utf-8"> + <script src="tab.js"><\/script> + </head></html>`, + }, + }); + + await extension.startup(); + await extension.awaitFinish("notifications"); + await extension.unload(); +}); + +add_task(async function teardown_mock_alert_service() { + await MockAlertsService.unregister(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_optional_permissions.html b/toolkit/components/extensions/test/mochitest/test_ext_optional_permissions.html new file mode 100644 index 0000000000..659a55f5c9 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_optional_permissions.html @@ -0,0 +1,98 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>optional permissions and preloaded processes</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.webextOptionalPermissionPrompts", false]], + }); +}); + +// This test case verifies that newly granted optional permissions are +// propagated to all processes, especially preloaded processes. +add_task(async function test_optional_permissions_should_be_propagated() { + let anOptionalPermission = "*://example.org/*"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "scripting", + "*://example.com/*", + ], + optional_permissions: [anOptionalPermission], + }, + async background() { + browser.test.onMessage.addListener(async (msg, value) => { + browser.test.assertEq("grant-permission", msg, "expected message"); + + let granted = await new Promise(resolve => { + browser.test.withHandlingUserInput(() => { + resolve(browser.permissions.request(value)); + }); + }); + browser.test.assertTrue(granted, "permission request succeeded"); + browser.test.sendMessage("permission-granted"); + }); + + await browser.scripting.registerContentScripts([ + { + id: "script", + js: ["script.js"], + matches: ["*://example.com/*", "*://example.org/*"], + persistAcrossSessions: false, + }, + ]); + + browser.test.sendMessage("background-ready"); + }, + files: { + "script.js": () => { + browser.test.sendMessage("script-ran", window.location.host); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + let tab = await AppTestDelegate.openNewForegroundTab( + window, + "http://example.com/", + true + ); + let host = await extension.awaitMessage("script-ran"); + is(host, "example.com", "expected host: example.com"); + await AppTestDelegate.removeTab(window, tab); + + extension.sendMessage("grant-permission", { + origins: ["*://example.org/*"], + }); + await extension.awaitMessage("permission-granted"); + + tab = await AppTestDelegate.openNewForegroundTab( + window, + "https://example.org/", + true + ); + host = await extension.awaitMessage("script-ran"); + is(host, "example.org", "expected host: example.org"); + await AppTestDelegate.removeTab(window, tab); + + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_pageAction_onClicked.html b/toolkit/components/extensions/test/mochitest/test_ext_pageAction_onClicked.html new file mode 100644 index 0000000000..5fce66159d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_pageAction_onClicked.html @@ -0,0 +1,111 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>pageAction.onClicked test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +function createExtension(background = {}) { + return ExtensionTestUtils.loadExtension({ + manifest: { + background, + page_action: { show_matches: ["<all_urls>"] }, + }, + async background() { + async function checkIsHandlingUserInput() { + try { + // permissions.request is declared with requireUserInput, + // so it would reject if inputHandling is false. + let granted = await browser.permissions.request({}); + // We haven't requested any permissions, so the API call grants the + // requested permissions without actually prompting the user. + browser.test.assertTrue(granted, "empty permissions granted"); + return true; + } catch (e) { + browser.test.assertEq( + e?.message, + "permissions.request may only be called from a user input handler", + "Expected error when permissions.request rejects" + ); + return false; + } + } + browser.pageAction.onClicked.addListener(async () => { + browser.test.assertTrue( + await checkIsHandlingUserInput(), + "pageAction.onClicked is handling user input" + ); + browser.test.notifyPass("action-clicked"); + }); + + // Sanity check: Verify that pageAction is shown (because it needs to be + // in order to trigger pageAction.onClicked). + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + browser.test.assertTrue( + await browser.pageAction.isShown({ tabId: tab.id }), + "pageAction should be visible (due to page_action.show_matches)" + ); + + browser.test.assertFalse( + await checkIsHandlingUserInput(), + "not handling user input by default" + ); + browser.test.sendMessage("background-ready"); + }, + }); +} + +add_task(async function test_pageAction_onClicked_and_inputHandling() { + const extension = createExtension(); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + await AppTestDelegate.clickPageAction(window, extension); + await extension.awaitFinish("action-clicked"); + await AppTestDelegate.closePageAction(window, extension); + + await extension.unload(); +}); + +add_task(async function test_pageAction_onClicked_persistent_event() { + const extension = createExtension({ persistent: false }); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + assertPersistentListeners(extension, "pageAction", ["onClicked"], { + primed: false, + }); + + await extension.terminateBackground(); + assertPersistentListeners(extension, "pageAction", ["onClicked"], { + primed: true, + }); + + await AppTestDelegate.clickPageAction(window, extension); + + // Background script will run again. + await extension.awaitMessage("background-ready"); + await extension.awaitFinish("action-clicked"); + + await AppTestDelegate.closePageAction(window, extension); + + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_protocolHandlers.html b/toolkit/components/extensions/test/mochitest/test_ext_protocolHandlers.html new file mode 100644 index 0000000000..7032bfe6f1 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_protocolHandlers.html @@ -0,0 +1,612 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test for protocol handlers</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +/* eslint-disable mozilla/balanced-listeners */ + +function protocolChromeScript() { + const PROTOCOL_HANDLER_OPEN_PERM_KEY = "open-protocol-handler"; + const PERMISSION_KEY_DELIMITER = "^"; + + /* eslint-env mozilla/chrome-script */ + addMessageListener("setup", ({ protocol, principalOrigins }) => { + let data = {}; + const protoSvc = Cc[ + "@mozilla.org/uriloader/external-protocol-service;1" + ].getService(Ci.nsIExternalProtocolService); + let protoInfo = protoSvc.getProtocolHandlerInfo(protocol); + data.preferredAction = protoInfo.preferredAction == protoInfo.useHelperApp; + + let handlers = protoInfo.possibleApplicationHandlers; + data.handlers = handlers.length; + + let handler = handlers.queryElementAt(0, Ci.nsIHandlerApp); + data.isWebHandler = handler instanceof Ci.nsIWebHandlerApp; + data.uriTemplate = handler.uriTemplate; + + // ext+ protocols should be set as default when there is only one + data.preferredApplicationHandler = + protoInfo.preferredApplicationHandler == handler; + data.alwaysAskBeforeHandling = protoInfo.alwaysAskBeforeHandling; + const handlerSvc = Cc[ + "@mozilla.org/uriloader/handler-service;1" + ].getService(Ci.nsIHandlerService); + handlerSvc.store(protoInfo); + + for (let origin of principalOrigins) { + let principal = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(origin), + {} + ); + let pbPrincipal = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(origin), + { + privateBrowsingId: 1, + } + ); + let permKey = + PROTOCOL_HANDLER_OPEN_PERM_KEY + PERMISSION_KEY_DELIMITER + protocol; + Services.perms.addFromPrincipal( + principal, + permKey, + Services.perms.ALLOW_ACTION, + Services.perms.EXPIRE_NEVER + ); + Services.perms.addFromPrincipal( + pbPrincipal, + permKey, + Services.perms.ALLOW_ACTION, + Services.perms.EXPIRE_NEVER + ); + } + + sendAsyncMessage("handlerData", data); + }); + addMessageListener("setPreferredAction", data => { + let { protocol, template } = data; + const protoSvc = Cc[ + "@mozilla.org/uriloader/external-protocol-service;1" + ].getService(Ci.nsIExternalProtocolService); + let protoInfo = protoSvc.getProtocolHandlerInfo(protocol); + + for (let handler of protoInfo.possibleApplicationHandlers.enumerate()) { + if (handler.uriTemplate.startsWith(template)) { + protoInfo.preferredApplicationHandler = handler; + protoInfo.preferredAction = protoInfo.useHelperApp; + protoInfo.alwaysAskBeforeHandling = false; + } + } + const handlerSvc = Cc[ + "@mozilla.org/uriloader/handler-service;1" + ].getService(Ci.nsIHandlerService); + handlerSvc.store(protoInfo); + sendAsyncMessage("set"); + }); +} + +add_task(async function test_protocolHandler() { + let extensionData = { + manifest: { + protocol_handlers: [ + { + protocol: "ext+foo", + name: "a foo protocol handler", + uriTemplate: "foo.html?val=%s", + }, + ], + }, + + background() { + browser.test.onMessage.addListener(async (msg, arg) => { + if (msg == "open") { + let tab = await browser.tabs.create({ url: arg }); + browser.test.sendMessage("opened", tab.id); + } else if (msg == "close") { + await browser.tabs.remove(arg); + browser.test.sendMessage("closed"); + } + }); + browser.test.sendMessage("test-url", browser.runtime.getURL("foo.html")); + }, + + files: { + "foo.js": function() { + browser.test.sendMessage("test-query", location.search); + browser.tabs.getCurrent().then(tab => browser.test.sendMessage("test-tab", tab.id)); + }, + "foo.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="foo.js"><\/script> + </head> + </html>`, + }, + }; + + let pb_extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.onMessage.addListener(async (msg, arg) => { + if (msg == "open") { + let win = await browser.windows.create({ url: arg, incognito: true }); + browser.test.sendMessage("opened", { + windowId: win.id, + tabId: win.tabs[0].id, + }); + } else if (msg == "nav") { + await browser.tabs.update(arg.tabId, { url: arg.url }); + browser.test.sendMessage("navigated"); + } else if (msg == "close") { + await browser.windows.remove(arg); + browser.test.sendMessage("closed"); + } + }); + }, + incognitoOverride: "spanning", + }); + await pb_extension.startup(); + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + let handlerUrl = await extension.awaitMessage("test-url"); + + // Ensure that the protocol handler is configured, and set it as default to + // bypass the dialog. + let chromeScript = SpecialPowers.loadChromeScript(protocolChromeScript); + + let msg = chromeScript.promiseOneMessage("handlerData"); + chromeScript.sendAsyncMessage("setup", { + protocol: "ext+foo", + principalOrigins: [ + `moz-extension://${extension.uuid}/`, + `moz-extension://${pb_extension.uuid}/`, + ], + }); + let data = await msg; + ok( + data.preferredAction, + "using a helper application is the preferred action" + ); + ok(data.preferredApplicationHandler, "handler was set as default handler"); + is(data.handlers, 1, "one handler is set"); + ok(!data.alwaysAskBeforeHandling, "will not show dialog"); + ok(data.isWebHandler, "the handler is a web handler"); + is(data.uriTemplate, `${handlerUrl}?val=%s`, "correct url template"); + chromeScript.destroy(); + + extension.sendMessage("open", "ext+foo:test"); + let id = await extension.awaitMessage("opened"); + + let query = await extension.awaitMessage("test-query"); + is(query, "?val=ext%2Bfoo%3Atest", "test query ok"); + is(id, await extension.awaitMessage("test-tab"), "id should match opened tab"); + + extension.sendMessage("close", id); + await extension.awaitMessage("closed"); + + // Test that handling a URL from the commandline works. + chromeScript = SpecialPowers.loadChromeScript(() => { + /* eslint-env mozilla/chrome-script */ + const CONTENT_HANDLING_URL = + "chrome://mozapps/content/handling/permissionDialog.xhtml"; + const { BrowserTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/BrowserTestUtils.sys.mjs" + ); + let cmdLineHandler = Cc["@mozilla.org/browser/final-clh;1"].getService( + Ci.nsICommandLineHandler + ); + let fakeCmdLine = Cu.createCommandLine( + ["-url", "ext+foo:cmdline"], + null, + Ci.nsICommandLine.STATE_REMOTE_EXPLICIT + ); + cmdLineHandler.handle(fakeCmdLine); + + // We aren't awaiting for this promise to resolve since it returns undefined + // (because it returns the reference to the dialog window that we close, below) + // once the callback promise below finishes, and because its not needed for anything + // outside of this loadChromeScript block. + BrowserTestUtils.promiseAlertDialogOpen( + null, + CONTENT_HANDLING_URL, + { + isSubDialog: true, + async callback(dialogWin) { + is(dialogWin.document.documentURI, CONTENT_HANDLING_URL, "Open dialog is the permission dialog") + + let closePromise = BrowserTestUtils.waitForEvent( + dialogWin.browsingContext.topChromeWindow, + "dialogclose", + true, + ); + let dialog = dialogWin.document.querySelector("dialog"); + let btn = dialog.getButton("accept"); + // The security delay disables this button, just bypass it. + btn.disabled = false; + btn.click(); + return closePromise; + } + } + ); + }); + query = await extension.awaitMessage("test-query"); + is(query, "?val=ext%2Bfoo%3Acmdline", "cmdline query ok"); + id = await extension.awaitMessage("test-tab"); + extension.sendMessage("close", id); + await extension.awaitMessage("closed"); + chromeScript.destroy(); + + // Test the protocol in a private window, watch for the + // console error. + consoleMonitor.start([{ message: /NS_ERROR_FILE_NOT_FOUND/ }]); + + // Expect the chooser window to be open, close it. + chromeScript = SpecialPowers.loadChromeScript(() => { + /* eslint-env mozilla/chrome-script */ + const CONTENT_HANDLING_URL = + "chrome://mozapps/content/handling/appChooser.xhtml"; + const { BrowserTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/BrowserTestUtils.sys.mjs" + ); + + sendAsyncMessage("listenWindow"); + + // We aren't awaiting for this promise to resolve since it returns undefined + // (because it returns the reference to the dialog window that we close, below) + // once the callback promise below finishes, and because its not needed for anything + // outside of this loadChromeScript block. + BrowserTestUtils.promiseAlertDialogOpen( + null, + CONTENT_HANDLING_URL, + { + isSubDialog: true, + async callback(dialogWin) { + is(dialogWin.document.documentURI, CONTENT_HANDLING_URL, "Open dialog is the app chooser dialog") + + let entry = dialogWin.document.getElementById("items") + .firstChild; + sendAsyncMessage("handling", { + name: entry.getAttribute("name"), + disabled: entry.disabled, + }); + + let closePromise = BrowserTestUtils.waitForEvent( + dialogWin.browsingContext.topChromeWindow, + "dialogclose", + true, + ); + dialogWin.close(); + return closePromise; + } + } + ); + + sendAsyncMessage("listenDialog"); + }); + + // Wait for the chrome script to attach window listener + await chromeScript.promiseOneMessage("listenWindow"); + + let listenDialog = chromeScript.promiseOneMessage("listenDialog"); + let windowOpen = pb_extension.awaitMessage("opened"); + + pb_extension.sendMessage("open", "ext+foo:test"); + + // Wait for chrome script to attach dialog listener + await listenDialog; + let { tabId, windowId } = await windowOpen; + + let testData = chromeScript.promiseOneMessage("handling"); + let navPromise = pb_extension.awaitMessage("navigated"); + pb_extension.sendMessage("nav", { url: "ext+foo:test", tabId }); + await navPromise; + await consoleMonitor.finished(); + let entry = await testData; + + is(entry.name, "a foo protocol handler", "entry is correct"); + ok(entry.disabled, "handler is disabled"); + + let promiseClosed = pb_extension.awaitMessage("closed"); + pb_extension.sendMessage("close", windowId); + await promiseClosed; + await pb_extension.unload(); + + // Shutdown the addon, then ensure the protocol was removed. + await extension.unload(); + chromeScript = SpecialPowers.loadChromeScript(() => { + /* eslint-env mozilla/chrome-script */ + addMessageListener("setup", () => { + const protoSvc = Cc[ + "@mozilla.org/uriloader/external-protocol-service;1" + ].getService(Ci.nsIExternalProtocolService); + let protoInfo = protoSvc.getProtocolHandlerInfo("ext+foo"); + sendAsyncMessage( + "preferredApplicationHandler", + !protoInfo.preferredApplicationHandler + ); + let handlers = protoInfo.possibleApplicationHandlers; + + sendAsyncMessage("handlerData", { + preferredApplicationHandler: !protoInfo.preferredApplicationHandler, + handlers: handlers.length, + }); + }); + }); + + msg = chromeScript.promiseOneMessage("handlerData"); + chromeScript.sendAsyncMessage("setup"); + data = await msg; + ok(data.preferredApplicationHandler, "no preferred handler is set"); + is(data.handlers, 0, "no handler is set"); + chromeScript.destroy(); +}); + +add_task(async function test_protocolHandler_two() { + let extensionData = { + manifest: { + protocol_handlers: [ + { + protocol: "ext+foo", + name: "a foo protocol handler", + uriTemplate: "foo.html?val=%s", + }, + { + protocol: "ext+foo", + name: "another foo protocol handler", + uriTemplate: "foo2.html?val=%s", + }, + ], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + // Ensure that the protocol handler is configured, and set it as default, + // but because there are two handlers, the dialog is not bypassed. We + // don't test the actual dialog ui, it's been here forever and works based + // on the alwaysAskBeforeHandling value. + let chromeScript = SpecialPowers.loadChromeScript(protocolChromeScript); + + let msg = chromeScript.promiseOneMessage("handlerData"); + chromeScript.sendAsyncMessage("setup", { + protocol: "ext+foo", + principalOrigins: [], + }); + let data = await msg; + ok( + data.preferredAction, + "using a helper application is the preferred action" + ); + ok(data.preferredApplicationHandler, "preferred handler is set"); + is(data.handlers, 2, "two handlers are set"); + ok(data.alwaysAskBeforeHandling, "will show dialog"); + ok(data.isWebHandler, "the handler is a web handler"); + chromeScript.destroy(); + await extension.unload(); +}); + +add_task(async function test_protocolHandler_https_target() { + let extensionData = { + manifest: { + protocol_handlers: [ + { + protocol: "ext+foo", + name: "http target", + uriTemplate: "https://example.com/foo.html?val=%s", + }, + ], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + ok(true, "https uriTemplate target works"); + await extension.unload(); +}); + +add_task(async function test_protocolHandler_http_target() { + let extensionData = { + manifest: { + protocol_handlers: [ + { + protocol: "ext+foo", + name: "http target", + uriTemplate: "http://example.com/foo.html?val=%s", + }, + ], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + ok(true, "http uriTemplate target works"); + await extension.unload(); +}); + +add_task(async function test_protocolHandler_restricted_protocol() { + let extensionData = { + manifest: { + protocol_handlers: [ + { + protocol: "http", + name: "take over the http protocol", + uriTemplate: "http.html?val=%s", + }, + ], + }, + }; + + consoleMonitor.start([ + { message: /processing protocol_handlers\.0\.protocol/ }, + ]); + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await Assert.rejects( + extension.startup(), + /startup failed/, + "unable to register restricted handler protocol" + ); + + await consoleMonitor.finished(); +}); + +add_task(async function test_protocolHandler_restricted_uriTemplate() { + let extensionData = { + manifest: { + protocol_handlers: [ + { + protocol: "ext+foo", + name: "take over the http protocol", + uriTemplate: "ftp://example.com/file.txt", + }, + ], + }, + }; + + consoleMonitor.start([ + { message: /processing protocol_handlers\.0\.uriTemplate/ }, + ]); + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await Assert.rejects( + extension.startup(), + /startup failed/, + "unable to register restricted handler uriTemplate" + ); + + await consoleMonitor.finished(); +}); + +add_task(async function test_protocolHandler_duplicate() { + let extensionData = { + manifest: { + protocol_handlers: [ + { + protocol: "ext+foo", + name: "foo protocol", + uriTemplate: "foo.html?val=%s", + }, + { + protocol: "ext+foo", + name: "foo protocol", + uriTemplate: "foo.html?val=%s", + }, + ], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + // Get the count of handlers installed. + let chromeScript = SpecialPowers.loadChromeScript(() => { + /* eslint-env mozilla/chrome-script */ + addMessageListener("setup", () => { + const protoSvc = Cc[ + "@mozilla.org/uriloader/external-protocol-service;1" + ].getService(Ci.nsIExternalProtocolService); + let protoInfo = protoSvc.getProtocolHandlerInfo("ext+foo"); + let handlers = protoInfo.possibleApplicationHandlers; + sendAsyncMessage("handlerData", handlers.length); + }); + }); + + let msg = chromeScript.promiseOneMessage("handlerData"); + chromeScript.sendAsyncMessage("setup"); + let data = await msg; + is(data, 1, "cannot re-register the same handler config"); + chromeScript.destroy(); + await extension.unload(); +}); + +// Test that a protocol handler will work if ftp is enabled +add_task(async function test_ftp_protocolHandler() { + await SpecialPowers.pushPrefEnv({ + set: [ + // Disabling the external protocol permission prompt. We don't need it + // for this test. + ["security.external_protocol_requires_permission", false], + ], + }); + let extensionData = { + manifest: { + protocol_handlers: [ + { + protocol: "ftp", + name: "an ftp protocol handler", + uriTemplate: "ftp.html?val=%s", + }, + ], + }, + + async background() { + let url = "ftp://example.com/file.txt"; + browser.test.onMessage.addListener(async () => { + await browser.tabs.create({ url }); + }); + }, + + files: { + "ftp.js": function() { + browser.test.sendMessage("test-query", location.search); + }, + "ftp.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="ftp.js"><\/script> + </head> + </html>`, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + const handlerUrl = `moz-extension://${extension.uuid}/ftp.html`; + + let chromeScript = SpecialPowers.loadChromeScript(protocolChromeScript); + + // Set the preferredAction to this extension as ftp will default to system. If + // we didn't bypass the dialog for this test, the user would get asked in this case. + let msg = chromeScript.promiseOneMessage("set"); + chromeScript.sendAsyncMessage("setPreferredAction", { + protocol: "ftp", + template: handlerUrl, + }); + await msg; + + msg = chromeScript.promiseOneMessage("handlerData"); + chromeScript.sendAsyncMessage("setup", { protocol: "ftp", principalOrigins: [] }); + let data = await msg; + ok( + data.preferredAction, + "using a helper application is the preferred action" + ); + ok(data.preferredApplicationHandler, "handler was set as default handler"); + is(data.handlers, 1, "one handler is set"); + ok(!data.alwaysAskBeforeHandling, "will not show dialog"); + ok(data.isWebHandler, "the handler is a web handler"); + is(data.uriTemplate, `${handlerUrl}?val=%s`, "correct url template"); + + chromeScript.destroy(); + + extension.sendMessage("run"); + let query = await extension.awaitMessage("test-query"); + is(query, "?val=ftp%3A%2F%2Fexample.com%2Ffile.txt", "test query ok"); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_redirect_jar.html b/toolkit/components/extensions/test/mochitest/test_ext_redirect_jar.html new file mode 100644 index 0000000000..18ff14a6de --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_redirect_jar.html @@ -0,0 +1,92 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script> +"use strict"; + +function getExtension() { + return ExtensionTestUtils.loadExtension({ + manifest: { + "browser_specific_settings": { + "gecko": { + "id": "redirect-to-jar@mochi.test", + }, + }, + "permissions": [ + "webRequest", + "webRequestBlocking", + "<all_urls>", + ], + "web_accessible_resources": [ + "finished.html", + ], + }, + useAddonManager: "temporary", + files: { + "finished.html": ` + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>redirected!</h1> + </body> + </html> + `, + }, + background: async () => { + let redirectUrl = browser.runtime.getURL("finished.html"); + browser.webRequest.onBeforeRequest.addListener(details => { + return {redirectUrl}; + }, {urls: ["*://*/intercept*"]}, ["blocking"]); + + let code = `new Promise(resolve => { + var s = document.createElement('iframe'); + s.src = "/intercept?r=" + Math.random(); + s.onload = async () => { + let url = await window.wrappedJSObject.SpecialPowers.spawn(s, [], () => content.location.href ); + resolve(['loaded', url]); + } + s.onerror = () => resolve(['error']); + document.documentElement.appendChild(s); + });`; + + async function testSubFrameResource(tabId, code) { + let [result] = await browser.tabs.executeScript(tabId, { code }); + return result; + } + + let tab = await browser.tabs.create({url: "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_sample.html"}); + let result = await testSubFrameResource(tab.id, code); + browser.test.assertEq("loaded", result[0], "frame 1 loaded"); + browser.test.assertEq(redirectUrl, result[1], "frame 1 redirected"); + // If jar caching breaks redirects, this next test will fail (See Bug 1390346). + result = await testSubFrameResource(tab.id, code); + browser.test.assertEq("loaded", result[0], "frame 2 loaded"); + browser.test.assertEq(redirectUrl, result[1], "frame 2 redirected"); + await browser.tabs.remove(tab.id); + browser.test.sendMessage("requestsCompleted"); + }, + }); +} + +add_task(async function test_redirect_to_jar() { + let extension = getExtension(); + await extension.startup(); + await extension.awaitMessage("requestsCompleted"); + await extension.unload(); +}); + +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_request_urlClassification.html b/toolkit/components/extensions/test/mochitest/test_ext_request_urlClassification.html new file mode 100644 index 0000000000..a139e94687 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_request_urlClassification.html @@ -0,0 +1,134 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for WebRequest urlClassification</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.trackingprotection.enabled", true]], + }); + + let chromeScript = SpecialPowers.loadChromeScript(async _ => { + /* eslint-env mozilla/chrome-script */ + const {UrlClassifierTestUtils} = ChromeUtils.importESModule( + "resource://testing-common/UrlClassifierTestUtils.sys.mjs" + ); + await UrlClassifierTestUtils.addTestTrackers(); + sendAsyncMessage("trackersLoaded"); + }); + await chromeScript.promiseOneMessage("trackersLoaded"); + chromeScript.destroy(); +}); + +add_task(async function test_urlClassification() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.security.https_first", false]], + }); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "proxy", "<all_urls>"], + }, + background() { + let expected = { + "http://tracking.example.org/": {first: "tracking", thirdParty: false, }, + "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_third_party.html?domain=tracking.example.org": { thirdParty: false, }, + "http://tracking.example.org/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png": {third: "tracking", thirdParty: true, }, + "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_third_party.html?domain=example.net": { thirdParty: false, }, + "http://example.net/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png": { thirdParty: true, }, + }; + function testRequest(details) { + let expect = expected[details.url]; + if (expect) { + if (expect.first) { + browser.test.assertTrue(details.urlClassification.firstParty.includes("tracking"), "tracking firstParty"); + } else { + browser.test.assertEq(details.urlClassification.firstParty.length, 0, "not tracking firstParty"); + } + if (expect.third) { + browser.test.assertTrue(details.urlClassification.thirdParty.includes("tracking"), "tracking thirdParty"); + } else { + browser.test.assertEq(details.urlClassification.thirdParty.length, 0, "not tracking thirdParty"); + } + + browser.test.assertEq(details.thirdParty, expect.thirdParty, "3rd party flag matches"); + return true; + } + return false; + } + + browser.proxy.onRequest.addListener(details => { + browser.test.log(`proxy.onRequest ${JSON.stringify(details)}`); + testRequest(details); + }, {urls: ["http://mochi.test/tests/*", "http://tracking.example.org/*", "http://example.net/*"]}); + browser.webRequest.onBeforeRequest.addListener(async (details) => { + browser.test.log(`webRequest.onBeforeRequest ${JSON.stringify(details)}`); + testRequest(details); + }, {urls: ["http://mochi.test/tests/*", "http://tracking.example.org/*", "http://example.net/*"]}, ["blocking"]); + browser.webRequest.onCompleted.addListener(async (details) => { + browser.test.log(`webRequest.onCompleted ${JSON.stringify(details)}`); + if (testRequest(details)) { + browser.test.sendMessage("classification", details.url); + } + }, {urls: ["http://mochi.test/tests/*", "http://tracking.example.org/*", "http://example.net/*"]}); + }, + }); + await extension.startup(); + + // Test first party tracking classification. + let url = "http://tracking.example.org/"; + let win = window.open(url); + is(await extension.awaitMessage("classification"), url, "request completed"); + win.close(); + + // Test third party tracking classification, expecting two results. + url = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_third_party.html?domain=tracking.example.org"; + win = window.open(url); + is(await extension.awaitMessage("classification"), url); + is(await extension.awaitMessage("classification"), + "http://tracking.example.org/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png", + "request completed"); + win.close(); + + // Test third party tracking classification, expecting two results. + url = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_third_party.html?domain=example.net"; + win = window.open(url); + is(await extension.awaitMessage("classification"), url); + is(await extension.awaitMessage("classification"), + "http://example.net/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png", + "request completed"); + win.close(); + + await extension.unload(); +}); + +add_task(async function teardown() { + let chromeScript = SpecialPowers.loadChromeScript(async _ => { + /* eslint-env mozilla/chrome-script */ + // Cleanup cache + await new Promise(resolve => { + Services.clearData.deleteData(Ci.nsIClearDataService.CLEAR_ALL, value => resolve()); + }); + + const {UrlClassifierTestUtils} = ChromeUtils.importESModule( + "resource://testing-common/UrlClassifierTestUtils.sys.mjs" + ); + await UrlClassifierTestUtils.cleanupTestTrackers(); + sendAsyncMessage("trackersUnloaded"); + }); + await chromeScript.promiseOneMessage("trackersUnloaded"); + chromeScript.destroy(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect.html b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect.html new file mode 100644 index 0000000000..85f98d5034 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect.html @@ -0,0 +1,83 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +function background() { + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq(port.name, "ernie", "port name correct"); + browser.test.assertTrue(port.sender.url.endsWith("file_sample.html"), "URL correct"); + browser.test.assertTrue(port.sender.tab.url.endsWith("file_sample.html"), "tab URL correct"); + browser.test.assertEq(port.sender.frameId, 0, "frameId of top frame"); + + let expected = "message 1"; + port.onMessage.addListener(msg => { + browser.test.assertEq(msg, expected, "message is expected"); + if (expected == "message 1") { + port.postMessage("message 2"); + expected = "message 3"; + } else if (expected == "message 3") { + expected = "disconnect"; + browser.test.notifyPass("runtime.connect"); + } + }); + port.onDisconnect.addListener(() => { + browser.test.assertEq(null, port.error, "No error because port is closed by disconnect() at other end"); + browser.test.assertEq(expected, "disconnect", "got disconnection at right time"); + }); + }); +} + +function contentScript() { + let port = browser.runtime.connect({name: "ernie"}); + port.postMessage("message 1"); + port.onMessage.addListener(msg => { + if (msg == "message 2") { + port.postMessage("message 3"); + port.disconnect(); + } + }); +} + +let extensionData = { + background, + manifest: { + "permissions": ["tabs"], + "content_scripts": [{ + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + "run_at": "document_start", + }], + }, + + files: { + "content_script.js": contentScript, + }, +}; + +add_task(async function test_contentscript() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let win = window.open("file_sample.html"); + + await Promise.all([waitForLoad(win), extension.awaitFinish("runtime.connect")]); + + win.close(); + + await extension.unload(); + info("extension unloaded"); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect2.html b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect2.html new file mode 100644 index 0000000000..13b9029c48 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect2.html @@ -0,0 +1,102 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +function backgroundScript(token) { + browser.runtime.onMessage.addListener(msg => { + browser.test.assertEq(msg, "done"); + browser.test.notifyPass("sendmessage_reply"); + }); + + browser.runtime.onConnect.addListener(port => { + browser.test.assertTrue(port.sender.url.endsWith("file_sample.html"), "sender url correct"); + browser.test.assertTrue(port.sender.tab.url.endsWith("file_sample.html"), "sender url correct"); + + let tabId = port.sender.tab.id; + browser.tabs.connect(tabId, {name: token}); + + browser.test.assertEq(port.name, token, "token matches"); + port.postMessage(token + "-done"); + }); + + browser.test.sendMessage("background-ready"); +} + +function contentScript(token) { + let gotTabMessage = false; + let badTabMessage = false; + browser.runtime.onConnect.addListener(port => { + if (port.name == token) { + gotTabMessage = true; + } else { + badTabMessage = true; + } + port.disconnect(); + }); + + let port = browser.runtime.connect(null, {name: token}); + port.onMessage.addListener(function(msg) { + if (msg != token + "-done" || !gotTabMessage || badTabMessage) { + return; // test failed + } + + // FIXME: Removing this line causes the test to fail: + // resource://gre/modules/ExtensionUtils.jsm, line 651: NS_ERROR_NOT_INITIALIZED + port.disconnect(); + browser.runtime.sendMessage("done"); + }); +} + +function makeExtension() { + let token = Math.random(); + let extensionData = { + background: `(${backgroundScript})("${token}")`, + manifest: { + "permissions": ["tabs"], + "content_scripts": [{ + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + "run_at": "document_idle", + }], + }, + + files: { + "content_script.js": `(${contentScript})("${token}")`, + }, + }; + return extensionData; +} + +add_task(async function test_contentscript() { + let extension1 = ExtensionTestUtils.loadExtension(makeExtension()); + let extension2 = ExtensionTestUtils.loadExtension(makeExtension()); + await Promise.all([extension1.startup(), extension2.startup()]); + + await extension1.awaitMessage("background-ready"); + await extension2.awaitMessage("background-ready"); + + let win = window.open("file_sample.html"); + + await Promise.all([waitForLoad(win), + extension1.awaitFinish("sendmessage_reply"), + extension2.awaitFinish("sendmessage_reply")]); + + win.close(); + + await extension1.unload(); + await extension2.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_iframe.html b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_iframe.html new file mode 100644 index 0000000000..9c64635063 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_iframe.html @@ -0,0 +1,136 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +// The purpose of this test is to verify that the port.sender properties are +// not set for messages from iframes in background scripts. This is the toolkit +// version of the browser_ext_contentscript_nontab_connect.js test, and exists +// to provide test coverage for non-toolkit builds (e.g. Android). +// +// This used to be a xpcshell test (from bug 1488105), but became a mochitest +// because port.sender.tab and port.sender.frameId do not represent the real +// values in xpcshell tests. +// Specifically, ProxyMessenger.prototype.getSender uses the tabTracker, which +// expects real tabs instead of browsers from the ContentPage API in xpcshell +// tests. +add_task(async function connect_from_background_frame() { + if (!SpecialPowers.getBoolPref("extensions.webextensions.remote", true)) { + info("Cannot load remote content in parent process; skipping test task"); + return; + } + async function background() { + const FRAME_URL = "https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html"; + browser.runtime.onConnect.addListener(port => { + // The next two assertions are the reason for this being a mochitest + // instead of a xpcshell test. + browser.test.assertEq(port.sender.tab, undefined, "Sender is not a tab"); + browser.test.assertEq(port.sender.frameId, undefined, "frameId unset"); + browser.test.assertEq(port.sender.url, FRAME_URL, "Expected sender URL"); + port.onMessage.addListener(msg => { + browser.test.assertEq("pong", msg, "Reply from content script"); + port.disconnect(); + }); + port.postMessage("ping"); + }); + + await browser.contentScripts.register({ + matches: [FRAME_URL], + js: [{ file: "contentscript.js" }], + allFrames: true, + }); + + let f = document.createElement("iframe"); + f.src = FRAME_URL; + document.body.appendChild(f); + } + + function contentScript() { + browser.test.log(`Running content script at ${document.URL}`); + + let port = browser.runtime.connect(); + port.onMessage.addListener(msg => { + browser.test.assertEq("ping", msg, "Expected message to content script"); + port.postMessage("pong"); + }); + port.onDisconnect.addListener(() => { + browser.test.sendMessage("disconnected_in_content_script"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["https://example.com/*"], + }, + files: { + "contentscript.js": contentScript, + }, + background, + }); + await extension.startup(); + await extension.awaitMessage("disconnected_in_content_script"); + await extension.unload(); +}); + +// The test_ext_contentscript_fission_frame.html test already checks the +// behavior of onConnect in cross-origin frames, so here we just limit the test +// to checking that the port.sender properties are sensible. +add_task(async function connect_from_content_script_in_frame() { + async function background() { + const TAB_URL = "https://example.org/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html"; + const FRAME_URL = "https://example.org/tests/toolkit/components/extensions/test/mochitest/file_contains_img.html"; + let createdTab; + browser.runtime.onConnect.addListener(port => { + // The next two assertions are the reason for this being a mochitest + // instead of a xpcshell test. + browser.test.assertEq(port.sender.tab.url, TAB_URL, "Sender is the tab"); + browser.test.assertTrue(port.sender.frameId > 0, "frameId is set"); + browser.test.assertEq(port.sender.url, FRAME_URL, "Expected sender URL"); + + browser.test.assertEq(createdTab.id, port.sender.tab.id, "Tab to close"); + browser.tabs.remove(port.sender.tab.id).then(() => { + browser.test.sendMessage("tab_port_checked_and_tab_closed"); + }); + }); + + await browser.contentScripts.register({ + matches: [FRAME_URL], + js: [{ file: "contentscript.js" }], + allFrames: true, + }); + + createdTab = await browser.tabs.create({ url: TAB_URL }); + } + + function contentScript() { + browser.test.log(`Running content script at ${document.URL}`); + + browser.runtime.connect(); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["https://example.org/*"], + }, + files: { + "contentscript.js": contentScript, + }, + background, + }); + await extension.startup(); + await extension.awaitMessage("tab_port_checked_and_tab_closed"); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_twoway.html b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_twoway.html new file mode 100644 index 0000000000..b671cba23d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_twoway.html @@ -0,0 +1,126 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"> +</head> +<body> + +<script> +"use strict"; + +add_task(async function test_connect_bidirectionally_and_postMessage() { + function background() { + let onConnectCount = 0; + browser.runtime.onConnect.addListener(port => { + // 3. onConnect by connect() from CS. + browser.test.assertEq("from-cs", port.name); + browser.test.assertEq(1, ++onConnectCount, + "BG onConnect should be called once"); + + let tabId = port.sender.tab.id; + browser.test.assertTrue(tabId, "content script must have a tab ID"); + + let port2; + let postMessageCount1 = 0; + port.onMessage.addListener(msg => { + // 11. port.onMessage by port.postMessage in CS. + browser.test.assertEq("from CS to port", msg); + browser.test.assertEq(1, ++postMessageCount1, + "BG port.onMessage should be called once"); + + // 12. should trigger port2.onMessage in CS. + port2.postMessage("from BG to port2"); + }); + + // 4. Should trigger onConnect in CS. + port2 = browser.tabs.connect(tabId, {name: "from-bg"}); + let postMessageCount2 = 0; + port2.onMessage.addListener(msg => { + // 7. onMessage by port2.postMessage in CS. + browser.test.assertEq("from CS to port2", msg); + browser.test.assertEq(1, ++postMessageCount2, + "BG port2.onMessage should be called once"); + + // 8. Should trigger port.onMessage in CS. + port.postMessage("from BG to port"); + }); + }); + + // 1. Notify test runner to create a new tab. + browser.test.sendMessage("ready"); + } + + function contentScript() { + let onConnectCount = 0; + let port; + browser.runtime.onConnect.addListener(port2 => { + // 5. onConnect by connect() from BG. + browser.test.assertEq("from-bg", port2.name); + browser.test.assertEq(1, ++onConnectCount, + "CS onConnect should be called once"); + + let postMessageCount2 = 0; + port2.onMessage.addListener(msg => { + // 12. port2.onMessage by port2.postMessage in BG. + browser.test.assertEq("from BG to port2", msg); + browser.test.assertEq(1, ++postMessageCount2, + "CS port2.onMessage should be called once"); + + // TODO(robwu): Do not explicitly disconnect, it should not be a problem + // if we keep the ports open. However, not closing the ports causes the + // test to fail with NS_ERROR_NOT_INITIALIZED in ExtensionUtils.jsm, in + // Port.prototype.disconnect (nsIMessageSender.sendAsyncMessage). + port.disconnect(); + port2.disconnect(); + browser.test.notifyPass("ping pong done"); + }); + // 6. should trigger port2.onMessage in BG. + port2.postMessage("from CS to port2"); + }); + + // 2. should trigger onConnect in BG. + port = browser.runtime.connect({name: "from-cs"}); + let postMessageCount1 = 0; + port.onMessage.addListener(msg => { + // 9. onMessage by port.postMessage in BG. + browser.test.assertEq("from BG to port", msg); + browser.test.assertEq(1, ++postMessageCount1, + "CS port.onMessage should be called once"); + + // 10. should trigger port.onMessage in BG. + port.postMessage("from CS to port"); + }); + } + + let extensionData = { + background, + manifest: { + content_scripts: [{ + js: ["contentscript.js"], + matches: ["http://mochi.test/*/file_sample.html"], + }], + }, + files: { + "contentscript.js": contentScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + info("extension loaded"); + + await extension.awaitMessage("ready"); + + let win = window.open("file_sample.html"); + await extension.awaitFinish("ping pong done"); + win.close(); + + await extension.unload(); + info("extension unloaded"); +}); +</script> +</body> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_runtime_disconnect.html b/toolkit/components/extensions/test/mochitest/test_ext_runtime_disconnect.html new file mode 100644 index 0000000000..f18190bf8b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_disconnect.html @@ -0,0 +1,77 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +function background() { + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq(port.name, "ernie", "port name correct"); + port.onDisconnect.addListener(() => { + browser.test.assertEq(null, port.error, "The port is implicitly closed without errors when the other context unloads"); + // Closing an already-disconnected port is a no-op. + port.disconnect(); + port.disconnect(); + browser.test.sendMessage("disconnected"); + }); + browser.test.sendMessage("connected"); + }); +} + +function contentScript() { + browser.runtime.connect({name: "ernie"}); +} + +let extensionData = { + background, + manifest: { + "permissions": ["tabs"], + "content_scripts": [{ + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + "run_at": "document_idle", + }], + }, + + files: { + "content_script.js": contentScript, + }, +}; + +add_task(async function test_contentscript() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let win = window.open("file_sample.html"); + await Promise.all([waitForLoad(win), extension.awaitMessage("connected")]); + win.close(); + await extension.awaitMessage("disconnected"); + + info("win.close() succeeded"); + + win = window.open("file_sample.html"); + await Promise.all([waitForLoad(win), extension.awaitMessage("connected")]); + + // Add an "unload" listener so that we don't put the window in the + // bfcache. This way it gets destroyed immediately upon navigation. + win.addEventListener("unload", function() {}); // eslint-disable-line mozilla/balanced-listeners + + win.location = "http://example.com"; + await extension.awaitMessage("disconnected"); + win.close(); + + await extension.unload(); + info("extension unloaded"); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_script_filenames.html b/toolkit/components/extensions/test/mochitest/test_ext_script_filenames.html new file mode 100644 index 0000000000..de0993c33d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_script_filenames.html @@ -0,0 +1,62 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Script Filenames Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_tabs_executeScript() { + let validFileName = "script.js"; + let invalidFileName = "script.xyz"; + + async function background() { + await browser.tabs.executeScript({ file: "script.js" }); + + await browser.test.assertRejects( + browser.tabs.executeScript({ file: "script.xyz" }), + Error, + "invalid filename does not execute" + ); + browser.test.notifyPass("execute-script"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["<all_urls>"], + }, + + background, + + files: { + [validFileName]: function contentScript1() { + browser.test.sendMessage("content-script-loaded"); + }, + [invalidFileName]: function contentScript2() { + browser.test.fail("this script should not be loaded"); + }, + }, + }); + + let tab = await AppTestDelegate.openNewForegroundTab( + window, + "http://mochi.test:8888/", + true + ); + await extension.startup(); + + await extension.awaitMessage("content-script-loaded"); + await extension.awaitFinish("execute-script"); + + await extension.unload(); + await AppTestDelegate.removeTab(window, tab); +}); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_scripting_contentScripts.html b/toolkit/components/extensions/test/mochitest/test_ext_scripting_contentScripts.html new file mode 100644 index 0000000000..9daff87416 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_scripting_contentScripts.html @@ -0,0 +1,1649 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tests scripting.*ContentScripts()</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> + +"use strict"; + +const MOCHITEST_HOST_PERMISSIONS = [ + "*://mochi.test/", + "*://mochi.xorigin-test/", + "*://test1.example.com/", +]; + +const makeExtension = ({ manifest: manifestProps, ...otherProps }) => { + return ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + permissions: ["scripting"], + host_permissions: [ + ...MOCHITEST_HOST_PERMISSIONS, + // Used in `file_contains_iframe.html` + "*://example.org/", + ], + granted_host_permissions: true, + ...manifestProps, + }, + useAddonManager: "temporary", + ...otherProps, + }); +}; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.manifestV3.enabled", true]], + }); +}); + +add_task(async function test_validate_registerContentScripts_params() { + let extension = makeExtension({ + async background() { + const TEST_CASES = [ + { + title: "no js and no css", + params: [ + { + id: "script", + matches: ["*://mochi.test/*"], + persistAcrossSessions: false, + }, + ], + expectedError: "At least one js or css must be specified.", + }, + { + title: "empty js", + params: [ + { + id: "script", + js: [], + matches: ["*://mochi.test/*"], + persistAcrossSessions: false, + }, + ], + expectedError: "At least one js or css must be specified.", + }, + { + title: "empty css", + params: [ + { + id: "script", + css: [], + matches: ["*://mochi.test/*"], + persistAcrossSessions: false, + }, + ], + expectedError: "At least one js or css must be specified.", + }, + { + title: "no matches", + params: [ + { + id: "script", + js: ["script.js"], + persistAcrossSessions: false, + }, + ], + expectedError: "matches must be specified.", + }, + { + title: "empty matches", + params: [ + { + id: "script", + js: ["script.js"], + matches: [], + persistAcrossSessions: false, + }, + ], + expectedError: "matches must be specified.", + }, + { + title: "one empty match", + params: [ + { + id: "script", + js: ["script.js"], + matches: [""], + persistAcrossSessions: false, + }, + ], + expectedError: "Invalid url pattern: ", + }, + { + title: "invalid match", + params: [ + { + id: "script", + js: ["script.js"], + matches: ["not-a-pattern"], + persistAcrossSessions: false, + }, + ], + expectedError: "Invalid url pattern: not-a-pattern", + }, + { + title: "invalid match and valid match", + params: [ + { + id: "script", + js: ["script.js"], + matches: ["*://mochi.test/*", "not-a-pattern"], + persistAcrossSessions: false, + }, + ], + expectedError: "Invalid url pattern: not-a-pattern", + }, + { + title: "one empty value in excludeMatches", + params: [ + { + id: "script", + js: ["script.js"], + matches: ["*://mochi.test/*"], + excludeMatches: [""], + persistAcrossSessions: false, + }, + ], + expectedError: "Invalid url pattern: ", + }, + { + title: "invalid value in excludeMatches", + params: [ + { + id: "script", + js: ["script.js"], + matches: ["*://mochi.test/*"], + excludeMatches: ["not-a-pattern"], + persistAcrossSessions: false, + }, + ], + expectedError: "Invalid url pattern: not-a-pattern", + }, + { + title: "duplicate IDs", + params: [ + { + id: "script-1", + js: ["script.js"], + matches: ["*://mochi.test/*"], + persistAcrossSessions: false, + }, + { + id: "script-1", + js: ["script.js"], + matches: ["*://mochi.test/*"], + persistAcrossSessions: false, + }, + ], + expectedError: `Script ID "script-1" found more than once in 'scripts' array.`, + }, + { + title: "empty id", + params: [ + { + id: "", + js: ["script.js"], + matches: ["*://mochi.test/*"], + persistAcrossSessions: false, + }, + ], + expectedError: "Invalid content script id.", + }, + { + title: "id starting with _", + params: [ + { + id: "_foo", + js: ["script.js"], + matches: ["*://mochi.test/*"], + persistAcrossSessions: false, + }, + ], + expectedError: "Invalid content script id.", + }, + ]; + + for (const { title, params, expectedError } of TEST_CASES) { + await browser.test.assertRejects( + browser.scripting.registerContentScripts(params), + expectedError, + `${title} - got expected error` + ); + } + + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(0, scripts.length, "expected no registered script"); + + browser.test.notifyPass("test-finished"); + }, + files: { + "script.js": "", + }, + }); + + await extension.startup(); + await extension.awaitFinish("test-finished"); + await extension.unload(); +}); + +add_task(async function test_registerContentScripts_with_already_registered_id() { + let extension = makeExtension({ + async background() { + const script = { + id: "script-1", + js: ["script.js"], + matches: ["*://test1.example.com/*"], + persistAcrossSessions: false, + }; + + await browser.scripting.registerContentScripts([script]); + + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(1, scripts.length, "expected 1 registered script"); + + await browser.test.assertRejects( + browser.scripting.registerContentScripts([script]), + `Content script with id "${script.id}" is already registered.`, + "got expected error" + ); + + scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(1, scripts.length, "expected 1 registered script"); + + browser.test.notifyPass("test-finished"); + }, + files: { + "script.js": "", + }, + }); + + await extension.startup(); + await extension.awaitFinish("test-finished"); + await extension.unload(); +}); + +add_task(async function test_validate_getRegisteredContentScripts_params() { + let extension = makeExtension({ + async background() { + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(0, scripts.length, "expected no registered scripts"); + + scripts = await browser.scripting.getRegisteredContentScripts({ + ids: ["non-existent-id"] + }); + browser.test.assertEq(0, scripts.length, "expected no registered scripts"); + + browser.test.log("test call with undefined filter and a chrome-compatible callback"); + scripts = await new Promise(resolve => { + browser.scripting.getRegisteredContentScripts(undefined, resolve); + }); + browser.test.assertEq(0, scripts.length, "expected no registered scripts"); + + browser.test.log("test call with only the chrome-compatible callback"); + scripts = await new Promise(resolve => { + browser.scripting.getRegisteredContentScripts(resolve); + }); + browser.test.assertEq(0, scripts.length, "expected no registered scripts"); + + browser.test.notifyPass("test-finished"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("test-finished"); + await extension.unload(); +}); + +add_task(async function test_getRegisteredContentScripts() { + let extension = makeExtension({ + async background() { + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(0, scripts.length, "expected no registered scripts"); + + const aScript = { + id: "a-script", + js: ["script.js"], + matches: ["<all_urls>"], + persistAcrossSessions: false, + }; + + await browser.scripting.registerContentScripts([aScript]); + + scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(1, scripts.length, "expected 1 registered script"); + browser.test.assertEq(aScript.id, scripts[0].id, "expected correct id"); + + // This should return no registered scripts. + scripts = await browser.scripting.getRegisteredContentScripts({ ids: [] }); + browser.test.assertEq(0, scripts.length, "expected 0 registered script"); + + // Verify that invalid IDs are omitted but valid IDs are used to return + // registered scripts. + scripts = await browser.scripting.getRegisteredContentScripts({ + ids: ["non-existent-id", aScript.id] + }); + browser.test.assertEq(1, scripts.length, "expected 1 registered script"); + browser.test.assertEq(aScript.id, scripts[0].id, "expected correct id"); + + browser.test.notifyPass("test-finished"); + }, + files: { + "script.js": "", + }, + }); + + await extension.startup(); + await extension.awaitFinish("test-finished"); + await extension.unload(); +}); + +add_task(async function test_registerContentScripts_js() { + let extension = makeExtension({ + async background() { + const TEST_CASES = [ + // This should have no effect but it should not throw. + { + title: "no script", + params: [], + }, + { + title: "one script", + params: [ + { + id: "script-1", + js: ["script-1.js"], + matches: ["*://test1.example.com/*"], + persistAcrossSessions: false, + } + ], + }, + { + title: "one script in all frames", + params: [ + { + id: "script-2", + js: ["script-2.js"], + matches: [ + "*://test1.example.com/*", + "*://example.org/*", + ], + allFrames: true, + persistAcrossSessions: false, + } + ], + }, + { + title: "one script in all frames with excludeMatches set", + params: [ + { + id: "script-3", + js: ["script-3.js"], + matches: [ + "*://test1.example.com/*", + "*://example.org/*", + ], + allFrames: true, + excludeMatches: ["*://test1.example.com/*"], + persistAcrossSessions: false, + } + ], + }, + { + title: "one script, two js paths", + params: [ + { + id: "script-4", + js: ["script-4-1.js", "script-4-2.js"], + matches: ["*://test1.example.com/*"], + persistAcrossSessions: false, + } + ], + }, + { + title: "empty excludeMatches", + params: [ + { + id: "script-5", + // This path should be normalized. + js: ["/script-5.js"], + matches: ["*://test1.example.com/*"], + excludeMatches: [], + persistAcrossSessions: false, + } + ], + }, + ]; + + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(0, scripts.length, "expected no registered script"); + + for (const { title, params } of TEST_CASES) { + const res = await browser.scripting.registerContentScripts(params); + browser.test.assertEq( + undefined, + res, + `${title} - expected no result` + ); + + const script = await browser.scripting.getRegisteredContentScripts({ + ids: params.map(param => param.id) + }); + browser.test.assertEq( + params.length, + script.length, + `${title} - got the expected number of registered scripts` + ); + } + + scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq( + // A test case declared above does not contain any script to register. + TEST_CASES.length - 1, + scripts.length, + "got the expected number of registered scripts" + ); + browser.test.assertEq( + JSON.stringify([ + { + id: "script-1", + allFrames: false, + matches: ["*://test1.example.com/*"], + runAt: "document_idle", + persistAcrossSessions: false, + js: ["script-1.js"], + }, + { + id: "script-2", + allFrames: true, + matches: [ + "*://test1.example.com/*", + "*://example.org/*", + ], + runAt: "document_idle", + persistAcrossSessions: false, + js: ["script-2.js"], + }, + { + id: "script-3", + allFrames: true, + matches: [ + "*://test1.example.com/*", + "*://example.org/*", + ], + runAt: "document_idle", + persistAcrossSessions: false, + excludeMatches: ["*://test1.example.com/*"], + js: ["script-3.js"], + }, + { + id: "script-4", + allFrames: false, + matches: ["*://test1.example.com/*"], + runAt: "document_idle", + persistAcrossSessions: false, + js: ["script-4-1.js", "script-4-2.js"], + }, + { + id: "script-5", + allFrames: false, + matches: ["*://test1.example.com/*"], + runAt: "document_idle", + persistAcrossSessions: false, + js: ["script-5.js"], + }, + ]), + JSON.stringify(scripts), + "got expected scripts" + ); + + browser.test.sendMessage("background-ready"); + }, + files: { + "script-1.js": () => { + browser.test.sendMessage( + "script-ran", + { file: "script-1.js", value: document.title } + ); + }, + "script-2.js": () => { + browser.test.sendMessage( + "script-ran", + { file: "script-2.js", value: document.title } + ); + }, + "script-3.js": () => { + browser.test.sendMessage( + "script-ran", + { file: "script-3.js", value: document.title } + ); + }, + "script-4-1.js": () => { + // We inject this script (first) as well as the one defined right + // after. The order should be respected, which is why we define a + // property here and check it in the second script. + window.SCRIPT_4_INJECTED = "SCRIPT_4_INJECTED"; + }, + "script-4-2.js": () => { + browser.test.sendMessage( + "script-ran", + { file: "script-4-2.js", value: window.SCRIPT_4_INJECTED } + ); + delete window.SCRIPT_4_INJECTED; + }, + "script-5.js": () => { + browser.test.sendMessage( + "script-ran", + { file: "script-5.js", value: document.title } + ); + }, + }, + }); + + let scriptsRan = 0; + let results = []; + let completePromise = new Promise(resolve => { + extension.onMessage("script-ran", result => { + results.push(result); + scriptsRan++; + + // The value below should be updated when TEST_CASES above is changed. + if (scriptsRan === 6) { + resolve(); + } + }); + }); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + // Load a page that will trigger the content scripts previously registered. + let tab = await AppTestDelegate.openNewForegroundTab( + window, + "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html", + true + ); + + // Wait for all content scripts to be executed. + await completePromise; + + // Verify that the scripts have been executed correctly. We sort the results + // to compare them against expected values. + results.sort((a, b) => { + return a.file.localeCompare(b.file) || a.value.localeCompare(b.value); + }); + ok( + JSON.stringify([ + { file: "script-1.js", value: "file contains iframe" }, + // script-2.js should be injected in two frames + { file: "script-2.js", value: "file contains iframe" }, + { file: "script-2.js", value: "file contains img" }, + { file: "script-3.js", value: "file contains img" }, + // script-4-1.js will add a prop to the `window` object, which should be + // read by `script-4-2.js`. + { file: "script-4-2.js", value: "SCRIPT_4_INJECTED" }, + { file: "script-5.js", value: "file contains iframe" }, + ]) === JSON.stringify(results), + "got expected script results" + JSON.stringify(results) + ); + + await AppTestDelegate.removeTab(window, tab); + await extension.unload(); +}); + +add_task(async function test_registerContentScripts_are_not_unregistered() { + let extension = makeExtension({ + files: { + "background.html": `<!DOCTYPE html> + <html> + <head><meta charset="utf-8"></head> + <body> + <script src="background.js"><\/script> + </body> + </html> + `, + "background.js": async () => { + await browser.scripting.registerContentScripts([ + { + id: "a-script", + js: ["script.js"], + matches: ["*://test1.example.com/*"], + persistAcrossSessions: false, + }, + ]); + + browser.test.sendMessage("background-executed"); + }, + "script.js": () => { + browser.test.sendMessage("script-executed"); + }, + }, + }); + + await extension.startup(); + + // Load the background page that registers a content script. + let tab = await AppTestDelegate.openNewForegroundTab( + window, + `moz-extension://${extension.uuid}/background.html`, + true + ); + await extension.awaitMessage("background-executed"); + await AppTestDelegate.removeTab(window, tab); + + // Load a page that will trigger the content scripts previously registered. + tab = await AppTestDelegate.openNewForegroundTab( + window, + "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html", + true + ); + + await extension.awaitMessage("script-executed"); + + await AppTestDelegate.removeTab(window, tab); + await extension.unload(); +}); + +add_task(async function test_scripts_dont_run_after_shutdown() { + let extension = makeExtension({ + async background() { + await browser.scripting.registerContentScripts([ + { + id: "script-that-should-not-run", + js: ["script.js"], + matches: ["*://test1.example.com/*"], + persistAcrossSessions: false, + }, + ]); + + browser.test.sendMessage("background-ready"); + }, + files: { + "script.js": () => { + browser.test.fail("this script should not be executed."); + }, + }, + }); + // We use a second extension to wait enough time to confirm that the script + // registered in the previous extension has not been executed at all, in case + // the tab closes before the scheduled content script has had a chance to + // run. + let anotherExtension = makeExtension({ + async background() { + await browser.scripting.registerContentScripts([ + { + id: "this-script-should-run", + js: ["script.js"], + matches: ["*://test1.example.com/*"], + persistAcrossSessions: false, + }, + ]); + + browser.test.sendMessage("background-ready"); + }, + files: { + "script.js": () => { + browser.test.sendMessage("script-ran"); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + await anotherExtension.startup(); + await anotherExtension.awaitMessage("background-ready"); + + await extension.unload(); + + let tab = await AppTestDelegate.openNewForegroundTab( + window, + "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html", + true + ); + await anotherExtension.awaitMessage("script-ran"); + await AppTestDelegate.removeTab(window, tab); + + await anotherExtension.unload(); +}); + +add_task(async function test_registerContentScripts_with_wrong_matches() { + let extension = makeExtension({ + async background() { + // Register a content script that should not be injected in this test + // case because the `matches` values don't match the host permissions. + await browser.scripting.registerContentScripts([ + { + id: "script-that-should-not-run", + js: ["script.js"], + matches: ["*://mozilla.org/*"], + persistAcrossSessions: false, + }, + ]); + + browser.test.sendMessage("background-ready"); + }, + files: { + "script.js": () => { + browser.test.fail("this script should not be executed."); + }, + }, + }); + // We use a second extension to wait enough time to confirm that the script + // registered in the previous extension has not been executed at all, in case + // the tab closes before the scheduled content script has had a chance to + // run. + let anotherExtension = makeExtension({ + async background() { + await browser.scripting.registerContentScripts([ + { + id: "this-script-should-run", + js: ["script.js"], + matches: ["*://test1.example.com/*"], + persistAcrossSessions: false, + }, + ]); + + browser.test.sendMessage("background-ready"); + }, + files: { + "script.js": () => { + browser.test.sendMessage("script-ran"); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + await anotherExtension.startup(); + await anotherExtension.awaitMessage("background-ready"); + + let tab = await AppTestDelegate.openNewForegroundTab( + window, + "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html", + true + ); + await anotherExtension.awaitMessage("script-ran"); + + await extension.unload(); + await anotherExtension.unload(); + + // We remove the tab after having unloaded the extensions to avoid failures + // on Windows, see: Bug 1761550. + await AppTestDelegate.removeTab(window, tab); +}); + +add_task(async function test_registerContentScripts_on_about_blank_frames() { + let extension = makeExtension({ + async background() { + await browser.scripting.registerContentScripts([ + { + id: "a-script", + js: ["script.js"], + allFrames: true, + // TODO bug 1853411: implement matchOriginAsFallback: true, + // For now, we run in about:blank if its origin matches, comparable + // to match_about_blank:true in content_scripts in manifest.json. + matches: ["*://test1.example.com/*/file_with_about_blank.html"], + persistAcrossSessions: false, + }, + ]); + + browser.test.sendMessage("background-ready"); + }, + files: { + "script.js": () => { + browser.test.assertEq( + "https://test1.example.com", + origin, + `Got expected origin at ${location.href}` + ); + browser.test.sendMessage("got_url:" + location.href.split("/").pop()); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + let tab = await AppTestDelegate.openNewForegroundTab( + window, + "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_with_about_blank.html", + true + ); + await extension.awaitMessage("got_url:file_with_about_blank.html"); + await extension.awaitMessage("got_url:about:blank"); + await extension.awaitMessage("got_url:about:srcdoc"); + + await extension.unload(); + + // We remove the tab after having unloaded the extensions to avoid failures + // on Windows, see: Bug 1761550. + await AppTestDelegate.removeTab(window, tab); +}); + +add_task(async function test_registerContentScripts_on_top_level_about_blank() { + // This is the default behavior, but fix pref value in case that is not the + // case. The flipped-pref case is tested later in this test. + // TODO bug 1856071: Remove this pref setter when the pref is removed. + await SpecialPowers.pushPrefEnv({ + set: [["extensions.script_about_blank_without_permission", false]], + }); + let extension = makeExtension({ + async background() { + await browser.scripting.registerContentScripts([ + { + id: "script-that-should-not-run", + js: ["script_should_not_run.js"], + // "matches" does not match all URLs, so the script should not run on + // top-level about:blank. Otherwise extensions may unexpectedly see + // their scripts running in more contexts than expected, as seen in + // bug 1853412. + matches: ["*://test1.example.com/does_not_match_any/*"], + persistAcrossSessions: false, + }, + { + id: "script-that-should-run", + js: ["script_all_urls.js"], + matches: ["*://*/*"], + // Undocumented feature: to only match on "about:blank", specify + // excludeMatches that excludes the URLs of interest. + excludeMatches: ["*://*/*"], + persistAcrossSessions: false, + }, + ]); + + browser.test.sendMessage("background-ready"); + }, + files: { + "script_should_not_run.js": () => { + browser.test.fail("this script should not be executed."); + }, + "script_all_urls.js": () => { + browser.test.assertEq("about:blank", document.URL, "Expected URL"); + browser.test.assertEq("null", origin, "Expected null origin"); + browser.test.sendMessage("script-ran"); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + let tab = await AppTestDelegate.openNewForegroundTab( + window, + "about:blank", + // Set waitForLoad to false because the Android implementation of + // openNewForegroundTab ignores "about:blank" loads. + // We don't need to wait for a full load, because the "script-ran" + // message will be sent by the content script when it is ready. + false + ); + await extension.awaitMessage("script-ran"); + + await extension.unload(); + + // We remove the tab after having unloaded the extensions to avoid failures + // on Windows, see: Bug 1761550. + await AppTestDelegate.removeTab(window, tab); + await SpecialPowers.popPrefEnv(); // Balances pushPrefEnv at start of task. + // TODO bug bug 1856071: Remove the above popPrefEnv call. +}); + +add_task(async function test_registerContentScripts_twice_with_same_id() { + let extension = makeExtension({ + async background() { + const script = { + id: "script-that-should-not-run", + js: ["script.js"], + matches: ["*://test1.example.com/*"], + persistAcrossSessions: false, + }; + + const results = await Promise.allSettled([ + browser.scripting.registerContentScripts([script]), + browser.scripting.registerContentScripts([script]), + ]); + + browser.test.assertEq(2, results.length, "got expected length"); + browser.test.assertEq( + "fulfilled", + results[0].status, + "expected fulfilled promise" + ); + browser.test.assertEq( + "rejected", + results[1].status, + "expected rejected promise" + ); + browser.test.assertEq( + `Content script with id "script-that-should-not-run" is already registered.`, + results[1].reason.message, + "expected reason" + ); + + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(1, scripts.length, "expected 1 registered script"); + + browser.test.sendMessage("background-done"); + }, + files: { + "script.js": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-done"); + await extension.unload(); +}); + +add_task(async function test_getRegisteredContentScripts_during_a_registration() { + let extension = makeExtension({ + async background() { + browser.scripting.registerContentScripts([ + { + id: "a-script", + js: ["script.js"], + matches: ["*://test1.example.com/*"], + persistAcrossSessions: false, + }, + ]); + + const scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq( + JSON.stringify([ + { + id: "a-script", + allFrames: false, + matches: ["*://test1.example.com/*"], + runAt: "document_idle", + persistAcrossSessions: false, + js: ["script.js"], + }, + ]), + JSON.stringify(scripts), + "expected 1 registered script" + ); + + browser.test.sendMessage("background-done"); + }, + files: { + "script.js": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-done"); + await extension.unload(); +}); + +add_task(async function test_validate_unregisterContentScripts_params() { + let extension = makeExtension({ + async background() { + const TEST_CASES = [ + { + title: "unknown id", + params: { + ids: ["non-existent-id"], + }, + expectedError: `Content script with id "non-existent-id" does not exist.` + }, + { + title: "invalid id", + params: { + ids: ["_invalid-id"], + }, + expectedError: "Invalid content script id.", + }, + ]; + + for (const { title, params, expectedError } of TEST_CASES) { + await browser.test.assertRejects( + browser.scripting.unregisterContentScripts(params), + expectedError, + `${title} - got expected error` + ); + } + + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(0, scripts.length, "expected no script"); + + browser.test.sendMessage("background-done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-done"); + await extension.unload(); +}); + +add_task(async function test_unregisterContentScripts_with_chrome_compatible_callback() { + let extension = makeExtension({ + async background() { + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(0, scripts.length, "expected no script"); + + // Register a script that we can unregister after. + await browser.scripting.registerContentScripts([ + { + id: "script-1", + js: ["script.js"], + matches: ["*://test1.example.com/*"], + persistAcrossSessions: false, + }, + ]); + scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(1, scripts.length, "expected 1 registered script"); + + browser.test.log("test call with undefined filter and a chrome-compatible callback"); + await new Promise(resolve => { + browser.scripting.unregisterContentScripts(undefined, resolve); + }); + + scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(0, scripts.length, "expected no registered scripts"); + + // Re-register a script that we can unregister after. + await browser.scripting.registerContentScripts([ + { + id: "script-1", + js: ["script.js"], + matches: ["*://test1.example.com/*"], + persistAcrossSessions: false, + }, + ]); + + browser.test.log("test call with only the chrome-compatible callback"); + await new Promise(resolve => { + browser.scripting.unregisterContentScripts(resolve); + }); + + scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(0, scripts.length, "expected no registered scripts"); + + browser.test.sendMessage("background-done"); + }, + files: { + "script.js": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-done"); + await extension.unload(); +}); + +add_task(async function test_unregisterContentScripts() { + let extension = makeExtension({ + async background() { + const script1 = { + id: "script-1", + js: ["script.js"], + matches: ["*://test1.example.com/*"], + persistAcrossSessions: false, + }; + const script2 = { + id: "script-2", + js: ["script.js"], + matches: ["*://test1.example.com/*"], + persistAcrossSessions: false, + } + const script3 = { + id: "script-3", + js: ["script.js"], + matches: ["*://test1.example.com/*"], + persistAcrossSessions: false, + } + + let res = await browser.scripting.registerContentScripts([ + script1, + script2, + script3, + ]); + browser.test.assertEq(undefined, res, "expected no result"); + + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(3, scripts.length, "expected 3 scripts"); + browser.test.assertEq(script1.id, scripts[0].id, "expected correct id"); + browser.test.assertEq(script2.id, scripts[1].id, "expected correct id"); + browser.test.assertEq(script3.id, scripts[2].id, "expected correct id"); + + // No unregistration when unknown IDs are passed along with valid IDs. + await browser.test.assertRejects( + browser.scripting.unregisterContentScripts({ + ids: [script2.id, "non-existent-id"], + }), + `Content script with id "non-existent-id" does not exist.` + ); + + scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(3, scripts.length, "expected 3 scripts"); + + // Unregister 1 script. + res = await browser.scripting.unregisterContentScripts({ + ids: [script2.id] + }); + browser.test.assertEq(undefined, res, "expected no result"); + + scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(2, scripts.length, "expected 2 scripts"); + browser.test.assertEq(script1.id, scripts[0].id, "expected correct id"); + browser.test.assertEq(script3.id, scripts[1].id, "expected correct id"); + + // This should unregister all the remaining registered scripts. + res = await browser.scripting.unregisterContentScripts(); + browser.test.assertEq(undefined, res, "expected no result"); + + scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(0, scripts.length, "expected no script"); + + browser.test.sendMessage("background-done"); + }, + files: { + "script.js": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-done"); + await extension.unload(); +}); + +add_task(async function test_unregisterContentScripts_twice_with_same_id() { + let extension = makeExtension({ + async background() { + const script = { + id: "script-to-unregister", + js: ["script.js"], + matches: ["*://test1.example.com/*"], + persistAcrossSessions: false, + }; + + await browser.scripting.registerContentScripts([script]); + + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(1, scripts.length, "expected 1 registered script"); + + const results = await Promise.allSettled([ + browser.scripting.unregisterContentScripts({ ids: [script.id] }), + browser.scripting.unregisterContentScripts({ ids: [script.id] }), + ]); + + browser.test.assertEq(2, results.length, "got expected length"); + browser.test.assertEq( + "fulfilled", + results[0].status, + "expected fulfilled promise" + ); + browser.test.assertEq( + "rejected", + results[1].status, + "expected rejected promise" + ); + browser.test.assertEq( + `Content script with id "script-to-unregister" does not exist.`, + results[1].reason.message, + "expected reason" + ); + + scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(0, scripts.length, "expected 0 registered script"); + + browser.test.sendMessage("background-done"); + }, + files: { + "script.js": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-done"); + await extension.unload(); +}); + +add_task(async function test_validate_updateContentScripts_params() { + let extension = makeExtension({ + async background() { + const script = { + id: "registered-script", + js: ["script-1.js"], + matches: ["*://test1.example.com/*"], + persistAcrossSessions: false, + }; + + const TEST_CASES = [ + { + title: "invalid script ID", + params: [ + { + id: "_invalid-id", + }, + ], + expectedError: 'Invalid content script id.', + }, + { + title: "empty script ID", + params: [ + { + id: "", + }, + ], + expectedError: 'Invalid content script id.', + }, + { + title: "unknown script ID", + params: [ + { + id: "unknown-id", + }, + ], + expectedError: 'Content script with id "unknown-id" does not exist.', + }, + { + title: "duplicate valid script IDs", + params: [ + { + id: script.id, + }, + { + id: script.id, + }, + ], + expectedError: `Script ID "${script.id}" found more than once in 'scripts' array.`, + }, + { + title: "empty matches", + params: [ + { + id: script.id, + matches: [], + }, + ], + expectedError: "matches must be specified.", + }, + { + title: "one empty match", + params: [ + { + id: script.id, + matches: [""], + }, + ], + expectedError: "Invalid url pattern: ", + }, + { + title: "invalid match", + params: [ + { + id: script.id, + matches: ["not-a-pattern"], + }, + ], + expectedError: "Invalid url pattern: not-a-pattern", + }, + { + title: "invalid match and valid match", + params: [ + { + id: script.id, + matches: ["*://mochi.test/*", "not-a-pattern"], + }, + ], + expectedError: "Invalid url pattern: not-a-pattern", + }, + { + title: "one empty value in excludeMatches", + params: [ + { + id: script.id, + excludeMatches: [""], + }, + ], + expectedError: "Invalid url pattern: ", + }, + { + title: "invalid value in excludeMatches", + params: [ + { + id: script.id, + excludeMatches: ["not-a-pattern"], + }, + ], + expectedError: "Invalid url pattern: not-a-pattern", + }, + { + title: "empty js", + params: [ + { + id: script.id, + js: [], + }, + ], + expectedError: "At least one js or css must be specified.", + }, + { + title: "empty js and css", + params: [ + { + id: script.id, + js: [], + css: [], + }, + ], + expectedError: "At least one js or css must be specified.", + }, + ]; + + // Register a valid script so that we can verify update params beyond + // script IDs. + await browser.scripting.registerContentScripts([script]); + + for (const { title, params, expectedError } of TEST_CASES) { + await browser.test.assertRejects( + browser.scripting.updateContentScripts(params), + expectedError, + `${title} - got expected error` + ); + } + + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(1, scripts.length, "expected 1 registered script"); + browser.test.assertEq( + JSON.stringify([ + { + id: script.id, + allFrames: false, + matches: script.matches, + runAt: "document_idle", + persistAcrossSessions: false, + js: script.js, + }, + ]), + JSON.stringify(scripts), + "expected script to not have been modified" + ); + + browser.test.sendMessage("background-done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-done"); + await extension.unload(); +}); + +add_task(async function test_updateContentScripts() { + let extension = makeExtension({ + async background() { + const SCRIPT_ID = "script-to-update"; + + await browser.scripting.registerContentScripts([ + { + id: SCRIPT_ID, + js: ["script-1.js"], + matches: ["*://test1.example.com/*"], + persistAcrossSessions: false, + }, + ]); + + browser.test.onMessage.addListener(async (msg, params) => { + switch (msg) { + case "updateContentScripts": { + const { + title, + updateContentScriptsParams, + expectedRegisteredContentScript + } = params; + + let result = await browser.scripting.updateContentScripts([ + updateContentScriptsParams, + ]); + browser.test.assertEq( + undefined, + result, + `${title} - expected no return value` + ); + + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq( + JSON.stringify([expectedRegisteredContentScript]), + JSON.stringify(scripts), + `${title} - expected registered script` + ); + + browser.test.sendMessage(`${msg}-done`); + break; + } + + default: + browser.test.fail(`invalid message received: ${msg}`); + } + }); + + browser.test.sendMessage("background-ready"); + }, + files: { + "script-1.js": () => { + browser.test.sendMessage( + `script-1 executed in ${location.pathname.split("/").pop()}` + ); + }, + "script-2.js": () => { + browser.test.sendMessage( + `script-2 executed in ${location.pathname.split("/").pop()}` + ); + }, + "script-3.js": () => { + browser.test.sendMessage( + `script-3 executed in ${location.pathname.split("/").pop()}` + ); + }, + "script-4.js": () => { + browser.test.sendMessage( + `script-4 executed in ${location.pathname.split("/").pop()}` + ); + }, + "style.css": "body { background-color: rgb(0, 255, 0); }", + "script-check-style.js": () => { + browser.test.assertEq( + "rgb(0, 255, 0)", + getComputedStyle(document.querySelector('body')).backgroundColor, + "expected background color" + ); + browser.test.sendMessage( + `script-check-style executed in ${location.pathname.split("/").pop()}` + ); + }, + }, + }); + + const SCRIPT_ID = "script-to-update"; + const TEST_PAGE = "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html"; + + const runTestCase = async ({ + title, + updateContentScriptsParams, + expectedRegisteredContentScript, + expectedMessages + }) => { + // Register content script and verify results. + extension.sendMessage("updateContentScripts", { + title, + updateContentScriptsParams, + expectedRegisteredContentScript, + }); + await extension.awaitMessage("updateContentScripts-done"); + + let tab = await AppTestDelegate.openNewForegroundTab( + window, + TEST_PAGE, + true + ); + + await Promise.all(expectedMessages.map(msg => extension.awaitMessage(msg))); + + await AppTestDelegate.removeTab(window, tab); + }; + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + // Load a page that will trigger the content script initially registered. + let tab = await AppTestDelegate.openNewForegroundTab(window, TEST_PAGE, true); + await extension.awaitMessage("script-1 executed in file_contains_iframe.html"); + await AppTestDelegate.removeTab(window, tab); + + // Now, let's update this content script a few times. + await runTestCase({ + title: "update ID only", + updateContentScriptsParams: { + id: SCRIPT_ID, + }, + expectedRegisteredContentScript: { + id: SCRIPT_ID, + allFrames: false, + matches: ["*://test1.example.com/*"], + runAt: "document_idle", + persistAcrossSessions: false, + js: ["script-1.js"], + }, + expectedMessages: ["script-1 executed in file_contains_iframe.html"], + }); + + await runTestCase({ + title: "update js", + updateContentScriptsParams: { + id: SCRIPT_ID, + js: ["script-2.js"], + }, + expectedRegisteredContentScript: { + id: SCRIPT_ID, + allFrames: false, + matches: ["*://test1.example.com/*"], + runAt: "document_idle", + persistAcrossSessions: false, + js: ["script-2.js"], + }, + expectedMessages: ["script-2 executed in file_contains_iframe.html"], + }); + + await runTestCase({ + title: "update allFrames and matches", + updateContentScriptsParams: { + id: SCRIPT_ID, + matches: [ + "*://test1.example.com/*", + "*://example.org/*", + ], + allFrames: true, + }, + expectedRegisteredContentScript: { + id: SCRIPT_ID, + allFrames: true, + matches: [ + "*://test1.example.com/*", + "*://example.org/*", + ], + runAt: "document_idle", + persistAcrossSessions: false, + js: ["script-2.js"], + }, + expectedMessages: [ + "script-2 executed in file_contains_iframe.html", + "script-2 executed in file_contains_img.html", + ], + }); + + await runTestCase({ + title: "update excludeMatches and js", + updateContentScriptsParams: { + id: SCRIPT_ID, + js: ["script-3.js"], + excludeMatches: ["*://test1.example.com/*"], + allFrames: true, + }, + expectedRegisteredContentScript: { + id: SCRIPT_ID, + allFrames: true, + matches: [ + "*://test1.example.com/*", + "*://example.org/*", + ], + runAt: "document_idle", + persistAcrossSessions: false, + excludeMatches: ["*://test1.example.com/*"], + js: ["script-3.js"], + }, + expectedMessages: [ + "script-3 executed in file_contains_img.html", + ], + }); + + await runTestCase({ + title: "update allFrames, excludeMatches, js and runAt", + updateContentScriptsParams: { + id: SCRIPT_ID, + allFrames: false, + excludeMatches: [], + js: ["script-4.js"], + runAt: "document_start", + }, + expectedRegisteredContentScript: { + id: SCRIPT_ID, + allFrames: false, + matches: [ + "*://test1.example.com/*", + "*://example.org/*", + ], + runAt: "document_start", + persistAcrossSessions: false, + js: ["script-4.js"], + }, + expectedMessages: [ + "script-4 executed in file_contains_iframe.html", + ], + }); + + await runTestCase({ + title: "update allFrames, css, js and runAt", + updateContentScriptsParams: { + id: SCRIPT_ID, + allFrames: true, + css: ["style.css"], + js: ["script-check-style.js"], + runAt: "document_idle", + }, + expectedRegisteredContentScript: { + id: SCRIPT_ID, + allFrames: true, + matches: [ + "*://test1.example.com/*", + "*://example.org/*", + ], + runAt: "document_idle", + persistAcrossSessions: false, + css: ["style.css"], + js: ["script-check-style.js"], + }, + expectedMessages: [ + "script-check-style executed in file_contains_iframe.html", + "script-check-style executed in file_contains_img.html", + ], + }); + + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript.html b/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript.html new file mode 100644 index 0000000000..a2d741606f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript.html @@ -0,0 +1,1479 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tests scripting.executeScript()</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<iframe src="https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html"></iframe> + +<script type="text/javascript"> + +"use strict"; + +const MOCHITEST_HOST_PERMISSIONS = [ + "*://mochi.test/", + "*://mochi.xorigin-test/", + "*://test1.example.com/", +]; + +const makeExtension = ({ manifest: manifestProps, ...otherProps }) => { + return ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + permissions: ["scripting"], + host_permissions: [ + ...MOCHITEST_HOST_PERMISSIONS, + "https://example.com/", + // Used in `file_contains_iframe.html` + "https://example.org/", + ], + granted_host_permissions: true, + ...manifestProps, + }, + useAddonManager: "temporary", + ...otherProps, + }); +}; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.manifestV3.enabled", true]], + }); +}); + +add_task(async function test_executeScript_params_validation() { + let extension = makeExtension({ + async background() { + const tabs = await browser.tabs.query({ active: true }); + const tabId = tabs[0].id; + + const TEST_CASES = [ + { + title: "no files and no func", + executeScriptParams: {}, + expectedError: /Exactly one of files and func must be specified/, + }, + { + title: "both files and func are passed", + executeScriptParams: { files: ["script.js"], func() {} }, + expectedError: /Exactly one of files and func must be specified/, + }, + { + title: "non-empty args is passed with files", + executeScriptParams: { files: ["script.js"], args: [123] }, + expectedError: /'args' may not be used with file injections/, + }, + { + title: "empty args is passed with files", + executeScriptParams: { files: ["script.js"], args: [] }, + expectedError: /'args' may not be used with file injections/, + }, + { + title: "unserializable argument", + executeScriptParams: { func() {}, args: [window] }, + expectedError: /Unserializable arguments/, + }, + { + title: "both allFrames and frameIds are passed", + executeScriptParams: { + target: { + tabId, + allFrames: true, + frameIds: [1, 2, 3], + }, + files: ["script.js"], + }, + expectedError: /Cannot specify both 'allFrames' and 'frameIds'/, + }, + { + title: "invalid IDs in frameIds", + executeScriptParams: { + target: { tabId, frameIds: [0, 1, 2] }, + func: () => {}, + }, + expectedError: "Invalid frame IDs: [1, 2].", + }, + { + title: "throw non-structurally cloneable data in all frames", + executeScriptParams: { + target: { + tabId, + allFrames: true, + }, + func: () => { + throw window; + }, + }, + expectedError: /Script '<anonymous code>' result is non-structured-clonable data/, + }, + ]; + + for (const { title, executeScriptParams, expectedError } of TEST_CASES) { + await browser.test.assertRejects( + browser.scripting.executeScript({ + target: { tabId: tabs[0].id }, + ...executeScriptParams, + }), + expectedError, + `expected error when: ${title}` + ); + } + + browser.test.notifyPass("execute-script"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("execute-script"); + await extension.unload(); +}); + +add_task(async function test_executeScript_main_world() { + let extension = makeExtension({ + async background() { + browser.test.assertThrows( + () => { + browser.scripting.executeScript({ + target: { tabId: 123 }, + func: () => {}, + world: "MAIN", + }); + }, + /world: Invalid enumeration value "MAIN"/, + "expected 'MAIN' world to not be supported yet" + ); + + browser.test.notifyPass("background-done"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("background-done"); + await extension.unload(); +}); + +add_task(async function test_executeScript_isolated_world() { + let extension = makeExtension({ + manifest: { + browser_specific_settings: { + gecko: { id: "@isolated-addon-id" }, + }, + }, + async background() { + const tabs = await browser.tabs.query({ active: true }); + browser.test.assertEq(1, tabs.length, "expected 1 tab"); + + let results = await browser.scripting.executeScript({ + target: { tabId: tabs[0].id }, + func: () => { + globalThis.defaultWorldVar = browser.runtime.id; + return "default world"; + }, + }); + + browser.test.assertEq( + 1, + results.length, + "got expected number of results" + ); + browser.test.assertEq( + "default world", + results[0].result, + "got expected return value" + ); + + results = await browser.scripting.executeScript({ + target: { tabId: tabs[0].id }, + func: () => { + return `isolated: ${browser.runtime.id}; existing default var: ${typeof defaultWorldVar}`; + }, + world: "ISOLATED", + }); + + browser.test.assertEq( + 1, + results.length, + "got expected number of results" + ); + browser.test.assertEq( + "isolated: @isolated-addon-id; existing default var: string", + results[0].result, + "got expected return value" + ); + + browser.test.notifyPass("execute-script"); + }, + }); + + let tab = await AppTestDelegate.openNewForegroundTab( + window, + "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html", + true + ); + + await extension.startup(); + await extension.awaitFinish("execute-script"); + await extension.unload(); + + await AppTestDelegate.removeTab(window, tab); +}); + +add_task(async function test_execution_world_constants() { + let extension = makeExtension({ + async background() { + browser.test.assertTrue( + !!browser.scripting.ExecutionWorld, + "expected scripting.ExecutionWorld to be defined" + ); + browser.test.assertEq( + 1, + Object.keys(browser.scripting.ExecutionWorld).length, + "expected 1 ExecutionWorld constant" + ); + browser.test.assertEq( + "ISOLATED", + browser.scripting.ExecutionWorld.ISOLATED, + "expected ISOLATED constant to be defined" + ); + // TODO: Bug 1736575 - Add support for other execution worlds like MAIN. + browser.test.assertEq( + undefined, + browser.scripting.ExecutionWorld.MAIN, + "expected MAIN constant to be undefined" + ); + + browser.test.notifyPass("background-done"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("background-done"); + await extension.unload(); +}); + +add_task(async function test_executeScript_with_wrong_host_permissions() { + let extension = makeExtension({ + manifest: { + host_permissions: [], + }, + async background() { + const tabs = await browser.tabs.query({ active: true }); + + browser.test.assertEq(1, tabs.length, "expected 1 tab"); + + await browser.test.assertRejects( + browser.scripting.executeScript({ + target: { tabId: tabs[0].id }, + func: () => { + browser.test.fail("Unexpected execution"); + }, + }), + "Missing host permission for the tab", + "expected host permission error" + ); + + browser.test.notifyPass("execute-script"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("execute-script"); + await extension.unload(); +}); + +add_task(async function test_executeScript_with_invalid_tabId() { + let extension = makeExtension({ + async background() { + // This tab ID should not exist. + const tabId = 123456789; + + await browser.test.assertRejects( + browser.scripting.executeScript({ + target: { tabId }, + func: () => { + browser.test.fail("Unexpected execution"); + }, + }), + `Invalid tab ID: ${tabId}` + ); + + browser.test.notifyPass("execute-script"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("execute-script"); + await extension.unload(); +}); + +add_task(async function test_executeScript_with_func() { + let extension = makeExtension({ + async background() { + const getTitle = () => { + return document.title; + }; + + const tabs = await browser.tabs.query({ active: true }); + + browser.test.assertEq(1, tabs.length, "expected 1 tab"); + + const results = await browser.scripting.executeScript({ + target: { tabId: tabs[0].id }, + func: getTitle, + }); + + browser.test.assertEq( + 1, + results.length, + "got expected number of results" + ); + browser.test.assertEq( + "file sample", + results[0].result, + "got the expected title" + ); + browser.test.assertEq(0, results[0].frameId, "got the expected frameId"); + + browser.test.notifyPass("execute-script"); + }, + }); + + let tab = await AppTestDelegate.openNewForegroundTab( + window, + "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html", + true + ); + + await extension.startup(); + await extension.awaitFinish("execute-script"); + await extension.unload(); + + await AppTestDelegate.removeTab(window, tab); +}); + +add_task(async function test_executeScript_with_func_and_args() { + let extension = makeExtension({ + async background() { + const formatArgs = (a, b, c) => { + return `received ${a}, ${b} and ${c}`; + }; + + const tabs = await browser.tabs.query({ active: true }); + + browser.test.assertEq(1, tabs.length, "expected 1 tab"); + + const results = await browser.scripting.executeScript({ + target: { tabId: tabs[0].id }, + func: formatArgs, + args: [true, undefined, "str"], + }); + + browser.test.assertEq( + 1, + results.length, + "got expected number of results" + ); + browser.test.assertEq( + // undefined is converted to null when json-stringified in an array. + "received true, null and str", + results[0].result, + "got the expected return value" + ); + browser.test.assertEq(0, results[0].frameId, "got the expected frameId"); + + browser.test.notifyPass("execute-script"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("execute-script"); + await extension.unload(); +}); + +add_task(async function test_executeScript_returns_nothing() { + let extension = makeExtension({ + async background() { + const tabs = await browser.tabs.query({ active: true }); + + browser.test.assertEq(1, tabs.length, "expected 1 tab"); + + const results = await browser.scripting.executeScript({ + target: { tabId: tabs[0].id }, + func: () => {}, + }); + + browser.test.assertEq( + 1, + results.length, + "got expected number of results" + ); + browser.test.assertEq( + undefined, + results[0].result, + "got expected undefined result" + ); + browser.test.assertEq(0, results[0].frameId, "got the expected frameId"); + + browser.test.notifyPass("execute-script"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("execute-script"); + await extension.unload(); +}); + +add_task(async function test_executeScript_returns_null() { + let extension = makeExtension({ + async background() { + const tabs = await browser.tabs.query({ active: true }); + + browser.test.assertEq(1, tabs.length, "expected 1 tab"); + + const results = await browser.scripting.executeScript({ + target: { tabId: tabs[0].id }, + func: () => { + return null; + }, + }); + + browser.test.assertEq( + 1, + results.length, + "got expected number of results" + ); + browser.test.assertEq( + null, + results[0].result, + "got expected null result" + ); + browser.test.assertEq(0, results[0].frameId, "got the expected frameId"); + + browser.test.notifyPass("execute-script"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("execute-script"); + await extension.unload(); +}); + +add_task(async function test_executeScript_with_error_in_func() { + let extension = makeExtension({ + async background() { + const tabs = await browser.tabs.query({ active: true }); + + browser.test.assertEq(1, tabs.length, "expected 1 tab"); + + const results = await browser.scripting.executeScript({ + target: { tabId: tabs[0].id }, + func: () => { + throw new Error(`Thrown at ${location.pathname.split("/").pop()}`); + }, + }); + + browser.test.assertEq( + 1, + results.length, + "got expected number of results" + ); + browser.test.assertEq(0, results[0].frameId, "got the expected frameId"); + browser.test.assertEq( + "Thrown at file_sample.html", + results[0].error.message, + "got the expected error message" + ); + + browser.test.notifyPass("execute-script"); + }, + }); + + let tab = await AppTestDelegate.openNewForegroundTab( + window, + "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html", + true + ); + + await extension.startup(); + await extension.awaitFinish("execute-script"); + await extension.unload(); + + await AppTestDelegate.removeTab(window, tab); +}); + +add_task(async function test_executeScript_with_a_file() { + let extension = makeExtension({ + async background() { + const tabs = await browser.tabs.query({ active: true }); + + browser.test.assertEq(1, tabs.length, "expected 1 tab"); + + const results = await browser.scripting.executeScript({ + target: { tabId: tabs[0].id }, + files: ["script.js"], + }); + + browser.test.assertEq( + 1, + results.length, + "got expected number of results" + ); + browser.test.assertEq( + "value from script.js", + results[0].result, + "got the expected result" + ); + browser.test.assertEq(0, results[0].frameId, "got the expected frameId"); + + browser.test.notifyPass("execute-script"); + }, + files: { + "script.js": function() { + return "value from script.js"; + }, + }, + }); + + await extension.startup(); + await extension.awaitFinish("execute-script"); + await extension.unload(); +}); + +add_task(async function test_executeScript_in_one_frame() { + let extension = makeExtension({ + manifest: { + permissions: ["scripting", "webNavigation"], + }, + async background() { + const tabs = await browser.tabs.query({ active: true }); + browser.test.assertEq(1, tabs.length, "expected 1 tab"); + + const tabId = tabs[0].id; + const frames = await browser.webNavigation.getAllFrames({ tabId }); + // 1. Top-level frame with the MochiTest runner + // 2. Frame for this file + // 3. Frame that loads `file_sample.html` at the top of this file + browser.test.assertEq(3, frames.length, "expected 3 frames"); + + const fileSampleFrameId = frames[2].frameId; + browser.test.assertTrue( + frames[2].url.includes("file_sample.html"), + "expected frame URL" + ); + + const TEST_CASES = [ + { + title: "with a file and a frame ID", + params: { + target: { tabId, frameIds: [fileSampleFrameId] }, + files: ["script.js"], + }, + expectedResults: [ + { + frameId: fileSampleFrameId, + result: "Sample text", + }, + ], + }, + { + title: "with no frame ID", + params: { + target: { tabId }, + func: () => { + return 123; + }, + }, + expectedResults: [{ frameId: 0, result: 123 }], + }, + ]; + + for (const { title, params, expectedResults } of TEST_CASES) { + const results = await browser.scripting.executeScript(params); + + browser.test.assertEq( + expectedResults.length, + results.length, + `${title} - got expected number of results` + ); + expectedResults.forEach(({ frameId, result }, index) => { + browser.test.assertEq( + result, + results[index].result, + `${title} - got the expected results[${index}].result` + ); + browser.test.assertEq( + frameId, + results[index].frameId, + `${title} - got the expected results[${index}].frameId` + ); + }); + } + + browser.test.notifyPass("execute-script"); + }, + files: { + "script.js": function() { + return document.getElementById("test").textContent; + }, + }, + }); + + await extension.startup(); + await extension.awaitFinish("execute-script"); + await extension.unload(); +}); + +add_task(async function test_executeScript_in_multiple_frameIds() { + let extension = makeExtension({ + manifest: { + permissions: ["scripting", "webNavigation"], + }, + async background() { + const tabs = await browser.tabs.query({ active: true }); + browser.test.assertEq(1, tabs.length, "expected 1 tab"); + + const tabId = tabs[0].id; + const frames = await browser.webNavigation.getAllFrames({ tabId }); + // 1. Top-level frame that loads `file_contains_iframe.html` + // 2. Frame that loads `file_contains_img.html` + browser.test.assertEq(2, frames.length, "expected 2 frames"); + + const frameIds = frames.map(frame => frame.frameId); + + const getTitle = () => { + return document.title; + }; + + const TEST_CASES = [ + { + title: "multiple frame IDs", + params: { + target: { tabId, frameIds }, + func: getTitle, + }, + expectedResults: [ + { + frameId: frameIds[0], + result: "file contains iframe", + }, + { + frameId: frameIds[1], + result: "file contains img", + }, + ], + }, + { + title: "empty list of frame IDs", + params: { + target: { tabId, frameIds: [] }, + func: getTitle, + }, + expectedResults: [], + }, + ]; + + for (const { title, params, expectedResults } of TEST_CASES) { + const results = await browser.scripting.executeScript(params); + + browser.test.assertEq( + expectedResults.length, + results.length, + `${title} - got expected number of results` + ); + // Sort injection results by frameId to always assert the results in + // the same order. + results.sort((a, b) => a.frameId - b.frameId); + + browser.test.assertEq( + JSON.stringify(expectedResults), + JSON.stringify(results), + `${title} - got expected results` + ); + } + + browser.test.notifyPass("execute-script"); + }, + }); + + let tab = await AppTestDelegate.openNewForegroundTab( + window, + "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html", + true + ); + + await extension.startup(); + await extension.awaitFinish("execute-script"); + await extension.unload(); + + await AppTestDelegate.removeTab(window, tab); +}); + +add_task(async function test_executeScript_with_errors_in_multiple_frameIds() { + let extension = makeExtension({ + manifest: { + permissions: ["scripting", "webNavigation"], + }, + async background() { + const tabs = await browser.tabs.query({ active: true }); + browser.test.assertEq(1, tabs.length, "expected 1 tab"); + + const tabId = tabs[0].id; + const frames = await browser.webNavigation.getAllFrames({ tabId }); + // 1. Top-level frame that loads `file_contains_iframe.html` + // 2. Frame that loads `file_contains_img.html` + browser.test.assertEq(2, frames.length, "expected 2 frames"); + + const frameIds = frames.map(frame => frame.frameId); + + const results = await browser.scripting.executeScript({ + target: { tabId, frameIds }, + func: () => { + throw new Error(`Thrown at ${location.pathname.split("/").pop()}`); + }, + }); + + browser.test.assertEq( + 2, + results.length, + "got expected number of results" + ); + browser.test.assertEq( + "Thrown at file_contains_iframe.html", + results[0].error.message, + "got expected error message in results[0]" + ); + browser.test.assertEq( + "Thrown at file_contains_img.html", + results[1].error.message, + "got expected error message in results[1]" + ); + + browser.test.notifyPass("execute-script"); + }, + }); + + let tab = await AppTestDelegate.openNewForegroundTab( + window, + "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html", + true + ); + + await extension.startup(); + await extension.awaitFinish("execute-script"); + await extension.unload(); + + await AppTestDelegate.removeTab(window, tab); +}); + +add_task(async function test_executeScript_with_frameId_and_wrong_host_permission() { + let extension = makeExtension({ + manifest: { + host_permissions: MOCHITEST_HOST_PERMISSIONS, + permissions: ["scripting", "webNavigation"], + }, + async background() { + const tabs = await browser.tabs.query({ active: true }); + browser.test.assertEq(1, tabs.length, "expected 1 tab"); + + const tabId = tabs[0].id; + const frames = await browser.webNavigation.getAllFrames({ tabId }); + // 1. Top-level frame with the MochiTest runner + // 2. Frame for this file + // 3. Frame that loads `file_sample.html` at the top of this file + browser.test.assertEq(3, frames.length, "expected 3 frames"); + + const frameIds = frames.map(frame => frame.frameId); + + await browser.test.assertRejects( + browser.scripting.executeScript({ + target: { tabId, frameIds: [frameIds[2]] }, + func: () => { + browser.test.fail("Unexpected execution"); + }, + }), + "Missing host permission for the tab or frames", + "got the expected error message" + ); + + browser.test.notifyPass("execute-script"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("execute-script"); + await extension.unload(); +}); + +add_task(async function test_executeScript_with_multiple_frameIds_and_wrong_host_permissions() { + let extension = makeExtension({ + manifest: { + host_permissions: MOCHITEST_HOST_PERMISSIONS, + permissions: ["scripting", "webNavigation"], + }, + async background() { + const tabs = await browser.tabs.query({ active: true }); + browser.test.assertEq(1, tabs.length, "expected 1 tab"); + + const tabId = tabs[0].id; + const frames = await browser.webNavigation.getAllFrames({ tabId }); + // 1. Top-level frame with the MochiTest runner + // 2. Frame for this file + // 3. Frame that loads `file_sample.html` at the top of this file + browser.test.assertEq(3, frames.length, "expected 3 frames"); + + const frameIds = frames.map(frame => frame.frameId); + + const results = await browser.scripting.executeScript({ + target: { tabId, frameIds }, + func: () => {}, + }); + + // We get 2 results because we cannot inject into the 3rd frame. + browser.test.assertEq( + 2, + results.length, + "got expected number of results" + ); + browser.test.assertTrue( + typeof results[0].error === "undefined", + "expected no error in results[0]" + ); + browser.test.assertTrue( + typeof results[1].error === "undefined", + "expected no error in results[1]" + ); + + browser.test.notifyPass("execute-script"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("execute-script"); + await extension.unload(); +}); + +add_task(async function test_executeScript_with_iframe_srcdoc_and_aboutblank() { + let iframe = document.createElement("iframe"); + iframe.srcdoc = `<!DOCTYPE html> + <html> + <head><title>iframe with srcdoc</title></head> + </html>`; + await new Promise(resolve => { + iframe.onload = resolve; + document.body.appendChild(iframe); + }); + + let iframeAboutBlank = document.createElement("iframe"); + iframeAboutBlank.src = "about:blank"; + await new Promise(resolve => { + iframeAboutBlank.onload = resolve; + document.body.appendChild(iframeAboutBlank); + }); + + let extension = makeExtension({ + manifest: { + permissions: ["scripting", "webNavigation"], + }, + async background() { + const tabs = await browser.tabs.query({ active: true }); + browser.test.assertEq(1, tabs.length, "expected 1 tab"); + + const tabId = tabs[0].id; + const frames = await browser.webNavigation.getAllFrames({ tabId }); + // 1. Top-level frame with the MochiTest runner + // 2. Frame for this file + // 3. Frame that loads `file_sample.html` at the top of this file + // 4. Frame that loads the `srcdoc` + // 5. Frame for `about:blank` + browser.test.assertEq(5, frames.length, "expected 5 frames"); + + const frameIds = frames.map(frame => frame.frameId); + + const TEST_CASES = [ + { + title: "with frameIds for all frames", + params: { + target: { tabId, frameIds }, + }, + expectedResults: { + count: 5, + entriesAtIndex: { + 3: { + frameId: frameIds[3], + result: "iframe with srcdoc", + }, + 4: { + frameId: frameIds[4], + result: "about:blank", + }, + }, + }, + }, + { + title: "with allFrames: true", + params: { + target: { tabId, allFrames: true }, + }, + expectedResults: { + count: 5, + entriesAtIndex: { + 3: { + frameId: frameIds[3], + result: "iframe with srcdoc", + }, + 4: { + frameId: frameIds[4], + result: "about:blank", + }, + }, + }, + }, + { + title: "with a single frame specified", + params: { + target: { tabId, frameIds: [frameIds[3]] }, + }, + expectedResults: { + count: 1, + entriesAtIndex: { + 0: { + frameId: frameIds[3], + result: "iframe with srcdoc", + }, + }, + }, + }, + ]; + + for (const { title, params, expectedResults } of TEST_CASES) { + const results = await browser.scripting.executeScript({ + ...params, + func: () => { + return document.title || document.URL; + }, + }); + // Sort injection results by frameId to always assert the results in + // the same order. + results.sort((a, b) => a.frameId - b.frameId); + + browser.test.assertEq( + expectedResults.count, + results.length, + `${title} - got the expected number of results` + ); + Object.keys(expectedResults.entriesAtIndex).forEach(index => { + browser.test.assertEq( + JSON.stringify(expectedResults.entriesAtIndex[index]), + JSON.stringify(results[index]), + `${title} - got expected results[${index}]` + ); + }); + } + + browser.test.notifyPass("execute-script"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("execute-script"); + await extension.unload(); + + iframe.remove(); + iframeAboutBlank.remove(); +}); + +add_task(async function test_executeScript_with_multiple_files() { + let extension = makeExtension({ + async background() { + const tabs = await browser.tabs.query({ active: true }); + + browser.test.assertEq(1, tabs.length, "expected 1 tab"); + + const results = await browser.scripting.executeScript({ + target: { tabId: tabs[0].id }, + files: ["1.js", "2.js"], + }); + + browser.test.assertEq( + 1, + results.length, + "got expected number of results" + ); + browser.test.assertEq( + "value from 2.js", + results[0].result, + "got the expected result" + ); + browser.test.assertEq(0, results[0].frameId, "got the expected frameId"); + + browser.test.notifyPass("execute-script"); + }, + files: { + "1.js": function() { + return "value from 1.js"; + }, + "2.js": function() { + return "value from 2.js"; + }, + }, + }); + + await extension.startup(); + await extension.awaitFinish("execute-script"); + await extension.unload(); +}); + +add_task(async function test_executeScript_with_multiple_files_and_an_error() { + let tab = await AppTestDelegate.openNewForegroundTab( + window, + "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html", + true + ); + + let extension = makeExtension({ + async background() { + const tabs = await browser.tabs.query({ active: true }); + + browser.test.assertEq(1, tabs.length, "expected 1 tab"); + + const results = await browser.scripting.executeScript({ + target: { tabId: tabs[0].id }, + files: ["1.js", "2.js"], + }); + + browser.test.assertEq( + 1, + results.length, + "got expected number of results" + ); + browser.test.assertEq(0, results[0].frameId, "got the expected frameId"); + browser.test.assertEq( + "Thrown at file_contains_iframe.html", + results[0].error.message, + "got the expected error message" + ); + + browser.test.notifyPass("execute-script"); + }, + files: { + "1.js": function() { + throw new Error(`Thrown at ${location.pathname.split("/").pop()}`); + }, + "2.js": function() { + return "value from 2.js"; + }, + }, + }); + + await extension.startup(); + await extension.awaitFinish("execute-script"); + await extension.unload(); + + await AppTestDelegate.removeTab(window, tab); +}); + +add_task(async function test_executeScript_with_file_not_in_extension() { + let tab = await AppTestDelegate.openNewForegroundTab( + window, + "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html", + true + ); + + let extension = makeExtension({ + async background() { + const tabs = await browser.tabs.query({ active: true }); + + browser.test.assertEq(1, tabs.length, "expected 1 tab"); + + await browser.test.assertRejects( + browser.scripting.executeScript({ + target: { tabId: tabs[0].id }, + files: ["https://example.com/script.js"], + }), + /Files to be injected must be within the extension/, + "got the expected error message" + ); + + browser.test.notifyPass("execute-script"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("execute-script"); + await extension.unload(); + + await AppTestDelegate.removeTab(window, tab); +}); + +add_task(async function test_executeScript_allFrames() { + let extension = makeExtension({ + manifest: { + permissions: ["scripting", "webNavigation"], + }, + async background() { + const tabs = await browser.tabs.query({ active: true }); + browser.test.assertEq(1, tabs.length, "expected 1 tab"); + + const tabId = tabs[0].id; + const frames = await browser.webNavigation.getAllFrames({ tabId }); + // 1. Top-level frame that loads `file_contains_iframe.html` + // 2. Frame that loads `file_contains_img.html` + browser.test.assertEq(2, frames.length, "expected 2 frames"); + const frameIds = frames.map(frame => frame.frameId); + + const getTitle = () => { + return document.title; + }; + + const TEST_CASES = [ + { + title: "allFrames set to true", + scriptingParams: { + target: { tabId, allFrames: true }, + func: getTitle, + }, + expectedResults: [ + { + frameId: frameIds[0], + result: "file contains iframe", + }, + { + frameId: frameIds[1], + result: "file contains img", + }, + ], + }, + { + title: "allFrames set to false", + scriptingParams: { + target: { tabId, allFrames: false }, + func: getTitle, + }, + expectedResults: [ + { + frameId: frameIds[0], + result: "file contains iframe", + }, + ], + }, + ]; + + for (const { title, scriptingParams, expectedResults } of TEST_CASES) { + const results = await browser.scripting.executeScript(scriptingParams); + // Sort injection results by frameId to always assert the results in + // the same order. + results.sort((a, b) => a.frameId - b.frameId); + + browser.test.assertDeepEq( + expectedResults, + results, + `${title} - got expected results` + ); + + // Make sure the `error` prop is never set. + for (const result of results) { + browser.test.assertFalse( + "error" in result, + `${title} - expected error property to be unset` + ); + } + } + + browser.test.notifyPass("execute-script"); + }, + }); + + let tab = await AppTestDelegate.openNewForegroundTab( + window, + "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html", + true + ); + + await extension.startup(); + await extension.awaitFinish("execute-script"); + await extension.unload(); + + await AppTestDelegate.removeTab(window, tab); +}); + +add_task(async function test_executeScript_runtime_errors() { + let extension = makeExtension({ + manifest: { + permissions: ["scripting", "webNavigation"], + }, + async background() { + const tabs = await browser.tabs.query({ active: true }); + browser.test.assertEq(1, tabs.length, "expected 1 tab"); + + const tabId = tabs[0].id; + const frames = await browser.webNavigation.getAllFrames({ tabId }); + // 1. Top-level frame that loads `file_contains_iframe.html` + // 2. Frame that loads `file_contains_img.html` + browser.test.assertEq(2, frames.length, "expected 2 frames"); + + const TEST_CASES = [ + { + title: "reference error", + scriptingParams: { + target: { tabId }, + func: () => { + // We do not define `e` on purpose. + // eslint-disable-next-line no-undef + return String(e); + }, + }, + expectedErrors: [ + { type: "Error", stringRepr: "ReferenceError: e is not defined" }, + ], + }, + { + title: "eval error", + scriptingParams: { + target: { tabId }, + func: () => { + // We use `eval()` on purpose. + // eslint-disable-next-line no-eval + eval(""); + }, + }, + expectedErrors: [ + { type: "Error", stringRepr: "EvalError: call to eval() blocked by CSP" }, + ], + }, + { + title: "errors thrown in allFrames", + scriptingParams: { + target: { tabId, allFrames: true }, + func: () => { + throw new Error(`Thrown at ${location.pathname.split("/").pop()}`); + }, + }, + expectedErrors: [ + { type: "Error", stringRepr: "Error: Thrown at file_contains_iframe.html" }, + { type: "Error", stringRepr: "Error: Thrown at file_contains_img.html" }, + ], + }, + { + title: "custom error", + scriptingParams: { + target: { tabId }, + func: () => { + class CustomError extends Error { + constructor(message) { + super(message); + + this.name = 'CustomError'; + } + } + + throw new CustomError("a custom error message"); + }, + }, + // See Bug 1556604 for why a custom (derived) error looks like a + // normal error object after cloning. + expectedErrors: [ + { type: "Error", stringRepr: "Error: a custom error message" }, + ], + }, + { + title: "promise rejection with a string value", + scriptingParams: { + target: { tabId }, + func: () => { + // eslint-disable-next-line no-throw-literal + throw 'an error message'; + }, + }, + expectedErrors: [ + { type: "String", stringRepr: "an error message" }, + ], + }, + { + title: "promise rejection with an error", + scriptingParams: { + target: { tabId }, + func: () => { + throw new Error('ooops'); + }, + }, + expectedErrors: [ + { type: "Error", stringRepr: "Error: ooops" }, + ], + }, + { + title: "promise rejection with null", + scriptingParams: { + target: { tabId }, + func: () => { + throw null; // eslint-disable-line no-throw-literal + }, + }, + expectedErrors: [ + // This means we would receive `error: null`. + { type: "Null", stringRepr: "null" }, + ], + }, + { + title: "promise rejection with undefined", + scriptingParams: { + target: { tabId }, + func: () => { + return new Promise((resolve, reject) => { + reject(undefined); + }); + }, + }, + expectedErrors: [ + // This means we would receive `error: undefined`. + { type: "Undefined", stringRepr: "undefined" }, + ], + }, + { + title: "promise rejection with empty string", + scriptingParams: { + target: { tabId }, + func: () => { + throw ""; // eslint-disable-line no-throw-literal + }, + }, + expectedErrors: [ + { type: "String", stringRepr: "" }, + ], + }, + { + title: "promise rejection with zero", + scriptingParams: { + target: { tabId }, + func: () => { + throw 0; // eslint-disable-line no-throw-literal + }, + }, + expectedErrors: [ + { type: "Number", stringRepr: "0" }, + ], + }, + { + title: "promise rejection with false", + scriptingParams: { + target: { tabId }, + func: () => { + throw false; // eslint-disable-line no-throw-literal + }, + }, + expectedErrors: [ + { type: "Boolean", stringRepr: "false" }, + ], + }, + ]; + + for (const { title, scriptingParams, expectedErrors } of TEST_CASES) { + const results = await browser.scripting.executeScript(scriptingParams); + // Sort injection results by frameId to always assert the results in + // the same order. + results.sort((a, b) => a.frameId - b.frameId); + + browser.test.assertEq( + expectedErrors.length, + results.length, + `expected ${expectedErrors.length} results` + ); + + for (const [i, { type, stringRepr }] of expectedErrors.entries()) { + browser.test.assertTrue( + "error" in results[i], + `${title} - expected error property to be set` + ); + browser.test.assertFalse( + "result" in results[i], + `${title} - expected result property to be unset` + ); + + const { frameId, error } = results[i]; + + browser.test.assertEq( + `[object ${type}]`, + Object.prototype.toString.call(error), + `${title} - expected instance of ${type} - ${frameId}` + ); + browser.test.assertEq( + stringRepr, + String(error), + `${title} - got expected errors - ${frameId}` + ); + } + } + + browser.test.notifyPass("execute-script"); + }, + }); + + let tab = await AppTestDelegate.openNewForegroundTab( + window, + "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html", + true + ); + + await extension.startup(); + await extension.awaitFinish("execute-script"); + await extension.unload(); + + await AppTestDelegate.removeTab(window, tab); +}); + +add_task( + async function test_executeScript_with_allFrames_and_wrong_host_permissions() { + let extension = makeExtension({ + manifest: { + host_permissions: MOCHITEST_HOST_PERMISSIONS, + permissions: ["scripting", "webNavigation"], + }, + async background() { + const tabs = await browser.tabs.query({ active: true }); + browser.test.assertEq(1, tabs.length, "expected 1 tab"); + + const tabId = tabs[0].id; + const frames = await browser.webNavigation.getAllFrames({ tabId }); + // 1. Top-level frame with the MochiTest runner + // 2. Frame for this file + // 3. Frame that loads `file_sample.html` at the top of this file + browser.test.assertEq(3, frames.length, "expected 3 frames"); + + const results = await browser.scripting.executeScript({ + target: { tabId, allFrames: true }, + func: () => {}, + }); + + browser.test.assertEq( + 2, + results.length, + "got expected number of results" + ); + browser.test.assertTrue( + typeof results[0].error === "undefined", + "expected no error in results[0]" + ); + browser.test.assertTrue( + typeof results[1].error === "undefined", + "expected no error in results[1]" + ); + + browser.test.notifyPass("execute-script"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("execute-script"); + await extension.unload(); + } +); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript_activeTab.html b/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript_activeTab.html new file mode 100644 index 0000000000..5eb2193409 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript_activeTab.html @@ -0,0 +1,144 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tests scripting.executeScript() and activeTab</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> + +"use strict"; + +const makeExtension = ({ manifest: manifestProps, ...otherProps }) => { + return ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + action: {}, + ...manifestProps, + }, + ...otherProps, + }); +}; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.manifestV3.enabled", true]], + }); +}); + +async function verifyExecuteScriptActiveTab(permissions, host_permissions) { + let extension = makeExtension({ + manifest: { + permissions: ["scripting", ...permissions], + host_permissions, + }, + background() { + browser.action.onClicked.addListener(async tab => { + const results = await browser.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => document.title, + }); + + browser.test.assertEq( + 1, + results.length, + "got expected number of results" + ); + browser.test.assertEq( + "file sample", + results[0].result, + "got the expected title" + ); + browser.test.assertEq( + 0, + results[0].frameId, + "got the expected frameId" + ); + + browser.test.sendMessage("execute-script"); + }); + + browser.test.onMessage.addListener(async msg => { + switch (msg) { + case "reload-and-execute": + const tabs = await browser.tabs.query({ active: true }); + const tabId = tabs[0].id; + + let promiseTabLoad = new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener(updatedTabId, changeInfo) { + browser.test.assertEq(tabId, updatedTabId, "got expected tabId"); + + if (tabId === updatedTabId && changeInfo.status === "complete") { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + + await browser.tabs.reload(); + await promiseTabLoad; + + await browser.test.assertRejects( + browser.scripting.executeScript({ + target: { tabId }, + func: () => { + browser.test.fail("Unexpected execution"); + }, + }), + "Missing host permission for the tab", + "expected host permission error" + ); + + browser.test.sendMessage("execute-script-after-reload"); + + break; + default: + browser.test.fail(`invalid message received: ${msg}`); + } + }); + + browser.test.sendMessage("background-ready"); + }, + }); + + let tab = await AppTestDelegate.openNewForegroundTab( + window, + "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html", + true + ); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + await AppTestDelegate.clickBrowserAction(window, extension); + await extension.awaitMessage("execute-script"); + await AppTestDelegate.closeBrowserAction(window, extension); + + extension.sendMessage("reload-and-execute"); + await extension.awaitMessage("execute-script-after-reload"); + + await extension.unload(); + + await AppTestDelegate.removeTab(window, tab); +} + +// Test executeScript works with the standard activeTab permission. +add_task(async function test_executeScript_activeTab_permission() { + await verifyExecuteScriptActiveTab(["activeTab"], []); +}); + +// Test executeScript works with automatic activeTab granted from optional +// host permissions. +add_task(async function test_executeScript_activeTab_automatic_originControls() { + await verifyExecuteScriptActiveTab([], ["*://test1.example.com/*"]); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript_injectImmediately.html b/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript_injectImmediately.html new file mode 100644 index 0000000000..9d05925adc --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript_injectImmediately.html @@ -0,0 +1,215 @@ +<!DOCTYPE HTML> + <html> +<head> + <meta charset="utf-8"> + <title>Tests scripting.executeScript() and injectImmediately</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> + +"use strict"; + +const MOCHITEST_HOST_PERMISSIONS = [ + "*://mochi.test/", + "*://mochi.xorigin-test/", + "*://test1.example.com/", +]; + +const makeExtension = ({ manifest: manifestProps, ...otherProps }) => { + return ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + permissions: ["scripting"], + host_permissions: [ + ...MOCHITEST_HOST_PERMISSIONS, + // Used in `file_contains_iframe.html` + "https://example.org/", + ], + granted_host_permissions: true, + ...manifestProps, + }, + useAddonManager: "temporary", + ...otherProps, + }); +}; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.manifestV3.enabled", true]], + }); +}); + +add_task(async function test_executeScript_injectImmediately() { + let extension = makeExtension({ + async background() { + const tabs = await browser.tabs.query({ active: true }); + const tabId = tabs[0].id; + + let onUpdatedPromise = (tabId, url, status) => { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener(_, changed, tab) { + if (tabId == tab.id && changed.status == status && tab.url == url) { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + }; + + const url = [ + "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/", + `file_slowed_document.sjs?with-iframe&r=${Math.random()}`, + ].join(""); + const loadingPromise = onUpdatedPromise(tabId, url, "loading"); + const completePromise = onUpdatedPromise(tabId, url, "complete"); + + await browser.tabs.update(tabId, { url }); + await loadingPromise; + + const func = () => { + window.counter = (window.counter || 0) + 1; + + return window.counter; + }; + + let results = await Promise.all([ + // counter = 1 + browser.scripting.executeScript({ + target: { tabId }, + func, + injectImmediately: true, + }), + // counter = 3 + browser.scripting.executeScript({ + target: { tabId }, + func, + injectImmediately: false, + }), + // counter = 4 + browser.scripting.executeScript({ + target: { tabId }, + func, + // `injectImmediately` is `false` by default + }), + // counter = 2 + browser.scripting.executeScript({ + target: { tabId }, + func, + injectImmediately: true, + }), + // counter = 5 + browser.scripting.executeScript({ + target: { tabId }, + func, + injectImmediately: false, + }), + ]); + browser.test.assertEq( + 5, + results.length, + "got expected number of results" + ); + browser.test.assertEq( + "1 3 4 2 5", + results.map(res => res[0].result).join(" "), + `got expected results: ${JSON.stringify(results)}` + ); + + await completePromise; + + browser.test.notifyPass("execute-script"); + }, + }); + + + let tab = await AppTestDelegate.openNewForegroundTab( + window, + "https://test1.example.com/", + true + ); + + await extension.startup(); + await extension.awaitFinish("execute-script"); + await extension.unload(); + + await AppTestDelegate.removeTab(window, tab); +}); + +add_task(async function test_executeScript_injectImmediately_after_document_idle() { + let extension = makeExtension({ + async background() { + const tabs = await browser.tabs.query({ active: true }); + browser.test.assertEq(1, tabs.length, "expected 1 tab"); + + const tabId = tabs[0].id; + + const func = () => { + window.counter = (window.counter || 0) + 1; + + return window.counter; + }; + + let results = await Promise.all([ + browser.scripting.executeScript({ + target: { tabId }, + func, + injectImmediately: true, + }), + browser.scripting.executeScript({ + target: { tabId }, + func, + injectImmediately: false, + }), + browser.scripting.executeScript({ + target: { tabId }, + func, + // `injectImmediately` is `false` by default + }), + browser.scripting.executeScript({ + target: { tabId }, + func, + injectImmediately: true, + }), + browser.scripting.executeScript({ + target: { tabId }, + func, + injectImmediately: false, + }), + ]); + browser.test.assertEq( + 5, + results.length, + "got expected number of results" + ); + browser.test.assertEq( + "1 2 3 4 5", + results.map(res => res[0].result).join(" "), + `got expected results: ${JSON.stringify(results)}` + ); + + browser.test.notifyPass("execute-script"); + }, + }); + + let tab = await AppTestDelegate.openNewForegroundTab( + window, + "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html", + true + ); + + await extension.startup(); + await extension.awaitFinish("execute-script"); + await extension.unload(); + + await AppTestDelegate.removeTab(window, tab); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_scripting_insertCSS.html b/toolkit/components/extensions/test/mochitest/test_ext_scripting_insertCSS.html new file mode 100644 index 0000000000..3e2cef8721 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_scripting_insertCSS.html @@ -0,0 +1,395 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tests scripting.insertCSS()</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +const MOCHITEST_HOST_PERMISSIONS = [ + "*://mochi.test/", + "*://mochi.xorigin-test/", + "*://test1.example.com/", +]; + +const makeExtension = ({ manifest: manifestProps, ...otherProps }) => { + return ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + permissions: ["scripting"], + host_permissions: [ + ...MOCHITEST_HOST_PERMISSIONS, + // Used in `file_contains_iframe.html` + "https://example.org/", + ], + granted_host_permissions: true, + ...manifestProps, + }, + useAddonManager: "temporary", + ...otherProps, + }); +}; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.manifestV3.enabled", true]], + }); +}); + +add_task(async function test_insertCSS_and_removeCSS_params_validation() { + let extension = makeExtension({ + async background() { + const tabs = await browser.tabs.query({ active: true }); + + const TEST_CASES = [ + { + title: "no files and no css", + cssParams: {}, + expectedError: "Exactly one of files and css must be specified.", + }, + { + title: "both files and css are passed", + cssParams: { + files: ["styles.css"], + css: "* { background: rgb(1, 1, 1) }", + }, + expectedError: "Exactly one of files and css must be specified.", + }, + { + title: "both allFrames and frameIds are passed", + cssParams: { + target: { + tabId: tabs[0].id, + allFrames: true, + frameIds: [1, 2, 3], + }, + files: ["styles.css"], + }, + expectedError: "Cannot specify both 'allFrames' and 'frameIds'.", + }, + { + title: "empty css string with a file", + cssParams: { + css: "", + files: ["styles.css"], + }, + expectedError: "Exactly one of files and css must be specified.", + }, + ]; + + for (const { title, cssParams, expectedError } of TEST_CASES) { + await browser.test.assertRejects( + browser.scripting.insertCSS({ + target: { tabId: tabs[0].id }, + ...cssParams, + }), + expectedError, + `${title} - expected error for insertCSS()` + ); + + await browser.test.assertRejects( + browser.scripting.removeCSS({ + target: { tabId: tabs[0].id }, + ...cssParams, + }), + expectedError, + `${title} - expected error for removeCSS()` + ); + } + + browser.test.notifyPass("checks-done"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("checks-done"); + await extension.unload(); +}); + +add_task(async function test_insertCSS_with_invalid_tabId() { + let extension = makeExtension({ + async background() { + // This tab ID should not exist. + const tabId = 123456789; + + await browser.test.assertRejects( + browser.scripting.insertCSS({ + target: { tabId }, + css: "* { background: rgb(1, 1, 1) }", + }), + `Invalid tab ID: ${tabId}` + ); + + browser.test.notifyPass("insert-css"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("insert-css"); + await extension.unload(); +}); + +add_task(async function test_insertCSS_with_wrong_host_permissions() { + let extension = makeExtension({ + manifest: { + host_permissions: [], + }, + async background() { + const tabs = await browser.tabs.query({ active: true }); + + browser.test.assertEq(1, tabs.length, "expected 1 tab"); + + browser.test.assertRejects( + browser.scripting.insertCSS({ + target: { tabId: tabs[0].id }, + css: "* { background: rgb(1, 1, 1) }", + }), + /Missing host permission for the tab/, + "expected host permission error" + ); + + browser.test.notifyPass("insert-css"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("insert-css"); + await extension.unload(); +}); + +add_task(async function test_insertCSS_and_removeCSS() { + let extension = makeExtension({ + manifest: { + permissions: ["scripting", "webNavigation"], + }, + async background() { + const tabs = await browser.tabs.query({ active: true }); + browser.test.assertEq(1, tabs.length, "expected 1 tab"); + + const tabId = tabs[0].id; + + const frames = await browser.webNavigation.getAllFrames({ tabId }); + // 1. Top-level frame that loads `file_contains_iframe.html` + // 2. Frame that loads `file_contains_img.html` + browser.test.assertEq(2, frames.length, "expected 2 frames"); + const frameIds = frames.map(frame => frame.frameId); + + const cssColor1 = "rgb(1, 1, 1)"; + const cssColor2 = "rgb(2, 2, 2)"; + const cssColorInFile1 = "rgb(3, 3, 3)"; + const defaultColor = "rgba(0, 0, 0, 0)"; + + const TEST_CASES = [ + { + title: "with css prop", + elementId: "div-1", + cssParams: [ + { + target: { tabId }, + css: `#div-1 { background: ${cssColor1} }`, + }, + ], + expectedResults: [cssColor1, defaultColor], + }, + { + title: "with a file", + elementId: "div-2", + cssParams: [ + { + target: { tabId }, + files: ["file1.css"], + }, + ], + expectedResults: [cssColorInFile1, defaultColor], + }, + { + title: "css prop in a single frame", + elementId: "div-3", + cssParams: [ + { + target: { tabId, frameIds: [frameIds[0]] }, + css: `#div-3 { background: ${cssColor2} }`, + }, + ], + expectedResults: [cssColor2, defaultColor], + }, + { + title: "css prop in multiple frames", + elementId: "div-4", + cssParams: [ + { + target: { tabId, frameIds }, + css: `#div-4 { background: ${cssColor1} }`, + }, + ], + expectedResults: [cssColor1, cssColor1], + }, + { + title: "allFrames is true", + elementId: "div-5", + cssParams: [ + { + target: { tabId, allFrames: true }, + css: `#div-5 { background: ${defaultColor} }`, + }, + ], + expectedResults: [defaultColor, defaultColor], + }, + { + title: "origin: 'AUTHOR'", + elementId: "div-6", + cssParams: [ + { + target: { tabId }, + css: `#div-6 { background: ${cssColor1} }`, + origin: "AUTHOR", + }, + { + target: { tabId }, + css: `#div-6 { background: ${cssColor2} }`, + origin: "AUTHOR", + }, + ], + expectedResults: [cssColor2, defaultColor], + }, + { + title: "origin: 'USER'", + elementId: "div-7", + cssParams: [ + { + target: { tabId }, + css: `#div-7 { background: ${cssColor1} !important }`, + origin: "USER", + }, + { + target: { tabId }, + css: `#div-7 { background: ${cssColor2} !important }`, + origin: "AUTHOR", + }, + ], + // User has higher importance. + expectedResults: [cssColor1, defaultColor], + }, + { + title: "empty css string", + elementId: "div-8", + cssParams: [ + { + target: { tabId }, + css: "", + }, + ], + expectedResults: [defaultColor, defaultColor], + }, + { + title: "allFrames is false", + elementId: "div-9", + cssParams: [ + { + target: { tabId, allFrames: false }, + css: `#div-9 { background: ${cssColor1} }`, + }, + ], + expectedResults: [cssColor1, defaultColor], + }, + ]; + + const getBackgroundColor = elementId => { + return window.getComputedStyle(document.getElementById(elementId)) + .backgroundColor; + }; + + for (const { + title, + elementId, + cssParams, + expectedResults, + } of TEST_CASES) { + // Create a unique element for the current test case. + await browser.scripting.executeScript({ + target: { tabId, allFrames: true }, + func: elementId => { + const element = document.createElement("div"); + element.setAttribute("id", elementId); + document.body.appendChild(element); + }, + args: [elementId], + }); + + for (const params of cssParams) { + const result = await browser.scripting.insertCSS(params); + // `insertCSS()` should not resolve to a value. + browser.test.assertEq(undefined, result, "got expected empty result"); + } + + let results = await browser.scripting.executeScript({ + target: { tabId, allFrames: true }, + func: getBackgroundColor, + args: [elementId], + }); + results.sort((a, b) => a.frameId - b.frameId); + + browser.test.assertEq( + expectedResults.length, + results.length, + `${title} - got the expected number of results` + ); + results.forEach((result, index) => { + browser.test.assertEq( + expectedResults[index], + result.result, + `${title} - got expected result (index=${index}): ${title}` + ); + }); + + results = await Promise.all( + cssParams.map(params => browser.scripting.removeCSS(params)) + ); + // `removeCSS()` should not resolve to a value. + results.forEach(result => { + browser.test.assertEq(undefined, result, "got expected empty result"); + }); + + results = await browser.scripting.executeScript({ + target: { tabId, allFrames: true }, + func: getBackgroundColor, + args: [elementId], + }); + + browser.test.assertTrue( + results.every(({ result }) => result === defaultColor), + "got expected default color in all frames" + ); + } + + browser.test.notifyPass("insert-and-remove-css"); + }, + files: { + "file1.css": "#div-2 { background: rgb(3, 3, 3) }", + }, + }); + + let tab = await AppTestDelegate.openNewForegroundTab( + window, + "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html", + true + ); + + await extension.startup(); + await extension.awaitFinish("insert-and-remove-css"); + await extension.unload(); + + await AppTestDelegate.removeTab(window, tab); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_scripting_permissions.html b/toolkit/components/extensions/test/mochitest/test_ext_scripting_permissions.html new file mode 100644 index 0000000000..e3e6552290 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_scripting_permissions.html @@ -0,0 +1,149 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tests scripting APIs and permissions</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> + +"use strict"; + +const verifyRegisterContentScripts = async ({ manifest_version }) => { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version, + permissions: ["scripting"], + host_permissions: ["*://example.com/*"], + optional_permissions: ["*://example.org/*"], + + }, + async background() { + browser.test.onMessage.addListener(async (msg, value) => { + switch (msg) { + case "grant-permission": + let granted = await new Promise(resolve => { + browser.test.withHandlingUserInput(() => { + resolve(browser.permissions.request(value)); + }); + }); + browser.test.assertTrue(granted, "permission request succeeded"); + browser.test.sendMessage("permission-granted"); + break; + + default: + browser.test.fail(`invalid message received: ${msg}`); + } + }); + + await browser.scripting.registerContentScripts([ + { + id: "script", + js: ["script.js"], + matches: [ + "*://example.com/*", + "*://example.net/*", + "*://example.org/*", + ], + persistAcrossSessions: false, + }, + ]); + + browser.test.sendMessage("background-ready"); + }, + files: { + "script.js": () => { + browser.test.sendMessage( + "script-ran", + window.location.host + window.location.search + ); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + if (manifest_version > 2) { + extension.sendMessage("grant-permission", { + origins: ["*://example.com/*"], + }); + await extension.awaitMessage("permission-granted"); + } + + // `example.net` is not declared in the list of `permissions`. + let tabExampleNet = await AppTestDelegate.openNewForegroundTab( + window, + "https://example.net/", + true + ); + // `example.org` is listed in `optional_permissions`. + let tabExampleOrg = await AppTestDelegate.openNewForegroundTab( + window, + "https://example.org/", + true + ); + // `example.com` is listed in `permissions`. + let tabExampleCom = await AppTestDelegate.openNewForegroundTab( + window, + "https://example.com/", + true + ); + + let value = await extension.awaitMessage("script-ran"); + ok( + value === "example.com", + `expected: example.com, received: ${value}` + ); + + extension.sendMessage("grant-permission", { + origins: ["*://example.org/*"], + }); + await extension.awaitMessage("permission-granted"); + + let tabExampleOrg2 = await AppTestDelegate.openNewForegroundTab( + window, + "https://example.org/?2", + true + ); + + value = await extension.awaitMessage("script-ran"); + ok( + value === "example.org?2", + `expected: example.org?2, received: ${value}` + ); + + await AppTestDelegate.removeTab(window, tabExampleNet); + await AppTestDelegate.removeTab(window, tabExampleOrg); + await AppTestDelegate.removeTab(window, tabExampleCom); + await AppTestDelegate.removeTab(window, tabExampleOrg2); + + await extension.unload(); +}; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.manifestV3.enabled", true], + ["extensions.webextOptionalPermissionPrompts", false], + ], + }); +}); + +add_task(async function test_scripting_registerContentScripts_mv2() { + await verifyRegisterContentScripts({ manifest_version: 2 }); +}); + +add_task(async function test_scripting_registerContentScripts_mv3() { + await verifyRegisterContentScripts({ manifest_version: 3 }); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_scripting_removeCSS.html b/toolkit/components/extensions/test/mochitest/test_ext_scripting_removeCSS.html new file mode 100644 index 0000000000..3036e49761 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_scripting_removeCSS.html @@ -0,0 +1,135 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tests scripting.removeCSS()</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +const MOCHITEST_HOST_PERMISSIONS = [ + "*://mochi.test/", + "*://mochi.xorigin-test/", + "*://test1.example.com/", +]; + +const makeExtension = ({ manifest: manifestProps, ...otherProps }) => { + return ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + permissions: ["scripting"], + host_permissions: [...MOCHITEST_HOST_PERMISSIONS], + granted_host_permissions: true, + ...manifestProps, + }, + useAddonManager: "temporary", + ...otherProps, + }); +}; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.manifestV3.enabled", true]], + }); +}); + +add_task(async function test_removeCSS_with_invalid_tabId() { + let extension = makeExtension({ + async background() { + // This tab ID should not exist. + const tabId = 123456789; + + await browser.test.assertRejects( + browser.scripting.removeCSS({ + target: { tabId }, + css: "* { background: rgb(42, 42, 42) }", + }), + `Invalid tab ID: ${tabId}` + ); + + browser.test.notifyPass("remove-css"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("remove-css"); + await extension.unload(); +}); + +add_task(async function test_removeCSS_without_insertCSS_called_before() { + let extension = makeExtension({ + async background() { + const tabs = await browser.tabs.query({ active: true }); + browser.test.assertEq(1, tabs.length, "expected 1 tab"); + + browser.scripting + .removeCSS({ + target: { tabId: tabs[0].id }, + css: "* { background: rgb(42, 42, 42) }", + }) + .then(() => { + browser.test.notifyPass("remove-css"); + }) + .catch(() => { + browser.test.notifyFail("remove-css"); + }); + }, + }); + + await extension.startup(); + await extension.awaitFinish("remove-css"); + await extension.unload(); +}); + +add_task(async function test_removeCSS_with_origin_mismatch() { + let extension = makeExtension({ + async background() { + const tabs = await browser.tabs.query({ active: true }); + browser.test.assertEq(1, tabs.length, "expected 1 tab"); + + const cssColor = "rgb(42, 42, 42)"; + const cssParams = { + target: { tabId: tabs[0].id }, + css: `* { background: ${cssColor} !important }`, + }; + + await browser.scripting.insertCSS({ ...cssParams, origin: "AUTHOR" }); + + let results = await browser.scripting.executeScript({ + target: { tabId: tabs[0].id }, + func: () => { + return window.getComputedStyle(document.body).backgroundColor; + }, + }); + + browser.test.assertEq(cssColor, results[0].result, "got expected color"); + + // Here, we pass a different origin, which should result in no CSS + // removal. + await browser.scripting.removeCSS({ ...cssParams, origin: "USER" }); + + browser.test.assertEq( + cssColor, + results[0].result, + "got expected color after removeCSS" + ); + + browser.test.notifyPass("remove-css"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("remove-css"); + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_doublereply.html b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_doublereply.html new file mode 100644 index 0000000000..ffdbc90efb --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_doublereply.html @@ -0,0 +1,100 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +function background() { + // Add two listeners that both send replies. We're supposed to ignore all but one + // of them. Which one is chosen is non-deterministic. + + browser.runtime.onMessage.addListener((msg, sender, sendReply) => { + browser.test.assertTrue(sender.tab.url.endsWith("file_sample.html"), "sender url correct"); + + if (msg == "getreply") { + sendReply("reply1"); + } + }); + + browser.runtime.onMessage.addListener((msg, sender, sendReply) => { + browser.test.assertTrue(sender.tab.url.endsWith("file_sample.html"), "sender url correct"); + + if (msg == "getreply") { + sendReply("reply2"); + } + }); + + function sleep(callback, n = 10) { + if (n == 0) { + callback(); + } else { + setTimeout(function() { sleep(callback, n - 1); }, 0); + } + } + + let done_count = 0; + browser.runtime.onMessage.addListener((msg, sender, sendReply) => { + browser.test.assertTrue(sender.tab.url.endsWith("file_sample.html"), "sender url correct"); + + if (msg == "done") { + done_count++; + browser.test.assertEq(done_count, 1, "got exactly one reply"); + + // Go through the event loop a few times to make sure we don't get multiple replies. + sleep(function() { + browser.test.notifyPass("sendmessage_doublereply"); + }); + } + }); +} + +function contentScript() { + browser.runtime.sendMessage("getreply", function(resp) { + if (resp != "reply1" && resp != "reply2") { + return; // test failed + } + browser.runtime.sendMessage("done"); + }); +} + +let extensionData = { + background, + manifest: { + "permissions": ["tabs"], + "content_scripts": [{ + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + "run_at": "document_start", + }], + }, + + files: { + "content_script.js": contentScript, + }, +}; + +add_task(async function test_contentscript() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let win = window.open("file_sample.html"); + + await Promise.all([waitForLoad(win), extension.awaitFinish("sendmessage_doublereply")]); + + win.close(); + + await extension.unload(); + info("extension unloaded"); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_frameId.html b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_frameId.html new file mode 100644 index 0000000000..6b42073031 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_frameId.html @@ -0,0 +1,45 @@ +<!doctype html> +<head> + <title>Test sendMessage frameId</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<script> +"use strict"; + +add_task(async function test_sendMessage_frameId() { + const html = `<!doctype html><meta charset="utf-8"><script src="script.js"><\/script>`; + + const extension = ExtensionTestUtils.loadExtension({ + background() { + browser.runtime.onMessage.addListener((msg, sender) => { + browser.test.sendMessage(msg, sender); + }); + browser.tabs.create({url: "tab.html"}); + }, + files: { + "iframe.html": html, + "tab.html": `${html}<iframe src="iframe.html"></iframe>`, + "script.js": () => { + browser.runtime.sendMessage(window.top === window ? "tab" : "iframe"); + }, + }, + }); + + await extension.startup(); + + const tab = await extension.awaitMessage("tab"); + ok(tab.url.endsWith("tab.html"), "Got the message from the tab"); + is(tab.frameId, 0, "And sender.frameId is zero"); + + const iframe = await extension.awaitMessage("iframe"); + ok(iframe.url.endsWith("iframe.html"), "Got the message from the iframe"); + is(typeof iframe.frameId, "number", "With sender.frameId of type number"); + ok(iframe.frameId > 0, "And sender.frameId greater than zero"); + + await extension.unload(); +}); + +</script> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_no_receiver.html b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_no_receiver.html new file mode 100644 index 0000000000..a18b003e48 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_no_receiver.html @@ -0,0 +1,115 @@ +<!DOCTYPE html> +<html> +<head> + <title>WebExtension test</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"> +</head> +<body> +<script> +"use strict"; + +async function testFn(expectPromise) { + browser.test.assertEq(null, chrome.runtime.lastError, "no lastError before call"); + let retval = chrome.runtime.sendMessage("msg"); + browser.test.assertEq(null, chrome.runtime.lastError, "no lastError after call"); + if (expectPromise) { + browser.test.assertTrue(retval instanceof Promise, "chrome.runtime.sendMessage should return a promise"); + } else { + browser.test.assertEq(undefined, retval, "return value of chrome.runtime.sendMessage with callback"); + } + + let isAsyncCall = false; + retval = chrome.runtime.sendMessage("msg", reply => { + browser.test.assertEq(undefined, reply, "no reply"); + browser.test.assertTrue(isAsyncCall, "chrome.runtime.sendMessage's callback must be called asynchronously"); + browser.test.assertEq(undefined, retval, "return value of chrome.runtime.sendMessage with callback"); + browser.test.assertEq("Could not establish connection. Receiving end does not exist.", chrome.runtime.lastError.message); + browser.test.sendMessage("finished", retval); + }); + isAsyncCall = true; +} + +add_task(async function test_content_script_sendMessage_without_listener() { + async function contentScript() { + await browser.test.assertRejects( + browser.runtime.sendMessage("msg"), + "Could not establish connection. Receiving end does not exist."); + + browser.test.notifyPass("sendMessage callback was invoked"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [{ + js: ["contentscript.js"], + matches: ["http://mochi.test/*/file_sample.html"], + }], + }, + files: { + "contentscript.js": contentScript, + }, + }); + + await extension.startup(); + + let win = window.open("file_sample.html"); + await extension.awaitFinish("sendMessage callback was invoked"); + win.close(); + + await extension.unload(); +}); + +add_task(async function test_content_script_chrome_sendMessage_without_listener() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 2, + content_scripts: [{ + js: ["contentscript.js"], + matches: ["http://mochi.test/*/file_sample.html"], + }], + }, + // In MV2, chrome namespace in content scripts do get promises, however in background pages they do not. + background: `(${testFn})(false)`, + files: { + "contentscript.js": `(${testFn})(true)`, + }, + }); + + await extension.startup(); + await extension.awaitMessage("finished"); + + let win = window.open("file_sample.html"); + await extension.awaitMessage("finished"); + win.close(); + + await extension.unload(); +}); + +add_task(async function test_chrome_sendMessage_without_listener_v3() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.manifestV3.enabled", true]], + }); + + // We only test the background here because content script behavior + // is independant of the manifest version. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + }, + background: `(${testFn})(true)`, + }); + + await extension.startup(); + + await extension.awaitMessage("finished"); + + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply.html b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply.html new file mode 100644 index 0000000000..a7f6314efd --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply.html @@ -0,0 +1,78 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +function background() { + browser.runtime.onMessage.addListener((msg, sender, sendReply) => { + browser.test.assertTrue(sender.tab.url.endsWith("file_sample.html"), "sender url correct"); + + if (msg == 0) { + sendReply("reply1"); + } else if (msg == 1) { + window.setTimeout(function() { + sendReply("reply2"); + }, 0); + return true; + } else if (msg == 2) { + browser.test.notifyPass("sendmessage_reply"); + } + }); +} + +function contentScript() { + browser.runtime.sendMessage(0, function(resp1) { + if (resp1 != "reply1") { + return; // test failed + } + browser.runtime.sendMessage(1, function(resp2) { + if (resp2 != "reply2") { + return; // test failed + } + browser.runtime.sendMessage(2); + }); + }); +} + +let extensionData = { + background, + manifest: { + "permissions": ["tabs"], + "content_scripts": [{ + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + "run_at": "document_idle", + }], + }, + + files: { + "content_script.js": contentScript, + }, +}; + +add_task(async function test_contentscript() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let win = window.open("file_sample.html"); + + await Promise.all([waitForLoad(win), extension.awaitFinish("sendmessage_reply")]); + + win.close(); + + await extension.unload(); + info("extension unloaded"); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply2.html b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply2.html new file mode 100644 index 0000000000..8cce833b49 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply2.html @@ -0,0 +1,202 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +function backgroundScript(token, id, otherId) { + browser.runtime.onMessage.addListener((msg, sender, sendReply) => { + browser.test.assertEq(id, sender.id, `${id}: Got expected sender ID`); + + if (msg === `content-${token}`) { + browser.test.assertTrue(sender.tab.url.endsWith("file_sample.html"), + `${id}: sender url correct`); + + let tabId = sender.tab.id; + browser.tabs.sendMessage(tabId, `${token}-contentMessage`); + + sendReply(`${token}-done`); + } else if (msg === `tab-${token}`) { + browser.runtime.sendMessage(otherId, `${otherId}-tabMessage`); + browser.runtime.sendMessage(`${token}-tabMessage`); + + sendReply(`${token}-done`); + } else { + browser.test.fail(`${id}: Unexpected runtime message received: ${msg} ${uneval(sender)}`); + } + }); + + browser.runtime.onMessageExternal.addListener((msg, sender, sendReply) => { + browser.test.assertEq(otherId, sender.id, `${id}: Got expected external sender ID`); + + if (msg === `content-${id}`) { + browser.test.assertTrue(sender.tab.url.endsWith("file_sample.html"), + `${id}: external sender url correct`); + + sendReply(`${otherId}-done`); + } else if (msg === `tab-${id}`) { + sendReply(`${otherId}-done`); + } else if (msg !== `${id}-tabMessage`) { + browser.test.fail(`${id}: Unexpected runtime external message received: ${msg} ${uneval(sender)}`); + } + }); + + browser.tabs.create({url: "tab.html"}); +} + +function contentScript(token, id, otherId) { + let gotContentMessage = false; + browser.runtime.onMessage.addListener((msg, sender, sendReply) => { + browser.test.assertEq(id, sender.id, `${id}: Got expected sender ID`); + + browser.test.assertEq(`${token}-contentMessage`, msg, + `${id}: Correct content script message`); + if (msg === `${token}-contentMessage`) { + gotContentMessage = true; + } + }); + + Promise.all([ + browser.runtime.sendMessage(otherId, `content-${otherId}`).then(resp => { + browser.test.assertEq(`${id}-done`, resp, `${id}: Correct content script external response token`); + }), + + browser.runtime.sendMessage(`content-${token}`).then(resp => { + browser.test.assertEq(`${token}-done`, resp, `${id}: Correct content script response token`); + }).catch(e => { + browser.test.fail(`content-${token} rejected with ${e.message}`); + }), + ]).then(() => { + browser.test.assertTrue(gotContentMessage, `${id}: Got content script message`); + + browser.test.sendMessage("content-script-done"); + }); +} + +async function tabScript(token, id, otherId) { + let gotTabMessage = false; + browser.runtime.onMessage.addListener((msg, sender, sendReply) => { + browser.test.assertEq(id, sender.id, `${id}: Got expected sender ID`); + + if (String(msg).startsWith("content-")) { + return; + } + + browser.test.assertEq(`${token}-tabMessage`, msg, + `${id}: Correct tab script message`); + if (msg === `${token}-tabMessage`) { + gotTabMessage = true; + } + }); + + browser.test.sendMessage("tab-script-loaded"); + + await new Promise(resolve => { + const listener = (msg) => { + if (msg !== "run-tab-script") { + return; + } + browser.test.onMessage.removeListener(listener); + resolve(); + }; + browser.test.onMessage.addListener(listener); + }); + + Promise.all([ + browser.runtime.sendMessage(otherId, `tab-${otherId}`).then(resp => { + browser.test.assertEq(`${id}-done`, resp, `${id}: Correct tab script external response token`); + }), + + browser.runtime.sendMessage(`tab-${token}`).then(resp => { + browser.test.assertEq(`${token}-done`, resp, `${id}: Correct tab script response token`); + }), + ]).then(() => { + browser.test.assertTrue(gotTabMessage, `${id}: Got tab script message`); + + window.close(); + + browser.test.sendMessage("tab-script-done"); + }); +} + +function makeExtension(id, otherId) { + let token = Math.random(); + + let args = `${token}, ${JSON.stringify(id)}, ${JSON.stringify(otherId)}`; + + let extensionData = { + background: `(${backgroundScript})(${args})`, + manifest: { + "browser_specific_settings": {"gecko": {id}}, + + "permissions": ["tabs"], + + "content_scripts": [{ + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + "run_at": "document_start", + }], + }, + + files: { + "tab.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="tab.js"><\/script> + </head> + </html>`, + + "tab.js": `(${tabScript})(${args})`, + + "content_script.js": `(${contentScript})(${args})`, + }, + }; + return extensionData; +} + +add_task(async function test_contentscript() { + const ID1 = "sendmessage1@mochitest.mozilla.org"; + const ID2 = "sendmessage2@mochitest.mozilla.org"; + + let extension1 = ExtensionTestUtils.loadExtension(makeExtension(ID1, ID2)); + let extension2 = ExtensionTestUtils.loadExtension(makeExtension(ID2, ID1)); + + await Promise.all([ + extension1.startup(), + extension2.startup(), + extension1.awaitMessage("tab-script-loaded"), + extension2.awaitMessage("tab-script-loaded"), + ]); + + extension1.sendMessage("run-tab-script"); + extension2.sendMessage("run-tab-script"); + + let win = window.open("file_sample.html"); + + await waitForLoad(win); + + await Promise.all([ + extension1.awaitMessage("content-script-done"), + extension2.awaitMessage("content-script-done"), + extension1.awaitMessage("tab-script-done"), + extension2.awaitMessage("tab-script-done"), + ]); + + win.close(); + + await extension1.unload(); + await extension2.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_storage_cleanup.html b/toolkit/components/extensions/test/mochitest/test_ext_storage_cleanup.html new file mode 100644 index 0000000000..33029cf61e --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_storage_cleanup.html @@ -0,0 +1,277 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +const { ExtensionStorageIDB } = SpecialPowers.ChromeUtils.importESModule( + "resource://gre/modules/ExtensionStorageIDB.sys.mjs" +); + +const storageTestHelpers = { + storageLocal: { + async writeData() { + await browser.storage.local.set({hello: "world"}); + browser.test.sendMessage("finished"); + }, + + async readData() { + const matchBrowserStorage = await browser.storage.local.get("hello").then(result => { + return (Object.keys(result).length == 1 && result.hello == "world"); + }); + + browser.test.sendMessage("results", {matchBrowserStorage}); + }, + + assertResults({results, keepOnUninstall}) { + if (keepOnUninstall) { + is(results.matchBrowserStorage, true, "browser.storage.local data is still present"); + } else { + is(results.matchBrowserStorage, false, "browser.storage.local data was cleared"); + } + }, + }, + storageSync: { + async writeData() { + await browser.storage.sync.set({hello: "world"}); + browser.test.sendMessage("finished"); + }, + + async readData() { + const matchBrowserStorage = await browser.storage.sync.get("hello").then(result => { + return (Object.keys(result).length == 1 && result.hello == "world"); + }); + + browser.test.sendMessage("results", {matchBrowserStorage}); + }, + + assertResults({results, keepOnUninstall}) { + if (keepOnUninstall) { + is(results.matchBrowserStorage, true, "browser.storage.sync data is still present"); + } else { + is(results.matchBrowserStorage, false, "browser.storage.sync data was cleared"); + } + }, + }, + webAPIs: { + async readData() { + let matchLocalStorage = (localStorage.getItem("hello") == "world"); + + let idbPromise = new Promise((resolve, reject) => { + let req = indexedDB.open("test"); + req.onerror = e => { + reject(new Error(`indexedDB open failed with ${e.errorCode}`)); + }; + + req.onupgradeneeded = e => { + // no database, data is not present + resolve(false); + }; + + req.onsuccess = e => { + let db = e.target.result; + let transaction = db.transaction("store", "readwrite"); + let addreq = transaction.objectStore("store").get("hello"); + addreq.onerror = addreqError => { + reject(new Error(`read from indexedDB failed with ${addreqError.errorCode}`)); + }; + addreq.onsuccess = () => { + let match = (addreq.result.value == "world"); + resolve(match); + }; + }; + }); + + await idbPromise.then(matchIDB => { + let result = {matchLocalStorage, matchIDB}; + browser.test.sendMessage("results", result); + }); + }, + + async writeData() { + localStorage.setItem("hello", "world"); + + let idbPromise = new Promise((resolve, reject) => { + let req = indexedDB.open("test"); + req.onerror = e => { + reject(new Error(`indexedDB open failed with ${e.errorCode}`)); + }; + + req.onupgradeneeded = e => { + let db = e.target.result; + db.createObjectStore("store", {keyPath: "name"}); + }; + + req.onsuccess = e => { + let db = e.target.result; + let transaction = db.transaction("store", "readwrite"); + let addreq = transaction.objectStore("store") + .add({name: "hello", value: "world"}); + addreq.onerror = addreqError => { + reject(new Error(`add to indexedDB failed with ${addreqError.errorCode}`)); + }; + addreq.onsuccess = () => { + resolve(); + }; + }; + }); + + await idbPromise.then(() => { + browser.test.sendMessage("finished"); + }); + }, + + assertResults({results, keepOnUninstall}) { + if (keepOnUninstall) { + is(results.matchLocalStorage, true, "localStorage data is still present"); + is(results.matchIDB, true, "indexedDB data is still present"); + } else { + is(results.matchLocalStorage, false, "localStorage data was cleared"); + is(results.matchIDB, false, "indexedDB data was cleared"); + } + }, + }, +}; + +async function test_uninstall({extensionId, writeData, readData, assertResults}) { + // Set the pref to prevent cleaning up storage on uninstall in a separate prefEnv + // so we can pop it below, leaving flags set in the previous prefEnvs unmodified. + await SpecialPowers.pushPrefEnv({ + set: [["extensions.webextensions.keepStorageOnUninstall", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + background: writeData, + manifest: { + browser_specific_settings: {gecko: {id: extensionId}}, + permissions: ["storage"], + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + await extension.awaitMessage("finished"); + await extension.unload(); + + // Check that we can still see data we wrote to storage but clear the + // "leave storage" flag so our storaged gets cleared on the next uninstall. + // This effectively tests the keepUuidOnUninstall logic, which ensures + // that when we read storage again and check that it is cleared, that + // it is actually a meaningful test! + await SpecialPowers.popPrefEnv(); + + extension = ExtensionTestUtils.loadExtension({ + background: readData, + manifest: { + browser_specific_settings: {gecko: {id: extensionId}}, + permissions: ["storage"], + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + let results = await extension.awaitMessage("results"); + + assertResults({results, keepOnUninstall: true}); + + await extension.unload(); + + // Read again. This time, our data should be gone. + extension = ExtensionTestUtils.loadExtension({ + background: readData, + manifest: { + browser_specific_settings: {gecko: {id: extensionId}}, + permissions: ["storage"], + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + results = await extension.awaitMessage("results"); + + assertResults({results, keepOnUninstall: false}); + + await extension.unload(); +} + + +add_task(async function test_setup_keep_uuid_on_uninstall() { + // Use a test-only pref to leave the addonid->uuid mapping around after + // uninstall so that we can re-attach to the same storage (this prefEnv + // is kept for this entire file and cleared automatically once all the + // tests in this file have been executed). + await SpecialPowers.pushPrefEnv({ + set: [["extensions.webextensions.keepUuidOnUninstall", true]], + }); +}); + +// Test extension indexedDB and localStorage storages get cleaned up when the +// extension is uninstalled. +add_task(async function test_uninstall_with_webapi_storages() { + await test_uninstall({ + extensionId: "storage.cleanup-WebAPIStorages@tests.mozilla.org", + ...(storageTestHelpers.webAPIs), + }); +}); + +// Test browser.storage.local with JSONFile backend gets cleaned up when the +// extension is uninstalled. +add_task(async function test_uninistall_with_storage_local_file_backend() { + await SpecialPowers.pushPrefEnv({ + set: [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]], + }); + + await test_uninstall({ + extensionId: "storage.cleanup-JSONFileBackend@tests.mozilla.org", + ...(storageTestHelpers.storageLocal), + }); + + await SpecialPowers.popPrefEnv(); +}); + +// Repeat the cleanup test when the storage.local IndexedDB backend is enabled. +add_task(async function test_uninistall_with_storage_local_idb_backend() { + await SpecialPowers.pushPrefEnv({ + set: [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], + }); + + await test_uninstall({ + extensionId: "storage.cleanup-IDBBackend@tests.mozilla.org", + ...(storageTestHelpers.storageLocal), + }); + + await SpecialPowers.popPrefEnv(); +}); + +// Legacy storage.sync backend is still being used on GeckoView builds. +const storageSyncOldKintoBackend = SpecialPowers.Services.prefs.getBoolPref( + "webextensions.storage.sync.kinto", + false +); + +// Verify browser.storage.sync rust backend is also cleared on uninstall. +async function test_uninistall_with_storage_sync() { + await test_uninstall({ + extensionId: "storage.cleanup-sync@tests.mozilla.org", + ...(storageTestHelpers.storageSync), + }); +} + +// NOTE: ideally we would be using a skip_if option on the add_task call, +// but we don't support that in the add_task defined in mochitest-plain. +if (!storageSyncOldKintoBackend) { + add_task(test_uninistall_with_storage_sync); +} + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_storage_manager_capabilities.html b/toolkit/components/extensions/test/mochitest/test_ext_storage_manager_capabilities.html new file mode 100644 index 0000000000..848a01c2c3 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_storage_manager_capabilities.html @@ -0,0 +1,129 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test Storage API </title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + "set": [ + ["dom.storageManager.prompt.testing", true], + ["dom.storageManager.prompt.testing.allow", true], + ], + }); +}); + +add_task(async function test_backgroundScript() { + function background() { + browser.test.assertTrue(navigator.storage !== undefined, "Has storage api interface"); + + // Test estimate. + browser.test.assertTrue("estimate" in navigator.storage, "Has estimate function"); + browser.test.assertEq("function", typeof navigator.storage.estimate, "estimate is function"); + browser.test.assertTrue(navigator.storage.estimate() instanceof Promise, "estimate returns a promise"); + + return browser.test.notifyPass("navigation_storage_api.done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + }); + + await extension.startup(); + await extension.awaitFinish("navigation_storage_api.done"); + await extension.unload(); +}); + +add_task(async function test_contentScript() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.security.https_first", false]], + }); + + function contentScript() { + // Should not access storage api in non-secure context. + browser.test.assertEq(undefined, navigator.storage, + "A page from the unsecure http protocol " + + "doesn't have access to the navigator.storage API"); + + return browser.test.notifyPass("navigation_storage_api.done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [{ + "matches": ["http://example.com/*/file_sample.html"], + "js": ["content_script.js"], + }], + }, + + files: { + "content_script.js": `(${contentScript})()`, + }, + }); + + await extension.startup(); + + // Open an explicit URL for testing Storage API in an insecure context. + let win = window.open("http://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html"); + + await extension.awaitFinish("navigation_storage_api.done"); + + await extension.unload(); + win.close(); +}); + +add_task(async function test_contentScriptSecure() { + function contentScript() { + browser.test.assertTrue(navigator.storage !== undefined, "Has storage api interface"); + + // Test estimate. + browser.test.assertTrue("estimate" in navigator.storage, "Has estimate function"); + browser.test.assertEq("function", typeof navigator.storage.estimate, "estimate is function"); + + // The promise that estimate function returns belongs to the content page, + // but the Promise constructor belongs to the content script sandbox. + // Check window.Promise here. + browser.test.assertTrue(navigator.storage.estimate() instanceof window.Promise, "estimate returns a promise"); + + return browser.test.notifyPass("navigation_storage_api.done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [{ + "matches": ["https://example.com/*/file_sample.html"], + "js": ["content_script.js"], + }], + }, + + files: { + "content_script.js": `(${contentScript})()`, + }, + }); + + await extension.startup(); + + // Open an explicit URL for testing Storage API in a secure context. + let win = window.open("file_sample.html"); + + await extension.awaitFinish("navigation_storage_api.done"); + + await extension.unload(); + win.close(); +}); + +add_task(async function cleanup() { + await SpecialPowers.popPrefEnv(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_storage_smoke_test.html b/toolkit/components/extensions/test/mochitest/test_ext_storage_smoke_test.html new file mode 100644 index 0000000000..e68caa7e55 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_storage_smoke_test.html @@ -0,0 +1,108 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +// The purpose of this test is making sure that the implementation enabled by +// default for the storage.local and storage.sync APIs does work across all +// platforms/builds/apps +add_task(async function test_storage_smoke_test() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + for (let storageArea of ["sync", "local"]) { + let storage = browser.storage[storageArea]; + + browser.test.assertTrue(!!storage, `StorageArea ${storageArea} is present.`) + + let data = await storage.get(); + browser.test.assertEq(0, Object.keys(data).length, + `Storage starts out empty for ${storageArea}`); + + data = await storage.get("test"); + browser.test.assertEq(0, Object.keys(data).length, + `Can read non-existent keys for ${storageArea}`); + + await storage.set({ + "test1": "test-value1", + "test2": "test-value2", + "test3": "test-value3" + }); + + browser.test.assertEq( + "test-value1", + (await storage.get("test1")).test1, + `Can set and read back single values for ${storageArea}`); + + browser.test.assertEq( + "test-value2", + (await storage.get("test2")).test2, + `Can set and read back single values for ${storageArea}`); + + data = await storage.get(); + browser.test.assertEq(3, Object.keys(data).length, + `Can set and read back all values for ${storageArea}`); + browser.test.assertEq("test-value1", data.test1, + `Can set and read back all values for ${storageArea}`); + browser.test.assertEq("test-value2", data.test2, + `Can set and read back all values for ${storageArea}`); + browser.test.assertEq("test-value3", data.test3, + `Can set and read back all values for ${storageArea}`); + + data = await storage.get(["test1", "test2"]); + browser.test.assertEq(2, Object.keys(data).length, + `Can set and read back array of values for ${storageArea}`); + browser.test.assertEq("test-value1", data.test1, + `Can set and read back array of values for ${storageArea}`); + browser.test.assertEq("test-value2", data.test2, + `Can set and read back array of values for ${storageArea}`); + + await storage.remove("test1"); + data = await storage.get(["test1", "test2"]); + browser.test.assertEq(1, Object.keys(data).length, + `Data can be removed for ${storageArea}`); + browser.test.assertEq("test-value2", data.test2, + `Data can be removed for ${storageArea}`); + + data = await storage.get({ + test1: 1, + test2: 2, + }); + browser.test.assertEq(2, Object.keys(data).length, + `Expected a key-value pair for every property for ${storageArea}`); + browser.test.assertEq(1, data.test1, + `Use default value if key was deleted for ${storageArea}`); + browser.test.assertEq("test-value2", data.test2, + `Use stored value if found for ${storageArea}`); + + await storage.clear(); + data = await storage.get(); + browser.test.assertEq(0, Object.keys(data).length, + `Data is empty after clear for ${storageArea}`); + } + + browser.test.sendMessage("done"); + }, + manifest: { + permissions: ["storage"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_streamfilter_multiple.html b/toolkit/components/extensions/test/mochitest/test_ext_streamfilter_multiple.html new file mode 100644 index 0000000000..d1bfbd824b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_streamfilter_multiple.html @@ -0,0 +1,91 @@ +<!DOCTYPE html> +<html> +<head> + <title>Test for multiple extensions trying to filterResponseData on the same request</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +const TEST_URL = + "http://example.org/tests/toolkit/components/extensions/test/mochitest/file_streamfilter.txt"; + +add_task(async () => { + const firstExtension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + ({ requestId }) => { + const filter = browser.webRequest.filterResponseData(requestId); + filter.ondata = event => { + filter.write(new TextEncoder().encode("Start ")); + filter.write(event.data); + filter.disconnect(); + }; + }, + { + urls: [ + "http://example.org/*/file_streamfilter.txt", + ], + }, + ["blocking"] + ); + }, + }); + + const secondExtension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + ({ requestId }) => { + const filter = browser.webRequest.filterResponseData(requestId); + filter.ondata = event => { + filter.write(event.data); + }; + filter.onstop = event => { + filter.write(new TextEncoder().encode(" End")); + filter.close(); + }; + }, + { + urls: [ + "http://example.org/tests/toolkit/components/extensions/test/mochitest/file_streamfilter.txt", + ], + }, + ["blocking"] + ); + }, + }); + + await firstExtension.startup(); + await secondExtension.startup(); + + let iframe = document.createElement("iframe"); + iframe.src = TEST_URL; + document.body.appendChild(iframe); + await new Promise(resolve => iframe.addEventListener("load", () => resolve(), {once: true})); + + let content = await SpecialPowers.spawn(iframe, [], async () => { + return this.content.document.body.textContent; + }); + SimpleTest.is(content, "Start Middle\n End", "Correctly intercepted page content"); + + await firstExtension.unload(); + await secondExtension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_streamfilter_processswitch.html b/toolkit/components/extensions/test/mochitest/test_ext_streamfilter_processswitch.html new file mode 100644 index 0000000000..049178cad0 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_streamfilter_processswitch.html @@ -0,0 +1,76 @@ +<!DOCTYPE html> +<html> +<head> + <title>Test for using filterResponseData to intercept a cross-origin navigation that will involve a process switch with fission</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +const TEST_HOST = "http://example.com/"; +const CROSS_ORIGIN_HOST = "http://example.org/"; +const TEST_PATH = + "tests/toolkit/components/extensions/test/mochitest/file_streamfilter.txt"; + +const TEST_URL = TEST_HOST + TEST_PATH; +const CROSS_ORIGIN_URL = CROSS_ORIGIN_HOST + TEST_PATH; + +add_task(async () => { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + ({ requestId }) => { + const filter = browser.webRequest.filterResponseData(requestId); + filter.onerror = () => browser.test.fail( + `Unexpected filterResponseData error: ${filter.error}` + ); + filter.ondata = event => { + filter.write(event.data); + }; + filter.onstop = event => { + filter.write(new TextEncoder().encode(" End")); + filter.close(); + }; + }, + { + urls: [ + "http://example.org/*/file_streamfilter.txt", + ], + }, + ["blocking"] + ); + }, + }); + + await extension.startup(); + + let iframe = document.createElement("iframe"); + iframe.src = TEST_URL; + document.body.appendChild(iframe); + await new Promise(resolve => iframe.addEventListener("load", () => resolve(), {once: true})); + + + iframe.src = CROSS_ORIGIN_URL; + await new Promise(resolve => iframe.addEventListener("load", () => resolve(), {once: true})); + + let content = await SpecialPowers.spawn(iframe, [], async () => { + return this.content.document.body.textContent; + }); + SimpleTest.is(content, "Middle\n End", "Correctly intercepted page content"); + + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_subframes_privileges.html b/toolkit/components/extensions/test/mochitest/test_ext_subframes_privileges.html new file mode 100644 index 0000000000..fd034f0b65 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_subframes_privileges.html @@ -0,0 +1,340 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; +/* eslint-disable mozilla/balanced-listeners */ + +add_task(async function test_webext_tab_subframe_privileges() { + function background() { + browser.runtime.onMessage.addListener(async ({msg, success, tabId, error}) => { + if (msg == "webext-tab-subframe-privileges") { + if (success) { + await browser.tabs.remove(tabId); + + browser.test.notifyPass(msg); + } else { + browser.test.log(`Got an unexpected error: ${error}`); + + let tabs = await browser.tabs.query({active: true}); + await browser.tabs.remove(tabs[0].id); + + browser.test.notifyFail(msg); + } + } + }); + browser.tabs.create({url: browser.runtime.getURL("/tab.html")}); + } + + async function tabSubframeScript() { + browser.test.assertTrue(browser.tabs != undefined, + "Subframe of a privileged page has access to privileged APIs"); + if (browser.tabs) { + try { + let tab = await browser.tabs.getCurrent(); + browser.runtime.sendMessage({ + msg: "webext-tab-subframe-privileges", + success: true, + tabId: tab.id, + }); + } catch (e) { + browser.runtime.sendMessage({msg: "webext-tab-subframe-privileges", success: false, error: `${e}`}); + } + } else { + browser.runtime.sendMessage({ + msg: "webext-tab-subframe-privileges", + success: false, + error: `Privileged APIs missing in WebExtension tab sub-frame`, + }); + } + } + + let extensionData = { + background, + files: { + "tab.html": `<!DOCTYPE> + <head> + <meta charset="utf-8"> + </head> + <body> + <iframe src="tab-subframe.html"></iframe> + </body> + </html>`, + "tab-subframe.html": `<!DOCTYPE> + <head> + <meta charset="utf-8"> + <script src="tab-subframe.js"><\/script> + </head> + </html>`, + "tab-subframe.js": tabSubframeScript, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + await extension.awaitFinish("webext-tab-subframe-privileges"); + await extension.unload(); +}); + +add_task(async function test_webext_background_subframe_privileges() { + function backgroundSubframeScript() { + browser.test.assertTrue(browser.tabs != undefined, + "Subframe of a background page has access to privileged APIs"); + browser.test.notifyPass("webext-background-subframe-privileges"); + } + + let extensionData = { + manifest: { + background: { + page: "background.html", + }, + }, + files: { + "background.html": `<!DOCTYPE> + <head> + <meta charset="utf-8"> + </head> + <body> + <iframe src="background-subframe.html"></iframe> + </body> + </html>`, + "background-subframe.html": `<!DOCTYPE> + <head> + <meta charset="utf-8"> + <script src="background-subframe.js"><\/script> + </head> + </html>`, + "background-subframe.js": backgroundSubframeScript, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + await extension.awaitFinish("webext-background-subframe-privileges"); + await extension.unload(); +}); + +add_task(async function test_webext_contentscript_iframe_subframe_privileges() { + function background() { + browser.runtime.onMessage.addListener(({name, hasTabsAPI, hasStorageAPI}) => { + if (name == "contentscript-iframe-loaded") { + browser.test.assertFalse(hasTabsAPI, + "Subframe of a content script privileged iframes has no access to privileged APIs"); + browser.test.assertTrue(hasStorageAPI, + "Subframe of a content script privileged iframes has access to content script APIs"); + + browser.test.notifyPass("webext-contentscript-subframe-privileges"); + } + }); + } + + function subframeScript() { + browser.runtime.sendMessage({ + name: "contentscript-iframe-loaded", + hasTabsAPI: browser.tabs != undefined, + hasStorageAPI: browser.storage != undefined, + }); + } + + function contentScript() { + let iframe = document.createElement("iframe"); + iframe.setAttribute("src", browser.runtime.getURL("/contentscript-iframe.html")); + document.body.appendChild(iframe); + } + + let extensionData = { + background, + manifest: { + "permissions": ["storage"], + "content_scripts": [{ + "matches": ["https://example.com/*"], + "js": ["contentscript.js"], + }], + web_accessible_resources: [ + "contentscript-iframe.html", + ], + }, + files: { + "contentscript.js": contentScript, + "contentscript-iframe.html": `<!DOCTYPE> + <head> + <meta charset="utf-8"> + </head> + <body> + <iframe src="contentscript-iframe-subframe.html"></iframe> + </body> + </html>`, + "contentscript-iframe-subframe.html": `<!DOCTYPE> + <head> + <meta charset="utf-8"> + <script src="contentscript-iframe-subframe.js"><\/script> + </head> + </html>`, + "contentscript-iframe-subframe.js": subframeScript, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + let win = window.open("https://example.com"); + + await extension.awaitFinish("webext-contentscript-subframe-privileges"); + + win.close(); + + await extension.unload(); +}); + +add_task(async function test_webext_background_remote_subframe_privileges() { + function backgroundSubframeScript() { + window.addEventListener("message", evt => { + browser.test.assertEq("http://mochi.test:8888", evt.origin, "postmessage origin ok"); + browser.test.assertFalse(evt.data.tabs, "remote frame cannot access webextension APIs"); + browser.test.assertEq("cookie=monster", evt.data.cookie, "Expected cookie value"); + browser.test.notifyPass("webext-background-subframe-privileges"); + }, {once: true}); + browser.cookies.set({url: "http://mochi.test:8888", name: "cookie", "value": "monster"}); + } + + let extensionData = { + manifest: { + permissions: ["cookies", "*://mochi.test/*", "tabs"], + background: { + page: "background.html", + }, + }, + files: { + "background.html": `<!DOCTYPE> + <head> + <meta charset="utf-8"> + <script src="background-subframe.js"><\/script> + </head> + <body> + <iframe src='${SimpleTest.getTestFileURL("file_remote_frame.html")}'></iframe> + </body> + </html>`, + "background-subframe.js": backgroundSubframeScript, + }, + }; + // Need remote webextensions to be able to load remote content from a background page. + if (!SpecialPowers.getBoolPref("extensions.webextensions.remote", true)) { + return; + } + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + await extension.awaitFinish("webext-background-subframe-privileges"); + await extension.unload(); +}); + +// Test a moz-extension:// iframe inside a content iframe in an extension page. +add_task(async function test_sub_subframe_conduit_verified_env() { + let manifest = { + content_scripts: [{ + matches: ["http://mochi.test/*/file_sample.html"], + all_frames: true, + js: ["cs.js"], + }], + background: { + page: "background.html", + }, + web_accessible_resources: ["iframe.html"], + }; + + let files = { + "iframe.html": `<!DOCTYPE html><meta charset=utf-8> iframe`, + "cs.js"() { + // A compromised content sandbox shouldn't be able to trick the parent + // process into giving it extension privileges by sending false metadata. + async function faker(extensionId, envType) { + try { + let id = envType + "-xyz1234"; + let wgc = this.content.windowGlobalChild; + + let conduit = wgc.getActor("Conduits").openConduit({}, { + id, + envType, + extensionId, + query: ["CreateProxyContext"], + }); + + return await conduit.queryCreateProxyContext({ + childId: id, + extensionId, + envType: "addon_parent", + url: this.content.location.href, + viewType: "tab", + }); + } catch (e) { + return e.message; + } + } + + let iframe = document.createElement("iframe"); + iframe.src = browser.runtime.getURL("iframe.html"); + + iframe.onload = async () => { + for (let envType of ["content_child", "addon_child"]) { + let msg = await this.wrappedJSObject.SpecialPowers.spawn( + iframe, [browser.runtime.id, envType], faker); + browser.test.sendMessage(envType, msg); + } + }; + document.body.appendChild(iframe); + }, + "background.html": `<!DOCTYPE html> + <meta charset=utf-8> + <iframe src="${SimpleTest.getTestFileURL("file_sample.html")}"> + </iframe> + page + `, + }; + + async function expectErrors(ext, log) { + let err = await ext.awaitMessage("content_child"); + is(err, "Bad sender context envType: content_child"); + + err = await ext.awaitMessage("addon_child"); + is(err, "Unknown sender or wrong actor for recvCreateProxyContext"); + } + + let remote = SpecialPowers.getBoolPref("extensions.webextensions.remote"); + + let badProcess = { message: /Bad {[\w-]+} process: web/ }; + let badPrincipal = { message: /Bad {[\w-]+} principal: http/ }; + consoleMonitor.start(remote ? [badPrincipal, badProcess] : [badProcess]); + + let extension = ExtensionTestUtils.loadExtension({ manifest, files }); + await extension.startup(); + + if (remote) { + info("Need OOP to spoof from a web iframe inside background page."); + await expectErrors(extension); + } + + info("Try spoofing from the web process."); + let win = window.open("./file_sample.html"); + await expectErrors(extension); + win.close(); + + await extension.unload(); + await consoleMonitor.finished(); + info("Conduit creation logged correct exception(s)."); +}); + +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_tabs_captureTab.html b/toolkit/components/extensions/test/mochitest/test_ext_tabs_captureTab.html new file mode 100644 index 0000000000..ab06a965ed --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_captureTab.html @@ -0,0 +1,324 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tests tabs.captureTab and tabs.captureVisibleTab</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> +<script type="text/javascript"> +"use strict"; + +async function runTest({ html, fullZoom, coords, rect, scale }) { + let url = `data:text/html,${encodeURIComponent(html)}#scroll`; + + async function background({ coords, rect, scale, method, fullZoom }) { + try { + // Wait for the page to load + await new Promise(resolve => { + browser.webNavigation.onCompleted.addListener( + () => resolve(), + {url: [{schemes: ["data"]}]}); + }); + + let [tab] = await browser.tabs.query({ + currentWindow: true, + active: true, + }); + + // TODO: Bug 1665429 - on mobile we ignore zoom for now + if (browser.tabs.setZoom) { + await browser.tabs.setZoom(tab.id, fullZoom ?? 1); + } + + let id = method === "captureVisibleTab" ? tab.windowId : tab.id; + + let [jpeg, png, ...pngs] = await Promise.all([ + browser.tabs[method](id, { format: "jpeg", quality: 95, rect, scale }), + browser.tabs[method](id, { format: "png", quality: 95, rect, scale }), + browser.tabs[method](id, { quality: 95, rect, scale }), + browser.tabs[method](id, { rect, scale }), + ]); + + browser.test.assertTrue( + pngs.every(url => url == png), + "All PNGs are identical" + ); + + browser.test.assertTrue( + jpeg.startsWith("data:image/jpeg;base64,"), + "jpeg is JPEG" + ); + browser.test.assertTrue( + png.startsWith("data:image/png;base64,"), + "png is PNG" + ); + + let promises = [jpeg, png].map( + url => + new Promise(resolve => { + let img = new Image(); + img.src = url; + img.onload = () => resolve(img); + }) + ); + + let width = (rect?.width ?? tab.width) * (scale ?? devicePixelRatio); + let height = (rect?.height ?? tab.height) * (scale ?? devicePixelRatio); + + [jpeg, png] = await Promise.all(promises); + let images = { jpeg, png }; + for (let format of Object.keys(images)) { + let img = images[format]; + + // WGP.drawSnapshot() deals in int coordinates, and rounds down. + browser.test.assertTrue( + Math.abs(width - img.width) <= 1, + `${format} ok image width: ${img.width}, expected: ${width}` + ); + browser.test.assertTrue( + Math.abs(height - img.height) <= 1, + `${format} ok image height ${img.height}, expected: ${height}` + ); + + let canvas = document.createElement("canvas"); + canvas.width = img.width; + canvas.height = img.height; + canvas.mozOpaque = true; + + let ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0); + + for (let { x, y, color } of coords) { + x = (x + img.width) % img.width; + y = (y + img.height) % img.height; + let imageData = ctx.getImageData(x, y, 1, 1).data; + + if (format == "png") { + browser.test.assertEq( + `rgba(${color},255)`, + `rgba(${[...imageData]})`, + `${format} image color is correct at (${x}, ${y})` + ); + } else { + // Allow for some deviation in JPEG version due to lossy compression. + const SLOP = 3; + + browser.test.log( + `Testing ${format} image color at (${x}, ${y}), have rgba(${[ + ...imageData, + ]}), expecting approx. rgba(${color},255)` + ); + + browser.test.assertTrue( + Math.abs(color[0] - imageData[0]) <= SLOP, + `${format} image color.red is correct at (${x}, ${y})` + ); + browser.test.assertTrue( + Math.abs(color[1] - imageData[1]) <= SLOP, + `${format} image color.green is correct at (${x}, ${y})` + ); + browser.test.assertTrue( + Math.abs(color[2] - imageData[2]) <= SLOP, + `${format} image color.blue is correct at (${x}, ${y})` + ); + browser.test.assertEq( + 255, + imageData[3], + `${format} image color.alpha is correct at (${x}, ${y})` + ); + } + } + } + + browser.test.notifyPass("captureTab"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("captureTab"); + } + } + + for (let method of ["captureTab", "captureVisibleTab"]) { + let options = { coords, rect, scale, method, fullZoom }; + info(`Testing configuration: ${JSON.stringify(options)}`); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["<all_urls>", "webNavigation"], + }, + + background: `(${background})(${JSON.stringify(options)})`, + }); + + await extension.startup(); + + let testWindow = window.open(url); + await extension.awaitFinish("captureTab"); + + testWindow.close(); + await extension.unload(); + } +} + +async function testEdgeToEdge({ color, fullZoom }) { + let neutral = [0xaa, 0xaa, 0xaa]; + + let html = ` + <!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + </head> + <body style="background-color: rgb(${color})"> + <!-- Fill most of the image with a neutral color to test edge-to-edge scaling. --> + <div style="position: absolute; + left: 2px; + right: 2px; + top: 2px; + bottom: 2px; + background: rgb(${neutral});"></div> + </body> + </html> + `; + + // Check the colors of the first and last pixels of the image, to make + // sure we capture the entire frame, and scale it correctly. + let coords = [ + { x: 0, y: 0, color }, + { x: -1, y: -1, color }, + { x: 300, y: 200, color: neutral }, + ]; + + info(`Test edge to edge color ${color} at fullZoom=${fullZoom}`); + await runTest({ html, fullZoom, coords }); +} + +add_task(async function testCaptureEdgeToEdge() { + await testEdgeToEdge({ color: [0, 0, 0], fullZoom: 1 }); + await testEdgeToEdge({ color: [0, 0, 0], fullZoom: 2 }); + await testEdgeToEdge({ color: [0, 0, 0], fullZoom: 0.5 }); + await testEdgeToEdge({ color: [255, 255, 255], fullZoom: 1 }); +}); + +const tallDoc = `<!DOCTYPE html> + <meta charset=utf-8> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <div style="background: yellow; width: 50%; height: 500px;"></div> + <div id=scroll style="background: red; width: 25%; height: 5000px;"></div> + Opened with the #scroll fragment, scrolls the div ^ into view. +`; + +// Test currently visible viewport is captured if scrolling is involved. +add_task(async function testScrolledViewport() { + await runTest({ + html: tallDoc, + coords: [ + { x: 50, y: 50, color: [255, 0, 0] }, + { x: 50, y: -50, color: [255, 0, 0] }, + { x: -50, y: -50, color: [255, 255, 255] }, + ], + }); +}); + +// Test rect and scale options. +add_task(async function testRectAndScale() { + await runTest({ + html: tallDoc, + rect: { x: 50, y: 50, width: 10, height: 1000 }, + scale: 4, + coords: [ + { x: 0, y: 0, color: [255, 255, 0] }, + { x: -1, y: 0, color: [255, 255, 0] }, + { x: 0, y: -1, color: [255, 0, 0] }, + { x: -1, y: -1, color: [255, 0, 0] }, + ], + }); +}); + +// Test OOP iframes are captured, for Fission compatibility. +add_task(async function testOOPiframe() { + await runTest({ + html: `<!DOCTYPE html> + <meta charset=utf-8> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <iframe src="http://example.net/tests/toolkit/components/extensions/test/mochitest/file_green.html"></iframe> + `, + coords: [ + { x: 50, y: 50, color: [0, 255, 0] }, + { x: 50, y: -50, color: [255, 255, 255] }, + { x: -50, y: 50, color: [255, 255, 255] }, + ], + }); +}); + +add_task(async function testOOPiframeScale() { + let scale = 2; + await runTest({ + html: `<!DOCTYPE html> + <meta charset=utf-8> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <style> + body { + background: yellow; + margin: 0; + } + </style> + <iframe frameborder="0" style="width: 300px; height: 300px" src="http://example.net/tests/toolkit/components/extensions/test/mochitest/file_green_blue.html"></iframe> + `, + coords: [ + { x: 20 * scale, y: 20 * scale, color: [0, 255, 0] }, + { x: 200 * scale, y: 20 * scale, color: [0, 0, 255] }, + { x: 20 * scale, y: 200 * scale, color: [0, 0, 255] }, + ], + scale, + }); +}); + +add_task(async function testCaptureTabPermissions() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background() { + browser.test.assertEq( + undefined, + browser.tabs.captureTab, + 'Extension without "<all_urls>" permission should not have access to captureTab' + ); + browser.test.notifyPass("captureTabPermissions"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("captureTabPermissions"); + await extension.unload(); +}); + +add_task(async function testCaptureVisibleTabPermissions() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background() { + browser.test.assertEq( + undefined, + browser.tabs.captureVisibleTab, + 'Extension without "<all_urls>" permission should not have access to captureVisibleTab' + ); + browser.test.notifyPass("captureVisibleTabPermissions"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("captureVisibleTabPermissions"); + await extension.unload(); +}); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_tabs_create_cookieStoreId.html b/toolkit/components/extensions/test/mochitest/test_ext_tabs_create_cookieStoreId.html new file mode 100644 index 0000000000..331faca016 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_create_cookieStoreId.html @@ -0,0 +1,210 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <title>Test tabs.create(cookieStoreId)</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> +<script type="text/javascript"> +"use strict"; + +add_task(async function no_cookies_permission() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + async background() { + await browser.test.assertRejects( + browser.tabs.create({ cookieStoreId: "firefox-container-1" }), + /No permission for cookieStoreId/, + "cookieStoreId requires cookies permission" + ); + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function invalid_cookieStoreId() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "cookies"], + }, + async background() { + await browser.test.assertRejects( + browser.tabs.create({ cookieStoreId: "not-firefox-container-1" }), + /Illegal cookieStoreId/, + "cookieStoreId must be valid" + ); + + await browser.test.assertRejects( + browser.tabs.create({ cookieStoreId: "firefox-private" }), + /Illegal to set private cookieStoreId in a non-private window/, + "cookieStoreId cannot be private in a non-private window" + ); + + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function perma_private_browsing_mode() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.privatebrowsing.autostart", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["tabs", "cookies"], + }, + async background() { + await browser.test.assertRejects( + browser.tabs.create({ cookieStoreId: "firefox-container-1" }), + /Contextual identities are unavailable in permanent private browsing mode/, + "cookieStoreId cannot be a container tab ID in perma-private browsing mode" + ); + + browser.test.sendMessage("done"); + }, + }); + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function userContext_disabled() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", false]], + }); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "cookies"], + }, + async background() { + await browser.test.assertRejects( + browser.tabs.create({ cookieStoreId: "firefox-container-1" }), + /Contextual identities are currently disabled/, + "cookieStoreId cannot be a container tab ID when contextual identities are disabled" + ); + browser.test.sendMessage("done"); + }, + }); + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function valid_cookieStoreId() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", true]], + }); + + const testCases = [ + { + description: "no explicit URL", + createProperties: { + cookieStoreId: "firefox-container-1", + }, + expectedCookieStoreId: "firefox-container-1", + }, + { + description: "pass explicit url", + createProperties: { + url: "about:blank", + cookieStoreId: "firefox-container-1", + }, + expectedCookieStoreId: "firefox-container-1", + },{ + description: "pass explicit not-blank url", + createProperties: { + url: "https://example.com/", + cookieStoreId: "firefox-container-1", + }, + expectedCookieStoreId: "firefox-container-1", + },{ + description: "pass extension page url", + createProperties: { + url: "blank.html", + cookieStoreId: "firefox-container-1", + }, + expectedCookieStoreId: "firefox-container-1", + } + ]; + + async function background(testCases) { + for (let { createProperties, expectedCookieStoreId } of testCases) { + const { url } = createProperties; + const updatedPromise = new Promise(resolve => { + const onUpdated = (changedTabId, changed) => { + // Loading an extension page causes two `about:blank` messages + // because of the process switch + if (changed.url && (url == "about:blank" || changed.url != "about:blank")) { + browser.tabs.onUpdated.removeListener(onUpdated); + resolve({tabId: changedTabId, url: changed.url}); + } + }; + browser.tabs.onUpdated.addListener(onUpdated); + }); + + const tab = await browser.tabs.create(createProperties); + browser.test.assertEq( + expectedCookieStoreId, + tab.cookieStoreId, + "Expected cookieStoreId for container tab" + ); + + if (url && url !== "about:blank") { + // Make sure tab can load successfully + const updated = await updatedPromise; + browser.test.assertEq(tab.id, updated.tabId, `Expected value for tab.id`); + if (updated.url.startsWith("moz-extension")) { + browser.test.assertEq(browser.runtime.getURL(url), updated.url, + `Expected value for extension page url`); + } else { + browser.test.assertEq(url, updated.url, `Expected value for tab.url`); + } + } + + await browser.tabs.remove(tab.id); + } + browser.test.sendMessage("done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "cookies"], + }, + files: { + "blank.html": `<html><head><meta charset="utf-8"></head></html>`, + }, + background: `(${background})(${JSON.stringify(testCases)})`, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_tabs_detectLanguage.html b/toolkit/components/extensions/test/mochitest/test_ext_tabs_detectLanguage.html new file mode 100644 index 0000000000..ad41545429 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_detectLanguage.html @@ -0,0 +1,85 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Verify detectLangauge</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + + +add_task(async function testDetectLanguage() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + host_permissions: ["<all_urls>"], + granted_host_permissions: true + }, + + background: async function() { + const BASE_PATH = "tests/toolkit/components/extensions/test/mochitest"; + + function loadTab(url) { + return browser.tabs.create({ url }); + } + + try { + let tab = await loadTab( + `https://example.com/${BASE_PATH}/file_language_ja.html` + ); + let lang = await browser.tabs.detectLanguage(tab.id); + browser.test.assertEq( + "ja", + lang, + "Japanese document should be detected as Japanese" + ); + await browser.tabs.remove(tab.id); + + tab = await loadTab( + `https://example.com/${BASE_PATH}/file_language_fr_en.html` + ); + lang = await browser.tabs.detectLanguage(tab.id); + browser.test.assertEq( + "fr", + lang, + "French/English document should be detected as primarily French" + ); + await browser.tabs.remove(tab.id); + + tab = await loadTab( + `https://example.com/${BASE_PATH}/file_language_tlh.html` + ); + lang = await browser.tabs.detectLanguage(tab.id); + browser.test.assertEq( + "und", + lang, + "Klingon document should not be detected, should return 'und'" + ); + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("detectLanguage"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("detectLanguage"); + } + }, + }); + + await extension.startup(); + + await extension.awaitFinish("detectLanguage"); + + await extension.unload(); +}); + + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_tabs_executeScript_good.html b/toolkit/components/extensions/test/mochitest/test_ext_tabs_executeScript_good.html new file mode 100644 index 0000000000..9b0f41f789 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_executeScript_good.html @@ -0,0 +1,167 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Tabs executeScript Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +async function testHasPermission(params) { + let contentSetup = params.contentSetup || (() => Promise.resolve()); + + async function background(contentSetup) { + browser.runtime.onMessage.addListener((msg, sender) => { + browser.test.assertEq(msg, "script ran", "script ran"); + browser.test.notifyPass("executeScript"); + }); + + browser.test.onMessage.addListener(msg => { + browser.test.assertEq(msg, "execute-script"); + + browser.tabs.executeScript({ + file: "script.js", + }); + }); + + await contentSetup(); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: params.manifest, + + background: `(${background})(${contentSetup})`, + + files: { + "panel.html": `<!DOCTYPE html> + <html> + <head><meta charset="utf-8"></head> + <body> + </body> + </html>`, + "script.js": function() { + browser.runtime.sendMessage("script ran"); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + if (params.setup) { + await params.setup(extension); + } + + extension.sendMessage("execute-script"); + + await extension.awaitFinish("executeScript"); + + if (params.tearDown) { + await params.tearDown(extension); + } + + await extension.unload(); +} + +add_task(async function testGoodPermissions() { + let tab = await AppTestDelegate.openNewForegroundTab( + window, + "http://mochi.test:8888/", + true + ); + + info("Test explicit host permission"); + await testHasPermission({ + manifest: { permissions: ["http://mochi.test/"] }, + }); + + info("Test explicit host subdomain permission"); + await testHasPermission({ + manifest: { permissions: ["http://*.mochi.test/"] }, + }); + + info("Test explicit <all_urls> permission"); + await testHasPermission({ + manifest: { permissions: ["<all_urls>"] }, + }); + + info("Test activeTab permission with a browser action click"); + await testHasPermission({ + manifest: { + permissions: ["activeTab"], + browser_action: {}, + }, + contentSetup: function() { + browser.browserAction.onClicked.addListener(() => { + browser.test.log("Clicked."); + }); + return Promise.resolve(); + }, + setup: extension => AppTestDelegate.clickBrowserAction(window, extension), + tearDown: extension => AppTestDelegate.closeBrowserAction(window, extension), + }); + + info("Test activeTab permission with a page action click"); + await testHasPermission({ + manifest: { + permissions: ["activeTab"], + page_action: {}, + }, + contentSetup: async () => { + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + await browser.pageAction.show(tab.id); + }, + setup: extension => AppTestDelegate.clickPageAction(window, extension), + tearDown: extension => AppTestDelegate.closePageAction(window, extension), + }); + + info("Test activeTab permission with a browser action w/popup click"); + await testHasPermission({ + manifest: { + permissions: ["activeTab"], + browser_action: { default_popup: "panel.html" }, + }, + setup: async extension => { + let promise = AppTestDelegate.awaitExtensionPanel(window, extension); + await AppTestDelegate.clickBrowserAction(window, extension); + await promise; + }, + tearDown: extension => AppTestDelegate.closeBrowserAction(window, extension), + }); + + info("Test activeTab permission with a page action w/popup click"); + await testHasPermission({ + manifest: { + permissions: ["activeTab"], + page_action: { default_popup: "panel.html" }, + }, + contentSetup: async () => { + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + await browser.pageAction.show(tab.id); + }, + setup: async extension => { + let promise = AppTestDelegate.awaitExtensionPanel(window, extension); + await AppTestDelegate.clickPageAction(window, extension); + await promise; + }, + tearDown: extension => AppTestDelegate.closePageAction(window, extension), + }); + + await AppTestDelegate.removeTab(window, tab); +}); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_tabs_permissions.html b/toolkit/components/extensions/test/mochitest/test_ext_tabs_permissions.html new file mode 100644 index 0000000000..217139f12b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_permissions.html @@ -0,0 +1,752 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Tabs permissions test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +const URL1 = + "https://www.example.com/tests/toolkit/components/extensions/test/mochitest/file_tabs_permission_page1.html"; +const URL2 = + "https://example.net/tests/toolkit/components/extensions/test/mochitest/file_tabs_permission_page2.html"; + +const helperExtensionDef = { + manifest: { + permissions: ["webNavigation", "<all_urls>"], + }, + + async background() { + browser.test.onMessage.addListener(async message => { + switch (message.subject) { + case "createTab": { + const tabLoaded = new Promise(resolve => { + browser.webNavigation.onCompleted.addListener(function listener( + details + ) { + if (details.url === message.data.url) { + browser.webNavigation.onCompleted.removeListener(listener); + resolve(); + } + }); + }); + + const tab = await browser.tabs.create({ url: message.data.url }); + await tabLoaded; + browser.test.sendMessage("tabCreated", tab.id); + break; + } + + case "changeTabURL": { + const tabLoaded = new Promise(resolve => { + browser.webNavigation.onCompleted.addListener(function listener( + details + ) { + if (details.url === message.data.url) { + browser.webNavigation.onCompleted.removeListener(listener); + resolve(); + } + }); + }); + + await browser.tabs.update(message.data.tabId, { + url: message.data.url, + }); + await tabLoaded; + browser.test.sendMessage("tabURLChanged", message.data.tabId); + break; + } + + case "changeTabHashAndTitle": { + const tabChanged = new Promise(resolve => { + let hasURLChangeInfo = false, + hasTitleChangeInfo = false; + browser.tabs.onUpdated.addListener(function listener( + tabId, + changeInfo, + tab + ) { + if (changeInfo.url?.endsWith(message.data.urlHash)) { + hasURLChangeInfo = true; + } + if (changeInfo.title === message.data.title) { + hasTitleChangeInfo = true; + } + if (hasURLChangeInfo && hasTitleChangeInfo) { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + + await browser.tabs.executeScript(message.data.tabId, { + code: ` + document.location.hash = ${JSON.stringify(message.data.urlHash)}; + document.title = ${JSON.stringify(message.data.title)}; + `, + }); + await tabChanged; + browser.test.sendMessage("tabHashAndTitleChanged"); + break; + } + + case "removeTab": { + await browser.tabs.remove(message.data.tabId); + browser.test.sendMessage("tabRemoved"); + break; + } + + default: + browser.test.fail(`Received unexpected message: ${message}`); + } + }); + }, +}; + +/* + * Test tabs.query function + * Check if the correct tabs are queried by url or title based on the granted permissions + */ +async function test_query(testCases, permissions) { + const helperExtension = ExtensionTestUtils.loadExtension(helperExtensionDef); + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions, + }, + + async background() { + // wait for start message + const [testCases, tabIdFromURL1, tabIdFromURL2] = await new Promise( + resolve => { + browser.test.onMessage.addListener(message => resolve(message)); + } + ); + + for (const testCase of testCases) { + const query = testCase.query; + const matchingTabs = testCase.matchingTabs; + + let tabQuery = await browser.tabs.query(query); + // ignore other tabs in the window + tabQuery = tabQuery.filter(tab => { + return tab.id === tabIdFromURL1 || tab.id === tabIdFromURL2; + }); + + browser.test.assertEq(matchingTabs, tabQuery.length, `Tabs queried`); + } + // send end message + browser.test.notifyPass("tabs.query"); + }, + }); + + await helperExtension.startup(); + await extension.startup(); + + helperExtension.sendMessage({ + subject: "createTab", + data: { url: URL1 }, + }); + const tabIdFromURL1 = await helperExtension.awaitMessage("tabCreated"); + + helperExtension.sendMessage({ + subject: "createTab", + data: { url: URL2 }, + }); + const tabIdFromURL2 = await helperExtension.awaitMessage("tabCreated"); + + if (permissions.includes("activeTab")) { + extension.grantActiveTab(tabIdFromURL2); + } + + extension.sendMessage([testCases, tabIdFromURL1, tabIdFromURL2]); + await extension.awaitFinish("tabs.query"); + + helperExtension.sendMessage({ + subject: "removeTab", + data: { tabId: tabIdFromURL1 }, + }); + await helperExtension.awaitMessage("tabRemoved"); + + helperExtension.sendMessage({ + subject: "removeTab", + data: { tabId: tabIdFromURL2 }, + }); + await helperExtension.awaitMessage("tabRemoved"); + + await extension.unload(); + await helperExtension.unload(); +} + +// https://www.example.com host permission +add_task(function query_with_host_permission_url1() { + return test_query( + [ + { + query: { url: "*://www.example.com/*" }, + matchingTabs: 1, + }, + { + query: { url: "<all_urls>" }, + matchingTabs: 1, + }, + { + query: { url: ["*://www.example.com/*", "*://example.net/*"] }, + matchingTabs: 1, + }, + { + query: { title: "The Title" }, + matchingTabs: 1, + }, + { + query: { title: "Another Title" }, + matchingTabs: 0, + }, + { + query: {}, + matchingTabs: 2, + }, + ], + ["*://www.example.com/*"] + ); +}); + +// https://example.net host permission +add_task(function query_with_host_permission_url2() { + return test_query( + [ + { + query: { url: "*://www.example.com/*" }, + matchingTabs: 0, + }, + { + query: { url: "<all_urls>" }, + matchingTabs: 1, + }, + { + query: { url: ["*://www.example.com/*", "*://example.net/*"] }, + matchingTabs: 1, + }, + { + query: { title: "The Title" }, + matchingTabs: 0, + }, + { + query: { title: "Another Title" }, + matchingTabs: 1, + }, + { + query: {}, + matchingTabs: 2, + }, + ], + ["*://example.net/*"] + ); +}); + +// <all_urls> permission +add_task(function query_with_host_permission_all_urls() { + return test_query( + [ + { + query: { url: "*://www.example.com/*" }, + matchingTabs: 1, + }, + { + query: { url: "<all_urls>" }, + matchingTabs: 2, + }, + { + query: { url: ["*://www.example.com/*", "*://example.net/*"] }, + matchingTabs: 2, + }, + { + query: { title: "The Title" }, + matchingTabs: 1, + }, + { + query: { title: "Another Title" }, + matchingTabs: 1, + }, + { + query: {}, + matchingTabs: 2, + }, + ], + ["<all_urls>"] + ); +}); + +// tabs permission +add_task(function query_with_tabs_permission() { + return test_query( + [ + { + query: { url: "*://www.example.com/*" }, + matchingTabs: 1, + }, + { + query: { url: "<all_urls>" }, + matchingTabs: 2, + }, + { + query: { url: ["*://www.example.com/*", "*://example.net/*"] }, + matchingTabs: 2, + }, + { + query: { title: "The Title" }, + matchingTabs: 1, + }, + { + query: { title: "Another Title" }, + matchingTabs: 1, + }, + { + query: {}, + matchingTabs: 2, + }, + ], + ["tabs"] + ); +}); + +// activeTab permission +add_task(function query_with_activeTab_permission() { + return test_query( + [ + { + query: { url: "*://www.example.com/*" }, + matchingTabs: 0, + }, + { + query: { url: "<all_urls>" }, + matchingTabs: 1, + }, + { + query: { url: ["*://www.example.com/*", "*://example.net/*"] }, + matchingTabs: 1, + }, + { + query: { title: "The Title" }, + matchingTabs: 0, + }, + { + query: { title: "Another Title" }, + matchingTabs: 1, + }, + { + query: {}, + matchingTabs: 2, + }, + ], + ["activeTab"] + ); +}); +// no permission +add_task(function query_without_permission() { + return test_query( + [ + { + query: { url: "*://www.example.com/*" }, + matchingTabs: 0, + }, + { + query: { url: "<all_urls>" }, + matchingTabs: 0, + }, + { + query: { url: ["*://www.example.com/*", "*://example.net/*"] }, + matchingTabs: 0, + }, + { + query: { title: "The Title" }, + matchingTabs: 0, + }, + { + query: { title: "Another Title" }, + matchingTabs: 0, + }, + { + query: {}, + matchingTabs: 2, + }, + ], + [] + ); +}); + +/* + * Test tabs.onUpdate and tabs.get function + * Check if the changeInfo or tab object contains the restricted properties + * url and title only when the right permissions are granted + * The tab is updated without causing navigation in order to also test activeTab permission + */ +async function test_restricted_properties( + permissions, + hasRestrictedProperties +) { + const helperExtension = ExtensionTestUtils.loadExtension(helperExtensionDef); + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions, + }, + + async background() { + // wait for test start signal and data + const [ + hasRestrictedProperties, + tabId, + urlHash, + title, + ] = await new Promise(resolve => { + browser.test.onMessage.addListener(message => { + resolve(message); + }); + }); + + let hasURLChangeInfo = false, + hasTitleChangeInfo = false; + function onUpdateListener(tabId, changeInfo, tab) { + if (changeInfo.url?.endsWith(urlHash)) { + hasURLChangeInfo = true; + } + if (changeInfo.title === title) { + hasTitleChangeInfo = true; + } + } + browser.tabs.onUpdated.addListener(onUpdateListener); + + // wait for test evaluation signal and data + await new Promise(resolve => { + browser.test.onMessage.addListener(message => { + if (message === "collectTestResults") { + resolve(message); + } + }); + browser.test.sendMessage("waitingForTabPropertyChanges"); + }); + + // check onUpdate changeInfo + browser.test.assertEq( + hasRestrictedProperties, + hasURLChangeInfo, + `Has changeInfo property "url"` + ); + browser.test.assertEq( + hasRestrictedProperties, + hasTitleChangeInfo, + `Has changeInfo property "title"` + ); + // check tab properties + const tabGet = await browser.tabs.get(tabId); + browser.test.assertEq( + hasRestrictedProperties, + !!tabGet.url?.endsWith(urlHash), + `Has tab property "url"` + ); + browser.test.assertEq( + hasRestrictedProperties, + tabGet.title === title, + `Has tab property "title"` + ); + // send end message + browser.test.notifyPass("tabs.restricted_properties"); + }, + }); + + const urlHash = "#ChangedURL"; + const title = "Changed Title"; + + await helperExtension.startup(); + await extension.startup(); + + helperExtension.sendMessage({ + subject: "createTab", + data: { url: URL1 }, + }); + const tabId = await helperExtension.awaitMessage("tabCreated"); + + if (permissions.includes("activeTab")) { + extension.grantActiveTab(tabId); + } + // send test start signal and data + extension.sendMessage([hasRestrictedProperties, tabId, urlHash, title]); + await extension.awaitMessage("waitingForTabPropertyChanges"); + + helperExtension.sendMessage({ + subject: "changeTabHashAndTitle", + data: { + tabId, + urlHash, + title, + }, + }); + await helperExtension.awaitMessage("tabHashAndTitleChanged"); + + // send end signal and evaluate results + extension.sendMessage("collectTestResults"); + await extension.awaitFinish("tabs.restricted_properties"); + + helperExtension.sendMessage({ + subject: "removeTab", + data: { tabId }, + }); + await helperExtension.awaitMessage("tabRemoved"); + + await extension.unload(); + await helperExtension.unload(); +} + +// https://www.example.com host permission +add_task(function has_restricted_properties_with_host_permission_url1() { + return test_restricted_properties(["*://www.example.com/*"], true); +}); +// https://example.net host permission +add_task(function has_restricted_properties_with_host_permission_url2() { + return test_restricted_properties(["*://example.net/*"], false); +}); +// <all_urls> permission +add_task(function has_restricted_properties_with_host_permission_all_urls() { + return test_restricted_properties(["<all_urls>"], true); +}); +// tabs permission +add_task(function has_restricted_properties_with_tabs_permission() { + return test_restricted_properties(["tabs"], true); +}); +// activeTab permission +add_task(function has_restricted_properties_with_activeTab_permission() { + return test_restricted_properties(["activeTab"], true); +}).skip(); // TODO bug 1686080: support changeInfo.url with activeTab +// no permission +add_task(function has_restricted_properties_without_permission() { + return test_restricted_properties([], false); +}); + + +/* + * Test tabs.onUpdate filter functionality + * Check if the restricted filter properties only work if the + * right permissions are granted + */ +async function test_onUpdateFilter(testCases, permissions) { + // Filters for onUpdated are not supported on Android. + if (AppConstants.platform === "android") { + return; + } + + const helperExtension = ExtensionTestUtils.loadExtension(helperExtensionDef); + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions, + }, + + async background() { + let listenerGotCalled = false; + function onUpdateListener(tabId, changeInfo, tab) { + listenerGotCalled = true; + } + + browser.test.onMessage.addListener(async message => { + switch (message.subject) { + case "setup": { + browser.tabs.onUpdated.addListener( + onUpdateListener, + message.data.filter + ); + browser.test.sendMessage("done"); + break; + } + + case "collectTestResults": { + browser.test.assertEq( + message.data.expectEvent, + listenerGotCalled, + `Update listener called` + ); + browser.tabs.onUpdated.removeListener(onUpdateListener); + listenerGotCalled = false; + browser.test.sendMessage("done"); + break; + } + + default: + browser.test.fail(`Received unexpected message: ${message}`); + } + }); + }, + }); + + await helperExtension.startup(); + await extension.startup(); + + for (const testCase of testCases) { + helperExtension.sendMessage({ + subject: "createTab", + data: { url: URL1 }, + }); + const tabId = await helperExtension.awaitMessage("tabCreated"); + + extension.sendMessage({ + subject: "setup", + data: { + filter: testCase.filter, + }, + }); + await extension.awaitMessage("done"); + + helperExtension.sendMessage({ + subject: "changeTabURL", + data: { + tabId, + url: URL2, + }, + }); + await helperExtension.awaitMessage("tabURLChanged"); + + extension.sendMessage({ + subject: "collectTestResults", + data: { + expectEvent: testCase.expectEvent, + }, + }); + await extension.awaitMessage("done"); + + helperExtension.sendMessage({ + subject: "removeTab", + data: { tabId }, + }); + await helperExtension.awaitMessage("tabRemoved"); + } + + await extension.unload(); + await helperExtension.unload(); +} + +// https://mozilla.org host permission +add_task(function onUpdateFilter_with_host_permission_url3() { + return test_onUpdateFilter( + [ + { + filter: { urls: ["*://mozilla.org/*"] }, + expectEvent: false, + }, + { + filter: { urls: ["<all_urls>"] }, + expectEvent: false, + }, + { + filter: { urls: ["*://mozilla.org/*", "*://example.net/*"] }, + expectEvent: false, + }, + { + filter: { properties: ["title"] }, + expectEvent: false, + }, + { + filter: {}, + expectEvent: true, + }, + ], + ["*://mozilla.org/*"] + ); +}); + +// https://example.net host permission +add_task(function onUpdateFilter_with_host_permission_url2() { + return test_onUpdateFilter( + [ + { + filter: { urls: ["*://mozilla.org/*"] }, + expectEvent: false, + }, + { + filter: { urls: ["<all_urls>"] }, + expectEvent: true, + }, + { + filter: { urls: ["*://mozilla.org/*", "*://example.net/*"] }, + expectEvent: true, + }, + { + filter: { properties: ["title"] }, + expectEvent: true, + }, + { + filter: {}, + expectEvent: true, + }, + ], + ["*://example.net/*"] + ); +}); + +// <all_urls> permission +add_task(function onUpdateFilter_with_host_permission_all_urls() { + return test_onUpdateFilter( + [ + { + filter: { urls: ["*://mozilla.org/*"] }, + expectEvent: false, + }, + { + filter: { urls: ["<all_urls>"] }, + expectEvent: true, + }, + { + filter: { urls: ["*://mozilla.org/*", "*://example.net/*"] }, + expectEvent: true, + }, + { + filter: { properties: ["title"] }, + expectEvent: true, + }, + { + filter: {}, + expectEvent: true, + }, + ], + ["<all_urls>"] + ); +}); + +// tabs permission +add_task(function onUpdateFilter_with_tabs_permission() { + return test_onUpdateFilter( + [ + { + filter: { urls: ["*://mozilla.org/*"] }, + expectEvent: false, + }, + { + filter: { urls: ["<all_urls>"] }, + expectEvent: true, + }, + { + filter: { urls: ["*://mozilla.org/*", "*://example.net/*"] }, + expectEvent: true, + }, + { + filter: { properties: ["title"] }, + expectEvent: true, + }, + { + filter: {}, + expectEvent: true, + }, + ], + ["tabs"] + ); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_tabs_query_popup.html b/toolkit/components/extensions/test/mochitest/test_ext_tabs_query_popup.html new file mode 100644 index 0000000000..80b6def0ab --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_query_popup.html @@ -0,0 +1,102 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Tabs create Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_setup(async () => { + // TODO bug 1799344: remove this when the pref is true by default. + await SpecialPowers.pushPrefEnv({ + "set": [ + ["extensions.openPopupWithoutUserGesture.enabled", true], + ], + }); +}); + +async function test_query(query) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "current-window@tests.mozilla.org", + } + }, + permissions: ["tabs"], + browser_action: { + default_popup: "popup.html", + }, + }, + + useAddonManager: "permanent", + + background: async function() { + let query = await new Promise(resolve => { + browser.test.onMessage.addListener(message => { + resolve(message); + }); + }); + let tab = await browser.tabs.create({ url: "http://www.example.com", active: true }); + browser.runtime.onMessage.addListener(message => { + if (message === "popup-loaded") { + browser.runtime.sendMessage({ tab, query }); + } + }); + browser.browserAction.openPopup(); + }, + + files: { + "popup.html": `<!DOCTYPE html><meta charset="utf-8"><script src="popup.js"><\/script>`, + "popup.js"() { + browser.runtime.onMessage.addListener(async function({ tab, query }) { + let tabs = await browser.tabs.query(query); + browser.test.assertEq(tabs.length, 1, `Got one tab`); + browser.test.assertEq(tabs[0].id, tab.id, "The tab is the right one"); + + // Create a new tab and verify that we still see the right result + let newTab = await browser.tabs.create({ url: "http://www.example.com", active: true }); + tabs = await browser.tabs.query(query); + browser.test.assertEq(tabs.length, 1, `Got one tab`); + browser.test.assertEq(tabs[0].id, newTab.id, "Got the newly-created tab"); + + await browser.tabs.remove(newTab.id); + + // Remove the tab and verify that we see the old tab + tabs = await browser.tabs.query(query); + browser.test.assertEq(tabs.length, 1, `Got one tab`); + browser.test.assertEq(tabs[0].id, tab.id, "Got the tab that was active before"); + + // Cleanup + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("tabs.query"); + }); + browser.runtime.sendMessage("popup-loaded"); + }, + }, + }); + + await extension.startup(); + extension.sendMessage(query); + await extension.awaitFinish("tabs.query"); + await extension.unload(); +} + +add_task(function test_query_currentWindow_from_popup() { + return test_query({ currentWindow: true, active: true }); +}); + +add_task(function test_query_lastActiveWindow_from_popup() { + return test_query({ lastFocusedWindow: true, active: true }); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_tabs_sendMessage.html b/toolkit/components/extensions/test/mochitest/test_ext_tabs_sendMessage.html new file mode 100644 index 0000000000..4b230c258c --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_sendMessage.html @@ -0,0 +1,152 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test tabs.sendMessage</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> +<script> +"use strict"; + +add_task(async function test_tabs_sendMessage_to_extension_page_frame() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [{ + matches: ["http://mochi.test/*/file_sample.html?tabs.sendMessage"], + js: ["cs.js"], + }], + web_accessible_resources: ["page.html", "page.js"], + }, + + async background() { + let tab; + + browser.runtime.onMessage.addListener(async (msg, sender) => { + browser.test.assertEq(msg, "page-script-ready"); + browser.test.assertEq(sender.url, browser.runtime.getURL("page.html")); + + let tabId = sender.tab.id; + let response = await browser.tabs.sendMessage(tabId, "tab-sendMessage"); + + switch (response) { + case "extension-tab": + browser.test.assertEq(tab.id, tabId, "Extension tab responded"); + browser.test.assertEq(sender.frameId, 0, "Response from top level"); + await browser.tabs.remove(tab.id); + browser.test.sendMessage("extension-tab-responded"); + break; + + case "extension-frame": + browser.test.assertTrue(sender.frameId > 0, "Response from iframe"); + browser.test.sendMessage("extension-frame-responded"); + break; + + default: + browser.test.fail("Unexpected response: " + response); + } + }); + + tab = await browser.tabs.create({ url: "page.html" }); + }, + + files: { + "cs.js"() { + let iframe = document.createElement("iframe"); + iframe.src = browser.runtime.getURL("page.html"); + document.body.append(iframe); + browser.test.sendMessage("content-script-done"); + }, + + "page.html": `<!DOCTYPE html> + <meta charset=utf-8> + <script src=page.js><\/script> + Extension page`, + + "page.js"() { + browser.runtime.onMessage.addListener(async msg => { + browser.test.assertEq(msg, "tab-sendMessage"); + return window.parent === window ? "extension-tab" : "extension-frame"; + }); + browser.runtime.sendMessage("page-script-ready"); + }, + } + }); + + await extension.startup(); + await extension.awaitMessage("extension-tab-responded"); + + let win = window.open("file_sample.html?tabs.sendMessage"); + await extension.awaitMessage("content-script-done"); + await extension.awaitMessage("extension-frame-responded"); + win.close(); + + await extension.unload(); +}); + +add_task(async function test_tabs_sendMessage_using_frameId() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://mochi.test/*/file_contains_iframe.html"], + run_at: "document_start", + js: ["cs_top.js"], + }, + { + matches: ["http://example.org/*/file_contains_img.html"], + js: ["cs_iframe.js"], + all_frames: true, + }, + ], + }, + + background() { + browser.runtime.onMessage.addListener(async (msg, sender) => { + let { tab, frameId } = sender; + browser.test.assertEq(msg, "cs_iframe_ready", "Iframe cs ready."); + browser.test.assertTrue(frameId > 0, "Not from the top frame."); + + let response = await browser.tabs.sendMessage(tab.id, "msg"); + browser.test.assertEq(response, "cs_top", "Top cs responded first."); + + response = await browser.tabs.sendMessage(tab.id, "msg", { frameId }); + browser.test.assertEq(response, "cs_iframe", "Iframe cs reponded."); + + browser.test.sendMessage("done"); + }); + browser.test.sendMessage("ready"); + }, + + files: { + "cs_top.js"() { + browser.test.log("Top content script loaded.") + browser.runtime.onMessage.addListener(async () => "cs_top"); + }, + "cs_iframe.js"() { + browser.test.log("Iframe content script loaded.") + browser.runtime.onMessage.addListener((msg, sender, sendResponse) => { + browser.test.log("Iframe content script received message.") + setTimeout(() => sendResponse("cs_iframe"), 100); + return true; + }); + browser.runtime.sendMessage("cs_iframe_ready"); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + let win = window.open("file_contains_iframe.html"); + await extension.awaitMessage("done"); + win.close(); + + await extension.unload(); +}); + +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_test.html b/toolkit/components/extensions/test/mochitest/test_ext_test.html new file mode 100644 index 0000000000..bf68786465 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_test.html @@ -0,0 +1,341 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Testing test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"> +</head> +<body> + +<script> +"use strict"; + +function loadExtensionAndInterceptTest(extensionData) { + let results = []; + let testResolve; + let testDone = new Promise(resolve => { testResolve = resolve; }); + let handler = { + testResult(...result) { + result.pop(); + results.push(result); + SimpleTest.info(`Received test result: ${JSON.stringify(result)}`); + }, + + testMessage(msg, ...args) { + results.push(["test-message", msg, ...args]); + SimpleTest.info(`Received message: ${msg} ${JSON.stringify(args)}`); + if (msg === "This is the last browser.test call") { + testResolve(); + } + }, + }; + let extension = SpecialPowers.loadExtension(extensionData, handler); + SimpleTest.registerCleanupFunction(() => { + if (extension.state == "pending" || extension.state == "running") { + SimpleTest.ok(false, "Extension left running at test shutdown"); + return extension.unload(); + } else if (extension.state == "unloading") { + SimpleTest.ok(false, "Extension not fully unloaded at test shutdown"); + } + }); + extension.awaitResults = () => testDone.then(() => results); + return extension; +} + +// NOTE: This test does not verify the behavior expected by calling the browser.test API methods. +// +// On the contrary it tests what messages ext-test.js sends to the parent process as a result of +// processing different kind of parameters (e.g. how a dom element or a JS object with a custom +// toString method are being serialized into strings). +// +// All browser.test calls results are intercepted by the test itself, see verifyTestResults for +// the expectations of each browser.test call. +function testScript() { + browser.test.notifyPass("dot notifyPass"); + browser.test.notifyFail("dot notifyFail"); + browser.test.log("dot log"); + browser.test.fail("dot fail"); + browser.test.succeed("dot succeed"); + browser.test.assertTrue(true); + browser.test.assertFalse(false); + browser.test.assertEq("", ""); + + let obj = {}; + let arr = []; + browser.test.assertTrue(obj, "Object truthy"); + browser.test.assertTrue(arr, "Array truthy"); + browser.test.assertTrue(true, "True truthy"); + browser.test.assertTrue(false, "False truthy"); + browser.test.assertTrue(null, "Null truthy"); + browser.test.assertTrue(undefined, "Void truthy"); + + browser.test.assertFalse(obj, "Object falsey"); + browser.test.assertFalse(arr, "Array falsey"); + browser.test.assertFalse(true, "True falsey"); + browser.test.assertFalse(false, "False falsey"); + browser.test.assertFalse(null, "Null falsey"); + browser.test.assertFalse(undefined, "Void falsey"); + + browser.test.assertEq(obj, obj, "Object equality"); + browser.test.assertEq(arr, arr, "Array equality"); + browser.test.assertEq(null, null, "Null equality"); + browser.test.assertEq(undefined, undefined, "Void equality"); + + browser.test.assertEq({}, {}, "Object reference inequality"); + browser.test.assertEq([], [], "Array reference inequality"); + browser.test.assertEq(true, 1, "strict: true and 1 inequality"); + browser.test.assertEq("1", 1, "strict: '1' and 1 inequality"); + browser.test.assertEq(null, undefined, "Null and void inequality"); + + browser.test.assertDeepEq({a: 1, b: 1}, {b: 1, a: 1}, "Object deep eq"); + browser.test.assertDeepEq([[2], [1]], [[2], [1]], "Array deep eq"); + browser.test.assertDeepEq(true, 1, "strict: true and 1 deep ineq"); + browser.test.assertDeepEq("1", 1, "strict: '1' and 1 deep ineq"); + // Key with undefined value should be different from object without key: + browser.test.assertDeepEq(null, undefined, "Null and void deep ineq"); + browser.test.assertDeepEq({c: undefined}, {c: null}, "void+null deep ineq"); + browser.test.assertDeepEq({a: undefined, b: 1}, {b: 1}, "void/- deep ineq"); + + browser.test.assertDeepEq(NaN, NaN, "NaN deep eq"); + browser.test.assertDeepEq(NaN, null, "NaN+null deep ineq"); + browser.test.assertDeepEq(Infinity, Infinity, "Infinity deep eq"); + browser.test.assertDeepEq(Infinity, null, "Infinity+null deep ineq"); + + obj = { + toString() { + return "Dynamic toString"; + }, + }; + browser.test.assertEq(obj, obj, "obj with dynamic toString()"); + + browser.test.assertThrows( + () => { throw new Error("dummy"); }, + /dummy2/, + "intentional failure" + ); + browser.test.assertThrows( + () => { throw new Error("dummy2"); }, + /dummy3/ + ); + browser.test.assertThrows( + () => {}, + /dummy/ + ); + + // The WebIDL version of assertDeepEq structurally clones before sending the + // params to the main thread. This check verifies that the behavior is + // consistent between the WebIDL and Schemas.jsm-generated API bindings. + browser.test.assertThrows( + () => browser.test.assertDeepEq(obj, obj, "obj with func"), + /An unexpected error occurred/, + "assertDeepEq obj with function throws" + ); + browser.test.assertThrows( + () => browser.test.assertDeepEq(() => {}, () => {}, "func to assertDeepEq"), + /An unexpected error occurred/, + "assertDeepEq with function throws" + ); + browser.test.assertThrows( + () => browser.test.assertDeepEq(/./, /./, "regexp"), + /Unsupported obj type: RegExp/, + "assertDeepEq with RegExp throws" + ); + + // Set of additional tests to only run on background page and content script + // (but skip on background service worker). + if (self === self.window) { + let dom = document.createElement("body"); + browser.test.assertTrue(dom, "Element truthy"); + browser.test.assertTrue(false, document.createElement("html")); + browser.test.assertFalse(dom, "Element falsey"); + browser.test.assertFalse(true, document.createElement("head")); + browser.test.assertEq(dom, dom, "Element equality"); + browser.test.assertEq(dom, document.createElement("body"), "Element inequality"); + browser.test.assertEq(true, false, document.createElement("div")); + } + + browser.test.sendMessage("Ran test at", location.protocol); + browser.test.sendMessage("This is the last browser.test call"); +} + +function verifyTestResults(results, shortName, expectedProtocol, useServiceWorker) { + let expectations = [ + ["test-done", true, "dot notifyPass"], + ["test-done", false, "dot notifyFail"], + ["test-log", true, "dot log"], + ["test-result", false, "dot fail"], + ["test-result", true, "dot succeed"], + ["test-result", true, "undefined"], + ["test-result", true, "undefined"], + ["test-eq", true, "undefined", "", ""], + + ["test-result", true, "Object truthy"], + ["test-result", true, "Array truthy"], + ["test-result", true, "True truthy"], + ["test-result", false, "False truthy"], + ["test-result", false, "Null truthy"], + ["test-result", false, "Void truthy"], + + ["test-result", false, "Object falsey"], + ["test-result", false, "Array falsey"], + ["test-result", false, "True falsey"], + ["test-result", true, "False falsey"], + ["test-result", true, "Null falsey"], + ["test-result", true, "Void falsey"], + + ["test-eq", true, "Object equality", "[object Object]", "[object Object]"], + ["test-eq", true, "Array equality", "", ""], + ["test-eq", true, "Null equality", "null", "null"], + ["test-eq", true, "Void equality", "undefined", "undefined"], + + ["test-eq", false, "Object reference inequality", "[object Object]", "[object Object] (different)"], + ["test-eq", false, "Array reference inequality", "", " (different)"], + ["test-eq", false, "strict: true and 1 inequality", "true", "1"], + ["test-eq", false, "strict: '1' and 1 inequality", "1", "1 (different)"], + ["test-eq", false, "Null and void inequality", "null", "undefined"], + + ["test-eq", true, "Object deep eq", `{"a":1,"b":1}`, `{"b":1,"a":1}`], + ["test-eq", true, "Array deep eq", "[[2],[1]]", "[[2],[1]]"], + ["test-eq", false, "strict: true and 1 deep ineq", "true", "1"], + ["test-eq", false, "strict: '1' and 1 deep ineq", `"1"`, "1"], + ["test-eq", false, "Null and void deep ineq", "null", "undefined"], + ["test-eq", false, "void+null deep ineq", `{"c":"undefined"}`, `{"c":null}`], + ["test-eq", false, "void/- deep ineq", `{"a":"undefined","b":1}`, `{"b":1}`], + + ["test-eq", true, "NaN deep eq", `NaN`, `NaN`], + ["test-eq", false, "NaN+null deep ineq", `NaN`, `null`], + ["test-eq", true, "Infinity deep eq", `Infinity`, `Infinity`], + ["test-eq", false, "Infinity+null deep ineq", `Infinity`, `null`], + + [ + "test-eq", + true, + "obj with dynamic toString()", + // - Privileged JS API Bindings: the ext-test.js module will get a XrayWrapper and so when + // the object is being stringified the custom `toString()` method will not be called and + // "[object Object]" is the value we expect. + // - WebIDL API Bindngs: the parameter is being serialized into a string on the worker thread, + // the object is stringified using the worker principal and so there is no XrayWrapper + // involved and the value expected is the value returned by the custom toString method the. + // object does provide. + useServiceWorker ? "Dynamic toString" : "[object Object]", + useServiceWorker ? "Dynamic toString" : "[object Object]", + ], + + [ + "test-result", false, + "Function threw, expecting error to match '/dummy2/', got \'Error: dummy\': intentional failure" + ], + [ + "test-result", false, + "Function threw, expecting error to match '/dummy3/', got \'Error: dummy2\'" + ], + [ + "test-result", false, + "Function did not throw, expected error '/dummy/'" + ], + [ + "test-result", true, + "Function threw, expecting error to match '/An unexpected error occurred/', got 'Error: An unexpected error occurred': assertDeepEq obj with function throws", + ], + [ + "test-result", true, + "Function threw, expecting error to match '/An unexpected error occurred/', got 'Error: An unexpected error occurred': assertDeepEq with function throws", + ], + [ + "test-result", true, + "Function threw, expecting error to match '/Unsupported obj type: RegExp/', got 'Error: Unsupported obj type: RegExp': assertDeepEq with RegExp throws", + ], + ]; + + if (!useServiceWorker) { + expectations.push(...[ + ["test-result", true, "Element truthy"], + ["test-result", false, "[object HTMLHtmlElement]"], + ["test-result", false, "Element falsey"], + ["test-result", false, "[object HTMLHeadElement]"], + ["test-eq", true, "Element equality", "[object HTMLBodyElement]", "[object HTMLBodyElement]"], + ["test-eq", false, "Element inequality", "[object HTMLBodyElement]", "[object HTMLBodyElement] (different)"], + ["test-eq", false, "[object HTMLDivElement]", "true", "false"], + ]); + } + + expectations.push(...[ + ["test-message", "Ran test at", expectedProtocol], + ["test-message", "This is the last browser.test call"], + ]); + + expectations.forEach((expectation, i) => { + let msg = expectation.slice(2).join(" - "); + isDeeply(results[i], expectation, `${shortName} (${msg})`); + }); + is(results[expectations.length], undefined, "No more results"); +} + +add_task(async function test_test_in_background() { + let extensionData = { + background: `(${testScript})()`, + // This test case should never run the background script in a worker, + // even if this test file is running when "extensions.backgroundServiceWorker.forceInTest" + // pref is true + useServiceWorker: false, + }; + + let extension = loadExtensionAndInterceptTest(extensionData); + await extension.startup(); + let results = await extension.awaitResults(); + verifyTestResults(results, "background page", "moz-extension:", false); + await extension.unload(); +}); + +add_task(async function test_test_in_background_service_worker() { + if (!ExtensionTestUtils.isInBackgroundServiceWorkerTests()) { + is( + ExtensionTestUtils.getBackgroundServiceWorkerEnabled(), + false, + "This test should only be skipped with background service worker disabled" + ) + info("Test intentionally skipped on 'extensions.backgroundServiceWorker.enabled=false'"); + return; + } + + let extensionData = { + background: `(${testScript})()`, + // This test case should always run the background script in a worker, + // or be skipped if the background service worker is disabled by prefs. + useServiceWorker: true, + }; + + let extension = loadExtensionAndInterceptTest(extensionData); + await extension.startup(); + let results = await extension.awaitResults(); + verifyTestResults(results, "background service worker", "moz-extension:", true); + await extension.unload(); +}); + +add_task(async function test_test_in_content_script() { + let extensionData = { + manifest: { + content_scripts: [{ + matches: ["http://mochi.test/*/file_sample.html"], + js: ["contentscript.js"], + }], + }, + files: { + "contentscript.js": `(${testScript})()`, + }, + }; + + let extension = loadExtensionAndInterceptTest(extensionData); + await extension.startup(); + let win = window.open("file_sample.html"); + let results = await extension.awaitResults(); + win.close(); + verifyTestResults(results, "content script", "http:", false); + await extension.unload(); +}); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_unlimitedStorage.html b/toolkit/components/extensions/test/mochitest/test_ext_unlimitedStorage.html new file mode 100644 index 0000000000..d4aeb04bb5 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_unlimitedStorage.html @@ -0,0 +1,139 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for simple WebExtension</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <script type="text/javascript" src="head_unlimitedStorage.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> + +"use strict"; + +async function test_background_storagePersist(EXTENSION_ID) { + await SpecialPowers.pushPrefEnv({ + "set": [ + ["dom.storageManager.prompt.testing", false], + ["dom.storageManager.prompt.testing.allow", false], + ], + }); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + + manifest: { + permissions: ["storage", "unlimitedStorage"], + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + + background: async function() { + const PROMISE_RACE_TIMEOUT = 8000; + + browser.test.sendMessage("extension-uuid", window.location.host); + + await browser.storage.local.set({testkey: "testvalue"}); + await browser.test.sendMessage("storage-local-called"); + + const requestStoragePersist = async () => { + const persistAllowed = await navigator.storage.persist(); + if (!persistAllowed) { + throw new Error("navigator.storage.persist() has been denied"); + } + }; + + await Promise.race([ + requestStoragePersist(), + new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error("Timeout opening persistent db from background page")); + }, PROMISE_RACE_TIMEOUT); + }), + ]).then( + () => { + browser.test.notifyPass("indexeddb-storagePersistent-unlimitedStorage-done"); + }, + (error) => { + browser.test.fail(`error while testing persistent IndexedDB storage: ${error}`); + browser.test.notifyFail("indexeddb-storagePersistent-unlimitedStorage-done"); + } + ); + }, + }); + + await extension.startup(); + + const uuid = await extension.awaitMessage("extension-uuid"); + + await extension.awaitMessage("storage-local-called"); + + let chromeScript = SpecialPowers.loadChromeScript(function test_country_data() { + /* eslint-env mozilla/chrome-script */ + const {addMessageListener, sendAsyncMessage} = this; + + addMessageListener("getPersistedStatus", (uuid) => { + const { + ExtensionStorageIDB, + } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionStorageIDB.sys.mjs" + ); + + const {WebExtensionPolicy} = Cu.getGlobalForObject(ExtensionStorageIDB); + const policy = WebExtensionPolicy.getByHostname(uuid); + const storagePrincipal = ExtensionStorageIDB.getStoragePrincipal(policy.extension); + const request = Services.qms.persisted(storagePrincipal); + request.callback = () => { + // request.result will be undeinfed if the request failed (request.resultCode !== Cr.NS_OK). + sendAsyncMessage("gotPersistedStatus", request.result); + }; + }); + }); + + const persistedPromise = chromeScript.promiseOneMessage("gotPersistedStatus"); + chromeScript.sendAsyncMessage("getPersistedStatus", uuid); + is(await persistedPromise, true, "Got the expected persist status for the storagePrincipal"); + + await extension.awaitFinish("indexeddb-storagePersistent-unlimitedStorage-done"); + await extension.unload(); + + checkSitePermissions(uuid, Services.perms.UNKNOWN_ACTION, "has been cleared"); +} + +add_task(async function test_unlimitedStorage() { + const EXTENSION_ID = "test-storagePersist@mozilla"; + await SpecialPowers.pushPrefEnv({ + "set": [ + ["extensions.webextensions.ExtensionStorageIDB.enabled", true], + ], + }); + + // Verify persist mode enabled when the storage.local IDB database is opened from + // the main process (from parent/ext-storage.js). + info("Test unlimitedStorage on an extension migrating to the IndexedDB storage.local backend)"); + await test_background_storagePersist(EXTENSION_ID); + + await SpecialPowers.pushPrefEnv({ + "set": [ + [`extensions.webextensions.ExtensionStorageIDB.migrated.` + EXTENSION_ID, true], + ], + }); + + // Verify persist mode enabled when the storage.local IDB database is opened from + // the child process (from child/ext-storage.js). + info("Test unlimitedStorage on an extension migrated to the IndexedDB storage.local backend"); + await test_background_storagePersist(EXTENSION_ID); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_incognito.html b/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_incognito.html new file mode 100644 index 0000000000..d1c41d2030 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_incognito.html @@ -0,0 +1,170 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test the web_accessible_resources incognito</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +let image = atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" + + "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII="); +const IMAGE_ARRAYBUFFER = Uint8Array.from(image, byte => byte.charCodeAt(0)).buffer; + +async function testImageLoading(src, expectedAction) { + let imageLoadingPromise = new Promise((resolve, reject) => { + let cleanupListeners; + let testImage = new window.Image(); + // Set the src via wrappedJSObject so the load is triggered with the + // content page's principal rather than ours. + testImage.wrappedJSObject.setAttribute("src", src); + + let loadListener = () => { + cleanupListeners(); + resolve(expectedAction === "loaded"); + }; + + let errorListener = (event) => { + cleanupListeners(); + resolve(expectedAction === "blocked"); + browser.test.log(`+++ image loading ${event.error}`); + }; + + cleanupListeners = () => { + testImage.removeEventListener("load", loadListener); + testImage.removeEventListener("error", errorListener); + }; + + testImage.addEventListener("load", loadListener); + testImage.addEventListener("error", errorListener); + }); + + let success = await imageLoadingPromise; + browser.runtime.sendMessage({name: "image-loading", expectedAction, success}); +} + +function testScript() { + window.postMessage("test-script-loaded", "*"); +} + +add_task(async function test_web_accessible_resources_incognito() { + // This extension will not have access to private browsing so its + // accessible resources should not be able to load in them. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + "web_accessible_resources": [ + "image.png", + "test_script.js", + "accessible.html", + ], + }, + background() { + browser.test.sendMessage("url", browser.runtime.getURL("")); + }, + files: { + "image.png": IMAGE_ARRAYBUFFER, + "test_script.js": testScript, + "accessible.html": `<html><head> + <meta charset="utf-8"> + </head></html>`, + }, + }); + + await extension.startup(); + let baseUrl = await extension.awaitMessage("url"); + + async function content() { + let baseUrl = await browser.runtime.sendMessage({name: "get-url"}); + testImageLoading(`${baseUrl}image.png`, "loaded"); + + let testScriptElement = document.createElement("script"); + // Set the src via wrappedJSObject so the load is triggered with the + // content page's principal rather than ours. + testScriptElement.wrappedJSObject.setAttribute("src", `${baseUrl}test_script.js`); + document.head.appendChild(testScriptElement); + + let iframe = document.createElement("iframe"); + // Set the src via wrappedJSObject so the load is triggered with the + // content page's principal rather than ours. + iframe.wrappedJSObject.setAttribute("src", `${baseUrl}accessible.html`); + document.body.appendChild(iframe); + + // eslint-disable-next-line mozilla/balanced-listeners + window.addEventListener("message", event => { + browser.runtime.sendMessage({"name": event.data}); + }); + } + + let pb_extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["tabs"], + content_scripts: [{ + "matches": ["*://example.com/*/file_sample.html"], + "run_at": "document_end", + "js": ["content_script_helper.js", "content_script.js"], + }], + }, + files: { + "content_script_helper.js": `${testImageLoading}`, + "content_script.js": content, + }, + background() { + let url = "http://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html"; + let baseUrl; + let window; + + browser.runtime.onMessage.addListener(async msg => { + switch (msg.name) { + case "image-loading": + browser.test.assertFalse(msg.success, `Image was ${msg.expectedAction}`); + browser.test.sendMessage(`image-${msg.expectedAction}`); + break; + case "get-url": + return baseUrl; + default: + browser.test.fail(`unexepected message ${msg.name}`); + } + }); + + browser.test.onMessage.addListener(async (msg, data) => { + if (msg == "start") { + baseUrl = data; + window = await browser.windows.create({url, incognito: true}); + } + if (msg == "close") { + browser.windows.remove(window.id); + } + }); + }, + }); + await pb_extension.startup(); + + consoleMonitor.start([ + {message: /may not load or link to.*image.png/}, + {message: /may not load or link to.*test_script.js/}, + {message: /\<script\> source URI is not allowed in this document/}, + {message: /may not load or link to.*accessible.html/}, + ]); + + pb_extension.sendMessage("start", baseUrl); + + await pb_extension.awaitMessage("image-loaded"); + + pb_extension.sendMessage("close"); + + await extension.unload(); + await pb_extension.unload(); + + await consoleMonitor.finished(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_resources.html b/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_resources.html new file mode 100644 index 0000000000..c13e40e265 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_resources.html @@ -0,0 +1,567 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test the web_accessible_resources manifest directive</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +// add_setup not available in mochitest +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({set: [["extensions.manifestV3.enabled", true]]}); +}) + +let image = atob( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" + + "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII=" +); +const IMAGE_ARRAYBUFFER = Uint8Array.from(image, byte => byte.charCodeAt(0)) + .buffer; + +const ANDROID = navigator.userAgent.includes("Android"); + +async function testImageLoading(src, expectedAction) { + let imageLoadingPromise = new Promise((resolve, reject) => { + let cleanupListeners; + let testImage = document.createElement("img"); + // Set the src via wrappedJSObject so the load is triggered with the + // content page's principal rather than ours. + testImage.wrappedJSObject.setAttribute("src", src); + + let loadListener = () => { + cleanupListeners(); + resolve(expectedAction === "loaded"); + }; + + let errorListener = () => { + cleanupListeners(); + resolve(expectedAction === "blocked"); + }; + + cleanupListeners = () => { + testImage.removeEventListener("load", loadListener); + testImage.removeEventListener("error", errorListener); + }; + + testImage.addEventListener("load", loadListener); + testImage.addEventListener("error", errorListener); + + document.body.appendChild(testImage); + }); + + let success = await imageLoadingPromise; + browser.runtime.sendMessage({ + name: "image-loading", + expectedAction, + success, + }); +} + +async function _test_web_accessible_resources({ + manifest, + expectShouldLoadByDefault = true, + usePagePrincipal = false, +}) { + function background(shouldLoad, usePagePrincipal) { + let gotURL; + let tabId; + let expectBrowserAPI; + + function loadFrame(url, sandbox = null, srcdoc = false) { + return new Promise(resolve => { + browser.tabs.sendMessage( + tabId, + ["load-iframe", url, sandbox, srcdoc, usePagePrincipal], + reply => { + resolve(reply); + } + ); + }); + } + + // shouldLoad will be true unless we expect all attempts to fail. + let urls = [ + // { url, shouldLoad, sandbox, srcdoc } + { + url: browser.runtime.getURL("accessible.html"), + shouldLoad, + }, + { + url: browser.runtime.getURL("accessible.html") + "?foo=bar", + shouldLoad, + }, + { + url: browser.runtime.getURL("accessible.html") + "#!foo=bar", + shouldLoad, + }, + { + url: browser.runtime.getURL("accessible.html"), + shouldLoad, + sandbox: "allow-scripts", + }, + { + url: browser.runtime.getURL("accessible.html"), + shouldLoad, + sandbox: "allow-same-origin allow-scripts", + }, + { + url: browser.runtime.getURL("accessible.html"), + shouldLoad, + sandbox: "allow-scripts", + srcdoc: true, + }, + { + url: browser.runtime.getURL("inaccessible.html"), + shouldLoad: false, + }, + { + url: browser.runtime.getURL("inaccessible.html"), + shouldLoad: false, + sandbox: "allow-same-origin allow-scripts", + }, + { + url: browser.runtime.getURL("inaccessible.html"), + shouldLoad: false, + sandbox: "allow-same-origin allow-scripts", + srcdoc: true, + }, + { + url: browser.runtime.getURL("wild1.html"), + shouldLoad, + }, + { + url: browser.runtime.getURL("wild2.htm"), + shouldLoad: false, + }, + ]; + + async function runTests() { + for (let { url, shouldLoad, sandbox, srcdoc } of urls) { + // Sandboxed pages with an opaque origin do not get browser api. + expectBrowserAPI = !sandbox || sandbox.includes("allow-same-origin"); + let success = await loadFrame(url, sandbox, srcdoc); + + browser.test.assertEq(shouldLoad, success, "Load was successful"); + if (shouldLoad && !srcdoc) { + browser.test.assertEq(url, gotURL, "Got expected url"); + } else { + browser.test.assertEq(undefined, gotURL, "Got no url"); + } + gotURL = undefined; + } + + browser.test.notifyPass("web-accessible-resources"); + } + + browser.runtime.onMessage.addListener( + ([msg, url, hasBrowserAPI], sender) => { + if (msg == "content-script-ready") { + tabId = sender.tab.id; + runTests(); + } else if (msg == "page-script") { + browser.test.assertEq( + undefined, + gotURL, + "Should have gotten only one message" + ); + browser.test.assertEq("string", typeof url, "URL should be a string"); + browser.test.assertEq( + expectBrowserAPI, + hasBrowserAPI, + "has access to browser api" + ); + gotURL = url; + } + } + ); + + browser.test.sendMessage("ready"); + } + + function contentScript() { + window.addEventListener("message", event => { + // bounce the postmessage to the background script + if (event.data[0] == "page-script") { + browser.runtime.sendMessage(event.data); + } + }); + + browser.runtime.onMessage.addListener( + ([msg, url, sandboxed, srcdoc, usePagePrincipal], sender, respond) => { + if (msg == "load-iframe") { + // construct the frame using srcdoc if requested. + if (srcdoc) { + sandboxed = sandboxed !== null ? `sandbox="${sandboxed}"` : ""; + let frameSrc = `<iframe ${sandboxed} src="${url}" onload="parent.postMessage(true, '*')" onerror="parent.postMessage(false, '*')">`; + let frame = document.createElement("iframe"); + frame.setAttribute("srcdoc", frameSrc); + window.addEventListener("message", function listener(event) { + if (event.source === frame.contentWindow) { + window.removeEventListener("message", listener); + respond(event.data); + } + }); + document.body.appendChild(frame); + return true; + } + + let iframe = document.createElement("iframe"); + if (sandboxed !== null) { + iframe.setAttribute("sandbox", sandboxed); + } + + if (usePagePrincipal) { + // Test using the page principal + iframe.wrappedJSObject.src = url; + } else { + // Test using the expanded principal + iframe.src = url; + } + iframe.addEventListener("load", () => { + respond(true); + }); + iframe.addEventListener("error", () => { + respond(false); + }); + document.body.appendChild(iframe); + return true; + } + } + ); + browser.runtime.sendMessage(["content-script-ready"]); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + content_scripts: [ + { + matches: ["https://example.com/"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + ...manifest, + }, + + background: `(${background})(${expectShouldLoadByDefault}, ${usePagePrincipal})`, + + files: { + "content_script.js": contentScript, + + "accessible.html": `<!DOCTYPE html><html><head> + <meta charset="utf-8"> + <script src="pagescript.js"><\/script> + </head></html>`, + + "inaccessible.html": `<!DOCTYPE html><html><head> + <meta charset="utf-8"> + <script src="pagescript.js"><\/script> + </head></html>`, + + "wild1.html": `<!DOCTYPE html><html><head> + <meta charset="utf-8"> + <script src="pagescript.js"><\/script> + </head></html>`, + + "wild2.htm": `<!DOCTYPE html><html><head> + <meta charset="utf-8"> + <script src="pagescript.js"><\/script> + </head></html>`, + + "pagescript.js": + // We postmessage so we can determine when browser is not available + 'window.parent.postMessage(["page-script", location.href, typeof browser !== "undefined"], "*");', + }, + }); + + await extension.startup(); + + await extension.awaitMessage("ready"); + + let win = window.open("https://example.com/"); + + await extension.awaitFinish("web-accessible-resources"); + + win.close(); + + await extension.unload(); +}; + +add_task(async function test_web_accessible_resources_v2() { + await SpecialPowers.pushPrefEnv({set: [["extensions.content_web_accessible.enabled", true]]}); + consoleMonitor.start([ + {message: /Content at https:\/\/example.com\/ may not load or link to.*inaccessible.html/}, + ]); + await _test_web_accessible_resources({ + manifest: { + manifest_version: 2, + web_accessible_resources: ["/accessible.html", "wild*.html"], + } + }); + await consoleMonitor.finished(); + await SpecialPowers.popPrefEnv(); +}); + +// Same test as above, but using only the content principal +add_task(async function test_web_accessible_resources_v2_content() { + await SpecialPowers.pushPrefEnv({set: [["extensions.content_web_accessible.enabled", true]]}); + consoleMonitor.start([ + {message: /Content at https:\/\/example.com\/ may not load or link to.*inaccessible.html/}, + ]); + await _test_web_accessible_resources({ + manifest: { + manifest_version: 2, + web_accessible_resources: ["/accessible.html", "wild*.html"], + }, + usePagePrincipal: true, + }); + await consoleMonitor.finished(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_web_accessible_resources_v3() { + // MV3 always requires this, pref off to ensure it works. + await SpecialPowers.pushPrefEnv({set: [["extensions.content_web_accessible.enabled", false]]}); + consoleMonitor.start([ + {message: /Content at https:\/\/example.com\/ may not load or link to.*inaccessible.html/}, + ]); + await _test_web_accessible_resources({ + manifest: { + manifest_version: 3, + web_accessible_resources: [ + { + resources: ["/accessible.html", "wild*.html"], + matches: ["*://example.com/*"] + }, + ], + host_permissions: ["*://example.com/*"], + granted_host_permissions: true, + } + }); + await consoleMonitor.finished(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_web_accessible_resources_v3_by_id() { + consoleMonitor.start([ + {message: /Content at https:\/\/example.com\/ may not load or link to.*accessible.html/}, + {message: /Content at https:\/\/example.com\/ may not load or link to.*inaccessible.html/}, + ]); + await _test_web_accessible_resources({ + manifest: { + manifest_version: 3, + browser_specific_settings: { + gecko: { + id: "extension_wac@mochitest", + }, + }, + web_accessible_resources: [ + { + resources: ["/accessible.html", "wild*.html"], + extension_ids: ["extension_wac@mochitest"] + }, + ], + host_permissions: ["*://example.com/*"], + // Work-around for bug 1766752 to allow content_scripts to run: + granted_host_permissions: true, + }, + expectShouldLoadByDefault: false, + }); + await consoleMonitor.finished(); +}); + +add_task(async function test_web_accessible_resources_mixed_content() { + function background() { + browser.runtime.onMessage.addListener(msg => { + if (msg.name === "image-loading") { + browser.test.assertTrue(msg.success, `Image was ${msg.expectedAction}`); + browser.test.sendMessage(`image-${msg.expectedAction}`); + } else { + browser.test.sendMessage(msg); + if (msg === "accessible-script-loaded") { + browser.test.notifyPass("mixed-test"); + } + } + }); + + browser.test.sendMessage("background-ready"); + } + + async function content() { + await testImageLoading( + "http://example.com/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png", + "blocked" + ); + await testImageLoading(browser.runtime.getURL("image.png"), "loaded"); + + let testScriptElement = document.createElement("script"); + // Set the src via wrappedJSObject so the load is triggered with the + // content page's principal rather than ours. + testScriptElement.wrappedJSObject.setAttribute( + "src", + browser.runtime.getURL("test_script.js") + ); + document.head.appendChild(testScriptElement); + + window.addEventListener("message", event => { + browser.runtime.sendMessage(event.data); + }); + } + + function testScript() { + window.postMessage("accessible-script-loaded", "*"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["https://example.com/*/file_mixed.html"], + run_at: "document_end", + js: ["content_script_helper.js", "content_script.js"], + }, + ], + web_accessible_resources: ["image.png", "test_script.js"], + }, + background, + files: { + "content_script_helper.js": `${testImageLoading}`, + "content_script.js": content, + "test_script.js": testScript, + "image.png": IMAGE_ARRAYBUFFER, + }, + }); + + await SpecialPowers.pushPrefEnv({set: [ + ["security.mixed_content.upgrade_display_content", false], + ["security.mixed_content.block_display_content", true], + ]}); + + await Promise.all([ + extension.startup(), + extension.awaitMessage("background-ready"), + ]); + + let win = window.open( + "https://example.com/tests/toolkit/components/extensions/test/mochitest/file_mixed.html" + ); + + await Promise.all([ + extension.awaitMessage("image-blocked"), + extension.awaitMessage("image-loaded"), + extension.awaitMessage("accessible-script-loaded"), + ]); + await extension.awaitFinish("mixed-test"); + win.close(); + + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); + +// test that MV2 extensions continue to open other MV2 extension pages +// when they are not listed in web_accessible_resources. This test also +// covers mobile/android tab creation. +add_task(async function test_web_accessible_resources_extensions_MV2() { + function background() { + let newtab; + let win; + let expectUrl; + browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { + if (!expectUrl || tab.url != expectUrl || changeInfo.status !== "complete") { + return; + } + expectUrl = undefined; + browser.test.log(`onUpdated ${JSON.stringify(changeInfo)} ${tab.url}`); + browser.test.sendMessage("onUpdated", tab.url); + }); + browser.test.onMessage.addListener(async (msg, url) => { + browser.test.log(`onMessage ${msg} ${url}`); + expectUrl = url; + if (msg == "create") { + newtab = await browser.tabs.create({ url }); + browser.test.assertTrue( + newtab.id !== browser.tabs.TAB_ID_NONE, + "New tab was created." + ); + } else if (msg == "update") { + await browser.tabs.update(newtab.id, { url }); + } else if (msg == "remove") { + await browser.tabs.remove(newtab.id); + newtab = null; + browser.test.sendMessage("completed"); + } else if (msg == "open-window") { + win = await browser.windows.create({ url }); + } else if (msg == "close-window") { + await browser.windows.remove(win.id); + browser.test.sendMessage("completed"); + win = null; + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: "this-mv2@mochitest" } }, + }, + background, + files: { + "page.html": `<!DOCTYPE html><html><head> + <meta charset="utf-8"> + </head></html>`, + }, + }); + + async function testTabsAction(ext, action, url) { + ext.sendMessage(action, url); + is(await ext.awaitMessage("onUpdated"), url, "extension url was loaded"); + } + + await extension.startup(); + let extensionUrl = `moz-extension://${extension.uuid}/page.html`; + + // Test opening its own pages + await testTabsAction(extension, "create", `${extensionUrl}?q=1`); + await testTabsAction(extension, "update", `${extensionUrl}?q=2`); + extension.sendMessage("remove"); + await extension.awaitMessage("completed"); + if (!ANDROID) { + await testTabsAction(extension, "open-window", `${extensionUrl}?q=3`); + extension.sendMessage("close-window"); + await extension.awaitMessage("completed"); + } + + // Extension used to open the homepage in a new window. + let other = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "<all_urls>"], + }, + background, + }); + await other.startup(); + + // Test opening another extensions pages + await testTabsAction(other, "create", `${extensionUrl}?q=4`); + await testTabsAction(other, "update", `${extensionUrl}?q=5`); + other.sendMessage("remove"); + await other.awaitMessage("completed"); + if (!ANDROID) { + await testTabsAction(other, "open-window", `${extensionUrl}?q=6`); + other.sendMessage("close-window"); + await other.awaitMessage("completed"); + } + + await extension.unload(); + await other.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webnavigation.html b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation.html new file mode 100644 index 0000000000..12c90f8350 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation.html @@ -0,0 +1,610 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for simple WebExtension</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +if (AppConstants.platform === "android") { + SimpleTest.requestLongerTimeout(3); +} + +/* globals sendMouseEvent */ + +function backgroundScript() { + const BASE = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest"; + const URL = BASE + "/file_WebNavigation_page1.html"; + + const EVENTS = [ + "onTabReplaced", + "onBeforeNavigate", + "onCommitted", + "onDOMContentLoaded", + "onCompleted", + "onErrorOccurred", + "onReferenceFragmentUpdated", + "onHistoryStateUpdated", + ]; + + let expectedTabId = -1; + + function gotEvent(event, details) { + if (!details.url.startsWith(BASE)) { + return; + } + browser.test.log(`Got ${event} ${details.url} ${details.frameId} ${details.parentFrameId}`); + + if (expectedTabId == -1) { + browser.test.assertTrue(details.tabId !== undefined, "tab ID defined"); + expectedTabId = details.tabId; + } + + browser.test.assertEq(details.tabId, expectedTabId, "correct tab"); + + browser.test.sendMessage("received", {url: details.url, event}); + + if (details.url == URL) { + browser.test.assertEq(0, details.frameId, "root frame ID correct"); + browser.test.assertEq(-1, details.parentFrameId, "root parent frame ID correct"); + } else { + browser.test.assertEq(0, details.parentFrameId, "parent frame ID correct"); + browser.test.assertTrue(details.frameId != 0, "frame ID probably okay"); + } + + browser.test.assertTrue(details.frameId !== undefined, "frameId != undefined"); + browser.test.assertTrue(details.parentFrameId !== undefined, "parentFrameId != undefined"); + } + + let listeners = {}; + for (let event of EVENTS) { + listeners[event] = gotEvent.bind(null, event); + browser.webNavigation[event].addListener(listeners[event]); + } + + browser.test.sendMessage("ready"); +} + +const BASE = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest"; +const URL = BASE + "/file_WebNavigation_page1.html"; +const FORM_URL = URL + "?"; +const FRAME = BASE + "/file_WebNavigation_page2.html"; +const FRAME2 = BASE + "/file_WebNavigation_page3.html"; +const FRAME_PUSHSTATE = BASE + "/file_WebNavigation_page3_pushState.html"; +const REDIRECT = BASE + "/redirection.sjs"; +const REDIRECTED = BASE + "/dummy_page.html"; +const CLIENT_REDIRECT = BASE + "/file_webNavigation_clientRedirect.html"; +const CLIENT_REDIRECT_HTTPHEADER = BASE + "/file_webNavigation_clientRedirect_httpHeaders.html"; +const FRAME_CLIENT_REDIRECT = BASE + "/file_webNavigation_frameClientRedirect.html"; +const FRAME_REDIRECT = BASE + "/file_webNavigation_frameRedirect.html"; +const FRAME_MANUAL = BASE + "/file_webNavigation_manualSubframe.html"; +const FRAME_MANUAL_PAGE1 = BASE + "/file_webNavigation_manualSubframe_page1.html"; +const FRAME_MANUAL_PAGE2 = BASE + "/file_webNavigation_manualSubframe_page2.html"; +const INVALID_PAGE = "https://invalid.localhost/"; + +const REQUIRED = [ + "onBeforeNavigate", + "onCommitted", + "onDOMContentLoaded", + "onCompleted", +]; + +var received = []; +var completedResolve; +var waitingURL, waitingEvent; + +function loadAndWait(win, event, url, script) { + received = []; + waitingEvent = event; + waitingURL = url; + dump(`RUN ${script}\n`); + script(); + return new Promise(resolve => { completedResolve = resolve; }); +} + +add_task(async function webnav_transitions_props() { + function backgroundScriptTransitions() { + const EVENTS = [ + "onCommitted", + "onHistoryStateUpdated", + "onReferenceFragmentUpdated", + "onCompleted", + ]; + + function gotEvent(event, details) { + browser.test.log(`Got ${event} ${details.url} ${details.transitionType} ${details.transitionQualifiers && JSON.stringify(details.transitionQualifiers)}`); + + browser.test.sendMessage("received", {url: details.url, details, event}); + } + + let listeners = {}; + for (let event of EVENTS) { + listeners[event] = gotEvent.bind(null, event); + browser.webNavigation[event].addListener(listeners[event]); + } + + browser.test.sendMessage("ready"); + } + + let extensionData = { + manifest: { + permissions: [ + "webNavigation", + ], + }, + background: backgroundScriptTransitions, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + extension.onMessage("received", ({url, event, details}) => { + received.push({url, event, details}); + + if (event == waitingEvent && url == waitingURL) { + completedResolve(); + } + }); + + await Promise.all([extension.startup(), extension.awaitMessage("ready")]); + info("webnavigation extension loaded"); + + let win = window.open(); + + await loadAndWait(win, "onCompleted", URL, () => { win.location = URL; }); + + // transitionType: reload + received = []; + await loadAndWait(win, "onCompleted", URL, () => { win.location.reload(); }); + + let found = received.find((data) => (data.event == "onCommitted" && data.url == URL)); + + ok(found, "Got the onCommitted event"); + + if (found) { + is(found.details.transitionType, "reload", + "Got the expected 'reload' transitionType in the OnCommitted event"); + ok(Array.isArray(found.details.transitionQualifiers), + "transitionQualifiers found in the OnCommitted events"); + } + + // transitionType: auto_subframe + found = received.find((data) => (data.event == "onCommitted" && data.url == FRAME)); + + ok(found, "Got the sub-frame onCommitted event"); + + if (found) { + is(found.details.transitionType, "auto_subframe", + "Got the expected 'auto_subframe' transitionType in the OnCommitted event"); + ok(Array.isArray(found.details.transitionQualifiers), + "transitionQualifiers found in the OnCommitted events"); + } + + // transitionType: form_submit + received = []; + await loadAndWait(win, "onCompleted", FORM_URL, () => { + win.document.querySelector("form").submit(); + }); + + found = received.find((data) => (data.event == "onCommitted" && data.url == FORM_URL)); + + ok(found, "Got the onCommitted event"); + + if (found) { + is(found.details.transitionType, "form_submit", + "Got the expected 'form_submit' transitionType in the OnCommitted event"); + ok(Array.isArray(found.details.transitionQualifiers), + "transitionQualifiers found in the OnCommitted events"); + } + + // transitionQualifier: server_redirect + received = []; + await loadAndWait(win, "onCompleted", REDIRECTED, () => { win.location = REDIRECT; }); + + found = received.find((data) => (data.event == "onCommitted" && data.url == REDIRECTED)); + + ok(found, "Got the onCommitted event"); + + if (found) { + is(found.details.transitionType, "link", + "Got the expected 'link' transitionType in the OnCommitted event"); + ok(Array.isArray(found.details.transitionQualifiers) && + found.details.transitionQualifiers.find((q) => q == "server_redirect"), + "Got the expected 'server_redirect' transitionQualifiers in the OnCommitted events"); + } + + // transitionQualifier: forward_back + received = []; + await loadAndWait(win, "onCompleted", FORM_URL, () => { win.history.back(); }); + + found = received.find((data) => (data.event == "onCommitted" && data.url == FORM_URL)); + + ok(found, "Got the onCommitted event"); + + if (found) { + is(found.details.transitionType, "link", + "Got the expected 'link' transitionType in the OnCommitted event"); + ok(Array.isArray(found.details.transitionQualifiers) && + found.details.transitionQualifiers.find((q) => q == "forward_back"), + "Got the expected 'forward_back' transitionQualifiers in the OnCommitted events"); + } + + // transitionQualifier: client_redirect + // (from meta http-equiv tag) + received = []; + await loadAndWait(win, "onCompleted", REDIRECTED, () => { + win.location = CLIENT_REDIRECT; + }); + + found = received.find((data) => (data.event == "onCommitted" && data.url == REDIRECTED)); + + ok(found, "Got the onCommitted event"); + + if (found) { + is(found.details.transitionType, "link", + "Got the expected 'link' transitionType in the OnCommitted event"); + ok(Array.isArray(found.details.transitionQualifiers) && + found.details.transitionQualifiers.find((q) => q == "client_redirect"), + "Got the expected 'client_redirect' transitionQualifiers in the OnCommitted events"); + } + + // transitionQualifier: client_redirect + // (from http headers) + received = []; + await loadAndWait(win, "onCompleted", REDIRECTED, () => { + win.location = CLIENT_REDIRECT_HTTPHEADER; + }); + + found = received.find((data) => (data.event == "onCommitted" && data.url == REDIRECTED)); + + ok(found, "Got the onCommitted event"); + + if (found) { + is(found.details.transitionType, "link", + "Got the expected 'link' transitionType in the OnCommitted event"); + ok(Array.isArray(found.details.transitionQualifiers) && + found.details.transitionQualifiers.find((q) => q == "client_redirect"), + "Got the expected 'client_redirect' transitionQualifiers in the OnCommitted events"); + } + + // transitionQualifier: client_redirect (sub-frame) + // (from meta http-equiv tag) + received = []; + await loadAndWait(win, "onCompleted", REDIRECTED, () => { + win.location = FRAME_CLIENT_REDIRECT; + }); + + found = received.find((data) => (data.event == "onCommitted" && data.url == REDIRECTED)); + + ok(found, "Got the onCommitted event"); + + if (found) { + is(found.details.transitionType, "auto_subframe", + "Got the expected 'auto_subframe' transitionType in the OnCommitted event"); + ok(Array.isArray(found.details.transitionQualifiers) && + found.details.transitionQualifiers.find((q) => q == "client_redirect"), + "Got the expected 'client_redirect' transitionQualifiers in the OnCommitted events"); + } + + // transitionQualifier: server_redirect (sub-frame) + received = []; + await loadAndWait(win, "onCompleted", REDIRECTED, () => { win.location = FRAME_REDIRECT; }); + + found = received.find((data) => (data.event == "onCommitted" && data.url == REDIRECT)); + + ok(found, "Got the onCommitted event"); + + if (found) { + is(found.details.transitionType, "auto_subframe", + "Got the expected 'auto_subframe' transitionType in the OnCommitted event"); + // TODO BUG 1264936: currently the server_redirect is not detected in sub-frames + // once we fix it we can test it here: + // + // ok(Array.isArray(found.details.transitionQualifiers) && + // found.details.transitionQualifiers.find((q) => q == "server_redirect"), + // "Got the expected 'server_redirect' transitionQualifiers in the OnCommitted events"); + } + + // transitionType: manual_subframe + received = []; + await loadAndWait(win, "onCompleted", FRAME_MANUAL, () => { win.location = FRAME_MANUAL; }); + found = received.find((data) => (data.event == "onCommitted" && + data.url == FRAME_MANUAL_PAGE1)); + + ok(found, "Got the onCommitted event"); + + if (found) { + is(found.details.transitionType, "auto_subframe", + "Got the expected 'auto_subframe' transitionType in the OnCommitted event"); + } + + received = []; + await loadAndWait(win, "onCompleted", FRAME_MANUAL_PAGE2, () => { + let el = win.document.querySelector("iframe") + .contentDocument.querySelector("a"); + sendMouseEvent({type: "click"}, el, win); + }); + + found = received.find((data) => (data.event == "onCommitted" && + data.url == FRAME_MANUAL_PAGE2)); + + ok(found, "Got the onCommitted event"); + + if (found) { + if (AppConstants.MOZ_BUILD_APP === "browser") { + is(found.details.transitionType, "manual_subframe", + "Got the expected 'manual_subframe' transitionType in the OnCommitted event"); + } else { + is(found.details.transitionType, "auto_subframe", + "Got the expected 'manual_subframe' transitionType in the OnCommitted event"); + } + } + + // Test transitions properties on onHistoryStateUpdated events. + + received = []; + await loadAndWait(win, "onCompleted", FRAME2, () => { win.location = FRAME2; }); + + received = []; + await loadAndWait(win, "onHistoryStateUpdated", `${FRAME2}/pushState`, () => { + win.history.pushState({}, "History PushState", `${FRAME2}/pushState`); + }); + + found = received.find((data) => (data.event == "onHistoryStateUpdated" && + data.url == `${FRAME2}/pushState`)); + + ok(found, "Got the onHistoryStateUpdated event"); + + if (found) { + is(typeof found.details.transitionType, "string", + "Got transitionType in the onHistoryStateUpdated event"); + ok(Array.isArray(found.details.transitionQualifiers), + "Got transitionQualifiers in the onHistoryStateUpdated event"); + } + + // Test transitions properties on onReferenceFragmentUpdated events. + + received = []; + await loadAndWait(win, "onReferenceFragmentUpdated", `${FRAME2}/pushState#ref2`, () => { + win.history.pushState({}, "ReferenceFragment Update", `${FRAME2}/pushState#ref2`); + }); + + found = received.find((data) => (data.event == "onReferenceFragmentUpdated" && + data.url == `${FRAME2}/pushState#ref2`)); + + ok(found, "Got the onReferenceFragmentUpdated event"); + + if (found) { + is(typeof found.details.transitionType, "string", + "Got transitionType in the onReferenceFragmentUpdated event"); + ok(Array.isArray(found.details.transitionQualifiers), + "Got transitionQualifiers in the onReferenceFragmentUpdated event"); + } + + // cleanup phase + win.close(); + + await extension.unload(); + info("webnavigation extension unloaded"); +}); + +add_task(async function webnav_ordering() { + let extensionData = { + manifest: { + permissions: [ + "webNavigation", + ], + }, + background: backgroundScript, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + extension.onMessage("received", ({url, event}) => { + received.push({url, event}); + + if (event == waitingEvent && url == waitingURL) { + completedResolve(); + } + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + info("webnavigation extension loaded"); + + let win = window.open(); + + await loadAndWait(win, "onCompleted", URL, () => { win.location = URL; }); + + function checkRequired(url) { + for (let event of REQUIRED) { + let found = false; + for (let r of received) { + if (r.url == url && r.event == event) { + found = true; + } + } + ok(found, `Received event ${event} from ${url}`); + } + } + + checkRequired(URL); + checkRequired(FRAME); + + function checkBefore(action1, action2) { + function find(action) { + for (let i = 0; i < received.length; i++) { + if (received[i].url == action.url && received[i].event == action.event) { + return i; + } + } + return -1; + } + + let index1 = find(action1); + let index2 = find(action2); + ok(index1 != -1, `Action ${JSON.stringify(action1)} happened`); + ok(index2 != -1, `Action ${JSON.stringify(action2)} happened`); + ok(index1 < index2, `Action ${JSON.stringify(action1)} happened before ${JSON.stringify(action2)}`); + } + + // As required in the webNavigation API documentation: + // If a navigating frame contains subframes, its onCommitted is fired before any + // of its children's onBeforeNavigate; while onCompleted is fired after + // all of its children's onCompleted. + checkBefore({url: URL, event: "onCommitted"}, {url: FRAME, event: "onBeforeNavigate"}); + checkBefore({url: FRAME, event: "onCompleted"}, {url: URL, event: "onCompleted"}); + + // As required in the webNAvigation API documentation, check the event sequence: + // onBeforeNavigate -> onCommitted -> onDOMContentLoaded -> onCompleted + let expectedEventSequence = [ + "onBeforeNavigate", "onCommitted", "onDOMContentLoaded", "onCompleted", + ]; + + for (let i = 1; i < expectedEventSequence.length; i++) { + let after = expectedEventSequence[i]; + let before = expectedEventSequence[i - 1]; + checkBefore({url: URL, event: before}, {url: URL, event: after}); + checkBefore({url: FRAME, event: before}, {url: FRAME, event: after}); + } + + await loadAndWait(win, "onCompleted", FRAME2, () => { win.frames[0].location = FRAME2; }); + + checkRequired(FRAME2); + + let navigationSequence = [ + { + action: () => { win.frames[0].document.getElementById("elt").click(); }, + waitURL: `${FRAME2}#ref`, + expectedEvent: "onReferenceFragmentUpdated", + description: "clicked an anchor link", + }, + { + action: () => { win.frames[0].history.pushState({}, "History PushState", `${FRAME2}#ref2`); }, + waitURL: `${FRAME2}#ref2`, + expectedEvent: "onReferenceFragmentUpdated", + description: "history.pushState, same pathname, different hash", + }, + { + action: () => { win.frames[0].history.pushState({}, "History PushState", `${FRAME2}#ref2`); }, + waitURL: `${FRAME2}#ref2`, + expectedEvent: "onHistoryStateUpdated", + description: "history.pushState, same pathname, same hash", + }, + { + action: () => { + win.frames[0].history.pushState({}, "History PushState", `${FRAME2}?query_param1=value#ref2`); + }, + waitURL: `${FRAME2}?query_param1=value#ref2`, + expectedEvent: "onHistoryStateUpdated", + description: "history.pushState, same pathname, same hash, different query params", + }, + { + action: () => { + win.frames[0].history.pushState({}, "History PushState", `${FRAME2}?query_param2=value#ref3`); + }, + waitURL: `${FRAME2}?query_param2=value#ref3`, + expectedEvent: "onHistoryStateUpdated", + description: "history.pushState, same pathname, different hash, different query params", + }, + { + action: () => { win.frames[0].history.pushState(null, "History PushState", FRAME_PUSHSTATE); }, + waitURL: FRAME_PUSHSTATE, + expectedEvent: "onHistoryStateUpdated", + description: "history.pushState, different pathname", + }, + ]; + + for (let navigation of navigationSequence) { + let {expectedEvent, waitURL, action, description} = navigation; + info(`Waiting ${expectedEvent} from ${waitURL} - ${description}`); + await loadAndWait(win, expectedEvent, waitURL, action); + info(`Received ${expectedEvent} from ${waitURL} - ${description}`); + } + + for (let i = navigationSequence.length - 1; i > 0; i--) { + let {waitURL: fromURL, expectedEvent} = navigationSequence[i]; + let {waitURL} = navigationSequence[i - 1]; + info(`Waiting ${expectedEvent} from ${waitURL} - history.back() from ${fromURL} to ${waitURL}`); + await loadAndWait(win, expectedEvent, waitURL, () => { win.frames[0].history.back(); }); + info(`Received ${expectedEvent} from ${waitURL} - history.back() from ${fromURL} to ${waitURL}`); + } + + for (let i = 0; i < navigationSequence.length - 1; i++) { + let {waitURL: fromURL} = navigationSequence[i]; + let {waitURL, expectedEvent} = navigationSequence[i + 1]; + info(`Waiting ${expectedEvent} from ${waitURL} - history.forward() from ${fromURL} to ${waitURL}`); + await loadAndWait(win, expectedEvent, waitURL, () => { win.frames[0].history.forward(); }); + info(`Received ${expectedEvent} from ${waitURL} - history.forward() from ${fromURL} to ${waitURL}`); + } + + win.close(); + + await extension.unload(); + info("webnavigation extension unloaded"); +}); + +add_task(async function webnav_error_event() { + function backgroundScriptErrorEvent() { + browser.webNavigation.onErrorOccurred.addListener((details) => { + browser.test.log(`Got onErrorOccurred ${details.url} ${details.error}`); + + browser.test.sendMessage("received", {url: details.url, details, event: "onErrorOccurred"}); + }); + + browser.test.sendMessage("ready"); + } + + let extensionData = { + manifest: { + permissions: [ + "webNavigation", + ], + }, + background: backgroundScriptErrorEvent, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + extension.onMessage("received", ({url, event, details}) => { + received.push({url, event, details}); + + if (event == waitingEvent && url == waitingURL) { + completedResolve(); + } + }); + + await Promise.all([extension.startup(), extension.awaitMessage("ready")]); + info("webnavigation extension loaded"); + + let win = window.open(); + + received = []; + await loadAndWait(win, "onErrorOccurred", INVALID_PAGE, () => { win.location = INVALID_PAGE; }); + + let found = received.find((data) => (data.event == "onErrorOccurred" && + data.url == INVALID_PAGE)); + + ok(found, "Got the onErrorOccurred event"); + + if (found) { + ok(found.details.error.match(/Error code [0-9]+/), + "Got the expected error string in the onErrorOccurred event"); + } + + // cleanup phase + win.close(); + + await extension.unload(); + info("webnavigation extension unloaded"); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_filters.html b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_filters.html new file mode 100644 index 0000000000..19cb6539d7 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_filters.html @@ -0,0 +1,313 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for simple WebExtension</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_webnav_unresolved_uri_on_expected_URI_scheme() { + function background() { + let listeners = []; + + function cleanupTestListeners() { + browser.test.log(`Cleanup previous test event listeners`); + for (let {event, listener} of listeners.splice(0)) { + browser.webNavigation[event].removeListener(listener); + } + } + + function createTestListener(event, fail, urlFilter) { + return new Promise(resolve => { + function listener(details) { + let log = JSON.stringify({url: details.url, urlFilter}); + if (fail) { + browser.test.fail(`Got an unexpected ${event} on the failure listener: ${log}`); + } else { + browser.test.succeed(`Got the expected ${event} on the success listener: ${log}`); + } + + resolve(); + } + + browser.webNavigation[event].addListener(listener, {url: urlFilter}); + listeners.push({event, listener}); + }); + } + + browser.test.onMessage.addListener((msg, events, data) => { + if (msg !== "test-filters") { + return; + } + + let promises = []; + + for (let {okFilter, failFilter} of data.filters) { + for (let event of events) { + promises.push( + Promise.race([ + createTestListener(event, false, okFilter), + createTestListener(event, true, failFilter), + ])); + } + } + + Promise.all(promises).catch(e => { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + }).then(() => { + cleanupTestListeners(); + browser.test.sendMessage("test-filter-next"); + }); + + browser.test.sendMessage("test-filter-ready"); + }); + } + + let extensionData = { + manifest: { + permissions: [ + "webNavigation", + ], + }, + background, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + let win = window.open(); + + let testFilterScenarios = [ + { + url: "https://example.net/browser", + filters: [ + // schemes + { + okFilter: [{schemes: ["https"]}], + failFilter: [{schemes: ["http"]}], + }, + // ports + { + okFilter: [{ports: [80, 22, 443]}], + failFilter: [{ports: [81, 82, 83]}], + }, + { + okFilter: [{ports: [22, 443, [10, 80]]}], + failFilter: [{ports: [22, 23, [81, 100]]}], + }, + // multiple criteria in a single filter: + // if one of the criteria is not verified, the event should not be received. + { + okFilter: [{schemes: ["https"], ports: [80, 22, 443]}], + failFilter: [{schemes: ["https"], ports: [81, 82, 83]}], + }, + { + okFilter: [{hostEquals: "example.net", ports: [80, 22, 443]}], + failFilter: [{hostEquals: "example.org", ports: [80, 22, 443]}], + }, + // multiple urlFilters on the same listener + // if at least one of the criteria is verified, the event should be received. + { + okFilter: [{schemes: ["http"]}, {ports: [80, 22, 443]}], + failFilter: [{schemes: ["http"]}, {ports: [81, 82, 83]}], + }, + ], + }, + { + url: "https://example.net/browser?param=1#ref", + filters: [ + // host: Equals, Contains, Prefix, Suffix + { + okFilter: [{hostEquals: "example.net"}], + failFilter: [{hostEquals: "example.com"}], + }, + { + okFilter: [{hostContains: ".example"}], + failFilter: [{hostContains: ".www"}], + }, + { + okFilter: [{hostPrefix: "example"}], + failFilter: [{hostPrefix: "www"}], + }, + { + okFilter: [{hostSuffix: "net"}], + failFilter: [{hostSuffix: "com"}], + }, + // path: Equals, Contains, Prefix, Suffix + { + okFilter: [{pathEquals: "/browser"}], + failFilter: [{pathEquals: "/"}], + }, + { + okFilter: [{pathContains: "brow"}], + failFilter: [{pathContains: "tool"}], + }, + { + okFilter: [{pathPrefix: "/bro"}], + failFilter: [{pathPrefix: "/tool"}], + }, + { + okFilter: [{pathSuffix: "wser"}], + failFilter: [{pathSuffix: "kit"}], + }, + // query: Equals, Contains, Prefix, Suffix + { + okFilter: [{queryEquals: "param=1"}], + failFilter: [{queryEquals: "wrongparam=2"}], + }, + { + okFilter: [{queryContains: "param"}], + failFilter: [{queryContains: "wrongparam"}], + }, + { + okFilter: [{queryPrefix: "param="}], + failFilter: [{queryPrefix: "wrong"}], + }, + { + okFilter: [{querySuffix: "=1"}], + failFilter: [{querySuffix: "=2"}], + }, + // urlMatches, originAndPathMatches + { + okFilter: [{urlMatches: "example.net/.*\?param=1"}], + failFilter: [{urlMatches: "example.net/.*\?wrongparam=2"}], + }, + { + okFilter: [{originAndPathMatches: "example.net\/browser"}], + failFilter: [{originAndPathMatches: "example.net/.*\?param=1"}], + }, + ], + }, + ]; + + info("WebNavigation event filters test scenarios starting..."); + + const EVENTS = [ + "onBeforeNavigate", + "onCommitted", + "onDOMContentLoaded", + "onCompleted", + ]; + + for (let data of testFilterScenarios) { + info(`Prepare the new test scenario: ${JSON.stringify(data)}`); + + win.location = "about:blank"; + + // Wait for the about:blank load to finish before continuing, in case this + // load is causing a process switch back into our process. + await SimpleTest.promiseWaitForCondition(() => { + try { + return win.location.href == "about:blank" && + win.document.readyState == "complete"; + } catch (e) { + return false; + } + }); + + extension.sendMessage("test-filters", EVENTS, data); + await extension.awaitMessage("test-filter-ready"); + + info(`Loading the test url: ${data.url}`); + win.location = data.url; + + await extension.awaitMessage("test-filter-next"); + + info("Test scenario completed. Moving to the next test scenario."); + } + + info("WebNavigation event filters test onReferenceFragmentUpdated scenario starting..."); + + const BASE = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest"; + let url = BASE + "/file_WebNavigation_page3.html"; + + let okFilter = [{urlContains: "_page3.html"}]; + let failFilter = [{ports: [444]}]; + let data = {filters: [{okFilter, failFilter}]}; + let event = "onCompleted"; + + info(`Loading the initial test url: ${url}`); + extension.sendMessage("test-filters", [event], data); + + await extension.awaitMessage("test-filter-ready"); + win.location = url; + await extension.awaitMessage("test-filter-next"); + + event = "onReferenceFragmentUpdated"; + extension.sendMessage("test-filters", [event], data); + + await extension.awaitMessage("test-filter-ready"); + win.location = url + "#ref1"; + await extension.awaitMessage("test-filter-next"); + + info("WebNavigation event filters test onHistoryStateUpdated scenario starting..."); + + event = "onHistoryStateUpdated"; + extension.sendMessage("test-filters", [event], data); + await extension.awaitMessage("test-filter-ready"); + + win.history.pushState({}, "", BASE + "/pushState_page3.html"); + await extension.awaitMessage("test-filter-next"); + + // TODO: add additional specific tests for the other webNavigation events: + // onErrorOccurred (and onCreatedNavigationTarget on supported) + + info("WebNavigation event filters test scenarios completed."); + + await extension.unload(); + + win.close(); +}); + +add_task(async function test_webnav_empty_filter_validation_error() { + function background() { + let catchedException; + + try { + browser.webNavigation.onCompleted.addListener( + // Empty callback (not really used) + () => {}, + // Empty filter (which should raise a validation error exception). + {url: []} + ); + } catch (e) { + catchedException = e; + browser.test.log(`Got an exception`); + } + + if (catchedException && + catchedException.message.includes("Type error for parameter filters") && + catchedException.message.includes("Array requires at least 1 items; you have 0")) { + browser.test.notifyPass("webNav.emptyFilterValidationError"); + } else { + browser.test.notifyFail("webNav.emptyFilterValidationError"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webNavigation", + ], + }, + background, + }); + + await extension.startup(); + + await extension.awaitFinish("webNav.emptyFilterValidationError"); + + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_incognito.html b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_incognito.html new file mode 100644 index 0000000000..45147365ee --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_incognito.html @@ -0,0 +1,105 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for simple WebExtension</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function webnav_test_incognito() { + // Monitor will fail if it gets any event. + let monitor = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["webNavigation", "*://mochi.test/*"], + }, + background() { + const EVENTS = [ + "onTabReplaced", + "onBeforeNavigate", + "onCommitted", + "onDOMContentLoaded", + "onCompleted", + "onErrorOccurred", + "onReferenceFragmentUpdated", + "onHistoryStateUpdated", + ]; + + function onEvent(event, details) { + browser.test.fail(`not_allowed - Got ${event} ${details.url} ${details.frameId} ${details.parentFrameId}`); + } + + let listeners = {}; + for (let event of EVENTS) { + listeners[event] = onEvent.bind(null, event); + browser.webNavigation[event].addListener(listeners[event]); + } + + browser.test.onMessage.addListener(async (message, tabId) => { + // try to access the private window + await browser.test.assertRejects(browser.webNavigation.getAllFrames({tabId}), + /Invalid tab ID/, + "should not be able to get incognito frames"); + await browser.test.assertRejects(browser.webNavigation.getFrame({tabId, frameId: 0}), + /Invalid tab ID/, + "should not be able to get incognito frames"); + browser.test.notifyPass("completed"); + }); + }, + }); + + // extension loads a private window and waits for the onCompleted event. + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["tabs", "webNavigation", "*://mochi.test/*"], + }, + async background() { + const BASE = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest"; + const url = BASE + "/file_WebNavigation_page1.html"; + let window; + + browser.webNavigation.onCompleted.addListener(async (details) => { + if (details.url !== url) { + return; + } + browser.test.log(`spanning - Got onCompleted ${details.url} ${details.frameId} ${details.parentFrameId}`); + browser.test.sendMessage("completed"); + }); + browser.test.onMessage.addListener(async () => { + await browser.windows.remove(window.id); + browser.test.notifyPass("done"); + }); + window = await browser.windows.create({url, incognito: true}); + let tabs = await browser.tabs.query({active: true, windowId: window.id}); + browser.test.sendMessage("tabId", tabs[0].id); + }, + }); + + await monitor.startup(); + await extension.startup(); + + await extension.awaitMessage("completed"); + let tabId = await extension.awaitMessage("tabId"); + + await monitor.sendMessage("tab", tabId); + await monitor.awaitFinish("completed"); + + await extension.sendMessage("close"); + await extension.awaitFinish("done"); + + await extension.unload(); + await monitor.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_and_proxy_filter.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_and_proxy_filter.html new file mode 100644 index 0000000000..b28cbb7635 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_and_proxy_filter.html @@ -0,0 +1,131 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"> +<script> +"use strict"; + +// Check that the windowId and tabId filter work as expected in the webRequest +// and proxy API: +// - A non-matching windowId / tabId listener won't trigger events. +// - A matching tabId from a tab triggers the event. +// - A matching windowId from a tab triggers the event. +// (unlike test_ext_webrequest_filter.html, this also works on Android) +// - Requests from background pages can be matched with windowId and tabId -1. +add_task(async function test_filter_tabId_and_windowId() { + async function tabScript() { + let pendingExpectations = new Set(); + // Helper to detect completion of expected requests. + function watchExpected(filter, desc) { + desc += ` - ${JSON.stringify(filter)}`; + const DESC_PROXY = `${desc} (proxy)`; + const DESC_WEBREQUEST = `${desc} (webRequest)`; + pendingExpectations.add(DESC_PROXY); + pendingExpectations.add(DESC_WEBREQUEST); + browser.proxy.onRequest.addListener(() => { + pendingExpectations.delete(DESC_PROXY); + }, filter); + browser.webRequest.onBeforeRequest.addListener( + () => { + pendingExpectations.delete(DESC_WEBREQUEST); + }, + filter, + ["blocking"] + ); + } + + // Helper to detect unexpected requests. + function watchUnexpected(filter, desc) { + desc += ` - ${JSON.stringify(filter)}`; + browser.proxy.onRequest.addListener(() => { + browser.test.fail(`${desc} - unexpected proxy event`); + }, filter); + browser.webRequest.onBeforeRequest.addListener(() => { + browser.test.fail(`${desc} - unexpected webRequest event`); + }, filter); + } + + function registerExpectations(url, windowId, tabId) { + const urls = [url]; + watchUnexpected({ urls, windowId: 0 }, "non-matching windowId"); + watchUnexpected({ urls, tabId: 0 }, "non-matching tabId"); + + watchExpected({ urls, windowId }, "windowId matches"); + watchExpected({ urls, tabId }, "tabId matches"); + } + + try { + let { windowId, tabId } = await browser.runtime.sendMessage("getIds"); + browser.test.log(`Dummy tab has: tabId=${tabId} windowId=${windowId}`); + registerExpectations("http://example.com/?tab", windowId, tabId); + registerExpectations("http://example.com/?bg", -1, -1); + + // Call an API method implemented in the parent process to ensure that + // the listeners have been registered (workaround for bug 1300234). + // There is a .catch() at the end because the call is rejected on Android. + await browser.proxy.settings.get({}).catch(() => {}); + + browser.test.log("Triggering request from background page."); + await browser.runtime.sendMessage("triggerBackgroundRequest"); + + browser.test.log("Triggering request from tab."); + await fetch("http://example.com/?tab"); + + browser.test.assertEq(0, pendingExpectations.size, "got all events"); + for (let description of pendingExpectations) { + browser.test.fail(`Event not observed: ${description}`); + } + } catch (e) { + browser.test.fail(`Unexpected test failure: ${e} :: ${e.stack}`); + } + browser.runtime.sendMessage("testCompleted"); + } + + function background() { + browser.runtime.onMessage.addListener(async (msg, sender) => { + if (msg === "getIds") { + return { windowId: sender.tab.windowId, tabId: sender.tab.id }; + } + if (msg === "triggerBackgroundRequest") { + await fetch("http://example.com/?bg"); + } + if (msg === "testCompleted") { + await browser.tabs.remove(sender.tab.id); + browser.test.sendMessage("testCompleted"); + } + }); + browser.tabs.create({ + url: browser.runtime.getURL("tab.html"), + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "proxy", + "webRequest", + "webRequestBlocking", + "http://example.com/*", + ], + }, + background, + files: { + "tab.html": `<!DOCTYPE html><script src="tab.js"><\/script>`, + "tab.js": tabScript, + }, + }); + await extension.startup(); + + await extension.awaitMessage("testCompleted"); + await extension.unload(); +}); +</script> +</head> +<body> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_auth.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_auth.html new file mode 100644 index 0000000000..f260f040a1 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_auth.html @@ -0,0 +1,181 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head_webrequest.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +<script> +"use strict"; + +// This file defines content scripts. + +let baseUrl = "http://mochi.test:8888/tests/toolkit/components/passwordmgr/test/mochitest/authenticate.sjs"; +function testXHR(url) { + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + xhr.open("GET", url); + xhr.onload = resolve; + xhr.onabort = reject; + xhr.onerror = reject; + xhr.send(); + }); +} + +function getAuthHandler(result, blocking = true) { + function background(result) { + browser.webRequest.onAuthRequired.addListener((details) => { + browser.test.succeed(`authHandler.onAuthRequired called with ${details.requestId} ${details.url} result ${JSON.stringify(result)}`); + browser.test.sendMessage("onAuthRequired"); + return result; + }, {urls: ["*://mochi.test/*"]}, ["blocking"]); + browser.webRequest.onCompleted.addListener((details) => { + browser.test.succeed(`authHandler.onCompleted called with ${details.requestId} ${details.url}`); + browser.test.sendMessage("onCompleted"); + }, {urls: ["*://mochi.test/*"]}); + browser.webRequest.onErrorOccurred.addListener((details) => { + browser.test.succeed(`authHandler.onErrorOccurred called with ${details.requestId} ${details.url}`); + browser.test.sendMessage("onErrorOccurred"); + }, {urls: ["*://mochi.test/*"]}); + } + + let permissions = [ + "webRequest", + "*://mochi.test/*", + ]; + if (blocking) { + permissions.push("webRequestBlocking"); + } + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions, + }, + background: `(${background})(${JSON.stringify(result)})`, + }); +} + +add_task(async function test_webRequest_auth_nonblocking_forwardAuthProvider() { + // The chrome script sets up a default auth handler on the channel, the + // extension does not return anything in the authRequred call. We should + // get the call in the extension first, then in the chrome code where we + // cancel the request to avoid dealing with the prompt dialog here. The test + // is to ensure that WebRequest calls the previous notificationCallbacks + // if the authorization is not handled by the onAuthRequired handler. + + let chromeScript = SpecialPowers.loadChromeScript(() => { + /* eslint-env mozilla/chrome-script */ + let observer = channel => { + if (!(channel instanceof Ci.nsIHttpChannel && channel.URI.host === "mochi.test" && + channel.URI.spec.includes("authenticate.sjs"))) { + return; + } + Services.obs.removeObserver(observer, "http-on-modify-request"); + channel.notificationCallbacks = { + QueryInterface: ChromeUtils.generateQI(["nsIInterfaceRequestor", + "nsIAuthPromptProvider", + "nsIAuthPrompt2"]), + getInterface: ChromeUtils.generateQI(["nsIAuthPromptProvider", + "nsIAuthPrompt2"]), + promptAuth(channel, level, authInfo) { + throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE); + }, + getAuthPrompt(reason, iid) { + return this; + }, + asyncPromptAuth(channel, callback, context, level, authInfo) { + // We just cancel here, we're only ensuring that non-webrequest + // notificationcallbacks get called if webrequest doesn't handle it. + Promise.resolve().then(() => { + callback.onAuthCancelled(context, false); + channel.cancel(Cr.NS_BINDING_ABORTED); + sendAsyncMessage("callback-complete"); + }); + }, + }; + }; + Services.obs.addObserver(observer, "http-on-modify-request"); + sendAsyncMessage("chrome-ready"); + }); + await chromeScript.promiseOneMessage("chrome-ready"); + let callbackComplete = chromeScript.promiseOneMessage("callback-complete"); + + let handlingExt = getAuthHandler(); + await handlingExt.startup(); + + await Assert.rejects(testXHR(`${baseUrl}?realm=auth_nonblocking_forwardAuth&user=auth_nonblocking_forwardAuth&pass=auth_nonblocking_forwardAuth`), + ProgressEvent, + "caught rejected xhr"); + + await callbackComplete; + await handlingExt.awaitMessage("onAuthRequired"); + // We expect onErrorOccurred because the "default" authprompt above cancelled + // the auth request to avoid a dialog. + await handlingExt.awaitMessage("onErrorOccurred"); + await handlingExt.unload(); + chromeScript.destroy(); +}); + +add_task(async function test_webRequest_auth_nonblocking_forwardAuthPrompt2() { + // The chrome script sets up a default auth handler on the channel, the + // extension does not return anything in the authRequred call. We should + // get the call in the extension first, then in the chrome code where we + // cancel the request to avoid dealing with the prompt dialog here. The test + // is to ensure that WebRequest calls the previous notificationCallbacks + // if the authorization is not handled by the onAuthRequired handler. + + let chromeScript = SpecialPowers.loadChromeScript(() => { + /* eslint-env mozilla/chrome-script */ + let observer = channel => { + if (!(channel instanceof Ci.nsIHttpChannel && channel.URI.host === "mochi.test" && + channel.URI.spec.includes("authenticate.sjs"))) { + return; + } + Services.obs.removeObserver(observer, "http-on-modify-request"); + channel.notificationCallbacks = { + QueryInterface: ChromeUtils.generateQI(["nsIInterfaceRequestor", + "nsIAuthPrompt2"]), + getInterface: ChromeUtils.generateQI(["nsIAuthPrompt2"]), + promptAuth(request, level, authInfo) { + throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE); + }, + asyncPromptAuth(request, callback, context, level, authInfo) { + // We just cancel here, we're only ensuring that non-webrequest + // notificationcallbacks get called if webrequest doesn't handle it. + Promise.resolve().then(() => { + request.cancel(Cr.NS_BINDING_ABORTED); + sendAsyncMessage("callback-complete"); + }); + }, + }; + }; + Services.obs.addObserver(observer, "http-on-modify-request"); + sendAsyncMessage("chrome-ready"); + }); + await chromeScript.promiseOneMessage("chrome-ready"); + let callbackComplete = chromeScript.promiseOneMessage("callback-complete"); + + let handlingExt = getAuthHandler(); + await handlingExt.startup(); + + await Assert.rejects(testXHR(`${baseUrl}?realm=auth_nonblocking_forwardAuthPromptProvider&user=auth_nonblocking_forwardAuth&pass=auth_nonblocking_forwardAuth`), + ProgressEvent, + "caught rejected xhr"); + + await callbackComplete; + await handlingExt.awaitMessage("onAuthRequired"); + // We expect onErrorOccurred because the "default" authprompt above cancelled + // the auth request to avoid a dialog. + await handlingExt.awaitMessage("onErrorOccurred"); + await handlingExt.unload(); + chromeScript.destroy(); +}); +</script> +</head> +<body> +<div id="test">Authorization Test</div> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_background_events.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_background_events.html new file mode 100644 index 0000000000..86cec62fb4 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_background_events.html @@ -0,0 +1,120 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for simple WebExtension</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_webRequest_serviceworker_events() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.serviceWorkers.testing.enabled", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "<all_urls>", + ], + }, + background() { + let eventNames = new Set([ + "onBeforeRequest", + "onBeforeSendHeaders", + "onSendHeaders", + "onHeadersReceived", + "onResponseStarted", + "onCompleted", + "onErrorOccurred", + ]); + + function listener(name, details) { + browser.test.assertTrue(eventNames.has(name), `received ${name}`); + eventNames.delete(name); + if (name == "onCompleted") { + eventNames.delete("onErrorOccurred"); + } else if (name == "onErrorOccurred") { + eventNames.delete("onCompleted"); + } + if (eventNames.size == 0) { + browser.test.sendMessage("done"); + } + } + + for (let name of eventNames) { + browser.webRequest[name].addListener( + listener.bind(null, name), + {urls: ["https://example.com/*"]} + ); + } + }, + }); + + await extension.startup(); + let registration = await navigator.serviceWorker.register("webrequest_worker.js", {scope: "."}); + await waitForState(registration.installing, "activated"); + await extension.awaitMessage("done"); + await registration.unregister(); + await extension.unload(); +}); + +add_task(async function test_webRequest_background_events() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "<all_urls>", + ], + }, + background() { + let eventNames = new Set([ + "onBeforeRequest", + "onBeforeSendHeaders", + "onSendHeaders", + "onHeadersReceived", + "onResponseStarted", + "onCompleted", + ]); + + function listener(name, details) { + browser.test.assertTrue(eventNames.has(name), `received ${name}`); + eventNames.delete(name); + + if (eventNames.size === 0) { + browser.test.assertEq("xmlhttprequest", details.type, "correct type for fetch [see bug 1366710]"); + browser.test.assertEq(0, eventNames.size, "messages received"); + browser.test.sendMessage("done"); + } + } + + for (let name of eventNames) { + browser.webRequest[name].addListener( + listener.bind(null, name), + {urls: ["https://example.com/*"]} + ); + } + + fetch("https://example.com/example.txt").then(() => { + browser.test.succeed("Fetch succeeded."); + }, () => { + browser.test.fail("fetch received"); + browser.test.sendMessage("done"); + }); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_basic.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_basic.html new file mode 100644 index 0000000000..9d57d55681 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_basic.html @@ -0,0 +1,445 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head_webrequest.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +<script> +"use strict"; + +const expectedBaseProps = { + // On Desktop builds, if "browse.chrome.guess_favicon" is set to true, + // a favicon requests may be triggered at a random time while the test + // cases are running, we include it the ignore list by default to prevent + // intermittent failures (e.g. see Bug 1733781 and Bug 1633189). + ignore: ["favicon.ico"], +}; + +function promiseWindowEvent(name, accept) { + return new Promise(resolve => { + window.addEventListener(name, function listener(event) { + if (event.data !== accept) { + return; + } + window.removeEventListener(name, listener); + resolve(event); + }); + }); +} + +if (AppConstants.platform === "android") { + SimpleTest.requestLongerTimeout(3); +} + +let extension; +add_task(async function setup() { + // Clear the image cache, since it gets in the way otherwise. + let imgTools = SpecialPowers.Cc["@mozilla.org/image/tools;1"].getService(SpecialPowers.Ci.imgITools); + let cache = imgTools.getImgCacheForDocument(document); + cache.clearCache(false); + await SpecialPowers.spawnChrome([], async () => { + Services.cache2.clear(); + }); + + await SpecialPowers.pushPrefEnv({ + set: [["network.http.rcwn.enabled", false]], + }); + + extension = makeExtension(); + await extension.startup(); +}); + +// expect is a set of test values used by the background script. +// +// type: type of request action +// events: optional, If defined only the events listed are expected for the +// request. If undefined, all events except onErrorOccurred +// and onBeforeRedirect are expected. Must be in order received. +// redirect: url to redirect to during onBeforeSendHeaders +// status: number expected status during onHeadersReceived, 200 default +// cancel: event in which we return cancel=true. cancelled message is sent. +// cached: expected fromCache value, default is false, checked in onCompletion +// headers: request or response headers to modify +// origin: The expected originUrl, a default origin can be passed for all files + +add_task(async function test_webRequest_links() { + let expect = { + "file_style_bad.css": { + type: "stylesheet", + events: ["onBeforeRequest", "onErrorOccurred"], + cancel: "onBeforeRequest", + }, + "file_style_redirect.css": { + type: "stylesheet", + events: ["onBeforeRequest", "onBeforeSendHeaders", "onBeforeRedirect"], + optional_events: ["onHeadersReceived"], + redirect: "file_style_good.css", + }, + "file_style_good.css": { + type: "stylesheet", + }, + }; + extension.sendMessage("set-expected", {expect, origin: location.href}); + await extension.awaitMessage("continue"); + addStylesheet("file_style_bad.css"); + await extension.awaitMessage("cancelled"); + // we redirect to style_good which completes the test + addStylesheet(`file_style_redirect.css?nocache=${Math.random()}`); + await extension.awaitMessage("done"); +}); + +add_task(async function test_webRequest_images() { + let expect = { + "file_image_bad.png": { + type: "image", + events: ["onBeforeRequest", "onErrorOccurred"], + cancel: "onBeforeRequest", + }, + "file_image_redirect.png": { + type: "image", + events: ["onBeforeRequest", "onBeforeSendHeaders", "onBeforeRedirect"], + optional_events: ["onHeadersReceived"], + redirect: "file_image_good.png", + }, + "file_image_good.png": { + type: "image", + }, + }; + extension.sendMessage("set-expected", {expect, origin: location.href}); + await extension.awaitMessage("continue"); + addImage("file_image_bad.png"); + await extension.awaitMessage("cancelled"); + // we redirect to image_good which completes the test + addImage("file_image_redirect.png"); + await extension.awaitMessage("done"); +}); + +add_task(async function test_webRequest_scripts() { + let expect = { + "file_script_bad.js": { + type: "script", + events: ["onBeforeRequest", "onErrorOccurred"], + cancel: "onBeforeRequest", + }, + "file_script_redirect.js": { + type: "script", + events: ["onBeforeRequest", "onBeforeSendHeaders", "onBeforeRedirect"], + optional_events: ["onHeadersReceived"], + redirect: "file_script_good.js", + }, + "file_script_good.js": { + type: "script", + }, + }; + extension.sendMessage("set-expected", {expect, origin: location.href}); + await extension.awaitMessage("continue"); + let message = promiseWindowEvent("message", "test1"); + addScript("file_script_bad.js"); + await extension.awaitMessage("cancelled"); + // we redirect to script_good which completes the test + addScript("file_script_redirect.js?q=test1"); + await extension.awaitMessage("done"); + + is((await message).data, "test1", "good script ran"); +}); + +add_task(async function test_webRequest_xhr_get() { + let expect = { + "file_script_xhr.js": { + type: "script", + }, + "xhr_resource": { + status: 404, + type: "xmlhttprequest", + }, + }; + extension.sendMessage("set-expected", {expect, origin: location.href}); + await extension.awaitMessage("continue"); + addScript("file_script_xhr.js"); + await extension.awaitMessage("done"); +}); + +add_task(async function test_webRequest_nonexistent() { + let expect = { + "nonexistent_script_url.js": { + status: 404, + type: "script", + }, + }; + extension.sendMessage("set-expected", {expect, origin: location.href}); + await extension.awaitMessage("continue"); + addScript("nonexistent_script_url.js"); + await extension.awaitMessage("done"); +}); + +add_task(async function test_webRequest_checkCached() { + let expect = { + "file_image_good.png": { + type: "image", + cached: true, + }, + "file_script_good.js": { + type: "script", + cached: true, + }, + "file_style_good.css": { + type: "stylesheet", + cached: false, + }, + "nonexistent_script_url.js": { + status: 404, + type: "script", + cached: false, + }, + }; + extension.sendMessage("set-expected", {expect, origin: location.href}); + await extension.awaitMessage("continue"); + let message = promiseWindowEvent("message", "test1"); + + addImage("file_image_good.png"); + addScript("file_script_good.js?q=test1"); + + is((await message).data, "test1", "good script ran"); + + addStylesheet(`file_style_good.css?nocache=${Math.random()}`); + addScript("nonexistent_script_url.js"); + await extension.awaitMessage("done"); +}); + +add_task(async function test_webRequest_headers() { + let expect = { + "file_script_nonexistent.js": { + type: "script", + status: 404, + headers: { + request: { + add: { + "X-WebRequest-request": "text", + "X-WebRequest-request-binary": "binary", + }, + modify: { + "user-agent": "WebRequest", + }, + remove: [ + "referer", + ], + }, + response: { + add: { + "X-WebRequest-response": "text", + "X-WebRequest-response-binary": "binary", + }, + modify: { + "server": "WebRequest", + "content-type": "text/html; charset=utf-8", + }, + remove: [ + "connection", + ], + }, + }, + completion: "onCompleted", + }, + }; + extension.sendMessage("set-expected", {expect, origin: location.href}); + await extension.awaitMessage("continue"); + addScript("file_script_nonexistent.js"); + await extension.awaitMessage("done"); +}); + +add_task(async function test_webRequest_tabId() { + function background() { + let tab; + browser.tabs.onCreated.addListener(newTab => { + tab = newTab; + }); + + browser.test.onMessage.addListener(msg => { + if (msg === "close-tab") { + browser.tabs.remove(tab.id); + browser.test.sendMessage("tab-closed"); + } + }); + } + + let tabExt = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "tabs", + ], + }, + background, + }); + await tabExt.startup(); + + let linkUrl = `file_WebRequest_page3.html?trigger=a&nocache=${Math.random()}`; + let expect = { + "file_WebRequest_page3.html": { + type: "main_frame", + }, + }; + + extension.sendMessage("set-expected", { + ...expectedBaseProps, + expect, + origin: location.href, + }); + await extension.awaitMessage("continue"); + let a = addLink(linkUrl); + a.click(); + await extension.awaitMessage("done"); + + let closed = tabExt.awaitMessage("tab-closed"); + tabExt.sendMessage("close-tab"); + await closed; + + await tabExt.unload(); +}); + +add_task(async function test_webRequest_tabId_browser() { + async function background(url) { + let tabId; + browser.test.onMessage.addListener(async (msg, expected) => { + if (msg == "create") { + let tab = await browser.tabs.create({url}); + tabId = tab.id; + return; + } + if (msg == "done") { + await browser.tabs.remove(tabId); + browser.test.sendMessage("done"); + } + }); + browser.test.sendMessage("origin", browser.runtime.getURL("/")); + } + + let pageUrl = `${SimpleTest.getTestFileURL("file_sample.html")}?nocache=${Math.random()}`; + let tabExt = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "tabs", + ], + }, + background: `(${background})('${pageUrl}')`, + }); + + let expect = { + "file_sample.html": { + type: "main_frame", + }, + }; + + await tabExt.startup(); + let origin = await tabExt.awaitMessage("origin"); + + // expecting origin == extension baseUrl + extension.sendMessage("set-expected", { + ...expectedBaseProps, + expect, + origin, + }); + await extension.awaitMessage("continue"); + + // open a tab from an extension principal + tabExt.sendMessage("create"); + await extension.awaitMessage("done"); + tabExt.sendMessage("done"); + await tabExt.awaitMessage("done"); + await tabExt.unload(); +}); + +add_task(async function test_webRequest_frames() { + let expect = { + "redirection.sjs": { + status: 302, + type: "sub_frame", + events: ["onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders", "onHeadersReceived", "onBeforeRedirect"], + }, + "dummy_page.html": { + type: "sub_frame", + status: 404, + }, + "badrobot": { + type: "sub_frame", + status: 404, + events: ["onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders", "onErrorOccurred"], + // When an url's hostname fails to be resolved, an NS_ERROR_NET_ON_RESOLVED/RESOLVING + // onError event may be fired right before the NS_ERROR_UNKNOWN_HOST + // (See Bug 1516862 for a rationale). + optional_events: ["onErrorOccurred"], + error: ["NS_ERROR_UNKNOWN_HOST", "NS_ERROR_NET_ON_RESOLVED", "NS_ERROR_NET_ON_RESOLVING"], + }, + }; + extension.sendMessage("set-expected", { + ...expectedBaseProps, + expect, + origin: location.href + }); + await extension.awaitMessage("continue"); + addFrame("redirection.sjs"); + addFrame("https://nonresolvablehostname.invalid/badrobot"); + await extension.awaitMessage("done"); +}); + +add_task(async function teardown() { + await extension.unload(); +}); + +add_task(async function test_case_preserving() { + const manifest = { + permissions: [ + "webRequest", + "webRequestBlocking", + "http://mochi.test/", + ], + }; + + async function background() { + // This is testing if header names preserve case, + // so the case-sensitive comparison is on purpose. + function ua({url, requestHeaders}) { + if (url.endsWith("?blind-add")) { + requestHeaders.push({name: "user-agent", value: "Blind/Add"}); + return {requestHeaders}; + } + for (const header of requestHeaders) { + if (header.name === "User-Agent") { + header.value = "Case/Sensitive"; + } + } + return {requestHeaders}; + } + + await browser.webRequest.onBeforeSendHeaders.addListener(ua, {urls: ["<all_urls>"]}, ["blocking", "requestHeaders"]); + browser.test.sendMessage("ready"); + } + + const extension = ExtensionTestUtils.loadExtension({manifest, background}); + + await extension.startup(); + await extension.awaitMessage("ready"); + + const response1 = await fetch(SimpleTest.getTestFileURL("return_headers.sjs")); + const headers1 = JSON.parse(await response1.text()); + + is(headers1["user-agent"], "Case/Sensitive", "User-Agent header matched and changed."); + + const response2 = await fetch(SimpleTest.getTestFileURL("return_headers.sjs?blind-add")); + const headers2 = JSON.parse(await response2.text()); + + is(headers2["user-agent"], "Blind/Add", "User-Agent header blindly added."); + + await extension.unload(); +}); + +</script> +</head> +<body> +<div id="test">Sample text</div> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_errors.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_errors.html new file mode 100644 index 0000000000..cbfc5c17e7 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_errors.html @@ -0,0 +1,59 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for WebRequest errors</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> +<script type="text/javascript"> +"use strict"; + +async function test_connection_refused(url, expectedError) { + async function background(url, expectedError) { + browser.test.log(`background url is ${url}`); + browser.webRequest.onErrorOccurred.addListener(details => { + if (details.url != url) { + return; + } + browser.test.assertTrue(details.error.startsWith(expectedError), "error correct"); + browser.test.sendMessage("onErrorOccurred"); + }, {urls: ["<all_urls>"]}); + + let tabId; + browser.test.onMessage.addListener(async (msg, expected) => { + await browser.tabs.remove(tabId); + browser.test.sendMessage("done"); + }); + + let tab = await browser.tabs.create({url}); + tabId = tab.id; + } + + let extensionData = { + manifest: { + permissions: ["webRequest", "tabs", "*://badchain.include-subdomains.pinning.example.com/*"], + }, + background: `(${background})("${url}", "${expectedError}")`, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitMessage("onErrorOccurred"); + extension.sendMessage("close-tab"); + await extension.awaitMessage("done"); + + await extension.unload(); +} + +add_task(function test_bad_cert() { + return test_connection_refused("https://badchain.include-subdomains.pinning.example.com/", "Unable to communicate securely with peer"); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_filter.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_filter.html new file mode 100644 index 0000000000..5ccbf761ec --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_filter.html @@ -0,0 +1,226 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <script type="text/javascript" src="head_webrequest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +<script> +"use strict"; + +if (AppConstants.platform === "android") { + SimpleTest.requestLongerTimeout(6); +} + +let windowData, testWindow; + +add_task(async function setup() { + await SpecialPowers.spawnChrome([], async () => { + Services.cache2.clear(); + }); + + testWindow = window.open("about:blank", "_blank", "width=100,height=100"); + await waitForLoad(testWindow); + + // Fetch the windowId and tabId we need to filter with WebRequest. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "tabs", + ], + }, + background() { + browser.tabs.query({currentWindow: true}).then(tabs => { + let tab = tabs.find(tab => tab.active); + let {windowId} = tab; + + browser.test.log(`current window ${windowId} tabs: ${JSON.stringify(tabs.map(tab => [tab.id, tab.url]))}`); + browser.test.sendMessage("windowData", {windowId, tabId: tab.id}); + }); + }, + }); + await extension.startup(); + windowData = await extension.awaitMessage("windowData"); + info(`window is ${JSON.stringify(windowData)}`); + await extension.unload(); +}); + +add_task(async function test_webRequest_filter_window() { + if (AppConstants.MOZ_BUILD_APP !== "browser") { + // Android does not support multiple windows. + return; + } + + await SpecialPowers.pushPrefEnv({ + set: [["dom.serviceWorkers.testing.enabled", true], + ["network.http.rcwn.enabled", false]], + }); + + let events = { + "onBeforeRequest": [{urls: ["<all_urls>"], windowId: windowData.windowId}], + "onBeforeSendHeaders": [{urls: ["<all_urls>"], windowId: windowData.windowId}, ["requestHeaders"]], + "onSendHeaders": [{urls: ["<all_urls>"], windowId: windowData.windowId}, ["requestHeaders"]], + "onBeforeRedirect": [{urls: ["<all_urls>"], windowId: windowData.windowId}], + "onHeadersReceived": [{urls: ["<all_urls>"], windowId: windowData.windowId}, ["responseHeaders"]], + "onResponseStarted": [{urls: ["<all_urls>"], windowId: windowData.windowId}], + "onCompleted": [{urls: ["<all_urls>"], windowId: windowData.windowId}, ["responseHeaders"]], + "onErrorOccurred": [{urls: ["<all_urls>"], windowId: windowData.windowId}], + }; + let expect = { + "file_image_bad.png": { + optional_events: ["onBeforeRedirect", "onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders"], + type: "main_frame", + }, + }; + + if (AppConstants.platform != "android") { + expect["favicon.ico"] = { + // These events only happen in non-e10s. See bug 1472156. + optional_events: ["onBeforeRedirect", "onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders"], + type: "image", + origin: SimpleTest.getTestFileURL("file_image_bad.png"), + }; + } + + let extension = makeExtension(events); + await extension.startup(); + extension.sendMessage("set-expected", {expect, origin: location.href}); + await extension.awaitMessage("continue"); + + // We should not get events for a new window load. + let newWindow = window.open("file_image_good.png", "_blank", "width=100,height=100"); + await waitForLoad(newWindow); + newWindow.close(); + + // We should not get background events. + let registration = await navigator.serviceWorker.register("webrequest_worker.js?test0", {scope: "."}); + await waitForState(registration.installing, "activated"); + + // We should get events for the reload. + testWindow.location = "file_image_bad.png"; + await extension.awaitMessage("done"); + + testWindow.location = "about:blank"; + await registration.unregister(); + await extension.unload(); +}); + +add_task(async function test_webRequest_filter_tab() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.serviceWorkers.testing.enabled", true]], + }); + + let img = `file_image_good.png?r=${Math.random()}`; + + let events = { + "onBeforeRequest": [{urls: ["<all_urls>"], tabId: windowData.tabId}], + "onBeforeSendHeaders": [{urls: ["<all_urls>"], tabId: windowData.tabId}, ["requestHeaders"]], + "onSendHeaders": [{urls: ["<all_urls>"], tabId: windowData.tabId}, ["requestHeaders"]], + "onBeforeRedirect": [{urls: ["<all_urls>"], tabId: windowData.tabId}], + "onHeadersReceived": [{urls: ["<all_urls>"], tabId: windowData.tabId}, ["responseHeaders"]], + "onResponseStarted": [{urls: ["<all_urls>"], tabId: windowData.tabId}], + "onCompleted": [{urls: ["<all_urls>"], tabId: windowData.tabId}, ["responseHeaders"]], + "onErrorOccurred": [{urls: ["<all_urls>"], tabId: windowData.tabId}], + }; + let expect = { + "file_image_good.png": { + // These events only happen in non-e10s. See bug 1472156. + optional_events: ["onBeforeRedirect", "onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders"], + type: "main_frame", + // cached: AppConstants.MOZ_BUILD_APP === "browser", + }, + }; + + if (AppConstants.platform != "android") { + // A favicon request may be initiated, and complete or be aborted. + expect["favicon.ico"] = { + optional_events: ["onBeforeRedirect", "onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders", "onHeadersReceived", "onResponseStarted", "onCompleted", "onErrorOccurred"], + type: "image", + origin: SimpleTest.getTestFileURL(img), + }; + } + + let extension = makeExtension(events); + await extension.startup(); + extension.sendMessage("set-expected", {expect, origin: location.href}); + await extension.awaitMessage("continue"); + + if (AppConstants.MOZ_BUILD_APP === "browser") { + // We should not get events for a new window load. + let newWindow = window.open(img, "_blank", "width=100,height=100"); + await waitForLoad(newWindow); + newWindow.close(); + } + + // We should not get background events. + let registration = await navigator.serviceWorker.register("webrequest_worker.js?test1", {scope: "."}); + await waitForState(registration.installing, "activated"); + + // We should get events for the reload. + testWindow.location = img; + await extension.awaitMessage("done"); + + testWindow.location = "about:blank"; + await registration.unregister(); + await extension.unload(); +}); + + +add_task(async function test_webRequest_filter_background() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.serviceWorkers.testing.enabled", true]], + }); + + let events = { + "onBeforeRequest": [{urls: ["<all_urls>"], tabId: -1}], + "onBeforeSendHeaders": [{urls: ["<all_urls>"], tabId: -1}, ["requestHeaders"]], + "onSendHeaders": [{urls: ["<all_urls>"], tabId: -1}, ["requestHeaders"]], + "onBeforeRedirect": [{urls: ["<all_urls>"], tabId: -1}], + "onHeadersReceived": [{urls: ["<all_urls>"], tabId: -1}, ["responseHeaders"]], + "onResponseStarted": [{urls: ["<all_urls>"], tabId: -1}], + "onCompleted": [{urls: ["<all_urls>"], tabId: -1}, ["responseHeaders"]], + "onErrorOccurred": [{urls: ["<all_urls>"], tabId: -1}], + }; + let expect = { + "webrequest_worker.js": { + type: "script", + }, + "example.txt": { + status: 404, + events: ["onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders", "onHeadersReceived", "onResponseStarted"], + optional_events: ["onCompleted", "onErrorOccurred"], + type: "xmlhttprequest", + origin: SimpleTest.getTestFileURL("webrequest_worker.js?test2"), + }, + }; + + let extension = makeExtension(events); + await extension.startup(); + extension.sendMessage("set-expected", {expect, origin: location.href}); + await extension.awaitMessage("continue"); + + // We should not get events for a window. + testWindow.location = "file_image_bad.png"; + + // We should get events for the background page. + let registration = await navigator.serviceWorker.register(SimpleTest.getTestFileURL("webrequest_worker.js?test2"), {scope: "."}); + await waitForState(registration.installing, "activated"); + await extension.awaitMessage("done"); + testWindow.location = "about:blank"; + await registration.unregister(); + + await extension.unload(); +}); + +add_task(async function teardown() { + testWindow.close(); +}); +</script> +</head> +<body> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_frameId.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_frameId.html new file mode 100644 index 0000000000..76a13be1af --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_frameId.html @@ -0,0 +1,213 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head_webrequest.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +<script> +"use strict"; + +let extensionData = { + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>", "tabs"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener(details => { + if (details.url.endsWith("/favicon.ico")) { + // We don't care about favicon.ico in this test. It is hard to control + // whether the request happens. + browser.test.log(`Ignoring favicon request: ${details.url}`); + return; + } + browser.test.sendMessage("onBeforeRequest", details); + }, {urls: ["<all_urls>"]}, ["blocking"]); + + let tab; + browser.tabs.onCreated.addListener(newTab => { + browser.test.sendMessage("tab-created"); + tab = newTab; + }); + + browser.test.onMessage.addListener(msg => { + if (msg === "close-tab") { + browser.tabs.remove(tab.id); + browser.test.sendMessage("tab-closed"); + } + }); + }, +}; + +let expected = { + "file_simple_xhr.html": { + type: "main_frame", + toplevel: true, + }, + "file_image_good.png": { + type: "image", + toplevel: true, + origin: "file_simple_xhr.html", + }, + "example.txt": { + type: "xmlhttprequest", + toplevel: true, + origin: "file_simple_xhr.html", + }, + // sub frames will have the origin and first ancestor is the + // parent document + "file_simple_xhr_frame.html": { + type: "sub_frame", + toplevelParent: true, + origin: "file_simple_xhr.html", + parent: "file_simple_xhr.html", + }, + // a resource in a sub frame will have origin of the subframe, + // but the ancestor chain starts with the parent document + "xhr_resource": { + type: "xmlhttprequest", + origin: "file_simple_xhr_frame.html", + parent: "file_simple_xhr.html", + }, + "file_image_bad.png": { + type: "image", + depth: 2, + origin: "file_simple_xhr_frame.html", + parent: "file_simple_xhr.html", + }, + "file_simple_xhr_frame2.html": { + type: "sub_frame", + depth: 2, + origin: "file_simple_xhr_frame.html", + parent: "file_simple_xhr_frame.html", + }, + "file_image_redirect.png": { + type: "image", + depth: 2, + origin: "file_simple_xhr_frame2.html", + parent: "file_simple_xhr_frame.html", + }, + "xhr_resource_2": { + type: "xmlhttprequest", + depth: 2, + origin: "file_simple_xhr_frame2.html", + parent: "file_simple_xhr_frame.html", + }, + // This is loaded in a sandbox iframe. originUrl is not available for that, + // and requests within a sandboxed iframe will additionally have an empty + // url on their immediate parent/ancestor. + "file_simple_sandboxed_frame.html": { + type: "sub_frame", + depth: 3, + parent: "file_simple_xhr_frame2.html", + }, + "xhr_sandboxed": { + type: "xmlhttprequest", + sandboxed: true, + depth: 3, + parent: "", + }, + "file_image_great.png": { + type: "image", + sandboxed: true, + depth: 3, + parent: "", + }, + "file_simple_sandboxed_subframe.html": { + type: "sub_frame", + depth: 4, + parent: "", + }, +}; + +function checkDetails(details) { + let url = new URL(details.url); + let filename = url.pathname.split("/").pop(); + ok(filename in expected, `Should be expecting a request for ${filename}`); + let expect = expected[filename]; + is(expect.type, details.type, `${details.type} type matches`); + if (details.parentFrameId == -1) { + is(details.frameAncestors.length, 0, "no ancestors for main_frame requests"); + } else if (details.parentFrameId == 0) { + is(details.frameAncestors.length, 1, "one ancestors for sub_frame requests"); + } else { + ok(details.frameAncestors.length > 1, "have multiple ancestors for deep subframe requests"); + is(details.frameAncestors.length, expect.depth, "have multiple ancestors for deep subframe requests"); + } + if (details.parentFrameId > -1) { + ok(!expect.origin || details.originUrl.includes(expect.origin), "origin url is correct"); + is(details.frameAncestors[0].frameId, details.parentFrameId, "first ancestor matches request.parentFrameId"); + ok(details.frameAncestors[0].url.includes(expect.parent), "ancestor parent page correct"); + is(details.frameAncestors[details.frameAncestors.length - 1].frameId, 0, "last ancestor is always zero"); + // All our tests should be somewhere within the frame that we set topframe in the query string. That + // frame will always be the last ancestor. + ok(details.frameAncestors[details.frameAncestors.length - 1].url.includes("topframe=true"), "last ancestor is always topframe"); + } + if (expect.toplevel) { + is(details.frameId, 0, "expect load at top level"); + is(details.parentFrameId, -1, "expect top level frame to have no parent"); + } else if (details.type == "sub_frame") { + ok(details.frameId > 0, "expect sub_frame to load into a new frame"); + if (expect.toplevelParent) { + is(details.parentFrameId, 0, "expect sub_frame to have top level parent"); + is(details.frameAncestors.length, 1, "one ancestor for top sub_frame request"); + } else { + ok(details.parentFrameId > 0, "expect sub_frame to have parent"); + ok(details.frameAncestors.length > 1, "sub_frame has ancestors"); + } + expect.subframeId = details.frameId; + expect.parentId = details.parentFrameId; + } else if (expect.sandboxed) { + is(details.documentUrl, undefined, "null principal documentUrl for sandboxed request"); + } else { + // get the parent frame. + let purl = new URL(details.documentUrl); + let pfilename = purl.pathname.split("/").pop(); + let parent = expected[pfilename]; + is(details.frameId, parent.subframeId, "expect load in subframe"); + is(details.parentFrameId, parent.parentId, "expect subframe parent"); + } + return filename; +} + +add_task(async function test_webRequest_main_frame() { + // Clear the image cache, since it gets in the way otherwise. + let imgTools = SpecialPowers.Cc["@mozilla.org/image/tools;1"].getService(SpecialPowers.Ci.imgITools); + let cache = imgTools.getImgCacheForDocument(document); + cache.clearCache(false); + await SpecialPowers.spawnChrome([], async () => { + Services.cache2.clear(); + }); + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let a = addLink(`file_simple_xhr.html?topframe=true&nocache=${Math.random()}`); + a.click(); + + let remaining = new Set(Object.keys(expected)); + let totalExpectedCount = remaining.size; + for (let i = 0; i < totalExpectedCount; i++) { + info(`Waiting for request ${i + 1} out of ${totalExpectedCount}`); + info(`Expecting one of: ${Array.from(remaining)}`); + let details = await extension.awaitMessage("onBeforeRequest"); + info(`Checking details for request ${i}: ${JSON.stringify(details)}`); + let filename = checkDetails(details); + ok(remaining.delete(filename), `Got only one request for ${filename}`); + } + + await extension.awaitMessage("tab-created"); + extension.sendMessage("close-tab"); + await extension.awaitMessage("tab-closed"); + + await extension.unload(); +}); +</script> +</head> +<body> +<div id="test">Sample text</div> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_getSecurityInfo.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_getSecurityInfo.html new file mode 100644 index 0000000000..5628109483 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_getSecurityInfo.html @@ -0,0 +1,98 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>browser.webRequest.getSecurityInfo()</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script> +"use strict"; + +add_task(async function test_getSecurityInfo() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "<all_urls>" + ], + }, + async background() { + const url = "https://example.org/tests/toolkit/components/extensions/test/mochitest/file_sample.html"; + + let tab; + browser.webRequest.onHeadersReceived.addListener(async details => { + const securityInfo = await browser.webRequest.getSecurityInfo( + details.requestId, + {} + ); + + // Some properties have dynamic values so let's take them out of the + // `securityInfo` object before asserting all the other props with deep + // equality. + const { + cipherSuite, + secretKeyLength, + keaGroupName, + signatureSchemeName, + protocolVersion, + certificates, + ...otherProps + } = securityInfo; + + browser.test.assertTrue(cipherSuite.length, "expected cipher suite"); + browser.test.assertTrue( + Number.isInteger(secretKeyLength), + "expected secret key length" + ); + browser.test.assertTrue( + keaGroupName.length, + "expected kea group name" + ); + browser.test.assertTrue( + signatureSchemeName.length, + "expected signature scheme name" + ); + browser.test.assertTrue( + protocolVersion.length, + "expected protocol version" + ); + browser.test.assertTrue( + Array.isArray(certificates), + "expected an array of certificates" + ); + + browser.test.assertDeepEq({ + state: "secure", + isExtendedValidation: false, + certificateTransparencyStatus: "not_applicable", + hsts: false, + hpkp: false, + usedEch: false, + usedDelegatedCredentials: false, + usedOcsp: false, + usedPrivateDns: false, + }, otherProps, "expected security info"); + + await browser.tabs.remove(tab.id); + browser.test.notifyPass("success"); + }, { urls: [url] } , ["blocking"]); + + tab = await browser.tabs.create({ url }); + }, + }); + await extension.startup(); + + await extension.awaitFinish("success"); + + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_hsts.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_hsts.html new file mode 100644 index 0000000000..e66b5c471a --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_hsts.html @@ -0,0 +1,252 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <script type="text/javascript" src="head_webrequest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +<script> +"use strict"; + +function getExtension() { + async function background() { + let expect; + let urls = ["*://*.example.org/tests/*"]; + browser.webRequest.onBeforeRequest.addListener(details => { + browser.test.assertEq(expect.shift(), "onBeforeRequest"); + }, {urls}, ["blocking"]); + browser.webRequest.onBeforeSendHeaders.addListener(details => { + browser.test.assertEq(expect.shift(), "onBeforeSendHeaders"); + }, {urls}, ["blocking", "requestHeaders"]); + browser.webRequest.onSendHeaders.addListener(details => { + browser.test.assertEq(expect.shift(), "onSendHeaders"); + }, {urls}, ["requestHeaders"]); + + async function testSecurityInfo(securityInfo, options) { + if (options.certificateChain) { + // Some of the tests here only produce a single cert in the chain. + browser.test.assertTrue(securityInfo.certificates.length >= 1, "have certificate chain"); + } else { + browser.test.assertTrue(securityInfo.certificates.length == 1, "no certificate chain"); + } + let cert = securityInfo.certificates[0]; + let now = Date.now(); + browser.test.assertTrue(Number.isInteger(cert.validity.start), "cert start is integer"); + browser.test.assertTrue(Number.isInteger(cert.validity.end), "cert end is integer"); + browser.test.assertTrue(cert.validity.start < now, "cert start validity is correct"); + browser.test.assertTrue(now < cert.validity.end, "cert end validity is correct"); + if (options.rawDER) { + for (let cert of securityInfo.certificates) { + browser.test.assertTrue(!!cert.rawDER.length, "have rawDER"); + } + } + } + + function stripQuery(url) { + // In this whole test we are not interested in the query part of the URL. + // Most tests include a cache buster (bustcache) param in the URL. + return url.split("?")[0]; + } + + browser.webRequest.onHeadersReceived.addListener(async (details) => { + browser.test.assertEq(expect.shift(), "onHeadersReceived"); + + // We expect all requests to have been upgraded at this point. + browser.test.assertTrue(details.url.startsWith("https"), "connection is https"); + let securityInfo = await browser.webRequest.getSecurityInfo(details.requestId, {}); + browser.test.assertTrue(securityInfo && securityInfo.state == "secure", + "security info reflects https"); + await testSecurityInfo(securityInfo, {}); + securityInfo = await browser.webRequest.getSecurityInfo(details.requestId, {certificateChain: true}); + await testSecurityInfo(securityInfo, {certificateChain: true}); + securityInfo = await browser.webRequest.getSecurityInfo(details.requestId, {rawDER: true}); + await testSecurityInfo(securityInfo, {rawDER: true}); + securityInfo = await browser.webRequest.getSecurityInfo(details.requestId, {certificateChain: true, rawDER: true}); + await testSecurityInfo(securityInfo, {certificateChain: true, rawDER: true}); + + browser.test.sendMessage("hsts", securityInfo.hsts); + let headers = details.responseHeaders || []; + for (let header of headers) { + if (header.name.toLowerCase() === "strict-transport-security") { + return; + } + } + if (details.url.includes("addHsts")) { + headers.push({ + name: "Strict-Transport-Security", + value: "max-age=31536000000", + }); + } + return {responseHeaders: headers}; + }, {urls}, ["blocking", "responseHeaders"]); + browser.webRequest.onBeforeRedirect.addListener(details => { + browser.test.assertEq(expect.shift(), "onBeforeRedirect"); + }, {urls}); + browser.webRequest.onResponseStarted.addListener(details => { + browser.test.assertEq(expect.shift(), "onResponseStarted"); + }, {urls}); + browser.webRequest.onCompleted.addListener(details => { + browser.test.assertEq(expect.shift(), "onCompleted"); + browser.test.sendMessage("onCompleted", stripQuery(details.url)); + }, {urls}); + browser.webRequest.onErrorOccurred.addListener(details => { + browser.test.notifyFail(`onErrorOccurred ${JSON.stringify(details)}`); + }, {urls}); + + async function onUpdated(tabId, tabInfo, tab) { + if (tabInfo.status !== "complete" || tab.url === "about:blank") { + return; + } + browser.tabs.remove(tabId); + browser.tabs.onUpdated.removeListener(onUpdated); + browser.test.sendMessage("tabs-done", stripQuery(tab.url)); + } + browser.test.onMessage.addListener((url, expected) => { + expect = expected; + browser.tabs.onUpdated.addListener(onUpdated); + browser.tabs.create({url}); + }); + } + + let manifest = { + "permissions": [ + "tabs", + "webRequest", + "webRequestBlocking", + "<all_urls>", + ], + }; + return ExtensionTestUtils.loadExtension({ + manifest, + background, + }); +} + +add_setup(async () => { + // In bug 1605515, we repeatedly saw a missing onHeadersReceived event, + // possibly related to bug 1595610. As a workaround, clear the cache. + await SpecialPowers.spawnChrome([], async () => { + Services.cache2.clear(); + await new Promise(resolve => { + Services.clearData.deleteData(Ci.nsIClearDataService.CLEAR_HSTS, resolve); + }); + }); +}); + +// This test makes a request against a server that redirects with a 302. +add_task(async function test_hsts_request() { + const testPath = "example.org/tests/toolkit/components/extensions/test/mochitest"; + + let extension = getExtension(); + await extension.startup(); + + // simple redirect + let sample = "https://example.org/tests/toolkit/components/extensions/test/mochitest/file_sample.html"; + extension.sendMessage( + `https://${testPath}/redirect_auto.sjs?redirect_uri=${sample}?bustcache1=${Math.random()}`, + ["onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders", + "onHeadersReceived", "onBeforeRedirect", "onBeforeRequest", + "onBeforeSendHeaders", "onSendHeaders", "onHeadersReceived", + "onResponseStarted", "onCompleted"]); + is(await extension.awaitMessage("hsts"), false, "First request to this host, not receiving a hsts header"); + is(await extension.awaitMessage("hsts"), false, "second (redirected) reqiest to the same host, still no knowledge about the hosts hsts preference"); + // Note: stripQuery strips query string added by redirect_auto. + is(await extension.awaitMessage("tabs-done"), sample, "redirection ok"); + is(await extension.awaitMessage("onCompleted"), sample, "redirection ok"); + + // priming hsts + extension.sendMessage( + `https://${testPath}/hsts.sjs`, + ["onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders", + "onHeadersReceived", "onResponseStarted", "onCompleted"]); + is(await extension.awaitMessage("hsts"), false, "First request to this host, receiving hsts header and saving the hosts STS preference for the next request"); + is(await extension.awaitMessage("tabs-done"), + "https://example.org/tests/toolkit/components/extensions/test/mochitest/hsts.sjs", + "hsts primed"); + is(await extension.awaitMessage("onCompleted"), + "https://example.org/tests/toolkit/components/extensions/test/mochitest/hsts.sjs"); + + // test upgrade + extension.sendMessage( + `http://${testPath}/hsts.sjs`, + ["onBeforeRequest", "onBeforeRedirect", "onBeforeRequest", + "onBeforeSendHeaders", "onSendHeaders", "onHeadersReceived", + "onResponseStarted", "onCompleted"]); + is(await extension.awaitMessage("hsts"), true, "second (redirected) reqiest to the same host, we know about the hsts status of the host this time"); + is(await extension.awaitMessage("tabs-done"), + "https://example.org/tests/toolkit/components/extensions/test/mochitest/hsts.sjs", + "hsts upgraded"); + is(await extension.awaitMessage("onCompleted"), + "https://example.org/tests/toolkit/components/extensions/test/mochitest/hsts.sjs"); + + await extension.unload(); +}); + +// This test makes a priming request and adds the STS header, then tests the upgrade. +add_task(async function test_hsts_header() { + const testPath = "test1.example.org/tests/toolkit/components/extensions/test/mochitest"; + + let extension = getExtension(); + await extension.startup(); + + // priming hsts, this time there is no STS header, onHeadersReceived adds it. + let completed = extension.awaitMessage("onCompleted"); + let tabdone = extension.awaitMessage("tabs-done"); + extension.sendMessage( + `https://${testPath}/file_sample.html?bustcache2=${Math.random()}&addHsts=true`, + ["onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders", + "onHeadersReceived", "onResponseStarted", "onCompleted"]); + is(await extension.awaitMessage("hsts"), false, "First reqeuest to this host, we don't know about the hosts STS setting yet"); + is(await tabdone, `https://${testPath}/file_sample.html`, "priming request done"); + is(await completed, `https://${testPath}/file_sample.html`, "priming request done"); + + // test upgrade from http to https due to onHeadersReceived adding STS header + completed = extension.awaitMessage("onCompleted"); + tabdone = extension.awaitMessage("tabs-done"); + extension.sendMessage( + `http://${testPath}/file_sample.html?bustcache3=${Math.random()}`, + ["onBeforeRequest", "onBeforeRedirect", "onBeforeRequest", + "onBeforeSendHeaders", "onSendHeaders", "onHeadersReceived", + "onResponseStarted", "onCompleted"]); + is(await extension.awaitMessage("hsts"), true, "We have received an hsts header last request via oneadersReceived"); + is(await tabdone, `https://${testPath}/file_sample.html`, "hsts upgraded"); + is(await completed, `https://${testPath}/file_sample.html`, "request upgraded"); + + await extension.unload(); +}); + +add_task(async function test_nonBlocking_securityInfo() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": [ + "webRequest", + "<all_urls>", + ], + }, + async background() { + let tab; + browser.webRequest.onHeadersReceived.addListener(async (details) => { + let securityInfo = await browser.webRequest.getSecurityInfo(details.requestId, {}); + browser.test.assertTrue(!securityInfo, "securityInfo undefined on http request"); + browser.tabs.remove(tab.id); + browser.test.notifyPass("success"); + }, {urls: ["<all_urls>"], types: ["main_frame"]}); + tab = await browser.tabs.create({ + url: `https://example.org/tests/toolkit/components/extensions/test/mochitest/file_sample.html?bustcache4=${Math.random()}`, + }); + }, + }); + await extension.startup(); + + await extension.awaitFinish("success"); + await extension.unload(); +}); +</script> +</head> +<body> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_bypass_cors.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_bypass_cors.html new file mode 100644 index 0000000000..87dbbd6598 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_bypass_cors.html @@ -0,0 +1,75 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Bug 1450965: Skip Cors Check for Early WebExtention Redirects </title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +/* Description of the test: + * We try to Check if a WebExtention can redirect a request and bypass CORS + * We're redirecting a fetch request in onBeforeRequest + * which should not be blocked, even though we do not have + * the CORS information yet. + */ + +const WIN_URL = + "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_redirect_cors_bypass.html"; + + +add_task(async function test_webRequest_redirect_cors_bypass() { + // disable third-party storage isolation so the test works as expected + await SpecialPowers.pushPrefEnv({ + set: [["privacy.partition.always_partition_third_party_non_cookie_storage", false]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "<all_urls>", + ], + }, + background() { + browser.webRequest.onBeforeRequest.addListener((details) => { + if (details.url.includes("file_cors_blocked.txt")) { + // File_cors_blocked does not need to exist, because we're redirecting anyway. + const testPath = "example.org/tests/toolkit/components/extensions/test/mochitest"; + let redirectUrl = `https://${testPath}/file_sample.txt`; + + // If the WebExtion cant bypass CORS, the fetch will throw a CORS-Exception + // because we do not have the CORS header yet for 'file-cors-blocked.txt' + return {redirectUrl}; + } + }, {urls: ["<all_urls>"]}, ["blocking"]); + }, + + }); + + await extension.startup(); + let win = window.open(WIN_URL); + // Creating a message channel to the new tab. + const channel = new BroadcastChannel("test_bus"); + await new Promise((resolve, reject) => { + channel.onmessage = async function(fetch_result) { + // Fetch result data will either be the text content of file_sample.txt -> 'Sample' + // or a network-Error. + // In case it's 'Sample' the redirect did happen correctly. + ok(fetch_result.data == "Sample", "Cors was Bypassed"); + win.close(); + await extension.unload(); + resolve(); + }; + }); +}); + +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_data_uri.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_data_uri.html new file mode 100644 index 0000000000..5d58549c46 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_data_uri.html @@ -0,0 +1,83 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Bug 1434357: Allow Web Request API to redirect to data: URI</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +/* Description of the test: + * We load a *.js file which gets redirected to a data: URI. + * Since there is no good way to communicate loaded data: URI scripts + * we use updating a divContainer as a detour to verify the data: URI + * script has loaded. + */ + +const WIN_URL = + "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_redirect_data_uri.html"; + +add_task(async function test_webRequest_redirect_data_uri() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "*://mochi.test/tests/*", + ], + content_scripts: [{ + matches: ["*://mochi.test/tests/*/file_redirect_data_uri.html"], + run_at: "document_end", + js: ["content_script.js"], + "all_frames": true, + }], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener((details) => { + if (details.url.includes("dummy_non_existend_file.js")) { + let redirectUrl = + "data:text/javascript,document.getElementById('testdiv').textContent='loaded'"; + return {redirectUrl}; + } + }, {urls: ["*://mochi.test/tests/*"]}, ["blocking"]); + }, + + files: { + "content_script.js": function() { + let scriptEl = document.createElement("script"); + // please note that dummy_non_existend_file.js file does not really need + // to exist because we redirect the load within onBeforeRequest(). + scriptEl.src = "dummy_non_existend_file.js"; + document.body.appendChild(scriptEl); + + scriptEl.onload = function() { + let divContent = document.getElementById("testdiv").textContent; + browser.test.assertEq(divContent, "loaded", + "redirect to data: URI allowed"); + browser.test.sendMessage("finished"); + }; + scriptEl.onerror = function() { + browser.test.fail("script load failure"); + browser.test.sendMessage("finished"); + }; + }, + }, + }); + + await extension.startup(); + let win = window.open(WIN_URL); + await extension.awaitMessage("finished"); + win.close(); + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upgrade.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upgrade.html new file mode 100644 index 0000000000..f086d29d02 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upgrade.html @@ -0,0 +1,139 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for simple WebExtension</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_webRequest_upgrade() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "*://mochi.test/tests/*", + ], + }, + background() { + browser.webRequest.onSendHeaders.addListener((details) => { + // At this point, the request should have been upgraded. + browser.test.assertTrue(details.url.startsWith("https:"), "request is upgraded"); + browser.test.assertTrue(details.url.includes("file_sample"), "redirect after upgrade worked"); + // Note: although not significant for the test assertions, note that + // the requested file won't load - https://mochi.test:8888/ does not + // resolve to anything on the test server. + browser.test.sendMessage("finished"); + }, {urls: ["*://mochi.test/tests/*"]}); + + browser.webRequest.onBeforeRequest.addListener((details) => { + browser.test.log(`onBeforeRequest ${details.requestId} ${details.url}`); + let url = new URL(details.url); + if (url.protocol == "http:") { + return {upgradeToSecure: true}; + } + // After the channel is initially upgraded, we get another onBeforeRequest + // call. Here we can redirect again to a new url. + if (details.url.includes("file_mixed.html")) { + let redirectUrl = new URL("file_sample.html", details.url).href; + return {redirectUrl}; + } + }, {urls: ["*://mochi.test/tests/*"]}, ["blocking"]); + }, + }); + + await extension.startup(); + let win = window.open("http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_mixed.html"); + await extension.awaitMessage("finished"); + win.close(); + await extension.unload(); +}); + +add_task(async function test_webRequest_redirect_wins() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "*://mochi.test/tests/*", + ], + }, + background() { + browser.webRequest.onSendHeaders.addListener((details) => { + // At this point, the request should have been redirected instead of upgraded. + browser.test.assertTrue(details.url.includes("file_sample"), "request was redirected"); + browser.test.sendMessage("finished"); + }, {urls: ["*://mochi.test/tests/*"]}); + + browser.webRequest.onBeforeRequest.addListener((details) => { + if (details.url.includes("file_mixed.html")) { + let redirectUrl = new URL("file_sample.html", details.url).href; + return {upgradeToSecure: true, redirectUrl}; + } + }, {urls: ["*://mochi.test/tests/*"]}, ["blocking"]); + }, + }); + + await extension.startup(); + let win = window.open("http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_mixed.html"); + await extension.awaitMessage("finished"); + win.close(); + await extension.unload(); +}); + +// Test that there is no infinite redirect loop when upgradeToSecure is used on +// https. This test checks that the redirect chain is: http -> https -> done. +add_task(async function upgradeToSecure_for_https_is_noop() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "*://example.com/tests/*", + ], + }, + background() { + let count = 0; + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.log(`onBeforeRequest ${details.requestId} ${details.url}`); + ++count; + if (details.url.startsWith("http:")) { + browser.test.assertEq(1, count, "Initial request is http:"); + } else { + browser.test.assertEq(2, count, "Second request is https:"); + } + return {upgradeToSecure: true}; + }, + { urls: ["*://example.com/tests/*file_sample.html"] }, + ["blocking"] + ); + browser.webRequest.onCompleted.addListener( + details => { + browser.test.log(`onCompleted ${details.requestId} ${details.url}`); + browser.test.assertTrue(details.url.startsWith("https"), "is https"); + browser.test.assertEq(2, count, "Seen two requests (http + https)"); + browser.test.sendMessage("finished"); + }, + { urls: ["*://example.com/tests/*file_sample.html"] }, + ); + }, + }); + + await extension.startup(); + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + let win = window.open("http://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html"); + await extension.awaitMessage("finished"); + win.close(); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upload.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upload.html new file mode 100644 index 0000000000..30ecb0aa78 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upload.html @@ -0,0 +1,265 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<form method="post" + action="file_WebRequest_page3.html?trigger=form" + target="_blank" + enctype="multipart/form-data" + > +<input type="text" name=""special" 
 ch�rs" value="sp�cial"> +<input type="file" name="testFile"> +<input type="file" name="emptyFile"> +<input type="text" name="textInput1" value="value1"> +</form> + +<form method="post" + action="file_WebRequest_page3.html?trigger=form" + target="_blank" + enctype="multipart/form-data" + > +<input type="text" name="textInput2" value="value2"> +<input type="file" name="testFile"> +<input type="file" name="emptyFile"> +</form> + +</form> +<form method="post" + action="file_WebRequest_page3.html?trigger=form" + target="_blank" + > +<input type="text" name="textInput" value="value1"> +<input type="text" name="textInput" value="value2"> +</form> +<script> +"use strict"; + +let files, testFile, blob, file, uploads; +add_task(async function test_setup() { + files = await new Promise(resolve => { + SpecialPowers.createFiles([{name: "testFile.pdf", data: "Not really a PDF file :)", "type": "application/x-pdf"}], (result) => { + resolve(result); + }); + }); + testFile = files[0]; + blob = { + name: "blobAsFile", + content: new Blob(["A blob sent as a file"], {type: "text/csv"}), + fileName: "blobAsFile.csv", + }; + file = { + name: "testFile", + fileName: testFile.name, + }; + uploads = { + [blob.name]: blob, + [file.name]: file, + "emptyFile": {fileName: ""} + }; +}); + +function background() { + const FILTERS = {urls: ["<all_urls>"]}; + + function onUpload(details) { + let url = new URL(details.url); + let upload = url.searchParams.get("upload"); + if (!upload) { + return; + } + + let requestBody = details.requestBody; + browser.test.log(`onBeforeRequest upload: ${details.url} ${JSON.stringify(details.requestBody)}`); + browser.test.assertTrue(!!requestBody, `Intercepted upload ${details.url} #${details.requestId} ${upload} have a requestBody`); + if (!requestBody) { + return; + } + let byteLength = parseInt(upload, 10); + if (byteLength) { + browser.test.assertTrue(!!requestBody.raw, `Binary upload ${details.url} #${details.requestId} ${upload} have a raw attribute`); + browser.test.assertEq(byteLength, requestBody.raw && requestBody.raw.map(r => r.bytes ? r.bytes.byteLength : 0).reduce((a, b) => a + b), `Binary upload size matches`); + return; + } + if ("raw" in requestBody) { + browser.test.assertEq(upload, JSON.stringify(requestBody.raw).replace(/(\bfile: ")[^"]+/, "$1<file>"), `Upload ${details.url} #${details.requestId} matches raw data`); + } else { + browser.test.assertEq(upload, JSON.stringify(requestBody.formData), `Upload ${details.url} #${details.requestId} matches form data.`); + } + } + + browser.webRequest.onCompleted.addListener( + details => { + browser.test.log(`onCompleted ${details.requestId} ${details.url}`); + // See bug 1471387 + if (details.url.endsWith("/favicon.ico") || details.originUrl == "about:newtab") { + return; + } + + browser.test.sendMessage("done"); + }, + FILTERS); + + let onBeforeRequest = details => { + browser.test.log(`${name} ${details.requestId} ${details.url}`); + // See bug 1471387 + if (details.url.endsWith("/favicon.ico") || details.originUrl == "about:newtab") { + return; + } + + onUpload(details); + }; + + browser.webRequest.onBeforeRequest.addListener( + onBeforeRequest, FILTERS, ["requestBody"]); + + let tab; + browser.tabs.onCreated.addListener(newTab => { + tab = newTab; + }); + + browser.test.onMessage.addListener(msg => { + if (msg === "close-tab") { + browser.tabs.remove(tab.id); + browser.test.sendMessage("tab-closed"); + } + }); +} + +add_task(async function test_xhr_forms() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "tabs", + "webRequest", + "webRequestBlocking", + "<all_urls>", + ], + }, + background, + }); + + await extension.startup(); + + async function doneAndTabClosed() { + await extension.awaitMessage("done"); + let closed = extension.awaitMessage("tab-closed"); + extension.sendMessage("close-tab"); + await closed; + } + + for (let form of document.forms) { + if (file.name in form.elements) { + SpecialPowers.wrap(form.elements[file.name]).mozSetFileArray(files); + } + let action = new URL(form.action); + let formData = new FormData(form); + let webRequestFD = {}; + + let updateActionURL = () => { + for (let name of formData.keys()) { + webRequestFD[name] = name in uploads ? [uploads[name].fileName] : formData.getAll(name); + } + action.searchParams.set("upload", JSON.stringify(webRequestFD)); + action.searchParams.set("enctype", form.enctype); + }; + + updateActionURL(); + + form.action = action; + form.submit(); + await doneAndTabClosed(); + + if (form.enctype !== "multipart/form-data") { + continue; + } + + let post = (data) => { + let xhr = new XMLHttpRequest(); + action.searchParams.set("xhr", "1"); + xhr.open("POST", action.href); + xhr.send(data); + action.searchParams.delete("xhr"); + return doneAndTabClosed(); + }; + + formData.append(blob.name, blob.content, blob.fileName); + formData.append("formDataField", "some value"); + updateActionURL(); + await post(formData); + + action.searchParams.set("upload", JSON.stringify([{file: "<file>"}])); + await post(testFile); + + action.searchParams.set("upload", `${blob.content.size} bytes`); + await post(blob.content); + + let byteLength = 16; + action.searchParams.set("upload", `${byteLength} bytes`); + await post(new ArrayBuffer(byteLength)); + } + + // Testing the decoding of percent escapes even in cases where the + // multipart/form-data serializer won't emit them. + { + let boundary = "-".repeat(27); + for (let i = 0; i < 3; i++) { + const randomNumber = Math.floor(Math.random() * (2 ** 32)); + boundary += String(randomNumber); + } + + const formPayload = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="percent escapes other than%20quotes and newlines"', + "", + "", + `--${boundary}`, + 'Content-Disposition: form-data; name="valid UTF-8: %F0%9F%92%A9"', + "", + "", + `--${boundary}`, + 'Content-Disposition: form-data; name="broken UTF-8: %F0%9F %92%A9"', + "", + "", + `--${boundary}`, + 'Content-Disposition: form-data; name="percent escapes aren\'t decoded in filenames"; filename="%0D%0A%22"', + "Content-Type: application/octet-stream", + "", + "", + `--${boundary}--`, + "" + ].join("\r\n"); + + const action = new URL("file_WebRequest_page3.html?trigger=form", document.location.href); + action.searchParams.set("xhr", "1"); + action.searchParams.set("upload", JSON.stringify({ + "percent escapes other than quotes and newlines": [""], + "valid UTF-8: 💩": [""], + "broken UTF-8: � ��": [""], + "percent escapes aren't decoded in filenames": ["%0D%0A%22"] + })); + action.searchParams.set("enctype", "multipart/form-data"); + + await fetch( + action.href, + { + method: "POST", + headers: {"Content-Type": `multipart/form-data; boundary=${boundary}`}, + body: formPayload + }, + ); + await doneAndTabClosed(); + } + + await extension.unload(); +}); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_worker.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_worker.html new file mode 100644 index 0000000000..9fc3e00f01 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_worker.html @@ -0,0 +1,192 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head_webrequest.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +<script> +"use strict"; + +let tabId; + +let extensionData = { + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>", "tabs"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener(details => { + if (details.url.endsWith("/favicon.ico")) { + // We don't care about favicon.ico in this test. It is hard to control + // whether the request happens. + browser.test.log(`Ignoring favicon request: ${details.url}`); + return; + } + browser.test.sendMessage("onBeforeRequest", details); + }, {urls: ["<all_urls>"]}, ["blocking"]); + + let tab; + browser.tabs.onCreated.addListener(newTab => { + tab = newTab; + browser.test.sendMessage("tab-created", tab.id); + }); + + browser.test.onMessage.addListener(async (msg) => { + if (msg === "close-tab") { + await browser.tabs.remove(tab.id); + browser.test.sendMessage("tab-closed"); + } + }); + }, +}; + +let expected = { + "file_simple_webrequest_worker.html?topframe=true": { + type: "main_frame", + toplevel: true, + origin: "test_ext_webrequest_worker.html", + tabId: true, + parentFrameId: -1, + }, + "file_simple_iframe_worker.html": { + type: "sub_frame", + toplevel: false, + origin: "file_simple_webrequest_worker.html?topframe=true", + tabId: true, + parentFrameId: 0, + }, + "file_simple_toplevel.txt": { + type: "xmlhttprequest", + toplevel: true, + origin: "file_simple_webrequest_worker.html?topframe=true", + tabId: true, + parentFrameId: -1, + }, + "file_simple_iframe.txt": { + type: "xmlhttprequest", + toplevel: false, + origin: "file_simple_iframe_worker.html", + tabId: true, + parentFrameId: 0, + }, + "file_simple_worker.txt": { + type: "xmlhttprequest", + toplevel: true, + origin: "file_simple_worker.js", + tabId: true, + parentFrameId: -1, + }, + "file_simple_iframe_worker.txt": { + type: "xmlhttprequest", + toplevel: false, + origin: "file_simple_worker.js?iniframe=true", + tabId: true, + parentFrameId: 0, + }, + "file_simple_sharedworker.txt": { + type: "xmlhttprequest", + toplevel: undefined, + origin: "file_simple_sharedworker.js", + tabId: false, + parentFrameId: -1, + }, + "file_simple_iframe_sharedworker.txt": { + type: "xmlhttprequest", + toplevel: undefined, + origin: "file_simple_sharedworker.js?iniframe=true", + tabId: false, + parentFrameId: -1, + }, + "file_simple_worker.js": { + type: "script", + toplevel: true, + origin: "file_simple_webrequest_worker.html?topframe=true", + tabId: true, + parentFrameId: -1, + }, + "file_simple_sharedworker.js": { + type: "script", + toplevel: undefined, + origin: "file_simple_webrequest_worker.html?topframe=true", + tabId: false, + parentFrameId: -1, + }, + "file_simple_worker.js?iniframe=true": { + type: "script", + toplevel: false, + origin: "file_simple_iframe_worker.html", + tabId: true, + parentFrameId: 0, + }, + "file_simple_sharedworker.js?iniframe=true": { + type: "script", + toplevel: undefined, + origin: "file_simple_iframe_worker.html", + tabId: false, + parentFrameId: -1, + }, + + +}; + +function checkDetails(details) { + let filename = details.url.split("/").pop(); + ok(filename in expected, `Should be expecting a request for ${filename}`); + let expect = expected[filename]; + is(expect.type, details.type, `${details.type} type matches`); + const originUrlSuffix = details.originUrl?.split("/").pop(); + ok(expect.origin === originUrlSuffix || originUrlSuffix.startsWith(expect.origin), `origin url is correct`); + is(details.parentFrameId, expect.parentFrameId, "parentFrameId matches"); + is(expect.tabId ? tabId : -1, details.tabId, "tabId matches"); + // TODO: When expect.toplevel is "undefined", the details.frameId is supposed + // to be -1. + // details in https://phabricator.services.mozilla.com/D182705#inline-1030548. + if (expect.toplevel === undefined || expect.toplevel) { + is(details.frameId, 0, "expect zero frameId"); + } else { + ok(details.frameId > 0, "expect non-zero frameId"); + } + return filename; +} + +add_task(async function test_webRequest_worker() { + await SpecialPowers.spawnChrome([], async () => { + Services.cache2.clear(); + }); + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let a = addLink(`file_simple_webrequest_worker.html?topframe=true`); + a.click(); + tabId = await extension.awaitMessage("tab-created"); + info(`Get created tab(${tabId}`); + + let remaining = new Set(Object.keys(expected)); + let totalExpectedCount = remaining.size; + let currentExpectedCount = 0; + while (remaining.size !== 0) { + info(`Waiting for request ${currentExpectedCount + 1} out of ${totalExpectedCount}`); + info(`Expecting one of: ${Array.from(remaining)}`); + let details = await extension.awaitMessage("onBeforeRequest"); + info(`Checking details for request: ${JSON.stringify(details)}`); + let filename = checkDetails(details); + ok(remaining.delete(filename), `Got only one request for ${filename}`); + currentExpectedCount = currentExpectedCount + 1; + } + + extension.sendMessage("close-tab"); + await extension.awaitMessage("tab-closed"); + await extension.unload(); +}); + +</script> +</head> +<body> +<div id="test">Sample text</div> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_window_postMessage.html b/toolkit/components/extensions/test/mochitest/test_ext_window_postMessage.html new file mode 100644 index 0000000000..53b19d0ead --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_window_postMessage.html @@ -0,0 +1,104 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for content script</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +/* eslint-disable mozilla/balanced-listeners */ + +add_task(async function test_postMessage() { + let extensionData = { + manifest: { + content_scripts: [ + { + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + "run_at": "document_start", + "all_frames": true, + }, + ], + + web_accessible_resources: ["iframe.html"], + }, + + background() { + browser.test.sendMessage("iframe-url", browser.runtime.getURL("iframe.html")); + }, + + files: { + "content_script.js": function() { + window.addEventListener("message", event => { + if (event.data == "ping") { + event.source.postMessage({pong: location.href}, + event.origin); + } + }); + }, + + "iframe.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="content_script.js"><\/script> + </head> + </html>`, + }, + }; + + let createIframe = url => { + let iframe = document.createElement("iframe"); + return new Promise(resolve => { + iframe.src = url; + iframe.onload = resolve; + document.body.appendChild(iframe); + }).then(() => { + return iframe; + }); + }; + + let awaitMessage = () => { + return new Promise(resolve => { + let listener = event => { + if (event.data.pong) { + window.removeEventListener("message", listener); + resolve(event.data); + } + }; + window.addEventListener("message", listener); + }); + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let iframeURL = await extension.awaitMessage("iframe-url"); + let testURL = SimpleTest.getTestFileURL("file_sample.html"); + + for (let url of [iframeURL, testURL]) { + info(`Testing URL ${url}`); + + let iframe = await createIframe(url); + + iframe.contentWindow.postMessage( + "ping", url); + + let pong = await awaitMessage(); + is(pong.pong, url, "Got expected pong"); + + iframe.remove(); + } + + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_startup_canary.html b/toolkit/components/extensions/test/mochitest/test_startup_canary.html new file mode 100644 index 0000000000..1f705940c2 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_startup_canary.html @@ -0,0 +1,76 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Check StartupCache</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +// The startup canary file is removed sometime after the startup, with a delay, +// e.g. 30 seconds on desktop: +// https://searchfox.org/mozilla-central/rev/aa46c2dcccbc6fd4265edca05d3d00cccdfc97b9/browser/components/BrowserGlue.jsm#2486-2490 +// e.g. up to 15 seconds (as an idle timeout) on Android: +// https://searchfox.org/mozilla-central/rev/aa46c2dcccbc6fd4265edca05d3d00cccdfc97b9/mobile/android/chrome/geckoview/geckoview.js#510 +// +// This test completes quickly if run sequentially after the many tests in this +// directory. Otherwise the test may wait for up to MAX_DELAY_SEC seconds. +const MAX_DELAY_SEC = 30; +SimpleTest.requestFlakyTimeout("trackStartupCrashEnd() is called with a delay"); + +// This test is not extension-specific, but placed in the extensions/ directory +// because it complements the test_check_startupcache.html test, and because +// the directory has many other tests, to minimize the amount of time wasted on +// waiting. + +add_task(async function check_startup_canary() { + // The ".startup-incomplete" file is created at the startup, and supposedly + // cleared "soon" after startup (when the application knows that the startup + // succeeded without crash). Bug 1624724 and bug 1728461 show that this has + // not always been the case, so this regression test verifies that the file + // is actually non-existent when this test start, see + // https://bugzilla.mozilla.org/show_bug.cgi?id=1728461#c12 + + // This test is opened as a web page in the browser, so that should have been + // a point where the startup should have been considered done. + + async function canaryExists() { + let chromeScript = loadChromeScript(async () => { + // This file is called FILE_STARTUP_INCOMPLETE in nsAppRunner.cpp and + // referenced via mozilla::startup::GetIncompleteStartupFile: + let file = Services.dirsvc.get("ProfLD", Ci.nsIFile); + file.append(".startup-incomplete"); + this.sendAsyncMessage("canary_exists", file.exists()); + }); + let exists = await chromeScript.promiseOneMessage("canary_exists"); + chromeScript.destroy(); + return exists; + } + + info("Checking if startup canary exists"); + let i = 0; + while (await canaryExists()) { + if (i++ > MAX_DELAY_SEC) { + info("Canary still exists, giving up on waiting"); + break; + } + info(`Startup canary exists, will retry ${i} / ${MAX_DELAY_SEC}.`); + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + is( + await canaryExists(), + false, + "Startup canary should have been removed after early startup" + ); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_verify_non_remote_mode.html b/toolkit/components/extensions/test/mochitest/test_verify_non_remote_mode.html new file mode 100644 index 0000000000..3713243c1b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_verify_non_remote_mode.html @@ -0,0 +1,32 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Verify non-remote mode</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; +add_task(async function verify_extensions_in_parent_process() { + // This test ensures we are running with the proper settings. + const { WebExtensionPolicy } = SpecialPowers.Cu.getGlobalForObject(SpecialPowers.Services); + SimpleTest.ok(!WebExtensionPolicy.useRemoteWebExtensions, "extensions running in-process"); + + let chromeScript = SpecialPowers.loadChromeScript(() => { + /* eslint-env mozilla/chrome-script */ + const { WebExtensionPolicy } = Cu.getGlobalForObject(Services); + Assert.ok(WebExtensionPolicy.isExtensionProcess, "parent is extension process"); + this.sendAsyncMessage("checks_done"); + }); + await chromeScript.promiseOneMessage("checks_done"); + chromeScript.destroy(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_verify_remote_mode.html b/toolkit/components/extensions/test/mochitest/test_verify_remote_mode.html new file mode 100644 index 0000000000..2be0e19179 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_verify_remote_mode.html @@ -0,0 +1,22 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Verify remote mode</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> + "use strict"; + // This test ensures we are running with the proper settings. + const {WebExtensionPolicy} = SpecialPowers.Cu.getGlobalForObject(SpecialPowers.Services); + SimpleTest.ok(WebExtensionPolicy.useRemoteWebExtensions, "extensions running remote"); + SimpleTest.ok(!WebExtensionPolicy.isExtensionProcess, "testing from remote process"); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_verify_sw_mode.html b/toolkit/components/extensions/test/mochitest/test_verify_sw_mode.html new file mode 100644 index 0000000000..5aea44b62b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_verify_sw_mode.html @@ -0,0 +1,24 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Verify WebExtension background service worker mode</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> + "use strict"; + // This test ensures we are running with the proper settings. + const {WebExtensionPolicy} = SpecialPowers.Cu.getGlobalForObject(SpecialPowers.Services); + SimpleTest.ok(WebExtensionPolicy.useRemoteWebExtensions, "extensions running remote"); + SimpleTest.ok(!WebExtensionPolicy.isExtensionProcess, "testing from remote process"); + SimpleTest.ok(WebExtensionPolicy.backgroundServiceWorkerEnabled, "extensions background service worker enabled"); + SimpleTest.ok(AppConstants.MOZ_WEBEXT_WEBIDL_ENABLED, "extensions API webidl bindings enabled"); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/webrequest_chromeworker.js b/toolkit/components/extensions/test/mochitest/webrequest_chromeworker.js new file mode 100644 index 0000000000..14d3ad2bab --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/webrequest_chromeworker.js @@ -0,0 +1,9 @@ +"use strict"; + +/* eslint-env worker */ + +onmessage = function (event) { + fetch("https://example.com/example.txt").then(() => { + postMessage("Done!"); + }); +}; diff --git a/toolkit/components/extensions/test/mochitest/webrequest_test.sys.mjs b/toolkit/components/extensions/test/mochitest/webrequest_test.sys.mjs new file mode 100644 index 0000000000..33554f3023 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/webrequest_test.sys.mjs @@ -0,0 +1,16 @@ +export var webrequest_test = { + testFetch(url) { + return fetch(url); + }, + + testXHR(url) { + return new Promise(resolve => { + let xhr = new XMLHttpRequest(); + xhr.open("HEAD", url); + xhr.onload = () => { + resolve(); + }; + xhr.send(); + }); + }, +}; diff --git a/toolkit/components/extensions/test/mochitest/webrequest_worker.js b/toolkit/components/extensions/test/mochitest/webrequest_worker.js new file mode 100644 index 0000000000..dcffd08578 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/webrequest_worker.js @@ -0,0 +1,3 @@ +"use strict"; + +fetch("https://example.com/example.txt"); diff --git a/toolkit/components/extensions/test/xpcshell/.eslintrc.js b/toolkit/components/extensions/test/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..60d784b53c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/.eslintrc.js @@ -0,0 +1,13 @@ +"use strict"; + +module.exports = { + env: { + // The tests in this folder are testing based on WebExtensions, so lets + // just define the webextensions environment here. + webextensions: true, + // Many parts of WebExtensions test definitions (e.g. content scripts) also + // interact with the browser environment, so define that here as we don't + // have an easy way to handle per-function/scope usage yet. + browser: true, + }, +}; diff --git a/toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherChild.sys.mjs b/toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherChild.sys.mjs new file mode 100644 index 0000000000..907631dec1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherChild.sys.mjs @@ -0,0 +1,62 @@ +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "wdm", + "@mozilla.org/dom/workers/workerdebuggermanager;1", + "nsIWorkerDebuggerManager" +); + +export class TestWorkerWatcherChild extends JSProcessActorChild { + async receiveMessage(msg) { + switch (msg.name) { + case "Test:StartWatchingWorkers": + this.startWatchingWorkers(); + break; + case "Test:StopWatchingWorkers": + this.stopWatchingWorkers(); + break; + default: + // Ensure the test case will fail if this JSProcessActorChild does receive + // unexpected messages. + return Promise.reject( + new Error(`Unexpected message received: ${msg.name}`) + ); + } + } + + startWatchingWorkers() { + if (!this._workerDebuggerListener) { + const actor = this; + this._workerDebuggerListener = { + onRegister(dbg) { + actor.sendAsyncMessage("Test:WorkerSpawned", { + workerType: dbg.type, + workerUrl: dbg.url, + }); + }, + onUnregister(dbg) { + actor.sendAsyncMessage("Test:WorkerTerminated", { + workerType: dbg.type, + workerUrl: dbg.url, + }); + }, + }; + + lazy.wdm.addListener(this._workerDebuggerListener); + } + } + + stopWatchingWorkers() { + if (this._workerDebuggerListener) { + lazy.wdm.removeListener(this._workerDebuggerListener); + this._workerDebuggerListener = null; + } + } + + willDestroy() { + this.stopWatchingWorkers(); + } +} diff --git a/toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherParent.sys.mjs b/toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherParent.sys.mjs new file mode 100644 index 0000000000..a9d919f1ed --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherParent.sys.mjs @@ -0,0 +1,20 @@ +export class TestWorkerWatcherParent extends JSProcessActorParent { + constructor() { + super(); + // This is set by the test helper that does use these process actors. + this.eventEmitter = null; + } + + receiveMessage(msg) { + switch (msg.name) { + case "Test:WorkerSpawned": + this.eventEmitter?.emit("worker-spawned", msg.data); + break; + case "Test:WorkerTerminated": + this.eventEmitter?.emit("worker-terminated", msg.data); + break; + default: + throw new Error(`Unexpected message received: ${msg.name}`); + } + } +} diff --git a/toolkit/components/extensions/test/xpcshell/data/dummy_page.html b/toolkit/components/extensions/test/xpcshell/data/dummy_page.html new file mode 100644 index 0000000000..c1c9a4e043 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/dummy_page.html @@ -0,0 +1,7 @@ +<!DOCTYPE html> + +<html> +<body> +<p>Page</p> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/empty_file_download.txt b/toolkit/components/extensions/test/xpcshell/data/empty_file_download.txt new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/empty_file_download.txt diff --git a/toolkit/components/extensions/test/xpcshell/data/file download.txt b/toolkit/components/extensions/test/xpcshell/data/file download.txt new file mode 100644 index 0000000000..6293c7af79 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file download.txt @@ -0,0 +1 @@ +This is a sample file used in download tests. diff --git a/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_page2.html b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_page2.html new file mode 100644 index 0000000000..b2cf48f9e1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_page2.html @@ -0,0 +1,25 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +<link rel="stylesheet" href="file_style_good.css"> +<link rel="stylesheet" href="file_style_bad.css"> +<link rel="stylesheet" href="file_style_redirect.css"> +</head> +<body> + +<div class="test">Sample text</div> + +<img id="img_good" src="file_image_good.png"> +<img id="img_bad" src="file_image_bad.png"> +<img id="img_redirect" src="file_image_redirect.png"> + +<script src="file_script_good.js"></script> +<script src="file_script_bad.js"></script> +<script src="file_script_redirect.js"></script> + +<script src="nonexistent_script_url.js"></script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.html b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.html new file mode 100644 index 0000000000..f6b5142c4d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.html @@ -0,0 +1,19 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<script src="http://example.org/data/file_WebRequest_permission_original.js"></script> +<script> +"use strict"; + +window.parent.postMessage({ + page: "original", + script: window.testScript, +}, "*"); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.js b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.js new file mode 100644 index 0000000000..2981108b64 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.js @@ -0,0 +1,2 @@ +"use strict"; +window.testScript = "original"; diff --git a/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.html b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.html new file mode 100644 index 0000000000..0979593f7b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.html @@ -0,0 +1,19 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<script src="http://example.org/data/file_WebRequest_permission_original.js"></script> +<script> +"use strict"; + +window.parent.postMessage({ + page: "redirected", + script: window.testScript, +}, "*"); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.js b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.js new file mode 100644 index 0000000000..06fd42aa40 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.js @@ -0,0 +1,2 @@ +"use strict"; +window.testScript = "redirected"; diff --git a/toolkit/components/extensions/test/xpcshell/data/file_content_script_errors.html b/toolkit/components/extensions/test/xpcshell/data/file_content_script_errors.html new file mode 100644 index 0000000000..da1d1c32bc --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_content_script_errors.html @@ -0,0 +1,7 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + </head> + <body>Content script errors</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_csp.html b/toolkit/components/extensions/test/xpcshell/data/file_csp.html new file mode 100644 index 0000000000..9f5cf92f5a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_csp.html @@ -0,0 +1,14 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<div id="test">Sample text</div> +<img id="bad-image" src="http://example.org/data/file_image_bad.png"> +<script id="bad-script" src="http://example.org/data/file_script_bad.js"></script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_csp.html^headers^ b/toolkit/components/extensions/test/xpcshell/data/file_csp.html^headers^ new file mode 100644 index 0000000000..4c6fa3c26a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_csp.html^headers^ @@ -0,0 +1 @@ +Content-Security-Policy: default-src 'self' diff --git a/toolkit/components/extensions/test/xpcshell/data/file_do_load_script_subresource.html b/toolkit/components/extensions/test/xpcshell/data/file_do_load_script_subresource.html new file mode 100644 index 0000000000..c74dec5f5a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_do_load_script_subresource.html @@ -0,0 +1,9 @@ +<!DOCTYPE HTML> +<html> +<head> +<meta charset="utf-8"> +</head> +<body> +<script src="http://example.net/intercept_by_webRequest.js"></script> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_document_open.html b/toolkit/components/extensions/test/xpcshell/data/file_document_open.html new file mode 100644 index 0000000000..dae5e90667 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_document_open.html @@ -0,0 +1,21 @@ +<!DOCTYPE HTML> +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + + <iframe id="iframe"></iframe> + + <script type="text/javascript"> + "use strict"; + addEventListener("load", () => { + let iframe = document.getElementById("iframe"); + let doc = iframe.contentDocument; + doc.open("text/html"); + doc.write("Hello."); + doc.close(); + }, {once: true}); + </script> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_document_write.html b/toolkit/components/extensions/test/xpcshell/data/file_document_write.html new file mode 100644 index 0000000000..f8369ae574 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_document_write.html @@ -0,0 +1,36 @@ +<!DOCTYPE HTML> +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + <iframe id="iframe"></iframe> + + <script type="text/javascript"> + "use strict"; + addEventListener("load", () => { + // Send a heap-minimize observer notification so our script cache is + // cleared, and our content script isn't available for synchronous + // insertion. + window.dispatchEvent(new CustomEvent("MozHeapMinimize")); + + let iframe = document.getElementById("iframe"); + let doc = iframe.contentDocument; + let win = iframe.contentWindow; + doc.open("text/html"); + // We need to do two writes here. The first creates the document element, + // which normally triggers parser blocking. The second triggers the + // creation of the element we're about to query for, which would normally + // happen asynchronously if the parser were blocked. + doc.write("<div id=meh>"); + doc.write("<div id=beer></div>"); + + let elem = doc.getElementById("beer"); + top.postMessage(elem instanceof win.HTMLDivElement ? "ok" : "fail", + "*"); + + doc.close(); + }, {once: true}); + </script> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_download.html b/toolkit/components/extensions/test/xpcshell/data/file_download.html new file mode 100644 index 0000000000..d970c63259 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_download.html @@ -0,0 +1,12 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<div>Download HTML File</div> + +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_download.txt b/toolkit/components/extensions/test/xpcshell/data/file_download.txt new file mode 100644 index 0000000000..6293c7af79 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_download.txt @@ -0,0 +1 @@ +This is a sample file used in download tests. diff --git a/toolkit/components/extensions/test/xpcshell/data/file_iframe.html b/toolkit/components/extensions/test/xpcshell/data/file_iframe.html new file mode 100644 index 0000000000..0cd68be586 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_iframe.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>Iframe document</title> +</head> +<body> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_image_bad.png b/toolkit/components/extensions/test/xpcshell/data/file_image_bad.png Binary files differnew file mode 100644 index 0000000000..4c3be50847 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_image_bad.png diff --git a/toolkit/components/extensions/test/xpcshell/data/file_image_good.png b/toolkit/components/extensions/test/xpcshell/data/file_image_good.png Binary files differnew file mode 100644 index 0000000000..769c636340 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_image_good.png diff --git a/toolkit/components/extensions/test/xpcshell/data/file_image_redirect.png b/toolkit/components/extensions/test/xpcshell/data/file_image_redirect.png Binary files differnew file mode 100644 index 0000000000..4c3be50847 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_image_redirect.png diff --git a/toolkit/components/extensions/test/xpcshell/data/file_page_xhr.html b/toolkit/components/extensions/test/xpcshell/data/file_page_xhr.html new file mode 100644 index 0000000000..387b5285f5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_page_xhr.html @@ -0,0 +1,34 @@ +<!DOCTYPE html> +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<script> +"use strict"; + +addEventListener("message", async function(event) { + const url = new URL("/return_headers.sjs", location).href; + + const webpageFetchResult = await fetch(url).then(res => res.json()); + const webpageXhrResult = await new Promise(resolve => { + const req = new XMLHttpRequest(); + req.open("GET", url); + req.addEventListener("load", () => resolve(JSON.parse(req.responseText)), + {once: true}); + req.addEventListener("error", () => resolve({error: "webpage xhr failed to complete"}), + {once: true}); + req.send(); + }); + + postMessage({ + type: "testPageGlobals", + webpageFetchResult, + webpageXhrResult, + }, "*"); +}, {once: true}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_permission_xhr.html b/toolkit/components/extensions/test/xpcshell/data/file_permission_xhr.html new file mode 100644 index 0000000000..6f1bb4648b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_permission_xhr.html @@ -0,0 +1,61 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<script> +"use strict"; + +/* globals privilegedFetch, privilegedXHR */ +/* eslint-disable mozilla/balanced-listeners */ + +addEventListener("message", function rcv(event) { + removeEventListener("message", rcv, false); + + function assertTrue(condition, description) { + postMessage({msg: "assertTrue", condition, description}, "*"); + } + + function assertThrows(func, expectedError, msg) { + try { + func(); + } catch (e) { + assertTrue(expectedError.test(e), msg + ": threw " + e); + return; + } + + assertTrue(false, "Function did not throw, " + + "expected error should have matched " + expectedError); + } + + function passListener() { + assertTrue(true, "Content XHR has no elevated privileges"); + postMessage({"msg": "finish"}, "*"); + } + + function failListener() { + assertTrue(false, "Content XHR has no elevated privileges"); + postMessage({"msg": "finish"}, "*"); + } + + assertThrows(function() { new privilegedXHR(); }, + /Permission denied to access object/, + "Content should not be allowed to construct a privileged XHR constructor"); + + assertThrows(function() { new privilegedFetch(); }, + / is not a constructor/, + "Content should not be allowed to construct a privileged fetch() constructor"); + + let req = new XMLHttpRequest(); + req.addEventListener("load", failListener); + req.addEventListener("error", passListener); + req.open("GET", "http://example.org/example.txt"); + req.send(); +}, false); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_privilege_escalation.html b/toolkit/components/extensions/test/xpcshell/data/file_privilege_escalation.html new file mode 100644 index 0000000000..258f7058d9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_privilege_escalation.html @@ -0,0 +1,13 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + <script type="text/javascript"> + "use strict"; + throw new Error(`WebExt Privilege Escalation: typeof(browser) = ${typeof(browser)}`); + </script> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_sample.html b/toolkit/components/extensions/test/xpcshell/data/file_sample.html new file mode 100644 index 0000000000..a20e49a1f0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_sample.html @@ -0,0 +1,12 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<div id="test">Sample text</div> + +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_sample_registered_styles.html b/toolkit/components/extensions/test/xpcshell/data/file_sample_registered_styles.html new file mode 100644 index 0000000000..9f5c5d5a6a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_sample_registered_styles.html @@ -0,0 +1,13 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<div id="registered-extension-url-style">Registered Extension URL style</div> +<div id="registered-extension-text-style">Registered Extension Text style</div> + +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_script.html b/toolkit/components/extensions/test/xpcshell/data/file_script.html new file mode 100644 index 0000000000..8d192b7d8e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_script.html @@ -0,0 +1,14 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +<script type="application/javascript" src="file_script_good.js"></script> +<script type="application/javascript" src="file_script_bad.js"></script> +</head> +<body> + +<div id="test">Sample text</div> + +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_script_bad.js b/toolkit/components/extensions/test/xpcshell/data/file_script_bad.js new file mode 100644 index 0000000000..ff4572865b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_script_bad.js @@ -0,0 +1,12 @@ +"use strict"; + +window.failure = true; +window.addEventListener( + "load", + () => { + let el = document.createElement("div"); + el.setAttribute("id", "bad"); + document.body.appendChild(el); + }, + { once: true } +); diff --git a/toolkit/components/extensions/test/xpcshell/data/file_script_good.js b/toolkit/components/extensions/test/xpcshell/data/file_script_good.js new file mode 100644 index 0000000000..bf47fb36d2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_script_good.js @@ -0,0 +1,12 @@ +"use strict"; + +window.success = window.success ? window.success + 1 : 1; +window.addEventListener( + "load", + () => { + let el = document.createElement("div"); + el.setAttribute("id", "good"); + document.body.appendChild(el); + }, + { once: true } +); diff --git a/toolkit/components/extensions/test/xpcshell/data/file_script_redirect.js b/toolkit/components/extensions/test/xpcshell/data/file_script_redirect.js new file mode 100644 index 0000000000..c425122c71 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_script_redirect.js @@ -0,0 +1,3 @@ +"use strict"; + +window.failure = true; diff --git a/toolkit/components/extensions/test/xpcshell/data/file_script_xhr.js b/toolkit/components/extensions/test/xpcshell/data/file_script_xhr.js new file mode 100644 index 0000000000..24a26cb8d1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_script_xhr.js @@ -0,0 +1,9 @@ +"use strict"; + +var request = new XMLHttpRequest(); +request.open( + "get", + "http://example.com/browser/toolkit/modules/tests/browser/xhr_resource", + false +); +request.send(); diff --git a/toolkit/components/extensions/test/xpcshell/data/file_shadowdom.html b/toolkit/components/extensions/test/xpcshell/data/file_shadowdom.html new file mode 100644 index 0000000000..c4e7db14e7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_shadowdom.html @@ -0,0 +1,13 @@ +<!DOCTYPE HTML> +<html> +<head> +<meta charset="utf-8"> +</head> +<body> +<div id="host">host</div> +<script> + "use strict"; + document.getElementById("host").attachShadow({mode: "closed"}); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_style_bad.css b/toolkit/components/extensions/test/xpcshell/data/file_style_bad.css new file mode 100644 index 0000000000..8dbc8dc7a4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_style_bad.css @@ -0,0 +1,3 @@ +#test { + color: green !important; +} diff --git a/toolkit/components/extensions/test/xpcshell/data/file_style_good.css b/toolkit/components/extensions/test/xpcshell/data/file_style_good.css new file mode 100644 index 0000000000..46f9774b5f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_style_good.css @@ -0,0 +1,3 @@ +#test { + color: red; +} diff --git a/toolkit/components/extensions/test/xpcshell/data/file_style_redirect.css b/toolkit/components/extensions/test/xpcshell/data/file_style_redirect.css new file mode 100644 index 0000000000..8dbc8dc7a4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_style_redirect.css @@ -0,0 +1,3 @@ +#test { + color: green !important; +} diff --git a/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.css b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.css new file mode 100644 index 0000000000..6a9140d97e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.css @@ -0,0 +1 @@ +:root { color: green; } diff --git a/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.html b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.html new file mode 100644 index 0000000000..6d6d187a27 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.html @@ -0,0 +1,3 @@ +<!doctype html> +<meta charset=utf-8> +<link rel=stylesheet href=file_stylesheet_cache.css> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache_2.html b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache_2.html new file mode 100644 index 0000000000..07a4324c44 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache_2.html @@ -0,0 +1,19 @@ +<!doctype html> +<meta charset=utf-8> +<!-- The first one should hit the cache, the second one should not. --> +<link rel=stylesheet href=file_stylesheet_cache.css> +<script> + "use strict"; + // This script guarantees that the load of the above stylesheet has happened + // by now. + // + // Now we can go ahead and load the other one programmatically. It's + // important that we don't just throw a <link> in the markup below to + // guarantee + // that the load happens afterwards (that is, to cheat the parser's speculative + // load mechanism). + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = "file_stylesheet_cache.css?2"; + document.head.appendChild(link); +</script> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_toplevel.html b/toolkit/components/extensions/test/xpcshell/data/file_toplevel.html new file mode 100644 index 0000000000..d93813d0f5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_toplevel.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>Top-level frame document</title> +</head> +<body> + <iframe src="file_iframe.html"></iframe> + <iframe src="about:blank"></iframe> + <iframe srcdoc="Iframe srcdoc"></iframe> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_with_iframe.html b/toolkit/components/extensions/test/xpcshell/data/file_with_iframe.html new file mode 100644 index 0000000000..705350d55c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_with_iframe.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + <title>file with iframe</title> + </head> + <body> + <div id="test"></div> + <iframe src="./file_sample.html"></iframe> + </body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_with_xorigin_frame.html b/toolkit/components/extensions/test/xpcshell/data/file_with_xorigin_frame.html new file mode 100644 index 0000000000..199c2ce4d4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_with_xorigin_frame.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>Document with example.org frame</title> +</head> +<body> + <iframe src="http://example.org/data/file_iframe.html"></iframe> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/lorem.html.gz b/toolkit/components/extensions/test/xpcshell/data/lorem.html.gz Binary files differnew file mode 100644 index 0000000000..9eb8d73d50 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/lorem.html.gz diff --git a/toolkit/components/extensions/test/xpcshell/data/pixel_green.gif b/toolkit/components/extensions/test/xpcshell/data/pixel_green.gif Binary files differnew file mode 100644 index 0000000000..baf8166dae --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/pixel_green.gif diff --git a/toolkit/components/extensions/test/xpcshell/data/pixel_red.gif b/toolkit/components/extensions/test/xpcshell/data/pixel_red.gif Binary files differnew file mode 100644 index 0000000000..48f97f74bd --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/pixel_red.gif diff --git a/toolkit/components/extensions/test/xpcshell/head.js b/toolkit/components/extensions/test/xpcshell/head.js new file mode 100644 index 0000000000..6935e3f0da --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head.js @@ -0,0 +1,530 @@ +"use strict"; +/* exported createHttpServer, cleanupDir, clearCache, optionalPermissionsPromptHandler, promiseConsoleOutput, + promiseQuotaManagerServiceReset, promiseQuotaManagerServiceClear, + runWithPrefs, testEnv, withHandlingUserInput, resetHandlingUserInput, + assertPersistentListeners, promiseExtensionEvent, assertHasPersistedScriptsCachedFlag, + assertIsPersistedScriptsCachedFlag + setup_crash_reporter_override_and_cleaner crashFrame crashExtensionBackground +*/ + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +var { + clearInterval, + clearTimeout, + setInterval, + setIntervalWithTarget, + setTimeout, + setTimeoutWithTarget, +} = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs"); +var { AddonTestUtils, MockAsyncShutdown } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + ContentTask: "resource://testing-common/ContentTask.sys.mjs", + Extension: "resource://gre/modules/Extension.sys.mjs", + ExtensionData: "resource://gre/modules/Extension.sys.mjs", + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", + ExtensionTestUtils: + "resource://testing-common/ExtensionXPCShellUtils.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + Management: "resource://gre/modules/Extension.sys.mjs", + MessageChannel: "resource://testing-common/MessageChannel.sys.mjs", + MockRegistrar: "resource://testing-common/MockRegistrar.sys.mjs", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", + PromiseTestUtils: "resource://testing-common/PromiseTestUtils.sys.mjs", + Schemas: "resource://gre/modules/Schemas.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Message manager disconnected/ +); + +// Persistent Listener test functionality +const { assertPersistentListeners } = ExtensionTestUtils.testAssertions; + +// https_first automatically upgrades http to https, but the tests are not +// designed to expect that. And it is not easy to change that because +// nsHttpServer does not support https (bug 1742061). So disable https_first. +Services.prefs.setBoolPref("dom.security.https_first", false); + +// These values may be changed in later head files and tested in check_remote +// below. +Services.prefs.setBoolPref("extensions.webextensions.remote", false); +const testEnv = { + expectRemote: false, +}; + +add_setup(function check_remote() { + Assert.equal( + WebExtensionPolicy.useRemoteWebExtensions, + testEnv.expectRemote, + "useRemoteWebExtensions matches" + ); + Assert.equal( + WebExtensionPolicy.isExtensionProcess, + !testEnv.expectRemote, + "testing from extension process" + ); +}); + +ExtensionTestUtils.init(this); + +var createHttpServer = (...args) => { + AddonTestUtils.maybeInit(this); + return AddonTestUtils.createHttpServer(...args); +}; + +// Some tests load non-moz-extension:-URLs in their extension document. When +// extensions run in-process (extensions.webextensions.remote set to false), +// that fails. +// For details, see: https://bugzilla.mozilla.org/show_bug.cgi?id=1724099 +// To avoid skip-if on the whole file, use this: +// +// add_task(async function test_description_here() { +// // Comment explaining why. +// allow_unsafe_parent_loads_when_extensions_not_remote(); +// ... +// revert_allow_unsafe_parent_loads_when_extensions_not_remote(); +// }); +var private_upl_cleanup_handlers = []; +function allow_unsafe_parent_loads_when_extensions_not_remote() { + if (WebExtensionPolicy.useRemoteWebExtensions) { + // We should only allow remote iframes in the main process. + return; + } + if (!Cu.isInAutomation) { + // isInAutomation is false by default in xpcshell (bug 1598804). Flip pref. + Services.prefs.setBoolPref( + "security.turn_off_all_security_so_that_viruses_can_take_over_this_computer", + true + ); + private_upl_cleanup_handlers.push(() => { + Services.prefs.setBoolPref( + "security.turn_off_all_security_so_that_viruses_can_take_over_this_computer", + false + ); + }); + // Sanity check: Fail immediately if setting the above pref does somehow + // not flip the isInAutomation flag. + if (!Cu.isInAutomation) { + // This condition is unexpected, because it is enforced at: + // https://searchfox.org/mozilla-central/rev/ea65de7c/js/xpconnect/src/xpcpublic.h#753-759 + throw new Error("Failed to set isInAutomation to true"); + } + } + // Note: The following pref requires the isInAutomation flag to be set. + // When unset, the pref is ignored, and tests would encounter bug 1724099. + if (!Services.prefs.getBoolPref("security.allow_unsafe_parent_loads")) { + info("Setting pref security.allow_unsafe_parent_loads to true"); + Services.prefs.setBoolPref("security.allow_unsafe_parent_loads", true); + private_upl_cleanup_handlers.push(() => { + info("Reverting pref security.allow_unsafe_parent_loads to false"); + Services.prefs.setBoolPref("security.allow_unsafe_parent_loads", false); + }); + } + + registerCleanupFunction( + // eslint-disable-next-line no-use-before-define + revert_allow_unsafe_parent_loads_when_extensions_not_remote + ); +} + +function revert_allow_unsafe_parent_loads_when_extensions_not_remote() { + for (let revert of private_upl_cleanup_handlers.splice(0)) { + revert(); + } +} + +/** + * Clears the HTTP and content image caches. + */ +function clearCache() { + Services.cache2.clear(); + + let imageCache = Cc["@mozilla.org/image/tools;1"] + .getService(Ci.imgITools) + .getImgCacheForDocument(null); + imageCache.clearCache(false); +} + +var promiseConsoleOutput = async function (task) { + const DONE = `=== console listener ${Math.random()} done ===`; + + let listener; + let messages = []; + let awaitListener = new Promise(resolve => { + listener = msg => { + if (msg == DONE) { + resolve(); + } else { + void (msg instanceof Ci.nsIConsoleMessage); + void (msg instanceof Ci.nsIScriptError); + messages.push(msg); + } + }; + }); + + Services.console.registerListener(listener); + try { + let result = await task(); + + Services.console.logStringMessage(DONE); + await awaitListener; + + return { messages, result }; + } finally { + Services.console.unregisterListener(listener); + } +}; + +// Attempt to remove a directory. If the Windows OS is still using the +// file sometimes remove() will fail. So try repeatedly until we can +// remove it or we give up. +function cleanupDir(dir) { + let count = 0; + return new Promise((resolve, reject) => { + function tryToRemoveDir() { + count += 1; + try { + dir.remove(true); + } catch (e) { + // ignore + } + if (!dir.exists()) { + return resolve(); + } + if (count >= 25) { + return reject(`Failed to cleanup directory: ${dir}`); + } + setTimeout(tryToRemoveDir, 100); + } + tryToRemoveDir(); + }); +} + +// Run a test with the specified preferences and then restores their initial values +// right after the test function run (whether it passes or fails). +async function runWithPrefs(prefsToSet, testFn) { + const setPrefs = prefs => { + for (let [pref, value] of prefs) { + if (value === undefined) { + // Clear any pref that didn't have a user value. + info(`Clearing pref "${pref}"`); + Services.prefs.clearUserPref(pref); + continue; + } + + info(`Setting pref "${pref}": ${value}`); + switch (typeof value) { + case "boolean": + Services.prefs.setBoolPref(pref, value); + break; + case "number": + Services.prefs.setIntPref(pref, value); + break; + case "string": + Services.prefs.setStringPref(pref, value); + break; + default: + throw new Error("runWithPrefs doesn't support this pref type yet"); + } + } + }; + + const getPrefs = prefs => { + return prefs.map(([pref, value]) => { + info(`Getting initial pref value for "${pref}"`); + if (!Services.prefs.prefHasUserValue(pref)) { + // Check if the pref doesn't have a user value. + return [pref, undefined]; + } + switch (typeof value) { + case "boolean": + return [pref, Services.prefs.getBoolPref(pref)]; + case "number": + return [pref, Services.prefs.getIntPref(pref)]; + case "string": + return [pref, Services.prefs.getStringPref(pref)]; + default: + throw new Error("runWithPrefs doesn't support this pref type yet"); + } + }); + }; + + let initialPrefsValues = []; + + try { + initialPrefsValues = getPrefs(prefsToSet); + + setPrefs(prefsToSet); + + await testFn(); + } finally { + info("Restoring initial preferences values on exit"); + setPrefs(initialPrefsValues); + } +} + +// "Handling User Input" test helpers. + +let extensionHandlers = new WeakSet(); + +function handlingUserInputFrameScript() { + /* globals content */ + // eslint-disable-next-line no-shadow + const { MessageChannel } = ChromeUtils.importESModule( + "resource://testing-common/MessageChannel.sys.mjs" + ); + + let handle; + MessageChannel.addListener(this, "ExtensionTest:HandleUserInput", { + receiveMessage({ name, data }) { + if (data) { + handle = content.windowUtils.setHandlingUserInput(true); + } else if (handle) { + handle.destruct(); + handle = null; + } + }, + }); +} + +// If you use withHandlingUserInput then restart the addon manager, +// you need to reset this before using withHandlingUserInput again. +function resetHandlingUserInput() { + extensionHandlers = new WeakSet(); +} + +async function withHandlingUserInput(extension, fn) { + let { messageManager } = extension.extension.groupFrameLoader; + + if (!extensionHandlers.has(extension)) { + messageManager.loadFrameScript( + `data:,(${encodeURI(handlingUserInputFrameScript)}).call(this)`, + false, + true + ); + extensionHandlers.add(extension); + } + + await MessageChannel.sendMessage( + messageManager, + "ExtensionTest:HandleUserInput", + true + ); + await fn(); + await MessageChannel.sendMessage( + messageManager, + "ExtensionTest:HandleUserInput", + false + ); +} + +// QuotaManagerService test helpers. + +function promiseQuotaManagerServiceReset() { + info("Calling QuotaManagerService.reset to enforce new test storage limits"); + return new Promise(resolve => { + Services.qms.reset().callback = resolve; + }); +} + +function promiseQuotaManagerServiceClear() { + info( + "Calling QuotaManagerService.clear to empty the test data and refresh test storage limits" + ); + return new Promise(resolve => { + Services.qms.clear().callback = resolve; + }); +} + +// Optional Permission prompt handling +const optionalPermissionsPromptHandler = { + sawPrompt: false, + acceptPrompt: false, + + init() { + Services.prefs.setBoolPref( + "extensions.webextOptionalPermissionPrompts", + true + ); + Services.obs.addObserver(this, "webextension-optional-permission-prompt"); + registerCleanupFunction(() => { + Services.obs.removeObserver( + this, + "webextension-optional-permission-prompt" + ); + Services.prefs.clearUserPref( + "extensions.webextOptionalPermissionPrompts" + ); + }); + }, + + observe(subject, topic, data) { + if (topic == "webextension-optional-permission-prompt") { + this.sawPrompt = true; + let { resolve } = subject.wrappedJSObject; + resolve(this.acceptPrompt); + } + }, +}; + +function promiseExtensionEvent(wrapper, event) { + return new Promise(resolve => { + wrapper.extension.once(event, (...args) => resolve(args)); + }); +} + +async function assertHasPersistedScriptsCachedFlag(ext) { + const { StartupCache } = ExtensionParent; + const allCachedGeneral = StartupCache._data.get("general"); + equal( + allCachedGeneral + .get(ext.id) + ?.get(ext.version) + ?.get("scripting") + ?.has("hasPersistedScripts"), + true, + "Expect the StartupCache to include hasPersistedScripts flag" + ); +} + +async function assertIsPersistentScriptsCachedFlag(ext, expectedValue) { + const { StartupCache } = ExtensionParent; + const allCachedGeneral = StartupCache._data.get("general"); + equal( + allCachedGeneral + .get(ext.id) + ?.get(ext.version) + ?.get("scripting") + ?.get("hasPersistedScripts"), + expectedValue, + "Expected cached value set on hasPersistedScripts flag" + ); +} + +function setup_crash_reporter_override_and_cleaner() { + const crashIds = []; + // Override CrashService.sys.mjs to intercept crash dumps, for two reasons: + // + // - The standard CrashService.sys.mjs implementation uses nsICrashReporter + // through Services.appinfo. Because appinfo has been overridden with an + // incomplete implementation, a promise rejection is triggered when a + // missing method is called at https://searchfox.org/mozilla-central/rev/c615dc4db129ece5cce6c96eb8cab8c5a3e26ac3/toolkit/components/crashes/CrashService.sys.mjs#183 + // + // - We want to intercept the generated crash dumps for expected crashes and + // remove them, to prevent the xpcshell test runner from misinterpreting + // them as "CRASH" failures. + let mockClassId = MockRegistrar.register("@mozilla.org/crashservice;1", { + addCrash(processType, crashType, id) { + // The files are ready to be removed now. We however postpone cleanup + // until the end of the test, to minimize noise during the test, and to + // ensure that the cleanup completes fully. + crashIds.push(id); + }, + QueryInterface: ChromeUtils.generateQI(["nsICrashService"]), + }); + registerCleanupFunction(async () => { + MockRegistrar.unregister(mockClassId); + + // Cannot use Services.appinfo because createAppInfo overrides it. + // eslint-disable-next-line mozilla/use-services + const appinfo = Cc["@mozilla.org/toolkit/crash-reporter;1"].getService( + Ci.nsICrashReporter + ); + + info(`Observed ${crashIds.length} crash dump(s).`); + let deletedCount = 0; + for (let id of crashIds) { + info(`Checking whether dumpID ${id} should be removed`); + let minidumpFile = appinfo.getMinidumpForID(id); + let extraFile = appinfo.getExtraFileForID(id); + let extra; + try { + extra = await IOUtils.readJSON(extraFile.path); + } catch (e) { + info(`Cannot parse crash metadata from ${extraFile.path} :: ${e}\n`); + continue; + } + // The "BrowserTestUtils:CrashFrame" handler annotates the crash + // report before triggering a crash. + if (extra.TestKey !== "CrashFrame") { + info(`Keeping ${minidumpFile.path}; we did not trigger the crash`); + continue; + } + info(`Deleting minidump ${minidumpFile.path} and ${extraFile.path}`); + minidumpFile.remove(false); + extraFile.remove(false); + ++deletedCount; + } + info(`Removed ${deletedCount} crash dumps out of ${crashIds.length}`); + }); +} + +// Crashes a <browser>'s remote process. +// Based on BrowserTestUtils.crashFrame. +function crashFrame(browser) { + if (!browser.isRemoteBrowser) { + // The browser should be remote, or the test runner would be killed. + throw new Error("<browser> must be remote"); + } + + const { BrowserTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/BrowserTestUtils.sys.mjs" + ); + + // Trigger crash by sending a message to BrowserTestUtils actor. + BrowserTestUtils.sendAsyncMessage( + browser.browsingContext, + "BrowserTestUtils:CrashFrame", + {} + ); +} + +/** + * Crash background page of browser and wait for the crash to have been + * detected and processed by ext-backgroundPage.js. + * + * @param {ExtensionWrapper} extension + * @param {XULElement} [bgBrowser] - The background browser. Optional, but must + * be set if the background's ProxyContextParent has not been initialized yet. + */ +async function crashExtensionBackground(extension, bgBrowser) { + bgBrowser ??= extension.extension.backgroundContext.xulBrowser; + + let byeProm = promiseExtensionEvent(extension, "shutdown-background-script"); + if (WebExtensionPolicy.useRemoteWebExtensions) { + info("Killing background page through process crash."); + crashFrame(bgBrowser); + } else { + // If extensions are not running in out-of-process mode, then the + // non-remote process should not be killed (or the test runner dies). + // Remove <browser> instead, to simulate the immediate disconnection + // of the message manager (that would happen if the process crashed). + info("Closing background page by destroying <browser>."); + + if (extension.extension.backgroundState === "running") { + // TODO bug 1844217: remove this whole if-block When close() is hooked up + // to setBgStateStopped. It currently is not, and browser destruction is + // currently not detected by the implementation. + let messageManager = bgBrowser.messageManager; + TestUtils.topicObserved( + "message-manager-close", + subject => subject === messageManager + ).then(() => { + Management.emit("extension-process-crash", { childID: 1337 }); + }); + } + bgBrowser.remove(); + } + + info("Waiting for crash to be detected by the internals"); + await byeProm; +} diff --git a/toolkit/components/extensions/test/xpcshell/head_dnr.js b/toolkit/components/extensions/test/xpcshell/head_dnr.js new file mode 100644 index 0000000000..0c65869722 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_dnr.js @@ -0,0 +1,203 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* exported assertDNRStoreData, getDNRRule, getSchemaNormalizedRule, getSchemaNormalizedRules + */ + +ChromeUtils.defineESModuleGetters(this, { + Schemas: "resource://gre/modules/Schemas.sys.mjs", +}); + +function getDNRRule({ + id = 1, + priority = 1, + action = {}, + condition = {}, +} = {}) { + return { + id, + priority, + action: { + type: "block", + ...action, + }, + condition: { + ...condition, + }, + }; +} + +const getSchemaNormalizedRule = (extensionTestWrapper, value) => { + const { extension } = extensionTestWrapper; + const validationContext = { + url: extension.baseURI.spec, + principal: extension.principal, + logError: err => { + // We don't expect this test helper function to be called on invalid rules, + // and so we trigger an explicit test failure if we ever hit any. + Assert.ok( + false, + `Unexpected logError on normalizing DNR rule ${JSON.stringify( + value + )} - ${err}` + ); + }, + preprocessors: {}, + manifestVersion: extension.manifestVersion, + }; + + return Schemas.normalize( + value, + "declarativeNetRequest.Rule", + validationContext + ); +}; + +const getSchemaNormalizedRules = (extensionTestWrapper, rules) => { + return rules.map(rule => { + const normalized = getSchemaNormalizedRule(extensionTestWrapper, rule); + if (normalized.error) { + throw new Error( + `Unexpected DNR Rule normalization error: ${normalized.error}` + ); + } + return normalized.value; + }); +}; + +const assertDNRStoreData = async ( + dnrStore, + extensionTestWrapper, + expectedRulesets, + { assertIndividualRules = true } = {} +) => { + const extUUID = extensionTestWrapper.uuid; + const rule_resources = + extensionTestWrapper.extension.manifest.declarative_net_request + ?.rule_resources; + const expectedRulesetIds = Array.from(Object.keys(expectedRulesets)); + const expectedRulesetIndexesMap = expectedRulesetIds.reduce((acc, rsId) => { + acc.set( + rsId, + rule_resources.findIndex(rr => rr.id === rsId) + ); + return acc; + }, new Map()); + + ok( + dnrStore._dataPromises.has(extUUID), + "Got promise for the test extension DNR data being loaded" + ); + + await dnrStore._dataPromises.get(extUUID); + + ok(dnrStore._data.has(extUUID), "Got data for the test extension"); + + const dnrExtData = dnrStore._data.get(extUUID); + Assert.deepEqual( + { + schemaVersion: dnrExtData.schemaVersion, + extVersion: dnrExtData.extVersion, + }, + { + schemaVersion: dnrExtData.constructor.VERSION, + extVersion: extensionTestWrapper.extension.version, + }, + "Got the expected data schema version and extension version in the store data" + ); + Assert.deepEqual( + Array.from(dnrExtData.staticRulesets.keys()), + expectedRulesetIds, + "Got the enabled rulesets in the stored data staticRulesets Map" + ); + + for (const rulesetId of expectedRulesetIds) { + const expectedRulesetIdx = expectedRulesetIndexesMap.get(rulesetId); + const expectedRulesetRules = getSchemaNormalizedRules( + extensionTestWrapper, + expectedRulesets[rulesetId] + ); + const actualData = dnrExtData.staticRulesets.get(rulesetId); + equal( + actualData.idx, + expectedRulesetIdx, + `Got the expected ruleset index for ruleset id ${rulesetId}` + ); + + // Asserting an entire array of rules all at once will produce + // a big enough output to don't be immediately useful to investigate + // failures, asserting each rule individually would produce more + // readable assertion failure logs. + const assertRuleAtIdx = ruleIdx => { + const actualRule = actualData.rules[ruleIdx]; + const expectedRule = expectedRulesetRules[ruleIdx]; + Assert.deepEqual( + actualRule, + expectedRule, + `Got the expected rule at index ${ruleIdx} for ruleset id "${rulesetId}"` + ); + Assert.equal( + actualRule.constructor.name, + "Rule", + `Expect rule at index ${ruleIdx} to be an instance of the Rule class` + ); + if (expectedRule.condition.regexFilter) { + const compiledRegexFilter = + actualData.rules[ruleIdx].condition.getCompiledRegexFilter(); + Assert.equal( + compiledRegexFilter?.constructor.name, + "RegExp", + `Expect rule ${ruleIdx} condition.getCompiledRegexFilter() to return a compiled regexp filter` + ); + Assert.equal( + compiledRegexFilter?.source, + new RegExp(expectedRule.condition.regexFilter).source, + `Expect rule ${ruleIdx} condition's compiled RegExp source to match the regexFilter string` + ); + Assert.equal( + compiledRegexFilter?.ignoreCase, + !expectedRule.condition.isUrlFilterCaseSensitive, + `Expect rule ${ruleIdx} conditions's compiled RegExp ignoreCase to be set based on condition.isUrlFilterCaseSensitive` + ); + } + }; + + // Some tests may be using a big enough number of rules that + // the assertiongs would be producing a huge amount of log spam, + // and so for those tests we only explicitly assert the first + // and last rule and that the total amount of rules matches the + // expected number of rules (there are still other tests explicitly + // asserting all loaded rules). + if (assertIndividualRules) { + info(`Verify each individual rule loaded for ruleset id "${rulesetId}"`); + for (let ruleIdx = 0; ruleIdx < expectedRulesetRules.length; ruleIdx++) { + assertRuleAtIdx(ruleIdx); + } + } else if (expectedRulesetRules.length) { + // NOTE: Only asserting the first and last rule also helps to speed up + // the test is some slower builds when the number of expected rules is + // big enough (e.g. the test task verifying the enforced rule count limits + // was timing out in tsan build because asserting all indidual rules was + // taking long enough and the event page was being suspended on the idle + // timeout by the time we did run all these assertion and proceeding with + // the rest of the test task assertions), we still confirm that the total + // number of expected vs actual rules also matches right after these + // assertions. + info( + `Verify the first and last rules loaded for ruleset id "${rulesetId}"` + ); + const lastExpectedRuleIdx = expectedRulesetRules.length - 1; + for (const ruleIdx of [0, lastExpectedRuleIdx]) { + assertRuleAtIdx(ruleIdx); + } + } + + equal( + actualData.rules.length, + expectedRulesetRules.length, + `Got the expected number of rules loaded for ruleset id "${rulesetId}"` + ); + } +}; diff --git a/toolkit/components/extensions/test/xpcshell/head_legacy_ep.js b/toolkit/components/extensions/test/xpcshell/head_legacy_ep.js new file mode 100644 index 0000000000..8bb39c0452 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_legacy_ep.js @@ -0,0 +1,13 @@ +"use strict"; + +// Bug 1646182: Test the legacy ExtensionPermission backend until we fully +// migrate to rkv + +{ + const { ExtensionPermissions } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" + ); + + ExtensionPermissions._useLegacyStorageBackend = true; + ExtensionPermissions._uninit(); +} diff --git a/toolkit/components/extensions/test/xpcshell/head_native_messaging.js b/toolkit/components/extensions/test/xpcshell/head_native_messaging.js new file mode 100644 index 0000000000..32b6948033 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_native_messaging.js @@ -0,0 +1,152 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* globals AppConstants, FileUtils */ +/* exported getSubprocessCount, setupHosts, waitForSubprocessExit */ + +ChromeUtils.defineESModuleGetters(this, { + MockRegistry: "resource://testing-common/MockRegistry.sys.mjs", +}); +if (AppConstants.platform == "win") { + ChromeUtils.defineESModuleGetters(this, { + SubprocessImpl: "resource://gre/modules/subprocess/subprocess_win.sys.mjs", + }); +} else { + ChromeUtils.defineESModuleGetters(this, { + SubprocessImpl: "resource://gre/modules/subprocess/subprocess_unix.sys.mjs", + }); +} + +const { Subprocess } = ChromeUtils.importESModule( + "resource://gre/modules/Subprocess.sys.mjs" +); + +// It's important that we use a space in this directory name to make sure we +// correctly handle executing batch files with spaces in their path. +let tmpDir = FileUtils.getDir("TmpD", ["Native Messaging"]); +tmpDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + +const TYPE_SLUG = + AppConstants.platform === "linux" + ? "native-messaging-hosts" + : "NativeMessagingHosts"; + +add_setup(async function setup() { + await IOUtils.makeDirectory(PathUtils.join(tmpDir.path, TYPE_SLUG)); +}); + +registerCleanupFunction(async () => { + await IOUtils.remove(tmpDir.path, { recursive: true }); +}); + +function getPath(filename) { + return PathUtils.join(tmpDir.path, TYPE_SLUG, filename); +} + +const ID = "native@tests.mozilla.org"; + +async function setupHosts(scripts) { + const pythonPath = await Subprocess.pathSearch(Services.env.get("PYTHON")); + + async function writeManifest(script, scriptPath, path) { + let body = `#!${pythonPath} -u\n${script.script}`; + + await IOUtils.writeUTF8(scriptPath, body); + await IOUtils.setPermissions(scriptPath, 0o755); + + let manifest = { + name: script.name, + description: script.description, + path, + type: "stdio", + allowed_extensions: [ID], + }; + + // Optionally, allow the test to change the manifest before writing. + script._hookModifyManifest?.(manifest); + + let manifestPath = getPath(`${script.name}.json`); + await IOUtils.writeJSON(manifestPath, manifest); + + return manifestPath; + } + + switch (AppConstants.platform) { + case "macosx": + case "linux": + let dirProvider = { + getFile(property) { + if (property == "XREUserNativeManifests") { + return tmpDir.clone(); + } else if (property == "XRESysNativeManifests") { + return tmpDir.clone(); + } + return null; + }, + }; + + Services.dirsvc.registerProvider(dirProvider); + registerCleanupFunction(() => { + Services.dirsvc.unregisterProvider(dirProvider); + }); + + for (let script of scripts) { + let path = getPath(`${script.name}.py`); + + await writeManifest(script, path, path); + } + break; + + case "win": + const REGKEY = String.raw`Software\Mozilla\NativeMessagingHosts`; + + let registry = new MockRegistry(); + registerCleanupFunction(() => { + registry.shutdown(); + }); + + for (let script of scripts) { + let { scriptExtension = "bat" } = script; + + // It's important that we use a space in this filename. See directory + // name comment above. + let batPath = getPath(`batch ${script.name}.${scriptExtension}`); + let scriptPath = getPath(`${script.name}.py`); + + let batBody = `@ECHO OFF\n${pythonPath} -u "${scriptPath}" %*\n`; + await IOUtils.writeUTF8(batPath, batBody); + + let manifestPath = await writeManifest(script, scriptPath, batPath); + + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGKEY}\\${script.name}`, + "", + manifestPath + ); + } + break; + + default: + ok( + false, + `Native messaging is not supported on ${AppConstants.platform}` + ); + } +} + +function getSubprocessCount() { + return SubprocessImpl.Process.getWorker() + .call("getProcesses", []) + .then(result => result.size); +} +function waitForSubprocessExit() { + return SubprocessImpl.Process.getWorker() + .call("waitForNoProcesses", []) + .then(() => { + // Return to the main event loop to give IO handlers enough time to consume + // their remaining buffered input. + return new Promise(resolve => setTimeout(resolve, 0)); + }); +} diff --git a/toolkit/components/extensions/test/xpcshell/head_remote.js b/toolkit/components/extensions/test/xpcshell/head_remote.js new file mode 100644 index 0000000000..f9c31144c9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_remote.js @@ -0,0 +1,7 @@ +"use strict"; + +Services.prefs.setBoolPref("extensions.webextensions.remote", true); +Services.prefs.setIntPref("dom.ipc.keepProcessesAlive.extension", 1); + +/* globals testEnv */ +testEnv.expectRemote = true; // tested in head_test.js diff --git a/toolkit/components/extensions/test/xpcshell/head_schemas.js b/toolkit/components/extensions/test/xpcshell/head_schemas.js new file mode 100644 index 0000000000..94af4a631a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_schemas.js @@ -0,0 +1,129 @@ +"use strict"; + +/* exported Schemas, LocalAPIImplementation, SchemaAPIInterface, getContextWrapper */ + +const { Schemas } = ChromeUtils.importESModule( + "resource://gre/modules/Schemas.sys.mjs" +); + +const { ExtensionCommon } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionCommon.sys.mjs" +); + +let { LocalAPIImplementation, SchemaAPIInterface } = ExtensionCommon; + +const contextCloneScope = this; + +class TallyingAPIImplementation extends SchemaAPIInterface { + constructor(context, namespace, name) { + super(); + this.namespace = namespace; + this.name = name; + this.context = context; + } + + callFunction(args) { + this.context.tally("call", this.namespace, this.name, args); + if (this.name === "sub_foo") { + return 13; + } + } + + callFunctionNoReturn(args) { + this.context.tally("call", this.namespace, this.name, args); + } + + getProperty() { + this.context.tally("get", this.namespace, this.name); + } + + setProperty(value) { + this.context.tally("set", this.namespace, this.name, value); + } + + addListener(listener, args) { + this.context.tally("addListener", this.namespace, this.name, [ + listener, + args, + ]); + } + + removeListener(listener) { + this.context.tally("removeListener", this.namespace, this.name, [listener]); + } + + hasListener(listener) { + this.context.tally("hasListener", this.namespace, this.name, [listener]); + } +} + +function getContextWrapper(manifestVersion = 2) { + return { + url: "moz-extension://b66e3509-cdb3-44f6-8eb8-c8b39b3a1d27/", + + cloneScope: contextCloneScope, + + manifestVersion, + + permissions: new Set(), + tallied: null, + talliedErrors: [], + + tally(kind, ns, name, args) { + this.tallied = [kind, ns, name, args]; + }, + + verify(...args) { + Assert.equal(JSON.stringify(this.tallied), JSON.stringify(args)); + this.tallied = null; + }, + + checkErrors(errors) { + let { talliedErrors } = this; + Assert.equal( + talliedErrors.length, + errors.length, + "Got expected number of errors" + ); + for (let [i, error] of errors.entries()) { + Assert.ok( + i in talliedErrors && String(talliedErrors[i]).includes(error), + `${JSON.stringify(error)} is a substring of error ${JSON.stringify( + talliedErrors[i] + )}` + ); + } + + talliedErrors.length = 0; + }, + + checkLoadURL(url) { + return !url.startsWith("chrome:"); + }, + + preprocessors: { + localize(value, context) { + return value.replace( + /__MSG_(.*?)__/g, + (m0, m1) => `${m1.toUpperCase()}` + ); + }, + }, + + logError(message) { + this.talliedErrors.push(message); + }, + + hasPermission(permission) { + return this.permissions.has(permission); + }, + + shouldInject(ns, name, allowedContexts) { + return name != "do-not-inject"; + }, + + getImplementation(namespace, name) { + return new TallyingAPIImplementation(this, namespace, name); + }, + }; +} diff --git a/toolkit/components/extensions/test/xpcshell/head_service_worker.js b/toolkit/components/extensions/test/xpcshell/head_service_worker.js new file mode 100644 index 0000000000..aa1cf5cb18 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_service_worker.js @@ -0,0 +1,158 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported TestWorkerWatcher */ + +ChromeUtils.defineESModuleGetters(this, { + ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs", +}); + +// Ensure that the profile-after-change message has been notified, +// so that ServiceWokerRegistrar is going to be initialized, +// otherwise tests using a background service worker will fail. +// in debug builds because of an assertion failure triggered +// by ServiceWorkerRegistrar.cpp (due to not being initialized +// automatically on startup as in a real Firefox instance). +Services.obs.notifyObservers( + null, + "profile-after-change", + "force-serviceworkerrestart-init" +); + +// A test utility class used in the test case to watch for a given extension +// service worker being spawned and terminated (using the same kind of Firefox DevTools +// internals that about:debugging is using to watch the workers activity). +// +// NOTE: this helper class does also depends from the two jsm files where the +// Parent and Child TestWorkerWatcher actor is defined: +// +// - data/TestWorkerWatcherParent.sys.mjs +// - data/TestWorkerWatcherChild.sys.mjs +class TestWorkerWatcher extends ExtensionCommon.EventEmitter { + JS_ACTOR_NAME = "TestWorkerWatcher"; + + constructor(dataRelPath = "./data") { + super(); + this.dataRelPath = dataRelPath; + this.extensionProcess = null; + this.extensionProcessActor = null; + this.registerProcessActor(); + this.getAndWatchExtensionProcess(); + // Observer child process creation and shutdown if the extension + // are meant to run in a child process. + Services.obs.addObserver(this, "ipc:content-created"); + Services.obs.addObserver(this, "ipc:content-shutdown"); + } + + async destroy() { + await this.stopWatchingWorkers(); + ChromeUtils.unregisterProcessActor(this.JS_ACTOR_NAME); + } + + get swm() { + return Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + } + + getRegistration(extension) { + return this.swm.getRegistrationByPrincipal( + extension.extension.principal, + extension.extension.principal.spec + ); + } + + watchExtensionServiceWorker(extension) { + // These events are emitted by TestWatchExtensionWorkersParent. + const promiseWorkerSpawned = this.waitForEvent("worker-spawned", extension); + const promiseWorkerTerminated = this.waitForEvent( + "worker-terminated", + extension + ); + + // Terminate the worker sooner by settng the idle_timeout to 0, + // then clear the pref as soon as the worker has been terminated. + const terminate = () => { + promiseWorkerTerminated.then(() => { + Services.prefs.clearUserPref("dom.serviceWorkers.idle_timeout"); + }); + Services.prefs.setIntPref("dom.serviceWorkers.idle_timeout", 0); + const swReg = this.getRegistration(extension); + // If the active worker is already active, we have to make sure the new value + // set on the idle_timeout pref is picked up by ServiceWorkerPrivate::ResetIdleTimeout. + swReg.activeWorker?.attachDebugger(); + swReg.activeWorker?.detachDebugger(); + return promiseWorkerTerminated; + }; + + return { + promiseWorkerSpawned, + promiseWorkerTerminated, + terminate, + }; + } + + // Methods only used internally. + + waitForEvent(event, extension) { + return new Promise(resolve => { + const listener = (_eventName, data) => { + if (!data.workerUrl.startsWith(extension.extension?.principal.spec)) { + return; + } + this.off(event, listener); + resolve(data); + }; + + this.on(event, listener); + }); + } + + registerProcessActor() { + const { JS_ACTOR_NAME } = this; + ChromeUtils.registerProcessActor(JS_ACTOR_NAME, { + parent: { + esModuleURI: `resource://testing-common/${JS_ACTOR_NAME}Parent.sys.mjs`, + }, + child: { + esModuleURI: `resource://testing-common/${JS_ACTOR_NAME}Child.sys.mjs`, + }, + }); + } + + startWatchingWorkers() { + if (!this.extensionProcessActor) { + return; + } + this.extensionProcessActor.eventEmitter = this; + return this.extensionProcessActor.sendQuery("Test:StartWatchingWorkers"); + } + + stopWatchingWorkers() { + if (!this.extensionProcessActor) { + return; + } + this.extensionProcessActor.eventEmitter = null; + return this.extensionProcessActor.sendQuery("Test:StopWatchingWorkers"); + } + + getAndWatchExtensionProcess() { + const extensionProcess = ChromeUtils.getAllDOMProcesses().find(p => { + return p.remoteType === "extension"; + }); + if (extensionProcess !== this.extensionProcess) { + this.extensionProcess = extensionProcess; + this.extensionProcessActor = extensionProcess + ? extensionProcess.getActor(this.JS_ACTOR_NAME) + : null; + this.startWatchingWorkers(); + } + } + + observe(subject, topic, childIDString) { + // Keep the watched process and related test child process actor updated + // when a process is created or destroyed. + this.getAndWatchExtensionProcess(); + } +} diff --git a/toolkit/components/extensions/test/xpcshell/head_storage.js b/toolkit/components/extensions/test/xpcshell/head_storage.js new file mode 100644 index 0000000000..139c84bf8d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_storage.js @@ -0,0 +1,1400 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* import-globals-from head.js */ + +const STORAGE_SYNC_PREF = "webextensions.storage.sync.enabled"; + +// Test implementations and utility functions that are used against multiple +// storage areas (eg, a test which is run against browser.storage.local and +// browser.storage.sync, or a test against browser.storage.sync but needs to +// be run against both the kinto and rust implementations.) + +/** + * Utility function to ensure that all supported APIs for getting are + * tested. + * + * @param {string} areaName + * either "local" or "sync" according to what we want to test + * @param {string} prop + * "key" to look up using the storage API + * @param {object} value + * "value" to compare against + */ +async function checkGetImpl(areaName, prop, value) { + let storage = browser.storage[areaName]; + + let data = await storage.get(); + browser.test.assertEq( + value, + data[prop], + `unspecified getter worked for ${prop} in ${areaName}` + ); + + data = await storage.get(null); + browser.test.assertEq( + value, + data[prop], + `null getter worked for ${prop} in ${areaName}` + ); + + data = await storage.get(prop); + browser.test.assertEq( + value, + data[prop], + `string getter worked for ${prop} in ${areaName}` + ); + browser.test.assertEq( + Object.keys(data).length, + 1, + `string getter should return an object with a single property` + ); + + data = await storage.get([prop]); + browser.test.assertEq( + value, + data[prop], + `array getter worked for ${prop} in ${areaName}` + ); + browser.test.assertEq( + Object.keys(data).length, + 1, + `array getter with a single key should return an object with a single property` + ); + + data = await storage.get({ [prop]: undefined }); + browser.test.assertEq( + value, + data[prop], + `object getter worked for ${prop} in ${areaName}` + ); + browser.test.assertEq( + Object.keys(data).length, + 1, + `object getter with a single key should return an object with a single property` + ); +} + +function test_config_flag_needed() { + async function testFn() { + function background() { + let promises = []; + let apiTests = [ + { method: "get", args: ["foo"] }, + { method: "set", args: [{ foo: "bar" }] }, + { method: "remove", args: ["foo"] }, + { method: "clear", args: [] }, + ]; + apiTests.forEach(testDef => { + promises.push( + browser.test.assertRejects( + browser.storage.sync[testDef.method](...testDef.args), + "Please set webextensions.storage.sync.enabled to true in about:config", + `storage.sync.${testDef.method} is behind a flag` + ) + ); + }); + + Promise.all(promises).then(() => browser.test.notifyPass("flag needed")); + } + + ok( + !Services.prefs.getBoolPref(STORAGE_SYNC_PREF, false), + "The `${STORAGE_SYNC_PREF}` should be set to false" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + }, + background, + }); + + await extension.startup(); + await extension.awaitFinish("flag needed"); + await extension.unload(); + } + + return runWithPrefs([[STORAGE_SYNC_PREF, false]], testFn); +} + +async function test_storage_after_reload(areaName, { expectPersistency }) { + // Just some random extension ID that we can re-use + const extensionId = "my-extension-id@1"; + + function loadExtension() { + async function background(areaName) { + browser.test.sendMessage( + "initialItems", + await browser.storage[areaName].get(null) + ); + await browser.storage[areaName].set({ a: "b" }); + browser.test.notifyPass("set-works"); + } + + return ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: extensionId } }, + permissions: ["storage"], + }, + background: `(${background})("${areaName}")`, + }); + } + + let extension1 = loadExtension(); + await extension1.startup(); + + Assert.deepEqual( + await extension1.awaitMessage("initialItems"), + {}, + "No stored items at first" + ); + + await extension1.awaitFinish("set-works"); + await extension1.unload(); + + let extension2 = loadExtension(); + await extension2.startup(); + + Assert.deepEqual( + await extension2.awaitMessage("initialItems"), + expectPersistency ? { a: "b" } : {}, + `Expect ${areaName} stored items ${ + expectPersistency ? "to" : "not" + } be available after restart` + ); + + await extension2.awaitFinish("set-works"); + await extension2.unload(); +} + +function test_sync_reloading_extensions_works() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], async () => { + ok( + Services.prefs.getBoolPref(STORAGE_SYNC_PREF, false), + "The `${STORAGE_SYNC_PREF}` should be set to true" + ); + + await test_storage_after_reload("sync", { expectPersistency: true }); + }); +} + +async function test_background_page_storage(testAreaName) { + async function backgroundScript(checkGet) { + let globalChanges, gResolve; + function clearGlobalChanges() { + globalChanges = new Promise(resolve => { + gResolve = resolve; + }); + } + clearGlobalChanges(); + let expectedAreaName; + + browser.storage.onChanged.addListener((changes, areaName) => { + browser.test.assertEq( + expectedAreaName, + areaName, + "Expected area name received by listener" + ); + gResolve(changes); + }); + + async function checkChanges(areaName, changes, message) { + function checkSub(obj1, obj2) { + for (let prop in obj1) { + browser.test.assertTrue( + obj1[prop] !== undefined, + `checkChanges ${areaName} ${prop} is missing (${message})` + ); + browser.test.assertTrue( + obj2[prop] !== undefined, + `checkChanges ${areaName} ${prop} is missing (${message})` + ); + browser.test.assertEq( + obj1[prop].oldValue, + obj2[prop].oldValue, + `checkChanges ${areaName} ${prop} old (${message})` + ); + browser.test.assertEq( + obj1[prop].newValue, + obj2[prop].newValue, + `checkChanges ${areaName} ${prop} new (${message})` + ); + } + } + + const recentChanges = await globalChanges; + checkSub(changes, recentChanges); + checkSub(recentChanges, changes); + clearGlobalChanges(); + } + + // Regression test for https://bugzilla.mozilla.org/show_bug.cgi?id=1645598 + async function testNonExistingKeys(storage, storageAreaDesc) { + let data = await storage.get({ test6: 6 }); + browser.test.assertEq( + `{"test6":6}`, + JSON.stringify(data), + `Use default value when not stored for ${storageAreaDesc}` + ); + + data = await storage.get({ test6: null }); + browser.test.assertEq( + `{"test6":null}`, + JSON.stringify(data), + `Use default value, even if null for ${storageAreaDesc}` + ); + + data = await storage.get("test6"); + browser.test.assertEq( + `{}`, + JSON.stringify(data), + `Empty result if key is not found for ${storageAreaDesc}` + ); + + data = await storage.get(["test6", "test7"]); + browser.test.assertEq( + `{}`, + JSON.stringify(data), + `Empty result if list of keys is not found for ${storageAreaDesc}` + ); + } + + async function testFalseyValues(areaName) { + let storage = browser.storage[areaName]; + const dataInitial = { + "test-falsey-value-bool": false, + "test-falsey-value-string": "", + "test-falsey-value-number": 0, + }; + const dataUpdate = { + "test-falsey-value-bool": true, + "test-falsey-value-string": "non-empty-string", + "test-falsey-value-number": 10, + }; + + // Compute the expected changes. + const onSetInitial = { + "test-falsey-value-bool": { newValue: false }, + "test-falsey-value-string": { newValue: "" }, + "test-falsey-value-number": { newValue: 0 }, + }; + const onRemovedFalsey = { + "test-falsey-value-bool": { oldValue: false }, + "test-falsey-value-string": { oldValue: "" }, + "test-falsey-value-number": { oldValue: 0 }, + }; + const onUpdatedFalsey = { + "test-falsey-value-bool": { newValue: true, oldValue: false }, + "test-falsey-value-string": { + newValue: "non-empty-string", + oldValue: "", + }, + "test-falsey-value-number": { newValue: 10, oldValue: 0 }, + }; + const keys = Object.keys(dataInitial); + + // Test on removing falsey values. + await storage.set(dataInitial); + await checkChanges(areaName, onSetInitial, "set falsey values"); + await storage.remove(keys); + await checkChanges(areaName, onRemovedFalsey, "remove falsey value"); + + // Test on updating falsey values. + await storage.set(dataInitial); + await checkChanges(areaName, onSetInitial, "set falsey values"); + await storage.set(dataUpdate); + await checkChanges(areaName, onUpdatedFalsey, "set non-falsey values"); + + // Clear the storage state. + await testNonExistingKeys(storage, `${areaName} before clearing`); + await storage.clear(); + await testNonExistingKeys(storage, `${areaName} after clearing`); + await globalChanges; + clearGlobalChanges(); + } + + function CustomObj() { + this.testKey1 = "testValue1"; + } + + CustomObj.prototype.toString = function () { + return '{"testKey2":"testValue2"}'; + }; + + CustomObj.prototype.toJSON = function customObjToJSON() { + return { testKey1: "testValue3" }; + }; + + /* eslint-disable dot-notation */ + async function runTests(areaName) { + expectedAreaName = areaName; + let storage = browser.storage[areaName]; + // Set some data and then test getters. + try { + await storage.set({ "test-prop1": "value1", "test-prop2": "value2" }); + await checkChanges( + areaName, + { + "test-prop1": { newValue: "value1" }, + "test-prop2": { newValue: "value2" }, + }, + "set (a)" + ); + + await checkGet(areaName, "test-prop1", "value1"); + await checkGet(areaName, "test-prop2", "value2"); + + let data = await storage.get({ + "test-prop1": undefined, + "test-prop2": undefined, + other: "default", + }); + browser.test.assertEq( + "value1", + data["test-prop1"], + "prop1 correct (a)" + ); + browser.test.assertEq( + "value2", + data["test-prop2"], + "prop2 correct (a)" + ); + browser.test.assertEq("default", data["other"], "other correct"); + + data = await storage.get(["test-prop1", "test-prop2", "other"]); + browser.test.assertEq( + "value1", + data["test-prop1"], + "prop1 correct (b)" + ); + browser.test.assertEq( + "value2", + data["test-prop2"], + "prop2 correct (b)" + ); + browser.test.assertFalse("other" in data, "other correct"); + + // Remove data in various ways. + await storage.remove("test-prop1"); + await checkChanges( + areaName, + { "test-prop1": { oldValue: "value1" } }, + "remove string" + ); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse( + "test-prop1" in data, + "prop1 absent (remove string)" + ); + browser.test.assertTrue( + "test-prop2" in data, + "prop2 present (remove string)" + ); + + await storage.set({ "test-prop1": "value1" }); + await checkChanges( + areaName, + { "test-prop1": { newValue: "value1" } }, + "set (c)" + ); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertEq( + data["test-prop1"], + "value1", + "prop1 correct (c)" + ); + browser.test.assertEq( + data["test-prop2"], + "value2", + "prop2 correct (c)" + ); + + await storage.remove(["test-prop1", "test-prop2"]); + await checkChanges( + areaName, + { + "test-prop1": { oldValue: "value1" }, + "test-prop2": { oldValue: "value2" }, + }, + "remove array" + ); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse( + "test-prop1" in data, + "prop1 absent (remove array)" + ); + browser.test.assertFalse( + "test-prop2" in data, + "prop2 absent (remove array)" + ); + + await testFalseyValues(areaName); + + // test storage.clear + await storage.set({ "test-prop1": "value1", "test-prop2": "value2" }); + // Make sure that set() handler happened before we clear the + // promise again. + await globalChanges; + + clearGlobalChanges(); + await storage.clear(); + + await checkChanges( + areaName, + { + "test-prop1": { oldValue: "value1" }, + "test-prop2": { oldValue: "value2" }, + }, + "clear" + ); + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse("test-prop1" in data, "prop1 absent (clear)"); + browser.test.assertFalse("test-prop2" in data, "prop2 absent (clear)"); + + // Make sure we can store complex JSON data. + // known previous values + await storage.set({ "test-prop1": "value1", "test-prop2": "value2" }); + + // Make sure the set() handler landed. + await globalChanges; + + let date = new Date(0); + + clearGlobalChanges(); + await storage.set({ + "test-prop1": { + str: "hello", + bool: true, + null: null, + undef: undefined, + obj: {}, + nestedObj: { + testKey: {}, + }, + intKeyObj: { + 4: "testValue1", + 3: "testValue2", + 99: "testValue3", + }, + floatKeyObj: { + 1.4: "testValue1", + 5.5: "testValue2", + }, + customObj: new CustomObj(), + arr: [1, 2], + nestedArr: [1, [2, 3]], + date, + regexp: /regexp/, + }, + }); + + await browser.test.assertRejects( + storage.set({ + window, + }), + /DataCloneError|cyclic object value/ + ); + + await browser.test.assertRejects( + storage.set({ "test-prop2": function func() {} }), + /DataCloneError/ + ); + + const recentChanges = await globalChanges; + + browser.test.assertEq( + "value1", + recentChanges["test-prop1"].oldValue, + "oldValue correct" + ); + browser.test.assertEq( + "object", + typeof recentChanges["test-prop1"].newValue, + "newValue is obj" + ); + clearGlobalChanges(); + + data = await storage.get({ + "test-prop1": undefined, + "test-prop2": undefined, + }); + let obj = data["test-prop1"]; + + browser.test.assertEq( + "object", + typeof obj.customObj, + "custom object part correct" + ); + browser.test.assertEq( + 1, + Object.keys(obj.customObj).length, + "customObj keys correct" + ); + + if (areaName === "local" || areaName === "session") { + browser.test.assertEq( + String(date), + String(obj.date), + "date part correct" + ); + browser.test.assertEq( + "/regexp/", + obj.regexp.toString(), + "regexp part correct" + ); + // storage.local and .session don't use toJSON(). + browser.test.assertEq( + "testValue1", + obj.customObj.testKey1, + "customObj keys correct" + ); + } else { + browser.test.assertEq( + "1970-01-01T00:00:00.000Z", + String(obj.date), + "date part correct" + ); + + browser.test.assertEq( + "object", + typeof obj.regexp, + "regexp part is an object" + ); + browser.test.assertEq( + 0, + Object.keys(obj.regexp).length, + "regexp part is an empty object" + ); + // storage.sync does call toJSON + browser.test.assertEq( + "testValue3", + obj.customObj.testKey1, + "customObj keys correct" + ); + } + + browser.test.assertEq("hello", obj.str, "string part correct"); + browser.test.assertEq(true, obj.bool, "bool part correct"); + browser.test.assertEq(null, obj.null, "null part correct"); + browser.test.assertEq(undefined, obj.undef, "undefined part correct"); + browser.test.assertEq(undefined, obj.window, "window part correct"); + browser.test.assertEq("object", typeof obj.obj, "object part correct"); + browser.test.assertEq( + "object", + typeof obj.nestedObj, + "nested object part correct" + ); + browser.test.assertEq( + "object", + typeof obj.nestedObj.testKey, + "nestedObj.testKey part correct" + ); + browser.test.assertEq( + "object", + typeof obj.intKeyObj, + "int key object part correct" + ); + browser.test.assertEq( + "testValue1", + obj.intKeyObj[4], + "intKeyObj[4] part correct" + ); + browser.test.assertEq( + "testValue2", + obj.intKeyObj[3], + "intKeyObj[3] part correct" + ); + browser.test.assertEq( + "testValue3", + obj.intKeyObj[99], + "intKeyObj[99] part correct" + ); + browser.test.assertEq( + "object", + typeof obj.floatKeyObj, + "float key object part correct" + ); + browser.test.assertEq( + "testValue1", + obj.floatKeyObj[1.4], + "floatKeyObj[1.4] part correct" + ); + browser.test.assertEq( + "testValue2", + obj.floatKeyObj[5.5], + "floatKeyObj[5.5] part correct" + ); + + browser.test.assertTrue(Array.isArray(obj.arr), "array part present"); + browser.test.assertEq(1, obj.arr[0], "arr[0] part correct"); + browser.test.assertEq(2, obj.arr[1], "arr[1] part correct"); + browser.test.assertEq(2, obj.arr.length, "arr.length part correct"); + browser.test.assertTrue( + Array.isArray(obj.nestedArr), + "nested array part present" + ); + browser.test.assertEq( + 2, + obj.nestedArr.length, + "nestedArr.length part correct" + ); + browser.test.assertEq(1, obj.nestedArr[0], "nestedArr[0] part correct"); + browser.test.assertTrue( + Array.isArray(obj.nestedArr[1]), + "nestedArr[1] part present" + ); + browser.test.assertEq( + 2, + obj.nestedArr[1].length, + "nestedArr[1].length part correct" + ); + browser.test.assertEq( + 2, + obj.nestedArr[1][0], + "nestedArr[1][0] part correct" + ); + browser.test.assertEq( + 3, + obj.nestedArr[1][1], + "nestedArr[1][1] part correct" + ); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("storage"); + } + } + + browser.test.onMessage.addListener(msg => { + let promise; + if (msg === "test-local") { + promise = runTests("local"); + } else if (msg === "test-sync") { + promise = runTests("sync"); + } else if (msg === "test-session") { + promise = runTests("session"); + } + promise.then(() => browser.test.sendMessage("test-finished")); + }); + + browser.test.sendMessage("ready"); + } + + let extensionData = { + background: `(${backgroundScript})(${checkGetImpl})`, + manifest: { + permissions: ["storage"], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage(`test-${testAreaName}`); + await extension.awaitMessage("test-finished"); + + await extension.unload(); +} + +function test_storage_sync_requires_real_id() { + async function testFn() { + async function background() { + const EXCEPTION_MESSAGE = + "The storage API will not work with a temporary addon ID. " + + "Please add an explicit addon ID to your manifest. " + + "For more information see https://mzl.la/3lPk1aE."; + + await browser.test.assertRejects( + browser.storage.sync.set({ foo: "bar" }), + EXCEPTION_MESSAGE + ); + + browser.test.notifyPass("exception correct"); + } + + let extensionData = { + background, + manifest: { + permissions: ["storage"], + }, + useAddonManager: "temporary", + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("exception correct"); + + await extension.unload(); + } + + return runWithPrefs([[STORAGE_SYNC_PREF, true]], testFn); +} + +// Test for storage areas which don't support getBytesInUse() nor QUOTA +// constants. +async function check_storage_area_no_bytes_in_use(area) { + let impl = browser.storage[area]; + + browser.test.assertEq( + typeof impl.getBytesInUse, + "undefined", + "getBytesInUse API method should not be available" + ); + browser.test.sendMessage("test-complete"); +} + +async function test_background_storage_area_no_bytes_in_use(area) { + const EXT_ID = "test-gbiu@mozilla.org"; + + const extensionDef = { + manifest: { + permissions: ["storage"], + browser_specific_settings: { gecko: { id: EXT_ID } }, + }, + background: `(${check_storage_area_no_bytes_in_use})("${area}")`, + }; + + const extension = ExtensionTestUtils.loadExtension(extensionDef); + + await extension.startup(); + await extension.awaitMessage("test-complete"); + await extension.unload(); +} + +async function test_contentscript_storage_area_no_bytes_in_use(area) { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + function contentScript(checkImpl) { + browser.test.onMessage.addListener(msg => { + if (msg === "test-local") { + checkImpl("local"); + } else if (msg === "test-sync") { + checkImpl("sync"); + } else if (msg === "test-session") { + checkImpl("session"); + } else { + browser.test.fail(`Unexpected test message received: ${msg}`); + browser.test.sendMessage("test-complete"); + } + }); + browser.test.sendMessage("ready"); + } + + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + + permissions: ["storage"], + }, + + files: { + "content_script.js": `(${contentScript})(${check_storage_area_no_bytes_in_use})`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage(`test-${area}`); + await extension.awaitMessage("test-complete"); + + await extension.unload(); + await contentPage.close(); +} + +// Test for storage areas which do support getBytesInUse() (but which may or may +// not support enforcement of the quota) +async function check_storage_area_with_bytes_in_use(area, expectQuota) { + let impl = browser.storage[area]; + + // QUOTA_* constants aren't currently exposed - see bug 1396810. + // However, the quotas are still enforced, so test them here. + // (Note that an implication of this is that we can't test area other than + // 'sync', because its limits are different - so for completeness...) + browser.test.assertEq( + area, + "sync", + "Running test on storage.sync API as expected" + ); + const QUOTA_BYTES_PER_ITEM = 8192; + const MAX_ITEMS = 512; + + // bytes is counted as "length of key as a string, length of value as + // JSON" - ie, quotes not counted in the key, but are in the value. + let value = "x".repeat(QUOTA_BYTES_PER_ITEM - 3); + + await impl.set({ x: value }); // Shouldn't reject on either kinto or rust-based storage.sync. + browser.test.assertEq(await impl.getBytesInUse(null), QUOTA_BYTES_PER_ITEM); + // kinto does implement getBytesInUse() but doesn't enforce a quota. + if (expectQuota) { + await browser.test.assertRejects( + impl.set({ x: value + "x" }), + /QuotaExceededError/, + "Got a rejection with the expected error message" + ); + // MAX_ITEMS + await impl.clear(); + let ob = {}; + for (let i = 0; i < MAX_ITEMS; i++) { + ob[`key-${i}`] = "x"; + } + await impl.set(ob); // should work. + await browser.test.assertRejects( + impl.set({ straw: "camel's back" }), // exceeds MAX_ITEMS + /QuotaExceededError/, + "Got a rejection with the expected error message" + ); + // QUOTA_BYTES is being already tested for the underlying StorageSyncService + // so we don't duplicate those tests here. + } else { + // Exceeding quota should work on the previous kinto-based storage.sync implementation + await impl.set({ x: value + "x" }); // exceeds quota but should work. + browser.test.assertEq( + await impl.getBytesInUse(null), + QUOTA_BYTES_PER_ITEM + 1, + "Got the expected result from getBytesInUse" + ); + } + browser.test.sendMessage("test-complete"); +} + +async function test_background_storage_area_with_bytes_in_use( + area, + expectQuota +) { + const EXT_ID = "test-gbiu@mozilla.org"; + + const extensionDef = { + manifest: { + permissions: ["storage"], + browser_specific_settings: { gecko: { id: EXT_ID } }, + }, + background: `(${check_storage_area_with_bytes_in_use})("${area}", ${expectQuota})`, + }; + + const extension = ExtensionTestUtils.loadExtension(extensionDef); + + await extension.startup(); + await extension.awaitMessage("test-complete"); + await extension.unload(); +} + +async function test_contentscript_storage_area_with_bytes_in_use( + area, + expectQuota +) { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + function contentScript(checkImpl) { + browser.test.onMessage.addListener(([area, expectQuota]) => { + if ( + !["local", "sync"].includes(area) || + typeof expectQuota !== "boolean" + ) { + browser.test.fail(`Unexpected test message: [${area}, ${expectQuota}]`); + // Let the test to fail immediately instead of wait for a timeout failure. + browser.test.sendMessage("test-complete"); + return; + } + checkImpl(area, expectQuota); + }); + browser.test.sendMessage("ready"); + } + + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + + permissions: ["storage"], + }, + + files: { + "content_script.js": `(${contentScript})(${check_storage_area_with_bytes_in_use})`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage([area, expectQuota]); + await extension.awaitMessage("test-complete"); + + await extension.unload(); + await contentPage.close(); +} + +// A couple of common tests for checking content scripts. +async function testStorageContentScript(checkGet) { + let globalChanges, gResolve; + function clearGlobalChanges() { + globalChanges = new Promise(resolve => { + gResolve = resolve; + }); + } + clearGlobalChanges(); + let expectedAreaName; + + browser.storage.onChanged.addListener((changes, areaName) => { + browser.test.assertEq( + expectedAreaName, + areaName, + "Expected area name received by listener" + ); + gResolve(changes); + }); + + async function checkChanges(areaName, changes, message) { + function checkSub(obj1, obj2) { + for (let prop in obj1) { + browser.test.assertTrue( + obj1[prop] !== undefined, + `checkChanges ${areaName} ${prop} is missing (${message})` + ); + browser.test.assertTrue( + obj2[prop] !== undefined, + `checkChanges ${areaName} ${prop} is missing (${message})` + ); + browser.test.assertEq( + obj1[prop].oldValue, + obj2[prop].oldValue, + `checkChanges ${areaName} ${prop} old (${message})` + ); + browser.test.assertEq( + obj1[prop].newValue, + obj2[prop].newValue, + `checkChanges ${areaName} ${prop} new (${message})` + ); + } + } + + const recentChanges = await globalChanges; + checkSub(changes, recentChanges); + checkSub(recentChanges, changes); + clearGlobalChanges(); + } + + /* eslint-disable dot-notation */ + async function runTests(areaName) { + expectedAreaName = areaName; + let storage = browser.storage[areaName]; + // Set some data and then test getters. + try { + await storage.set({ "test-prop1": "value1", "test-prop2": "value2" }); + await checkChanges( + areaName, + { + "test-prop1": { newValue: "value1" }, + "test-prop2": { newValue: "value2" }, + }, + "set (a)" + ); + + await checkGet(areaName, "test-prop1", "value1"); + await checkGet(areaName, "test-prop2", "value2"); + + let data = await storage.get({ + "test-prop1": undefined, + "test-prop2": undefined, + other: "default", + }); + browser.test.assertEq("value1", data["test-prop1"], "prop1 correct (a)"); + browser.test.assertEq("value2", data["test-prop2"], "prop2 correct (a)"); + browser.test.assertEq("default", data["other"], "other correct"); + + data = await storage.get(["test-prop1", "test-prop2", "other"]); + browser.test.assertEq("value1", data["test-prop1"], "prop1 correct (b)"); + browser.test.assertEq("value2", data["test-prop2"], "prop2 correct (b)"); + browser.test.assertFalse("other" in data, "other correct"); + + // Remove data in various ways. + await storage.remove("test-prop1"); + await checkChanges( + areaName, + { "test-prop1": { oldValue: "value1" } }, + "remove string" + ); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse( + "test-prop1" in data, + "prop1 absent (remove string)" + ); + browser.test.assertTrue( + "test-prop2" in data, + "prop2 present (remove string)" + ); + + await storage.set({ "test-prop1": "value1" }); + await checkChanges( + areaName, + { "test-prop1": { newValue: "value1" } }, + "set (c)" + ); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertEq(data["test-prop1"], "value1", "prop1 correct (c)"); + browser.test.assertEq(data["test-prop2"], "value2", "prop2 correct (c)"); + + await storage.remove(["test-prop1", "test-prop2"]); + await checkChanges( + areaName, + { + "test-prop1": { oldValue: "value1" }, + "test-prop2": { oldValue: "value2" }, + }, + "remove array" + ); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse( + "test-prop1" in data, + "prop1 absent (remove array)" + ); + browser.test.assertFalse( + "test-prop2" in data, + "prop2 absent (remove array)" + ); + + // test storage.clear + await storage.set({ "test-prop1": "value1", "test-prop2": "value2" }); + // Make sure that set() handler happened before we clear the + // promise again. + await globalChanges; + + clearGlobalChanges(); + await storage.clear(); + + await checkChanges( + areaName, + { + "test-prop1": { oldValue: "value1" }, + "test-prop2": { oldValue: "value2" }, + }, + "clear" + ); + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse("test-prop1" in data, "prop1 absent (clear)"); + browser.test.assertFalse("test-prop2" in data, "prop2 absent (clear)"); + + // Make sure we can store complex JSON data. + // known previous values + await storage.set({ "test-prop1": "value1", "test-prop2": "value2" }); + + // Make sure the set() handler landed. + await globalChanges; + + let date = new Date(0); + + clearGlobalChanges(); + await storage.set({ + "test-prop1": { + str: "hello", + bool: true, + null: null, + undef: undefined, + obj: {}, + arr: [1, 2], + date: new Date(0), + regexp: /regexp/, + }, + }); + + await browser.test.assertRejects( + storage.set({ + window, + }), + /DataCloneError|cyclic object value/ + ); + + await browser.test.assertRejects( + storage.set({ "test-prop2": function func() {} }), + /DataCloneError/ + ); + + const recentChanges = await globalChanges; + + browser.test.assertEq( + "value1", + recentChanges["test-prop1"].oldValue, + "oldValue correct" + ); + browser.test.assertEq( + "object", + typeof recentChanges["test-prop1"].newValue, + "newValue is obj" + ); + clearGlobalChanges(); + + data = await storage.get({ + "test-prop1": undefined, + "test-prop2": undefined, + }); + let obj = data["test-prop1"]; + + if (areaName === "local") { + browser.test.assertEq( + String(date), + String(obj.date), + "date part correct" + ); + browser.test.assertEq( + "/regexp/", + obj.regexp.toString(), + "regexp part correct" + ); + } else { + browser.test.assertEq( + "1970-01-01T00:00:00.000Z", + String(obj.date), + "date part correct" + ); + + browser.test.assertEq( + "object", + typeof obj.regexp, + "regexp part is an object" + ); + browser.test.assertEq( + 0, + Object.keys(obj.regexp).length, + "regexp part is an empty object" + ); + } + + browser.test.assertEq("hello", obj.str, "string part correct"); + browser.test.assertEq(true, obj.bool, "bool part correct"); + browser.test.assertEq(null, obj.null, "null part correct"); + browser.test.assertEq(undefined, obj.undef, "undefined part correct"); + browser.test.assertEq(undefined, obj.window, "window part correct"); + browser.test.assertEq("object", typeof obj.obj, "object part correct"); + browser.test.assertTrue(Array.isArray(obj.arr), "array part present"); + browser.test.assertEq(1, obj.arr[0], "arr[0] part correct"); + browser.test.assertEq(2, obj.arr[1], "arr[1] part correct"); + browser.test.assertEq(2, obj.arr.length, "arr.length part correct"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("storage"); + } + } + + browser.test.onMessage.addListener(msg => { + let promise; + if (msg === "test-local") { + promise = runTests("local"); + } else if (msg === "test-sync") { + promise = runTests("sync"); + } else if (msg === "test-session") { + promise = runTests("session"); + } + promise.then(() => browser.test.sendMessage("test-finished")); + }); + + browser.test.sendMessage("ready"); +} + +async function test_contentscript_storage(storageType) { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + + permissions: ["storage"], + }, + + files: { + "content_script.js": `(${testStorageContentScript})(${checkGetImpl})`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage(`test-${storageType}`); + await extension.awaitMessage("test-finished"); + + await extension.unload(); + await contentPage.close(); +} + +async function test_storage_empty_events(areaName) { + async function background(areaName) { + let eventCount = 0; + + browser.storage[areaName].onChanged.addListener(changes => { + browser.test.sendMessage("onChanged", [++eventCount, changes]); + }); + + browser.test.onMessage.addListener(async (method, arg) => { + let result = await browser.storage[areaName][method](arg); + browser.test.sendMessage("result", result); + }); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { permissions: ["storage"] }, + background: `(${background})("${areaName}")`, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + async function callStorageMethod(method, arg) { + info(`call storage.${areaName}.${method}(${JSON.stringify(arg) ?? ""})`); + extension.sendMessage(method, arg); + await extension.awaitMessage("result"); + } + + async function expectEvent(expectCount, expectChanges) { + equal( + JSON.stringify([expectCount, expectChanges]), + JSON.stringify(await extension.awaitMessage("onChanged")), + "Correct onChanged events count and data in the last changes notified." + ); + } + + await callStorageMethod("set", { alpha: 1 }); + await expectEvent(1, { alpha: { newValue: 1 } }); + + await callStorageMethod("set", {}); + // Setting nothing doesn't trigger onChanged event. + + await callStorageMethod("set", { beta: 12 }); + await expectEvent(2, { beta: { newValue: 12 } }); + + await callStorageMethod("remove", "alpha"); + await expectEvent(3, { alpha: { oldValue: 1 } }); + + await callStorageMethod("remove", "alpha"); + // Trying to remove alpha again doesn't trigger onChanged. + + await callStorageMethod("clear"); + await expectEvent(4, { beta: { oldValue: 12 } }); + + await callStorageMethod("clear"); + // Clear again wothout onChanged. Test will fail on unexpected event/message. + + await extension.unload(); +} + +async function test_storage_change_event_page(areaName) { + async function testOnChanged(targetIsStorageArea) { + function backgroundTestStorageTopNamespace(areaName) { + browser.storage.onChanged.addListener((changes, area) => { + browser.test.assertEq(area, areaName, "Expected areaName"); + browser.test.assertEq( + JSON.stringify(changes), + `{"storageKey":{"newValue":"newStorageValue"}}`, + "Expected changes" + ); + browser.test.sendMessage("onChanged_was_fired"); + }); + } + function backgroundTestStorageAreaNamespace(areaName) { + browser.storage[areaName].onChanged.addListener((changes, ...args) => { + browser.test.assertEq(args.length, 0, "no more args after changes"); + browser.test.assertEq( + JSON.stringify(changes), + `{"storageKey":{"newValue":"newStorageValue"}}`, + `Expected changes via ${areaName}.onChanged event` + ); + browser.test.sendMessage("onChanged_was_fired"); + }); + } + let background, onChangedName; + if (targetIsStorageArea) { + // Test storage.local.onChanged / storage.sync.onChanged. + background = backgroundTestStorageAreaNamespace; + onChangedName = `${areaName}.onChanged`; + } else { + background = backgroundTestStorageTopNamespace; + onChangedName = "onChanged"; + } + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + background: { persistent: false }, + }, + background: `(${background})("${areaName}")`, + files: { + "trigger-change.html": ` + <!DOCTYPE html><meta charset="utf-8"> + <script src="trigger-change.js"></script> + `, + "trigger-change.js": async () => { + let areaName = location.search.slice(1); + await browser.storage[areaName].set({ + storageKey: "newStorageValue", + }); + browser.test.sendMessage("tried_to_trigger_change"); + }, + }, + }); + await extension.startup(); + assertPersistentListeners(extension, "storage", onChangedName, { + primed: false, + }); + + await extension.terminateBackground(); + assertPersistentListeners(extension, "storage", onChangedName, { + primed: true, + }); + + // Now trigger the event + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/trigger-change.html?${areaName}` + ); + await extension.awaitMessage("tried_to_trigger_change"); + await contentPage.close(); + await extension.awaitMessage("onChanged_was_fired"); + + assertPersistentListeners(extension, "storage", onChangedName, { + primed: false, + }); + await extension.unload(); + } + + async function testFn() { + // Test browser.storage.onChanged.addListener + await testOnChanged(/* targetIsStorageArea */ false); + // Test browser.storage.local.onChanged.addListener + // and browser.storage.sync.onChanged.addListener, depending on areaName. + await testOnChanged(/* targetIsStorageArea */ true); + } + + return runWithPrefs([["extensions.eventPages.enabled", true]], testFn); +} diff --git a/toolkit/components/extensions/test/xpcshell/head_sync.js b/toolkit/components/extensions/test/xpcshell/head_sync.js new file mode 100644 index 0000000000..7c88e23a86 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_sync.js @@ -0,0 +1,66 @@ +/* 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"; + +/* exported withSyncContext */ + +const { ExtensionCommon } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionCommon.sys.mjs" +); + +class KintoExtContext extends ExtensionCommon.BaseContext { + constructor(principal) { + let fakeExtension = { id: "test@web.extension", manifestVersion: 2 }; + super("addon_parent", fakeExtension); + Object.defineProperty(this, "principal", { + value: principal, + configurable: true, + }); + this.sandbox = Cu.Sandbox(principal, { wantXrays: false }); + } + + get cloneScope() { + return this.sandbox; + } +} + +/** + * Call the given function with a newly-constructed context. + * Unload the context on the way out. + * + * @param {Function} f the function to call + */ +async function withContext(f) { + const ssm = Services.scriptSecurityManager; + const PRINCIPAL1 = ssm.createContentPrincipalFromOrigin( + "http://www.example.org" + ); + const context = new KintoExtContext(PRINCIPAL1); + try { + await f(context); + } finally { + await context.unload(); + } +} + +/** + * Like withContext(), but also turn on the "storage.sync" pref for + * the duration of the function. + * Calls to this function can be replaced with calls to withContext + * once the pref becomes on by default. + * + * @param {Function} f the function to call + */ +async function withSyncContext(f) { + const STORAGE_SYNC_PREF = "webextensions.storage.sync.enabled"; + let prefs = Services.prefs; + + try { + prefs.setBoolPref(STORAGE_SYNC_PREF, true); + await withContext(f); + } finally { + prefs.clearUserPref(STORAGE_SYNC_PREF); + } +} diff --git a/toolkit/components/extensions/test/xpcshell/head_telemetry.js b/toolkit/components/extensions/test/xpcshell/head_telemetry.js new file mode 100644 index 0000000000..a05211d006 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_telemetry.js @@ -0,0 +1,594 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported IS_OOP, valueSum, clearHistograms, getSnapshots, promiseTelemetryRecorded, + assertDNRTelemetryMetricsDefined, assertDNRTelemetryMetricsNoSamples, assertDNRTelemetryMetricsGetValueEq, + assertDNRTelemetryMetricsSamplesCount, resetTelemetryData, setupTelemetryForTests */ + +ChromeUtils.defineESModuleGetters(this, { + ContentTaskUtils: "resource://testing-common/ContentTaskUtils.sys.mjs", +}); + +// Allows to run xpcshell telemetry test also on products (e.g. Thunderbird) where +// that telemetry wouldn't be actually collected in practice (but to be sure +// that it will work on those products as well by just adding the product in +// the telemetry metric definitions if it turns out we want to). +Services.prefs.setBoolPref( + "toolkit.telemetry.testing.overrideProductsCheck", + true +); + +const IS_OOP = Services.prefs.getBoolPref("extensions.webextensions.remote"); + +const WEBEXT_EVENTPAGE_RUNNING_TIME_MS = "WEBEXT_EVENTPAGE_RUNNING_TIME_MS"; +const WEBEXT_EVENTPAGE_RUNNING_TIME_MS_BY_ADDONID = + "WEBEXT_EVENTPAGE_RUNNING_TIME_MS_BY_ADDONID"; +const WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT = "WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT"; +const WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID = + "WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID"; + +// Keep this in sync with the order in Histograms.json for "WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT": +// the position of the category string determines the index of the values collected in the categorial +// histogram and so the existing labels should be kept in the exact same order and any new category +// to be added in the future should be appended to the existing ones. +const HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES = [ + "suspend", + "reset_other", + "reset_event", + "reset_listeners", + "reset_nativeapp", + "reset_streamfilter", + "reset_parentapicall", +]; + +const GLEAN_EVENTPAGE_IDLE_RESULT_CATEGORIES = [ + ...HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + "__other__", +]; + +function valueSum(arr) { + return Object.values(arr).reduce((a, b) => a + b, 0); +} + +function clearHistograms() { + Services.telemetry.getSnapshotForHistograms("main", true /* clear */); + Services.telemetry.getSnapshotForKeyedHistograms("main", true /* clear */); +} + +function clearScalars() { + Services.telemetry.getSnapshotForScalars("main", true /* clear */); + Services.telemetry.getSnapshotForKeyedScalars("main", true /* clear */); +} + +function getSnapshots(process) { + return Services.telemetry.getSnapshotForHistograms("main", false /* clear */)[ + process + ]; +} + +function getKeyedSnapshots(process) { + return Services.telemetry.getSnapshotForKeyedHistograms( + "main", + false /* clear */ + )[process]; +} + +// TODO Bug 1357509: There is no good way to make sure that the parent received +// the histogram entries from the extension and content processes. Let's stick +// to the ugly, spinning the event loop until we have a good approach. +function promiseTelemetryRecorded(id, process, expectedCount) { + let condition = () => { + let snapshot = Services.telemetry.getSnapshotForHistograms( + "main", + false /* clear */ + )[process][id]; + return snapshot && valueSum(snapshot.values) >= expectedCount; + }; + return ContentTaskUtils.waitForCondition(condition); +} + +function promiseKeyedTelemetryRecorded( + id, + process, + expectedKey, + expectedCount +) { + let condition = () => { + let snapshot = Services.telemetry.getSnapshotForKeyedHistograms( + "main", + false /* clear */ + )[process][id]; + return ( + snapshot && + snapshot[expectedKey] && + valueSum(snapshot[expectedKey].values) >= expectedCount + ); + }; + return ContentTaskUtils.waitForCondition(condition); +} + +function assertHistogramSnapshot( + histogramId, + { keyed, processSnapshot, expectedValue }, + msg +) { + let histogram; + + if (keyed) { + histogram = Services.telemetry.getKeyedHistogramById(histogramId); + } else { + histogram = Services.telemetry.getHistogramById(histogramId); + } + + let res = processSnapshot(histogram.snapshot()); + Assert.deepEqual(res, expectedValue, msg); + return res; +} + +function assertHistogramEmpty(histogramId) { + assertHistogramSnapshot( + histogramId, + { + processSnapshot: snapshot => snapshot.sum, + expectedValue: 0, + }, + `No data recorded for histogram: ${histogramId}.` + ); +} + +function assertKeyedHistogramEmpty(histogramId) { + assertHistogramSnapshot( + histogramId, + { + keyed: true, + processSnapshot: snapshot => Object.keys(snapshot).length, + expectedValue: 0, + }, + `No data recorded for histogram: ${histogramId}.` + ); +} + +function assertHistogramCategoryNotEmpty( + histogramId, + { category, categories, keyed, key }, + msg +) { + let message = msg; + + if (!msg) { + message = `Data recorded for histogram: ${histogramId}, category "${category}"`; + if (keyed) { + message += `, key "${key}"`; + } + } + + assertHistogramSnapshot( + histogramId, + { + keyed, + processSnapshot: snapshot => { + const categoryIndex = categories.indexOf(category); + if (keyed) { + return { + [key]: snapshot[key] + ? snapshot[key].values[categoryIndex] > 0 + : null, + }; + } + return snapshot.values[categoryIndex] > 0; + }, + expectedValue: keyed ? { [key]: true } : true, + }, + message + ); +} + +function setupTelemetryForTests() { + // FOG needs a profile directory to put its data in. + do_get_profile(); + // FOG needs to be initialized in order for data to flow. + Services.fog.initializeFOG(); +} + +function resetTelemetryData() { + Services.fog.testResetFOG(); + + // Clear histograms data recorded in the unified telemetry + // (needed to make sure we can keep asserting that the same + // amount of samples collected by Glean should also be found + // in the related mirrored unified telemetry probe after we + // have reset Glean metrics data using testResetFOG). + clearHistograms(); + clearScalars(); +} + +function assertValidGleanMetric({ + metricId, + gleanMetric, + gleanMetricConstructor, + msg, +}) { + const { GleanMetric } = globalThis; + if (!(gleanMetric instanceof GleanMetric)) { + throw new Error( + `gleanMetric "${metricId}" ${gleanMetric} should be an instance of GleanMetric ${msg}` + ); + } + + if ( + gleanMetricConstructor && + !(gleanMetric instanceof gleanMetricConstructor) + ) { + throw new Error( + `gleanMetric "${metricId}" should be an instance of the given GleanMetric constructor: ${gleanMetric} not an instance of ${gleanMetricConstructor} ${msg}` + ); + } +} + +// TODO reuse this helper inside the DNR specific test helper which would be doing +// a similar assertion on DNR metrics. +function assertGleanMetricsNoSamples({ + metricId, + gleanMetric, + gleanMetricConstructor, + message, +}) { + const msg = message ? `(${message})` : ""; + assertValidGleanMetric({ + metricId, + gleanMetric, + gleanMetricConstructor, + msg, + }); + const gleanData = gleanMetric.testGetValue(); + Assert.deepEqual( + gleanData, + undefined, + `Got no sample for Glean metric ${metricId} ${msg}` + ); +} + +// TODO reuse this helper inside the DNR specific test helper which would be doing +// a similar assertion on DNR metrics. +function assertGleanMetricsSamplesCount({ + metricId, + gleanMetric, + gleanMetricConstructor, + expectedSamplesCount, + message, +}) { + const msg = message ? `(${message})` : ""; + assertValidGleanMetric({ + metricId, + gleanMetric, + gleanMetricConstructor, + msg, + }); + const gleanData = gleanMetric.testGetValue(); + Assert.notEqual( + gleanData, + undefined, + `Got some sample for Glean metric ${metricId} ${msg}` + ); + Assert.equal( + valueSum(gleanData.values), + expectedSamplesCount, + `Got the expected number of samples for Glean metric ${metricId} ${msg}` + ); +} + +function assertGleanLabeledCounter({ + metricId, + gleanMetric, + gleanMetricLabels, + expectedLabelsValue, + ignoreNonExpectedLabels, + ignoreUnknownLabels, + message, +}) { + const { GleanLabeled } = globalThis; + const msg = message ? `(${message})` : ""; + if (!Array.isArray(gleanMetricLabels) || !gleanMetricLabels.length) { + throw new Error( + `Missing mandatory gleanMetricLabels property ${msg}: ${gleanMetricLabels}` + ); + } + + if (!(gleanMetric instanceof GleanLabeled)) { + throw new Error( + `Glean metric "${metricId}" should be an instance of GleanLabeled: ${gleanMetric} ${msg}` + ); + } + + for (const label of gleanMetricLabels) { + const expectedLabelValue = expectedLabelsValue[label]; + if (ignoreNonExpectedLabels && !(label in expectedLabelsValue)) { + continue; + } + Assert.deepEqual( + gleanMetric[label].testGetValue(), + expectedLabelValue, + `Expect Glean "${metricId}" metric label "${label}" to be ${ + expectedLabelValue > 0 ? expectedLabelValue : "empty" + }` + ); + } + + if (!ignoreUnknownLabels) { + Assert.deepEqual( + gleanMetric["__other__"].testGetValue(), // eslint-disable-line dot-notation + undefined, + `Expect Glean "${metricId}" metric label "__other__" to be empty.` + ); + } +} + +function assertGleanLabeledCounterEmpty({ + metricId, + gleanMetric, + gleanMetricLabels, + message, +}) { + // All empty labels passed to the other helpers to make it + // assert that all labels are empty. + assertGleanLabeledCounter({ + metricId, + gleanMetric, + gleanMetricLabels, + expectedLabelsValue: {}, + message, + }); +} + +function assertGleanLabeledCounterNotEmpty({ + metricId, + gleanMetric, + expectedNotEmptyLabels, + ignoreUnknownLabels, + message, +}) { + const { GleanLabeled } = globalThis; + const msg = message ? `(${message})` : ""; + if ( + !Array.isArray(expectedNotEmptyLabels) || + !expectedNotEmptyLabels.length + ) { + throw new Error( + `Missing mandatory expectedNotEmptyLabels property ${msg}: ${expectedNotEmptyLabels}` + ); + } + + if (!(gleanMetric instanceof GleanLabeled)) { + throw new Error( + `Glean metric "${metricId}" should be an instance of GleanLabeled: ${gleanMetric} ${msg}` + ); + } + + for (const label of expectedNotEmptyLabels) { + Assert.notEqual( + gleanMetric[label].testGetValue(), + undefined, + `Expect Glean "${metricId}" metric label "${label}" to not be empty` + ); + } + + if (!ignoreUnknownLabels) { + Assert.deepEqual( + gleanMetric["__other__"].testGetValue(), // eslint-disable-line dot-notation + undefined, + `Expect Glean "${metricId}" metric label "__other__" to be empty.` + ); + } +} + +function assertDNRTelemetryMetricsDefined(metrics) { + const metricsNotFound = metrics.filter(metricDetails => { + const { metric, label } = metricDetails; + if (!Glean.extensionsApisDnr[metric]) { + return true; + } + if (label) { + return !Glean.extensionsApisDnr[metric][label]; + } + return false; + }); + Assert.deepEqual( + metricsNotFound, + [], + `All expected extensionsApisDnr Glean metrics should be found` + ); +} + +function assertDNRTelemetryMirrored({ + gleanMetric, + gleanLabel, + unifiedName, + unifiedType, +}) { + assertDNRTelemetryMetricsDefined([ + { metric: gleanMetric, label: gleanLabel }, + ]); + const gleanData = gleanLabel + ? Glean.extensionsApisDnr[gleanMetric][gleanLabel].testGetValue() + : Glean.extensionsApisDnr[gleanMetric].testGetValue(); + + if (!unifiedName) { + Assert.ok( + false, + `Unexpected missing unifiedName parameter on call to assertDNRTelemetryMirrored` + ); + return; + } + + let unifiedData; + + switch (unifiedType) { + case "histogram": { + let found = false; + try { + const histogram = Services.telemetry.getHistogramById(unifiedName); + found = !!histogram; + } catch (err) { + Cu.reportError(err); + } + Assert.ok(found, `Expect an histogram named ${unifiedName} to be found`); + unifiedData = Services.telemetry.getSnapshotForHistograms("main", false) + .parent[unifiedName]; + break; + } + case "keyedScalar": { + const snapshot = Services.telemetry.getSnapshotForKeyedScalars( + "main", + false + ); + if (unifiedName in (snapshot?.parent || {})) { + unifiedData = snapshot.parent[unifiedName][gleanLabel]; + } + break; + } + case "scalar": { + const snapshot = Services.telemetry.getSnapshotForScalars("main", false); + if (unifiedName in (snapshot?.parent || {})) { + unifiedData = snapshot.parent[unifiedName]; + } + break; + } + default: + Assert.ok( + false, + `Unexpected unifiedType ${unifiedType} on call to assertDNRTelemetryMirrored` + ); + return; + } + + if (gleanData == undefined) { + Assert.deepEqual( + unifiedData, + undefined, + `Expect mirrored unified telemetry ${unifiedType} ${unifiedName} has no samples as Glean ${gleanMetric}` + ); + } else { + switch (unifiedType) { + case "histogram": { + Assert.deepEqual( + valueSum(unifiedData.values), + valueSum(gleanData.values), + `Expect mirrored unified telemetry ${unifiedType} ${unifiedName} has samples mirrored from Glean ${gleanMetric}` + ); + break; + } + case "scalar": + case "keyedScalar": { + Assert.deepEqual( + unifiedData, + gleanData, + `Expect mirrored unified telemetry ${unifiedType} ${unifiedName} has samples mirrored from Glean ${gleanMetric}` + ); + break; + } + } + } +} + +function assertDNRTelemetryMetricsNoSamples(metrics, msg) { + assertDNRTelemetryMetricsDefined(metrics); + for (const metricDetails of metrics) { + const { metric, label } = metricDetails; + + const gleanData = label + ? Glean.extensionsApisDnr[metric][label].testGetValue() + : Glean.extensionsApisDnr[metric].testGetValue(); + Assert.deepEqual( + gleanData, + undefined, + `Expect no sample for Glean metric extensionApisDnr.${metric} (${msg}): ${gleanData}` + ); + + if (metricDetails.mirroredName) { + const { mirroredName, mirroredType } = metricDetails; + assertDNRTelemetryMirrored({ + gleanMetric: metric, + gleanLabel: label, + unifiedName: mirroredName, + unifiedType: mirroredType, + }); + } + } +} + +function assertDNRTelemetryMetricsGetValueEq(metrics, msg) { + assertDNRTelemetryMetricsDefined(metrics); + for (const metricDetails of metrics) { + const { metric, label, expectedGetValue } = metricDetails; + + const gleanData = label + ? Glean.extensionsApisDnr[metric][label].testGetValue() + : Glean.extensionsApisDnr[metric].testGetValue(); + Assert.deepEqual( + gleanData, + expectedGetValue, + `Got expected value set on Glean metric extensionApisDnr.${metric}${ + label ? `.${label}` : "" + } (${msg})` + ); + + if (metricDetails.mirroredName) { + const { mirroredName, mirroredType } = metricDetails; + assertDNRTelemetryMirrored({ + gleanMetric: metric, + gleanLabel: label, + unifiedName: mirroredName, + unifiedType: mirroredType, + }); + } + } +} + +function assertDNRTelemetryMetricsSamplesCount(metrics, msg) { + assertDNRTelemetryMetricsDefined(metrics); + + // This assertion helpers doesn't currently handle labeled metrics, + // raise an explicit error to catch if one is included by mistake. + const labeledMetricsFound = metrics.filter(metric => !!metric.label); + if (labeledMetricsFound.length) { + throw new Error( + `Unexpected labeled metrics in call to assertDNRTelemetryMetricsSamplesCount: ${labeledMetricsFound}` + ); + } + + for (const metricDetails of metrics) { + const { metric, expectedSamplesCount } = metricDetails; + + const gleanData = Glean.extensionsApisDnr[metric].testGetValue(); + Assert.notEqual( + gleanData, + undefined, + `Got some sample for Glean metric extensionApisDnr.${metric}: ${ + gleanData && JSON.stringify(gleanData) + }` + ); + Assert.equal( + valueSum(gleanData.values), + expectedSamplesCount, + `Got the expected number of samples for Glean metric extensionsApisDnr.${metric} (${msg})` + ); + // Make sure we are accumulating meaningfull values in the sample, + // if we do have samples for the bucket "0" it likely means we have + // not been collecting the value correctly (e.g. typo in the property + // name being collected). + Assert.ok( + !gleanData.values["0"], + `No sample for Glean metric extensionsApisDnr.${metric} should be collected for the bucket "0"` + ); + + if (metricDetails.mirroredName) { + const { mirroredName, mirroredType } = metricDetails; + assertDNRTelemetryMirrored({ + gleanMetric: metric, + unifiedName: mirroredName, + unifiedType: mirroredType, + }); + } + } +} diff --git a/toolkit/components/extensions/test/xpcshell/native_messaging.toml b/toolkit/components/extensions/test/xpcshell/native_messaging.toml new file mode 100644 index 0000000000..468636fcc6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/native_messaging.toml @@ -0,0 +1,19 @@ +[DEFAULT] +head = "head.js head_native_messaging.js head_telemetry.js" +firefox-appdir = "browser" +skip-if = [ + "appname == 'thunderbird'", + "os == 'android'", +] +subprocess = true +support-files = ["data/**"] +tags = "webextensions" + +["test_ext_native_messaging.js"] +skip-if = ["apple_silicon"] # bug 1729540 +run-sequentially = "very high failure rate in parallel" + +["test_ext_native_messaging_perf.js"] +skip-if = ["tsan"] # Unreasonably slow, bug 1612707 + +["test_ext_native_messaging_unresponsive.js"] diff --git a/toolkit/components/extensions/test/xpcshell/test_ExtensionShortcutKeyMap.js b/toolkit/components/extensions/test/xpcshell/test_ExtensionShortcutKeyMap.js new file mode 100644 index 0000000000..8e48684095 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ExtensionShortcutKeyMap.js @@ -0,0 +1,141 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { ExtensionShortcutKeyMap } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionShortcuts.sys.mjs" +); + +add_task(function test_ExtensionShortcutKeymap() { + const shortcutsMap = new ExtensionShortcutKeyMap(); + + shortcutsMap.recordShortcut("Ctrl+Shift+1", "Addon1", "Command1"); + shortcutsMap.recordShortcut("Ctrl+Shift+1", "Addon2", "Command2"); + shortcutsMap.recordShortcut("Ctrl+Alt+2", "Addon2", "Command3"); + // Empty shortcut not expected to be recorded, just ignored. + shortcutsMap.recordShortcut("", "Addon3", "Command4"); + + Assert.equal( + shortcutsMap.size, + 2, + "Got the expected number of shortcut entries" + ); + Assert.deepEqual( + { + shortcutWithTwoExtensions: shortcutsMap.getFirstAddonName("Ctrl+Shift+1"), + shortcutWithOnlyOneExtension: + shortcutsMap.getFirstAddonName("Ctrl+Alt+2"), + shortcutWithNoExtension: shortcutsMap.getFirstAddonName(""), + }, + { + shortcutWithTwoExtensions: "Addon1", + shortcutWithOnlyOneExtension: "Addon2", + shortcutWithNoExtension: null, + }, + "Got the expected results from getFirstAddonName calls" + ); + + Assert.deepEqual( + { + shortcutWithTwoExtensions: shortcutsMap.has("Ctrl+Shift+1"), + shortcutWithOnlyOneExtension: shortcutsMap.has("Ctrl+Alt+2"), + shortcutWithNoExtension: shortcutsMap.has(""), + }, + { + shortcutWithTwoExtensions: true, + shortcutWithOnlyOneExtension: true, + shortcutWithNoExtension: false, + }, + "Got the expected results from `has` calls" + ); + + shortcutsMap.removeShortcut("Ctrl+Shift+1", "Addon1", "Command1"); + Assert.equal( + shortcutsMap.has("Ctrl+Shift+1"), + true, + "Expect shortcut to already exist after removing one duplicate" + ); + Assert.equal( + shortcutsMap.getFirstAddonName("Ctrl+Shift+1"), + "Addon2", + "Expect getFirstAddonName to return the remaining addon name" + ); + + shortcutsMap.removeShortcut("Ctrl+Shift+1", "Addon2", "Command2"); + Assert.equal( + shortcutsMap.has("Ctrl+Shift+1"), + false, + "Expect shortcut to not exist anymore after removing last entry" + ); + Assert.equal(shortcutsMap.size, 1, "Got only one shortcut as expected"); + + shortcutsMap.clear(); + Assert.equal( + shortcutsMap.size, + 0, + "Got no shortcut as expected after clearing the map" + ); +}); + +// This test verify that ExtensionShortcutKeyMap does catch duplicated +// shortcut when the two modifiers strings are associated to the same +// key (in particular on macOS where Ctrl and Command keys are both translated +// in the same modifier in the keyboard shortcuts). +add_task(function test_PlatformShortcutString() { + const shortcutsMap = new ExtensionShortcutKeyMap(); + + // Make the class instance behave like it would while running on macOS. + // (this is just for unit testing purpose, there is a separate integration + // test exercising this behavior in a real "Manage Extension Shortcut" + // about:addons view and only running on macOS, skipped on other platforms). + shortcutsMap._os = "mac"; + + shortcutsMap.recordShortcut("Ctrl+Shift+1", "Addon1", "MacCommand1"); + + Assert.deepEqual( + { + hasWithCtrl: shortcutsMap.has("Ctrl+Shift+1"), + hasWithCommand: shortcutsMap.has("Command+Shift+1"), + }, + { + hasWithCtrl: true, + hasWithCommand: true, + }, + "Got the expected results from `has` calls" + ); + + Assert.deepEqual( + { + nameWithCtrl: shortcutsMap.getFirstAddonName("Ctrl+Shift+1"), + nameWithCommand: shortcutsMap.getFirstAddonName("Command+Shift+1"), + }, + { + nameWithCtrl: "Addon1", + nameWithCommand: "Addon1", + }, + "Got the expected results from `getFirstAddonName` calls" + ); + + // Add a duplicate shortcut using Command instead of Ctrl and + // verify the expected behaviors. + shortcutsMap.recordShortcut("Command+Shift+1", "Addon2", "MacCommand2"); + Assert.equal(shortcutsMap.size, 1, "Got still one shortcut as expected"); + shortcutsMap.removeShortcut("Ctrl+Shift+1", "Addon1", "MacCommand1"); + Assert.equal(shortcutsMap.size, 1, "Got still one shortcut as expected"); + Assert.deepEqual( + { + nameWithCtrl: shortcutsMap.getFirstAddonName("Ctrl+Shift+1"), + nameWithCommand: shortcutsMap.getFirstAddonName("Command+Shift+1"), + }, + { + nameWithCtrl: "Addon2", + nameWithCommand: "Addon2", + }, + "Got the expected results from `getFirstAddonName` calls" + ); + + // Remove the entry added with a shortcut using "Command" by using the + // equivalent shortcut using Ctrl. + shortcutsMap.removeShortcut("Ctrl+Shift+1", "Addon2", "MacCommand2"); + Assert.equal(shortcutsMap.size, 0, "Got no shortcut as expected"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ExtensionStorageSync_migration_kinto.js b/toolkit/components/extensions/test/xpcshell/test_ExtensionStorageSync_migration_kinto.js new file mode 100644 index 0000000000..7cde68ee98 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ExtensionStorageSync_migration_kinto.js @@ -0,0 +1,86 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Import the rust-based and kinto-based implementations +const { extensionStorageSync: rustImpl } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionStorageSync.sys.mjs" +); +const { extensionStorageSyncKinto: kintoImpl } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionStorageSyncKinto.sys.mjs" +); + +Services.prefs.setBoolPref("webextensions.storage.sync.kinto", false); + +add_task(async function test_sync_migration() { + // There's no good reason to perform this test via test extensions - we just + // call the underlying APIs directly. + + // Set some stuff using the kinto-based impl. + let e1 = { id: "test@mozilla.com" }; + let c1 = { extension: e1, callOnClose() {} }; + await kintoImpl.set(e1, { foo: "bar" }, c1); + + let e2 = { id: "test-2@mozilla.com" }; + let c2 = { extension: e2, callOnClose() {} }; + await kintoImpl.set(e2, { second: "2nd" }, c2); + + let e3 = { id: "test-3@mozilla.com" }; + let c3 = { extension: e3, callOnClose() {} }; + + // And all the data should be magically migrated. + Assert.deepEqual(await rustImpl.get(e1, "foo", c1), { foo: "bar" }); + Assert.deepEqual(await rustImpl.get(e2, null, c2), { second: "2nd" }); + + // Sanity check we really are doing what we think we are - set a value in our + // new one, it should not be reflected by kinto. + await rustImpl.set(e3, { third: "3rd" }, c3); + Assert.deepEqual(await rustImpl.get(e3, null, c3), { third: "3rd" }); + Assert.deepEqual(await kintoImpl.get(e3, null, c3), {}); + // cleanup. + await kintoImpl.clear(e1, c1); + await kintoImpl.clear(e2, c2); + await kintoImpl.clear(e3, c3); + await rustImpl.clear(e1, c1); + await rustImpl.clear(e2, c2); + await rustImpl.clear(e3, c3); +}); + +// It would be great to have failure tests, but that seems impossible to have +// in automated tests given the conditions under which we migrate - it would +// basically require us to arrange for zero free disk space or to somehow +// arrange for sqlite to see an io error. Specially crafted "corrupt" +// sqlite files doesn't help because that file must not exist for us to even +// attempt migration. +// +// But - what we can test is that if .migratedOk on the new impl ever goes to +// false we delegate correctly. +add_task(async function test_sync_migration_delgates() { + let e1 = { id: "test@mozilla.com" }; + let c1 = { extension: e1, callOnClose() {} }; + await kintoImpl.set(e1, { foo: "bar" }, c1); + + // We think migration went OK - `get` shouldn't see kinto. + Assert.deepEqual(rustImpl.get(e1, null, c1), {}); + + info( + "Setting migration failure flag to ensure we delegate to kinto implementation" + ); + rustImpl.migrationOk = false; + // get should now be seeing kinto. + Assert.deepEqual(await rustImpl.get(e1, null, c1), { foo: "bar" }); + // check everything else delegates. + + await rustImpl.set(e1, { foo: "foo" }, c1); + Assert.deepEqual(await kintoImpl.get(e1, null, c1), { foo: "foo" }); + + Assert.equal(await rustImpl.getBytesInUse(e1, null, c1), 8); + + await rustImpl.remove(e1, "foo", c1); + Assert.deepEqual(await kintoImpl.get(e1, null, c1), {}); + + await rustImpl.set(e1, { foo: "foo" }, c1); + Assert.deepEqual(await kintoImpl.get(e1, null, c1), { foo: "foo" }); + await rustImpl.clear(e1, c1); + Assert.deepEqual(await kintoImpl.get(e1, null, c1), {}); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_MatchPattern.js b/toolkit/components/extensions/test/xpcshell/test_MatchPattern.js new file mode 100644 index 0000000000..4d7da529e3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_MatchPattern.js @@ -0,0 +1,685 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +registerCleanupFunction(async () => { + Services.prefs.clearUserPref("network.url.useDefaultURI"); +}); + +add_setup(async function () { + // unknown-scheme://foo tests will fail with default URI + // see bug 1868413 (to re-enable) + Services.prefs.setBoolPref("network.url.useDefaultURI", false); +}); + +add_task(async function test_MatchPattern_matches() { + function test(url, pattern, normalized = pattern, options = {}, explicit) { + let uri = Services.io.newURI(url); + + pattern = Array.prototype.concat.call(pattern); + normalized = Array.prototype.concat.call(normalized); + + let patterns = pattern.map(pat => new MatchPattern(pat, options)); + + let set = new MatchPatternSet(pattern, options); + let set2 = new MatchPatternSet(patterns, options); + + deepEqual( + set2.patterns, + patterns, + "Patterns in set should equal the input patterns" + ); + + equal( + set.matches(uri, explicit), + set2.matches(uri, explicit), + "Single pattern and pattern set should return the same match" + ); + + for (let [i, pat] of patterns.entries()) { + equal( + pat.pattern, + normalized[i], + "Pattern property should contain correct normalized pattern value" + ); + } + + if (patterns.length == 1) { + equal( + patterns[0].matches(uri, explicit), + set.matches(uri, explicit), + "Single pattern and string set should return the same match" + ); + } + + return set.matches(uri, explicit); + } + + function pass({ url, pattern, normalized, options, explicit }) { + ok( + test(url, pattern, normalized, options, explicit), + `Expected match: ${JSON.stringify(pattern)}, ${url}` + ); + } + + function fail({ url, pattern, normalized, options, explicit }) { + ok( + !test(url, pattern, normalized, options, explicit), + `Expected no match: ${JSON.stringify(pattern)}, ${url}` + ); + } + + function invalid({ pattern }) { + Assert.throws( + () => new MatchPattern(pattern), + /.*/, + `Invalid pattern '${pattern}' should throw` + ); + Assert.throws( + () => new MatchPatternSet([pattern]), + /.*/, + `Invalid pattern '${pattern}' should throw` + ); + } + + // Invalid pattern. + invalid({ pattern: "" }); + + // Pattern must include trailing slash. + invalid({ pattern: "http://mozilla.org" }); + + // Protocol not allowed. + invalid({ pattern: "gopher://wuarchive.wustl.edu/" }); + + pass({ url: "http://mozilla.org", pattern: "http://mozilla.org/" }); + pass({ url: "http://mozilla.org/", pattern: "http://mozilla.org/" }); + + pass({ url: "http://mozilla.org/", pattern: "*://mozilla.org/" }); + pass({ url: "https://mozilla.org/", pattern: "*://mozilla.org/" }); + fail({ url: "file://mozilla.org/", pattern: "*://mozilla.org/" }); + fail({ url: "ftp://mozilla.org/", pattern: "*://mozilla.org/" }); + + fail({ url: "http://mozilla.com", pattern: "http://*mozilla.com*/" }); + fail({ url: "http://mozilla.com", pattern: "http://mozilla.*/" }); + invalid({ pattern: "http:/mozilla.com/" }); + + pass({ url: "http://google.com", pattern: "http://*.google.com/" }); + pass({ url: "http://docs.google.com", pattern: "http://*.google.com/" }); + + pass({ url: "http://mozilla.org:8080", pattern: "http://mozilla.org/" }); + pass({ url: "http://mozilla.org:8080", pattern: "*://mozilla.org/" }); + fail({ url: "http://mozilla.org:8080", pattern: "http://mozilla.org:8080/" }); + + // Now try with * in the path. + pass({ url: "http://mozilla.org", pattern: "http://mozilla.org/*" }); + pass({ url: "http://mozilla.org/", pattern: "http://mozilla.org/*" }); + + pass({ url: "http://mozilla.org/", pattern: "*://mozilla.org/*" }); + pass({ url: "https://mozilla.org/", pattern: "*://mozilla.org/*" }); + fail({ url: "file://mozilla.org/", pattern: "*://mozilla.org/*" }); + fail({ url: "http://mozilla.com", pattern: "http://mozilla.*/*" }); + + pass({ url: "http://google.com", pattern: "http://*.google.com/*" }); + pass({ url: "http://docs.google.com", pattern: "http://*.google.com/*" }); + + // Check path stuff. + fail({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/" }); + pass({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*" }); + pass({ + url: "http://mozilla.com/abc/def", + pattern: "http://mozilla.com/a*f", + }); + pass({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/a*" }); + pass({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*f" }); + fail({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*e" }); + fail({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*c" }); + + invalid({ pattern: "http:///a.html" }); + pass({ url: "file:///foo", pattern: "file:///foo*" }); + pass({ url: "file:///foo/bar.html", pattern: "file:///foo*" }); + + pass({ url: "http://mozilla.org/a", pattern: "<all_urls>" }); + pass({ url: "https://mozilla.org/a", pattern: "<all_urls>" }); + pass({ url: "ftp://mozilla.org/a", pattern: "<all_urls>" }); + pass({ url: "file:///a", pattern: "<all_urls>" }); + fail({ url: "gopher://wuarchive.wustl.edu/a", pattern: "<all_urls>" }); + + // Multiple patterns. + pass({ url: "http://mozilla.org", pattern: ["http://mozilla.org/"] }); + pass({ + url: "http://mozilla.org", + pattern: ["http://mozilla.org/", "http://mozilla.com/"], + }); + pass({ + url: "http://mozilla.com", + pattern: ["http://mozilla.org/", "http://mozilla.com/"], + }); + fail({ + url: "http://mozilla.biz", + pattern: ["http://mozilla.org/", "http://mozilla.com/"], + }); + + // Match url with fragments. + pass({ + url: "http://mozilla.org/base#some-fragment", + pattern: "http://mozilla.org/base", + }); + + // Match data:-URLs. + pass({ url: "data:text/plain,foo", pattern: ["data:text/plain,foo"] }); + pass({ url: "data:text/plain,foo", pattern: ["data:text/plain,*"] }); + pass({ + url: "data:text/plain;charset=utf-8,foo", + pattern: ["data:text/plain;charset=utf-8,foo"], + }); + fail({ + url: "data:text/plain,foo", + pattern: ["data:text/plain;charset=utf-8,foo"], + }); + fail({ + url: "data:text/plain;charset=utf-8,foo", + pattern: ["data:text/plain,foo"], + }); + + // Privileged matchers: + invalid({ pattern: "about:foo" }); + invalid({ pattern: "resource://foo/*" }); + + pass({ + url: "about:foo", + pattern: ["about:foo", "about:foo*"], + options: { restrictSchemes: false }, + }); + pass({ + url: "about:foo", + pattern: ["about:foo*"], + options: { restrictSchemes: false }, + }); + pass({ + url: "about:foobar", + pattern: ["about:foo*"], + options: { restrictSchemes: false }, + }); + + pass({ + url: "resource://foo/bar", + pattern: ["resource://foo/bar"], + options: { restrictSchemes: false }, + }); + fail({ + url: "resource://fog/bar", + pattern: ["resource://foo/bar"], + options: { restrictSchemes: false }, + }); + fail({ + url: "about:foo", + pattern: ["about:meh"], + options: { restrictSchemes: false }, + }); + + // Matchers for schemes without host should ignore ignorePath. + pass({ + url: "about:reader?http://e.com/", + pattern: ["about:reader*"], + options: { ignorePath: true, restrictSchemes: false }, + }); + pass({ url: "data:,", pattern: ["data:,*"], options: { ignorePath: true } }); + + // Matchers for schems without host should still match even if the explicit (host) flag is set. + pass({ + url: "about:reader?explicit", + pattern: ["about:reader*"], + options: { restrictSchemes: false }, + explicit: true, + }); + pass({ + url: "about:reader?explicit", + pattern: ["about:reader?explicit"], + options: { restrictSchemes: false }, + explicit: true, + }); + pass({ url: "data:,explicit", pattern: ["data:,explicit"], explicit: true }); + pass({ url: "data:,explicit", pattern: ["data:,*"], explicit: true }); + + // Matchers without "//" separator in the pattern. + pass({ url: "data:text/plain;charset=utf-8,foo", pattern: ["data:*"] }); + pass({ + url: "about:blank", + pattern: ["about:*"], + options: { restrictSchemes: false }, + }); + pass({ + url: "view-source:https://example.com", + pattern: ["view-source:*"], + options: { restrictSchemes: false }, + }); + invalid({ pattern: ["chrome:*"], options: { restrictSchemes: false } }); + invalid({ pattern: "http:*" }); + + // Matchers for unrecognized schemes. + invalid({ pattern: "unknown-scheme:*" }); + pass({ + url: "unknown-scheme:foo", + pattern: ["unknown-scheme:foo"], + options: { restrictSchemes: false }, + }); + pass({ + url: "unknown-scheme:foo", + pattern: ["unknown-scheme:*"], + options: { restrictSchemes: false }, + }); + pass({ + url: "unknown-scheme://foo", + pattern: ["unknown-scheme://foo"], + options: { restrictSchemes: false }, + }); + pass({ + url: "unknown-scheme://foo", + pattern: ["unknown-scheme://*"], + options: { restrictSchemes: false }, + }); + pass({ + url: "unknown-scheme://foo", + pattern: ["unknown-scheme:*"], + options: { restrictSchemes: false }, + }); + pass({ + url: "unknown-scheme:/foo", + pattern: ["unknown-scheme:/*"], + options: { restrictSchemes: false }, + }); + fail({ + url: "unknown-scheme://foo", + pattern: ["unknown-scheme:foo"], + options: { restrictSchemes: false }, + }); + fail({ + url: "unknown-scheme:foo", + pattern: ["unknown-scheme://foo"], + options: { restrictSchemes: false }, + }); + fail({ + url: "unknown-scheme:foo", + pattern: ["unknown-scheme://*"], + options: { restrictSchemes: false }, + }); + fail({ + url: "unknown-scheme:foo", + pattern: ["unknown-scheme:/*"], + options: { restrictSchemes: false }, + }); + + // Matchers for IPv6 + pass({ url: "http://[::1]/", pattern: ["http://[::1]/"] }); + pass({ + url: "http://[2a03:4000:6:310e:216:3eff:fe53:99b]/", + pattern: ["http://[2a03:4000:6:310e:216:3eff:fe53:99b]/"], + }); + fail({ + url: "http://[2:4:6:3:2:3:f:b]/", + pattern: ["http://[2a03:4000:6:310e:216:3eff:fe53:99b]/"], + }); + + // Before fixing Bug 1529230, the only way to match a specific IPv6 url is by droping the brackets in pattern, + // thus we keep this pattern valid for the sake of backward compatibility + pass({ url: "http://[::1]/", pattern: ["http://::1/"] }); + pass({ + url: "http://[2a03:4000:6:310e:216:3eff:fe53:99b]/", + pattern: ["http://2a03:4000:6:310e:216:3eff:fe53:99b/"], + }); +}); + +add_task(async function test_MatchPattern_overlaps() { + function test(filter, hosts, optional) { + filter = Array.prototype.concat.call(filter); + hosts = Array.prototype.concat.call(hosts); + optional = Array.prototype.concat.call(optional); + + const set = new MatchPatternSet([...hosts, ...optional]); + const pat = new MatchPatternSet(filter); + return set.overlapsAll(pat); + } + + function pass({ filter = [], hosts = [], optional = [] }) { + ok( + test(filter, hosts, optional), + `Expected overlap: ${filter}, ${hosts} (${optional})` + ); + } + + function fail({ filter = [], hosts = [], optional = [] }) { + ok( + !test(filter, hosts, optional), + `Expected no overlap: ${filter}, ${hosts} (${optional})` + ); + } + + // Direct comparison. + pass({ hosts: "http://ab.cd/", filter: "http://ab.cd/" }); + fail({ hosts: "http://ab.cd/", filter: "ftp://ab.cd/" }); + + // Wildcard protocol. + pass({ hosts: "*://ab.cd/", filter: "https://ab.cd/" }); + fail({ hosts: "*://ab.cd/", filter: "ftp://ab.cd/" }); + + // Wildcard subdomain. + pass({ hosts: "http://*.ab.cd/", filter: "http://ab.cd/" }); + pass({ hosts: "http://*.ab.cd/", filter: "http://www.ab.cd/" }); + fail({ hosts: "http://*.ab.cd/", filter: "http://ab.cd.ef/" }); + fail({ hosts: "http://*.ab.cd/", filter: "http://www.cd/" }); + + // Wildcard subsumed. + pass({ hosts: "http://*.ab.cd/", filter: "http://*.cd/" }); + fail({ hosts: "http://*.cd/", filter: "http://*.xy/" }); + + // Subdomain vs substring. + fail({ hosts: "http://*.ab.cd/", filter: "http://fake-ab.cd/" }); + fail({ hosts: "http://*.ab.cd/", filter: "http://*.fake-ab.cd/" }); + + // Wildcard domain. + pass({ hosts: "http://*/", filter: "http://ab.cd/" }); + fail({ hosts: "http://*/", filter: "https://ab.cd/" }); + + // Wildcard wildcards. + pass({ hosts: "<all_urls>", filter: "ftp://ab.cd/" }); + fail({ hosts: "<all_urls>" }); + + // Multiple hosts. + pass({ hosts: ["http://ab.cd/"], filter: ["http://ab.cd/"] }); + pass({ hosts: ["http://ab.cd/", "http://ab.xy/"], filter: "http://ab.cd/" }); + pass({ hosts: ["http://ab.cd/", "http://ab.xy/"], filter: "http://ab.xy/" }); + fail({ hosts: ["http://ab.cd/", "http://ab.xy/"], filter: "http://ab.zz/" }); + + // Multiple Multiples. + pass({ + hosts: ["http://*.ab.cd/"], + filter: ["http://ab.cd/", "http://www.ab.cd/"], + }); + pass({ + hosts: ["http://ab.cd/", "http://ab.xy/"], + filter: ["http://ab.cd/", "http://ab.xy/"], + }); + fail({ + hosts: ["http://ab.cd/", "http://ab.xy/"], + filter: ["http://ab.cd/", "http://ab.zz/"], + }); + + // Optional. + pass({ hosts: [], optional: "http://ab.cd/", filter: "http://ab.cd/" }); + pass({ + hosts: "http://ab.cd/", + optional: "http://ab.xy/", + filter: ["http://ab.cd/", "http://ab.xy/"], + }); + fail({ + hosts: "http://ab.cd/", + optional: "https://ab.xy/", + filter: "http://ab.xy/", + }); +}); + +add_task(async function test_MatchGlob() { + function test(url, pattern) { + let m = new MatchGlob(pattern[0]); + return m.matches(Services.io.newURI(url).spec); + } + + function pass({ url, pattern }) { + ok( + test(url, pattern), + `Expected match: ${JSON.stringify(pattern)}, ${url}` + ); + } + + function fail({ url, pattern }) { + ok( + !test(url, pattern), + `Expected no match: ${JSON.stringify(pattern)}, ${url}` + ); + } + + let moz = "http://mozilla.org"; + + pass({ url: moz, pattern: ["*"] }); + pass({ url: moz, pattern: ["http://*"] }); + pass({ url: moz, pattern: ["*mozilla*"] }); + // pass({url: moz, pattern: ["*example*", "*mozilla*"]}); + + pass({ url: moz, pattern: ["*://*"] }); + pass({ url: "https://mozilla.org", pattern: ["*://*"] }); + + // Documentation example + pass({ + url: "http://www.example.com/foo/bar", + pattern: ["http://???.example.com/foo/*"], + }); + pass({ + url: "http://the.example.com/foo/", + pattern: ["http://???.example.com/foo/*"], + }); + fail({ + url: "http://my.example.com/foo/bar", + pattern: ["http://???.example.com/foo/*"], + }); + fail({ + url: "http://example.com/foo/", + pattern: ["http://???.example.com/foo/*"], + }); + fail({ + url: "http://www.example.com/foo", + pattern: ["http://???.example.com/foo/*"], + }); + + // Matches path + let path = moz + "/abc/def"; + pass({ url: path, pattern: ["*def"] }); + pass({ url: path, pattern: ["*c/d*"] }); + pass({ url: path, pattern: ["*org/abc*"] }); + fail({ url: path + "/", pattern: ["*def"] }); + + // Trailing slash + pass({ url: moz, pattern: ["*.org/"] }); + fail({ url: moz, pattern: ["*.org"] }); + + // Wrong TLD + fail({ url: moz, pattern: ["*oz*.com/"] }); + // Case sensitive + fail({ url: moz, pattern: ["*.ORG/"] }); +}); + +add_task(async function test_MatchGlob_redundant_wildcards_backtracking() { + const slow_build = + AppConstants.DEBUG || AppConstants.TSAN || AppConstants.ASAN; + const first_limit = slow_build ? 200 : 20; + { + // Bug 1570868 - repeated * in tabs.query glob causes too much backtracking. + let title = `Monster${"*".repeat(99)}Mash`; + + // The first run could take longer than subsequent runs, as the DFA is lazily created. + let first_start = Date.now(); + let glob = new MatchGlob(title); + let first_matches = glob.matches(title); + let first_duration = Date.now() - first_start; + ok(first_matches, `Expected match: ${title}, ${title}`); + Assert.less( + first_duration, + first_limit, + `First matching duration: ${first_duration}ms (limit: ${first_limit}ms)` + ); + + let start = Date.now(); + let matches = glob.matches(title); + let duration = Date.now() - start; + + ok(matches, `Expected match: ${title}, ${title}`); + Assert.less(duration, 10, `Matching duration: ${duration}ms`); + } + { + // Similarly with any continuous combination of ?**???****? wildcards. + let title = `Monster${"?*".repeat(99)}Mash`; + + // The first run could take longer than subsequent runs, as the DFA is lazily created. + let first_start = Date.now(); + let glob = new MatchGlob(title); + let first_matches = glob.matches(title); + let first_duration = Date.now() - first_start; + ok(first_matches, `Expected match: ${title}, ${title}`); + Assert.less( + first_duration, + first_limit, + `First matching duration: ${first_duration}ms (limit: ${first_limit}ms)` + ); + + let start = Date.now(); + let matches = glob.matches(title); + let duration = Date.now() - start; + + ok(matches, `Expected match: ${title}, ${title}`); + Assert.less(duration, 10, `Matching duration: ${duration}ms`); + } +}); + +add_task(async function test_MatchPattern_subsumes() { + function test(oldPat, newPat) { + let m = new MatchPatternSet(oldPat); + return m.subsumes(new MatchPattern(newPat)); + } + + function pass({ oldPat, newPat }) { + ok(test(oldPat, newPat), `${JSON.stringify(oldPat)} subsumes "${newPat}"`); + } + + function fail({ oldPat, newPat }) { + ok( + !test(oldPat, newPat), + `${JSON.stringify(oldPat)} doesn't subsume "${newPat}"` + ); + } + + pass({ oldPat: ["<all_urls>"], newPat: "*://*/*" }); + pass({ oldPat: ["<all_urls>"], newPat: "http://*/*" }); + pass({ oldPat: ["<all_urls>"], newPat: "http://*.example.com/*" }); + + pass({ oldPat: ["*://*/*"], newPat: "http://*/*" }); + pass({ oldPat: ["*://*/*"], newPat: "wss://*/*" }); + pass({ oldPat: ["*://*/*"], newPat: "http://*.example.com/*" }); + + pass({ oldPat: ["*://*.example.com/*"], newPat: "http://*.example.com/*" }); + pass({ oldPat: ["*://*.example.com/*"], newPat: "*://sub.example.com/*" }); + + pass({ oldPat: ["https://*/*"], newPat: "https://*.example.com/*" }); + pass({ + oldPat: ["http://*.example.com/*"], + newPat: "http://subdomain.example.com/*", + }); + pass({ + oldPat: ["http://*.sub.example.com/*"], + newPat: "http://sub.example.com/*", + }); + pass({ + oldPat: ["http://*.sub.example.com/*"], + newPat: "http://sec.sub.example.com/*", + }); + pass({ + oldPat: ["http://www.example.com/*"], + newPat: "http://www.example.com/path/*", + }); + pass({ + oldPat: ["http://www.example.com/path/*"], + newPat: "http://www.example.com/*", + }); + + fail({ oldPat: ["*://*/*"], newPat: "<all_urls>" }); + fail({ oldPat: ["*://*/*"], newPat: "ftp://*/*" }); + fail({ oldPat: ["*://*/*"], newPat: "file://*/*" }); + + fail({ oldPat: ["http://example.com/*"], newPat: "*://example.com/*" }); + fail({ oldPat: ["http://example.com/*"], newPat: "https://example.com/*" }); + fail({ + oldPat: ["http://example.com/*"], + newPat: "http://otherexample.com/*", + }); + fail({ oldPat: ["http://example.com/*"], newPat: "http://*.example.com/*" }); + fail({ + oldPat: ["http://example.com/*"], + newPat: "http://subdomain.example.com/*", + }); + + fail({ + oldPat: ["http://subdomain.example.com/*"], + newPat: "http://example.com/*", + }); + fail({ + oldPat: ["http://subdomain.example.com/*"], + newPat: "http://*.example.com/*", + }); + fail({ + oldPat: ["http://sub.example.com/*"], + newPat: "http://*.sub.example.com/*", + }); + + fail({ oldPat: ["ws://example.com/*"], newPat: "wss://example.com/*" }); + fail({ oldPat: ["http://example.com/*"], newPat: "ws://example.com/*" }); + fail({ oldPat: ["https://example.com/*"], newPat: "wss://example.com/*" }); +}); + +add_task(async function test_MatchPattern_matchesAllWebUrls() { + function test(patterns, options) { + let m = new MatchPatternSet(patterns, options); + if (patterns.length === 1) { + // Sanity check: with a single pattern, MatchPatternSet and MatchPattern + // have equivalent outputs. + equal( + new MatchPattern(patterns[0], options).matchesAllWebUrls, + m.matchesAllWebUrls, + "matchesAllWebUrls() is consistent in MatchPattern and MatchPatternSet" + ); + } + return m.matchesAllWebUrls; + } + function pass(patterns, options) { + ok( + test(patterns, options), + `${JSON.stringify(patterns)} ${ + options ? JSON.stringify(options) : "" + } matches all web URLs` + ); + } + + function fail(patterns, options) { + ok( + !test(patterns, options), + `${JSON.stringify(patterns)} ${ + options ? JSON.stringify(options) : "" + } doesn't match all web URLs` + ); + } + + pass(["<all_urls>"]); + pass(["*://*/*"]); + pass(["*://*/"], { ignorePath: true }); + + fail(["*://*/"]); // missing path wildcard. + fail(["http://*/*"]); + fail(["https://*/*"]); + fail(["wss://*/*"]); + fail(["ws://*/*"]); + fail(["file://*/*"]); + + // Edge case: unusual number of wildcards in path. + pass(["*://*/**"]); + pass(["*://*/***"]); + pass(["*://*/***"], { ignorePath: true }); + fail(["*://*//***"]); + + // After the singular cases, test non-single cases. + fail([]); + pass(["<all_urls>", "https://example.com/"]); + pass(["https://example.com/", "http://example.com/", "*://*/*"]); + + pass(["https://*/*", "http://*/*"]); + pass(["https://*/", "http://*/"], { ignorePath: true }); + fail(["https://*/", "http://*/"]); // missing path wildcard everywhere. + fail(["https://*/*", "http://*/"]); // missing http://*/*. + fail(["https://*/", "http://*/*"]); // missing https://*/*. +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_QuarantinedDomains.js b/toolkit/components/extensions/test/xpcshell/test_QuarantinedDomains.js new file mode 100644 index 0000000000..fdb243150b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_QuarantinedDomains.js @@ -0,0 +1,392 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +AddonTestUtils.overrideCertDB(); +AddonTestUtils.usePrivilegedSignatures = id => id.startsWith("privileged"); + +add_setup(async () => { + await AddonTestUtils.promiseStartupManager(); +}); + +const server = createHttpServer({ hosts: ["example.org", "example.net"] }); +server.registerPathHandler("/", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +const ADDONS_RESTRICTED_DOMAINS_PREF = + "extensions.webextensions.addons-restricted-domains@mozilla.com.disabled"; + +const DOMAINS = [ + "addons-dev.allizom.org", + "mixed.badssl.com", + "careers.mozilla.com", + "developer.mozilla.org", + "test.example.com", +]; + +const CAN_ACCESS_ALL = DOMAINS.reduce((map, domain) => { + return { ...map, [domain]: true }; +}, {}); + +function makePolicy(options) { + return new WebExtensionPolicy({ + baseURL: "file:///foo/", + localizeCallback: str => str, + allowedOrigins: new MatchPatternSet(["<all_urls>"], { ignorePath: true }), + mozExtensionHostname: Services.uuid.generateUUID().toString().slice(1, -1), + ...options, + }); +} + +function makeCS(policy) { + return new WebExtensionContentScript(policy, { + matches: new MatchPatternSet(["<all_urls>"]), + }); +} + +function makeExtension({ id }) { + return ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id } }, + permissions: ["<all_urls>"], + content_scripts: [ + { + js: ["script.js"], + matches: ["<all_urls>"], + }, + ], + }, + useAddonManager: "permanent", + files: { + "script.js": ` + browser.test.sendMessage("tld", location.host.split(".").at(-1)); + browser.test.sendMessage("cs"); + `, + }, + }); +} + +function expectQuarantined(expectedDomains) { + for (let domain of DOMAINS) { + let uri = Services.io.newURI(`https://${domain}/`); + let quarantined = expectedDomains.includes(domain); + + equal( + quarantined, + WebExtensionPolicy.isQuarantinedURI(uri), + `Expect ${uri.spec} to ${quarantined ? "" : "not"} be quarantined.` + ); + } +} + +function expectAccess(policy, cs, expected) { + for (let domain of Object.keys(expected)) { + let uri = Services.io.newURI(`https://${domain}/`); + let access = expected[domain]; + let match = access; + + equal( + access, + !policy.quarantinedFromURI(uri), + `${policy.id} is ${access ? "not" : ""} quarantined from ${uri.spec}.` + ); + equal( + access, + policy.canAccessURI(uri), + `Expect ${policy.id} ${access ? "can" : "can't"} access ${uri.spec}.` + ); + + equal( + match, + cs.matchesURI(uri), + `Expect ${cs.extension.id} to ${match ? "" : "not"} match ${uri.spec}.` + ); + } +} + +function expectHost(desc, host, quarantined) { + let uri = Services.io.newURI(`https://${host}/`); + equal( + quarantined, + WebExtensionPolicy.isQuarantinedURI(uri), + `Expect ${desc} "${host}" to ${quarantined ? "" : "not"} be quarantined.` + ); +} + +function makePolicies() { + const plain = makePolicy({ id: "plain@test" }); + const system = makePolicy({ id: "system@test", isPrivileged: true }); + const exempt = makePolicy({ id: "exempt@test", ignoreQuarantine: true }); + + return { plain, system, exempt }; +} + +function makeContentScripts(policies) { + return policies.map(makeCS); +} + +add_task(async function test_QuarantinedDomains() { + const { plain, system, exempt } = makePolicies(); + const [plainCS, systemCS, exemptCS] = makeContentScripts([ + plain, + system, + exempt, + ]); + + info("Initial pref state is an empty list."); + expectQuarantined([]); + + expectAccess(plain, plainCS, CAN_ACCESS_ALL); + expectAccess(system, systemCS, CAN_ACCESS_ALL); + expectAccess(exempt, exemptCS, CAN_ACCESS_ALL); + + info("Default test domain list."); + Services.prefs.setStringPref( + "extensions.quarantinedDomains.list", + "addons-dev.allizom.org,mixed.badssl.com,test.example.com" + ); + + expectQuarantined([ + "addons-dev.allizom.org", + "mixed.badssl.com", + "test.example.com", + ]); + + const EXPECT_DEFAULTS = { + "addons-dev.allizom.org": false, + "mixed.badssl.com": false, + "careers.mozilla.com": true, + "developer.mozilla.org": true, + "test.example.com": false, + }; + + expectAccess(plain, plainCS, EXPECT_DEFAULTS); + expectAccess(system, systemCS, CAN_ACCESS_ALL); + expectAccess(exempt, exemptCS, CAN_ACCESS_ALL); + + info("Test changing policy.ignoreQuarantine after creation."); + + ok(!plain.ignoreQuarantine, "plain policy does not ignore quarantine."); + ok(system.ignoreQuarantine, "system policy does ignore quarantine."); + ok(exempt.ignoreQuarantine, "exempt policy does ignore quarantine."); + + plain.ignoreQuarantine = true; + system.ignoreQuarantine = false; + exempt.ignoreQuarantine = false; + + ok(plain.ignoreQuarantine, "expect plain.ignoreQuarantine to be true."); + ok(!system.ignoreQuarantine, "expect system.ignoreQuarantine to be false."); + ok(!exempt.ignoreQuarantine, "expect exempt.ignoreQuarantine to be false."); + + expectAccess(plain, plainCS, CAN_ACCESS_ALL); + expectAccess(system, systemCS, EXPECT_DEFAULTS); + expectAccess(exempt, exemptCS, EXPECT_DEFAULTS); + + plain.ignoreQuarantine = false; + system.ignoreQuarantine = true; + exempt.ignoreQuarantine = true; + + expectAccess(plain, plainCS, EXPECT_DEFAULTS); + expectAccess(system, systemCS, CAN_ACCESS_ALL); + expectAccess(exempt, exemptCS, CAN_ACCESS_ALL); + + info("Disable the Quarantined Domains feature."); + Services.prefs.setBoolPref("extensions.quarantinedDomains.enabled", false); + expectQuarantined([]); + + expectAccess(plain, plainCS, CAN_ACCESS_ALL); + expectAccess(system, systemCS, CAN_ACCESS_ALL); + expectAccess(exempt, exemptCS, CAN_ACCESS_ALL); + + info( + "Enable again, drop addons-dev.allizom.org and add developer.mozilla.org to the pref." + ); + Services.prefs.setBoolPref("extensions.quarantinedDomains.enabled", true); + + Services.prefs.setStringPref( + "extensions.quarantinedDomains.list", + "mixed.badssl.com,developer.mozilla.org,test.example.com" + ); + expectQuarantined([ + "mixed.badssl.com", + "developer.mozilla.org", + "test.example.com", + ]); + + expectAccess(plain, plainCS, { + "addons-dev.allizom.org": true, + "mixed.badssl.com": false, + "careers.mozilla.com": true, + "developer.mozilla.org": false, + "test.example.com": false, + }); + + expectAccess(system, systemCS, CAN_ACCESS_ALL); + expectAccess(exempt, exemptCS, CAN_ACCESS_ALL); + + expectHost("host with a port", "test.example.com:1025", true); + + expectHost("FQDN", "test.example.com.", false); + expectHost("subdomain", "subdomain.test.example.com", false); + expectHost("domain with prefix", "pretest.example.com", false); + expectHost("domain with suffix", "test.example.comsuf", false); +}); + +function setIgnorePref(id, value = false) { + Services.prefs.setBoolPref(`extensions.quarantineIgnoredByUser.${id}`, value); +} + +// Test that ignore prefs take effect in the content process. +add_task( + { pref_set: [["extensions.quarantinedDomains.list", "example.net"]] }, + async function test_QuarantinedDomains_ignored() { + const EXPECT_DEFAULTS = { "example.org": true, "example.net": false }; + const CAN_ACCESS_ALL = { "example.org": true, "example.net": true }; + + let alpha = makeExtension({ id: "alpha@test" }); + let beta = makeExtension({ id: "beta@test" }); + let system = makeExtension({ id: "privileged@test" }); + + let page = await ExtensionTestUtils.loadContentPage("about:blank"); + + await alpha.startup(); + await beta.startup(); + await system.startup(); + + equal(system.extension.isPrivileged, true, "is privileged"); + + let alphaPolicy = alpha.extension.policy; + let betaPolicy = beta.extension.policy; + + let alphaCounters = { org: 0, net: 0 }; + let betaCounters = { org: 0, net: 0 }; + + alpha.onMessage("tld", tld => alphaCounters[tld]++); + beta.onMessage("tld", tld => betaCounters[tld]++); + system.onMessage("tld", () => {}); + + async function testTLD(tld, expectAlpha, expectBeta) { + let alphaCount = alphaCounters[tld]; + let betaCount = betaCounters[tld]; + + await page.loadURL(`http://example.${tld}/`); + if (expectAlpha) { + await alpha.awaitMessage("cs"); + alphaCount++; + } + if (expectBeta) { + await beta.awaitMessage("cs"); + betaCount++; + } + // Sanity check, plus always having something to await. + await system.awaitMessage("cs"); + + equal(alphaCount, alphaCounters[tld], `Expected ${tld} alpha CS counter`); + equal(betaCount, betaCounters[tld], `Expected ${tld} beta CS counter`); + } + + info("Test defaults, example.org is accessible, example.net is not."); + + await testTLD("org", true, true); + await testTLD("net", false, false); + + expectAccess(alphaPolicy, alphaPolicy.contentScripts[0], EXPECT_DEFAULTS); + expectAccess(betaPolicy, betaPolicy.contentScripts[0], EXPECT_DEFAULTS); + + info("Test setting the pref for alpha@test."); + setIgnorePref("alpha@test", true); + + await testTLD("net", true, false); + await testTLD("org", true, true); + + expectAccess(alphaPolicy, alphaPolicy.contentScripts[0], CAN_ACCESS_ALL); + expectAccess(betaPolicy, betaPolicy.contentScripts[0], EXPECT_DEFAULTS); + + info("Test setting the pref for beta@test."); + setIgnorePref("beta@test", true); + + await testTLD("org", true, true); + await testTLD("net", true, true); + expectAccess(betaPolicy, betaPolicy.contentScripts[0], CAN_ACCESS_ALL); + + info("Test unsetting the pref for alpha@test."); + setIgnorePref("alpha@test", false); + + await testTLD("net", false, true); + await testTLD("org", true, true); + + info("Test unsetting the pref for beta@test."); + setIgnorePref("beta@test", false); + + await testTLD("org", true, true); + await testTLD("net", false, false); + + expectAccess(alphaPolicy, alphaPolicy.contentScripts[0], EXPECT_DEFAULTS); + expectAccess(betaPolicy, betaPolicy.contentScripts[0], EXPECT_DEFAULTS); + + Assert.deepEqual( + alphaCounters, + { org: 5, net: 2 }, + "Expected final Alpha content script counters." + ); + + Assert.deepEqual( + betaCounters, + { org: 5, net: 2 }, + "Expected final Beta content script counters." + ); + + await system.unload(); + await beta.unload(); + await alpha.unload(); + + await page.close(); + } +); + +// Make sure we honor the system add-on pref. +add_task( + { + pref_set: [ + [ADDONS_RESTRICTED_DOMAINS_PREF, true], + [ + "extensions.quarantinedDomains.list", + "addons-dev.allizom.org,mixed.badssl.com,test.example.com", + ], + ], + }, + async function test_QuarantinedDomains_with_system_addon_disabled() { + await AddonTestUtils.promiseRestartManager(); + + const { plain, system, exempt } = makePolicies(); + const [plainCS, systemCS, exemptCS] = makeContentScripts([ + plain, + system, + exempt, + ]); + + expectQuarantined([]); + expectAccess(plain, plainCS, CAN_ACCESS_ALL); + expectAccess(system, systemCS, CAN_ACCESS_ALL); + expectAccess(exempt, exemptCS, CAN_ACCESS_ALL); + + // When the user changes this pref to re-enable the system add-on... + Services.prefs.setBoolPref(ADDONS_RESTRICTED_DOMAINS_PREF, false); + // ...after a AOM restart... + await AddonTestUtils.promiseRestartManager(); + // ...we expect no change. + expectQuarantined([]); + expectAccess(plain, plainCS, CAN_ACCESS_ALL); + expectAccess(system, systemCS, CAN_ACCESS_ALL); + expectAccess(exempt, exemptCS, CAN_ACCESS_ALL); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_QuarantinedDomains_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_QuarantinedDomains_telemetry.js new file mode 100644 index 0000000000..c18785b3d6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_QuarantinedDomains_telemetry.js @@ -0,0 +1,99 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +ChromeUtils.defineESModuleGetters(this, { + computeSha1HashAsString: "resource://gre/modules/addons/crypto-utils.sys.mjs", + QuarantinedDomains: "resource://gre/modules/ExtensionPermissions.sys.mjs", +}); + +add_setup(() => { + setupTelemetryForTests(); +}); + +add_task(async function test_QuarantinedDomainsList_telemetry() { + const cleanupPrefs = () => { + Services.prefs.clearUserPref(QuarantinedDomains.PREF_DOMAINSLIST_NAME); + }; + registerCleanupFunction(cleanupPrefs); + + const assertDomainsListTelemetry = ({ prefValue, expected }) => { + resetTelemetryData(); + Services.prefs.setStringPref( + QuarantinedDomains.PREF_DOMAINSLIST_NAME, + prefValue + ); + Assert.deepEqual( + { + listsize: QuarantinedDomains.currentDomainsList.set.size, + listhash: QuarantinedDomains.currentDomainsList.hash, + }, + expected, + "Got the expected domains list data computed for the probes" + ); + Assert.deepEqual( + { + listsize: Glean.extensionsQuarantinedDomains.listsize.testGetValue(), + listhash: Glean.extensionsQuarantinedDomains.listhash.testGetValue(), + }, + expected, + "Got the expected computed domains list probes recorded by the Glean metrics" + ); + const scalars = Services.telemetry.getSnapshotForScalars().parent; + Assert.deepEqual( + { + listsize: scalars?.["extensions.quarantinedDomains.listsize"], + listhash: scalars?.["extensions.quarantinedDomains.listhash"], + }, + expected, + "Got the expected metrics mirrored into the unified telemetry scalars" + ); + }; + + let prefValue; + + info("Verify Glean 'Quarantined Domains list' probes on empty domain list"); + prefValue = ""; + assertDomainsListTelemetry({ + prefValue, + expected: { + listsize: 0, + listhash: computeSha1HashAsString(prefValue), + }, + }); + + info( + "Verify Glean 'Quarantined Domains list' probes on non-empty domain list" + ); + prefValue = "example.com,example.org"; + assertDomainsListTelemetry({ + prefValue, + expected: { + listsize: 2, + listhash: computeSha1HashAsString(prefValue), + }, + }); + + info( + "Verify Glean 'Quarantined Domains list' probes on non-empty domain list with duplicated domains" + ); + prefValue = "example.com,example.org, example.org, example.com "; + assertDomainsListTelemetry({ + prefValue, + expected: { + listsize: 2, + listhash: computeSha1HashAsString(prefValue), + }, + }); + + cleanupPrefs(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_StorageSyncService.js b/toolkit/components/extensions/test/xpcshell/test_StorageSyncService.js new file mode 100644 index 0000000000..ef55ed37e8 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_StorageSyncService.js @@ -0,0 +1,274 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const NS_ERROR_DOM_QUOTA_EXCEEDED_ERR = 0x80530016; + +XPCOMUtils.defineLazyServiceGetter( + this, + "StorageSyncService", + "@mozilla.org/extensions/storage/sync;1", + "nsIInterfaceRequestor" +); + +function promisify(func, ...params) { + return new Promise((resolve, reject) => { + let changes = []; + func(...params, { + QueryInterface: ChromeUtils.generateQI([ + "mozIExtensionStorageListener", + "mozIExtensionStorageCallback", + "mozIBridgedSyncEngineCallback", + "mozIBridgedSyncEngineApplyCallback", + ]), + onChanged(extId, json) { + changes.push({ extId, changes: JSON.parse(json) }); + }, + handleSuccess(value) { + resolve({ + changes, + value: typeof value == "string" ? JSON.parse(value) : value, + }); + }, + handleError(code, message) { + reject(Components.Exception(message, code)); + }, + }); + }); +} + +add_task(async function setup_storage_sync() { + // So that we can write to the profile directory. + do_get_profile(); +}); + +add_task(async function test_storage_sync_service() { + const service = StorageSyncService.getInterface(Ci.mozIExtensionStorageArea); + { + let { changes, value } = await promisify( + service.set, + "ext-1", + JSON.stringify({ + hi: "hello! 💖", + bye: "adiós", + }) + ); + deepEqual( + changes, + [ + { + extId: "ext-1", + changes: { + hi: { + newValue: "hello! 💖", + }, + bye: { + newValue: "adiós", + }, + }, + }, + ], + "`set` should notify listeners about changes" + ); + ok(!value, "`set` should not return a value"); + } + + { + let { changes, value } = await promisify( + service.get, + "ext-1", + JSON.stringify(["hi"]) + ); + deepEqual(changes, [], "`get` should not notify listeners"); + deepEqual( + value, + { + hi: "hello! 💖", + }, + "`get` with key should return value" + ); + + let { value: allValues } = await promisify(service.get, "ext-1", "null"); + deepEqual( + allValues, + { + hi: "hello! 💖", + bye: "adiós", + }, + "`get` without a key should return all values" + ); + } + + { + await promisify( + service.set, + "ext-2", + JSON.stringify({ + hi: "hola! 👋", + }) + ); + await promisify(service.clear, "ext-1"); + let { value: allValues } = await promisify(service.get, "ext-1", "null"); + deepEqual(allValues, {}, "clear removed ext-1"); + + let { value: allValues2 } = await promisify(service.get, "ext-2", "null"); + deepEqual(allValues2, { hi: "hola! 👋" }, "clear didn't remove ext-2"); + // We need to clear data for ext-2 too, so later tests don't fail due to + // this data. + await promisify(service.clear, "ext-2"); + } +}); + +add_task(async function test_storage_sync_bridged_engine() { + const area = StorageSyncService.getInterface(Ci.mozIExtensionStorageArea); + const engine = StorageSyncService.getInterface(Ci.mozIBridgedSyncEngine); + + info("Add some local items"); + await promisify(area.set, "ext-1", JSON.stringify({ a: "abc" })); + await promisify(area.set, "ext-2", JSON.stringify({ b: "xyz" })); + + info("Start a sync"); + await promisify(engine.syncStarted); + + info("Store some incoming synced items"); + let incomingEnvelopesAsJSON = [ + { + id: "guidAAA", + modified: 0.1, + payload: JSON.stringify({ + extId: "ext-2", + data: JSON.stringify({ + c: 1234, + }), + }), + }, + { + id: "guidBBB", + modified: 0.1, + payload: JSON.stringify({ + extId: "ext-3", + data: JSON.stringify({ + d: "new! ✨", + }), + }), + }, + ].map(e => JSON.stringify(e)); + await promisify(area.storeIncoming, incomingEnvelopesAsJSON); + + info("Merge"); + // Three levels of JSON wrapping: each outgoing envelope, the cleartext in + // each envelope, and the extension storage data in each cleartext payload. + let { value: outgoingEnvelopesAsJSON } = await promisify(area.apply); + let outgoingEnvelopes = outgoingEnvelopesAsJSON.map(json => JSON.parse(json)); + let parsedCleartexts = outgoingEnvelopes.map(e => JSON.parse(e.payload)); + let parsedData = parsedCleartexts.map(c => JSON.parse(c.data)); + + let { changes } = await promisify( + area.QueryInterface(Ci.mozISyncedExtensionStorageArea) + .fetchPendingSyncChanges + ); + deepEqual( + changes, + [ + { + extId: "ext-2", + changes: { + c: { newValue: 1234 }, + }, + }, + { + extId: "ext-3", + changes: { + d: { newValue: "new! ✨" }, + }, + }, + ], + "Should return pending synced changes for observers" + ); + + // ext-1 doesn't exist remotely yet, so the Rust sync layer will generate + // a GUID for it. We don't know what it is, so we find it by the extension + // ID. + let ext1Index = parsedCleartexts.findIndex(c => c.extId == "ext-1"); + greater(ext1Index, -1, "Should find envelope for ext-1"); + let ext1Guid = outgoingEnvelopes[ext1Index].id; + + // ext-2 has a remote GUID that we set in the test above. + let ext2Index = outgoingEnvelopes.findIndex(c => c.id == "guidAAA"); + greater(ext2Index, -1, "Should find envelope for ext-2"); + + equal(outgoingEnvelopes.length, 2, "Should upload ext-1 and ext-2"); + deepEqual( + parsedData[ext1Index], + { + a: "abc", + }, + "Should upload new data for ext-1" + ); + deepEqual( + parsedData[ext2Index], + { + b: "xyz", + c: 1234, + }, + "Should merge local and remote data for ext-2" + ); + + info("Mark all extensions as uploaded"); + await promisify(engine.setUploaded, 0, [ext1Guid, "guidAAA"]); + + info("Finish sync"); + await promisify(engine.syncFinished); + + // Try fetching values for the remote-only extension we just synced. + let { value: ext3Value } = await promisify(area.get, "ext-3", "null"); + deepEqual( + ext3Value, + { + d: "new! ✨", + }, + "Should return new keys for ext-3" + ); + + info("Try applying a second time"); + let secondApply = await promisify(area.apply); + deepEqual(secondApply.value, {}, "Shouldn't merge anything on second apply"); + + info("Wipe all items"); + await promisify(engine.wipe); + + for (let extId of ["ext-1", "ext-2", "ext-3"]) { + // `get` always returns an object, even if there are no keys for the + // extension ID. + let { value } = await promisify(area.get, extId, "null"); + deepEqual(value, {}, `Wipe should remove all values for ${extId}`); + } +}); + +add_task(async function test_storage_sync_quota() { + const service = StorageSyncService.getInterface(Ci.mozIExtensionStorageArea); + const engine = StorageSyncService.getInterface(Ci.mozIBridgedSyncEngine); + await promisify(engine.wipe); + await promisify(service.set, "ext-1", JSON.stringify({ x: "hi" })); + await promisify(service.set, "ext-1", JSON.stringify({ longer: "value" })); + + let { value: v1 } = await promisify(service.getBytesInUse, "ext-1", '"x"'); + Assert.equal(v1, 5); // key len without quotes, value len with quotes. + let { value: v2 } = await promisify(service.getBytesInUse, "ext-1", "null"); + // 5 from 'x', plus 'longer' (6 for key, 7 for value = 13) = 18. + Assert.equal(v2, 18); + + // Now set something greater than our quota. + await Assert.rejects( + promisify( + service.set, + "ext-1", + JSON.stringify({ + big: "x".repeat(Ci.mozIExtensionStorageArea.SYNC_QUOTA_BYTES), + }) + ), + ex => ex.result == NS_ERROR_DOM_QUOTA_EXCEEDED_ERR, + "should reject with NS_ERROR_DOM_QUOTA_EXCEEDED_ERR" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_WebExtensionContentScript.js b/toolkit/components/extensions/test/xpcshell/test_WebExtensionContentScript.js new file mode 100644 index 0000000000..fe05893f84 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_WebExtensionContentScript.js @@ -0,0 +1,323 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { newURI } = Services.io; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +async function test_url_matching({ + manifestVersion = 2, + allowedOrigins = [], + checkPermissions, + expectMatches, +}) { + let policy = new WebExtensionPolicy({ + id: "foo@bar.baz", + mozExtensionHostname: "88fb51cd-159f-4859-83db-7065485bc9b2", + baseURL: "file:///foo", + + manifestVersion, + allowedOrigins: new MatchPatternSet(allowedOrigins), + localizeCallback() {}, + }); + + let contentScript = new WebExtensionContentScript(policy, { + checkPermissions, + + matches: new MatchPatternSet(["http://*.foo.com/bar", "*://bar.com/baz/*"]), + + excludeMatches: new MatchPatternSet(["*://bar.com/baz/quux"]), + + includeGlobs: ["*flerg*", "*.com/bar", "*/quux"].map( + glob => new MatchGlob(glob) + ), + + excludeGlobs: ["*glorg*"].map(glob => new MatchGlob(glob)), + }); + + equal( + expectMatches, + contentScript.matchesURI(newURI("http://www.foo.com/bar")), + `Simple matches include should ${expectMatches ? "" : "not "} match.` + ); + + equal( + expectMatches, + contentScript.matchesURI(newURI("https://bar.com/baz/xflergx")), + `Simple matches include should ${expectMatches ? "" : "not "} match.` + ); + + ok( + !contentScript.matchesURI(newURI("https://bar.com/baz/xx")), + "Failed includeGlobs match pattern should not match" + ); + + ok( + !contentScript.matchesURI(newURI("https://bar.com/baz/quux")), + "Excluded match pattern should not match" + ); + + ok( + !contentScript.matchesURI(newURI("https://bar.com/baz/xflergxglorgx")), + "Excluded match glob should not match" + ); +} + +add_task(function test_WebExtensionContentScript_urls_mv2() { + return test_url_matching({ manifestVersion: 2, expectMatches: true }); +}); + +add_task(function test_WebExtensionContentScript_urls_mv2_checkPermissions() { + return test_url_matching({ + manifestVersion: 2, + checkPermissions: true, + expectMatches: false, + }); +}); + +add_task(function test_WebExtensionContentScript_urls_mv2_with_permissions() { + return test_url_matching({ + manifestVersion: 2, + checkPermissions: true, + allowedOrigins: ["<all_urls>"], + expectMatches: true, + }); +}); + +add_task(function test_WebExtensionContentScript_urls_mv3() { + // checkPermissions ignored here because it's forced for MV3. + return test_url_matching({ + manifestVersion: 3, + checkPermissions: false, + expectMatches: false, + }); +}); + +add_task(function test_WebExtensionContentScript_mv3_all_urls() { + return test_url_matching({ + manifestVersion: 3, + allowedOrigins: ["<all_urls>"], + expectMatches: true, + }); +}); + +add_task(function test_WebExtensionContentScript_mv3_wildcards() { + return test_url_matching({ + manifestVersion: 3, + allowedOrigins: ["*://*.foo.com/*", "*://*.bar.com/*"], + expectMatches: true, + }); +}); + +add_task(function test_WebExtensionContentScript_mv3_specific() { + return test_url_matching({ + manifestVersion: 3, + allowedOrigins: ["http://www.foo.com/*", "https://bar.com/*"], + expectMatches: true, + }); +}); + +add_task(function test_WebExtensionContentScript_restricted() { + let tests = [ + { + manifestVersion: 2, + permissions: [], + expect: false, + }, + { + manifestVersion: 2, + permissions: ["mozillaAddons"], + expect: true, + }, + { + manifestVersion: 3, + permissions: [], + expect: false, + }, + { + manifestVersion: 3, + permissions: ["mozillaAddons"], + expect: true, + }, + ]; + + for (let { manifestVersion, permissions, expect } of tests) { + let policy = new WebExtensionPolicy({ + id: "foo@bar.baz", + mozExtensionHostname: "88fb51cd-159f-4859-83db-7065485bc9b2", + baseURL: "file:///foo", + + manifestVersion, + permissions, + allowedOrigins: new MatchPatternSet(["<all_urls>"]), + localizeCallback() {}, + }); + let contentScript = new WebExtensionContentScript(policy, { + checkPermissions: true, + matches: new MatchPatternSet(["<all_urls>"]), + }); + + // AMO is on the extensions.webextensions.restrictedDomains list. + equal( + expect, + contentScript.matchesURI(newURI("https://addons.mozilla.org/foo")), + `Expect extension with [${permissions}] to ${expect ? "" : "not"} match` + ); + } +}); + +async function test_frame_matching(meta) { + if (AppConstants.platform == "linux") { + // The windowless browser currently does not load correctly on Linux on + // infra. + return; + } + + let baseURL = `http://example.com/data`; + let urls = { + topLevel: `${baseURL}/file_toplevel.html`, + iframe: `${baseURL}/file_iframe.html`, + srcdoc: "about:srcdoc", + aboutBlank: "about:blank", + }; + + let contentPage = await ExtensionTestUtils.loadContentPage(urls.topLevel); + + let tests = [ + { + matches: ["http://example.com/data/*"], + contentScript: {}, + topLevel: true, + iframe: false, + aboutBlank: false, + srcdoc: false, + }, + + { + matches: ["http://example.com/data/*"], + contentScript: { + frameID: 0, + }, + topLevel: true, + iframe: false, + aboutBlank: false, + srcdoc: false, + }, + + { + matches: ["http://example.com/data/*"], + contentScript: { + allFrames: true, + }, + topLevel: true, + iframe: true, + aboutBlank: false, + srcdoc: false, + }, + + { + matches: ["http://example.com/data/*"], + contentScript: { + allFrames: true, + matchAboutBlank: true, + }, + topLevel: true, + iframe: true, + aboutBlank: true, + srcdoc: true, + }, + + { + matches: ["http://foo.com/data/*"], + contentScript: { + allFrames: true, + matchAboutBlank: true, + }, + topLevel: false, + iframe: false, + aboutBlank: false, + srcdoc: false, + }, + ]; + + // matchesWindowGlobal tests against content frames + await contentPage.spawn([{ tests, urls, meta }], args => { + let { manifestVersion = 2, allowedOrigins = [], expectMatches } = args.meta; + + this.windows = new Map(); + this.windows.set(this.content.location.href, this.content); + for (let c of Array.from(this.content.frames)) { + this.windows.set(c.location.href, c); + } + const { MatchPatternSet, WebExtensionContentScript, WebExtensionPolicy } = + Cu.getGlobalForObject(Services); + this.policy = new WebExtensionPolicy({ + id: "foo@bar.baz", + mozExtensionHostname: "88fb51cd-159f-4859-83db-7065485bc9b2", + baseURL: "file:///foo", + + manifestVersion, + allowedOrigins: new MatchPatternSet(allowedOrigins), + localizeCallback() {}, + }); + + let tests = args.tests.map(t => { + t.contentScript.matches = new MatchPatternSet(t.matches); + t.script = new WebExtensionContentScript(this.policy, t.contentScript); + return t; + }); + for (let [i, test] of tests.entries()) { + for (let [frame, url] of Object.entries(args.urls)) { + let should = test[frame] ? "should" : "should not"; + let wgc = this.windows.get(url).windowGlobalChild; + Assert.equal( + test.script.matchesWindowGlobal(wgc), + test[frame] && expectMatches, + `Script ${i} ${should} match the ${frame} frame` + ); + } + } + }); + + await contentPage.close(); +} + +add_task(function test_WebExtensionContentScript_frames_mv2() { + return test_frame_matching({ + manifestVersion: 2, + expectMatches: true, + }); +}); + +add_task(function test_WebExtensionContentScript_frames_mv3() { + return test_frame_matching({ + manifestVersion: 3, + expectMatches: false, + }); +}); + +add_task(function test_WebExtensionContentScript_frames_mv3_all_urls() { + return test_frame_matching({ + manifestVersion: 3, + allowedOrigins: ["<all_urls>"], + expectMatches: true, + }); +}); + +add_task(function test_WebExtensionContentScript_frames_mv3_wildcards() { + return test_frame_matching({ + manifestVersion: 3, + allowedOrigins: ["*://*.example.com/*"], + expectMatches: true, + }); +}); + +add_task(function test_WebExtensionContentScript_frames_mv3_specific() { + return test_frame_matching({ + manifestVersion: 3, + allowedOrigins: ["http://example.com/*"], + expectMatches: true, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js b/toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js new file mode 100644 index 0000000000..ff2cc3c2ac --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js @@ -0,0 +1,620 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { newURI } = Services.io; + +add_task(async function test_WebExtensionPolicy() { + const id = "foo@bar.baz"; + const uuid = "ca9d3f23-125c-4b24-abfc-1ca2692b0610"; + + const baseURL = "file:///foo/"; + const mozExtURL = `moz-extension://${uuid}/`; + const mozExtURI = newURI(mozExtURL); + + let policy = new WebExtensionPolicy({ + id, + mozExtensionHostname: uuid, + baseURL, + + localizeCallback(str) { + return `<${str}>`; + }, + + allowedOrigins: new MatchPatternSet(["http://foo.bar/", "*://*.baz/"], { + ignorePath: true, + }), + permissions: ["<all_urls>"], + webAccessibleResources: [ + { + resources: ["/foo/*", "/bar.baz"].map(glob => new MatchGlob(glob)), + }, + ], + }); + + equal(policy.active, false, "Active attribute should initially be false"); + + // GetURL + + equal( + policy.getURL(), + mozExtURL, + "getURL() should return the correct root URL" + ); + equal( + policy.getURL("path/foo.html"), + `${mozExtURL}path/foo.html`, + "getURL(path) should return the correct URL" + ); + + // Permissions + + deepEqual( + policy.permissions, + ["<all_urls>"], + "Initial permissions should be correct" + ); + + ok( + policy.hasPermission("<all_urls>"), + "hasPermission should match existing permission" + ); + ok( + !policy.hasPermission("history"), + "hasPermission should not match nonexistent permission" + ); + + Assert.throws( + () => { + policy.permissions[0] = "foo"; + }, + TypeError, + "Permissions array should be frozen" + ); + + policy.permissions = ["history"]; + deepEqual( + policy.permissions, + ["history"], + "Permissions should be updateable as a set" + ); + + ok( + policy.hasPermission("history"), + "hasPermission should match existing permission" + ); + ok( + !policy.hasPermission("<all_urls>"), + "hasPermission should not match nonexistent permission" + ); + + // Origins + + ok( + policy.canAccessURI(newURI("http://foo.bar/quux")), + "Should be able to access permitted URI" + ); + ok( + policy.canAccessURI(newURI("https://x.baz/foo")), + "Should be able to access permitted URI" + ); + + ok( + !policy.canAccessURI(newURI("https://foo.bar/quux")), + "Should not be able to access non-permitted URI" + ); + + policy.allowedOrigins = new MatchPatternSet(["https://foo.bar/"], { + ignorePath: true, + }); + + ok( + policy.canAccessURI(newURI("https://foo.bar/quux")), + "Should be able to access updated permitted URI" + ); + ok( + !policy.canAccessURI(newURI("https://x.baz/foo")), + "Should not be able to access removed permitted URI" + ); + + // Web-accessible resources + + ok( + policy.isWebAccessiblePath("/foo/bar"), + "Web-accessible glob should be web-accessible" + ); + ok( + policy.isWebAccessiblePath("/bar.baz"), + "Web-accessible path should be web-accessible" + ); + ok( + !policy.isWebAccessiblePath("/bar.baz/quux"), + "Non-web-accessible path should not be web-accessible" + ); + + ok( + policy.sourceMayAccessPath(mozExtURI, "/bar.baz"), + "Web-accessible path should be web-accessible to self" + ); + + // Localization + + equal( + policy.localize("foo"), + "<foo>", + "Localization callback should work as expected" + ); + + // Protocol and lookups. + + let proto = Services.io + .getProtocolHandler("moz-extension", uuid) + .QueryInterface(Ci.nsISubstitutingProtocolHandler); + + deepEqual( + WebExtensionPolicy.getActiveExtensions(), + [], + "Should have no active extensions" + ); + equal( + WebExtensionPolicy.getByID(id), + null, + "ID lookup should not return extension when not active" + ); + equal( + WebExtensionPolicy.getByHostname(uuid), + null, + "Hostname lookup should not return extension when not active" + ); + Assert.throws( + () => proto.resolveURI(mozExtURI), + /NS_ERROR_NOT_AVAILABLE/, + "URL should not resolve when not active" + ); + + policy.active = true; + equal(policy.active, true, "Active attribute should be updated"); + + let exts = WebExtensionPolicy.getActiveExtensions(); + equal(exts.length, 1, "Should have one active extension"); + equal(exts[0], policy, "Should have the correct active extension"); + + equal( + WebExtensionPolicy.getByID(id), + policy, + "ID lookup should return extension when active" + ); + equal( + WebExtensionPolicy.getByHostname(uuid), + policy, + "Hostname lookup should return extension when active" + ); + + equal( + proto.resolveURI(mozExtURI), + baseURL, + "URL should resolve correctly while active" + ); + + policy.active = false; + equal(policy.active, false, "Active attribute should be updated"); + + deepEqual( + WebExtensionPolicy.getActiveExtensions(), + [], + "Should have no active extensions" + ); + equal( + WebExtensionPolicy.getByID(id), + null, + "ID lookup should not return extension when not active" + ); + equal( + WebExtensionPolicy.getByHostname(uuid), + null, + "Hostname lookup should not return extension when not active" + ); + Assert.throws( + () => proto.resolveURI(mozExtURI), + /NS_ERROR_NOT_AVAILABLE/, + "URL should not resolve when not active" + ); + + // Conflicting policies. + + // This asserts in debug builds, so only test in non-debug builds. + if (!AppConstants.DEBUG) { + policy.active = true; + + let attrs = [ + { id, uuid }, + { id, uuid: "d916886c-cfdf-482e-b7b1-d7f5b0facfa5" }, + { id: "foo@quux", uuid }, + ]; + + // eslint-disable-next-line no-shadow + for (let { id, uuid } of attrs) { + let policy2 = new WebExtensionPolicy({ + id, + mozExtensionHostname: uuid, + baseURL: "file://bar/", + + localizeCallback() {}, + + allowedOrigins: new MatchPatternSet([]), + }); + + Assert.throws( + () => { + policy2.active = true; + }, + /NS_ERROR_UNEXPECTED/, + `Should not be able to activate conflicting policy: ${id} ${uuid}` + ); + } + + policy.active = false; + } +}); + +// mozExtensionHostname is normalized to lower case when using +// policy.getURL whereas using policy.getByHostname does +// not. Tests below will fail without case insensitive +// comparisons in ExtensionPolicyService +add_task(async function test_WebExtensionPolicy_case_sensitivity() { + const id = "policy-case@mochitest"; + const uuid = "BAD93A23-125C-4B24-ABFC-1CA2692B0610"; + + const baseURL = "file:///foo/"; + const mozExtURL = `moz-extension://${uuid}/`; + const mozExtURI = newURI(mozExtURL); + + let policy = new WebExtensionPolicy({ + id: id, + mozExtensionHostname: uuid, + baseURL, + localizeCallback() {}, + allowedOrigins: new MatchPatternSet([]), + permissions: ["<all_urls>"], + }); + policy.active = true; + + equal( + WebExtensionPolicy.getByHostname(uuid)?.mozExtensionHostname, + policy.mozExtensionHostname, + "Hostname lookup should match policy" + ); + + equal( + WebExtensionPolicy.getByHostname(uuid.toLowerCase())?.mozExtensionHostname, + policy.mozExtensionHostname, + "Hostname lookup should match policy" + ); + + equal(policy.getURL(), mozExtURI.spec, "Urls should match policy"); + ok( + policy.sourceMayAccessPath(mozExtURI, "/bar.baz"), + "Extension path should be accessible to self" + ); + + policy.active = false; +}); + +add_task(async function test_WebExtensionPolicy_V3() { + const id = "foo@bar.baz"; + const uuid = "ca9d3f23-125c-4b24-abfc-1ca2692b0610"; + const id2 = "foo-2@bar.baz"; + const uuid2 = "89383c45-7db4-4999-83f7-f4cc246372cd"; + const id3 = "foo-3@bar.baz"; + const uuid3 = "56652231-D7E2-45D1-BDBD-BD3BFF80927E"; + + const baseURL = "file:///foo/"; + const mozExtURL = `moz-extension://${uuid}/`; + const mozExtURI = newURI(mozExtURL); + const fooSite = newURI("http://foo.bar/"); + const exampleSite = newURI("https://example.com/"); + + let policy = new WebExtensionPolicy({ + id, + mozExtensionHostname: uuid, + baseURL, + manifestVersion: 3, + + localizeCallback(str) { + return `<${str}>`; + }, + + allowedOrigins: new MatchPatternSet(["http://foo.bar/", "*://*.baz/"], { + ignorePath: true, + }), + permissions: ["<all_urls>"], + webAccessibleResources: [ + { + resources: ["/foo/*", "/bar.baz"].map(glob => new MatchGlob(glob)), + matches: ["http://foo.bar/"], + extension_ids: [id3], + }, + { + resources: ["/foo.bar.baz"].map(glob => new MatchGlob(glob)), + extension_ids: ["*"], + }, + ], + }); + policy.active = true; + equal( + WebExtensionPolicy.getByHostname(uuid), + policy, + "Hostname lookup should match policy" + ); + + let policy2 = new WebExtensionPolicy({ + id: id2, + mozExtensionHostname: uuid2, + baseURL, + localizeCallback() {}, + allowedOrigins: new MatchPatternSet([]), + permissions: ["<all_urls>"], + }); + policy2.active = true; + equal( + WebExtensionPolicy.getByHostname(uuid2), + policy2, + "Hostname lookup should match policy" + ); + + let policy3 = new WebExtensionPolicy({ + id: id3, + mozExtensionHostname: uuid3, + baseURL, + localizeCallback() {}, + allowedOrigins: new MatchPatternSet([]), + permissions: ["<all_urls>"], + }); + policy3.active = true; + equal( + WebExtensionPolicy.getByHostname(uuid3), + policy3, + "Hostname lookup should match policy" + ); + + ok( + policy.isWebAccessiblePath("/bar.baz"), + "Web-accessible path should be web-accessible" + ); + ok( + !policy.isWebAccessiblePath("/bar.baz/quux"), + "Non-web-accessible path should not be web-accessible" + ); + // Extension can always access itself + ok( + policy.sourceMayAccessPath(mozExtURI, "/bar.baz"), + "Web-accessible path should be accessible to self" + ); + ok( + policy.sourceMayAccessPath(mozExtURI, "/foo.bar.baz"), + "Web-accessible path should be accessible to self" + ); + + ok( + !policy.sourceMayAccessPath(newURI(`https://${uuid}/`), "/bar.baz"), + "Web-accessible path should not be accessible due to scheme mismatch" + ); + + // non-matching site cannot access url + ok( + policy.sourceMayAccessPath(fooSite, "/bar.baz"), + "Web-accessible path should be accessible to foo.bar site" + ); + ok( + !policy.sourceMayAccessPath(fooSite, "/foo.bar.baz"), + "Web-accessible path should not be accessible to foo.bar site" + ); + + // non-matching site cannot access url + ok( + !policy.sourceMayAccessPath(exampleSite, "/bar.baz"), + "Web-accessible path should not be accessible to example.com" + ); + ok( + !policy.sourceMayAccessPath(exampleSite, "/foo.bar.baz"), + "Web-accessible path should not be accessible to example.com" + ); + + let extURI = newURI(policy2.getURL("")); + ok( + !policy.sourceMayAccessPath(extURI, "/bar.baz"), + "Web-accessible path should not be accessible to other extension" + ); + ok( + policy.sourceMayAccessPath(extURI, "/foo.bar.baz"), + "Web-accessible path should be accessible to other extension" + ); + + extURI = newURI(policy3.getURL("")); + ok( + policy.sourceMayAccessPath(extURI, "/bar.baz"), + "Web-accessible path should be accessible to other extension" + ); + ok( + policy.sourceMayAccessPath(extURI, "/foo.bar.baz"), + "Web-accessible path should be accessible to other extension" + ); + + policy.active = false; + policy2.active = false; + policy3.active = false; +}); + +add_task(async function test_WebExtensionPolicy_registerContentScripts() { + const id = "foo@bar.baz"; + const uuid = "77a7b9d3-e73c-4cf3-97fb-1824868fe00f"; + + const id2 = "foo-2@bar.baz"; + const uuid2 = "89383c45-7db4-4999-83f7-f4cc246372cd"; + + const baseURL = "file:///foo/"; + + const mozExtURL = `moz-extension://${uuid}/`; + const mozExtURL2 = `moz-extension://${uuid2}/`; + + let policy = new WebExtensionPolicy({ + id, + mozExtensionHostname: uuid, + baseURL, + localizeCallback() {}, + allowedOrigins: new MatchPatternSet([]), + permissions: ["<all_urls>"], + }); + + let policy2 = new WebExtensionPolicy({ + id: id2, + mozExtensionHostname: uuid2, + baseURL, + localizeCallback() {}, + allowedOrigins: new MatchPatternSet([]), + permissions: ["<all_urls>"], + }); + + let script1 = new WebExtensionContentScript(policy, { + run_at: "document_end", + js: [`${mozExtURL}/registered-content-script.js`], + matches: new MatchPatternSet(["http://localhost/data/*"]), + }); + + let script2 = new WebExtensionContentScript(policy, { + run_at: "document_end", + css: [`${mozExtURL}/registered-content-style.css`], + matches: new MatchPatternSet(["http://localhost/data/*"]), + }); + + let script3 = new WebExtensionContentScript(policy2, { + run_at: "document_end", + css: [`${mozExtURL2}/registered-content-style.css`], + matches: new MatchPatternSet(["http://localhost/data/*"]), + }); + + deepEqual( + policy.contentScripts, + [], + "The policy contentScripts is initially empty" + ); + + policy.registerContentScript(script1); + + deepEqual( + policy.contentScripts, + [script1], + "script1 has been added to the policy contentScripts" + ); + + Assert.throws( + () => policy.registerContentScript(script1), + e => e.result == Cr.NS_ERROR_ILLEGAL_VALUE, + "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to register a script more than once" + ); + + Assert.throws( + () => policy.registerContentScript(script3), + e => e.result == Cr.NS_ERROR_ILLEGAL_VALUE, + "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to register a script related to " + + "a different extension" + ); + + Assert.throws( + () => policy.unregisterContentScript(script3), + e => e.result == Cr.NS_ERROR_ILLEGAL_VALUE, + "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to unregister a script related to " + + "a different extension" + ); + + deepEqual( + policy.contentScripts, + [script1], + "script1 has not been added twice" + ); + + policy.registerContentScript(script2); + + deepEqual( + policy.contentScripts, + [script1, script2], + "script2 has the last item of the policy contentScripts array" + ); + + policy.unregisterContentScript(script1); + + deepEqual( + policy.contentScripts, + [script2], + "script1 has been removed from the policy contentscripts" + ); + + Assert.throws( + () => policy.unregisterContentScript(script1), + e => e.result == Cr.NS_ERROR_ILLEGAL_VALUE, + "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to unregister a script more than once" + ); + + deepEqual( + policy.contentScripts, + [script2], + "the policy contentscripts is unmodified when unregistering an unknown contentScript" + ); + + policy.unregisterContentScript(script2); + + deepEqual( + policy.contentScripts, + [], + "script2 has been removed from the policy contentScripts" + ); +}); + +add_task(async function test_WebExtensionPolicy_static_themes_resources() { + const uuid = "0e7ae607-b5b3-4204-9838-c2138c14bc3c"; + const mozExtURL = `moz-extension://${uuid}/`; + const mozExtURI = newURI(mozExtURL); + + let policy = new WebExtensionPolicy({ + id: "test-extension@mochitest", + mozExtensionHostname: uuid, + baseURL: "file:///foo/foo/", + localizeCallback() {}, + allowedOrigins: new MatchPatternSet([]), + permissions: [], + }); + policy.active = true; + + let staticThemePolicy = new WebExtensionPolicy({ + id: "statictheme@bar.baz", + mozExtensionHostname: "164d05dc-b45b-4731-aefc-7c1691bae9a4", + baseURL: "file:///static_theme/", + type: "theme", + allowedOrigins: new MatchPatternSet([]), + localizeCallback() {}, + }); + + staticThemePolicy.active = true; + + ok( + staticThemePolicy.sourceMayAccessPath(mozExtURI, "/someresource.ext"), + "Active extensions should be allowed to access the static themes resources" + ); + + policy.active = false; + + ok( + !staticThemePolicy.sourceMayAccessPath(mozExtURI, "/someresource.ext"), + "Disabled extensions should be disallowed the static themes resources" + ); + + ok( + !staticThemePolicy.sourceMayAccessPath( + Services.io.newURI("http://example.com"), + "/someresource.ext" + ), + "Web content should be disallowed the static themes resources" + ); + + staticThemePolicy.active = false; +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_change_backgroundServiceWorker_enabled_pref_false.js b/toolkit/components/extensions/test/xpcshell/test_change_backgroundServiceWorker_enabled_pref_false.js new file mode 100644 index 0000000000..0be36788c1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_change_backgroundServiceWorker_enabled_pref_false.js @@ -0,0 +1,47 @@ +"use strict"; + +// extensions.backgroundServiceWorker.enabled=false is set in the test manifest +// because there is no guarantee that the pref value set at runtime takes effect +// due to the pref being declared "mirror: once". The value of this pref is +// frozen upon the first access to any "mirror:once" pref, and we can therefore +// not assume the pref value to be mutable at runtime. +const PREF_EXT_SW_ENABLED = "extensions.backgroundServiceWorker.enabled"; + +add_task(async function test_backgroundServiceWorkerEnabled() { + // Sanity check: + Assert.equal( + Services.prefs.getBoolPref(PREF_EXT_SW_ENABLED), + false, + "Pref value should be false" + ); + Assert.equal( + WebExtensionPolicy.backgroundServiceWorkerEnabled, + false, + "backgroundServiceWorkerEnabled should be false" + ); + + if (AppConstants.MOZ_WEBEXT_WEBIDL_ENABLED) { + Assert.ok( + !Services.prefs.prefIsLocked(PREF_EXT_SW_ENABLED), + "Pref should be not locked when MOZ_WEBEXT_WEBIDL_ENABLED is true" + ); + } else { + Assert.ok( + Services.prefs.prefIsLocked(PREF_EXT_SW_ENABLED), + "Pref should be locked when MOZ_WEBEXT_WEBIDL_ENABLED is false" + ); + Services.prefs.unlockPref(PREF_EXT_SW_ENABLED); + } + + // Flip pref and test result. + Services.prefs.setBoolPref(PREF_EXT_SW_ENABLED, true); + Assert.ok( + Services.prefs.getBoolPref(PREF_EXT_SW_ENABLED), + "pref can change after setting it" + ); + Assert.equal( + WebExtensionPolicy.backgroundServiceWorkerEnabled, + false, + "backgroundServiceWorkerEnabled is still false despite the pref flip" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_change_backgroundServiceWorker_enabled_pref_true.js b/toolkit/components/extensions/test/xpcshell/test_change_backgroundServiceWorker_enabled_pref_true.js new file mode 100644 index 0000000000..06286427f5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_change_backgroundServiceWorker_enabled_pref_true.js @@ -0,0 +1,85 @@ +"use strict"; + +// extensions.backgroundServiceWorker.enabled=true is set in the test manifest +// because there is no guarantee that the pref value set at runtime takes effect +// due to the pref being declared "mirror: once". The value of this pref is +// frozen upon the first access to any "mirror:once" pref, and we can therefore +// not assume the pref value to be mutable at runtime. +const PREF_EXT_SW_ENABLED = "extensions.backgroundServiceWorker.enabled"; +const defaultPrefs = Services.prefs.getDefaultBranch(""); + +add_task( + { skip_if: () => AppConstants.MOZ_WEBEXT_WEBIDL_ENABLED }, + async function test_when_extensions_webidl_bindings_disabled() { + Assert.equal( + defaultPrefs.getBoolPref(PREF_EXT_SW_ENABLED), + false, + "The default pref value should be false" + ); + Assert.ok( + Services.prefs.prefIsLocked(PREF_EXT_SW_ENABLED), + "Pref should be locked when MOZ_WEBEXT_WEBIDL_ENABLED is false" + ); + // Despite the pref set to true (see comment at PREF_EXT_SW_ENABLED), the + // pref should be locked to false when IDL is disabled. + Assert.equal( + Services.prefs.getBoolPref(PREF_EXT_SW_ENABLED), + false, + "Pref value should be the default value" + ); + Assert.equal( + WebExtensionPolicy.backgroundServiceWorkerEnabled, + false, + "backgroundServiceWorkerEnabled should be false" + ); + + Services.prefs.unlockPref(PREF_EXT_SW_ENABLED); + + // After unlocking the pref, the pref set to true in the test manifest + // should apply now. + Assert.ok( + Services.prefs.getBoolPref(PREF_EXT_SW_ENABLED), + "After unlocking the pref, the pref can have a non-default value" + ); + Assert.equal( + WebExtensionPolicy.backgroundServiceWorkerEnabled, + false, + "backgroundServiceWorkerEnabled is still false despite the pref flip" + ); + } +); + +add_task( + { skip_if: () => !AppConstants.MOZ_WEBEXT_WEBIDL_ENABLED }, + async function test_when_extensions_webidl_bindings_enabled() { + const defaultPrefValue = defaultPrefs.getBoolPref(PREF_EXT_SW_ENABLED); + info(`The default pref value is ${defaultPrefValue}`); + Assert.ok( + !Services.prefs.prefIsLocked(PREF_EXT_SW_ENABLED), + "Pref should not be locked when MOZ_WEBEXT_WEBIDL_ENABLED is true" + ); + // Note: Pref is set to true, see comment at PREF_EXT_SW_ENABLED. + Assert.equal( + Services.prefs.getBoolPref(PREF_EXT_SW_ENABLED), + true, + "Pref value should be true" + ); + Assert.equal( + WebExtensionPolicy.backgroundServiceWorkerEnabled, + true, + "backgroundServiceWorkerEnabled should be false" + ); + + // Flip pref and test result. + Services.prefs.setBoolPref(PREF_EXT_SW_ENABLED, false); + Assert.ok( + !Services.prefs.getBoolPref(PREF_EXT_SW_ENABLED), + "The pref can be flipped to a different value" + ); + Assert.equal( + WebExtensionPolicy.backgroundServiceWorkerEnabled, + true, + "backgroundServiceWorkerEnabled is still true despite the pref flip" + ); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_change_remote_mode.js b/toolkit/components/extensions/test/xpcshell/test_change_remote_mode.js new file mode 100644 index 0000000000..a6d22e8703 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_change_remote_mode.js @@ -0,0 +1,20 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function change_remote() { + let remote = Services.prefs.getBoolPref("extensions.webextensions.remote"); + Assert.equal( + WebExtensionPolicy.useRemoteWebExtensions, + remote, + "value of useRemoteWebExtensions matches the pref" + ); + + Services.prefs.setBoolPref("extensions.webextensions.remote", !remote); + + Assert.equal( + WebExtensionPolicy.useRemoteWebExtensions, + remote, + "value of useRemoteWebExtensions is still the same after changing the pref" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js b/toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js new file mode 100644 index 0000000000..c860d73cc8 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js @@ -0,0 +1,303 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); + +const ADDON_ID = "test@web.extension"; + +const aps = Cc["@mozilla.org/addons/policy-service;1"].getService( + Ci.nsIAddonPolicyService +); + +const v2_csp = Preferences.get( + "extensions.webextensions.base-content-security-policy" +); +const v3_csp = Preferences.get( + "extensions.webextensions.base-content-security-policy.v3" +); + +add_task(async function test_invalid_addon_csp() { + await Assert.throws( + () => aps.getBaseCSP("invalid@missing"), + /NS_ERROR_ILLEGAL_VALUE/, + "no base csp for non-existent addon" + ); + await Assert.throws( + () => aps.getExtensionPageCSP("invalid@missing"), + /NS_ERROR_ILLEGAL_VALUE/, + "no extension page csp for non-existent addon" + ); +}); + +add_task(async function test_policy_csp() { + equal( + aps.defaultCSP, + Preferences.get("extensions.webextensions.default-content-security-policy"), + "Expected default CSP value" + ); + + const CUSTOM_POLICY = "script-src: 'self' https://xpcshell.test.custom.csp"; + + let tests = [ + { + name: "manifest version 2, no custom policy", + policyData: {}, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest version 2, no custom policy", + policyData: { + manifestVersion: 2, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "version 2 custom extension policy", + policyData: { + extensionPageCSP: CUSTOM_POLICY, + }, + expectedPolicy: CUSTOM_POLICY, + }, + { + name: "manifest version 2 set, custom extension policy", + policyData: { + manifestVersion: 2, + extensionPageCSP: CUSTOM_POLICY, + }, + expectedPolicy: CUSTOM_POLICY, + }, + { + name: "manifest version 3, no custom policy", + policyData: { + manifestVersion: 3, + }, + expectedPolicy: aps.defaultCSPV3, + }, + { + name: "manifest 3 version set, custom extensionPage policy", + policyData: { + manifestVersion: 3, + extensionPageCSP: CUSTOM_POLICY, + }, + expectedPolicy: CUSTOM_POLICY, + }, + ]; + + let policy = null; + + function setExtensionCSP({ manifestVersion, extensionPageCSP }) { + if (policy) { + policy.active = false; + } + + policy = new WebExtensionPolicy({ + id: ADDON_ID, + mozExtensionHostname: ADDON_ID, + baseURL: "file:///", + + allowedOrigins: new MatchPatternSet([]), + localizeCallback() {}, + + manifestVersion, + extensionPageCSP, + }); + + policy.active = true; + } + + for (let test of tests) { + info(test.name); + setExtensionCSP(test.policyData); + equal( + aps.getBaseCSP(ADDON_ID), + test.policyData.manifestVersion == 3 ? v3_csp : v2_csp, + "baseCSP is correct" + ); + equal( + aps.getExtensionPageCSP(ADDON_ID), + test.expectedPolicy, + "extensionPageCSP is correct" + ); + } +}); + +add_task(async function test_extension_csp() { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + + ExtensionTestUtils.failOnSchemaWarnings(false); + + let extension_pages = "script-src 'self'; img-src 'none'"; + + let tests = [ + { + name: "manifest_v2 invalid csp results in default csp used", + manifest: { + content_security_policy: `script-src 'none'`, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest_v2 allows https protocol", + manifest: { + manifest_version: 2, + content_security_policy: `script-src 'self' https://example.com`, + }, + expectedPolicy: `script-src 'self' https://example.com`, + }, + { + name: "manifest_v2 allows unsafe-eval", + manifest: { + manifest_version: 2, + content_security_policy: `script-src 'self' 'unsafe-eval'`, + }, + expectedPolicy: `script-src 'self' 'unsafe-eval'`, + }, + { + name: "manifest_v2 allows wasm-unsafe-eval", + manifest: { + manifest_version: 2, + content_security_policy: `script-src 'self' 'wasm-unsafe-eval'`, + }, + expectedPolicy: `script-src 'self' 'wasm-unsafe-eval'`, + }, + { + // object-src used to require local sources, but now we accept anything. + name: "manifest_v2 allows object-src, with non-local sources", + manifest: { + manifest_version: 2, + content_security_policy: `script-src 'self'; object-src https:'`, + }, + expectedPolicy: `script-src 'self'; object-src https:'`, + }, + { + name: "manifest_v3 invalid csp results in default csp used", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'none'`, + }, + }, + expectedPolicy: aps.defaultCSPV3, + }, + { + name: "manifest_v3 forbidden protocol results in default csp used", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' https://*`, + }, + }, + expectedPolicy: aps.defaultCSPV3, + }, + { + name: "manifest_v3 forbidden eval results in default csp used", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' 'unsafe-eval'`, + }, + }, + expectedPolicy: aps.defaultCSPV3, + }, + { + name: "manifest_v3 disallows localhost", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' https://localhost`, + }, + }, + expectedPolicy: aps.defaultCSPV3, + }, + { + name: "manifest_v3 disallows 127.0.0.1", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' https://127.0.0.1`, + }, + }, + expectedPolicy: aps.defaultCSPV3, + }, + { + name: "manifest_v3 allows wasm-unsafe-eval", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' 'wasm-unsafe-eval'`, + }, + }, + expectedPolicy: `script-src 'self' 'wasm-unsafe-eval'`, + }, + { + // object-src used to require local sources, but now we accept anything. + name: "manifest_v3 allows object-src, with non-local sources", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self'; object-src https:'`, + }, + }, + expectedPolicy: `script-src 'self'; object-src https:'`, + }, + { + name: "manifest_v2 csp", + manifest: { + manifest_version: 2, + content_security_policy: extension_pages, + }, + expectedPolicy: extension_pages, + }, + { + name: "manifest_v2 with no csp, expect default", + manifest: { + manifest_version: 2, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest_v3 used with no csp, expect default", + manifest: { + manifest_version: 3, + }, + expectedPolicy: aps.defaultCSPV3, + }, + { + name: "manifest_v3 syntax used", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages, + }, + }, + expectedPolicy: extension_pages, + }, + ]; + + for (let test of tests) { + info(test.name); + let extension = ExtensionTestUtils.loadExtension({ + manifest: test.manifest, + }); + await extension.startup(); + let policy = WebExtensionPolicy.getByID(extension.id); + equal( + policy.baseCSP, + test.manifest.manifest_version == 3 ? v3_csp : v2_csp, + "baseCSP is correct" + ); + equal( + policy.extensionPageCSP, + test.expectedPolicy, + "extensionPageCSP is correct." + ); + await extension.unload(); + } + + ExtensionTestUtils.failOnSchemaWarnings(true); + + Services.prefs.clearUserPref("extensions.manifestV3.enabled"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_csp_validator.js b/toolkit/components/extensions/test/xpcshell/test_csp_validator.js new file mode 100644 index 0000000000..12ba3f93e9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_csp_validator.js @@ -0,0 +1,322 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const cps = Cc["@mozilla.org/addons/content-policy;1"].getService( + Ci.nsIAddonContentPolicy +); + +add_task(async function test_csp_validator_flags() { + let checkPolicy = (policy, flags, expectedResult, message = null) => { + info(`Checking policy: ${policy}`); + + let result = cps.validateAddonCSP(policy, flags); + equal(result, expectedResult); + }; + + let flags = Ci.nsIAddonContentPolicy; + + checkPolicy( + "default-src 'self'; script-src 'self' http://localhost", + 0, + "\u2018script-src\u2019 directive contains a forbidden http: protocol source", + "localhost disallowed" + ); + checkPolicy( + "default-src 'self'; script-src 'self' http://localhost", + flags.CSP_ALLOW_LOCALHOST, + null, + "localhost allowed" + ); + + checkPolicy( + "default-src 'self'; script-src 'self' 'unsafe-eval'", + 0, + "\u2018script-src\u2019 directive contains a forbidden 'unsafe-eval' keyword", + "eval disallowed" + ); + checkPolicy( + "default-src 'self'; script-src 'self' 'unsafe-eval'", + flags.CSP_ALLOW_EVAL, + null, + "eval allowed" + ); + + checkPolicy( + "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'", + 0, + "\u2018script-src\u2019 directive contains a forbidden 'wasm-unsafe-eval' keyword", + "wasm disallowed" + ); + checkPolicy( + "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'", + flags.CSP_ALLOW_WASM, + null, + "wasm allowed" + ); + checkPolicy( + "default-src 'self'; script-src 'self' 'unsafe-eval' 'wasm-unsafe-eval'", + flags.CSP_ALLOW_EVAL, + null, + "wasm and eval allowed" + ); + + checkPolicy( + "default-src 'self'; script-src 'self' https://example.com", + 0, + "\u2018script-src\u2019 directive contains a forbidden https: protocol source", + "remote disallowed" + ); + checkPolicy( + "default-src 'self'; script-src 'self' https://example.com", + flags.CSP_ALLOW_REMOTE, + null, + "remote allowed" + ); +}); + +add_task(async function test_csp_validator() { + let checkPolicy = (policy, expectedResult, message = null) => { + info(`Checking policy: ${policy}`); + + let result = cps.validateAddonCSP( + policy, + Ci.nsIAddonContentPolicy.CSP_ALLOW_ANY + ); + equal(result, expectedResult); + }; + + checkPolicy("script-src 'self';", null); + + // In the past, object-src was required to be secure and defaulted to 'self'. + // But that is no longer required (see bug 1766881). + checkPolicy("script-src 'self'; object-src 'self';", null); + checkPolicy("script-src 'self'; object-src https:;", null); + + let hash = + "'sha256-NjZhMDQ1YjQ1MjEwMmM1OWQ4NDBlYzA5N2Q1OWQ5NDY3ZTEzYTNmMzRmNjQ5NGU1MzlmZmQzMmMxYmIzNWYxOCAgLQo='"; + + checkPolicy( + `script-src 'self' https://com https://*.example.com moz-extension://09abcdef blob: filesystem: ${hash} 'unsafe-eval'; ` + + `object-src 'self' https://com https://*.example.com moz-extension://09abcdef blob: filesystem: ${hash}`, + null + ); + + checkPolicy( + "", + "Policy is missing a required \u2018script-src\u2019 directive" + ); + + checkPolicy( + "object-src 'none';", + "Policy is missing a required \u2018script-src\u2019 directive" + ); + + checkPolicy( + "default-src 'self' http:", + "Policy is missing a required \u2018script-src\u2019 directive", + "A strict default-src is required as a fallback if script-src is missing" + ); + + checkPolicy( + "default-src 'self' http:; script-src 'self'", + null, + "A valid script-src removes the need for a strict default-src fallback" + ); + + checkPolicy( + "default-src 'self'", + null, + "A valid default-src should count as a valid script-src" + ); + + checkPolicy( + "default-src 'self'; script-src 'self'", + null, + "A valid default-src should count as a valid script-src" + ); + + checkPolicy( + "default-src 'self'; script-src http://example.com", + "\u2018script-src\u2019 directive contains a forbidden http: protocol source", + "A valid default-src should not allow an invalid script-src directive" + ); + + checkPolicy( + "script-src 'none'", + "\u2018script-src\u2019 must include the source 'self'" + ); + + checkPolicy( + "script-src 'self' 'unsafe-inline'", + "\u2018script-src\u2019 directive contains a forbidden 'unsafe-inline' keyword" + ); + + // Localhost is always valid + for (let src of [ + "http://localhost", + "https://localhost", + "http://127.0.0.1", + "https://127.0.0.1", + ]) { + checkPolicy(`script-src 'self' ${src};`, null); + } + + let directives = ["script-src", "worker-src"]; + + for (let [directive, other] of [directives, directives.slice().reverse()]) { + for (let src of ["https://*", "https://*.blogspot.com", "https://*"]) { + checkPolicy( + `${directive} 'self' ${src}; ${other} 'self';`, + `https: wildcard sources in \u2018${directive}\u2019 directives must include at least one non-generic sub-domain (e.g., *.example.com rather than *.com)` + ); + } + + for (let protocol of ["http", "https"]) { + checkPolicy( + `${directive} 'self' ${protocol}:; ${other} 'self';`, + `${protocol}: protocol requires a host in \u2018${directive}\u2019 directives` + ); + } + + checkPolicy( + `${directive} 'self' http://example.com; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden http: protocol source` + ); + + for (let protocol of ["ftp", "meh"]) { + checkPolicy( + `${directive} 'self' ${protocol}:; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden ${protocol}: protocol source` + ); + } + + checkPolicy( + `${directive} 'self' 'nonce-01234'; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden 'nonce-*' keyword` + ); + } +}); + +add_task(async function test_csp_validator_extension_pages() { + let checkPolicy = (policy, expectedResult, message = null) => { + info(`Checking policy: ${policy}`); + + // While Schemas.jsm uses Ci.nsIAddonContentPolicy.CSP_ALLOW_WASM, we don't + // pass that here because we are only verifying that remote scripts are + // blocked here. + let result = cps.validateAddonCSP(policy, 0); + equal(result, expectedResult); + }; + + checkPolicy("script-src 'self';", null); + checkPolicy("script-src 'self'; worker-src 'none'", null); + checkPolicy("script-src 'self'; worker-src 'self'", null); + + // In the past, object-src was required to be secure and defaulted to 'self'. + // But that is no longer required (see bug 1766881). + checkPolicy("script-src 'self'; object-src 'self';", null); + checkPolicy("script-src 'self'; object-src https:;", null); + + let hash = + "'sha256-NjZhMDQ1YjQ1MjEwMmM1OWQ4NDBlYzA5N2Q1OWQ5NDY3ZTEzYTNmMzRmNjQ5NGU1MzlmZmQzMmMxYmIzNWYxOCAgLQo='"; + + checkPolicy( + `script-src 'self' moz-extension://09abcdef blob: filesystem: ${hash}; `, + null + ); + + for (let policy of ["", "script-src-elem 'none';", "worker-src 'none';"]) { + checkPolicy( + policy, + "Policy is missing a required \u2018script-src\u2019 directive" + ); + } + + checkPolicy( + "default-src 'self' http:; script-src 'self'", + null, + "A valid script-src removes the need for a strict default-src fallback" + ); + + checkPolicy( + "default-src 'self'", + null, + "A valid default-src should count as a valid script-src" + ); + + for (let directive of ["script-src", "worker-src"]) { + checkPolicy( + `default-src 'self'; ${directive} 'self'`, + null, + `A valid default-src should count as a valid ${directive}` + ); + checkPolicy( + `default-src 'self'; ${directive} http://example.com`, + `\u2018${directive}\u2019 directive contains a forbidden http: protocol source`, + `A valid default-src should not allow an invalid ${directive} directive` + ); + } + + checkPolicy( + "script-src 'none'", + "\u2018script-src\u2019 must include the source 'self'" + ); + + checkPolicy( + "script-src 'self' 'unsafe-inline';", + "\u2018script-src\u2019 directive contains a forbidden 'unsafe-inline' keyword" + ); + + checkPolicy( + "script-src 'self' 'unsafe-eval';", + "\u2018script-src\u2019 directive contains a forbidden 'unsafe-eval' keyword" + ); + + // Localhost is invalid + for (let src of [ + "http://localhost", + "https://localhost", + "http://127.0.0.1", + "https://127.0.0.1", + ]) { + const protocol = src.split(":")[0]; + checkPolicy( + `script-src 'self' ${src};`, + `\u2018script-src\u2019 directive contains a forbidden ${protocol}: protocol source` + ); + } + + let directives = ["script-src", "worker-src"]; + + for (let [directive, other] of [directives, directives.slice().reverse()]) { + for (let protocol of ["http", "https"]) { + checkPolicy( + `${directive} 'self' ${protocol}:; ${other} 'self';`, + `${protocol}: protocol requires a host in \u2018${directive}\u2019 directives` + ); + } + + checkPolicy( + `${directive} 'self' https://example.com; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden https: protocol source` + ); + + checkPolicy( + `${directive} 'self' http://example.com; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden http: protocol source` + ); + + for (let protocol of ["ftp", "meh"]) { + checkPolicy( + `${directive} 'self' ${protocol}:; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden ${protocol}: protocol source` + ); + } + + checkPolicy( + `${directive} 'self' 'nonce-01234'; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden 'nonce-*' keyword` + ); + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_MessageManagerProxy.js b/toolkit/components/extensions/test/xpcshell/test_ext_MessageManagerProxy.js new file mode 100644 index 0000000000..cb6ec47b52 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_MessageManagerProxy.js @@ -0,0 +1,77 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { MessageManagerProxy } = ChromeUtils.importESModule( + "resource://gre/modules/MessageManagerProxy.sys.mjs" +); + +class TestMessageManagerProxy extends MessageManagerProxy { + constructor(contentPage, identifier) { + super(contentPage.browser); + this.identifier = identifier; + this.contentPage = contentPage; + this.deferred = null; + } + + // Registers message listeners. Call dispose() once you've finished. + async setupPingPongListeners() { + await this.contentPage.loadFrameScript(`() => { + this.addMessageListener("test:MessageManagerProxy:Ping", ({data}) => { + this.sendAsyncMessage("test:MessageManagerProxy:Pong", "${this.identifier}:" + data); + }); + }`); + + // Register the listener here instead of during testPingPong, to make sure + // that the listener is correctly registered during the whole test. + this.addMessageListener("test:MessageManagerProxy:Pong", event => { + ok( + this.deferred, + `[${this.identifier}] expected to be waiting for ping-pong` + ); + this.deferred.resolve(event.data); + this.deferred = null; + }); + } + + async testPingPong(description) { + equal(this.deferred, null, "should not be waiting for a message"); + this.deferred = Promise.withResolvers(); + this.sendAsyncMessage("test:MessageManagerProxy:Ping", description); + let result = await this.deferred.promise; + equal(result, `${this.identifier}:${description}`, "Expected ping-pong"); + } +} + +// Tests that MessageManagerProxy continues to proxy messages after docshells +// have been swapped. +add_task(async function test_message_after_swapdocshells() { + let page1 = await ExtensionTestUtils.loadContentPage("about:blank"); + let page2 = await ExtensionTestUtils.loadContentPage("about:blank"); + + let testProxyOne = new TestMessageManagerProxy(page1, "page1"); + let testProxyTwo = new TestMessageManagerProxy(page2, "page2"); + + await testProxyOne.setupPingPongListeners(); + await testProxyTwo.setupPingPongListeners(); + + await testProxyOne.testPingPong("after setup (to 1)"); + await testProxyTwo.testPingPong("after setup (to 2)"); + + page1.browser.swapDocShells(page2.browser); + + await testProxyOne.testPingPong("after docshell swap (to 1)"); + await testProxyTwo.testPingPong("after docshell swap (to 2)"); + + // Swap again to verify that listeners are repeatedly moved when needed. + page1.browser.swapDocShells(page2.browser); + + await testProxyOne.testPingPong("after another docshell swap (to 1)"); + await testProxyTwo.testPingPong("after another docshell swap (to 2)"); + + // Verify that dispose() works regardless of the browser's validity. + await testProxyOne.dispose(); + await page1.close(); + await page2.close(); + await testProxyTwo.dispose(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_activityLog.js b/toolkit/components/extensions/test/xpcshell/test_ext_activityLog.js new file mode 100644 index 0000000000..00173f3a4d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_activityLog.js @@ -0,0 +1,78 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.usePrivilegedSignatures = false; +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_setup(async () => { + await AddonTestUtils.promiseStartupManager(); +}); + +// This test should produce a warning, but still startup +add_task(async function test_api_restricted() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id: "activityLog-permission@tests.mozilla.org" }, + }, + permissions: ["activityLog"], + }, + async background() { + browser.test.assertEq( + undefined, + browser.activityLog, + "activityLog is privileged" + ); + }, + useAddonManager: "permanent", + }); + await extension.startup(); + await extension.unload(); +}); + +// This test should produce a error and not startup +add_task( + { + // Some builds (e.g. thunderbird) have experiments enabled by default. + pref_set: [["extensions.experiments.enabled", false]], + }, + async function test_api_restricted_temporary_without_privilege() { + let extension = ExtensionTestUtils.loadExtension({ + temporarilyInstalled: true, + isPrivileged: false, + manifest: { + browser_specific_settings: { + gecko: { id: "activityLog-permission@tests.mozilla.org" }, + }, + permissions: ["activityLog"], + }, + }); + ExtensionTestUtils.failOnSchemaWarnings(false); + let { messages } = await promiseConsoleOutput(async () => { + await Assert.rejects( + extension.startup(), + /Using the privileged permission/, + "Startup failed with privileged permission" + ); + }); + ExtensionTestUtils.failOnSchemaWarnings(true); + AddonTestUtils.checkMessages( + messages, + { + expected: [ + { + message: + /Using the privileged permission 'activityLog' requires a privileged add-on/, + }, + ], + }, + true + ); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_private_field_xrays.js b/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_private_field_xrays.js new file mode 100644 index 0000000000..2d8b02bcd9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_private_field_xrays.js @@ -0,0 +1,160 @@ +"use strict"; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function test_contentscript_private_field_xrays() { + async function contentScript() { + let node = window.document.createElement("div"); + + class Base { + constructor(o) { + return o; + } + } + + class A extends Base { + #x = 5; + static gx(o) { + return o.#x; + } + static sx(o, v) { + o.#x = v; + } + } + + browser.test.log(A.toString()); + + // Stamp node with A's private field. + new A(node); + + browser.test.log("stamped"); + + browser.test.assertEq( + A.gx(node), + 5, + "We should be able to see our expando private field" + ); + browser.test.log("Read"); + browser.test.assertThrows( + () => A.gx(node.wrappedJSObject), + /can't access private field or method/, + "Underlying object should not have our private field" + ); + + browser.test.log("threw"); + window.frames[0].document.adoptNode(node); + browser.test.log("adopted"); + browser.test.assertEq( + A.gx(node), + 5, + "Adoption should not change expando private field" + ); + browser.test.log("read"); + browser.test.assertThrows( + () => A.gx(node.wrappedJSObject), + /can't access private field or method/, + "Adoption should really not change expandos private fields" + ); + browser.test.log("threw2"); + + // Repeat but now with an object that has a reference from the + // window it's being cloned into. + node = window.document.createElement("div"); + // Stamp node with A's private field. + new A(node); + A.sx(node, 6); + + browser.test.assertEq( + A.gx(node), + 6, + "We should be able to see our expando (2)" + ); + browser.test.assertThrows( + () => A.gx(node.wrappedJSObject), + /can't access private field or method/, + "Underlying object should not have exxpando. (2)" + ); + + window.frames[0].wrappedJSObject.incoming = node.wrappedJSObject; + window.frames[0].document.adoptNode(node); + + browser.test.assertEq( + A.gx(node), + 6, + "We should be able to see our expando (3)" + ); + browser.test.assertThrows( + () => A.gx(node.wrappedJSObject), + /can't access private field or method/, + "Underlying object should not have exxpando. (3)" + ); + + // Repeat once more, now with an expando that refers to the object itself + node = window.document.createElement("div"); + new A(node); + A.sx(node, node); + + browser.test.assertEq( + A.gx(node), + node, + "We should be able to see our self-referential expando (4)" + ); + browser.test.assertThrows( + () => A.gx(node.wrappedJSObject), + /can't access private field or method/, + "Underlying object should not have exxpando. (4)" + ); + + window.frames[0].document.adoptNode(node); + + browser.test.assertEq( + A.gx(node), + node, + "Adoption should not change our self-referential expando (4)" + ); + browser.test.assertThrows( + () => A.gx(node.wrappedJSObject), + /can't access private field or method/, + "Adoption should not change underlying object. (4)" + ); + + // And test what happens if we now set document.domain and cause + // wrapper remapping. + let doc = window.frames[0].document; + // eslint-disable-next-line no-self-assign + doc.domain = doc.domain; + + browser.test.notifyPass("privateFieldXRayAdoption"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_toplevel.html"], + js: ["content_script.js"], + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_toplevel.html` + ); + + await extension.awaitFinish("privateFieldXRayAdoption"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_xrays.js b/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_xrays.js new file mode 100644 index 0000000000..9655c157d1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_xrays.js @@ -0,0 +1,129 @@ +"use strict"; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function test_contentscript_xrays() { + async function contentScript() { + let node = window.document.createElement("div"); + node.expando = 5; + + browser.test.assertEq( + node.expando, + 5, + "We should be able to see our expando" + ); + browser.test.assertEq( + node.wrappedJSObject.expando, + undefined, + "Underlying object should not have our expando" + ); + + window.frames[0].document.adoptNode(node); + browser.test.assertEq( + node.expando, + 5, + "Adoption should not change expandos" + ); + browser.test.assertEq( + node.wrappedJSObject.expando, + undefined, + "Adoption should really not change expandos" + ); + + // Repeat but now with an object that has a reference from the + // window it's being cloned into. + node = window.document.createElement("div"); + node.expando = 6; + + browser.test.assertEq( + node.expando, + 6, + "We should be able to see our expando (2)" + ); + browser.test.assertEq( + node.wrappedJSObject.expando, + undefined, + "Underlying object should not have our expando (2)" + ); + + window.frames[0].wrappedJSObject.incoming = node.wrappedJSObject; + + window.frames[0].document.adoptNode(node); + browser.test.assertEq( + node.expando, + 6, + "Adoption should not change expandos (2)" + ); + browser.test.assertEq( + node.wrappedJSObject.expando, + undefined, + "Adoption should really not change expandos (2)" + ); + + // Repeat once more, now with an expando that refers to the object itself. + node = window.document.createElement("div"); + node.expando = node; + + browser.test.assertEq( + node.expando, + node, + "We should be able to see our self-referential expando (3)" + ); + browser.test.assertEq( + node.wrappedJSObject.expando, + undefined, + "Underlying object should not have our self-referential expando (3)" + ); + + window.frames[0].document.adoptNode(node); + browser.test.assertEq( + node.expando, + node, + "Adoption should not change self-referential expando (3)" + ); + browser.test.assertEq( + node.wrappedJSObject.expando, + undefined, + "Adoption should really not change self-referential expando (3)" + ); + + // And test what happens if we now set document.domain and cause + // wrapper remapping. + let doc = window.frames[0].document; + // eslint-disable-next-line no-self-assign + doc.domain = doc.domain; + + browser.test.notifyPass("contentScriptAdoptionWithXrays"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_toplevel.html"], + js: ["content_script.js"], + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_toplevel.html` + ); + + await extension.awaitFinish("contentScriptAdoptionWithXrays"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_alarms.js b/toolkit/components/extensions/test/xpcshell/test_ext_alarms.js new file mode 100644 index 0000000000..892a82e2e3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms.js @@ -0,0 +1,346 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +add_task( + { + // TODO(Bug 1725478): remove the skip if once webidl API bindings will be hidden based on permissions. + skip_if: () => ExtensionTestUtils.isInBackgroundServiceWorkerTests(), + }, + async function test_alarm_without_permissions() { + function backgroundScript() { + browser.test.assertTrue( + !browser.alarms, + "alarm API is not available when the alarm permission is not required" + ); + browser.test.notifyPass("alarms_permission"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: [], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarms_permission"); + await extension.unload(); + } +); + +add_task(async function test_alarm_clear_non_matching_name() { + async function backgroundScript() { + let ALARM_NAME = "test_ext_alarms"; + + browser.alarms.create(ALARM_NAME, { when: Date.now() + 2000000 }); + + let wasCleared = await browser.alarms.clear(ALARM_NAME + "1"); + browser.test.assertFalse(wasCleared, "alarm was not cleared"); + + let alarms = await browser.alarms.getAll(); + browser.test.assertEq(1, alarms.length, "alarm was not removed"); + browser.test.notifyPass("alarm-clear"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-clear"); + await extension.unload(); +}); + +add_task(async function test_alarm_get_and_clear_single_argument() { + async function backgroundScript() { + browser.alarms.create({ when: Date.now() + 2000000 }); + + let alarm = await browser.alarms.get(); + browser.test.assertEq("", alarm.name, "expected alarm returned"); + + let wasCleared = await browser.alarms.clear(); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + let alarms = await browser.alarms.getAll(); + browser.test.assertEq(0, alarms.length, "alarm was removed"); + + browser.test.notifyPass("alarm-single-arg"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-single-arg"); + await extension.unload(); +}); + +// This test case covers the behavior of browser.alarms.create when the +// first optional argument (the alarm name) is passed explicitly as null +// or undefined instead of being omitted. +add_task(async function test_alarm_name_arg_null_or_undefined() { + async function backgroundScript(alarmName) { + browser.alarms.create(alarmName, { when: Date.now() + 2000000 }); + + let alarm = await browser.alarms.get(); + browser.test.assertTrue(alarm, "got an alarm"); + browser.test.assertEq("", alarm.name, "expected alarm returned"); + + let wasCleared = await browser.alarms.clear(); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + let alarms = await browser.alarms.getAll(); + browser.test.assertEq(0, alarms.length, "alarm was removed"); + + browser.test.notifyPass("alarm-test-done"); + } + + for (const alarmName of [null, undefined]) { + info(`Test alarm.create with alarm name ${alarmName}`); + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})(${alarmName})`, + manifest: { + permissions: ["alarms"], + }, + }); + await extension.startup(); + await extension.awaitFinish("alarm-test-done"); + await extension.unload(); + } +}); + +add_task(async function test_get_get_all_clear_all_alarms() { + async function backgroundScript() { + const ALARM_NAME = "test_alarm"; + + let suffixes = [0, 1, 2]; + + for (let suffix of suffixes) { + browser.alarms.create(ALARM_NAME + suffix, { + when: Date.now() + (suffix + 1) * 10000, + }); + } + + let alarms = await browser.alarms.getAll(); + browser.test.assertEq( + suffixes.length, + alarms.length, + "expected number of alarms were found" + ); + alarms.forEach((alarm, index) => { + browser.test.assertEq( + ALARM_NAME + index, + alarm.name, + "alarm has the expected name" + ); + }); + + for (let suffix of suffixes) { + let alarm = await browser.alarms.get(ALARM_NAME + suffix); + browser.test.assertEq( + ALARM_NAME + suffix, + alarm.name, + "alarm has the expected name" + ); + browser.test.sendMessage(`get-${suffix}`); + } + + let wasCleared = await browser.alarms.clear(ALARM_NAME + suffixes[0]); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + alarms = await browser.alarms.getAll(); + browser.test.assertEq(2, alarms.length, "alarm was removed"); + + let alarm = await browser.alarms.get(ALARM_NAME + suffixes[0]); + browser.test.assertEq(undefined, alarm, "non-existent alarm is undefined"); + browser.test.sendMessage(`get-invalid`); + + wasCleared = await browser.alarms.clearAll(); + browser.test.assertTrue(wasCleared, "alarms were cleared"); + + alarms = await browser.alarms.getAll(); + browser.test.assertEq(0, alarms.length, "no alarms exist"); + browser.test.sendMessage("clearAll"); + browser.test.sendMessage("clear"); + browser.test.sendMessage("getAll"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await Promise.all([ + extension.startup(), + extension.awaitMessage("getAll"), + extension.awaitMessage("get-0"), + extension.awaitMessage("get-1"), + extension.awaitMessage("get-2"), + extension.awaitMessage("clear"), + extension.awaitMessage("get-invalid"), + extension.awaitMessage("clearAll"), + ]); + await extension.unload(); +}); + +function getAlarmExtension(alarmCreateOptions, extOpts = {}) { + info( + `Test alarms.create fires with options: ${JSON.stringify( + alarmCreateOptions + )}` + ); + + function backgroundScript(createOptions) { + let ALARM_NAME = "test_ext_alarms"; + let timer; + + browser.alarms.onAlarm.addListener(alarm => { + browser.test.assertEq( + ALARM_NAME, + alarm.name, + "alarm has the expected name" + ); + clearTimeout(timer); + browser.test.sendMessage("alarms-create-with-options"); + }); + + browser.alarms.create(ALARM_NAME, createOptions); + + timer = setTimeout(async () => { + browser.test.fail("alarm fired within expected time"); + let wasCleared = await browser.alarms.clear(ALARM_NAME); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + browser.test.sendMessage("alarms-create-with-options"); + }, 10000); + } + + let { persistent, useAddonManager } = extOpts; + return ExtensionTestUtils.loadExtension({ + useAddonManager, + // Pass the alarms.create options to the background page. + background: `(${backgroundScript})(${JSON.stringify(alarmCreateOptions)})`, + manifest: { + permissions: ["alarms"], + background: { persistent }, + }, + }); +} + +async function test_alarm_fires_with_options(alarmCreateOptions) { + let extension = getAlarmExtension(alarmCreateOptions); + + await extension.startup(); + await extension.awaitMessage("alarms-create-with-options"); + + // Defer unloading the extension so the asynchronous event listener + // reply finishes. + await new Promise(resolve => setTimeout(resolve, 0)); + await extension.unload(); +} + +add_task(async function test_alarm_fires() { + Services.prefs.setBoolPref( + "privacy.resistFingerprinting.reduceTimerPrecision.jitter", + false + ); + + await test_alarm_fires_with_options({ delayInMinutes: 0.01 }); + await test_alarm_fires_with_options({ when: Date.now() + 1000 }); + await test_alarm_fires_with_options({ delayInMinutes: -10 }); + await test_alarm_fires_with_options({ when: Date.now() - 1000 }); + + Services.prefs.clearUserPref( + "privacy.resistFingerprinting.reduceTimerPrecision.jitter" + ); +}); + +function trackEvents(wrapper) { + let events = new Map(); + for (let event of ["background-script-event", "start-background-script"]) { + events.set(event, false); + wrapper.extension.once(event, () => events.set(event, true)); + } + return events; +} + +add_task( + { + // TODO(Bug 1748665): remove the skip once background service worker is also + // woken up by persistent listeners. + skip_if: () => ExtensionTestUtils.isInBackgroundServiceWorkerTests(), + pref_set: [ + ["privacy.resistFingerprinting.reduceTimerPrecision.jitter", false], + ["extensions.eventPages.enabled", true], + ], + }, + async function test_alarm_persists() { + await AddonTestUtils.promiseStartupManager(); + + let extension = getAlarmExtension( + { periodInMinutes: 0.01 }, + { useAddonManager: "permanent", persistent: false } + ); + info(`wait startup`); + await extension.startup(); + assertPersistentListeners(extension, "alarms", "onAlarm", { + primed: false, + }); + info(`wait first alarm`); + await extension.awaitMessage("alarms-create-with-options"); + + await extension.terminateBackground({ disableResetIdleForTest: true }); + ok( + !extension.extension.backgroundContext, + "Background Extension context should have been destroyed" + ); + + assertPersistentListeners(extension, "alarms", "onAlarm", { + primed: true, + }); + + // Test an early startup event + let events = trackEvents(extension); + ok( + !events.get("background-script-event"), + "Should not have received a background script event" + ); + ok( + !events.get("start-background-script"), + "Background script should not be started" + ); + + info("waiting for alarm to wake background"); + await extension.awaitMessage("alarms-create-with-options"); + ok( + events.get("background-script-event"), + "Should have received a background script event" + ); + ok( + events.get("start-background-script"), + "Background script should be started" + ); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_alarms_does_not_fire.js b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_does_not_fire.js new file mode 100644 index 0000000000..fe385004ba --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_does_not_fire.js @@ -0,0 +1,34 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +add_task(async function test_cleared_alarm_does_not_fire() { + async function backgroundScript() { + let ALARM_NAME = "test_ext_alarms"; + + browser.alarms.onAlarm.addListener(alarm => { + browser.test.fail("cleared alarm does not fire"); + browser.test.notifyFail("alarm-cleared"); + }); + browser.alarms.create(ALARM_NAME, { when: Date.now() + 1000 }); + + let wasCleared = await browser.alarms.clear(ALARM_NAME); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + browser.test.notifyPass("alarm-cleared"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-cleared"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_alarms_periodic.js b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_periodic.js new file mode 100644 index 0000000000..b78d6da649 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_periodic.js @@ -0,0 +1,50 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +add_task(async function test_periodic_alarm_fires() { + function backgroundScript() { + const ALARM_NAME = "test_ext_alarms"; + let count = 0; + let timer; + + browser.alarms.onAlarm.addListener(alarm => { + browser.test.assertEq( + alarm.name, + ALARM_NAME, + "alarm has the expected name" + ); + if (count++ === 3) { + clearTimeout(timer); + browser.alarms.clear(ALARM_NAME).then(wasCleared => { + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + browser.test.notifyPass("alarm-periodic"); + }); + } + }); + + browser.alarms.create(ALARM_NAME, { periodInMinutes: 0.02 }); + + timer = setTimeout(async () => { + browser.test.fail("alarm fired expected number of times"); + + let wasCleared = await browser.alarms.clear(ALARM_NAME); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + browser.test.notifyFail("alarm-periodic"); + }, 30000); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-periodic"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_alarms_replaces.js b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_replaces.js new file mode 100644 index 0000000000..0d7597fa5a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_replaces.js @@ -0,0 +1,56 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_duplicate_alarm_name_replaces_alarm() { + function backgroundScript() { + let count = 0; + + browser.alarms.onAlarm.addListener(async alarm => { + browser.test.assertEq( + "replaced alarm", + alarm.name, + "Expected last alarm" + ); + browser.test.assertEq( + 0, + count++, + "duplicate named alarm replaced existing alarm" + ); + let results = await browser.alarms.getAll(); + + // "replaced alarm" is expected to be replaced with a non-repeating + // alarm, so it should not appear in the list of alarms. + browser.test.assertEq(1, results.length, "exactly one alarms exists"); + browser.test.assertEq( + "unrelated alarm", + results[0].name, + "remaining alarm has the expected name" + ); + + browser.test.notifyPass("alarm-duplicate"); + }); + + // Alarm that is so far in the future that it is never triggered. + browser.alarms.create("unrelated alarm", { delayInMinutes: 60 }); + // Alarm that repeats. + browser.alarms.create("replaced alarm", { + delayInMinutes: 1 / 60, + periodInMinutes: 1 / 60, + }); + // Before the repeating alarm is triggered, it is immediately replaced with + // a non-repeating alarm. + browser.alarms.create("replaced alarm", { delayInMinutes: 3 / 60 }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-duplicate"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_api_events_listener_calls_exceptions.js b/toolkit/components/extensions/test/xpcshell/test_ext_api_events_listener_calls_exceptions.js new file mode 100644 index 0000000000..44ff592d83 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_api_events_listener_calls_exceptions.js @@ -0,0 +1,369 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ExtensionStorageIDB } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionStorageIDB.sys.mjs" +); + +// Detect if the current build is still using the legacy storage.sync Kinto-based backend +// (currently only GeckoView builds does have that still enabled). +// +// TODO(Bug 1625257): remove this once the rust-based storage.sync backend has been enabled +// also on GeckoView build and the legacy Kinto-based backend has been ripped off. +const storageSyncKintoEnabled = Services.prefs.getBoolPref( + "webextensions.storage.sync.kinto" +); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); +server.registerPathHandler("/test-page.html", (req, res) => { + res.setHeader("Content-Type", "text/html", false); + res.write(`<!DOCTYPE html> + <html><body><script> + window.onerror = (evt) => { + browser.test.log("webpage page got error event, error property set to: " + String(evt.error) + "::" + + evt.error?.stack + "\\n"); + window.postMessage( + { + message: evt.message, + sourceName: evt.filename, + lineNumber: evt.lineno, + columnNumber: evt.colno, + errorIsDefined: !!evt.error, + }, + "*" + ); + }; + window.errorListenerReady = true; + </script></body></html> + `); +}); + +add_task(async function test_api_listener_call_exception() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "storage", + "webRequest", + "webRequestBlocking", + "http://example.com/*", + ], + content_scripts: [ + { + js: ["contentscript.js"], + matches: ["http://example.com/test-page.html"], + run_at: "document_start", + }, + ], + }, + files: { + "contentscript.js": () => { + window.onload = () => { + browser.test.assertEq( + window.wrappedJSObject.errorListenerReady, + true, + "Got an onerror listener on the content page side" + ); + browser.test.sendMessage("contentscript-attached"); + }; + // eslint-disable-next-line mozilla/balanced-listeners + window.addEventListener("message", evt => { + browser.test.fail( + `Webpage got notified on an exception raised from the content script: ${JSON.stringify( + evt.data + )}` + ); + }); + // eslint-disable-next-line mozilla/balanced-listeners + window.addEventListener("error", evt => { + const errorDetails = { + message: evt.message, + sourceName: evt.filename, + lineNumber: evt.lineno, + columnNumber: evt.colno, + errorIsDefined: !!evt.error, + }; + browser.test.fail( + `Webpage got notified on an exception raised from the content script: ${JSON.stringify( + errorDetails + )}` + ); + }); + const throwAnError = () => { + throw new Error("test-contentscript-error"); + }; + browser.storage.sync.onChanged.addListener(() => { + throwAnError(); + }); + + browser.storage.local.onChanged.addListener(() => { + throw undefined; // eslint-disable-line no-throw-literal + }); + }, + "extpage.html": `<!DOCTYPE html><script src="extpage.js"></script>`, + "extpage.js": () => { + // eslint-disable-next-line mozilla/balanced-listeners + window.addEventListener("error", evt => { + browser.test.log( + `Extension page got error event, error property set to: ${evt.error} :: ${evt.error?.stack}\n` + ); + const errorDetails = { + message: evt.message, + sourceName: evt.filename, + lineNumber: evt.lineno, + columnNumber: evt.colno, + errorIsDefined: !!evt.error, + }; + + // Theoretically the exception thrown by a listener registered + // from an extension webpage should be emitting an error event + // (e.g. like for a DOM Event listener in a similar scenario), + // but we never emitted it and so it would be better to only emit + // it after have explicitly accepted the slightly change in behavior. + browser.test.log( + `extension page got notified on an exception raised from the API event listener: ${JSON.stringify( + errorDetails + )}` + ); + }); + browser.webRequest.onBeforeRequest.addListener( + () => { + throw new Error(`Mock webRequest listener exception`); + }, + { urls: ["http://example.com/data/*"] }, + ["blocking"] + ); + + // An object with a custom getter for the `message` property and a custom + // toString method, both are triggering a test failure to make sure we do + // catch with a failure if we are running the extension code as a side effect + // of logging the error to the console service. + const nonError = { + get message() { + browser.test.fail(`Unexpected extension code executed`); + }, + + toString() { + browser.test.fail(`Unexpected extension code executed`); + }, + }; + browser.storage.sync.onChanged.addListener(() => { + throw nonError; + }); + + // Throwing undefined or null is also allowed and so we cover that here as well + // to confirm we are not making any assumption about the value being raised to + // be always defined. + browser.storage.local.onChanged.addListener(() => { + throw undefined; // eslint-disable-line no-throw-literal + }); + }, + }, + }); + + await extension.startup(); + + const page = await ExtensionTestUtils.loadContentPage( + extension.extension.baseURI.resolve("extpage.html"), + { extension } + ); + + // Prepare to collect the error reported for the exception being triggered + // by the test itself. + const prepareWaitForConsoleMessage = () => { + this.content.waitForConsoleMessage = new Promise(resolve => { + const currInnerWindowID = this.content.windowGlobalChild?.innerWindowId; + const consoleListener = { + QueryInterface: ChromeUtils.generateQI(["nsIConsoleListener"]), + observe: message => { + if ( + message instanceof Ci.nsIScriptError && + message.innerWindowID === currInnerWindowID + ) { + resolve({ + message: message.message, + category: message.category, + sourceName: message.sourceName, + hasStack: !!message.stack, + }); + Services.console.unregisterListener(consoleListener); + } + }, + }; + Services.console.registerListener(consoleListener); + }); + }; + + const notifyStorageSyncListener = extensionTestWrapper => { + // The notifyListeners method from ExtensionStorageSyncKinto does use + // the Extension class instance as the key for the storage.sync listeners + // map, whereas ExtensionStorageSync does use the extension id instead. + // + // TODO(Bug 1625257): remove this once the rust-based storage.sync backend has been enabled + // also on GeckoView build and the legacy Kinto-based backend has been ripped off. + let listenersMapKey = storageSyncKintoEnabled + ? extensionTestWrapper.extension + : extensionTestWrapper.id; + ok( + ExtensionParent.apiManager.global.extensionStorageSync.listeners.has( + listenersMapKey + ), + "Got a storage.sync onChanged listener for the test extension" + ); + ExtensionParent.apiManager.global.extensionStorageSync.notifyListeners( + listenersMapKey, + {} + ); + }; + + // Retrieve the message collected from the previously created promise. + const asyncAssertConsoleMessage = async ({ + targetPage, + expectedErrorRegExp, + expectedSourceName, + shouldIncludeStack, + }) => { + const { message, category, sourceName, hasStack } = await targetPage.spawn( + [], + () => this.content.waitForConsoleMessage + ); + + ok( + expectedErrorRegExp.test(message), + `Got the expected error message: ${message}` + ); + + Assert.deepEqual( + { category, sourceName, hasStack }, + { + category: "content javascript", + sourceName: expectedSourceName, + hasStack: shouldIncludeStack, + }, + "Expected category and sourceName are set on the nsIScriptError" + ); + }; + + { + info("Test exception raised by webRequest listener"); + const expectedErrorRegExp = new RegExp( + `Error: Mock webRequest listener exception` + ); + const expectedSourceName = + extension.extension.baseURI.resolve("extpage.js"); + await page.spawn([], prepareWaitForConsoleMessage); + await ExtensionTestUtils.fetch( + "http://example.com", + "http://example.com/data/file_sample.html" + ); + await asyncAssertConsoleMessage({ + targetPage: page, + expectedErrorRegExp, + expectedSourceName, + // TODO(Bug 1810582): this should be expected to be true. + shouldIncludeStack: false, + }); + } + + { + info("Test exception raised by storage.sync listener"); + // The listener has throw an object that isn't an Error instance and + // it also has a getter for the message property, we expect it to be + // logged using the string returned by the native toString method. + const expectedErrorRegExp = new RegExp( + `uncaught exception: \\[object Object\\]` + ); + // TODO(Bug 1810582): this should be expected to be the script url + // where the exception has been originated from. + const expectedSourceName = + extension.extension.baseURI.resolve("extpage.html"); + + await page.spawn([], prepareWaitForConsoleMessage); + notifyStorageSyncListener(extension); + await asyncAssertConsoleMessage({ + targetPage: page, + expectedErrorRegExp, + expectedSourceName, + // TODO(Bug 1810582): this should be expected to be true. + shouldIncludeStack: false, + }); + } + + { + info("Test exception raised by storage.local listener"); + // The listener has throw an object that isn't an Error instance and + // it also has a getter for the message property, we expect it to be + // logged using the string returned by the native toString method. + const expectedErrorRegExp = new RegExp(`uncaught exception: undefined`); + // TODO(Bug 1810582): this should be expected to be the script url + // where the exception has been originated from. + const expectedSourceName = + extension.extension.baseURI.resolve("extpage.html"); + await page.spawn([], prepareWaitForConsoleMessage); + ExtensionStorageIDB.notifyListeners(extension.id, {}); + await asyncAssertConsoleMessage({ + targetPage: page, + expectedErrorRegExp, + expectedSourceName, + // TODO(Bug 1810582): this should be expected to be true. + shouldIncludeStack: false, + }); + } + + await page.close(); + + info("Test content script API event listeners exception"); + + const contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/test-page.html" + ); + + await extension.awaitMessage("contentscript-attached"); + + { + info("Test exception raised by content script storage.sync listener"); + // The listener has throw an object that isn't an Error instance and + // it also has a getter for the message property, we expect it to be + // logged using the string returned by the native toString method. + const expectedErrorRegExp = new RegExp(`Error: test-contentscript-error`); + const expectedSourceName = + extension.extension.baseURI.resolve("contentscript.js"); + + await contentPage.spawn([], prepareWaitForConsoleMessage); + notifyStorageSyncListener(extension); + await asyncAssertConsoleMessage({ + targetPage: contentPage, + expectedErrorRegExp, + expectedSourceName, + // TODO(Bug 1810582): this should be expected to be true. + shouldIncludeStack: false, + }); + } + + { + info("Test exception raised by content script storage.local listener"); + // The listener has throw an object that isn't an Error instance and + // it also has a getter for the message property, we expect it to be + // logged using the string returned by the native toString method. + const expectedErrorRegExp = new RegExp(`uncaught exception: undefined`); + // TODO(Bug 1810582): this should be expected to be the script url + // where the exception has been originated from. + const expectedSourceName = extension.extension.baseURI.resolve("/"); + + await contentPage.spawn([], prepareWaitForConsoleMessage); + ExtensionStorageIDB.notifyListeners(extension.id, {}); + await asyncAssertConsoleMessage({ + targetPage: contentPage, + expectedErrorRegExp, + expectedSourceName, + // TODO(Bug 1810582): this should be expected to be true. + shouldIncludeStack: false, + }); + } + + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js b/toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js new file mode 100644 index 0000000000..8083f5c920 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js @@ -0,0 +1,75 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { Management } = ChromeUtils.importESModule( + "resource://gre/modules/Extension.sys.mjs" +); +function getNextContext() { + return new Promise(resolve => { + Management.on("proxy-context-load", function listener(type, context) { + Management.off("proxy-context-load", listener); + resolve(context); + }); + }); +} + +add_task(async function test_storage_api_without_permissions() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + // Force API initialization. + try { + browser.storage.onChanged.addListener(() => {}); + } catch (e) { + // Ignore. + } + }, + + manifest: { + permissions: [], + }, + }); + + let contextPromise = getNextContext(); + await extension.startup(); + + let context = await contextPromise; + + // Force API initialization. + void context.apiObj; + + ok( + !("storage" in context.apiObj), + "The storage API should not be initialized" + ); + + await extension.unload(); +}); + +add_task(async function test_storage_api_with_permissions() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.storage.onChanged.addListener(() => {}); + }, + + manifest: { + permissions: ["storage"], + }, + }); + + let contextPromise = getNextContext(); + await extension.startup(); + + let context = await contextPromise; + + // Force API initialization. + void context.apiObj; + + equal( + typeof context.apiObj.storage, + "object", + "The storage API should be initialized" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_asyncAPICall_isHandlingUserInput.js b/toolkit/components/extensions/test/xpcshell/test_ext_asyncAPICall_isHandlingUserInput.js new file mode 100644 index 0000000000..73593b7e81 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_asyncAPICall_isHandlingUserInput.js @@ -0,0 +1,149 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ExtensionAPI } = ExtensionCommon; + +const API_CLASS = class extends ExtensionAPI { + getAPI(context) { + return { + testMockAPI: { + async anAsyncAPIMethod(...args) { + const callContextDataBeforeAwait = context.callContextData; + await Promise.resolve(); + const callContextDataAfterAwait = context.callContextData; + return { + args, + callContextDataBeforeAwait, + callContextDataAfterAwait, + }; + }, + }, + }; + } +}; + +const API_SCRIPT = ` + this.testMockAPI = ${API_CLASS.toString()}; +`; + +const API_SCHEMA = [ + { + namespace: "testMockAPI", + functions: [ + { + name: "anAsyncAPIMethod", + type: "function", + async: true, + parameters: [ + { + name: "param1", + type: "object", + additionalProperties: { + type: "string", + }, + }, + { + name: "param2", + type: "string", + }, + ], + }, + ], + }, +]; + +const MODULE_INFO = { + testMockAPI: { + schema: `data:,${JSON.stringify(API_SCHEMA)}`, + scopes: ["addon_parent"], + paths: [["testMockAPI"]], + url: URL.createObjectURL(new Blob([API_SCRIPT])), + }, +}; + +add_setup(async function () { + // The blob:-URL registered above in MODULE_INFO gets loaded at + // https://searchfox.org/mozilla-central/rev/0fec57c05d3996cc00c55a66f20dd5793a9bfb5d/toolkit/components/extensions/ExtensionCommon.jsm#1649 + Services.prefs.setBoolPref( + "security.allow_parent_unrestricted_js_loads", + true + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("security.allow_parent_unrestricted_js_loads"); + }); + + ExtensionParent.apiManager.registerModules(MODULE_INFO); +}); + +add_task( + async function test_propagated_isHandlingUserInput_on_async_api_methods_calls() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: "@test-ext" } }, + }, + background() { + browser.test.onMessage.addListener(async (msg, args) => { + if (msg !== "async-method-call") { + browser.test.fail(`Unexpected test message: ${msg}`); + return; + } + + try { + let result = await browser.testMockAPI.anAsyncAPIMethod(...args); + browser.test.sendMessage("async-method-call:result", result); + } catch (err) { + browser.test.sendMessage("async-method-call:error", err.message); + } + }); + }, + }); + + await extension.startup(); + + const callArgs = [{ param1: "param1" }, "param2"]; + + info("Test API method called without handling user input"); + + extension.sendMessage("async-method-call", callArgs); + const result = await extension.awaitMessage("async-method-call:result"); + Assert.deepEqual( + result?.args, + callArgs, + "Got the expected parameters when called without handling user input" + ); + Assert.deepEqual( + result?.callContextDataBeforeAwait, + { isHandlingUserInput: false }, + "Got the expected callContextData before awaiting on a promise" + ); + Assert.deepEqual( + result?.callContextDataAfterAwait, + null, + "context.callContextData should have been nullified after awaiting on a promise" + ); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("async-method-call", callArgs); + const result = await extension.awaitMessage("async-method-call:result"); + Assert.deepEqual( + result?.args, + callArgs, + "Got the expected parameters when called while handling user input" + ); + Assert.deepEqual( + result?.callContextDataBeforeAwait, + { isHandlingUserInput: true }, + "Got the expected callContextData before awaiting on a promise" + ); + Assert.deepEqual( + result?.callContextDataAfterAwait, + null, + "context.callContextData should have been nullified after awaiting on a promise" + ); + }); + + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_api_injection.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_api_injection.js new file mode 100644 index 0000000000..2a0bdba156 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_api_injection.js @@ -0,0 +1,41 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function testBackgroundWindow() { + // Background navigates to http:-URL. + // TODO bug 1286083: Disallow background navigation. + allow_unsafe_parent_loads_when_extensions_not_remote(); + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.log("background script executed"); + window.location = + "http://example.com/data/file_privilege_escalation.html"; + }, + }); + + let awaitConsole = new Promise(resolve => { + Services.console.registerListener(function listener(message) { + if (/WebExt Privilege Escalation/.test(message.message)) { + Services.console.unregisterListener(listener); + resolve(message); + } + }); + }); + + await extension.startup(); + + let message = await awaitConsole; + ok( + message.message.includes( + "WebExt Privilege Escalation: typeof(browser) = undefined" + ), + "Document does not have `browser` APIs." + ); + + await extension.unload(); + + revert_allow_unsafe_parent_loads_when_extensions_not_remote(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_early_shutdown.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_early_shutdown.js new file mode 100644 index 0000000000..706f6d0a67 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_early_shutdown.js @@ -0,0 +1,905 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { ExtensionTestCommon } = ChromeUtils.importESModule( + "resource://testing-common/ExtensionTestCommon.sys.mjs" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +let { promiseRestartManager, promiseShutdownManager, promiseStartupManager } = + AddonTestUtils; + +const { ExtensionProcessCrashObserver, Management } = + ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + +const server = AddonTestUtils.createHttpServer({ + hosts: ["example.com"], +}); +function registerSlowStyleSheet() { + // We can delay DOMContentLoaded of a background page by loading a slow + // stylesheet and using `<script defer>`. For more detail about this + // trick, see test_ext_background_iframe.js. + let allowStylesheetToLoad; + let stylesheetBlockerPromise = new Promise(resolve => { + allowStylesheetToLoad = resolve; + }); + let resolveFirstLoad; + let firstLoadPromise = new Promise(resolve => { + resolveFirstLoad = resolve; + }); + let requestCount = 0; + server.registerPathHandler("/slow.css", (request, response) => { + response.setHeader("Cache-Control", "no-cache", false); + response.setHeader("Content-Type", "text/css", false); + response.processAsync(); + ++requestCount; + resolveFirstLoad(); + stylesheetBlockerPromise.then(() => { + response.write("body { color: rgb(1, 2, 3); }"); + response.finish(); + }); + }); + const getRequestCount = () => requestCount; + return { allowStylesheetToLoad, getRequestCount, firstLoadPromise }; +} + +// We can only crash the extension process when they are running out-of-process. +// Otherwise we would be killing the test runner itself... +const CAN_CRASH_EXTENSIONS = WebExtensionPolicy.useRemoteWebExtensions; + +add_setup(function () { + // Set a high threshold because this test crashes a few times on purpose and + // we don't want to disable process spawning. + Services.prefs.setIntPref("extensions.webextensions.crash.threshold", 100); + + // Need a profile to init Glean. + do_get_profile(); + Services.fog.initializeFOG(); + + Assert.equal( + ExtensionProcessCrashObserver.appInForeground, + AppConstants.platform !== "android", + "Expect appInForeground to be initially true on desktop and false on android builds" + ); + + // For Android build we mock the app moving in the foreground for the first time + // (which, in a real Fenix instance, happens when the application receives the first + // call to the onPause lifecycle callback and the geckoview-initial-foreground + // topic is being notified to Gecko as a side-effect of that). + // + // We have to mock the app moving in the foreground before any of the test extension + // startup, so that both Desktop and Mobile builds are in the same initial foreground + // state for the rest of the test file. + if (AppConstants.platform === "android") { + info("Mock geckoview-initial-foreground observer service topic"); + ExtensionProcessCrashObserver.observe(null, "geckoview-initial-foreground"); + Assert.equal( + ExtensionProcessCrashObserver.appInForeground, + true, + "Expect appInForeground to be true after geckoview-initial-foreground topic" + ); + } +}); + +add_setup( + // Crash dumps are only generated when MOZ_CRASHREPORTER is set. + // Crashes are only generated if tests can crash the extension process. + { skip_if: () => !AppConstants.MOZ_CRASHREPORTER || !CAN_CRASH_EXTENSIONS }, + setup_crash_reporter_override_and_cleaner +); + +// Verifies that a delayed background page is not loaded when an extension is +// shut down during startup. +add_task(async function test_unload_extension_before_background_page_startup() { + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background() { + browser.test.sendMessage("background_startup_observed"); + }, + }); + + // Delayed startup are only enabled for browser (re)starts, so we need to + // install the extension first, and then unload it. + // ^ Note: an alternative is to use APP_STARTUP, see elsewhere in this file. + + await extension.startup(); + await extension.awaitMessage("background_startup_observed"); + + // Now the actual test: Unloading an extension before the startup has + // finished should interrupt the start-up and abort pending delayed loads. + info("Starting extension whose startup will be interrupted"); + await promiseRestartManager({ earlyStartup: false }); + await extension.awaitStartup(); + + let extensionBrowserInsertions = 0; + let onExtensionBrowserInserted = () => ++extensionBrowserInsertions; + Management.on("extension-browser-inserted", onExtensionBrowserInserted); + + info("Unloading extension before the delayed background page starts loading"); + await extension.addon.disable(); + + // Re-enable the add-on to let enough time pass to load a whole background + // page. If at the end of this the original background page hasn't loaded, + // we can consider the test successful. + await extension.addon.enable(); + + // Trigger the notification that would load a background page. + info("Forcing pending delayed background page to load"); + AddonTestUtils.notifyLateStartup(); + + // This is the expected message from the re-enabled add-on. + await extension.awaitMessage("background_startup_observed"); + await extension.unload(); + + await promiseShutdownManager(); + + Management.off("extension-browser-inserted", onExtensionBrowserInserted); + Assert.equal( + extensionBrowserInsertions, + 1, + "Extension browser should have been inserted only once" + ); +}); + +// Verifies that the "build" method of BackgroundPage in ext-backgroundPage.js +// does not deadlock when startup is interrupted by extension shutdown. +add_task(async function test_unload_extension_during_background_page_startup() { + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background() { + browser.test.sendMessage("background_starting"); + }, + }); + + // Delayed startup are only enabled for browser (re)starts, so we need to + // install the extension first, and then reload it. + // ^ Note: an alternative is to use APP_STARTUP, see elsewhere in this file. + await extension.startup(); + await extension.awaitMessage("background_starting"); + + await promiseRestartManager({ lateStartup: false }); + await extension.awaitStartup(); + + let bgStartupPromise = new Promise(resolve => { + function onBackgroundPageDone(eventName) { + extension.extension.off( + "background-script-started", + onBackgroundPageDone + ); + extension.extension.off( + "background-script-aborted", + onBackgroundPageDone + ); + + if (eventName === "background-script-aborted") { + info("Background script startup was interrupted"); + resolve("bg_aborted"); + } else { + info("Background script startup finished normally"); + resolve("bg_fully_loaded"); + } + } + extension.extension.on("background-script-started", onBackgroundPageDone); + extension.extension.on("background-script-aborted", onBackgroundPageDone); + }); + + let bgStartingPromise = new Promise(resolve => { + let backgroundLoadCount = 0; + let backgroundPageUrl = extension.extension.baseURI.resolve( + "_generated_background_page.html" + ); + + // Prevent the background page from actually loading. + Management.once("extension-browser-inserted", (eventName, browser) => { + // Intercept background page load. + let browserFixupAndLoadURIString = browser.fixupAndLoadURIString; + browser.fixupAndLoadURIString = function () { + Assert.equal(++backgroundLoadCount, 1, "loadURI should be called once"); + Assert.equal( + arguments[0], + backgroundPageUrl, + "Expected background page" + ); + // Reset to "about:blank" to not load the actual background page. + arguments[0] = "about:blank"; + browserFixupAndLoadURIString.apply(this, arguments); + + // And force the extension process to crash. + if (CAN_CRASH_EXTENSIONS) { + crashFrame(browser); + } else { + // If extensions are not running in out-of-process mode, then the + // non-remote process should not be killed (or the test runner dies). + // Remove <browser> instead, to simulate the immediate disconnection + // of the message manager (that would happen if the process crashed). + browser.remove(); + } + resolve(); + }; + }); + }); + + // Force background page to initialize. + AddonTestUtils.notifyLateStartup(); + await bgStartingPromise; + + await extension.unload(); + await promiseShutdownManager(); + + // This part is the regression test for bug 1501375. It verifies that the + // background building completes eventually. + // If it does not, then the next line will cause a timeout. + info("Waiting for background builder to finish"); + let bgLoadState = await bgStartupPromise; + Assert.equal(bgLoadState, "bg_aborted", "Startup should be interrupted"); +}); + +// Verifies correct state when background crashes while starting. +// The difference with test_unload_extension_during_background_page_startup is +// that in this version, the background has progressed far enough for the +// extension context to have been initialized, but before the background is +// considered loaded. +// withContext: Whether to trigger initialization of ProxyContextParent. +async function do_test_crash_while_starting_background({ + // Whether to trigger initialization of ProxyContextParent during startup. + withContext = false, + // Whether to use an event page instead of a persistent background page. + isEventPage = false, +}) { + let extension = ExtensionTestUtils.loadExtension({ + // Delay startup, so that we can get an extension reference before the + // background page starts. + startupReason: "APP_STARTUP", + // APP_STARTUP is not enough, delayedStartup is needed (bug 1756225). + delayedStartup: true, + manifest: { + background: { + page: "background.html", + persistent: !isEventPage, + }, + }, + files: { + "background.html": `<!DOCTYPE html> + <meta charset="utf-8"> + <script src="background-immediate.js"></script> + + <!-- Delays DOMContentLoaded - see registerSlowStyleSheet --> + <link rel="stylesheet" href="http://example.com/slow.css"> + <script src="background-deferred.js" defer></script> + `, + "background-immediate.js": String.raw` + dump("background-immediate.js is executing as expected.\n"); + if (${!!withContext}) { + // Accessing the browser API triggers context creation. + browser.test.sendMessage("background_started_to_load"); + } + `, + "background-deferred.js": () => { + dump("background-deferred.js is UNEXPECTEDLY executing.\n"); + browser.test.fail("Background startup should have been interrupted"); + }, + }, + }); + let slowStyleSheet = registerSlowStyleSheet(); + await ExtensionTestCommon.resetStartupPromises(); + await extension.startup(); + function assertBackgroundState(expected, message) { + Assert.equal(extension.extension.backgroundState, expected, message); + } + + assertBackgroundState("stopped", "Background should not have started yet"); + + let bgBrowserPromise = new Promise(resolve => { + Management.once("extension-browser-inserted", (eventName, browser) => { + assertBackgroundState("starting", "State when bg <browser> is inserted"); + resolve(browser); + }); + }); + + info("Triggering background creation..."); + await ExtensionTestCommon.notifyEarlyStartup(); + await ExtensionTestCommon.notifyLateStartup(); + + let bgBrowser = await bgBrowserPromise; + + if (withContext) { + info("Waiting for background-immediate.js to notify us..."); + await extension.awaitMessage("background_started_to_load"); + Assert.ok( + extension.extension.backgroundContext, + "Context exists when an extension API was called" + ); + // Probably resolved by now, but wait explicitly in case it hasn't, so we + // know that the stylesheet has started to load. + await slowStyleSheet.firstLoadPromise; + } else { + // Wait for the stylesheet request to infer that the background content has + // started to be loaded. + await slowStyleSheet.firstLoadPromise; + Assert.ok( + !extension.extension.backgroundContext, + "Context should not be set while loading" + ); + } + + // Still starting because registerSlowStyleSheet postponed startup completion. + assertBackgroundState("starting", "Background should still be loading"); + + await crashExtensionBackground(extension, bgBrowser); + + assertBackgroundState("stopped", "Background state after crash"); + + // Now that the background is gone, the server can respond without the + // possibility of triggering the execution of background-deferred.js + slowStyleSheet.allowStylesheetToLoad(); + await extension.unload(); + + // Can't be 0 because the background has started to load. + // Can't be 2 because we are loading the background only once. + Assert.equal( + slowStyleSheet.getRequestCount(), + 1, + "Expected exactly one request for slow.css from background page" + ); +} + +add_task( + { + // TODO: consider adding explicit coverage for auto-restart behavior + // when a crash is hit while there is not background context yet. + pref_set: [ + ["extensions.background.disableRestartPersistentAfterCrash", true], + ], + }, + async function test_crash_while_starting_background_without_context() { + await do_test_crash_while_starting_background({ withContext: false }); + } +); + +add_task( + { + // Expected auto-restart behavior is tested in the test task named + // test_persistent_restarted_after_crash. + pref_set: [ + ["extensions.background.disableRestartPersistentAfterCrash", true], + ], + }, + async function test_crash_while_starting_background_with_context() { + await do_test_crash_while_starting_background({ withContext: true }); + } +); + +add_task(async function test_crash_while_starting_event_page_without_context() { + await do_test_crash_while_starting_background({ + withContext: false, + isEventPage: true, + }); +}); + +add_task(async function test_crash_while_starting_event_page_with_context() { + await do_test_crash_while_starting_background({ + withContext: true, + isEventPage: true, + }); +}); + +async function do_test_crash_while_running_background({ isEventPage = false }) { + // wakeupBackground() only wakes up after the early startup notification. + // Trigger explicitly to be independent of other tests. + await ExtensionTestCommon.resetStartupPromises(); + await AddonTestUtils.notifyEarlyStartup(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + background: { persistent: !isEventPage }, + }, + background() { + window.onload = () => { + browser.test.sendMessage("background_has_fully_loaded"); + }; + }, + }); + await extension.startup(); + function assertBackgroundState(expected, message) { + Assert.equal(extension.extension.backgroundState, expected, message); + } + + await extension.awaitMessage("background_has_fully_loaded"); + assertBackgroundState("running", "Background should have started"); + + await crashExtensionBackground(extension); + assertBackgroundState("stopped", "Background state after crash"); + + await extension.wakeupBackground(); + await extension.awaitMessage("background_has_fully_loaded"); + assertBackgroundState("running", "Background resumed after crash recovery"); + await extension.terminateBackground(); + assertBackgroundState("stopped", "Background can sleep after crash recovery"); + + await extension.unload(); +} + +add_task( + { + // Disable auto-restart persistent background pages after a crash, this test + // case is checking that the backgroundState is set to stopped when an + // extension process crash is it but if the background page is restarted + // automatically then the background state will be already set to "starting". + pref_set: [ + ["extensions.background.disableRestartPersistentAfterCrash", true], + ], + }, + async function test_crash_after_background_startup() { + await do_test_crash_while_running_background({ isEventPage: false }); + } +); + +add_task(async function test_crash_after_event_page_startup() { + await do_test_crash_while_running_background({ isEventPage: true }); +}); + +add_task(async function test_crash_and_wakeup_via_persistent_listeners() { + // Ensure that the background can start in response to a primed listener. + await ExtensionTestCommon.resetStartupPromises(); + await AddonTestUtils.notifyEarlyStartup(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + background: { persistent: false }, + optional_permissions: ["tabs"], + }, + background() { + const domreadyPromise = new Promise(resolve => { + window.addEventListener("load", resolve, { once: true }); + }); + browser.permissions.onAdded.addListener(() => { + browser.test.log("permissions.onAdded has fired"); + // Wait for DOMContentLoaded to have fired before notifying the test. + // This guarantees that backgroundState is "running" instead of + // potentially "starting". + domreadyPromise.then(() => { + browser.test.sendMessage("event_fired"); + }); + }); + }, + }); + await extension.startup(); + function assertBackgroundState(expected, message) { + Assert.equal(extension.extension.backgroundState, expected, message); + } + function triggerEventInEventPage() { + // Trigger an event, with the expectation that the event page will wake up. + // As long as we are the only one to trigger the extension API event in this + // test, the exact event is not significant. Trigger permissions.onAdded: + Management.emit("change-permissions", { + extensionId: extension.id, + added: { + origins: [], + permissions: ["tabs"], + }, + }); + } + + assertBackgroundState("running", "Background after extension.startup()"); + + // Sanity check: triggerEventInEventPage does actually trigger event_fired. + triggerEventInEventPage(); + await extension.awaitMessage("event_fired"); + + // Restart a few times to verify that the behavior is consistent over time. + const TEST_RESTART_ATTEMPTS = 5; + for (let i = 1; i <= TEST_RESTART_ATTEMPTS; ++i) { + info(`Testing that a crashed background wakes via event, attempt ${i}/5`); + + await crashExtensionBackground(extension); + + assertBackgroundState("stopped", "Background state after crash"); + + triggerEventInEventPage(); + await extension.awaitMessage("event_fired"); + + assertBackgroundState("running", "Persistent event can wake up event page"); + } + + await extension.unload(); +}); + +add_task( + { + skip_if: () => !CAN_CRASH_EXTENSIONS, + pref_set: [ + ["extensions.webextensions.crash.threshold", 3], + // Set a long timeframe to make sure the few crashes we produce in this + // test will all be counted within the same timeframe. + ["extensions.webextensions.crash.timeframe", 60 * 1000], + ], + }, + async function test_process_spawning_disabled_because_of_too_many_crashes() { + // Force-enable process spawning because that will reset the internals of + // the crash observer. + ExtensionProcessCrashObserver.enableProcessSpawning(); + Services.fog.testResetFOG(); + + function assertCrashThresholdTelemetry({ expectToBeSet }) { + // Desktop builds are only expected to record crashed_over_threshold_fg, + // on Android builds xpcshell tests are detected as being in foreground + // unless we explicitly mock the app being moved in the background as + // the test tasks test_background_restarted_after_crash already does + // (and crashed_over_threshold_bg is covered in that test task). + + equal( + undefined, + Glean.extensions.processEvent.crashed_over_threshold_bg.testGetValue(), + `Initial value of crashed_over_threshold_bg.` + ); + + if (expectToBeSet) { + Assert.greater( + Glean.extensions.processEvent.crashed_over_threshold_fg.testGetValue(), + 0, + "Expect crashed_over_threshold_fg count to be set." + ); + return; + } + + equal( + undefined, + Glean.extensions.processEvent.crashed_over_threshold_fg.testGetValue(), + `Initial value of crashed_over_threshold_fg.` + ); + } + + assertCrashThresholdTelemetry({ expectToBeSet: false }); + + Assert.equal( + ExtensionProcessCrashObserver.processSpawningDisabled, + false, + "Expect process spawning to be enabled" + ); + + // Ensure that the background can start in response to a primed listener. + await ExtensionTestCommon.resetStartupPromises(); + await AddonTestUtils.notifyEarlyStartup(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + background: { persistent: false }, + optional_permissions: ["tabs"], + }, + background() { + const domreadyPromise = new Promise(resolve => { + window.addEventListener("load", resolve, { once: true }); + }); + browser.permissions.onAdded.addListener(() => { + browser.test.log("permissions.onAdded has fired"); + // Wait for DOMContentLoaded to have fired before notifying the test. + // This guarantees that backgroundState is "running" instead of + // potentially "starting". + domreadyPromise.then(() => { + browser.test.sendMessage("event_fired"); + }); + }); + }, + }); + await extension.startup(); + function assertBackgroundState(expected, message) { + Assert.equal(extension.extension.backgroundState, expected, message); + } + function triggerEventInEventPage() { + // Trigger an event, with the expectation that the event page will wake up. + // As long as we are the only one to trigger the extension API event in this + // test, the exact event is not significant. Trigger permissions.onAdded: + Management.emit("change-permissions", { + extensionId: extension.id, + added: { + origins: [], + permissions: ["tabs"], + }, + }); + } + + assertBackgroundState("running", "Background after extension.startup()"); + + // Sanity check: triggerEventInEventPage does actually trigger event_fired. + triggerEventInEventPage(); + await extension.awaitMessage("event_fired"); + + // Crash/restart a few times to force the crash observer to disable process + // spawning on the crash _after_ the loop. Note that the value below should + // match the "threshold" pref set above. + const TEST_RESTART_ATTEMPTS = 3; + for (let i = 1; i <= TEST_RESTART_ATTEMPTS; ++i) { + info( + `Crash/restart extension background, attempt ${i}/${TEST_RESTART_ATTEMPTS}` + ); + + await crashExtensionBackground(extension); + assertBackgroundState("stopped", "Background state after crash"); + + triggerEventInEventPage(); + await extension.awaitMessage("event_fired"); + assertBackgroundState( + "running", + "Persistent event can wake up event page" + ); + } + + assertCrashThresholdTelemetry({ expectToBeSet: false }); + + info("Crash one more time"); + await crashExtensionBackground(extension); + + assertCrashThresholdTelemetry({ expectToBeSet: true }); + + Assert.ok( + ExtensionProcessCrashObserver.processSpawningDisabled, + "Expect process spawning to be disabled" + ); + + info("Trigger an event, which shouldn't wake up the event page"); + triggerEventInEventPage(); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 100)); + assertBackgroundState("stopped", "Background should not have started yet"); + + info("Enable process spawning"); + ExtensionProcessCrashObserver.enableProcessSpawning(); + Assert.equal( + ExtensionProcessCrashObserver.processSpawningDisabled, + false, + "Expect process spawning to be enabled" + ); + assertBackgroundState("stopped", "Background should still be suspended"); + + info("Trigger an event, which should wake up the event page"); + triggerEventInEventPage(); + await extension.awaitMessage("event_fired"); + assertBackgroundState("running", "Persistent event can wake up event page"); + + info("Crash again"); + await crashExtensionBackground(extension); + assertBackgroundState("stopped", "Background state after crash"); + Assert.equal( + ExtensionProcessCrashObserver.processSpawningDisabled, + false, + "Expect process spawning to be enabled" + ); + + info("Trigger an event, which should wake up the event page again"); + triggerEventInEventPage(); + await extension.awaitMessage("event_fired"); + assertBackgroundState("running", "Persistent event can wake up event page"); + + await extension.unload(); + } +); + +add_task( + { + skip_if: () => !CAN_CRASH_EXTENSIONS, + pref_set: [ + ["extensions.webextensions.crash.threshold", 2], + // Set a long timeframe to make sure the few crashes we produce in this + // test will all be counted within the same timeframe. + ["extensions.webextensions.crash.timeframe", 60 * 1000], + ], + }, + async function test_background_restarted_after_crash() { + // Force-enable process spawning because that will reset the internals of + // the crash observer. + ExtensionProcessCrashObserver.enableProcessSpawning(); + Assert.equal( + ExtensionProcessCrashObserver.processSpawningDisabled, + false, + "Expect process spawning to be enabled" + ); + + function assertCrashThresholdTelemetry({ fg, bg }) { + if (fg) { + Assert.greater( + Glean.extensions.processEvent.crashed_over_threshold_fg.testGetValue(), + 0, + "Expect crashed_over_threshold_fg count to be set." + ); + } else { + equal( + undefined, + Glean.extensions.processEvent.crashed_over_threshold_fg.testGetValue(), + `Initial value of crashed_over_threshold_fg.` + ); + } + if (bg) { + Assert.greater( + Glean.extensions.processEvent.crashed_over_threshold_bg.testGetValue(), + 0, + "Expect crashed_over_threshold_bg count to be set." + ); + } else { + equal( + undefined, + Glean.extensions.processEvent.crashed_over_threshold_bg.testGetValue(), + `Initial value of crashed_over_threshold_bg.` + ); + } + } + + // Setup test environment to match a fully started browser instance + await ExtensionTestCommon.resetStartupPromises(); + await AddonTestUtils.notifyEarlyStartup(); + Services.fog.testResetFOG(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + background: { persistent: true }, + }, + background() { + window.addEventListener( + "load", + () => { + browser.test.sendMessage("persistentbg_started"); + }, + { once: true } + ); + }, + }); + + await extension.startup(); + await extension.awaitMessage("persistentbg_started"); + + function assertBackgroundState(expected, message) { + Assert.equal(extension.extension.backgroundState, expected, message); + } + + async function assertStillStoppedAfterTimeout(timeout = 100) { + // Confirm that the state is still stopped and the background page + // was not actually in the process of being restarted. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, timeout)); + assertBackgroundState("stopped", "Background should still be stopped"); + } + + async function mockCrashOnAndroidAppInBackground() { + info("Mock application-background observer service topic"); + ExtensionProcessCrashObserver.observe(null, "application-background"); + + Assert.equal( + ExtensionProcessCrashObserver.appInForeground, + false, + "Got expected value set on ExtensionProcessCrashObserver.appInForeground" + ); + await crashExtensionBackground(extension); + assertBackgroundState( + "stopped", + "Persistent Background state after crash while in the background" + ); + + await assertStillStoppedAfterTimeout(); + + info("Mock application-foreground observer service topic"); + ExtensionProcessCrashObserver.observe(null, "application-foreground"); + + Assert.equal( + ExtensionProcessCrashObserver.appInForeground, + true, + "Ensure the application is detected as being in foreground" + ); + } + + assertBackgroundState("running", "Background after extension.startup()"); + + // Restart a few times to verify that the behavior is consistent over time. + info( + "Testing that a crashed persistent background is restarted after a crash" + ); + + Assert.equal( + ExtensionProcessCrashObserver.appInForeground, + true, + "Ensure the application is detected as being in foreground" + ); + await crashExtensionBackground(extension); + + await extension.awaitMessage("persistentbg_started"); + assertBackgroundState("running", "Persistent Background state after crash"); + + // Mock application moved into the background and background page + // auto-restart to be deferred to the application being moved + // back in the foreground. + if (ExtensionProcessCrashObserver._isAndroid) { + await mockCrashOnAndroidAppInBackground(); + } else { + await crashExtensionBackground(extension); + } + + info("Wait for the persistent background context to be started"); + await extension.awaitMessage("persistentbg_started"); + assertBackgroundState("running", "Persistent Background state after crash"); + + info("Mock another crash to be exceeding enforced crash threshold"); + + assertCrashThresholdTelemetry({ fg: false, bg: false }); + + // Mock application moved into the background and background page + // auto-restart to be deferred to the application being moved + // back in the foreground. + if (ExtensionProcessCrashObserver._isAndroid) { + await mockCrashOnAndroidAppInBackground(); + assertCrashThresholdTelemetry({ fg: false, bg: true }); + } else { + await crashExtensionBackground(extension); + assertCrashThresholdTelemetry({ fg: true, bg: false }); + } + + Assert.ok( + ExtensionProcessCrashObserver.processSpawningDisabled, + "Expect process spawning to be disabled" + ); + + assertBackgroundState( + "stopped", + "Persistent Background state after crash exceeding threshold" + ); + + await assertStillStoppedAfterTimeout(); + + info("Enable process spawning"); + ExtensionProcessCrashObserver.enableProcessSpawning(); + Assert.equal( + ExtensionProcessCrashObserver.processSpawningDisabled, + false, + "Expect process spawning to be enabled" + ); + + info("Wait for Persistent Background Context to be started"); + await extension.awaitMessage("persistentbg_started"); + assertBackgroundState("running", "Persistent Background to be running"); + + // Crash again to confirm the threshold has been reset. + await crashExtensionBackground(extension); + info("Wait for Persistent Background Context to be started"); + await extension.awaitMessage("persistentbg_started"); + assertBackgroundState("running", "Persistent Background to be running"); + + // Crash again to cover explicitly exceeding the crash threshold + // while the application is in foreground. + if (ExtensionProcessCrashObserver._isAndroid) { + Assert.equal( + ExtensionProcessCrashObserver.appInForeground, + true, + "Ensure the application is detected as being in foreground" + ); + + await crashExtensionBackground(extension); + + info("Wait for Persistent Background Context to be started"); + await extension.awaitMessage("persistentbg_started"); + assertBackgroundState("running", "Persistent Background to be running"); + + // Crash one more time to exceed the threshold. + await crashExtensionBackground(extension); + + assertCrashThresholdTelemetry({ fg: true, bg: true }); + + Assert.ok( + ExtensionProcessCrashObserver.processSpawningDisabled, + "Expect process spawning to be disabled" + ); + await assertStillStoppedAfterTimeout(); + } + + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_load_events.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_load_events.js new file mode 100644 index 0000000000..cac574b8ca --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_load_events.js @@ -0,0 +1,23 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* eslint-disable mozilla/balanced-listeners */ + +add_task(async function test_DOMContentLoaded_in_generated_background_page() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + function reportListener(event) { + browser.test.sendMessage("eventname", event.type); + } + document.addEventListener("DOMContentLoaded", reportListener); + window.addEventListener("load", reportListener); + }, + }); + + await extension.startup(); + equal("DOMContentLoaded", await extension.awaitMessage("eventname")); + equal("load", await extension.awaitMessage("eventname")); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_reload.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_reload.js new file mode 100644 index 0000000000..a22db9d582 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_reload.js @@ -0,0 +1,24 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_reload_generated_background_page() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + if (location.hash !== "#firstrun") { + browser.test.sendMessage("first run"); + location.hash = "#firstrun"; + browser.test.assertEq("#firstrun", location.hash); + location.reload(); + } else { + browser.test.notifyPass("second run"); + } + }, + }); + + await extension.startup(); + await extension.awaitMessage("first run"); + await extension.awaitFinish("second run"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_global_history.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_global_history.js new file mode 100644 index 0000000000..19a918eff9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_global_history.js @@ -0,0 +1,24 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { PlacesTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PlacesTestUtils.sys.mjs" +); + +add_task(async function test_global_history() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("background-loaded", location.href); + }, + }); + + await extension.startup(); + + let backgroundURL = await extension.awaitMessage("background-loaded"); + + await extension.unload(); + + let exists = await PlacesTestUtils.isPageInDB(backgroundURL); + ok(!exists, "Background URL should not be in history database"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_iframe.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_iframe.js new file mode 100644 index 0000000000..827a2a8697 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_iframe.js @@ -0,0 +1,349 @@ +"use strict"; + +const { Management } = ChromeUtils.importESModule( + "resource://gre/modules/Extension.sys.mjs" +); + +const server = AddonTestUtils.createHttpServer({ + hosts: ["example.com"], +}); + +// One test below relies on a slow-loading stylesheet. This function and promise +// enables the script to control exactly when the stylesheet load should finish. +let allowStylesheetToLoad; +let stylesheetBlockerPromise = new Promise(resolve => { + allowStylesheetToLoad = resolve; +}); +server.registerPathHandler("/slow.css", (request, response) => { + response.setHeader("Cache-Control", "no-cache", false); + response.setHeader("Content-Type", "text/css", false); + + response.processAsync(); + + stylesheetBlockerPromise.then(() => { + response.write("body { color: rgb(1, 2, 3); }"); + response.finish(); + }); +}); + +// Test helper to keep track of the number of background context loads, from +// any extension. +class BackgroundWatcher { + constructor() { + // Number of background page loads observed. + this.bgBrowserCount = 0; + // Number of top-level background context loads observed. + this.bgViewCount = 0; + this.observing = false; + + this.onBrowserInserted = this.onBrowserInserted.bind(this); + this.onBackgroundViewLoaded = this.onBackgroundViewLoaded.bind(this); + + this.startObserving(); + } + + startObserving() { + this.observing = true; + Management.on("extension-browser-inserted", this.onBrowserInserted); + } + + stopObserving() { + this.observing = false; + Management.off("extension-browser-inserted", this.onBrowserInserted); + // Note: onBrowserInserted adds message listeners to the message manager + // of background contexts, but we do not explicitly unregister these here + // because the caller should only stop observing after knowing for sure + // that there are no new or pending loads of background pages. + } + + onBrowserInserted(eventName, browser) { + Assert.equal(eventName, "extension-browser-inserted", "Seen bg browser"); + if (!browser.getAttribute("webextension-view-type") === "background") { + return; + } + this.bgBrowserCount++; + browser.messageManager.addMessageListener( + "Extension:BackgroundViewLoaded", + this.onBackgroundViewLoaded + ); + } + + onBackgroundViewLoaded({ data }) { + if (!this.observing) { + // We shouldn't receive this event - see comment in stopObserving. + Assert.ok(false, "Got onBackgroundViewLoaded while !observing"); + } + this.bgViewCount++; + Assert.ok(data.childId, "childId passed to Extension:BackgroundViewLoaded"); + } +} + +add_task(async function test_first_extension_api_call_in_iframe() { + // In this test we test what happens when an extension API call happens in + // an iframe before the top-level document observes DOMContentLoaded. + // + // 1. Because DOMContentLoaded is blocked on the execution on <script defer>, + // and <script defer> is blocked on stylesheet load completion, we embed + // both elements in the top-level document to postpone DOMContentLoaded. + // 2. We load an iframe with a moz-extension document and call an extension + // API in the context of that frame at iframe.onload. + // Because there is no <script> in the iframe, iframe.onload dispatches + // before DOMContentLoaded of the top-level document. + // 3. We add several sanity checks to verify that the order of execution is + // as expected. + // + function backgroundScript() { + // Note: no extension API calls until window.onload to avoid side effects. + // Saving any relevant state in global variables to assert later. + const readyStateAtTopLevelScriptExecution = document.readyState; + globalThis.readyStateAtFrameLoad = "(to be set in iframe.onload)"; + globalThis.readyStateInScriptDefer = "(to be set in deferred script)"; + globalThis.styleSheetStateAtFrameLoad = "(to be set in iframe.onload)"; + globalThis.styleSheetStateInScriptDefer = "(to be set in deferred script)"; + globalThis.bodyAtFrameLoad = "(to be set in iframe.onload)"; + globalThis.scriptRunInFrame = "(to be set in iframe.onload)"; + globalThis.scriptDeferRunAfterFrameLoad = "(to be set in deferred script)"; + + // We use a slow-loading stylesheet to block `<script defer>` execution, + // which in turn postpones DOMContentLoaded. This method checks whether + // the stylesheet has been loaded. + globalThis.getStyleSheetState = () => { + if (getComputedStyle(document.body).color === "rgb(1, 2, 3)") { + return "slow.css loaded"; + } + return "slow.css not loaded"; + }; + + const handle_iframe_onload = event => { + const iframe = event.target; + // Note: using dump instead of browser.test.log because we want to avoid + // extension API calls until we've completed the extension API call in + // the iframe below. + dump(`iframe.onload triggered\n`); + globalThis.styleSheetStateAtFrameLoad = globalThis.getStyleSheetState(); + globalThis.readyStateAtFrameLoad = document.readyState; + globalThis.bodyAtFrameLoad = iframe.contentDocument.body.textContent; + // Now, call the extension API in the context of the iframe. We cannot + // use <script> inside the iframe, because its execution is also + // postponed by the slow style trick that we use to postpone + // DOMContentLoaded. + // The exact API call does not matter, as any extension API call will + // ensure that ProxyContextParent is initialized if it was not before: + // https://searchfox.org/mozilla-central/rev/892475f3ba2b959aeaef19d1d8602494e3f2ae32/toolkit/components/extensions/ExtensionPageChild.sys.mjs#221,223,227-228 + iframe.contentWindow.browser.runtime.getPlatformInfo().then(info => { + dump(`Extension API call made a roundtrip through the parent\n`); + globalThis.scriptRunInFrame = true; + // By now, we have run an extension API call in the iframe, so we + // don't need to be careful with avoiding extension API calls in the + // top-level context. Thus we can now use browser.test APIs here. + browser.test.assertTrue("os" in info, "extension API called in iframe"); + + browser.test.assertTrue( + iframe.contentWindow.browser.extension.getBackgroundPage() === window, + "extension.getBackgroundPage() returns the top context" + ); + + // Allow stylesheet load to complete, which unblocks DOMContentLoaded + // and window.onload. + browser.test.sendMessage("allowStylesheetToLoad"); + }); + }; + + // background.js runs before <iframe> is inserted. This capturing listener + // should detect iframe.onload once it fires, through event bubbling. + document.addEventListener( + "load", + function listener(event) { + if (event.target.id === "iframe") { + document.removeEventListener("load", listener, true); + handle_iframe_onload(event); + } + }, + true + ); + + window.onload = () => { + // First, several sanity checks to verify that the timing of script + // execution in the iframe vs DOMContentLoaded is as expected. + browser.test.assertEq( + "loading", + readyStateAtTopLevelScriptExecution, + "Top-level script should run immediately while DOM is still loading" + ); + function assertBeforeDOMContentLoaded(actualReadyState, message) { + browser.test.assertTrue( + actualReadyState === "interactive" || actualReadyState === "loading", + `${message}, actual readyState=${actualReadyState}` + ); + } + assertBeforeDOMContentLoaded( + globalThis.readyStateAtFrameLoad, + "frame.onload + script should run before DOMContentLoaded fires" + ); + assertBeforeDOMContentLoaded( + globalThis.readyStateInScriptDefer, + "<script defer> should run right before DOMContentLoaded fires" + ); + browser.test.assertEq( + "complete", + document.readyState, + "Sanity check: DOMContentLoaded has triggerd before window.onload" + ); + + // Sanity check: Verify that the stylesheet was still loading while + // frame.onload was executing. This should be true because the style + // sheet loads very slowly. + browser.test.assertEq( + "slow.css not loaded", + globalThis.styleSheetStateAtFrameLoad, + "Sanity check: stylesheet load pending during frame.onload" + ); + browser.test.assertEq( + "slow.css loaded", + globalThis.styleSheetStateInScriptDefer, + "Sanity check: stylesheet loaded before deferred script" + ); + + // Sanity check: The deferred script should execute after iframe.onload, + // because `<script defer>` execution is blocked on stylesheet load + // completion, and we have a slow-loading stylesheet. + browser.test.assertEq( + globalThis.scriptDeferRunAfterFrameLoad, + true, + "Sanity check: iframe.onload should run before <script defer>" + ); + + // Sanity check: Verify that the moz-extension:-document was loaded in + // the frame at iframe.onload. + browser.test.assertEq( + "body_of_iframe", + globalThis.bodyAtFrameLoad, + "Sanity check: iframe.onload runs when document in frame was ready" + ); + + browser.test.sendMessage("top_and_frame_done"); + }; + dump(`background.js ran. Waiting for iframe.onload to continue.\n`); + } + function backgroundScriptDeferred() { + globalThis.scriptDeferRunAfterFrameLoad = globalThis.scriptRunInFrame; + globalThis.styleSheetStateInScriptDefer = globalThis.getStyleSheetState(); + globalThis.readyStateInScriptDefer = document.readyState; + dump(`background-deferred.js ran. Expecting window.onload to run next.\n`); + } + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + background: { + page: "background.html", + }, + }, + files: { + "background.html": `<!DOCTYPE html> + <meta charset="utf-8"> + <!-- background.js first, to never miss the iframe.onload event --> + <script src="background.js"></script> + <!-- iframe should start loading before slow.css blocks the DOM --> + <iframe src="background-subframe.html" id="iframe"></iframe> + <!-- + Load a slow stylesheet AND add <script defer> to intentionally postpone + the DOMContentLoaded notification until well after background-top.js + has run and extension API calls have been processed, if any. + --> + <link rel="stylesheet" href="http://example.com/slow.css"> + <script src="background-deferred.js" defer></script> + `, + "background-subframe.html": `<!DOCTYPE html> + <meta charset="utf-8"> + <!-- No <script> here because their presence blocks iframe.onload --> + <body>body_of_iframe</body>`, + "background.js": backgroundScript, + "background-deferred.js": backgroundScriptDeferred, + }, + }); + + const bgWatcher = new BackgroundWatcher(); + // No "await extension.startup();" because extension.startup() in tests is + // currently blocked on background startup (due to TEST_NO_DELAYED_STARTUP + // defaulting to true). Because the background startup completion is blocked + // on the DOMContentLoaded of the background, extension.startup() does not + // resolve until we've unblocked the DOMContentLoaded notification. + const startupPromise = extension.startup(); + await extension.awaitMessage("allowStylesheetToLoad"); + Assert.equal(bgWatcher.bgBrowserCount, 1, "Got background page"); + Assert.equal(bgWatcher.bgViewCount, 0, "Background view still loading"); + info("frame loaded; allowing slow.css to load to unblock DOMContentLoaded"); + allowStylesheetToLoad(); + + info("Waiting for extension.startup() to resolve (background completion)"); + await startupPromise; + info("extension.startup() resolved. Waiting for top_and_frame_done..."); + + await extension.awaitMessage("top_and_frame_done"); + Assert.equal( + extension.extension.backgroundContext?.uri?.spec, + `moz-extension://${extension.uuid}/background.html`, + `extension.backgroundContext should exist and point to the main background` + ); + Assert.equal(bgWatcher.bgViewCount, 1, "Background has loaded once"); + Assert.equal( + extension.extension.views.size, + 2, + "Got ProxyContextParent instances for background and iframe" + ); + + await extension.unload(); + bgWatcher.stopObserving(); +}); + +add_task(async function test_only_script_execution_in_iframe() { + function backgroundSubframeScript() { + // The exact API call does not matter, as any extension API call will + // ensure that ProxyContextParent is initialized if it was not before: + // https://searchfox.org/mozilla-central/rev/892475f3ba2b959aeaef19d1d8602494e3f2ae32/toolkit/components/extensions/ExtensionPageChild.sys.mjs#221,223,227-228 + browser.runtime.getPlatformInfo().then(info => { + browser.test.assertTrue("os" in info, "extension API called in iframe"); + browser.test.assertTrue( + browser.extension.getBackgroundPage() === top, + "extension.getBackgroundPage() returns the top context" + ); + browser.test.sendMessage("iframe_done"); + }); + } + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + background: { + page: "background.html", + }, + }, + files: { + "background.html": `<!DOCTYPE html> + <meta charset="utf-8"> + <iframe src="background-subframe.html"></iframe> + `, + "background-subframe.html": `<!DOCTYPE html> + <meta charset="utf-8"> + <script src="background-subframe.js"></script> + `, + "background-subframe.js": backgroundSubframeScript, + }, + }); + const bgWatcher = new BackgroundWatcher(); + await extension.startup(); + await extension.awaitMessage("iframe_done"); + Assert.equal(bgWatcher.bgBrowserCount, 1, "Got background page"); + Assert.equal(bgWatcher.bgViewCount, 1, "Got background view"); + Assert.equal( + extension.extension.views.size, + 2, + "Got ProxyContextParent instances for background and iframe" + ); + + Assert.equal( + extension.extension.backgroundContext?.uri?.spec, + `moz-extension://${extension.uuid}/background.html`, + `extension.backgroundContext should exist and point to the main background` + ); + + await extension.unload(); + bgWatcher.stopObserving(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_private_browsing.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_private_browsing.js new file mode 100644 index 0000000000..9ce80f3fda --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_private_browsing.js @@ -0,0 +1,44 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_background_incognito() { + info( + "Test background page incognito value with permanent private browsing enabled" + ); + + Services.prefs.setBoolPref("browser.privatebrowsing.autostart", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.privatebrowsing.autostart"); + }); + + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + async background() { + browser.test.assertEq( + window, + browser.extension.getBackgroundPage(), + "Caller should be able to access itself as a background page" + ); + browser.test.assertEq( + window, + await browser.runtime.getBackgroundPage(), + "Caller should be able to access itself as a background page" + ); + + browser.test.assertEq( + browser.extension.inIncognitoContext, + true, + "inIncognitoContext is true for permanent private browsing" + ); + + browser.test.notifyPass("incognito"); + }, + }); + + await extension.startup(); + + await extension.awaitFinish("incognito"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_runtime_connect_params.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_runtime_connect_params.js new file mode 100644 index 0000000000..aa0976434b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_runtime_connect_params.js @@ -0,0 +1,88 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function backgroundScript() { + let received_ports_number = 0; + + const expected_received_ports_number = 1; + + function countReceivedPorts(port) { + received_ports_number++; + + if (port.name == "check-results") { + browser.runtime.onConnect.removeListener(countReceivedPorts); + + browser.test.assertEq( + expected_received_ports_number, + received_ports_number, + "invalid connect should not create a port" + ); + + browser.test.notifyPass("runtime.connect invalid params"); + } + } + + browser.runtime.onConnect.addListener(countReceivedPorts); + + let childFrame = document.createElement("iframe"); + childFrame.src = "extensionpage.html"; + document.body.appendChild(childFrame); +} + +function senderScript() { + let detected_invalid_connect_params = 0; + + const invalid_connect_params = [ + // too many params + [ + "fake-extensions-id", + { name: "fake-conn-name" }, + "unexpected third params", + ], + // invalid params format + [{}, {}], + ["fake-extensions-id", "invalid-connect-info-format"], + ]; + const expected_detected_invalid_connect_params = + invalid_connect_params.length; + + function assertInvalidConnectParamsException(params) { + try { + browser.runtime.connect(...params); + } catch (e) { + detected_invalid_connect_params++; + browser.test.assertTrue( + e.toString().includes("Incorrect argument types for runtime.connect."), + "exception message is correct" + ); + } + } + for (let params of invalid_connect_params) { + assertInvalidConnectParamsException(params); + } + browser.test.assertEq( + expected_detected_invalid_connect_params, + detected_invalid_connect_params, + "all invalid runtime.connect params detected" + ); + + browser.runtime.connect(browser.runtime.id, { name: "check-results" }); +} + +let extensionData = { + background: backgroundScript, + files: { + "senderScript.js": senderScript, + "extensionpage.html": `<!DOCTYPE html><meta charset="utf-8"><script src="senderScript.js"></script>`, + }, +}; + +add_task(async function test_backgroundRuntimeConnectParams() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitFinish("runtime.connect invalid params"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_script_and_service_worker.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_script_and_service_worker.js new file mode 100644 index 0000000000..fc59b1810d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_script_and_service_worker.js @@ -0,0 +1,81 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function testExtensionWithBackground({ + with_scripts = false, + with_service_worker = false, + with_page = false, + expected_background_type, + expected_manifest_warnings = [], +}) { + let background = {}; + if (with_scripts) { + background.scripts = ["scripts.js"]; + } + if (with_service_worker) { + background.service_worker = "sw.js"; + } + if (with_page) { + background.page = "page.html"; + } + let extension = ExtensionTestUtils.loadExtension({ + manifest: { background }, + files: { + "scripts.js": () => { + browser.test.sendMessage("from_bg", "scripts"); + }, + "sw.js": () => { + browser.test.sendMessage("from_bg", "service_worker"); + }, + "page.html": `<!DOCTYPE html><script src="page.js"></script>`, + "page.js": () => { + browser.test.sendMessage("from_bg", "page"); + }, + }, + }); + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + Assert.deepEqual( + extension.extension.warnings, + expected_manifest_warnings, + "Expected manifest warnings" + ); + info("Waiting for background to start"); + Assert.equal( + await extension.awaitMessage("from_bg"), + expected_background_type, + "Expected background type" + ); + await extension.unload(); +} + +add_task(async function test_page_and_scripts() { + await testExtensionWithBackground({ + with_page: true, + with_scripts: true, + // Should be expected_background_type: "scripts", not "page". + // https://github.com/w3c/webextensions/issues/282#issuecomment-1443332913 + // ... but changing that may potentially affect backcompat of existing + // Firefox add-ons. + expected_background_type: "page", + expected_manifest_warnings: [ + "Reading manifest: Warning processing background.scripts: An unexpected property was found in the WebExtension manifest.", + ], + }); +}); + +add_task( + { skip_if: () => WebExtensionPolicy.backgroundServiceWorkerEnabled }, + async function test_scripts_and_service_worker_when_sw_disabled() { + await testExtensionWithBackground({ + with_scripts: true, + with_service_worker: true, + expected_background_type: "scripts", + expected_manifest_warnings: [ + "Reading manifest: Warning processing background.service_worker: An unexpected property was found in the WebExtension manifest.", + ], + }); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_service_worker.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_service_worker.js new file mode 100644 index 0000000000..2efbc52739 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_service_worker.js @@ -0,0 +1,321 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", +}); + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); +AddonTestUtils.overrideCertDB(); + +add_task(async function setup() { + ok( + WebExtensionPolicy.useRemoteWebExtensions, + "Expect remote-webextensions mode enabled" + ); + ok( + WebExtensionPolicy.backgroundServiceWorkerEnabled, + "Expect remote-webextensions mode enabled" + ); + + await AddonTestUtils.promiseStartupManager(); + + Services.prefs.setBoolPref("dom.serviceWorkers.testing.enabled", true); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("dom.serviceWorkers.testing.enabled"); + Services.prefs.clearUserPref("dom.serviceWorkers.idle_timeout"); + }); +}); + +add_task( + async function test_fail_spawn_extension_worker_for_disabled_extension() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + version: "1.0", + background: { + service_worker: "sw.js", + }, + browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } }, + }, + files: { + "page.html": "<!DOCTYPE html><body></body>", + "sw.js": "dump('Background ServiceWorker - executed\\n');", + }, + }); + + const testWorkerWatcher = new TestWorkerWatcher(); + let watcher = await testWorkerWatcher.watchExtensionServiceWorker( + extension + ); + + await extension.startup(); + + info("Wait for the background service worker to be spawned"); + + ok( + await watcher.promiseWorkerSpawned, + "The extension service worker has been spawned as expected" + ); + + info("Wait for the background service worker to be terminated"); + ok( + await watcher.terminate(), + "The extension service worker has been terminated as expected" + ); + + const swReg = testWorkerWatcher.getRegistration(extension); + ok(swReg, "Got a service worker registration"); + ok(swReg?.activeWorker, "Got an active worker"); + + info("Spawn the active worker by attaching the debugger"); + + watcher = await testWorkerWatcher.watchExtensionServiceWorker(extension); + + swReg.activeWorker.attachDebugger(); + info( + "Wait for the background service worker to be spawned after attaching the debugger" + ); + ok( + await watcher.promiseWorkerSpawned, + "The extension service worker has been spawned as expected" + ); + + swReg.activeWorker.detachDebugger(); + info( + "Wait for the background service worker to be terminated after detaching the debugger" + ); + ok( + await watcher.terminate(), + "The extension service worker has been terminated as expected" + ); + + info( + "Disabling the addon policy, and then double-check that the worker can't be spawned" + ); + const policy = WebExtensionPolicy.getByID(extension.id); + policy.active = false; + + await Assert.throws( + () => swReg.activeWorker.attachDebugger(), + /InvalidStateError/, + "Got the expected extension when trying to spawn a worker for a disabled addon" + ); + + info( + "Enabling the addon policy and double-check the worker is spawned successfully" + ); + policy.active = true; + + watcher = await testWorkerWatcher.watchExtensionServiceWorker(extension); + + swReg.activeWorker.attachDebugger(); + info( + "Wait for the background service worker to be spawned after attaching the debugger" + ); + ok( + await watcher.promiseWorkerSpawned, + "The extension service worker has been spawned as expected" + ); + + swReg.activeWorker.detachDebugger(); + info( + "Wait for the background service worker to be terminated after detaching the debugger" + ); + ok( + await watcher.terminate(), + "The extension service worker has been terminated as expected" + ); + + await testWorkerWatcher.destroy(); + await extension.unload(); + } +); + +add_task(async function test_serviceworker_lifecycle_events() { + async function assertLifecycleEvents({ extension, expected, message }) { + const getLifecycleEvents = async () => { + const { active } = await this.content.navigator.serviceWorker.ready; + const { port1, port2 } = new content.MessageChannel(); + + return new Promise(resolve => { + port1.onmessage = msg => resolve(msg.data.lifecycleEvents); + active.postMessage("test", [port2]); + }); + }; + const page = await ExtensionTestUtils.loadContentPage( + extension.extension.baseURI.resolve("page.html"), + { extension } + ); + Assert.deepEqual( + await page.spawn([], getLifecycleEvents), + expected, + `Got the expected lifecycle events on ${message}` + ); + await page.close(); + } + + const swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + background: { + service_worker: "sw.js", + }, + browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } }, + }, + files: { + "page.html": "<!DOCTYPE html><body></body>", + "sw.js": ` + dump('Background ServiceWorker - executed\\n'); + + const lifecycleEvents = []; + self.oninstall = () => { + dump('Background ServiceWorker - oninstall\\n'); + lifecycleEvents.push("install"); + }; + self.onactivate = () => { + dump('Background ServiceWorker - onactivate\\n'); + lifecycleEvents.push("activate"); + }; + self.onmessage = (evt) => { + dump('Background ServiceWorker - onmessage\\n'); + evt.ports[0].postMessage({ lifecycleEvents }); + dump('Background ServiceWorker - postMessage\\n'); + }; + `, + }, + }); + + const testWorkerWatcher = new TestWorkerWatcher(); + let watcher = await testWorkerWatcher.watchExtensionServiceWorker(extension); + + await extension.startup(); + + await assertLifecycleEvents({ + extension, + expected: ["install", "activate"], + message: "initial worker registration", + }); + + const file = Services.dirsvc.get("ProfD", Ci.nsIFile); + file.append("serviceworker.txt"); + await TestUtils.waitForCondition( + () => file.exists(), + "Wait for service worker registrations to have been dumped on disk" + ); + + const managerShutdownCompleted = AddonTestUtils.promiseShutdownManager(); + + const firstSwReg = swm.getRegistrationByPrincipal( + extension.extension.principal, + extension.extension.principal.spec + ); + // Force the worker shutdown (in normal condition the worker would have been + // terminated as part of the entire application shutting down). + firstSwReg.forceShutdown(); + + info( + "Wait for the background service worker to be terminated while the app is shutting down" + ); + ok( + await watcher.promiseWorkerTerminated, + "The extension service worker has been terminated as expected" + ); + await managerShutdownCompleted; + + Assert.equal( + firstSwReg, + swm.getRegistrationByPrincipal( + extension.extension.principal, + extension.extension.principal.spec + ), + "Expect the service worker to not be unregistered on application shutdown" + ); + + info("Restart AddonManager (mocking Browser instance restart)"); + // Start the addon manager with `earlyStartup: false` to keep the background service worker + // from being started right away: + // + // - the call to `swm.reloadRegistrationForTest()` that follows is making sure that + // the previously registered service worker is in the same state it would be when + // the entire browser is restarted. + // + // - if the background service worker is being spawned again by the time we call + // `swm.reloadRegistrationForTest()`, ServiceWorkerUpdateJob would fail and trigger + // an `mState == State::Started` diagnostic assertion from ServiceWorkerJob::Finish + // and the xpcshell test will fail for the crash triggered by the assertion. + await AddonTestUtils.promiseStartupManager({ lateStartup: false }); + await extension.awaitStartup(); + + info( + "Force reload ServiceWorkerManager registrations (mocking a Browser instance restart)" + ); + swm.reloadRegistrationsForTest(); + + info( + "trigger delayed call to nsIServiceWorkerManager.registerForAddonPrincipal" + ); + // complete the startup notifications, then start the background + AddonTestUtils.notifyLateStartup(); + extension.extension.emit("start-background-script"); + + info("Force activate the extension worker"); + const newSwReg = swm.getRegistrationByPrincipal( + extension.extension.principal, + extension.extension.principal.spec + ); + + Assert.notEqual( + newSwReg, + firstSwReg, + "Expect the service worker registration to have been recreated" + ); + + await assertLifecycleEvents({ + extension, + expected: [], + message: "on previous registration loaded", + }); + + const { principal } = extension.extension; + const addon = await AddonManager.getAddonByID(extension.id); + await addon.disable(); + + ok( + await watcher.promiseWorkerTerminated, + "The extension service worker has been terminated as expected" + ); + + Assert.throws( + () => swm.getRegistrationByPrincipal(principal, principal.spec), + /NS_ERROR_FAILURE/, + "Expect the service worker to have been unregistered on addon disabled" + ); + + await addon.enable(); + await assertLifecycleEvents({ + extension, + expected: ["install", "activate"], + message: "on disabled addon re-enabled", + }); + + await testWorkerWatcher.destroy(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_sub_windows.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_sub_windows.js new file mode 100644 index 0000000000..1c3180b1b6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_sub_windows.js @@ -0,0 +1,46 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testBackgroundWindow() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.log("background script executed"); + + browser.test.sendMessage("background-script-load"); + + let img = document.createElement("img"); + img.src = + "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; + document.body.appendChild(img); + + img.onload = () => { + browser.test.log("image loaded"); + + let iframe = document.createElement("iframe"); + iframe.src = "about:blank?1"; + + iframe.onload = () => { + browser.test.log("iframe loaded"); + setTimeout(() => { + browser.test.notifyPass("background sub-window test done"); + }, 0); + }; + document.body.appendChild(iframe); + }; + }, + }); + + let loadCount = 0; + extension.onMessage("background-script-load", () => { + loadCount++; + }); + + await extension.startup(); + + await extension.awaitFinish("background sub-window test done"); + + equal(loadCount, 1, "background script loaded only once"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_teardown.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_teardown.js new file mode 100644 index 0000000000..243bc27867 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_teardown.js @@ -0,0 +1,98 @@ +"use strict"; + +add_task(async function test_background_reload_and_unload() { + let events = []; + { + const { Management } = ChromeUtils.importESModule( + "resource://gre/modules/Extension.sys.mjs" + ); + let record = (type, extensionContext) => { + let eventType = type == "proxy-context-load" ? "load" : "unload"; + let url = extensionContext.uri.spec; + let extensionId = extensionContext.extension.id; + events.push({ eventType, url, extensionId }); + }; + + Management.on("proxy-context-load", record); + Management.on("proxy-context-unload", record); + registerCleanupFunction(() => { + Management.off("proxy-context-load", record); + Management.off("proxy-context-unload", record); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.onMessage.addListener(msg => { + browser.test.assertEq("reload-background", msg); + location.reload(); + }); + browser.test.sendMessage("background-url", location.href); + }, + }); + + await extension.startup(); + let backgroundUrl = await extension.awaitMessage("background-url"); + + let contextEvents = events.splice(0); + equal( + contextEvents.length, + 1, + "ExtensionContext state change after loading an extension" + ); + equal(contextEvents[0].eventType, "load"); + equal( + contextEvents[0].url, + backgroundUrl, + "The ExtensionContext should be the background page" + ); + + extension.sendMessage("reload-background"); + await extension.awaitMessage("background-url"); + + contextEvents = events.splice(0); + equal( + contextEvents.length, + 2, + "ExtensionContext state changes after reloading the background page" + ); + equal( + contextEvents[0].eventType, + "unload", + "Unload ExtensionContext of background page" + ); + equal( + contextEvents[0].url, + backgroundUrl, + "ExtensionContext URL = background" + ); + equal( + contextEvents[1].eventType, + "load", + "Create new ExtensionContext for background page" + ); + equal( + contextEvents[1].url, + backgroundUrl, + "ExtensionContext URL = background" + ); + + await extension.unload(); + + contextEvents = events.splice(0); + equal( + contextEvents.length, + 1, + "ExtensionContext state change after unloading the extension" + ); + equal( + contextEvents[0].eventType, + "unload", + "Unload ExtensionContext for background page after extension unloads" + ); + equal( + contextEvents[0].url, + backgroundUrl, + "ExtensionContext URL = background" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_telemetry.js new file mode 100644 index 0000000000..4af16405a2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_telemetry.js @@ -0,0 +1,119 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const HISTOGRAM = "WEBEXT_BACKGROUND_PAGE_LOAD_MS"; +const HISTOGRAM_KEYED = "WEBEXT_BACKGROUND_PAGE_LOAD_MS_BY_ADDONID"; + +add_task(async function test_telemetry() { + const { GleanTimingDistribution } = globalThis; + + let extension1 = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("loaded"); + }, + }); + + let extension2 = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("loaded"); + }, + }); + + resetTelemetryData(); + + assertHistogramEmpty(HISTOGRAM); + assertKeyedHistogramEmpty(HISTOGRAM_KEYED); + assertGleanMetricsNoSamples({ + metricId: "backgroundPageLoad", + gleanMetric: Glean.extensionsTiming.backgroundPageLoad, + gleanMetricConstructor: GleanTimingDistribution, + }); + + await extension1.startup(); + await extension1.awaitMessage("loaded"); + + const processSnapshot = snapshot => { + return snapshot.sum > 0; + }; + + const processKeyedSnapshot = snapshot => { + let res = {}; + for (let key of Object.keys(snapshot)) { + res[key] = snapshot[key].sum > 0; + } + return res; + }; + + assertHistogramSnapshot( + HISTOGRAM, + { processSnapshot, expectedValue: true }, + `Data recorded for first extension for histogram: ${HISTOGRAM}.` + ); + + assertHistogramSnapshot( + HISTOGRAM_KEYED, + { + keyed: true, + processSnapshot: processKeyedSnapshot, + expectedValue: { + [extension1.extension.id]: true, + }, + }, + `Data recorded for first extension for histogram ${HISTOGRAM_KEYED}` + ); + + assertGleanMetricsSamplesCount({ + metricId: "backgroundPageLoad", + gleanMetric: Glean.extensionsTiming.backgroundPageLoad, + gleanMetricConstructor: GleanTimingDistribution, + expectedSamplesCount: 1, + }); + + let histogram = Services.telemetry.getHistogramById(HISTOGRAM); + let histogramKeyed = + Services.telemetry.getKeyedHistogramById(HISTOGRAM_KEYED); + let histogramSum = histogram.snapshot().sum; + let histogramSumExt1 = histogramKeyed.snapshot()[extension1.extension.id].sum; + + await extension2.startup(); + await extension2.awaitMessage("loaded"); + + assertHistogramSnapshot( + HISTOGRAM, + { + processSnapshot: snapshot => snapshot.sum > histogramSum, + expectedValue: true, + }, + `Data recorded for second extension for histogram: ${HISTOGRAM}.` + ); + + assertHistogramSnapshot( + HISTOGRAM_KEYED, + { + keyed: true, + processSnapshot: processKeyedSnapshot, + expectedValue: { + [extension1.extension.id]: true, + [extension2.extension.id]: true, + }, + }, + `Data recorded for second extension for histogram ${HISTOGRAM_KEYED}` + ); + + equal( + histogramKeyed.snapshot()[extension1.extension.id].sum, + histogramSumExt1, + `Data recorder for first extension is unchanged on the keyed histogram ${HISTOGRAM_KEYED}` + ); + + assertGleanMetricsSamplesCount({ + metricId: "backgroundPageLoad", + gleanMetric: Glean.extensionsTiming.backgroundPageLoad, + gleanMetricConstructor: GleanTimingDistribution, + expectedSamplesCount: 2, + }); + + await extension1.unload(); + await extension2.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_type_module.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_type_module.js new file mode 100644 index 0000000000..74512e1e41 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_type_module.js @@ -0,0 +1,133 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +async function assertBackgroundScriptTypes( + extensionTestWrapper, + expectedScriptTypesMap +) { + const { baseURI } = extensionTestWrapper.extension; + let expectedMapWithResolvedURLs = Object.keys(expectedScriptTypesMap).reduce( + (result, scriptPath) => { + result[baseURI.resolve(scriptPath)] = expectedScriptTypesMap[scriptPath]; + return result; + }, + {} + ); + const page = await ExtensionTestUtils.loadContentPage( + baseURI.resolve("_generated_background_page.html") + ); + const scriptTypesMap = await page.spawn([], () => { + const scripts = Array.from( + this.content.document.querySelectorAll("script") + ); + return scripts.reduce((result, script) => { + result[script.getAttribute("src")] = script.getAttribute("type"); + return result; + }, {}); + }); + await page.close(); + Assert.deepEqual( + scriptTypesMap, + expectedMapWithResolvedURLs, + "Got the expected script type from the generated background page" + ); +} + +async function testBackgroundScriptClassic({ manifestTypeClassicSet }) { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + background: { + scripts: ["anotherScript.js", "main.js"], + type: manifestTypeClassicSet ? "classic" : undefined, + }, + }, + files: { + "main.js": ``, + "anotherScript.js": ``, + }, + }); + + await extension.startup(); + await assertBackgroundScriptTypes(extension, { + "main.js": "text/javascript", + "anotherScript.js": "text/javascript", + }); + await extension.unload(); +} + +add_task(async function test_background_scripts_type_default() { + await testBackgroundScriptClassic({ manifestTypeClassicSet: false }); +}); + +add_task(async function test_background_scripts_type_classic() { + await testBackgroundScriptClassic({ manifestTypeClassicSet: true }); +}); + +add_task(async function test_background_scripts_type_module() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + background: { + scripts: ["anotherModule.js", "mainModule.js"], + type: "module", + }, + }, + files: { + "mainModule.js": ` + import { initBackground } from "/importedModule.js"; + browser.test.log("mainModule.js - ESM module executing"); + initBackground(); + `, + "importedModule.js": ` + export function initBackground() { + browser.test.onMessage.addListener((msg) => { + browser.test.log("importedModule.js - test message received"); + browser.test.sendMessage("esm-module-reply", msg); + }); + browser.test.log("importedModule.js - initBackground executed"); + } + browser.test.log("importedModule.js - ESM module loaded"); + `, + "anotherModule.js": ` + browser.test.log("anotherModule.js - ESM module loaded"); + `, + }, + }); + + await extension.startup(); + await extension.sendMessage("test-event-value"); + equal( + await extension.awaitMessage("esm-module-reply"), + "test-event-value", + "Got the expected event from the ESM module loaded from the background script" + ); + await assertBackgroundScriptTypes(extension, { + "mainModule.js": "module", + "anotherModule.js": "module", + }); + await extension.unload(); +}); + +add_task(async function test_background_scripts_type_invalid() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + background: { + scripts: ["anotherScript.js", "main.js"], + type: "invalid", + }, + }, + files: { + "main.js": ``, + "anotherScript.js": ``, + }, + }); + + ExtensionTestUtils.failOnSchemaWarnings(false); + await Assert.rejects( + extension.startup(), + /Error processing background: .* \.type must be one of/, + "Expected install to fail" + ); + ExtensionTestUtils.failOnSchemaWarnings(true); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_window_properties.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_window_properties.js new file mode 100644 index 0000000000..fb2ca27482 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_window_properties.js @@ -0,0 +1,41 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testBackgroundWindowProperties() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + let expectedValues = { + screenX: 0, + screenY: 0, + outerWidth: 0, + outerHeight: 0, + }; + + for (let k in window) { + try { + if (k in expectedValues) { + browser.test.assertEq( + expectedValues[k], + window[k], + `should return the expected value for window property: ${k}` + ); + } else { + void window[k]; + } + } catch (e) { + browser.test.assertEq( + null, + e, + `unexpected exception accessing window property: ${k}` + ); + } + } + + browser.test.notifyPass("background.testWindowProperties.done"); + }, + }); + await extension.startup(); + await extension.awaitFinish("background.testWindowProperties.done"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_brokenlinks.js b/toolkit/components/extensions/test/xpcshell/test_ext_brokenlinks.js new file mode 100644 index 0000000000..c066147268 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_brokenlinks.js @@ -0,0 +1,54 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* + * This test extension has a background script 'missing.js' that is missing + * from the XPI. Such an extension should install/uninstall cleanly without + * causing timeouts. + */ +add_task(async function testXPIMissingBackGroundScript() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + background: { + scripts: ["missing.js"], + }, + }, + }); + + await extension.startup(); + await extension.unload(); + ok(true, "load/unload completed without timing out"); +}); + +/* + * This test extension includes a page with a missing script. The + * extension should install/uninstall cleanly without causing hangs. + */ +add_task(async function testXPIMissingPageScript() { + async function pageScript() { + browser.test.sendMessage("pageReady"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("ready", browser.runtime.getURL("page.html")); + }, + files: { + "page.html": `<html><head> + <script src="missing.js"></script> + <script src="page.js"></script> + </head></html>`, + "page.js": pageScript, + }, + }); + + await extension.startup(); + let url = await extension.awaitMessage("ready"); + let contentPage = await ExtensionTestUtils.loadContentPage(url); + await extension.awaitMessage("pageReady"); + await extension.unload(); + await contentPage.close(); + + ok(true, "load/unload completed without timing out"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js b/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js new file mode 100644 index 0000000000..f1f681b240 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js @@ -0,0 +1,528 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +// The test extension uses an insecure update url. +Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false); + +const SETTINGS_ID = "test_settings_staged_restart_webext@tests.mozilla.org"; + +const { createAppInfo, promiseShutdownManager, promiseStartupManager } = + AddonTestUtils; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42"); + +add_task(async function test_browser_settings() { + const PERM_DENY_ACTION = Services.perms.DENY_ACTION; + const PERM_UNKNOWN_ACTION = Services.perms.UNKNOWN_ACTION; + + // Create an object to hold the values to which we will initialize the prefs. + const PREFS = { + "browser.cache.disk.enable": true, + "browser.cache.memory.enable": true, + "dom.popup_allowed_events": Preferences.get("dom.popup_allowed_events"), + "image.animation_mode": "none", + "permissions.default.desktop-notification": PERM_UNKNOWN_ACTION, + "ui.context_menus.after_mouseup": false, + "browser.tabs.closeTabByDblclick": false, + "browser.tabs.loadBookmarksInTabs": false, + "browser.search.openintab": false, + "browser.tabs.insertRelatedAfterCurrent": true, + "browser.tabs.insertAfterCurrent": false, + "browser.display.document_color_use": 1, + "layout.css.prefers-color-scheme.content-override": 2, + "browser.display.use_document_fonts": 1, + "browser.zoom.full": true, + "browser.zoom.siteSpecific": true, + }; + + async function background() { + let listeners = new Set([]); + browser.test.onMessage.addListener(async (msg, apiName, value) => { + let apiObj = browser.browserSettings; + let apiNameSplit = apiName.split("."); + for (let apiPart of apiNameSplit) { + apiObj = apiObj[apiPart]; + } + if (msg == "get") { + browser.test.sendMessage("settingData", await apiObj.get({})); + return; + } + + // set and setNoOp + + // Don't add more than one listner per apiName. We leave the + // listener to ensure we do not get more calls than we expect. + if (!listeners.has(apiName)) { + apiObj.onChange.addListener(details => { + browser.test.sendMessage("onChange", { + details, + setting: apiName, + }); + }); + listeners.add(apiName); + } + let result = await apiObj.set({ value }); + if (msg === "set") { + browser.test.assertTrue(result, "set returns true."); + browser.test.sendMessage("settingData", await apiObj.get({})); + } else { + browser.test.assertFalse(result, "set returns false for a no-op."); + browser.test.sendMessage("no-op set"); + } + }); + } + + // Set prefs to our initial values. + for (let pref in PREFS) { + Preferences.set(pref, PREFS[pref]); + } + + registerCleanupFunction(() => { + // Reset the prefs. + for (let pref in PREFS) { + Preferences.reset(pref); + } + }); + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browserSettings"], + }, + useAddonManager: "temporary", + }); + + await promiseStartupManager(); + await extension.startup(); + + async function testSetting(setting, value, expected, expectedValue = value) { + extension.sendMessage("set", setting, value); + let data = await extension.awaitMessage("settingData"); + let dataChange = await extension.awaitMessage("onChange"); + equal(setting, dataChange.setting, "onChange fired"); + equal( + data.value, + dataChange.details.value, + "onChange fired with correct value" + ); + deepEqual( + data.value, + expectedValue, + `The ${setting} setting has the expected value.` + ); + equal( + data.levelOfControl, + "controlled_by_this_extension", + `The ${setting} setting has the expected levelOfControl.` + ); + for (let pref in expected) { + equal( + Preferences.get(pref), + expected[pref], + `${pref} set correctly for ${value}` + ); + } + } + + async function testNoOpSetting(setting, value, expected) { + extension.sendMessage("setNoOp", setting, value); + await extension.awaitMessage("no-op set"); + for (let pref in expected) { + equal( + Preferences.get(pref), + expected[pref], + `${pref} set correctly for ${value}` + ); + } + } + + await testSetting("cacheEnabled", false, { + "browser.cache.disk.enable": false, + "browser.cache.memory.enable": false, + }); + await testSetting("cacheEnabled", true, { + "browser.cache.disk.enable": true, + "browser.cache.memory.enable": true, + }); + + await testSetting("allowPopupsForUserEvents", false, { + "dom.popup_allowed_events": "", + }); + await testSetting("allowPopupsForUserEvents", true, { + "dom.popup_allowed_events": PREFS["dom.popup_allowed_events"], + }); + + for (let value of ["normal", "none", "once"]) { + await testSetting("imageAnimationBehavior", value, { + "image.animation_mode": value, + }); + } + + await testSetting("webNotificationsDisabled", true, { + "permissions.default.desktop-notification": PERM_DENY_ACTION, + }); + await testSetting("webNotificationsDisabled", false, { + // This pref is not defaulted on Android. + "permissions.default.desktop-notification": + AppConstants.MOZ_BUILD_APP !== "browser" + ? undefined + : PERM_UNKNOWN_ACTION, + }); + + // This setting is a no-op on Android. + if (AppConstants.platform === "android") { + await testNoOpSetting("contextMenuShowEvent", "mouseup", { + "ui.context_menus.after_mouseup": false, + }); + } else { + await testSetting("contextMenuShowEvent", "mouseup", { + "ui.context_menus.after_mouseup": true, + }); + } + + // "mousedown" is also a no-op on Windows. + if (["android", "win"].includes(AppConstants.platform)) { + await testNoOpSetting("contextMenuShowEvent", "mousedown", { + "ui.context_menus.after_mouseup": AppConstants.platform === "win", + }); + } else { + await testSetting("contextMenuShowEvent", "mousedown", { + "ui.context_menus.after_mouseup": false, + }); + } + + if (AppConstants.platform !== "android") { + await testSetting("closeTabsByDoubleClick", true, { + "browser.tabs.closeTabByDblclick": true, + }); + await testSetting("closeTabsByDoubleClick", false, { + "browser.tabs.closeTabByDblclick": false, + }); + } + + extension.sendMessage("get", "ftpProtocolEnabled"); + let data = await extension.awaitMessage("settingData"); + equal(data.value, false); + equal( + data.levelOfControl, + "not_controllable", + `ftpProtocolEnabled is not controllable.` + ); + + await testSetting("newTabPosition", "afterCurrent", { + "browser.tabs.insertRelatedAfterCurrent": false, + "browser.tabs.insertAfterCurrent": true, + }); + await testSetting("newTabPosition", "atEnd", { + "browser.tabs.insertRelatedAfterCurrent": false, + "browser.tabs.insertAfterCurrent": false, + }); + await testSetting("newTabPosition", "relatedAfterCurrent", { + "browser.tabs.insertRelatedAfterCurrent": true, + "browser.tabs.insertAfterCurrent": false, + }); + + await testSetting("openBookmarksInNewTabs", true, { + "browser.tabs.loadBookmarksInTabs": true, + }); + await testSetting("openBookmarksInNewTabs", false, { + "browser.tabs.loadBookmarksInTabs": false, + }); + + await testSetting("openSearchResultsInNewTabs", true, { + "browser.search.openintab": true, + }); + await testSetting("openSearchResultsInNewTabs", false, { + "browser.search.openintab": false, + }); + + await testSetting("openUrlbarResultsInNewTabs", true, { + "browser.urlbar.openintab": true, + }); + await testSetting("openUrlbarResultsInNewTabs", false, { + "browser.urlbar.openintab": false, + }); + + await testSetting("overrideDocumentColors", "high-contrast-only", { + "browser.display.document_color_use": 0, + }); + await testSetting("overrideDocumentColors", "never", { + "browser.display.document_color_use": 1, + }); + await testSetting("overrideDocumentColors", "always", { + "browser.display.document_color_use": 2, + }); + + await testSetting("overrideContentColorScheme", "dark", { + "layout.css.prefers-color-scheme.content-override": 0, + }); + await testSetting("overrideContentColorScheme", "light", { + "layout.css.prefers-color-scheme.content-override": 1, + }); + await testSetting("overrideContentColorScheme", "auto", { + "layout.css.prefers-color-scheme.content-override": 2, + }); + + await testSetting("useDocumentFonts", false, { + "browser.display.use_document_fonts": 0, + }); + await testSetting("useDocumentFonts", true, { + "browser.display.use_document_fonts": 1, + }); + + await testSetting("zoomFullPage", true, { + "browser.zoom.full": true, + }); + await testSetting("zoomFullPage", false, { + "browser.zoom.full": false, + }); + + await testSetting("zoomSiteSpecific", true, { + "browser.zoom.siteSpecific": true, + }); + await testSetting("zoomSiteSpecific", false, { + "browser.zoom.siteSpecific": false, + }); + + await testSetting("colorManagement.mode", "off", { + "gfx.color_management.mode": 0, + }); + await testSetting("colorManagement.mode", "full", { + "gfx.color_management.mode": 1, + }); + await testSetting("colorManagement.mode", "tagged_only", { + "gfx.color_management.mode": 2, + }); + + await testSetting("colorManagement.useNativeSRGB", false, { + "gfx.color_management.native_srgb": false, + }); + await testSetting("colorManagement.useNativeSRGB", true, { + "gfx.color_management.native_srgb": true, + }); + + await testSetting("colorManagement.useWebRenderCompositor", false, { + "gfx.webrender.compositor": false, + }); + await testSetting("colorManagement.useWebRenderCompositor", true, { + "gfx.webrender.compositor": true, + }); + + await extension.unload(); + await promiseShutdownManager(); +}); + +add_task(async function test_bad_value() { + async function background() { + await browser.test.assertRejects( + browser.browserSettings.contextMenuShowEvent.set({ value: "bad" }), + /bad is not a valid value for contextMenuShowEvent/, + "contextMenuShowEvent.set rejects with an invalid value." + ); + + await browser.test.assertRejects( + browser.browserSettings.overrideDocumentColors.set({ value: 2 }), + /2 is not a valid value for overrideDocumentColors/, + "overrideDocumentColors.set rejects with an invalid value." + ); + + await browser.test.assertRejects( + browser.browserSettings.overrideDocumentColors.set({ value: "bad" }), + /bad is not a valid value for overrideDocumentColors/, + "overrideDocumentColors.set rejects with an invalid value." + ); + + await browser.test.assertRejects( + browser.browserSettings.overrideContentColorScheme.set({ value: 0 }), + /0 is not a valid value for overrideContentColorScheme/, + "overrideContentColorScheme.set rejects with an invalid value." + ); + + await browser.test.assertRejects( + browser.browserSettings.overrideContentColorScheme.set({ value: "bad" }), + /bad is not a valid value for overrideContentColorScheme/, + "overrideContentColorScheme.set rejects with an invalid value." + ); + + await browser.test.assertRejects( + browser.browserSettings.zoomFullPage.set({ value: 0 }), + /0 is not a valid value for zoomFullPage/, + "zoomFullPage.set rejects with an invalid value." + ); + + await browser.test.assertRejects( + browser.browserSettings.zoomFullPage.set({ value: "bad" }), + /bad is not a valid value for zoomFullPage/, + "zoomFullPage.set rejects with an invalid value." + ); + + await browser.test.assertRejects( + browser.browserSettings.zoomSiteSpecific.set({ value: 0 }), + /0 is not a valid value for zoomSiteSpecific/, + "zoomSiteSpecific.set rejects with an invalid value." + ); + + await browser.test.assertRejects( + browser.browserSettings.zoomSiteSpecific.set({ value: "bad" }), + /bad is not a valid value for zoomSiteSpecific/, + "zoomSiteSpecific.set rejects with an invalid value." + ); + + browser.test.sendMessage("done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browserSettings"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function test_bad_value_android() { + if (AppConstants.platform !== "android") { + return; + } + + async function background() { + await browser.test.assertRejects( + browser.browserSettings.closeTabsByDoubleClick.set({ value: true }), + /android is not a supported platform for the closeTabsByDoubleClick setting/, + "closeTabsByDoubleClick.set rejects on Android." + ); + + await browser.test.assertRejects( + browser.browserSettings.closeTabsByDoubleClick.get({}), + /android is not a supported platform for the closeTabsByDoubleClick setting/, + "closeTabsByDoubleClick.get rejects on Android." + ); + + await browser.test.assertRejects( + browser.browserSettings.closeTabsByDoubleClick.clear({}), + /android is not a supported platform for the closeTabsByDoubleClick setting/, + "closeTabsByDoubleClick.clear rejects on Android." + ); + + browser.test.sendMessage("done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browserSettings"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +// Verifies settings remain after a staged update on restart. +add_task(async function delay_updates_settings_after_restart() { + let server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] }); + AddonTestUtils.registerJSON(server, "/test_update.json", { + addons: { + "test_settings_staged_restart_webext@tests.mozilla.org": { + updates: [ + { + version: "2.0", + update_link: + "http://example.com/addons/test_settings_staged_restart_v2.xpi", + }, + ], + }, + }, + }); + const update_xpi = AddonTestUtils.createTempXPIFile({ + "manifest.json": { + manifest_version: 2, + name: "Delay Upgrade", + version: "2.0", + browser_specific_settings: { + gecko: { id: SETTINGS_ID }, + }, + permissions: ["browserSettings"], + }, + }); + server.registerFile( + `/addons/test_settings_staged_restart_v2.xpi`, + update_xpi + ); + + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: SETTINGS_ID, + update_url: `http://example.com/test_update.json`, + }, + }, + permissions: ["browserSettings"], + }, + background() { + browser.runtime.onUpdateAvailable.addListener(async details => { + if (details) { + await browser.browserSettings.webNotificationsDisabled.set({ + value: true, + }); + if (details.version) { + // This should be the version of the pending update. + browser.test.assertEq("2.0", details.version, "correct version"); + browser.test.notifyPass("delay"); + } + } else { + browser.test.fail("no details object passed"); + } + }); + browser.test.sendMessage("ready"); + }, + }); + + await Promise.all([extension.startup(), extension.awaitMessage("ready")]); + + let prefname = "permissions.default.desktop-notification"; + let val = Services.prefs.getIntPref(prefname); + Assert.notEqual(val, 2, "webNotificationsDisabled pref not set"); + + let update = await AddonTestUtils.promiseFindAddonUpdates(extension.addon); + let install = update.updateAvailable; + Assert.ok(install, `install is available ${update.error}`); + + await AddonTestUtils.promiseCompleteAllInstalls([install]); + + Assert.equal(install.state, AddonManager.STATE_POSTPONED); + await extension.awaitFinish("delay"); + + // restarting allows upgrade to proceed + await AddonTestUtils.promiseRestartManager(); + + await extension.awaitStartup(); + + // If an update is not handled correctly we would fail here. Bug 1639705. + val = Services.prefs.getIntPref(prefname); + Assert.equal(val, 2, "webNotificationsDisabled pref set"); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + + val = Services.prefs.getIntPref(prefname); + Assert.notEqual(val, 2, "webNotificationsDisabled pref not set"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings_homepage.js b/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings_homepage.js new file mode 100644 index 0000000000..8d1d16c743 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings_homepage.js @@ -0,0 +1,36 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_homepage_get_without_set() { + async function background() { + let homepage = await browser.browserSettings.homepageOverride.get({}); + browser.test.sendMessage("homepage", homepage); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browserSettings"], + }, + }); + + let defaultHomepage = Services.prefs.getStringPref( + "browser.startup.homepage" + ); + + await extension.startup(); + let homepage = await extension.awaitMessage("homepage"); + equal( + homepage.value, + defaultHomepage, + "The homepageOverride setting has the expected value." + ); + equal( + homepage.levelOfControl, + "not_controllable", + "The homepageOverride setting has the expected levelOfControl." + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browser_style_deprecation.js b/toolkit/components/extensions/test/xpcshell/test_ext_browser_style_deprecation.js new file mode 100644 index 0000000000..ab12181302 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_browser_style_deprecation.js @@ -0,0 +1,330 @@ +"use strict"; + +AddonTestUtils.init(this); +// This test expects and checks deprecation warnings. +ExtensionTestUtils.failOnSchemaWarnings(false); + +const PREF_SUPPORTED = "extensions.browser_style_mv3.supported"; +const PREF_SAME_AS_MV2 = "extensions.browser_style_mv3.same_as_mv2"; + +function checkBrowserStyleInManifestKey(extension, key, expected) { + let actual = extension.extension.manifest[key].browser_style; + Assert.strictEqual(actual, expected, `Expected browser_style of "${key}"`); +} + +const BROWSER_STYLE_MV2_DEFAULTS = "BROWSER_STYLE_MV2_DEFAULTS"; +async function checkBrowserStyle({ + manifest_version = 3, + browser_style_in_manifest = null, + expected_browser_style, + expected_warnings, +}) { + const actionKey = manifest_version === 2 ? "browser_action" : "action"; + // sidebar_action is implemented in browser/ and therefore only available to + // Firefox desktop and not other toolkit apps such as Firefox for Android, + // Thunderbird, etc. + const IS_SIDEBAR_SUPPORTED = AppConstants.MOZ_BUILD_APP === "browser"; + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version, + options_ui: { + page: "options.html", + browser_style: browser_style_in_manifest, + }, + [actionKey]: { + browser_style: browser_style_in_manifest, + }, + page_action: { + browser_style: browser_style_in_manifest, + }, + sidebar_action: { + default_panel: "sidebar.html", + browser_style: browser_style_in_manifest, + }, + }, + }); + await extension.startup(); + if (expected_browser_style === BROWSER_STYLE_MV2_DEFAULTS) { + checkBrowserStyleInManifestKey(extension, "options_ui", true); + checkBrowserStyleInManifestKey(extension, actionKey, false); + checkBrowserStyleInManifestKey(extension, "page_action", false); + if (IS_SIDEBAR_SUPPORTED) { + checkBrowserStyleInManifestKey(extension, "sidebar_action", true); + } + } else { + let value = expected_browser_style; + checkBrowserStyleInManifestKey(extension, "options_ui", value); + checkBrowserStyleInManifestKey(extension, actionKey, value); + checkBrowserStyleInManifestKey(extension, "page_action", value); + if (IS_SIDEBAR_SUPPORTED) { + checkBrowserStyleInManifestKey(extension, "sidebar_action", value); + } + } + if (!IS_SIDEBAR_SUPPORTED) { + expected_warnings = expected_warnings.filter( + msg => !msg.includes("sidebar_action") + ); + expected_warnings.unshift( + `Reading manifest: Warning processing sidebar_action: An unexpected property was found in the WebExtension manifest.` + ); + } + const warnings = extension.extension.warnings; + await extension.unload(); + Assert.deepEqual( + warnings, + expected_warnings, + `Got expected warnings for MV${manifest_version} extension with browser_style:${browser_style_in_manifest}.` + ); +} + +async function checkBrowserStyleWithOpenInTabTrue({ + manifest_version = 3, + browser_style_in_manifest = null, + expected_browser_style, +}) { + info( + `Testing options_ui.open_in_tab=true + browser_style=${browser_style_in_manifest} for MV${manifest_version} extension` + ); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version, + options_ui: { + page: "options.html", + browser_style: browser_style_in_manifest, + open_in_tab: true, + }, + }, + }); + await extension.startup(); + checkBrowserStyleInManifestKey( + extension, + "options_ui", + expected_browser_style + ); + const warnings = extension.extension.warnings; + await extension.unload(); + Assert.deepEqual( + warnings, + [], + "Expected no warnings on extension with options_ui.open_in_tab true" + ); +} + +async function repeatTestIndependentOfPref_browser_style_same_as_mv2(testFn) { + for (let same_as_mv2 of [true, false]) { + await runWithPrefs([[PREF_SAME_AS_MV2, same_as_mv2]], testFn); + } +} +async function repeatTestIndependentOf_browser_style_deprecation_prefs(testFn) { + for (let supported of [true, false]) { + for (let same_as_mv2 of [true, false]) { + await runWithPrefs( + [ + [PREF_SUPPORTED, supported], + [PREF_SAME_AS_MV2, same_as_mv2], + ], + testFn + ); + } + } +} + +add_task(async function browser_style_never_deprecated_in_MV2() { + async function check_browser_style_never_deprecated_in_MV2() { + await checkBrowserStyle({ + manifest_version: 2, + browser_style_in_manifest: true, + expected_browser_style: true, + expected_warnings: [], + }); + await checkBrowserStyle({ + manifest_version: 2, + browser_style_in_manifest: false, + expected_browser_style: false, + expected_warnings: [], + }); + await checkBrowserStyle({ + manifest_version: 2, + browser_style_in_manifest: null, + expected_browser_style: "BROWSER_STYLE_MV2_DEFAULTS", + expected_warnings: [], + }); + + // When open_in_tab is true, browser_style is not used and its value does + // not matter. Since we want the parsed value to be false in MV3, and the + // implementation is simpler if consistently applied to MV2, browser_style + // is false when open_in_tab is true (even if browser_style:true is set). + await checkBrowserStyleWithOpenInTabTrue({ + manifest_version: 2, + browser_style_in_manifest: null, + expected_browser_style: false, + }); + await checkBrowserStyleWithOpenInTabTrue({ + manifest_version: 2, + browser_style_in_manifest: true, + expected_browser_style: false, + }); + } + // Regardless of all potential test configurations, browser_style is never + // deprecated in MV2. + await repeatTestIndependentOf_browser_style_deprecation_prefs( + check_browser_style_never_deprecated_in_MV2 + ); +}); + +add_task(async function open_in_tab_implies_browser_style_false_MV3() { + // Regardless of all potential test configurations, when + // options_ui.open_in_tab is true, options_ui.browser_style should be false, + // because it being true would print deprecation warnings in MV3, and + // browser_style:true does not have any effect when open_in_tab is true. + await repeatTestIndependentOf_browser_style_deprecation_prefs(async () => { + await checkBrowserStyleWithOpenInTabTrue({ + manifest_version: 3, + browser_style_in_manifest: null, + expected_browser_style: false, + }); + await checkBrowserStyleWithOpenInTabTrue({ + manifest_version: 3, + browser_style_in_manifest: true, + expected_browser_style: false, + }); + }); +}); + +// Disable browser_style:true - bug 1830711. +add_task(async function unsupported_and_browser_style_true() { + await checkBrowserStyle({ + manifest_version: 3, + browser_style_in_manifest: true, + expected_browser_style: false, + expected_warnings: [ + // TODO bug 1830712: Update warnings when max_manifest_version:2 is used. + `Reading manifest: Warning processing options_ui.browser_style: "browser_style:true" is no longer supported in Manifest Version 3.`, + `Reading manifest: Warning processing action.browser_style: "browser_style:true" is no longer supported in Manifest Version 3.`, + `Reading manifest: Warning processing page_action.browser_style: "browser_style:true" is no longer supported in Manifest Version 3.`, + `Reading manifest: Warning processing sidebar_action.browser_style: "browser_style:true" is no longer supported in Manifest Version 3.`, + ], + }); +}); + +add_task(async function unsupported_and_browser_style_false() { + await checkBrowserStyle({ + manifest_version: 3, + browser_style_in_manifest: false, + expected_browser_style: false, + // TODO bug 1830712: Add warnings when max_manifest_version:2 is used. + expected_warnings: [], + }); +}); + +add_task(async function unsupported_and_browser_style_default() { + await checkBrowserStyle({ + manifest_version: 3, + browser_style_in_manifest: null, + expected_browser_style: false, + expected_warnings: [], + }); +}); + +add_task( + { pref_set: [[PREF_SUPPORTED, true]] }, + async function supported_with_browser_style_true() { + await repeatTestIndependentOfPref_browser_style_same_as_mv2(async () => { + await checkBrowserStyle({ + manifest_version: 3, + browser_style_in_manifest: true, + expected_browser_style: true, + expected_warnings: [ + `Reading manifest: Warning processing options_ui.browser_style: "browser_style:true" has been deprecated in Manifest Version 3 and will be unsupported in the near future.`, + `Reading manifest: Warning processing action.browser_style: "browser_style:true" has been deprecated in Manifest Version 3 and will be unsupported in the near future.`, + `Reading manifest: Warning processing page_action.browser_style: "browser_style:true" has been deprecated in Manifest Version 3 and will be unsupported in the near future.`, + `Reading manifest: Warning processing sidebar_action.browser_style: "browser_style:true" has been deprecated in Manifest Version 3 and will be unsupported in the near future.`, + ], + }); + }); + } +); + +add_task( + { pref_set: [[PREF_SUPPORTED, true]] }, + async function supported_with_browser_style_false() { + await repeatTestIndependentOfPref_browser_style_same_as_mv2(async () => { + await checkBrowserStyle({ + manifest_version: 3, + browser_style_in_manifest: false, + expected_browser_style: false, + expected_warnings: [], + }); + }); + } +); + +// Initial prefs - warn only - https://bugzilla.mozilla.org/show_bug.cgi?id=1827910#c1 +add_task( + { + pref_set: [ + [PREF_SUPPORTED, true], + [PREF_SAME_AS_MV2, true], + ], + }, + async function supported_with_mv2_defaults() { + const makeWarning = manifestKey => + `Reading manifest: Warning processing ${manifestKey}.browser_style: "browser_style:true" has been deprecated in Manifest Version 3 and will be unsupported in the near future. While "${manifestKey}.browser_style" was not explicitly specified in manifest.json, its default value was true. Its default will change to false in Manifest Version 3 starting from Firefox 115.`; + await checkBrowserStyle({ + manifest_version: 3, + browser_style_in_manifest: null, + expected_browser_style: "BROWSER_STYLE_MV2_DEFAULTS", + expected_warnings: [ + makeWarning("options_ui"), + makeWarning("sidebar_action"), + ], + }); + } +); + +// Deprecation + change defaults - bug 1830710. +add_task( + { + pref_set: [ + [PREF_SUPPORTED, true], + [PREF_SAME_AS_MV2, false], + ], + }, + async function supported_with_browser_style_default_false() { + const makeWarning = manifestKey => + `Reading manifest: Warning processing ${manifestKey}.browser_style: "browser_style:true" has been deprecated in Manifest Version 3 and will be unsupported in the near future. While "${manifestKey}.browser_style" was not explicitly specified in manifest.json, its default value was true. The default value of "${manifestKey}.browser_style" has changed from true to false in Manifest Version 3.`; + await checkBrowserStyle({ + manifest_version: 3, + browser_style_in_manifest: null, + expected_browser_style: false, + expected_warnings: [ + makeWarning("options_ui"), + makeWarning("sidebar_action"), + ], + }); + } +); + +// While we are not planning to set this pref combination, users can do so if +// they desire. +add_task( + { + pref_set: [ + [PREF_SUPPORTED, false], + [PREF_SAME_AS_MV2, true], + ], + }, + async function unsupported_with_mv2_defaults() { + const makeWarning = manifestKey => + `Reading manifest: Warning processing ${manifestKey}.browser_style: "browser_style:true" is no longer supported in Manifest Version 3. While "${manifestKey}.browser_style" was not explicitly specified in manifest.json, its default value was true. Its default will change to false in Manifest Version 3 starting from Firefox 115.`; + await checkBrowserStyle({ + manifest_version: 3, + browser_style_in_manifest: null, + expected_browser_style: false, + expected_warnings: [ + makeWarning("options_ui"), + makeWarning("sidebar_action"), + ], + }); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browsingData.js b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData.js new file mode 100644 index 0000000000..1df5e60478 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData.js @@ -0,0 +1,48 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testInvalidArguments() { + async function background() { + const UNSUPPORTED_DATA_TYPES = ["appcache", "fileSystems", "webSQL"]; + + await browser.test.assertRejects( + browser.browsingData.remove( + { originTypes: { protectedWeb: true } }, + { cookies: true } + ), + "Firefox does not support protectedWeb or extension as originTypes.", + "Expected error received when using protectedWeb originType." + ); + + await browser.test.assertRejects( + browser.browsingData.removeCookies({ originTypes: { extension: true } }), + "Firefox does not support protectedWeb or extension as originTypes.", + "Expected error received when using extension originType." + ); + + for (let dataType of UNSUPPORTED_DATA_TYPES) { + let dataTypes = {}; + dataTypes[dataType] = true; + browser.test.assertThrows( + () => browser.browsingData.remove({}, dataTypes), + /Type error for parameter dataToRemove/, + `Expected error received when using ${dataType} dataType.` + ); + } + + browser.test.notifyPass("invalidArguments"); + } + + let extensionData = { + background: background, + manifest: { + permissions: ["browsingData"], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("invalidArguments"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cache.js b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cache.js new file mode 100644 index 0000000000..577d727a49 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cache.js @@ -0,0 +1,456 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +const { SiteDataTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/SiteDataTestUtils.sys.mjs" +); + +const COOKIE = { + host: "example.com", + name: "test_cookie", + path: "/", +}; +const COOKIE_NET = { + host: "example.net", + name: "test_cookie", + path: "/", +}; +const COOKIE_ORG = { + host: "example.org", + name: "test_cookie", + path: "/", +}; +let since, oldCookie; + +function addCookie(cookie) { + Services.cookies.add( + cookie.host, + cookie.path, + cookie.name, + "test", + false, + false, + false, + Date.now() / 1000 + 10000, + {}, + Ci.nsICookie.SAMESITE_NONE, + Ci.nsICookie.SCHEME_HTTPS + ); + ok( + Services.cookies.cookieExists(cookie.host, cookie.path, cookie.name, {}), + `Cookie ${cookie.name} was created.` + ); +} + +async function setUpCookies() { + Services.cookies.removeAll(); + + // Add a cookie which will end up with an older creationTime. + oldCookie = Object.assign({}, COOKIE, { name: Date.now() }); + addCookie(oldCookie); + await new Promise(resolve => setTimeout(resolve, 10)); + since = Date.now(); + await new Promise(resolve => setTimeout(resolve, 10)); + + // Add a cookie which will end up with a more recent creationTime. + addCookie(COOKIE); + + // Add cookies for different domains. + addCookie(COOKIE_NET); + addCookie(COOKIE_ORG); +} + +async function setUpCache() { + Services.cache2.clear(); + + // Add cache entries for different domains. + for (const domain of ["example.net", "example.org", "example.com"]) { + await SiteDataTestUtils.addCacheEntry(`http://${domain}/`, "disk"); + await SiteDataTestUtils.addCacheEntry(`http://${domain}/`, "memory"); + } +} + +function hasCacheEntry(domain) { + const disk = SiteDataTestUtils.hasCacheEntry(`http://${domain}/`, "disk"); + const memory = SiteDataTestUtils.hasCacheEntry(`http://${domain}/`, "memory"); + + equal( + disk, + memory, + `For ${domain} either either both or neither caches need to exists.` + ); + return disk; +} + +add_task(async function testCache() { + function background() { + browser.test.onMessage.addListener(async msg => { + if (msg == "removeCache") { + await browser.browsingData.removeCache({}); + } else { + await browser.browsingData.remove({}, { cache: true }); + } + browser.test.sendMessage("cacheRemoved"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + async function testRemovalMethod(method) { + await setUpCache(); + + extension.sendMessage(method); + await extension.awaitMessage("cacheRemoved"); + + ok(!hasCacheEntry("example.net"), "example.net cache was removed"); + ok(!hasCacheEntry("example.org"), "example.org cache was removed"); + ok(!hasCacheEntry("example.com"), "example.com cache was removed"); + } + + await extension.startup(); + + await testRemovalMethod("removeCache"); + await testRemovalMethod("remove"); + + await extension.unload(); +}); + +add_task(async function testCookies() { + // Above in setUpCookies we create an 'old' cookies, wait 10ms, then log a timestamp. + // Here we ask the browser to delete all cookies after the timestamp, with the intention + // that the 'old' cookie is not removed. The issue arises when the timer precision is + // low enough such that the timestamp that gets logged is the same as the 'old' cookie. + // We hardcode a precision value to ensure that there is time between the 'old' cookie + // and the timestamp generation. + Services.prefs.setBoolPref("privacy.reduceTimerPrecision", true); + Services.prefs.setIntPref( + "privacy.resistFingerprinting.reduceTimerPrecision.microseconds", + 2000 + ); + + registerCleanupFunction(function () { + Services.prefs.clearUserPref("privacy.reduceTimerPrecision"); + Services.prefs.clearUserPref( + "privacy.resistFingerprinting.reduceTimerPrecision.microseconds" + ); + }); + + function background() { + browser.test.onMessage.addListener(async (msg, options) => { + if (msg == "removeCookies") { + await browser.browsingData.removeCookies(options); + } else { + await browser.browsingData.remove(options, { cookies: true }); + } + browser.test.sendMessage("cookiesRemoved"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + async function testRemovalMethod(method) { + // Clear cookies with a recent since value. + await setUpCookies(); + extension.sendMessage(method, { since }); + await extension.awaitMessage("cookiesRemoved"); + + ok( + Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + "Old cookie was not removed." + ); + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + "Recent cookie was removed." + ); + + // Clear cookies with an old since value. + await setUpCookies(); + addCookie(COOKIE); + extension.sendMessage(method, { since: since - 100000 }); + await extension.awaitMessage("cookiesRemoved"); + + ok( + !Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + "Old cookie was removed." + ); + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + "Recent cookie was removed." + ); + + // Clear cookies with no since value and valid originTypes. + await setUpCookies(); + extension.sendMessage(method, { + originTypes: { unprotectedWeb: true, protectedWeb: false }, + }); + await extension.awaitMessage("cookiesRemoved"); + + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + `Cookie ${COOKIE.name} was removed.` + ); + ok( + !Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + `Cookie ${oldCookie.name} was removed.` + ); + } + + await extension.startup(); + + await testRemovalMethod("removeCookies"); + await testRemovalMethod("remove"); + + await extension.unload(); +}); + +add_task(async function testCacheAndCookies() { + function background() { + browser.test.onMessage.addListener(async options => { + await browser.browsingData.remove(options, { + cache: true, + cookies: true, + }); + browser.test.sendMessage("cacheAndCookiesRemoved"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + await extension.startup(); + + // Clear cache and cookies with a recent since value. + await setUpCookies(); + await setUpCache(); + extension.sendMessage({ since }); + await extension.awaitMessage("cacheAndCookiesRemoved"); + + ok( + Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + "Old cookie was not removed." + ); + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + "Recent cookie was removed." + ); + + // Cache does not support |since| and deletes everything! + ok(!hasCacheEntry("example.net"), "example.net cache was removed"); + ok(!hasCacheEntry("example.org"), "example.org cache was removed"); + ok(!hasCacheEntry("example.com"), "example.com cache was removed"); + + // Clear cache and cookies with an old since value. + await setUpCookies(); + await setUpCache(); + extension.sendMessage({ since: since - 100000 }); + await extension.awaitMessage("cacheAndCookiesRemoved"); + + // Cache does not support |since| and deletes everything! + ok(!hasCacheEntry("example.net"), "example.net cache was removed"); + ok(!hasCacheEntry("example.org"), "example.org cache was removed"); + ok(!hasCacheEntry("example.com"), "example.com cache was removed"); + + ok( + !Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + "Old cookie was removed." + ); + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + "Recent cookie was removed." + ); + + // Clear cache and cookies with hostnames value. + await setUpCookies(); + await setUpCache(); + extension.sendMessage({ + hostnames: ["example.net", "example.org", "unknown.com"], + }); + await extension.awaitMessage("cacheAndCookiesRemoved"); + + ok( + Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + `Cookie ${COOKIE.name} was not removed.` + ); + ok( + !Services.cookies.cookieExists( + COOKIE_NET.host, + COOKIE_NET.path, + COOKIE_NET.name, + {} + ), + `Cookie ${COOKIE_NET.name} was removed.` + ); + ok( + !Services.cookies.cookieExists( + COOKIE_ORG.host, + COOKIE_ORG.path, + COOKIE_ORG.name, + {} + ), + `Cookie ${COOKIE_ORG.name} was removed.` + ); + + ok(!hasCacheEntry("example.net"), "example.net cache was removed"); + ok(!hasCacheEntry("example.org"), "example.org cache was removed"); + ok(hasCacheEntry("example.com"), "example.com cache was not removed"); + + // Clear cache and cookies with (empty) hostnames value. + await setUpCookies(); + await setUpCache(); + extension.sendMessage({ hostnames: [] }); + await extension.awaitMessage("cacheAndCookiesRemoved"); + + ok( + Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + `Cookie ${COOKIE.name} was not removed.` + ); + ok( + Services.cookies.cookieExists( + COOKIE_NET.host, + COOKIE_NET.path, + COOKIE_NET.name, + {} + ), + `Cookie ${COOKIE_NET.name} was not removed.` + ); + ok( + Services.cookies.cookieExists( + COOKIE_ORG.host, + COOKIE_ORG.path, + COOKIE_ORG.name, + {} + ), + `Cookie ${COOKIE_ORG.name} was not removed.` + ); + + ok(hasCacheEntry("example.net"), "example.net cache was not removed"); + ok(hasCacheEntry("example.org"), "example.org cache was not removed"); + ok(hasCacheEntry("example.com"), "example.com cache was not removed"); + + // Clear cache and cookies with both hostnames and since values. + await setUpCache(); + await setUpCookies(); + extension.sendMessage({ hostnames: ["example.com"], since }); + await extension.awaitMessage("cacheAndCookiesRemoved"); + + ok( + Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + "Old cookie was not removed." + ); + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + "Recent cookie was removed." + ); + ok( + Services.cookies.cookieExists( + COOKIE_NET.host, + COOKIE_NET.path, + COOKIE_NET.name, + {} + ), + "Cookie with different hostname was not removed" + ); + ok( + Services.cookies.cookieExists( + COOKIE_ORG.host, + COOKIE_ORG.path, + COOKIE_ORG.name, + {} + ), + "Cookie with different hostname was not removed" + ); + + ok(hasCacheEntry("example.net"), "example.net cache was not removed"); + ok(hasCacheEntry("example.org"), "example.org cache was not removed"); + ok(!hasCacheEntry("example.com"), "example.com cache was removed"); + + // Clear cache and cookies with no since or hostnames value. + await setUpCache(); + await setUpCookies(); + extension.sendMessage({}); + await extension.awaitMessage("cacheAndCookiesRemoved"); + + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + `Cookie ${COOKIE.name} was removed.` + ); + ok( + !Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + `Cookie ${oldCookie.name} was removed.` + ); + ok( + !Services.cookies.cookieExists( + COOKIE_NET.host, + COOKIE_NET.path, + COOKIE_NET.name, + {} + ), + `Cookie ${COOKIE_NET.name} was removed.` + ); + ok( + !Services.cookies.cookieExists( + COOKIE_ORG.host, + COOKIE_ORG.path, + COOKIE_ORG.name, + {} + ), + `Cookie ${COOKIE_ORG.name} was removed.` + ); + + ok(!hasCacheEntry("example.net"), "example.net cache was removed"); + ok(!hasCacheEntry("example.org"), "example.org cache was removed"); + ok(!hasCacheEntry("example.com"), "example.com cache was removed"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cookieStoreId.js b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cookieStoreId.js new file mode 100644 index 0000000000..d3d066efd2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cookieStoreId.js @@ -0,0 +1,192 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +// "Normal" cookie +const COOKIE_NORMAL = { + host: "example.com", + name: "test_cookie", + path: "/", + originAttributes: {}, +}; +// Private browsing cookie +const COOKIE_PRIVATE = { + host: "example.net", + name: "test_cookie", + path: "/", + originAttributes: { + privateBrowsingId: 1, + }, +}; +// "firefox-container-1" cookie +const COOKIE_CONTAINER = { + host: "example.org", + name: "test_cookie", + path: "/", + originAttributes: { + userContextId: 1, + }, +}; + +function cookieExists(cookie) { + return Services.cookies.cookieExists( + cookie.host, + cookie.path, + cookie.name, + cookie.originAttributes + ); +} + +function addCookie(cookie) { + const THE_FUTURE = Date.now() + 5 * 60; + + Services.cookies.add( + cookie.host, + cookie.path, + cookie.name, + "test", + false, + false, + false, + THE_FUTURE, + cookie.originAttributes, + Ci.nsICookie.SAMESITE_NONE, + Ci.nsICookie.SCHEME_HTTPS + ); + + ok(cookieExists(cookie), `Cookie ${cookie.name} was created.`); +} + +async function setUpCookies() { + Services.cookies.removeAll(); + + addCookie(COOKIE_NORMAL); + addCookie(COOKIE_PRIVATE); + addCookie(COOKIE_CONTAINER); +} + +add_task(async function testCookies() { + Services.prefs.setBoolPref("privacy.userContext.enabled", true); + + function background() { + browser.test.onMessage.addListener(async (msg, options) => { + if (msg == "removeCookies") { + await browser.browsingData.removeCookies(options); + } else { + await browser.browsingData.remove(options, { cookies: true }); + } + browser.test.sendMessage("cookiesRemoved"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + async function testRemovalMethod(method) { + // Clear only "normal"/default cookies. + await setUpCookies(); + + extension.sendMessage(method, { cookieStoreId: "firefox-default" }); + await extension.awaitMessage("cookiesRemoved"); + + ok(!cookieExists(COOKIE_NORMAL), "Normal cookie was removed"); + ok(cookieExists(COOKIE_PRIVATE), "Private cookie was not removed"); + ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed"); + + // Clear container cookie + await setUpCookies(); + + extension.sendMessage(method, { cookieStoreId: "firefox-container-1" }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(cookieExists(COOKIE_PRIVATE), "Private cookie was not removed"); + ok(!cookieExists(COOKIE_CONTAINER), "Container cookie was removed"); + + // Clear private cookie + await setUpCookies(); + + extension.sendMessage(method, { cookieStoreId: "firefox-private" }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(!cookieExists(COOKIE_PRIVATE), "Private cookie was removed"); + ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed"); + + // Clear container cookie with correct hostname + await setUpCookies(); + + extension.sendMessage(method, { + cookieStoreId: "firefox-container-1", + hostnames: ["example.org"], + }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(cookieExists(COOKIE_PRIVATE), "Private cookie was not removed"); + ok(!cookieExists(COOKIE_CONTAINER), "Container cookie was removed"); + + // Clear container cookie with incorrect hostname; nothing is removed + await setUpCookies(); + + extension.sendMessage(method, { + cookieStoreId: "firefox-container-1", + hostnames: ["example.com"], + }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(cookieExists(COOKIE_PRIVATE), "Private cookie was not removed"); + ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed"); + + // Clear private cookie with correct hostname + await setUpCookies(); + + extension.sendMessage(method, { + cookieStoreId: "firefox-private", + hostnames: ["example.net"], + }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(!cookieExists(COOKIE_PRIVATE), "Private cookie was removed"); + ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed"); + + // Clear private cookie with incorrect hostname; nothing is removed + await setUpCookies(); + + extension.sendMessage(method, { + cookieStoreId: "firefox-private", + hostnames: ["example.com"], + }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(cookieExists(COOKIE_PRIVATE), "Private cookie was not removed"); + ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed"); + + // Clear private cookie by hostname + await setUpCookies(); + + extension.sendMessage(method, { + hostnames: ["example.net"], + }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(!cookieExists(COOKIE_PRIVATE), "Private cookie was removed"); + ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed"); + } + + await extension.startup(); + + await testRemovalMethod("removeCookies"); + await testRemovalMethod("remove"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cache_api.js b/toolkit/components/extensions/test/xpcshell/test_ext_cache_api.js new file mode 100644 index 0000000000..14fa8a342a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_cache_api.js @@ -0,0 +1,296 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +const server = createHttpServer({ + hosts: ["example.com", "anotherdomain.com"], +}); +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("test_ext_cache_api.js"); +}); + +add_setup(() => { + // NOTE: Services.io.offline shouldn't be set to offline, + // otherwise we would hit an unexpected behavior when + // the extension worker tries to fetch from an + // http url or cache an http url response, see Bug 1845317. + Assert.ok( + !Services.io.offline, + "Services.io.offline should not be set to true while running this test" + ); +}); + +add_task(async function test_cache_api_http_resource_allowed() { + async function background() { + try { + const BASE_URL = `http://example.com/dummy`; + + const cache = await window.caches.open("test-cache-api"); + browser.test.assertTrue( + await window.caches.has("test-cache-api"), + "CacheStorage.has should resolve to true" + ); + + // Test that adding and requesting cached http urls + // works as well. + await cache.add(BASE_URL); + browser.test.assertEq( + "test_ext_cache_api.js", + await cache.match(BASE_URL).then(res => res?.text()), + "Got the expected content from the cached http url" + ); + + // Test that the http urls that the cache API is allowed + // to fetch and cache are limited by the host permissions + // associated to the extensions (same as when the extension + // for fetch from those urls using fetch or XHR). + await browser.test.assertRejects( + cache.add(`http://anotherdomain.com/dummy`), + "NetworkError when attempting to fetch resource.", + "Got the expected rejection of requesting an http not allowed by host permissions" + ); + + // Test that deleting the cache storage works as expected. + browser.test.assertTrue( + await window.caches.delete("test-cache-api"), + "Cache deleted successfully" + ); + browser.test.assertTrue( + !(await window.caches.has("test-cache-api")), + "CacheStorage.has should resolve to false" + ); + } catch (err) { + browser.test.fail(`Unexpected error on using Cache API: ${err}`); + throw err; + } finally { + browser.test.sendMessage("test-cache-api-allowed"); + } + } + + // Verify that Cache API support for http urls is available + // regardless of extensions.backgroundServiceWorker.enabled pref. + const extension = ExtensionTestUtils.loadExtension({ + manifest: { permissions: ["http://example.com/*"] }, + background, + }); + + await extension.startup(); + + await extension.awaitMessage("test-cache-api-allowed"); + await extension.unload(); +}); + +// This test is similar to `test_cache_api_http_resource_allowed` but it does +// exercise the Cache API from a moz-extension shared worker. +// We expect the cache API calls to be successfull when it is being used to +// cache an HTTP url that is allowed for the extensions based on its host +// permission, but to fail if the extension doesn't have the required host +// permission to fetch data from that url. +add_task(async function test_cache_api_from_ext_shared_worker() { + if (!WebExtensionPolicy.useRemoteWebExtensions) { + // Ensure RemoteWorkerService has been initialized in the main + // process. + Services.obs.notifyObservers(null, "profile-after-change"); + } + + const background = async function () { + const BASE_URL_OK = `http://example.com/dummy`; + const BASE_URL_KO = `http://anotherdomain.com/dummy`; + const worker = new SharedWorker("worker.js"); + const { data: resultOK } = await new Promise(resolve => { + worker.port.onmessage = resolve; + worker.port.postMessage(["worker-cacheapi-test-allowed", BASE_URL_OK]); + }); + browser.test.assertDeepEq( + ["worker-cacheapi-test-allowed:result", { success: true }], + resultOK, + "Got success result from extension worker for allowed host url" + ); + const { data: resultKO } = await new Promise(resolve => { + worker.port.onmessage = resolve; + worker.port.postMessage(["worker-cacheapi-test-disallowed", BASE_URL_KO]); + }); + browser.test.assertDeepEq( + [ + "worker-cacheapi-test-disallowed:result", + { error: "NetworkError when attempting to fetch resource." }, + ], + resultKO, + "Got result from extension worker for disallowed host url" + ); + + browser.test.assertTrue( + await window.caches.has("test-cache-api"), + "CacheStorage.has should resolve to true" + ); + const cache = await window.caches.open("test-cache-api"); + browser.test.assertEq( + "test_ext_cache_api.js", + await cache.match(BASE_URL_OK).then(res => res?.text()), + "Got the expected content from the cached http url" + ); + browser.test.assertEq( + true, + await cache.match(BASE_URL_KO).then(res => res == undefined), + "Got no match for the http url that isn't allowed by host permissions" + ); + + browser.test.sendMessage("test-cacheapi-sharedworker:done"); + }; + + const extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { permissions: ["http://example.com/*"] }, + files: { + "worker.js": function () { + self.onconnect = evt => { + const port = evt.ports[0]; + port.onmessage = async evt => { + let result = {}; + let message; + try { + const [msg, BASE_URL] = evt.data; + message = msg; + const cache = await self.caches.open("test-cache-api"); + await cache.add(BASE_URL); + result.success = true; + } catch (err) { + result.error = err.message; + throw err; + } finally { + port.postMessage([`${message}:result`, result]); + } + }; + }; + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("test-cacheapi-sharedworker:done"); + await extension.unload(); +}); + +add_task(async function test_cache_storage_evicted_on_addon_uninstalled() { + async function background() { + try { + const BASE_URL = `http://example.com/dummy`; + + const cache = await window.caches.open("test-cache-api"); + browser.test.assertTrue( + await window.caches.has("test-cache-api"), + "CacheStorage.has should resolve to true" + ); + + // Test that adding and requesting cached http urls + // works as well. + await cache.add(BASE_URL); + browser.test.assertEq( + "test_ext_cache_api.js", + await cache.match(BASE_URL).then(res => res?.text()), + "Got the expected content from the cached http url" + ); + } catch (err) { + browser.test.fail(`Unexpected error on using Cache API: ${err}`); + throw err; + } finally { + browser.test.sendMessage("cache-storage-created"); + } + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { permissions: ["http://example.com/*"] }, + background, + // Necessary to be sure the expected extension stored data cleanup callback + // will be called when the extension is uninstalled from an AddonManager + // perspective. + useAddonManager: "temporary", + }); + + await AddonTestUtils.promiseStartupManager(); + await extension.startup(); + await extension.awaitMessage("cache-storage-created"); + + const extURL = `moz-extension://${extension.extension.uuid}`; + const extPrincipal = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(extURL), + {} + ); + let extCacheStorage = new CacheStorage("content", extPrincipal); + + ok( + await extCacheStorage.has("test-cache-api"), + "Got the expected extension cache storage" + ); + + await extension.unload(); + + ok( + !(await extCacheStorage.has("test-cache-api")), + "The extension cache storage data should have been evicted on addon uninstall" + ); +}); + +add_task( + { + // Pref used to allow to use the Cache WebAPI related to a page loaded from http + // (otherwise Gecko will throw a SecurityError when trying to access the webpage + // cache storage from the content script, unless the webpage is loaded from https). + pref_set: [["dom.caches.testing.enabled", true]], + }, + async function test_cache_put_from_contentscript() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/*"], + js: ["content.js"], + }, + ], + }, + files: { + "content.js": async function () { + const cache = await caches.open("test-cachestorage"); + const request = "http://example.com"; + const response = await fetch(request); + await cache.put(request, response).catch(err => { + browser.test.sendMessage("cache-put-error", { + name: err.name, + message: err.message, + }); + }); + }, + }, + }); + + await extension.startup(); + + const page = await ExtensionTestUtils.loadContentPage("http://example.com"); + const actualError = await extension.awaitMessage("cache-put-error"); + equal( + actualError.name, + "SecurityError", + "Got a security error from cache.put call as expected" + ); + ok( + /Disallowed on WebExtension ContentScript Request/.test( + actualError.message + ), + `Got the expected error message: ${actualError.message}` + ); + + await page.close(); + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal.js b/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal.js new file mode 100644 index 0000000000..dfb5c4c415 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal.js @@ -0,0 +1,202 @@ +"use strict"; + +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + true +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +/** + * This duplicates the test from netwerk/test/unit/test_captive_portal_service.js + * however using an extension to gather the captive portal information. + */ + +const PREF_CAPTIVE_ENABLED = "network.captive-portal-service.enabled"; +const PREF_CAPTIVE_TESTMODE = "network.captive-portal-service.testMode"; +const PREF_CAPTIVE_MINTIME = "network.captive-portal-service.minInterval"; +const PREF_CAPTIVE_ENDPOINT = "captivedetect.canonicalURL"; +const PREF_DNS_NATIVE_IS_LOCALHOST = "network.dns.native-is-localhost"; + +const SUCCESS_STRING = + '<meta http-equiv="refresh" content="0;url=https://support.mozilla.org/kb/captive-portal"/>'; +let cpResponse = SUCCESS_STRING; + +const httpserver = createHttpServer(); +httpserver.registerPathHandler("/captive.txt", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/plain"); + response.write(cpResponse); +}); + +registerCleanupFunction(() => { + Services.prefs.clearUserPref(PREF_CAPTIVE_ENABLED); + Services.prefs.clearUserPref(PREF_CAPTIVE_TESTMODE); + Services.prefs.clearUserPref(PREF_CAPTIVE_ENDPOINT); + Services.prefs.clearUserPref(PREF_CAPTIVE_MINTIME); + Services.prefs.clearUserPref(PREF_DNS_NATIVE_IS_LOCALHOST); +}); + +add_task(async function setup() { + Services.prefs.setCharPref( + PREF_CAPTIVE_ENDPOINT, + `http://localhost:${httpserver.identity.primaryPort}/captive.txt` + ); + Services.prefs.setBoolPref(PREF_CAPTIVE_TESTMODE, true); + Services.prefs.setIntPref(PREF_CAPTIVE_MINTIME, 0); + Services.prefs.setBoolPref(PREF_DNS_NATIVE_IS_LOCALHOST, true); + + Services.prefs.setBoolPref("extensions.eventPages.enabled", true); + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_captivePortal_basic() { + let cps = Cc["@mozilla.org/network/captive-portal-service;1"].getService( + Ci.nsICaptivePortalService + ); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["captivePortal"], + background: { persistent: false }, + }, + isPrivileged: true, + async background() { + browser.captivePortal.onConnectivityAvailable.addListener(details => { + browser.test.log( + `onConnectivityAvailable received ${JSON.stringify(details)}` + ); + browser.test.sendMessage("connectivity", details); + }); + + browser.captivePortal.onStateChanged.addListener(details => { + browser.test.log(`onStateChanged received ${JSON.stringify(details)}`); + browser.test.sendMessage("state", details); + }); + + browser.captivePortal.canonicalURL.onChange.addListener(details => { + browser.test.sendMessage("url", details); + }); + + browser.test.onMessage.addListener(async msg => { + if (msg == "getstate") { + browser.test.sendMessage( + "getstate", + await browser.captivePortal.getState() + ); + } + }); + }, + }); + await extension.startup(); + + extension.sendMessage("getstate"); + let details = await extension.awaitMessage("getstate"); + equal(details, "unknown", "initial state"); + + // The captive portal service is started by nsIOService when the pref becomes true, so we + // toggle the pref. We cannot set to false before the extension loads above. + Services.prefs.setBoolPref(PREF_CAPTIVE_ENABLED, false); + Services.prefs.setBoolPref(PREF_CAPTIVE_ENABLED, true); + + details = await extension.awaitMessage("connectivity"); + equal(details.status, "clear", "initial connectivity"); + extension.sendMessage("getstate"); + details = await extension.awaitMessage("getstate"); + equal(details, "not_captive", "initial state"); + + info("REFRESH to other"); + cpResponse = "other"; + cps.recheckCaptivePortal(); + details = await extension.awaitMessage("state"); + equal(details.state, "locked_portal", "state in portal"); + + info("REFRESH to success"); + cpResponse = SUCCESS_STRING; + cps.recheckCaptivePortal(); + details = await extension.awaitMessage("connectivity"); + equal(details.status, "captive", "final connectivity"); + + details = await extension.awaitMessage("state"); + equal(details.state, "unlocked_portal", "state after unlocking portal"); + + assertPersistentListeners( + extension, + "captivePortal", + "onConnectivityAvailable", + { + primed: false, + } + ); + + assertPersistentListeners(extension, "captivePortal", "onStateChanged", { + primed: false, + }); + + assertPersistentListeners(extension, "captivePortal", "captiveURL.onChange", { + primed: false, + }); + + info("Test event page terminate/waken"); + + await extension.terminateBackground({ disableResetIdleForTest: true }); + ok( + !extension.extension.backgroundContext, + "Background Extension context should have been destroyed" + ); + + assertPersistentListeners(extension, "captivePortal", "onStateChanged", { + primed: true, + }); + assertPersistentListeners( + extension, + "captivePortal", + "onConnectivityAvailable", + { + primed: true, + } + ); + + assertPersistentListeners(extension, "captivePortal", "captiveURL.onChange", { + primed: true, + }); + + info("REFRESH 2nd pass to other"); + cpResponse = "other"; + cps.recheckCaptivePortal(); + details = await extension.awaitMessage("state"); + equal(details.state, "locked_portal", "state in portal"); + + info("Test event page terminate/waken with settings"); + + await extension.terminateBackground({ disableResetIdleForTest: true }); + ok( + !extension.extension.backgroundContext, + "Background Extension context should have been destroyed" + ); + + assertPersistentListeners(extension, "captivePortal", "captiveURL.onChange", { + primed: true, + }); + + Services.prefs.setStringPref( + "captivedetect.canonicalURL", + "http://example.com" + ); + let url = await extension.awaitMessage("url"); + equal( + url.value, + "http://example.com", + "The canonicalURL setting has the expected value." + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal_url.js b/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal_url.js new file mode 100644 index 0000000000..7bd83b0572 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal_url.js @@ -0,0 +1,53 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_url_get_without_set() { + async function background() { + browser.captivePortal.canonicalURL.onChange.addListener(details => { + browser.test.sendMessage("url", details); + }); + let url = await browser.captivePortal.canonicalURL.get({}); + browser.test.sendMessage("url", url); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["captivePortal"], + }, + }); + + let defaultURL = Services.prefs.getStringPref("captivedetect.canonicalURL"); + + await extension.startup(); + let url = await extension.awaitMessage("url"); + equal( + url.value, + defaultURL, + "The canonicalURL setting has the expected value." + ); + equal( + url.levelOfControl, + "not_controllable", + "The canonicalURL setting has the expected levelOfControl." + ); + + Services.prefs.setStringPref( + "captivedetect.canonicalURL", + "http://example.com" + ); + url = await extension.awaitMessage("url"); + equal( + url.value, + "http://example.com", + "The canonicalURL setting has the expected value." + ); + equal( + url.levelOfControl, + "not_controllable", + "The canonicalURL setting has the expected levelOfControl." + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_clear_cached_resources.js b/toolkit/components/extensions/test/xpcshell/test_ext_clear_cached_resources.js new file mode 100644 index 0000000000..95bef23383 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_clear_cached_resources.js @@ -0,0 +1,418 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +Services.prefs.setBoolPref("extensions.blocklist.enabled", false); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +const { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" +); + +const LEAVE_UUID_PREF = "extensions.webextensions.keepUuidOnUninstall"; + +const BASE64_R_PIXEL = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P4z8DwHwAFAAH/F1FwBgAAAABJRU5ErkJggg=="; +const BASE64_G_PIXEL = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2Ng+M/wHwAEAQH/7yMK/gAAAABJRU5ErkJggg=="; +const BASE64_B_PIXEL = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2NgYPj/HwADAgH/eL9GtQAAAABJRU5ErkJggg=="; + +const toArrayBuffer = b64data => + Uint8Array.from(atob(b64data), c => c.charCodeAt(0)); +const IMAGE_RED = toArrayBuffer(BASE64_R_PIXEL).buffer; +const IMAGE_GREEN = toArrayBuffer(BASE64_G_PIXEL).buffer; +const IMAGE_BLUE = toArrayBuffer(BASE64_B_PIXEL).buffer; + +const RGB_RED = "rgb(255, 0, 0)"; +const RGB_GREEN = "rgb(0, 255, 0)"; +const RGB_BLUE = "rgb(0, 0, 255)"; + +const CSS_RED_BG = `body { background-color: ${RGB_RED}; }`; +const CSS_GREEN_BG = `body { background-color: ${RGB_GREEN}; }`; +const CSS_BLUE_BG = `body { background-color: ${RGB_BLUE}; }`; + +const ADDON_ID = "test-cached-resources@test"; + +const manifest = { + version: "1", + browser_specific_settings: { gecko: { id: ADDON_ID } }, +}; + +const files = { + "extpage.html": `<!DOCTYPE html> + <html> + <head> + <link rel="stylesheet" href="extpage.css"> + </head> + <body> + <img id="test-image" src="image.png"> + </body> + </html> + `, + "other_extpage.html": `<!DOCTYPE html> + <html> + <body> + </body> + </html> + `, + "extpage.css": CSS_RED_BG, + "image.png": IMAGE_RED, +}; + +const getBackgroundColor = () => { + return this.content.getComputedStyle(this.content.document.body) + .backgroundColor; +}; + +const hasCachedImage = imgUrl => { + const { document } = this.content; + + const imageCache = Cc["@mozilla.org/image/tools;1"] + .getService(Ci.imgITools) + .getImgCacheForDocument(document); + + const imgCacheProps = imageCache.findEntryProperties( + Services.io.newURI(imgUrl), + document + ); + + // return true if the image was in the cache. + return !!imgCacheProps; +}; + +const getImageColor = () => { + const { document } = this.content; + const img = document.querySelector("img#test-image"); + const canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + const ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0); // Draw without scaling. + const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data; + if (a < 1) { + return `rgba(${r}, ${g}, ${b}, ${a})`; + } + return `rgb(${r}, ${g}, ${b})`; +}; + +async function assertBackgroundColor(page, color, message) { + equal( + await page.spawn([], getBackgroundColor), + color, + `Got the expected ${message}` + ); +} + +async function assertImageColor(page, color, message) { + equal(await page.spawn([], getImageColor), color, message); +} + +async function assertImageCached(page, imageUrl, message) { + ok(await page.spawn([imageUrl], hasCachedImage), message); +} + +// This test verifies that cached css are cleared across addon upgrades and downgrades +// for permanently installed addon (See Bug 1746841). +add_task(async function test_cached_resources_cleared_across_addon_updates() { + await AddonTestUtils.promiseStartupManager(); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest, + files, + }); + + await extension.startup(); + equal( + extension.version, + "1", + "Got the expected version for the initial extension" + ); + + const url = extension.extension.baseURI.resolve("extpage.html"); + let page = await ExtensionTestUtils.loadContentPage(url); + await assertBackgroundColor( + page, + RGB_RED, + "background color (initial extension version)" + ); + await assertImageColor(page, RGB_RED, "image (initial extension version)"); + + info("Verify extension page css and image after addon upgrade"); + + await extension.upgrade({ + useAddonManager: "permanent", + manifest: { + ...manifest, + version: "2", + }, + files: { + ...files, + "extpage.css": CSS_GREEN_BG, + "image.png": IMAGE_GREEN, + }, + }); + equal( + extension.version, + "2", + "Got the expected version for the upgraded extension" + ); + + await page.loadURL(url); + + await assertBackgroundColor( + page, + RGB_GREEN, + "background color (upgraded extension version)" + ); + await assertImageColor(page, RGB_GREEN, "image (upgraded extension version)"); + + info("Verify extension page css and image after addon downgrade"); + + await extension.upgrade({ + useAddonManager: "permanent", + manifest, + files, + }); + equal( + extension.version, + "1", + "Got the expected version for the downgraded extension" + ); + + await page.loadURL(url); + + await assertBackgroundColor( + page, + RGB_RED, + "background color (downgraded extension version)" + ); + await assertImageColor( + page, + RGB_RED, + "image color (downgraded extension version)" + ); + + await page.close(); + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); +}); + +// This test verifies that cached css are cleared if we are installing a new +// extension and we did not clear the cache for a previous one with the same uuid +// when it was uninstalled (See Bug 1746841). +add_task(async function test_cached_resources_cleared_on_addon_install() { + // Make sure the test addon installed without an AddonManager addon wrapper + // and the ones installed right after that using the AddonManager will share + // the same uuid (and so also the same moz-extension resource urls). + Services.prefs.setBoolPref(LEAVE_UUID_PREF, true); + registerCleanupFunction(() => Services.prefs.clearUserPref(LEAVE_UUID_PREF)); + + await AddonTestUtils.promiseStartupManager(); + + const nonAOMExtension = ExtensionTestUtils.loadExtension({ + manifest, + files: { + ...files, + // Override css with a different color from the one expected + // later in this test case. + "extpage.css": CSS_BLUE_BG, + "image.png": IMAGE_BLUE, + }, + }); + + await nonAOMExtension.startup(); + equal( + await AddonManager.getAddonByID(ADDON_ID), + null, + "No AOM addon wrapper found as expected" + ); + let url = nonAOMExtension.extension.baseURI.resolve("extpage.html"); + let page = await ExtensionTestUtils.loadContentPage(url); + await assertBackgroundColor( + page, + RGB_BLUE, + "background color (addon installed without uninstall observer)" + ); + await assertImageColor( + page, + RGB_BLUE, + "image (addon uninstalled without clearing cache)" + ); + + // NOTE: unloading a test extension that does not have an AddonManager addon wrapper + // does not trigger the uninstall observer, and this is what this test needs to make + // sure that if the cached resources were not cleared on uninstall, then we will still + // clear it when a newly installed addon is installed even if the two extensions + // are sharing the same addon uuid (and so also the same moz-extension resource urls). + await nonAOMExtension.unload(); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest, + files, + }); + + await extension.startup(); + await page.loadURL(url); + + await assertBackgroundColor( + page, + RGB_RED, + "background color (newly installed addon, same addon id)" + ); + await assertImageColor( + page, + RGB_RED, + "image (newly installed addon, same addon id)" + ); + + await page.close(); + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); +}); + +// This test verifies that reloading a temporarily installed addon after +// changing a css file cached in a previous run clears the previously +// cached css and uses the new one changed on disk (See Bug 1746841). +add_task( + async function test_cached_resources_cleared_on_temporary_addon_reload() { + await AddonTestUtils.promiseStartupManager(); + + const xpi = AddonTestUtils.createTempWebExtensionFile({ + manifest, + files, + }); + + // This temporary directory is going to be removed from the + // cleanup function, but also make it unique as we do for the + // other temporary files (e.g. like getTemporaryFile as defined + // in XPInstall.jsm). + const random = Math.round(Math.random() * 36 ** 3).toString(36); + const tmpDirName = `xpcshelltest_unpacked_addons_${random}`; + let tmpExtPath = FileUtils.getDir("TmpD", [tmpDirName]); + tmpExtPath.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + registerCleanupFunction(() => { + tmpExtPath.remove(true); + }); + + // Unpacking the xpi file into the temporary directory. + const extDir = await AddonTestUtils.manuallyInstall( + xpi, + tmpExtPath, + null, + /* unpacked */ true + ); + + let extension = ExtensionTestUtils.expectExtension(ADDON_ID); + await AddonManager.installTemporaryAddon(extDir); + await extension.awaitStartup(); + + equal( + extension.version, + "1", + "Got the expected version for the initial extension" + ); + + const url = extension.extension.baseURI.resolve("extpage.html"); + let page = await ExtensionTestUtils.loadContentPage(url); + await assertBackgroundColor( + page, + RGB_RED, + "background color (initial extension version)" + ); + await assertImageColor(page, RGB_RED, "image (initial extension version)"); + + info("Verify updated extension page css and image after addon reload"); + + const targetCSSFile = extDir.clone(); + targetCSSFile.append("extpage.css"); + ok( + targetCSSFile.exists(), + `Found the ${targetCSSFile.path} target file on disk` + ); + await IOUtils.writeUTF8(targetCSSFile.path, CSS_GREEN_BG); + + const targetPNGFile = extDir.clone(); + targetPNGFile.append("image.png"); + ok( + targetPNGFile.exists(), + `Found the ${targetPNGFile.path} target file on disk` + ); + await IOUtils.write(targetPNGFile.path, toArrayBuffer(BASE64_G_PIXEL)); + + const addon = await AddonManager.getAddonByID(ADDON_ID); + ok(addon, "Got an AddonWrapper for the test extension"); + await addon.reload(); + + await page.loadURL(url); + + await assertBackgroundColor( + page, + RGB_GREEN, + "background (updated files on disk)" + ); + await assertImageColor(page, RGB_GREEN, "image (updated files on disk)"); + + await page.close(); + await addon.uninstall(); + await AddonTestUtils.promiseShutdownManager(); + } +); + +// This test verifies that cached images are not cleared between +// permanently installed addon reloads. +add_task(async function test_cached_image_kept_on_permanent_addon_restarts() { + await AddonTestUtils.promiseStartupManager(); + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest, + files, + }); + + await extension.startup(); + + equal( + extension.version, + "1", + "Got the expected version for the initial extension" + ); + + const imageUrl = extension.extension.baseURI.resolve("image.png"); + const url = extension.extension.baseURI.resolve("extpage.html"); + + let page = await ExtensionTestUtils.loadContentPage(url); + await assertBackgroundColor( + page, + RGB_RED, + "background color (first startup)" + ); + await assertImageColor(page, RGB_RED, "image (first startup)"); + await assertImageCached(page, imageUrl, "image cached (first startup)"); + + info("Reload the AddonManager to simulate browser restart"); + extension.setRestarting(); + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + await page.loadURL(extension.extension.baseURI.resolve("other_extpage.html")); + await assertImageCached( + page, + imageUrl, + "image still cached after AddonManager restart" + ); + + await page.close(); + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentScripts_register.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentScripts_register.js new file mode 100644 index 0000000000..c92ed11022 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentScripts_register.js @@ -0,0 +1,808 @@ +"use strict"; + +const { createAppInfo } = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49"); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +function check_applied_styles() { + const urlElStyle = getComputedStyle( + document.querySelector("#registered-extension-url-style") + ); + const blobElStyle = getComputedStyle( + document.querySelector("#registered-extension-text-style") + ); + + browser.test.sendMessage("registered-styles-results", { + registeredExtensionUrlStyleBG: urlElStyle["background-color"], + registeredExtensionBlobStyleBG: blobElStyle["background-color"], + }); +} + +add_task(async function test_contentscripts_register_css() { + async function background() { + let cssCode = ` + #registered-extension-text-style { + background-color: blue; + } + `; + + const matches = ["http://localhost/*/file_sample_registered_styles.html"]; + + browser.test.assertThrows( + () => { + browser.contentScripts.register({ + matches, + unknownParam: "unexpected property", + }); + }, + /Unexpected property "unknownParam"/, + "contentScripts.register throws on unexpected properties" + ); + + let fileScript = await browser.contentScripts.register({ + css: [{ file: "registered_ext_style.css" }], + matches, + runAt: "document_start", + }); + + let textScript = await browser.contentScripts.register({ + css: [{ code: cssCode }], + matches, + runAt: "document_start", + }); + + browser.test.onMessage.addListener(async msg => { + switch (msg) { + case "unregister-text": + await textScript.unregister().catch(err => { + browser.test.fail( + `Unexpected exception while unregistering text style: ${err}` + ); + }); + + await browser.test.assertRejects( + textScript.unregister(), + /Content script already unregistered/, + "Got the expected rejection on calling script.unregister() multiple times" + ); + + browser.test.sendMessage("unregister-text:done"); + break; + case "unregister-file": + await fileScript.unregister().catch(err => { + browser.test.fail( + `Unexpected exception while unregistering url style: ${err}` + ); + }); + + await browser.test.assertRejects( + fileScript.unregister(), + /Content script already unregistered/, + "Got the expected rejection on calling script.unregister() multiple times" + ); + + browser.test.sendMessage("unregister-file:done"); + break; + default: + browser.test.fail(`Unexpected test message received: ${msg}`); + } + }); + + browser.test.sendMessage("background_ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "http://localhost/*/file_sample_registered_styles.html", + "<all_urls>", + ], + content_scripts: [ + { + matches: ["http://localhost/*/file_sample_registered_styles.html"], + run_at: "document_idle", + js: ["check_applied_styles.js"], + }, + ], + }, + background, + + files: { + "check_applied_styles.js": check_applied_styles, + "registered_ext_style.css": ` + #registered-extension-url-style { + background-color: red; + } + `, + }, + }); + + await extension.startup(); + + await extension.awaitMessage("background_ready"); + + // Ensure that a content page running in a content process and which has been + // started after the content scripts has been registered, it still receives + // and registers the expected content scripts. + let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`); + + await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`); + + const registeredStylesResults = await extension.awaitMessage( + "registered-styles-results" + ); + + equal( + registeredStylesResults.registeredExtensionUrlStyleBG, + "rgb(255, 0, 0)", + "The expected style has been applied from the registered extension url style" + ); + equal( + registeredStylesResults.registeredExtensionBlobStyleBG, + "rgb(0, 0, 255)", + "The expected style has been applied from the registered extension blob style" + ); + + extension.sendMessage("unregister-file"); + await extension.awaitMessage("unregister-file:done"); + + await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`); + + const unregisteredURLStylesResults = await extension.awaitMessage( + "registered-styles-results" + ); + + equal( + unregisteredURLStylesResults.registeredExtensionUrlStyleBG, + "rgba(0, 0, 0, 0)", + "The expected style has been applied once extension url style has been unregistered" + ); + equal( + unregisteredURLStylesResults.registeredExtensionBlobStyleBG, + "rgb(0, 0, 255)", + "The expected style has been applied from the registered extension blob style" + ); + + extension.sendMessage("unregister-text"); + await extension.awaitMessage("unregister-text:done"); + + await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`); + + const unregisteredBlobStylesResults = await extension.awaitMessage( + "registered-styles-results" + ); + + equal( + unregisteredBlobStylesResults.registeredExtensionUrlStyleBG, + "rgba(0, 0, 0, 0)", + "The expected style has been applied once extension url style has been unregistered" + ); + equal( + unregisteredBlobStylesResults.registeredExtensionBlobStyleBG, + "rgba(0, 0, 0, 0)", + "The expected style has been applied once extension blob style has been unregistered" + ); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_contentscripts_unregister_on_context_unload() { + async function background() { + const frame = document.createElement("iframe"); + frame.setAttribute("src", "/background-frame.html"); + + document.body.appendChild(frame); + + browser.test.onMessage.addListener(msg => { + switch (msg) { + case "unload-frame": + frame.remove(); + browser.test.sendMessage("unload-frame:done"); + break; + default: + browser.test.fail(`Unexpected test message received: ${msg}`); + } + }); + + browser.test.sendMessage("background_ready"); + } + + async function background_frame() { + await browser.contentScripts.register({ + css: [{ file: "registered_ext_style.css" }], + matches: ["http://localhost/*/file_sample_registered_styles.html"], + runAt: "document_start", + }); + + browser.test.sendMessage("background_frame_ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://localhost/*/file_sample_registered_styles.html"], + content_scripts: [ + { + matches: ["http://localhost/*/file_sample_registered_styles.html"], + run_at: "document_idle", + js: ["check_applied_styles.js"], + }, + ], + }, + background, + + files: { + "background-frame.html": `<!DOCTYPE html> + <html> + <head> + <script src="background-frame.js"></script> + </head> + <body> + </body> + </html> + `, + "background-frame.js": background_frame, + "check_applied_styles.js": check_applied_styles, + "registered_ext_style.css": ` + #registered-extension-url-style { + background-color: red; + } + `, + }, + }); + + await extension.startup(); + + await extension.awaitMessage("background_ready"); + + // Wait the background frame to have been loaded and its script + // executed. + await extension.awaitMessage("background_frame_ready"); + + // Ensure that a content page running in a content process and which has been + // started after the content scripts has been registered, it still receives + // and registers the expected content scripts. + let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`); + + await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`); + + const registeredStylesResults = await extension.awaitMessage( + "registered-styles-results" + ); + + equal( + registeredStylesResults.registeredExtensionUrlStyleBG, + "rgb(255, 0, 0)", + "The expected style has been applied from the registered extension url style" + ); + + extension.sendMessage("unload-frame"); + await extension.awaitMessage("unload-frame:done"); + + await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`); + + const unregisteredURLStylesResults = await extension.awaitMessage( + "registered-styles-results" + ); + + equal( + unregisteredURLStylesResults.registeredExtensionUrlStyleBG, + "rgba(0, 0, 0, 0)", + "The expected style has been applied once extension url style has been unregistered" + ); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_contentscripts_register_js() { + async function background() { + browser.runtime.onMessage.addListener( + ([msg, expectedStates, readyState], sender) => { + if (msg == "chrome-namespace-ok") { + browser.test.sendMessage(msg); + return; + } + + browser.test.assertEq("script-run", msg, "message type is correct"); + browser.test.assertTrue( + expectedStates.includes(readyState), + `readyState "${readyState}" is one of [${expectedStates}]` + ); + browser.test.sendMessage("script-run-" + expectedStates[0]); + } + ); + + // Raise an exception when the content script cannot be registered + // because the extension has no permission to access the specified origin. + + await browser.test.assertRejects( + browser.contentScripts.register({ + matches: ["http://*/*"], + js: [ + { + code: 'browser.test.fail("content script with wrong matches should not run")', + }, + ], + }), + /Permission denied to register a content script for/, + "The reject contains the expected error message" + ); + + // Register a content script from a JS code string. + + function textScriptCodeStart() { + browser.runtime.sendMessage([ + "script-run", + ["loading"], + document.readyState, + ]); + } + function textScriptCodeEnd() { + browser.runtime.sendMessage([ + "script-run", + ["interactive", "complete"], + document.readyState, + ]); + } + function textScriptCodeIdle() { + browser.runtime.sendMessage([ + "script-run", + ["complete"], + document.readyState, + ]); + } + + // Register content scripts from both extension URLs and plain JS code strings. + + const content_scripts = [ + // Plain JS code strings. + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ code: `(${textScriptCodeStart})()` }], + runAt: "document_start", + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ code: `(${textScriptCodeEnd})()` }], + runAt: "document_end", + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ code: `(${textScriptCodeIdle})()` }], + runAt: "document_idle", + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ code: `(${textScriptCodeIdle})()` }], + runAt: "document_idle", + cookieStoreId: "firefox-container-1", + }, + // Extension URLs. + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ file: "content_script_start.js" }], + runAt: "document_start", + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ file: "content_script_end.js" }], + runAt: "document_end", + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ file: "content_script_idle.js" }], + runAt: "document_idle", + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ file: "content_script.js" }], + // "runAt" is not specified here to ensure that it defaults to document_idle when missing. + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ file: "content_script_idle.js" }], + runAt: "document_idle", + cookieStoreId: "firefox-container-1", + }, + ]; + + const expectedAPIs = ["unregister"]; + + for (const scriptOptions of content_scripts) { + const script = await browser.contentScripts.register(scriptOptions); + const actualAPIs = Object.keys(script); + + browser.test.assertEq( + JSON.stringify(expectedAPIs), + JSON.stringify(actualAPIs), + `Got a script API object for ${scriptOptions.js[0]}` + ); + } + + browser.test.sendMessage("background-ready"); + } + + function contentScriptStart() { + browser.runtime.sendMessage([ + "script-run", + ["loading"], + document.readyState, + ]); + } + function contentScriptEnd() { + browser.runtime.sendMessage([ + "script-run", + ["interactive", "complete"], + document.readyState, + ]); + } + function contentScriptIdle() { + browser.runtime.sendMessage([ + "script-run", + ["complete"], + document.readyState, + ]); + } + + function contentScript() { + let manifest = browser.runtime.getManifest(); + void manifest.permissions; + browser.runtime.sendMessage(["chrome-namespace-ok"]); + } + + let extensionData = { + manifest: { + permissions: ["http://localhost/*/file_sample.html"], + }, + background, + + files: { + "content_script_start.js": contentScriptStart, + "content_script_end.js": contentScriptEnd, + "content_script_idle.js": contentScriptIdle, + "content_script.js": contentScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + let loadingCount = 0; + let interactiveCount = 0; + let completeCount = 0; + extension.onMessage("script-run-loading", () => { + loadingCount++; + }); + extension.onMessage("script-run-interactive", () => { + interactiveCount++; + }); + + let completePromise = new Promise(resolve => { + extension.onMessage("script-run-complete", () => { + completeCount++; + if (completeCount == 2) { + resolve(); + } + }); + }); + + let chromeNamespacePromise = extension.awaitMessage("chrome-namespace-ok"); + + // Ensure that a content page running in a content process and which has been + // already loaded when the content scripts has been registered, it has received + // and registered the expected content scripts. + let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + await contentPage.loadURL(`${BASE_URL}/file_sample.html`); + + await Promise.all([completePromise, chromeNamespacePromise]); + + await contentPage.close(); + + // Expect two content scripts to run (one registered using an extension URL + // and one registered from plain JS code). + equal(loadingCount, 2, "document_start script ran exactly twice"); + equal(interactiveCount, 2, "document_end script ran exactly twice"); + equal(completeCount, 2, "document_idle script ran exactly twice"); + + await extension.unload(); +}); + +// Test that the contentScripts.register options are correctly translated +// into the expected WebExtensionContentScript properties. +add_task(async function test_contentscripts_register_all_options() { + async function background() { + await browser.contentScripts.register({ + js: [{ file: "content_script.js" }], + css: [{ file: "content_style.css" }], + matches: ["http://localhost/*"], + excludeMatches: ["http://localhost/exclude/*"], + excludeGlobs: ["*_exclude.html"], + includeGlobs: ["*_include.html"], + allFrames: true, + matchAboutBlank: true, + runAt: "document_start", + }); + + browser.test.sendMessage("background-ready", window.location.origin); + } + + const extensionData = { + manifest: { + permissions: ["http://localhost/*"], + }, + background, + + files: { + "content_script.js": "", + "content_style.css": "", + }, + }; + + const extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + const baseExtURL = await extension.awaitMessage("background-ready"); + + const policy = WebExtensionPolicy.getByID(extension.id); + + ok(policy, "Got the WebExtensionPolicy for the test extension"); + equal( + policy.contentScripts.length, + 1, + "Got the expected number of registered content scripts" + ); + + const script = policy.contentScripts[0]; + let { + allFrames, + cssPaths, + jsPaths, + matchAboutBlank, + runAt, + originAttributesPatterns, + } = script; + + deepEqual( + { + allFrames, + cssPaths, + jsPaths, + matchAboutBlank, + runAt, + originAttributesPatterns, + }, + { + allFrames: true, + cssPaths: [`${baseExtURL}/content_style.css`], + jsPaths: [`${baseExtURL}/content_script.js`], + matchAboutBlank: true, + runAt: "document_start", + originAttributesPatterns: null, + }, + "Got the expected content script properties" + ); + + ok( + script.matchesURI(Services.io.newURI("http://localhost/ok_include.html")), + "matched and include globs should match" + ); + ok( + !script.matchesURI( + Services.io.newURI("http://localhost/exclude/ok_include.html") + ), + "exclude matches should not match" + ); + ok( + !script.matchesURI(Services.io.newURI("http://localhost/ok_exclude.html")), + "exclude globs should not match" + ); + + await extension.unload(); +}); + +add_task(async function test_contentscripts_register_cookieStoreId() { + async function background() { + let cookieStoreIdCSSArray = [ + { id: null, color: "rgb(123, 45, 67)" }, + { id: "firefox-private", color: "rgb(255,255,0)" }, + { id: "firefox-default", color: "red" }, + { id: "firefox-container-1", color: "green" }, + { id: "firefox-container-2", color: "blue" }, + { + id: ["firefox-container-3", "firefox-container-4"], + color: "rgb(100,100,0)", + }, + ]; + const matches = ["http://localhost/*/file_sample_registered_styles.html"]; + + for (let { id, color } of cookieStoreIdCSSArray) { + await browser.contentScripts.register({ + css: [ + { + code: `#registered-extension-text-style { + background-color: ${color}}`, + }, + ], + matches, + runAt: "document_start", + cookieStoreId: id, + }); + } + await browser.test.assertRejects( + browser.contentScripts.register({ + css: [{ code: `body {}` }], + matches, + cookieStoreId: "not_a_valid_cookieStoreId", + }), + /Invalid cookieStoreId/, + "contentScripts.register with an invalid cookieStoreId" + ); + + if (!navigator.userAgent.includes("Android")) { + await browser.test.assertRejects( + browser.contentScripts.register({ + css: [{ code: `body {}` }], + matches, + cookieStoreId: "firefox-container-999", + }), + /Invalid cookieStoreId/, + "contentScripts.register with an invalid cookieStoreId" + ); + } else { + // On Android, any firefox-container-... is treated as valid, so it doesn't + // result in an error. + // TODO bug 1743616: Fix implementation and remove this branch. + await browser.contentScripts.register({ + css: [{ code: `body {}` }], + matches, + cookieStoreId: "firefox-container-999", + }); + } + + await browser.test.assertRejects( + browser.contentScripts.register({ + css: [{ code: `body {}` }], + matches, + cookieStoreId: "", + }), + /Invalid cookieStoreId/, + "contentScripts.register with an invalid cookieStoreId" + ); + + browser.test.sendMessage("background_ready"); + } + + const extensionData = { + manifest: { + permissions: [ + "http://localhost/*/file_sample_registered_styles.html", + "<all_urls>", + ], + content_scripts: [ + { + matches: ["http://localhost/*/file_sample_registered_styles.html"], + run_at: "document_idle", + js: ["check_applied_styles.js"], + }, + ], + }, + background, + files: { + "check_applied_styles.js": check_applied_styles, + }, + }; + + const extension = ExtensionTestUtils.loadExtension({ + ...extensionData, + incognitoOverride: "spanning", + }); + + await extension.startup(); + await extension.awaitMessage("background_ready"); + // Index 0 is the one from manifest.json. + let contentScriptMatchTests = [ + { + contentPageOptions: { userContextId: 5 }, + expectedStyles: "rgb(123, 45, 67)", + originAttributesPatternExpected: null, + contentScriptIndex: 1, + }, + { + contentPageOptions: { privateBrowsing: true }, + expectedStyles: "rgb(255, 255, 0)", + originAttributesPatternExpected: [ + { + privateBrowsingId: 1, + userContextId: 0, + }, + ], + contentScriptIndex: 2, + }, + { + contentPageOptions: { userContextId: 0 }, + expectedStyles: "rgb(255, 0, 0)", + originAttributesPatternExpected: [ + { + privateBrowsingId: 0, + userContextId: 0, + }, + ], + contentScriptIndex: 3, + }, + { + contentPageOptions: { userContextId: 1 }, + expectedStyles: "rgb(0, 128, 0)", + originAttributesPatternExpected: [{ userContextId: 1 }], + contentScriptIndex: 4, + }, + { + contentPageOptions: { userContextId: 2 }, + expectedStyles: "rgb(0, 0, 255)", + originAttributesPatternExpected: [{ userContextId: 2 }], + contentScriptIndex: 5, + }, + { + contentPageOptions: { userContextId: 3 }, + expectedStyles: "rgb(100, 100, 0)", + originAttributesPatternExpected: [ + { userContextId: 3 }, + { userContextId: 4 }, + ], + contentScriptIndex: 6, + }, + { + contentPageOptions: { userContextId: 4 }, + expectedStyles: "rgb(100, 100, 0)", + originAttributesPatternExpected: [ + { userContextId: 3 }, + { userContextId: 4 }, + ], + contentScriptIndex: 6, + }, + ]; + + const policy = WebExtensionPolicy.getByID(extension.id); + + for (const testCase of contentScriptMatchTests) { + const { + contentPageOptions, + expectedStyles, + originAttributesPatternExpected, + contentScriptIndex, + } = testCase; + const script = policy.contentScripts[contentScriptIndex]; + + deepEqual(script.originAttributesPatterns, originAttributesPatternExpected); + let contentPage = await ExtensionTestUtils.loadContentPage( + `about:blank`, + contentPageOptions + ); + await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`); + + let registeredStylesResults = await extension.awaitMessage( + "registered-styles-results" + ); + + equal( + registeredStylesResults.registeredExtensionBlobStyleBG, + expectedStyles, + `Expected styles applied on content page loaded with options + ${JSON.stringify(contentPageOptions)}` + ); + await contentPage.close(); + } + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_content_security_policy.js b/toolkit/components/extensions/test/xpcshell/test_ext_content_security_policy.js new file mode 100644 index 0000000000..335a278329 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_content_security_policy.js @@ -0,0 +1,362 @@ +"use strict"; + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +server.registerPathHandler("/worker.js", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "application/javascript", false); + response.write("let x = true;"); +}); + +const baseCSP = []; +// Keep in sync with extensions.webextensions.base-content-security-policy +baseCSP[2] = { + "script-src": [ + "'unsafe-eval'", + "'wasm-unsafe-eval'", + "'unsafe-inline'", + "blob:", + "filesystem:", + "http://localhost:*", + "http://127.0.0.1:*", + "https://*", + "moz-extension:", + "'self'", + ], +}; +// Keep in sync with extensions.webextensions.base-content-security-policy.v3 +baseCSP[3] = { + "script-src": ["'self'", "'wasm-unsafe-eval'"], +}; + +/** + * @typedef TestPolicyExpects + * @type {object} + * @param {boolean} workerEvalAllowed + * @param {boolean} workerImportScriptsAllowed + * @param {boolean} workerWasmAllowed + */ + +/** + * Tests that content security policies for an add-on are actually applied to * + * documents that belong to it. This tests both the base policies and add-on + * specific policies, and ensures that the parsed policies applied to the + * document's principal match what was specified in the policy string. + * + * @param {object} options + * @param {number} [options.manifest_version] + * @param {object} [options.customCSP] + * @param {TestPolicyExpects} options.expects + */ +async function testPolicy({ + manifest_version = 2, + customCSP = null, + expects = {}, +}) { + info( + `Enter tests for extension CSP with ${JSON.stringify({ + manifest_version, + customCSP, + })}` + ); + + let baseURL; + + let addonCSP = { + "script-src": ["'self'"], + }; + + if (manifest_version < 3) { + addonCSP["script-src"].push("'wasm-unsafe-eval'"); + } + + let content_security_policy = null; + + if (customCSP) { + for (let key of Object.keys(customCSP)) { + addonCSP[key] = customCSP[key].split(/\s+/); + } + + content_security_policy = Object.keys(customCSP) + .map(key => `${key} ${customCSP[key]}`) + .join("; "); + } + + function checkSource(name, policy, expected) { + // fallback to script-src when comparing worker-src if policy does not include worker-src + let policySrc = + name != "worker-src" || policy[name] + ? policy[name] + : policy["script-src"]; + equal( + JSON.stringify(policySrc.sort()), + JSON.stringify(expected[name].sort()), + `Expected value for ${name}` + ); + } + + function checkCSP(csp, location) { + let policies = csp["csp-policies"]; + + info(`Base policy for ${location}`); + let base = baseCSP[manifest_version]; + + equal(policies[0]["report-only"], false, "Policy is not report-only"); + for (let key in base) { + checkSource(key, policies[0], base); + } + + info(`Add-on policy for ${location}`); + + equal(policies[1]["report-only"], false, "Policy is not report-only"); + for (let key in addonCSP) { + checkSource(key, policies[1], addonCSP); + } + } + + function background() { + browser.test.sendMessage( + "base-url", + browser.runtime.getURL("").replace(/\/$/, "") + ); + + browser.test.sendMessage("background-csp", window.getCsp()); + } + + function tabScript() { + browser.test.sendMessage("tab-csp", window.getCsp()); + + const worker = new Worker("worker.js"); + worker.onmessage = event => { + browser.test.sendMessage("worker-csp", event.data); + }; + + worker.postMessage({}); + } + + function testWorker(port) { + this.onmessage = () => { + let importScriptsAllowed; + let evalAllowed; + let wasmAllowed; + + try { + eval("let y = true;"); // eslint-disable-line no-eval + evalAllowed = true; + } catch (e) { + evalAllowed = false; + } + + try { + new WebAssembly.Module( + new Uint8Array([0, 0x61, 0x73, 0x6d, 0x1, 0, 0, 0]) + ); + wasmAllowed = true; + } catch (e) { + wasmAllowed = false; + } + + try { + // eslint-disable-next-line no-undef + importScripts(`http://127.0.0.1:${port}/worker.js`); + importScriptsAllowed = true; + } catch (e) { + importScriptsAllowed = false; + } + + postMessage({ evalAllowed, importScriptsAllowed, wasmAllowed }); + }; + } + + let web_accessible_resources = ["content.html", "tab.html"]; + if (manifest_version == 3) { + let extension_pages = content_security_policy; + content_security_policy = { + extension_pages, + }; + let resources = web_accessible_resources; + web_accessible_resources = [ + { resources, matches: ["http://example.com/*"] }, + ]; + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + + files: { + "tab.html": `<html><head><meta charset="utf-8"> + <script src="tab.js"></${"script"}></head></html>`, + + "tab.js": tabScript, + + "content.html": `<html><head><meta charset="utf-8"></head></html>`, + "worker.js": `(${testWorker})(${server.identity.primaryPort})`, + }, + + manifest: { + manifest_version, + content_security_policy, + web_accessible_resources, + }, + }); + + function frameScript() { + // eslint-disable-next-line mozilla/balanced-listeners + addEventListener( + "DOMWindowCreated", + event => { + let win = event.target.ownerGlobal; + function getCsp() { + let { cspJSON } = win.document; + return win.wrappedJSObject.JSON.parse(cspJSON); + } + Cu.exportFunction(getCsp, win, { defineAs: "getCsp" }); + }, + true + ); + } + let frameScriptURL = `data:,(${encodeURI(frameScript)}).call(this)`; + Services.mm.loadFrameScript(frameScriptURL, true, true); + + info(`Testing CSP for policy: ${JSON.stringify(content_security_policy)}`); + + await extension.startup(); + + baseURL = await extension.awaitMessage("base-url"); + + let tabPage = await ExtensionTestUtils.loadContentPage( + `${baseURL}/tab.html`, + { extension } + ); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + + let contentCSP = await contentPage.spawn( + [`${baseURL}/content.html`], + async src => { + let doc = this.content.document; + + let frame = doc.createElement("iframe"); + frame.src = src; + doc.body.appendChild(frame); + + await new Promise(resolve => { + frame.onload = resolve; + }); + + return frame.contentWindow.wrappedJSObject.getCsp(); + } + ); + + let backgroundCSP = await extension.awaitMessage("background-csp"); + checkCSP(backgroundCSP, "background page"); + + let tabCSP = await extension.awaitMessage("tab-csp"); + checkCSP(tabCSP, "tab page"); + + checkCSP(contentCSP, "content frame"); + + let workerCSP = await extension.awaitMessage("worker-csp"); + equal( + workerCSP.importScriptsAllowed, + expects.workerImportAllowed, + "worker importScript" + ); + equal(workerCSP.evalAllowed, expects.workerEvalAllowed, "worker eval"); + equal(workerCSP.wasmAllowed, expects.workerWasmAllowed, "worker wasm"); + + await contentPage.close(); + await tabPage.close(); + + await extension.unload(); + + Services.mm.removeDelayedFrameScript(frameScriptURL); +} + +add_task(async function testCSP() { + await testPolicy({ + manifest_version: 2, + customCSP: null, + expects: { + workerEvalAllowed: false, + workerImportAllowed: false, + workerWasmAllowed: true, + }, + }); + + let hash = + "'sha256-NjZhMDQ1YjQ1MjEwMmM1OWQ4NDBlYzA5N2Q1OWQ5NDY3ZTEzYTNmMzRmNjQ5NGU1MzlmZmQzMmMxYmIzNWYxOCAgLQo='"; + + await testPolicy({ + manifest_version: 2, + customCSP: { + "script-src": `'self' https://*.example.com 'unsafe-eval' ${hash}`, + }, + expects: { + workerEvalAllowed: true, + workerImportAllowed: false, + workerWasmAllowed: true, + }, + }); + + await testPolicy({ + manifest_version: 2, + customCSP: { + "script-src": `'self'`, + }, + expects: { + workerEvalAllowed: false, + workerImportAllowed: false, + workerWasmAllowed: true, + }, + }); + + await testPolicy({ + manifest_version: 3, + customCSP: { + "script-src": `'self' ${hash}`, + "worker-src": `'self'`, + }, + expects: { + workerEvalAllowed: false, + workerImportAllowed: false, + workerWasmAllowed: false, + }, + }); + + await testPolicy({ + manifest_version: 3, + customCSP: { + "script-src": `'self'`, + "worker-src": `'self'`, + }, + expects: { + workerEvalAllowed: false, + workerImportAllowed: false, + workerWasmAllowed: false, + }, + }); + + await testPolicy({ + manifest_version: 3, + customCSP: { + "script-src": `'self' 'wasm-unsafe-eval'`, + "worker-src": `'self' 'wasm-unsafe-eval'`, + }, + expects: { + workerEvalAllowed: false, + workerImportAllowed: false, + workerWasmAllowed: true, + }, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript.js new file mode 100644 index 0000000000..cb2f342d4e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript.js @@ -0,0 +1,270 @@ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +add_task(async function test_contentscript_runAt() { + function background() { + browser.runtime.onMessage.addListener( + ([msg, expectedStates, readyState], sender) => { + if (msg == "chrome-namespace-ok") { + browser.test.sendMessage(msg); + return; + } + + browser.test.assertEq("script-run", msg, "message type is correct"); + browser.test.assertTrue( + expectedStates.includes(readyState), + `readyState "${readyState}" is one of [${expectedStates}]` + ); + browser.test.sendMessage("script-run-" + expectedStates[0]); + } + ); + } + + function contentScriptStart() { + browser.runtime.sendMessage([ + "script-run", + ["loading"], + document.readyState, + ]); + } + function contentScriptEnd() { + browser.runtime.sendMessage([ + "script-run", + ["interactive", "complete"], + document.readyState, + ]); + } + function contentScriptIdle() { + browser.runtime.sendMessage([ + "script-run", + ["complete"], + document.readyState, + ]); + } + + function contentScript() { + let manifest = browser.runtime.getManifest(); + void manifest.applications.gecko.id; + browser.runtime.sendMessage(["chrome-namespace-ok"]); + } + + let extensionData = { + manifest: { + browser_specific_settings: { + gecko: { id: "contentscript@tests.mozilla.org" }, + }, + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script_start.js"], + run_at: "document_start", + }, + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script_end.js"], + run_at: "document_end", + }, + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script_idle.js"], + run_at: "document_idle", + }, + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script_idle.js"], + // Test default `run_at`. + }, + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + }, + background, + + files: { + "content_script_start.js": contentScriptStart, + "content_script_end.js": contentScriptEnd, + "content_script_idle.js": contentScriptIdle, + "content_script.js": contentScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + let loadingCount = 0; + let interactiveCount = 0; + let completeCount = 0; + extension.onMessage("script-run-loading", () => { + loadingCount++; + }); + extension.onMessage("script-run-interactive", () => { + interactiveCount++; + }); + + let completePromise = new Promise(resolve => { + extension.onMessage("script-run-complete", () => { + completeCount++; + if (completeCount > 1) { + resolve(); + } + }); + }); + + let chromeNamespacePromise = extension.awaitMessage("chrome-namespace-ok"); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await Promise.all([completePromise, chromeNamespacePromise]); + + await contentPage.close(); + + equal(loadingCount, 1, "document_start script ran exactly once"); + equal(interactiveCount, 1, "document_end script ran exactly once"); + equal(completeCount, 2, "document_idle script ran exactly twice"); + + await extension.unload(); +}); + +add_task(async function test_contentscript_window_open() { + if (AppConstants.DEBUG && Services.appinfo.browserTabsRemoteAutostart) { + return; + } + + let script = async () => { + /* globals x */ + browser.test.assertEq(1, x, "Should only run once"); + + if (top !== window) { + // Wait for our parent page to load, then set a timeout to wait for the + // document.open call, so we make sure to not tear down the extension + // until after we've done the document.open. + await new Promise(resolve => { + top.addEventListener("load", () => setTimeout(resolve, 0), { + once: true, + }); + }); + } + + browser.test.sendMessage("content-script", [location.href, top === window]); + }; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id: "contentscript@tests.mozilla.org" }, + }, + content_scripts: [ + { + matches: ["<all_urls>"], + js: ["content_script.js"], + run_at: "document_start", + match_about_blank: true, + all_frames: true, + }, + ], + }, + + files: { + "content_script.js": ` + var x = (x || 0) + 1; + (${script})(); + `, + }, + }); + + await extension.startup(); + + let url = `${BASE_URL}/file_document_open.html`; + let contentPage = await ExtensionTestUtils.loadContentPage(url); + + let [pageURL, pageIsTop] = await extension.awaitMessage("content-script"); + + // Sometimes we get a content script load for the initial about:blank + // top level frame here, sometimes we don't. Either way is fine, as long as we + // don't get two loads into the same document.open() document. + if (pageURL === "about:blank") { + equal(pageIsTop, true); + [pageURL, pageIsTop] = await extension.awaitMessage("content-script"); + } + + Assert.deepEqual([pageURL, pageIsTop], [url, true]); + + let [frameURL, isTop] = await extension.awaitMessage("content-script"); + Assert.deepEqual([frameURL, isTop], [url, false]); + + await contentPage.close(); + await extension.unload(); +}); + +// This test verify that a cached script is still able to catch the document +// while it is still loading (when we do not block the document parsing as +// we do for a non cached script). +add_task(async function test_cached_contentscript_on_document_start() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://localhost/*/file_document_open.html"], + js: ["content_script.js"], + run_at: "document_start", + }, + ], + }, + + files: { + "content_script.js": ` + browser.test.sendMessage("content-script-loaded", { + url: window.location.href, + documentReadyState: document.readyState, + }); + `, + }, + }); + + await extension.startup(); + + let url = `${BASE_URL}/file_document_open.html`; + let contentPage = await ExtensionTestUtils.loadContentPage(url); + + let msg = await extension.awaitMessage("content-script-loaded"); + Assert.deepEqual( + msg, + { + url, + documentReadyState: "loading", + }, + "Got the expected url and document.readyState from a non cached script" + ); + + // Reload the page and check that the cached content script is still able to + // run on document_start. + await contentPage.loadURL(url); + + let msgFromCached = await extension.awaitMessage("content-script-loaded"); + Assert.deepEqual( + msgFromCached, + { + url, + documentReadyState: "loading", + }, + "Got the expected url and document.readyState from a cached script" + ); + + await extension.unload(); + + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_about_blank_start.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_about_blank_start.js new file mode 100644 index 0000000000..1297f105a1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_about_blank_start.js @@ -0,0 +1,78 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/blank-iframe.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write("<iframe></iframe>"); +}); + +add_task(async function content_script_at_document_start() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["<all_urls>"], + js: ["start.js"], + run_at: "document_start", + match_about_blank: true, + }, + ], + }, + + files: { + "start.js": function () { + browser.test.sendMessage("content-script-done"); + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`); + await extension.awaitMessage("content-script-done"); + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function content_style_at_document_start() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["<all_urls>"], + css: ["start.css"], + run_at: "document_start", + match_about_blank: true, + }, + { + matches: ["<all_urls>"], + js: ["end.js"], + run_at: "document_end", + match_about_blank: true, + }, + ], + }, + + files: { + "start.css": "body { background: red; }", + "end.js": function () { + let style = window.getComputedStyle(document.body); + browser.test.assertEq( + "rgb(255, 0, 0)", + style.backgroundColor, + "document_start style should have been applied" + ); + browser.test.sendMessage("content-script-done"); + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`); + await extension.awaitMessage("content-script-done"); + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_api_injection.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_api_injection.js new file mode 100644 index 0000000000..4e42181e71 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_api_injection.js @@ -0,0 +1,65 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_contentscript_api_injection() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + }, + ], + web_accessible_resources: ["content_script_iframe.html"], + }, + + files: { + "content_script.js"() { + let iframe = document.createElement("iframe"); + iframe.src = browser.runtime.getURL("content_script_iframe.html"); + document.body.appendChild(iframe); + }, + "content_script_iframe.js"() { + window.location = `http://example.com/data/file_privilege_escalation.html`; + }, + "content_script_iframe.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script type="text/javascript" src="content_script_iframe.js"></script> + </head> + </html>`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + let awaitConsole = new Promise(resolve => { + Services.console.registerListener(function listener(message) { + if (/WebExt Privilege Escalation/.test(message.message)) { + Services.console.unregisterListener(listener); + resolve(message); + } + }); + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + let message = await awaitConsole; + ok( + message.message.includes( + "WebExt Privilege Escalation: typeof(browser) = undefined" + ), + "Document does not have `browser` APIs." + ); + + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_async_loading.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_async_loading.js new file mode 100644 index 0000000000..cb9a07142d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_async_loading.js @@ -0,0 +1,79 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +add_task(async function test_async_loading() { + const adder = `(function add(a = 1) { this.count += a; })();\n`; + + const extension = { + manifest: { + content_scripts: [ + { + run_at: "document_start", + matches: ["http://example.com/dummy"], + js: ["first.js", "second.js"], + }, + { + run_at: "document_end", + matches: ["http://example.com/dummy"], + js: ["third.js"], + }, + ], + }, + files: { + "first.js": ` + this.count = 0; + ${adder.repeat(50000)}; // 2Mb + browser.test.assertEq(this.count, 50000, "A 50k line script"); + + this.order = (this.order || 0) + 1; + browser.test.sendMessage("first", this.order); + `, + "second.js": ` + this.order = (this.order || 0) + 1; + browser.test.sendMessage("second", this.order); + `, + "third.js": ` + this.order = (this.order || 0) + 1; + browser.test.sendMessage("third", this.order); + `, + }, + }; + + async function checkOrder(ext) { + const [first, second, third] = await Promise.all([ + ext.awaitMessage("first"), + ext.awaitMessage("second"), + ext.awaitMessage("third"), + ]); + + equal(first, 1, "first.js finished execution first."); + equal(second, 2, "second.js finished execution second."); + equal(third, 3, "third.js finished execution third."); + } + + info("Test pages observed while extension is running"); + const observed = ExtensionTestUtils.loadExtension(extension); + await observed.startup(); + + const contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await checkOrder(observed); + await observed.unload(); + + info("Test pages already existing on extension startup"); + const existing = ExtensionTestUtils.loadExtension(extension); + + await existing.startup(); + await checkOrder(existing); + await existing.unload(); + + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_canvas_tainting.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_canvas_tainting.js new file mode 100644 index 0000000000..4ac22dc700 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_canvas_tainting.js @@ -0,0 +1,128 @@ +"use strict"; + +const server = createHttpServer({ + hosts: ["green.example.com", "red.example.com"], +}); + +server.registerDirectory("/data/", do_get_file("data")); + +server.registerPathHandler("/pixel.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write(`<!DOCTYPE html> + <script> + function readByWeb() { + let ctx = document.querySelector("canvas").getContext("2d"); + let {data} = ctx.getImageData(0, 0, 1, 1); + return data.slice(0, 3).join(); + } + </script> + `); +}); + +add_task(async function test_contentscript_canvas_tainting() { + async function contentScript() { + let canvas = document.createElement("canvas"); + let ctx = canvas.getContext("2d"); + document.body.appendChild(canvas); + + function draw(url) { + return new Promise(resolve => { + let img = document.createElement("img"); + img.onload = () => { + ctx.drawImage(img, 0, 0, 1, 1); + resolve(); + }; + img.src = url; + }); + } + + function readByExt() { + let { data } = ctx.getImageData(0, 0, 1, 1); + return data.slice(0, 3).join(); + } + + let readByWeb = window.wrappedJSObject.readByWeb; + + // Test reading after drawing an image from the same origin as the web page. + await draw("http://green.example.com/data/pixel_green.gif"); + browser.test.assertEq( + readByWeb(), + "0,255,0", + "Content can read same-origin image" + ); + browser.test.assertEq( + readByExt(), + "0,255,0", + "Extension can read same-origin image" + ); + + // Test reading after drawing a blue pixel data URI from extension content script. + await draw( + "data:image/gif;base64,R0lGODlhAQABAIABAAAA/wAAACwAAAAAAQABAAACAkQBADs=" + ); + browser.test.assertThrows( + readByWeb, + /operation is insecure/, + "Content can't read extension's image" + ); + browser.test.assertEq( + readByExt(), + "0,0,255", + "Extension can read its own image" + ); + + // Test after tainting the canvas with an image from a third party domain. + await draw("http://red.example.com/data/pixel_red.gif"); + browser.test.assertThrows( + readByWeb, + /operation is insecure/, + "Content can't read third party image" + ); + browser.test.assertThrows( + readByExt, + /operation is insecure/, + "Extension can't read fully tainted" + ); + + // Test canvas is still fully tainted after drawing extension's data: image again. + await draw( + "data:image/gif;base64,R0lGODlhAQABAIABAAAA/wAAACwAAAAAAQABAAACAkQBADs=" + ); + browser.test.assertThrows( + readByWeb, + /operation is insecure/, + "Canvas still fully tainted for content" + ); + browser.test.assertThrows( + readByExt, + /operation is insecure/, + "Canvas still fully tainted for extension" + ); + + browser.test.sendMessage("done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://green.example.com/pixel.html"], + js: ["cs.js"], + }, + ], + }, + files: { + "cs.js": contentScript, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://green.example.com/pixel.html" + ); + await extension.awaitMessage("done"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context.js new file mode 100644 index 0000000000..fc27b84200 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context.js @@ -0,0 +1,359 @@ +"use strict"; + +/* eslint-disable mozilla/balanced-listeners */ + +const server = createHttpServer({ hosts: ["example.com", "example.org"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +function loadExtension() { + function contentScript() { + browser.test.sendMessage("content-script-ready"); + + window.addEventListener( + "pagehide", + () => { + browser.test.sendMessage("content-script-hide"); + }, + true + ); + window.addEventListener("pageshow", () => { + browser.test.sendMessage("content-script-show"); + }); + } + + return ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy*"], + js: ["content_script.js"], + run_at: "document_start", + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); +} + +add_task(async function test_contentscript_context() { + let extension = loadExtension(); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitMessage("content-script-ready"); + await extension.awaitMessage("content-script-show"); + + // Get the content script context and check that it points to the correct window. + await contentPage.legacySpawn(extension.id, async extensionId => { + const { ExtensionContent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionContent.sys.mjs" + ); + this.context = ExtensionContent.getContextByExtensionId( + extensionId, + this.content + ); + + Assert.ok(this.context, "Got content script context"); + + Assert.equal( + this.context.contentWindow, + this.content, + "Context's contentWindow property is correct" + ); + + // Navigate so that the content page is hidden in the bfcache. + + this.content.location = "http://example.org/dummy"; + }); + + await extension.awaitMessage("content-script-hide"); + + await contentPage.legacySpawn(null, async () => { + Assert.equal( + this.context.contentWindow, + null, + "Context's contentWindow property is null" + ); + + // Navigate back so the content page is resurrected from the bfcache. + this.content.history.back(); + }); + + await extension.awaitMessage("content-script-show"); + + await contentPage.legacySpawn(null, async () => { + Assert.equal( + this.context.contentWindow, + this.content, + "Context's contentWindow property is correct" + ); + }); + + await contentPage.close(); + await extension.awaitMessage("content-script-hide"); + await extension.unload(); +}); + +add_task(async function test_contentscript_context_incognito_not_allowed() { + async function background() { + await browser.contentScripts.register({ + js: [{ file: "registered_script.js" }], + matches: ["http://example.com/dummy"], + runAt: "document_start", + }); + + browser.test.sendMessage("background-ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy"], + js: ["content_script.js"], + run_at: "document_start", + }, + ], + permissions: ["http://example.com/*"], + }, + background, + files: { + "content_script.js": () => { + browser.test.notifyFail("content_script_loaded"); + }, + "registered_script.js": () => { + browser.test.notifyFail("registered_script_loaded"); + }, + }, + }); + + // Bug 1715801: Re-enable pbm portion on GeckoView + if (AppConstants.platform == "android") { + Services.prefs.setBoolPref("dom.security.https_first_pbm", false); + } + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy", + { privateBrowsing: true } + ); + + await contentPage.legacySpawn(extension.id, async extensionId => { + const { ExtensionContent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionContent.sys.mjs" + ); + let context = ExtensionContent.getContextByExtensionId( + extensionId, + this.content + ); + Assert.equal( + context, + null, + "Extension unable to use content_script in private browsing window" + ); + }); + + await contentPage.close(); + await extension.unload(); + + // Bug 1715801: Re-enable pbm portion on GeckoView + if (AppConstants.platform == "android") { + Services.prefs.clearUserPref("dom.security.https_first_pbm"); + } +}); + +add_task(async function test_contentscript_context_unload_while_in_bfcache() { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy?first" + ); + let extension = loadExtension(); + await extension.startup(); + await extension.awaitMessage("content-script-ready"); + + // Get the content script context and check that it points to the correct window. + await contentPage.legacySpawn(extension.id, async extensionId => { + const { ExtensionContent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionContent.sys.mjs" + ); + // Save context so we can verify that contentWindow is nulled after unload. + this.context = ExtensionContent.getContextByExtensionId( + extensionId, + this.content + ); + + Assert.equal( + this.context.contentWindow, + this.content, + "Context's contentWindow property is correct" + ); + + this.contextUnloadedPromise = new Promise(resolve => { + this.context.callOnClose({ close: resolve }); + }); + this.pageshownPromise = new Promise(resolve => { + this.content.addEventListener( + "pageshow", + () => { + // Yield to the event loop once more to ensure that all pageshow event + // handlers have been dispatched before fulfilling the promise. + let { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" + ); + setTimeout(resolve, 0); + }, + { once: true, mozSystemGroup: true } + ); + }); + + // Navigate so that the content page is hidden in the bfcache. + this.content.location = "http://example.org/dummy?second"; + }); + + await extension.awaitMessage("content-script-hide"); + + await extension.unload(); + await contentPage.legacySpawn(null, async () => { + await this.contextUnloadedPromise; + Assert.equal(this.context.unloaded, true, "Context has been unloaded"); + + // Normally, when a page is not in the bfcache, context.contentWindow is + // not null when the callOnClose handler is invoked (this is checked by the + // previous subtest). + // Now wait a little bit and check again to ensure that the contentWindow + // property is not somehow restored. + await new Promise(resolve => this.content.setTimeout(resolve, 0)); + Assert.equal( + this.context.contentWindow, + null, + "Context's contentWindow property is null" + ); + + // Navigate back so the content page is resurrected from the bfcache. + this.content.history.back(); + + await this.pageshownPromise; + + Assert.equal( + this.context.contentWindow, + null, + "Context's contentWindow property is null after restore from bfcache" + ); + }); + + await contentPage.close(); +}); + +add_task(async function test_contentscript_context_valid_during_execution() { + // This test does the following: + // - Load page + // - Load extension; inject content script. + // - Navigate page; pagehide triggered. + // - Navigate back; pageshow triggered. + // - Close page; pagehide, unload triggered. + // At each of these last four events, the validity of the context is checked. + + function contentScript() { + browser.test.sendMessage("content-script-ready"); + window.wrappedJSObject.checkContextIsValid("Context is valid on execution"); + + window.addEventListener( + "pagehide", + () => { + window.wrappedJSObject.checkContextIsValid( + "Context is valid on pagehide" + ); + browser.test.sendMessage("content-script-hide"); + }, + true + ); + window.addEventListener("pageshow", () => { + window.wrappedJSObject.checkContextIsValid( + "Context is valid on pageshow" + ); + + // This unload listener is registered after pageshow, to ensure that the + // page can be stored in the bfcache at the previous pagehide. + window.addEventListener("unload", () => { + window.wrappedJSObject.checkContextIsValid( + "Context is valid on unload" + ); + browser.test.sendMessage("content-script-unload"); + }); + + browser.test.sendMessage("content-script-show"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy*"], + js: ["content_script.js"], + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy?first" + ); + await contentPage.legacySpawn(extension.id, async extensionId => { + let context; + let checkContextIsValid = description => { + if (!context) { + const { ExtensionContent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionContent.sys.mjs" + ); + context = ExtensionContent.getContextByExtensionId( + extensionId, + this.content + ); + } + Assert.equal( + context.contentWindow, + this.content, + `${description}: contentWindow` + ); + Assert.equal(context.active, true, `${description}: active`); + }; + Cu.exportFunction(checkContextIsValid, this.content, { + defineAs: "checkContextIsValid", + }); + }); + await extension.startup(); + await extension.awaitMessage("content-script-ready"); + + await contentPage.legacySpawn(extension.id, async extensionId => { + // Navigate so that the content page is frozen in the bfcache. + this.content.location = "http://example.org/dummy?second"; + }); + + await extension.awaitMessage("content-script-hide"); + await contentPage.legacySpawn(null, async () => { + // Navigate back so the content page is resurrected from the bfcache. + this.content.history.back(); + }); + + await extension.awaitMessage("content-script-show"); + await contentPage.close(); + await extension.awaitMessage("content-script-hide"); + await extension.awaitMessage("content-script-unload"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context_isolation.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context_isolation.js new file mode 100644 index 0000000000..7794a66d57 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context_isolation.js @@ -0,0 +1,168 @@ +"use strict"; + +/* globals exportFunction */ +/* eslint-disable mozilla/balanced-listeners */ + +const server = createHttpServer({ hosts: ["example.com", "example.org"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +server.registerPathHandler("/bfcachetestpage", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html;charset=utf-8", false); + response.write(`<!DOCTYPE html> +<script> + window.addEventListener("pageshow", (event) => { + event.stopImmediatePropagation(); + if (window.browserTestSendMessage) { + browserTestSendMessage("content-script-show"); + } + }); + window.addEventListener("pagehide", (event) => { + event.stopImmediatePropagation(); + if (window.browserTestSendMessage) { + if (event.persisted) { + browserTestSendMessage("content-script-hide"); + } else { + browserTestSendMessage("content-script-unload"); + } + } + }, true); +</script>`); +}); + +add_task(async function test_contentscript_context_isolation() { + function contentScript() { + browser.test.sendMessage("content-script-ready"); + + exportFunction(browser.test.sendMessage, window, { + defineAs: "browserTestSendMessage", + }); + + window.addEventListener("pageshow", () => { + browser.test.fail( + "pageshow should have been suppressed by stopImmediatePropagation" + ); + }); + window.addEventListener( + "pagehide", + () => { + browser.test.fail( + "pagehide should have been suppressed by stopImmediatePropagation" + ); + }, + true + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/bfcachetestpage"], + js: ["content_script.js"], + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/bfcachetestpage" + ); + await extension.startup(); + await extension.awaitMessage("content-script-ready"); + + // Get the content script context and check that it points to the correct window. + await contentPage.legacySpawn(extension.id, async extensionId => { + const { ExtensionContent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionContent.sys.mjs" + ); + this.context = ExtensionContent.getContextByExtensionId( + extensionId, + this.content + ); + + Assert.ok(this.context, "Got content script context"); + + Assert.equal( + this.context.contentWindow, + this.content, + "Context's contentWindow property is correct" + ); + + // Navigate so that the content page is hidden in the bfcache. + + this.content.location = "http://example.org/dummy?noscripthere1"; + }); + + await extension.awaitMessage("content-script-hide"); + + await contentPage.legacySpawn(null, async () => { + Assert.equal( + this.context.contentWindow, + null, + "Context's contentWindow property is null" + ); + Assert.ok(this.context.sandbox, "Context's sandbox exists"); + + // Navigate back so the content page is resurrected from the bfcache. + this.content.history.back(); + }); + + await extension.awaitMessage("content-script-show"); + + async function testWithoutBfcache() { + return contentPage.legacySpawn(null, async () => { + Assert.equal( + this.context.contentWindow, + this.content, + "Context's contentWindow property is correct" + ); + Assert.ok(this.context.sandbox, "Context's sandbox exists before unload"); + + let contextUnloadedPromise = new Promise(resolve => { + this.context.callOnClose({ close: resolve }); + }); + + // Now add an "unload" event listener, which should prevent a page from entering the bfcache. + await new Promise(resolve => { + this.content.addEventListener("unload", () => { + Assert.equal( + this.context.contentWindow, + this.content, + "Context's contentWindow property should be non-null at unload" + ); + resolve(); + }); + this.content.location = "http://example.org/dummy?noscripthere2"; + }); + + await contextUnloadedPromise; + }); + } + await runWithPrefs( + [["docshell.shistory.bfcache.allow_unload_listeners", false]], + testWithoutBfcache + ); + + await extension.awaitMessage("content-script-unload"); + + await contentPage.legacySpawn(null, async () => { + Assert.equal( + this.context.sandbox, + null, + "Context's sandbox has been destroyed after unload" + ); + }); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_create_iframe.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_create_iframe.js new file mode 100644 index 0000000000..41d9901c80 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_create_iframe.js @@ -0,0 +1,177 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_contentscript_create_iframe() { + function background() { + browser.runtime.onMessage.addListener((msg, sender) => { + let { name, availableAPIs, manifest, testGetManifest } = msg; + let hasExtTabsAPI = availableAPIs.indexOf("tabs") > 0; + let hasExtWindowsAPI = availableAPIs.indexOf("windows") > 0; + + browser.test.assertFalse( + hasExtTabsAPI, + "the created iframe should not be able to use privileged APIs (tabs)" + ); + browser.test.assertFalse( + hasExtWindowsAPI, + "the created iframe should not be able to use privileged APIs (windows)" + ); + + let { + browser_specific_settings: { + gecko: { id: expectedManifestGeckoId }, + }, + } = chrome.runtime.getManifest(); + let { + browser_specific_settings: { + gecko: { id: actualManifestGeckoId }, + }, + } = manifest; + + browser.test.assertEq( + actualManifestGeckoId, + expectedManifestGeckoId, + "the add-on manifest should be accessible from the created iframe" + ); + + let { + browser_specific_settings: { + gecko: { id: testGetManifestGeckoId }, + }, + } = testGetManifest; + + browser.test.assertEq( + testGetManifestGeckoId, + expectedManifestGeckoId, + "GET_MANIFEST() returns manifest data before extension unload" + ); + + browser.test.sendMessage(name); + }); + } + + function contentScriptIframe() { + window.GET_MANIFEST = browser.runtime.getManifest.bind(null); + + window.testGetManifestException = () => { + try { + window.GET_MANIFEST(); + } catch (exception) { + return String(exception); + } + }; + + let testGetManifest = window.GET_MANIFEST(); + + let manifest = browser.runtime.getManifest(); + let availableAPIs = Object.keys(browser).filter(key => browser[key]); + + browser.runtime.sendMessage({ + name: "content-script-iframe-loaded", + availableAPIs, + manifest, + testGetManifest, + }); + } + + const ID = "contentscript@tests.mozilla.org"; + let extensionData = { + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + web_accessible_resources: ["content_script_iframe.html"], + }, + + background, + + files: { + "content_script.js"() { + let iframe = document.createElement("iframe"); + iframe.src = browser.runtime.getURL("content_script_iframe.html"); + document.body.appendChild(iframe); + }, + "content_script_iframe.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script type="text/javascript" src="content_script_iframe.js"></script> + </head> + </html>`, + "content_script_iframe.js": contentScriptIframe, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + await extension.awaitMessage("content-script-iframe-loaded"); + + info("testing APIs availability once the extension is unloaded..."); + + await contentPage.legacySpawn(null, () => { + this.iframeWindow = this.content[0]; + + Assert.ok(this.iframeWindow, "content script enabled iframe found"); + Assert.ok( + /content_script_iframe\.html$/.test(this.iframeWindow.location), + "the found iframe has the expected URL" + ); + }); + + await extension.unload(); + + info( + "test content script APIs not accessible from the frame once the extension is unloaded" + ); + + await contentPage.legacySpawn(null, () => { + let win = Cu.waiveXrays(this.iframeWindow); + ok( + !Cu.isDeadWrapper(win.browser), + "the API object should not be a dead object" + ); + + let manifest; + let manifestException; + try { + manifest = win.browser.runtime.getManifest(); + } catch (e) { + manifestException = e; + } + + Assert.ok(!manifest, "manifest should be undefined"); + + Assert.equal( + manifestException.constructor.name, + "TypeError", + "expected exception received" + ); + + Assert.ok( + manifestException.message.endsWith("win.browser.runtime is undefined"), + "expected exception received" + ); + + let getManifestException = win.testGetManifestException(); + + Assert.equal( + getManifestException, + "TypeError: can't access dead object", + "expected exception received" + ); + }); + + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_csp.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_csp.js new file mode 100644 index 0000000000..6b03f5b0b0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_csp.js @@ -0,0 +1,433 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const server = createHttpServer({ + hosts: ["example.com", "csplog.example.net"], +}); +server.registerDirectory("/data/", do_get_file("data")); + +var gDefaultCSP = `default-src 'self' 'report-sample'; script-src 'self' 'report-sample';`; +var gCSP = gDefaultCSP; +const pageContent = `<!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="UTF-8"> + <title></title> + </head> + <body> + <img id="testimg"> + </body> + </html>`; + +server.registerPathHandler("/plain.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html"); + if (gCSP) { + info(`Content-Security-Policy: ${gCSP}`); + response.setHeader("Content-Security-Policy", gCSP); + } + response.write(pageContent); +}); + +const BASE_URL = `http://example.com`; +const pageURL = `${BASE_URL}/plain.html`; + +const CSP_REPORT_PATH = "/csp-report.sjs"; + +function readUTF8InputStream(stream) { + let buffer = NetUtil.readInputStream(stream, stream.available()); + return new TextDecoder().decode(buffer); +} + +server.registerPathHandler(CSP_REPORT_PATH, (request, response) => { + response.setStatusLine(request.httpVersion, 204, "No Content"); + let data = readUTF8InputStream(request.bodyInputStream); + Services.obs.notifyObservers(null, "extension-test-csp-report", data); +}); + +async function promiseCSPReport(test) { + let res = await TestUtils.topicObserved("extension-test-csp-report", test); + return JSON.parse(res[1]); +} + +// Test functions loaded into extension content script. +function testImage(data = {}) { + return new Promise(resolve => { + let img = window.document.getElementById("testimg"); + img.onload = () => resolve(true); + img.onerror = () => { + browser.test.log(`img error: ${img.src}`); + resolve(false); + }; + img.src = data.image_url; + }); +} + +function testFetch(data = {}) { + let f = data.content ? content.fetch : fetch; + return f(data.url) + .then(() => true) + .catch(e => { + browser.test.assertEq( + e.message, + "NetworkError when attempting to fetch resource.", + "expected fetch failure" + ); + return false; + }); +} + +async function testEval(data = {}) { + try { + // eslint-disable-next-line no-eval + let ev = data.content ? window.eval : eval; + return ev("true"); + } catch (e) { + return false; + } +} + +async function testFunction(data = {}) { + try { + // eslint-disable-next-line no-eval + let fn = data.content ? window.Function : Function; + let sum = new fn("a", "b", "return a + b"); + return sum(1, 1); + } catch (e) { + return 0; + } +} + +function testScriptTag(data) { + return new Promise(resolve => { + let script = document.createElement("script"); + script.src = data.url; + script.onload = () => { + resolve(true); + }; + script.onerror = () => { + resolve(false); + }; + document.body.appendChild(script); + }); +} + +async function testHttpRequestUpgraded(data = {}) { + let f = data.content ? content.fetch : fetch; + return f(data.url) + .then(() => "http:") + .catch(() => "https:"); +} + +async function testWebSocketUpgraded(data = {}) { + let ws = data.content ? content.WebSocket : WebSocket; + new ws(data.url); +} + +function webSocketUpgradeListenerBackground() { + // Catch websocket requests and send the protocol back to be asserted. + browser.webRequest.onBeforeRequest.addListener( + details => { + // Send the protocol back as test result. + // This will either be "wss:", "ws:" + browser.test.sendMessage("result", new URL(details.url).protocol); + return { cancel: true }; + }, + { urls: ["wss://example.com/*", "ws://example.com/*"] }, + ["blocking"] + ); +} + +// If the violation source is the extension the securitypolicyviolation event is not fired. +// If the page is the source, the event is fired and both the content script or page scripts +// will receive the event. If we're expecting a moz-extension report we'll fail in the +// event listener if we receive a report. Otherwise we want to resolve in the listener to +// ensure we've received the event for the test. +function contentScript(report) { + return new Promise(resolve => { + if (!report || report["document-uri"] === "moz-extension") { + resolve(); + } + // eslint-disable-next-line mozilla/balanced-listeners + document.addEventListener("securitypolicyviolation", e => { + browser.test.assertTrue( + e.documentURI !== "moz-extension", + `securitypolicyviolation: ${e.violatedDirective} ${e.documentURI}` + ); + resolve(); + }); + }); +} + +let TESTS = [ + // Image Tests + { + description: + "Image from content script using default extension csp. Image is allowed.", + pageCSP: `${gDefaultCSP} img-src 'none';`, + script: testImage, + data: { image_url: `${BASE_URL}/data/file_image_good.png` }, + expect: true, + }, + // Fetch Tests + { + description: "Fetch url in content script uses default extension csp.", + pageCSP: `${gDefaultCSP} connect-src 'none';`, + script: testFetch, + data: { url: `${BASE_URL}/data/file_image_good.png` }, + expect: true, + }, + { + description: "Fetch full url from content script uses page csp.", + pageCSP: `${gDefaultCSP} connect-src 'none';`, + script: testFetch, + data: { + content: true, + url: `${BASE_URL}/data/file_image_good.png`, + }, + expect: false, + report: { + "blocked-uri": `${BASE_URL}/data/file_image_good.png`, + "document-uri": `${BASE_URL}/plain.html`, + "violated-directive": "connect-src", + }, + }, + + // Eval tests. + { + description: "Eval from content script uses page csp with unsafe-eval.", + pageCSP: `default-src 'none'; script-src 'unsafe-eval';`, + script: testEval, + data: { content: true }, + expect: true, + }, + { + description: "Eval from content script uses page csp.", + pageCSP: `default-src 'self' 'report-sample'; script-src 'self';`, + version: 3, + script: testEval, + data: { content: true }, + expect: false, + report: { + "blocked-uri": "eval", + "document-uri": "http://example.com/plain.html", + "violated-directive": "script-src", + }, + }, + { + description: "Eval in content script allowed by v2 csp.", + pageCSP: `script-src 'self' 'unsafe-eval';`, + script: testEval, + expect: true, + }, + { + description: "Eval in content script disallowed by v3 csp.", + pageCSP: `script-src 'self' 'unsafe-eval';`, + version: 3, + script: testEval, + expect: false, + }, + { + description: "Wrapped Eval in content script uses page csp.", + pageCSP: `script-src 'self' 'unsafe-eval';`, + version: 3, + script: async () => { + return window.wrappedJSObject.eval("true"); + }, + expect: true, + }, + { + description: "Wrapped Eval in content script denied by page csp.", + pageCSP: `script-src 'self';`, + version: 3, + script: async () => { + try { + return window.wrappedJSObject.eval("true"); + } catch (e) { + return false; + } + }, + expect: false, + }, + + { + description: "Function from content script uses page csp.", + pageCSP: `default-src 'self'; script-src 'self' 'unsafe-eval';`, + script: testFunction, + data: { content: true }, + expect: 2, + }, + { + description: "Function from content script uses page csp.", + pageCSP: `default-src 'self' 'report-sample'; script-src 'self';`, + version: 3, + script: testFunction, + data: { content: true }, + expect: 0, + report: { + "blocked-uri": "eval", + "document-uri": "http://example.com/plain.html", + "violated-directive": "script-src", + }, + }, + { + description: "Function in content script uses extension csp.", + pageCSP: `default-src 'self'; script-src 'self' 'unsafe-eval';`, + version: 3, + script: testFunction, + expect: 0, + }, + + // The javascript url tests are not included as we do not execute those, + // aparently even with the urlbar filtering pref flipped. + // (browser.urlbar.filter.javascript) + // https://bugzilla.mozilla.org/show_bug.cgi?id=866522 + + // script tag injection tests + { + description: "remote script in content script passes in v2", + version: 2, + pageCSP: "script-src http://example.com:*;", + script: testScriptTag, + data: { url: `${BASE_URL}/data/file_script_good.js` }, + expect: true, + }, + { + description: "remote script in content script fails in v3", + version: 3, + pageCSP: "script-src http://example.com:*;", + script: testScriptTag, + data: { url: `${BASE_URL}/data/file_script_good.js` }, + expect: false, + }, + { + description: "content.WebSocket in content script is affected by page csp.", + version: 2, + pageCSP: `upgrade-insecure-requests;`, + data: { content: true, url: "ws://example.com/ws_dummy" }, + script: testWebSocketUpgraded, + expect: "wss:", // we expect the websocket to be upgraded. + backgroundScript: webSocketUpgradeListenerBackground, + }, + { + description: "WebSocket in content script is not affected by page csp.", + version: 2, + pageCSP: `upgrade-insecure-requests;`, + data: { url: "ws://example.com/ws_dummy" }, + script: testWebSocketUpgraded, + expect: "ws:", // we expect the websocket to not be upgraded. + backgroundScript: webSocketUpgradeListenerBackground, + }, + { + description: "WebSocket in content script is not affected by page csp. v3", + version: 3, + pageCSP: `upgrade-insecure-requests;`, + data: { url: "ws://example.com/ws_dummy" }, + script: testWebSocketUpgraded, + // TODO bug 1766813: MV3+WebSocket should use content script CSP. + expect: "wss:", // TODO: we expect the websocket to not be upgraded (ws:). + backgroundScript: webSocketUpgradeListenerBackground, + }, + { + description: "Http request in content script is not affected by page csp.", + version: 2, + pageCSP: `upgrade-insecure-requests;`, + data: { url: "http://example.com/plain.html" }, + script: testHttpRequestUpgraded, + expect: "http:", // we expect the request to not be upgraded. + }, + { + description: + "Http request in content script is not affected by page csp. v3", + version: 3, + pageCSP: `upgrade-insecure-requests;`, + data: { url: "http://example.com/plain.html" }, + script: testHttpRequestUpgraded, + // TODO bug 1766813: MV3+fetch should use content script CSP. + expect: "https:", // TODO: we expect the request to not be upgraded (http:). + }, + { + description: "content.fetch in content script is affected by page csp.", + version: 2, + pageCSP: `upgrade-insecure-requests;`, + data: { content: true, url: "http://example.com/plain.html" }, + script: testHttpRequestUpgraded, + expect: "https:", // we expect the request to be upgraded. + }, +]; + +async function runCSPTest(test) { + // Set the CSP for the page loaded into the tab. + gCSP = `${test.pageCSP || gDefaultCSP} report-uri ${CSP_REPORT_PATH}`; + let data = { + manifest: { + manifest_version: test.version || 2, + content_scripts: [ + { + matches: ["http://*/plain.html"], + run_at: "document_idle", + js: ["content_script.js"], + }, + ], + permissions: ["webRequest", "webRequestBlocking"], + host_permissions: ["<all_urls>"], + granted_host_permissions: true, + background: { scripts: ["background.js"] }, + }, + temporarilyInstalled: true, + files: { + "content_script.js": ` + (${contentScript})(${JSON.stringify(test.report)}).then(() => { + browser.test.sendMessage("violationEvent"); + }); + (${test.script})(${JSON.stringify(test.data)}).then(result => { + if(result !== undefined) { + browser.test.sendMessage("result", result); + } + }); + `, + "background.js": `(${test.backgroundScript || (() => {})})()`, + ...test.files, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(data); + await extension.startup(); + + let reportPromise = test.report && promiseCSPReport(); + let contentPage = await ExtensionTestUtils.loadContentPage(pageURL); + + info(`running: ${test.description}`); + await extension.awaitMessage("violationEvent"); + + let result = await extension.awaitMessage("result"); + equal(result, test.expect, test.description); + + if (test.report) { + let report = await reportPromise; + for (let key of Object.keys(test.report)) { + equal( + report["csp-report"][key], + test.report[key], + `csp-report ${key} matches` + ); + } + } + + await extension.unload(); + await contentPage.close(); + clearCache(); +} + +add_task(async function test_contentscript_csp() { + for (let test of TESTS) { + await runCSPTest(test); + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_css.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_css.js new file mode 100644 index 0000000000..a75c397b8c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_css.js @@ -0,0 +1,48 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +add_task(async function test_content_script_css() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy"], + css: ["content.css"], + run_at: "document_start", + }, + ], + }, + + files: { + "content.css": "body { max-width: 42px; }", + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + + function task() { + let style = this.content.getComputedStyle(this.content.document.body); + return style.maxWidth; + } + + let maxWidth = await contentPage.spawn([], task); + equal(maxWidth, "42px", "Stylesheet correctly applied"); + + await extension.unload(); + + maxWidth = await contentPage.spawn([], task); + equal(maxWidth, "none", "Stylesheet correctly removed"); + + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_dynamic_registration.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_dynamic_registration.js new file mode 100644 index 0000000000..0133b5d86c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_dynamic_registration.js @@ -0,0 +1,205 @@ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, to use +// the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +// Do not use preallocated processes. +Services.prefs.setBoolPref("dom.ipc.processPrelaunch.enabled", false); +// This is needed for Android. +Services.prefs.setIntPref("dom.ipc.keepProcessesAlive.web", 0); + +const makeExtension = ({ background, manifest }) => { + return ExtensionTestUtils.loadExtension({ + manifest: { + ...manifest, + permissions: + manifest.manifest_version === 3 ? ["scripting"] : ["http://*/*/*.html"], + }, + temporarilyInstalled: true, + background, + files: { + "script.js": () => { + browser.test.sendMessage( + `script-ran: ${location.pathname.split("/").pop()}` + ); + }, + "inject_browser.js": () => { + browser.userScripts.onBeforeScript.addListener(script => { + // Inject `browser.test.sendMessage()` so that it can be used in the + // `script.js` defined above when using "user scripts". + script.defineGlobals({ + browser: { + test: { + sendMessage(msg) { + browser.test.sendMessage(msg); + }, + }, + }, + }); + }); + }, + }, + }); +}; + +const verifyRegistrationWithNewProcess = async extension => { + // We override the `broadcast()` method to reliably verify Bug 1756495: when + // a new process is spawned while we register a content script, the script + // should be correctly registered and executed in this new process. Below, + // when we receive the `Extension:RegisterContentScripts`, we open a new tab + // (which is the "new process") and then we invoke the original "broadcast + // logic". The expected result is that the content script registered by the + // extension will run. + const originalBroadcast = Extension.prototype.broadcast; + + let broadcastCalledCount = 0; + let secondContentPage; + + extension.extension.broadcast = async function broadcast(msg, data) { + if (msg !== "Extension:RegisterContentScripts") { + return originalBroadcast.call(this, msg, data); + } + + broadcastCalledCount++; + Assert.equal( + 1, + broadcastCalledCount, + "broadcast override should be called once" + ); + + await originalBroadcast.call(this, msg, data); + + Assert.equal(extension.id, data.id, "got expected extension ID"); + Assert.equal(1, data.scripts.length, "expected 1 script to register"); + Assert.ok( + data.scripts[0].options.jsPaths[0].endsWith("script.js"), + "got expected js file" + ); + + const newPids = []; + const topic = "ipc:content-created"; + + let obs = (subject, topic, data) => { + newPids.push(parseInt(data, 10)); + }; + Services.obs.addObserver(obs, topic); + + secondContentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/dummy_page.html` + ); + + const { childID } = + secondContentPage.browsingContext.currentWindowGlobal.domProcess; + + Services.obs.removeObserver(obs, topic); + + // We expect to have a new process created for `secondContentPage`. + Assert.ok( + newPids.includes(childID), + `expected PID ${childID} to be in [${newPids.join(", ")}])` + ); + }; + + await extension.startup(); + await extension.awaitMessage("background-done"); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await Promise.all([ + extension.awaitMessage("script-ran: file_sample.html"), + extension.awaitMessage("script-ran: dummy_page.html"), + ]); + + // Unload extension first to avoid an issue on Windows platforms. + await extension.unload(); + await contentPage.close(); + await secondContentPage.close(); +}; + +add_task( + { + pref_set: [["extensions.manifestV3.enabled", true]], + }, + async function test_scripting_registerContentScripts() { + let extension = makeExtension({ + manifest: { + manifest_version: 3, + host_permissions: ["<all_urls>"], + granted_host_permissions: true, + }, + async background() { + const script = { + id: "a-script", + js: ["script.js"], + matches: ["http://*/*/*.html"], + persistAcrossSessions: false, + }; + + await browser.scripting.registerContentScripts([script]); + + browser.test.sendMessage("background-done"); + }, + }); + + await verifyRegistrationWithNewProcess(extension); + } +); + +add_task( + { + // We don't have WebIDL bindings for `browser.contentScripts`. + skip_if: () => ExtensionTestUtils.isInBackgroundServiceWorkerTests(), + }, + async function test_contentScripts_register() { + let extension = makeExtension({ + manifest: { + manifest_version: 2, + }, + async background() { + await browser.contentScripts.register({ + js: [{ file: "script.js" }], + matches: ["http://*/*/*.html"], + }); + + browser.test.sendMessage("background-done"); + }, + }); + + await verifyRegistrationWithNewProcess(extension); + } +); + +add_task( + { + // We don't have WebIDL bindings for `browser.userScripts`. + skip_if: () => ExtensionTestUtils.isInBackgroundServiceWorkerTests(), + }, + async function test_userScripts_register() { + let extension = makeExtension({ + manifest: { + manifest_version: 2, + user_scripts: { + api_script: "inject_browser.js", + }, + }, + async background() { + await browser.userScripts.register({ + js: [{ file: "script.js" }], + matches: ["http://*/*/*.html"], + }); + + browser.test.sendMessage("background-done"); + }, + }); + + await verifyRegistrationWithNewProcess(extension); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_errors.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_errors.js new file mode 100644 index 0000000000..6fae3b838a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_errors.js @@ -0,0 +1,150 @@ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; +const TEST_URL_1 = `${BASE_URL}/file_sample.html`; +const TEST_URL_2 = `${BASE_URL}/file_content_script_errors.html`; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +add_task(async function test_cached_contentscript_on_document_start() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + // Use distinct content scripts as some will throw and would prevent executing the next script + { + matches: ["http://*/*/file_content_script_errors.html"], + js: ["script1.js"], + run_at: "document_start", + }, + { + matches: ["http://*/*/file_content_script_errors.html"], + js: ["script2.js"], + run_at: "document_start", + }, + { + matches: ["http://*/*/file_content_script_errors.html"], + js: ["script3.js"], + run_at: "document_start", + }, + { + matches: ["http://*/*/file_content_script_errors.html"], + js: ["script4.js"], + run_at: "document_start", + }, + { + matches: ["http://*/*/file_content_script_errors.html"], + js: ["script5.js"], + run_at: "document_start", + }, + ], + }, + + files: { + "script1.js": ` + throw new Error("Object exception"); + `, + "script2.js": ` + throw "String exception"; + `, + "script3.js": ` + undefinedSymbol(); + `, + "script4.js": ` + ) + `, + "script5.js": ` + Promise.reject("rejected promise"); + + (async () => { + /* make the async, really async */ + await new Promise(r => setTimeout(r, 0)); + throw new Error("async function exception"); + })(); + + setTimeout(() => { + asyncUndefinedSymbol(); + }); + + /* Use a delay in order to resume test execution after these async errors */ + setTimeout(() => { + browser.test.sendMessage("content-script-loaded"); + }, 500); + `, + }, + }); + + // Error messages, in roughly the order they appear above. + let expectedMessages = [ + "Error: Object exception", + "uncaught exception: String exception", + "ReferenceError: undefinedSymbol is not defined", + "SyntaxError: expected expression, got ')'", + "uncaught exception: rejected promise", + "Error: async function exception", + "ReferenceError: asyncUndefinedSymbol is not defined", + ]; + + await extension.startup(); + + // Load a first page in order to be able to register a console listener in the content process. + // This has to be done in the same domain of the second page to stay in the same process. + let contentPage = await ExtensionTestUtils.loadContentPage(TEST_URL_1); + + // Listen to the errors logged in the content process. + let errorsPromise = ContentTask.spawn(contentPage.browser, {}, async () => { + return new Promise(resolve => { + function listener(error0) { + let error = error0.QueryInterface(Ci.nsIScriptError); + + // Ignore errors from ExtensionContent.jsm + if (!error.innerWindowID) { + return; + } + + this.collectedErrors.push({ + innerWindowID: error.innerWindowID, + message: error.errorMessage, + }); + if (this.collectedErrors.length == 7) { + Services.console.unregisterListener(this); + resolve(this.collectedErrors); + } + } + listener.collectedErrors = []; + Services.console.registerListener(listener); + }); + }); + + // Reload the page and check that the cached content script is still able to + // run on document_start. + await contentPage.loadURL(TEST_URL_2); + + let errors = await errorsPromise; + + await extension.awaitMessage("content-script-loaded"); + + equal(errors.length, 7); + let messages = []; + for (const { innerWindowID, message } of errors) { + equal( + innerWindowID, + contentPage.browser.innerWindowID, + `Message ${message} has the innerWindowID set` + ); + + messages.push(message); + } + + messages.sort(); + expectedMessages.sort(); + Assert.deepEqual(messages, expectedMessages, "Got the expected errors"); + + await extension.unload(); + + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_exporthelpers.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_exporthelpers.js new file mode 100644 index 0000000000..ec3b11ee7d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_exporthelpers.js @@ -0,0 +1,98 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_contentscript_exportHelpers() { + function contentScript() { + browser.test.assertTrue(typeof cloneInto === "function"); + browser.test.assertTrue(typeof createObjectIn === "function"); + browser.test.assertTrue(typeof exportFunction === "function"); + + /* globals exportFunction, precisePi, reportPi */ + let value = 3.14; + exportFunction(() => value, window, { defineAs: "precisePi" }); + + browser.test.assertEq( + "undefined", + typeof precisePi, + "exportFunction should export to the page's scope only" + ); + + browser.test.assertEq( + "undefined", + typeof window.precisePi, + "exportFunction should export to the page's scope only" + ); + + let results = []; + exportFunction(pi => results.push(pi), window, { defineAs: "reportPi" }); + + let s = document.createElement("script"); + s.textContent = `(${function () { + let result1 = "unknown 1"; + let result2 = "unknown 2"; + try { + result1 = precisePi(); + } catch (e) { + result1 = "err:" + e; + } + try { + result2 = window.precisePi(); + } catch (e) { + result2 = "err:" + e; + } + reportPi(result1); + reportPi(result2); + }})();`; + + document.documentElement.appendChild(s); + // Inline script ought to run synchronously. + + browser.test.assertEq( + 3.14, + results[0], + "exportFunction on window should define a global function" + ); + browser.test.assertEq( + 3.14, + results[1], + "exportFunction on window should export a property to window." + ); + + browser.test.assertEq( + 2, + results.length, + "Expecting the number of results to match the number of method calls" + ); + + browser.test.notifyPass("export helper test completed"); + } + + let extensionData = { + manifest: { + content_scripts: [ + { + js: ["contentscript.js"], + matches: ["http://example.com/data/file_sample.html"], + run_at: "document_start", + }, + ], + }, + + files: { + "contentscript.js": contentScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + await extension.awaitFinish("export helper test completed"); + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_importmap.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_importmap.js new file mode 100644 index 0000000000..ba7f7120d9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_importmap.js @@ -0,0 +1,123 @@ +"use strict"; + +// Currently import maps are not supported for web extensions, neither for +// content scripts nor moz-extension documents. +// For content scripts that's because they use their own sandbox module loaders, +// which is different from the DOM module loader. +// As for moz-extension documents, that's because inline script tags is not +// allowed by CSP. (Currently import maps can be only added through inline +// script tag.) +// +// This test is used to verified import maps are not supported for web +// extensions. +// See Bug 1765275: Enable Import maps for web extension content scripts. + +const server = createHttpServer({ hosts: ["example.com"] }); + +const importMapString = ` + <script type="importmap"> + { + "imports": { + "simple": "./simple.js", + "simple2": "./simple2.js" + } + } + </script>`; + +const importMapHtml = ` + <!DOCTYPE html> + <html> + <meta charset=utf-8> + <title>Test a simple import map in normal webpage</title> + <body> + ${importMapString} + </body></html>`; + +// page.html will load page.js, which will call import(); +const pageHtml = ` + <!DOCTYPE html> + <html> + <meta charset=utf-8> + <title>Test a simple import map in moz-extension documents</title> + <body> + ${importMapString} + <script src="page.js"></script> + </body></html>`; + +const simple2JS = `export let foo = 2;`; + +server.registerPathHandler("/importmap.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write(importMapHtml); +}); + +server.registerPathHandler("/simple.js", (request, response) => { + ok(false, "Unexpected request to /simple.js"); +}); + +server.registerPathHandler("/simple2.js", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/javascript", false); + response.write(simple2JS); +}); + +add_task(async function test_importMaps_not_supported() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/importmap.html"], + js: ["main.js"], + }, + ], + }, + + files: { + "main.js": async function () { + // Content scripts shouldn't be able to use the bare specifier from + // the import map. + await browser.test.assertRejects( + import("simple"), + /The specifier “simple” was a bare specifier/, + `should reject import("simple")` + ); + + browser.test.sendMessage("done"); + }, + "page.html": pageHtml, + "page.js": async function () { + await browser.test.assertRejects( + import("simple"), + /The specifier “simple” was a bare specifier/, + `should reject import("simple")` + ); + browser.test.sendMessage("page-done"); + }, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/importmap.html" + ); + await extension.awaitMessage("done"); + + await contentPage.spawn([], async () => { + // Import maps should work for documents. + let promise = content.eval(`import("simple2")`); + let mod = (await promise.wrappedJSObject).wrappedJSObject; + Assert.equal(mod.foo, 2, "mod.foo should be 2"); + }); + + // moz-extension documents doesn't allow inline scripts, so the import map + // script tag won't be processed. + let url = `moz-extension://${extension.uuid}/page.html`; + let page = await ExtensionTestUtils.loadContentPage(url, { extension }); + await extension.awaitMessage("page-done"); + + await page.close(); + await extension.unload(); + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_in_background.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_in_background.js new file mode 100644 index 0000000000..15c5b30542 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_in_background.js @@ -0,0 +1,48 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/dummyFrame", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write(""); +}); + +add_task(async function content_script_in_background_frame() { + // Test loads http: frame in background page. + allow_unsafe_parent_loads_when_extensions_not_remote(); + + async function background() { + const FRAME_URL = "http://example.com:8888/dummyFrame"; + await browser.contentScripts.register({ + matches: ["http://example.com/dummyFrame"], + js: [{ file: "contentscript.js" }], + allFrames: true, + }); + + let f = document.createElement("iframe"); + f.src = FRAME_URL; + document.body.appendChild(f); + } + + function contentScript() { + browser.test.log(`Running content script at ${document.URL}`); + browser.test.sendMessage("done_in_content_script"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://example.com/*"], + }, + files: { + "contentscript.js": contentScript, + }, + background, + }); + await extension.startup(); + await extension.awaitMessage("done_in_content_script"); + await extension.unload(); + + revert_allow_unsafe_parent_loads_when_extensions_not_remote(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_json_api.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_json_api.js new file mode 100644 index 0000000000..ca37e2e951 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_json_api.js @@ -0,0 +1,102 @@ +"use strict"; + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write( + `<script> + // Clobber the JSON API to allow us to confirm that the page's value for + // the "JSON" object does not affect the content script's JSON API. + window.JSON = new String("overridden by page"); + window.objFromPage = { serializeMe: "thanks" }; + window.objWithToJSON = { toJSON: () => "toJSON ran", should_not_see: 1 }; + </script> + ` + ); +}); + +async function test_JSON_parse_and_stringify({ manifest_version }) { + let extension = ExtensionTestUtils.loadExtension({ + temporarilyInstalled: true, // Needed for granted_host_permissions + manifest: { + manifest_version, + granted_host_permissions: true, // Test-only: grant permissions in MV3. + host_permissions: ["http://example.com/"], // Work-around for bug 1766752. + content_scripts: [ + { + matches: ["http://example.com/dummy"], + run_at: "document_end", + js: ["contentscript.js"], + }, + ], + }, + files: { + "contentscript.js"() { + let json = `{"a":[123,true,null]}`; + browser.test.assertEq( + JSON.stringify({ a: [123, true, null] }), + json, + "JSON.stringify with basic values" + ); + let parsed = JSON.parse(json); + browser.test.assertTrue( + parsed instanceof Object, + "Parsed JSON is an Object" + ); + browser.test.assertTrue( + parsed.a instanceof Array, + "Parsed JSON has an Array" + ); + browser.test.assertEq( + JSON.stringify(parsed), + json, + "JSON.stringify for parsed JSON returns original input" + ); + browser.test.assertEq( + JSON.stringify({ toJSON: () => "overridden", hideme: true }), + `"overridden"`, + "JSON.parse with toJSON method" + ); + + browser.test.assertEq( + JSON.stringify(window.wrappedJSObject.objFromPage), + `{"serializeMe":"thanks"}`, + "JSON.parse with value from the page" + ); + + browser.test.assertEq( + JSON.stringify(window.wrappedJSObject.objWithToJSON), + `"toJSON ran"`, + "JSON.parse with object with toJSON method from the page" + ); + + browser.test.assertTrue(JSON === globalThis.JSON, "JSON === this.JSON"); + browser.test.assertTrue(JSON === window.JSON, "JSON === window.JSON"); + browser.test.assertEq( + "overridden by page", + window.wrappedJSObject.JSON.toString(), + "page's JSON object is still the original value (overridden by page)" + ); + browser.test.sendMessage("done"); + }, + }, + }); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitMessage("done"); + await contentPage.close(); + await extension.unload(); +} + +add_task(async function test_JSON_apis_MV2() { + await test_JSON_parse_and_stringify({ manifest_version: 2 }); +}); + +add_task(async function test_JSON_apis_MV3() { + await test_JSON_parse_and_stringify({ manifest_version: 3 }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_module_import.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_module_import.js new file mode 100644 index 0000000000..3e4e5dd983 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_module_import.js @@ -0,0 +1,277 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +server.registerPathHandler("/script.js", (request, response) => { + ok(false, "Unexpected request to /script.js"); +}); + +/* eslint-disable no-eval, no-implied-eval */ + +const MODULE1 = ` + import {foo} from "./module2.js"; + export let bar = foo; + + let count = 0; + + export function counter () { return count++; } +`; + +const MODULE2 = `export let foo = 2;`; + +add_task(async function test_disallowed_import() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy"], + js: ["main.js"], + }, + ], + }, + + files: { + "main.js": async function () { + let disallowedURLs = [ + "data:text/javascript,void 0", + "javascript:void 0", + "http://example.com/script.js", + URL.createObjectURL( + new Blob(["void 0", { type: "text/javascript" }]) + ), + ]; + + for (let url of disallowedURLs) { + await browser.test.assertRejects( + import(url), + /error loading dynamically imported module/, + `should reject import("${url}")` + ); + } + + browser.test.sendMessage("done"); + }, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitMessage("done"); + await extension.unload(); + await contentPage.close(); +}); + +add_task(async function test_normal_import() { + Services.prefs.setBoolPref("extensions.content_web_accessible.enabled", true); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy"], + js: ["main.js"], + }, + ], + }, + + files: { + "main.js": async function () { + /* global exportFunction */ + const url = browser.runtime.getURL("module1.js"); + + await browser.test.assertRejects( + import(url), + /error loading dynamically imported module/, + "Cannot import script that is not web-accessible from page context" + ); + + await browser.test.assertRejects( + window.eval(`import("${url}")`), + /error loading dynamically imported module/, + "Cannot import script that is not web-accessible from page context" + ); + + let promise = new Promise((resolve, reject) => { + exportFunction(resolve, window, { defineAs: "resolve" }); + exportFunction(reject, window, { defineAs: "reject" }); + }); + + window.setTimeout(`import("${url}").then(resolve, reject)`, 0); + + await browser.test.assertRejects( + promise, + /error loading dynamically imported module/, + "Cannot import script that is not web-accessible from page context" + ); + + browser.test.sendMessage("done"); + }, + "module1.js": MODULE1, + "module2.js": MODULE2, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + + await extension.awaitMessage("done"); + + // Web page can not import non-web-accessible files. + await contentPage.spawn([extension.uuid], async uuid => { + let files = ["main.js", "module1.js", "module2.js"]; + + for (let file of files) { + let url = `moz-extension://${uuid}/${file}`; + await Assert.rejects( + content.eval(`import("${url}")`), + /error loading dynamically imported module/, + "Cannot import script that is not web-accessible" + ); + } + }); + + await extension.unload(); + await contentPage.close(); +}); + +add_task(async function test_import_web_accessible() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy"], + js: ["main.js"], + }, + ], + web_accessible_resources: ["module1.js", "module2.js"], + }, + + files: { + "main.js": async function () { + let mod = await import(browser.runtime.getURL("module1.js")); + browser.test.assertEq(mod.bar, 2); + browser.test.assertEq(mod.counter(), 0); + browser.test.sendMessage("done"); + }, + "module1.js": MODULE1, + "module2.js": MODULE2, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitMessage("done"); + + // Web page can import web-accessible files, + // even after WebExtension imported the same files. + await contentPage.spawn([extension.uuid], async uuid => { + let base = `moz-extension://${uuid}`; + + await Assert.rejects( + content.eval(`import("${base}/main.js")`), + /error loading dynamically imported module/, + "Cannot import script that is not web-accessible" + ); + + let promise = content.eval(`import("${base}/module1.js")`); + let mod = (await promise.wrappedJSObject).wrappedJSObject; + Assert.equal(mod.bar, 2, "exported value should match"); + Assert.equal(mod.counter(), 0, "Counter should be fresh"); + Assert.equal(mod.counter(), 1, "Counter should be fresh"); + + promise = content.eval(`import("${base}/module2.js")`); + mod = (await promise.wrappedJSObject).wrappedJSObject; + Assert.equal(mod.foo, 2, "exported value should match"); + }); + + await extension.unload(); + await contentPage.close(); +}); + +add_task(async function test_import_web_accessible_after_page() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy"], + js: ["main.js"], + }, + ], + web_accessible_resources: ["module1.js", "module2.js"], + }, + + files: { + "main.js": async function () { + browser.test.onMessage.addListener(async msg => { + browser.test.assertEq(msg, "import"); + + const url = browser.runtime.getURL("module1.js"); + let mod = await import(url); + browser.test.assertEq(mod.bar, 2); + browser.test.assertEq(mod.counter(), 0, "Counter should be fresh"); + + let promise = window.eval(`import("${url}")`); + let mod2 = (await promise.wrappedJSObject).wrappedJSObject; + browser.test.assertEq( + mod2.counter(), + 2, + "Counter should have been incremented by page" + ); + + browser.test.sendMessage("done"); + }); + browser.test.sendMessage("ready"); + }, + "module1.js": MODULE1, + "module2.js": MODULE2, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitMessage("ready"); + + // The web page imports the web-accessible files first, + // when the WebExtension imports the same file, they should + // not be shared. + await contentPage.spawn([extension.uuid], async uuid => { + let base = `moz-extension://${uuid}`; + + await Assert.rejects( + content.eval(`import("${base}/main.js")`), + /error loading dynamically imported module/, + "Cannot import script that is not web-accessible" + ); + + let promise = content.eval(`import("${base}/module1.js")`); + let mod = (await promise.wrappedJSObject).wrappedJSObject; + Assert.equal(mod.bar, 2, "exported value should match"); + Assert.equal(mod.counter(), 0); + Assert.equal(mod.counter(), 1); + + promise = content.eval(`import("${base}/module2.js")`); + mod = (await promise.wrappedJSObject).wrappedJSObject; + Assert.equal(mod.foo, 2, "exported value should match"); + }); + + extension.sendMessage("import"); + + await extension.awaitMessage("done"); + + await extension.unload(); + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_perf_observers.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_perf_observers.js new file mode 100644 index 0000000000..012c91dc23 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_perf_observers.js @@ -0,0 +1,71 @@ +"use strict"; + +const server = createHttpServer({ + hosts: ["a.example.com", "b.example.com", "c.example.com"], +}); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_perf_observers_cors() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://b.example.com/"], + content_scripts: [ + { + matches: ["http://a.example.com/data/file_sample.html"], + js: ["cs.js"], + }, + ], + }, + files: { + "cs.js"() { + let obs = new window.PerformanceObserver(list => { + list.getEntries().forEach(e => { + browser.test.sendMessage("observed", { + url: e.name, + time: e.connectEnd, + size: e.encodedBodySize, + }); + }); + }); + obs.observe({ entryTypes: ["resource"] }); + + let b = document.createElement("link"); + b.rel = "stylesheet"; + + // Simulate page including a cross-origin resource from b.example.com. + b.wrappedJSObject.href = "http://b.example.com/data/file_download.txt"; + document.head.appendChild(b); + + let c = document.createElement("link"); + c.rel = "stylesheet"; + + // Simulate page including a cross-origin resource from c.example.com. + c.wrappedJSObject.href = "http://c.example.com/data/file_download.txt"; + document.head.appendChild(c); + }, + }, + }); + + let page = await ExtensionTestUtils.loadContentPage( + "http://a.example.com/data/file_sample.html" + ); + await extension.startup(); + + let b = await extension.awaitMessage("observed"); + let c = await extension.awaitMessage("observed"); + + if (b.url.startsWith("http://c.")) { + [c, b] = [b, c]; + } + + ok(b.url.startsWith("http://b."), "Observed resource from b.example.com"); + Assert.greater(b.time, 0, "connectionEnd available from b.example.com"); + equal(b.size, 46, "encodedBodySize available from b.example.com"); + + ok(c.url.startsWith("http://c."), "Observed resource from c.example.com"); + equal(c.time, 0, "connectionEnd == 0 from c.example.com"); + equal(c.size, 0, "encodedBodySize == 0 from c.example.com"); + + await extension.unload(); + await page.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_change.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_change.js new file mode 100644 index 0000000000..842994858e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_change.js @@ -0,0 +1,104 @@ +"use strict"; + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const server = createHttpServer({ hosts: ["example.com", "example.net"] }); +server.registerDirectory("/data/", do_get_file("data")); + +const HOSTS = ["http://example.com/*", "http://example.net/*"]; + +const { ExtensionPermissions } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" +); + +function grantOptional({ extension: ext }, origins) { + return ExtensionPermissions.add(ext.id, { origins, permissions: [] }, ext); +} + +function revokeOptional({ extension: ext }, origins) { + return ExtensionPermissions.remove(ext.id, { origins, permissions: [] }, ext); +} + +function makeExtension(id, content_scripts) { + return ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + + browser_specific_settings: { gecko: { id } }, + content_scripts, + + permissions: ["scripting"], + host_permissions: HOSTS, + }, + files: { + "cs.js"() { + browser.test.log(`${browser.runtime.id} script on ${location.host}`); + browser.test.sendMessage(`${browser.runtime.id}_on_${location.host}`); + }, + }, + background() { + browser.test.onMessage.addListener(async (msg, origins) => { + browser.test.log(`${browser.runtime.id} registering content scripts`); + await browser.scripting.registerContentScripts([ + { + id: "cs1", + persistAcrossSessions: false, + matches: origins, + js: ["cs.js"], + }, + ]); + browser.test.sendMessage("done"); + }); + }, + }); +} + +// Test that content scripts in MV3 enforce origin permissions. +// Test granted optional permissions are available in newly spawned processes. +add_task(async function test_contentscript_mv3_permissions() { + // Alpha lists content scripts in the manifest. + let alpha = makeExtension("alpha@test", [{ matches: HOSTS, js: ["cs.js"] }]); + let beta = makeExtension("beta@test"); + + await grantOptional(alpha, HOSTS); + await grantOptional(beta, ["http://example.net/*"]); + info("Granted initial permissions for both."); + + await alpha.startup(); + await beta.startup(); + + // Beta registers same content scripts using the scripting api. + beta.sendMessage("register", HOSTS); + await beta.awaitMessage("done"); + + // Only Alpha has origin permissions for example.com. + { + let page = await ExtensionTestUtils.loadContentPage( + `http://example.com/data/file_sample.html` + ); + info("Loaded a page from example.com."); + + await alpha.awaitMessage("alpha@test_on_example.com"); + info("Got a message from alpha@test on example.com."); + await page.close(); + } + + await revokeOptional(alpha, ["http://example.net/*"]); + info("Revoked example.net permissions from Alpha."); + + // Now only Beta has origin permissions for example.net. + { + let page = await ExtensionTestUtils.loadContentPage( + `http://example.net/data/file_sample.html` + ); + info("Loaded a page from example.net."); + + await beta.awaitMessage("beta@test_on_example.net"); + info("Got a message from beta@test on example.net."); + await page.close(); + } + + info("Done, unloading Alpha and Beta."); + await beta.unload(); + await alpha.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_fetch.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_fetch.js new file mode 100644 index 0000000000..f9c1b360a0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_fetch.js @@ -0,0 +1,87 @@ +"use strict"; + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const server = createHttpServer({ hosts: ["example.com", "example.net"] }); +server.registerDirectory("/data/", do_get_file("data")); + +const { ExtensionPermissions } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" +); + +function grantOptional({ extension: ext }, origins) { + return ExtensionPermissions.add(ext.id, { origins, permissions: [] }, ext); +} + +function revokeOptional({ extension: ext }, origins) { + return ExtensionPermissions.remove(ext.id, { origins, permissions: [] }, ext); +} + +// Test granted optional permissions work with XHR/fetch in new processes. +add_task( + { + pref_set: [["dom.ipc.keepProcessesAlive.extension", 0]], + }, + async function test_fetch_origin_permissions_change() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + host_permissions: ["http://example.com/*"], + optional_permissions: ["http://example.net/*"], + }, + files: { + "page.js"() { + fetch("http://example.net/data/file_sample.html") + .then(req => req.text()) + .then(text => browser.test.sendMessage("done", { text })) + .catch(e => browser.test.sendMessage("done", { error: e.message })); + }, + "page.html": `<!DOCTYPE html><meta charset="utf-8"><script src="page.js"></script>`, + }, + }); + + await extension.startup(); + + let osPid; + { + // Grant permissions before extension process exists. + await grantOptional(extension, ["http://example.net/*"]); + + let page = await ExtensionTestUtils.loadContentPage( + extension.extension.baseURI.resolve("page.html") + ); + + let { text } = await extension.awaitMessage("done"); + ok(text.includes("Sample text"), "Can read from granted optional host."); + + osPid = page.browsingContext.currentWindowGlobal.osPid; + await page.close(); + } + + // Release the extension process so that next part starts a new one. + Services.ppmm.releaseCachedProcesses(); + + { + // Revoke permissions and confirm fetch fails. + await revokeOptional(extension, ["http://example.net/*"]); + + let page = await ExtensionTestUtils.loadContentPage( + extension.extension.baseURI.resolve("page.html") + ); + + let { error } = await extension.awaitMessage("done"); + ok(error.includes("NetworkError"), `Expected error: ${error}`); + + if (WebExtensionPolicy.useRemoteWebExtensions) { + notEqual( + osPid, + page.browsingContext.currentWindowGlobal.osPid, + "Second part of the test used a new process." + ); + } + + await page.close(); + } + + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_restrictSchemes.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_restrictSchemes.js new file mode 100644 index 0000000000..d775bb2cfb --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_restrictSchemes.js @@ -0,0 +1,149 @@ +"use strict"; + +function makeExtension({ id, isPrivileged, withScriptingAPI = false }) { + let permissions = []; + let content_scripts = []; + let background = () => { + browser.test.sendMessage("background-ready"); + }; + + if (isPrivileged) { + permissions.push("mozillaAddons"); + } + + if (withScriptingAPI) { + permissions.push("scripting"); + // When we don't use a content script registered via the manifest, we + // should add the origin as a permission. + permissions.push("resource://foo/file_sample.html"); + + // Redefine background script to dynamically register the content script. + if (isPrivileged) { + background = async () => { + await browser.scripting.registerContentScripts([ + { + id: "content_script", + js: ["content_script.js"], + matches: ["resource://foo/file_sample.html"], + persistAcrossSessions: false, + runAt: "document_start", + }, + ]); + + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(1, scripts.length, "expected 1 script"); + + browser.test.sendMessage("background-ready"); + }; + } else { + background = async () => { + await browser.test.assertRejects( + browser.scripting.registerContentScripts([ + { + id: "content_script", + js: ["content_script.js"], + matches: ["resource://foo/file_sample.html"], + persistAcrossSessions: false, + runAt: "document_start", + }, + ]), + /Invalid url pattern: resource:/, + "got expected error" + ); + + browser.test.sendMessage("background-ready"); + }; + } + } else { + content_scripts.push({ + js: ["content_script.js"], + matches: ["resource://foo/file_sample.html"], + run_at: "document_start", + }); + } + + return ExtensionTestUtils.loadExtension({ + isPrivileged, + + manifest: { + manifest_version: 2, + browser_specific_settings: { gecko: { id } }, + content_scripts, + permissions, + }, + + background, + + files: { + "content_script.js"() { + browser.test.assertEq( + "resource://foo/file_sample.html", + document.documentURI, + `Loaded content script into the correct document (extension: ${browser.runtime.id})` + ); + browser.test.sendMessage(`content-script-${browser.runtime.id}`); + }, + }, + }); +} + +const verifyRestrictSchemes = async ({ withScriptingAPI }) => { + let resProto = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + resProto.setSubstitutionWithFlags( + "foo", + Services.io.newFileURI(do_get_file("data")), + resProto.ALLOW_CONTENT_ACCESS + ); + + let unprivileged = makeExtension({ + id: "unprivileged@tests.mozilla.org", + isPrivileged: false, + withScriptingAPI, + }); + let privileged = makeExtension({ + id: "privileged@tests.mozilla.org", + isPrivileged: true, + withScriptingAPI, + }); + + await unprivileged.startup(); + await unprivileged.awaitMessage("background-ready"); + + await privileged.startup(); + await privileged.awaitMessage("background-ready"); + + unprivileged.onMessage( + "content-script-unprivileged@tests.mozilla.org", + () => { + ok( + false, + "Unprivileged extension executed content script on resource URL" + ); + } + ); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `resource://foo/file_sample.html` + ); + + await privileged.awaitMessage("content-script-privileged@tests.mozilla.org"); + + await contentPage.close(); + + await privileged.unload(); + await unprivileged.unload(); +}; + +// Bug 1780507: this only works with MV2 currently because MV3's optional +// permission mechanism lacks `restrictSchemes` flags. +add_task(async function test_contentscript_restrictSchemes_mv2() { + await verifyRestrictSchemes({ withScriptingAPI: false }); +}); + +// Bug 1780507: this only works with MV2 currently because MV3's optional +// permission mechanism lacks `restrictSchemes` flags. +add_task(async function test_contentscript_restrictSchemes_scripting_mv2() { + await verifyRestrictSchemes({ withScriptingAPI: true }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_scriptCreated.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_scriptCreated.js new file mode 100644 index 0000000000..2f10f8f252 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_scriptCreated.js @@ -0,0 +1,61 @@ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +// Test that document_start content scripts don't block script-created +// parsers. +add_task(async function test_contentscript_scriptCreated() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_document_write.html"], + js: ["content_script.js"], + run_at: "document_start", + match_about_blank: true, + all_frames: true, + }, + ], + }, + + files: { + "content_script.js": function () { + if (window === top) { + addEventListener( + "message", + msg => { + browser.test.assertEq( + "ok", + msg.data, + "document.write() succeeded" + ); + browser.test.sendMessage("content-script-done"); + }, + { once: true } + ); + } + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_document_write.html` + ); + + await extension.awaitMessage("content-script-done"); + + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_teardown.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_teardown.js new file mode 100644 index 0000000000..9ec72e6455 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_teardown.js @@ -0,0 +1,101 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_contentscript_reload_and_unload() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["contentscript.js"], + }, + ], + }, + + files: { + "contentscript.js"() { + browser.test.sendMessage("contentscript-run"); + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let events = []; + { + const { Management } = ChromeUtils.importESModule( + "resource://gre/modules/Extension.sys.mjs" + ); + let record = (type, extensionContext) => { + let eventType = type == "proxy-context-load" ? "load" : "unload"; + let url = extensionContext.uri.spec; + let extensionId = extensionContext.extension.id; + events.push({ eventType, url, extensionId }); + }; + + Management.on("proxy-context-load", record); + Management.on("proxy-context-unload", record); + registerCleanupFunction(() => { + Management.off("proxy-context-load", record); + Management.off("proxy-context-unload", record); + }); + } + + const tabUrl = "http://example.com/data/file_sample.html"; + let contentPage = await ExtensionTestUtils.loadContentPage(tabUrl); + + await extension.awaitMessage("contentscript-run"); + + let contextEvents = events.splice(0); + equal( + contextEvents.length, + 1, + "ExtensionContext state change after loading a content script" + ); + equal( + contextEvents[0].eventType, + "load", + "Create ExtensionContext for content script" + ); + equal(contextEvents[0].url, tabUrl, "ExtensionContext URL = page"); + + await contentPage.spawn([], () => { + this.content.location.reload(); + }); + await extension.awaitMessage("contentscript-run"); + + contextEvents = events.splice(0); + equal( + contextEvents.length, + 2, + "ExtensionContext state changes after reloading a content script" + ); + equal(contextEvents[0].eventType, "unload", "Unload old ExtensionContext"); + equal(contextEvents[0].url, tabUrl, "ExtensionContext URL = page"); + equal( + contextEvents[1].eventType, + "load", + "Create new ExtensionContext for content script" + ); + equal(contextEvents[1].url, tabUrl, "ExtensionContext URL = page"); + + await contentPage.close(); + + contextEvents = events.splice(0); + equal( + contextEvents.length, + 1, + "ExtensionContext state change after unloading a content script" + ); + equal( + contextEvents[0].eventType, + "unload", + "Unload ExtensionContext after closing the tab with the content script" + ); + equal(contextEvents[0].url, tabUrl, "ExtensionContext URL = page"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js new file mode 100644 index 0000000000..3b8721ad8d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js @@ -0,0 +1,1385 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/** + * Tests that various types of inline content elements initiate requests + * with the triggering pringipal of the caller that requested the load, + * and that the correct security policies are applied to the resulting + * loads. + */ + +// Make sure media pre-loading is enabled on Android so that our <audio> and +// <video> elements trigger the expected requests. +Services.prefs.setIntPref("media.autoplay.default", Ci.nsIAutoplay.ALLOWED); +Services.prefs.setIntPref("media.preload.default", 3); + +// Increase the length of the code samples included in CSP reports so that we +// can correctly validate them. +Services.prefs.setIntPref( + "security.csp.reporting.script-sample.max-length", + 4096 +); + +// Do not limit the number of CSP reports. +Services.prefs.setIntPref("security.csp.reporting.limit.count", 0); + +// Do not trunacate the blocked-uri in CSP reports for frame navigations. +Services.prefs.setBoolPref( + "security.csp.truncate_blocked_uri_for_frame_navigations", + false +); + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +const server = createHttpServer({ + hosts: ["example.com", "csplog.example.net"], +}); + +server.registerDirectory("/data/", do_get_file("data")); + +var gContentSecurityPolicy = null; + +const BASE_URL = `http://example.com`; +const CSP_REPORT_PATH = "/csp-report.sjs"; + +/** + * Registers a static HTML document with the given content at the given + * path in our test HTTP server. + * + * @param {string} path + * @param {string} content + */ +function registerStaticPage(path, content) { + server.registerPathHandler(path, (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html"); + if (gContentSecurityPolicy) { + response.setHeader("Content-Security-Policy", gContentSecurityPolicy); + } + response.write(content); + }); +} + +/** + * A set of tags which are automatically closed in HTML documents, and + * do not require an explicit closing tag. + */ +const AUTOCLOSE_TAGS = new Set(["img", "input", "link", "source"]); + +/** + * An object describing the elements to create for a specific test. + * + * @typedef {object} ElementTestCase + * @property {Array} element + * A recursive array, describing the element to create, in the + * following format: + * + * ["tagname", {attr: "attrValue"}, + * ["child-tagname", {attr: "value"}], + * ...] + * + * For each test, a DOM tree will be created with this structure. + * A source attribute, with the name `test.srcAttr` and a value + * based on the values of `test.src` and `opts`, will be added to + * the first leaf node encountered. + * @property {string} src + * The relative URL to use as the source of the element. Each + * load of this URL will have a separate set of query parameters + * appended to it, based on the values in `opts`. + * @property {string} [srcAttr = "src"] + * The attribute in which to store the element's source URL. + * @property {boolean} [liveSrc = false] + * If true, changing the source attribute after the element has + * been inserted into the document is expected to trigger a new + * load, and that configuration will be tested. + */ + +/** + * Options for this specific configuration of an element test. + * + * @typedef {object} ElementTestOptions + * @property {string} origin + * The origin with which the content is expected to load. This + * may be one of "page", "contentScript", or "extension". The actual load + * of the URL will be tested against the computed origin strings for + * those two contexts. + * @property {string} source + * An arbitrary string which uniquely identifies the source of + * the load. For instance, each of these should have separate + * origin strings: + * + * - An element present in the initial page HTML. + * - An element injected by a page script belonging to web + * content. + * - An element injected by an extension content script. + */ + +/** + * Data describing a test element, which can be used to create a + * corresponding DOM tree. + * + * @typedef {object} ElementData + * @property {string} tagName + * The tag name for the element. + * @property {object} attrs + * A property containing key-value pairs for each of the + * attribute's elements. + * @property {Array<ElementData>} children + * A possibly empty array of element data for child elements. + */ + +/** + * Returns data necessary to create test elements for the given test, + * with the given options. + * + * @param {ElementTestCase} test + * An object describing the elements to create for a specific + * test. This element will be created under various + * circumstances, as described by `opts`. + * @param {ElementTestOptions} opts + * Options for this specific configuration of the test. + * @returns {ElementData} + */ +function getElementData(test, opts) { + let baseURL = typeof BASE_URL !== "undefined" ? BASE_URL : location.href; + + let { srcAttr, src } = test; + + // Absolutify the URL, so it passes sanity checks that ignore + // triggering principals for relative URLs. + src = new URL( + src + + `?origin=${encodeURIComponent(opts.origin)}&source=${encodeURIComponent( + opts.source + )}`, + baseURL + ).href; + + let haveSrc = false; + function rec(element) { + let [tagName, attrs, ...children] = element; + + if (children.length) { + children = children.map(rec); + } else if (!haveSrc) { + attrs = Object.assign({ [srcAttr]: src }, attrs); + haveSrc = true; + } + + return { tagName, attrs, children }; + } + return rec(test.element); +} + +/** + * The result type of the {@see createElement} function. + * + * @typedef {object} CreateElementResult + * @property {Element} elem + * The root element of the created DOM tree. + * @property {Element} srcElem + * The element in the tree to which the source attribute must be + * added. + * @property {string} src + * The value of the source element. + */ + +/** + * Creates a DOM tree for a given test, in a given configuration, as + * understood by {@see getElementData}, but without the `test.srcAttr` + * attribute having been set. The caller must set the value of that + * attribute to the returned `src` value. + * + * There are many different ways most source values can be set + * (DOM attribute, DOM property, ...) and many different contexts + * (content script verses page script). Each test should be run with as + * many variants of these as possible. + * + * @param {ElementTestCase} test + * A test object, as passed to {@see getElementData}. + * @param {ElementTestOptions} opts + * An options object, as passed to {@see getElementData}. + * @returns {CreateElementResult} + */ +function createElement(test, opts) { + let srcElem; + let src; + + function rec({ tagName, attrs, children }) { + let elem = document.createElement(tagName); + + for (let [key, val] of Object.entries(attrs)) { + if (key === test.srcAttr) { + srcElem = elem; + src = val; + } else { + elem.setAttribute(key, val); + } + } + for (let child of children) { + elem.appendChild(rec(child)); + } + return elem; + } + let elem = rec(getElementData(test, opts)); + + return { elem, srcElem, src }; +} + +/** + * Escapes any occurrences of &, ", < or > with XML entities. + * + * @param {string} str + * The string to escape. + * @returns {string} The escaped string. + */ +function escapeXML(str) { + let replacements = { + "&": "&", + '"': """, + "'": "'", + "<": "<", + ">": ">", + }; + return String(str).replace(/[&"''<>]/g, m => replacements[m]); +} + +/** + * A tagged template function which escapes any XML metacharacters in + * interpolated values. + * + * @param {Array<string>} strings + * An array of literal strings extracted from the templates. + * @param {Array} values + * An array of interpolated values extracted from the template. + * @returns {string} + * The result of the escaped values interpolated with the literal + * strings. + */ +function escaped(strings, ...values) { + let result = []; + + for (let [i, string] of strings.entries()) { + result.push(string); + if (i < values.length) { + result.push(escapeXML(values[i])); + } + } + + return result.join(""); +} + +/** + * Converts the given test data, as accepted by {@see getElementData}, + * to an HTML representation. + * + * @param {ElementTestCase} test + * A test object, as passed to {@see getElementData}. + * @param {ElementTestOptions} opts + * An options object, as passed to {@see getElementData}. + * @returns {string} + */ +function toHTML(test, opts) { + function rec({ tagName, attrs, children }) { + let html = [`<${tagName}`]; + for (let [key, val] of Object.entries(attrs)) { + html.push(escaped` ${key}="${val}"`); + } + + html.push(">"); + if (!AUTOCLOSE_TAGS.has(tagName)) { + for (let child of children) { + html.push(rec(child)); + } + + html.push(`</${tagName}>`); + } + return html.join(""); + } + return rec(getElementData(test, opts)); +} + +/** + * Injects various permutations of inline CSS into a content page, from both + * extension content script and content page contexts, and sends a "css-sources" + * message to the test harness describing the injected content for verification. + */ +function testInlineCSS() { + let urls = []; + let sources = []; + + /** + * Constructs the URL of an image to be loaded by the given origin, and + * returns a CSS url() expression for it. + * + * The `name` parameter is an arbitrary name which should describe how the URL + * is loaded. The `opts` object may contain arbitrary properties which + * describe the load. Currently, only `inline` is recognized, and indicates + * that the URL is being used in an inline stylesheet which may be blocked by + * CSP. + * + * The URL and its parameters are recorded, and sent to the parent process for + * verification. + * + * @param {string} origin + * @param {string} name + * @param {object} [opts] + * @returns {string} + */ + let i = 0; + let url = (origin, name, opts = {}) => { + let source = `${origin}-${name}`; + + let { href } = new URL( + `css-${i++}.png?origin=${encodeURIComponent( + origin + )}&source=${encodeURIComponent(source)}`, + location.href + ); + + urls.push(Object.assign({}, opts, { href, origin, source })); + return `url("${href}")`; + }; + + /** + * Registers the given inline CSS source as being loaded by the given origin, + * and returns that CSS text. + * + * @param {string} origin + * @param {string} css + * @returns {string} + */ + let source = (origin, css) => { + sources.push({ origin, css }); + return css; + }; + + /** + * Saves the given function to be run after a short delay, just before sending + * the list of loaded sources to the parent process. + */ + let laters = []; + let later = fn => { + laters.push(fn); + }; + + // Note: When accessing an element through `wrappedJSObject`, the operations + // occur in the content page context, using the content subject principal. + // When accessing it through X-ray wrappers, they happen in the content script + // context, using its subject principal. + + { + let li = document.createElement("li"); + li.setAttribute( + "style", + source( + "contentScript", + `background: ${url("contentScript", "li.style-first")}` + ) + ); + li.style.wrappedJSObject.listStyleImage = url( + "page", + "li.style.listStyleImage-second" + ); + document.body.appendChild(li); + } + + { + let li = document.createElement("li"); + li.wrappedJSObject.setAttribute( + "style", + source( + "page", + `background: ${url("page", "li.style-first", { inline: true })}` + ) + ); + li.style.listStyleImage = url( + "contentScript", + "li.style.listStyleImage-second" + ); + document.body.appendChild(li); + } + + { + let li = document.createElement("li"); + document.body.appendChild(li); + li.setAttribute( + "style", + source( + "contentScript", + `background: ${url("contentScript", "li.style-first")}` + ) + ); + later(() => + li.wrappedJSObject.setAttribute( + "style", + source( + "page", + `background: ${url("page", "li.style-second", { inline: true })}` + ) + ) + ); + } + + { + let li = document.createElement("li"); + document.body.appendChild(li); + li.wrappedJSObject.setAttribute( + "style", + source( + "page", + `background: ${url("page", "li.style-first", { inline: true })}` + ) + ); + later(() => + li.setAttribute( + "style", + source( + "contentScript", + `background: ${url("contentScript", "li.style-second")}` + ) + ) + ); + } + + { + let li = document.createElement("li"); + document.body.appendChild(li); + li.style.cssText = source( + "contentScript", + `background: ${url("contentScript", "li.style.cssText-first")}` + ); + + // TODO: This inline style should be blocked, since our style-src does not + // include 'unsafe-eval', but that is currently unimplemented. + later(() => { + li.style.wrappedJSObject.cssText = `background: ${url( + "page", + "li.style.cssText-second" + )}`; + }); + } + + // Creates a new element, inserts it into the page, and returns its CSS selector. + let divNum = 0; + function getSelector() { + let div = document.createElement("div"); + div.id = `generated-div-${divNum++}`; + document.body.appendChild(div); + return `#${div.id}`; + } + + for (let prop of ["textContent", "innerHTML"]) { + // Test creating <style> element from the extension side and then replacing + // its contents from the content side. + { + let sel = getSelector(); + let style = document.createElement("style"); + style[prop] = source( + "extension", + `${sel} { background: ${url("extension", `style-${prop}-first`)}; }` + ); + document.head.appendChild(style); + + later(() => { + style.wrappedJSObject[prop] = source( + "page", + `${sel} { background: ${url("page", `style-${prop}-second`, { + inline: true, + })}; }` + ); + }); + } + + // Test creating <style> element from the extension side and then appending + // a text node to it. Regardless of whether the append happens from the + // content or extension side, this should cause the principal to be + // forgotten. + let testModifyAfterInject = (name, modifyFunc) => { + let sel = getSelector(); + let style = document.createElement("style"); + style[prop] = source( + "extension", + `${sel} { background: ${url( + "extension", + `style-${name}-${prop}-first` + )}; }` + ); + document.head.appendChild(style); + + later(() => { + modifyFunc( + style, + `${sel} { background: ${url("page", `style-${name}-${prop}-second`, { + inline: true, + })}; }` + ); + source("page", style.textContent); + }); + }; + + testModifyAfterInject("appendChild", (style, css) => { + style.appendChild(document.createTextNode(css)); + }); + + // Test creating <style> element from the extension side and then appending + // to it using insertAdjacentHTML, with the same rules as above. + testModifyAfterInject("insertAdjacentHTML", (style, css) => { + style.insertAdjacentHTML("beforeend", css); + }); + + // And again using insertAdjacentText. + testModifyAfterInject("insertAdjacentText", (style, css) => { + style.insertAdjacentText("beforeend", css); + }); + + // Test creating a style element and then accessing its CSSStyleSheet object. + { + let sel = getSelector(); + let style = document.createElement("style"); + style[prop] = source( + "extension", + `${sel} { background: ${url("extension", `style-${prop}-sheet`)}; }` + ); + document.head.appendChild(style); + + browser.test.assertThrows( + () => style.sheet.wrappedJSObject.cssRules, + /Not allowed to access cross-origin stylesheet/, + "Page content should not be able to access extension-generated CSS rules" + ); + + style.sheet.insertRule( + source( + "extension", + `${sel} { border-image: ${url( + "extension", + `style-${prop}-sheet-insertRule` + )}; }` + ) + ); + } + } + + setTimeout(() => { + for (let fn of laters) { + fn(); + } + browser.test.sendMessage("css-sources", { urls, sources }); + }); +} + +/** + * A function which will be stringified, and run both as a page script + * and an extension content script, to test element injection under + * various configurations. + * + * @param {Array<ElementTestCase>} tests + * A list of test objects, as understood by {@see getElementData}. + * @param {ElementTestOptions} baseOpts + * A base options object, as understood by {@see getElementData}, + * which represents the default values for injections under this + * context. + */ +function injectElements(tests, baseOpts) { + window.addEventListener( + "load", + () => { + if (typeof browser === "object") { + try { + testInlineCSS(); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + } + } + + // Basic smoke test to check that SVG images do not try to create a document + // with an expanded principal, which would cause a crash. + let img = document.createElement("img"); + img.src = "data:image/svg+xml,%3Csvg%2F%3E"; + document.body.appendChild(img); + + let rand = Math.random(); + + // Basic smoke test to check that we don't try to create stylesheets with an + // expanded principal, which would cause a crash when loading font sets. + let cssText = ` + @font-face { + font-family: "DoesNotExist${rand}"; + src: url("fonts/DoesNotExist.${rand}.woff") format("woff"); + font-weight: normal; + font-style: normal; + }`; + + let link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = "data:text/css;base64," + btoa(cssText); + document.head.appendChild(link); + + let style = document.createElement("style"); + style.textContent = cssText; + document.head.appendChild(style); + + let overrideOpts = opts => Object.assign({}, baseOpts, opts); + let opts = baseOpts; + + // Build the full element with setAttr, then inject. + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + srcElem.setAttribute(test.srcAttr, src); + document.body.appendChild(elem); + } + + // Build the full element with a property setter. + opts = overrideOpts({ source: `${baseOpts.source}-prop` }); + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + srcElem[test.srcAttr] = src; + document.body.appendChild(elem); + } + + // Build the element without the source attribute, inject, then set + // it. + opts = overrideOpts({ source: `${baseOpts.source}-attr-after-inject` }); + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + document.body.appendChild(elem); + srcElem.setAttribute(test.srcAttr, src); + } + + // Build the element without the source attribute, inject, then set + // the corresponding property. + opts = overrideOpts({ source: `${baseOpts.source}-prop-after-inject` }); + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + document.body.appendChild(elem); + srcElem[test.srcAttr] = src; + } + + // Build the element with a relative, rather than absolute, URL, and + // make sure it always has the page origin. + opts = overrideOpts({ + source: `${baseOpts.source}-relative-url`, + origin: "page", + }); + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + // Note: This assumes that the content page and the src URL are + // always at the server root. If that changes, the test will + // timeout waiting for matching requests. + src = src.replace(/.*\//, ""); + srcElem.setAttribute(test.srcAttr, src); + document.body.appendChild(elem); + } + + // If we're in an extension content script, do some additional checks. + if (typeof browser !== "undefined") { + // Build the element without the source attribute, inject, then + // have content set it. + opts = overrideOpts({ + source: `${baseOpts.source}-content-attr-after-inject`, + origin: "page", + }); + + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + + document.body.appendChild(elem); + window.wrappedJSObject.elem = srcElem; + window.wrappedJSObject.eval( + `elem.setAttribute(${JSON.stringify( + test.srcAttr + )}, ${JSON.stringify(src)})` + ); + } + + // Build the full element, then let content inject. + opts = overrideOpts({ + source: `${baseOpts.source}-content-inject-after-attr`, + }); + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + srcElem.setAttribute(test.srcAttr, src); + window.wrappedJSObject.elem = elem; + window.wrappedJSObject.eval(`document.body.appendChild(elem)`); + } + + // Build the element without the source attribute, let content set + // it, then inject. + opts = overrideOpts({ + source: `${baseOpts.source}-inject-after-content-attr`, + origin: "page", + }); + + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + window.wrappedJSObject.elem = srcElem; + window.wrappedJSObject.eval( + `elem.setAttribute(${JSON.stringify( + test.srcAttr + )}, ${JSON.stringify(src)})` + ); + document.body.appendChild(elem); + } + + // Build the element with a dummy source attribute, inject, then + // let content change it. + opts = overrideOpts({ + source: `${baseOpts.source}-content-change-after-inject`, + origin: "page", + }); + + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + srcElem.setAttribute(test.srcAttr, "meh.txt"); + document.body.appendChild(elem); + window.wrappedJSObject.elem = srcElem; + window.wrappedJSObject.eval( + `elem.setAttribute(${JSON.stringify( + test.srcAttr + )}, ${JSON.stringify(src)})` + ); + } + } + }, + { once: true } + ); +} + +/** + * Stringifies the {@see injectElements} function for use as a page or + * content script. + * + * @param {Array<ElementTestCase>} tests + * A list of test objects, as understood by {@see getElementData}. + * @param {ElementTestOptions} opts + * A base options object, as understood by {@see getElementData}, + * which represents the default values for injections under this + * context. + * @returns {string} + */ +function getInjectionScript(tests, opts) { + return ` + ${getElementData} + ${createElement} + ${testInlineCSS} + (${injectElements})(${JSON.stringify(tests)}, + ${JSON.stringify(opts)}); + `; +} + +/** + * Extracts the "origin" query parameter from the given URL, and returns it, + * along with the URL sans origin parameter. + * + * @param {string} origURL + * @returns {object} + * An object with `origin` and `baseURL` properties, containing the value + * or the URL's "origin" query parameter and the URL with that parameter + * removed, respectively. + */ +function getOriginBase(origURL) { + let url = new URL(origURL); + let origin = url.searchParams.get("origin"); + url.searchParams.delete("origin"); + + return { origin, baseURL: url.href }; +} + +/** + * An object containing sets of base URLs and CSS sources which are present in + * the test page, sorted based on how they should be treated by CSP. + * + * @typedef {object} RequestedURLs + * @property {Set<string>} expectedURLs + * A set of URLs which should be successfully requested by the content + * page. + * @property {Set<string>} forbiddenURLs + * A set of URLs which are present in the content page, but should never + * generate requests. + * @property {Set<string>} blockedURLs + * A set of URLs which are present in the content page, and should be + * blocked by CSP, and reported in a CSP report. + * @property {Set<string>} blockedSources + * A set of inline CSS sources which should be blocked by CSP, and + * reported in a CSP report. + */ + +/** + * Computes a list of expected and forbidden base URLs for the given + * sets of tests and sources. The base URL is the complete request URL + * with the `origin` query parameter removed. + * + * @param {Array<ElementTestCase>} tests + * A list of tests, as understood by {@see getElementData}. + * @param {Object<string, object>} expectedSources + * A set of sources for which each of the above tests is expected + * to generate one request, if each of the properties in the + * value object matches the value of the same property in the + * test object. + * @param {Object<string, object>} [forbiddenSources = {}] + * A set of sources for which requests should never be sent. Any + * matching requests from these sources will cause the test to + * fail. + * @returns {RequestedURLs} + */ +function computeBaseURLs(tests, expectedSources, forbiddenSources = {}) { + let expectedURLs = new Set(); + let forbiddenURLs = new Set(); + + function* iterSources(test, sources) { + for (let [source, attrs] of Object.entries(sources)) { + // if a source defines attributes (e.g. liveSrc in PAGE_SOURCES etc.) then all + // attributes in the source must be matched by the test (see const TEST). + if (Object.keys(attrs).every(attr => attrs[attr] === test[attr])) { + yield `${BASE_URL}/${test.src}?source=${source}`; + } + } + } + + for (let test of tests) { + for (let urlPrefix of iterSources(test, expectedSources)) { + expectedURLs.add(urlPrefix); + } + for (let urlPrefix of iterSources(test, forbiddenSources)) { + forbiddenURLs.add(urlPrefix); + } + } + + return { expectedURLs, forbiddenURLs, blockedURLs: forbiddenURLs }; +} + +/** + * @typedef InjectedUrl + * A URL present in styles injected by the content script. + * @type {object} + * @property {string} origin + * The origin of the URL, one of "page", "contentScript", or "extension". + * @param {string} href + * The URL string. + * @param {boolean} inline + * If true, the URL is present in an inline stylesheet, which may be + * blocked by CSP prior to parsing, depending on its origin. + */ + +/** + * @typedef InjectedSource + * An inline CSS source injected by the content script. + * @type {object} + * @param {string} origin + * The origin of the CSS, one of "page", "contentScript", or "extension". + * @param {string} css + * The CSS source text. + */ + +/** + * Generates a set of expected and forbidden URLs and sources based on the CSS + * injected by our content script. + * + * @param {object} message + * The "css-sources" message sent by the content script, containing lists + * of CSS sources injected into the page. + * @param {Array<InjectedUrl>} message.urls + * A list of URLs present in styles injected by the content script. + * @param {Array<InjectedSource>} message.sources + * A list of inline CSS sources injected by the content script. + * @param {boolean} [cspEnabled = false] + * If true, a strict CSP is enabled for this page, and inline page + * sources should be blocked. URLs present in these sources will not be + * expected to generate a CSP report, the inline sources themselves will. + * @param {boolean} [contentCspEnabled = false] + * @returns {RequestedURLs} + */ +function computeExpectedForbiddenURLs( + { urls, sources }, + cspEnabled = false, + contentCspEnabled = false +) { + let expectedURLs = new Set(); + let forbiddenURLs = new Set(); + let blockedURLs = new Set(); + let blockedSources = new Set(); + + for (let { href, origin, inline } of urls) { + let { baseURL } = getOriginBase(href); + if (cspEnabled && origin === "page") { + if (inline) { + forbiddenURLs.add(baseURL); + } else { + blockedURLs.add(baseURL); + } + } else if (contentCspEnabled && origin === "contentScript") { + if (inline) { + forbiddenURLs.add(baseURL); + } + } else { + expectedURLs.add(baseURL); + } + } + + if (cspEnabled) { + for (let { origin, css } of sources) { + if (origin === "page") { + blockedSources.add(css); + } + } + } + + return { expectedURLs, forbiddenURLs, blockedURLs, blockedSources }; +} + +/** + * Awaits the content loads for each of the given expected base URLs, + * and checks that their origin strings are as expected. Triggers a test + * failure if any of the given forbidden URLs is requested. + * + * @param {Promise<object>} urlsPromise + * A promise which resolves to an object containing expected and + * forbidden URL sets, as returned by {@see computeBaseURLs}. + * @param {Object<string, string>} origins + * A mapping of origin parameters as they appear in URL query + * strings to the origin strings returned by corresponding + * principals. These values are used to test requests against + * their expected origins. + * @returns {Promise} + * A promise which resolves when all requests have been + * processed. + */ +function awaitLoads(urlsPromise, origins) { + return new Promise(resolve => { + let expectedURLs, forbiddenURLs; + let queuedChannels = []; + + let observer; + + function checkChannel(channel) { + let origURL = channel.URI.spec; + let { baseURL, origin } = getOriginBase(origURL); + + if (forbiddenURLs.has(baseURL)) { + ok(false, `Got unexpected request for forbidden URL ${origURL}`); + } + + if (expectedURLs.has(baseURL)) { + expectedURLs.delete(baseURL); + + equal( + channel.loadInfo.triggeringPrincipal.origin, + origins[origin], + `Got expected origin for URL ${origURL}` + ); + + if (!expectedURLs.size) { + Services.obs.removeObserver(observer, "http-on-modify-request"); + info("Got all expected requests"); + resolve(); + } + } + } + + urlsPromise.then(urls => { + expectedURLs = new Set(urls.expectedURLs); + forbiddenURLs = new Set([...urls.forbiddenURLs, ...urls.blockedURLs]); + + for (let channel of queuedChannels.splice(0)) { + checkChannel(channel.QueryInterface(Ci.nsIChannel)); + } + }); + + observer = (channel, topic, data) => { + if (expectedURLs) { + checkChannel(channel.QueryInterface(Ci.nsIChannel)); + } else { + queuedChannels.push(channel); + } + }; + Services.obs.addObserver(observer, "http-on-modify-request"); + }); +} + +function readUTF8InputStream(stream) { + let buffer = NetUtil.readInputStream(stream, stream.available()); + return new TextDecoder().decode(buffer); +} + +/** + * Awaits CSP reports for each of the given forbidden base URLs. + * Triggers a test failure if any of the given expected URLs triggers a + * report. + * + * @param {Promise<object>} urlsPromise + * A promise which resolves to an object containing expected and + * forbidden URL sets, as returned by {@see computeBaseURLs}. + * @returns {Promise} + * A promise which resolves when all requests have been + * processed. + */ +function awaitCSP(urlsPromise) { + return new Promise(resolve => { + let expectedURLs, blockedURLs, blockedSources; + let queuedRequests = []; + + function checkRequest(request) { + let body = JSON.parse(readUTF8InputStream(request.bodyInputStream)); + let report = body["csp-report"]; + + let origURL = report["blocked-uri"]; + if (origURL !== "inline" && origURL !== "") { + let { baseURL } = getOriginBase(origURL); + + if (expectedURLs.has(baseURL)) { + ok(false, `Got unexpected CSP report for allowed URL ${origURL}`); + } + + if (blockedURLs.has(baseURL)) { + blockedURLs.delete(baseURL); + + ok(true, `Got CSP report for forbidden URL ${origURL}`); + } + } + + let source = report["script-sample"]; + if (source) { + if (blockedSources.has(source)) { + blockedSources.delete(source); + + ok( + true, + `Got CSP report for forbidden inline source ${JSON.stringify( + source + )}` + ); + } + } + + if (!blockedURLs.size && !blockedSources.size) { + ok(true, "Got all expected CSP reports"); + resolve(); + } + } + + urlsPromise.then(urls => { + blockedURLs = new Set(urls.blockedURLs); + blockedSources = new Set(urls.blockedSources); + ({ expectedURLs } = urls); + + for (let request of queuedRequests.splice(0)) { + checkRequest(request); + } + }); + + server.registerPathHandler(CSP_REPORT_PATH, (request, response) => { + response.setStatusLine(request.httpVersion, 204, "No Content"); + + if (expectedURLs) { + checkRequest(request); + } else { + queuedRequests.push(request); + } + }); + }); +} + +/** + * A list of tests to run in each context, as understood by + * {@see getElementData}. + */ +const TESTS = [ + { + element: ["audio", {}], + src: "audio.webm", + }, + { + element: ["audio", {}, ["source", {}]], + src: "audio-source.webm", + }, + // TODO: <frame> element, which requires a frameset document. + { + // the blocked-uri for frame-navigations is the pre-path URI. For the + // purpose of this test we do not strip the blocked-uri by setting the + // preference 'truncate_blocked_uri_for_frame_navigations' + element: ["iframe", {}], + src: "iframe.html", + }, + { + element: ["img", {}], + src: "img.png", + }, + { + element: ["img", {}], + src: "imgset.png", + srcAttr: "srcset", + }, + { + element: ["input", { type: "image" }], + src: "input.png", + }, + { + element: ["link", { rel: "stylesheet" }], + src: "link.css", + srcAttr: "href", + }, + { + element: ["picture", {}, ["source", {}], ["img", {}]], + src: "picture.png", + srcAttr: "srcset", + }, + { + element: ["script", {}], + src: "script.js", + liveSrc: false, + }, + { + element: ["video", {}], + src: "video.webm", + }, + { + element: ["video", {}, ["source", {}]], + src: "video-source.webm", + }, +]; + +for (let test of TESTS) { + if (!test.srcAttr) { + test.srcAttr = "src"; + } + if (!("liveSrc" in test)) { + test.liveSrc = true; + } +} + +/** + * A set of sources for which each of the above tests is expected to + * generate one request, if each of the properties in the value object + * matches the value of the same property in the test object. + */ +// Sources which load with the page context. +const PAGE_SOURCES = { + "contentScript-content-attr-after-inject": { liveSrc: true }, + "contentScript-content-change-after-inject": { liveSrc: true }, + "contentScript-inject-after-content-attr": {}, + "contentScript-relative-url": {}, + pageHTML: {}, + pageScript: {}, + "pageScript-attr-after-inject": {}, + "pageScript-prop": {}, + "pageScript-prop-after-inject": {}, + "pageScript-relative-url": {}, +}; +// Sources which load with the extension context. +const EXTENSION_SOURCES = { + contentScript: {}, + "contentScript-attr-after-inject": { liveSrc: true }, + "contentScript-content-inject-after-attr": {}, + "contentScript-prop": {}, + "contentScript-prop-after-inject": {}, +}; +// When our default content script CSP is applied, only +// liveSrc: true are loading. IOW, the "script" test above +// will fail. +const EXTENSION_SOURCES_CONTENT_CSP = { + contentScript: { liveSrc: true }, + "contentScript-attr-after-inject": { liveSrc: true }, + "contentScript-content-inject-after-attr": { liveSrc: true }, + "contentScript-prop": { liveSrc: true }, + "contentScript-prop-after-inject": { liveSrc: true }, +}; +// All sources. +const SOURCES = Object.assign({}, PAGE_SOURCES, EXTENSION_SOURCES); + +registerStaticPage( + "/page.html", + `<!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="UTF-8"> + <title></title> + <script nonce="deadbeef"> + ${getInjectionScript(TESTS, { source: "pageScript", origin: "page" })} + </script> + </head> + <body> + ${TESTS.map(test => + toHTML(test, { source: "pageHTML", origin: "page" }) + ).join("\n ")} + </body> + </html>` +); + +function catchViolation() { + // eslint-disable-next-line mozilla/balanced-listeners + document.addEventListener("securitypolicyviolation", e => { + browser.test.assertTrue( + e.documentURI !== "moz-extension", + `securitypolicyviolation: ${e.violatedDirective} ${e.documentURI}` + ); + }); +} + +const EXTENSION_DATA = { + manifest: { + content_scripts: [ + { + matches: ["http://*/page.html"], + run_at: "document_start", + js: ["violation.js", "content_script.js"], + }, + ], + }, + + files: { + "violation.js": catchViolation, + "content_script.js": getInjectionScript(TESTS, { + source: "contentScript", + origin: "contentScript", + }), + }, +}; + +const pageURL = `${BASE_URL}/page.html`; +const pageURI = Services.io.newURI(pageURL); + +// Merges the sets of expected URL and source data returned by separate +// computedExpectedForbiddenURLs and computedBaseURLs calls. +function mergeSources(a, b) { + return { + expectedURLs: new Set([...a.expectedURLs, ...b.expectedURLs]), + forbiddenURLs: new Set([...a.forbiddenURLs, ...b.forbiddenURLs]), + blockedURLs: new Set([...a.blockedURLs, ...b.blockedURLs]), + blockedSources: a.blockedSources || b.blockedSources, + }; +} + +// Returns a set of origin strings for the given extension and content page, for +// use in verifying request triggering principals. +function getOrigins(extension) { + return { + page: Services.scriptSecurityManager.createContentPrincipal(pageURI, {}) + .origin, + contentScript: Cu.getObjectPrincipal( + Cu.Sandbox([pageURL, extension.principal]) + ).origin, + extension: extension.principal.origin, + }; +} + +/** + * Tests that various types of inline content elements initiate requests + * with the triggering pringipal of the caller that requested the load. + */ +add_task(async function test_contentscript_triggeringPrincipals() { + let extension = ExtensionTestUtils.loadExtension(EXTENSION_DATA); + await extension.startup(); + + let urlsPromise = extension.awaitMessage("css-sources").then(msg => { + return mergeSources( + computeExpectedForbiddenURLs(msg), + computeBaseURLs(TESTS, SOURCES) + ); + }); + + let origins = getOrigins(extension.extension); + let finished = awaitLoads(urlsPromise, origins); + + let contentPage = await ExtensionTestUtils.loadContentPage(pageURL); + + await finished; + + await extension.unload(); + await contentPage.close(); + + clearCache(); +}); + +/** + * Tests that the correct CSP is applied to loads of inline content + * depending on whether the load was initiated by an extension or the + * content page. + */ +add_task(async function test_contentscript_csp() { + // TODO bug 1408193: We currently don't get the full set of CSP reports when + // running in network scheduling chaos mode. It's not entirely clear why. + let chaosMode = parseInt(Services.env.get("MOZ_CHAOSMODE"), 16); + let checkCSPReports = !(chaosMode === 0 || chaosMode & 0x02); + + gContentSecurityPolicy = `default-src 'none' 'report-sample'; script-src 'nonce-deadbeef' 'unsafe-eval' 'report-sample'; report-uri ${CSP_REPORT_PATH};`; + + let extension = ExtensionTestUtils.loadExtension(EXTENSION_DATA); + await extension.startup(); + + let urlsPromise = extension.awaitMessage("css-sources").then(msg => { + return mergeSources( + computeExpectedForbiddenURLs(msg, true), + computeBaseURLs(TESTS, EXTENSION_SOURCES, PAGE_SOURCES) + ); + }); + + let origins = getOrigins(extension.extension); + + let finished = Promise.all([ + awaitLoads(urlsPromise, origins), + checkCSPReports && awaitCSP(urlsPromise), + ]); + + let contentPage = await ExtensionTestUtils.loadContentPage(pageURL); + + await finished; + + await extension.unload(); + await contentPage.close(); +}); + +/** + * Tests that the correct CSP is applied to loads of inline content + * depending on whether the load was initiated by an extension or the + * content page. + */ +add_task(async function test_extension_contentscript_csp() { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + + // TODO bug 1408193: We currently don't get the full set of CSP reports when + // running in network scheduling chaos mode. It's not entirely clear why. + let chaosMode = parseInt(Services.env.get("MOZ_CHAOSMODE"), 16); + let checkCSPReports = !(chaosMode === 0 || chaosMode & 0x02); + + gContentSecurityPolicy = `default-src 'none' 'report-sample'; script-src 'nonce-deadbeef' 'unsafe-eval' 'report-sample'; report-uri ${CSP_REPORT_PATH};`; + + let data = { + ...EXTENSION_DATA, + manifest: { + ...EXTENSION_DATA.manifest, + manifest_version: 3, + host_permissions: ["http://example.com/*"], + granted_host_permissions: true, + }, + temporarilyInstalled: true, + }; + + let extension = ExtensionTestUtils.loadExtension(data); + await extension.startup(); + + let urlsPromise = extension.awaitMessage("css-sources").then(msg => { + return mergeSources( + computeExpectedForbiddenURLs(msg, true, true), + computeBaseURLs(TESTS, EXTENSION_SOURCES_CONTENT_CSP, PAGE_SOURCES) + ); + }); + + let origins = getOrigins(extension.extension); + + let finished = Promise.all([ + awaitLoads(urlsPromise, origins), + checkCSPReports && awaitCSP(urlsPromise), + ]); + + let contentPage = await ExtensionTestUtils.loadContentPage(pageURL); + + await finished; + + await extension.unload(); + await contentPage.close(); + Services.prefs.clearUserPref("extensions.manifestV3.enabled"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_unregister_during_loadContentScript.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_unregister_during_loadContentScript.js new file mode 100644 index 0000000000..9191f7633e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_unregister_during_loadContentScript.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function content_script_unregistered_during_loadContentScript() { + let content_scripts = []; + + for (let i = 0; i < 10; i++) { + content_scripts.push({ + matches: ["<all_urls>"], + js: ["dummy.js"], + run_at: "document_start", + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts, + }, + files: { + "dummy.js": function () { + browser.test.sendMessage("content-script-executed"); + }, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + info("Wait for all the content scripts to be executed"); + await Promise.all( + content_scripts.map(() => extension.awaitMessage("content-script-executed")) + ); + + const promiseDone = contentPage.legacySpawn([extension.id], extensionId => { + const { ExtensionProcessScript } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionProcessScript.sys.mjs" + ); + + return new Promise(resolve => { + // This recreates a scenario similar to Bug 1593240 and ensures that the + // related fix doesn't regress. Replacing loadContentScript with a + // function that unregisters all the content scripts make us sure that + // mutating the policy contentScripts doesn't trigger a crash due to + // the invalidation of the contentScripts iterator being used by the + // caller (ExtensionPolicyService::CheckContentScripts). + const { loadContentScript } = ExtensionProcessScript; + ExtensionProcessScript.loadContentScript = async (...args) => { + const policy = WebExtensionPolicy.getByID(extensionId); + let initial = policy.contentScripts.length; + let i = initial; + while (i) { + policy.unregisterContentScript(policy.contentScripts[--i]); + } + Services.tm.dispatchToMainThread(() => + resolve({ + initial, + final: policy.contentScripts.length, + }) + ); + // Call the real loadContentScript method. + return loadContentScript(...args); + }; + }); + }); + + info("Reload the webpage"); + await contentPage.loadURL(`${BASE_URL}/file_sample.html`); + info("Wait for all the content scripts to be executed again"); + await Promise.all( + content_scripts.map(() => extension.awaitMessage("content-script-executed")) + ); + info("No crash triggered as expected"); + + Assert.deepEqual( + await promiseDone, + { initial: content_scripts.length, final: 0 }, + "All content scripts unregistered as expected" + ); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xml_prettyprint.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xml_prettyprint.js new file mode 100644 index 0000000000..0682d30933 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xml_prettyprint.js @@ -0,0 +1,75 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.prefs.setBoolPref("layout.xml.prettyprint", true); + +const BASE_XML = '<?xml version="1.0" encoding="UTF-8"?>'; +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/test.xml", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/xml; charset=utf-8", false); + response.write(`${BASE_XML}\n<note></note>`); +}); + +// Make sure that XML pretty printer runs after content scripts +// that runs at document_start (See Bug 1605657). +add_task(async function content_script_on_xml_prettyprinted_document() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["<all_urls>"], + js: ["start.js"], + run_at: "document_start", + }, + ], + }, + files: { + "start.js": async function () { + const el = document.createElement("ext-el"); + document.documentElement.append(el); + if (document.readyState !== "complete") { + await new Promise(resolve => { + document.addEventListener("DOMContentLoaded", resolve, { + once: true, + }); + }); + } + browser.test.sendMessage("content-script-done"); + }, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/test.xml" + ); + + info("Wait content script and xml document to be fully loaded"); + await extension.awaitMessage("content-script-done"); + + info("Verify the xml file is still pretty printed"); + const res = await contentPage.spawn([], () => { + const doc = this.content.document; + const shadowRoot = doc.documentElement.openOrClosedShadowRoot; + const prettyPrintLink = + shadowRoot && + shadowRoot.querySelector("link[href*='XMLPrettyPrint.css']"); + return { + hasShadowRoot: !!shadowRoot, + hasPrettyPrintLink: !!prettyPrintLink, + }; + }); + + Assert.deepEqual( + res, + { hasShadowRoot: true, hasPrettyPrintLink: true }, + "The XML file has the pretty print shadowRoot" + ); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xorigin_frame.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xorigin_frame.js new file mode 100644 index 0000000000..8a58b2475c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xorigin_frame.js @@ -0,0 +1,62 @@ +"use strict"; + +const server = createHttpServer({ + hosts: ["example.net", "example.org"], +}); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_process_switch_cross_origin_frame() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.org/*/file_iframe.html"], + all_frames: true, + js: ["cs.js"], + }, + ], + }, + + files: { + "cs.js"() { + browser.test.assertEq( + location.href, + "http://example.org/data/file_iframe.html", + "url is ok" + ); + + // frameId is the BrowsingContext ID in practice. + let frameId = browser.runtime.getFrameId(window); + browser.test.sendMessage("content-script-loaded", frameId); + }, + }, + }); + + await extension.startup(); + + const contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.net/data/file_with_xorigin_frame.html" + ); + + const browserProcessId = + contentPage.browser.browsingContext.currentWindowGlobal.domProcess.childID; + + const scriptFrameId = await extension.awaitMessage("content-script-loaded"); + + const children = contentPage.browser.browsingContext.children.map(bc => ({ + browsingContextId: bc.id, + processId: bc.currentWindowGlobal.domProcess.childID, + })); + + Assert.equal(children.length, 1); + Assert.equal(scriptFrameId, children[0].browsingContextId); + + if (contentPage.remoteSubframes) { + Assert.notEqual(browserProcessId, children[0].processId); + } else { + Assert.equal(browserProcessId, children[0].processId); + } + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xrays.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xrays.js new file mode 100644 index 0000000000..7b92d5c4b7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xrays.js @@ -0,0 +1,59 @@ +"use strict"; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function test_contentscript_xrays() { + async function contentScript() { + let unwrapped = window.wrappedJSObject; + + browser.test.assertEq( + "undefined", + typeof test, + "Should not have named X-ray property access" + ); + browser.test.assertEq( + undefined, + window.test, + "Should not have named X-ray property access" + ); + browser.test.assertEq( + "object", + typeof unwrapped.test, + "Should always have non-X-ray named property access" + ); + + browser.test.notifyPass("contentScriptXrays"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.awaitFinish("contentScriptXrays"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contexts.js b/toolkit/components/extensions/test/xpcshell/test_ext_contexts.js new file mode 100644 index 0000000000..028f5b5638 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contexts.js @@ -0,0 +1,201 @@ +"use strict"; + +const global = this; + +var { BaseContext, EventManager, EventEmitter } = ExtensionCommon; + +class FakeExtension extends EventEmitter { + constructor(id) { + super(); + this.id = id; + } +} + +class StubContext extends BaseContext { + constructor() { + let fakeExtension = new FakeExtension("test@web.extension"); + super("testEnv", fakeExtension); + this.sandbox = Cu.Sandbox(global); + } + + logActivity(type, name, data) { + // no-op required by subclass + } + + get cloneScope() { + return this.sandbox; + } + + get principal() { + return Cu.getObjectPrincipal(this.sandbox); + } +} + +add_task(async function test_post_unload_promises() { + let context = new StubContext(); + + let fail = result => { + ok(false, `Unexpected callback: ${result}`); + }; + + // Make sure promises resolve normally prior to unload. + let promises = [ + context.wrapPromise(Promise.resolve()), + context.wrapPromise(Promise.reject({ message: "" })).catch(() => {}), + ]; + + await Promise.all(promises); + + // Make sure promises that resolve after unload do not trigger + // resolution handlers. + + context.wrapPromise(Promise.resolve("resolved")).then(fail); + + context.wrapPromise(Promise.reject({ message: "rejected" })).then(fail, fail); + + context.unload(); + + // The `setTimeout` ensures that we return to the event loop after + // promise resolution, which means we're guaranteed to return after + // any micro-tasks that get enqueued by the resolution handlers above. + await new Promise(resolve => setTimeout(resolve, 0)); +}); + +add_task(async function test_post_unload_listeners() { + let context = new StubContext(); + + let fire; + let manager = new EventManager({ + context, + name: "EventManager", + register: _fire => { + fire = () => { + _fire.async(); + }; + return () => {}; + }, + }); + + let fail = event => { + ok(false, `Unexpected event: ${event}`); + }; + + // Check that event listeners isn't called after it has been removed. + manager.addListener(fail); + + let promise = new Promise(resolve => manager.addListener(resolve)); + + fire(); + + // The `fireSingleton` call ia dispatched asynchronously, so it won't + // have fired by this point. The `fail` listener that we remove now + // should not be called, even though the event has already been + // enqueued. + manager.removeListener(fail); + + // Wait for the remaining listener to be called, which should always + // happen after the `fail` listener would normally be called. + await promise; + + // Check that the event listener isn't called after the context has + // unloaded. + manager.addListener(fail); + + // The `fire` callback always dispatches events + // asynchronously, so we need to test that any pending event callbacks + // aren't fired after the context unloads. We also need to test that + // any `fire` calls that happen *after* the context is unloaded also + // do not trigger callbacks. + fire(); + Promise.resolve().then(fire); + + context.unload(); + + // The `setTimeout` ensures that we return to the event loop after + // promise resolution, which means we're guaranteed to return after + // any micro-tasks that get enqueued by the resolution handlers above. + await new Promise(resolve => setTimeout(resolve, 0)); +}); + +class Context extends BaseContext { + constructor(principal) { + let fakeExtension = new FakeExtension("test@web.extension"); + super("testEnv", fakeExtension); + Object.defineProperty(this, "principal", { + value: principal, + configurable: true, + }); + this.sandbox = Cu.Sandbox(principal, { wantXrays: false }); + } + + logActivity(type, name, data) { + // no-op required by subclass + } + + get cloneScope() { + return this.sandbox; + } +} + +let ssm = Services.scriptSecurityManager; +const PRINCIPAL1 = ssm.createContentPrincipalFromOrigin( + "http://www.example.org" +); +const PRINCIPAL2 = ssm.createContentPrincipalFromOrigin( + "http://www.somethingelse.org" +); + +// Test that toJSON() works in the json sandbox +add_task(async function test_stringify_toJSON() { + let context = new Context(PRINCIPAL1); + let obj = Cu.evalInSandbox( + "({hidden: true, toJSON() { return {visible: true}; } })", + context.sandbox + ); + + let stringified = context.jsonStringify(obj); + let expected = JSON.stringify({ visible: true }); + equal( + stringified, + expected, + "Stringified object with toJSON() method is as expected" + ); +}); + +// Test that stringifying in inaccessible property throws +add_task(async function test_stringify_inaccessible() { + let context = new Context(PRINCIPAL1); + let sandbox = context.sandbox; + let sandbox2 = Cu.Sandbox(PRINCIPAL2); + + Cu.waiveXrays(sandbox).subobj = Cu.evalInSandbox( + "({ subobject: true })", + sandbox2 + ); + let obj = Cu.evalInSandbox("({ local: true, nested: subobj })", sandbox); + Assert.throws(() => { + context.jsonStringify(obj); + }, /Permission denied to access property "toJSON"/); +}); + +add_task(async function test_stringify_accessible() { + // Test that an accessible property from another global is included + let principal = Cu.getObjectPrincipal(Cu.Sandbox([PRINCIPAL1, PRINCIPAL2])); + let context = new Context(principal); + let sandbox = context.sandbox; + let sandbox2 = Cu.Sandbox(PRINCIPAL2); + + Cu.waiveXrays(sandbox).subobj = Cu.evalInSandbox( + "({ subobject: true })", + sandbox2 + ); + let obj = Cu.evalInSandbox("({ local: true, nested: subobj })", sandbox); + let stringified = context.jsonStringify(obj); + + let expected = JSON.stringify({ local: true, nested: { subobject: true } }); + equal( + stringified, + expected, + "Stringified object with accessible property is as expected" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contexts_gc.js b/toolkit/components/extensions/test/xpcshell/test_ext_contexts_gc.js new file mode 100644 index 0000000000..a828584ced --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contexts_gc.js @@ -0,0 +1,277 @@ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +// Each of these tests do the following: +// 1. Load document to create an extension context (instance of BaseContext). +// 2. Get weak reference to that context. +// 3. Unload the document. +// 4. Force GC and check that the weak reference has been invalidated. + +async function reloadTopContext(contentPage) { + await contentPage.legacySpawn(null, async () => { + let { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" + ); + let windowNukeObserved = TestUtils.topicObserved("inner-window-nuked"); + info(`Reloading top-level document`); + this.content.location.reload(); + await windowNukeObserved; + info(`Reloaded top-level document`); + }); +} + +async function assertContextReleased(contentPage, description) { + await contentPage.legacySpawn(description, async assertionDescription => { + // Force GC, see https://searchfox.org/mozilla-central/rev/b0275bc977ad7fda615ef34b822bba938f2b16fd/testing/talos/talos/tests/devtools/addon/content/damp.js#84-98 + // and https://searchfox.org/mozilla-central/rev/33c21c060b7f3a52477a73d06ebcb2bf313c4431/xpcom/base/nsMemoryReporterManager.cpp#2574-2585,2591-2594 + let gcCount = 0; + while (gcCount < 30 && this.contextWeakRef.get() !== null) { + ++gcCount; + // The JS engine will sometimes hold IC stubs for function + // environments alive across multiple CCs, which can keep + // closed-over JS objects alive. A shrinking GC will throw those + // stubs away, and therefore side-step the problem. + Cu.forceShrinkingGC(); + Cu.forceCC(); + Cu.forceGC(); + await new Promise(resolve => this.content.setTimeout(resolve, 0)); + } + + // The above loop needs to be repeated at most 3 times according to MinimizeMemoryUsage: + // https://searchfox.org/mozilla-central/rev/6f86cc3479f80ace97f62634e2c82a483d1ede40/xpcom/base/nsMemoryReporterManager.cpp#2644-2647 + Assert.lessOrEqual( + gcCount, + 3, + `Context should have been GCd within a few GC attempts.` + ); + + // Each test will set this.contextWeakRef before unloading the document. + Assert.ok(!this.contextWeakRef.get(), assertionDescription); + }); +} + +add_task(async function test_ContentScriptContextChild_in_child_frame() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_iframe.html"], + js: ["content_script.js"], + all_frames: true, + }, + ], + }, + + files: { + "content_script.js": "browser.test.sendMessage('contentScriptLoaded');", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_toplevel.html` + ); + await extension.awaitMessage("contentScriptLoaded"); + + await contentPage.legacySpawn(extension.id, async extensionId => { + const { ExtensionContent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionContent.sys.mjs" + ); + let frame = this.content.document.querySelector( + "iframe[src*='file_iframe.html']" + ); + let context = ExtensionContent.getContextByExtensionId( + extensionId, + frame.contentWindow + ); + + Assert.ok(!!context, "Got content script context"); + + this.contextWeakRef = Cu.getWeakReference(context); + frame.remove(); + }); + + await assertContextReleased( + contentPage, + "ContentScriptContextChild should have been released" + ); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_ContentScriptContextChild_in_toplevel() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + all_frames: true, + }, + ], + }, + + files: { + "content_script.js": "browser.test.sendMessage('contentScriptLoaded');", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + await extension.awaitMessage("contentScriptLoaded"); + + await contentPage.legacySpawn(extension.id, async extensionId => { + const { ExtensionContent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionContent.sys.mjs" + ); + let context = ExtensionContent.getContextByExtensionId( + extensionId, + this.content + ); + + Assert.ok(!!context, "Got content script context"); + + this.contextWeakRef = Cu.getWeakReference(context); + }); + + await reloadTopContext(contentPage); + await extension.awaitMessage("contentScriptLoaded"); + await assertContextReleased( + contentPage, + "ContentScriptContextChild should have been released" + ); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_ExtensionPageContextChild_in_child_frame() { + let extensionData = { + files: { + "iframe.html": ` + <!DOCTYPE html><meta charset="utf8"> + <script src="script.js"></script> + `, + "toplevel.html": ` + <!DOCTYPE html><meta charset="utf8"> + <iframe src="iframe.html"></iframe> + `, + "script.js": "browser.test.sendMessage('extensionPageLoaded');", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/toplevel.html`, + { + extension, + remote: extension.extension.remote, + } + ); + await extension.awaitMessage("extensionPageLoaded"); + + await contentPage.legacySpawn(extension.id, async extensionId => { + let { ExtensionPageChild } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPageChild.sys.mjs" + ); + + let frame = this.content.document.querySelector( + "iframe[src*='iframe.html']" + ); + let innerWindowID = + frame.browsingContext.currentWindowContext.innerWindowId; + let context = ExtensionPageChild.extensionContexts.get(innerWindowID); + + Assert.ok(!!context, "Got extension page context for child frame"); + + this.contextWeakRef = Cu.getWeakReference(context); + frame.remove(); + }); + + await assertContextReleased( + contentPage, + "ExtensionPageContextChild should have been released" + ); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_ExtensionPageContextChild_in_toplevel() { + let extensionData = { + files: { + "toplevel.html": ` + <!DOCTYPE html><meta charset="utf8"> + <script src="script.js"></script> + `, + "script.js": "browser.test.sendMessage('extensionPageLoaded');", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/toplevel.html`, + { + extension, + remote: extension.extension.remote, + } + ); + await extension.awaitMessage("extensionPageLoaded"); + + await contentPage.legacySpawn(extension.id, async extensionId => { + let { ExtensionPageChild } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPageChild.sys.mjs" + ); + + let innerWindowID = this.content.windowGlobalChild.innerWindowId; + let context = ExtensionPageChild.extensionContexts.get(innerWindowID); + + Assert.ok(!!context, "Got extension page context for top-level document"); + + this.contextWeakRef = Cu.getWeakReference(context); + }); + + await reloadTopContext(contentPage); + await extension.awaitMessage("extensionPageLoaded"); + // For some unknown reason, the context cannot forcidbly be released by the + // garbage collector unless we wait for a short while. + await contentPage.spawn([], async () => { + let start = Date.now(); + // The treshold was found after running this subtest only, 300 times + // in a release build (100 of xpcshell, xpcshell-e10s and xpcshell-remote). + // With treshold 8, almost half of the tests complete after a 17-18 ms delay. + // With treshold 7, over half of the tests complete after a 13-14 ms delay, + // with 12 failures in 300 tests runs. + // Let's double that number to have a safety margin. + for (let i = 0; i < 15; ++i) { + await new Promise(resolve => this.content.setTimeout(resolve, 0)); + } + info(`Going to GC after waiting for ${Date.now() - start} ms.`); + }); + await assertContextReleased( + contentPage, + "ExtensionPageContextChild should have been released" + ); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities.js b/toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities.js new file mode 100644 index 0000000000..93eeac729a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities.js @@ -0,0 +1,607 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ContextualIdentityService: + "resource://gre/modules/ContextualIdentityService.sys.mjs", + ExtensionPreferencesManager: + "resource://gre/modules/ExtensionPreferencesManager.sys.mjs", + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", +}); + +const CONTAINERS_PREF = "privacy.userContext.enabled"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +add_task(async function startup() { + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_contextualIdentities_without_permissions() { + function background() { + browser.test.assertTrue( + !browser.contextualIdentities, + "contextualIdentities API is not available when the contextualIdentities permission is not required" + ); + browser.test.notifyPass("contextualIdentities_without_permission"); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + background, + manifest: { + browser_specific_settings: { + gecko: { id: "testing@thing.com" }, + }, + permissions: [], + }, + }); + + await extension.startup(); + await extension.awaitFinish("contextualIdentities_without_permission"); + await extension.unload(); +}); + +add_task(async function test_contextualIdentity_events() { + async function background() { + function createOneTimeListener(type) { + return new Promise((resolve, reject) => { + try { + browser.test.assertTrue( + type in browser.contextualIdentities, + `Found API object browser.contextualIdentities.${type}` + ); + const listener = change => { + browser.test.assertTrue( + "contextualIdentity" in change, + `Found identity in change` + ); + browser.contextualIdentities[type].removeListener(listener); + resolve(change); + }; + browser.contextualIdentities[type].addListener(listener); + } catch (e) { + reject(e); + } + }); + } + + function assertExpected(expected, container) { + // Number of keys that are added by the APIs + const createdCount = 2; + for (let key of Object.keys(container)) { + browser.test.assertTrue(key in expected, `found property ${key}`); + browser.test.assertEq( + expected[key], + container[key], + `property value for ${key} is correct` + ); + } + const hexMatch = /^#[0-9a-f]{6}$/; + browser.test.assertTrue( + hexMatch.test(expected.colorCode), + "Color code property was expected Hex shape" + ); + const iconMatch = /^resource:\/\/usercontext-content\/[a-z]+[.]svg$/; + browser.test.assertTrue( + iconMatch.test(expected.iconUrl), + "Icon url property was expected shape" + ); + browser.test.assertEq( + Object.keys(expected).length, + Object.keys(container).length + createdCount, + "all expected properties found" + ); + } + + let onCreatePromise = createOneTimeListener("onCreated"); + + let containerObj = { name: "foobar", color: "red", icon: "circle" }; + let ci = await browser.contextualIdentities.create(containerObj); + browser.test.assertTrue(!!ci, "We have an identity"); + const onCreateListenerResponse = await onCreatePromise; + const cookieStoreId = ci.cookieStoreId; + assertExpected( + onCreateListenerResponse.contextualIdentity, + Object.assign(containerObj, { cookieStoreId }) + ); + + let onUpdatedPromise = createOneTimeListener("onUpdated"); + let updateContainerObj = { name: "testing", color: "blue", icon: "dollar" }; + ci = await browser.contextualIdentities.update( + cookieStoreId, + updateContainerObj + ); + browser.test.assertTrue(!!ci, "We have an update identity"); + const onUpdatedListenerResponse = await onUpdatedPromise; + assertExpected( + onUpdatedListenerResponse.contextualIdentity, + Object.assign(updateContainerObj, { cookieStoreId }) + ); + + let onRemovePromise = createOneTimeListener("onRemoved"); + ci = await browser.contextualIdentities.remove( + updateContainerObj.cookieStoreId + ); + browser.test.assertTrue(!!ci, "We have an remove identity"); + const onRemoveListenerResponse = await onRemovePromise; + assertExpected( + onRemoveListenerResponse.contextualIdentity, + Object.assign(updateContainerObj, { cookieStoreId }) + ); + + browser.test.notifyPass("contextualIdentities_events"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { + gecko: { id: "testing@thing.com" }, + }, + permissions: ["contextualIdentities"], + }, + }); + + Services.prefs.setBoolPref(CONTAINERS_PREF, true); + + await extension.startup(); + await extension.awaitFinish("contextualIdentities_events"); + await extension.unload(); + + Services.prefs.clearUserPref(CONTAINERS_PREF); +}); + +add_task(async function test_contextualIdentity_with_permissions() { + async function background() { + let ci; + await browser.test.assertRejects( + browser.contextualIdentities.get("foobar"), + "Invalid contextual identity: foobar", + "API should reject here" + ); + await browser.test.assertRejects( + browser.contextualIdentities.update("foobar", { name: "testing" }), + "Invalid contextual identity: foobar", + "API should reject for unknown updates" + ); + await browser.test.assertRejects( + browser.contextualIdentities.remove("foobar"), + "Invalid contextual identity: foobar", + "API should reject for removing unknown containers" + ); + + ci = await browser.contextualIdentities.get("firefox-container-1"); + browser.test.assertTrue(!!ci, "We have an identity"); + browser.test.assertTrue("name" in ci, "We have an identity.name"); + browser.test.assertTrue("color" in ci, "We have an identity.color"); + browser.test.assertTrue("icon" in ci, "We have an identity.icon"); + browser.test.assertEq("Personal", ci.name, "identity.name is correct"); + browser.test.assertEq( + "firefox-container-1", + ci.cookieStoreId, + "identity.cookieStoreId is correct" + ); + + function listenForMessage(messageName, stateChangeBool) { + return new Promise(resolve => { + browser.test.onMessage.addListener(function listener(msg) { + browser.test.log(`Got message from background: ${msg}`); + if (msg === messageName + "-response") { + browser.test.onMessage.removeListener(listener); + resolve(); + } + }); + browser.test.log( + `Sending message to background: ${messageName} ${stateChangeBool}` + ); + browser.test.sendMessage(messageName, stateChangeBool); + }); + } + + await listenForMessage("containers-state-change", false); + + browser.test.assertRejects( + browser.contextualIdentities.query({}), + "Contextual identities are currently disabled", + "Throws when containers are disabled" + ); + + await listenForMessage("containers-state-change", true); + + let cis = await browser.contextualIdentities.query({}); + browser.test.assertEq( + 4, + cis.length, + "by default we should have 4 containers" + ); + + cis = await browser.contextualIdentities.query({ name: "Personal" }); + browser.test.assertEq( + 1, + cis.length, + "by default we should have 1 container called Personal" + ); + + cis = await browser.contextualIdentities.query({ name: "foobar" }); + browser.test.assertEq( + 0, + cis.length, + "by default we should have 0 container called foobar" + ); + + ci = await browser.contextualIdentities.create({ + name: "foobar", + color: "red", + icon: "gift", + }); + browser.test.assertTrue(!!ci, "We have an identity"); + browser.test.assertEq("foobar", ci.name, "identity.name is correct"); + browser.test.assertEq("red", ci.color, "identity.color is correct"); + browser.test.assertEq("gift", ci.icon, "identity.icon is correct"); + browser.test.assertTrue( + !!ci.cookieStoreId, + "identity.cookieStoreId is correct" + ); + + browser.test.assertRejects( + browser.contextualIdentities.create({ + name: "foobar", + color: "red", + icon: "firefox", + }), + "Invalid icon firefox for container", + "Create container called with an invalid icon" + ); + + browser.test.assertRejects( + browser.contextualIdentities.create({ + name: "foobar", + color: "firefox-orange", + icon: "gift", + }), + "Invalid color name firefox-orange for container", + "Create container called with an invalid color" + ); + + cis = await browser.contextualIdentities.query({}); + browser.test.assertEq( + 5, + cis.length, + "we should still have have 5 containers" + ); + + ci = await browser.contextualIdentities.get(ci.cookieStoreId); + browser.test.assertTrue(!!ci, "We have an identity"); + browser.test.assertEq("foobar", ci.name, "identity.name is correct"); + browser.test.assertEq("red", ci.color, "identity.color is correct"); + browser.test.assertEq("gift", ci.icon, "identity.icon is correct"); + + browser.test.assertRejects( + browser.contextualIdentities.update(ci.cookieStoreId, { + name: "foobar", + color: "red", + icon: "firefox", + }), + "Invalid icon firefox for container", + "Create container called with an invalid icon" + ); + + browser.test.assertRejects( + browser.contextualIdentities.update(ci.cookieStoreId, { + name: "foobar", + color: "firefox-orange", + icon: "gift", + }), + "Invalid color name firefox-orange for container", + "Create container called with an invalid color" + ); + + cis = await browser.contextualIdentities.query({}); + browser.test.assertEq(5, cis.length, "now we have 5 identities"); + + ci = await browser.contextualIdentities.update(ci.cookieStoreId, { + name: "barfoo", + color: "blue", + icon: "cart", + }); + browser.test.assertTrue(!!ci, "We have an identity"); + browser.test.assertEq("barfoo", ci.name, "identity.name is correct"); + browser.test.assertEq("blue", ci.color, "identity.color is correct"); + browser.test.assertEq("cart", ci.icon, "identity.icon is correct"); + + ci = await browser.contextualIdentities.get(ci.cookieStoreId); + browser.test.assertTrue(!!ci, "We have an identity"); + browser.test.assertEq("barfoo", ci.name, "identity.name is correct"); + browser.test.assertEq("blue", ci.color, "identity.color is correct"); + browser.test.assertEq("cart", ci.icon, "identity.icon is correct"); + + ci = await browser.contextualIdentities.remove(ci.cookieStoreId); + browser.test.assertTrue(!!ci, "We have an identity"); + browser.test.assertEq("barfoo", ci.name, "identity.name is correct"); + browser.test.assertEq("blue", ci.color, "identity.color is correct"); + browser.test.assertEq("cart", ci.icon, "identity.icon is correct"); + + cis = await browser.contextualIdentities.query({}); + browser.test.assertEq(4, cis.length, "we are back to 4 identities"); + + browser.test.notifyPass("contextualIdentities"); + } + + function makeExtension(id) { + return ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + background, + manifest: { + browser_specific_settings: { + gecko: { id }, + }, + permissions: ["contextualIdentities"], + }, + }); + } + + let extension = makeExtension("containers-test@mozilla.org"); + + extension.onMessage("containers-state-change", stateBool => { + Cu.reportError(`Got message "containers-state-change", ${stateBool}`); + Services.prefs.setBoolPref(CONTAINERS_PREF, stateBool); + Cu.reportError("Changed pref"); + extension.sendMessage("containers-state-change-response"); + }); + + await extension.startup(); + await extension.awaitFinish("contextualIdentities"); + equal( + Services.prefs.getBoolPref(CONTAINERS_PREF), + true, + "Pref should now be enabled, whatever it's initial state" + ); + await extension.unload(); + equal( + Services.prefs.getBoolPref(CONTAINERS_PREF), + true, + "Pref should remain enabled" + ); + + Services.prefs.clearUserPref(CONTAINERS_PREF); +}); + +add_task(async function test_contextualIdentity_extensions_enable_containers() { + async function background() { + let ci = await browser.contextualIdentities.get("firefox-container-1"); + browser.test.assertTrue(!!ci, "We have an identity"); + + browser.test.notifyPass("contextualIdentities"); + } + function makeExtension(id) { + return ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background, + manifest: { + browser_specific_settings: { + gecko: { id }, + }, + permissions: ["contextualIdentities"], + }, + }); + } + async function testSetting(expect, message) { + let setting = await ExtensionPreferencesManager.getSetting( + "privacy.containers" + ); + if (expect === null) { + equal(setting, null, message); + } else { + equal(setting.value, expect, message); + } + } + function testPref(expect, message) { + equal(Services.prefs.getBoolPref(CONTAINERS_PREF), expect, message); + } + + let extension = makeExtension("containers-test@mozilla.org"); + await extension.startup(); + await extension.awaitFinish("contextualIdentities"); + equal( + Services.prefs.getBoolPref(CONTAINERS_PREF), + true, + "Pref should now be enabled, whatever it's initial state" + ); + await extension.unload(); + await testSetting(null, "setting should be unset"); + equal( + Services.prefs.getBoolPref(CONTAINERS_PREF), + true, + "Pref should remain enabled" + ); + + // Lets set containers explicitly to be off and test we keep it that way after removal + Services.prefs.setBoolPref(CONTAINERS_PREF, false); + + let extension1 = makeExtension("containers-test-1@mozilla.org"); + await extension1.startup(); + await extension1.awaitFinish("contextualIdentities"); + await testSetting(extension1.id, "setting should be controlled"); + testPref(true, "Pref should now be enabled, whatever it's initial state"); + + // Test that disabling leaves containers on, and that re-enabling with containers off + // will re-enable containers. + const addon = await AddonManager.getAddonByID(extension1.id); + await addon.disable(); + await testSetting(undefined, "setting should not be an addon"); + testPref(true, "Pref should remain enabled, whatever it's initial state"); + + Services.prefs.setBoolPref(CONTAINERS_PREF, false); + + await addon.enable(); + await testSetting(extension1.id, "setting should be controlled"); + testPref(true, "Pref should be enabled"); + + await extension1.unload(); + await testSetting(null, "setting should be unset"); + equal( + Services.prefs.getBoolPref(CONTAINERS_PREF), + true, + "Pref should remain enabled" + ); + + // Lets set containers explicitly to be on and test we keep it that way after removal. + Services.prefs.setBoolPref(CONTAINERS_PREF, true); + + let extension2 = makeExtension("containers-test-2@mozilla.org"); + let extension3 = makeExtension("containers-test-3@mozilla.org"); + await extension2.startup(); + await extension2.awaitFinish("contextualIdentities"); + await extension3.startup(); + await extension3.awaitFinish("contextualIdentities"); + + // Flip the ordering to check it's still enabled + await testSetting(extension3.id, "setting should still be controlled by 3"); + testPref(true, "Pref should now be enabled 1"); + await extension3.unload(); + await testSetting(extension2.id, "setting should still be controlled by 2"); + testPref(true, "Pref should now be enabled 2"); + await extension2.unload(); + await testSetting(null, "setting should be unset"); + testPref(true, "Pref should now be enabled 3"); + + Services.prefs.clearUserPref(CONTAINERS_PREF); +}); + +add_task(async function test_contextualIdentity_preference_change() { + async function background() { + let extensionInfo = await browser.management.getSelf(); + if (extensionInfo.version == "1.0.0") { + const containers = await browser.contextualIdentities.query({}); + browser.test.assertEq( + containers.length, + 4, + "We still have the original containers" + ); + await browser.contextualIdentities.create({ + name: "foobar", + color: "red", + icon: "circle", + }); + } + const containers = await browser.contextualIdentities.query({}); + browser.test.assertEq(containers.length, 5, "We have a new container"); + if (extensionInfo.version == "1.1.0") { + await browser.contextualIdentities.remove(containers[4].cookieStoreId); + } + browser.test.notifyPass("contextualIdentities"); + } + function makeExtension(id, version) { + return ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + background, + manifest: { + version, + browser_specific_settings: { + gecko: { id }, + }, + permissions: ["contextualIdentities"], + }, + }); + } + + Services.prefs.setBoolPref(CONTAINERS_PREF, false); + let extension = makeExtension("containers-pref-test@mozilla.org", "1.0.0"); + await extension.startup(); + await extension.awaitFinish("contextualIdentities"); + equal( + Services.prefs.getBoolPref(CONTAINERS_PREF), + true, + "Pref should now be enabled, whatever it's initial state" + ); + + let extension2 = makeExtension("containers-pref-test@mozilla.org", "1.1.0"); + await extension2.startup(); + await extension2.awaitFinish("contextualIdentities"); + + await extension.unload(); + equal( + Services.prefs.getBoolPref(CONTAINERS_PREF), + true, + "Pref should remain enabled" + ); + + Services.prefs.clearUserPref(CONTAINERS_PREF); +}); + +add_task( + { pref_set: [["extensions.eventPages.enabled", true]] }, + async function test_contextualIdentity_event_page() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { + gecko: { id: "eventpage@mochitest" }, + }, + permissions: ["contextualIdentities"], + background: { persistent: false }, + }, + background() { + browser.contextualIdentities.onCreated.addListener(() => { + browser.test.sendMessage("created"); + }); + browser.contextualIdentities.onUpdated.addListener(() => {}); + browser.contextualIdentities.onRemoved.addListener(() => { + browser.test.sendMessage("removed"); + }); + browser.test.sendMessage("ready"); + }, + }); + + const EVENTS = ["onCreated", "onUpdated", "onRemoved"]; + + await extension.startup(); + await extension.awaitMessage("ready"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "contextualIdentities", event, { + primed: false, + }); + } + + await extension.terminateBackground(); + for (let event of EVENTS) { + assertPersistentListeners(extension, "contextualIdentities", event, { + primed: true, + }); + } + + // test events waken background + let identity = ContextualIdentityService.create("foobar", "circle", "red"); + + await extension.awaitMessage("ready"); + await extension.awaitMessage("created"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "contextualIdentities", event, { + primed: false, + }); + } + + ContextualIdentityService.remove(identity.userContextId); + await extension.awaitMessage("removed"); + + // check primed listeners after startup + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + for (let event of EVENTS) { + assertPersistentListeners(extension, "contextualIdentities", event, { + primed: true, + }); + } + + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities_move.js b/toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities_move.js new file mode 100644 index 0000000000..0150ccf281 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities_move.js @@ -0,0 +1,99 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +add_setup(async () => { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_contextualIdentity_move() { + async function background() { + async function listCookieStoreIds() { + const contextualIds = await browser.contextualIdentities.query({}); + return contextualIds.map(ctxId => ctxId.cookieStoreId); + } + const containerIds = await listCookieStoreIds(); + browser.test.assertEq(containerIds.length, 4, "We start from 4 containers"); + await browser.test.assertRejects( + browser.contextualIdentities.move("foobar", 2), + "Invalid contextual identity: foobar", + "API should reject for moving unknown containers" + ); + await browser.test.assertRejects( + browser.contextualIdentities.move("firefox-container-123456", 2), + "Invalid contextual identity: firefox-container-123456", + "API should reject for moving unknown containers" + ); + const [id0, id1, id2, id3] = containerIds; + await browser.test.assertRejects( + browser.contextualIdentities.move([id0, id0], 2), + `Duplicate contextual identity: ${id0}`, + "API should reject moves with duplicate identities containers" + ); + await browser.test.assertRejects( + browser.contextualIdentities.move([id0], 4), + `Moving to invalid position 4`, + "API should reject moves with invalid destinations" + ); + await browser.test.assertRejects( + browser.contextualIdentities.move([id0, id1, id2, id3], 1), + `Moving to invalid position 1`, + "API should reject moves with invalid destinations" + ); + await browser.test.assertDeepEq( + [id0, id1, id2, id3], + await listCookieStoreIds(), + "Rejected calls should not have modified the list" + ); + await browser.contextualIdentities.move(id0, -1); + await browser.test.assertDeepEq( + [id1, id2, id3, id0], + await listCookieStoreIds(), + "Moving single value to last position works" + ); + await browser.test.assertRejects( + browser.contextualIdentities.move([id0], -2), + `Moving to invalid position -2`, + "API should reject moves with invalid destinations" + ); + await browser.contextualIdentities.move([id1, id3], 1); + await browser.test.assertDeepEq( + [id2, id1, id3, id0], + await listCookieStoreIds(), + "Moving bulk values to specified position works" + ); + await browser.contextualIdentities.move([], 2); + await browser.test.assertDeepEq( + [id2, id1, id3, id0], + await listCookieStoreIds(), + "Moving an empty list of values has no effect" + ); + await browser.contextualIdentities.move([id0, id3, id1, id2], -1); + await browser.test.assertDeepEq( + [id2, id1, id3, id0], + await listCookieStoreIds(), + "Moving all values to the end has no effect" + ); + browser.test.notifyPass("contextualIdentities_move"); + } + + function makeExtension(id) { + return ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + background, + manifest: { + browser_specific_settings: { + gecko: { id }, + }, + permissions: ["contextualIdentities"], + }, + }); + } + + let extension = makeExtension("containers-test@mozilla.org"); + + await extension.startup(); + await extension.awaitFinish("contextualIdentities_move"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cookieBehaviors.js b/toolkit/components/extensions/test/xpcshell/test_ext_cookieBehaviors.js new file mode 100644 index 0000000000..e7dd1e99c6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookieBehaviors.js @@ -0,0 +1,590 @@ +"use strict"; + +const { UrlClassifierTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/UrlClassifierTestUtils.sys.mjs" +); + +const { + // cookieBehavior constants. + BEHAVIOR_REJECT, + BEHAVIOR_REJECT_TRACKER, +} = Ci.nsICookieService; + +function createPage({ script, body = "" } = {}) { + if (script) { + body += `<script src="${script}"></script>`; + } + + return `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + ${body} + </body> + </html>`; +} + +const server = createHttpServer({ hosts: ["example.com", "itisatracker.org"] }); +server.registerDirectory("/data/", do_get_file("data")); +server.registerPathHandler("/test-cookies", (request, response) => { + response.setHeader("Cache-Control", "no-cache", false); + response.setHeader("Content-Type", "text/json", false); + response.setHeader("Set-Cookie", "myKey=myCookie", true); + response.write('{"success": true}'); +}); +server.registerPathHandler("/subframe.html", (request, response) => { + response.write(createPage()); +}); +server.registerPathHandler("/page-with-tracker.html", (request, response) => { + response.write( + createPage({ + body: `<iframe src="http://itisatracker.org/test-cookies"></iframe>`, + }) + ); +}); +server.registerPathHandler("/sw.js", (request, response) => { + response.setHeader("Content-Type", "text/javascript", false); + response.write(""); +}); + +function assertCookiesForHost(url, cookiesCount, message) { + const { host } = new URL(url); + const cookies = Services.cookies.cookies.filter( + cookie => cookie.host === host + ); + equal(cookies.length, cookiesCount, message); + return cookies; +} + +// Test that the indexedDB and localStorage are allowed in an extension page +// and that the indexedDB is allowed in a extension worker. +add_task(async function test_ext_page_allowed_storage() { + function testWebStorages() { + const url = window.location.href; + + try { + // In a webpage accessing indexedDB throws on cookiesBehavior reject, + // here we verify that doesn't happen for an extension page. + browser.test.assertTrue( + indexedDB, + "IndexedDB global should be accessible" + ); + + // In a webpage localStorage is undefined on cookiesBehavior reject, + // here we verify that doesn't happen for an extension page. + browser.test.assertTrue( + localStorage, + "localStorage global should be defined" + ); + + const worker = new Worker("worker.js"); + worker.onmessage = event => { + browser.test.assertTrue( + event.data.pass, + "extension page worker have access to indexedDB" + ); + + browser.test.sendMessage("test-storage:done", url); + }; + + worker.postMessage({}); + } catch (err) { + browser.test.fail(`Unexpected error: ${err}`); + browser.test.sendMessage("test-storage:done", url); + } + } + + function testWorker() { + this.onmessage = () => { + try { + void indexedDB; + postMessage({ pass: true }); + } catch (err) { + postMessage({ pass: false }); + throw err; + } + }; + } + + async function createExtension() { + let extension = ExtensionTestUtils.loadExtension({ + files: { + "test_web_storages.js": testWebStorages, + "worker.js": testWorker, + "page_subframe.html": createPage({ script: "test_web_storages.js" }), + "page_with_subframe.html": createPage({ + body: '<iframe src="page_subframe.html"></iframe>', + }), + "page.html": createPage({ + script: "test_web_storages.js", + }), + }, + }); + + await extension.startup(); + + const EXT_BASE_URL = `moz-extension://${extension.uuid}/`; + + return { extension, EXT_BASE_URL }; + } + + const cookieBehaviors = [ + "BEHAVIOR_LIMIT_FOREIGN", + "BEHAVIOR_REJECT_FOREIGN", + "BEHAVIOR_REJECT", + "BEHAVIOR_REJECT_TRACKER", + "BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN", + ]; + equal( + cookieBehaviors.length, + Ci.nsICookieService.BEHAVIOR_LAST, + "all behaviors should be covered" + ); + + for (const behavior of cookieBehaviors) { + info( + `Test extension page access to indexedDB & localStorage with ${behavior}` + ); + ok( + behavior in Ci.nsICookieService, + `${behavior} is a valid CookieBehavior` + ); + Services.prefs.setIntPref( + "network.cookie.cookieBehavior", + Ci.nsICookieService[behavior] + ); + + // Create a new extension to ensure that the cookieBehavior just set is going to be + // used for the requests triggered by the extension page. + const { extension, EXT_BASE_URL } = await createExtension(); + const extPage = await ExtensionTestUtils.loadContentPage("about:blank", { + extension, + remote: extension.extension.remote, + }); + + info("Test from a top level extension page"); + await extPage.loadURL(`${EXT_BASE_URL}page.html`); + + let testedFromURL = await extension.awaitMessage("test-storage:done"); + equal( + testedFromURL, + `${EXT_BASE_URL}page.html`, + "Got the results from the expected url" + ); + + info("Test from a sub frame extension page"); + await extPage.loadURL(`${EXT_BASE_URL}page_with_subframe.html`); + + testedFromURL = await extension.awaitMessage("test-storage:done"); + equal( + testedFromURL, + `${EXT_BASE_URL}page_subframe.html`, + "Got the results from the expected url" + ); + + await extPage.close(); + await extension.unload(); + } +}); + +add_task(async function test_ext_page_3rdparty_cookies() { + if (AppConstants.platform === "android") { + // TODO bug 1844702: Fix test_ext_page_3rdparty_cookies on Android. + info("Skipped test_ext_page_3rdparty_cookies"); + return; + } + // moz-extension:-document embeds http://example.com/page-with-tracker.html + allow_unsafe_parent_loads_when_extensions_not_remote(); + + // Disable tracking protection to test cookies on BEHAVIOR_REJECT_TRACKER + // (otherwise tracking protection would block the tracker iframe and + // we would not be actually checking the cookie behavior). + Services.prefs.setBoolPref("privacy.trackingprotection.enabled", false); + await UrlClassifierTestUtils.addTestTrackers(); + registerCleanupFunction(function () { + UrlClassifierTestUtils.cleanupTestTrackers(); + Services.prefs.clearUserPref("privacy.trackingprotection.enabled"); + Services.cookies.removeAll(); + }); + + function testRequestScript() { + browser.test.onMessage.addListener((msg, url) => { + const done = () => { + browser.test.sendMessage(`${msg}:done`); + }; + + switch (msg) { + case "xhr": { + let req = new XMLHttpRequest(); + req.onload = done; + req.open("GET", url); + req.send(); + break; + } + case "fetch": { + window.fetch(url).then(done); + break; + } + case "worker fetch": { + const worker = new Worker("test_worker.js"); + worker.onmessage = evt => { + if (evt.data.requestDone) { + done(); + } + }; + worker.postMessage({ url }); + break; + } + default: { + browser.test.fail(`Received an unexpected message: ${msg}`); + done(); + } + } + }); + + browser.test.sendMessage("testRequestScript:ready", window.location.href); + } + + function testWorker() { + this.onmessage = evt => { + fetch(evt.data.url).then(() => { + postMessage({ requestDone: true }); + }); + }; + } + + async function createExtension() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://example.com/*", "http://itisatracker.org/*"], + }, + files: { + "test_worker.js": testWorker, + "test_request.js": testRequestScript, + "page_subframe.html": createPage({ script: "test_request.js" }), + "page_with_subframe.html": createPage({ + body: '<iframe src="page_subframe.html"></iframe>', + }), + "page.html": createPage({ script: "test_request.js" }), + }, + }); + + await extension.startup(); + + const EXT_BASE_URL = `moz-extension://${extension.uuid}`; + + return { extension, EXT_BASE_URL }; + } + + const testUrl = "http://example.com/test-cookies"; + const testRequests = ["xhr", "fetch", "worker fetch"]; + const tests = [ + { behavior: "BEHAVIOR_ACCEPT", cookiesCount: 1 }, + { behavior: "BEHAVIOR_REJECT_FOREIGN", cookiesCount: 1 }, + { behavior: "BEHAVIOR_REJECT", cookiesCount: 0 }, + { behavior: "BEHAVIOR_LIMIT_FOREIGN", cookiesCount: 1 }, + { behavior: "BEHAVIOR_REJECT_TRACKER", cookiesCount: 1 }, + ]; + + function clearAllCookies() { + Services.cookies.removeAll(); + let cookies = Services.cookies.cookies; + equal(cookies.length, 0, "There shouldn't be any cookies after clearing"); + } + + async function runTestRequests(extension, cookiesCount, msg) { + for (const testRequest of testRequests) { + clearAllCookies(); + extension.sendMessage(testRequest, testUrl); + await extension.awaitMessage(`${testRequest}:done`); + assertCookiesForHost( + testUrl, + cookiesCount, + `${msg}: cookies count on ${testRequest} "${testUrl}"` + ); + } + } + + for (const { behavior, cookiesCount } of tests) { + info(`Test cookies on http requests with ${behavior}`); + ok( + behavior in Ci.nsICookieService, + `${behavior} is a valid CookieBehavior` + ); + Services.prefs.setIntPref( + "network.cookie.cookieBehavior", + Ci.nsICookieService[behavior] + ); + + // Create a new extension to ensure that the cookieBehavior just set is going to be + // used for the requests triggered by the extension page. + const { extension, EXT_BASE_URL } = await createExtension(); + + // Run all the test requests on a top level extension page. + let extPage = await ExtensionTestUtils.loadContentPage( + `${EXT_BASE_URL}/page.html`, + { + extension, + remote: extension.extension.remote, + } + ); + await extension.awaitMessage("testRequestScript:ready"); + await runTestRequests( + extension, + cookiesCount, + `Test top level extension page on ${behavior}` + ); + await extPage.close(); + + // Rerun all the test requests on a sub frame extension page. + extPage = await ExtensionTestUtils.loadContentPage( + `${EXT_BASE_URL}/page_with_subframe.html`, + { + extension, + remote: extension.extension.remote, + } + ); + await extension.awaitMessage("testRequestScript:ready"); + await runTestRequests( + extension, + cookiesCount, + `Test sub frame extension page on ${behavior}` + ); + await extPage.close(); + + await extension.unload(); + } + + // Test tracking url blocking from a webpage subframe. + info( + "Testing blocked tracker cookies in webpage subframe on BEHAVIOR_REJECT_TRACKERS" + ); + Services.prefs.setIntPref( + "network.cookie.cookieBehavior", + BEHAVIOR_REJECT_TRACKER + ); + + const trackerURL = "http://itisatracker.org/test-cookies"; + const { extension, EXT_BASE_URL } = await createExtension(); + const extPage = await ExtensionTestUtils.loadContentPage( + `${EXT_BASE_URL}/_generated_background_page.html`, + { + extension, + remote: extension.extension.remote, + } + ); + clearAllCookies(); + + await extPage.spawn( + ["http://example.com/page-with-tracker.html"], + async iframeURL => { + const iframe = this.content.document.createElement("iframe"); + iframe.setAttribute("src", iframeURL); + return new Promise(resolve => { + iframe.onload = () => resolve(); + this.content.document.body.appendChild(iframe); + }); + } + ); + + assertCookiesForHost( + trackerURL, + 0, + "Test cookies on web subframe inside top level extension page on BEHAVIOR_REJECT_TRACKER" + ); + clearAllCookies(); + + await extPage.close(); + await extension.unload(); + + revert_allow_unsafe_parent_loads_when_extensions_not_remote(); +}); + +// Test that a webpage embedded as a subframe of an extension page is not allowed to use +// IndexedDB and register a ServiceWorker when it shouldn't be based on the cookieBehavior. +add_task( + async function test_webpage_subframe_storage_respect_cookiesBehavior() { + if (Services.appinfo.fissionAutostart) { + // TODO bug 1762638: Fix this test. It fails because it tries to read + // properties through .contentWindow cross-origin. That doesn't work with + // Fission enabled; Should spawn tasks in individual frames instead. + info("Skipped test_webpage_subframe_storage_respect_cookiesBehavior"); + return; + } + // moz-extension://[uuid]/toplevel.html loads example.com/subframe.html + allow_unsafe_parent_loads_when_extensions_not_remote(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://example.com/*"], + web_accessible_resources: ["subframe.html"], + }, + files: { + "toplevel.html": createPage({ + body: ` + <iframe id="ext" src="subframe.html"></iframe> + <iframe id="web" src="http://example.com/subframe.html"></iframe> + `, + }), + "subframe.html": createPage(), + }, + }); + + Services.prefs.setIntPref("network.cookie.cookieBehavior", BEHAVIOR_REJECT); + + await extension.startup(); + + let extensionPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/toplevel.html`, + { + extension, + remote: extension.extension.remote, + } + ); + + let results = await extensionPage.spawn([], async () => { + let extFrame = this.content.document.querySelector("iframe#ext"); + let webFrame = this.content.document.querySelector("iframe#web"); + + function testIDB(win) { + try { + void win.indexedDB; + return { success: true }; + } catch (err) { + return { error: `${err}` }; + } + } + + async function testServiceWorker(win) { + try { + await win.navigator.serviceWorker.register("sw.js"); + return { success: true }; + } catch (err) { + return { error: `${err}` }; + } + } + + return { + extTopLevel: testIDB(this.content), + // TODO bug 1762638: Execute the following in their own tasks. + extSubFrame: testIDB(extFrame.contentWindow), + webSubFrame: testIDB(webFrame.contentWindow), + webServiceWorker: await testServiceWorker(webFrame.contentWindow), + }; + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/subframe.html" + ); + + results.extSubFrameContent = await contentPage.spawn( + [extension.uuid], + uuid => { + return new Promise(resolve => { + let frame = this.content.document.createElement("iframe"); + frame.setAttribute("src", `moz-extension://${uuid}/subframe.html`); + frame.onload = () => { + try { + void frame.contentWindow.indexedDB; + resolve({ success: true }); + } catch (err) { + resolve({ error: `${err}` }); + } + }; + this.content.document.body.appendChild(frame); + }); + } + ); + + Assert.deepEqual( + results.extTopLevel, + { success: true }, + "IndexedDB allowed in a top level extension page" + ); + + Assert.deepEqual( + results.extSubFrame, + { success: true }, + "IndexedDB allowed in a subframe extension page with a top level extension page" + ); + + Assert.deepEqual( + results.webSubFrame, + { error: "SecurityError: The operation is insecure." }, + "IndexedDB not allowed in a subframe webpage with a top level extension page" + ); + Assert.deepEqual( + results.webServiceWorker, + { error: "SecurityError: The operation is insecure." }, + "IndexedDB and Cache not allowed in a service worker registered in the subframe webpage extension page" + ); + + Assert.deepEqual( + results.extSubFrameContent, + { success: true }, + "IndexedDB allowed in a subframe extension page with a top level webpage" + ); + + await extensionPage.close(); + await contentPage.close(); + + await extension.unload(); + + revert_allow_unsafe_parent_loads_when_extensions_not_remote(); + } +); + +// Test that the webpage's indexedDB and localStorage are still not allowed from a content script +// when the cookie behavior doesn't allow it, even when they are allowed in the extension pages. +add_task(async function test_content_script_on_cookieBehaviorReject() { + Services.prefs.setIntPref("network.cookie.cookieBehavior", BEHAVIOR_REJECT); + + function contentScript() { + // Ensure that when the current cookieBehavior doesn't allow a webpage to use indexedDB + // or localStorage, then a WebExtension content script is not allowed to use it as well. + browser.test.assertThrows( + () => indexedDB, + /The operation is insecure/, + "a content script can't use indexedDB from a page where it is disallowed" + ); + + browser.test.assertThrows( + () => localStorage, + /The operation is insecure/, + "a content script can't use localStorage from a page where it is disallowed" + ); + + browser.test.notifyPass("cs_disallowed_storage"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + }, + ], + }, + files: { + "content_script.js": contentScript, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + await extension.awaitFinish("cs_disallowed_storage"); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(function clear_cookieBehavior_pref() { + Services.prefs.clearUserPref("network.cookie.cookieBehavior"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cookies_errors.js b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_errors.js new file mode 100644 index 0000000000..1c40f2f73f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_errors.js @@ -0,0 +1,168 @@ +"use strict"; + +add_task(async function setup_cookies() { + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + async background() { + const url = "http://example.com/"; + const name = "dummyname"; + await browser.cookies.set({ url, name, value: "from_setup:normal" }); + await browser.cookies.set({ + url, + name, + value: "from_setup:private", + storeId: "firefox-private", + }); + await browser.cookies.set({ + url, + name, + value: "from_setup:container", + storeId: "firefox-container-1", + }); + browser.test.sendMessage("setup_done"); + }, + manifest: { + permissions: ["cookies", "http://example.com/"], + }, + }); + await extension.startup(); + await extension.awaitMessage("setup_done"); + await extension.unload(); +}); + +add_task(async function test_error_messages() { + async function background() { + const url = "http://example.com/"; + const name = "dummyname"; + // Shorthands to minimize boilerplate. + const set = d => browser.cookies.set({ url, name, value: "x", ...d }); + const remove = d => browser.cookies.remove({ url, name, ...d }); + const get = d => browser.cookies.get({ url, name, ...d }); + const getAll = d => browser.cookies.getAll(d); + + // Host permission permission missing. + await browser.test.assertRejects( + set({}), + /^Permission denied to set cookie \{.*\}$/, + "cookies.set without host permissions rejects with error" + ); + browser.test.assertEq( + null, + await remove({}), + "cookies.remove without host permissions does not remove any cookies" + ); + browser.test.assertEq( + null, + await get({}), + "cookies.get without host permissions does not match any cookies" + ); + browser.test.assertEq( + "[]", + JSON.stringify(await getAll({})), + "cookies.getAll without host permissions does not match any cookies" + ); + + // Private browsing cookies without access to private browsing mode. + await browser.test.assertRejects( + set({ storeId: "firefox-private" }), + "Extension disallowed access to the private cookies storeId.", + "cookies.set cannot modify private cookies without permission" + ); + await browser.test.assertRejects( + remove({ storeId: "firefox-private" }), + "Extension disallowed access to the private cookies storeId.", + "cookies.remove cannot modify private cookies without permission" + ); + await browser.test.assertRejects( + get({ storeId: "firefox-private" }), + "Extension disallowed access to the private cookies storeId.", + "cookies.get cannot read private cookies without permission" + ); + await browser.test.assertRejects( + getAll({ storeId: "firefox-private" }), + "Extension disallowed access to the private cookies storeId.", + "cookies.getAll cannot read private cookies without permission" + ); + + // On Android, any firefox-container-... is treated as valid, so it doesn't + // result in an error. However, because the test extension does not have + // any host permissions, it will fail with an error any way (but a + // different one than expected). + // TODO bug 1743616: Fix implementation and this test. + const kErrorInvalidContainer = navigator.userAgent.includes("Android") + ? /Permission denied to set cookie/ + : `Invalid cookie store id: "firefox-container-99"`; + + // Invalid storeId. + await browser.test.assertRejects( + set({ storeId: "firefox-container-99" }), + kErrorInvalidContainer, + "cookies.set with invalid storeId (non-existent container)" + ); + + await browser.test.assertRejects( + set({ storeId: "0" }), + `Invalid cookie store id: "0"`, + "cookies.set with invalid storeId (format not recognized)" + ); + + for (let method of [remove, get, getAll]) { + let resultWithInvalidStoreId = method == getAll ? [] : null; + browser.test.assertEq( + JSON.stringify(await method({ storeId: "firefox-container-99" })), + JSON.stringify(resultWithInvalidStoreId), + `cookies.${method.name} with invalid storeId (non-existent container)` + ); + + browser.test.assertEq( + JSON.stringify(await method({ storeId: "0" })), + JSON.stringify(resultWithInvalidStoreId), + `cookies.${method.name} with invalid storeId (format not recognized)` + ); + } + + browser.test.sendMessage("test_done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["cookies"], + }, + }); + await extension.startup(); + await extension.awaitMessage("test_done"); + await extension.unload(); +}); + +add_task(async function expected_cookies_at_end_of_test() { + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + async background() { + async function checkCookie(storeId, value) { + let cookies = await browser.cookies.getAll({ storeId }); + let index = cookies.findIndex(c => c.value === value); + browser.test.assertTrue(index !== -1, `Found cookie: ${value}`); + if (index >= 0) { + cookies.splice(index, 1); + } + browser.test.assertEq( + "[]", + JSON.stringify(cookies), + `No more cookies left in cookieStoreId=${storeId}` + ); + } + // Added in setup. + await checkCookie("firefox-default", "from_setup:normal"); + await checkCookie("firefox-private", "from_setup:private"); + await checkCookie("firefox-container-1", "from_setup:container"); + browser.test.sendMessage("final_check_done"); + }, + manifest: { + permissions: ["cookies", "<all_urls>"], + }, + }); + await extension.startup(); + await extension.awaitMessage("final_check_done"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cookies_firstParty.js b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_firstParty.js new file mode 100644 index 0000000000..77bd94d9c4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_firstParty.js @@ -0,0 +1,348 @@ +"use strict"; + +const server = createHttpServer({ + hosts: ["example.org", "example.net", "example.com"], +}); + +function promiseSetCookies() { + return new Promise(resolve => { + server.registerPathHandler("/setCookies", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.setHeader("Set-Cookie", "none=a; sameSite=none", true); + response.setHeader("Set-Cookie", "lax=b; sameSite=lax", true); + response.setHeader("Set-Cookie", "strict=c; sameSite=strict", true); + response.write("<html></html>"); + resolve(); + }); + }); +} + +function promiseLoadedCookies() { + return new Promise(resolve => { + let cookies; + + server.registerPathHandler("/checkCookies", (request, response) => { + cookies = request.hasHeader("Cookie") ? request.getHeader("Cookie") : ""; + + response.setStatusLine(request.httpVersion, 302, "Moved Permanently"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.setHeader("Location", "/ready"); + }); + + server.registerPathHandler("/navigate", (request, response) => { + cookies = request.hasHeader("Cookie") ? request.getHeader("Cookie") : ""; + + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write( + "<html><script>location = '/checkCookies';</script></html>" + ); + }); + + server.registerPathHandler("/fetch", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + // /checkCookies ultimately redirects to /ready, which resolves the + // promise returned by promiseLoadedCookies(). At that point, the test + // can choose to close the ContentPage that hosts us, and abort the + // pending fetch(). When extensions.webextensions.remote is false, the + // PromiseTestUtils.assertNoUncaughtRejections() check may detect this + // and cause the test to fail unexpectedly. To avoid this issue, we catch + // the error unconditionally. + response.write(`<html><script> + fetch("/checkCookies").catch(e => console.log("fetch() error: " + e)); + </script></html>`); + }); + + server.registerPathHandler("/nestedfetch", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write( + "<html><iframe src='http://example.net/nestedfetch2'></iframe></html>" + ); + }); + + server.registerPathHandler("/nestedfetch2", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write( + "<html><iframe src='http://example.org/fetch'></iframe></html>" + ); + }); + + server.registerPathHandler("/ready", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write("<html></html>"); + + resolve(cookies); + }); + }); +} + +add_task(async function setup() { + Services.prefs.setIntPref("network.cookie.cookieBehavior", 0); + Services.prefs.setBoolPref("network.cookie.sameSite.laxByDefault", true); + + // We don't want to have 'secure' cookies because our test http server doesn't run in https. + Services.prefs.setBoolPref( + "network.cookie.sameSite.noneRequiresSecure", + false + ); + + // Let's set 3 cookies before loading the extension. + let cookiesPromise = promiseSetCookies(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.org/setCookies" + ); + await cookiesPromise; + await contentPage.close(); + Assert.equal(Services.cookies.cookies.length, 3); +}); + +add_task(async function test_cookies_firstParty() { + // Loads a http:-URL in moz-extension://[uuid]/page.html + allow_unsafe_parent_loads_when_extensions_not_remote(); + + async function pageScript() { + const ifr = document.createElement("iframe"); + ifr.src = "http://example.org/" + location.search.slice(1); + document.body.appendChild(ifr); + } + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["*://example.org/"], + }, + files: { + "page.html": `<body><script src="page.js"></script></body>`, + "page.js": pageScript, + }, + }); + + await extension.startup(); + + // This page will load example.org in an iframe. + let url = `moz-extension://${extension.uuid}/page.html`; + let cookiesPromise = promiseLoadedCookies(); + let contentPage = await ExtensionTestUtils.loadContentPage( + url + "?checkCookies", + { extension } + ); + + // Let's check the cookies received during the last loading. + Assert.equal(await cookiesPromise, "none=a; lax=b; strict=c"); + await contentPage.close(); + + // Let's navigate. + cookiesPromise = promiseLoadedCookies(); + contentPage = await ExtensionTestUtils.loadContentPage(url + "?navigate", { + extension, + }); + + // Let's check the cookies received during the last loading. + Assert.equal(await cookiesPromise, "none=a; lax=b; strict=c"); + await contentPage.close(); + + // Let's run a fetch() + cookiesPromise = promiseLoadedCookies(); + contentPage = await ExtensionTestUtils.loadContentPage(url + "?fetch", { + extension, + }); + + // Let's check the cookies received during the last loading. + Assert.equal(await cookiesPromise, "none=a; lax=b; strict=c"); + await contentPage.close(); + + // Let's run a fetch() from a nested iframe (extension -> example.net -> + // example.org -> fetch) + cookiesPromise = promiseLoadedCookies(); + contentPage = await ExtensionTestUtils.loadContentPage(url + "?nestedfetch", { + extension, + }); + + // Let's check the cookies received during the last loading. + Assert.equal(await cookiesPromise, "none=a"); + await contentPage.close(); + + // Let's run a fetch() from a nested iframe (extension -> example.org -> fetch) + cookiesPromise = promiseLoadedCookies(); + contentPage = await ExtensionTestUtils.loadContentPage( + url + "?nestedfetch2", + { + extension, + } + ); + + // Let's check the cookies received during the last loading. + Assert.equal(await cookiesPromise, "none=a; lax=b; strict=c"); + await contentPage.close(); + + await extension.unload(); + + revert_allow_unsafe_parent_loads_when_extensions_not_remote(); +}); + +add_task(async function test_cookies_iframes() { + server.registerPathHandler("/echocookies", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write( + request.hasHeader("Cookie") ? request.getHeader("Cookie") : "" + ); + }); + + server.registerPathHandler("/contentScriptHere", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write("<html></html>"); + }); + + server.registerPathHandler("/pageWithFrames", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + + response.write(` + <html> + <iframe src="http://example.com/contentScriptHere"></iframe> + <iframe src="http://example.net/contentScriptHere"></iframe> + </html> + `); + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["*://example.org/"], + content_scripts: [ + { + js: ["contentScript.js"], + matches: [ + "*://example.com/contentScriptHere", + "*://example.net/contentScriptHere", + ], + run_at: "document_end", + all_frames: true, + }, + ], + }, + files: { + "contentScript.js": async () => { + const res = await fetch("http://example.org/echocookies"); + const cookies = await res.text(); + browser.test.assertEq( + "none=a", + cookies, + "expected cookies in content script" + ); + browser.test.sendMessage("extfetch:" + location.hostname); + }, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/pageWithFrames" + ); + await Promise.all([ + extension.awaitMessage("extfetch:example.com"), + extension.awaitMessage("extfetch:example.net"), + ]); + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_cookies_background() { + async function background() { + const res = await fetch("http://example.org/echocookies", { + credentials: "include", + }); + const cookies = await res.text(); + browser.test.sendMessage("fetchcookies", cookies); + } + + const tests = [ + { + permissions: ["http://example.org/*"], + cookies: "none=a; lax=b; strict=c", + }, + { + permissions: [], + cookies: "none=a", + }, + ]; + + for (let test of tests) { + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: test.permissions, + }, + }); + + server.registerPathHandler("/echocookies", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.setHeader( + "Access-Control-Allow-Origin", + `moz-extension://${extension.uuid}`, + false + ); + response.setHeader("Access-Control-Allow-Credentials", "true", false); + response.write( + request.hasHeader("Cookie") ? request.getHeader("Cookie") : "" + ); + }); + + await extension.startup(); + equal( + await extension.awaitMessage("fetchcookies"), + test.cookies, + "extension with permissions can see SameSite-restricted cookies" + ); + + await extension.unload(); + } +}); + +add_task(async function test_cookies_contentScript() { + server.registerPathHandler("/empty", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write("<html><body></body></html>"); + }); + + async function contentScript() { + let res = await fetch("http://example.org/checkCookies"); + browser.test.assertEq(location.origin + "/ready", res.url, "request OK"); + browser.test.sendMessage("fetch-done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + run_at: "document_end", + js: ["contentscript.js"], + matches: ["*://*/*"], + }, + ], + }, + files: { + "contentscript.js": contentScript, + }, + }); + + await extension.startup(); + + let cookiesPromise = promiseLoadedCookies(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.org/empty" + ); + await extension.awaitMessage("fetch-done"); + + // Let's check the cookies received during the last loading. + Assert.equal(await cookiesPromise, "none=a; lax=b; strict=c"); + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cookies_onChanged.js b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_onChanged.js new file mode 100644 index 0000000000..6eef222297 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_onChanged.js @@ -0,0 +1,142 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +// In this test, we want to check the behavior of extensions without private +// browsing access. Privileged add-ons automatically have private browsing +// access, so make sure that the test add-ons are not privileged. +AddonTestUtils.usePrivilegedSignatures = false; + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); + + Services.prefs.setBoolPref("extensions.eventPages.enabled", true); +}); + +function createTestExtension({ privateAllowed }) { + return ExtensionTestUtils.loadExtension({ + incognitoOverride: privateAllowed ? "spanning" : null, + manifest: { + permissions: ["cookies"], + host_permissions: ["https://example.com/"], + background: { persistent: false }, + }, + background() { + browser.cookies.onChanged.addListener(changeInfo => { + browser.test.sendMessage("cookie-event", changeInfo); + }); + }, + }); +} + +function addAndRemoveCookie({ isPrivate }) { + const cookie = { + name: "cookname", + value: "cookvalue", + domain: "example.com", + hostOnly: true, + path: "/", + secure: true, + httpOnly: false, + sameSite: "lax", + session: false, + firstPartyDomain: "", + partitionKey: null, + expirationDate: Date.now() + 3600000, + storeId: isPrivate ? "firefox-private" : "firefox-default", + }; + const originAttributes = { privateBrowsingId: isPrivate ? 1 : 0 }; + Services.cookies.add( + cookie.domain, + cookie.path, + cookie.name, + cookie.value, + cookie.secure, + cookie.httpOnly, + cookie.session, + cookie.expirationDate, + originAttributes, + Ci.nsICookie.SAMESITE_LAX, + Ci.nsICookie.SCHEME_HTTPS + ); + Services.cookies.remove( + cookie.domain, + cookie.name, + cookie.path, + originAttributes + ); + return cookie; +} + +add_task(async function test_onChanged_event_page() { + let nonPrivateExtension = createTestExtension({ privateAllowed: false }); + let privateExtension = createTestExtension({ privateAllowed: true }); + await privateExtension.startup(); + await nonPrivateExtension.startup(); + assertPersistentListeners(privateExtension, "cookies", "onChanged", { + primed: false, + }); + assertPersistentListeners(nonPrivateExtension, "cookies", "onChanged", { + primed: false, + }); + + // Suspend both event pages. + await privateExtension.terminateBackground(); + assertPersistentListeners(privateExtension, "cookies", "onChanged", { + primed: true, + }); + await nonPrivateExtension.terminateBackground(); + assertPersistentListeners(nonPrivateExtension, "cookies", "onChanged", { + primed: true, + }); + + // Modifying a private cookie should wake up the private extension, but not + // the other one that does not have access to private browsing data. + let privateCookie = addAndRemoveCookie({ isPrivate: true }); + + Assert.deepEqual( + await privateExtension.awaitMessage("cookie-event"), + { removed: false, cookie: privateCookie, cause: "explicit" }, + "cookies.onChanged for private cookie creation" + ); + Assert.deepEqual( + await privateExtension.awaitMessage("cookie-event"), + { removed: true, cookie: privateCookie, cause: "explicit" }, + "cookies.onChanged for private cookie removal" + ); + // Private extension should have awakened... + assertPersistentListeners(privateExtension, "cookies", "onChanged", { + primed: false, + }); + // ... but the non-private extension should still be sound asleep. + assertPersistentListeners(nonPrivateExtension, "cookies", "onChanged", { + primed: true, + }); + + // A non-private cookie modification should notify both extensions. + let nonPrivateCookie = addAndRemoveCookie({ isPrivate: false }); + Assert.deepEqual( + await privateExtension.awaitMessage("cookie-event"), + { removed: false, cookie: nonPrivateCookie, cause: "explicit" }, + "cookies.onChanged for cookie creation in privateExtension" + ); + Assert.deepEqual( + await privateExtension.awaitMessage("cookie-event"), + { removed: true, cookie: nonPrivateCookie, cause: "explicit" }, + "cookies.onChanged for cookie removal in privateExtension" + ); + Assert.deepEqual( + await nonPrivateExtension.awaitMessage("cookie-event"), + { removed: false, cookie: nonPrivateCookie, cause: "explicit" }, + "cookies.onChanged for cookie creation in nonPrivateExtension" + ); + Assert.deepEqual( + await nonPrivateExtension.awaitMessage("cookie-event"), + { removed: true, cookie: nonPrivateCookie, cause: "explicit" }, + "cookies.onChanged for cookie removal in nonPrivateCookie" + ); + + await privateExtension.unload(); + await nonPrivateExtension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cookies_partitionKey.js b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_partitionKey.js new file mode 100644 index 0000000000..7a4170a51e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_partitionKey.js @@ -0,0 +1,895 @@ +"use strict"; + +/** + * This test verifies that the extension API's access to cookies is consistent + * with the cookies as seen by web pages under the following modes: + * - Every top-level document shares the same cookie jar, every subdocument of + * the top-level document has a distinct cookie jar tied to the site of the + * top-level document (dFPI). + * - All documents have a cookie jar keyed by the domain of the top-level + * document (FPI). + * - All cookies are in one cookie jar (classic behavior = no FPI nor dFPI) + * + * FPI and dFPI are implemented using OriginAttributes, and historically the + * consequence of not recognizing an origin attribute is that cookies cannot be + * deleted. Hence, the functionality of the cookies API is verified as follows, + * by the testCookiesAPI/runTestCase methods. + * + * 1. Load page that creates cookies for the top and a framed document: + * - "delete_me" + * - "edit_me" + * 2. cookies.getAll: get all cookies with extension API. + * 3. cookies.remove: Remove "delete_me" cookies with the extension API. + * 4. cookies.set: Edit "edit_me" cookie with the extension API. + * 5. Verify that the web page can see "edit_me" cookie (via document.cookie). + * 6. cookies.get: "edit_me" is still present. + * 7. cookies.remove: "edit_me" can be removed. + * 8. cookies.getAll: no cookies left. + */ + +const FIRST_DOMAIN = "first.example.com"; +const FIRST_DOMAIN_ETLD_PLUS_1 = "example.com"; +const FIRST_DOMAIN_ETLD_PLUS_MANY = "nested.under.first.example.com"; +const THIRD_PARTY_DOMAIN = "third.example.net"; +const server = createHttpServer({ + hosts: [FIRST_DOMAIN, FIRST_DOMAIN_ETLD_PLUS_MANY, THIRD_PARTY_DOMAIN], +}); +const LOCAL_IP_AND_PORT = `127.0.0.1:${server.identity.primaryPort}`; + +server.registerPathHandler("/top", (request, response) => { + response.setHeader("Set-Cookie", `delete_me=top; SameSite=none`); + response.setHeader("Set-Cookie", `edit_me=top; SameSite=none`, true); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write( + `<!DOCTYPE html><iframe src="//third.example.net/framed"></iframe>` + ); +}); +server.registerPathHandler("/framed", (request, response) => { + response.setHeader("Set-Cookie", `delete_me=frame; SameSite=none`); + response.setHeader("Set-Cookie", `edit_me=frame; SameSite=none`, true); +}); + +// Background script of the extension that drives the test. +// It first waits for the content scripts in /top and /framed to connect, +// in order to verify that cookie operations by the extension API are reflected +// to the web page (verified through document.cookie from the content script). +function backgroundScript() { + let portsByDomain = new Map(); + + async function getDocumentCookies(port) { + return new Promise(resolve => { + port.onMessage.addListener(function listener(cookieString) { + port.onMessage.removeListener(listener); + resolve(cookieString); + }); + port.postMessage("get_cookies"); + }); + } + + // Stringify cookie identifier for comparisons in assertions. + function stringifyCookie(cookie) { + if (!cookie) { + return "COOKIE MISSING"; + } + let domain = cookie.domain; + if (!domain) { + // The return value of `cookies.remove` has a URL instead of a domain. + domain = new URL(cookie.url).hostname; + } + return `${cookie.name} domain=${domain} firstPartyDomain=${ + cookie.firstPartyDomain + } partitionKey=${JSON.stringify(cookie.partitionKey)}`; + } + function stringifyCookies(cookies) { + return cookies.map(stringifyCookie).sort().join(" , "); + } + + // detailsIn may have partitionKey and firstPartyDomain attributes. + // expectedOut has partitionKey and firstPartyDomain attributes. + async function runTestCase({ domain, detailsIn, expectedOut }) { + const port = portsByDomain.get(domain); + browser.test.assertTrue(port, `Got port to document for ${domain}`); + + let allCookies = await browser.cookies.getAll({ + domain, + firstPartyDomain: null, + partitionKey: {}, + }); + + let allCookiesWithFPD = await browser.cookies.getAll({ + domain, + ...detailsIn, + }); + browser.test.assertEq( + stringifyCookies(allCookies), + stringifyCookies(allCookiesWithFPD), + "cookies.getAll returns consistent results" + ); + + for (let [key, expectedValue] of Object.entries(expectedOut)) { + expectedValue = JSON.stringify(expectedValue); + browser.test.assertTrue( + allCookies.every(c => JSON.stringify(c[key]) === expectedValue), + `All ${allCookies.length} cookies have ${key}=${expectedValue}` + ); + } + + // delete_me: get, remove, get. + const cookieToDelete = { + url: `http://${domain}/`, + name: "delete_me", + ...detailsIn, + }; + const deletedCookie = { + ...cookieToDelete, + ...expectedOut, + }; + browser.test.assertEq( + stringifyCookie(deletedCookie), + stringifyCookie(await browser.cookies.get(cookieToDelete)), + "delete_me cookie exists before removal" + ); + browser.test.assertEq( + stringifyCookie(deletedCookie), + stringifyCookie(await browser.cookies.remove(cookieToDelete)), + "delete_me cookie has been removed by cookies.remove" + ); + browser.test.assertEq( + null, + await browser.cookies.get(cookieToDelete), + "delete_me cookie does not exist any more" + ); + + // edit_me: set, retrieve via document.cookie + const cookieToEdit = { + url: `http://${domain}/`, + name: "edit_me", + ...detailsIn, + }; + const editedCookie = await browser.cookies.set({ + ...cookieToEdit, + value: `new_value_${domain}`, + }); + browser.test.assertEq( + stringifyCookie({ ...cookieToEdit, ...expectedOut }), + stringifyCookie(editedCookie), + "edit_me cookie updated" + ); + browser.test.assertEq( + await getDocumentCookies(port), + `edit_me=new_value_${domain}`, + "Expected cookies after removing and editing a cookie" + ); + + // edit_me: get, remove, getAll. + browser.test.assertEq( + stringifyCookie(editedCookie), + stringifyCookie(await browser.cookies.get(cookieToEdit)), + "edit_me cookie still exists" + ); + await browser.cookies.remove(cookieToEdit); + let allCookiesAtEnd = await browser.cookies.getAll({ + domain, + firstPartyDomain: null, + partitionKey: {}, + }); + browser.test.assertEq( + "[]", + JSON.stringify(allCookiesAtEnd), + "No cookies left" + ); + } + + let resolveTestReady; + let testReadyPromise = new Promise(resolve => { + resolveTestReady = resolve; + }); + + browser.test.onMessage.addListener(async (msg, testCase) => { + await testReadyPromise; + browser.test.assertEq("runTest", msg, `Starting: ${testCase.description}`); + try { + await runTestCase(testCase); + } catch (e) { + browser.test.fail(`Unexpected error: ${e} :: ${e.stack}`); + } + browser.test.sendMessage("runTest_done"); + }); + + // cookie-checker-contentscript.js will connect. + browser.runtime.onConnect.addListener(port => { + portsByDomain.set(port.name, port); + browser.test.log(`Got port #${portsByDomain.size} ${port.name}`); + if (portsByDomain.size === 2) { + // The top document and the embedded frame has loaded and the + // content script that we use to read cookies is connected. + // The test can now start. + resolveTestReady(); + } + }); +} + +// The primary purpose of this test is to verify that the cookies API can read +// and write cookies that are actually in use by the web page. +async function testCookiesAPI({ testCases, topDomain = FIRST_DOMAIN }) { + let extension = ExtensionTestUtils.loadExtension({ + background: backgroundScript, + manifest: { + permissions: [ + "cookies", + // Remove port to work around bug 1350523. + `*://${topDomain.replace(/:\d+$/, "")}/*`, + `*://${THIRD_PARTY_DOMAIN}/*`, + ], + content_scripts: [ + { + js: ["cookie-checker-contentscript.js"], + matches: [ + // Remove port to work around bug 1362809. + `*://${topDomain.replace(/:\d+$/, "")}/top`, + `*://${THIRD_PARTY_DOMAIN}/framed`, + ], + all_frames: true, + run_at: "document_end", + }, + ], + }, + files: { + "cookie-checker-contentscript.js": () => { + const port = browser.runtime.connect({ name: location.hostname }); + port.onMessage.addListener(msg => { + browser.test.assertEq(msg, "get_cookies", "Expected port message"); + port.postMessage(document.cookie); + }); + }, + }, + }); + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `http://${topDomain}/top` + ); + for (let testCase of testCases) { + info(`Running test case: ${testCase.description}`); + extension.sendMessage("runTest", testCase); + await extension.awaitMessage("runTest_done"); + } + await contentPage.close(); + await extension.unload(); +} + +add_task(async function setup() { + // SameSite=none is needed to set cookies in third-party contexts. + // SameSite=none usually requires Secure, but the test server doesn't support + // https, so disable the Secure requirement for SameSite=none. + Services.prefs.setBoolPref( + "network.cookie.sameSite.noneRequiresSecure", + false + ); +}); + +add_task(async function test_no_partitioning() { + const testCases = [ + { + description: "first-party cookies without any partitioning", + domain: FIRST_DOMAIN, + detailsIn: { + firstPartyDomain: "", + partitionKey: null, + }, + expectedOut: { + firstPartyDomain: "", + partitionKey: null, + }, + }, + { + description: "third-party cookies without any partitioning", + domain: THIRD_PARTY_DOMAIN, + detailsIn: { + // Without (d)FPI, firstPartyDomain and partitionKey are optional. + }, + expectedOut: { + firstPartyDomain: "", + partitionKey: null, + }, + }, + ]; + await runWithPrefs( + // dFPI is enabled by default on Nightly, disable it. + [["network.cookie.cookieBehavior", 4]], + () => testCookiesAPI({ testCases }) + ); +}); + +add_task(async function test_firstPartyIsolate() { + const testCases = [ + { + description: "first-party cookies with FPI", + domain: FIRST_DOMAIN, + detailsIn: { + firstPartyDomain: FIRST_DOMAIN_ETLD_PLUS_1, + }, + expectedOut: { + firstPartyDomain: FIRST_DOMAIN_ETLD_PLUS_1, + partitionKey: null, + }, + }, + { + description: "third-party cookies with FPI", + domain: THIRD_PARTY_DOMAIN, + detailsIn: { + firstPartyDomain: FIRST_DOMAIN_ETLD_PLUS_1, + }, + expectedOut: { + firstPartyDomain: FIRST_DOMAIN_ETLD_PLUS_1, + partitionKey: null, + }, + }, + ]; + await runWithPrefs( + [ + // FPI is mutually exclusive with dFPI. Disable dFPI. + ["network.cookie.cookieBehavior", 4], + ["privacy.firstparty.isolate", true], + ], + () => testCookiesAPI({ testCases }) + ); +}); + +add_task(async function test_dfpi() { + const testCases = [ + { + description: "first-party cookies with dFPI", + domain: FIRST_DOMAIN, + detailsIn: { + // partitionKey is optional and expected to default to unpartitioned. + }, + expectedOut: { + firstPartyDomain: "", + partitionKey: null, + }, + }, + { + description: "third-party cookies with dFPI", + domain: THIRD_PARTY_DOMAIN, + detailsIn: { + partitionKey: { topLevelSite: `http://${FIRST_DOMAIN_ETLD_PLUS_1}` }, + }, + expectedOut: { + firstPartyDomain: "", + partitionKey: { topLevelSite: `http://${FIRST_DOMAIN_ETLD_PLUS_1}` }, + }, + }, + ]; + await runWithPrefs( + // Enable dFPI; 5 = BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN. + [["network.cookie.cookieBehavior", 5]], + () => testCookiesAPI({ testCases }) + ); +}); + +add_task(async function test_dfpi_with_ip_and_port() { + const testCases = [ + { + description: "first-party cookies for IP with port", + domain: "127.0.0.1", + detailsIn: { + partitionKey: null, + }, + expectedOut: { + firstPartyDomain: "", + partitionKey: null, + }, + }, + { + description: "third-party cookies for IP with port", + domain: THIRD_PARTY_DOMAIN, + detailsIn: { + partitionKey: { topLevelSite: `http://${LOCAL_IP_AND_PORT}` }, + }, + expectedOut: { + firstPartyDomain: "", + partitionKey: { topLevelSite: `http://${LOCAL_IP_AND_PORT}` }, + }, + }, + ]; + await runWithPrefs( + // Enable dFPI; 5 = BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN. + [["network.cookie.cookieBehavior", 5]], + () => testCookiesAPI({ testCases, topDomain: LOCAL_IP_AND_PORT }) + ); +}); + +add_task(async function test_dfpi_with_nested_subdomains() { + const testCases = [ + { + description: "first-party cookies with DFPI at eTLD+many", + domain: FIRST_DOMAIN_ETLD_PLUS_MANY, + detailsIn: { + partitionKey: null, + }, + expectedOut: { + firstPartyDomain: "", + partitionKey: null, + }, + }, + { + description: "third-party cookies for first party with eTLD+many", + domain: THIRD_PARTY_DOMAIN, + detailsIn: { + // Partitioned cookies are keyed by eTLD+1, so even if eTLD+many is + // passed, then eTLD+1 is stored (and returned). + partitionKey: { topLevelSite: `http://${FIRST_DOMAIN_ETLD_PLUS_MANY}` }, + }, + expectedOut: { + firstPartyDomain: "", + partitionKey: { topLevelSite: `http://${FIRST_DOMAIN_ETLD_PLUS_1}` }, + }, + }, + ]; + await runWithPrefs( + // Enable dFPI; 5 = BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN. + [["network.cookie.cookieBehavior", 5]], + () => testCookiesAPI({ testCases, topDomain: FIRST_DOMAIN_ETLD_PLUS_MANY }) + ); +}); + +add_task(async function test_dfpi_with_non_default_use_site() { + // privacy.dynamic_firstparty.use_site is a pref that can be used to toggle + // the internal representation of partitionKey. True (default) means keyed + // by site (scheme, host, port); false means keyed by host only. + const testCases = [ + { + description: "first-party cookies with dFPI and use_site=false", + domain: FIRST_DOMAIN, + detailsIn: { + partitionKey: null, + }, + expectedOut: { + firstPartyDomain: "", + partitionKey: null, + }, + }, + { + description: "third-party cookies with dFPI and use_site=false", + domain: THIRD_PARTY_DOMAIN, + detailsIn: { + partitionKey: { topLevelSite: `http://${FIRST_DOMAIN_ETLD_PLUS_1}` }, + }, + expectedOut: { + firstPartyDomain: "", + // When use_site=false, the scheme is not stored, and the + // implementation just prepends "https" as a dummy scheme. + partitionKey: { topLevelSite: `https://${FIRST_DOMAIN_ETLD_PLUS_1}` }, + }, + }, + ]; + await runWithPrefs( + [ + // Enable dFPI; 5 = BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN. + ["network.cookie.cookieBehavior", 5], + ["privacy.dynamic_firstparty.use_site", false], + ], + () => testCookiesAPI({ testCases }) + ); +}); +add_task(async function test_dfpi_with_ip_and_port_and_non_default_use_site() { + // privacy.dynamic_firstparty.use_site is a pref that can be used to toggle + // the internal representation of partitionKey. True (default) means keyed + // by site (scheme, host, port); false means keyed by host only. + const testCases = [ + { + description: "first-party cookies for IP:port with dFPI+use_site=false", + domain: "127.0.0.1", + detailsIn: { + partitionKey: null, + }, + expectedOut: { + firstPartyDomain: "", + partitionKey: null, + }, + }, + { + description: "third-party cookies for IP:port with dFPI+use_site=false", + domain: THIRD_PARTY_DOMAIN, + detailsIn: { + // When use_site=false, the scheme is not stored in the internal + // representation of the partitionKey. So even though the web page + // creates the cookie at HTTP, the cookies are still detected when + // "https" is used. + partitionKey: { topLevelSite: `https://${LOCAL_IP_AND_PORT}` }, + }, + expectedOut: { + firstPartyDomain: "", + // When use_site=false, the scheme and port are not stored. + // "https" is used as a dummy scheme, and the port is not used. + partitionKey: { topLevelSite: "https://127.0.0.1" }, + }, + }, + ]; + await runWithPrefs( + [ + // Enable dFPI; 5 = BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN. + ["network.cookie.cookieBehavior", 5], + ["privacy.dynamic_firstparty.use_site", false], + ], + () => testCookiesAPI({ testCases, topDomain: LOCAL_IP_AND_PORT }) + ); +}); + +add_task(async function dfpi_invalid_partitionKey() { + AddonTestUtils.init(globalThis); + AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" + ); + // The test below uses the browser.privacy API, which relies on + // ExtensionSettingsStore, which in turn depends on AddonManager. + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + permissions: ["cookies", "*://example.com/*", "privacy"], + }, + async background() { + const url = "http://example.com/"; + const name = "dfpi_invalid_partitionKey_dummy_name"; + const value = "1"; + + // Shorthands to minimize boilerplate. + const set = d => browser.cookies.set({ url, name, value, ...d }); + const remove = d => browser.cookies.remove({ url, name, ...d }); + const get = d => browser.cookies.get({ url, name, ...d }); + const getAll = d => browser.cookies.getAll(d); + + await browser.test.assertRejects( + set({ partitionKey: { topLevelSite: "example.net" } }), + /Invalid value for 'partitionKey' attribute/, + "partitionKey must be a URL, not a domain" + ); + await browser.test.assertRejects( + set({ partitionKey: { topLevelSite: "chrome://foo" } }), + /Invalid value for 'partitionKey' attribute/, + "partitionKey cannot be the chrome:-scheme (canonicalization fails)" + ); + await browser.test.assertRejects( + set({ partitionKey: { topLevelSite: "chrome://foo/foo/foo" } }), + /Invalid value for 'partitionKey' attribute/, + "partitionKey cannot be the chrome:-scheme (canonicalization passes)" + ); + await browser.test.assertRejects( + set({ partitionKey: { topLevelSite: "http://[]:" } }), + /Invalid value for 'partitionKey' attribute/, + "partitionKey must be a valid URL" + ); + + browser.test.assertThrows( + () => get({ partitionKey: "" }), + /Error processing partitionKey: Expected object instead of ""/, + "cookies.get should reject invalid partitionKey (string)" + ); + browser.test.assertThrows( + () => get({ partitionKey: { topLevelSite: "http://x", badkey: 0 } }), + /Error processing partitionKey: Unexpected property "badkey"/, + "cookies.get should reject unsupported keys in partitionKey" + ); + await browser.test.assertRejects( + remove({ partitionKey: { topLevelSite: "invalid" } }), + /Invalid value for 'partitionKey' attribute/, + "cookies.remove should reject invalid partitionKey.topLevelSite" + ); + await browser.test.assertRejects( + get({ partitionKey: { topLevelSite: "invalid" } }), + /Invalid value for 'partitionKey' attribute/, + "cookies.get should reject invalid partitionKey.topLevelSite" + ); + await browser.test.assertRejects( + getAll({ partitionKey: { topLevelSite: "invalid" } }), + /Invalid value for 'partitionKey' attribute/, + "cookies.getAll should reject invalid partitionKey.topLevelSite" + ); + + // firstPartyDomain and partitionKey are mutually exclusive, because + // FPI and dFPI are mutually exclusive. + await browser.test.assertRejects( + set({ firstPartyDomain: "example.net", partitionKey: {} }), + /Partitioned cookies cannot have a 'firstPartyDomain' attribute./, + "partitionKey and firstPartyDomain cannot both be non-empty" + ); + + // On Nightly, dFPI is enabled by default. We have to disable it first, + // before we can enable FPI. Otherwise we would get error: + // Can't enable firstPartyIsolate when cookieBehavior is 'reject_trackers_and_partition_foreign' + await browser.privacy.websites.cookieConfig.set({ + value: { behavior: "reject_trackers" }, + }); + await browser.privacy.websites.firstPartyIsolate.set({ + value: true, + }); + + // FPI and dFPI are mutually exclusive. FPI is documented to require the + // firstPartyDomain attribute, let's verify that, despite it being + // technically possible to support both attributes. + for (let cookiesMethod of [get, getAll, remove, set]) { + await browser.test.assertRejects( + cookiesMethod({ partitionKey: { topLevelSite: url } }), + /First-Party Isolation is enabled, but the required 'firstPartyDomain' attribute was not set./, + `cookies.${cookiesMethod.name} requires firstPartyDomain when FPI is enabled` + ); + } + + // The pref changes above (to dFPI/FPI) via the browser.privacy API will + // be undone when the extension unloads. + + browser.test.sendMessage("test_done"); + }, + }); + await extension.startup(); + await extension.awaitMessage("test_done"); + await extension.unload(); + + await AddonTestUtils.promiseShutdownManager(); +}); + +add_task(async function dfpi_moz_extension() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["cookies", "*://example.com/*"], + }, + async background() { + let cookie = await browser.cookies.set({ + url: "http://example.com/", + name: "moz_ext_party", + value: "1", + // moz-extension: URL is passed here, in an attempt to mark the cookie + // as part of the "moz-extension:"-partition. Below we will expect "" + // because the dFPI implementation treats "moz-extension" as + // unpartitioned, see + // https://searchfox.org/mozilla-central/rev/ac7da6c7306d86e2f86a302ce1e170ad54b3c1fe/caps/OriginAttributes.cpp#79-82 + partitionKey: { topLevelSite: browser.runtime.getURL("/") }, + }); + browser.test.assertEq( + null, + cookie.partitionKey, + "Cookies in moz-extension:-URL are unpartitioned" + ); + let deletedCookie = await browser.cookies.remove({ + url: "http://example.com/", + name: "moz_ext_party", + partitionKey: { topLevelSite: "moz-extension://ignoreme" }, + }); + browser.test.assertEq( + null, + deletedCookie.partitionKey, + "moz-extension:-partition key is treated as unpartitioned" + ); + browser.test.sendMessage("test_done"); + }, + }); + await extension.startup(); + await extension.awaitMessage("test_done"); + await extension.unload(); +}); + +add_task(async function dfpi_about_scheme_as_partitionKey() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["cookies", "*://example.com/*"], + }, + async background() { + let cookie = await browser.cookies.set({ + url: "http://example.com/", + name: "moz_ext_party", + value: "1", + partitionKey: { topLevelSite: "about:blank" }, + }); + // It doesn't really make sense to partition in `about:blank` (since it + // cannot really be a first party), but for completeness of test coverage + // we also check that the use of an about:-scheme results in predictable + // behavior. The weird "about://"-URL below is the serialization of the + // internal value of the partitionKey attribute: + // https://searchfox.org/mozilla-central/rev/ac7da6c7306d86e2f86a302ce1e170ad54b3c1fe/caps/OriginAttributes.cpp#73-77 + browser.test.assertEq( + "about://about.ef2a7dd5-93bc-417f-a698-142c3116864f.mozilla", + cookie.partitionKey.topLevelSite, + "An URL-like representation of the internal about:-format is returned" + ); + let deletedCookie = await browser.cookies.remove({ + url: "http://example.com/", + name: "moz_ext_party", + partitionKey: { + topLevelSite: + "about://about.ef2a7dd5-93bc-417f-a698-142c3116864f.mozilla", + }, + }); + browser.test.assertEq( + "about://about.ef2a7dd5-93bc-417f-a698-142c3116864f.mozilla", + deletedCookie.partitionKey.topLevelSite, + "Cookie can be deleted via the dummy about:-scheme" + ); + browser.test.sendMessage("test_done"); + }, + }); + await extension.startup(); + await extension.awaitMessage("test_done"); + await extension.unload(); +}); + +// Same-site frames are expected to be unpartitioned. +// The cookies API can receive partitionKey and url that are same-site. While +// such cookies won't be sent to websites in practice, we do want to verify that +// the behavior is predictable. +add_task(async function test_url_is_same_site_as_partitionKey() { + // This loads a page with a frame at third.example.net (= THIRD_PARTY_DOMAIN). + let contentPage = await ExtensionTestUtils.loadContentPage( + `http://${THIRD_PARTY_DOMAIN}/top` + ); + await contentPage.close(); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["cookies", "*://third.example.net/"], + }, + async background() { + // Retrieve all cookies, partitioned and unpartitioned. We expect only + // unpartitioned cookies at first because the top frame and the child + // frame have the same origin. + let initialCookies = await browser.cookies.getAll({ partitionKey: {} }); + browser.test.assertEq( + "delete_me=frame,edit_me=frame", + initialCookies.map(c => `${c.name}=${c.value}`).join(), + "Same-site frames are in unpartitioned storage; /frame overwrites /top" + ); + browser.test.assertTrue( + await browser.cookies.remove({ + url: "https://third.example.net/", + name: "delete_me", + }), + "Removed unpartitioned cookie" + ); + browser.test.assertEq( + "[null,null]", + JSON.stringify(initialCookies.map(c => c.partitionKey)), + "Cookies in same-site/same-origin frames are not partitioned" + ); + + // We only have one unpartitioned cookie (edit_cookie) left. + + // Add new cookie whose partitionKey is same-site relative to url. + let newCookie = await browser.cookies.set({ + url: "http://third.example.net/", + name: "edit_me", + value: "url_is_partitionKey_eTLD+2", + partitionKey: { topLevelSite: "http://third.example.net" }, + }); + browser.test.assertEq( + "http://example.net", + newCookie.partitionKey.topLevelSite, + "Created cookie with partitionKey=url; eTLD+2 is normalized as eTLD+1" + ); + + browser.test.assertTrue( + await browser.cookies.remove({ + url: "http://third.example.net/", + name: "edit_me", + partitionKey: {}, + }), + "Removed unpartitioned cookie when partitionKey: {} is used" + ); + + browser.test.assertEq( + null, + await browser.cookies.remove({ + url: "http://third.example.net/", + name: "edit_me", + partitionKey: {}, + }), + "No more unpartitioned cookies to remove" + ); + + browser.test.assertTrue( + await browser.cookies.remove({ + url: "http://third.example.net/", + name: "edit_me", + partitionKey: { topLevelSite: "http://example.net" }, + }), + "Removed partitioned cookie when partitionKey is passed" + ); + + browser.test.sendMessage("test_done"); + }, + }); + await extension.startup(); + await extension.awaitMessage("test_done"); + await extension.unload(); +}); + +add_task(async function test_getAll_partitionKey() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["cookies", "*://third.example.net/"], + }, + async background() { + const url = "http://third.example.net"; + const name = "test_url_is_identical_to_partitionKey"; + const partitionKey = { topLevelSite: "http://example.com" }; + const firstPartyDomain = "example.net"; + + // Create non-partitioned cookie, create partitioned cookie. + await browser.cookies.set({ url, name, value: "no_partition" }); + await browser.cookies.set({ url, name, value: "fpd", firstPartyDomain }); + await browser.cookies.set({ url, name, partitionKey, value: "party" }); + // partitionKey + firstPartyDomain was tested in dfpi_invalid_partitionKey + + async function getAllValues(details) { + let cookies = await browser.cookies.getAll(details); + let values = cookies.map(c => c.value); + return values.sort().join(); // Serialize for use with assertEq. + } + + browser.test.assertEq( + "no_partition", + await getAllValues({}), + "getAll() returns unpartitioned by default" + ); + + browser.test.assertEq( + "no_partition,party", + await getAllValues({ partitionKey: {} }), + "getAll() with partitionKey: {} returns all cookies" + ); + + browser.test.assertEq( + "party", + await getAllValues({ partitionKey }), + "getAll() with specific partitionKey returns partitionKey cookies only" + ); + + browser.test.assertEq( + "", + await getAllValues({ partitionKey: { topLevelSite: url } }), + "getAll() with partitionKey set to cookie URL does not match anything" + ); + + browser.test.assertEq( + "", + await getAllValues({ partitionKey, firstPartyDomain }), + "getAll() with non-empty partitionKey and firstPartyDomain does not match anything" + ); + browser.test.assertEq( + "fpd", + await getAllValues({ partitionKey: {}, firstPartyDomain }), + "getAll() with empty partitionKey and firstPartyDomain matches fpd" + ); + + browser.test.assertEq( + "fpd,no_partition,party", + await getAllValues({ partitionKey: {}, firstPartyDomain: null }), + "getAll() with empty partitionKey and firstPartyDomain:null matches everything" + ); + + await browser.cookies.remove({ url, name }); + await browser.cookies.remove({ url, name, firstPartyDomain }); + await browser.cookies.remove({ url, name, partitionKey }); + + browser.test.sendMessage("test_done"); + }, + }); + await extension.startup(); + await extension.awaitMessage("test_done"); + await extension.unload(); +}); + +add_task(async function no_unexpected_cookies_at_end_of_test() { + let results = []; + for (const cookie of Services.cookies.cookies) { + results.push({ + name: cookie.name, + value: cookie.value, + host: cookie.host, + originAttributes: cookie.originAttributes, + }); + } + Assert.deepEqual(results, [], "Test should not leave any cookies"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cookies_samesite.js b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_samesite.js new file mode 100644 index 0000000000..618ed820d4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_samesite.js @@ -0,0 +1,114 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.org"] }); +server.registerPathHandler("/sameSiteCookiesApiTest", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +add_task(async function test_samesite_cookies() { + // Bug 1617611 - Fix all the tests broken by "cookies SameSite=Lax by default" + Services.prefs.setBoolPref("network.cookie.sameSite.laxByDefault", false); + + function contentScript() { + document.cookie = "test1=whatever"; + document.cookie = "test2=whatever; SameSite=lax"; + document.cookie = "test3=whatever; SameSite=strict"; + browser.runtime.sendMessage("do-check-cookies"); + } + async function background() { + await new Promise(resolve => { + browser.runtime.onMessage.addListener(msg => { + browser.test.assertEq("do-check-cookies", msg, "expected message"); + resolve(); + }); + }); + + const url = "https://example.org/"; + + // Baseline. Every cookie must have the expected sameSite. + let cookie = await browser.cookies.get({ url, name: "test1" }); + browser.test.assertEq( + "no_restriction", + cookie.sameSite, + "Expected sameSite for test1" + ); + + cookie = await browser.cookies.get({ url, name: "test2" }); + browser.test.assertEq( + "lax", + cookie.sameSite, + "Expected sameSite for test2" + ); + + cookie = await browser.cookies.get({ url, name: "test3" }); + browser.test.assertEq( + "strict", + cookie.sameSite, + "Expected sameSite for test3" + ); + + // Testing cookies.getAll + cookies.set + let cookies = await browser.cookies.getAll({ url, name: "test3" }); + browser.test.assertEq(1, cookies.length, "There is only one test3 cookie"); + + cookie = await browser.cookies.set({ + url, + name: "test3", + value: "newvalue", + }); + browser.test.assertEq( + "no_restriction", + cookie.sameSite, + "sameSite defaults to no_restriction" + ); + + for (let sameSite of ["no_restriction", "lax", "strict"]) { + cookie = await browser.cookies.set({ url, name: "test3", sameSite }); + browser.test.assertEq( + sameSite, + cookie.sameSite, + `Expected sameSite=${sameSite} in return value of cookies.set` + ); + cookies = await browser.cookies.getAll({ url, name: "test3" }); + browser.test.assertEq( + 1, + cookies.length, + `test3 is still the only cookie after setting sameSite=${sameSite}` + ); + browser.test.assertEq( + sameSite, + cookies[0].sameSite, + `test3 was updated to sameSite=${sameSite}` + ); + } + + browser.test.notifyPass("cookies"); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["cookies", "*://example.org/"], + content_scripts: [ + { + matches: ["*://example.org/sameSiteCookiesApiTest*"], + js: ["contentscript.js"], + }, + ], + }, + files: { + "contentscript.js": contentScript, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.org/sameSiteCookiesApiTest" + ); + await extension.awaitFinish("cookies"); + await contentPage.close(); + await extension.unload(); + + Services.prefs.clearUserPref("network.cookie.sameSite.laxByDefault"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cors_mozextension.js b/toolkit/components/extensions/test/xpcshell/test_ext_cors_mozextension.js new file mode 100644 index 0000000000..fc50388c77 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_cors_mozextension.js @@ -0,0 +1,220 @@ +"use strict"; + +const server = createHttpServer({ + hosts: ["example.com", "x.example.com"], +}); +server.registerPathHandler("/dummy", (req, res) => { + res.write("dummy"); +}); +server.registerPathHandler("/redir", (req, res) => { + res.setStatusLine(req.httpVersion, 302, "Found"); + res.setHeader("Access-Control-Allow-Origin", "http://example.com"); + res.setHeader("Access-Control-Allow-Credentials", "true"); + res.setHeader("Location", new URLSearchParams(req.queryString).get("url")); +}); + +add_task(async function load_moz_extension_with_and_without_cors() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + web_accessible_resources: ["ok.js"], + }, + files: { + "ok.js": "window.status = 'loaded';", + "deny.js": "window.status = 'unexpected load'", + }, + }); + await extension.startup(); + const EXT_BASE_URL = `moz-extension://${extension.uuid}`; + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await contentPage.spawn([EXT_BASE_URL], async EXT_BASE_URL => { + const { document, window } = this.content; + async function checkScriptLoad({ setupScript, expectLoad, description }) { + const scriptElem = document.createElement("script"); + setupScript(scriptElem); + return new Promise(resolve => { + window.status = "initial"; + scriptElem.onload = () => { + Assert.equal(window.status, "loaded", "Script executed upon load"); + Assert.ok(expectLoad, `Script loaded - ${description}`); + resolve(); + }; + scriptElem.onerror = () => { + Assert.equal(window.status, "initial", "not executed upon error"); + Assert.ok(!expectLoad, `Script not loaded - ${description}`); + resolve(); + }; + document.head.append(scriptElem); + }); + } + + function sameOriginRedirectUrl(url) { + return `http://example.com/redir?url=` + encodeURIComponent(url); + } + function crossOriginRedirectUrl(url) { + return `http://x.example.com/redir?url=` + encodeURIComponent(url); + } + + // Direct load of web-accessible extension script. + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = `${EXT_BASE_URL}/ok.js`; + }, + expectLoad: true, + description: "web-accessible script, plain load", + }); + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = `${EXT_BASE_URL}/ok.js`; + scriptElem.crossOrigin = "anonymous"; + }, + expectLoad: true, + description: "web-accessible script, cors", + }); + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = `${EXT_BASE_URL}/ok.js`; + scriptElem.crossOrigin = "use-credentials"; + }, + expectLoad: true, + description: "web-accessible script, cors+credentials", + }); + + // Load of web-accessible extension scripts, after same-origin redirect. + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = sameOriginRedirectUrl(`${EXT_BASE_URL}/ok.js`); + }, + expectLoad: true, + description: "same-origin redirect to web-accessible script, plain load", + }); + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = sameOriginRedirectUrl(`${EXT_BASE_URL}/ok.js`); + scriptElem.crossOrigin = "anonymous"; + }, + expectLoad: true, + description: "same-origin redirect to web-accessible script, cors", + }); + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = sameOriginRedirectUrl(`${EXT_BASE_URL}/ok.js`); + scriptElem.crossOrigin = "use-credentials"; + }, + expectLoad: true, + description: + "same-origin redirect to web-accessible script, cors+credentials", + }); + + // Load of web-accessible extension scripts, after cross-origin redirect. + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = crossOriginRedirectUrl(`${EXT_BASE_URL}/ok.js`); + }, + expectLoad: true, + description: "cross-origin redirect to web-accessible script, plain load", + }); + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = crossOriginRedirectUrl(`${EXT_BASE_URL}/ok.js`); + scriptElem.crossOrigin = "anonymous"; + }, + expectLoad: true, + description: "cross-origin redirect to web-accessible script, cors", + }); + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = crossOriginRedirectUrl(`${EXT_BASE_URL}/ok.js`); + scriptElem.crossOrigin = "use-credentials"; + }, + expectLoad: true, + description: + "cross-origin redirect to web-accessible script, cors+credentials", + }); + + // Various loads of non-web-accessible extension script. + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = `${EXT_BASE_URL}/deny.js`; + }, + expectLoad: false, + description: "non-accessible script, plain load", + }); + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = `${EXT_BASE_URL}/deny.js`; + scriptElem.crossOrigin = "anonymous"; + }, + expectLoad: false, + description: "non-accessible script, cors", + }); + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = sameOriginRedirectUrl(`${EXT_BASE_URL}/deny.js`); + scriptElem.crossOrigin = "anonymous"; + }, + expectLoad: false, + description: "same-origin redirect to non-accessible script, cors", + }); + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = crossOriginRedirectUrl(`${EXT_BASE_URL}/deny.js`); + scriptElem.crossOrigin = "anonymous"; + }, + expectLoad: false, + description: "cross-origin redirect to non-accessible script, cors", + }); + + // Sub-resource integrity usually requires CORS. Verify that web-accessible + // extension resources are still subjected to SRI. + const sriHashOkJs = // SRI hash for "window.status = 'loaded';" (=ok.js). + "sha384-EAofaAZpgy6JshegITJJHeE3ROzn9ngGw1GAuuzjSJV1c/YS9PLvHMt9oh4RovrI"; + + async function testSRI({ integrityMatches }) { + const integrity = integrityMatches ? sriHashOkJs : "sha384-bad-sri-hash"; + const sriDescription = integrityMatches + ? "web-accessible script, good sri, " + : "web-accessible script, sri not matching, "; + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = `${EXT_BASE_URL}/ok.js`; + scriptElem.integrity = integrity; + }, + expectLoad: integrityMatches, + description: `${sriDescription} no cors, plain load`, + }); + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = `${EXT_BASE_URL}/ok.js`; + scriptElem.crossOrigin = "anonymous"; + scriptElem.integrity = integrity; + }, + expectLoad: integrityMatches, + description: `${sriDescription} cors, plain load`, + }); + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = sameOriginRedirectUrl(`${EXT_BASE_URL}/ok.js`); + scriptElem.crossOrigin = "anonymous"; + scriptElem.integrity = integrity; + }, + expectLoad: integrityMatches, + description: `${sriDescription} cors, same-origin redirect`, + }); + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = crossOriginRedirectUrl(`${EXT_BASE_URL}/ok.js`); + scriptElem.crossOrigin = "anonymous"; + scriptElem.integrity = integrity; + }, + expectLoad: integrityMatches, + description: `${sriDescription} cors, cross-origin redirect`, + }); + } + await testSRI({ integrityMatches: true }); + await testSRI({ integrityMatches: false }); + }); + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_csp_frame_ancestors.js b/toolkit/components/extensions/test/xpcshell/test_ext_csp_frame_ancestors.js new file mode 100644 index 0000000000..ae931dfe06 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_csp_frame_ancestors.js @@ -0,0 +1,221 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const server = createHttpServer({ hosts: ["example.com", "example.net"] }); +server.registerPathHandler("/parent.html", (request, response) => { + let frameUrl = new URLSearchParams(request.queryString).get("iframe_src"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write(`<!DOCTYPE html><iframe src="${frameUrl}"></iframe>`); +}); + +// Loads an extension frame as a frame at ancestorOrigins[0], which in turn is +// a child of ancestorOrigins[1], etc. +// The frame should either load successfully, or trigger exactly one failure due +// to one of the ancestorOrigins being blocked by the content_security_policy. +async function checkExtensionLoadInFrame({ + ancestorOrigins, + content_security_policy, + expectLoad, +}) { + const extensionData = { + manifest: { + content_security_policy, + web_accessible_resources: ["parent.html", "frame.html"], + }, + files: { + "frame.html": `<!DOCTYPE html><script src="frame.js"></script>`, + "frame.js": () => { + browser.test.sendMessage("frame_load_completed"); + }, + "parent.html": `<!DOCTYPE html><body><script src="parent.js"></script>`, + "parent.js": () => { + let iframe = document.createElement("iframe"); + iframe.src = new URLSearchParams(location.search).get("iframe_src"); + document.body.append(iframe); + }, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + const EXTENSION_FRAME_URL = `moz-extension://${extension.uuid}/frame.html`; + + // ancestorOrigins is a list of origins, from the parent up to the top frame. + let topUrl = EXTENSION_FRAME_URL; + for (let origin of ancestorOrigins) { + if (origin === "EXTENSION_ORIGIN") { + origin = `moz-extension://${extension.uuid}`; + } + // origin is either the origin for |server| or the test extension. Both + // endpoints serve a page at parent.html that embeds iframe_src. + topUrl = `${origin}/parent.html?iframe_src=${encodeURIComponent(topUrl)}`; + } + + let cspViolationObserver; + let cspViolationCount = 0; + let frameLoadedCount = 0; + let frameLoadOrFailedPromise = new Promise(resolve => { + extension.onMessage("frame_load_completed", () => { + ++frameLoadedCount; + resolve(); + }); + cspViolationObserver = { + observe(subject, topic, data) { + ++cspViolationCount; + Assert.equal(data, "frame-ancestors", "CSP violation directive"); + resolve(); + }, + }; + Services.obs.addObserver(cspViolationObserver, "csp-on-violate-policy"); + }); + + const contentPage = await ExtensionTestUtils.loadContentPage(topUrl); + + // Firstly, wait for the frame load to either complete or fail. + await frameLoadOrFailedPromise; + + // Secondly, do a round trip to the content process to make sure that any + // unexpected extra load/failures are observed. This is necessary, because + // the "csp-on-violate-policy" notification is triggered from the parent, + // while it may be possible for the load to continue in the child anyway. + // + // And while we are at it, this verifies that the CSP does not block regular + // reads of a file that's part of web_accessible_resources. For comparable + // results, the load should ideally happen in the parent of the extension + // frame, but contentPage.fetch only works in the top frame, so this does not + // work perfectly in case ancestorOrigins.length > 1. + // But that is OK, as we mainly care about unexpected frame loads/failures. + equal( + await contentPage.fetch(EXTENSION_FRAME_URL), + extensionData.files["frame.html"], + "web-accessible extension resource can still be read with fetch" + ); + + // Finally, clean up. + Services.obs.removeObserver(cspViolationObserver, "csp-on-violate-policy"); + await contentPage.close(); + await extension.unload(); + + if (expectLoad) { + equal(cspViolationCount, 0, "Expected no CSP violations"); + equal( + frameLoadedCount, + 1, + `Frame should accept ancestors (${ancestorOrigins}) in CSP: ${content_security_policy}` + ); + } else { + equal(cspViolationCount, 1, "Expected CSP violation count"); + equal( + frameLoadedCount, + 0, + `Frame should reject one of the ancestors (${ancestorOrigins}) in CSP: ${content_security_policy}` + ); + } +} + +add_task(async function test_frame_ancestors_missing_allows_self() { + await checkExtensionLoadInFrame({ + ancestorOrigins: ["EXTENSION_ORIGIN"], + content_security_policy: "default-src 'self'", // missing frame-ancestors. + expectLoad: true, // an extension can embed itself by default. + }); +}); + +add_task(async function test_frame_ancestors_self_allows_self() { + await checkExtensionLoadInFrame({ + ancestorOrigins: ["EXTENSION_ORIGIN"], + content_security_policy: "default-src 'self'; frame-ancestors 'self'", + expectLoad: true, + }); +}); + +add_task(async function test_frame_ancestors_none_blocks_self() { + await checkExtensionLoadInFrame({ + ancestorOrigins: ["EXTENSION_ORIGIN"], + content_security_policy: "default-src 'self'; frame-ancestors", + expectLoad: false, // frame-ancestors 'none' blocks extension frame. + }); +}); + +add_task(async function test_frame_ancestors_missing_allowed_in_web_page() { + await checkExtensionLoadInFrame({ + ancestorOrigins: ["http://example.com"], + content_security_policy: "default-src 'self'", // missing frame-ancestors + expectLoad: true, // Web page can embed web-accessible extension frames. + }); +}); + +add_task(async function test_frame_ancestors_self_blocked_in_web_page() { + await checkExtensionLoadInFrame({ + ancestorOrigins: ["http://example.com"], + content_security_policy: "default-src 'self'; frame-ancestors 'self'", + expectLoad: false, + }); +}); + +add_task(async function test_frame_ancestors_scheme_allowed_in_web_page() { + await checkExtensionLoadInFrame({ + ancestorOrigins: ["http://example.com"], + content_security_policy: "default-src 'self'; frame-ancestors http:", + expectLoad: true, + }); +}); + +add_task(async function test_frame_ancestors_origin_allowed_in_web_page() { + await checkExtensionLoadInFrame({ + ancestorOrigins: ["http://example.com"], + content_security_policy: + "default-src 'self'; frame-ancestors http://example.com", + expectLoad: true, + }); +}); + +add_task(async function test_frame_ancestors_mismatch_blocked_in_web_page() { + await checkExtensionLoadInFrame({ + ancestorOrigins: ["http://example.com"], + content_security_policy: + "default-src 'self'; frame-ancestors http://not.example.com", + expectLoad: false, + }); +}); + +add_task(async function test_frame_ancestors_top_mismatch_blocked() { + await checkExtensionLoadInFrame({ + ancestorOrigins: ["http://example.com", "http://example.net"], + content_security_policy: + "default-src 'self'; frame-ancestors http://example.com", + // example.com is allowed, but the top origin (example.net) is rejected. + expectLoad: false, + }); +}); + +add_task(async function test_frame_ancestors_parent_mismatch_blocked() { + await checkExtensionLoadInFrame({ + ancestorOrigins: ["http://example.net", "http://example.com"], + content_security_policy: + "default-src 'self'; frame-ancestors http://example.com", + // example.com is allowed, but the parent origin (example.net) is rejected. + expectLoad: false, + }); +}); + +add_task(async function test_frame_ancestors_middle_rejected() { + if (!WebExtensionPolicy.useRemoteWebExtensions) { + // This test load http://example.com in an extension page, which fails if + // extensions run in the parent process. This is not a default config on + // desktop, but see https://bugzilla.mozilla.org/show_bug.cgi?id=1724099 + info("Web pages cannot be loaded in extension page without OOP extensions"); + return; + } + await checkExtensionLoadInFrame({ + ancestorOrigins: ["http://example.com", "EXTENSION_ORIGIN"], + content_security_policy: + "default-src 'self'; frame-src http: 'self'; frame-ancestors 'self'", + // Although the top frame has the same origin as the extension, the load + // should be rejected anyway because there is a non-allowlisted origin in + // the middle (child of top frame, parent of extension frame). + expectLoad: false, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_csp_upgrade_requests.js b/toolkit/components/extensions/test/xpcshell/test_ext_csp_upgrade_requests.js new file mode 100644 index 0000000000..6780293f04 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_csp_upgrade_requests.js @@ -0,0 +1,74 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/", (req, res) => { + res.write("ok"); +}); + +add_setup(async () => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); +}); + +add_task(async function test_csp_upgrade() { + async function background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertEq( + details.url, + "https://example.com/", + "request upgraded and sent" + ); + browser.test.notifyPass(); + return { cancel: true }; + }, + { + urls: ["https://example.com/*"], + }, + ["blocking"] + ); + + await browser.test.assertRejects( + fetch("http://example.com/"), + "NetworkError when attempting to fetch resource.", + "request was upgraded" + ); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + temporarilyInstalled: true, + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions: ["*://example.com/*"], + permissions: ["webRequest", "webRequestBlocking"], + }, + }); + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task(async function test_csp_noupgrade() { + async function background() { + let req = await fetch("http://example.com/"); + browser.test.assertEq( + req.url, + "http://example.com/", + "request not upgraded" + ); + browser.test.notifyPass(); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + temporarilyInstalled: true, + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions: ["*://example.com/*"], + }, + }); + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_debugging_utils.js b/toolkit/components/extensions/test/xpcshell/test_ext_debugging_utils.js new file mode 100644 index 0000000000..4618ea44f7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_debugging_utils.js @@ -0,0 +1,318 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { ExtensionParent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" +); + +add_task(async function testExtensionDebuggingUtilsCleanup() { + const extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("background.ready"); + }, + }); + + const expectedEmptyDebugUtils = { + hiddenXULWindow: null, + cacheSize: 0, + }; + + let { hiddenXULWindow, debugBrowserPromises } = ExtensionParent.DebugUtils; + + deepEqual( + { hiddenXULWindow, cacheSize: debugBrowserPromises.size }, + expectedEmptyDebugUtils, + "No ExtensionDebugUtils resources has been allocated yet" + ); + + await extension.startup(); + + await extension.awaitMessage("background.ready"); + + hiddenXULWindow = ExtensionParent.DebugUtils.hiddenXULWindow; + deepEqual( + { hiddenXULWindow, cacheSize: debugBrowserPromises.size }, + expectedEmptyDebugUtils, + "No debugging resources has been yet allocated once the extension is running" + ); + + const fakeAddonActor = { + addonId: extension.id, + }; + + const anotherAddonActor = { + addonId: extension.id, + }; + + const waitFirstBrowser = + ExtensionParent.DebugUtils.getExtensionProcessBrowser(fakeAddonActor); + const waitSecondBrowser = + ExtensionParent.DebugUtils.getExtensionProcessBrowser(anotherAddonActor); + + const addonDebugBrowser = await waitFirstBrowser; + equal( + addonDebugBrowser.isRemoteBrowser, + extension.extension.remote, + "The addon debugging browser has the expected remote type" + ); + + equal( + await waitSecondBrowser, + addonDebugBrowser, + "Two addon debugging actors related to the same addon get the same browser element " + ); + + equal( + debugBrowserPromises.size, + 1, + "The expected resources has been allocated" + ); + + const nonExistentAddonActor = { + addonId: "non-existent-addon@test", + }; + + const waitRejection = ExtensionParent.DebugUtils.getExtensionProcessBrowser( + nonExistentAddonActor + ); + + await Assert.rejects( + waitRejection, + /Extension not found/, + "Reject with the expected message for non existent addons" + ); + + equal( + debugBrowserPromises.size, + 1, + "No additional debugging resources has been allocated" + ); + + await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser( + fakeAddonActor + ); + + equal( + debugBrowserPromises.size, + 1, + "The addon debugging browser is cached until all the related actors have released it" + ); + + await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser( + anotherAddonActor + ); + + hiddenXULWindow = ExtensionParent.DebugUtils.hiddenXULWindow; + + deepEqual( + { hiddenXULWindow, cacheSize: debugBrowserPromises.size }, + expectedEmptyDebugUtils, + "All the allocated debugging resources has been cleared" + ); + + await extension.unload(); +}); + +add_task(async function testExtensionDebuggingUtilsAddonReloaded() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "test-reloaded@test.mozilla.com", + }, + }, + }, + background() { + browser.test.sendMessage("background.ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("background.ready"); + + let fakeAddonActor = { + addonId: extension.id, + }; + + const addonDebugBrowser = + await ExtensionParent.DebugUtils.getExtensionProcessBrowser(fakeAddonActor); + equal( + addonDebugBrowser.isRemoteBrowser, + extension.extension.remote, + "The addon debugging browser has the expected remote type" + ); + equal( + ExtensionParent.DebugUtils.debugBrowserPromises.size, + 1, + "Got the expected number of requested debug browsers" + ); + + const { chromeDocument } = ExtensionParent.DebugUtils.hiddenXULWindow; + + Assert.strictEqual( + addonDebugBrowser.parentElement, + chromeDocument.documentElement, + "The addon debugging browser is part of the hiddenXULWindow chromeDocument" + ); + + await extension.unload(); + + // Install an extension with the same id to recreate for the DebugUtils + // conditions similar to an addon reloaded while the Addon Debugger is opened. + extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "test-reloaded@test.mozilla.com", + }, + }, + }, + background() { + browser.test.sendMessage("background.ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("background.ready"); + + equal( + ExtensionParent.DebugUtils.debugBrowserPromises.size, + 1, + "Got the expected number of requested debug browsers" + ); + + const newAddonDebugBrowser = + await ExtensionParent.DebugUtils.getExtensionProcessBrowser(fakeAddonActor); + + equal( + addonDebugBrowser, + newAddonDebugBrowser, + "The existent debugging browser has been reused" + ); + + equal( + newAddonDebugBrowser.isRemoteBrowser, + extension.extension.remote, + "The addon debugging browser has the expected remote type" + ); + + await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser( + fakeAddonActor + ); + + equal( + ExtensionParent.DebugUtils.debugBrowserPromises.size, + 0, + "All the addon debugging browsers has been released" + ); + + await extension.unload(); +}); + +add_task(async function testExtensionDebuggingUtilsWithMultipleAddons() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "test-addon-1@test.mozilla.com", + }, + }, + }, + background() { + browser.test.sendMessage("background.ready"); + }, + }); + let anotherExtension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "test-addon-2@test.mozilla.com", + }, + }, + }, + background() { + browser.test.sendMessage("background.ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("background.ready"); + + await anotherExtension.startup(); + await anotherExtension.awaitMessage("background.ready"); + + const fakeAddonActor = { + addonId: extension.id, + }; + + const anotherFakeAddonActor = { + addonId: anotherExtension.id, + }; + + const { DebugUtils } = ExtensionParent; + const debugBrowser = await DebugUtils.getExtensionProcessBrowser( + fakeAddonActor + ); + const anotherDebugBrowser = await DebugUtils.getExtensionProcessBrowser( + anotherFakeAddonActor + ); + + const chromeDocument = DebugUtils.hiddenXULWindow.chromeDocument; + + equal( + ExtensionParent.DebugUtils.debugBrowserPromises.size, + 2, + "Got the expected number of debug browsers requested" + ); + Assert.strictEqual( + debugBrowser.parentElement, + chromeDocument.documentElement, + "The first debug browser is part of the hiddenXUL chromeDocument" + ); + Assert.strictEqual( + anotherDebugBrowser.parentElement, + chromeDocument.documentElement, + "The second debug browser is part of the hiddenXUL chromeDocument" + ); + + await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser( + fakeAddonActor + ); + + equal( + ExtensionParent.DebugUtils.debugBrowserPromises.size, + 1, + "Got the expected number of debug browsers requested" + ); + + Assert.strictEqual( + anotherDebugBrowser.parentElement, + chromeDocument.documentElement, + "The second debug browser is still part of the hiddenXUL chromeDocument" + ); + + Assert.equal( + debugBrowser.parentElement, + null, + "The first debug browser has been removed from the hiddenXUL chromeDocument" + ); + + await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser( + anotherFakeAddonActor + ); + + Assert.equal( + anotherDebugBrowser.parentElement, + null, + "The second debug browser has been removed from the hiddenXUL chromeDocument" + ); + equal( + ExtensionParent.DebugUtils.debugBrowserPromises.size, + 0, + "All the addon debugging browsers has been released" + ); + + await extension.unload(); + await anotherExtension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_allowAllRequests.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_allowAllRequests.js new file mode 100644 index 0000000000..ccb380180f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_allowAllRequests.js @@ -0,0 +1,1231 @@ +"use strict"; + +// This file tests whether the "allowAllRequests" action is correctly applied +// to subresource requests. The relative precedence to other actions/extensions +// is tested in test_ext_dnr_testMatchOutcome.js, specifically by test tasks +// rule_priority_and_action_type_precedence and +// action_precedence_between_extensions. + +ChromeUtils.defineESModuleGetters(this, { + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +add_setup(() => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); +}); + +const server = createHttpServer({ + hosts: ["example.com", "example.net", "example.org"], +}); +server.registerPathHandler("/never_reached", (req, res) => { + Assert.ok(false, "Server should never have been reached"); +}); +server.registerPathHandler("/allowed", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Max-Age", "0"); + // Any test that is able to check the response body will be able to assert + // the response body's value. Let's use "fetchAllowed" so that the compared + // values are obvious when assertEq/assertDeepEq are used. + res.write("fetchAllowed"); +}); +server.registerPathHandler("/", (req, res) => { + res.write("Dummy page"); +}); +server.registerPathHandler("/echo_html", (req, res) => { + let code = decodeURIComponent(req.queryString); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + if (req.hasHeader("prependhtml")) { + code = req.getHeader("prependhtml") + code; + } + res.write(`<!DOCTYPE html>${code}`); +}); +server.registerPathHandler("/bfcache_test", (req, res) => { + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.write(`<body><script> + // false at initial load, true when loaded from bfcache. + onpageshow = e => document.body.textContent = e.persisted; + </script>`); +}); + +async function waitForRequestAtServer(path) { + return new Promise(resolve => { + let callCount = 0; + server.registerPathHandler(path, (req, res) => { + Assert.equal(++callCount, 1, `Got one request for: ${path}`); + res.processAsync(); + resolve({ req, res }); + }); + }); +} + +// Several tests expect fetch() to fail due to the request being blocked. +// They can use testLoadInFrame({ ..., expectedError: FETCH_BLOCKED }). +const FETCH_BLOCKED = + "TypeError: NetworkError when attempting to fetch resource."; + +function urlEchoHtml(domain, html) { + return `http://${domain}/echo_html?${encodeURIComponent(html)}`; +} + +function htmlEscape(html) { + return html + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +// Values for domains in testLoadInFrame. +const ABOUT_SRCDOC_SAME_ORIGIN = "about:srcdoc (same-origin)"; +const ABOUT_SRCDOC_CROSS_ORIGIN = "about:srcdoc (cross-origin)"; + +async function testLoadInFrame({ + description, + // domains[0] = main frame, every extra item is a child frame. + domains = ["example.com"], + htmlPrependedToEachFrame = "", + // jsForFrame will be serialized and run in the deepest frame. + jsForFrame, + // The expected (potentially async) return value of jsForFrame. + expectedResult, + // The expected (potentially async) error thrown from jsForFrame. + expectedError, +}) { + const frameJs = async jsForFrame => { + let result = {}; + try { + result.returnValue = await jsForFrame(); + } catch (e) { + result.error = String(e); + } + // jsForFrame may return "delay_postMessage" to postpone the resolution of + // the promise. When the test is ready to resume, `top.postMessage()` can + // be called with the result, from any frame. This would also happen if the + // URL generated by this testLoadInFrame helper are re-used, e.g. by a new + // navigation to the URL that triggers a return value from jsForFrame that + // differs from "delay_postMessage". + if (result.returnValue !== "delay_postMessage") { + top.postMessage(result, "*"); + } + }; + const frameHtml = `<body><script>(${frameJs})(${jsForFrame})</script>`; + + // Construct the frame tree so that domains[0] is the main frame, and + // domains[domains.length - 1] is the deepest level frame (if any). + + const [mainFrameDomain, ...subFramesDomains] = domains; + + // The loop below generates the HTML for the deepest frame first, so we have + // to reverse the list of domains. + subFramesDomains.reverse(); + + let html = frameHtml; + for (let domain of subFramesDomains) { + html = htmlPrependedToEachFrame + html; + if (domain === ABOUT_SRCDOC_SAME_ORIGIN) { + html = `<iframe srcdoc="${htmlEscape(html)}"></iframe>`; + } else if (domain === ABOUT_SRCDOC_CROSS_ORIGIN) { + html = `<iframe srcdoc="${htmlEscape( + html + )}" sandbox="allow-scripts"></iframe>`; + } else { + html = `<iframe src="${urlEchoHtml(domain, html)}"></iframe>`; + } + } + + const mainFrameJs = () => { + window.resultPromise = new Promise(resolve => { + window.onmessage = e => resolve(e.data); + }); + }; + const mainFrameHtml = `<script>(${mainFrameJs})()</script>${html}`; + const mainFrameUrl = urlEchoHtml(mainFrameDomain, mainFrameHtml); + + let contentPage = await ExtensionTestUtils.loadContentPage(mainFrameUrl); + let result = await contentPage.spawn([], () => { + return content.wrappedJSObject.resultPromise; + }); + await contentPage.close(); + if (expectedError) { + Assert.deepEqual(result, { error: expectedError }, description); + } else { + Assert.deepEqual(result, { returnValue: expectedResult }, description); + } +} + +async function loadExtensionWithDNRRules( + rules, + { + // host_permissions is only required for modifyHeaders/redirect, or when + // "declarativeNetRequestWithHostAccess" is used. + host_permissions = [], + permissions = ["declarativeNetRequest"], + } = {} +) { + async function background(rules) { + try { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: rules, + }); + } catch (e) { + browser.test.fail(`Failed to register DNR rules: ${e} :: ${e.stack}`); + } + browser.test.sendMessage("dnr_registered"); + } + let extension = ExtensionTestUtils.loadExtension({ + background: `(${background})(${JSON.stringify(rules)})`, + temporarilyInstalled: true, // Needed for granted_host_permissions + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions, + permissions, + }, + }); + await extension.startup(); + await extension.awaitMessage("dnr_registered"); + return extension; +} + +add_task(async function allowAllRequests_allows_request() { + let extension = await loadExtensionWithDNRRules([ + // allowAllRequests should take precedence over block. + { + id: 1, + condition: { resourceTypes: ["main_frame", "xmlhttprequest"] }, + action: { type: "block" }, + }, + { + id: 2, + condition: { resourceTypes: ["main_frame"] }, + action: { type: "allowAllRequests" }, + }, + { + id: 3, + priority: 2, + // Note: when not specified, main_frame is excluded by default. So + // when a main_frame request is triggered, only rules 1 and 2 match. + condition: { requestDomains: ["example.com"] }, + action: { type: "block" }, + }, + ]); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/" + ); + Assert.equal( + await contentPage.spawn([], () => content.document.URL), + "http://example.com/", + "main_frame request should have been allowed by allowAllRequests" + ); + + async function checkCanFetch(url) { + return contentPage.spawn([url], async url => { + try { + return await (await content.fetch(url)).text(); + } catch (e) { + return e.toString(); + } + }); + } + + Assert.equal( + await checkCanFetch("http://example.com/never_reached"), + FETCH_BLOCKED, + "should be blocked by DNR rule 3" + ); + Assert.equal( + await checkCanFetch("http://example.net/allowed"), + "fetchAllowed", + "should not be blocked by block rule due to allowAllRequests rule" + ); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function allowAllRequests_in_sub_frame() { + const extension = await loadExtensionWithDNRRules([ + { + id: 1, + condition: { resourceTypes: ["xmlhttprequest"] }, + action: { type: "block" }, + }, + { + id: 2, + condition: { + requestDomains: ["example.com"], + resourceTypes: ["main_frame", "sub_frame"], + }, + action: { type: "allowAllRequests" }, + }, + ]); + + const testFetch = async () => { + // Should be able to read, unless blocked by DNR rule 1 above. + return (await fetch("http://example.com/allowed")).text(); + }; + + // Sanity check: verify that the test result is different (i.e. FETCH_BLOCKED) + // when the "allowAllRequests" rule (rule ID 2) is not matched. + await testLoadInFrame({ + description: "allowAllRequests was not matched anywhere, req in subframe", + domains: ["example.net", "example.org"], + jsForFrame: testFetch, + expectedError: FETCH_BLOCKED, + }); + + // allowAllRequests applied to domains[0], i.e. "main_frame". + await testLoadInFrame({ + description: "allowAllRequests for main frame, req in main frame", + domains: ["example.com"], + jsForFrame: testFetch, + expectedResult: "fetchAllowed", + }); + await testLoadInFrame({ + description: "allowAllRequests for main frame, req in same-origin frame", + domains: ["example.com", "example.com"], + jsForFrame: testFetch, + expectedResult: "fetchAllowed", + }); + await testLoadInFrame({ + description: "allowAllRequests for main frame, req in cross-origin frame", + domains: ["example.com", "example.net"], + jsForFrame: testFetch, + expectedResult: "fetchAllowed", + }); + + // allowAllRequests applied to domains[1], i.e. "sub_frame". + await testLoadInFrame({ + description: "allowAllRequests for subframe, req in same subframe", + domains: ["example.net", "example.com"], + jsForFrame: testFetch, + expectedResult: "fetchAllowed", + }); + await testLoadInFrame({ + description: "allowAllRequests for subframe, req in same-origin subframe", + domains: ["example.net", "example.com", "example.com"], + jsForFrame: testFetch, + expectedResult: "fetchAllowed", + }); + await testLoadInFrame({ + description: "allowAllRequests for subframe, req in cross-origin subframe", + domains: ["example.net", "example.com", "example.org"], + jsForFrame: testFetch, + expectedResult: "fetchAllowed", + }); + + await extension.unload(); +}); + +add_task(async function allowAllRequests_does_not_affect_other_extension() { + const extension = await loadExtensionWithDNRRules([ + { + id: 1, + condition: { resourceTypes: ["xmlhttprequest"] }, + action: { type: "block" }, + }, + ]); + const otherExtension = await loadExtensionWithDNRRules([ + { + id: 2, + condition: { resourceTypes: ["main_frame", "sub_frame"] }, + action: { type: "allowAllRequests" }, + }, + ]); + + const testFetch = async () => { + return (await fetch("http://example.com/allowed")).text(); + }; + + // Sanity check: verify that the test result is different (i.e. FETCH_BLOCKED) + // when the "allowAllRequests" rule (rule ID 2) is not matched. + await testLoadInFrame({ + description: "block rule from extension not superseded by otherExtension", + domains: ["example.net", "example.org"], + jsForFrame: testFetch, + expectedError: FETCH_BLOCKED, + }); + + await extension.unload(); + await otherExtension.unload(); +}); + +// When there are multiple frames and matching allowAllRequests, we need to +// use the highest-priority allowAllRequests rule. The selected rule can be +// observed through interleaved modifyHeaders rules. +add_task(async function allowAllRequests_multiple_frames_and_modifyHeaders() { + const domains = ["example.com", "example.com", "example.net", "example.org"]; + const rules = [ + { + id: 1, + priority: 3, + condition: { requestDomains: [domains[1]], resourceTypes: ["sub_frame"] }, + action: { type: "allowAllRequests" }, + }, + { + id: 2, + priority: 7, + condition: { requestDomains: [domains[2]], resourceTypes: ["sub_frame"] }, + action: { type: "allowAllRequests" }, + }, + { + id: 3, + priority: 5, + condition: { requestDomains: [domains[3]], resourceTypes: ["sub_frame"] }, + action: { type: "allowAllRequests" }, + }, + // The loop below will add modifyHeaders rules with priorities 1 - 9. + ]; + for (let i = 1; i <= 9; ++i) { + rules.push({ + id: 10 + i, // not overlapping with any rule in |rules|. + priority: i, + condition: { resourceTypes: ["xmlhttprequest"] }, + action: { + type: "modifyHeaders", + responseHeaders: [ + { + // Expose the header via CORS to allow fetch() to read the header. + operation: "set", + header: "Access-Control-Expose-Headers", + value: "addedByDnr", + }, + { operation: "append", header: "addedByDnr", value: `${i}` }, + ], + }, + }); + } + + const extension = await loadExtensionWithDNRRules(rules, { + // host_permissions required for "modifyHeaders" action. + host_permissions: ["<all_urls>"], + }); + + await testLoadInFrame({ + description: "Should select highest-prio allowAllRequests among ancestors", + domains, + jsForFrame: async () => { + let res = await fetch("http://example.com/allowed"); + return res.headers.get("addedByDnr"); + }, + // The fetch request matches all xmlhttprequest rules, which would append + // the numbers 1...9 to the results via "modifyHeaders". + // + // But every frame also has one matching "allowAllRequests" rule. Among + // these, we should not select an arbitrary rule, but the one with the + // highest priority, i.e. priority 7 (matches domains[2]). + // + // Given the "allowAllRequests" of priority 7, all rules of lower-or-equal + // priority are ignored, so only "modifyHeaders" remain with priority 8 & 9. + // + // modifyHeaders are applied in the order of priority: "9, 8", not "8, 9". + expectedResult: "9, 8", + }); + + await extension.unload(); +}); + +add_task(async function allowAllRequests_initiatorDomains() { + const rules = [ + { + id: 1, + condition: { + initiatorDomains: ["example.com"], // Note: in host_permissions below. + resourceTypes: ["main_frame", "sub_frame"], + }, + action: { type: "allowAllRequests" }, + }, + { + id: 2, + condition: { + initiatorDomains: ["example.net"], // Note: NOT in host_permissions. + resourceTypes: ["sub_frame"], + }, + action: { type: "allowAllRequests" }, + }, + { + id: 3, + condition: { resourceTypes: ["xmlhttprequest"] }, + action: { type: "block" }, + }, + ]; + + const extension = await loadExtensionWithDNRRules(rules, { + // host_permissions matches initiatorDomains from rule 1 (allowAllRequests) + // and the origin of the frame that calls testCanFetch. + host_permissions: ["*://example.com/*", "*://example.org/*"], + }); + + const testCanFetch = async () => { + return (await fetch("http://example.com/allowed")).text(); + }; + + await testLoadInFrame({ + description: "main_frame request does not have an initiator", + domains: ["example.com"], + jsForFrame: testCanFetch, + // Rule 1 (initiatorDomains: ["example.com"]) should not match. + expectedError: FETCH_BLOCKED, + }); + await testLoadInFrame({ + description: "sub_frame loaded by initiator in host_permissions", + domains: ["example.com", "example.org"], + jsForFrame: testCanFetch, + // Matched by rule 1 (initiatorDomains: ["example.com"]) + expectedResult: "fetchAllowed", + }); + await testLoadInFrame({ + description: "sub_frame loaded by initiator not in host_permissions", + domains: ["example.net", "example.org"], + jsForFrame: testCanFetch, + // Matched by rule 2 (initiatorDomains: ["example.net"]). While example.net + // is not in host_permissions, the "allowAllRequests" rule can apply because + // the extension does have the "declarativeNetRequest" permission (opposed + // to just "declarativeNetRequestWithHostAccess", which is covered by the + // allowAllRequests_initiatorDomains_dnrWithHostAccess test task below). + expectedResult: "fetchAllowed", + }); + + // about:srcdoc inherits parent origin. + await testLoadInFrame({ + description: "about:srcdoc with matching initiator", + domains: ["example.com", ABOUT_SRCDOC_SAME_ORIGIN], + jsForFrame: testCanFetch, + // While the "about:srcdoc" frame's initiator is matched by rule 1 + // (initiatorDomains: ["example.com"]), the frame's URL itself is + // "about:srcdoc" and consequently ignored in the matcher. + expectedError: FETCH_BLOCKED, + }); + await testLoadInFrame({ + description: "subframe in about:srcdoc with matching initiator", + domains: ["example.com", ABOUT_SRCDOC_SAME_ORIGIN, "example.org"], + jsForFrame: testCanFetch, + // The parent URL is "about:srcdoc", but its principal is inherit from its + // parent, i.e. "example.com". Therefore it matches rule 1. + expectedResult: "fetchAllowed", + }); + await testLoadInFrame({ + description: "subframe in opaque about:srcdoc despite matching initiator", + domains: ["example.com", ABOUT_SRCDOC_CROSS_ORIGIN, "example.org"], + jsForFrame: testCanFetch, + // The parent URL is "about:srcdoc". Because it is sandboxed, it has an + // opaque origin and therefore none of the allowAllRequests rules match, + // even not rule 1 even though the "about:srcdoc" frame was created by + // "example.com". + expectedError: FETCH_BLOCKED, + }); + + await extension.unload(); +}); + +add_task(async function allowAllRequests_initiatorDomains_dnrWithHostAccess() { + const rules = [ + { + id: 1, + condition: { + // This test shows that it does not matter whether initiatorDomains is + // in host_permissions; it only matters if the frame's URL is matched + // by host_permissions. + initiatorDomains: ["example.net"], // Not in host_permissions. + resourceTypes: ["sub_frame"], + }, + action: { type: "allowAllRequests" }, + }, + { + id: 2, + condition: { resourceTypes: ["xmlhttprequest"] }, + action: { type: "block" }, + }, + ]; + + const extension = await loadExtensionWithDNRRules(rules, { + host_permissions: ["*://example.org/*"], + permissions: ["declarativeNetRequestWithHostAccess"], + }); + + const testCanFetch = async () => { + // example.org is in host_permissions above so "xmlhttprequest" rule is + // always expected to match this, unless "allowAllRequests" applied. + // If "allowAllRequests" applies, then expectedResult: "fetchAllowed". + // If "allowAllRequests" did not apply, then expectedError: FETCH_BLOCKED. + return (await fetch("http://example.org/allowed")).text(); + }; + + await testLoadInFrame({ + description: + "frame URL in host_permissions despite initiator not in host_permissions", + domains: ["example.com", "example.net", "example.org"], + jsForFrame: testCanFetch, + // The "xmlhttprequest" block rule applies because the request URL + // (example.org) and initiator (example.org) are part of host_permissions. + // + // The "allowAllRequests" rule applies and overrides the block because the + // "example.org" frame has "example.net" as initiator (as specified in the + // initiatorDomains DNR rule). Despite the lack of host_permissions for + // "example.net", the DNR rule is matched because navigation requests do + // not require host permissions. + expectedResult: "fetchAllowed", + }); + + await testLoadInFrame({ + description: "frame URL and initiator not in host_permissions", + domains: ["example.net", "example.com", "example.org"], + jsForFrame: testCanFetch, + // The "xmlhttprequest" block rule applies because the request URL + // (example.org) and initiator (example.org) are part of host_permissions. + // + // The "allowAllRequests" rule does not apply because it would only apply + // to the "example.com" frame (that frame has "example.net" as initiator), + // but the DNR extension does not have host permissions for example.com. + expectedError: FETCH_BLOCKED, + }); + + await extension.unload(); +}); + +add_task(async function allowAllRequests_initiator_is_parent() { + // The actual initiator of a request is the principal (origin) that triggered + // the request. Navigations of subframes are usually triggered by the parent, + // except in case of cross-frame/window navigations. + // + // There are some limits on cross-frame navigations, specified by: + // https://html.spec.whatwg.org/multipage/browsing-the-web.html#allowed-to-navigate + // An ancestor can always navigate a descendant, so we do that here. + // + // - example.com (main frame) + // - example.net (sub frame 1) + // - example.org (sub frame 2) + // - example.com (sub frame 3) - will be navigated by sub frame 1. + // + // "initiatorDomains" is usually matched against the actual initiator of a + // request. Since the actual initiator (triggering principal) is not always + // known nor obvious, the parent principal (origin) is used instead, when the + // conditions for "allowAllRequests" are retroactively checked for a document. + const domains = ["example.com", "example.net", "example.org", "example.com"]; + const rules = [ + { + id: 1, + condition: { + // Note: restrict to example.org, so that we can verify that the + // "allowAllRequests" rule applies to subresource requests within any + // child frame of "example.org" (i.e. that rule 3 is ignored). + // + // Side note: the ultimate navigation request for the child frame + // itself has actual initiator "example.net" and does not match this + // rule, which we verify by confirming that rule 2 matches. + initiatorDomains: ["example.org"], + requestDomains: ["example.com"], + resourceTypes: ["sub_frame"], + }, + action: { type: "allowAllRequests" }, + }, + { + id: 2, + condition: { resourceTypes: ["xmlhttprequest"] }, + action: { type: "block" }, + }, + // The modifyHeaders rules below are not affected by the "allowAllRequests" + // rule above, but are part of the test to serve as a sanity check that the + // "initiatorDomains" field of sub_frame navigations are compared against + // the actual initiator. + { + id: 3, + priority: 2, // To not be ignored by allowAllRequests (rule 1). + condition: { + // The initial sub_frame navigation request is initiated by its parent, + // i.e. example.org. + initiatorDomains: ["example.org"], + requestDomains: ["example.com"], + resourceTypes: ["sub_frame"], + }, + action: { + type: "modifyHeaders", + requestHeaders: [ + { + operation: "append", + header: "prependhtml", + value: "<title>DNR rule 3 for initiator example.org</title>", + }, + ], + }, + }, + { + id: 4, + condition: { + // The final sub_frame navigation request is initiated by a frame other + // than the parent (i.e. example.net). + initiatorDomains: ["example.net"], + requestDomains: ["example.com"], + resourceTypes: ["sub_frame"], + }, + action: { + type: "modifyHeaders", + requestHeaders: [ + { + operation: "append", + header: "prependhtml", + value: "<title>DNR rule 4 for initiator example.net</title>", + }, + ], + }, + }, + ]; + + const extension = await loadExtensionWithDNRRules(rules, { + // host_permissions needed for allowAllRequests of ancestors + // (initiatorDomains & requestDomains) and modifyHeaders. + host_permissions: ["<all_urls>"], + }); + + const jsNavigateOnMessage = () => { + window.onmessage = e => { + dump(`\nReceived message at ${origin} from ${e.origin}: ${e.data}\n`); + e.source.location = e.data; + }; + }; + const htmlNavigateOnMessage = `<script>(${jsNavigateOnMessage})()</script>`; + + // First: sanity check that the actual initiators are as expected, which we + // verify through the modifyHeaders+initiatorDomains rules, observed through + // document.title (/echo_html prepends the "prependhtml" header's value). + await testLoadInFrame({ + description: "Sanity check: navigation matches actual initiator (parent)", + domains, + jsForFrame: () => document.title, + expectedResult: "DNR rule 3 for initiator example.org", + }); + + await testLoadInFrame({ + description: "Sanity check: navigation matches actual initiator (ancestor)", + domains, + htmlPrependedToEachFrame: htmlNavigateOnMessage, + jsForFrame: () => { + if (location.hash !== "#End") { + dump("Sanity: Trying to navigate with initiator set to example.net\n"); + parent.parent.postMessage(document.URL + ".#End", "http://example.net"); + return "delay_postMessage"; + } + return document.title; + }, + expectedResult: "DNR rule 4 for initiator example.net", + }); + + // Now the actual test: when fetch() is called, "allowAllRequests" should use + // the parent origin for each frame in the frame tree. + + await testLoadInFrame({ + description: "allowAllRequests matches parent (which is the initiator)", + domains, + jsForFrame: async () => { + return (await fetch("http://example.com/allowed")).text(); + }, + expectedResult: "fetchAllowed", // fetch() not blocked, rule 1 > rule 2. + }); + + // This is where the result differs from what one may expect from + // "initiatorDomains". This is consistent with Chrome's behavior, + // https://source.chromium.org/chromium/chromium/src/+/main:extensions/browser/api/declarative_net_request/request_params.cc;l=123-130;drc=8a27797c643fb0f2d9ae835f8d8b509e027c97e9 + await testLoadInFrame({ + description: "allowAllRequests matches parent (not actual initiator)", + domains, + htmlPrependedToEachFrame: htmlNavigateOnMessage, + jsForFrame: async () => { + if (location.hash !== "#End") { + dump("Final: Trying to navigate with initiator set to example.net\n"); + parent.parent.postMessage(document.URL + ".#End", "http://example.net"); + return "delay_postMessage"; + } + return (await fetch("http://example.com/allowed")).text(); + }, + expectedResult: "fetchAllowed", // fetch() not blocked, rule 1 > rule 2. + }); + + await extension.unload(); +}); + +// Tests how initiatorDomains applies to document and non-document (fetch) +// requests triggered from content scripts. +add_task(async function allowAllRequests_initiatorDomains_content_script() { + const rules = [ + { + id: 1, + condition: { + initiatorDomains: ["example.com"], + resourceTypes: ["sub_frame"], + }, + action: { type: "allowAllRequests" }, + }, + { + id: 2, + condition: { resourceTypes: ["xmlhttprequest"] }, + action: { type: "block" }, + }, + { + id: 3, + condition: { + resourceTypes: ["sub_frame"], + requestDomains: ["example.com"], + }, + action: { + type: "redirect", + redirect: { transform: { host: "example.net" } }, + }, + }, + ]; + + const extension = await loadExtensionWithDNRRules(rules, { + host_permissions: ["*://example.com/*", "*://example.net/*"], + }); + + let contentScriptExtension = ExtensionTestUtils.loadExtension({ + manifest: { + // Intentionally MV2 because its fetch() is tied to the content script + // sandbox, and thus potentially more likely to trigger bugs than the MV3 + // fetch (fetch in MV3 is the same as the web page due to bug 1578405). + manifest_version: 2, + content_scripts: [ + { + run_at: "document_end", + js: ["contentscript_load_frame.js"], + matches: ["http://*/?test_contentscript_load_frame"], + }, + { + all_frames: true, + run_at: "document_end", + js: ["contentscript_in_iframe.js"], + matches: ["http://example.net/?test_contentscript_triggered_frame"], + }, + ], + }, + files: { + "contentscript_load_frame.js": () => { + browser.test.log("Waiting for frame, then contentscript_in_iframe.js"); + // Created by content script; initiatorDomains should match the page's + // domain (and not somehow be confused by the content script principal). + // let document = window.document.wrappedJSObject; + let f = document.createElement("iframe"); + f.src = "http://example.com/?test_contentscript_triggered_frame"; + document.body.append(f); + }, + "contentscript_in_iframe.js": async () => { + // When the iframe request was generated by the content script, its + // initiator is void because the content script has an ExpandedPrincipal + // that is treated as void when the request initiator is computed: + // https://searchfox.org/mozilla-central/rev/d85572c1963f72e8bef2787d900e0a8ffd8e6728/toolkit/components/extensions/webrequest/ChannelWrapper.cpp#551 + // Therefore the initiatorDomains condition of rule 1 (allowAllRequests) + // does not match, so rule 3 (redirect to example.net) applies. + browser.test.assertEq( + "example.net", // instead of the pre-redirect URL (example.com). + location.host, + "redirect rule matched because initiator is void for content-script-triggered navigation" + ); + async function isFetchOk(fetchPromise) { + try { + await fetchPromise; + return true; // allowAllRequests matched. + } catch (e) { + await browser.test.assertRejects(fetchPromise, /NetworkError/); + return false; // block rule matched because allowAllRequests didn't. + } + } + browser.test.assertTrue( + await isFetchOk(content.fetch("http://example.net/allowed")), + "frame's parent origin matches initiatorDomains (content script fetch)" + ); + // fetch() in MV2 content script is associated with the content script + // sandbox, not the frame, so there are no allowAllRequests rules to + // apply. For equivalent request details, see bug 1444729. + browser.test.assertFalse( + await isFetchOk(fetch("http://example.net/allowed")), + "MV2 content script fetch() is not associated with the document" + ); + browser.test.sendMessage("contentscript_initiator"); + }, + }, + }); + await contentScriptExtension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/?test_contentscript_load_frame" + ); + info("Waiting for page load, will continue at contentscript_load_frame.js"); + await contentScriptExtension.awaitMessage("contentscript_initiator"); + await contentScriptExtension.unload(); + await contentPage.close(); + await extension.unload(); +}); + +// Verifies that allowAllRequests is evaluated against the currently committed +// document, even if another document load has been initiated. +add_task(async function allowAllRequests_during_and_after_navigation() { + let extension = await loadExtensionWithDNRRules([ + { + id: 1, + condition: { resourceTypes: ["xmlhttprequest"] }, + action: { type: "block" }, + }, + { + id: 2, + condition: { urlFilter: "WITH_AAR", resourceTypes: ["sub_frame"] }, + action: { type: "allowAllRequests" }, + }, + ]); + + const contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/?dummy_see_iframe_for_interesting_stuff" + ); + await contentPage.spawn([], async () => { + let f = content.document.createElement("iframe"); + f.id = "frame_to_navigate"; + f.src = "/?init_WITH_AAR"; // allowAllRequests initially applies. + await new Promise(resolve => { + f.onload = resolve; + content.document.body.append(f); + }); + }); + async function navigateIframe(url) { + await contentPage.spawn([url], url => { + let f = content.document.getElementById("frame_to_navigate"); + content.frameLoadedPromise = new Promise(resolve => { + f.addEventListener("load", resolve, { once: true }); + }); + f.contentWindow.location.href = url; + }); + } + async function waitForNavigationCompleted(expectLoad = true) { + await contentPage.spawn([expectLoad], async expectLoad => { + if (expectLoad) { + info("Waiting for frame load - if stuck the load never happened\n"); + return content.frameLoadedPromise.then(() => {}); + } + // When HTTP 204 No Content is used, onload is not fired. + // Here we load another frame, and assume that once this completes, that + // any previous load of navigateIframe() would have completed by now. + let f = content.document.createElement("iframe"); + f.src = "/?dummy_no_dnr_matched_" + Math.random(); + await new Promise(resolve => { + f.onload = resolve; + content.document.body.append(f); + }); + f.remove(); + }); + } + async function assertIframePath(expectedPath, description) { + let actualPath = await contentPage.spawn([], () => { + return content.frames[0].location.pathname; + }); + Assert.equal(actualPath, expectedPath, description); + } + async function assertHasAAR(expected, description) { + let actual = await contentPage.spawn([], async () => { + try { + await (await content.frames[0].fetch("/allowed")).text(); + return true; // allowAllRequests overrides block rule. + } catch (e) { + // Sanity check: NetworkError from fetch(), not a random other error. + Assert.equal( + e.toString(), + "TypeError: NetworkError when attempting to fetch resource.", + "Got error for failed fetch" + ); + return false; // blocked by xmlhttprequest block rule. + } + }); + Assert.equal(actual, expected, description); + } + await assertHasAAR(true, "Initial allowAllRequests overrides block rule"); + + const PATH_1_NO_AAR = "/delayed/PATH_1_NO_AAR"; + const PATH_2_WITH_AAR = "/delayed/PATH_2_WITH_AAR"; + const PATH_3_NO_AAR = "/delayed/PATH_3_NO_AAR"; + info("First: transition from /?init_WITH_AAR to PATH_NOT_MATCHED_BY_DNR."); + { + let promisedServerReq = waitForRequestAtServer(PATH_1_NO_AAR); + await navigateIframe(PATH_1_NO_AAR); + let serverReq = await promisedServerReq; + await assertHasAAR( + true, + "Initial allowAllRequests still applies despite pending navigation" + ); + await assertIframePath("/", "Frame has not navigated yet"); + serverReq.res.finish(); + await waitForNavigationCompleted(); + await assertIframePath(PATH_1_NO_AAR, "Navigated to PATH_1_NO_AAR"); + + await assertHasAAR( + false, + "Old allowAllRequests should no longer apply after navigation to PATH_1_NO_AAR" + ); + } + + info("Second: transition from PATH_1_NO_AAR to PATH_2_WITH_AAR."); + { + let promisedServerReq = waitForRequestAtServer(PATH_2_WITH_AAR); + await navigateIframe(PATH_2_WITH_AAR); + let serverReq = await promisedServerReq; + await assertHasAAR( + false, + "No allowAllRequests yet despite pending navigation to PATH_2_WITH_AAR" + ); + await assertIframePath(PATH_1_NO_AAR, "Frame has not navigated yet"); + serverReq.res.finish(); + await waitForNavigationCompleted(); + await assertIframePath(PATH_2_WITH_AAR, "Navigated to PATH_2_WITH_AAR"); + + await assertHasAAR( + true, + "allowAllRequests should apply after navigation to PATH_2_WITH_AAR" + ); + } + + info("Third: AAR still applies after canceling navigation to PATH_3_NO_AAR."); + { + let promisedServerReq = waitForRequestAtServer(PATH_3_NO_AAR); + await navigateIframe(PATH_3_NO_AAR); + let serverReq = await promisedServerReq; + serverReq.res.setStatusLine(serverReq.req.httpVersion, 204, "No Content"); + serverReq.res.finish(); + await waitForNavigationCompleted(/* expectLoad */ false); + await assertIframePath(PATH_2_WITH_AAR, "HTTP 204 does not navigate away"); + + await assertHasAAR( + true, + "allowAllRequests still applied after aborted navigation to PATH_3_NO_AAR" + ); + } + + await contentPage.close(); + await extension.unload(); +}); + +add_task( + { + // Ensure that there is room for at least 2 non-evicted bfcache entries. + // Note: this pref is ignored (i.e forced 0) when configured (non-default) + // with bfcacheInParent=false while SHIP is enabled: + // https://searchfox.org/mozilla-central/rev/00ea1649b59d5f427979e2d6ba42be96f62d6e82/docshell/shistory/nsSHistory.cpp#360-363 + // ... we mainly care about the bfcache here because it triggers interesting + // behavior. DNR evaluation is correct regardless of bfcache. + pref_set: [["browser.sessionhistory.max_total_viewers", 3]], + }, + async function allowAllRequests_and_bfcache_navigation() { + let extension = await loadExtensionWithDNRRules([ + { + id: 1, + condition: { resourceTypes: ["xmlhttprequest"] }, + action: { type: "block" }, + }, + { + id: 2, + condition: { urlFilter: "aar_yes", resourceTypes: ["main_frame"] }, + action: { type: "allowAllRequests" }, + }, + ]); + + info("Navigating to initial URL: 1_aar_no"); + const contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/bfcache_test?1_aar_no" + ); + async function navigateBackInHistory(expectedUrl) { + await contentPage.spawn([], () => { + content.history.back(); + }); + await TestUtils.waitForCondition( + () => contentPage.browsingContext.currentURI.spec === expectedUrl, + `Waiting for history.back() to trigger navigation to ${expectedUrl}` + ); + await contentPage.spawn([expectedUrl], async expectedUrl => { + Assert.equal(content.location.href, expectedUrl, "URL after back"); + Assert.equal(content.document.body.textContent, "true", "from bfcache"); + }); + } + async function checkCanFetch(url) { + return contentPage.spawn([url], async url => { + try { + return await (await content.fetch(url)).text(); + } catch (e) { + return e.toString(); + } + }); + } + + info("Navigating from initial URL to: 2_aar_yes"); + await contentPage.loadURL("http://example.com/bfcache_test?2_aar_yes"); + info("Navigating from 2_aar_yes to: 3_aar_no"); + await contentPage.loadURL("http://example.com/bfcache_test?3_aar_no"); + + info("Going back in history (from 3_aar_no to 2_aar_yes)"); + await navigateBackInHistory("http://example.com/bfcache_test?2_aar_yes"); + Assert.equal( + await checkCanFetch("http://example.com/allowed"), + "fetchAllowed", + "after history.back(), allowAllRequests should apply from 2_aar_yes" + ); + + info("Going back in history (from 2_aar_yes to 1_aar_no)"); + await navigateBackInHistory("http://example.com/bfcache_test?1_aar_no"); + Assert.equal( + await checkCanFetch("http://example.net/never_reached"), + FETCH_BLOCKED, + "after history.back(), no allowAllRequests action applied at 1_aar_no" + ); + + await contentPage.close(); + await extension.unload(); + } +); + +add_task( + { + // Usually, back/forward navigation to a POST form requires the user to + // confirm the form resubmission. Set pref to approve without prompting. + pref_set: [["dom.confirm_repost.testing.always_accept", true]], + }, + async function allowAllRequests_navigate_with_http_method_POST() { + const rules = [ + { + id: 1, + condition: { + requestMethods: ["post"], + resourceTypes: ["main_frame", "sub_frame"], + }, + action: { type: "allowAllRequests" }, + }, + { + id: 2, + condition: { resourceTypes: ["xmlhttprequest"] }, + action: { type: "block" }, + }, + ]; + + if (!Services.appinfo.sessionHistoryInParent) { + // POST detection relies on SHIP being enabled. This is true by default, + // but there are some test configurations with SHIP disabled. When SHIP + // is disabled, all methods are interpreted as GET instead of POST. + // Rewrite the rule to specifically match the POST requests that are + // misinterpreted as GET, to verify that the request evaluation by DNR is + // functional (opposed to throwing errors). + rules[0].condition.requestMethods = ["get"]; + rules[0].condition.urlFilter = "do_post|"; + info(`WARNING: SHIP is disabled. POST will be misinterpreted as GET`); + } + + const extension = await loadExtensionWithDNRRules(rules); + + const contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/?do_get" + ); + async function checkCanFetch(url) { + return contentPage.spawn([url], async url => { + try { + return await (await content.fetch(url)).text(); + } catch (e) { + return e.toString(); + } + }); + } + + // Check fetch() with regular GET navigation in main_frame. + Assert.equal( + await checkCanFetch("http://example.net/never_reached"), + FETCH_BLOCKED, + "main_frame: non-POST not matched by requestMethods:['post']" + ); + + // Check fetch() after POST navigation in main_frame. + await contentPage.spawn([], () => { + let form = content.document.createElement("form"); + form.action = "/?do_post"; + form.method = "POST"; + content.document.body.append(form); + form.submit(); + }); + await TestUtils.waitForCondition( + () => contentPage.browsingContext.currentURI.pathQueryRef === "/?do_post", + "Waiting for navigation with POST to complete" + ); + Assert.equal( + await checkCanFetch("http://example.net/allowed"), + "fetchAllowed", + "main_frame: requestMethods:['post'] applies to POST" + ); + + // Navigate back to the beginning and verify that allowAllRequests does not + // match any more. + await contentPage.spawn([], () => { + content.history.back(); + }); + await TestUtils.waitForCondition( + () => contentPage.browsingContext.currentURI.pathQueryRef === "/?do_get", + "Waiting for (back) navigation to initial GET page to complete" + ); + Assert.equal( + await checkCanFetch("http://example.net/never_reached"), + FETCH_BLOCKED, + "main_frame: back to non-POST not matched by requestMethods:['post']" + ); + + // Now navigate forwards to verify that the POST method is still seen. + await contentPage.spawn([], () => { + content.history.forward(); + }); + await TestUtils.waitForCondition( + () => contentPage.browsingContext.currentURI.pathQueryRef === "/?do_post", + "Waiting for (forward) navigation to POST page to complete" + ); + + Assert.equal( + await checkCanFetch("http://example.net/allowed"), + "fetchAllowed", + "main_frame: requestMethods:['post'] detects POST after history.forward()" + ); + + // Now check that adding a new history entry drops the POST method. + await contentPage.spawn([], () => { + content.history.pushState(null, null, "/?hist_p"); + }); + await TestUtils.waitForCondition( + () => contentPage.browsingContext.currentURI.pathQueryRef === "/?hist_p", + "Waiting for history.pushState to have changed the URL" + ); + Assert.equal( + await checkCanFetch("http://example.net/never_reached"), + FETCH_BLOCKED, + "history.pushState drops POST, not matched by requestMethods:['post']" + ); + + await contentPage.close(); + + // Finally, check that POST detection also works for child frames. + await testLoadInFrame({ + description: "sub_frame: non-POST not matched by requestMethods:['post']", + domains: ["example.com", "example.com"], + jsForFrame: async () => { + return (await fetch("http://example.com/allowed")).text(); + }, + expectedError: FETCH_BLOCKED, + }); + + await testLoadInFrame({ + description: "sub_frame: requestMethods:['post'] applies to POST", + domains: ["example.com", "example.com"], + jsForFrame: async () => { + if (!location.href.endsWith("?do_post")) { + dump("Triggering navigation with POST\n"); + let form = document.createElement("form"); + form.action = location.href + "?do_post"; + form.method = "POST"; + document.body.append(form); + form.submit(); + return "delay_postMessage"; + } + dump("Navigation with POST completed; testing fetch()...\n"); + return (await fetch("http://example.com/allowed")).text(); + }, + expectedResult: "fetchAllowed", + }); + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_api.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_api.js new file mode 100644 index 0000000000..0fc92dcb94 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_api.js @@ -0,0 +1,383 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionDNRLimits: "resource://gre/modules/ExtensionDNRLimits.sys.mjs", +}); + +AddonTestUtils.init(this); + +const PREF_DNR_FEEDBACK_DEFAULT_VALUE = Services.prefs.getBoolPref( + "extensions.dnr.feedback", + false +); + +// To distinguish from testMatchOutcome, undefined vs resolving vs rejecting. +const kTestMatchOutcomeNotAllowed = "kTestMatchOutcomeNotAllowed"; + +async function testAvailability({ + allowDNRFeedback = false, + testExpectations, + ...extensionData +}) { + async function background(testExpectations) { + let { + // declarativeNetRequest should be available if "declarativeNetRequest" or + // "declarativeNetRequestWithHostAccess" permission is requested. + // (and always unavailable when "extensions.dnr.enabled" pref is false) + declarativeNetRequest_available = false, + // testMatchOutcome is available when the "declarativeNetRequestFeedback" + // permission is granted AND the "extensions.dnr.feedback" pref is true. + // (and always unavailable when "extensions.dnr.enabled" pref is false) + // testMatchOutcome_available: true - permission granted + pref true. + // testMatchOutcome_available: false - no permission, pref doesn't matter. + // testMatchOutcome_available: kTestMatchOutcomeNotAllowed - permission + // granted, but pref is false. + testMatchOutcome_available = false, + } = testExpectations; + browser.test.assertEq( + declarativeNetRequest_available, + !!browser.declarativeNetRequest, + "declarativeNetRequest API namespace availability" + ); + + // Dummy param for testMatchOutcome: + const dummyRequest = { url: "https://example.com/", type: "other" }; + + if (!testMatchOutcome_available) { + browser.test.assertEq( + undefined, + browser.declarativeNetRequest?.testMatchOutcome, + "declarativeNetRequest.testMatchOutcome availability" + ); + } else if (testMatchOutcome_available === "kTestMatchOutcomeNotAllowed") { + await browser.test.assertRejects( + browser.declarativeNetRequest.testMatchOutcome(dummyRequest), + `declarativeNetRequest.testMatchOutcome is only available when the "extensions.dnr.feedback" preference is set to true.`, + "declarativeNetRequest.testMatchOutcome is unavailable" + ); + } else { + browser.test.assertDeepEq( + { matchedRules: [] }, + await browser.declarativeNetRequest.testMatchOutcome(dummyRequest), + "declarativeNetRequest.testMatchOutcome is available" + ); + } + browser.test.sendMessage("done"); + } + let extension = ExtensionTestUtils.loadExtension({ + ...extensionData, + manifest: { + manifest_version: 3, + ...extensionData.manifest, + }, + background: `(${background})(${JSON.stringify(testExpectations)});`, + }); + Services.prefs.setBoolPref("extensions.dnr.feedback", allowDNRFeedback); + try { + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); + } finally { + Services.prefs.clearUserPref("extensions.dnr.feedback"); + } +} + +add_setup(async () => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + + // test_optional_declarativeNetRequestFeedback calls permission.request(). + // We don't care about the UI, only about the effect of being granted. + Services.prefs.setBoolPref( + "extensions.webextOptionalPermissionPrompts", + false + ); +}); + +add_task( + { + pref_set: [["extensions.dnr.enabled", false]], + }, + async function extensions_dnr_enabled_pref_disabled_dnr_feature() { + let { messages } = await promiseConsoleOutput(async () => { + await testAvailability({ + allowDNRFeedback: PREF_DNR_FEEDBACK_DEFAULT_VALUE, + testExpectations: { + declarativeNetRequest_available: false, + }, + manifest: { + permissions: [ + "declarativeNetRequest", + "declarativeNetRequestFeedback", + "declarativeNetRequestWithHostAccess", + ], + }, + }); + }); + + AddonTestUtils.checkMessages(messages, { + expected: [ + { + message: + /Reading manifest: Invalid extension permission: declarativeNetRequest$/, + }, + { + message: + /Reading manifest: Invalid extension permission: declarativeNetRequestFeedback/, + }, + { + message: + /Reading manifest: Invalid extension permission: declarativeNetRequestWithHostAccess/, + }, + ], + }); + } +); + +add_task(async function dnr_feedback_apis_disabled_by_default() { + let { messages } = await promiseConsoleOutput(async () => { + await testAvailability({ + allowDNRFeedback: PREF_DNR_FEEDBACK_DEFAULT_VALUE, + testExpectations: { + declarativeNetRequest_available: true, + testMatchOutcome_available: kTestMatchOutcomeNotAllowed, + }, + manifest: { + permissions: [ + "declarativeNetRequest", + "declarativeNetRequestFeedback", + "declarativeNetRequestWithHostAccess", + ], + }, + }); + }); + + AddonTestUtils.checkMessages(messages, { + forbidden: [ + { + message: + /Reading manifest: Invalid extension permission: declarativeNetRequest$/, + }, + { + message: + /Reading manifest: Invalid extension permission: declarativeNetRequestFeedback/, + }, + { + message: + /Reading manifest: Invalid extension permission: declarativeNetRequestWithHostAccess/, + }, + ], + }); +}); + +add_task(async function dnr_available_in_mv2() { + await testAvailability({ + allowDNRFeedback: true, + testExpectations: { + declarativeNetRequest_available: true, + testMatchOutcome_available: true, + }, + manifest: { + manifest_version: 2, + permissions: [ + "declarativeNetRequest", + "declarativeNetRequestFeedback", + "declarativeNetRequestWithHostAccess", + ], + }, + }); +}); + +add_task(async function with_declarativeNetRequest_permission() { + await testAvailability({ + allowDNRFeedback: true, + testExpectations: { + declarativeNetRequest_available: true, + // feature allowed, but missing declarativeNetRequestFeedback: + testMatchOutcome_available: false, + }, + manifest: { + permissions: ["declarativeNetRequest"], + }, + }); +}); + +add_task(async function with_declarativeNetRequestWithHostAccess_permission() { + await testAvailability({ + allowDNRFeedback: true, + testExpectations: { + declarativeNetRequest_available: true, + // feature allowed, but missing declarativeNetRequestFeedback: + testMatchOutcome_available: false, + }, + manifest: { + permissions: ["declarativeNetRequestWithHostAccess"], + }, + }); +}); + +add_task(async function with_all_declarativeNetRequest_permissions() { + await testAvailability({ + allowDNRFeedback: true, + testExpectations: { + declarativeNetRequest_available: true, + // feature allowed, but missing declarativeNetRequestFeedback: + testMatchOutcome_available: false, + }, + manifest: { + permissions: [ + "declarativeNetRequest", + "declarativeNetRequestWithHostAccess", + ], + }, + }); +}); + +add_task(async function no_declarativeNetRequest_permission() { + await testAvailability({ + allowDNRFeedback: true, + testExpectations: { + // Just declarativeNetRequestFeedback should not unlock the API. + declarativeNetRequest_available: false, + }, + manifest: { + permissions: ["declarativeNetRequestFeedback"], + }, + }); +}); + +add_task(async function with_declarativeNetRequestFeedback_permission() { + await testAvailability({ + allowDNRFeedback: true, + testExpectations: { + declarativeNetRequest_available: true, + // feature allowed, and all permissions specified: + testMatchOutcome_available: true, + }, + manifest: { + permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"], + }, + }); +}); + +add_task(async function declarativeNetRequestFeedback_without_feature() { + await testAvailability({ + allowDNRFeedback: false, + testExpectations: { + declarativeNetRequest_available: true, + // all permissions set, but DNR feedback feature not allowed. + testMatchOutcome_available: kTestMatchOutcomeNotAllowed, + }, + manifest: { + permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"], + }, + }); +}); + +add_task( + { pref_set: [["extensions.dnr.feedback", true]] }, + async function declarativeNetRequestFeedback_is_optional() { + async function background() { + async function assertTestMatchOutcomeEnabled(expected, description) { + let enabled; + try { + // testAvailability already checks the errors etc, so here we only + // care about the method working vs not working. + await browser.declarativeNetRequest.testMatchOutcome({ + url: "https://example.com/", + type: "other", + }); + enabled = true; + } catch (e) { + enabled = false; + } + browser.test.assertEq(expected, enabled, description); + } + + await assertTestMatchOutcomeEnabled(false, "disabled when not granted"); + + await new Promise(resolve => { + // browser.test.withHandlingUserInput would have been simpler, but due + // to bug 1598804 it cannot be used. + browser.test.onMessage.addListener(async msg => { + browser.test.assertEq("withHandlingUserInput_ok", msg, "Resuming"); + await browser.permissions.request({ + permissions: ["declarativeNetRequestFeedback"], + }); + browser.test.sendMessage("withHandlingUserInput_done"); + resolve(); + }); + browser.test.sendMessage("withHandlingUserInput_wanted"); + }); + + await assertTestMatchOutcomeEnabled(true, "enabled by permission"); + + await browser.permissions.remove({ + permissions: ["declarativeNetRequestFeedback"], + }); + await assertTestMatchOutcomeEnabled(false, "disabled after perm removal"); + + browser.test.sendMessage("done"); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequestWithHostAccess"], + optional_permissions: ["declarativeNetRequestFeedback"], + }, + }); + await extension.startup(); + await extension.awaitMessage("withHandlingUserInput_wanted"); + await withHandlingUserInput(extension, async () => { + extension.sendMessage("withHandlingUserInput_ok"); + await extension.awaitMessage("withHandlingUserInput_done"); + }); + await extension.awaitMessage("done"); + await extension.unload(); + } +); + +add_task(async function test_dnr_limits_namespace_properties() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest"], + }, + background() { + browser.test.assertEq( + "_dynamic", + browser.declarativeNetRequest.DYNAMIC_RULESET_ID, + "Value of DYNAMIC_RULESET_ID constant" + ); + browser.test.assertEq( + "_session", + browser.declarativeNetRequest.SESSION_RULESET_ID, + "Value of SESSION_RULESET_ID constant" + ); + const { + GUARANTEED_MINIMUM_STATIC_RULES, + MAX_NUMBER_OF_STATIC_RULESETS, + MAX_NUMBER_OF_ENABLED_STATIC_RULESETS, + MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES, + MAX_NUMBER_OF_REGEX_RULES, + } = browser.declarativeNetRequest; + browser.test.sendMessage("dnr-namespace-properties", { + GUARANTEED_MINIMUM_STATIC_RULES, + MAX_NUMBER_OF_STATIC_RULESETS, + MAX_NUMBER_OF_ENABLED_STATIC_RULESETS, + MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES, + MAX_NUMBER_OF_REGEX_RULES, + }); + }, + }); + + await extension.startup(); + + Assert.deepEqual( + await extension.awaitMessage("dnr-namespace-properties"), + ExtensionDNRLimits, + "Got the expected limits values set on the dnr namespace" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_download.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_download.js new file mode 100644 index 0000000000..cd24b75855 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_download.js @@ -0,0 +1,193 @@ +"use strict"; + +let server = createHttpServer({ hosts: ["example.com"] }); +let downloadReqCount = 0; +server.registerPathHandler("/downloadtest", (req, res) => { + ++downloadReqCount; +}); + +add_setup(async () => { + let downloadDir = await IOUtils.createUniqueDirectory( + Services.dirsvc.get("TmpD", Ci.nsIFile).path, + "downloadDirForDnrDownloadTest" + ); + info(`Using download directory ${downloadDir.path}`); + + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setCharPref("browser.download.dir", downloadDir); + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + try { + await IOUtils.remove(downloadDir); + } catch (e) { + info(`Failed to remove ${downloadDir} because: ${e}`); + // Downloaded files should have been deleted by tests. + // Clean up + report error otherwise. + let children = await IOUtils.getChildren(downloadDir).catch(e => e); + ok(false, `Unexpected files in downloadDir: ${children}`); + await IOUtils.remove(downloadDir, { recursive: true }); + } + }); + + Services.prefs.setBoolPref("extensions.dnr.enabled", true); +}); + +// Test for Bug 1579911: Check that download requests created by the +// downloads.download API can be observed by extensions. +// The webRequest version is in test_ext_webRequest_download.js. +add_task(async function test_download_api_can_be_blocked_by_dnr() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest", "downloads"], + // No host_permissions here because neither the downloads nor the DNR API + // require host permissions to download and/or block the request. + }, + // Not needed, but to rule out downloads being blocked by CSP: + allowInsecureRequests: true, + background: async function () { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { urlFilter: "|http://example.com/downloadtest" }, + action: { type: "block" }, + }, + ], + }); + + browser.downloads.onChanged.addListener(delta => { + browser.test.assertEq(delta.state.current, "interrupted"); + browser.test.sendMessage("done"); + }); + + await browser.downloads.download({ + url: "http://example.com/downloadtest", + filename: "example.txt", + }); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); + + Assert.equal(downloadReqCount, 0, "Did not expect any download requests"); +}); + +add_task(async function test_download_api_ignores_dnr_from_other_extension() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest"], + }, + background: async function () { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { urlFilter: "|http://example.com/downloadtest" }, + action: { type: "block" }, + }, + ], + }); + + browser.test.sendMessage("dnr_registered"); + }, + }); + + let otherExtension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["downloads"], + }, + background: async function () { + let downloadDonePromise = new Promise(resolve => { + browser.downloads.onChanged.addListener(delta => { + if (delta.state.current === "interrupted") { + browser.test.fail("Download was unexpectedly interrupted"); + browser.test.notifyFail("done"); + } else if (delta.state.current === "complete") { + resolve(); + } + }); + }); + + // This download should not have been interrupted by the other extension, + // because declarativeNetRequest cannot match requests from other + // extensions. + let downloadId = await browser.downloads.download({ + url: "http://example.com/downloadtest", + filename: "example_from_other_ext.txt", + }); + await downloadDonePromise; + browser.test.log("Download completed, removing file..."); + // TODO bug 1654819: On Windows the file may be recreated. + await browser.downloads.removeFile(downloadId); + browser.test.notifyPass("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("dnr_registered"); + + await otherExtension.startup(); + await otherExtension.awaitFinish("done"); + await otherExtension.unload(); + await extension.unload(); + + Assert.equal(downloadReqCount, 1, "Expected one download request"); + downloadReqCount = 0; +}); + +add_task( + { + pref_set: [["extensions.dnr.match_requests_from_other_extensions", true]], + }, + async function test_download_api_dnr_blocks_other_extension_with_pref() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest"], + }, + background: async function () { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { urlFilter: "|http://example.com/downloadtest" }, + action: { type: "block" }, + }, + ], + }); + + browser.test.sendMessage("dnr_registered"); + }, + }); + let otherExtension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["downloads"], + }, + background: async function () { + browser.downloads.onChanged.addListener(delta => { + browser.test.assertEq(delta.state.current, "interrupted"); + browser.test.sendMessage("done"); + }); + await browser.downloads.download({ + url: "http://example.com/downloadtest", + filename: "example_from_other_ext_with_pref.txt", + }); + }, + }); + + await extension.startup(); + await extension.awaitMessage("dnr_registered"); + await otherExtension.startup(); + await otherExtension.awaitMessage("done"); + await otherExtension.unload(); + await extension.unload(); + + Assert.equal(downloadReqCount, 0, "Did not expect any download requests"); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_dynamic_rules.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_dynamic_rules.js new file mode 100644 index 0000000000..4ba120852f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_dynamic_rules.js @@ -0,0 +1,1245 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionDNR: "resource://gre/modules/ExtensionDNR.sys.mjs", + ExtensionDNRLimits: "resource://gre/modules/ExtensionDNRLimits.sys.mjs", + ExtensionDNRStore: "resource://gre/modules/ExtensionDNRStore.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +Services.scriptloader.loadSubScript( + Services.io.newFileURI(do_get_file("head_dnr.js")).spec, + this +); + +const { promiseStartupManager, promiseRestartManager } = AddonTestUtils; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.write("response from server"); +}); + +add_setup(async () => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.feedback", true); + + setupTelemetryForTests(); + + await promiseStartupManager(); +}); + +// This function is serialized and called in the context of the test extension's +// background page. dnrTestUtils is passed to the background function. +function makeDnrTestUtils() { + const dnrTestUtils = {}; + const dnr = browser.declarativeNetRequest; + + function serializeForLog(data) { + // JSON-stringify, but drop null values (replacing them with undefined + // causes JSON.stringify to drop them), so that optional keys with the null + // values are hidden. + let str = JSON.stringify(data, rep => rep ?? undefined); + return str; + } + + async function testInvalidRule(rule, expectedError, isSchemaError) { + if (isSchemaError) { + // Schema validation error = thrown error instead of a rejection. + browser.test.assertThrows( + () => dnr.updateDynamicRules({ addRules: [rule] }), + expectedError, + `Rule should be invalid (schema-validated): ${serializeForLog(rule)}` + ); + } else { + await browser.test.assertRejects( + dnr.updateDynamicRules({ addRules: [rule] }), + expectedError, + `Rule should be invalid: ${serializeForLog(rule)}` + ); + } + } + + Object.assign(dnrTestUtils, { + testInvalidRule, + serializeForLog, + }); + return dnrTestUtils; +} + +async function runAsDNRExtension({ + background, + unloadTestAtEnd = true, + awaitFinish = false, + id = "test-dynamic-rules@test-extension", +}) { + const testExtensionParams = { + background: `(${background})((${makeDnrTestUtils})())`, + useAddonManager: "permanent", + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"], + browser_specific_settings: { + gecko: { id }, + }, + }, + }; + const extension = ExtensionTestUtils.loadExtension(testExtensionParams); + await extension.startup(); + if (awaitFinish) { + await extension.awaitFinish(); + } + if (unloadTestAtEnd) { + await extension.unload(); + } + return { extension, testExtensionParams }; +} + +function callTestMessageHandler(extension, testMessage, ...args) { + extension.sendMessage(testMessage, ...args); + return extension.awaitMessage(`${testMessage}:done`); +} + +add_task(async function test_dynamic_rule_registration() { + await runAsDNRExtension({ + background: async () => { + const dnr = browser.declarativeNetRequest; + + await dnr.updateDynamicRules({ + addRules: [{ id: 1, condition: {}, action: { type: "block" } }], + }); + + const url = "https://example.com/some-dummy-url"; + const type = "font"; + browser.test.assertDeepEq( + { matchedRules: [{ ruleId: 1, rulesetId: "_dynamic" }] }, + await dnr.testMatchOutcome({ url, type }), + "Dynamic rule matched after registration" + ); + + await dnr.updateDynamicRules({ + removeRuleIds: [ + 1, + 1234567890, // Invalid rules should be ignored. + ], + addRules: [{ id: 2, condition: {}, action: { type: "block" } }], + }); + browser.test.assertDeepEq( + { matchedRules: [{ ruleId: 2, rulesetId: "_dynamic" }] }, + await dnr.testMatchOutcome({ url, type }), + "Dynamic rule matched after update" + ); + + await dnr.updateDynamicRules({ removeRuleIds: [2] }); + browser.test.assertDeepEq( + { matchedRules: [] }, + await dnr.testMatchOutcome({ url, type }), + "Dynamic rule not matched after unregistration" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_dynamic_rules_count_limits() { + await runAsDNRExtension({ + unloadTestAtEnd: true, + awaitFinish: true, + background: async () => { + const dnr = browser.declarativeNetRequest; + const [dyamicRules, sessionRules] = await Promise.all([ + dnr.getDynamicRules(), + dnr.getSessionRules(), + ]); + + browser.test.assertDeepEq( + { session: [], dynamic: [] }, + { session: sessionRules, dynamic: dyamicRules }, + "Expect no session and no dynamic rules" + ); + + const { MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES } = dnr; + const DUMMY_RULE = { + action: { type: "block" }, + condition: { resourceTypes: ["main_frame"] }, + }; + const rules = []; + for (let i = 0; i < MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES; i++) { + rules.push({ ...DUMMY_RULE, id: i + 1 }); + } + + await browser.test.assertRejects( + dnr.updateDynamicRules({ + addRules: [ + ...rules, + { ...DUMMY_RULE, id: MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES + 1 }, + ], + }), + `Number of rules in ruleset "_dynamic" exceeds MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES.`, + "Got the expected rejection of exceeding the number of dynamic rules allowed" + ); + + await dnr.updateDynamicRules({ + addRules: rules, + }); + browser.test.assertEq( + 5000, + (await dnr.getDynamicRules()).length, + "Got the expected number of dynamic rules stored" + ); + + await dnr.updateDynamicRules({ + removeRuleIds: rules.map(r => r.id), + }); + + browser.test.assertEq( + 0, + (await dnr.getDynamicRules()).length, + "All dynamic rules should have been removed" + ); + + browser.test.log( + "Verify rules count limits with multiple async API calls" + ); + + const [updateDynamicRulesSingle, updateDynamicRulesTooMany] = + await Promise.allSettled([ + dnr.updateDynamicRules({ + addRules: [ + { + ...DUMMY_RULE, + id: MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES + 1, + }, + ], + }), + dnr.updateDynamicRules({ addRules: rules }), + ]); + + browser.test.assertDeepEq( + updateDynamicRulesSingle, + { status: "fulfilled", value: undefined }, + "Expect the first updateDynamicRules call to be successful" + ); + + await browser.test.assertRejects( + updateDynamicRulesTooMany?.status === "rejected" + ? Promise.reject(updateDynamicRulesTooMany.reason) + : Promise.resolve(), + `Number of rules in ruleset "_dynamic" exceeds MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES.`, + "Got the expected rejection on the second call exceeding the number of dynamic rules allowed" + ); + + browser.test.assertDeepEq( + (await dnr.getDynamicRules()).map(rule => rule.id), + [MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES + 1], + "Got the expected dynamic rules" + ); + + await dnr.updateDynamicRules({ + removeRuleIds: [MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES + 1], + }); + + const [updateSessionResult, updateDynamicResult] = + await Promise.allSettled([ + dnr.updateSessionRules({ addRules: rules }), + dnr.updateDynamicRules({ addRules: rules }), + ]); + + browser.test.assertDeepEq( + updateDynamicResult, + { status: "fulfilled", value: undefined }, + "Expect the number of dynamic rules to be still allowed, despite the session rule added" + ); + + // NOTE: In this test we do not exceed the quota of session rules. The + // updateSessionRules call here is to verify that the quota of session and + // dynamic rules are separate. The limits for session rules are tested + // by session_rules_total_rule_limit in test_ext_dnr_session_rules.js. + browser.test.assertDeepEq( + updateSessionResult, + { status: "fulfilled", value: undefined }, + "Got expected success from the updateSessionRules request" + ); + + browser.test.assertDeepEq( + { sessionRulesCount: 5000, dynamicRulesCount: 5000 }, + { + sessionRulesCount: (await dnr.getSessionRules()).length, + dynamicRulesCount: (await dnr.getDynamicRules()).length, + }, + "Got expected session and dynamic rules counts" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_stored_dynamic_rules_exceeding_limits() { + const { extension } = await runAsDNRExtension({ + unloadTestAtEnd: false, + awaitFinish: false, + background: async () => { + const dnr = browser.declarativeNetRequest; + + browser.test.onMessage.addListener(async (msg, ...args) => { + switch (msg) { + case "createDynamicRules": { + const [{ updateRuleOptions }] = args; + await dnr.updateDynamicRules(updateRuleOptions); + break; + } + case "assertGetDynamicRulesCount": { + const [{ expectedRulesCount }] = args; + browser.test.assertEq( + expectedRulesCount, + (await dnr.getDynamicRules()).length, + "getDynamicRules() resolves to the expected number of dynamic rules" + ); + break; + } + default: + browser.test.fail( + `Got unexpected unhandled test message: "${msg}"` + ); + break; + } + browser.test.sendMessage(`${msg}:done`); + }); + browser.test.sendMessage("bgpage:ready"); + }, + }); + + const initialRules = [getDNRRule({ id: 1 })]; + await extension.awaitMessage("bgpage:ready"); + await callTestMessageHandler(extension, "createDynamicRules", { + updateRuleOptions: { addRules: initialRules }, + }); + await callTestMessageHandler(extension, "assertGetDynamicRulesCount", { + expectedRulesCount: 1, + }); + + const extUUID = extension.uuid; + const dnrStore = ExtensionDNRStore._getStoreForTesting(); + await dnrStore._savePromises.get(extUUID); + const { storeFile } = dnrStore.getFilePaths(extUUID); + + await extension.addon.disable(); + + ok( + !dnrStore._dataPromises.has(extUUID), + "DNR store read data promise cleared after the extension has been disabled" + ); + ok( + !dnrStore._data.has(extUUID), + "DNR store data cleared from memory after the extension has been disabled" + ); + + ok(await IOUtils.exists(storeFile), `DNR storeFile ${storeFile} found`); + const dnrDataFromFile = await IOUtils.readJSON(storeFile, { + decompress: true, + }); + + const { MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES } = ExtensionDNRLimits; + + const expectedDynamicRules = []; + const unexpectedDynamicRules = []; + + for (let i = 0; i < MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES + 5; i++) { + const rule = getDNRRule({ id: i + 1 }); + if (i < MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES) { + expectedDynamicRules.push(rule); + } else { + unexpectedDynamicRules.push(rule); + } + } + + const tooManyDynamicRules = [ + ...expectedDynamicRules, + ...unexpectedDynamicRules, + ]; + + const dnrDataNew = { + schemaVersion: dnrDataFromFile.schemaVersion, + extVersion: extension.extension.version, + staticRulesets: [], + dynamicRuleset: getSchemaNormalizedRules(extension, tooManyDynamicRules), + }; + + await IOUtils.writeJSON(storeFile, dnrDataNew, { compress: true }); + + const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + await extension.addon.enable(); + await extension.awaitMessage("bgpage:ready"); + }); + + await callTestMessageHandler(extension, "assertGetDynamicRulesCount", { + expectedRulesCount: 0, + }); + + AddonTestUtils.checkMessages(messages, { + expected: [ + { + message: new RegExp( + `Ignoring dynamic ruleset in extension "${extension.id}" because: Number of rules in ruleset "_dynamic" exceeds MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES` + ), + }, + ], + }); + + await extension.unload(); +}); + +add_task(async function test_save_and_load_dynamic_rules() { + let { extension, testExtensionParams } = await runAsDNRExtension({ + unloadTestAtEnd: false, + awaitFinish: false, + background: async dnrTestUtils => { + const dnr = browser.declarativeNetRequest; + + browser.test.onMessage.addListener(async (msg, ...args) => { + switch (msg) { + case "assertGetDynamicRules": { + const [{ expectedRules }] = args; + browser.test.assertDeepEq( + expectedRules, + await dnr.getDynamicRules(), + "getDynamicRules() resolves to the expected dynamic rules" + ); + break; + } + case "testUpdateDynamicRules": { + const [{ updateRulesRequests, expectedRules }] = args; + const promiseResults = await Promise.allSettled( + updateRulesRequests.map(updateRuleOptions => + dnr.updateDynamicRules(updateRuleOptions) + ) + ); + + // All calls should have been resolved successfully. + for (const [i, request] of updateRulesRequests.entries()) { + browser.test.assertDeepEq( + { status: "fulfilled", value: undefined }, + promiseResults[i], + `Expect resolved updateDynamicRules request for ${dnrTestUtils.serializeForLog( + request + )}` + ); + } + + browser.test.assertDeepEq( + expectedRules, + await dnr.getDynamicRules(), + "getDynamicRules resolves to the expected updated dynamic rules" + ); + break; + } + case "testInvalidDynamicAddRule": { + const [{ rule, expectedError, isSchemaError, isErrorRegExp }] = + args; + await dnrTestUtils.testInvalidRule( + rule, + expectedError, + isSchemaError, + isErrorRegExp + ); + break; + } + default: + browser.test.fail( + `Got unexpected unhandled test message: "${msg}"` + ); + break; + } + + browser.test.sendMessage(`${msg}:done`); + }); + + browser.test.sendMessage("bgpage:ready"); + }, + }); + + await extension.awaitMessage("bgpage:ready"); + await callTestMessageHandler(extension, "assertGetDynamicRules", { + expectedRules: [], + }); + + const rules = [ + getDNRRule({ + id: 1, + action: { type: "allow" }, + condition: { resourceTypes: ["main_frame"] }, + }), + getDNRRule({ + id: 2, + action: { type: "block" }, + condition: { resourceTypes: ["main_frame", "script"] }, + }), + ]; + + info("Verify updateDynamicRules adding new valid rules"); + // Send two concurrent API requests, the first one adds 3 rules and the second + // one removing a rule defined in the first call, the result of the combined + // API calls is expected to only store 2 dynamic rules in the DNR store. + await callTestMessageHandler(extension, "testUpdateDynamicRules", { + updateRulesRequests: [ + { addRules: [...rules, getDNRRule({ id: 3 })] }, + { removeRuleIds: [3] }, + ], + expectedRules: getSchemaNormalizedRules(extension, rules), + }); + + const extUUID = extension.uuid; + const dnrStore = ExtensionDNRStore._getStoreForTesting(); + await dnrStore._savePromises.get(extUUID); + const { storeFile } = dnrStore.getFilePaths(extUUID); + const dnrDataFromFile = await IOUtils.readJSON(storeFile, { + decompress: true, + }); + + Assert.deepEqual( + dnrDataFromFile.dynamicRuleset, + getSchemaNormalizedRules(extension, rules), + "Got the expected rules stored on disk" + ); + + info("Verify updateDynamicRules rejects on new invalid rules"); + await callTestMessageHandler(extension, "testInvalidDynamicAddRule", { + rule: rules[0], + expectedError: "Duplicate rule ID: 1", + isSchemaError: false, + }); + + await callTestMessageHandler(extension, "testInvalidDynamicAddRule", { + rule: getDNRRule({ action: { type: "invalid-action" } }), + expectedError: + /addRules.0.action.type: Invalid enumeration value "invalid-action"/, + isSchemaError: true, + }); + + info("Expect dynamic rules to not have been changed"); + await callTestMessageHandler(extension, "assertGetDynamicRules", { + expectedRules: getSchemaNormalizedRules(extension, rules), + }); + + Assert.deepEqual( + dnrStore._data.get(extUUID).dynamicRuleset, + getSchemaNormalizedRules(extension, rules), + "Got the expected dynamic rules in the DNR store" + ); + + info("Verify dynamic rules loaded back from disk on addon restart"); + ok(await IOUtils.exists(storeFile), `DNR storeFile ${storeFile} found`); + + // force deleting the data stored in memory to confirm if it being loaded again from + // the files stored on disk. + dnrStore._data.delete(extUUID); + dnrStore._dataPromises.delete(extUUID); + + const { addon } = extension; + await addon.disable(); + + ok( + !dnrStore._dataPromises.has(extUUID), + "DNR store read data promise cleared after the extension has been disabled" + ); + ok( + !dnrStore._data.has(extUUID), + "DNR store data cleared from memory after the extension has been disabled" + ); + + await addon.enable(); + await extension.awaitMessage("bgpage:ready"); + + info("Expect dynamic rules to have been loaded back"); + await callTestMessageHandler(extension, "assertGetDynamicRules", { + expectedRules: getSchemaNormalizedRules(extension, rules), + }); + + Assert.deepEqual( + dnrStore._data.get(extUUID).dynamicRuleset, + getSchemaNormalizedRules(extension, rules), + "Got the expected dynamic rules loaded back from the DNR store after addon restart" + ); + + info("Verify dynamic rules loaded back as expected on AOM restart"); + dnrStore._data.delete(extUUID); + dnrStore._dataPromises.delete(extUUID); + + // NOTE: promiseRestartManager will not be enough to make sure the + // DNR store data for the test extension is going to be loaded from + // the DNR startup cache file. + // See test_ext_dnr_startup_cache.js for a test case that more completely + // simulates ExtensionDNRStore initialization on browser restart. + await promiseRestartManager(); + + await extension.awaitStartup(); + await extension.awaitMessage("bgpage:ready"); + + await callTestMessageHandler(extension, "assertGetDynamicRules", { + expectedRules: getSchemaNormalizedRules(extension, rules), + }); + + // Verify the dynamic rules are converted back into Rule class instances + // as expected when loaded back from the DNR store file + Assert.ok( + !!dnrStore._data.get(extUUID).dynamicRuleset.length, + "Expected dynamic rules to have been loaded back from the DNR store file" + ); + Assert.deepEqual( + dnrStore._data + .get(extUUID) + .dynamicRuleset.filter(rule => rule.constructor.name !== "Rule"), + [], + "Expect dynamic rules loaded back from the DNR store file to be converted to Rule class instances" + ); + + Assert.deepEqual( + dnrStore._data.get(extUUID).dynamicRuleset, + getSchemaNormalizedRules(extension, rules), + "Got the expected dynamic rules loaded back from the DNR store after AOM restart" + ); + + info( + "Verify updateDynamicRules adding new valid rules and remove one of the existing" + ); + // Expect the first rule to be removed and a new one being added. + const newRule3 = getDNRRule({ + id: 3, + action: { type: "allow" }, + condition: { resourceTypes: ["main_frame"] }, + }); + const updatedRules = [rules[1], newRule3]; + + await callTestMessageHandler(extension, "testUpdateDynamicRules", { + updateRulesRequests: [{ addRules: [newRule3], removeRuleIds: [1] }], + expectedRules: getSchemaNormalizedRules(extension, updatedRules), + }); + + info("Verify dynamic rules preserved across addon updates"); + + const staticRules = [ + getDNRRule({ + id: 4, + action: { type: "block" }, + condition: { resourceTypes: ["xmlhttprequest"] }, + }), + ]; + await extension.upgrade({ + ...testExtensionParams, + manifest: { + ...testExtensionParams.manifest, + version: "2.0", + declarative_net_request: { + rule_resources: [ + { + id: "ruleset_1", + enabled: true, + path: "ruleset_1.json", + }, + ], + }, + }, + files: { "ruleset_1.json": JSON.stringify(staticRules) }, + }); + await extension.awaitMessage("bgpage:ready"); + + await callTestMessageHandler(extension, "assertGetDynamicRules", { + expectedRules: getSchemaNormalizedRules(extension, updatedRules), + }); + + info( + "Verify static rules included in the new addon version have been loaded" + ); + + await assertDNRStoreData(dnrStore, extension, { + ruleset_1: getSchemaNormalizedRules(extension, staticRules), + }); + + info("Verify rules after extension downgrade"); + await extension.upgrade({ + ...testExtensionParams, + manifest: { + ...testExtensionParams.manifest, + version: "1.0", + }, + }); + await extension.awaitMessage("bgpage:ready"); + + info("Verify stored dynamic rules are unchanged"); + + await callTestMessageHandler(extension, "assertGetDynamicRules", { + expectedRules: getSchemaNormalizedRules(extension, updatedRules), + }); + + info( + "Verify static rules included in the new addon version are cleared on downgrade to previous version" + ); + await assertDNRStoreData(dnrStore, extension, {}); + + info("Verify rules after extension upgrade to one without DNR permissions"); + await extension.upgrade({ + ...testExtensionParams, + manifest: { + ...testExtensionParams.manifest, + permissions: [], + version: "1.1", + }, + background: async () => { + browser.test.assertEq( + browser.declarativeNetRequest, + undefined, + "Expect DNR API namespace to not be available" + ); + browser.test.sendMessage("bgpage:ready"); + }, + }); + await extension.awaitMessage("bgpage:ready"); + ok( + !dnrStore._dataPromises.has(extension.uuid), + "Expect dnrStore to not have any promise for the extension DNR data being loaded" + ); + ok( + !ExtensionDNR.getRuleManager( + extension.extension, + false /* createIfMissing */ + ), + "Expect no ruleManager found for the extenson" + ); + + info( + "Verify rules are loaded back after upgrading again to one with DNR permissions" + ); + await extension.upgrade({ + ...testExtensionParams, + manifest: { + ...testExtensionParams.manifest, + version: "1.2", + }, + }); + await extension.awaitMessage("bgpage:ready"); + + // NOTE: To make sure that the test extension rule manager is removed + // on the extension shutdown also when the declarativeNetRequest + // ExtensionAPI class instance has not been created at all, this part + // on the test is purposely not calling any declarativeNetRequest API method + // not calling ExtensionDNR.ensureInitialized, instead we wait for the + // RuleManager instance to be created and then we disable the + // test extension and assert that the RuleManager has been cleared. + let ruleManager = await TestUtils.waitForCondition( + () => + ExtensionDNR.getRuleManager( + extension.extension, + /* createIfMissing= */ false + ), + "Wait for the test extension RuleManager to have neem created" + ); + Assert.ok(ruleManager, "Rule manager exists before unload"); + Assert.deepEqual( + ruleManager.getDynamicRules(), + getSchemaNormalizedRules(extension, updatedRules), + "Found the expected dynamic rules in the Rule manager" + ); + await extension.addon.disable(); + Assert.ok( + !ExtensionDNR.getRuleManager( + extension.extension, + /* createIfMissing= */ false + ), + "Rule manager erased after unload" + ); + + await extension.addon.enable(); + await extension.awaitMessage("bgpage:ready"); + await callTestMessageHandler(extension, "assertGetDynamicRules", { + expectedRules: getSchemaNormalizedRules(extension, updatedRules), + }); + + info("Verify dynamic rules updates after corrupted storage"); + + async function testLoadedRulesAfterDataCorruption({ + name, + asyncWriteStoreFile, + expectedCorruptFile, + }) { + info(`Tampering DNR store data: ${name}`); + + await extension.addon.disable(); + Assert.ok( + !ExtensionDNR.getRuleManager( + extension.extension, + /* createIfMissing= */ false + ), + "Rule manager erased after unload" + ); + + ok( + !dnrStore._dataPromises.has(extUUID), + "DNR store read data promise cleared after the extension has been disabled" + ); + ok( + !dnrStore._data.has(extUUID), + "DNR store data cleared from memory after the extension has been disabled" + ); + + await asyncWriteStoreFile(); + + await extension.addon.enable(); + await extension.awaitMessage("bgpage:ready"); + + await TestUtils.waitForCondition( + () => IOUtils.exists(`${expectedCorruptFile}`), + `Wait for the "${expectedCorruptFile}" file to have been created` + ); + + ok( + !(await IOUtils.exists(storeFile)), + "Corrupted store file expected to be removed" + ); + + await callTestMessageHandler(extension, "assertGetDynamicRules", { + expectedRules: [], + }); + + const newRules = [getDNRRule({ id: 3 })]; + const expectedRules = getSchemaNormalizedRules(extension, newRules); + await callTestMessageHandler(extension, "testUpdateDynamicRules", { + updateRulesRequests: [{ addRules: newRules }], + expectedRules, + }); + + await TestUtils.waitForCondition( + () => IOUtils.exists(storeFile), + `Wait for the "${storeFile}" file to have been created` + ); + + const newData = await IOUtils.readJSON(storeFile, { decompress: true }); + Assert.deepEqual( + newData.dynamicRuleset, + expectedRules, + "Expect the new rules to have been stored on disk" + ); + } + + await testLoadedRulesAfterDataCorruption({ + name: "invalid lz4 header", + asyncWriteStoreFile: () => + IOUtils.writeUTF8(storeFile, "not an lz4 compressed file", { + compress: false, + }), + expectedCorruptFile: `${storeFile}.corrupt`, + }); + + await testLoadedRulesAfterDataCorruption({ + name: "invalid json data", + asyncWriteStoreFile: () => + IOUtils.writeUTF8(storeFile, "invalid json data", { compress: true }), + expectedCorruptFile: `${storeFile}-1.corrupt`, + }); + + await testLoadedRulesAfterDataCorruption({ + name: "empty json data", + asyncWriteStoreFile: () => + IOUtils.writeUTF8(storeFile, "{}", { compress: true }), + expectedCorruptFile: `${storeFile}-2.corrupt`, + }); + + await testLoadedRulesAfterDataCorruption({ + name: "invalid staticRulesets property type", + asyncWriteStoreFile: () => + IOUtils.writeUTF8( + storeFile, + JSON.stringify({ + schemaVersion: dnrDataFromFile.schemaVersion, + extVersion: extension.extension.version, + staticRulesets: "Not an array", + }), + { compress: true } + ), + expectedCorruptFile: `${storeFile}-3.corrupt`, + }); + + await testLoadedRulesAfterDataCorruption({ + name: "invalid dynamicRuleset property type", + asyncWriteStoreFile: () => + IOUtils.writeUTF8( + storeFile, + JSON.stringify({ + schemaVersion: dnrDataFromFile.schemaVersion, + extVersion: extension.extension.version, + staticRulesets: [], + dynamicRuleset: "Not an array", + }), + { compress: true } + ), + expectedCorruptFile: `${storeFile}-4.corrupt`, + }); + + await extension.unload(); +}); + +add_task(async function test_tabId_conditions_invalid_in_dynamic_rules() { + await runAsDNRExtension({ + unloadTestAtEnd: true, + awaitFinish: true, + background: async dnrTestUtils => { + await dnrTestUtils.testInvalidRule( + { id: 1, action: { type: "block" }, condition: { tabIds: [1] } }, + "tabIds and excludedTabIds can only be specified in session rules" + ); + await dnrTestUtils.testInvalidRule( + { + id: 1, + action: { type: "block" }, + condition: { excludedTabIds: [1] }, + }, + "tabIds and excludedTabIds can only be specified in session rules" + ); + browser.test.assertDeepEq( + [], + await browser.declarativeNetRequest.getDynamicRules(), + "Expect the invalid rules to not be enabled" + ); + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_dynamic_rules_telemetry() { + resetTelemetryData(); + + let { extension } = await runAsDNRExtension({ + unloadTestAtEnd: false, + awaitFinish: false, + id: "test-dynamic-rules-telemetry@test-extension", + background: () => { + const dnr = browser.declarativeNetRequest; + + browser.test.onMessage.addListener(async (msg, ...args) => { + switch (msg) { + case "getDynamicRules": { + browser.test.sendMessage( + `${msg}:done`, + await dnr.getDynamicRules() + ); + break; + } + case "updateDynamicRules": { + const { addRules, removeRuleIds } = args[0]; + await dnr.updateDynamicRules({ + addRules, + removeRuleIds, + }); + browser.test.sendMessage( + `${msg}:done`, + await dnr.getDynamicRules() + ); + break; + } + default: { + browser.test.fail(`Unexpected test message: ${msg}`); + browser.test.sendMessage(`${msg}:done`); + break; + } + } + }); + browser.test.sendMessage("bgpage:ready"); + }, + }); + + await extension.awaitMessage("bgpage:ready"); + + extension.sendMessage("getDynamicRules"); + Assert.deepEqual( + await extension.awaitMessage("getDynamicRules:done"), + [], + "Expect no dynamic DNR rules" + ); + + assertDNRTelemetryMetricsNoSamples( + [ + { + metric: "validateRulesTime", + mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS", + mirroredType: "histogram", + }, + ], + "before test extension have been loaded" + ); + + const dynamicRules = [ + getDNRRule({ + id: 1, + action: { type: "block" }, + condition: { + resourceTypes: ["xmlhttprequest"], + requestDomains: ["example.com"], + }, + }), + getDNRRule({ + id: 2, + action: { type: "block" }, + condition: { + resourceTypes: ["xmlhttprequest"], + requestDomains: ["example.org"], + }, + }), + ]; + + await extension.sendMessage("updateDynamicRules", { + addRules: dynamicRules, + }); + + Assert.deepEqual( + await extension.awaitMessage("updateDynamicRules:done"), + getSchemaNormalizedRules(extension, dynamicRules), + "Expect new dynamic DNR rules to have been added" + ); + + assertDNRTelemetryMetricsNoSamples( + [ + { + metric: "validateRulesTime", + mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS", + mirroredType: "histogram", + }, + ], + "no additional rule validation expected for dynamic rules pre-validated on a updateDynamicRules API call" + ); + + extension.sendMessage("updateDynamicRules", { + removeRuleIds: [dynamicRules[1].id], + }); + + Assert.deepEqual( + await extension.awaitMessage("updateDynamicRules:done"), + getSchemaNormalizedRules(extension, [dynamicRules[0]]), + `Expect dynamic DNR rule with id ${dynamicRules[1].id} to have been removed` + ); + + assertDNRTelemetryMetricsNoSamples( + [ + { + metric: "validateRulesTime", + mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS", + mirroredType: "histogram", + }, + ], + "no additional rule validation expected for dynamic rules removed by a updateDynamicRules API call" + ); + + info("Disabling test extension"); + await extension.addon.disable(); + + assertDNRTelemetryMetricsNoSamples( + [ + { + metric: "validateRulesTime", + mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS", + mirroredType: "histogram", + }, + ], + "no rule validation hit after disabling the extension" + ); + + info("Re-enabling test extension"); + await extension.addon.enable(); + await extension.awaitMessage("bgpage:ready"); + info( + "Wait for DNR initialization completed for the re-enabled permanently installed extension" + ); + await ExtensionDNR.ensureInitialized(extension.extension); + + assertDNRTelemetryMetricsSamplesCount( + [ + { + metric: "validateRulesTime", + mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS", + mirroredType: "histogram", + expectedSamplesCount: 1, + }, + ], + "expected rule validation to be hit on re-loading dynamic rules from DNR store file" + ); + assertDNRTelemetryMetricsNoSamples( + [ + // Expected no startup cache file to be loaded or used on re-enabling a disabled extension. + { + metric: "startupCacheReadSize", + mirroredName: "WEBEXT_DNR_STARTUPCACHE_READ_BYTES", + mirroredType: "histogram", + }, + { + metric: "startupCacheReadTime", + mirroredName: "WEBEXT_DNR_STARTUPCACHE_READ_MS", + mirroredType: "histogram", + }, + ], + "on loading dnr rules for newly installed extension" + ); + + info("Verify evaluateRulesCountMax telemetry probe"); + + assertDNRTelemetryMetricsNoSamples( + [ + { + metric: "evaluateRulesTime", + mirroredName: "WEBEXT_DNR_EVALUATE_RULES_MS", + mirroredType: "histogram", + }, + { + metric: "evaluateRulesCountMax", + mirroredName: "extensions.apis.dnr.evaluate_rules_count_max", + mirroredType: "scalar", + }, + ], + "before any request have been intercepted" + ); + + Assert.equal( + await fetch("http://example.com/").then(res => res.text()), + "response from server", + "DNR should not block system requests" + ); + + assertDNRTelemetryMetricsNoSamples( + [ + { + metric: "evaluateRulesTime", + mirroredName: "WEBEXT_DNR_EVALUATE_RULES_MS", + mirroredType: "histogram", + }, + { + metric: "evaluateRulesCountMax", + mirroredName: "extensions.apis.dnr.evaluate_rules_count_max", + mirroredType: "scalar", + }, + ], + "after restricted request have been intercepted (but no rules evaluated)" + ); + + const page = await ExtensionTestUtils.loadContentPage("http://example.com"); + const callPageFetch = async () => { + Assert.equal( + await page.spawn([], () => { + return this.content.fetch("http://example.com/").then( + res => res.text(), + err => err.message + ); + }), + "NetworkError when attempting to fetch resource.", + "DNR should have blocked test request to example.com" + ); + }; + + // Expect one sample recorded on evaluating rules for the + // top level navigation. + let expectedEvaluateRulesTimeSamples = 1; + assertDNRTelemetryMetricsSamplesCount( + [ + { + metric: "evaluateRulesTime", + mirroredName: "WEBEXT_DNR_EVALUATE_RULES_MS", + mirroredType: "histogram", + expectedSamplesCount: expectedEvaluateRulesTimeSamples, + }, + ], + "evaluateRulesTime should be collected after evaluated rulesets" + ); + // Expect same number of rules currently included in the dynamic ruleset. + let expectedEvaluateRulesCountMax = 1; + assertDNRTelemetryMetricsGetValueEq( + [ + { + metric: "evaluateRulesCountMax", + mirroredName: "extensions.apis.dnr.evaluate_rules_count_max", + mirroredType: "scalar", + expectedGetValue: expectedEvaluateRulesCountMax, + }, + ], + "evaluateRulesCountMax should be collected after evaluated dynamic rulesets" + ); + + extension.sendMessage("updateDynamicRules", { + addRules: [dynamicRules[1]], + }); + + Assert.deepEqual( + await extension.awaitMessage("updateDynamicRules:done"), + getSchemaNormalizedRules(extension, dynamicRules), + `Expect second dynamic DNR rules to have been added` + ); + + await callPageFetch(); + + // Expect one new sample reported on evaluating rules for the + // first fetch request originated from the test page. + expectedEvaluateRulesTimeSamples += 1; + assertDNRTelemetryMetricsSamplesCount( + [ + { + metric: "evaluateRulesTime", + mirroredName: "WEBEXT_DNR_EVALUATE_RULES_MS", + mirroredType: "histogram", + expectedSamplesCount: expectedEvaluateRulesTimeSamples, + }, + ], + "evaluateRulesTime should be collected after evaluated rulesets" + ); + + // Expect new number of rules currently included in the dynamic ruleset. + expectedEvaluateRulesCountMax = dynamicRules.length; + assertDNRTelemetryMetricsGetValueEq( + [ + { + metric: "evaluateRulesCountMax", + mirroredName: "extensions.apis.dnr.evaluate_rules_count_max", + mirroredType: "scalar", + expectedGetValue: expectedEvaluateRulesCountMax, + }, + ], + "evaluateRulesCountMax should be increased after evaluated two dynamic rules" + ); + + extension.sendMessage("updateDynamicRules", { + removeRuleIds: [dynamicRules[1].id], + }); + + await callPageFetch(); + + Assert.deepEqual( + await extension.awaitMessage("updateDynamicRules:done"), + getSchemaNormalizedRules(extension, [dynamicRules[0]]), + `Expect only first dynamic DNR rule to have be available` + ); + + assertDNRTelemetryMetricsGetValueEq( + [ + { + metric: "evaluateRulesCountMax", + mirroredName: "extensions.apis.dnr.evaluate_rules_count_max", + mirroredType: "scalar", + expectedGetValue: expectedEvaluateRulesCountMax, + }, + ], + "evaluateRulesCountMax should NOT be decreased after removing one dynamic rules" + ); + + await page.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_modifyHeaders.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_modifyHeaders.js new file mode 100644 index 0000000000..236cda4e37 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_modifyHeaders.js @@ -0,0 +1,1072 @@ +"use strict"; + +const server = createHttpServer({ + hosts: ["dummy", "restricted", "yes", "no", "maybe", "cookietest"], +}); +server.registerPathHandler("/echoheaders", (req, res) => { + res.setHeader("Content-Type", "application/json"); + const headers = Object.create(null); + for (const nameSupports of req.headers) { + const name = nameSupports.QueryInterface(Ci.nsISupportsString).data; + // httpd.js automatically concats headers with ",", but in some cases it + // stores them separately, joined with "\n". + // https://searchfox.org/mozilla-central/rev/c1180ea13e73eb985a49b15c0d90e977a1aa919c/netwerk/test/httpserver/httpd.js#5271-5286 + const values = req.getHeader(name).split("\n"); + headers[name] = values.length === 1 ? values[0] : values; + } + + // Only keep custom headers, so that the test expectations does not have to + // enumerate all headers of interest. + function dropDefaultHeader(name) { + if (!(name in headers)) { + Assert.ok(false, `Header unexpectedly not found: ${name}`); + } + delete headers[name]; + } + dropDefaultHeader("host"); + dropDefaultHeader("user-agent"); + dropDefaultHeader("accept"); + dropDefaultHeader("accept-language"); + dropDefaultHeader("accept-encoding"); + dropDefaultHeader("connection"); + + res.write(JSON.stringify(headers)); +}); + +server.registerPathHandler("/host", (req, res) => { + res.write(req.getHeader("Host")); +}); + +server.registerPathHandler("/csptest", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.write("EXPECTED_RESPONSE_FOR /csp test"); +}); +server.registerPathHandler("/csp", (req, res) => { + // Inserting the ";" just in case something somehow merges the headers by "," + // (e.g. to "bla,; default-src http://yes http://maybe ;,bla"). + // This ensures that the server-set "default-src" CSP is not somehow mangled. + res.setHeader( + "Content-Security-Policy", + "; default-src http://yes http://maybe ;" + ); +}); + +server.registerPathHandler("/responseheadersFixture", (req, res) => { + res.setHeader("a", "server_a"); + res.setHeader("b", "server_b"); + res.setHeader("c", "server_c"); + res.setHeader("d", "server_d"); + res.setHeader("e", "server_e"); + // www-authenticate and proxy-authenticate are among the few headers where + // the test server (httpd.js) allows multiple header lines instead of + // automatically concatenating them with ",": + // https://searchfox.org/mozilla-central/rev/a4a41aafa80bf38f6e456238a60781fed46f9d08/netwerk/test/httpserver/httpd.js#5280 + res.setHeader("www-authenticate", "first_line"); + res.setHeader("www-authenticate", "second_line", /* merge */ true); + res.setHeader("proxy-authenticate", "first_line"); + res.setHeader("proxy-authenticate", "second_line", /* merge */ true); +}); + +server.registerPathHandler("/setcookie", (req, res) => { + // set-cookie is also allowed to span multiple lines. + res.setHeader("Set-Cookie", "food=yummy; max-age=999"); + res.setHeader("Set-Cookie", "second=serving; max-age=999", /* merge */ true); + res.write(req.hasHeader("Cookie") ? req.getHeader("Cookie") : ""); +}); +server.registerPathHandler("/empty", (req, res) => {}); + +add_setup(() => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); + + // The restrictedDomains pref should be set early, because the pref is read + // only once (on first use) by WebExtensionPolicy::IsRestrictedURI. + Services.prefs.setCharPref( + "extensions.webextensions.restrictedDomains", + "restricted" + ); +}); + +// This function is serialized and called in the context of the test extension's +// background page. dnrTestUtils is passed to the background function. +function makeDnrTestUtils() { + const dnrTestUtils = {}; + async function fetchAsJson(url, options) { + let res = await fetch(url, options); + let txt = await res.text(); + try { + return JSON.parse(txt); + } catch (e) { + return txt; + } + } + Object.assign(dnrTestUtils, { + fetchAsJson, + }); + return dnrTestUtils; +} + +async function runAsDNRExtension({ + background, + manifest, + unloadTestAtEnd = true, +}) { + let extension = ExtensionTestUtils.loadExtension({ + background: `(${background})((${makeDnrTestUtils})())`, + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest"], + host_permissions: ["<all_urls>"], + granted_host_permissions: true, + ...manifest, + }, + temporarilyInstalled: true, // <-- for granted_host_permissions + }); + await extension.startup(); + await extension.awaitFinish(); + if (unloadTestAtEnd) { + await extension.unload(); + } + return extension; +} + +add_task(async function modifyHeaders_requestHeaders() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { fetchAsJson } = dnrTestUtils; + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { urlFilter: "set_twice" }, + action: { + type: "modifyHeaders", + requestHeaders: [ + { operation: "set", header: "a", value: "a-first" }, + // second set should be ignored after set. + { operation: "set", header: "a", value: "a-second" }, + ], + }, + }, + { + id: 2, + condition: { urlFilter: "set_and_remove" }, + action: { + type: "modifyHeaders", + requestHeaders: [ + { operation: "set", header: "b", value: "b-value" }, + // remove should be ignored after set. + { operation: "remove", header: "b" }, + ], + }, + }, + { + id: 3, + condition: { urlFilter: "remove_and_set" }, + action: { + type: "modifyHeaders", + requestHeaders: [ + { operation: "remove", header: "c" }, + // set should be ignored after remove. + { operation: "set", header: "c", value: "c-value" }, + // append should be ignored after remove. + { operation: "append", header: "c", value: "c-appended" }, + ], + }, + }, + { + id: 4, + condition: { urlFilter: "remove_only" }, + action: { + type: "modifyHeaders", + requestHeaders: [{ operation: "remove", header: "d" }], + }, + }, + { + id: 5, + condition: { urlFilter: "append_twice" }, + action: { + type: "modifyHeaders", + requestHeaders: [ + { operation: "append", header: "e", value: "e-first" }, + { operation: "append", header: "e", value: "e-second" }, + ], + }, + }, + { + id: 6, + condition: { urlFilter: "set_and_append" }, + action: { + type: "modifyHeaders", + requestHeaders: [ + { operation: "set", header: "f", value: "f-first" }, + { operation: "append", header: "f", value: "f-second" }, + ], + }, + }, + ], + }); + + browser.test.assertDeepEq( + { existing: "header" }, + await fetchAsJson( + "http://dummy/echoheaders?not_matching_any_dnr_rule", + { headers: { existing: "header" } } + ), + "Sanity check: should echo original headers without matching DNR rules" + ); + + // Tests set_twice rule: + + browser.test.assertDeepEq( + { a: "a-first" }, + await fetchAsJson("http://dummy/echoheaders?set_twice"), + "only the first header should be used when set twice" + ); + browser.test.assertDeepEq( + { a: "a-first" }, + await fetchAsJson("http://dummy/echoheaders?set_twice", { + headers: { a: "original" }, + }), + "original header should be overwritten by DNR" + ); + + // Tests set_and_remove rule: + + browser.test.assertDeepEq( + { b: "b-value" }, + await fetchAsJson("http://dummy/echoheaders?set_and_remove"), + "after setting a header, remove should be ignored" + ); + browser.test.assertDeepEq( + { b: "b-value" }, + await fetchAsJson("http://dummy/echoheaders?set_and_remove", { + headers: { b: "original" }, + }), + "after overwriting a header, remove should be ignored" + ); + + // Tests remove_and_set rule: + + browser.test.assertDeepEq( + { start: "START", end: "end" }, + await fetchAsJson("http://dummy/echoheaders?remove_and_set", { + headers: { start: "START", c: "remove me", end: "end" }, + }), + "after removing a header, remove should be ignored" + ); + browser.test.assertDeepEq( + {}, + await fetchAsJson("http://dummy/echoheaders?remove_and_set"), + "after a remove op (despite no existing header), set should be ignored" + ); + + // Tests remove_only rule: + + browser.test.assertDeepEq( + {}, + await fetchAsJson("http://dummy/echoheaders?remove_only", { + headers: { d: "remove me please" }, + }), + "should remove header" + ); + + // Tests append_twice rule: + + browser.test.assertDeepEq( + { e: "original, e-first, e-second" }, + await fetchAsJson("http://dummy/echoheaders?append_twice", { + headers: { e: "original" }, + }), + "should append headers" + ); + browser.test.assertDeepEq( + { e: "e-first, e-second" }, + await fetchAsJson("http://dummy/echoheaders?append_twice"), + "should append headers if there are no existing ones yet" + ); + + // Tests set_and_append rule: + + browser.test.assertDeepEq( + { f: "f-first, f-second" }, + await fetchAsJson("http://dummy/echoheaders?set_and_append", { + headers: { f: "original" }, + }), + "should overwrite and append headers" + ); + + // All rules together: + + browser.test.assertDeepEq( + { + a: "a-first", + b: "b-value", + e: "olde, e-first, e-second", + f: "f-first, f-second", + extra: "", + }, + await fetchAsJson( + "http://dummy/echoheaders?set_twice,set_and_remove,remove_and_set,remove_only,append_twice,set_and_append", + { + headers: { + a: "olda", + b: "oldb", + c: "oldc", + d: "oldd", + e: "olde", + f: "oldf", + extra: "", + }, + } + ), + "modifyHeaders actions from multiple rules should all apply" + ); + + browser.test.notifyPass(); + }, + }); +}); + +// Host header is restricted, for details see bug 1467523. +add_task(async function requestHeaders_set_host_header() { + async function background() { + const makeModifyHostRule = (id, urlFilter, value) => ({ + id, + condition: { urlFilter }, + action: { + type: "modifyHeaders", + requestHeaders: [{ operation: "set", header: "Host", value }], + }, + }); + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + makeModifyHostRule(1, "yes_host_permissions", "yes"), + makeModifyHostRule(2, "no_host_permissions", "no"), + makeModifyHostRule(3, "restricted_domain", "restricted"), + ], + }); + + browser.test.assertEq( + "yes", + await (await fetch("http://dummy/host?yes_host_permissions")).text(), + "Host header value allowed if extension has permission for new value" + ); + + browser.test.assertEq( + "dummy", + await (await fetch("http://dummy/host?no_host_permissions")).text(), + "Host header value ignored if extension misses permission for new value" + ); + + browser.test.assertEq( + "dummy", + await (await fetch("http://dummy/host?restricted_domain")).text(), + "Host header value ignored if new host is in restrictedDomains" + ); + + browser.test.notifyPass(); + } + const { messages } = await promiseConsoleOutput(async () => { + await runAsDNRExtension({ + manifest: { + // Note: host_permissions without "*://no/*". + host_permissions: ["*://dummy/*", "*://yes/*", "*://restricted/*"], + }, + background, + }); + }); + + AddonTestUtils.checkMessages(messages, { + expected: [ + { + message: + /Failed to apply modifyHeaders action to header "Host" \(DNR rule id 2 from ruleset "_session"\): Error: Unable to set host header, url missing from permissions\./, + }, + { + message: + /Failed to apply modifyHeaders action to header "Host" \(DNR rule id 3 from ruleset "_session"\): Error: Unable to set host header to restricted url\./, + }, + ], + }); +}); + +add_task(async function requestHeaders_set_host_header_multiple_extensions() { + async function background() { + const hostHeaderValue = browser.runtime.getManifest().name; + + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { resourceTypes: ["xmlhttprequest"] }, + action: { + type: "modifyHeaders", + requestHeaders: [ + { operation: "set", header: "Host", value: hostHeaderValue }, + // Add a unique header for each request to verify that the + // extension can still modify other headers despite failure to + // modify the host header. + { operation: "set", header: hostHeaderValue, value: "setbydnr" }, + ], + }, + }, + ], + }); + browser.test.notifyPass(); + } + // Precedence is in install order, most recent first. + // While this extension is permitted to change Host to "maybe", it has a lower + // precedence than extensionWithPermissionAndHigherPrecedence. + let extensionWithPermissionButLowerPrecedence = await runAsDNRExtension({ + unloadTestAtEnd: false, + manifest: { + name: "maybe", + host_permissions: ["*://dummy/*", "*://maybe/*"], + }, + background, + }); + // This extension is permitted to change Host to "yes". + let extensionWithPermissionAndHigherPrecedence = await runAsDNRExtension({ + unloadTestAtEnd: false, + manifest: { name: "yes", host_permissions: ["*://dummy/*", "*://yes/*"] }, + background, + }); + // While this extension has the highest precedence by install order, it does + // not have permission to change "Host" to "no". + let extensionWithoutPermissionForHostHeader = await runAsDNRExtension({ + unloadTestAtEnd: false, + manifest: { name: "no", host_permissions: ["*://dummy/*"] }, + background, + }); + + Assert.equal( + await ExtensionTestUtils.fetch("http://dummy/", "http://dummy/host"), + "yes", + "Host header changedby the most recently installed extension with the right permission" + ); + + const { messages, result } = await promiseConsoleOutput(() => + ExtensionTestUtils.fetch("http://dummy/", "http://dummy/echoheaders") + ); + Assert.equal( + result, + `{"referer":"http://dummy/","no":"setbydnr","yes":"setbydnr","maybe":"setbydnr"}`, + "Host header changedby the most recently installed extension with the right permission" + ); + AddonTestUtils.checkMessages(messages, { + expected: [ + { + message: + /Failed to apply modifyHeaders action to header "Host" \(DNR rule id 1 from ruleset "_session"\): Error: Unable to set host header, url missing from permissions\./, + }, + ], + }); + + await extensionWithPermissionButLowerPrecedence.unload(); + await extensionWithPermissionAndHigherPrecedence.unload(); + await extensionWithoutPermissionForHostHeader.unload(); +}); + +add_task(async function modifyHeaders_responseHeaders() { + await runAsDNRExtension({ + background: async () => { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { urlFilter: "/responseheadersFixture" }, + action: { + type: "modifyHeaders", + responseHeaders: [ + { operation: "set", header: "a", value: "a-first" }, + // remove after set should be ignored: + { operation: "remove", header: "a" }, + // Second set should be ignored: + { operation: "set", header: "a", value: "a-second" }, + // But append is permitted: + { operation: "append", header: "a", value: "a-third" }, + // Another append is allowed too: + { operation: "append", header: "a", value: "a-fourth" }, + // An unrelated set is accepted: + { operation: "set", header: "b", value: "b-dnr" }, + // An unrelated remove is also accepted: + { operation: "remove", header: "c" }, + // An unrelated append is also accepted: + { operation: "append", header: "d", value: "d-dnr" }, + // The server also sends the "e" header, we don't touch that. + + // The server sends the www-authenticate header on two lines, + // which should be removed. + { operation: "remove", header: "www-authenticate" }, + // The server also sends the proxy-authenticate header on two + // lines, but we don't touch that. + ], + }, + }, + ], + }); + + let { headers } = await fetch("http://dummy/responseheadersFixture"); + browser.test.assertEq( + "a-first, a-third, a-fourth", + headers.get("a"), + "a set, ignored set + remove, 2x append" + ); + browser.test.assertEq("b-dnr", headers.get("b"), "b set"); + browser.test.assertEq(null, headers.get("c"), "c removed"); + browser.test.assertEq("server_d, d-dnr", headers.get("d"), "d appended"); + browser.test.assertEq("server_e", headers.get("e"), "e not touched"); + browser.test.assertEq( + null, + headers.get("www-authenticate"), + "multi-line www-authenticate header removed" + ); + + // Multi-line http headers cannot be tested through fetch/Headers. This is + // a known limitation of that API, see e.g. note about Set-Cookie in the + // fetch spec - https://fetch.spec.whatwg.org/#headers-class + browser.test.assertEq( + null, // Note: null because Headers does not see multi-line headers. + headers.get("proxy-authenticate"), + "multi-line proxy-authenticate header kept (but fetch cannot see it)" + ); + + // XMLHttpRequest can return multi-line values, so we use that instead. + const xhr = new XMLHttpRequest(); + await new Promise(r => { + xhr.onloadend = r; + xhr.open("GET", "http://dummy/responseheadersFixture?xhr"); + xhr.send(); + }); + browser.test.assertEq( + null, + xhr.getResponseHeader("www-authenticate"), + "multi-line www-authenticate header removed" + ); + browser.test.assertEq( + "first_line\nsecond_line", + xhr.getResponseHeader("proxy-authenticate"), + "multi-line proxy-authenticate header kept (seen through XHR)" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function responseHeaders_set_content_security_policy_header() { + let extension = await runAsDNRExtension({ + unloadTestAtEnd: false, + background: async () => { + // By default, a DNR condition excludes the main frame. But to verify that + // the CSP works, we have to modify the CSP header of a document request. + const resourceTypes = ["main_frame"]; + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { resourceTypes, urlFilter: "/csp?remove" }, + action: { + type: "modifyHeaders", + responseHeaders: [ + { operation: "remove", header: "Content-Security-Policy" }, + ], + }, + }, + { + id: 2, + condition: { resourceTypes, urlFilter: "/csp?append_to_server" }, + action: { + type: "modifyHeaders", + responseHeaders: [ + { + operation: "append", + header: "Content-Security-Policy", + // Server has "default-src http://yes http://maybe". When + // multiple CSP header lines are present, all policies should + // be enforced, thus "http://no" below should be ignored, and + // the "http://maybe" from the server be ignored. + value: "connect-src http://YES http://not-maybe http://no", + }, + ], + }, + }, + { + id: 3, + condition: { resourceTypes, urlFilter: "/csp?set_and_append" }, + action: { + type: "modifyHeaders", + responseHeaders: [ + { + operation: "set", + header: "Content-Security-Policy", + value: "connect-src 1-of-2 http://yes http://maybe", + }, + { + operation: "append", + header: "Content-Security-Policy", + value: "connect-src 2-of-2 http://yes", + }, + ], + }, + }, + ], + }); + + browser.test.notifyPass(); + }, + }); + + async function testFetchAndCSP(url) { + info(`testFetchAndCSP: ${url}`); + let contentPage = await ExtensionTestUtils.loadContentPage(url); + let cspTestResults = await contentPage.spawn([], async () => { + const { document } = content; + async function doFetchAndCheckCSP(url) { + const cspTestResult = { url, violatedCSP: [] }; + let cspListener; + let cspEventPromise = new Promise(resolve => { + cspListener = e => { + cspTestResult.violatedCSP.push(e.originalPolicy); + // A CSP violation results in an event for each violated policy, + // dispatched after each other. Post a macrotask to ensure that all + // violations are caught. + content.setTimeout(resolve, 0); + }; + }); + document.addEventListener("securitypolicyviolation", cspListener); + try { + let res = await content.fetch(url); + let responseText = await res.text(); + if (responseText !== "EXPECTED_RESPONSE_FOR /csp test") { + cspTestResult.unexpectedResponseText = responseText; + } + // No await cspEventPromise, because we are not expecting any errors. + // If there was any CSP violation, we would have ended in catch. + } catch (e) { + dump(`\nFailed to fetch ${url}, waiting for CSP report/event.\n`); + await cspEventPromise; + } + document.removeEventListener("securitypolicyviolation", cspListener); + return cspTestResult; + } + + return { + yes: await doFetchAndCheckCSP("http://yes/csptest"), + maybe: await doFetchAndCheckCSP("http://maybe/csptest"), + no: await doFetchAndCheckCSP("http://no/csptest"), + }; + }); + await contentPage.close(); + return cspTestResults; + } + + // Note: this is derived from the server's policy. The server sends a bit more + // in the Content-Security-Policy header (i.e. ";"), but the normalized form + // is as follows. + const SERVER_DEFAULT_CSP = "default-src http://yes http://maybe"; + + // First, sanity check: + Assert.deepEqual( + await testFetchAndCSP("http://dummy/csp"), + { + yes: { url: "http://yes/csptest", violatedCSP: [] }, + maybe: { url: "http://maybe/csptest", violatedCSP: [] }, + no: { url: "http://no/csptest", violatedCSP: [SERVER_DEFAULT_CSP] }, + }, + "Sanity check: Server sends CSP that only allows requests to http://yes." + ); + + Assert.deepEqual( + await testFetchAndCSP("http://dummy/csp?remove"), + { + yes: { url: "http://yes/csptest", violatedCSP: [] }, + maybe: { url: "http://maybe/csptest", violatedCSP: [] }, + no: { url: "http://no/csptest", violatedCSP: [] }, + }, + "DNR remove CSP: results in no requests blocked by CSP" + ); + + Assert.deepEqual( + { + yes: { url: "http://yes/csptest", violatedCSP: [] }, + maybe: { + url: "http://maybe/csptest", + violatedCSP: [ + // This value was appended by DNR (with upper-case "http://YES", but + // the normalized form should be lowercase "http://yes"), and notably + // the "yes" request above should still pass. + "connect-src http://yes http://not-maybe http://no", + ], + }, + no: { url: "http://no/csptest", violatedCSP: [SERVER_DEFAULT_CSP] }, + }, + await testFetchAndCSP("http://dummy/csp?append_to_server"), + "DNR append CSP: should enforce CSP of server and DNR" + ); + + Assert.deepEqual( + await testFetchAndCSP("http://dummy/csp?set_and_append"), + { + yes: { url: "http://yes/csptest", violatedCSP: [] }, + maybe: { + url: "http://maybe/csptest", + violatedCSP: [ + // Note: "http://" is before 2-of-2 due to bug 1804145. + "connect-src http://2-of-2 http://yes", + ], + }, + no: { + url: "http://no/csptest", + violatedCSP: [ + // Note: "http://" is before 1-of-2 and 2-of-2 due to bug 1804145. + "connect-src http://1-of-2 http://yes http://maybe", + "connect-src http://2-of-2 http://yes", + ], + }, + }, + "DNR set + append CSP: should enforce both CSPs from DNR" + ); + + await extension.unload(); +}); + +// Set-Cookie is special because it may span multiple lines. This test tests a +// combination of requestHeaders/responseHeaders and that the DNR-set cookies +// are really working, i.e. visible to server and/or modifying the client's +// cookie jar. +add_task(async function requestHeaders_and_responseHeaders_cookies() { + let extension = await runAsDNRExtension({ + unloadTestAtEnd: false, + background: async () => { + // By default, a DNR condition excludes the main frame. But this test uses + // a document load to verify that cookie header modifications (if any) are + // reflected in document.cookie. + const resourceTypes = ["main_frame"]; + + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { resourceTypes, urlFilter: "dnr_resp_drop_cookie" }, + action: { + type: "modifyHeaders", + responseHeaders: [{ operation: "remove", header: "set-cookie" }], + }, + }, + { + id: 2, + condition: { resourceTypes, urlFilter: "dnr_resp_set_cookie" }, + action: { + type: "modifyHeaders", + responseHeaders: [ + { + operation: "set", + header: "set-cookie", + value: "dnr_res=set; max-age=999", + }, + ], + }, + }, + { + id: 3, + condition: { resourceTypes, urlFilter: "dnr_set_cookie_to_req" }, + action: { + type: "modifyHeaders", + requestHeaders: [ + { operation: "set", header: "cookie", value: "dnr_req=1" }, + ], + }, + }, + { + id: 4, + condition: { + resourceTypes, + urlFilter: "dnr_append_cookie_to_req_and_res", + }, + action: { + type: "modifyHeaders", + requestHeaders: [ + // Just for extra coverage, mix upper/lower case. + { operation: "append", header: "Cookie", value: "DNR_APP=1" }, + { operation: "append", header: "cookie", value: "DNR_app=2" }, + ], + responseHeaders: [ + { + operation: "append", + header: "set-cookie", + value: "dnr_res=appended; max-age=999", + }, + ], + }, + }, + { + id: 5, + condition: { + resourceTypes, + urlFilter: "dnr_set_server_cookies_expired", + }, + action: { + type: "modifyHeaders", + responseHeaders: [ + { + operation: "set", + header: "set-cookie", + value: "food=deletedbydnr; second=deletedbydnr; max-age=-1", + }, + { + operation: "append", + header: "set-cookie", + value: "second=deletedbydnr; max-age=-1", + }, + ], + }, + }, + { + id: 6, + condition: { + resourceTypes, + urlFilter: "dnr_resp_append_expired_cookie", + }, + action: { + type: "modifyHeaders", + responseHeaders: [ + { + operation: "append", + header: "set-cookie", + value: "dnr_res=deleteme; max-age=-1", + }, + ], + }, + }, + ], + }); + + browser.test.notifyPass(); + }, + }); + + async function loadPageAndGetCookies(pathAndQuery) { + const url = `http://cookietest${pathAndQuery}`; + info(`loadPageAndGetCookies: ${url}`); + let contentPage = await ExtensionTestUtils.loadContentPage(url); + let res = await contentPage.spawn([], () => { + const { document } = content; + const sortCookies = s => s.split("; ").sort().join("; "); + return { + // Server at /setcookie echos value of Cookie request header. + serverSeenCookies: sortCookies(document.body.textContent), + clientSeenCookies: sortCookies(document.cookie), + }; + }); + await contentPage.close(); + return res; + } + + Assert.deepEqual( + { serverSeenCookies: "", clientSeenCookies: "" }, + await loadPageAndGetCookies("/setcookie?dnr_resp_drop_cookie"), + "Set-Cookie from server ignored due to DNR (remove Set-Cookie)" + ); + Assert.deepEqual( + { + serverSeenCookies: "", + clientSeenCookies: "dnr_res=set", + }, + await loadPageAndGetCookies("/setcookie?dnr_resp_set_cookie"), + "Set-Cookie from server overwritten by DNR (set Set-Cookie)" + ); + Assert.deepEqual( + { + // No cookies from previous request + request-specific cookie from DNR. + serverSeenCookies: "dnr_req=1", + // Notably, "dnr_req=1" should be missing from clientSeenCookies, because + // it is added in the request, so only seen by the server. Only cookies + // set by Set-Cookie are persisted/seen by the client. + clientSeenCookies: "dnr_res=set; food=yummy; second=serving", + }, + await loadPageAndGetCookies("/setcookie?dnr_set_cookie_to_req"), + "Cookie req header from DNR, shadows existing client-generated Cookie header" + ); + Assert.deepEqual( + { + // Cookies from previous request + request-specific cookies from DNR. + serverSeenCookies: + "DNR_APP=1; DNR_app=2; dnr_res=set; food=yummy; second=serving", + // NDR_APP and DNR_app are notably missing. dnr_res was modified by DNR, + // because an appended cookie with the same name overwrites existing one. + clientSeenCookies: "dnr_res=appended; food=yummy; second=serving", + }, + await loadPageAndGetCookies("/setcookie?dnr_append_cookie_to_req_and_res"), + "Cookie req header from DNR, merged with existing client cookies; Set-Cookie from server merged with DNR (append Set-Cookie)" + ); + Assert.deepEqual( + { + // Cookies from previous request (not changed by DNR): + serverSeenCookies: "dnr_res=appended; food=yummy; second=serving", + // Server cookies removed, only previously added DNR cookie sticks: + clientSeenCookies: "dnr_res=appended", + }, + await loadPageAndGetCookies("/setcookie?dnr_set_server_cookies_expired"), + "Set-Cookie from server expired by DNR (set Set-Cookie + expire server cookies)" + ); + Assert.deepEqual( + { + // Cookies from previous request (not changed by DNR): + serverSeenCookies: "dnr_res=appended", + // Cookies from server; because we used "append", they should merge, and + // expire the previous DNR cookie, and create the server-set cookies. + clientSeenCookies: "food=yummy; second=serving", + }, + await loadPageAndGetCookies("/setcookie?dnr_resp_append_expired_cookie"), + "Set-Cookie from server merged with DNR (append Set-Cookie + expire dnr_res)" + ); + // We've already tested dnr_set_server_cookies_expired before, now we're just + // cleaning up. + Assert.deepEqual( + { + serverSeenCookies: "food=yummy; second=serving", + clientSeenCookies: "", + }, + await loadPageAndGetCookies("/setcookie?dnr_set_server_cookies_expired"), + "DNR cleared remaining cookies (set Set-Cookie + expire server cookies)" + ); + + await extension.unload(); +}); + +// This test confirms the effective modifyHeaders actions if multiple extensions +// have matching modifyHeaders rules. Only one extension is allowed to modify +// headers. +add_task(async function modifyHeaders_multiple_extensions() { + async function background() { + const extName = browser.runtime.getManifest().name; + function makeModifyHeadersRule(id, operation, headerName) { + const urlFilter = `${extName}_${operation}_${headerName}`; + let value; + if (operation !== "remove") { + // Use the urlFilter as value so that it's obvious which rule added it. + value = urlFilter; + } + return { + id, + condition: { urlFilter }, + action: { + type: "modifyHeaders", + // As the logic of responseHeaders and requestHeaders is shared, it + // suffices to only check responseHeaders here. + responseHeaders: [{ operation, header: headerName, value }], + }, + }; + } + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + makeModifyHeadersRule(1, "set", "a"), + makeModifyHeadersRule(2, "remove", "a"), + makeModifyHeadersRule(3, "append", "a"), + makeModifyHeadersRule(4, "set", "b"), + makeModifyHeadersRule(5, "remove", "b"), + makeModifyHeadersRule(6, "append", "b"), + ], + }); + browser.test.notifyPass(); + } + + // Cross-extension rule precedence is in the order of extension installation. + const prioTwoExtension = await runAsDNRExtension({ + manifest: { name: "prioTwo" }, + background, + unloadTestAtEnd: false, + }); + const prioOneExtension = await runAsDNRExtension({ + manifest: { name: "prioOne" }, + background, + unloadTestAtEnd: false, + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://dummy/empty" + ); + async function checkHeaderActionResult(query, expectedHeaders, description) { + const url = `/responseheadersFixture?${query}`; + const result = await contentPage.spawn([url], async url => { + const res = await content.fetch(url); + return { + a: res.headers.get("a"), + b: res.headers.get("b"), + }; + }); + Assert.deepEqual( + result, + expectedHeaders, + `${description} - Expected headers for ${url}` + ); + } + + await checkHeaderActionResult( + "", + { + a: "server_a", + b: "server_b", + }, + "Sanity check: headers should be unmodified without matching DNR rules" + ); + + // First: verify that "set" is only permitted if there are no other extensions + // that have already modified the header. Note that this requirement already + // holds for actions within one extension, so they should still be enforced + // for modifyHeaders actions from multiple extensions. + await checkHeaderActionResult( + "prioOne_set_a,prioTwo_set_a,prioTwo_set_b", + { + a: "prioOne_set_a", + b: "prioTwo_set_b", + }, + "set should only be allowed if no other extension has set a header" + ); + await checkHeaderActionResult( + "prioOne_remove_a,prioTwo_set_a,prioTwo_set_b", + { + a: null, + b: "prioTwo_set_b", + }, + "set should only be allowed if no other extension has removed the header" + ); + await checkHeaderActionResult( + "prioOne_append_a,prioTwo_set_a,prioTwo_set_b", + { + a: "server_a, prioOne_append_a", + b: "prioTwo_set_b", + }, + "set should only be allowed if no other extension has appended the header" + ); + + // The "remove" operation is not logically conflicting, let's confirm that it + // works as usual. + await checkHeaderActionResult( + "prioOne_remove_a,prioTwo_remove_a,prioTwo_remove_b", + { + a: null, + b: null, + }, + "remove should work, regardless of the number of extensions that use it" + ); + + // While an extension can specify multiple "append" operations, only one + // extension should be able to use it. Another extension is still allowed to + // modify an unrelated, not-yet-modified header. + await checkHeaderActionResult( + "prioOne_append_a,prioTwo_append_a,prioTwo_append_b", + { + a: "server_a, prioOne_append_a", + b: "server_b, prioTwo_append_b", + }, + "Only one extension may modify a specific header" + ); + + await contentPage.close(); + await prioOneExtension.unload(); + await prioTwoExtension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_private_browsing.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_private_browsing.js new file mode 100644 index 0000000000..e9e7f90b01 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_private_browsing.js @@ -0,0 +1,130 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Max-Age", "0"); +}); + +add_setup(() => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); +}); + +async function startDNRExtension({ privateBrowsingAllowed }) { + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: privateBrowsingAllowed ? "spanning" : undefined, + async background() { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [{ id: 1, condition: {}, action: { type: "block" } }], + }); + browser.test.sendMessage("dnr_registered"); + }, + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest"], + browser_specific_settings: { gecko: { id: "@dnr-ext" } }, + }, + }); + await extension.startup(); + await extension.awaitMessage("dnr_registered"); + return extension; +} + +async function testMatchedByDNR(privateBrowsing) { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/?page", + { privateBrowsing } + ); + let wasRequestBlocked = await contentPage.legacySpawn(null, async () => { + try { + await content.fetch("http://example.com/?fetch"); + return false; + } catch (e) { + // Request blocked by DNR rule from startDNRExtension(). + return true; + } + }); + await contentPage.close(); + return wasRequestBlocked; +} + +add_task(async function private_browsing_not_allowed_by_default() { + let extension = await startDNRExtension({ privateBrowsingAllowed: false }); + Assert.equal( + await testMatchedByDNR(false), + true, + "DNR applies to non-private browsing requests by default" + ); + Assert.equal( + await testMatchedByDNR(true), + false, + "DNR not applied to private browsing requests by default" + ); + await extension.unload(); +}); + +add_task(async function private_browsing_allowed() { + let extension = await startDNRExtension({ privateBrowsingAllowed: true }); + Assert.equal( + await testMatchedByDNR(false), + true, + "DNR applies to non-private requests regardless of privateBrowsingAllowed" + ); + Assert.equal( + await testMatchedByDNR(true), + true, + "DNR applied to private browsing requests when privateBrowsingAllowed" + ); + await extension.unload(); +}); + +add_task( + { pref_set: [["extensions.dnr.feedback", true]] }, + async function testMatchOutcome_unaffected_by_privateBrowsing() { + let extensionWithoutPrivateBrowsingAllowed = await startDNRExtension({}); + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"], + }, + files: { + "page.html": `<!DOCTYPE html><script src="page.js"></script>`, + "page.js": async () => { + browser.test.assertTrue( + browser.extension.inIncognitoContext, + "Extension page is opened in a private browsing context" + ); + browser.test.assertDeepEq( + { + matchedRules: [ + { ruleId: 1, rulesetId: "_session", extensionId: "@dnr-ext" }, + ], + }, + // testMatchOutcome does not offer a way to specify the private + // browsing mode of a request. Confirm that testMatchOutcome always + // simulates requests in normal private browsing mode, even if the + // testMatchOutcome method itself is called from an extension page + // in private browsing mode. + await browser.declarativeNetRequest.testMatchOutcome( + { url: "http://example.com/?simulated_request", type: "image" }, + { includeOtherExtensions: true } + ), + "testMatchOutcome includes DNR from extensions without pbm access" + ); + browser.test.sendMessage("done"); + }, + }, + }); + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/page.html`, + { privateBrowsing: true } + ); + await extension.awaitMessage("done"); + await contentPage.close(); + await extension.unload(); + await extensionWithoutPrivateBrowsingAllowed.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_redirect_transform.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_redirect_transform.js new file mode 100644 index 0000000000..de01169dea --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_redirect_transform.js @@ -0,0 +1,723 @@ +"use strict"; + +// The validate_action_redirect_transform task of test_ext_dnr_session_rules.js +// confirms that redirect transform rules meet some minimum bar of validation. +// Despite passing validation, there are still interesting cases to explore, +// ranging from verifying that special characters appear as expected, to +// verifying that an invalid URL (e.g. too long after the transform) is handled +// reasonably well. + +add_setup(() => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); + + // Allow navigation to URLs with embedded credentials, without prompt. + Services.prefs.setBoolPref("network.auth.confirmAuth.enabled", false); +}); + +const server = createHttpServer({ + hosts: ["from", "dest", "127.0.0.127", "[::1]", "xn--stra-yna.de", "fqdn."], +}); +server.identity.add("http", "dest", 443); // test_redirect_transform_port +server.identity.add("http", "dest", 700); // test_redirect_transform_port +server.identity.add("http", "dest", 777); // Dummy port in test cases. + +server.registerPrefixHandler("/", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.write("GOOD_RESPONSE"); +}); + +// This function is serialized and called in the context of the test extension's +// background page. dnrTestUtils is passed to the background function. +function makeDnrTestUtils() { + const dnrTestUtils = {}; + const dnr = browser.declarativeNetRequest; + function makeRedirectTransformRule(transform) { + return { + id: 1, + condition: { requestDomains: ["from"] }, + action: { + type: "redirect", + // redirect to "dest" by default, different from "from", to avoid an + // infinite redirect loop. + redirect: { transform: { host: "dest", ...transform } }, + }, + }; + } + async function setRedirectTransform(transform) { + await dnr.updateSessionRules({ + removeRuleIds: [1], + addRules: [makeRedirectTransformRule(transform)], + }); + } + // testFetch is simple/fast, but cannot always be used: + // - when the request URL contains embedded credentials. + // - when the final URL is supposed to contain a reference fragment. + async function testFetch(from, to, description) { + let res = await fetch(from); + browser.test.assertEq(to, res.url, description); + browser.test.assertEq("GOOD_RESPONSE", await res.text(), "expected body"); + } + // testNavigate is the slower, complex version of testFetch. It should be + // used in tests where the username, password or fragment components of a URL + // are significant. + async function testNavigate(from, to, description) { + let resultPromise = new Promise(resolve => { + browser.test.onMessage.addListener(function listener(msg, result) { + if (msg === "test_navigate_result") { + browser.test.onMessage.removeListener(listener); + // resolve only resolves on the first call, which is ideal because + // browser.test.onMessage.removeListener does not work (bug 1428213). + resolve(result); + } + }); + }); + browser.test.sendMessage("test_navigate", from); + browser.test.assertDeepEq({ from, to }, await resultPromise, description); + } + Object.assign(dnrTestUtils, { + makeRedirectTransformRule, + setRedirectTransform, + testFetch, + testNavigate, + }); + return dnrTestUtils; +} + +async function runAsDNRExtension({ background, manifest }) { + let extension = ExtensionTestUtils.loadExtension({ + background: `(${background})((${makeDnrTestUtils})())`, + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest"], + host_permissions: ["<all_urls>"], + granted_host_permissions: true, + web_accessible_resources: [ + { resources: ["war.txt"], matches: ["http://from/*"] }, + ], + ...manifest, + }, + temporarilyInstalled: true, // <-- for granted_host_permissions + files: { + "war.txt": "GOOD_RESPONSE", + "nowar.txt": "nowar.txt is not in web_accessible_resources", + }, + }); + extension.onMessage("test_navigate", async url => { + // The DNR rule does not redirect the main frame. + let contentPage = await ExtensionTestUtils.loadContentPage("http://from/"); + info(`Loading ${url}`); + await contentPage.spawn([url], async url => { + let { document } = this.content; + let frame = document.createElement("iframe"); + frame.src = url; + await new Promise(resolve => { + frame.onload = resolve; + document.body.appendChild(frame); + }); + }); + let finalURL = contentPage.browsingContext.children[0].currentURI.spec; + await contentPage.close(); + extension.sendMessage("test_navigate_result", { from: url, to: finalURL }); + }); + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +} + +add_task(async function test_redirect_transform_all_at_once() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ + scheme: "http", + username: "a", + password: "b", + host: "dest", + port: "777", + path: "/d", + query: "?e", + queryTransform: null, + fragment: "#f", + }); + await testFetch( + "https://from", + "http://a:b@dest:777/d?e", // note: fetch cannot see '#f'. + "Adds components to minimal URL (fetch)" + ); + await testNavigate( + "https://from", + "http://a:b@dest:777/d?e#f", + "Adds components to minimal URL (navigation)" + ); + + await browser.test.assertRejects( + testFetch("https://user:pass@from:777/path?query#ref"), + "Window.fetch: https://user:pass@from:777/path?query#ref is an url with embedded credentials.", + "fetch does not work with embedded credentials" + ); + await testNavigate( + "https://user:pass@from:777/path?query#ref", + "http://a:b@dest:777/d?e#f", + "Replaces all components in existing URL (navigation)" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_scheme() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ scheme: "http" }); + await testFetch("https://from/", "http://dest/", "scheme change"); + await testNavigate( + "https://user:pass@from:777/path?query#ref", + "http://user:pass@dest:777/path?query#ref", + "scheme change in complex URL with embedded credentials" + ); + + await setRedirectTransform({ + scheme: "moz-extension", + host: location.hostname, + }); + await testFetch( + "http://from/war.txt", + browser.runtime.getURL("war.txt"), + "Scheme change to moz-extension:-URL" + ); + await testNavigate( + "http://from/war.txt", + browser.runtime.getURL("war.txt"), + "Scheme change to moz-extension:-URL (navigation)" + ); + // While the initiator (extension) would be allowed to read the resource + // due to it being same-origin, the pre-redirect URL (http://from) is not + // matching web_accessible_resources[].matches, so the load is rejected. + // This scenario is also tested in test_ext_dnr_without_webrequest.js, at + // the redirect_request_with_dnr_to_extensionPath task. + await browser.test.assertRejects( + testFetch("http://from/nowar.txt"), + "NetworkError when attempting to fetch resource.", + "Cannot load redirect to moz-extension: not in web_accessible_resources" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_username() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ username: "" }); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://:pass@dest:777/path?query#ref", + "username cleared" + ); + + await setRedirectTransform({ username: "new" }); + // Cannot pass credentials to fetch, but can read from response.url: + await testFetch("http://from/", "http://new@dest/", "username added"); + await testNavigate("http://from/", "http://new@dest/", "username added"); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://new:pass@dest:777/path?query#ref", + "username changed" + ); + + await setRedirectTransform({ username: "new User:name@%%20/" }); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://new%20User%3Aname%40%%20%2F:pass@dest:777/path?query#ref", + "username changed to complex value" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_password() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ password: "" }); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://user@dest:777/path?query#ref", + "password cleared" + ); + + await setRedirectTransform({ password: "new" }); + // Cannot pass credentials to fetch, but can read from response.url: + await testFetch("http://from/", "http://:new@dest/", "password added"); + await testNavigate("http://from/", "http://:new@dest/", "password added"); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://user:new@dest:777/path?query#ref", + "password changed" + ); + + await setRedirectTransform({ password: "new Pass:@%%20/" }); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://user:new%20Pass%3A%40%%20%2F@dest:777/path?query#ref", + "password changed to complex value" + ); + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_host() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ host: "dest" }); + await testFetch( + "http://from:777/path?query", + "http://dest:777/path?query", + "host changed" + ); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://user:pass@dest:777/path?query#ref", + "host changed without affecting embedded credentials" + ); + + await setRedirectTransform({ host: "DEST" }); + await testFetch( + "http://from/", + "http://dest/", + "host changed (non-canonical, upper case)" + ); + + await setRedirectTransform({ host: "%44%65%73%54" }); // "DesT", escaped. + await testFetch( + "http://from:777/", + "http://dest:777/", + "host changed (non-canonical, percent-escaped)" + ); + + await setRedirectTransform({ host: "127.0.0.127" }); + await testFetch( + "http://from/", + "http://127.0.0.127/", + "host change to IPv4" + ); + + await setRedirectTransform({ host: "[::1]" }); + await testFetch("http://from/", "http://[::1]/", "host change to IPv6"); + + await setRedirectTransform({ host: "xn--stra-yna.de" }); + await testFetch( + "http://from/", + "http://xn--stra-yna.de/", + "host change to IDN (internationalized domain name, in punycode)" + ); + + await setRedirectTransform({ host: "straß.de" }); + await testFetch( + "http://from/", + "http://xn--stra-yna.de/", + "host change to IDN (not punycode-encoded)" + ); + + await setRedirectTransform({ host: "fqdn." }); + await testFetch( + "http://from/", + "http://fqdn./", + "host change to FQDN (fully-qualified domain name)" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_port() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ port: "" }); + await testFetch("http://from:777/", "http://dest/", "port cleared"); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://user:pass@dest/path?query#ref", + "port cleared from URL with embedded credentials" + ); + + await setRedirectTransform({ port: "700" }); + await testFetch("http://from/", "http://dest:700/", "port added"); + await testFetch("http://from:777/", "http://dest:700/", "port changed"); + + // 0-padded should not be misinterpreted as an octal number. + await setRedirectTransform({ port: "0700" }); + await testFetch( + "http://from:777/", + "http://dest:700/", + "port changed (non-canonical, 0-padded port)" + ); + + await setRedirectTransform({ port: "80" }); + await testFetch( + "http://from:777/", + "http://dest/", + "port cleared if default protocol" + ); + + await setRedirectTransform({ scheme: "http", port: "443" }); + await testFetch( + "https://from/", + "http://dest:443/", + "port added if new port is not default port of new protocol" + ); + + await setRedirectTransform({ scheme: "http", port: "80" }); + await testFetch( + "https://from:777/", + "http://dest/", + "port cleared if new port is default port of new protocol" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_path() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ path: "" }); + await testFetch("http://from/path", "http://dest/", "path cleared"); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://user:pass@dest:777/?query#ref", + "path cleared from URL with embedded credentials" + ); + + await setRedirectTransform({ path: "/new" }); + await testFetch("http://from/", "http://dest/new", "path added"); + await testFetch("http://from/path", "http://dest/new", "path changed"); + + await setRedirectTransform({ path: "///" }); + await testFetch("http://from/", "http://dest///", "path added (///)"); + + await setRedirectTransform({ path: "path" }); + await testFetch( + "http://from/", + "http://dest/path", + "path added (non-canonical, missing slash)" + ); + + // " " -> "%20" (space) + // "\x00" -> "%00" (null byte) + // "<>" -> "%3C%3E" (URL encoding of angle brackets) + // "%", "%20", "%3A", "%3a" -> not changed (%-encoding kept as-is). + await setRedirectTransform({ path: "/Path_%_ _%20_?_#_\x00_<>_%3A%3a" }); + await testFetch( + "http://from/", + "http://dest/Path_%_%20_%20_%3F_%23_%00_%3C%3E_%3A%3a", + "path added (non-canonical, partial percent encoding)" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_query() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ query: "" }); + await testFetch("http://from/?query", "http://dest/", "query cleared"); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://user:pass@dest:777/path#ref", + "query cleared from URL with embedded credentials" + ); + + await setRedirectTransform({ query: "?new" }); + await testFetch("http://from/", "http://dest/?new", "query added"); + await testFetch( + "http://from/?query", + "http://dest/?new", + "query changed" + ); + + await setRedirectTransform({ query: "?" }); + await testFetch("http://from/", "http://dest/?", "query set to just '?'"); + + await setRedirectTransform({ query: "?Query_#_ _%20_%3a%3A_<>_\x00" }); + await testFetch( + "http://from/", + "http://dest/?Query_%23_%20_%20_%3a%3A_%3C%3E_%00", + "query added (non-canonical, partial percent encoding)" + ); + + // Now rule.action.redirect.transform.queryTransform: + await setRedirectTransform({ + queryTransform: { + removeParams: ["query"], + }, + }); + await testFetch( + "http://from/?query", + "http://dest/", + "queryTransform removed query" + ); + await testFetch( + "http://from/?prefix&query&suffix", + "http://dest/?prefix&suffix", + "queryTransform removed part of query" + ); + await testFetch( + "http://from/?query&aquery&queryb&query=withvalue¬=query&QUERY&", + "http://dest/?aquery&queryb¬=query&QUERY&", + "queryTransform removed all occurrences of 'query' key" + ); + await testFetch( + "http://from/??query", + "http://dest/??query", + "queryTransform does not match param when it starts with '??'" + ); + + await setRedirectTransform({ + queryTransform: { + removeParams: ["query"], + addOrReplaceParams: [{ key: "query", value: "newvalue" }], + }, + }); + await testFetch( + "http://from/", + "http://dest/?query=newvalue", + "queryTransform appended query despite new param being in removeParams" + ); + await testFetch( + "http://from/?prefix&query&suffix", + "http://dest/?prefix&suffix&query=newvalue", + "queryTransform removed query, and appended new value" + ); + await testFetch( + "http://from/??query", + "http://dest/??query&query=newvalue", + "queryTransform ignores existing param starting with '??', and appends" + ); + + await setRedirectTransform({ + queryTransform: { + addOrReplaceParams: [{ key: "query", value: "newvalue" }], + }, + }); + await testFetch( + "http://from/", + "http://dest/?query=newvalue", + "queryTransform appended query" + ); + await testFetch( + "http://from/?prefix&query=oldvalue&query=2&query=3", + "http://dest/?prefix&query=newvalue&query=2&query=3", + "queryTransform replaced the first occurrence and kept the others" + ); + + await setRedirectTransform({ + queryTransform: { + addOrReplaceParams: [ + { key: "r", value: "default" }, // default:false + { key: "r", value: "false", replaceOnly: false }, + { key: "r", value: "true", replaceOnly: true }, + { key: "r", value: "false2", replaceOnly: false }, + { key: "r", value: "true2", replaceOnly: true }, + ], + }, + }); + // r=true and r=true2 are missing because there are no matching "r". + await testFetch( + "http://from/", + "http://dest/?r=default&r=false&r=false2", + "queryTransform appends all except replaceOnly=true" + ); + // r=true2 should be missing because there is no matching "r". + await testFetch( + "http://from/?r=1&r=2&r=3&___", + "http://dest/?r=default&r=false&r=true&___&r=false2", + "queryTransform replaced in order and ignores last replaceOnly=true" + ); + + await setRedirectTransform({ + queryTransform: { + addOrReplaceParams: [ + { key: "a", value: "appenda" }, + { key: "b", value: "b1" }, + { key: "c", value: "c1" }, + { key: "c", value: "c2" }, + { key: "c", value: "appendc" }, + { key: "d", value: "d1" }, + ], + }, + }); + // Test case has: b c c d. + // Rule only has: appenda b1 c2 appendc d1. + // Expected out : b1 c2 d1 appenda appendc. + await testFetch( + "http://from/?b=01&c=02&c=03&d=06", + "http://dest/?b=b1&c=c1&c=c2&d=d1&a=appenda&c=appendc", + "queryTransform replaces matched queries and appends the rest, in order" + ); + + await setRedirectTransform({ + queryTransform: { + addOrReplaceParams: [{ key: "query", value: " _+_%00_#" }], + }, + }); + await testFetch( + "http://from/", + "http://dest/?query=+_%2B_%2500_%23", + "queryTransform urlencodes values" + ); + + // This part tests how param names with non-alphanumeric characters can be + // (and not be) matched and replaced. This follows Chrome's behavior, see + // https://bugzilla.mozilla.org/show_bug.cgi?id=1801870#c1 + await setRedirectTransform({ + queryTransform: { + removeParams: ["?x", "%3Fx", "&x", "%26x"], + addOrReplaceParams: [ + // Internally interpreted as: %3Fp: + { key: "?p", value: "rawq", replaceOnly: true }, + // Internally interpreted as: %253Fp: + { key: "%3Fp", value: "escape_upper_q", replaceOnly: true }, + // Internally interpreted as: %253fp: + { key: "%3fp", value: "escape_lower_q", replaceOnly: true }, + // Internally interpreted as: %26p: + { key: "&p", value: "rawa", replaceOnly: true }, + // Internally interpreted as: %2526p: + { key: "%26p", value: "escape_a", replaceOnly: true }, + ], + }, + }); + await testFetch( + "http://from/?x&x&?x", + "http://dest/?x&x&?x", + "queryTransform does not match the '?' or '&' separators" + ); + await testFetch( + "http://from/??p&&p&?p", + "http://dest/??p&&p&?p", + "queryTransform cannot match literal '?p' because it is not urlencoded" + ); + await testFetch( + "http://from/?%3Fp", + "http://dest/?%3Fp=rawq", + "queryTransform matches already-urlencoded '%3Fp' with raw '?p'" + ); + await testFetch( + "http://from/?%3fp", + "http://dest/?%3fp", + "queryTransform cannot match non-canonical percent encoding (lowercase)" + ); + await testFetch( + "http://from/?%253fp&%253Fp", + "http://dest/?%253fp=escape_lower_q&%253Fp=escape_upper_q", + "queryTransform matches double-urlencoded '?p' with single-encoded '?p'" + ); + await testFetch( + "http://from/?%26p", + "http://dest/?%26p=rawa", + "queryTransform matches already-urlencoded '%26p' with raw '&p'" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_fragment() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + // Note: not using testFetch because it cannot see fragment changes. + const { setRedirectTransform, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ fragment: "" }); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://user:pass@dest:777/path?query", + "fragment cleared from URL with embedded credentials" + ); + + await setRedirectTransform({ fragment: "#new" }); + await testNavigate("http://from/", "http://dest/#new", "fragment added"); + await testNavigate( + "http://from/#ref", + "http://dest/#new", + "fragment changed" + ); + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_failed_at_runtime() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform } = dnrTestUtils; + + // Maximum length of a UTL is 1048576 (network.standard-url.max-length). + const network_standard_url_max_length = 1048576; + // updateSessionRules does some validation on the limit (as seen by + // validate_action_redirect_transform in test_ext_dnr_session_rules.js), + // but it is still possible to pass validation and fail in practice when + // the existing URL + new component exceeds the limit. + const VERY_LONG_STRING = "x".repeat(network_standard_url_max_length - 20); + + // Like testFetch, except truncates URLs in log messages to avoid logspam. + async function testFetchPossiblyLongUrl(from, to, body, description) { + let res = await fetch(from); + const shortx = s => s.replace(/x{10,}/g, xxx => `x{${xxx.length}}`); + // VERY_LONG_STRING consists of many 'X'. Shorten to avoid logspam. + browser.test.assertEq(shortx(to), shortx(res.url), description); + browser.test.assertEq(body, await res.text(), "expected body"); + } + + await setRedirectTransform({ query: "?" + VERY_LONG_STRING }); + await testFetchPossiblyLongUrl( + "http://from/short", + `http://dest/short?${VERY_LONG_STRING}`, + // Somehow the httpd server raises NS_ERROR_MALFORMED_URI when it tries + // to use newURI to parse the received URL. But the server responding + // with that implies that the redirect was successful, so for the + // purpose of this test, that response is acceptable. + "Bad request\n", + "Can redirect to URL near (but not over) url max-length" + ); + + // This check confirms that not only does the request not redirect to + // an invalid URL, but also that the request does not somehow end up in + // an infinite redirect loop. + await testFetchPossiblyLongUrl( + "http://from/1234567890_1234567890", + "http://from/1234567890_1234567890", + "GOOD_RESPONSE", + "Redirect to URL over max length is ignored; request continues" + ); + + browser.test.notifyPass(); + }, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter.js new file mode 100644 index 0000000000..0ee1bff815 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter.js @@ -0,0 +1,590 @@ +"use strict"; + +// This file provides test coverage for regexFilter and regexSubstitution. +// +// The validate_actions task of test_ext_dnr_session_rules.js checks that the +// basic requirements of regexFilter + regexSubstitution are met. +// +// The match_regexFilter task of test_ext_dnr_testMatchOutcome.js verifies that +// regexFilter is evaluated correctly in testMatchOutcome. +// +// The quota on regexFilter is verified in test_ext_dnr_regexFilter_limits.js. + +add_setup(() => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.feedback", true); +}); + +const server = createHttpServer({ + hosts: ["example.com", "example-com", "from", "dest"], +}); +server.registerPrefixHandler("/", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.write("GOOD_RESPONSE"); +}); + +// This function is serialized and called in the context of the test extension's +// background page. dnrTestUtils is passed to the background function. +function makeDnrTestUtils() { + const dnrTestUtils = {}; + const dnr = browser.declarativeNetRequest; + + async function testFetch(from, to, description) { + let res = await fetch(from); + browser.test.assertEq(to, res.url, description); + browser.test.assertEq("GOOD_RESPONSE", await res.text(), "expected body"); + } + + async function _testRegexFilterOrRedirect({ + description, + regexFilter, + isUrlFilterCaseSensitive, + expectedRedirectUrl = "http://dest/", + regexSubstitution = expectedRedirectUrl, + urlsMatching, + urlsNonMatching, + }) { + browser.test.log(`Test description: ${description}`); + await dnr.updateSessionRules({ + addRules: [ + { + id: 12345, + condition: { regexFilter, isUrlFilterCaseSensitive }, + action: { type: "redirect", redirect: { regexSubstitution } }, + }, + ], + }); + for (let url of urlsMatching) { + const description = `regexFilter ${regexFilter} should match: ${url}`; + await testFetch(url, expectedRedirectUrl, description); + } + for (let url of urlsNonMatching) { + const description = `regexFilter ${regexFilter} should not match: ${url}`; + let expectedUrl = new URL(url); + expectedUrl.hash = ""; + await testFetch(url, expectedUrl.href, description); + } + await dnr.updateSessionRules({ removeRuleIds: [12345] }); + } + + async function testValidRegexFilter({ + description, + regexFilter, + isUrlFilterCaseSensitive, + urlsMatching, + urlsNonMatching, + }) { + browser.test.assertDeepEq( + { isSupported: true }, + await dnr.isRegexSupported({ + regex: regexFilter, + isCaseSensitive: isUrlFilterCaseSensitive, + }), + `isRegexSupported should detect support for: ${regexFilter}` + ); + await _testRegexFilterOrRedirect({ + description, + regexFilter, + isUrlFilterCaseSensitive, + expectedRedirectUrl: "http://dest/", + regexSubstitution: "http://dest/", + urlsMatching, + urlsNonMatching, + }); + } + + async function testValidRegexSubstitution({ + description, + regexFilter, + regexSubstitution, + inputUrl, + expectedRedirectUrl, + }) { + browser.test.assertDeepEq( + { isSupported: true }, + await dnr.isRegexSupported({ + regex: regexFilter, + // requireCapturing option not strictly needed, but included to verify + // that the method can take the option without issues. + requireCapturing: true, + }), + `isRegexSupported should accept regexFilter: ${regexFilter}` + ); + + await _testRegexFilterOrRedirect({ + description, + regexFilter, + regexSubstitution, + urlsMatching: [inputUrl], + urlsNonMatching: [], + expectedRedirectUrl, + }); + } + + async function testInvalidRegexFilter(regexFilter, expectedError, msg) { + browser.test.assertDeepEq( + { isSupported: false, reason: "syntaxError" }, + await dnr.isRegexSupported({ regex: regexFilter }), + `isRegexSupported should detect unsupported regex: ${regexFilter}` + ); + await browser.test.assertRejects( + dnr.updateSessionRules({ + addRules: [ + { id: 123, condition: { regexFilter }, action: { type: "block" } }, + ], + }), + expectedError, + `Should reject invalid regexFilter (${regexFilter}) - ${msg}` + ); + } + + async function testInvalidRegexSubstitution( + regexSubstitution, + expectedError, + msg + ) { + await browser.test.assertRejects( + _testRegexFilterOrRedirect({ + description: `testInvalidRegexSubstitution: "${regexSubstitution}"`, + regexFilter: ".", + regexSubstitution, + urlsMatching: [], + urlsNonMatching: [], + }), + expectedError, + msg + ); + } + + async function testRejectedRedirectAtRuntime({ regexSubstitution, url }) { + // Some regexSubstitution rules pass validation but the generated redirect + // URL is rejected at runtime. That is validated here. + await _testRegexFilterOrRedirect({ + description: `testRejectedRedirectAtRuntime for URL: ${url}`, + regexFilter: "http://from/.*", + regexSubstitution, + // When regexSubstitution is invalid, it should not be redirected: + expectedRedirectUrl: url, + urlsMatching: [url], + urlsNonMatching: [], + }); + } + + Object.assign(dnrTestUtils, { + testValidRegexFilter, + testValidRegexSubstitution, + testInvalidRegexFilter, + testInvalidRegexSubstitution, + testRejectedRedirectAtRuntime, + }); + return dnrTestUtils; +} + +async function runAsDNRExtension({ background, manifest }) { + let extension = ExtensionTestUtils.loadExtension({ + background: `(${background})((${makeDnrTestUtils})())`, + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest"], + // host_permissions are needed for the redirect action. + host_permissions: ["<all_urls>"], + granted_host_permissions: true, + ...manifest, + }, + temporarilyInstalled: true, // <-- for granted_host_permissions + }); + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +} + +// The least common denominator across Chrome, Safari and Firefox is Safari, at +// the time of writing, the supported syntax in Safari's regexFilter is +// documented at https://webkit.org/blog/3476/content-blockers-first-look/, +// section "The Regular expression format": +// +// - Matching any character with “.”. +// - Matching ranges with the range syntax [a-b]. +// - Quantifying expressions with “?”, “+” and “*”. +// - Groups with parenthesis. +// - ... beginning of line (“^”) and end of line (“$”) marker ... +// +// The above syntax is very limited, as expressed at +// https://github.com/w3c/webextensions/issues/344 +// +// The tests continue in regexFilter_more_than_basic. +add_task(async function regexFilter_basic() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testValidRegexFilter } = dnrTestUtils; + + await testValidRegexFilter({ + description: "URL as regexFilter is sometimes a valid regexp", + regexFilter: "http://example.com/", + urlsMatching: [ + "http://example.com/", + // dot is wildcard. + "http://example-com/", + // Without ^ anchor, matches substring elsewhere. + "http://from/http://example.com/", + ], + urlsNonMatching: [ + "http://dest/http://example.com-no-slash-after-.com", + // Does not match reference fragment. + "http://dest/#http://example.com/", + ], + }); + + await testValidRegexFilter({ + description: "\\. is literal dot", + regexFilter: "http://example\\.com/", + urlsMatching: ["http://example.com/"], + urlsNonMatching: ["http://example-com/"], + }); + + await testValidRegexFilter({ + description: "[a-b] range is supported", + regexFilter: "http://from/[a-b]", + urlsMatching: ["http://from/a", "http://from/b"], + urlsNonMatching: ["http://from/c", "http://from/"], + }); + + await testValidRegexFilter({ + description: "groups with parenthesis are supported", + regexFilter: "http://from/(a)", + urlsMatching: ["http://from/a", "http://from/aa"], + urlsNonMatching: ["http://from/b", "http://from/ba"], + }); + + await testValidRegexFilter({ + description: "+, * and ? are quantifiers", + regexFilter: "a+b*c?d", + urlsMatching: [ + "http://from/ad", + "http://from/abcd", + "http://from/aaabbcd", + ], + urlsNonMatching: [ + "http://from/bcd", // "a+" requires "a" to be specified. + "http://from/abccd", // "c?" matches only one c, but got two. + ], + }); + + await testValidRegexFilter({ + description: ".* matches anything", + regexFilter: "a.*b", + urlsMatching: ["http://from/ab/", "http://from/aANYTHINGb"], + urlsNonMatching: ["http://from/a"], + }); + + await testValidRegexFilter({ + description: "^ is start-of-string anchor", + regexFilter: "^http://from/", + urlsMatching: ["http://from/", "http://from/path"], + urlsNonMatching: ["http://dest/^http://from/"], + }); + + await testValidRegexFilter({ + description: "$ is end-of-string anchor", + regexFilter: "http://from/$", + urlsMatching: ["http://from/", "http://dest/http://from/"], + urlsNonMatching: ["http://from/path", "http://from/$"], + }); + + browser.test.notifyPass(); + }, + }); +}); + +// regexFilter_basic lists the bare minimum, this tests more useful features. +add_task(async function regexFilter_more_than_basic() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testValidRegexFilter } = dnrTestUtils; + + // Use cases listed at + // https://github.com/w3c/webextensions/issues/344#issuecomment-1430358116 + + await testValidRegexFilter({ + description: "{n,m} quantifier", + regexFilter: "http://from/a{2,3}b", + urlsMatching: ["http://from/aab", "http://from/aaab"], + urlsNonMatching: ["http://from/ab", "http://from/aaaab"], + }); + + await testValidRegexFilter({ + description: "{n,} quantifier", + regexFilter: "http://from/a{2,}$", + urlsMatching: ["http://from/aa", "http://from/aaa", "http://from/aaaa"], + urlsNonMatching: ["http://from/a"], + }); + + await testValidRegexFilter({ + description: "| disjunction and within groups", + regexFilter: "from/a|from/b$|c$", + urlsMatching: ["http://from/a", "http://from/b", "http://from/c"], + urlsNonMatching: ["http://from/b$|c$"], + }); + + await testValidRegexFilter({ + description: "(?!) negative look-ahead", + regexFilter: "http://from/a(?!notme|$)", + urlsMatching: ["http://from/aOK"], + urlsNonMatching: ["http://from/anotme", "http://from/a"], + }); + + // Features based on + // https://github.com/w3c/webextensions/issues/344#issuecomment-1430127543 + await testValidRegexFilter({ + description: "Negated character class", + regexFilter: "http://from/[^a-z]", + urlsMatching: ["http://from/1"], + urlsNonMatching: ["http://from/a", "http://from/y", "http://from/"], + }); + + await testValidRegexFilter({ + description: "Word character class (\\w)", + regexFilter: "http://from/\\w", + urlsMatching: ["http://from/1", "http://from/a", "http://from/_"], + urlsNonMatching: ["http://from/-", "http://from/%20"], + }); + + // Rule that leads to "memoryLimitExceeded" in Chrome: + // https://github.com/w3c/webextensions/issues/344#issuecomment-1424527627 + await testValidRegexFilter({ + description: "regexFilter that triggers memoryLimitExceeded in Chrome", + regexFilter: "(https?://)104\\.154\\..{100,}", + urlsMatching: ["http://from/http://104.154.0.0/" + "x".repeat(100)], + urlsNonMatching: ["http://from/http://104.154.0.0/too-short"], + }); + + browser.test.notifyPass(); + }, + }); +}); + +// Adds more coverage in addition to what was tested by validate_regexFilter in +// test_ext_dnr_session_rules.js. +add_task(async function regexFilter_invalid() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testInvalidRegexFilter } = dnrTestUtils; + + await testInvalidRegexFilter( + "(", + "regexFilter is not a valid regular expression", + "( opens a group and should be closed" + ); + + await testInvalidRegexFilter( + "straß.d", + "regexFilter should not contain non-ASCII characters", + "regexFilter matches the canonical URL which does not contain non-ASCII" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function regexFilter_isUrlFilterCaseSensitive() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testValidRegexFilter } = dnrTestUtils; + + await testValidRegexFilter({ + description: "isUrlFilterCaseSensitive omitted (= false by default)", + // isUrlFilterCaseSensitive = false by default. + regexFilter: "from/Pa", + urlsMatching: ["http://from/Pa", "http://from/pa", "http://from/PA"], + urlsNonMatching: [], + }); + + await testValidRegexFilter({ + description: "isUrlFilterCaseSensitive: false", + isUrlFilterCaseSensitive: false, + regexFilter: "from/Pa", + urlsMatching: ["http://from/Pa", "http://from/pa", "http://from/PA"], + urlsNonMatching: [], + }); + + await testValidRegexFilter({ + description: "isUrlFilterCaseSensitive: true", + isUrlFilterCaseSensitive: true, + regexFilter: "from/Pa", + urlsMatching: ["http://from/Pa"], + urlsNonMatching: ["http://from/pa", "http://from/PA"], + }); + + await testValidRegexFilter({ + description: "Case-sensitive uppercase regexFilter cannot match HOST", + isUrlFilterCaseSensitive: true, + regexFilter: "FROM", + urlsMatching: [], + urlsNonMatching: ["http://FROM/canonical_host_is_lowercase"], + }); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function regexSubstitution_invalid() { + let { messages } = await promiseConsoleOutput(async () => { + await runAsDNRExtension({ + manifest: { browser_specific_settings: { gecko: { id: "@dnr" } } }, + background: async dnrTestUtils => { + const { testRejectedRedirectAtRuntime, testInvalidRegexSubstitution } = + dnrTestUtils; + + await testInvalidRegexSubstitution( + "http://dest/\\x20", + "redirect.regexSubstitution only allows digit or \\ after \\.", + "\\x should not be allowed in regexSubstitution" + ); + + await testInvalidRegexSubstitution( + "http://dest/?\\", + "redirect.regexSubstitution only allows digit or \\ after \\.", + "\\<end> should not be allowed in regexSubstitution" + ); + + await testRejectedRedirectAtRuntime({ + regexSubstitution: "not-URL", + url: "http://from/should_not_be_directed_invalid_url", + }); + + await testRejectedRedirectAtRuntime({ + regexSubstitution: "javascript://-URL", + url: "http://from/should_not_be_directed_javascript_url", + }); + + // May be allowed once bug 1622986 is fixed. + await testRejectedRedirectAtRuntime({ + regexSubstitution: "data:,redirect-from-dnr", + url: "http://from/should_not_be_directed_disallowed_url", + }); + + await testRejectedRedirectAtRuntime({ + regexSubstitution: "resource://gre/", + url: "http://from/should_not_be_directed_resource_url", + }); + + browser.test.notifyPass(); + }, + }); + }); + + AddonTestUtils.checkMessages(messages, { + expected: [ + { + message: /Extension @dnr tried to redirect to an invalid URL: not-URL/, + }, + { + message: /Extension @dnr may not redirect to: javascript:\/\/-URL/, + }, + { + message: /Extension @dnr may not redirect to: data:,redirect-from-dnr/, + }, + { + message: /Extension @dnr may not redirect to: resource:\/\/gre\//, + }, + ], + }); +}); + +add_task(async function regexSubstitution_valid() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testValidRegexSubstitution } = dnrTestUtils; + + await testValidRegexSubstitution({ + description: "All captured groups can be accessed by \\1 - \\9", + regexFilter: "from/(.)(.)(.)(.)(.)(.)(.)(.)(.)(.)(.)", + regexSubstitution: "http://dest/\\9\\8\\7\\6\\5\\4\\3\\2\\1", + inputUrl: "http://from/abcdef?gh-ignoredsuffix", + // ^ captured groups: 123456789 + expectedRedirectUrl: "http://dest/hg?fedcba", + }); + + await testValidRegexSubstitution({ + description: "\\0 captures the full match", + regexFilter: "from/$", + regexSubstitution: "http://dest/\\0/end", + inputUrl: "http://from/", + expectedRedirectUrl: "http://dest/from//end", + }); + + await testValidRegexSubstitution({ + description: "\\10 means: captured group 1 + literal 0", + regexFilter: "/(captured)$", + regexSubstitution: "http://dest/\\10", + inputUrl: "http://from/captured", + expectedRedirectUrl: "http://dest/captured0", + }); + + await testValidRegexSubstitution({ + description: "\\\\ is an escaped backslash", + regexFilter: "/(XXX)", + regexSubstitution: "http://dest/?\\1\\\\1\\\\\\\\1\\1", + inputUrl: "http://from/XXX", + expectedRedirectUrl: "http://dest/?XXX\\1\\\\1XXX", + }); + + await testValidRegexSubstitution({ + description: "Captured groups can be repeated", + regexFilter: "/(captured)$", + regexSubstitution: "http://dest/\\1+\\1", + inputUrl: "http://from/captured", + expectedRedirectUrl: "http://dest/captured+captured", + }); + + await testValidRegexSubstitution({ + description: "Non-matching optional group is an empty string", + regexFilter: "(doesnotmatch)?suffix", + regexSubstitution: "http://dest/[\\1]=group1_is_optional", + inputUrl: "http://from/suffix", + expectedRedirectUrl: "http://dest/[]=group1_is_optional", + }); + + await testValidRegexSubstitution({ + description: "Non-existing capturing group is an empty string", + regexFilter: "(captured)", + regexSubstitution: "http://dest/[\\2]=missing_group_2", + inputUrl: "http://from/captured", + expectedRedirectUrl: "http://dest/[]=missing_group_2", + }); + + await testValidRegexSubstitution({ + description: "Non-capturing group is not captured", + regexFilter: "(?:non-)(captured)", + regexSubstitution: "http://dest/[\\1]=only_captured_group", + inputUrl: "http://from/non-captured", + expectedRedirectUrl: "http://dest/[captured]=only_captured_group", + }); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function regexSubstitution_redirect_chain() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testValidRegexSubstitution } = dnrTestUtils; + + await testValidRegexSubstitution({ + description: "regexFilter matches intermediate redirect URLs", + regexFilter: "^(http://from/)(a|b|c)(.+)", + regexSubstitution: "\\1\\3", + inputUrl: "http://from/abcdef", + // After redirecting three times, we end up here: + expectedRedirectUrl: "http://from/def", + }); + + browser.test.notifyPass(); + }, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter_limits.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter_limits.js new file mode 100644 index 0000000000..443f69c2d1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter_limits.js @@ -0,0 +1,549 @@ +"use strict"; + +// This file verifies the quota on regexFilter rules for all ruleset. +// +// The generic rule limits (not specific to regexFilter) are covered elsewhere: +// - session_rules_total_rule_limit in test_ext_dnr_session_rules.js +// - test_dynamic_rules_count_limits in test_ext_dnr_dynamic_rules.js +// (also checks that the quota of session and dynamic rules are separate.) +// - test_getAvailableStaticRulesCountAndLimits and test_static_rulesets_limits +// in test_ext_dnr_static_rules.js. + +ChromeUtils.defineESModuleGetters(this, { + ExtensionDNRLimits: "resource://gre/modules/ExtensionDNRLimits.sys.mjs", +}); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +add_setup(async () => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.feedback", true); + + // We want to install add-ons through the add-on manager in order to be able + // to disable / re-enable the add-on. + await ExtensionTestUtils.startAddonManager(); +}); + +const _origDescs = {}; +function restoreDefaultDnrLimit(key) { + info(`Restoring original value of ExtensionDNRLimits.${key}`); + Object.defineProperty(ExtensionDNRLimits, key, _origDescs[key]); +} +function overrideDefaultDnrLimit(key, value) { + // Until DNR limits can be customized through prefs (bug 1803370), we need to + // overwrite the internals here in the parent process. That is sufficient to + // control the limits. Notably, this does NOT affect the values of the + // constants exposed through the declarativeNetRequest keyspace, because + // their values are directly read from the extension (child) process. + if (!_origDescs[key]) { + _origDescs[key] = Object.getOwnPropertyDescriptor(ExtensionDNRLimits, key); + registerCleanupFunction(() => restoreDefaultDnrLimit(key)); + } + Assert.ok( + typeof value === "number" && Number.isInteger(value), + `Setting ExtensionDNRLimits.${key} = ${value} (was: ${ExtensionDNRLimits[key]})` + ); + Object.defineProperty(ExtensionDNRLimits, key, { + configurable: true, + writable: true, + enumerable: true, + value, + }); +} + +// Create an extension composed of the given test cases, and start or reload +// the extension before each test case. +// +// testCases is an array of: +// - name - unique name describing purpose of test +// - setup - optional function run before (re-)enabling the extension. +// - backgroundFn - logic to run in the extension's background. +// - checkConsoleMessages - function to run to verify the console messages +// collected between extension (re)startup and the execution of backgroundFn. +// +// extensionDataTemplate should be a value for ExtensionTestUtils.loadExtension, +// without the background key. +async function startOrReloadExtensionForEach(testCases, extensionDataTemplate) { + for (let testCase of testCases) { + // Verify that the keys are supported, so that the test does not pass + // trivially because of a typo or something. + let okKeys = ["name", "setup", "backgroundFn", "checkConsoleMessages"]; + let keys = Object.keys(testCase).filter(k => !okKeys.includes(k)); + if (keys.length) { + throw new Error(`Unexpected key in testCase ${testCase.name}: ${keys}`); + } + } + if (extensionDataTemplate.background) { + // background is generated here, so the template should not specify it. + throw new Error("extensionDataTemplate.background should not be set"); + } + function background(testCases) { + browser.test.onMessage.addListener(async testName => { + try { + browser.test.log(`Starting backgroundFn for ${testName}`); + await testCases.find(({ name }) => name === testName).backgroundFn(); + } catch (e) { + browser.test.fail(`Unexpected error for ${testName}: ${e}`); + } + browser.test.log(`Completed backgroundFn for ${testName}`); + browser.test.sendMessage(`${testName}:done`); + }); + browser.test.sendMessage("background_started"); + } + + const serializedTestCases = testCases.map( + ({ name, backgroundFn }) => `{name:"${name}",backgroundFn:${backgroundFn}}` + ); + let extension = ExtensionTestUtils.loadExtension({ + ...extensionDataTemplate, + background: `(${background})([${serializedTestCases.join(",")}])`, + }); + + for (let [i, { name, setup, checkConsoleMessages }] of testCases.entries()) { + info(`Running test case: ${name}`); + await setup?.(); + + let { messages } = await promiseConsoleOutput(async () => { + if (i === 0) { + await extension.startup(); + } else { + await extension.addon.enable(); + } + await extension.awaitMessage("background_started"); + + // DNR rule loading errors should be emitted at startup. But since the + // rule loading is async and not blocking background startup, we need to + // roundtrip through the DNR API before we can verify the error message. + extension.sendMessage(name); + await extension.awaitMessage(`${name}:done`); + }); + + checkConsoleMessages(name, messages); + + if (i === testCases.length - 1) { + await extension.unload(); + } else { + await extension.addon.disable(); + } + info(`Completed test case: ${name}`); + } +} + +// Create the extensionDataTemplate value (without "background" key!) for use +// with ExtensionTestUtils.loadExtension, through startOrReloadExtensionForEach. +function makeExtensionDataTemplate({ manifest, files }) { + return { + // Note: no "background" key because startOrReloadExtensionForEach adds it. + useAddonManager: "permanent", + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"], + browser_specific_settings: { gecko: { id: "dnr@ext" } }, + ...manifest, + }, + files, + }; +} + +function genStaticRules(count) { + let rules = []; + for (let i = 1; i <= count; ++i) { + rules.push({ + id: i, + condition: { regexFilter: `prefix_${i}_suffix` }, + action: { type: "block" }, + }); + } + return JSON.stringify(rules); +} + +add_task(async function session_and_dynamic_regexFilter_limit() { + let extensionDataTemplate = makeExtensionDataTemplate({}); + + // Note: Every testPart* function will be serialized and be part of the test + // extension's background script. + + async function testPart1_session_and_dynamic_quota() { + let rules = []; + const { MAX_NUMBER_OF_REGEX_RULES } = browser.declarativeNetRequest; + for (let i = 1; i <= MAX_NUMBER_OF_REGEX_RULES; ++i) { + rules.push({ + id: i, + condition: { regexFilter: `prefix_${i}_suffix` }, + action: { type: "block" }, + }); + } + const lastRuleId = rules[rules.length - 1].id; + + browser.test.log(`Adding ${rules.length} regex rules (dynamic)`); + await browser.declarativeNetRequest.updateDynamicRules({ + addRules: rules, + }); + + browser.test.assertDeepEq( + { matchedRules: [{ ruleId: lastRuleId, rulesetId: "_dynamic" }] }, + await browser.declarativeNetRequest.testMatchOutcome({ + url: `http://example.com/prefix_${lastRuleId}_suffix`, + type: "other", + }), + "Expected last regexFilter to match the request" + ); + + // Dynamic and session rules should have a separate quota for regexFilter. + browser.test.log(`Adding ${rules.length} regex rules (session)`); + await browser.declarativeNetRequest.updateSessionRules({ + addRules: rules, + }); + + browser.test.assertDeepEq( + { matchedRules: [{ ruleId: lastRuleId, rulesetId: "_session" }] }, + await browser.declarativeNetRequest.testMatchOutcome({ + url: `http://example.com/prefix_${lastRuleId}_suffix`, + type: "other", + }), + "Expected registered regexFilter to match" + ); + + // Now we should not be able to add another one. + const newRule = { + id: lastRuleId + 1, + condition: { regexFilter: "." }, + action: { type: "block" }, + }; + await browser.test.assertRejects( + browser.declarativeNetRequest.updateSessionRules({ addRules: [newRule] }), + `Number of regexFilter rules in ruleset "_session" exceeds MAX_NUMBER_OF_REGEX_RULES.`, + "Should not allow regexFilter over quota for session ruleset" + ); + await browser.test.assertRejects( + browser.declarativeNetRequest.updateDynamicRules({ addRules: [newRule] }), + `Number of regexFilter rules in ruleset "_dynamic" exceeds MAX_NUMBER_OF_REGEX_RULES.`, + "Should not allow regexFilter over quota for dynamic ruleset" + ); + } + + async function testPart2_after_reload() { + browser.test.assertEq( + 0, + (await browser.declarativeNetRequest.getSessionRules()).length, + "Session rules gone after restart" + ); + let rules = await browser.declarativeNetRequest.getDynamicRules(); + browser.test.assertEq( + browser.declarativeNetRequest.MAX_NUMBER_OF_REGEX_RULES, + rules.length, + "Dynamic regexFilter rules still there after restart" + ); + + // This confirms that the quota for session rules is not somehow persisted + // somewhere else. + browser.test.log(`Verifying that we can add ${rules.length} rules again.`); + await browser.declarativeNetRequest.updateSessionRules({ + addRules: rules, + }); + } + + async function testPart3_too_many_regexFilters_stored_after_lowering_quota() { + browser.test.assertEq( + 0, + (await browser.declarativeNetRequest.getDynamicRules()).length, + "Ignore all stored dynamic rules when regexFilter quota is exceeded" + ); + } + + async function testPart4_reload_after_quota_back() { + // Implementation detail: while the in-memory representation of the + // dynamic rules has been wiped at the previous extension load, the disk + // representation did not change because we only read the dynamic rules + // without anything else triggering a save request. + // + // Therefore, when the limit was somehow restored, the on-disk data is + // now considered valid again. + browser.test.assertEq( + browser.declarativeNetRequest.MAX_NUMBER_OF_REGEX_RULES, + (await browser.declarativeNetRequest.getDynamicRules()).length, + "On-disk dynamic rules accepted when regexFilter quota is not exceeded" + ); + } + + // Expected warning in console when there are too many regexFilter rules in + // the dynamic ruleset data on disk. + const errorMsg = `Ignoring dynamic ruleset in extension "dnr@ext" because: Number of regexFilter rules in ruleset "_dynamic" exceeds MAX_NUMBER_OF_REGEX_RULES.`; + function expectError(testName, messages) { + Assert.equal( + messages.filter(m => m.message.includes(errorMsg)).length, + 1, + `${testName} should trigger the following error in the log: ${errorMsg}` + ); + } + function noErrors(testName, messages) { + Assert.equal( + messages.filter(m => m.message.includes(errorMsg)).length, + 0, + `${testName} should not trigger any logged errors` + ); + } + + const testCases = [ + { + name: "testPart1_session_and_dynamic_quota", + backgroundFn: testPart1_session_and_dynamic_quota, + checkConsoleMessages: noErrors, + }, + { + name: "testPart2_after_reload", + backgroundFn: testPart2_after_reload, + checkConsoleMessages: noErrors, + }, + { + name: "testPart3_too_many_regexFilters_stored_after_lowering_quota", + setup() { + // Artificially decrease the max number of allowed regexFilter rules, + // so that whatever that was stored on disk is no longer within quota. + overrideDefaultDnrLimit("MAX_NUMBER_OF_REGEX_RULES", 1); + }, + backgroundFn: testPart3_too_many_regexFilters_stored_after_lowering_quota, + checkConsoleMessages: expectError, + }, + { + name: "testPart4_reload_after_quota_back", + setup() { + // Restore the original quota after it was lowered in testPart3. + restoreDefaultDnrLimit("MAX_NUMBER_OF_REGEX_RULES"); + }, + backgroundFn: testPart4_reload_after_quota_back, + checkConsoleMessages: noErrors, + }, + ]; + + await startOrReloadExtensionForEach(testCases, extensionDataTemplate); +}); + +add_task(async function static_regexFilter_limit() { + const { MAX_NUMBER_OF_REGEX_RULES } = ExtensionDNRLimits; + + let extensionDataTemplate = makeExtensionDataTemplate({ + manifest: { + declarative_net_request: { + rule_resources: [ + // limit_plus_1 is over quota, but the other rules should be loaded + // if possible. + { id: "limit_plus_1", path: "limit_plus_1.json", enabled: true }, + { id: "just_one", path: "just_one.json", enabled: true }, + { id: "just_two", path: "just_two.json", enabled: true }, + { id: "limit_minus_2", path: "limit_minus_2.json", enabled: true }, + { id: "limit_minus_1", path: "limit_minus_1.json", enabled: false }, + ], + }, + }, + files: { + "limit_plus_1.json": genStaticRules(MAX_NUMBER_OF_REGEX_RULES + 1), + "just_one.json": genStaticRules(1), + "just_two.json": genStaticRules(2), + "limit_minus_2.json": genStaticRules(MAX_NUMBER_OF_REGEX_RULES - 2), + "limit_minus_1.json": genStaticRules(MAX_NUMBER_OF_REGEX_RULES - 1), + }, + }); + + // Note: Every testPart* function will be serialized and be part of the test + // extension's background script. + + async function testPart1_start_over_static_quota() { + browser.test.assertDeepEq( + ["just_one", "just_two"], + await browser.declarativeNetRequest.getEnabledRulesets(), + "Should only load rules that fit in the quota for static rules" + ); + } + + async function testPart2_after_reload() { + // Should still be the same. + browser.test.assertDeepEq( + ["just_one", "just_two"], + await browser.declarativeNetRequest.getEnabledRulesets(), + "Should only load rules that fit in the quota for static rules (again)" + ); + + await browser.declarativeNetRequest.updateEnabledRulesets({ + disableRulesetIds: ["just_one"], + }); + + browser.test.assertDeepEq( + ["just_two"], + await browser.declarativeNetRequest.getEnabledRulesets(), + "After disabling 'just_one', there should only be one enabled ruleset" + ); + } + + async function testPart3_after_updateEnabledRulesets_within_limit() { + browser.test.assertDeepEq( + ["just_two"], + await browser.declarativeNetRequest.getEnabledRulesets(), + "limit_minus_2 should still be disabled because of a previous updateEnabledRulesets call" + ); + + // This should succeed, as there is now enough space. + await browser.declarativeNetRequest.updateEnabledRulesets({ + enableRulesetIds: ["limit_minus_2"], + }); + browser.test.assertDeepEq( + ["just_two", "limit_minus_2"], + await browser.declarativeNetRequest.getEnabledRulesets(), + "limit_minus_2 should be enabled by updateEnabledRulesets" + ); + + await browser.test.assertRejects( + browser.declarativeNetRequest.updateEnabledRulesets({ + enableRulesetIds: ["just_one"], + }), + `Number of regexFilter rules across all enabled static rulesets exceeds MAX_NUMBER_OF_REGEX_RULES if ruleset "just_one" were to be enabled.`, + "Should not be able to enable just_one because limit was reached" + ); + } + + async function testPart4_toggling_rulesets_at_quota() { + browser.test.assertDeepEq( + ["just_two", "limit_minus_2"], + await browser.declarativeNetRequest.getEnabledRulesets(), + "Should have the two rulesets occupying all quota from the previous run" + ); + + await browser.declarativeNetRequest.updateEnabledRulesets({ + disableRulesetIds: ["just_two"], + enableRulesetIds: ["just_one"], + }); + browser.test.assertDeepEq( + ["just_one", "limit_minus_2"], + await browser.declarativeNetRequest.getEnabledRulesets(), + "Should be able to replace a ruleset as long as the result is within quota" + ); + + // Try to enable just_one + just_two (+existing limit_minus_2). + await browser.test.assertRejects( + browser.declarativeNetRequest.updateEnabledRulesets({ + disableRulesetIds: ["just_one"], + enableRulesetIds: ["just_one", "just_two"], + }), + `Number of regexFilter rules across all enabled static rulesets exceeds MAX_NUMBER_OF_REGEX_RULES if ruleset "just_two" were to be enabled.`, + "Should reject updateEnabledRulesets that would exceed the quota by 1" + ); + browser.test.assertDeepEq( + ["just_one", "limit_minus_2"], + await browser.declarativeNetRequest.getEnabledRulesets(), + "Rulesets should not have changed due to rejection" + ); + } + + async function testPart5_after_doubling_quota() { + browser.test.assertDeepEq( + ["just_one", "limit_minus_2"], + await browser.declarativeNetRequest.getEnabledRulesets(), + "Initial ruleset not changed after bumping the quota" + ); + // Explicitly disable before re-enabling them all to see whether the order + // of passed-in rulesets has any impact on the evaluation order at startup. + await browser.declarativeNetRequest.updateEnabledRulesets({ + disableRulesetIds: ["limit_minus_2", "just_one"], + }); + await browser.declarativeNetRequest.updateEnabledRulesets({ + enableRulesetIds: [ + "limit_minus_2", + "just_two", + "just_one", + "limit_minus_1", + ], + }); + browser.test.assertDeepEq( + ["just_one", "just_two", "limit_minus_2", "limit_minus_1"], + await browser.declarativeNetRequest.getEnabledRulesets(), + "All rulesets within new quota - ruleset order should match manifest order" + ); + } + + async function testPart6_after_restoring_original_quota_half() { + browser.test.assertDeepEq( + ["just_one", "just_two"], + await browser.declarativeNetRequest.getEnabledRulesets(), + "Should have trimmed excess rules in the manifest order" + ); + } + + const errorMsgPattern = + /Ignoring static ruleset "([^"]+)" in extension "dnr@ext" because: Number of regexFilter rules across all enabled static rulesets exceeds MAX_NUMBER_OF_REGEX_RULES if ruleset "\1" were to be enabled./; + function checkFailedRulesets(testName, messages, rulesetIds) { + let actualRulesetIds = messages + .map(m => errorMsgPattern.exec(m.message)?.[1]) + .filter(Boolean); + Assert.deepEqual( + rulesetIds, + actualRulesetIds, + `${testName} should only trigger errors for rejected rulesets at start` + ); + } + + const testCases = [ + { + name: "testPart1_start_over_static_quota", + backgroundFn: testPart1_start_over_static_quota, + checkConsoleMessages: (n, m) => + checkFailedRulesets(n, m, ["limit_plus_1", "limit_minus_2"]), + }, + { + name: "testPart2_after_reload", + backgroundFn: testPart2_after_reload, + // The extension has not called updateEnabledRulesets, so the "enabled" + // state of limit_minus_2 from manifest.json is still the extension's + // desired state for the ruleset. When the browser thus tries to enable + // the ruleset, it should encounter the same error as before. + // + // An alternative would be for the latest understanding of "enabled" to + // be persisted to disk and used when we load the persisted ruleset state. + // But if we do that, then we would not be able to distinguish "disabled + // because of a browser limit" from "disabled by extension". And if we + // cannot do that, then we would not be able to enable rulesets from + // already-installed extensions if we were to bump the limits in a browser + // update. + // + // Note: even if caching is implemented (bug 1803365), the observed + // behavior should happen, because the cache is cleared when we disable + // the extension. + checkConsoleMessages: (n, m) => + checkFailedRulesets(n, m, ["limit_plus_1", "limit_minus_2"]), + }, + { + name: "testPart3_after_updateEnabledRulesets_within_limit", + backgroundFn: testPart3_after_updateEnabledRulesets_within_limit, + checkConsoleMessages: (n, m) => checkFailedRulesets(n, m, []), + }, + { + name: "testPart4_toggling_rulesets_at_quota", + backgroundFn: testPart4_toggling_rulesets_at_quota, + checkConsoleMessages: (n, m) => checkFailedRulesets(n, m, []), + }, + { + name: "testPart5_after_doubling_quota", + setup() { + overrideDefaultDnrLimit( + "MAX_NUMBER_OF_REGEX_RULES", + 2 * MAX_NUMBER_OF_REGEX_RULES + ); + }, + backgroundFn: testPart5_after_doubling_quota, + checkConsoleMessages: (n, m) => checkFailedRulesets(n, m, []), + }, + { + name: "testPart6_after_restoring_original_quota_half", + setup() { + // Restore the original quota after it was raised in testPart5. + restoreDefaultDnrLimit("MAX_NUMBER_OF_REGEX_RULES"); + }, + backgroundFn: testPart6_after_restoring_original_quota_half, + checkConsoleMessages: (n, m) => + checkFailedRulesets(n, m, ["limit_minus_2", "limit_minus_1"]), + }, + ]; + + await startOrReloadExtensionForEach(testCases, extensionDataTemplate); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_session_rules.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_session_rules.js new file mode 100644 index 0000000000..5f0b0d72a2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_session_rules.js @@ -0,0 +1,1111 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionDNR: "resource://gre/modules/ExtensionDNR.sys.mjs", +}); + +add_setup(() => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); +}); + +// This function is serialized and called in the context of the test extension's +// background page. dnrTestUtils is passed to the background function. +function makeDnrTestUtils() { + const dnrTestUtils = {}; + const dnr = browser.declarativeNetRequest; + dnrTestUtils.makeRuleInput = id => { + return { + id, + condition: {}, + action: { type: "block" }, + }; + }; + dnrTestUtils.makeRuleOutput = id => { + return { + id, + condition: { + urlFilter: null, + regexFilter: null, + isUrlFilterCaseSensitive: null, + initiatorDomains: null, + excludedInitiatorDomains: null, + requestDomains: null, + excludedRequestDomains: null, + resourceTypes: null, + excludedResourceTypes: null, + requestMethods: null, + excludedRequestMethods: null, + domainType: null, + tabIds: null, + excludedTabIds: null, + }, + action: { + type: "block", + redirect: null, + requestHeaders: null, + responseHeaders: null, + }, + priority: 1, + }; + }; + + function serializeForLog(rule) { + // JSON-stringify, but drop null values (replacing them with undefined + // causes JSON.stringify to drop them), so that optional keys with the null + // values are hidden. + let str = JSON.stringify(rule, rep => rep ?? undefined); + // VERY_LONG_STRING consists of many 'X'. Shorten to avoid logspam. + str = str.replace(/x{10,}/g, xxx => `x{${xxx.length}}`); + return str; + } + + async function testInvalidRule(rule, expectedError, isSchemaError) { + if (isSchemaError) { + // Schema validation error = thrown error instead of a rejection. + browser.test.assertThrows( + () => dnr.updateSessionRules({ addRules: [rule] }), + expectedError, + `Rule should be invalid (schema-validated): ${serializeForLog(rule)}` + ); + } else { + await browser.test.assertRejects( + dnr.updateSessionRules({ addRules: [rule] }), + expectedError, + `Rule should be invalid: ${serializeForLog(rule)}` + ); + } + } + async function testInvalidCondition(condition, expectedError, isSchemaError) { + await testInvalidRule( + { id: 1, condition, action: { type: "block" } }, + expectedError, + isSchemaError + ); + } + async function testInvalidAction(action, expectedError, isSchemaError) { + await testInvalidRule( + { id: 1, condition: {}, action }, + expectedError, + isSchemaError + ); + } + + // The tests in this file merely verify whether rule registration and + // retrieval works. test_ext_dnr_testMatchOutcome.js checks rule evaluation. + async function testValidRule(rule) { + await dnr.updateSessionRules({ addRules: [rule] }); + + // Default rule with null for optional fields. + const expectedRule = dnrTestUtils.makeRuleOutput(); + expectedRule.id = rule.id; + Object.assign(expectedRule.condition, rule.condition); + Object.assign(expectedRule.action, rule.action); + if (rule.action.redirect) { + expectedRule.action.redirect = { + extensionPath: null, + url: null, + transform: null, + regexSubstitution: null, + ...rule.action.redirect, + }; + if (rule.action.redirect.transform) { + expectedRule.action.redirect.transform = { + scheme: null, + username: null, + password: null, + host: null, + port: null, + path: null, + query: null, + queryTransform: null, + fragment: null, + ...rule.action.redirect.transform, + }; + if (rule.action.redirect.transform.queryTransform) { + const qt = { + removeParams: null, + addOrReplaceParams: null, + ...rule.action.redirect.transform.queryTransform, + }; + if (qt.addOrReplaceParams) { + qt.addOrReplaceParams = qt.addOrReplaceParams.map(v => ({ + key: null, + value: null, + replaceOnly: false, + ...v, + })); + } + expectedRule.action.redirect.transform.queryTransform = qt; + } + } + } + if (rule.action.requestHeaders) { + expectedRule.action.requestHeaders = rule.action.requestHeaders.map( + h => ({ header: null, operation: null, value: null, ...h }) + ); + } + if (rule.action.responseHeaders) { + expectedRule.action.responseHeaders = rule.action.responseHeaders.map( + h => ({ header: null, operation: null, value: null, ...h }) + ); + } + + browser.test.assertDeepEq( + [expectedRule], + await dnr.getSessionRules(), + "Rule should be valid" + ); + + await dnr.updateSessionRules({ removeRuleIds: [rule.id] }); + } + async function testValidCondition(condition) { + await testValidRule({ id: 1, condition, action: { type: "block" } }); + } + async function testValidAction(action) { + await testValidRule({ id: 1, condition: {}, action }); + } + + Object.assign(dnrTestUtils, { + testInvalidRule, + testInvalidCondition, + testInvalidAction, + testValidRule, + testValidCondition, + testValidAction, + }); + return dnrTestUtils; +} + +async function runAsDNRExtension({ background, unloadTestAtEnd = true }) { + let extension = ExtensionTestUtils.loadExtension({ + background: `(${background})((${makeDnrTestUtils})())`, + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"], + }, + }); + await extension.startup(); + await extension.awaitFinish(); + if (unloadTestAtEnd) { + await extension.unload(); + } + return extension; +} + +add_task(async function register_and_retrieve_session_rules() { + let extension = await runAsDNRExtension({ + background: async dnrTestUtils => { + const dnr = browser.declarativeNetRequest; + // Rules input to updateSessionRules: + const RULE_1234_IN = dnrTestUtils.makeRuleInput(1234); + const RULE_4321_IN = dnrTestUtils.makeRuleInput(4321); + const RULE_9001_IN = dnrTestUtils.makeRuleInput(9001); + // Rules expected to be returned by getSessionRules: + const RULE_1234_OUT = dnrTestUtils.makeRuleOutput(1234); + const RULE_4321_OUT = dnrTestUtils.makeRuleOutput(4321); + const RULE_9001_OUT = dnrTestUtils.makeRuleOutput(9001); + + await dnr.updateSessionRules({ + // Deliberately rule 4321 before 1234, see next getSessionRules test. + addRules: [RULE_4321_IN, RULE_1234_IN], + removeRuleIds: [1234567890], // Invalid rules should be ignored. + }); + browser.test.assertDeepEq( + // Order is same as the original input. + [RULE_4321_OUT, RULE_1234_OUT], + await dnr.getSessionRules(), + "getSessionRules() returns all registered session rules" + ); + + await browser.test.assertRejects( + dnr.updateSessionRules({ + addRules: [RULE_9001_IN, RULE_1234_IN], + removeRuleIds: [RULE_4321_IN.id], + }), + "Duplicate rule ID: 1234", + "updateSessionRules of existing rule without removeRuleIds should fail" + ); + browser.test.assertDeepEq( + [RULE_4321_OUT, RULE_1234_OUT], + await dnr.getSessionRules(), + "session rules should not be changed if an error has occurred" + ); + + // From [4321,1234] to [1234,9001,4321]; 4321 moves to the end because + // the rule is deleted before inserted, NOT updated in-place. + await dnr.updateSessionRules({ + addRules: [RULE_9001_IN, RULE_4321_IN], + removeRuleIds: [RULE_4321_IN.id], + }); + browser.test.assertDeepEq( + [RULE_1234_OUT, RULE_9001_OUT, RULE_4321_OUT], + await dnr.getSessionRules(), + "existing session rule ID can be re-used for a new rule" + ); + + await dnr.updateSessionRules({ + removeRuleIds: [RULE_1234_IN.id, RULE_4321_IN.id, RULE_9001_IN.id], + }); + browser.test.assertDeepEq( + [], + await dnr.getSessionRules(), + "deleted all rules" + ); + + browser.test.notifyPass(); + }, + unloadTestAtEnd: false, + }); + + const realExtension = extension.extension; + Assert.ok( + ExtensionDNR.getRuleManager(realExtension, /* createIfMissing= */ false), + "Rule manager exists before unload" + ); + await extension.unload(); + Assert.ok( + !ExtensionDNR.getRuleManager(realExtension, /* createIfMissing= */ false), + "Rule manager erased after unload" + ); +}); + +add_task(async function validate_resourceTypes() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { + testInvalidCondition, + testInvalidRule, + testValidRule, + testValidCondition, + } = dnrTestUtils; + + await testInvalidCondition( + { resourceTypes: ["font", "image"], excludedResourceTypes: ["image"] }, + "resourceTypes and excludedResourceTypes should not overlap" + ); + await testInvalidCondition( + { resourceTypes: [], excludedResourceTypes: ["image"] }, + /resourceTypes: Array requires at least 1 items; you have 0/, + /* isSchemaError */ true + ); + await testValidCondition({ + resourceTypes: ["font"], + excludedResourceTypes: ["image"], + }); + await testValidCondition({ + resourceTypes: ["font"], + excludedResourceTypes: [], + }); + + // Validation specific to allowAllRequests + await testInvalidRule( + { + id: 1, + condition: {}, + action: { type: "allowAllRequests" }, + }, + "An allowAllRequests rule must have a non-empty resourceTypes array" + ); + await testInvalidRule( + { + id: 1, + condition: { resourceTypes: [] }, + action: { type: "allowAllRequests" }, + }, + /resourceTypes: Array requires at least 1 items; you have 0/, + /* isSchemaError */ true + ); + await testInvalidRule( + { + id: 1, + condition: { resourceTypes: ["main_frame", "image"] }, + action: { type: "allowAllRequests" }, + }, + "An allowAllRequests rule may only include main_frame/sub_frame in resourceTypes" + ); + await testValidRule({ + id: 1, + condition: { resourceTypes: ["main_frame"] }, + action: { type: "allowAllRequests" }, + }); + await testValidRule({ + id: 1, + condition: { resourceTypes: ["sub_frame"] }, + action: { type: "allowAllRequests" }, + }); + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function validate_requestMethods() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testInvalidCondition, testValidCondition } = dnrTestUtils; + + await testInvalidCondition( + { requestMethods: ["get"], excludedRequestMethods: ["post", "get"] }, + "requestMethods and excludedRequestMethods should not overlap" + ); + await testInvalidCondition( + { requestMethods: [] }, + /requestMethods: Array requires at least 1 items; you have 0/, + /* isSchemaError */ true + ); + await testInvalidCondition( + { requestMethods: ["GET"] }, + "request methods must be in lower case" + ); + await testInvalidCondition( + { excludedRequestMethods: ["PUT"] }, + "request methods must be in lower case" + ); + await testValidCondition({ excludedRequestMethods: [] }); + await testValidCondition({ + requestMethods: ["get", "head"], + excludedRequestMethods: ["post"], + }); + await testValidCondition({ + requestMethods: ["connect", "delete", "options", "patch", "put", "xxx"], + }); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function validate_tabIds() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testInvalidCondition, testValidCondition } = dnrTestUtils; + + await testInvalidCondition( + { tabIds: [1], excludedTabIds: [1] }, + "tabIds and excludedTabIds should not overlap" + ); + await testInvalidCondition( + { tabIds: [] }, + /tabIds: Array requires at least 1 items; you have 0/, + /* isSchemaError */ true + ); + await testValidCondition({ excludedTabIds: [] }); + await testValidCondition({ tabIds: [-1, 0, 1], excludedTabIds: [2] }); + await testValidCondition({ tabIds: [Number.MAX_SAFE_INTEGER] }); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function validate_domains() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testInvalidCondition, testValidCondition } = dnrTestUtils; + + await testInvalidCondition( + { requestDomains: [] }, + /requestDomains: Array requires at least 1 items; you have 0/, + /* isSchemaError */ true + ); + await testInvalidCondition( + { initiatorDomains: [] }, + /initiatorDomains: Array requires at least 1 items; you have 0/, + /* isSchemaError */ true + ); + // The include and exclude overlaps, but the validator doesn't reject it: + await testValidCondition({ + requestDomains: ["example.com"], + excludedRequestDomains: ["example.com"], + initiatorDomains: ["example.com"], + excludedInitiatorDomains: ["example.com"], + }); + await testValidCondition({ + excludedRequestDomains: [], + excludedInitiatorDomains: [], + }); + + // "null" is valid as a way to match an opaque initiator. + await testInvalidCondition( + { requestDomains: [null] }, + /requestDomains\.0: Expected string instead of null/, + /* isSchemaError */ true + ); + await testValidCondition({ requestDomains: ["null"] }); + + // IPv4 adress should be 4 digits separated by a dot. + await testInvalidCondition( + { requestDomains: ["1.2"] }, + /requestDomains\.0: Error: Invalid domain 1.2/, + /* isSchemaError */ true + ); + await testValidCondition({ requestDomains: ["0.0.1.2"] }); + + // IPv6 should be wrapped in brackets. + await testInvalidCondition( + { requestDomains: ["::1"] }, + /requestDomains\.0: Error: Invalid domain ::1/, + /* isSchemaError */ true + ); + // IPv6 addresses cannot contain dots. + await testInvalidCondition( + { requestDomains: ["[::ffff:127.0.0.1]"] }, + /requestDomains\.0: Error: Invalid domain \[::ffff:127\.0\.0\.1\]/, + /* isSchemaError */ true + ); + await testValidCondition({ + // "[::ffff:7f00:1]" is the canonical form of "[::ffff:127.0.0.1]". + requestDomains: ["[::1]", "[::ffff:7f00:1]"], + }); + + // International Domain Names should be punycode-encoded. + await testInvalidCondition( + { requestDomains: ["straß.de"] }, + /requestDomains\.0: Error: Invalid domain straß.de/, + /* isSchemaError */ true + ); + await testValidCondition({ requestDomains: ["xn--stra-yna.de"] }); + + // Domain may not contain a port. + await testInvalidCondition( + { requestDomains: ["a.com:1234"] }, + /requestDomains\.0: Error: Invalid domain a.com:1234/, + /* isSchemaError */ true + ); + // Upper case is not canonical. + await testInvalidCondition( + { requestDomains: ["UPPERCASE"] }, + /requestDomains\.0: Error: Invalid domain UPPERCASE/, + /* isSchemaError */ true + ); + // URL encoded is not canonical. + await testInvalidCondition( + { requestDomains: ["ex%61mple.com"] }, + /requestDomains\.0: Error: Invalid domain ex%61mple.com/, + /* isSchemaError */ true + ); + + // Verify that the validation is applied to all domain-related keys. + for (let domainsKey of [ + "initiatorDomains", + "excludedInitiatorDomains", + "requestDomains", + "excludedRequestDomains", + ]) { + await testInvalidCondition( + { [domainsKey]: [""] }, + new RegExp(String.raw`${domainsKey}\.0: Error: Invalid domain \)`), + /* isSchemaError */ true + ); + } + + browser.test.notifyPass(); + }, + }); +}); + +// Basic urlFilter validation; test_ext_dnr_urlFilter.js has more tests. +add_task(async function validate_urlFilter() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testInvalidCondition, testValidCondition } = dnrTestUtils; + + await testInvalidCondition( + { urlFilter: "", regexFilter: "" }, + "urlFilter and regexFilter are mutually exclusive" + ); + + await testInvalidCondition( + { urlFilter: 0 }, + /urlFilter: Expected string instead of 0/, + /* isSchemaError */ true + ); + await testInvalidCondition( + { urlFilter: "" }, + "urlFilter should not be an empty string" + ); + await testInvalidCondition( + { urlFilter: "||*" }, + "urlFilter should not start with '||*'" // should use '*' instead. + ); + await testInvalidCondition( + { urlFilter: "||*/" }, + "urlFilter should not start with '||*'" // should use '*' instead. + ); + await testInvalidCondition( + { urlFilter: "straß.de" }, + "urlFilter should not contain non-ASCII characters" + ); + await testValidCondition({ urlFilter: "xn--stra-yna.de" }); + await testValidCondition({ urlFilter: "||xn--stra-yna.de/" }); + + // The following are all logically equivalent to "||*" (and ""), but are + // considered valid in the DNR API implemented/documented by Chrome. + await testValidCondition({ urlFilter: "*" }); + await testValidCondition({ urlFilter: "****************" }); + await testValidCondition({ urlFilter: "||" }); + await testValidCondition({ urlFilter: "|" }); + await testValidCondition({ urlFilter: "|*|" }); + await testValidCondition({ urlFilter: "^" }); + await testValidCondition({ urlFilter: null }); + + await testValidCondition({ urlFilter: "||example^" }); + await testValidCondition({ urlFilter: "||example.com" }); + await testValidCondition({ urlFilter: "||example.com/index^" }); + await testValidCondition({ urlFilter: ".gif|" }); + await testValidCondition({ urlFilter: "|https:" }); + await testValidCondition({ urlFilter: "|https:*" }); + await testValidCondition({ urlFilter: "e" }); + await testValidCondition({ urlFilter: "%80" }); + await testValidCondition({ urlFilter: "*e*" }); // FYI: same as just "e". + await testValidCondition({ urlFilter: "*e*|" }); // FYI: same as just "e". + + let validchars = ""; + for (let i = 0; i < 0x80; ++i) { + validchars += String.fromCharCode(i); + } + await testValidCondition({ urlFilter: validchars }); + // Confirming that 0x80 and up is invalid. + await testInvalidCondition( + { urlFilter: "\x80" }, + "urlFilter should not contain non-ASCII characters" + ); + + browser.test.notifyPass(); + }, + }); +}); + +// Basic regexFilter validation; test_ext_dnr_regexFilter.js has more tests. +add_task(async function validate_regexFilter() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testInvalidCondition, testValidCondition } = dnrTestUtils; + + // This check is duplicated in validate_urlFilter. + await testInvalidCondition( + { urlFilter: "", regexFilter: "" }, + "urlFilter and regexFilter are mutually exclusive" + ); + + await testInvalidCondition( + { regexFilter: /regex/ }, + /regexFilter: Expected string instead of \{\}/, + /* isSchemaError */ true + ); + + await testInvalidCondition( + { regexFilter: "" }, + "regexFilter should not be an empty string" + ); + await testInvalidCondition( + { regexFilter: "*" }, + "regexFilter is not a valid regular expression" + ); + await testValidCondition( + { regexFilter: "^https://example\\.com\\/" }, + "regexFilter with valid regexp should be accepted" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function validate_actions() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testInvalidAction, testValidAction, testValidRule } = + dnrTestUtils; + + await testValidAction({ type: "allow" }); + // Note: allowAllRequests is already covered in validate_resourceTypes + await testValidAction({ type: "block" }); + await testValidAction({ type: "upgradeScheme" }); + await testValidAction({ type: "block" }); + + // redirect actions, invalid cases + await testInvalidAction( + { type: "redirect" }, + "A redirect rule must have a non-empty action.redirect object" + ); + await testInvalidAction( + { type: "redirect", redirect: {} }, + "A redirect rule must have a non-empty action.redirect object" + ); + await testInvalidAction( + { type: "redirect", redirect: { extensionPath: "/", url: "http://a" } }, + "redirect.url, redirect.extensionPath, redirect.transform and redirect.regexSubstitution are mutually exclusive" + ); + await testInvalidAction( + { type: "redirect", redirect: { extensionPath: "", url: "http://a" } }, + "redirect.url, redirect.extensionPath, redirect.transform and redirect.regexSubstitution are mutually exclusive" + ); + await testInvalidAction( + { + type: "redirect", + redirect: { regexSubstitution: "", transform: {} }, + }, + "redirect.url, redirect.extensionPath, redirect.transform and redirect.regexSubstitution are mutually exclusive" + ); + await testInvalidAction( + { + type: "redirect", + redirect: { regexSubstitution: "x", transform: {}, url: "http://a" }, + }, + "redirect.url, redirect.extensionPath, redirect.transform and redirect.regexSubstitution are mutually exclusive" + ); + await testInvalidAction( + { + type: "redirect", + redirect: { + url: "http://a", + extensionPath: "/", + transform: {}, + regexSubstitution: "http://a", + }, + }, + "redirect.url, redirect.extensionPath, redirect.transform and redirect.regexSubstitution are mutually exclusive" + ); + await testInvalidAction( + { type: "redirect", redirect: { extensionPath: "" } }, + "redirect.extensionPath should start with a '/'" + ); + await testInvalidAction( + { + type: "redirect", + redirect: { extensionPath: browser.runtime.getURL("/") }, + }, + "redirect.extensionPath should start with a '/'" + ); + await testInvalidAction( + { type: "redirect", redirect: { url: "javascript:" } }, + /Access denied for URL javascript:/, + /* isSchemaError */ true + ); + await testInvalidAction( + { type: "redirect", redirect: { url: "JAVASCRIPT:// Hmmm" } }, + /Access denied for URL javascript:\/\/ Hmmm/, + /* isSchemaError */ true + ); + await testInvalidAction( + { type: "redirect", redirect: { url: "about:addons" } }, + /Access denied for URL about:addons/, + /* isSchemaError */ true + ); + // TODO bug 1622986: allow redirects to data:-URLs. + await testInvalidAction( + { type: "redirect", redirect: { url: "data:," } }, + /Access denied for URL data:,/, + /* isSchemaError */ true + ); + await testInvalidAction( + { type: "redirect", redirect: { regexSubstitution: "http:///" } }, + "redirect.regexSubstitution requires the regexFilter condition to be specified" + ); + + // redirect actions, valid cases + await testValidAction({ + type: "redirect", + redirect: { extensionPath: "/foo.txt" }, + }); + await testValidAction({ + type: "redirect", + redirect: { url: "https://example.com/" }, + }); + await testValidAction({ + type: "redirect", + redirect: { url: browser.runtime.getURL("/") }, + }); + await testValidAction({ + type: "redirect", + redirect: { transform: {} }, + }); + // redirect.transform is validated in validate_action_redirect_transform. + await testValidRule({ + id: 1, + condition: { regexFilter: ".+" }, + action: { + type: "redirect", + redirect: { regexSubstitution: "http://example.com/" }, + }, + }); + // ^ redirect.regexSubstitution is tested by test_ext_dnr_regexFilter.js. + + // modifyHeaders actions, invalid cases + await testInvalidAction( + { type: "modifyHeaders" }, + "A modifyHeaders rule must have a non-empty requestHeaders or modifyHeaders list" + ); + await testInvalidAction( + { type: "modifyHeaders", requestHeaders: [] }, + /requestHeaders: Array requires at least 1 items; you have 0/, + /* isSchemaError */ true + ); + await testInvalidAction( + { type: "modifyHeaders", responseHeaders: [] }, + /responseHeaders: Array requires at least 1 items; you have 0/, + /* isSchemaError */ true + ); + await testInvalidAction( + { + type: "modifyHeaders", + requestHeaders: [{ header: "", operation: "remove" }], + }, + "header must be non-empty" + ); + await testInvalidAction( + { + type: "modifyHeaders", + responseHeaders: [{ header: "", operation: "remove" }], + }, + "header must be non-empty" + ); + await testInvalidAction( + { + type: "modifyHeaders", + responseHeaders: [{ header: "x", operation: "append" }], + }, + "value is required for operations append/set" + ); + await testInvalidAction( + { + type: "modifyHeaders", + responseHeaders: [{ header: "x", operation: "set" }], + }, + "value is required for operations append/set" + ); + await testInvalidAction( + { + type: "modifyHeaders", + responseHeaders: [{ header: "x", operation: "remove", value: "x" }], + }, + "value must not be provided for operation remove" + ); + await testInvalidAction( + { + type: "modifyHeaders", + responseHeaders: [{ header: "x", operation: "REMOVE", value: "x" }], + }, + /operation: Invalid enumeration value "REMOVE"/, + /* isSchemaError */ true + ); + + // modifyHeaders actions, valid cases + await testValidAction({ + type: "modifyHeaders", + requestHeaders: [{ header: "x", operation: "set", value: "x" }], + }); + await testValidAction({ + type: "modifyHeaders", + responseHeaders: [{ header: "x", operation: "set", value: "x" }], + }); + await testValidAction({ + type: "modifyHeaders", + requestHeaders: [{ header: "y", operation: "set", value: "y" }], + responseHeaders: [{ header: "z", operation: "set", value: "z" }], + }); + await testValidAction({ + type: "modifyHeaders", + requestHeaders: [ + { header: "reqh", operation: "set", value: "b" }, + // Note: contrary to Chrome, we support "append" for requestHeaders: + // https://bugzilla.mozilla.org/show_bug.cgi?id=1797404#c1 + { header: "reqh", operation: "append", value: "b" }, + { header: "reqh", operation: "remove" }, + ], + responseHeaders: [ + { header: "resh", operation: "set", value: "b" }, + { header: "resh", operation: "append", value: "b" }, + { header: "resh", operation: "remove" }, + ], + }); + + await testInvalidAction( + { type: "MODIFYHEADERS" }, + /type: Invalid enumeration value "MODIFYHEADERS"/, + /* isSchemaError */ true + ); + + browser.test.notifyPass(); + }, + }); +}); + +// This test task only verifies that a redirect transform is validated upon +// registration. A transform can result in an invalid redirect despite passing +// validation (see e.g. VERY_LONG_STRING below). +// test_ext_dnr_redirect_transform.js will test the behavior of such cases. +add_task(async function validate_action_redirect_transform() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testInvalidAction, testValidAction } = dnrTestUtils; + + const GENERIC_TRANSFORM_ERROR = + "redirect.transform does not describe a valid URL transformation"; + + const testValidTransform = transform => + testValidAction({ type: "redirect", redirect: { transform } }); + const testInvalidTransform = (transform, expectedError, isSchemaError) => + testInvalidAction( + { type: "redirect", redirect: { transform } }, + expectedError ?? GENERIC_TRANSFORM_ERROR, + isSchemaError + ); + + // Maximum length of a UTL is 1048576 (network.standard-url.max-length). + // Since URLs have other characters (separators), using VERY_LONG_STRING + // anywhere in a transform should be rejected. Note that this is mainly + // to verify that there is some bounds check on the URL. It is possible + // to generate a transform that is borderline valid at validation time, + // but invalid when applied to an existing longer URL. + const VERY_LONG_STRING = "x".repeat(1048576); + + // An empty transformation is still valid. + await testValidTransform({}); + + // redirect.transform.scheme + await testValidTransform({ scheme: "http" }); + await testValidTransform({ scheme: "https" }); + await testValidTransform({ scheme: "moz-extension" }); + await testInvalidTransform( + { scheme: "HTTPS" }, + /scheme: Invalid enumeration value "HTTPS"/, + /* isSchemaError */ true + ); + await testInvalidTransform( + { scheme: "javascript" }, + /scheme: Invalid enumeration value "javascript"/, + /* isSchemaError */ true + ); + // "ftp" is unsupported because support for it was dropped in Firefox. + // Chrome documents "ftp" as a supported scheme, but in practice it does + // not do anything useful, because it cannot handle ftp schemes either. + await testInvalidTransform( + { scheme: "ftp" }, + /scheme: Invalid enumeration value "ftp"/, + /* isSchemaError */ true + ); + + // redirect.transform.host + await testValidTransform({ host: "example.com" }); + await testValidTransform({ host: "example.com." }); + await testValidTransform({ host: "localhost" }); + await testValidTransform({ host: "127.0.0.1" }); + await testValidTransform({ host: "[::1]" }); + await testValidTransform({ host: "." }); + await testValidTransform({ host: "straß.de" }); + await testValidTransform({ host: "xn--stra-yna.de" }); + await testInvalidTransform({ host: "::1" }); // Invalid IPv6. + await testInvalidTransform({ host: "[]" }); // Invalid IPv6. + await testInvalidTransform({ host: "/" }); // Invalid host + await testInvalidTransform({ host: " a" }); // Invalid host + await testInvalidTransform({ host: "foo:1234" }); // Port not allowed. + await testInvalidTransform({ host: "foo:" }); // Port sep not allowed. + await testInvalidTransform({ host: "" }); // Host cannot be empty. + await testInvalidTransform({ host: VERY_LONG_STRING }); + + // redirect.transform.port + await testValidTransform({ port: "" }); // empty = strip port. + await testValidTransform({ port: "0" }); + await testValidTransform({ port: "0700" }); + await testValidTransform({ port: "65535" }); + const PORT_ERR = "redirect.transform.port should be empty or an integer"; + await testInvalidTransform({ port: "65536" }, GENERIC_TRANSFORM_ERROR); + await testInvalidTransform({ port: " 0" }, PORT_ERR); + await testInvalidTransform({ port: "0 " }, PORT_ERR); + await testInvalidTransform({ port: "0." }, PORT_ERR); + await testInvalidTransform({ port: "0x1" }, PORT_ERR); + await testInvalidTransform({ port: "1.2" }, PORT_ERR); + await testInvalidTransform({ port: "-1" }, PORT_ERR); + await testInvalidTransform({ port: "a" }, PORT_ERR); + // A naive implementation of `host = hostname + ":" + port` could be + // misinterpreted as an IPv6 address. Verify that this is not the case. + await testInvalidTransform({ host: "[::1", port: "2]" }, PORT_ERR); + await testInvalidTransform({ port: VERY_LONG_STRING }, PORT_ERR); + + // redirect.transform.path + await testValidTransform({ path: "" }); // empty = strip path. + await testValidTransform({ path: "/slash" }); + await testValidTransform({ path: "/ref#ok" }); // # will be escaped. + await testValidTransform({ path: "/\n\t\x00" }); // Will all be escaped. + // A path should start with a '/', but the implementation works fine + // without it, and Chrome doesn't require it either. + await testValidTransform({ path: "noslash" }); + await testValidTransform({ path: "http://example.com/" }); + await testInvalidTransform({ path: VERY_LONG_STRING }); + + // redirect.transform.query + await testValidTransform({ query: "" }); // empty = strip query. + await testValidTransform({ query: "?suffix" }); + await testValidTransform({ query: "?ref#ok" }); // # will be escaped. + await testValidTransform({ query: "?\n\t\x00" }); // Will all be escaped. + await testInvalidTransform( + { query: "noquestionmark" }, + "redirect.transform.query should be empty or start with a '?'" + ); + await testInvalidTransform({ query: "?" + VERY_LONG_STRING }); + + // redirect.transform.queryTransform + await testInvalidTransform( + { query: "", queryTransform: {} }, + "redirect.transform.query and redirect.transform.queryTransform are mutually exclusive" + ); + await testValidTransform({ queryTransform: {} }); + await testValidTransform({ queryTransform: { removeParams: [] } }); + await testValidTransform({ queryTransform: { removeParams: ["x"] } }); + await testValidTransform({ queryTransform: { addOrReplaceParams: [] } }); + await testValidTransform({ + queryTransform: { + addOrReplaceParams: [{ key: "k", value: "v" }], + }, + }); + await testValidTransform({ + queryTransform: { + addOrReplaceParams: [{ key: "k", value: "v", replaceOnly: true }], + }, + }); + await testInvalidTransform({ + queryTransform: { + addOrReplaceParams: [{ key: "k", value: VERY_LONG_STRING }], + }, + }); + await testInvalidTransform( + { + queryTransform: { + addOrReplaceParams: [{ key: "k" }], + }, + }, + /addOrReplaceParams\.0: Property "value" is required/, + /* isSchemaError */ true + ); + await testInvalidTransform( + { + queryTransform: { + addOrReplaceParams: [{ value: "v" }], + }, + }, + /addOrReplaceParams\.0: Property "key" is required/, + /* isSchemaError */ true + ); + + // redirect.transform.fragment + await testValidTransform({ fragment: "" }); // empty = strip fragment. + await testValidTransform({ fragment: "#suffix" }); + await testValidTransform({ fragment: "#\n\t\x00" }); // will be escaped. + await testInvalidTransform( + { fragment: "nohash" }, + "redirect.transform.fragment should be empty or start with a '#'" + ); + await testInvalidTransform({ fragment: "#" + VERY_LONG_STRING }); + + // redirect.transform.username + await testValidTransform({ username: "" }); // empty = strip username. + await testValidTransform({ username: "username" }); + await testValidTransform({ username: "@:" }); // will be escaped. + await testInvalidTransform({ username: VERY_LONG_STRING }); + + // redirect.transform.password + await testValidTransform({ password: "" }); // empty = strip password. + await testValidTransform({ password: "pass" }); + await testValidTransform({ password: "@:" }); // will be escaped. + await testInvalidTransform({ password: VERY_LONG_STRING }); + + // All together: + await testValidTransform({ + scheme: "http", + username: "a", + password: "b", + host: "c", + port: "12345", + path: "/d", + query: "?e", + queryTransform: null, + fragment: "#f", + }); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function session_rules_total_rule_limit() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES } = + browser.declarativeNetRequest; + + let inputRules = []; + let nextRuleId = 1; + for (let i = 0; i < MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES; ++i) { + inputRules.push(dnrTestUtils.makeRuleInput(nextRuleId++)); + } + let excessRule = dnrTestUtils.makeRuleInput(nextRuleId++); + + browser.test.log(`Should be able to add ${inputRules.length} rules.`); + await browser.declarativeNetRequest.updateSessionRules({ + addRules: inputRules, + }); + + browser.test.assertEq( + inputRules.length, + (await browser.declarativeNetRequest.getSessionRules()).length, + "Added up to MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES session rules" + ); + + await browser.test.assertRejects( + browser.declarativeNetRequest.updateSessionRules({ + addRules: [excessRule], + }), + `Number of rules in ruleset "_session" exceeds MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES.`, + "Should not accept more than MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES rules" + ); + + await browser.test.assertRejects( + browser.declarativeNetRequest.updateSessionRules({ + removeRuleIds: [inputRules[0].id], + addRules: [inputRules[0], excessRule], + }), + `Number of rules in ruleset "_session" exceeds MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES.`, + "Removing one rule is not enough to make space for two rules" + ); + + browser.test.log("Should be able to replace one rule while at the limit"); + await browser.declarativeNetRequest.updateSessionRules({ + removeRuleIds: [inputRules[0].id], + addRules: [excessRule], + }); + + browser.test.log("Should be able to remove many rules, even at quota"); + await browser.declarativeNetRequest.updateSessionRules({ + // Note: inputRules[0].id was already removed, but that's fine. + removeRuleIds: inputRules.map(r => r.id), + }); + + browser.test.assertDeepEq( + [dnrTestUtils.makeRuleOutput(excessRule.id)], + await browser.declarativeNetRequest.getSessionRules(), + "Expected one rule after removing all-but-one-rule" + ); + + await browser.test.assertRejects( + browser.declarativeNetRequest.updateSessionRules({ + addRules: inputRules, + }), + `Number of rules in ruleset "_session" exceeds MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES.`, + "Should not be able to add MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES when there is already a rule" + ); + + await browser.declarativeNetRequest.updateSessionRules({ + removeRuleIds: [excessRule.id], + }); + browser.test.assertDeepEq( + [], + await browser.declarativeNetRequest.getSessionRules(), + "Removed last remaining rule" + ); + + browser.test.notifyPass(); + }, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_startup_cache.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_startup_cache.js new file mode 100644 index 0000000000..bcb05eec23 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_startup_cache.js @@ -0,0 +1,651 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Schemas } = ChromeUtils.importESModule( + "resource://gre/modules/Schemas.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + ExtensionDNR: "resource://gre/modules/ExtensionDNR.sys.mjs", + ExtensionDNRStore: "resource://gre/modules/ExtensionDNRStore.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetters(this, { + aomStartup: [ + "@mozilla.org/addons/addon-manager-startup;1", + "amIAddonManagerStartup", + ], +}); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +Services.scriptloader.loadSubScript( + Services.io.newFileURI(do_get_file("head_dnr.js")).spec, + this +); + +const EXT_ID = "test-dnr-store-startup-cache@test-extension"; +const TEMP_EXT_ID = "test-dnr-store-temporarily-installed@test-extension"; + +// Test rulesets should include fields that are special cased during Rule object deserialization +// from the plain objects loaded from the StartupCache data objects. +// In particular regexFilter are included to make sure that RuleValidator.deserializeRule is +// internally compiling the regexFilter and storing it into the RuleCondition instance as expected +// (implicitly asserted internally by the test helper assertDNRStoreData in head_dnr.js). +const RULESET_1_DATA = [ + getDNRRule({ + id: 1, + action: { type: "allow" }, + condition: { resourceTypes: ["main_frame"], regexFilter: "http://from/$" }, + }), + getDNRRule({ + id: 2, + action: { type: "allow" }, + condition: { + resourceTypes: ["main_frame"], + regexFilter: "http://from2/$", + isUrlFilterCaseSensitive: true, + }, + }), +]; +const RULESET_2_DATA = [ + getDNRRule({ + action: { type: "block" }, + condition: { resourceTypes: ["main_frame", "script"] }, + }), +]; + +function getDNRExtension({ + id = EXT_ID, + version = "1.0", + useAddonManager = "permanent", + background, + rule_resources, + declarative_net_request, + files, +}) { + // Omit declarative_net_request if rule_resources isn't defined + // (because declarative_net_request fails the manifest validation + // if rule_resources is missing). + const dnr = rule_resources ? { rule_resources } : undefined; + + return { + background, + useAddonManager, + manifest: { + manifest_version: 3, + version, + permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"], + // Needed to make sure the upgraded extension will have the same id and + // same uuid (which is mapped based on the extension id). + browser_specific_settings: { + gecko: { id }, + }, + declarative_net_request: declarative_net_request + ? { ...declarative_net_request, ...(dnr ?? {}) } + : dnr, + }, + files, + }; +} + +add_setup(async () => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.feedback", true); + + setupTelemetryForTests(); + + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_dnr_startup_cache_save_and_load() { + resetTelemetryData(); + + const rule_resources = [ + { + id: "ruleset_1", + enabled: true, + path: "ruleset_1.json", + }, + { + id: "ruleset_2", + enabled: false, + path: "ruleset_2.json", + }, + ]; + const files = { + "ruleset_1.json": JSON.stringify(RULESET_1_DATA), + "ruleset_2.json": JSON.stringify(RULESET_2_DATA), + }; + + let dnrStore = ExtensionDNRStore._getStoreForTesting(); + let sandboxStoreSpies = sinon.createSandbox(); + const spyScheduleCacheDataSave = sandboxStoreSpies.spy( + dnrStore, + "scheduleCacheDataSave" + ); + + const extension = ExtensionTestUtils.loadExtension( + getDNRExtension({ rule_resources, files }) + ); + + const temporarilyInstalledExt = ExtensionTestUtils.loadExtension( + getDNRExtension({ + id: TEMP_EXT_ID, + useAddonManager: "temporary", + rule_resources, + files, + }) + ); + + assertDNRTelemetryMetricsNoSamples( + [ + { + metric: "validateRulesTime", + mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS", + mirroredType: "histogram", + }, + ], + "before any test extensions have been loaded" + ); + + await temporarilyInstalledExt.startup(); + await extension.startup(); + info( + "Wait for DNR initialization completed for the temporarily installed extension" + ); + await ExtensionDNR.ensureInitialized(temporarilyInstalledExt.extension); + info( + "Wait for DNR initialization completed for the permanently installed extension" + ); + await ExtensionDNR.ensureInitialized(extension.extension); + + assertDNRTelemetryMetricsSamplesCount( + [ + { + metric: "validateRulesTime", + mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS", + mirroredType: "histogram", + expectedSamplesCount: 2, + }, + ], + "after two test extensions have been loaded" + ); + + Assert.equal( + spyScheduleCacheDataSave.callCount, + 1, + "Expect ExtensionDNRStore scheduleCacheDataSave method to have been called once" + ); + + sandboxStoreSpies.restore(); + + const extUUID = extension.uuid; + const { cacheFile } = dnrStore.getFilePaths(extUUID); + + await assertDNRStoreData(dnrStore, extension, { + ruleset_1: getSchemaNormalizedRules(extension, RULESET_1_DATA), + }); + + assertDNRTelemetryMetricsNoSamples( + [ + { + metric: "startupCacheWriteTime", + mirroredName: "WEBEXT_DNR_STARTUPCACHE_WRITE_MS", + mirroredType: "histogram", + }, + { + metric: "startupCacheWriteSize", + mirroredName: "WEBEXT_DNR_STARTUPCACHE_WRITE_BYTES", + mirroredType: "histogram", + }, + // Expected no startup cache file to be loaded or used for a newly installed extension. + { + metric: "startupCacheReadSize", + mirroredName: "WEBEXT_DNR_STARTUPCACHE_READ_BYTES", + mirroredType: "histogram", + }, + { + metric: "startupCacheReadTime", + mirroredName: "WEBEXT_DNR_STARTUPCACHE_READ_MS", + mirroredType: "histogram", + }, + { + metric: "startupCacheEntries", + label: "miss", + mirroredName: "extensions.apis.dnr.startup_cache_entries", + mirroredType: "keyedScalar", + }, + { + metric: "startupCacheEntries", + label: "hit", + mirroredName: "extensions.apis.dnr.startup_cache_entries", + mirroredType: "keyedScalar", + }, + ], + "on loading dnr rules for newly installed extension" + ); + await dnrStore.waitSaveCacheDataForTesting(); + assertDNRTelemetryMetricsSamplesCount( + [ + { + metric: "startupCacheWriteTime", + mirroredName: "WEBEXT_DNR_STARTUPCACHE_WRITE_MS", + mirroredType: "histogram", + expectedSamplesCount: 1, + }, + { + metric: "startupCacheWriteSize", + mirroredName: "WEBEXT_DNR_STARTUPCACHE_WRITE_BYTES", + mirroredType: "histogram", + expectedSamplesCount: 1, + }, + ], + "after writing DNR startup cache data to disk" + ); + + ok( + await IOUtils.exists(cacheFile), + "Expect the DNR store startupCache file exist" + ); + + const assertDNRStoreDataLoadOnStartup = async ({ + expectLoadedFromCache, + expectClearLastUpdateTagPref, + }) => { + info( + `Mock browser restart and assert DNR rules ${ + expectLoadedFromCache ? "NOT " : "" + }going through Schemas.normalize` + ); + await AddonTestUtils.promiseShutdownManager(); + // Recreate the DNR store to more easily mock its initial state after a browser restart. + dnrStore = ExtensionDNRStore._recreateStoreForTesting(); + const StoreData = ExtensionDNRStore._getStoreDataClassForTesting(); + + let sandbox = sinon.createSandbox(); + const schemasNormalizeSpy = sandbox.spy(Schemas, "normalize"); + const ruleValidatorAddRulesSpy = sandbox.spy( + ExtensionDNR.RuleValidator.prototype, + "addRules" + ); + const deserializeRuleSpy = sandbox.spy( + ExtensionDNR.RuleValidator, + "deserializeRule" + ); + const clearLastUpdateTagPrefSpy = sandbox.spy( + StoreData, + "clearLastUpdateTagPref" + ); + const scheduleCacheDataSaveSpy = sandbox.spy( + dnrStore, + "scheduleCacheDataSave" + ); + + resetTelemetryData(); + await AddonTestUtils.promiseStartupManager(); + await extension.awaitStartup(); + await ExtensionDNR.ensureInitialized(extension.extension); + + if (expectLoadedFromCache) { + assertDNRTelemetryMetricsSamplesCount( + [ + { + metric: "startupCacheReadSize", + mirroredName: "WEBEXT_DNR_STARTUPCACHE_READ_BYTES", + mirroredType: "histogram", + expectedSamplesCount: 1, + }, + { + metric: "startupCacheReadTime", + mirroredName: "WEBEXT_DNR_STARTUPCACHE_READ_MS", + mirroredType: "histogram", + expectedSamplesCount: 1, + }, + ], + "after app startup and expected startup cache hit" + ); + assertDNRTelemetryMetricsGetValueEq( + [ + { + metric: "startupCacheEntries", + label: "hit", + expectedGetValue: 1, + mirroredName: "extensions.apis.dnr.startup_cache_entries", + mirroredType: "keyedScalar", + }, + ], + "after app startup and expected startup cache hit" + ); + assertDNRTelemetryMetricsNoSamples( + [ + { + metric: "validateRulesTime", + mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS", + mirroredType: "histogram", + }, + { + metric: "startupCacheEntries", + label: "miss", + mirroredName: "extensions.apis.dnr.startup_cache_entries", + mirroredType: "keyedScalar", + }, + ], + "after DNR store loaded startup cache data" + ); + } else { + assertDNRTelemetryMetricsSamplesCount( + [ + { + metric: "validateRulesTime", + mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS", + mirroredType: "histogram", + expectedSamplesCount: 1, + }, + { + metric: "startupCacheReadSize", + mirroredName: "WEBEXT_DNR_STARTUPCACHE_READ_BYTES", + mirroredType: "histogram", + expectedSamplesCount: 1, + }, + { + metric: "startupCacheReadTime", + mirroredName: "WEBEXT_DNR_STARTUPCACHE_READ_MS", + mirroredType: "histogram", + expectedSamplesCount: 1, + }, + ], + "after app startup and expected startup cache miss" + ); + assertDNRTelemetryMetricsGetValueEq( + [ + { + metric: "startupCacheEntries", + label: "miss", + expectedGetValue: 1, + mirroredName: "extensions.apis.dnr.startup_cache_entries", + mirroredType: "keyedScalar", + }, + ], + "after app startup and expected startup cache miss" + ); + assertDNRTelemetryMetricsNoSamples( + [ + { + metric: "startupCacheEntries", + label: "hit", + mirroredName: "extensions.apis.dnr.startup_cache_entries", + mirroredType: "keyedScalar", + }, + ], + "after DNR store loaded startup cache data" + ); + } + + Assert.equal( + scheduleCacheDataSaveSpy.called, + !expectLoadedFromCache, + "scheduleCacheDataSave to not be called when the extension DNR rules are initialized from startup cache data" + ); + + Assert.equal( + clearLastUpdateTagPrefSpy.callCount, + expectClearLastUpdateTagPref ? 1 : 0, + "Expect clearLastUpdateTagPrefSpy to have been called the expected number of times" + ); + if (expectClearLastUpdateTagPref === true) { + Assert.ok( + clearLastUpdateTagPrefSpy.calledWith(extension.uuid), + "Expect clearLastUpdateTagPrefSpy to have been called with the test extension uuid" + ); + } + + Assert.equal( + schemasNormalizeSpy.calledWith( + sinon.match.any, + sinon.match("declarativeNetRequest.Rule"), + sinon.match.any + ), + !expectLoadedFromCache, + `Expect DNR rules to ${ + expectLoadedFromCache ? "NOT " : "" + }be going through Schemas.normalize` + ); + + Assert.equal( + ruleValidatorAddRulesSpy.called, + !expectLoadedFromCache, + `Expect DNR rules to ${ + expectLoadedFromCache ? "NOT " : "" + }be going through RuleValidator addRules` + ); + + Assert.equal( + deserializeRuleSpy.called, + expectLoadedFromCache, + `Expect RuleValidator.deserializeRule to ${ + expectLoadedFromCache ? "NOT " : "" + }be called to convert StartupCache data back into Rule class instances` + ); + + await assertDNRStoreData(dnrStore, extension, { + ruleset_1: getSchemaNormalizedRules(extension, RULESET_1_DATA), + }); + + sandbox.restore(); + }; + + const expectedLastUpdateTag = ExtensionDNRStore._getLastUpdateTag( + extension.uuid + ); + + Assert.ok( + typeof expectedLastUpdateTag == "string" && !!expectedLastUpdateTag.length, + `Expect lastUpdateTag for ${extension.id} to be set to a non empty string: ${expectedLastUpdateTag}` + ); + + Assert.equal( + ExtensionDNRStore._getLastUpdateTag(temporarilyInstalledExt.uuid), + null, + `Expect no lastUpdateTag value set for temporarily installed extensions` + ); + + await assertDNRStoreDataLoadOnStartup({ + expectLoadedFromCache: true, + expectClearLastUpdateTagPref: false, + }); + + { + const { buffer } = await IOUtils.read(cacheFile); + const decodedData = aomStartup.decodeBlob(buffer); + Assert.equal( + expectedLastUpdateTag, + decodedData?.cacheData.get(extension.uuid).lastUpdateTag, + "Expect cacheData entry's lastUpdateTag to match the value stored in the related pref" + ); + Assert.equal( + decodedData?.cacheData.has(temporarilyInstalledExt.uuid), + false, + "Expect no cache data entry for temporarily installed extensions" + ); + + info("Confirm startupCache data dropped if last tag pref value mismatches"); + ExtensionDNRStore._storeLastUpdateTag( + extension.uuid, + "mismatching-tag-value" + ); + Assert.notEqual( + ExtensionDNRStore._getLastUpdateTag(extension.uuid), + decodedData?.cacheData.get(extension.uuid).lastUpdateTag, + "Expect cacheData.lastDNRStoreUpdateTag to NOT match the tampered value stored in the related pref" + ); + } + + await assertDNRStoreDataLoadOnStartup({ + expectLoadedFromCache: false, + expectClearLastUpdateTagPref: true, + }); + + info( + "Verify that startupCache data mismatching with the StoreData schema version is being dropped" + ); + await dnrStore.waitSaveCacheDataForTesting(); + await assertDNRStoreDataLoadOnStartup({ + expectLoadedFromCache: true, + expectClearLastUpdateTagPref: false, + }); + + { + info("Tamper the StoreData version in the startupCache data"); + const { buffer } = await IOUtils.read(cacheFile); + const decodedData = aomStartup.decodeBlob(buffer); + decodedData.cacheData.get(extUUID).schemaVersion = -1; + await IOUtils.write( + cacheFile, + new Uint8Array(aomStartup.encodeBlob(decodedData)) + ); + } + + await assertDNRStoreDataLoadOnStartup({ + expectLoadedFromCache: false, + expectClearLastUpdateTagPref: true, + }); + + info( + "Verify that startupCache data mismatching with the extension version is being dropped" + ); + await dnrStore.waitSaveCacheDataForTesting(); + await assertDNRStoreDataLoadOnStartup({ + expectLoadedFromCache: true, + expectClearLastUpdateTagPref: false, + }); + + { + info("Tamper the extension version in the startupCache data"); + const { buffer } = await IOUtils.read(cacheFile); + const decodedData = aomStartup.decodeBlob(buffer); + decodedData.cacheData.get(extUUID).extVersion = "0.1"; + await IOUtils.write( + cacheFile, + new Uint8Array(aomStartup.encodeBlob(decodedData)) + ); + } + await assertDNRStoreDataLoadOnStartup({ + expectLoadedFromCache: false, + expectClearLastUpdateTagPref: true, + }); + + await extension.unload(); + + Assert.equal( + ExtensionDNRStore._getLastUpdateTag(extension.uuid), + null, + "LastUpdateTag pref should have been removed after addon uninstall" + ); +}); + +add_task(async function test_detect_and_reschedule_save_cache_on_new_changes() { + const rule_resources = [ + { + id: "ruleset_1", + enabled: true, + path: "ruleset_1.json", + }, + ]; + const files = { + "ruleset_1.json": JSON.stringify(RULESET_1_DATA), + }; + + let dnrStore = ExtensionDNRStore._getStoreForTesting(); + let sandboxStore = sinon.createSandbox(); + const spyScheduleCacheDataSave = sandboxStore.spy( + dnrStore, + "scheduleCacheDataSave" + ); + + let extension; + const tamperedLastUpdateTag = Services.uuid.generateUUID().toString(); + let resolvePromiseSaveCacheRescheduled; + let promiseSaveCacheRescheduled = new Promise(resolve => { + resolvePromiseSaveCacheRescheduled = resolve; + }); + const realDetectStartupCacheDataChanged = + dnrStore.detectStartupCacheDataChanged.bind(dnrStore); + const stubDetectCacheDataChanges = sandboxStore.stub( + dnrStore, + "detectStartupCacheDataChanged" + ); + + stubDetectCacheDataChanges.callsFake(seenLastUpdateTags => { + const extData = dnrStore._data.get(extension.extension.uuid); + Assert.ok(extData, "Got StoreData instance for the test extension"); + Assert.ok( + typeof extData.lastUpdateTag === "string" && + !!extData.lastUpdateTag.length, + "Expect a non empty lastUpdateTag assigned to the extension StoreData" + ); + Assert.deepEqual( + Array.from(seenLastUpdateTags), + [extData.lastUpdateTag], + "Expects the extension storeData lastUpdateTag to have been seen" + ); + if (stubDetectCacheDataChanges.callCount == 1) { + Assert.notEqual( + extData.lastUpdateTag, + tamperedLastUpdateTag, + "New tampered lastUpdateTag should not be equal to the one already set" + ); + extData.lastUpdateTag = tamperedLastUpdateTag; + Assert.equal( + realDetectStartupCacheDataChanged(seenLastUpdateTags), + true, + "Expect dnrStore.detectStartupCacheDataChanged to detect a change" + ); + return true; + } + Assert.equal( + realDetectStartupCacheDataChanged(seenLastUpdateTags), + false, + "Expect dnrStore.detectStartupCacheDataChanged to NOT have detected any change" + ); + + Promise.resolve().then(resolvePromiseSaveCacheRescheduled); + return false; + }); + + extension = ExtensionTestUtils.loadExtension( + getDNRExtension({ + id: "test-reschedule-save-on-detected-changes@test", + rule_resources, + files, + }) + ); + + await extension.startup(); + info( + "Wait for DNR initialization completed for the permanently installed extension" + ); + await ExtensionDNR.ensureInitialized(extension.extension); + info("Wait for the saveCacheDataNow task to have been rescheduled"); + await promiseSaveCacheRescheduled; + + Assert.equal( + spyScheduleCacheDataSave.callCount, + 2, + "Expect ExtensionDNRStore scheduleCacheDataSave method to have been called twice" + ); + Assert.equal( + stubDetectCacheDataChanges.callCount, + 2, + "Expect ExtensionDNRStore detectStartupCacheDataChanged method to have been called twice" + ); + + sandboxStore.restore(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_static_rules.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_static_rules.js new file mode 100644 index 0000000000..4d20bd330e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_static_rules.js @@ -0,0 +1,1850 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionDNR: "resource://gre/modules/ExtensionDNR.sys.mjs", + ExtensionDNRLimits: "resource://gre/modules/ExtensionDNRLimits.sys.mjs", + ExtensionDNRStore: "resource://gre/modules/ExtensionDNRStore.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +Services.scriptloader.loadSubScript( + Services.io.newFileURI(do_get_file("head_dnr.js")).spec, + this +); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.write("response from server"); +}); + +function backgroundWithDNRAPICallHandlers() { + browser.test.onMessage.addListener(async (msg, ...args) => { + let result; + switch (msg) { + case "getEnabledRulesets": + result = await browser.declarativeNetRequest.getEnabledRulesets(); + break; + case "getAvailableStaticRuleCount": + result = + await browser.declarativeNetRequest.getAvailableStaticRuleCount(); + break; + case "testMatchOutcome": + result = await browser.declarativeNetRequest + .testMatchOutcome(...args) + .catch(err => + browser.test.fail( + `Unexpected rejection from testMatchOutcome call: ${err}` + ) + ); + break; + case "updateEnabledRulesets": + // Run (one or more than one concurrently) updateEnabledRulesets calls + // and report back the results. + result = await Promise.all( + args.map(arg => { + return browser.declarativeNetRequest + .updateEnabledRulesets(arg) + .catch(err => { + return { rejectedWithErrorMessage: err.message }; + }); + }) + ); + break; + default: + browser.test.fail(`Unexpected test message: ${msg}`); + return; + } + + browser.test.sendMessage(`${msg}:done`, result); + }); + + browser.test.sendMessage("bgpage:ready"); +} + +function getDNRExtension({ + id = "test-dnr-static-rules@test-extension", + version = "1.0", + background = backgroundWithDNRAPICallHandlers, + useAddonManager = "permanent", + rule_resources, + declarative_net_request, + files, +}) { + // Omit declarative_net_request if rule_resources isn't defined + // (because declarative_net_request fails the manifest validation + // if rule_resources is missing). + const dnr = rule_resources ? { rule_resources } : undefined; + + return { + background, + useAddonManager, + manifest: { + manifest_version: 3, + version, + permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"], + // Needed to make sure the upgraded extension will have the same id and + // same uuid (which is mapped based on the extension id). + browser_specific_settings: { + gecko: { id }, + }, + declarative_net_request: declarative_net_request + ? { ...declarative_net_request, ...(dnr ?? {}) } + : dnr, + }, + files, + }; +} + +const assertDNRTestMatchOutcome = async ( + { extension, testRequest, expected }, + assertMessage +) => { + extension.sendMessage("testMatchOutcome", testRequest); + Assert.deepEqual( + expected, + await extension.awaitMessage("testMatchOutcome:done"), + assertMessage ?? + "Got the expected matched rules from testMatchOutcome API call" + ); +}; + +const assertDNRGetAvailableStaticRuleCount = async ( + extensionTestWrapper, + expectedCount, + assertMessage +) => { + extensionTestWrapper.sendMessage("getAvailableStaticRuleCount"); + Assert.deepEqual( + await extensionTestWrapper.awaitMessage("getAvailableStaticRuleCount:done"), + expectedCount, + assertMessage ?? + "Got the expected count value from dnr.getAvailableStaticRuleCount API method" + ); +}; + +const assertDNRGetEnabledRulesets = async ( + extensionTestWrapper, + expectedRulesetIds +) => { + extensionTestWrapper.sendMessage("getEnabledRulesets"); + Assert.deepEqual( + await extensionTestWrapper.awaitMessage("getEnabledRulesets:done"), + expectedRulesetIds, + "Got the expected enabled ruleset ids from dnr.getEnabledRulesets API method" + ); +}; + +add_setup(async () => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.feedback", true); + + setupTelemetryForTests(); + + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_load_static_rules() { + const ruleset1Data = [ + getDNRRule({ + action: { type: "allow" }, + condition: { resourceTypes: ["main_frame"] }, + }), + ]; + const ruleset2Data = [ + getDNRRule({ + action: { type: "block" }, + condition: { resourceTypes: ["main_frame", "script"] }, + }), + ]; + + const rule_resources = [ + { + id: "ruleset_1", + enabled: true, + path: "ruleset_1.json", + }, + { + id: "ruleset_2", + enabled: true, + path: "ruleset_2.json", + }, + { + id: "ruleset_3", + enabled: false, + path: "ruleset_3.json", + }, + ]; + const files = { + // Missing ruleset_3.json on purpose. + "ruleset_1.json": JSON.stringify(ruleset1Data), + "ruleset_2.json": JSON.stringify(ruleset2Data), + }; + + const extension = ExtensionTestUtils.loadExtension( + getDNRExtension({ rule_resources, files }) + ); + + await extension.startup(); + + const extUUID = extension.uuid; + + await extension.awaitMessage("bgpage:ready"); + + const dnrStore = ExtensionDNRStore._getStoreForTesting(); + + info("Verify DNRStore data for the test extension"); + await assertDNRGetEnabledRulesets(extension, ["ruleset_1", "ruleset_2"]); + + await assertDNRStoreData(dnrStore, extension, { + ruleset_1: getSchemaNormalizedRules(extension, ruleset1Data), + ruleset_2: getSchemaNormalizedRules(extension, ruleset2Data), + }); + + info("Verify matched rules using testMatchOutcome"); + const testRequestMainFrame = { + url: "https://example.com/some-dummy-url", + type: "main_frame", + }; + const testRequestScript = { + url: "https://example.com/some-dummy-url.js", + type: "script", + }; + + await assertDNRTestMatchOutcome( + { + extension, + testRequest: testRequestMainFrame, + expected: { + matchedRules: [{ ruleId: 1, rulesetId: "ruleset_1" }], + }, + }, + "Expect ruleset_1 to be matched on the main-frame test request" + ); + await assertDNRTestMatchOutcome( + { + extension, + testRequest: testRequestScript, + expected: { + matchedRules: [{ ruleId: 1, rulesetId: "ruleset_2" }], + }, + }, + "Expect ruleset_2 to be matched on the script test request" + ); + + info("Verify DNRStore data persisted on disk for the test extension"); + // The data will not be stored on disk until something is being changed + // from what was already available in the manifest and so in this + // test we save manually (a test for the updateEnabledRulesets will + // take care of asserting that the data has been stored automatically + // on disk when it is meant to). + await dnrStore.save(extension.extension); + + const { storeFile } = dnrStore.getFilePaths(extUUID); + + ok(await IOUtils.exists(storeFile), `DNR storeFile ${storeFile} found`); + + // force deleting the data stored in memory to confirm if it being loaded again from + // the files stored on disk. + dnrStore._data.delete(extUUID); + dnrStore._dataPromises.delete(extUUID); + + info("Verify the expected DNRStore data persisted on disk is loaded back"); + const { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" + ); + const addon = await AddonManager.getAddonByID(extension.id); + await addon.disable(); + + ok( + !dnrStore._dataPromises.has(extUUID), + "DNR store read data promise cleared after the extension has been disabled" + ); + ok( + !dnrStore._data.has(extUUID), + "DNR store data cleared from memory after the extension has been disabled" + ); + + await addon.enable(); + await extension.awaitMessage("bgpage:ready"); + await assertDNRGetEnabledRulesets(extension, ["ruleset_1", "ruleset_2"]); + + await assertDNRStoreData(dnrStore, extension, { + ruleset_1: getSchemaNormalizedRules(extension, ruleset1Data), + ruleset_2: getSchemaNormalizedRules(extension, ruleset2Data), + }); + + info("Verify matched rules using testMatchOutcome"); + await assertDNRTestMatchOutcome( + { + extension, + testRequest: testRequestMainFrame, + expected: { + matchedRules: [{ ruleId: 1, rulesetId: "ruleset_1" }], + }, + }, + "Expect ruleset_1 to be matched on the main-frame test request" + ); + + info("Verify enabled static rules updated on addon updates"); + await extension.upgrade( + getDNRExtension({ + version: "2.0", + rule_resources: [ + { + id: "ruleset_1", + enabled: false, + path: "ruleset_1.json", + }, + { + id: "ruleset_2", + enabled: true, + path: "ruleset_2.json", + }, + ], + files: { + "ruleset_2.json": JSON.stringify(ruleset2Data), + }, + }) + ); + await extension.awaitMessage("bgpage:ready"); + await assertDNRGetEnabledRulesets(extension, ["ruleset_2"]); + await assertDNRStoreData(dnrStore, extension, { + ruleset_2: getSchemaNormalizedRules(extension, ruleset2Data), + }); + + info("Verify matched rules using testMatchOutcome"); + await assertDNRTestMatchOutcome( + { + extension, + testRequest: testRequestMainFrame, + expected: { + matchedRules: [{ ruleId: 1, rulesetId: "ruleset_2" }], + }, + }, + "Expect ruleset_2 to be matched on the main-frame test request" + ); + + info( + "Verify enabled static rules updated on addon updates even if version in the manifest did not change" + ); + await extension.upgrade( + getDNRExtension({ + rule_resources: [ + { + id: "ruleset_1", + enabled: true, + path: "ruleset_1.json", + }, + { + id: "ruleset_2", + enabled: false, + path: "ruleset_2.json", + }, + ], + files: { + "ruleset_1.json": JSON.stringify(ruleset1Data), + }, + }) + ); + await extension.awaitMessage("bgpage:ready"); + await assertDNRGetEnabledRulesets(extension, ["ruleset_1"]); + await assertDNRStoreData(dnrStore, extension, { + ruleset_1: getSchemaNormalizedRules(extension, ruleset1Data), + }); + + info("Verify matched rules using testMatchOutcome"); + await assertDNRTestMatchOutcome( + { + extension, + testRequest: testRequestMainFrame, + expected: { + matchedRules: [{ ruleId: 1, rulesetId: "ruleset_1" }], + }, + }, + "Expect ruleset_2 to be matched on the main-script test request" + ); + + info( + "Verify updated addon version with no static rules but declarativeNetRequest permission granted" + ); + await extension.upgrade( + getDNRExtension({ + version: "3.0", + rule_resources: undefined, + files: {}, + }) + ); + await extension.awaitMessage("bgpage:ready"); + await assertDNRGetEnabledRulesets(extension, []); + await assertDNRStoreData(dnrStore, extension, {}); + + info("Verify matched rules using testMatchOutcome"); + await assertDNRTestMatchOutcome( + { + extension, + testRequest: testRequestScript, + expected: { + matchedRules: [], + }, + }, + "Expect no match on the script test request on test extension without no static rules" + ); + + info("Verify store file removed on addon uninstall"); + await extension.unload(); + + ok( + !dnrStore._dataPromises.has(extUUID), + "DNR store read data promise cleared after the extension has been unloaded" + ); + ok( + !dnrStore._data.has(extUUID), + "DNR store data cleared from memory after the extension has been unloaded" + ); + + ok( + !(await IOUtils.exists(storeFile)), + `DNR storeFile ${storeFile} removed on addon uninstalled` + ); +}); + +add_task(async function test_load_from_corrupted_data() { + const ruleset1Data = [ + getDNRRule({ + action: { type: "allow" }, + condition: { resourceTypes: ["main_frame"] }, + }), + ]; + + const rule_resources = [ + { + id: "ruleset_1", + enabled: true, + path: "ruleset_1.json", + }, + ]; + + const files = { + "ruleset_1.json": JSON.stringify(ruleset1Data), + }; + + const extension = ExtensionTestUtils.loadExtension( + getDNRExtension({ rule_resources, files }) + ); + + await extension.startup(); + + const extUUID = extension.uuid; + + await extension.awaitMessage("bgpage:ready"); + + const dnrStore = ExtensionDNRStore._getStoreForTesting(); + + info("Verify DNRStore data for the test extension"); + await assertDNRGetEnabledRulesets(extension, ["ruleset_1"]); + + await assertDNRStoreData(dnrStore, extension, { + ruleset_1: getSchemaNormalizedRules(extension, ruleset1Data), + }); + + info("Verify DNRStore data after loading corrupted store data"); + await dnrStore.save(extension.extension); + + const { storeFile } = dnrStore.getFilePaths(extUUID); + ok(await IOUtils.exists(storeFile), `DNR storeFile ${storeFile} found`); + + const nonCorruptedData = await IOUtils.readJSON(storeFile, { + decompress: true, + }); + + async function testLoadedRulesAfterDataCorruption({ + name, + asyncWriteStoreFile, + expectedCorruptFile, + }) { + info(`Tempering DNR store data: ${name}`); + + await extension.addon.disable(); + + ok( + !dnrStore._dataPromises.has(extUUID), + "DNR store read data promise cleared after the extension has been disabled" + ); + ok( + !dnrStore._data.has(extUUID), + "DNR store data cleared from memory after the extension has been disabled" + ); + + // Make sure we remove a previous corrupt file in case there is one from a previous run. + await IOUtils.remove(expectedCorruptFile, { ignoreAbsent: true }); + + await asyncWriteStoreFile(); + + await extension.addon.enable(); + await extension.awaitMessage("bgpage:ready"); + + info("Verify DNRStore data for the test extension"); + await assertDNRGetEnabledRulesets(extension, ["ruleset_1"]); + + await assertDNRStoreData(dnrStore, extension, { + ruleset_1: getSchemaNormalizedRules(extension, ruleset1Data), + }); + + await TestUtils.waitForCondition( + () => IOUtils.exists(`${expectedCorruptFile}`), + `Wait for the "${expectedCorruptFile}" file to have been created` + ); + } + + await testLoadedRulesAfterDataCorruption({ + name: "invalid lz4 header", + asyncWriteStoreFile: () => + IOUtils.writeUTF8(storeFile, "not an lz4 compressed file", { + compress: false, + }), + expectedCorruptFile: `${storeFile}.corrupt`, + }); + + await testLoadedRulesAfterDataCorruption({ + name: "invalid json data", + asyncWriteStoreFile: () => + IOUtils.writeUTF8(storeFile, "invalid json data", { compress: true }), + expectedCorruptFile: `${storeFile}-1.corrupt`, + }); + + await testLoadedRulesAfterDataCorruption({ + name: "empty json data", + asyncWriteStoreFile: () => + IOUtils.writeUTF8(storeFile, "{}", { compress: true }), + expectedCorruptFile: `${storeFile}-2.corrupt`, + }); + + await testLoadedRulesAfterDataCorruption({ + name: "invalid staticRulesets property type", + asyncWriteStoreFile: () => + IOUtils.writeUTF8( + storeFile, + JSON.stringify({ + schemaVersion: nonCorruptedData.schemaVersion, + extVersion: extension.extension.version, + staticRulesets: "Not an array", + }), + { compress: true } + ), + expectedCorruptFile: `${storeFile}-3.corrupt`, + }); + + await extension.unload(); +}); + +add_task(async function test_ruleset_validation() { + const invalidRulesetIdCases = [ + { + description: "empty ruleset id", + rule_resources: [ + { + // Invalid empty ruleset id. + id: "", + path: "ruleset_0.json", + enabled: true, + }, + ], + expected: [ + // Validation error emitted from the manifest schema validation. + { + message: /rule_resources\.0\.id: String "" must match/, + }, + ], + }, + { + description: "invalid ruleset id starting with '_'", + rule_resources: [ + { + // Invalid empty ruleset id. + id: "_invalid_ruleset_id", + path: "ruleset_0.json", + enabled: true, + }, + ], + expected: [ + // Validation error emitted from the manifest schema validation. + { + message: + /rule_resources\.0\.id: String "_invalid_ruleset_id" must match/, + }, + ], + }, + { + description: "duplicated ruleset ids", + rule_resources: [ + { + id: "ruleset_2", + path: "ruleset_2.json", + enabled: true, + }, + { + // Duplicated ruleset id. + id: "ruleset_2", + path: "duplicated_ruleset_2.json", + enabled: true, + }, + { + id: "ruleset_3", + path: "ruleset_3.json", + enabled: true, + }, + { + // Other duplicated ruleset id. + id: "ruleset_3", + path: "duplicated_ruleset_3.json", + enabled: true, + }, + ], + // NOTE: this is currently a warning logged from onManifestEntry, and so it would actually + // fail in test harness due to the manifest warning, because it is too late at that point + // the addon is technically already starting at that point. + expectInstallFailed: false, + expected: [ + { + message: + /declarative_net_request: Static ruleset ids should be unique.*: "ruleset_2" at index 1, "ruleset_3" at index 3/, + }, + ], + }, + { + description: "missing mandatory path", + rule_resources: [ + { + // Missing mandatory path. + id: "ruleset_3", + enabled: true, + }, + ], + expected: [ + { + message: /rule_resources\.0: Property "path" is required/, + }, + ], + }, + { + description: "missing mandatory id", + rule_resources: [ + { + // Missing mandatory id. + enabled: true, + path: "missing_ruleset_id.json", + }, + ], + expected: [ + { + message: /rule_resources\.0: Property "id" is required/, + }, + ], + }, + { + description: "duplicated ruleset path", + rule_resources: [ + { + id: "ruleset_2", + path: "ruleset_2.json", + enabled: true, + }, + { + // Duplicate path. + id: "ruleset_3", + path: "ruleset_2.json", + enabled: true, + }, + ], + // NOTE: we couldn't get on agreement about making this a manifest validation error, apparently Chrome doesn't validate it and doesn't + // even report any warning, and so it is logged only as an informative warning but without triggering an install failure. + expectInstallFailed: false, + expected: [ + { + message: + /declarative_net_request: Static rulesets paths are not unique.*: ".*ruleset_2.json" at index 1/, + }, + ], + }, + { + description: "missing mandatory enabled", + rule_resources: [ + { + id: "ruleset_without_enabled", + path: "ruleset.json", + }, + ], + expected: [ + { + message: /rule_resources\.0: Property "enabled" is required/, + }, + ], + }, + { + description: "allows and warns additional properties", + declarative_net_request: { + unexpected_prop: true, + rule_resources: [ + { + id: "ruleset1", + path: "ruleset1.json", + enabled: false, + unexpected_prop: true, + }, + ], + }, + expectInstallFailed: false, + expected: [ + { + message: + /declarative_net_request.unexpected_prop: An unexpected property was found/, + }, + { + message: + /rule_resources.0.unexpected_prop: An unexpected property was found/, + }, + ], + }, + { + description: "invalid ruleset JSON - unexpected comments", + rule_resources: [ + { + id: "invalid_ruleset_with_comments", + path: "invalid_ruleset_with_comments.json", + enabled: true, + }, + ], + files: { + "invalid_ruleset_with_comments.json": + "/* an unexpected inline comment */\n[]", + }, + expectInstallFailed: false, + expected: [ + { + message: + /Reading declarative_net_request .*invalid_ruleset_with_comments\.json: JSON.parse: unexpected character/, + }, + ], + }, + { + description: "invalid ruleset JSON - empty string", + rule_resources: [ + { + id: "invalid_ruleset_emptystring", + path: "invalid_ruleset_emptystring.json", + enabled: true, + }, + ], + files: { + "invalid_ruleset_emptystring.json": JSON.stringify(""), + }, + expectInstallFailed: false, + expected: [ + { + message: + /Reading declarative_net_request .*invalid_ruleset_emptystring\.json: rules file must contain an Array/, + }, + ], + }, + { + description: "invalid ruleset JSON - object", + rule_resources: [ + { + id: "invalid_ruleset_object", + path: "invalid_ruleset_object.json", + enabled: true, + }, + ], + files: { + "invalid_ruleset_object.json": JSON.stringify({}), + }, + expectInstallFailed: false, + expected: [ + { + message: + /Reading declarative_net_request .*invalid_ruleset_object\.json: rules file must contain an Array/, + }, + ], + }, + { + description: "invalid ruleset JSON - null", + rule_resources: [ + { + id: "invalid_ruleset_null", + path: "invalid_ruleset_null.json", + enabled: true, + }, + ], + files: { + "invalid_ruleset_null.json": JSON.stringify(null), + }, + expectInstallFailed: false, + expected: [ + { + message: + /Reading declarative_net_request .*invalid_ruleset_null\.json: rules file must contain an Array/, + }, + ], + }, + ]; + + for (const { + description, + declarative_net_request, + rule_resources, + files, + expected, + expectInstallFailed = true, + } of invalidRulesetIdCases) { + info(`Test manifest validation: ${description}`); + let extension = ExtensionTestUtils.loadExtension( + getDNRExtension({ rule_resources, declarative_net_request, files }) + ); + + const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + ExtensionTestUtils.failOnSchemaWarnings(false); + if (expectInstallFailed) { + await Assert.rejects( + extension.startup(), + /Install failed/, + "Expected install to fail" + ); + } else { + await extension.startup(); + await extension.awaitMessage("bgpage:ready"); + await extension.unload(); + } + ExtensionTestUtils.failOnSchemaWarnings(true); + }); + + AddonTestUtils.checkMessages(messages, { expected }); + } +}); + +add_task(async function test_updateEnabledRuleset_id_validation() { + const rule_resources = [ + { + id: "ruleset_1", + enabled: true, + path: "ruleset_1.json", + }, + { + id: "ruleset_2", + enabled: false, + path: "ruleset_2.json", + }, + ]; + + const ruleset1Data = [ + getDNRRule({ + action: { type: "allow" }, + condition: { resourceTypes: ["main_frame"] }, + }), + ]; + const ruleset2Data = [ + getDNRRule({ + action: { type: "block" }, + condition: { resourceTypes: ["main_frame", "script"] }, + }), + ]; + + const files = { + "ruleset_1.json": JSON.stringify(ruleset1Data), + "ruleset_2.json": JSON.stringify(ruleset2Data), + }; + + let extension = ExtensionTestUtils.loadExtension( + getDNRExtension({ rule_resources, files }) + ); + + await extension.startup(); + await extension.awaitMessage("bgpage:ready"); + + await assertDNRGetEnabledRulesets(extension, ["ruleset_1"]); + + const dnrStore = ExtensionDNRStore._getStoreForTesting(); + await assertDNRStoreData(dnrStore, extension, { + ruleset_1: getSchemaNormalizedRules(extension, ruleset1Data), + }); + + const invalidStaticRulesetIds = [ + // The following two are reserved for session and dynamic rules. + "_session", + "_dynamic", + "ruleset_non_existing", + ]; + + for (const invalidRSId of invalidStaticRulesetIds) { + extension.sendMessage( + "updateEnabledRulesets", + // Only in rulesets to be disabled. + { disableRulesetIds: [invalidRSId] }, + // Only in rulesets to be enabled. + { enableRulesetIds: [invalidRSId] }, + // In both rulesets to be enabled and disabled. + { disableRulesetIds: [invalidRSId], enableRulesetIds: [invalidRSId] }, + // Along with existing rulesets (and expected the existing rulesets + // to stay unchanged due to the invalid ruleset ids.) + { + disableRulesetIds: [invalidRSId, "ruleset_1"], + enableRulesetIds: [invalidRSId, "ruleset_2"], + } + ); + const [ + resInDisable, + resInEnable, + resInEnableAndDisable, + resInSameRequestAsValid, + ] = await extension.awaitMessage("updateEnabledRulesets:done"); + await Assert.rejects( + Promise.reject(resInDisable?.rejectedWithErrorMessage), + new RegExp(`Invalid ruleset id: "${invalidRSId}"`), + `Got the expected rejection on invalid ruleset id "${invalidRSId}" in disableRulesetIds` + ); + await Assert.rejects( + Promise.reject(resInEnable?.rejectedWithErrorMessage), + new RegExp(`Invalid ruleset id: "${invalidRSId}"`), + `Got the expected rejection on invalid ruleset id "${invalidRSId}" in enableRulesetIds` + ); + await Assert.rejects( + Promise.reject(resInEnableAndDisable?.rejectedWithErrorMessage), + new RegExp(`Invalid ruleset id: "${invalidRSId}"`), + `Got the expected rejection on invalid ruleset id "${invalidRSId}" in both enable/disableRulesetIds` + ); + await Assert.rejects( + Promise.reject(resInSameRequestAsValid?.rejectedWithErrorMessage), + new RegExp(`Invalid ruleset id: "${invalidRSId}"`), + `Got the expected rejection on invalid ruleset id "${invalidRSId}" along with valid ruleset ids` + ); + } + + // Confirm that the expected rulesets didn't change neither. + await assertDNRGetEnabledRulesets(extension, ["ruleset_1"]); + await assertDNRStoreData(dnrStore, extension, { + ruleset_1: getSchemaNormalizedRules(extension, ruleset1Data), + }); + + // - List the same ruleset ids more than ones is expected to work and + // to be resulting in the same set of rules being enabled + // - Disabling and Enabling the same ruleset id should result in the + // ruleset being enabled. + await extension.sendMessage("updateEnabledRulesets", { + disableRulesetIds: [ + "ruleset_1", + "ruleset_1", + "ruleset_2", + "ruleset_2", + "ruleset_2", + ], + enableRulesetIds: ["ruleset_2", "ruleset_2"], + }); + Assert.deepEqual( + await extension.awaitMessage("updateEnabledRulesets:done"), + [undefined], + "Expect the updateEnabledRulesets to result successfully" + ); + + await assertDNRGetEnabledRulesets(extension, ["ruleset_2"]); + await assertDNRStoreData(dnrStore, extension, { + ruleset_2: getSchemaNormalizedRules(extension, ruleset2Data), + }); + + await extension.unload(); +}); + +add_task(async function test_getAvailableStaticRulesCountAndLimits() { + // NOTE: this test is going to load and validate the maximum amount of static rules + // that an extension can enable, which on slower builds (in particular in tsan builds, + // e.g. see Bug 1803801) have a higher chance that the test extension may have hit the + // idle timeout and being suspended by the time the test is going to trigger API method + // calls through test API events (which do not expect the lifetime of the event page). + Services.prefs.setBoolPref("extensions.background.idle.enabled", false); + + const dnrStore = ExtensionDNRStore._getStoreForTesting(); + const { GUARANTEED_MINIMUM_STATIC_RULES } = ExtensionDNRLimits; + equal( + typeof GUARANTEED_MINIMUM_STATIC_RULES, + "number", + "Expect GUARANTEED_MINIMUM_STATIC_RULES to be a number" + ); + + const availableStaticRulesCount = GUARANTEED_MINIMUM_STATIC_RULES; + + const rule_resources = [ + { + id: "ruleset_0", + path: "/ruleset_0.json", + enabled: true, + }, + { + id: "ruleset_1", + path: "/ruleset_1.json", + enabled: true, + }, + // A ruleset initially disabled (to make sure it doesn't count for the + // rules count limit). + { + id: "ruleset_disabled", + path: "/ruleset_disabled.json", + enabled: false, + }, + // A ruleset including an invalid rule and valid rule. + { + id: "ruleset_withInvalid", + path: "/ruleset_withInvalid.json", + enabled: false, + }, + // An empty ruleset (to make sure it can still be enabled/disabled just fine, + // e.g. in case on some browser version all rules are technically invalid). + { + id: "ruleset_empty", + path: "/ruleset_empty.json", + enabled: false, + }, + ]; + + const files = {}; + const rules = {}; + + const rulesetDisabledData = [getDNRRule({ id: 1 })]; + const ruleValid = getDNRRule({ id: 2, action: { type: "allow" } }); + const rulesetWithInvalidData = [ + getDNRRule({ id: 1, action: { type: "invalid_action" } }), + ruleValid, + ]; + + rules.ruleset_0 = [getDNRRule({ id: 1 }), getDNRRule({ id: 2 })]; + + rules.ruleset_1 = []; + for (let i = 0; i < availableStaticRulesCount; i++) { + rules.ruleset_1.push(getDNRRule({ id: i + 1 })); + } + + for (const [k, v] of Object.entries(rules)) { + files[`${k}.json`] = JSON.stringify(v); + } + files[`ruleset_disabled.json`] = JSON.stringify(rulesetDisabledData); + files[`ruleset_withInvalid.json`] = JSON.stringify(rulesetWithInvalidData); + files[`ruleset_empty.json`] = JSON.stringify([]); + + const extension = ExtensionTestUtils.loadExtension( + getDNRExtension({ + id: "dnr-getAvailable-count-@mochitest", + rule_resources, + files, + }) + ); + + await extension.startup(); + await extension.awaitMessage("bgpage:ready"); + + async function updateEnabledRulesets({ expectedErrorMessage, ...options }) { + // Note: options = { disableRulesetIds, enableRulesetIds } + extension.sendMessage("updateEnabledRulesets", options); + let [result] = await extension.awaitMessage("updateEnabledRulesets:done"); + if (expectedErrorMessage) { + Assert.deepEqual( + result, + { rejectedWithErrorMessage: expectedErrorMessage }, + "updateEnabledRulesets() should reject with the given error" + ); + } else { + Assert.deepEqual( + result, + undefined, + "updateEnabledRulesets() should resolve without error" + ); + } + } + + const expectedEnabledRulesets = {}; + expectedEnabledRulesets.ruleset_0 = getSchemaNormalizedRules( + extension, + rules.ruleset_0 + ); + + info( + "Expect ruleset_1 to not be enabled because along with ruleset_0 exceeded the static rules count limit" + ); + await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets); + + await assertDNRGetAvailableStaticRuleCount( + extension, + availableStaticRulesCount - rules.ruleset_0.length, + "Got the available static rule count on ruleset_0 initially enabled" + ); + + // Try to enable ruleset_1 again from the API method. + await updateEnabledRulesets({ + enableRulesetIds: ["ruleset_1"], + expectedErrorMessage: `Number of rules across all enabled static rulesets exceeds GUARANTEED_MINIMUM_STATIC_RULES if ruleset "ruleset_1" were to be enabled.`, + }); + + info( + "Expect ruleset_1 to not be enabled because still exceeded the static rules count limit" + ); + await assertDNRGetEnabledRulesets(extension, ["ruleset_0"]); + await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets); + + await assertDNRGetAvailableStaticRuleCount( + extension, + availableStaticRulesCount - rules.ruleset_0.length, + "Got the available static rule count on ruleset_0 still the only one enabled" + ); + + await updateEnabledRulesets({ + disableRulesetIds: ["ruleset_0"], + enableRulesetIds: ["ruleset_1"], + }); + + info("Expect ruleset_1 to be enabled along with disabling ruleset_0"); + await assertDNRGetEnabledRulesets(extension, ["ruleset_1"]); + delete expectedEnabledRulesets.ruleset_0; + expectedEnabledRulesets.ruleset_1 = getSchemaNormalizedRules( + extension, + rules.ruleset_1 + ); + await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets, { + // Assert total amount of expected rules and only the first and last rule + // individually, to avoid generating a huge amount of logs and potential + // timeout failures on slower builds. + assertIndividualRules: false, + }); + + await assertDNRGetAvailableStaticRuleCount( + extension, + 0, + "Expect no additional static rules count available when ruleset_1 is enabled" + ); + + info( + "Expect ruleset_disabled to stay disabled because along with ruleset_1 exceeeds the limits" + ); + await updateEnabledRulesets({ + enableRulesetIds: ["ruleset_disabled"], + expectedErrorMessage: `Number of rules across all enabled static rulesets exceeds GUARANTEED_MINIMUM_STATIC_RULES if ruleset "ruleset_disabled" were to be enabled.`, + }); + await assertDNRGetEnabledRulesets(extension, ["ruleset_1"]); + await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets, { + // Assert total amount of expected rules and only the first and last rule + // individually, to avoid generating a huge amount of logs and potential + // timeout failures on slower builds. + assertIndividualRules: false, + }); + await assertDNRGetAvailableStaticRuleCount( + extension, + 0, + "Expect no additional static rules count available" + ); + + info("Expect ruleset_empty to be enabled despite having reached the limit"); + await updateEnabledRulesets({ + enableRulesetIds: ["ruleset_empty"], + }); + await assertDNRGetEnabledRulesets(extension, ["ruleset_1", "ruleset_empty"]); + await assertDNRStoreData( + dnrStore, + extension, + { + ...expectedEnabledRulesets, + ruleset_empty: [], + }, + // Assert total amount of expected rules and only the first and last rule + // individually, to avoid generating a huge amount of logs and potential + // timeout failures on slower builds. + { assertIndividualRules: false } + ); + await assertDNRGetAvailableStaticRuleCount( + extension, + 0, + "Expect no additional static rules count available" + ); + + info("Expect invalid rules to not be counted towards the limits"); + await updateEnabledRulesets({ + disableRulesetIds: ["ruleset_1", "ruleset_empty"], + enableRulesetIds: ["ruleset_withInvalid"], + }); + await assertDNRGetEnabledRulesets(extension, ["ruleset_withInvalid"]); + await assertDNRStoreData(dnrStore, extension, { + // Only the valid rule has been actually loaded, and the invalid one + // ignored. + ruleset_withInvalid: [ruleValid], + }); + await assertDNRGetAvailableStaticRuleCount( + extension, + availableStaticRulesCount - 1, + "Expect only valid rules to be counted" + ); + + await extension.unload(); + + Services.prefs.clearUserPref("extensions.background.idle.enabled"); +}); + +add_task(async function test_static_rulesets_limits() { + const dnrStore = ExtensionDNRStore._getStoreForTesting(); + + const getRulesetManifestData = (rulesetNumber, enabled) => { + return { + id: `ruleset_${rulesetNumber}`, + enabled, + path: `ruleset_${rulesetNumber}.json`, + }; + }; + const { + MAX_NUMBER_OF_STATIC_RULESETS, + MAX_NUMBER_OF_ENABLED_STATIC_RULESETS, + } = ExtensionDNRLimits; + + equal( + typeof MAX_NUMBER_OF_STATIC_RULESETS, + "number", + "Expect MAX_NUMBER_OF_STATIC_RULESETS to be a number" + ); + equal( + typeof MAX_NUMBER_OF_ENABLED_STATIC_RULESETS, + "number", + "Expect MAX_NUMBER_OF_ENABLED_STATIC_RULESETS to be a number" + ); + Assert.greater( + MAX_NUMBER_OF_STATIC_RULESETS, + MAX_NUMBER_OF_ENABLED_STATIC_RULESETS, + "Expect MAX_NUMBER_OF_STATIC_RULESETS to be greater" + ); + + const rules = [getDNRRule()]; + + const rule_resources = []; + const files = {}; + for (let i = 0; i < MAX_NUMBER_OF_STATIC_RULESETS + 1; i++) { + const enabled = i < MAX_NUMBER_OF_ENABLED_STATIC_RULESETS + 1; + files[`ruleset_${i}.json`] = JSON.stringify(rules); + rule_resources.push(getRulesetManifestData(i, enabled)); + } + + let extension = ExtensionTestUtils.loadExtension( + getDNRExtension({ + rule_resources, + files, + }) + ); + + const expectedEnabledRulesets = {}; + + const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + await extension.awaitMessage("bgpage:ready"); + + for (let i = 0; i < MAX_NUMBER_OF_ENABLED_STATIC_RULESETS; i++) { + expectedEnabledRulesets[`ruleset_${i}`] = getSchemaNormalizedRules( + extension, + rules + ); + } + + await assertDNRGetEnabledRulesets( + extension, + Array.from(Object.keys(expectedEnabledRulesets)) + ); + await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets); + }); + + AddonTestUtils.checkMessages(messages, { + expected: [ + // Warnings emitted from the manifest schema validation. + { + message: + /declarative_net_request: Static rulesets are exceeding the MAX_NUMBER_OF_STATIC_RULESETS limit/, + }, + { + message: + /declarative_net_request: Enabled static rulesets are exceeding the MAX_NUMBER_OF_ENABLED_STATIC_RULESETS limit .* "ruleset_10"/, + }, + // Error reported on the browser console as part of loading enabled rulesets) + // on enabled rulesets being ignored because exceeding the limit. + { + message: + /Ignoring enabled static ruleset exceeding the MAX_NUMBER_OF_ENABLED_STATIC_RULESETS .* "ruleset_10"/, + }, + ], + }); + + info( + "Verify updateEnabledRulesets reject when the request is exceeding the enabled rulesets count limit" + ); + extension.sendMessage("updateEnabledRulesets", { + disableRulesetIds: ["ruleset_0"], + enableRulesetIds: ["ruleset_10", "ruleset_11"], + }); + + await Assert.rejects( + extension.awaitMessage("updateEnabledRulesets:done").then(results => { + if (results[0].rejectedWithErrorMessage) { + return Promise.reject(new Error(results[0].rejectedWithErrorMessage)); + } + return results[0]; + }), + /updatedEnabledRulesets request is exceeding MAX_NUMBER_OF_ENABLED_STATIC_RULESETS/, + "Expected rejection on updateEnabledRulesets exceeting enabled rulesets count limit" + ); + + // Confirm that the expected rulesets didn't change neither. + await assertDNRGetEnabledRulesets( + extension, + Array.from(Object.keys(expectedEnabledRulesets)) + ); + await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets); + + info( + "Verify updateEnabledRulesets applies the expected changes when resolves successfully" + ); + extension.sendMessage( + "updateEnabledRulesets", + { + disableRulesetIds: ["ruleset_0"], + enableRulesetIds: ["ruleset_10"], + }, + { + disableRulesetIds: ["ruleset_10"], + enableRulesetIds: ["ruleset_11"], + } + ); + await extension.awaitMessage("updateEnabledRulesets:done"); + + // Expect ruleset_0 disabled, ruleset_10 to be enabled but then disabled by the + // second update queued after the first one, and ruleset_11 to be enabled. + delete expectedEnabledRulesets.ruleset_0; + expectedEnabledRulesets.ruleset_11 = getSchemaNormalizedRules( + extension, + rules + ); + + await assertDNRGetEnabledRulesets( + extension, + Array.from(Object.keys(expectedEnabledRulesets)) + ); + await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets); + + // Ensure all changes were stored and reloaded from disk store and the + // DNR store update queue can accept new updates. + info("Verify static rules load and updates after extension is restarted"); + + // NOTE: promiseRestartManager will not be enough to make sure the + // DNR store data for the test extension is going to be loaded from + // the DNR startup cache file. + // See test_ext_dnr_startup_cache.js for a test case that more completely + // simulates ExtensionDNRStore initialization on browser restart. + await AddonTestUtils.promiseRestartManager(); + + await extension.awaitStartup(); + await extension.awaitMessage("bgpage:ready"); + await assertDNRGetEnabledRulesets( + extension, + Array.from(Object.keys(expectedEnabledRulesets)) + ); + await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets); + + extension.sendMessage("updateEnabledRulesets", { + disableRulesetIds: ["ruleset_11"], + }); + await extension.awaitMessage("updateEnabledRulesets:done"); + delete expectedEnabledRulesets.ruleset_11; + await assertDNRGetEnabledRulesets( + extension, + Array.from(Object.keys(expectedEnabledRulesets)) + ); + await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets); + + await extension.unload(); +}); + +add_task(async function test_tabId_conditions_invalid_in_static_rules() { + const ruleset1_with_tabId_condition = [ + getDNRRule({ id: 1, condition: { tabIds: [1] } }), + getDNRRule({ id: 3, condition: { urlFilter: "valid-ruleset1-rule" } }), + ]; + + const ruleset2_with_excludeTabId_condition = [ + getDNRRule({ id: 2, condition: { excludedTabIds: [1] } }), + getDNRRule({ id: 3, condition: { urlFilter: "valid-ruleset2-rule" } }), + ]; + + const rule_resources = [ + { + id: "ruleset1_with_tabId_condition", + enabled: true, + path: "ruleset1.json", + }, + { + id: "ruleset2_with_excludeTabId_condition", + enabled: true, + path: "ruleset2.json", + }, + ]; + + const files = { + "ruleset1.json": JSON.stringify(ruleset1_with_tabId_condition), + "ruleset2.json": JSON.stringify(ruleset2_with_excludeTabId_condition), + }; + + const extension = ExtensionTestUtils.loadExtension( + getDNRExtension({ + id: "tabId-invalid-in-session-rules@mochitest", + rule_resources, + files, + }) + ); + + const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + await extension.awaitMessage("bgpage:ready"); + await assertDNRGetEnabledRulesets(extension, [ + "ruleset1_with_tabId_condition", + "ruleset2_with_excludeTabId_condition", + ]); + }); + + AddonTestUtils.checkMessages(messages, { + expected: [ + { + message: + /"ruleset1_with_tabId_condition": tabIds and excludedTabIds can only be specified in session rules/, + }, + { + message: + /"ruleset2_with_excludeTabId_condition": tabIds and excludedTabIds can only be specified in session rules/, + }, + ], + }); + + info("Expect the invalid rule to not be enabled"); + const dnrStore = ExtensionDNRStore._getStoreForTesting(); + // Expect the two valid rules to have been loaded as expected. + await assertDNRStoreData(dnrStore, extension, { + ruleset1_with_tabId_condition: getSchemaNormalizedRules(extension, [ + ruleset1_with_tabId_condition[1], + ]), + ruleset2_with_excludeTabId_condition: getSchemaNormalizedRules(extension, [ + ruleset2_with_excludeTabId_condition[1], + ]), + }); + + await extension.unload(); +}); + +add_task(async function test_dnr_all_rules_disabled_allowed() { + const ruleset1 = [ + getDNRRule({ id: 3, condition: { urlFilter: "valid-ruleset1-rule" } }), + ]; + + const rule_resources = [ + { + id: "ruleset1", + enabled: true, + path: "ruleset1.json", + }, + ]; + + const files = { + "ruleset1.json": JSON.stringify(ruleset1), + }; + + const extension = ExtensionTestUtils.loadExtension( + getDNRExtension({ + id: "all-static-rulesets-disabled-allowed@mochitest", + rule_resources, + files, + }) + ); + + await extension.startup(); + await extension.awaitMessage("bgpage:ready"); + + await assertDNRGetEnabledRulesets(extension, ["ruleset1"]); + + const dnrStore = ExtensionDNRStore._getStoreForTesting(); + await assertDNRStoreData(dnrStore, extension, { + ruleset1: getSchemaNormalizedRules(extension, ruleset1), + }); + + info("Disable static ruleset1"); + extension.sendMessage("updateEnabledRulesets", { + disableRulesetIds: ["ruleset1"], + }); + await extension.awaitMessage("updateEnabledRulesets:done"); + await assertDNRGetEnabledRulesets(extension, []); + await assertDNRStoreData(dnrStore, extension, {}); + + info("Verify that static ruleset1 is still disable after browser restart"); + + // NOTE: promiseRestartManager will not be enough to make sure the + // DNR store data for the test extension is going to be loaded from + // the DNR startup cache file. + // See test_ext_dnr_startup_cache.js for a test case that more completely + // simulates ExtensionDNRStore initialization on browser restart. + await AddonTestUtils.promiseRestartManager(); + + await extension.awaitStartup; + await ExtensionDNR.ensureInitialized(extension.extension); + await extension.awaitMessage("bgpage:ready"); + + await assertDNRGetEnabledRulesets(extension, []); + await assertDNRStoreData(dnrStore, extension, {}); + + await extension.unload(); +}); + +add_task(async function test_static_rules_telemetry() { + resetTelemetryData(); + + const ruleset1 = [ + getDNRRule({ + id: 1, + action: { type: "block" }, + condition: { + resourceTypes: ["xmlhttprequest"], + requestDomains: ["example.com"], + }, + }), + ]; + const ruleset2 = [ + getDNRRule({ + id: 1, + action: { type: "block" }, + condition: { + resourceTypes: ["xmlhttprequest"], + requestDomains: ["example.org"], + }, + }), + getDNRRule({ + id: 2, + action: { type: "block" }, + condition: { + resourceTypes: ["xmlhttprequest"], + requestDomains: ["example2.org"], + }, + }), + ]; + + const rule_resources = [ + { + id: "ruleset1", + enabled: false, + path: "ruleset1.json", + }, + { + id: "ruleset2", + enabled: false, + path: "ruleset2.json", + }, + ]; + + const files = { + "ruleset1.json": JSON.stringify(ruleset1), + "ruleset2.json": JSON.stringify(ruleset2), + }; + + const extension = ExtensionTestUtils.loadExtension( + getDNRExtension({ + id: "tabId-invalid-in-session-rules@mochitest", + rule_resources, + files, + }) + ); + + assertDNRTelemetryMetricsNoSamples( + [ + { + metric: "validateRulesTime", + mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS", + mirroredType: "histogram", + }, + { + metric: "evaluateRulesTime", + mirroredName: "WEBEXT_DNR_EVALUATE_RULES_MS", + mirroredType: "histogram", + }, + ], + "before test extension have been loaded" + ); + + await extension.startup(); + await extension.awaitMessage("bgpage:ready"); + + await assertDNRGetEnabledRulesets(extension, []); + + assertDNRTelemetryMetricsNoSamples( + [ + { + metric: "validateRulesTime", + mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS", + mirroredType: "histogram", + }, + ], + "after test extension loaded with all static rulesets disabled" + ); + + info("Enable static ruleset1"); + extension.sendMessage("updateEnabledRulesets", { + enableRulesetIds: ["ruleset1"], + }); + await extension.awaitMessage("updateEnabledRulesets:done"); + + await assertDNRGetEnabledRulesets(extension, ["ruleset1"]); + + // Expect one sample after enabling ruleset1. + let expectedValidateRulesTimeSamples = 1; + assertDNRTelemetryMetricsSamplesCount( + [ + { + metric: "validateRulesTime", + mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS", + mirroredType: "histogram", + expectedSamplesCount: expectedValidateRulesTimeSamples, + }, + ], + "after enabling static rulesets1" + ); + + info("Enable static ruleset2"); + extension.sendMessage("updateEnabledRulesets", { + enableRulesetIds: ["ruleset2"], + }); + await extension.awaitMessage("updateEnabledRulesets:done"); + + await assertDNRGetEnabledRulesets(extension, ["ruleset1", "ruleset2"]); + + // Expect one new sample after enabling ruleset2. + expectedValidateRulesTimeSamples += 1; + assertDNRTelemetryMetricsSamplesCount( + [ + { + metric: "validateRulesTime", + mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS", + mirroredType: "histogram", + expectedSamplesCount: expectedValidateRulesTimeSamples, + }, + ], + "after enabling static rulesets2" + ); + + await extension.addon.disable(); + + assertDNRTelemetryMetricsSamplesCount( + [ + { + metric: "validateRulesTime", + mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS", + mirroredType: "histogram", + expectedSamplesCount: expectedValidateRulesTimeSamples, + }, + ], + "no new samples expected after disabling test extension" + ); + + await extension.addon.enable(); + await extension.awaitMessage("bgpage:ready"); + await ExtensionDNR.ensureInitialized(extension.extension); + + // Expect 2 new samples after re-enabling the addon with + // the 2 rulesets enabled being loaded from the DNR store file. + expectedValidateRulesTimeSamples += 2; + assertDNRTelemetryMetricsSamplesCount( + [ + { + metric: "validateRulesTime", + mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS", + mirroredType: "histogram", + expectedSamplesCount: expectedValidateRulesTimeSamples, + }, + ], + "after re-enabling test extension" + ); + + info("Disable static ruleset1"); + + extension.sendMessage("updateEnabledRulesets", { + disableRulesetIds: ["ruleset1"], + }); + await extension.awaitMessage("updateEnabledRulesets:done"); + + await assertDNRGetEnabledRulesets(extension, ["ruleset2"]); + + assertDNRTelemetryMetricsSamplesCount( + [ + { + metric: "validateRulesTime", + mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS", + mirroredType: "histogram", + expectedSamplesCount: expectedValidateRulesTimeSamples, + }, + ], + "no new validation should be hit after disabling ruleset1" + ); + + info("Verify telemetry recorded on rules evaluation"); + extension.sendMessage("updateEnabledRulesets", { + enableRulesetIds: ["ruleset1"], + disableRulesetIds: ["ruleset2"], + }); + await extension.awaitMessage("updateEnabledRulesets:done"); + await assertDNRGetEnabledRulesets(extension, ["ruleset1"]); + + assertDNRTelemetryMetricsNoSamples( + [ + { + metric: "evaluateRulesTime", + mirroredName: "WEBEXT_DNR_EVALUATE_RULES_MS", + mirroredType: "histogram", + }, + { + metric: "evaluateRulesCountMax", + mirroredName: "extensions.apis.dnr.evaluate_rules_count_max", + mirroredType: "scalar", + }, + ], + "before any request have been intercepted" + ); + + Assert.equal( + await fetch("http://example.com/").then(res => res.text()), + "response from server", + "DNR should not block system requests" + ); + + assertDNRTelemetryMetricsNoSamples( + [ + { + metric: "evaluateRulesTime", + mirroredName: "WEBEXT_DNR_EVALUATE_RULES_MS", + mirroredType: "histogram", + }, + { + metric: "evaluateRulesCountMax", + mirroredName: "extensions.apis.dnr.evaluate_rules_count_max", + mirroredType: "scalar", + }, + ], + "after restricted request have been intercepted (but no rules evaluated)" + ); + + const page = await ExtensionTestUtils.loadContentPage("http://example.com"); + const callPageFetch = async () => { + Assert.equal( + await page.spawn([], () => { + return this.content.fetch("http://example.com/").then( + res => res.text(), + err => err.message + ); + }), + "NetworkError when attempting to fetch resource.", + "DNR should have blocked test request to example.com" + ); + }; + + // Expect one sample recorded on evaluating rules for the + // top level navigation. + let expectedEvaluateRulesTimeSamples = 1; + assertDNRTelemetryMetricsSamplesCount( + [ + { + metric: "evaluateRulesTime", + mirroredName: "WEBEXT_DNR_EVALUATE_RULES_MS", + mirroredType: "histogram", + expectedSamplesCount: expectedEvaluateRulesTimeSamples, + }, + ], + "evaluateRulesTime should be collected after evaluated rulesets" + ); + // Expect same number of rules included in the single ruleset + // currently enabled. + let expectedEvaluateRulesCountMax = ruleset1.length; + assertDNRTelemetryMetricsGetValueEq( + [ + { + metric: "evaluateRulesCountMax", + mirroredName: "extensions.apis.dnr.evaluate_rules_count_max", + mirroredType: "scalar", + expectedGetValue: expectedEvaluateRulesCountMax, + }, + ], + "evaluateRulesCountMax should be collected after evaluated rulesets1" + ); + + await callPageFetch(); + + // Expect one new sample reported on evaluating rules for the + // first fetch request originated from the test page. + expectedEvaluateRulesTimeSamples += 1; + assertDNRTelemetryMetricsSamplesCount( + [ + { + metric: "evaluateRulesTime", + mirroredName: "WEBEXT_DNR_EVALUATE_RULES_MS", + mirroredType: "histogram", + expectedSamplesCount: expectedEvaluateRulesTimeSamples, + }, + ], + "evaluateRulesTime should be collected after evaluated rulesets" + ); + + extension.sendMessage("updateEnabledRulesets", { + enableRulesetIds: ["ruleset2"], + }); + await extension.awaitMessage("updateEnabledRulesets:done"); + await assertDNRGetEnabledRulesets(extension, ["ruleset1", "ruleset2"]); + + await callPageFetch(); + + // Expect 3 rules with both rulesets enabled + // (1 from ruleset1 and 2 more from ruleset2). + expectedEvaluateRulesCountMax += ruleset2.length; + assertDNRTelemetryMetricsGetValueEq( + [ + { + metric: "evaluateRulesCountMax", + mirroredName: "extensions.apis.dnr.evaluate_rules_count_max", + mirroredType: "scalar", + expectedGetValue: expectedEvaluateRulesCountMax, + }, + ], + "evaluateRulesCountMax should have been increased after enabling ruleset2" + ); + + extension.sendMessage("updateEnabledRulesets", { + disableRulesetIds: ["ruleset2"], + }); + await extension.awaitMessage("updateEnabledRulesets:done"); + await assertDNRGetEnabledRulesets(extension, ["ruleset1"]); + + await callPageFetch(); + + assertDNRTelemetryMetricsGetValueEq( + [ + { + metric: "evaluateRulesCountMax", + mirroredName: "extensions.apis.dnr.evaluate_rules_count_max", + mirroredType: "scalar", + expectedGetValue: expectedEvaluateRulesCountMax, + }, + ], + "evaluateRulesCountMax should have not been decreased after disabling ruleset2" + ); + + await page.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_system_restrictions.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_system_restrictions.js new file mode 100644 index 0000000000..84464d0cba --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_system_restrictions.js @@ -0,0 +1,283 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com", "restricted"] }); +server.registerPathHandler("/", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.write("response from server"); +}); +server.registerPathHandler("/style_with_import.css", (req, res) => { + res.setHeader("Content-Type", "text/css"); + res.setHeader("Access-Control-Allow-Origin", "*"); + res.write("@import url('http://example.com/imported.css');"); +}); +server.registerPathHandler("/imported.css", (req, res) => { + res.setHeader("Content-Type", "text/css"); + res.setHeader("Access-Control-Allow-Origin", "*"); + res.write("imported_stylesheet_here { }"); +}); + +add_setup(() => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); + // The restrictedDomains pref should be set early, because the pref is read + // only once (on first use) by WebExtensionPolicy::IsRestrictedURI. + Services.prefs.setCharPref( + "extensions.webextensions.restrictedDomains", + "restricted" + ); +}); + +async function startDNRExtension() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { resourceTypes: ["xmlhttprequest", "stylesheet"] }, + action: { type: "block" }, + }, + { + id: 2, + condition: { urlFilter: "blockme", resourceTypes: ["main_frame"] }, + action: { type: "block" }, + }, + ], + }); + browser.test.sendMessage("dnr_registered"); + }, + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest"], + }, + }); + await extension.startup(); + await extension.awaitMessage("dnr_registered"); + return extension; +} + +add_task(async function dnr_ignores_system_requests() { + let extension = await startDNRExtension(); + Assert.equal( + await (await fetch("http://example.com/")).text(), + "response from server", + "DNR should not block requests from system principal" + ); + await extension.unload(); +}); + +add_task(async function dnr_ignores_requests_to_restrictedDomains() { + let extension = await startDNRExtension(); + Assert.equal( + await ExtensionTestUtils.fetch("http://example.com/", "http://restricted/"), + "response from server", + "DNR should not block destination in restrictedDomains" + ); + await extension.unload(); +}); + +add_task(async function dnr_ignores_initiator_from_restrictedDomains() { + let extension = await startDNRExtension(); + Assert.equal( + await ExtensionTestUtils.fetch("http://restricted/", "http://example.com/"), + "response from server", + "DNR should not block requests initiated from a page in restrictedDomains" + ); + await extension.unload(); +}); + +add_task(async function dnr_ignores_navigation_to_restrictedDomains() { + let extension = await startDNRExtension(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://restricted/?blockme" + ); + await contentPage.spawn([], () => { + const { document } = content; + Assert.equal(document.URL, "http://restricted/?blockme", "Same URL"); + Assert.equal(document.body.textContent, "response from server", "body"); + }); + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function dnr_ignores_css_import_at_restrictedDomains() { + // CSS @import have triggeringPrincipal set to the URL of the stylesheet, + // and the loadingPrincipal set to the web page. To verify that access is + // indeed being restricted as expected, confirm that none of the stylesheet + // requests are blocked by the DNR extension. + let extension = await startDNRExtension(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://restricted/" + ); + await contentPage.spawn([], async () => { + // Use wrappedJSObject so that all operations below are with the principal + // of the content instead of the system principal (from this ContentTask). + const { document } = content.wrappedJSObject; + const style = document.createElement("link"); + style.rel = "stylesheet"; + // Note: intentionally not at "http://restricted/" because we want to check + // that subresources from a restricted domain are ignored by DNR.. + style.href = "http://example.com/style_with_import.css"; + style.crossOrigin = "anonymous"; + await new Promise(resolve => { + info("Waiting for style sheet to load..."); + style.onload = resolve; + document.head.append(style); + }); + const importRule = style.sheet.cssRules[0]; + Assert.equal( + importRule?.cssText, + `@import url("http://example.com/imported.css");`, + "Not blocked by DNR: Loaded style_with_import.css" + ); + // Waiving Xrays here because we cannot read cssRules despite CORS because + // that is not implemented for child stylesheets (loaded via @import): + // https://searchfox.org/mozilla-central/rev/55d5c4b9dffe5e59eb6b019c1a930ec9ada47e10/layout/style/Loader.cpp#2052 + const importedStylesheet = Cu.unwaiveXrays(importRule.styleSheet); + Assert.equal( + importedStylesheet.cssRules[0]?.cssText, + "imported_stylesheet_here { }", + "Not blocked by DNR: Loaded import.css" + ); + }); + await contentPage.close(); + await extension.unload(); +}); + +add_task( + { pref_set: [["extensions.dnr.feedback", true]] }, + async function testMatchOutcome_and_restrictedDomains() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [{ id: 1, condition: {}, action: { type: "block" } }], + }); + const type = "other"; // matches the condition of the above rule. + + browser.test.assertDeepEq( + { matchedRules: [] }, + await browser.declarativeNetRequest.testMatchOutcome({ + url: "http://restricted/", + type, + }), + "testMatchOutcome ignores restricted url" + ); + browser.test.assertDeepEq( + { matchedRules: [] }, + await browser.declarativeNetRequest.testMatchOutcome({ + url: "http://example.com/", + initiator: "http://restricted/", + type, + }), + "testMatchOutcome ignores restricted initiator" + ); + browser.test.sendMessage("done"); + }, + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"], + }, + }); + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); + } +); + +add_task( + // In debug builds, any attempt to load data:-URLs in the parent process + // results in a crash or at least a logged error, via + // nsContentSecurityUtils::ValidateScriptFilename. + // + // Xpcshell tests use loadFrameScript with data:-URLs, which could trigger the + // above error / crash, when a page is loaded in the parent process. + // For example, the following error message (or crash), + // "InternalError: unsafe filename: data:text/javascript,//" + // "Hit MOZ_CRASH(Blocking a script load data:text/javascript,// from file (None))" + // is triggered because of the loadFrameScript call at + // https://searchfox.org/mozilla-central/rev/11dbac7f64f509b78037465cbb4427ed71f8b565/testing/modules/XPCShellContentUtils.sys.mjs#308 + // + // This test loads about:logo in the parent, because nsAboutRedirector.cpp + // registers about:logo without nsIAboutModule::URI_MUST_LOAD_IN_CHILD. + // When about:logo is loaded, the ContentPage test helper also triggers the + // above error/crash at: + // https://searchfox.org/mozilla-central/rev/11dbac7f64f509b78037465cbb4427ed71f8b565/testing/modules/XPCShellContentUtils.sys.mjs#224,242 + // + // Opt out of the check/crash from ValidateScriptFilename: + { pref_set: [["security.allow_parent_unrestricted_js_loads", true]] }, + async function non_system_request_with_disallowed_scheme() { + let extension = await startDNRExtension(); + Assert.equal( + await (await fetch("http://example.com/")).text(), + "response from server", + "DNR should not block requests from system principal" + ); + // We are loading about:logo for the following reasons: + // - It is a regular content principal, NOT a system principal. + // - It is an about:-URL that resolves across all builds (part of toolkit/). + // - It does not have a CSP (intentional - bug 1587417). That enables us to + // send a fetch() request below. + let contentPage = await ExtensionTestUtils.loadContentPage( + "about:logo?blockme" + ); + await contentPage.spawn([], async () => { + const { document } = content; + // To make sure that the test does not pass trivially, we verify that it + // is not the system principal (because dnr_ignores_system_requests + // already tests that) and not a null principal (because that translates + // to a void "initiator" in the DNR API, which would pass access checks). + Assert.ok( + document.nodePrincipal.isContentPrincipal, + "about:logo has content principal (not system or NullPrincipal))" + ); + Assert.equal(document.URL, "about:logo?blockme", "Same URL"); + Assert.equal( + await (await content.fetch("http://example.com/")).text(), + "response from server", + "fetch() at about:logo not blocked by DNR" + ); + }); + await contentPage.close(); + await extension.unload(); + } +); + +add_task( + { pref_set: [["extensions.dnr.feedback", true]] }, + async function testMatchOutcome_non_system_request_with_disallowed_scheme() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [{ id: 1, condition: {}, action: { type: "block" } }], + }); + const type = "other"; // matches the condition of the above rule. + + browser.test.assertDeepEq( + { matchedRules: [] }, + await browser.declarativeNetRequest.testMatchOutcome({ + url: "about:logo", + type, + }), + "testMatchOutcome ignores url with disallowed schema" + ); + browser.test.assertDeepEq( + { matchedRules: [] }, + await browser.declarativeNetRequest.testMatchOutcome({ + url: "http://example.com/", + initiator: "about:logo", + type, + }), + "testMatchOutcome ignores initiator with disallowed schema" + ); + browser.test.sendMessage("done"); + }, + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"], + }, + }); + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_tabIds.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_tabIds.js new file mode 100644 index 0000000000..84b75bb5be --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_tabIds.js @@ -0,0 +1,249 @@ +"use strict"; + +// This test verifies that the internals for associating requests with tabId +// are only active when a session rule with a tabId rule exists. +// +// There are tests for the logic of tabId matching in the match_tabIds task in +// toolkit/components/extensions/test/xpcshell/test_ext_dnr_testMatchOutcome.js +// +// And there are tests that verify matching with real network requests in +// toolkit/components/extensions/test/mochitest/test_ext_dnr_tabIds.html + +const server = createHttpServer({ hosts: ["from", "any", "in", "ex"] }); +server.registerPathHandler("/", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); +}); + +let gTabLookupSpy; + +add_setup(async () => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); + + // Install a spy on WebRequest.getTabIdForChannelWrapper. + const { WebRequest } = ChromeUtils.importESModule( + "resource://gre/modules/WebRequest.sys.mjs" + ); + const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" + ); + gTabLookupSpy = sinon.spy(WebRequest, "getTabIdForChannelWrapper"); + + await ExtensionTestUtils.startAddonManager(); +}); + +function numberOfTabLookupsSinceLastCheck() { + let result = gTabLookupSpy.callCount; + gTabLookupSpy.resetHistory(); + return result; +} + +// This test checks that WebRequest.getTabIdForChannelWrapper is only called +// when there are any registered tabId/excludedTabIds rules. Moreover, it +// verifies that after unloading (reloading) the extension, that the method is +// still not called unnecessarily. +add_task(async function getTabIdForChannelWrapper_only_called_when_needed() { + async function background() { + const RULE_ANY_TAB_ID = { + id: 1, + condition: { requestDomains: ["from"] }, + action: { type: "redirect", redirect: { url: "http://any/" } }, + }; + const RULE_INCLUDE_TAB_ID = { + id: 2, + condition: { requestDomains: ["from"], tabIds: [-1] }, + action: { type: "redirect", redirect: { url: "http://in/" } }, + priority: 2, + }; + const RULE_EXCLUDE_TAB_ID = { + id: 3, + condition: { requestDomains: ["from"], excludedTabIds: [-1] }, + action: { type: "redirect", redirect: { url: "http://ex/" } }, + priority: 2, + }; + async function promiseOneMessage(messageName) { + return new Promise(resolve => { + browser.test.onMessage.addListener(function listener(msg, result) { + if (messageName === msg) { + browser.test.onMessage.removeListener(listener); + resolve(result); + } + }); + }); + } + async function numberOfTabLookupsSinceLastCheck() { + let promise = promiseOneMessage("tabLookups"); + browser.test.sendMessage("getTabLookups"); + return promise; + } + async function testFetchUrl(url, expectedUrl, expectedCount, description) { + let res = await fetch(url); + browser.test.assertEq(expectedUrl, res.url, `Final URL for ${url}`); + browser.test.assertEq( + expectedCount, + await numberOfTabLookupsSinceLastCheck(), + `Expected number of tab lookups - ${url} - ${description}` + ); + } + + const startupCountPromise = promiseOneMessage("extensionStartupCount"); + browser.test.sendMessage("extensionStarted"); + const startupCount = await startupCountPromise; + if (startupCount !== 0) { + browser.test.assertEq(1, startupCount, "Extension restarted once"); + + // Note: declarativeNetRequest.updateSessionRules is intentionally not + // called here, because we want to verify that upon unloading the + // extension, that the tabId lookup logic was properly cleaned up, + // i.e. that NetworkIntegration.maybeUpdateTabIdChecker() was called. + + await testFetchUrl( + "http://from/?after-restart-supposedly-no-include-tab", + "http://from/?after-restart-supposedly-no-include-tab", + 0, + "No lookup because session rules should have disappeared at reload" + ); + + browser.test.assertDeepEq( + [], + await browser.declarativeNetRequest.getSessionRules(), + "The session rules have indeed been cleared upon reload." + ); + + browser.test.sendMessage("test_completed_after_reload"); + return; + } + + browser.test.assertEq( + 0, + await numberOfTabLookupsSinceLastCheck(), + "Initially, no tab lookups" + ); + + await testFetchUrl( + "http://from/?no_dnr_rules", + "http://from/?no_dnr_rules", + 0, + "No tab lookups without any registered DNR rules" + ); + + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [RULE_ANY_TAB_ID], + }); + // Active rules now: RULE_ANY_TAB_ID + + await testFetchUrl( + "http://from/?only_dnr_rule_matches_any_tab", + "http://any/", + 0, + "No tab lookups when only rule has no tabIds/excludedTabIds conditions" + ); + + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [RULE_EXCLUDE_TAB_ID], + }); + // Active rules now: RULE_ANY_TAB_ID, RULE_EXCLUDE_TAB_ID + + await testFetchUrl( + "http://from/?dnr_rule_matches_any,dnr_rule_excludes_-1", + // should be "any" instead of "ex" because excludedTabIds: [-1] should + // exclude the background. + "http://any/", + 2, // initial request + redirect request. + "Expected tabId lookup when a tabId rule is registered" + ); + + await browser.declarativeNetRequest.updateSessionRules({ + removeRuleIds: [RULE_ANY_TAB_ID.id], + }); + // Active rules now: RULE_EXCLUDE_TAB_ID + + await testFetchUrl( + "http://from/?only_dnr_rule_excludes_-1", + // Not redirected to "ex" because excludedTabIds: [-1] does not match the + // background that has tabId -1. + "http://from/?only_dnr_rule_excludes_-1", + 1, + "Expected lookup after unregistering unrelated rule, keeping tabId rule" + ); + + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [RULE_INCLUDE_TAB_ID], + }); + // Active rules now: RULE_EXCLUDE_TAB_ID, RULE_INCLUDE_TAB_ID + await testFetchUrl( + "http://from/?two_dnr_rule_include_and_exclude_-1", + "http://in/", + 2, // initial request + redirect request. + "Expecting lookup because of 2 DNR rules with tabId and excludedTabIds" + ); + + await browser.declarativeNetRequest.updateSessionRules({ + removeRuleIds: [RULE_EXCLUDE_TAB_ID.id], + }); + // Active rules now: RULE_INCLUDE_TAB_ID + + await testFetchUrl( + "http://from/?only_dnr_rule_includes_-1", + "http://in/", + 2, // initial request + redirect request. + "Expecting lookup because of remaining tabId DNR rule" + ); + + await browser.declarativeNetRequest.updateSessionRules({ + removeRuleIds: [RULE_INCLUDE_TAB_ID.id], + }); + // Active rules now: none + + await testFetchUrl( + "http://from/?no_rules_again", + "http://from/?no_rules_again", + 0, + "Expected no lookups after unregistering the last remaining rule" + ); + + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [RULE_INCLUDE_TAB_ID], + }); + // Active rules now: RULE_INCLUDE_TAB_ID + + await testFetchUrl( + "http://from/?again_with-include-1", + "http://in/", + 2, // initial request + redirect request. + "Expecting lookup again because of include rule" + ); + + // Ending test with remaining rule: RULE_INCLUDE_TAB_ID + // Reload extension. + browser.test.sendMessage("reload_extension"); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + useAddonManager: "temporary", // for reload and granted_host_permissions. + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + host_permissions: ["*://from/*"], + granted_host_permissions: true, + permissions: ["declarativeNetRequest"], + }, + }); + extension.onMessage("getTabLookups", () => { + extension.sendMessage("tabLookups", numberOfTabLookupsSinceLastCheck()); + }); + let startupCount = 0; + extension.onMessage("extensionStarted", () => { + extension.sendMessage("extensionStartupCount", startupCount++); + }); + await extension.startup(); + await extension.awaitMessage("reload_extension"); + await extension.addon.reload(); + await extension.awaitMessage("test_completed_after_reload"); + Assert.equal( + 0, + numberOfTabLookupsSinceLastCheck(), + "No new tab lookups since completion of extension tests" + ); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_testMatchOutcome.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_testMatchOutcome.js new file mode 100644 index 0000000000..8a34f2fa95 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_testMatchOutcome.js @@ -0,0 +1,1499 @@ +"use strict"; + +add_setup(() => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.feedback", true); + + // Don't turn warnings in errors, to make sure that the parameter validation + // tests verify real-world behavior, instead of the stricter test-only mode. + ExtensionTestUtils.failOnSchemaWarnings(false); +}); + +// This function is serialized and called in the context of the test extension's +// background page. dnrTestUtils is passed to the background function. +function makeDnrTestUtils() { + const dnrTestUtils = {}; + const dnr = browser.declarativeNetRequest; + function makeDummyAction(type) { + switch (type) { + case "redirect": + return { type, redirect: { url: "https://example.com/dummy" } }; + case "modifyHeaders": + return { + type, + responseHeaders: [{ operation: "append", header: "x", value: "y" }], + }; + default: + return { type }; + } + } + function makeDummyRequest() { + // A value that matches the condition from makeDummyRule(). + return { url: "https://example.com/some-dummy-url", type: "main_frame" }; + } + function makeDummyRule(id, actionType) { + return { + id, + // condition matches makeDummyRequest(). + condition: { resourceTypes: ["main_frame"] }, + action: makeDummyAction(actionType), + }; + } + async function testMatchesRequest(request, ruleIds, description) { + browser.test.assertDeepEq( + ruleIds, + (await dnr.testMatchOutcome(request)).matchedRules.map(mr => mr.ruleId), + description + ); + } + async function testCanMatchAnyBlock({ matchedRequests, nonMatchedRequests }) { + await dnr.updateSessionRules({ + addRules: [ + { + // A rule that is supposed to match everything. + id: 1, + condition: { excludedResourceTypes: [] }, + action: { type: "block" }, + }, + ], + }); + for (let request of matchedRequests) { + await testMatchesRequest( + request, + [1], + `${JSON.stringify(request)} - should match wildcard DNR block rule` + ); + } + for (let request of nonMatchedRequests) { + await testMatchesRequest( + request, + [], + `${JSON.stringify(request)} - should not match any DNR rule` + ); + } + await dnr.updateSessionRules({ removeRuleIds: [1] }); + } + async function testCanUseAction(type, canUse) { + await dnr.updateSessionRules({ addRules: [makeDummyRule(1, type)] }); + await testMatchesRequest( + makeDummyRequest(), + canUse ? [1] : [], + `${type} - should${canUse ? "" : " not"} match` + ); + await dnr.updateSessionRules({ removeRuleIds: [1] }); + } + Object.assign(dnrTestUtils, { + makeDummyAction, + makeDummyRequest, + makeDummyRule, + testMatchesRequest, + testCanMatchAnyBlock, + testCanUseAction, + }); + return dnrTestUtils; +} + +async function runAsDNRExtension({ + background, + manifest, + unloadTestAtEnd = true, +}) { + let extension = ExtensionTestUtils.loadExtension({ + background: `(${background})((${makeDnrTestUtils})())`, + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"], + host_permissions: ["<all_urls>"], + granted_host_permissions: true, + ...manifest, + }, + temporarilyInstalled: true, // <-- for granted_host_permissions + }); + await extension.startup(); + await extension.awaitFinish(); + if (unloadTestAtEnd) { + await extension.unload(); + } + return extension; +} + +add_task(async function validate_required_params() { + await runAsDNRExtension({ + background: async () => { + const testMatchOutcome = browser.declarativeNetRequest.testMatchOutcome; + + browser.test.assertThrows( + () => testMatchOutcome({ type: "image" }), + /Type error for parameter request \(Property "url" is required\)/, + "url is required" + ); + browser.test.assertThrows( + () => testMatchOutcome({ url: "https://example.com/" }), + /Type error for parameter request \(Property "type" is required\)/, + "resource type is required" + ); + + browser.test.assertDeepEq( + { matchedRules: [] }, + await testMatchOutcome({ url: "https://example.com/", type: "image" }), + "testMatchOutcome with url and type succeeds" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function resource_type_validation() { + await runAsDNRExtension({ + background: async () => { + const testMatchOutcome = browser.declarativeNetRequest.testMatchOutcome; + + const url = "https://example.com/some-dummy-url"; + + browser.test.assertThrows( + () => testMatchOutcome({ url, type: "MAIN_FRAME" }), + /Error processing type: Invalid enumeration value "MAIN_FRAME"/, + "testMatchOutcome should expects a lowercase type" + ); + + // Check that at least one ResourceType exists. + browser.test.assertEq( + "main_frame", + browser.declarativeNetRequest.ResourceType.MAIN_FRAME, + "ResourceType.MAIN_FRAME exists" + ); + + for (let type of Object.values( + browser.declarativeNetRequest.ResourceType + )) { + browser.test.assertDeepEq( + { matchedRules: [] }, + await testMatchOutcome({ url, type }), + `testMatchOutcome for type=${type} is allowed` + ); + } + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function url_validation() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const dnr = browser.declarativeNetRequest; + const { testMatchesRequest } = dnrTestUtils; + + const type = "other"; // Dummy resource type. + await dnr.updateSessionRules({ + addRules: [{ id: 1, condition: {}, action: { type: "block" } }], + }); + + const supportedUrls = [ + // All schemes that are potentially hooked up to the network are here. + "http://example.com/", + "https://example.com/", + // While host permissions permits more (e.g. file:, moz-extension:), + // we don't list them here since they are not hooked up to the network. + // Trying to match such URLs is undefined behavior for now. + ]; + const supportedInitiators = [ + // Supported URLs are also supported initiators. + ...supportedUrls, + // Note: moz-extension: has more tests in match_initiator_moz_extension. + `moz-extension://${location.host}`, + "file:///tmp/", + // data:-URIs have a null principal. + "data:text/plain,", + ]; + const disallowedUrlsOrInitiators = [ + // about:-URI with system principal: + "about:config", + // Unprivileged about:-URL: + "about:logo", + "chrome://extensions/content/dummy.xhtml", + "resource://pdf.js/web/viewer.html", + // Extensions cannot see "view-source", only the result: bug 1683646. + "view-source:http://example.com/", + "view-source:about:config", + // blob:-URLs do not go through the network. An actual network request + // will never have a blob-URI as initiator, always the actual principal + // URI. We don't try to extract the actual principal from the blob:-URI + // because that is expensive and also performs a validation that the + // blob:-URI is still valid, so testMatchOutcome could then return + // inconsistent results. + URL.createObjectURL(new Blob([])), + ]; + const disallowedUrls = [ + ...disallowedUrlsOrInitiators, + // data:-URIs are not hooked up to the network (bug 1631933), so we do + // not support it in the testMatchOutcome API, even though the URL + // matches "<all_urls>". + "data:text/plain,", + ]; + const disallowedInitiator = [ + ...disallowedUrlsOrInitiators, + // "about:blank" inherits the principal or is null. testMatchOutcome + // does not offer a way to specify it more precisely. + "about:blank", + // This is bogus: A principal URL can never be about:srcdoc. It is + // always inherit from something. + "about:srcdoc", + "moz-extension://someone-elses-extension-here", + ]; + + for (let url of supportedUrls) { + await testMatchesRequest({ url, type }, [1], `Supported url: ${url}`); + } + for (let initiator of supportedInitiators) { + await testMatchesRequest( + { url: "http://example.com/", type, initiator }, + [1], + `Supported initiator: ${initiator}` + ); + } + for (let url of disallowedUrls) { + await testMatchesRequest({ type, url }, [], `Disallowed url: ${url}`); + } + for (let initiator of disallowedInitiator) { + await testMatchesRequest( + { url: "http://example.com/", type, initiator }, + [], + `Disallowed initiator: ${initiator}` + ); + } + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function rule_priority_and_action_type_precedence() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const dnr = browser.declarativeNetRequest; + const { makeDummyRule, makeDummyRequest } = dnrTestUtils; + + await dnr.updateSessionRules({ + addRules: [ + makeDummyRule(1, "allow"), + makeDummyRule(2, "allowAllRequests"), + makeDummyRule(3, "block"), + makeDummyRule(4, "upgradeScheme"), + makeDummyRule(5, "redirect"), + makeDummyRule(6, "modifyHeaders"), + { ...makeDummyRule(7, "modifyHeaders"), priority: 2 }, + { ...makeDummyRule(8, "allow"), priority: 2 }, + { ...makeDummyRule(9, "block"), priority: 2 }, + // Repeat rules so that we can verify that the outcome is due to the + // rule action, instead of the rule ID / input order. + makeDummyRule(11, "allow"), + makeDummyRule(12, "allowAllRequests"), + makeDummyRule(13, "block"), + makeDummyRule(14, "upgradeScheme"), + makeDummyRule(15, "redirect"), + makeDummyRule(16, "modifyHeaders"), + { ...makeDummyRule(17, "modifyHeaders"), priority: 2 }, + ], + }); + async function testAndRemove(ruleId, expectedRuleIds, description) { + browser.test.assertDeepEq( + expectedRuleIds.map(ruleId => ({ ruleId, rulesetId: "_session" })), + (await dnr.testMatchOutcome(makeDummyRequest())).matchedRules, + description + ); + await dnr.updateSessionRules({ removeRuleIds: [ruleId] }); + } + + await testAndRemove(8, [8], "highest-prio allow wins"); + await testAndRemove(9, [9], "highest-prio block wins"); + // after this point, we only have same-prio rules and two higher-prio + // modifyHeaders rules (7 & 17). + + await testAndRemove( + 1, + [1, 7, 17], + "1st allow ignores other rules, except for higher-prio modifyHeaders" + ); + await testAndRemove( + 11, + [11, 7, 17], + "2nd allow ignores other rules, except for higher-prio modifyHeaders" + ); + + await testAndRemove( + 2, + [2, 7, 17], + "1st allowAllRequests ignores other rules, except for higher-prio modifyHeaders" + ); + await testAndRemove( + 12, + [12, 7, 17], + "2nd allowAllRequests ignores other rules, except for higher-prio modifyHeaders" + ); + + await testAndRemove(3, [3], "1st block > all other actions"); + await testAndRemove(13, [13], "2nd block > all other actions"); + + await testAndRemove(4, [4], "1st upgradeScheme > redirect"); + await testAndRemove(14, [14], "2nd upgradeScheme > redirect"); + + await testAndRemove(5, [5], "1st redirect > modifyHeaders"); + await testAndRemove(15, [15], "2nd redirect > modifyHeaders"); + + await testAndRemove( + 6, + [7, 17, 6, 16], + "All modifyHeaders match if there is no other action" + ); + + // Verify that a new rule takes precedence again. + await dnr.updateSessionRules({ + addRules: [makeDummyRule(11, "allow")], + }); + await testAndRemove( + 11, + [11, 7, 17], + "After adding an allow rule, only higher-prio modifyHeaders are shown" + ); + + browser.test.assertDeepEq( + [7, 16, 17], + (await dnr.getSessionRules()).map(r => r.id), + "Remaining rules at end of test" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function declarativeNetRequest_and_host_permissions() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testCanUseAction, testCanMatchAnyBlock } = dnrTestUtils; + + // Unlocked by declarativeNetRequest permission: + await testCanUseAction("allow", true); + await testCanUseAction("allowAllRequests", true); + await testCanUseAction("block", true); + await testCanUseAction("upgradeScheme", true); + // Unlocked by host permissions: + await testCanUseAction("redirect", true); + await testCanUseAction("modifyHeaders", true); + + const url = "https://example.com/"; + await testCanMatchAnyBlock({ + matchedRequests: [ + { url, type: "other" }, + { url, type: "main_frame" }, + { url, type: "sub_frame" }, + { url, initiator: url, type: "other" }, + { url, initiator: url, type: "main_frame" }, + { url, initiator: url, type: "sub_frame" }, + ], + nonMatchedRequests: [], + }); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function declarativeNetRequest_permission_only() { + await runAsDNRExtension({ + manifest: { + host_permissions: [], + }, + background: async dnrTestUtils => { + const { testCanUseAction, testCanMatchAnyBlock } = dnrTestUtils; + + // Unlocked by declarativeNetRequest permission: + await testCanUseAction("allow", true); + await testCanUseAction("allowAllRequests", true); + await testCanUseAction("block", true); + await testCanUseAction("upgradeScheme", true); + // These require host permissions, which we don't have: + await testCanUseAction("redirect", false); + await testCanUseAction("modifyHeaders", false); + + const url = "https://example.com/"; + await testCanMatchAnyBlock({ + matchedRequests: [ + { url, type: "other" }, + { url, type: "main_frame" }, + { url, type: "sub_frame" }, + { url, initiator: url, type: "other" }, + { url, initiator: url, type: "main_frame" }, + { url, initiator: url, type: "sub_frame" }, + ], + nonMatchedRequests: [], + }); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function declarativeNetRequestWithHostAccess_only() { + await runAsDNRExtension({ + manifest: { + permissions: [ + "declarativeNetRequestWithHostAccess", + "declarativeNetRequestFeedback", + ], + host_permissions: [], + }, + background: async dnrTestUtils => { + const { testCanUseAction } = dnrTestUtils; + + // declarativeNetRequestWithHostAccess requires host permissions, + // which we don't have. So none of the rules should match: + await testCanUseAction("allow", false); + await testCanUseAction("allowAllRequests", false); + await testCanUseAction("block", false); + await testCanUseAction("upgradeScheme", false); + await testCanUseAction("redirect", false); + await testCanUseAction("modifyHeaders", false); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function declarativeNetRequestWithHostAccess_and_host_perm() { + await runAsDNRExtension({ + manifest: { + permissions: [ + "declarativeNetRequestWithHostAccess", + "declarativeNetRequestFeedback", + ], + // Origin used by makeDummyRequest() & makeDummyRule(): + host_permissions: ["https://example.com/"], + }, + background: async dnrTestUtils => { + const { testCanUseAction, testCanMatchAnyBlock } = dnrTestUtils; + + // declarativeNetRequestWithHostAccess + host permissions allows all: + await testCanUseAction("allow", true); + await testCanUseAction("allowAllRequests", true); + await testCanUseAction("block", true); + await testCanUseAction("upgradeScheme", true); + await testCanUseAction("redirect", true); + await testCanUseAction("modifyHeaders", true); + + const url = "https://example.com/"; + const urlNoPerm = "https://example.net/?not_in:host_permissions"; + await testCanMatchAnyBlock({ + matchedRequests: [ + { url, type: "other" }, + { url, type: "main_frame" }, + { url, type: "sub_frame" }, + // Navigations do no require host permissions for initiator. + { url, initiator: urlNoPerm, type: "main_frame" }, + { url, initiator: urlNoPerm, type: "sub_frame" }, + ], + nonMatchedRequests: [ + // url always requires declarativeNetRequest or host permissions. + { url: urlNoPerm, type: "other" }, + // Non-navigations require host permissions for initiator. + { url, initiator: urlNoPerm, type: "other" }, + ], + }); + + browser.test.notifyPass(); + }, + }); +}); + +// Tests: resourceTypes, excludedResourceTypes +// Tests: requestMethods, excludedRequestMethods +add_task(async function match_condition_types_and_methods() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const dnr = browser.declarativeNetRequest; + const { makeDummyAction, testMatchesRequest } = dnrTestUtils; + + // "modifyHeaders" is the only action that allows multiple rule matches. + const action = makeDummyAction("modifyHeaders"); + + await dnr.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { + resourceTypes: ["xmlhttprequest"], + requestMethods: ["put"], + }, + action, + }, + { + id: 2, + condition: { + excludedResourceTypes: ["sub_frame"], + excludedRequestMethods: ["post"], + }, + action, + }, + { + id: 3, + condition: { + // resourceTypes not specified should imply all-minus-main_frame. + requestMethods: ["get", "post"], + }, + action, + }, + { + id: 4, + condition: { + resourceTypes: ["main_frame", "xmlhttprequest"], + excludedRequestMethods: ["get"], + }, + action, + }, + ], + }); + + const url = "https://example.com/some-dummy-url"; + await testMatchesRequest( + { url, type: "main_frame" }, + [2], + "main_frame + GET" + ); + + await testMatchesRequest( + { url, type: "xmlhttprequest" }, + [2, 3], + "xmlhttprequest + GET" + ); + + await testMatchesRequest( + { url, type: "xmlhttprequest", method: "put" }, + [1, 2, 4], + "xmlhttprequest + PUT" + ); + + await testMatchesRequest( + { url, type: "sub_frame", method: "post" }, + [3], + "sub_frame + POST" + ); + + await testMatchesRequest( + { url, type: "sub_frame", method: "post" }, + [3], + "sub_frame + POST" + ); + + browser.test.notifyPass(); + }, + }); +}); + +// Tests: requestDomains, excludedRequestDomains +add_task(async function match_request_domains() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const dnr = browser.declarativeNetRequest; + const { makeDummyAction, testMatchesRequest } = dnrTestUtils; + + // "modifyHeaders" is the only action that allows multiple rule matches. + const action = makeDummyAction("modifyHeaders"); + + await dnr.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { + requestDomains: ["a.com", "www.b.com"], + }, + action, + }, + { + id: 2, + condition: { + excludedRequestDomains: ["a.com", "www.b.com", "127.0.0.1"], + }, + action, + }, + { + id: 3, + condition: { + requestDomains: ["one.net"], + excludedRequestDomains: ["sub.one.net"], + }, + action, + }, + { + id: 4, + condition: { + // This can never match. + requestDomains: ["sub.one.net"], + excludedRequestDomains: ["one.net"], + }, + action, + }, + { + id: 5, + condition: { + requestDomains: ["127.0.0.1", "[::1]"], + }, + action, + }, + { + id: 6, + condition: { + requestDomains: [ + "~b.com", // "~" should not be interpreted as pattern negation. + ], + }, + action, + }, + { + id: 7, + condition: { + // A canonical domain does not start with a ".". Domains filters + // starting with a "." are therefore not matching anything. + requestDomains: [".a.com"], + }, + action, + }, + ], + }); + + const type = "sub_frame"; + // Tests related to a.com: + await testMatchesRequest( + { url: "https://a.com:1234/path", type }, + [1], + "a.com: url's domain is equal to a.com" + ); + await testMatchesRequest( + { url: "http://sub.a.com/", type }, + [1], + "sub.a.com: url is subdomain of a.com" + ); + await testMatchesRequest( + { url: "http://nota.com/a.com?a.com#a.com", type }, + [2], + "nota.com: url's domain does not match a.com" + ); + await testMatchesRequest( + { url: "http://a.com.not/a.com?a.com#a.com", type }, + [2], + "a.com.not: url's domain does not match a.com" + ); + await testMatchesRequest( + { url: "http://a.com./a.com?a.com#a.com", type }, + [2], + "a.com.: url's domain (ending with dot) does not match a.com" + ); + + // Tests related to www.b.com: + await testMatchesRequest( + { url: "http://www.b.com/", type }, + [1], + "www.b.com: url's domain is equal to www.b.com" + ); + await testMatchesRequest( + { url: "http://sub.www.b.com", type }, + [1], + "sub.www.b.com: url's domain is a subdomain of www.b.com" + ); + await testMatchesRequest( + { url: "http://b.com/", type }, + [2], + "b.com: url's domain is a superdomain, NOT a subdomain of www.b.com" + ); + + // Tests related to sub.one.net / one.net + await testMatchesRequest( + { url: "http://one.net/", type }, + [2, 3], + "one.net: url's domain matches one.net, but not sub.one.net" + ); + await testMatchesRequest( + { url: "http://sub.one.net/", type }, + [2], // Rule 4 was a candidate, but excluded anyway. + "sub.one.net: url's domain matches sub.one.net, but excluded by one.net" + ); + + // Tests related to IP addresses + await testMatchesRequest( + { url: "http://127.0.0.1:8080/", type }, + [5], + "127.0.0.1: IP address is exact match for 127.0.0.1" + ); + await testMatchesRequest( + { url: "http://8.8.8.8/", type }, + [2], + "8.8.8.8: not matched by any of the domains" + ); + await testMatchesRequest( + { url: "http://[::1]/", type }, + [2, 5], + "[::1]: IPv6 matches with bracket" + ); + + // For completeness, verify that the non-resolving domain "~b.com" + // matches the input, so that we know that "~" was not given special + // treatment. In filter list syntax, "~" before the domain negates the + // meaning, but that should not be supported in DNR. + await testMatchesRequest( + { url: "http://~b.com/", type }, + [2, 6], + "~b.com: Although a non-resolving domain, it matches the pattern" + ); + + // match_initiator_domains has more tests; here we just confirm that + // requestDomains rules don't match initiator. + await testMatchesRequest( + { url: "http://url.does.not.match/", type, initiator: "http://a.com/" }, + [2], + "requestDomains should not match initiator URL" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function match_request_domains_punycode() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const dnr = browser.declarativeNetRequest; + const { makeDummyAction, testMatchesRequest } = dnrTestUtils; + + // "modifyHeaders" is the only action that allows multiple rule matches. + const action = makeDummyAction("modifyHeaders"); + + // Note that the non-punycode domains are rejected by schema validation, + // and checked by test validate_domains in test_ext_dnr_session_rules.js. + + await dnr.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { + // straß.de + requestDomains: ["xn--stra-yna.de"], + }, + action, + }, + { + id: 2, + condition: { + // IDNA2003 converted ß to ss. But IDNA2008 requires punycode. + requestDomains: ["strass.de", "stras.de"], + }, + action, + }, + ], + }); + + const type = "sub_frame"; + + await testMatchesRequest( + { url: "https://straß.de/", type }, + [1], + "straß.de matches" + ); + await testMatchesRequest( + { url: "https://xn--stra-yna.de/", type }, + [1], + "xn--stra-yna.de matches" + ); + await testMatchesRequest( + { url: "https://strass.de/", type }, + [2], + "strass.de does not match the punycode pattern of straß" + ); + await testMatchesRequest( + { url: "https://stras.de/", type }, + [2], + "stras.de does not match the punycode pattern of straß" + ); + + browser.test.notifyPass(); + }, + }); +}); + +// Tests: initiatorDomains, excludedInitiatorDomains +// More tests in: match_initiator_moz_extension. +add_task(async function match_initiator_domains() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const dnr = browser.declarativeNetRequest; + const { makeDummyAction, testMatchesRequest } = dnrTestUtils; + + // "modifyHeaders" is the only action that allows multiple rule matches. + const action = makeDummyAction("modifyHeaders"); + + // The validation of initiatorDomains and requestDomains are shared. + // The match_request_domains and match_request_domains_punycode tests + // already verify semantics; this test just tests that the conditional + // logic works as expected, plus coverage for initiator being void. + + await dnr.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { + initiatorDomains: ["a.com"], + }, + action, + }, + { + id: 2, + condition: { + excludedInitiatorDomains: ["a.com"], + }, + action, + }, + { + id: 3, + condition: { + initiatorDomains: ["c.com"], + excludedInitiatorDomains: ["c.com"], + }, + action, + }, + { + id: 4, // To verify that it does not match a void initiator. + condition: { + initiatorDomains: ["null"], + }, + action, + }, + { + id: 5, + condition: { + excludedInitiatorDomains: ["null", "undefined"], + }, + action, + }, + { + id: 6, // To verify that it does not match a void initiator. + condition: { + initiatorDomains: ["undefined"], + }, + action, + }, + ], + }); + + const url = "https://do.not.look.here/look_at_initator_instead"; + const type = "image"; + await testMatchesRequest( + { url, type, initiator: "http://a.com/" }, + [1, 5], + "initiatorDomains matches" + ); + await testMatchesRequest( + { url, type, initiator: "http://b.com/" }, + [2, 5], + "excludedInitiatorDomains does not match, so request matched" + ); + await testMatchesRequest( + { url, type, initiator: "http://c.com/" }, + [2, 5], // 3 is not here, despite containing "c.com". + "excludedInitiatorDomains takes precedence over initiatorDomains" + ); + // When initiator is not specified, rules with initiatorDomains should not + // match, and rules with excludedInitiatorDomains may match. + await testMatchesRequest( + { url, type }, + [2, 5], + "request without initiator matches every excludedInitiatorDomains" + ); + // http://null is unlikely to exist in practice. Regardless, verify that + // it won't match a void initiators. + await testMatchesRequest( + { url, type, initiator: "http://null/" }, + [2, 4], + "http://null is matched by the 'null' domain" + ); + await testMatchesRequest( + { url, type, initiator: "http://undefined/" }, + [2, 6], + "http://null is matched by the 'undefined' domain" + ); + await testMatchesRequest( + { url: "http://a.com/", type }, + [2, 5], + "initiatorDomains should not match the request URL (initiator=null)" + ); + + browser.test.notifyPass(); + }, + }); +}); + +// Tests: initiatorDomains, excludedInitiatorDomains with moz-extension:-URLs. +add_task(async function match_initiator_moz_extension() { + let extension = await runAsDNRExtension({ + manifest: { browser_specific_settings: { gecko: { id: "other@ext" } } }, + background: async dnrTestUtils => { + const dnr = browser.declarativeNetRequest; + const { makeDummyAction, testMatchesRequest } = dnrTestUtils; + + // "modifyHeaders" is the only action that allows multiple rule matches. + // But we cannot use "modifyHeaders" because that feature depends on + // access to "triggering principal". Fortunately, the two test rules in + // this test case are mutually exclusive, so the block action works. + // TODO bug 1825824: change to makeDummyAction("modifyHeaders"). + const action = makeDummyAction("block"); + + const thisExtensionUUID = location.hostname; + await dnr.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { + initiatorDomains: [thisExtensionUUID], + }, + action, + }, + { + id: 2, + condition: { + excludedInitiatorDomains: [thisExtensionUUID], + }, + action, + }, + ], + }); + + const url = "https://do.not.look.here/look_at_initator_instead"; + const type = "other"; + // Sanity check with non-moz-extension:-schemes as initiator. + await testMatchesRequest( + { url, type, initiator: `https://${thisExtensionUUID}/` }, + [1], + "https:+UUID matches initiatorDomains" + ); + await testMatchesRequest( + { url, type, initiator: "https://random-uuid-here/" }, + [2], + "https:+UUID matches excludedInitiatorDomains" + ); + // Now test with moz-extension: as initiator. + await testMatchesRequest( + { url, type, initiator: location.origin }, + [1], + "moz-extension: initiator matches when it should" + ); + await testMatchesRequest( + { url, type, initiator: `moz-extension://random-uuid-here/` }, + [], + "moz-extension: from unrelated extension cannot match by default" + ); + + browser.test.onMessage.addListener(async msg => { + browser.test.assertEq("test_with_pref", msg, "expected msg"); + await testMatchesRequest( + { url, type, initiator: `moz-extension://random-uuid-here/` }, + [2], + "With pref, moz-extension: from unrelated extension can match" + ); + browser.test.sendMessage("test_with_pref:done"); + }); + + // Notify to continue. We don't exit yet due to unloadTestAtEnd:false + browser.test.notifyPass(); + }, + // Continue running the DNR extension because we want to test the current + // DNR rules with other extensions. + unloadTestAtEnd: false, + }); + + info("Testing foreign moz-extension request within same ext, with pref on"); + await runWithPrefs( + [["extensions.dnr.match_requests_from_other_extensions", true]], + async () => { + extension.sendMessage("test_with_pref"); + await extension.awaitMessage("test_with_pref:done"); + } + ); + + const otherExtensionUUID = extension.uuid; + + await runAsDNRExtension({ + manifest: { + // Pass the DNR extension UUID to this extension. + description: otherExtensionUUID, + }, + background: async () => { + const otherExtensionUUID = browser.runtime.getManifest().description; + const dnr = browser.declarativeNetRequest; + + const url = "https://do.not.look.here/look_at_initator_instead"; + const type = "other"; + + browser.test.assertDeepEq( + { matchedRules: [] }, + await dnr.testMatchOutcome({ url, type, initiator: location.origin }), + "testMatchOutcome excludes other extensions by default" + ); + browser.test.assertDeepEq( + { matchedRules: [] }, + await dnr.testMatchOutcome( + { url, type, initiator: location.origin }, + { includeOtherExtensions: true } + ), + "No matches when initiator is moz-extension:, different from DNR ext" + ); + browser.test.assertDeepEq( + { + matchedRules: [ + { ruleId: 1, rulesetId: "_session", extensionId: "other@ext" }, + ], + }, + await dnr.testMatchOutcome( + { url, type, initiator: `moz-extension://${otherExtensionUUID}` }, + { includeOtherExtensions: true } + ), + "Simulated moz-extension: for original extension finds a match" + ); + + browser.test.notifyPass(); + }, + }); + + info("Testing foreign moz-extension request in other ext, with pref on"); + await runWithPrefs( + [["extensions.dnr.match_requests_from_other_extensions", true]], + async () => { + await runAsDNRExtension({ + manifest: { + // Pass the DNR extension UUID to this extension. + description: otherExtensionUUID, + }, + background: async () => { + const otherExtensionUUID = browser.runtime.getManifest().description; + const dnr = browser.declarativeNetRequest; + + const url = "https://do.not.look.here/look_at_initator_instead"; + const type = "other"; + + // Sanity check: testMatchOutcome for moz-extension:-URL different + // from the DNR extension and the current test extension. + browser.test.assertDeepEq( + { + matchedRules: [ + { ruleId: 2, rulesetId: "_session", extensionId: "other@ext" }, + ], + }, + await dnr.testMatchOutcome( + { url, type, initiator: "moz-extension://random-uuid-here/" }, + { includeOtherExtensions: true } + ), + "With pref, moz-extension: from unrelated extensions can match" + ); + + // Usually, DNR does not affect requests from other extensions. That + // was checked in the previous test extension (without pref override). + // Here, we check that with the pref override, testMatchOutcome can + // return matches from other extensions for the given extension UUID. + browser.test.assertDeepEq( + { + matchedRules: [ + { ruleId: 2, rulesetId: "_session", extensionId: "other@ext" }, + ], + }, + await dnr.testMatchOutcome( + { url, type, initiator: location.origin }, + { includeOtherExtensions: true } + ), + "With pref, moz-extension:-initiator different from DNR ext matches" + ); + + // Identical test as in the previous test extension (that ran without + // the pref override). This verifies that the pref does not affect the + // behavior of request matching for requests within that extension. + browser.test.assertDeepEq( + { + matchedRules: [ + { ruleId: 1, rulesetId: "_session", extensionId: "other@ext" }, + ], + }, + await dnr.testMatchOutcome( + { url, type, initiator: `moz-extension://${otherExtensionUUID}` }, + { includeOtherExtensions: true } + ), + "With pref, moz-extension: for DNR ext still matches" + ); + + browser.test.notifyPass(); + }, + }); + } + ); + + await extension.unload(); +}); + +// Tests: urlFilter. For more comprehensive tests, see +// toolkit/components/extensions/test/xpcshell/test_ext_dnr_urlFilter.js +add_task(async function match_urlFilter() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const dnr = browser.declarativeNetRequest; + const { makeDummyAction, testMatchesRequest } = dnrTestUtils; + + // "modifyHeaders" is the only action that allows multiple rule matches. + const action = makeDummyAction("modifyHeaders"); + + await dnr.updateSessionRules({ + addRules: [ + // Some patterns that match literally everything: + { id: 1, condition: { urlFilter: "." }, action }, + { id: 2, condition: { urlFilter: "^" }, action }, + { id: 3, condition: { urlFilter: "|" }, action }, + // Patterns that match the test URLs + { id: 4, condition: { urlFilter: "https://example.com" }, action }, + { + // urlFilter matches, requestDomains matches. + id: 5, + condition: { urlFilter: "*", requestDomains: ["example.com"] }, + action, + }, + { + // urlFilter matches, requestDomains does not match. + id: 6, + condition: { urlFilter: "*", requestDomains: ["notexample.com"] }, + action, + }, + { + // urlFilter does not match, requestDomains matches. + id: 7, + condition: { urlFilter: "notm", requestDomains: ["example.com"] }, + action, + }, + ], + }); + + await testMatchesRequest( + { url: "https://example.com/file.txt", type: "font" }, + [1, 2, 3, 4, 5], + "urlFilter should match when needed, and correctly with requestDomains" + ); + + browser.test.notifyPass(); + }, + }); +}); + +// Tests: regexFilter. For more comprehensive tests, see +// toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter.js +add_task(async function match_regexFilter() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const dnr = browser.declarativeNetRequest; + const { makeDummyAction, testMatchesRequest } = dnrTestUtils; + + // "modifyHeaders" is the only action that allows multiple rule matches. + const action = makeDummyAction("modifyHeaders"); + + await dnr.updateSessionRules({ + addRules: [ + // Some patterns that match literally everything: + { id: 1, condition: { regexFilter: ".*" }, action }, + { id: 2, condition: { regexFilter: "^" }, action }, + // Patterns that match the test URLs + { id: 3, condition: { regexFilter: "https://.xample\\." }, action }, + { id: 4, condition: { regexFilter: "https://example.com" }, action }, + { + // regexFilter matches, requestDomains matches. + id: 5, + condition: { regexFilter: "$", requestDomains: ["example.com"] }, + action, + }, + { + // regexFilter matches, requestDomains does not match. + id: 6, + condition: { regexFilter: "$", requestDomains: ["notexample.com"] }, + action, + }, + { + // regexFilter does not match, requestDomains matches. + id: 7, + condition: { regexFilter: "notm", requestDomains: ["example.com"] }, + action, + }, + ], + }); + + await testMatchesRequest( + { url: "https://example.com/file.txt", type: "font" }, + [1, 2, 3, 4, 5], + "regexFilter should match when needed, and correctly with requestDomains" + ); + + browser.test.notifyPass(); + }, + }); +}); + +// Tests: tabIds, excludedTabIds +add_task(async function match_tabIds() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const dnr = browser.declarativeNetRequest; + const { makeDummyAction, testMatchesRequest } = dnrTestUtils; + + // "modifyHeaders" is the only action that allows multiple rule matches. + const action = makeDummyAction("modifyHeaders"); + + await dnr.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { + excludedTabIds: [-1, Number.MAX_SAFE_INTEGER], + }, + action, + }, + { + id: 2, + condition: { + tabIds: [1, Number.MAX_SAFE_INTEGER], + }, + action, + }, + { + id: 3, + condition: { + tabIds: [-1], + }, + action, + }, + ], + }); + + const url = "https://example.com/some-dummy-url"; + const type = "font"; + await testMatchesRequest({ url, type }, [3], "tabId defaults to -1"); + await testMatchesRequest({ url, type, tabId: -1 }, [3], "tabId -1"); + await testMatchesRequest({ url, type, tabId: 1 }, [1, 2], "tabId 1"); + await testMatchesRequest( + { + url, + type, + tabId: Number.MAX_SAFE_INTEGER, + }, + [2], + `tabId high number (MAX_SAFE_INTEGER=${Number.MAX_SAFE_INTEGER})` + ); + + // tabId -2 is invalid and not encountered in practice, but technically + // it matches the first rule. + await testMatchesRequest({ url, type, tabId: -2 }, [1], "bad tabId -2"); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function action_precedence_between_extensions() { + // This test is structured as follows: + // - otherExtension registers rules for several numeric conditions (tabId). + // - otherExtensionNonBlockAndModifyHeaders adds allowAllRequests and + // modifyHeaders to all requests. + // - otherExtensionModifyHeaders adds modifyHeaders rules to all requests. + // - the main test extension also registers rules, and then simulates requests + // with testMatchOutcome for each tabId, and checks the result. + + let otherExtension = await runAsDNRExtension({ + manifest: { browser_specific_settings: { gecko: { id: "other@ext" } } }, + background: async dnrTestUtils => { + const { makeDummyAction } = dnrTestUtils; + + // Dummy condition for testing requests in this test. + const c = tabId => ({ resourceTypes: ["main_frame"], tabIds: [tabId] }); + + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { id: 11, condition: c(1), action: makeDummyAction("allow") }, + { id: 12, condition: c(2), action: makeDummyAction("block") }, + { id: 13, condition: c(3), action: makeDummyAction("redirect") }, + { id: 14, condition: c(4), action: makeDummyAction("upgradeScheme") }, + { + id: 15, + condition: c(5), + action: makeDummyAction("allowAllRequests"), + }, + { + id: 16, + condition: c(6), + action: makeDummyAction("allowAllRequests"), + }, + ], + }); + // Notify to continue. We don't exit yet due to unloadTestAtEnd:false + browser.test.notifyPass(); + }, + unloadTestAtEnd: false, + }); + + let otherExtensionNonBlockAndModifyHeaders = await runAsDNRExtension({ + manifest: { browser_specific_settings: { gecko: { id: "other@ext2" } } }, + background: async dnrTestUtils => { + const { makeDummyAction } = dnrTestUtils; + + // Matches all requests from this test. + const condition = { resourceTypes: ["main_frame"] }; + + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1000, + condition, + action: makeDummyAction("modifyHeaders"), + // Same-or-lower priority "modifyHeaders" actions are ignored when + // an "allowAllRequests" action exists within the same extension. + // Since we have such a rule (ID 1001), this modifyHeaders rule must + // have "priority: 2" to avoid being ignored. + priority: 2, + }, + { id: 1001, condition, action: makeDummyAction("allowAllRequests") }, + { + id: 1002, + condition, + action: makeDummyAction("modifyHeaders"), + priority: 2, // necessary as explained above at rule ID 1000. + }, + // should never appear because the first allowAllRequests rule should + // take precedence: + { id: 1003, condition, action: makeDummyAction("allowAllRequests") }, + ], + }); + + // Notify to continue. We don't exit yet due to unloadTestAtEnd:false + browser.test.notifyPass(); + }, + unloadTestAtEnd: false, + }); + + // |otherExtensionModifyHeaders| and |otherExtensionNonBlockAndModifyHeaders| + // both have "modifyHeaders" rules. The documented order of rules is for + // the most recently installed extension to take precedence when applying + // modifyHeaders actions. The "priority" key is extension-specific, so even + // though |otherExtensionNonBlockAndModifyHeaders| defines "priority: 2" for + // modifyHeaders action (ID 1001), the modifyHeaders below (ID 1337) takes + // precedence because the extension was installed later. + let otherExtensionModifyHeaders = await runAsDNRExtension({ + manifest: { browser_specific_settings: { gecko: { id: "other@ext3" } } }, + background: async dnrTestUtils => { + const { makeDummyAction } = dnrTestUtils; + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1337, + // Matches all requests from this test. + condition: { resourceTypes: ["main_frame"] }, + action: makeDummyAction("modifyHeaders"), + // Note: no "priority" key set, so defaults to 1. + }, + ], + }); + // Notify to continue. We don't exit yet due to unloadTestAtEnd:false + browser.test.notifyPass(); + }, + unloadTestAtEnd: false, + }); + + await runAsDNRExtension({ + background: async dnrTestUtils => { + const dnr = browser.declarativeNetRequest; + const { makeDummyAction } = dnrTestUtils; + + // Dummy condition for testing requests in this test. + const c = tabId => ({ resourceTypes: ["main_frame"], tabIds: [tabId] }); + + await dnr.updateSessionRules({ + addRules: [ + { id: 91, condition: c(1), action: makeDummyAction("block") }, + { id: 92, condition: c(2), action: makeDummyAction("allow") }, + { id: 93, condition: c(3), action: makeDummyAction("block") }, + { id: 94, condition: c(4), action: makeDummyAction("block") }, + { id: 95, condition: c(5), action: makeDummyAction("allow") }, + { + id: 96, + condition: c(6), + action: makeDummyAction("allowAllRequests"), + }, + ], + }); + + const url = "https://example.com/dummy-url"; + const type = "main_frame"; + const options = { includeOtherExtensions: true }; + browser.test.assertDeepEq( + [{ ruleId: 91, rulesetId: "_session" }], + (await dnr.testMatchOutcome({ url, type, tabId: 1 }, options)) + .matchedRules, + "block takes precedence over allow (from other extension)" + ); + + browser.test.assertDeepEq( + [{ ruleId: 12, rulesetId: "_session", extensionId: "other@ext" }], + (await dnr.testMatchOutcome({ url, type, tabId: 2 }, options)) + .matchedRules, + "block (from other extension) takes precedence over allow" + ); + browser.test.assertDeepEq( + [{ ruleId: 93, rulesetId: "_session" }], + (await dnr.testMatchOutcome({ url, type, tabId: 3 }, options)) + .matchedRules, + "block takes precedence over redirect (from other extension)" + ); + browser.test.assertDeepEq( + [{ ruleId: 94, rulesetId: "_session" }], + (await dnr.testMatchOutcome({ url, type, tabId: 4 }, options)) + .matchedRules, + "block takes precedence over upgradeScheme (from other extension)" + ); + browser.test.assertDeepEq( + [ + // allow: + { ruleId: 95, rulesetId: "_session" }, + // allowAllRequests (newest install first): + { ruleId: 1001, rulesetId: "_session", extensionId: "other@ext2" }, + { ruleId: 15, rulesetId: "_session", extensionId: "other@ext" }, + // modifyHeaders (see comment at otherExtensionModifyHeaders): + { ruleId: 1337, rulesetId: "_session", extensionId: "other@ext3" }, + { ruleId: 1000, rulesetId: "_session", extensionId: "other@ext2" }, + { ruleId: 1002, rulesetId: "_session", extensionId: "other@ext2" }, + ], + (await dnr.testMatchOutcome({ url, type, tabId: 5 }, options)) + .matchedRules, + "When allow matches, allowAllRequests from other extension matches too" + ); + browser.test.assertDeepEq( + [ + // allowAllRequests (newest install first): + { ruleId: 96, rulesetId: "_session" }, + { ruleId: 1001, rulesetId: "_session", extensionId: "other@ext2" }, + { ruleId: 16, rulesetId: "_session", extensionId: "other@ext" }, + // modifyHeaders (see comment at otherExtensionModifyHeaders): + { ruleId: 1337, rulesetId: "_session", extensionId: "other@ext3" }, + { ruleId: 1000, rulesetId: "_session", extensionId: "other@ext2" }, + { ruleId: 1002, rulesetId: "_session", extensionId: "other@ext2" }, + ], + (await dnr.testMatchOutcome({ url, type, tabId: 6 }, options)) + .matchedRules, + "allowAllRequests from all other extensions are matched" + ); + + browser.test.notifyPass(); + }, + }); + + await otherExtension.unload(); + await otherExtensionNonBlockAndModifyHeaders.unload(); + await otherExtensionModifyHeaders.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_urlFilter.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_urlFilter.js new file mode 100644 index 0000000000..dd12184cbe --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_urlFilter.js @@ -0,0 +1,1159 @@ +"use strict"; + +add_setup(() => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.feedback", true); +}); + +// This function is serialized and called in the context of the test extension's +// background page. dnrTestUtils is passed to the background function. +function makeDnrTestUtils() { + const dnrTestUtils = {}; + const dnr = browser.declarativeNetRequest; + + const DUMMY_ACTION = { + // "modifyHeaders" is the only action that allows multiple rule matches. + type: "modifyHeaders", + responseHeaders: [{ operation: "append", header: "x", value: "y" }], + }; + async function testMatchesRequest(request, ruleIds, description) { + browser.test.assertDeepEq( + ruleIds, + (await dnr.testMatchOutcome(request)).matchedRules.map(mr => mr.ruleId), + description + ); + } + async function testMatchesUrlFilter({ + urlFilter, + isUrlFilterCaseSensitive, + urls = [], + urlsNonMatching = [], + }) { + // Sanity check: verify that there are no unexpected escaped characters, + // because that can surprise. + function sanityCheckUrl(url) { + const normalizedUrl = new URL(url).href; + if (normalizedUrl.split("%").length !== url.split("*").length) { + // ^ we only check for %-escapes and not exact URL equality because the + // tests imported from Chrome often omit the "/" (path separator). + browser.test.assertEq(normalizedUrl, url, "url should be canonical"); + } + } + + await dnr.updateSessionRules({ + addRules: [ + { + id: 12345, + condition: { urlFilter, isUrlFilterCaseSensitive }, + action: DUMMY_ACTION, + }, + ], + }); + for (let url of urls) { + sanityCheckUrl(url); + const request = { url, type: "other" }; + const description = `urlFilter ${urlFilter} should match: ${url}`; + await testMatchesRequest(request, [12345], description); + } + for (let url of urlsNonMatching) { + sanityCheckUrl(url); + const request = { url, type: "other" }; + const description = `urlFilter ${urlFilter} should not match: ${url}`; + await testMatchesRequest(request, [], description); + } + await dnr.updateSessionRules({ removeRuleIds: [12345] }); + } + Object.assign(dnrTestUtils, { + DUMMY_ACTION, + testMatchesRequest, + testMatchesUrlFilter, + }); + return dnrTestUtils; +} + +async function runAsDNRExtension({ background, manifest }) { + let extension = ExtensionTestUtils.loadExtension({ + background: `(${background})((${makeDnrTestUtils})())`, + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"], + // While testing urlFilter itself does not require any host permissions, + // we are asking for host permissions anyway because the "modifyHeaders" + // action requires host permissions, and we use the "modifyHeaders" action + // to ensure that we can detect when multiple rules match. + host_permissions: ["<all_urls>"], + granted_host_permissions: true, + ...manifest, + }, + temporarilyInstalled: true, // <-- for granted_host_permissions + }); + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +} + +// This test checks various urlFilters with a possibly ambiguous interpretation. +// In some cases the semantic difference in interpretation can have different +// outcomes; in these cases we have chosen the behavior as observed in Chrome. +add_task(async function ambiguous_urlFilter_patterns() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testMatchesUrlFilter } = dnrTestUtils; + + // Left anchor, empty pattern: always matches + // Ambiguous with Right anchor, but same result. + await testMatchesUrlFilter({ + urlFilter: "|", + urls: ["http://a/"], + urlsNonMatching: [], + }); + + // Domain anchor, empty pattern: always matches. + // Ambiguous with Left anchor + Right anchor, the latter would not match + // anything (only an empty string, but URLs cannot be empty). + await testMatchesUrlFilter({ + urlFilter: "||", + urls: ["http://a/"], + urlsNonMatching: [], + }); + + // Domain anchor plus Right separator: never matches. + // Ambiguous with Left anchor + | + Right anchor, that is no match either. + await testMatchesUrlFilter({ + urlFilter: "|||", + urls: [], + urlsNonMatching: ["http://a./|||"], + }); + + // Repeated separator: ^^^^ matches separator chars (=everything except + // alphanumeric, "_", "-", ".", "%"), but when at the end of a string, + // the last "^" can also be interpreted as a right anchor (like ^^^|). + // Ambiguous: while "^" is defined to match the end of URL, it could also + // be interpreted as "^^^^" matching the end of URL 4x, i.e. always. + await testMatchesUrlFilter({ + urlFilter: "^^^^", + urls: [ + // Note: "^" is escaped "%5E" when part of the URL, except after "#". + "http://a/#frag^^^^", // four ^ characters ("^^^^"). + "http://a/#frag^^^", // three ^ characters ("^^^") + end of URL. + "http://a/?&#", // four separator characters ("/?&#"); + "http://a/#^", // three separator characters ("/??") + end of URL. + // ^ Note that "^" is after "#" and therefore not %5E. If "^" were to + // somehow be %-encoded to "%5E", then the end would become "/#%5E" + // and the "/#%" would only be 3 separators followed by alphanum. The + // test matching shows that the canonical representation of "^" after + // a "#" is "^" and can be matched. + ], + urlsNonMatching: [ + "http://a/?", // Just two separator + end of URL, not matching 4x "^". + "http://a/____", // _ is specified to not match ^. + "http://a/----", // - is specified to not match ^. + "http://a/....", // . is specified to not match ^. + ], + }); + // Not ambiguous, but for comparison with "^^^^": all http(s) match. + await testMatchesUrlFilter({ + urlFilter: "^^^", + urls: ["https://a/"], // "://" always matches "^^^". + // Not seen by DNR in practice, but could be passed to testMatchOutcome: + urlsNonMatching: ["file:hello/no/three/consecutive/special/characters"], + }); + + // Separator plus Right anchor: always matches. + // Ambiguous: "^" is defined to match the end of URL once, but a right + // domain anchor already matches that. A potential interpretation is for + // "^" to be required to match a non-alphanumeric (etc.), but in practice + // "^" is allowed to match the end of the URL. Effectively "^|" = "|". + await testMatchesUrlFilter({ + urlFilter: "^|", + urls: [ + "http://a/", // "/" matches "^". + "http://a/a", // "a" does not match "^", but "^" matches the end. + ], + urlsNonMatching: [], + }); + + // Domain anchor plus separator: "^" only matches non-alphanum (etc.) + // Ambiguous: "||" is defined to match a domain anchor. There is no + // domain part after the trailing "." of a FQDN. Still, "." matches. + await testMatchesUrlFilter({ + urlFilter: "||^", + urls: ["http://a./"], // FQDN: "/" after "." matches "^". + urlsNonMatching: ["http://a/", "http://a/||"], + }); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function urlFilter_domain_anchor() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testMatchesUrlFilter } = dnrTestUtils; + + await testMatchesUrlFilter({ + // Not a domain anchor, but for comparison with "||ps" below: + urlFilter: "ps", + urls: [ + "https://example.com/", // ps in scheme. + "http://ps.example.com/", // ps at start of domain. + "http://sub.ps.example.com/", // ps at superdomain. + "http://ps/", // ps as sole host. + "http://example-ps.com/", // ps in middle of domain. + "http://ps@example.com/", // ps as user without password. + "http://user:ps@example.com/", // ps in password. + "http://ps:pass@example.com/", // ps in user. + "http://example.com/ps", // ps at end. + "http://example.com/#ps", // ps in fragment. + ], + urlsNonMatching: [ + "http://example.com/", // no ps anywhere. + ], + }); + + await testMatchesUrlFilter({ + urlFilter: "||ps", + urls: [ + "http://ps.example.com/", // ps at start of domain. + "http://sub.ps.example.com/", // ps at superdomain. + "http://ps/", // ps as sole host. + ], + urlsNonMatching: [ + "http://example.com/", // no ps anywhere. + "https://example.com/", // ps in scheme. + "http://example-ps.com/", // ps in middle + "http://ps@example.com/", // ps as user without password. + "http://user:ps@example.com/", // ps in password. + "http://ps:pass@example.com/", // ps in user. + "http://example.com/ps", // ps at end. + ], + }); + + await testMatchesUrlFilter({ + urlFilter: "||1", + urls: [ + "http://127.0.0.1/", + "http://2.0.0.1/", + "http://www.1example.com/", + ], + urlsNonMatching: [ + "http://[::1]/", + "http://[1::1]/", + "http://hostwithport:1/", + "http://host/1", + "http://fqdn.:1/", + "http://fqdn./1", + ], + }); + + await testMatchesUrlFilter({ + urlFilter: "||^1", + urls: [ + "http://[1::1]/", // "[1" at start matches "^1". + "http://fqdn.:1/", // ":1" matches "^1" and is after a ".". + "http://fqdn./1", // "/1" matches "^1" and is after a ".". + ], + urlsNonMatching: [ + "http://127.0.0.1/", + "http://2.0.0.1/", + "http://www.1example.com/", + "http://[::1]/", + "http://hostwithport:1/", + "http://host/1", + ], + }); + + browser.test.notifyPass(); + }, + }); +}); + +// Extreme patterns that should not be used in practice, but are not explicitly +// documented to be disallowed. +add_task( + // Stuck in ccov: https://bugzilla.mozilla.org/show_bug.cgi?id=1806494#c4 + { skip_if: () => mozinfo.ccov }, + async function extreme_urlFilter_patterns() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testMatchesRequest, DUMMY_ACTION } = dnrTestUtils; + + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { + urlFilter: "*".repeat(1e6), + }, + action: DUMMY_ACTION, + }, + { + id: 2, + condition: { + urlFilter: "^".repeat(1e6), + }, + action: DUMMY_ACTION, + }, + { + id: 3, + condition: { + // Note: 2 chars repeat 5e5 instead of 1e6 because newURI limits + // the length of the URL (to network.standard-url.max-length), + // so we would not be able to verify whether the URL is really + // that long. + urlFilter: "*^".repeat(5e5), + }, + action: DUMMY_ACTION, + }, + { + id: 4, + condition: { + // Note: well beyond the maximum length of a URL. But as "*" can + // match any char (including zero length), this still matches. + urlFilter: "h" + "*".repeat(1e7) + "endofurl", + }, + action: DUMMY_ACTION, + }, + ], + }); + + await testMatchesRequest( + { url: "http://example.com/", type: "other" }, + [1], + "urlFilter with 1M wildcard chars matches any URL" + ); + + await testMatchesRequest( + { url: "http://example.com/" + "x".repeat(1e6), type: "other" }, + [1], + "urlFilter with 1M wildcards matches, other '^' do not match alpha" + ); + + await testMatchesRequest( + { url: "http://example.com/" + "/".repeat(1e6), type: "other" }, + [1, 2, 3], + "urlFilter with 1M wildcards, ^ and *^ all match URL with 1M '/' chars" + ); + + await testMatchesRequest( + { url: "http://example.com/" + "x/".repeat(5e5), type: "other" }, + [1, 3], + "urlFilter with 1M wildcards and *^ match URL with 1M 'x/' chars" + ); + + await testMatchesRequest( + { url: "http://example.com/endofurl", type: "other" }, + [1, 4], + "urlFilter with 1M and 10M wildcards matches URL" + ); + + browser.test.notifyPass(); + }, + }); + } +); + +add_task(async function test_isUrlFilterCaseSensitive() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testMatchesUrlFilter } = dnrTestUtils; + + await testMatchesUrlFilter({ + urlFilter: "AbC", + isUrlFilterCaseSensitive: true, + urls: [ + "http://true.example.com/AbC", // Exact match. + ], + urlsNonMatching: [ + "http://true.example.com/abc", // All lower. + "http://true.example.com/ABC", // All upper. + "http://true.example.com/???", // ABC not present at all. + "http://true.AbC/", // When canonicalized, the host is lower case. + ], + }); + await testMatchesUrlFilter({ + urlFilter: "AbC", + isUrlFilterCaseSensitive: false, + urls: [ + "http://false.example.com/AbC", // Exact match. + "http://false.example.com/abc", // All lower. + "http://false.example.com/ABC", // All upper. + "http://false.AbC/", // When canonicalized, the host is lower case. + ], + urlsNonMatching: [ + "http://false.example.com/???", // ABC not present at all. + ], + }); + + // Chrome's initial DNR API specified isUrlFilterCaseSensitive to be true + // by default. Later, it became false by default. + // https://github.com/w3c/webextensions/issues/269 + await testMatchesUrlFilter({ + urlFilter: "AbC", + // isUrlFilterCaseSensitive: false, // is implied by default. + urls: [ + "http://default.example.com/AbC", // Exact match. + "http://default.example.com/abc", // All lower. + "http://default.example.com/ABC", // All upper. + "http://default.AbC/", // When canonicalized, the host is lower case. + ], + urlsNonMatching: [ + "http://default.example.com/???", // ABC not present at all. + ], + }); + + browser.test.notifyPass(); + }, + }); +}); + +// Imported tests from Chromium from: +// https://chromium.googlesource.com/chromium/src.git/+/refs/tags/110.0.5442.0/components/url_pattern_index/url_pattern_unittest.cc +// kAnchorNone -> "" (anywhere in the string) +// kBoundary -> | (start or end of string) +// kSubdomain -> || (start of (sub)domain) +// kMatchCase -> isUrlFilterCaseSensitive: true +// kDonotMatchCase -> isUrlFilterCaseSensitive: false (this is the default). +// proto::URL_PATTERN_TYPE_WILDCARDED / proto::URL_PATTERN_TYPE_SUBSTRING -> "" +// +// Minus two tests ("", kBoundary, kBoundary) because the resulting pattern is +// "||" and ambiguous with ("", kSubdomain, ""). +add_task(async function test_chrome_parity() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testMatchesUrlFilter } = dnrTestUtils; + const testCases = [ + // {"", proto::URL_PATTERN_TYPE_SUBSTRING} + { + urlFilter: "*", + url: "http://ex.com/", + expectMatch: true, + }, + // // {"", proto::URL_PATTERN_TYPE_WILDCARDED} + // { // Already tested before. + // urlFilter: "*", + // url: "http://ex.com/", + // expectMatch: true, + // }, + // {"", kBoundary, kAnchorNone} + { + urlFilter: "|", + url: "http://ex.com/", + expectMatch: true, + }, + // {"", kSubdomain, kAnchorNone} + { + urlFilter: "||", + url: "http://ex.com/", + expectMatch: true, + }, + // // {"", kSubdomain, kAnchorNone} + // { // Already tested before. + // urlFilter: "||", + // url: "http://ex.com/", + // expectMatch: true, + // }, + // {"^", kSubdomain, kAnchorNone} + { + urlFilter: "||^", + url: "http://ex.com/", + expectMatch: false, + }, + // {".", kSubdomain, kAnchorNone} + { + urlFilter: "||.", + url: "http://ex.com/", + expectMatch: false, + }, + // // {"", kAnchorNone, kBoundary} + // { // Already tested before. + // urlFilter: "|", + // url: "http://ex.com/", + // expectMatch: true, + // }, + // {"^", kAnchorNone, kBoundary} + { + urlFilter: "^|", + url: "http://ex.com/", + expectMatch: true, + }, + // {".", kAnchorNone, kBoundary} + { + urlFilter: ".|", + url: "http://ex.com/", + expectMatch: false, + }, + // // {"", kBoundary, kBoundary} + // { // "||" is ambiguous, cannot mean Left anchor + Right anchor + // urlFilter: "||", + // url: "http://ex.com/", + // expectMatch: false, + // }, + // {"", kSubdomain, kBoundary} + { + urlFilter: "|||", + url: "http://ex.com/", + expectMatch: false, + }, + // {"com/", kSubdomain, kBoundary} + { + urlFilter: "||com/|", + url: "http://ex.com/", + expectMatch: true, + }, + // {"xampl", proto::URL_PATTERN_TYPE_SUBSTRING} + { + urlFilter: "xampl", + url: "http://example.com", + expectMatch: true, + }, + // {"example", proto::URL_PATTERN_TYPE_SUBSTRING} + { + urlFilter: "example", + url: "http://example.com", + expectMatch: true, + }, + // {"/a?a"} + { + urlFilter: "/a?a", + url: "http://ex.com/a?a", + expectMatch: true, + }, + // {"^abc"} + { + urlFilter: "^abc", + url: "http://ex.com/abc?a", + expectMatch: true, + }, + // {"^abc"} + { + urlFilter: "^abc", + url: "http://ex.com/a?abc", + expectMatch: true, + }, + // {"^abc"} + { + urlFilter: "^abc", + url: "http://ex.com/abc?abc", + expectMatch: true, + }, + // {"^abc^abc"} + { + urlFilter: "^abc^abc", + url: "http://ex.com/abc?abc", + expectMatch: true, + }, + // {"^com^abc^abc"} + { + urlFilter: "^com^abc^abc", + url: "http://ex.com/abc?abc", + expectMatch: false, + }, + // {"http://ex", kBoundary, kAnchorNone} + { + urlFilter: "|http://ex", + url: "http://example.com", + expectMatch: true, + }, + // {"http://ex", kAnchorNone, kAnchorNone} + { + urlFilter: "http://ex", + url: "http://example.com", + expectMatch: true, + }, + // {"mple.com/", kAnchorNone, kBoundary} + { + urlFilter: "mple.com/|", + url: "http://example.com", + expectMatch: true, + }, + // {"mple.com/", kAnchorNone, kAnchorNone} + { + urlFilter: "mple.com/", + url: "http://example.com", + expectMatch: true, + }, + // {"mple.com/", kSubdomain, kAnchorNone} + { + urlFilter: "||mple.com/", + url: "http://example.com", + expectMatch: false, + }, + // {"ex.com", kSubdomain, kAnchorNone} + { + urlFilter: "||ex.com", + url: "http://hex.com", + expectMatch: false, + }, + // {"ex.com", kSubdomain, kAnchorNone} + { + urlFilter: "||ex.com", + url: "http://ex.com", + expectMatch: true, + }, + // {"ex.com", kSubdomain, kAnchorNone} + { + urlFilter: "||ex.com", + url: "http://hex.ex.com", + expectMatch: true, + }, + // {"ex.com", kSubdomain, kAnchorNone} + { + urlFilter: "||ex.com", + url: "http://hex.hex.com", + expectMatch: false, + }, + // {"example.com^", kSubdomain, kAnchorNone} + { + urlFilter: "||example.com^", + url: "http://www.example.com", + expectMatch: true, + }, + // {"http://*mpl", kBoundary, kAnchorNone} + { + urlFilter: "|http://*mpl", + url: "http://example.com", + expectMatch: true, + }, + // {"mpl*com/", kAnchorNone, kBoundary} + { + urlFilter: "mpl*com/|", + url: "http://example.com", + expectMatch: true, + }, + // {"example^com"} + { + urlFilter: "example^com", + url: "http://example.com", + expectMatch: false, + }, + // {"example^com"} + { + urlFilter: "example^com", + url: "http://example/com", + expectMatch: true, + }, + // {"example.com^"} + { + urlFilter: "example.com^", + url: "http://example.com:8080", + expectMatch: true, + }, + // {"http*.com/", kBoundary, kBoundary} + { + urlFilter: "|http*.com/|", + url: "http://example.com", + expectMatch: true, + }, + // {"http*.org/", kBoundary, kBoundary} + { + urlFilter: "|http*.org/|", + url: "http://example.com", + expectMatch: false, + }, + // {"/path?*&p1=*&p2="} + { + urlFilter: "/path?*&p1=*&p2=", + url: "http://ex.com/aaa/path/bbb?k=v&p1=0&p2=1", + expectMatch: false, + }, + // {"/path?*&p1=*&p2="} + { + urlFilter: "/path?*&p1=*&p2=", + url: "http://ex.com/aaa/path?k=v&p1=0&p2=1", + expectMatch: true, + }, + // {"/path?*&p1=*&p2="} + { + urlFilter: "/path?*&p1=*&p2=", + url: "http://ex.com/aaa/path?k=v&k=v&p1=0&p2=1", + expectMatch: true, + }, + // {"/path?*&p1=*&p2="} + { + urlFilter: "/path?*&p1=*&p2=", + url: "http://ex.com/aaa/path?k=v&p1=0&p3=10&p2=1", + expectMatch: true, + }, + // {"/path?*&p1=*&p2="} + { + urlFilter: "/path?*&p1=*&p2=", + url: "http://ex.com/aaa/path&p1=0&p2=1", + expectMatch: false, + }, + // {"/path?*&p1=*&p2="} + { + urlFilter: "/path?*&p1=*&p2=", + url: "http://ex.com/aaa/path?k=v&p2=0&p1=1", + expectMatch: false, + }, + // {"abc*def*ghijk*xyz"} + { + urlFilter: "abc*def*ghijk*xyz", + url: "http://example.com/abcdeffffghijkmmmxyzzz", + expectMatch: true, + }, + // {"abc*cdef"} + { + urlFilter: "abc*cdef", + url: "http://example.com/abcdef", + expectMatch: false, + }, + // {"^^a^^"} + { + urlFilter: "^^a^^", + url: "http://ex.com/?a=/", + expectMatch: true, + }, + // {"^^a^^"} + { + urlFilter: "^^a^^", + url: "http://ex.com/?a=/&b=0", + expectMatch: true, + }, + // {"^^a^^"} + { + urlFilter: "^^a^^", + url: "http://ex.com/?a=x", + expectMatch: false, + }, + // {"^^a^^"} + { + urlFilter: "^^a^^", + url: "http://ex.com/?a=", + expectMatch: true, + }, + // {"ex.com^path^*k=v^"} + { + urlFilter: "ex.com^path^*k=v^", + url: "http://ex.com/path/?k1=v1&ak=v&kk=vv", + expectMatch: true, + }, + // {"ex.com^path^*k=v^"} + { + urlFilter: "ex.com^path^*k=v^", + url: "http://ex.com/p/path/?k1=v1&ak=v&kk=vv", + expectMatch: false, + }, + // {"a^a&a^a&"} + { + urlFilter: "a^a&a^a&", + url: "http://ex.com/a/a/a/a/?a&a&a&a&a", + expectMatch: true, + }, + // {"abc*def^"} + { + urlFilter: "abc*def^", + url: "http://ex.com/abc/a/ddef/", + expectMatch: true, + }, + // {"https://example.com/"} + { + urlFilter: "https://example.com/", + url: "http://example.com/", + expectMatch: false, + }, + // {"example.com/", kSubdomain, kAnchorNone} + { + urlFilter: "||example.com/", + url: "http://example.com/", + expectMatch: true, + }, + // {"examp", kSubdomain, kAnchorNone} + { + urlFilter: "||examp", + url: "http://example.com/", + expectMatch: true, + }, + // {"xamp", kSubdomain, kAnchorNone} + { + urlFilter: "||xamp", + url: "http://example.com/", + expectMatch: false, + }, + // {"examp", kSubdomain, kAnchorNone} + { + urlFilter: "||examp", + url: "http://test.example.com/", + expectMatch: true, + }, + // {"t.examp", kSubdomain, kAnchorNone} + { + urlFilter: "||t.examp", + url: "http://test.example.com/", + expectMatch: false, + }, + // {"com^", kSubdomain, kAnchorNone} + { + urlFilter: "||com^", + url: "http://test.example.com/", + expectMatch: true, + }, + // {"com^x", kSubdomain, kBoundary} + { + urlFilter: "||com^x|", + url: "http://a.com/x", + expectMatch: true, + }, + // {"x.com", kSubdomain, kAnchorNone} + { + urlFilter: "||x.com", + url: "http://ex.com/?url=x.com", + expectMatch: false, + }, + // {"ex.com/", kSubdomain, kBoundary} + { + urlFilter: "||ex.com/|", + url: "http://ex.com/", + expectMatch: true, + }, + // {"ex.com^", kSubdomain, kBoundary} + { + urlFilter: "||ex.com^|", + url: "http://ex.com/", + expectMatch: true, + }, + // {"ex.co", kSubdomain, kBoundary} + { + urlFilter: "||ex.co|", + url: "http://ex.com/", + expectMatch: false, + }, + // {"ex.com", kSubdomain, kBoundary} + { + urlFilter: "||ex.com|", + url: "http://rex.com.ex.com/", + expectMatch: false, + }, + // {"ex.com/", kSubdomain, kBoundary} + { + urlFilter: "||ex.com/|", + url: "http://rex.com.ex.com/", + expectMatch: true, + }, + // {"http", kSubdomain, kBoundary} + { + urlFilter: "||http|", + url: "http://http.com/", + expectMatch: false, + }, + // {"http", kSubdomain, kAnchorNone} + { + urlFilter: "||http", + url: "http://http.com/", + expectMatch: true, + }, + // {"/example.com", kSubdomain, kBoundary} + { + urlFilter: "||/example.com|", + url: "http://example.com/", + expectMatch: false, + }, + // {"/example.com/", kSubdomain, kBoundary} + { + urlFilter: "||/example.com/|", + url: "http://example.com/", + expectMatch: false, + }, + // {".", kSubdomain, kAnchorNone} + { + urlFilter: "||.", + url: "http://a..com/", + expectMatch: true, + }, + // {"^", kSubdomain, kAnchorNone} + { + urlFilter: "||^", + url: "http://a..com/", + expectMatch: false, + }, + // {".", kSubdomain, kAnchorNone} + { + urlFilter: "||.", + url: "http://a.com./", + expectMatch: false, + }, + // {"^", kSubdomain, kAnchorNone} + { + urlFilter: "||^", + url: "http://a.com./", + expectMatch: true, + }, + // {".", kSubdomain, kAnchorNone} + { + urlFilter: "||.", + url: "http://a.com../", + expectMatch: true, + }, + // {"^", kSubdomain, kAnchorNone} + { + urlFilter: "||^", + url: "http://a.com../", + expectMatch: true, + }, + // {"/path", kSubdomain, kAnchorNone} + { + urlFilter: "||/path", + url: "http://a.com./path/to/x", + expectMatch: true, + }, + // {"^path", kSubdomain, kAnchorNone} + { + urlFilter: "||^path", + url: "http://a.com./path/to/x", + expectMatch: true, + }, + // {"/path", kSubdomain, kBoundary} + { + urlFilter: "||/path|", + url: "http://a.com./path", + expectMatch: true, + }, + // {"^path", kSubdomain, kBoundary} + { + urlFilter: "||^path|", + url: "http://a.com./path", + expectMatch: true, + }, + // {"path", kSubdomain, kBoundary} + { + urlFilter: "||path|", + url: "http://a.com./path", + expectMatch: false, + }, + // {"path", proto::URL_PATTERN_TYPE_SUBSTRING, kDonotMatchCase} + { + urlFilter: "path", + url: "http://a.com/PaTh", + isUrlFilterCaseSensitive: false, + expectMatch: true, + }, + // {"path", proto::URL_PATTERN_TYPE_SUBSTRING, kMatchCase} + { + urlFilter: "path", + url: "http://a.com/PaTh", + isUrlFilterCaseSensitive: true, + expectMatch: false, + }, + // {"path", proto::URL_PATTERN_TYPE_SUBSTRING, kDonotMatchCase} + { + urlFilter: "path", + url: "http://a.com/path", + isUrlFilterCaseSensitive: false, + expectMatch: true, + }, + // {"path", proto::URL_PATTERN_TYPE_SUBSTRING, kMatchCase} + { + urlFilter: "path", + url: "http://a.com/path", + isUrlFilterCaseSensitive: true, + expectMatch: true, + }, + // {"abc*def^", proto::URL_PATTERN_TYPE_WILDCARDED, kMatchCase} + { + urlFilter: "abc*def^", + url: "http://a.com/abcxAdef/vo", + isUrlFilterCaseSensitive: true, + expectMatch: true, + }, + // {"abc*def^", proto::URL_PATTERN_TYPE_WILDCARDED, kMatchCase} + { + urlFilter: "abc*def^", + url: "http://a.com/aBcxAdeF/vo", + isUrlFilterCaseSensitive: true, + expectMatch: false, + }, + // {"abc*def^", proto::URL_PATTERN_TYPE_WILDCARDED, kDonotMatchCase} + { + urlFilter: "abc*def^", + url: "http://a.com/aBcxAdeF/vo", + isUrlFilterCaseSensitive: false, + expectMatch: true, + }, + // {"abc*def^", proto::URL_PATTERN_TYPE_WILDCARDED, kDonotMatchCase} + { + urlFilter: "abc*def^", + url: "http://a.com/abcxAdef/vo", + isUrlFilterCaseSensitive: false, + expectMatch: true, + }, + // {"abc^", kAnchorNone, kAnchorNone} + { + urlFilter: "abc^", + url: "https://xyz.com/abc/123", + expectMatch: true, + }, + // {"abc^", kAnchorNone, kAnchorNone} + { + urlFilter: "abc^", + url: "https://xyz.com/abc", + expectMatch: true, + }, + // {"abc^", kAnchorNone, kAnchorNone} + { + urlFilter: "abc^", + url: "https://abc.com", + expectMatch: false, + }, + // {"abc^", kAnchorNone, kBoundary} + { + urlFilter: "abc^|", + url: "https://xyz.com/abc/", + expectMatch: true, + }, + // {"abc^", kAnchorNone, kBoundary} + { + urlFilter: "abc^|", + url: "https://xyz.com/abc", + expectMatch: true, + }, + // {"abc^", kAnchorNone, kBoundary} + { + urlFilter: "abc^|", + url: "https://xyz.com/abc/123", + expectMatch: false, + }, + // {"http://abc.com/x^", kBoundary, kAnchorNone} + { + urlFilter: "|http://abc.com/x^", + url: "http://abc.com/x", + expectMatch: true, + }, + // {"http://abc.com/x^", kBoundary, kAnchorNone} + { + urlFilter: "|http://abc.com/x^", + url: "http://abc.com/x/", + expectMatch: true, + }, + // {"http://abc.com/x^", kBoundary, kAnchorNone} + { + urlFilter: "|http://abc.com/x^", + url: "http://abc.com/x/123", + expectMatch: true, + }, + // {"http://abc.com/x^", kBoundary, kBoundary} + { + urlFilter: "|http://abc.com/x^|", + url: "http://abc.com/x", + expectMatch: true, + }, + // {"http://abc.com/x^", kBoundary, kBoundary} + { + urlFilter: "|http://abc.com/x^|", + url: "http://abc.com/x/", + expectMatch: true, + }, + // {"http://abc.com/x^", kBoundary, kBoundary} + { + urlFilter: "|http://abc.com/x^|", + url: "http://abc.com/x/123", + expectMatch: false, + }, + // {"abc.com^", kSubdomain, kAnchorNone} + { + urlFilter: "||abc.com^", + url: "http://xyz.abc.com/123", + expectMatch: true, + }, + // {"abc.com^", kSubdomain, kAnchorNone} + { + urlFilter: "||abc.com^", + url: "http://xyz.abc.com", + expectMatch: true, + }, + // {"abc.com^", kSubdomain, kAnchorNone} + { + urlFilter: "||abc.com^", + url: "http://abc.com.xyz.com?q=abc.com", + expectMatch: false, + }, + // {"abc.com^", kSubdomain, kBoundary} + { + urlFilter: "||abc.com^|", + url: "http://xyz.abc.com/123", + expectMatch: false, + }, + // {"abc.com^", kSubdomain, kBoundary} + { + urlFilter: "||abc.com^|", + url: "http://xyz.abc.com", + expectMatch: true, + }, + // {"abc.com^", kSubdomain, kBoundary} + { + urlFilter: "||abc.com^|", + url: "http://abc.com.xyz.com?q=abc.com/", + expectMatch: false, + }, + // {"abc*^", kAnchorNone, kAnchorNone} + { + urlFilter: "abc*^", + url: "https://abc.com", + expectMatch: true, + }, + // {"abc*^", kAnchorNone, kAnchorNone} + { + urlFilter: "abc*^", + url: "https://abc.com?q=123", + expectMatch: true, + }, + // {"abc*^", kAnchorNone, kBoundary} + { + urlFilter: "abc*^|", + url: "https://abc.com", + expectMatch: true, + }, + // {"abc*^", kAnchorNone, kBoundary} + { + urlFilter: "abc*^|", + url: "https://abc.com?q=123", + expectMatch: true, + }, + // {"abc*", kAnchorNone, kBoundary} + { + urlFilter: "abc*|", + url: "https://a.com/abcxyz", + expectMatch: true, + }, + // {"*google.com", kBoundary, kAnchorNone} + { + urlFilter: "|*google.com", + url: "https://www.google.com", + expectMatch: true, + }, + // {"*", kBoundary, kBoundary} + { + urlFilter: "|*|", + url: "https://example.com", + expectMatch: true, + }, + // // {"", kBoundary, kBoundary} + // { // "||" is ambiguous, cannot mean Left anchor + Right anchor + // urlFilter: "||", + // url: "https://example.com", + // expectMatch: false, + // }, + ]; + for (let test of testCases) { + let { urlFilter, url, expectMatch, isUrlFilterCaseSensitive } = test; + if (expectMatch) { + await testMatchesUrlFilter({ + urlFilter, + isUrlFilterCaseSensitive, + urls: [url], + }); + } else { + await testMatchesUrlFilter({ + urlFilter, + isUrlFilterCaseSensitive, + urlsNonMatching: [url], + }); + } + } + + browser.test.notifyPass(); + }, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_webrequest.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_webrequest.js new file mode 100644 index 0000000000..415ab42c5f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_webrequest.js @@ -0,0 +1,296 @@ +"use strict"; + +add_setup(() => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); +}); + +const server = createHttpServer({ + hosts: ["example.com", "redir"], +}); +server.registerPathHandler("/never_reached", (req, res) => { + Assert.ok(false, "Server should never have been reached"); +}); +server.registerPathHandler("/source", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); +}); +server.registerPathHandler("/destination", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); +}); + +add_task(async function block_request_with_dnr() { + async function background() { + let onBeforeRequestPromise = new Promise(resolve => { + browser.webRequest.onBeforeRequest.addListener(resolve, { + urls: ["*://example.com/*"], + }); + }); + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { requestDomains: ["example.com"] }, + action: { type: "block" }, + }, + ], + }); + + await browser.test.assertRejects( + fetch("http://example.com/never_reached"), + "NetworkError when attempting to fetch resource.", + "blocked by DNR rule" + ); + // DNR is documented to take precedence over webRequest. We should still + // receive the webRequest event, however. + browser.test.log("Waiting for webRequest.onBeforeRequest..."); + await onBeforeRequestPromise; + browser.test.log("Seen webRequest.onBeforeRequest!"); + + browser.test.notifyPass(); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + temporarilyInstalled: true, // Needed for granted_host_permissions + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions: ["*://example.com/*"], + permissions: ["declarativeNetRequest", "webRequest"], + }, + }); + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task(async function upgradeScheme_and_redirect_request_with_dnr() { + async function background() { + let onBeforeRequestSeen = []; + browser.webRequest.onBeforeRequest.addListener( + d => { + onBeforeRequestSeen.push(d.url); + // webRequest cancels, but DNR should actually be taking precedence. + return { cancel: true }; + }, + { urls: ["*://example.com/*", "http://redir/here"] }, + ["blocking"] + ); + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { requestDomains: ["example.com"] }, + action: { type: "upgradeScheme" }, + }, + { + id: 2, + condition: { requestDomains: ["example.com"], urlFilter: "|https:*" }, + action: { type: "redirect", redirect: { url: "http://redir/here" } }, + // The upgradeScheme and redirect actions have equal precedence. To + // make sure that the redirect action is executed when both rules + // match, we assign a higher priority to the redirect action. + priority: 2, + }, + ], + }); + + await browser.test.assertRejects( + fetch("http://example.com/never_reached"), + "NetworkError when attempting to fetch resource.", + "although initially redirected by DNR, ultimately blocked by webRequest" + ); + // DNR is documented to take precedence over webRequest. + // So we should actually see redirects according to the DNR rules, and + // the webRequest listener should still be able to observe all requests. + browser.test.assertDeepEq( + [ + "http://example.com/never_reached", + "https://example.com/never_reached", + "http://redir/here", + ], + onBeforeRequestSeen, + "Expected onBeforeRequest events" + ); + + browser.test.notifyPass(); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + temporarilyInstalled: true, // Needed for granted_host_permissions + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions: ["*://example.com/*", "*://redir/*"], + permissions: [ + "declarativeNetRequest", + "webRequest", + "webRequestBlocking", + ], + }, + }); + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task(async function block_request_with_webRequest_after_allow_with_dnr() { + async function background() { + let onBeforeRequestSeen = []; + browser.webRequest.onBeforeRequest.addListener( + d => { + onBeforeRequestSeen.push(d.url); + return { cancel: !d.url.includes("webRequestNoCancel") }; + }, + { urls: ["*://example.com/*"] }, + ["blocking"] + ); + // All DNR actions that do not end up canceling/redirecting the request: + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { requestMethods: ["get"] }, + action: { type: "allow" }, + }, + { + id: 2, + condition: { requestMethods: ["put"] }, + action: { + type: "modifyHeaders", + requestHeaders: [{ operation: "set", header: "x", value: "y" }], + }, + }, + ], + }); + + await browser.test.assertRejects( + fetch("http://example.com/never_reached?1", { method: "get" }), + "NetworkError when attempting to fetch resource.", + "despite DNR 'allow' rule, still blocked by webRequest" + ); + await browser.test.assertRejects( + fetch("http://example.com/never_reached?2", { method: "put" }), + "NetworkError when attempting to fetch resource.", + "despite DNR 'modifyHeaders' rule, still blocked by webRequest" + ); + // Just to rule out the request having been canceled by DNR instead of + // webRequest, repeat the requests and verify that they succeed. + await fetch("http://example.com/?webRequestNoCancel1", { method: "get" }); + await fetch("http://example.com/?webRequestNoCancel2", { method: "put" }); + + browser.test.assertDeepEq( + [ + "http://example.com/never_reached?1", + "http://example.com/never_reached?2", + "http://example.com/?webRequestNoCancel1", + "http://example.com/?webRequestNoCancel2", + ], + onBeforeRequestSeen, + "Expected onBeforeRequest events" + ); + + browser.test.notifyPass(); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + temporarilyInstalled: true, // Needed for granted_host_permissions + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions: ["*://example.com/*"], + permissions: [ + "declarativeNetRequest", + "webRequest", + "webRequestBlocking", + ], + }, + }); + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task(async function redirect_with_webRequest_after_failing_dnr_redirect() { + async function background() { + // Maximum length of a UTL is 1048576 (network.standard-url.max-length). + const network_standard_url_max_length = 1048576; + // updateSessionRules does some validation on the limit (as seen by + // validate_action_redirect_transform in test_ext_dnr_session_rules.js), + // but it is still possible to pass validation and fail in practice when + // the existing URL + new component exceeds the limit. + const VERY_LONG_STRING = "x".repeat(network_standard_url_max_length - 20); + + browser.webRequest.onBeforeRequest.addListener( + d => { + return { redirectUrl: "http://redir/destination?by-webrequest" }; + }, + { urls: ["*://example.com/*"] }, + ["blocking"] + ); + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { requestDomains: ["example.com"] }, + action: { + type: "redirect", + redirect: { + transform: { + host: "redir", + path: "/destination", + queryTransform: { + addOrReplaceParams: [ + { key: "dnr", value: VERY_LONG_STRING, replaceOnly: true }, + ], + }, + }, + }, + }, + }, + ], + }); + + // Note: we are not expecting successful DNR redirects below, but in case + // that ever changes (e.g. due to VERY_LONG_STRING not resulting in an + // invalid URL), we will truncate the URL out of caution. + // VERY_LONG_STRING consists of many 'X'. Shorten to avoid logspam. + const shortx = s => s.replace(/x{10,}/g, xxx => `x{${xxx.length}}`); + + browser.test.assertEq( + "http://redir/destination?1", + shortx((await fetch("http://example.com/never_reached?1")).url), + "Successful DNR redirect." + ); + + // DNR redirect failure is expected to be very rare, and only to occur when + // an extension intentionally explores the boundaries of the DNR API. When + // DNR fails, we fall back to allowing webRequest to take over. + browser.test.assertEq( + "http://redir/destination?by-webrequest", + shortx((await fetch("http://example.com/source?dnr")).url), + "When DNR fails, we fall back to webRequest redirect" + ); + + browser.test.notifyPass(); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + temporarilyInstalled: true, // Needed for granted_host_permissions + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions: ["*://example.com/*"], + permissions: [ + "declarativeNetRequest", + "webRequest", + "webRequestBlocking", + ], + }, + }); + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_without_webrequest.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_without_webrequest.js new file mode 100644 index 0000000000..5a69b255d2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_without_webrequest.js @@ -0,0 +1,877 @@ +"use strict"; + +// This test file verifies that the declarativeNetRequest API can modify +// network requests as expected without the presence of the webRequest API. See +// test_ext_dnr_webRequest.js for the interaction between webRequest and DNR. + +add_setup(() => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); +}); + +const server = createHttpServer({ + hosts: ["example.com", "example.net", "example.org", "redir", "dummy"], +}); +server.registerPathHandler("/cors_202", (req, res) => { + res.setStatusLine(req.httpVersion, 202, "Accepted"); + // The extensions in this test have minimal permissions, so grant CORS to + // allow them to read the response without host permissions. + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Max-Age", "0"); + res.write("cors_response"); +}); +server.registerPathHandler("/never_reached", (req, res) => { + Assert.ok(false, "Server should never have been reached"); + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Max-Age", "0"); +}); +let gPreflightCount = 0; +server.registerPathHandler("/preflight_count", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Max-Age", "0"); + res.setHeader("Access-Control-Allow-Methods", "NONSIMPLE"); + if (req.method === "OPTIONS") { + ++gPreflightCount; + } else { + // CORS Preflight considers 2xx to be successful. To rule out inadvertent + // server opt-in to CORS, respond with a non-2xx response. + res.setStatusLine(req.httpVersion, 418, "I'm a teapot"); + res.write(`count=${gPreflightCount}`); + } +}); +server.registerPathHandler("/", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Max-Age", "0"); + res.write("Dummy page"); +}); + +async function contentFetch(initiatorURL, url, options) { + let contentPage = await ExtensionTestUtils.loadContentPage(initiatorURL); + // Sanity check: that the initiator is as specified, and not redirected. + Assert.equal( + await contentPage.spawn([], () => content.document.URL), + initiatorURL, + `Expected document load at: ${initiatorURL}` + ); + let result = await contentPage.spawn([{ url, options }], async args => { + try { + let req = await content.fetch(args.url, args.options); + return { + status: req.status, + url: req.url, + body: await req.text(), + }; + } catch (e) { + return { error: e.message }; + } + }); + await contentPage.close(); + return result; +} + +async function checkCanFetchFromOtherExtension() { + let otherExtension = ExtensionTestUtils.loadExtension({ + async background() { + let req = await fetch("http://example.com/cors_202", { method: "get" }); + browser.test.assertEq(202, req.status, "not blocked by other extension"); + browser.test.assertEq("cors_response", await req.text()); + browser.test.sendMessage("other_extension_done"); + }, + }); + await otherExtension.startup(); + await otherExtension.awaitMessage("other_extension_done"); + await otherExtension.unload(); +} + +add_task(async function block_request_with_dnr() { + async function background() { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { requestMethods: ["get"] }, + action: { type: "block" }, + }, + { + id: 2, + condition: { requestMethods: ["head"] }, + action: { type: "allow" }, + }, + ], + }); + { + // Request not matching DNR. + let req = await fetch("http://example.com/cors_202", { method: "post" }); + browser.test.assertEq(202, req.status, "allowed without DNR rule"); + browser.test.assertEq("cors_response", await req.text()); + } + { + // Request with "allow" DNR action. + let req = await fetch("http://example.com/cors_202", { method: "head" }); + browser.test.assertEq(202, req.status, "allowed by DNR rule"); + browser.test.assertEq("", await req.text(), "no response for HEAD"); + } + + // Request with "block" DNR action. + await browser.test.assertRejects( + fetch("http://example.com/never_reached", { method: "get" }), + "NetworkError when attempting to fetch resource.", + "blocked by DNR rule" + ); + + browser.test.sendMessage("tested_dnr_block"); + } + let extension = ExtensionTestUtils.loadExtension({ + allowInsecureRequests: true, + background, + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest"], + }, + }); + await extension.startup(); + await extension.awaitMessage("tested_dnr_block"); + + // DNR should not only work with requests within the extension, but also from + // web pages. + Assert.deepEqual( + await contentFetch("http://dummy/", "http://example.com/never_reached"), + { error: "NetworkError when attempting to fetch resource." }, + "Blocked by DNR with declarativeNetRequestWithHostAccess" + ); + + // declarativeNetRequest does not allow extensions to block requests from + // other extensions. + await checkCanFetchFromOtherExtension(); + + // Except when the user opts in via a preference. When the pref is on, then: + // The declarativeNetRequest permission grants the ability to block requests + // from other extensions. (The declarativeNetRequestWithHostAccess permission + // does not; see test task block_with_declarativeNetRequestWithHostAccess.) + let otherExtension = ExtensionTestUtils.loadExtension({ + async background() { + await browser.test.assertRejects( + fetch("http://example.com/never_reached", { method: "get" }), + "NetworkError when attempting to fetch resource.", + "blocked by different extension with declarativeNetRequest permission" + ); + browser.test.sendMessage("other_extension_done"); + }, + }); + await runWithPrefs( + [["extensions.dnr.match_requests_from_other_extensions", true]], + async () => { + info("Verifying that fetch() from extension is intercepted with pref"); + await otherExtension.startup(); + await otherExtension.awaitMessage("other_extension_done"); + await otherExtension.unload(); + } + ); + + await extension.unload(); +}); + +// Verifies that the "declarativeNetRequestWithHostAccess" permission can only +// block if it has permission for the initiator. +add_task(async function block_with_declarativeNetRequestWithHostAccess() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [{ id: 1, condition: {}, action: { type: "block" } }], + }); + browser.test.sendMessage("dnr_registered"); + }, + temporarilyInstalled: true, // Needed for granted_host_permissions + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions: ["<all_urls>"], + permissions: ["declarativeNetRequestWithHostAccess"], + }, + }); + await extension.startup(); + await extension.awaitMessage("dnr_registered"); + + // Initiator "http://dummy" does match "<all_urls>", so DNR rule should apply. + Assert.deepEqual( + await contentFetch("http://dummy/", "http://example.com/never_reached"), + { error: "NetworkError when attempting to fetch resource." }, + "Blocked by DNR with declarativeNetRequestWithHostAccess" + ); + + // Extensions cannot have permissions for another extension and therefore the + // DNR rule never applies. + await checkCanFetchFromOtherExtension(); + + // Sanity check: even with the pref, the declarativeNetRequestWithHostAccess + // permission should not grant access. + info("Verifying that access is not allowed, despite the pref being true"); + await runWithPrefs( + [["extensions.dnr.match_requests_from_other_extensions", true]], + checkCanFetchFromOtherExtension + ); + + await extension.unload(); +}); + +add_task(async function block_in_sandboxed_extension_page() { + const filesWithSandbox = { + "page_with_sandbox.html": `<!DOCTYPE html><meta charset="utf-8"> + <script src="page_with_sandbox.js"></script> + <iframe src="sandbox.html" sandbox="allow-scripts"></iframe> + `, + "page_with_sandbox.js": () => { + // Sent by sandbox.js: + window.onmessage = e => { + browser.test.assertEq("null", e.origin, "Sender has opaque origin"); + browser.test.sendMessage("fetch_result", e.data); + }; + }, + "sandbox.html": `<script src="sandbox.js"></script>`, + "sandbox.js": async () => { + try { + // Note that the test server responds with CORS headers, so we should + // be able to fetch this URL: + await fetch("http://example.com/?fetch_by_sandbox"); + parent.postMessage("FETCH_ALLOWED", "*"); + } catch (e) { + // The only way for this to fail in this test is when DNR blocks it. + parent.postMessage("FETCH_BLOCKED", "*"); + } + }, + }; + async function checkFetchInSandboxedExtensionPage(ext) { + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${ext.uuid}/page_with_sandbox.html` + ); + let result = await ext.awaitMessage("fetch_result"); + await contentPage.close(); + return result; + } + + let extension = ExtensionTestUtils.loadExtension({ + async background() { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [{ id: 1, condition: {}, action: { type: "block" } }], + }); + browser.test.sendMessage("dnr_registered"); + }, + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest"], + }, + files: filesWithSandbox, + }); + await extension.startup(); + await extension.awaitMessage("dnr_registered"); + + Assert.equal( + await checkFetchInSandboxedExtensionPage(extension), + "FETCH_BLOCKED", + "DNR blocks request from sandboxed page in own extension" + ); + + let otherExtension = ExtensionTestUtils.loadExtension({ + files: filesWithSandbox, + }); + await otherExtension.startup(); + + // Note: In Firefox, webRequest can intercept requests from opaque origins + // opened by other extensions. In contrast, that is not the case in Chrome. + Assert.equal( + await checkFetchInSandboxedExtensionPage(otherExtension), + "FETCH_BLOCKED", + "DNR can block request from sandboxed page in other extension" + ); + + await runWithPrefs( + [["extensions.dnr.match_requests_from_other_extensions", true]], + async () => { + info("Verifying that fetch() from extension sandbox is matched via pref"); + Assert.equal( + await checkFetchInSandboxedExtensionPage(otherExtension), + "FETCH_BLOCKED", + "DNR can block request from sandboxed page in other extension via pref" + ); + } + ); + await extension.unload(); + + // As a sanity check, to verify that the tests above do not always return + // FETCH_BLOCKED, run a test case that returns FETCH_ALLOWED: + Assert.equal( + await checkFetchInSandboxedExtensionPage(otherExtension), + "FETCH_ALLOWED", + "DNR does not affect sandboxed extensions after unloading the DNR extension" + ); + + await otherExtension.unload(); +}); + +// Verifies that upgradeScheme works. +// The HttpServer helper does not support https (bug 1742061), so in this +// test we just verify whether the upgrade has been attempted. Coverage that +// verifies that the upgraded request completes is in: +// toolkit/components/extensions/test/mochitest/test_ext_dnr_upgradeScheme.html +add_task(async function upgradeScheme_declarativeNetRequestWithHostAccess() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { excludedRequestDomains: ["dummy"] }, + action: { type: "upgradeScheme" }, + }, + { + id: 2, + // HttpServer does not support https (bug 1742061). + // As a work-around, we just redirect the https:-request to http. + condition: { urlFilter: "|https:*" }, + action: { + type: "redirect", + redirect: { url: "http://dummy/cors_202?from_https" }, + }, + // The upgradeScheme and redirect actions have equal precedence. To + // make sure that the redirect action is executed when both rules + // match, we assign a higher priority to the redirect action. + priority: 2, + }, + ], + }); + + let req = await fetch("http://redir/never_reached"); + browser.test.assertEq( + "http://dummy/cors_202?from_https", + req.url, + "upgradeScheme upgraded to https" + ); + browser.test.assertEq("cors_response", await req.text()); + + browser.test.sendMessage("tested_dnr_upgradeScheme"); + }, + temporarilyInstalled: true, // Needed for granted_host_permissions. + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions: ["*://dummy/*", "*://redir/*"], + permissions: ["declarativeNetRequestWithHostAccess"], + }, + }); + await extension.startup(); + await extension.awaitMessage("tested_dnr_upgradeScheme"); + + // Request to same-origin subresource, which should be upgraded. + Assert.equal( + (await contentFetch("http://redir/", "http://redir/never_reached")).url, + "http://dummy/cors_202?from_https", + "upgradeScheme + host access should upgrade (same-origin request)" + ); + + // Request to cross-origin subresource, which should be upgraded. + // Note: after the upgrade, a cross-origin redirect happens. Internally, we + // reflect the Origin request header in the Access-Control-Allow-Origin (ACAO) + // response header, to ensure that the request is accepted by CORS. See + // https://github.com/w3c/webappsec-upgrade-insecure-requests/issues/32 + Assert.equal( + (await contentFetch("http://dummy/", "http://redir/never_reached")).url, + "http://dummy/cors_202?from_https", + "upgradeScheme + host access should upgrade (cross-origin request)" + ); + + // The DNR extension does not have example.net in host_permissions. + const urlNoHostPerms = "http://example.net/cors_202?missing_host_permission"; + Assert.equal( + (await contentFetch("http://dummy/", urlNoHostPerms)).url, + urlNoHostPerms, + "upgradeScheme not matched when extension lacks host access" + ); + + await extension.unload(); +}); + +add_task(async function redirect_request_with_dnr() { + async function background() { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { + requestDomains: ["example.com"], + requestMethods: ["get"], + }, + action: { + type: "redirect", + redirect: { + url: "http://example.net/cors_202?1", + }, + }, + }, + { + id: 2, + // Note: extension does not have example.org host permission. + condition: { requestDomains: ["example.org"] }, + action: { + type: "redirect", + redirect: { + url: "http://example.net/cors_202?2", + }, + }, + }, + ], + }); + // The extension only has example.com permission, but the redirects to + // example.net are still due to the CORS headers from the server. + { + // Simple GET request. + let req = await fetch("http://example.com/never_reached"); + browser.test.assertEq(202, req.status, "redirected by DNR (simple)"); + browser.test.assertEq("http://example.net/cors_202?1", req.url); + browser.test.assertEq("cors_response", await req.text()); + } + { + // GeT request should be matched despite having a different case. + let req = await fetch("http://example.com/never_reached", { + method: "GeT", + }); + browser.test.assertEq(202, req.status, "redirected by DNR (GeT)"); + browser.test.assertEq("http://example.net/cors_202?1", req.url); + browser.test.assertEq("cors_response", await req.text()); + } + { + // Host permission missing for request, request not redirected by DNR. + // Response is readable due to the CORS response headers from the server. + let req = await fetch("http://example.org/cors_202?noredir"); + browser.test.assertEq(202, req.status, "not redirected by DNR"); + browser.test.assertEq("http://example.org/cors_202?noredir", req.url); + browser.test.assertEq("cors_response", await req.text()); + } + + browser.test.notifyPass(); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + temporarilyInstalled: true, // Needed for granted_host_permissions + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions: ["*://example.com/*"], + permissions: ["declarativeNetRequest"], + }, + }); + await extension.startup(); + await extension.awaitFinish(); + + let otherExtension = ExtensionTestUtils.loadExtension({ + async background() { + // The DNR extension has permissions for example.com, but not for this + // extension. Therefore the "redirect" action should not apply. + let req = await fetch("http://example.com/cors_202?other_ext"); + browser.test.assertEq(202, req.status, "not redirected by DNR"); + browser.test.assertEq("http://example.com/cors_202?other_ext", req.url); + browser.test.assertEq("cors_response", await req.text()); + browser.test.sendMessage("other_extension_done"); + }, + }); + await otherExtension.startup(); + await otherExtension.awaitMessage("other_extension_done"); + await otherExtension.unload(); + + await extension.unload(); +}); + +// Verifies that DNR redirects requiring a CORS preflight behave as expected. +add_task(async function redirect_request_with_dnr_cors_preflight() { + // Most other test tasks only test requests within the test extension. This + // test intentionally triggers requests outside the extension, to make sure + // that the usual CORS mechanisms is triggered (instead of exceptions from + // host permissions). + async function background() { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { + requestDomains: ["redir"], + excludedRequestMethods: ["options"], + }, + action: { + type: "redirect", + redirect: { + url: "http://example.com/preflight_count", + }, + }, + }, + { + id: 2, + condition: { + requestDomains: ["example.net"], + excludedRequestMethods: ["nonsimple"], // note: redirects "options" + }, + action: { + type: "redirect", + redirect: { + url: "http://example.com/preflight_count", + }, + }, + }, + ], + }); + let req = await fetch("http://redir/never_reached", { + method: "NONSIMPLE", + }); + // Extension has permission for "redir", but not for the redirect target. + // The request is non-simple (see below for explanation of non-simple), so + // a preflight (OPTIONS) request to /preflight_count is expected before the + // redirection target is requested. + browser.test.assertEq( + "count=1", + await req.text(), + "Got preflight before redirect target because of missing host_permissions" + ); + + browser.test.sendMessage("continue_preflight_tests"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + temporarilyInstalled: true, // Needed for granted_host_permissions + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + granted_host_permissions: true, + // "redir" and "example.net" are needed to allow redirection of these. + // "dummy" is needed to redirect requests initiated from http://dummy. + host_permissions: ["*://redir/*", "*://example.net/*", "*://dummy/*"], + permissions: ["declarativeNetRequest"], + }, + }); + gPreflightCount = 0; + await extension.startup(); + await extension.awaitMessage("continue_preflight_tests"); + gPreflightCount = 0; // value already checked before continue_preflight_tests. + + // Simple request (i.e. without preflight requirement), that's redirected to + // another URL by the DNR rule. The redirect should be accepted, and in + // particular not be blocked by the same-origin policy. The redirect target + // (/preflight_count) is readable due to the CORS headers from the server. + Assert.deepEqual( + await contentFetch("http://dummy/", "http://redir/never_reached"), + // count=0: A simple request does not trigger a preflight (OPTIONS) request. + { status: 418, url: "http://example.com/preflight_count", body: "count=0" }, + "Simple request should not have a preflight." + ); + + // Any request method other than "GET", "POST" and "PUT" (e.g "NONSIMPLE") is + // a non-simple request that triggers a preflight request ("OPTIONS"). + // + // Usually, this happens (without extension-triggered redirects): + // 1. NONSIMPLE /never_reached : is started, but does NOT hit the server yet. + // 2. OPTIONS /never_reached + Access-Control-Request-Method: NONSIMPLE + // 3. NONSIMPLE /never_reached : reaches the server if allowed by OPTIONS. + // + // With an extension-initiated redirect to /preflight_count: + // 1. NONSIMPLE /never_reached : is started, but does not hit the server yet. + // 2. extension redirects to /preflight_count + // 3. OPTIONS /preflight_count + Access-Control-Request-Method: NONSIMPLE + // - This is because the redirect preserves the request method/body/etc. + // 4. NONSIMPLE /preflight_count : reaches the server if allowed by OPTIONS. + Assert.deepEqual( + await contentFetch("http://dummy/", "http://redir/never_reached", { + method: "NONSIMPLE", + }), + // Due to excludedRequestMethods: ["options"], the preflight for the + // redirect target is not intercepted, so the server sees a preflight. + { status: 418, url: "http://example.com/preflight_count", body: "count=1" }, + "Initial URL redirected, redirection target has preflight" + ); + gPreflightCount = 0; + + // The "example.net" rule has "excludedRequestMethods": ["nonsimple"], so the + // initial "NONSIMPLE" request is not immediately redirected. Therefore the + // preflight request happens. This OPTIONS request is matched by the DNR rule + // and redirected to /preflight_count. While preflight_count offers a very + // permissive preflight response, it is not even fetched: + // Only a 2xx HTTP status is considered a valid response to a pre-flight. + // A redirect is like a 3xx HTTP status, so the whole request is rejected, + // and the redirect is not followed for the OPTIONS request. + Assert.deepEqual( + await contentFetch("http://dummy/", "http://example.net/never_reached", { + method: "NONSIMPLE", + }), + { error: "NetworkError when attempting to fetch resource." }, + "Redirect of preflight request (OPTIONS) should be a CORS failure" + ); + + Assert.equal(gPreflightCount, 0, "Preflight OPTIONS has been intercepted"); + + await extension.unload(); +}); + +// Tests that DNR redirect rules can be chained. +add_task(async function redirect_request_with_dnr_multiple_hops() { + async function background() { + // Set up redirects from example.com up until dummy. + let hosts = ["example.com", "example.net", "example.org", "redir", "dummy"]; + let rules = []; + for (let i = 1; i < hosts.length; ++i) { + const from = hosts[i - 1]; + const to = hosts[i]; + const end = hosts.length - 1 === i; + rules.push({ + id: i, + condition: { requestDomains: [from] }, + action: { + type: "redirect", + redirect: { + // All intermediate redirects should never hit the server, but the + // last one should.. + url: end ? `http://${to}/?end` : `http://${to}/never_reached`, + }, + }, + }); + } + await browser.declarativeNetRequest.updateSessionRules({ addRules: rules }); + let req = await fetch("http://example.com/never_reached"); + browser.test.assertEq(200, req.status, "redirected by DNR (multiple)"); + browser.test.assertEq("http://dummy/?end", req.url, "Last URL in chain"); + browser.test.assertEq("Dummy page", await req.text()); + + browser.test.notifyPass(); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + temporarilyInstalled: true, // Needed for granted_host_permissions + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions: ["*://*/*"], // matches all in the |hosts| list. + permissions: ["declarativeNetRequest"], + }, + }); + await extension.startup(); + await extension.awaitFinish(); + + // Test again, but without special extension permissions to verify that DNR + // redirects pass CORS checks. + Assert.deepEqual( + await contentFetch("http://dummy/", "http://redir/never_reached"), + { status: 200, url: "http://dummy/?end", body: "Dummy page" }, + "Multiple redirects by DNR, requested from web origin." + ); + + await extension.unload(); +}); + +add_task(async function redirect_request_with_dnr_with_redirect_loop() { + async function background() { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + // requestMethods is mutually exclusive with the other rule. + condition: { regexFilter: "^(.+)$", requestMethods: ["post"] }, + action: { + type: "redirect", + redirect: { + // Appends "?loop" to the request URL + regexSubstitution: "\\1?loop", + }, + }, + }, + { + id: 2, + // requestMethods is mutually exclusive with the other rule. + condition: { requestDomains: ["redir"], requestMethods: ["get"] }, + action: { + type: "redirect", + redirect: { + // Despite redirect.url matching the condition, the redirect loop + // should be caught because of the obvious fact that the URL did + // not change. + url: "http://redir/cors_202?loop", + }, + }, + }, + ], + }); + + // Redirect where the redirect URL changes at every redirect. + await browser.test.assertRejects( + fetch("http://redir/cors_202?loop", { method: "post" }), + "NetworkError when attempting to fetch resource.", + "Redirect loop caught (redirect target differs at every redirect)" + ); + + async function assertRedirect(url, expected, description) { + // method: "get" could only match rule 2. + let res = await fetch(url); + browser.test.assertDeepEq( + expected, + { status: res.status, url: res.url, redirected: res.redirected }, + description + ); + } + + // Redirect with initially a different URL. + await assertRedirect( + "http://redir/never_reached?", + { status: 202, url: "http://redir/cors_202?loop", redirected: true }, + "Redirect loop caught (initially different URL)" + ); + + // Redirect where redirect is exactly the same URL as requested. + await assertRedirect( + "http://redir/cors_202?loop", + { status: 202, url: "http://redir/cors_202?loop", redirected: false }, + "Redirect loop caught (redirect target same as initial URL)" + ); + + browser.test.notifyPass(); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + temporarilyInstalled: true, // Needed for granted_host_permissions + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions: ["*://redir/*"], + permissions: ["declarativeNetRequest"], + }, + }); + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); + +// Tests that redirect to extensionPath works, provided that the initiator is +// either the extension itself, or in host_permissions. Moreover, the requested +// resource must match a web_accessible_resources entry for both the initiator +// AND the pre-redirect URL. +add_task(async function redirect_request_with_dnr_to_extensionPath() { + async function background() { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { requestDomains: ["redir"], requestMethods: ["post"] }, + action: { + type: "redirect", + redirect: { + extensionPath: "/war.txt?1", + }, + }, + }, + { + id: 2, + condition: { requestDomains: ["redir"], requestMethods: ["put"] }, + action: { + type: "redirect", + redirect: { + extensionPath: "/nonwar.txt?2", + }, + }, + }, + ], + }); + { + let req = await fetch("http://redir/never_reached", { method: "post" }); + browser.test.assertEq(200, req.status, "redirected to extensionPath"); + browser.test.assertEq(`${location.origin}/war.txt?1`, req.url); + browser.test.assertEq("war_ext_res", await req.text()); + } + // Redirects to extensionPath that is not in web_accessible_resources. + // While the initiator (extension) would be allowed to read the resource + // due to it being same-origin, the pre-redirect URL (http://redir) is not + // matching web_accessible_resources[].matches, so the load is rejected. + // + // This behavior differs from Chrome (e.g. at least in Chrome 109) that + // does allow the load to complete. Extensions who really care about + // exposing a web-accessible resource to the world can just put an all_urls + // pattern in web_accessible_resources[].matches. + await browser.test.assertRejects( + fetch("http://redir/never_reached", { method: "put" }), + "NetworkError when attempting to fetch resource.", + "Redirect to nowar.txt, but pre-redirect host is not in web_accessible_resources[].matches" + ); + + browser.test.notifyPass(); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + temporarilyInstalled: true, // Needed for granted_host_permissions + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions: ["*://redir/*", "*://dummy/*"], + permissions: ["declarativeNetRequest"], + web_accessible_resources: [ + // *://redir/* is in matches, because that is the pre-redirect host. + // *://dummy/* is in matches, because that is an initiator below. + { resources: ["war.txt"], matches: ["*://redir/*", "*://dummy/*"] }, + // without "matches", this is almost equivalent to not being listed in + // web_accessible_resources at all. This entry is listed here to verify + // that the presence of extension_ids does not somehow allow a request + // with an extension initiator to complete. + { resources: ["nonwar.txt"], extension_ids: ["*"] }, + ], + }, + files: { + "war.txt": "war_ext_res", + "nonwar.txt": "non_war_ext_res", + }, + }); + await extension.startup(); + await extension.awaitFinish(); + const extPrefix = `moz-extension://${extension.uuid}`; + + // Request from origin in host_permissions, for web-accessible resource. + Assert.deepEqual( + await contentFetch( + "http://dummy/", // <-- Matching web_accessible_resources[].matches + "http://redir/never_reached", // <-- With matching host_permissions + { method: "post" } + ), + { status: 200, url: `${extPrefix}/war.txt?1`, body: "war_ext_res" }, + "Should have got redirect to web_accessible_resources (war.txt)" + ); + + // Request from origin in host_permissions, for non-web-accessible resource. + let { messages } = await promiseConsoleOutput(async () => { + Assert.deepEqual( + await contentFetch( + "http://dummy/", // <-- Matching web_accessible_resources[].matches + "http://redir/never_reached", // <-- With matching host_permissions + { method: "put" } + ), + { error: "NetworkError when attempting to fetch resource." }, + "Redirect to nowar.txt, without matching web_accessible_resources[].matches" + ); + }); + const EXPECTED_SECURITY_ERROR = `Content at http://redir/never_reached may not load or link to ${extPrefix}/nonwar.txt?2.`; + Assert.equal( + messages.filter(m => m.message.includes(EXPECTED_SECURITY_ERROR)).length, + 1, + `Should log SecurityError: ${EXPECTED_SECURITY_ERROR}` + ); + + // Request from origin not in host_permissions. DNR rule should not apply. + Assert.deepEqual( + await contentFetch( + "http://dummy/", // <-- Matching web_accessible_resources[].matches + "http://example.com/cors_202", // <-- NOT in host_permissions + { method: "post" } + ), + { status: 202, url: "http://example.com/cors_202", body: "cors_response" }, + "Extension should not have redirected, due to lack of host permissions" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dns.js b/toolkit/components/extensions/test/xpcshell/test_ext_dns.js new file mode 100644 index 0000000000..4b8599b0c5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dns.js @@ -0,0 +1,177 @@ +"use strict"; + +// Some test machines and android are not returning ipv6, turn it +// off to get consistent test results. +Services.prefs.setBoolPref("network.dns.disableIPv6", true); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +function getExtension(background = undefined) { + let manifest = { + permissions: ["dns", "proxy"], + }; + return ExtensionTestUtils.loadExtension({ + manifest, + background() { + browser.test.onMessage.addListener(async (msg, data) => { + if (msg == "proxy") { + await browser.proxy.settings.set({ value: data }); + browser.test.sendMessage("proxied"); + return; + } + browser.test.log(`=== dns resolve test ${JSON.stringify(data)}`); + browser.dns + .resolve(data.hostname, data.flags) + .then(result => { + browser.test.log( + `=== dns resolve result ${JSON.stringify(result)}` + ); + browser.test.sendMessage("resolved", result); + }) + .catch(e => { + browser.test.log(`=== dns resolve error ${e.message}`); + browser.test.sendMessage("resolved", { message: e.message }); + }); + }); + browser.test.sendMessage("ready"); + }, + incognitoOverride: "spanning", + useAddonManager: "temporary", + }); +} + +const tests = [ + { + request: { + hostname: "localhost", + }, + expect: { + addresses: ["127.0.0.1"], // ipv6 disabled , "::1" + }, + }, + { + request: { + hostname: "localhost", + flags: ["offline"], + }, + expect: { + addresses: ["127.0.0.1"], // ipv6 disabled , "::1" + }, + }, + { + request: { + hostname: "test.example", + }, + expect: { + // android will error with offline + error: /NS_ERROR_UNKNOWN_HOST|NS_ERROR_OFFLINE/, + }, + }, + { + request: { + hostname: "127.0.0.1", + flags: ["canonical_name"], + }, + expect: { + canonicalName: "127.0.0.1", + addresses: ["127.0.0.1"], + }, + }, + { + request: { + hostname: "localhost", + flags: ["disable_ipv6"], + }, + expect: { + addresses: ["127.0.0.1"], + }, + }, +]; + +add_setup(async function startup() { + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_dns_resolve() { + let extension = getExtension(); + await extension.startup(); + await extension.awaitMessage("ready"); + + for (let test of tests) { + extension.sendMessage("resolve", test.request); + let result = await extension.awaitMessage("resolved"); + if (test.expect.error) { + ok( + test.expect.error.test(result.message), + `expected error ${result.message}` + ); + } else { + equal( + result.canonicalName, + test.expect.canonicalName, + "canonicalName match" + ); + // It seems there are platform differences happening that make this + // testing difficult. We're going to rely on other existing dns tests to validate + // the dns service itself works and only validate that we're getting generally + // expected results in the webext api. + Assert.greaterOrEqual( + result.addresses.length, + test.expect.addresses.length, + "expected number of addresses returned" + ); + if (test.expect.addresses.length && result.addresses.length) { + ok( + result.addresses.includes(test.expect.addresses[0]), + "got expected ip address" + ); + } + } + } + + await extension.unload(); +}); + +add_task(async function test_dns_resolve_socks() { + let extension = getExtension(); + await extension.startup(); + await extension.awaitMessage("ready"); + extension.sendMessage("proxy", { + proxyType: "manual", + socks: "127.0.0.1", + socksVersion: 5, + proxyDNS: true, + }); + await extension.awaitMessage("proxied"); + equal( + Services.prefs.getIntPref("network.proxy.type"), + 1 /* PROXYCONFIG_MANUAL */, + "manual proxy" + ); + equal( + Services.prefs.getStringPref("network.proxy.socks"), + "127.0.0.1", + "socks proxy" + ); + ok( + Services.prefs.getBoolPref("network.proxy.socks_remote_dns"), + "socks remote dns" + ); + extension.sendMessage("resolve", { + hostname: "mozilla.org", + }); + let result = await extension.awaitMessage("resolved"); + ok( + /NS_ERROR_UNKNOWN_PROXY_HOST/.test(result.message), + `expected error ${result.message}` + ); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads.js new file mode 100644 index 0000000000..f65df707e1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads.js @@ -0,0 +1,38 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_downloads_api_namespace_and_permissions() { + function backgroundScript() { + browser.test.assertTrue(!!browser.downloads, "`downloads` API is present."); + browser.test.assertTrue( + !!browser.downloads.FilenameConflictAction, + "`downloads.FilenameConflictAction` enum is present." + ); + browser.test.assertTrue( + !!browser.downloads.InterruptReason, + "`downloads.InterruptReason` enum is present." + ); + browser.test.assertTrue( + !!browser.downloads.DangerType, + "`downloads.DangerType` enum is present." + ); + browser.test.assertTrue( + !!browser.downloads.State, + "`downloads.State` enum is present." + ); + browser.test.notifyPass("downloads tests"); + } + + let extensionData = { + background: backgroundScript, + manifest: { + permissions: ["downloads", "downloads.open"], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("downloads tests"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookieStoreId.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookieStoreId.js new file mode 100644 index 0000000000..e79e3adbfb --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookieStoreId.js @@ -0,0 +1,469 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function cookiesToMime(cookies) { + return `dummy/${encodeURIComponent(cookies)}`.toLowerCase(); +} + +function mimeToCookies(mime) { + return decodeURIComponent(mime.replace("dummy/", "")); +} + +const server = createHttpServer({ hosts: ["example.net"] }); + +server.registerPathHandler("/download", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + let cookies = request.hasHeader("Cookie") ? request.getHeader("Cookie") : ""; + // Assign the result through the MIME-type, to make it easier to read the + // result via the downloads API. + response.setHeader("Content-Type", cookiesToMime(cookies)); + // Response of length 7. + response.write("1234567"); +}); + +const DOWNLOAD_URL = "http://example.net/download"; + +async function setUpCookies() { + Services.cookies.removeAll(); + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["cookies", "http://example.net/download"], + }, + async background() { + let url = "http://example.net/download"; + // Add default cookie + await browser.cookies.set({ + url, + name: "cookie_normal", + value: "1", + }); + + // Add private cookie + await browser.cookies.set({ + url, + storeId: "firefox-private", + name: "cookie_private", + value: "1", + }); + + // Add container cookie + await browser.cookies.set({ + url, + storeId: "firefox-container-1", + name: "cookie_container", + value: "1", + }); + browser.test.sendMessage("cookies set"); + }, + }); + await extension.startup(); + await extension.awaitMessage("cookies set"); + await extension.unload(); +} + +function createDownloadTestExtension(extraPermissions = [], incognito = false) { + let extensionOptions = { + manifest: { + permissions: ["downloads", ...extraPermissions], + }, + background() { + browser.test.onMessage.addListener(async (method, data) => { + async function getDownload(data) { + let donePromise = new Promise(resolve => { + browser.downloads.onChanged.addListener(async delta => { + if (delta.state?.current === "complete") { + resolve(delta.id); + } + }); + }); + let downloadId = await browser.downloads.download(data); + browser.test.assertEq(await donePromise, downloadId, "got download"); + let [download] = await browser.downloads.search({ id: downloadId }); + browser.test.log(`Download results: ${JSON.stringify(download)}`); + // Delete the file since we aren't interested in it. + // TODO bug 1654819: On Windows the file may be recreated. + await browser.downloads.removeFile(download.id); + // Sanity check to verify that we got the result from /download. + browser.test.assertEq(7, download.fileSize, "download succeeded"); + return download; + } + function checkDownloadError(data) { + return browser.test.assertRejects( + browser.downloads.download(data.downloadData), + data.exceptionRe + ); + } + function search(data) { + return browser.downloads.search(data); + } + function erase(data) { + return browser.downloads.erase(data); + } + switch (method) { + case "getDownload": + return browser.test.sendMessage(method, await getDownload(data)); + case "checkDownloadError": + return browser.test.sendMessage( + method, + await checkDownloadError(data) + ); + case "search": + return browser.test.sendMessage(method, await search(data)); + case "erase": + return browser.test.sendMessage(method, await erase(data)); + } + }); + }, + }; + if (incognito) { + extensionOptions.incognitoOverride = "spanning"; + } + return ExtensionTestUtils.loadExtension(extensionOptions); +} + +function getResult(extension, method, data) { + extension.sendMessage(method, data); + return extension.awaitMessage(method); +} + +async function getCookies(extension, data) { + let download = await getResult(extension, "getDownload", data); + // The "/download" endpoint mirrors received cookies via Content-Type. + let cookies = mimeToCookies(download.mime); + return cookies; +} + +async function runTests(extension, containerDownloadAllowed, privateAllowed) { + let forcedIncognitoException = null; + if (!privateAllowed) { + forcedIncognitoException = /private browsing access not allowed/; + } else if (!containerDownloadAllowed) { + forcedIncognitoException = /No permission for cookieStoreId/; + } + + // Test default container download + if (containerDownloadAllowed) { + equal( + await getCookies(extension, { + url: DOWNLOAD_URL, + cookieStoreId: "firefox-default", + }), + "cookie_normal=1", + "Default container cookies for downloads.download" + ); + } else { + await getResult(extension, "checkDownloadError", { + exceptionRe: /No permission for cookieStoreId/, + downloadData: { + url: DOWNLOAD_URL, + cookieStoreId: "firefox-default", + }, + }); + } + + // Test private container download + if (privateAllowed && containerDownloadAllowed) { + equal( + await getCookies(extension, { + url: DOWNLOAD_URL, + cookieStoreId: "firefox-private", + incognito: true, + }), + "cookie_private=1", + "Private container cookies for downloads.download" + ); + } else { + await getResult(extension, "checkDownloadError", { + exceptionRe: forcedIncognitoException, + downloadData: { + url: DOWNLOAD_URL, + cookieStoreId: "firefox-private", + incognito: true, + }, + }); + } + + // Test firefox-container-1 download + if (containerDownloadAllowed) { + equal( + await getCookies(extension, { + url: DOWNLOAD_URL, + cookieStoreId: "firefox-container-1", + }), + "cookie_container=1", + "firefox-container-1 cookies for downloads.download" + ); + } else { + await getResult(extension, "checkDownloadError", { + exceptionRe: /No permission for cookieStoreId/, + downloadData: { + url: DOWNLOAD_URL, + cookieStoreId: "firefox-container-1", + }, + }); + } + + // Test mismatched incognito and cookieStoreId download + await getResult(extension, "checkDownloadError", { + exceptionRe: forcedIncognitoException + ? forcedIncognitoException + : /Illegal to set non-private cookieStoreId in a private window/, + downloadData: { + url: DOWNLOAD_URL, + incognito: true, + cookieStoreId: "firefox-container-1", + }, + }); + await getResult(extension, "checkDownloadError", { + exceptionRe: containerDownloadAllowed + ? /Illegal to set private cookieStoreId in a non-private window/ + : /No permission for cookieStoreId/, + downloadData: { + url: DOWNLOAD_URL, + incognito: false, + cookieStoreId: "firefox-private", + }, + }); + + // Test invalid cookieStoreId download + await getResult(extension, "checkDownloadError", { + exceptionRe: containerDownloadAllowed + ? /Illegal cookieStoreId/ + : /No permission for cookieStoreId/, + downloadData: { + url: DOWNLOAD_URL, + cookieStoreId: "invalid-invalid-invalid", + }, + }); + + let searchRes, searchResDownload; + // Test default container search + searchRes = await getResult(extension, "search", { + cookieStoreId: "firefox-default", + }); + equal( + searchRes.length, + 1, + "Default container results length for downloads.search" + ); + [searchResDownload] = searchRes; + equal( + mimeToCookies(searchResDownload.mime), + "cookie_normal=1", + "Default container cookies for downloads.search" + ); + // Test default container search with mismatched container + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_normal=1"), + cookieStoreId: "firefox-container-1", + }); + equal( + searchRes.length, + 0, + "Default container results length for downloads.search when container mismatched" + ); + + // Test private container search + searchRes = await getResult(extension, "search", { + cookieStoreId: "firefox-private", + }); + if (privateAllowed) { + equal( + searchRes.length, + 1, + "Private container results length for downloads.search" + ); + [searchResDownload] = searchRes; + equal( + mimeToCookies(searchResDownload.mime), + "cookie_private=1", + "Private container cookies for downloads.search" + ); + // Test private container search with mismatched container + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_private=1"), + cookieStoreId: "firefox-container-1", + }); + equal( + searchRes.length, + 0, + "Private container results length for downloads.search when container mismatched" + ); + } else { + equal( + searchRes.length, + 0, + "Private container results length for downloads.search when private disallowed" + ); + } + + // Test firefox-container-1 search + searchRes = await getResult(extension, "search", { + cookieStoreId: "firefox-container-1", + }); + equal( + searchRes.length, + 1, + "firefox-container-1 results length for downloads.search" + ); + [searchResDownload] = searchRes; + equal( + mimeToCookies(searchResDownload.mime), + "cookie_container=1", + "firefox-container-1 cookies for downloads.search" + ); + // Test firefox-container-1 search with mismatched container + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_container=1"), + cookieStoreId: "firefox-default", + }); + equal( + searchRes.length, + 0, + "firefox-container-1 container results length for downloads.search when container mismatched" + ); + + // Test default container erase with mismatched container + await getResult(extension, "erase", { + mime: cookiesToMime("cookie_normal=1"), + cookieStoreId: "firefox-container-1", + }); + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_normal=1"), + }); + equal( + searchRes.length, + 1, + "Default container results length for downloads.search after erase with mismatched container" + ); + + // Test private container erase with mismatched container + await getResult(extension, "erase", { + mime: cookiesToMime("cookie_private=1"), + cookieStoreId: "firefox-container-1", + }); + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_private=1"), + }); + equal( + searchRes.length, + privateAllowed ? 1 : 0, + "Private container results length for downloads.search after erase with mismatched container" + ); + + // Test firefox-container-1 erase with mismatched container + await getResult(extension, "erase", { + mime: cookiesToMime("cookie_container=1"), + cookieStoreId: "firefox-default", + }); + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_container=1"), + }); + equal( + searchRes.length, + 1, + "firefox-container-1 results length for downloads.search after erase with mismatched container" + ); + + // Test default container erase + await getResult(extension, "erase", { + cookieStoreId: "firefox-default", + }); + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_normal=1"), + }); + equal( + searchRes.length, + 0, + "Default container results length for downloads.search after erase" + ); + + // Test private container erase + await getResult(extension, "erase", { + cookieStoreId: "firefox-private", + }); + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_private=1"), + }); + // The following will also pass when incognito disabled + equal( + searchRes.length, + 0, + "Private container results length for downloads.search after erase" + ); + + // Test firefox-container-1 erase + await getResult(extension, "erase", { + cookieStoreId: "firefox-container-1", + }); + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_container=1"), + }); + equal( + searchRes.length, + 0, + "firefox-container-1 results length for downloads.search after erase" + ); +} + +async function populateDownloads(extension) { + await getResult(extension, "erase", {}); + await getResult(extension, "getDownload", { + url: DOWNLOAD_URL, + }); + await getResult(extension, "getDownload", { + url: DOWNLOAD_URL, + incognito: true, + }); + await getResult(extension, "getDownload", { + url: DOWNLOAD_URL, + cookieStoreId: "firefox-container-1", + }); +} + +add_task(async function setup() { + const nsIFile = Ci.nsIFile; + const downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir); + Services.prefs.setBoolPref("privacy.userContext.enabled", true); + await setUpCookies(); + registerCleanupFunction(() => { + Services.cookies.removeAll(); + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + downloadDir.remove(false); + }); +}); + +add_task(async function download_cookieStoreId() { + // Test extension with cookies permission and incognito enabled + let extension = createDownloadTestExtension(["cookies"], true); + await extension.startup(); + await runTests(extension, true, true); + + // Test extension with incognito enabled and no cookies permission + await populateDownloads(extension); + let noCookiesExtension = createDownloadTestExtension([], true); + await noCookiesExtension.startup(); + await runTests(noCookiesExtension, false, true); + await noCookiesExtension.unload(); + + // Test extension with incognito disabled and no cookies permission + await populateDownloads(extension); + let noCookiesAndPrivateExtension = createDownloadTestExtension([], false); + await noCookiesAndPrivateExtension.startup(); + await runTests(noCookiesAndPrivateExtension, false, false); + await noCookiesAndPrivateExtension.unload(); + + // Verify that incognito disabled test did not delete private download + let searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_private=1"), + }); + ok(searchRes.length, "Incognito disabled does not delete private download"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookies.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookies.js new file mode 100644 index 0000000000..8d3984e8e2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookies.js @@ -0,0 +1,219 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { UrlClassifierTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/UrlClassifierTestUtils.sys.mjs" +); + +// Value for network.cookie.cookieBehavior to reject all third-party cookies. +const { BEHAVIOR_REJECT_FOREIGN } = Ci.nsICookieService; + +const server = createHttpServer({ hosts: ["example.net", "itisatracker.org"] }); +server.registerPathHandler("/setcookies", (request, response) => { + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.setHeader("Set-Cookie", "c_none=1; sameSite=none", true); + response.setHeader("Set-Cookie", "c_lax=1; sameSite=lax", true); + response.setHeader("Set-Cookie", "c_strict=1; sameSite=strict", true); +}); + +server.registerPathHandler("/download", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + + let cookies = request.hasHeader("Cookie") ? request.getHeader("Cookie") : ""; + // Assign the result through the MIME-type, to make it easier to read the + // result via the downloads API. + response.setHeader("Content-Type", `dummy/${encodeURIComponent(cookies)}`); + // Response of length 7. + response.write("1234567"); +}); + +server.registerPathHandler("/redirect", (request, response) => { + response.setStatusLine(request.httpVersion, 302, "Found"); + response.setHeader("Location", "/download"); +}); + +function createDownloadTestExtension(extraPermissions = []) { + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["downloads", ...extraPermissions], + }, + incognitoOverride: "spanning", + background() { + async function getCookiesForDownload(url) { + let donePromise = new Promise(resolve => { + browser.downloads.onChanged.addListener(async delta => { + if (delta.state?.current === "complete") { + resolve(delta.id); + } + }); + }); + // TODO bug 1653636: Remove this when the correct browsing mode is used. + const incognito = browser.extension.inIncognitoContext; + let downloadId = await browser.downloads.download({ url, incognito }); + browser.test.assertEq(await donePromise, downloadId, "got download"); + let [download] = await browser.downloads.search({ id: downloadId }); + browser.test.log(`Download results: ${JSON.stringify(download)}`); + + // Delete the file since we aren't interested in it. + // TODO bug 1654819: On Windows the file may be recreated. + await browser.downloads.removeFile(download.id); + // Sanity check to verify that we got the result from /download. + browser.test.assertEq(7, download.fileSize, "download succeeded"); + + // The "/download" endpoint mirrors received cookies via Content-Type. + let cookies = decodeURIComponent(download.mime.replace("dummy/", "")); + return cookies; + } + + browser.test.onMessage.addListener(async url => { + browser.test.sendMessage("result", await getCookiesForDownload(url)); + }); + }, + }); +} + +async function downloadAndGetCookies(extension, url) { + extension.sendMessage(url); + return extension.awaitMessage("result"); +} + +add_task(async function setup() { + const nsIFile = Ci.nsIFile; + const downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir); + + // Support sameSite=none despite the server using http instead of https. + Services.prefs.setBoolPref( + "network.cookie.sameSite.noneRequiresSecure", + false + ); + async function loadAndClose(url) { + let contentPage = await ExtensionTestUtils.loadContentPage(url); + await contentPage.close(); + } + // Generate cookies for use in this test. + await loadAndClose("http://example.net/setcookies"); + await loadAndClose("http://itisatracker.org/setcookies"); + + await UrlClassifierTestUtils.addTestTrackers(); + registerCleanupFunction(() => { + UrlClassifierTestUtils.cleanupTestTrackers(); + Services.cookies.removeAll(); + + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + + downloadDir.remove(false); + }); +}); + +// Checks that (sameSite) cookies are included in download requests. +add_task(async function download_cookies_basic() { + let extension = createDownloadTestExtension(["*://example.net/*"]); + await extension.startup(); + + equal( + await downloadAndGetCookies(extension, "http://example.net/download"), + "c_none=1; c_lax=1; c_strict=1", + "Cookies for downloads.download with sameSite cookies" + ); + + equal( + await downloadAndGetCookies(extension, "http://example.net/redirect"), + "c_none=1; c_lax=1; c_strict=1", + "Cookies for downloads.download with redirect" + ); + + await runWithPrefs( + [["network.cookie.cookieBehavior", BEHAVIOR_REJECT_FOREIGN]], + async () => { + equal( + await downloadAndGetCookies(extension, "http://example.net/download"), + "c_none=1; c_lax=1; c_strict=1", + "Cookies for downloads.download with all third-party cookies disabled" + ); + } + ); + + await extension.unload(); +}); + +// Checks that (sameSite) cookies are included even when tracking protection +// would block cookies from third-party requests. +add_task(async function download_cookies_from_tracker_url() { + let extension = createDownloadTestExtension(["*://itisatracker.org/*"]); + await extension.startup(); + + equal( + await downloadAndGetCookies(extension, "http://itisatracker.org/download"), + "c_none=1; c_lax=1; c_strict=1", + "Cookies for downloads.download of itisatracker.org" + ); + + await extension.unload(); +}); + +// Checks that (sameSite) cookies are included even without host permissions. +add_task(async function download_cookies_without_host_permissions() { + let extension = createDownloadTestExtension(); + await extension.startup(); + + equal( + await downloadAndGetCookies(extension, "http://example.net/download"), + "c_none=1; c_lax=1; c_strict=1", + "Cookies for downloads.download without host permissions" + ); + + equal( + await downloadAndGetCookies(extension, "http://itisatracker.org/download"), + "c_none=1; c_lax=1; c_strict=1", + "Cookies for downloads.download of itisatracker.org" + ); + + await runWithPrefs( + [["network.cookie.cookieBehavior", BEHAVIOR_REJECT_FOREIGN]], + async () => { + equal( + await downloadAndGetCookies(extension, "http://example.net/download"), + "c_none=1; c_lax=1; c_strict=1", + "Cookies for downloads.download with all third-party cookies disabled" + ); + } + ); + + await extension.unload(); +}); + +// Checks that (sameSite) cookies from private browsing are included. +add_task(async function download_cookies_in_perma_private_browsing() { + Services.prefs.setBoolPref("browser.privatebrowsing.autostart", true); + Services.prefs.setBoolPref("dom.security.https_first_pbm", false); + + let extension = createDownloadTestExtension(["*://example.net/*"]); + await extension.startup(); + + equal( + await downloadAndGetCookies(extension, "http://example.net/download"), + "", + "Initially no cookies in permanent private browsing mode" + ); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.net/setcookies", + { privateBrowsing: true } + ); + + equal( + await downloadAndGetCookies(extension, "http://example.net/download"), + "c_none=1; c_lax=1; c_strict=1", + "Cookies for downloads.download in perma-private-browsing mode" + ); + + await extension.unload(); + await contentPage.close(); + Services.prefs.clearUserPref("browser.privatebrowsing.autostart"); + Services.prefs.clearUserPref("dom.security.https_first_pbm"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js new file mode 100644 index 0000000000..e2867d1f03 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js @@ -0,0 +1,685 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { Downloads } = ChromeUtils.importESModule( + "resource://gre/modules/Downloads.sys.mjs" +); + +const gServer = createHttpServer(); +gServer.registerDirectory("/data/", do_get_file("data")); + +gServer.registerPathHandler("/dir/", (_, res) => res.write("length=8")); + +const WINDOWS = AppConstants.platform == "win"; + +const BASE = `http://localhost:${gServer.identity.primaryPort}/`; +const FILE_NAME = "file_download.txt"; +const FILE_NAME_W_SPACES = "file download.txt"; +const FILE_URL = BASE + "data/" + FILE_NAME; +const FILE_NAME_UNIQUE = "file_download(1).txt"; +const FILE_LEN = 46; + +let downloadDir; + +function joinPath(...components) { + const separator = WINDOWS ? "\\" : "/"; + + return components.join(separator); +} + +function setup() { + downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique( + Ci.nsIFile.DIRECTORY_TYPE, + FileUtils.PERMS_DIRECTORY + ); + info(`Using download directory ${downloadDir.path}`); + + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue( + "browser.download.dir", + Ci.nsIFile, + downloadDir + ); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + + let entries = downloadDir.directoryEntries; + while (entries.hasMoreElements()) { + let entry = entries.nextFile; + ok(false, `Leftover file ${entry.path} in download directory`); + entry.remove(false); + } + + downloadDir.remove(false); + }); +} + +function backgroundScript() { + let blobUrl; + browser.test.onMessage.addListener(async (msg, ...args) => { + if (msg == "download.request") { + let options = args[0]; + + if (options.blobme) { + let blob = new Blob(options.blobme); + delete options.blobme; + blobUrl = options.url = window.URL.createObjectURL(blob); + } + + try { + let id = await browser.downloads.download(options); + browser.test.sendMessage("download.done", { status: "success", id }); + } catch (error) { + browser.test.sendMessage("download.done", { + status: "error", + errmsg: error.message, + }); + } + } else if (msg == "killTheBlob") { + window.URL.revokeObjectURL(blobUrl); + blobUrl = null; + } + }); + + browser.test.sendMessage("ready"); +} + +// This function is a bit of a sledgehammer, it looks at every download +// the browser knows about and waits for all active downloads to complete. +// But we only start one at a time and only do a handful in total, so +// this lets us test download() without depending on anything else. +async function waitForDownloads() { + let list = await Downloads.getList(Downloads.ALL); + let downloads = await list.getAll(); + + let inprogress = downloads.filter(dl => !dl.stopped); + return Promise.all(inprogress.map(dl => dl.whenSucceeded())); +} + +// Create a file in the downloads directory. +function touch(filename) { + let file = downloadDir.clone(); + file.append(filename); + file.create(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); +} + +// Remove a file in the downloads directory. +function remove(filename, recursive = false) { + let file = downloadDir.clone(); + file.append(filename); + file.remove(recursive); +} + +add_task(async function test_downloads() { + setup(); + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["downloads"], + }, + incognitoOverride: "spanning", + }); + + function download(options) { + extension.sendMessage("download.request", options); + return extension.awaitMessage("download.done"); + } + + async function testDownload(options, localFile, expectedSize, description) { + let msg = await download(options); + equal( + msg.status, + "success", + `downloads.download() works with ${description}` + ); + + await waitForDownloads(); + + let localPath = downloadDir.clone(); + let parts = Array.isArray(localFile) ? localFile : [localFile]; + + parts.map(p => localPath.append(p)); + equal( + localPath.fileSize, + expectedSize, + "Downloaded file has expected size" + ); + localPath.remove(false); + } + + await extension.startup(); + await extension.awaitMessage("ready"); + info("extension started"); + + // Call download() with just the url property. + await testDownload({ url: FILE_URL }, FILE_NAME, FILE_LEN, "just source"); + + // Call download() with a filename property. + await testDownload( + { + url: FILE_URL, + filename: "newpath.txt", + }, + "newpath.txt", + FILE_LEN, + "source and filename" + ); + + // Call download() with a filename with subdirs. + await testDownload( + { + url: FILE_URL, + filename: "sub/dir/file", + }, + ["sub", "dir", "file"], + FILE_LEN, + "source and filename with subdirs" + ); + + // Call download() with a filename with existing subdirs. + await testDownload( + { + url: FILE_URL, + filename: "sub/dir/file2", + }, + ["sub", "dir", "file2"], + FILE_LEN, + "source and filename with existing subdirs" + ); + + // Only run Windows path separator test on Windows. + if (WINDOWS) { + // Call download() with a filename with Windows path separator. + await testDownload( + { + url: FILE_URL, + filename: "sub\\dir\\file3", + }, + ["sub", "dir", "file3"], + FILE_LEN, + "filename with Windows path separator" + ); + } + remove("sub", true); + + // Call download(), filename with subdir, skipping parts. + await testDownload( + { + url: FILE_URL, + filename: "skip//part", + }, + ["skip", "part"], + FILE_LEN, + "source, filename, with subdir, skipping parts" + ); + remove("skip", true); + + // Check conflictAction of "uniquify". + touch(FILE_NAME); + await testDownload( + { + url: FILE_URL, + conflictAction: "uniquify", + }, + FILE_NAME_UNIQUE, + FILE_LEN, + "conflictAction=uniquify" + ); + // todo check that preexisting file was not modified? + remove(FILE_NAME); + + // Check conflictAction of "overwrite". + touch(FILE_NAME); + await testDownload( + { + url: FILE_URL, + conflictAction: "overwrite", + }, + FILE_NAME, + FILE_LEN, + "conflictAction=overwrite" + ); + + // Try to download in invalid url + await download({ url: "this is not a valid URL" }).then(msg => { + equal(msg.status, "error", "downloads.download() fails with invalid url"); + ok( + /not a valid URL/.test(msg.errmsg), + "error message for invalid url is correct" + ); + }); + + // Try to download to an empty path. + await download({ + url: FILE_URL, + filename: "", + }).then(msg => { + equal( + msg.status, + "error", + "downloads.download() fails with empty filename" + ); + equal( + msg.errmsg, + "filename must not be empty", + "error message for empty filename is correct" + ); + }); + + // Try to download to an absolute path. + const absolutePath = PathUtils.join( + WINDOWS ? "C:\\tmp" : "/tmp", + "file_download.txt" + ); + await download({ + url: FILE_URL, + filename: absolutePath, + }).then(msg => { + equal( + msg.status, + "error", + "downloads.download() fails with absolute filename" + ); + equal( + msg.errmsg, + "filename must not be an absolute path", + `error message for absolute path (${absolutePath}) is correct` + ); + }); + + if (WINDOWS) { + await download({ + url: FILE_URL, + filename: "C:\\file_download.txt", + }).then(msg => { + equal( + msg.status, + "error", + "downloads.download() fails with absolute filename" + ); + equal( + msg.errmsg, + "filename must not be an absolute path", + "error message for absolute path with drive letter is correct" + ); + }); + } + + // Try to download to a relative path containing .. + await download({ + url: FILE_URL, + filename: joinPath("..", "file_download.txt"), + }).then(msg => { + equal( + msg.status, + "error", + "downloads.download() fails with back-references" + ); + equal( + msg.errmsg, + "filename must not contain back-references (..)", + "error message for back-references is correct" + ); + }); + + // Try to download to a long relative path containing .. + await download({ + url: FILE_URL, + filename: joinPath("foo", "..", "..", "file_download.txt"), + }).then(msg => { + equal( + msg.status, + "error", + "downloads.download() fails with back-references" + ); + equal( + msg.errmsg, + "filename must not contain back-references (..)", + "error message for back-references is correct" + ); + }); + + // Test illegal characters. + await download({ + url: FILE_URL, + filename: "like:this", + }).then(msg => { + equal(msg.status, "error", "downloads.download() fails with illegal chars"); + equal( + msg.errmsg, + "filename must not contain illegal characters", + "error message correct" + ); + }); + + // Try to download a blob url + const BLOB_STRING = "Hello, world"; + await testDownload( + { + blobme: [BLOB_STRING], + filename: FILE_NAME, + }, + FILE_NAME, + BLOB_STRING.length, + "blob url" + ); + extension.sendMessage("killTheBlob"); + + // Try to download a blob url without a given filename + await testDownload( + { + blobme: [BLOB_STRING], + }, + "download", + BLOB_STRING.length, + "blob url with no filename" + ); + extension.sendMessage("killTheBlob"); + + // Download a normal URL with an empty filename part. + await testDownload( + { + url: BASE + "dir/", + }, + "download", + 8, + "normal url with empty filename" + ); + + // Download a filename with multiple spaces, url is ignored for this test. + await testDownload( + { + url: FILE_URL, + filename: "a file.txt", + }, + "a file.txt", + FILE_LEN, + "filename with multiple spaces" + ); + + // Download a normal URL with a leafname containing multiple spaces. + // Note: spaces are compressed by file name normalization. + await testDownload( + { + url: BASE + "data/" + FILE_NAME_W_SPACES, + }, + FILE_NAME_W_SPACES.replace(/\s+/, " "), + FILE_LEN, + "leafname with multiple spaces" + ); + + // Check that the "incognito" property is supported. + await testDownload( + { + url: FILE_URL, + incognito: false, + }, + FILE_NAME, + FILE_LEN, + "incognito=false" + ); + + await testDownload( + { + url: FILE_URL, + incognito: true, + }, + FILE_NAME, + FILE_LEN, + "incognito=true" + ); + + await extension.unload(); +}); + +async function testHttpErrors(allowHttpErrors) { + const server = createHttpServer(); + const url = `http://localhost:${server.identity.primaryPort}/error`; + const content = "HTTP Error test"; + + server.registerPathHandler("/error", (request, response) => { + response.setStatusLine( + "1.1", + parseInt(request.queryString, 10), + "Some Error" + ); + response.setHeader("Content-Type", "text/plain", false); + response.setHeader("Content-Length", content.length.toString()); + response.write(content); + }); + + function background(code) { + let dlid = 0; + let expectedState; + browser.test.onMessage.addListener(async options => { + try { + expectedState = options.allowHttpErrors ? "complete" : "interrupted"; + dlid = await browser.downloads.download(options); + } catch (err) { + browser.test.fail(`Unexpected error in downloads.download(): ${err}`); + } + }); + function onChanged({ id, state }) { + if (dlid !== id || !state || state.current === "in_progress") { + return; + } + browser.test.assertEq(state.current, expectedState, "correct state"); + browser.downloads.search({ id }).then(([download]) => { + browser.test.sendMessage("done", download.error); + }); + } + browser.downloads.onChanged.addListener(onChanged); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["downloads"], + }, + background, + }); + await extension.startup(); + + async function download(code, expected_when_disallowed) { + const options = { + url: url + "?" + code, + filename: `test-${code}`, + conflictAction: "overwrite", + allowHttpErrors, + }; + extension.sendMessage(options); + const rv = await extension.awaitMessage("done"); + + if (allowHttpErrors) { + const localPath = downloadDir.clone(); + localPath.append(options.filename); + equal( + localPath.fileSize, + // The 20x No content errors will not produce any response body, + // only "true" errors do. + code >= 400 ? content.length : 0, + "Downloaded file has expected size" + code + ); + localPath.remove(false); + + ok(!rv, "error must be ignored and hence false-y"); + return; + } + + equal( + rv, + expected_when_disallowed, + "error must have the correct InterruptReason" + ); + } + + await download(204, "SERVER_BAD_CONTENT"); // No Content + await download(205, "SERVER_BAD_CONTENT"); // Reset Content + await download(404, "SERVER_BAD_CONTENT"); // Not Found + await download(403, "SERVER_FORBIDDEN"); // Forbidden + await download(402, "SERVER_UNAUTHORIZED"); // Unauthorized + await download(407, "SERVER_UNAUTHORIZED"); // Proxy auth required + await download(504, "SERVER_FAILED"); //General errors, here Gateway Timeout + + await extension.unload(); +} + +add_task(function test_download_disallowed_http_errors() { + return testHttpErrors(false); +}); + +add_task(function test_download_allowed_http_errors() { + return testHttpErrors(true); +}); + +add_task(async function test_download_http_details() { + const server = createHttpServer(); + const url = `http://localhost:${server.identity.primaryPort}/post-log`; + + let received; + server.registerPathHandler("/post-log", (request, response) => { + received = request; + response.setHeader("Set-Cookie", "monster=", false); + }); + + // Confirm received vs. expected values. + function confirm(method, headers = {}, body) { + equal(received.method, method, "method is correct"); + + for (let name in headers) { + ok(received.hasHeader(name), `header ${name} received`); + equal( + received.getHeader(name), + headers[name], + `header ${name} is correct` + ); + } + + if (body) { + const str = NetUtil.readInputStreamToString( + received.bodyInputStream, + received.bodyInputStream.available() + ); + equal(str, body, "body is correct"); + } + } + + function background() { + browser.test.onMessage.addListener(async options => { + try { + await browser.downloads.download(options); + } catch (err) { + browser.test.sendMessage("done", { err: err.message }); + } + }); + browser.downloads.onChanged.addListener(({ state }) => { + if (state && state.current === "complete") { + browser.test.sendMessage("done", { ok: true }); + } + }); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["downloads"], + }, + background, + incognitoOverride: "spanning", + }); + await extension.startup(); + + function download(options) { + options.url = url; + options.conflictAction = "overwrite"; + + extension.sendMessage(options); + return extension.awaitMessage("done"); + } + + // Test that site cookies are sent with download requests, + // and "incognito" downloads use a separate cookie jar. + let testDownloadCookie = async function (incognito) { + let result = await download({ incognito }); + ok(result.ok, `preflight to set cookies with incognito=${incognito}`); + ok(!received.hasHeader("cookie"), "first request has no cookies"); + + result = await download({ incognito }); + ok(result.ok, `download with cookie with incognito=${incognito}`); + equal( + received.getHeader("cookie"), + "monster=", + "correct cookie header sent for second download" + ); + }; + + await testDownloadCookie(false); + await testDownloadCookie(true); + + // Test method option. + let result = await download({}); + ok(result.ok, "download works without the method option, defaults to GET"); + confirm("GET"); + + result = await download({ method: "PUT" }); + ok(!result.ok, "download rejected with PUT method"); + ok( + /method: Invalid enumeration/.test(result.err), + "descriptive error message" + ); + + result = await download({ method: "POST" }); + ok(result.ok, "download works with POST method"); + confirm("POST"); + + // Test body option values. + result = await download({ body: [] }); + ok(!result.ok, "download rejected because of non-string body"); + ok(/body: Expected string/.test(result.err), "descriptive error message"); + + result = await download({ method: "POST", body: "of work" }); + ok(result.ok, "download works with POST method and body"); + confirm("POST", { "Content-Length": 7 }, "of work"); + + // Test custom headers. + result = await download({ headers: [{ name: "X-Custom" }] }); + ok(!result.ok, "download rejected because of missing header value"); + ok(/"value" is required/.test(result.err), "descriptive error message"); + + result = await download({ headers: [{ name: "X-Custom", value: "13" }] }); + ok(result.ok, "download works with a custom header"); + confirm("GET", { "X-Custom": "13" }); + + // Test Referer header. + const referer = "http://example.org/test"; + result = await download({ headers: [{ name: "Referer", value: referer }] }); + ok(result.ok, "download works with Referer header"); + confirm("GET", { Referer: referer }); + + // Test forbidden headers. + result = await download({ headers: [{ name: "DNT", value: "1" }] }); + ok(!result.ok, "download rejected because of forbidden header name DNT"); + ok(/Forbidden request header/.test(result.err), "descriptive error message"); + + result = await download({ + headers: [{ name: "Proxy-Connection", value: "keep" }], + }); + ok( + !result.ok, + "download rejected because of forbidden header name prefix Proxy-" + ); + ok(/Forbidden request header/.test(result.err), "descriptive error message"); + + result = await download({ headers: [{ name: "Sec-ret", value: "13" }] }); + ok( + !result.ok, + "download rejected because of forbidden header name prefix Sec-" + ); + ok(/Forbidden request header/.test(result.err), "descriptive error message"); + + remove("post-log"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_eventpage.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_eventpage.js new file mode 100644 index 0000000000..9c71c63e96 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_eventpage.js @@ -0,0 +1,162 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE = `http://localhost:${server.identity.primaryPort}/data`; +const TXT_FILE = "file_download.txt"; +const TXT_URL = BASE + "/" + TXT_FILE; + +add_task(function setup() { + let downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique( + Ci.nsIFile.DIRECTORY_TYPE, + FileUtils.PERMS_DIRECTORY + ); + info(`Using download directory ${downloadDir.path}`); + + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue( + "browser.download.dir", + Ci.nsIFile, + downloadDir + ); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + + let entries = downloadDir.directoryEntries; + while (entries.hasMoreElements()) { + let entry = entries.nextFile; + ok(false, `Leftover file ${entry.path} in download directory`); + entry.remove(false); + } + + downloadDir.remove(false); + }); +}); + +add_task( + { pref_set: [["extensions.eventPages.enabled", true]] }, + async function test_downloads_event_page() { + await AddonTestUtils.promiseStartupManager(); + + // A simple download driving extension + let dl_extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: "downloader@mochitest" } }, + permissions: ["downloads"], + background: { persistent: false }, + }, + background() { + let downloadId; + browser.downloads.onChanged.addListener(async info => { + if (info.state && info.state.current === "complete") { + browser.test.sendMessage("downloadComplete"); + } + }); + browser.test.onMessage.addListener(async (msg, opts) => { + if (msg == "download") { + downloadId = await browser.downloads.download(opts); + } + if (msg == "erase") { + await browser.downloads.removeFile(downloadId); + await browser.downloads.erase({ id: downloadId }); + } + }); + }, + }); + await dl_extension.startup(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["downloads"], + background: { persistent: false }, + }, + background() { + browser.downloads.onChanged.addListener(() => { + browser.test.sendMessage("onChanged"); + }); + browser.downloads.onCreated.addListener(() => { + browser.test.sendMessage("onCreated"); + }); + browser.downloads.onErased.addListener(() => { + browser.test.sendMessage("onErased"); + }); + browser.test.sendMessage("ready"); + }, + }); + + // onDeterminingFilename is never persisted, it is an empty event handler. + const EVENTS = ["onChanged", "onCreated", "onErased"]; + + await extension.startup(); + await extension.awaitMessage("ready"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "downloads", event, { + primed: false, + }); + } + + await extension.terminateBackground({ disableResetIdleForTest: true }); + ok( + !extension.extension.backgroundContext, + "Background Extension context should have been destroyed" + ); + + for (let event of EVENTS) { + assertPersistentListeners(extension, "downloads", event, { + primed: true, + }); + } + + // test download events waken background + dl_extension.sendMessage("download", { + url: TXT_URL, + filename: TXT_FILE, + }); + await extension.awaitMessage("ready"); + await extension.awaitMessage("onCreated"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "downloads", event, { + primed: false, + }); + } + await extension.awaitMessage("onChanged"); + + await extension.terminateBackground({ disableResetIdleForTest: true }); + ok( + !extension.extension.backgroundContext, + "Background Extension context should have been destroyed" + ); + + await dl_extension.awaitMessage("downloadComplete"); + dl_extension.sendMessage("erase"); + await extension.awaitMessage("ready"); + await extension.awaitMessage("onErased"); + await dl_extension.unload(); + + // check primed listeners after startup + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + for (let event of EVENTS) { + assertPersistentListeners(extension, "downloads", event, { + primed: true, + }); + } + + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_misc.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_misc.js new file mode 100644 index 0000000000..bb54283c3c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_misc.js @@ -0,0 +1,1173 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { Downloads } = ChromeUtils.importESModule( + "resource://gre/modules/Downloads.sys.mjs" +); + +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const ROOT = `http://localhost:${server.identity.primaryPort}`; +const BASE = `${ROOT}/data`; +const TXT_FILE = "file_download.txt"; +const TXT_URL = BASE + "/" + TXT_FILE; + +// Keep these in sync with code in interruptible.sjs +const INT_PARTIAL_LEN = 15; +const INT_TOTAL_LEN = 31; + +const TEST_DATA = "This is 31 bytes of sample data"; +const TOTAL_LEN = TEST_DATA.length; +const PARTIAL_LEN = 15; + +// A handler to let us systematically test pausing/resuming/canceling +// of downloads. This target represents a small text file but a simple +// GET will stall after sending part of the data, to give the test code +// a chance to pause or do other operations on an in-progress download. +// A resumed download (ie, a GET with a Range: header) will allow the +// download to complete. +function handleRequest(request, response) { + response.setHeader("Content-Type", "text/plain", false); + + if (request.hasHeader("Range")) { + let start, end; + let matches = request + .getHeader("Range") + .match(/^\s*bytes=(\d+)?-(\d+)?\s*$/); + if (matches != null) { + start = matches[1] ? parseInt(matches[1], 10) : 0; + end = matches[2] ? parseInt(matches[2], 10) : TOTAL_LEN - 1; + } + + if (end == undefined || end >= TOTAL_LEN) { + response.setStatusLine( + request.httpVersion, + 416, + "Requested Range Not Satisfiable" + ); + response.setHeader("Content-Range", `*/${TOTAL_LEN}`, false); + response.finish(); + return; + } + + response.setStatusLine(request.httpVersion, 206, "Partial Content"); + response.setHeader("Content-Range", `${start}-${end}/${TOTAL_LEN}`, false); + response.write(TEST_DATA.slice(start, end + 1)); + } else if (request.queryString.includes("stream")) { + response.processAsync(); + response.setHeader("Content-Length", "10000", false); + response.write("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"); + setInterval(() => { + response.write("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"); + }, 50); + } else { + response.processAsync(); + response.setHeader("Content-Length", `${TOTAL_LEN}`, false); + response.write(TEST_DATA.slice(0, PARTIAL_LEN)); + } + + registerCleanupFunction(() => { + try { + response.finish(); + } catch (e) { + // This will throw, but we don't care at this point. + } + }); +} + +server.registerPrefixHandler("/interruptible/", handleRequest); + +let interruptibleCount = 0; +function getInterruptibleUrl(filename = "interruptible.html") { + let n = interruptibleCount++; + return `${ROOT}/interruptible/${filename}?count=${n}`; +} + +function backgroundScript() { + let events = new Set(); + let eventWaiter = null; + + browser.downloads.onCreated.addListener(data => { + events.add({ type: "onCreated", data }); + if (eventWaiter) { + eventWaiter(); + } + }); + + browser.downloads.onChanged.addListener(data => { + events.add({ type: "onChanged", data }); + if (eventWaiter) { + eventWaiter(); + } + }); + + browser.downloads.onErased.addListener(data => { + events.add({ type: "onErased", data }); + if (eventWaiter) { + eventWaiter(); + } + }); + + // Returns a promise that will resolve when the given list of expected + // events have all been seen. By default, succeeds only if the exact list + // of expected events is seen in the given order. options.exact can be + // set to false to allow other events and options.inorder can be set to + // false to allow the events to arrive in any order. + function waitForEvents(expected, options = {}) { + function compare(a, b) { + if (typeof b == "object" && b != null) { + if (typeof a != "object") { + return false; + } + return Object.keys(b).every(fld => compare(a[fld], b[fld])); + } + return a == b; + } + + const exact = "exact" in options ? options.exact : true; + const inorder = "inorder" in options ? options.inorder : true; + return new Promise((resolve, reject) => { + function check() { + function fail(msg) { + browser.test.fail(msg); + reject(new Error(msg)); + } + if (events.size < expected.length) { + return; + } + if (exact && expected.length < events.size) { + fail( + `Got ${events.size} events but only expected ${expected.length}` + ); + return; + } + + let remaining = new Set(events); + if (inorder) { + for (let event of events) { + if (compare(event, expected[0])) { + expected.shift(); + remaining.delete(event); + } + } + } else { + expected = expected.filter(val => { + for (let remainingEvent of remaining) { + if (compare(remainingEvent, val)) { + remaining.delete(remainingEvent); + return false; + } + } + return true; + }); + } + + // Events that did occur have been removed from expected so if + // expected is empty, we're done. If we didn't see all the + // expected events and we're not looking for an exact match, + // then we just may not have seen the event yet, so return without + // failing and check() will be called again when a new event arrives. + if (!expected.length) { + events = remaining; + eventWaiter = null; + resolve(); + } else if (exact) { + fail( + `Mismatched event: expecting ${JSON.stringify( + expected[0] + )} but got ${JSON.stringify(Array.from(remaining)[0])}` + ); + } + } + eventWaiter = check; + check(); + }); + } + + browser.test.onMessage.addListener(async (msg, ...args) => { + let match = msg.match(/(\w+).request$/); + if (!match) { + return; + } + + let what = match[1]; + if (what == "waitForEvents") { + try { + await waitForEvents(...args); + browser.test.sendMessage("waitForEvents.done", { status: "success" }); + } catch (error) { + browser.test.sendMessage("waitForEvents.done", { + status: "error", + errmsg: error.message, + }); + } + } else if (what == "clearEvents") { + events = new Set(); + browser.test.sendMessage("clearEvents.done", { status: "success" }); + } else { + try { + let result = await browser.downloads[what](...args); + browser.test.sendMessage(`${what}.done`, { status: "success", result }); + } catch (error) { + browser.test.sendMessage(`${what}.done`, { + status: "error", + errmsg: error.message, + }); + } + } + }); + + browser.test.sendMessage("ready"); +} + +let downloadDir; +let extension; + +async function waitForCreatedPartFile(baseFilename = "interruptible.html") { + const partFilePath = PathUtils.join(downloadDir.path, `${baseFilename}.part`); + + info(`Wait for ${partFilePath} to be created`); + let lastError; + await TestUtils.waitForCondition( + () => + IOUtils.exists(partFilePath).catch(err => { + lastError = err; + return false; + }), + `Wait for the ${partFilePath} to exists before pausing the download` + ).catch(err => { + if (lastError) { + throw lastError; + } + throw err; + }); +} + +async function clearDownloads() { + let list = await Downloads.getList(Downloads.ALL); + let downloads = await list.getAll(); + + await Promise.all( + downloads.map(async download => { + await download.finalize(true); + list.remove(download); + }) + ); + + return downloads; +} + +function runInExtension(what, ...args) { + extension.sendMessage(`${what}.request`, ...args); + return extension.awaitMessage(`${what}.done`); +} + +// This is pretty simplistic, it looks for a progress update for a +// download of the given url in which the total bytes are exactly equal +// to the given value. Unless you know exactly how data will arrive from +// the server (eg see interruptible.sjs), it probably isn't very useful. +async function waitForProgress(url, testFn) { + let list = await Downloads.getList(Downloads.ALL); + + return new Promise(resolve => { + const view = { + onDownloadChanged(download) { + if (download.source.url == url && testFn(download.currentBytes)) { + list.removeView(view); + resolve(download.currentBytes); + } + }, + }; + list.addView(view); + }); +} + +add_setup(async () => { + const nsIFile = Ci.nsIFile; + downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + info(`downloadDir ${downloadDir.path}`); + + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir); + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + await clearDownloads(); + downloadDir.remove(true); + }); + + await clearDownloads().then(downloads => { + info(`removed ${downloads.length} pre-existing downloads from history`); + }); + + extension = ExtensionTestUtils.loadExtension({ + background: backgroundScript, + manifest: { + permissions: ["downloads"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + registerCleanupFunction(async () => { + await extension.unload(); + }); +}); + +add_task(async function test_events() { + let msg = await runInExtension("download", { url: TXT_URL }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + msg = await runInExtension("waitForEvents", [ + { type: "onCreated", data: { id, url: TXT_URL } }, + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "complete", + }, + }, + }, + ]); + equal(msg.status, "success", "got onCreated and onChanged events"); +}); + +add_task(async function test_cancel() { + let url = getInterruptibleUrl(); + info(url); + let msg = await runInExtension("download", { url }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + let progressPromise = waitForProgress(url, bytes => bytes == INT_PARTIAL_LEN); + + msg = await runInExtension("waitForEvents", [ + { type: "onCreated", data: { id } }, + ]); + equal(msg.status, "success", "got created and changed events"); + + await progressPromise; + info(`download reached ${INT_PARTIAL_LEN} bytes`); + + msg = await runInExtension("cancel", id); + equal(msg.status, "success", "cancel() succeeded"); + + // TODO bug 1256243: This sequence of events is bogus + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + state: { + previous: "in_progress", + current: "interrupted", + }, + paused: { + previous: false, + current: true, + }, + }, + }, + { + type: "onChanged", + data: { + id, + error: { + previous: null, + current: "USER_CANCELED", + }, + }, + }, + { + type: "onChanged", + data: { + id, + paused: { + previous: true, + current: false, + }, + }, + }, + ]); + equal( + msg.status, + "success", + "got onChanged events corresponding to cancel()" + ); + + msg = await runInExtension("search", { error: "USER_CANCELED" }); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + equal(msg.result[0].id, id, "download.id is correct"); + equal(msg.result[0].state, "interrupted", "download.state is correct"); + equal(msg.result[0].paused, false, "download.paused is correct"); + equal( + msg.result[0].estimatedEndTime, + null, + "download.estimatedEndTime is correct" + ); + equal(msg.result[0].canResume, false, "download.canResume is correct"); + equal(msg.result[0].error, "USER_CANCELED", "download.error is correct"); + equal( + msg.result[0].totalBytes, + INT_TOTAL_LEN, + "download.totalBytes is correct" + ); + equal(msg.result[0].exists, false, "download.exists is correct"); + + msg = await runInExtension("pause", id); + equal(msg.status, "error", "cannot pause a canceled download"); + + msg = await runInExtension("resume", id); + equal(msg.status, "error", "cannot resume a canceled download"); +}); + +add_task(async function test_pauseresume() { + const filename = "pauseresume.html"; + let url = getInterruptibleUrl(filename); + let msg = await runInExtension("download", { url }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + let progressPromise = waitForProgress(url, bytes => bytes == INT_PARTIAL_LEN); + + msg = await runInExtension("waitForEvents", [ + { type: "onCreated", data: { id } }, + ]); + equal(msg.status, "success", "got created and changed events"); + + await progressPromise; + info(`download reached ${INT_PARTIAL_LEN} bytes`); + + // Prevent intermittent timeouts due to the part file not yet created + // (e.g. see Bug 1573360). + await waitForCreatedPartFile(filename); + + info("Pause the download item"); + msg = await runInExtension("pause", id); + equal(msg.status, "success", "pause() succeeded"); + + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "interrupted", + }, + paused: { + previous: false, + current: true, + }, + canResume: { + previous: false, + current: true, + }, + }, + }, + { + type: "onChanged", + data: { + id, + error: { + previous: null, + current: "USER_CANCELED", + }, + }, + }, + ]); + equal(msg.status, "success", "got onChanged event corresponding to pause"); + + msg = await runInExtension("search", { paused: true }); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + equal(msg.result[0].id, id, "download.id is correct"); + equal(msg.result[0].state, "interrupted", "download.state is correct"); + equal(msg.result[0].paused, true, "download.paused is correct"); + equal( + msg.result[0].estimatedEndTime, + null, + "download.estimatedEndTime is correct" + ); + equal(msg.result[0].canResume, true, "download.canResume is correct"); + equal(msg.result[0].error, "USER_CANCELED", "download.error is correct"); + equal( + msg.result[0].bytesReceived, + INT_PARTIAL_LEN, + "download.bytesReceived is correct" + ); + equal( + msg.result[0].totalBytes, + INT_TOTAL_LEN, + "download.totalBytes is correct" + ); + equal(msg.result[0].exists, false, "download.exists is correct"); + + msg = await runInExtension("search", { error: "USER_CANCELED" }); + equal(msg.status, "success", "search() succeeded"); + let found = msg.result.filter(item => item.id == id); + equal(found.length, 1, "search() by error found the paused download"); + + msg = await runInExtension("pause", id); + equal(msg.status, "error", "cannot pause an already paused download"); + + msg = await runInExtension("resume", id); + equal(msg.status, "success", "resume() succeeded"); + + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + id, + state: { + previous: "interrupted", + current: "in_progress", + }, + paused: { + previous: true, + current: false, + }, + canResume: { + previous: true, + current: false, + }, + error: { + previous: "USER_CANCELED", + current: null, + }, + }, + }, + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "complete", + }, + }, + }, + ]); + equal(msg.status, "success", "got onChanged events for resume and complete"); + + msg = await runInExtension("search", { id }); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + equal(msg.result[0].state, "complete", "download.state is correct"); + equal(msg.result[0].paused, false, "download.paused is correct"); + equal( + msg.result[0].estimatedEndTime, + null, + "download.estimatedEndTime is correct" + ); + equal(msg.result[0].canResume, false, "download.canResume is correct"); + equal(msg.result[0].error, null, "download.error is correct"); + equal( + msg.result[0].bytesReceived, + INT_TOTAL_LEN, + "download.bytesReceived is correct" + ); + equal( + msg.result[0].totalBytes, + INT_TOTAL_LEN, + "download.totalBytes is correct" + ); + equal(msg.result[0].exists, true, "download.exists is correct"); + + msg = await runInExtension("pause", id); + equal(msg.status, "error", "cannot pause a completed download"); + + msg = await runInExtension("resume", id); + equal(msg.status, "error", "cannot resume a completed download"); +}); + +add_task(async function test_pausecancel() { + let url = getInterruptibleUrl(); + let msg = await runInExtension("download", { url }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + let progressPromise = waitForProgress(url, bytes => bytes == INT_PARTIAL_LEN); + + msg = await runInExtension("waitForEvents", [ + { type: "onCreated", data: { id } }, + ]); + equal(msg.status, "success", "got created and changed events"); + + await progressPromise; + info(`download reached ${INT_PARTIAL_LEN} bytes`); + + msg = await runInExtension("pause", id); + equal(msg.status, "success", "pause() succeeded"); + + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "interrupted", + }, + paused: { + previous: false, + current: true, + }, + canResume: { + previous: false, + current: true, + }, + }, + }, + { + type: "onChanged", + data: { + id, + error: { + previous: null, + current: "USER_CANCELED", + }, + }, + }, + ]); + equal(msg.status, "success", "got onChanged event corresponding to pause"); + + msg = await runInExtension("search", { paused: true }); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + equal(msg.result[0].id, id, "download.id is correct"); + equal(msg.result[0].state, "interrupted", "download.state is correct"); + equal(msg.result[0].paused, true, "download.paused is correct"); + equal( + msg.result[0].estimatedEndTime, + null, + "download.estimatedEndTime is correct" + ); + equal(msg.result[0].canResume, true, "download.canResume is correct"); + equal(msg.result[0].error, "USER_CANCELED", "download.error is correct"); + equal( + msg.result[0].bytesReceived, + INT_PARTIAL_LEN, + "download.bytesReceived is correct" + ); + equal( + msg.result[0].totalBytes, + INT_TOTAL_LEN, + "download.totalBytes is correct" + ); + equal(msg.result[0].exists, false, "download.exists is correct"); + + msg = await runInExtension("search", { error: "USER_CANCELED" }); + equal(msg.status, "success", "search() succeeded"); + let found = msg.result.filter(item => item.id == id); + equal(found.length, 1, "search() by error found the paused download"); + + msg = await runInExtension("cancel", id); + equal(msg.status, "success", "cancel() succeeded"); + + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + id, + paused: { + previous: true, + current: false, + }, + canResume: { + previous: true, + current: false, + }, + }, + }, + ]); + equal(msg.status, "success", "got onChanged event for cancel"); + + msg = await runInExtension("search", { id }); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + equal(msg.result[0].state, "interrupted", "download.state is correct"); + equal(msg.result[0].paused, false, "download.paused is correct"); + equal( + msg.result[0].estimatedEndTime, + null, + "download.estimatedEndTime is correct" + ); + equal(msg.result[0].canResume, false, "download.canResume is correct"); + equal(msg.result[0].error, "USER_CANCELED", "download.error is correct"); + equal( + msg.result[0].totalBytes, + INT_TOTAL_LEN, + "download.totalBytes is correct" + ); + equal(msg.result[0].exists, false, "download.exists is correct"); +}); + +add_task(async function test_pause_resume_cancel_badargs() { + let BAD_ID = 1000; + + let msg = await runInExtension("pause", BAD_ID); + equal(msg.status, "error", "pause() failed with a bad download id"); + ok(/Invalid download id/.test(msg.errmsg), "error message is descriptive"); + + msg = await runInExtension("resume", BAD_ID); + equal(msg.status, "error", "resume() failed with a bad download id"); + ok(/Invalid download id/.test(msg.errmsg), "error message is descriptive"); + + msg = await runInExtension("cancel", BAD_ID); + equal(msg.status, "error", "cancel() failed with a bad download id"); + ok(/Invalid download id/.test(msg.errmsg), "error message is descriptive"); +}); + +add_task(async function test_file_removal() { + let msg = await runInExtension("download", { url: TXT_URL }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + msg = await runInExtension("waitForEvents", [ + { type: "onCreated", data: { id, url: TXT_URL } }, + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "complete", + }, + }, + }, + ]); + + equal(msg.status, "success", "got onCreated and onChanged events"); + + msg = await runInExtension("removeFile", id); + equal(msg.status, "success", "removeFile() succeeded"); + + msg = await runInExtension("removeFile", id); + equal( + msg.status, + "error", + "removeFile() fails since the file was already removed." + ); + equal( + msg.errmsg, + `Could not remove download id ${id} because the file doesn't exist`, + "removeFile() failed on removed file." + ); + + msg = await runInExtension("removeFile", 1000); + equal( + msg.errmsg, + "Invalid download id 1000", + "removeFile() failed due to non-existent id" + ); +}); + +add_task(async function test_file_removeFile_permission_failure() { + const inputDirname = "subdir_for_download"; + const inputFilename = "downloaded_filename.txt"; + const expectedDir = PathUtils.join(downloadDir.path, inputDirname); + const expectedPath = PathUtils.join(expectedDir, inputFilename); + + let msg = await runInExtension("download", { + url: TXT_URL, + filename: `${inputDirname}/${inputFilename}`, + }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + msg = await runInExtension("waitForEvents", [ + { type: "onCreated", data: { id, url: TXT_URL } }, + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "complete", + }, + }, + }, + ]); + + equal(msg.status, "success", "got onCreated and onChanged events"); + + msg = await runInExtension("search", { id }); + equal(msg.status, "success", "search() succeeded"); + equal(expectedPath, msg.result[0]?.filename, "Got expected filename"); + + async function withUndeletableFileUnix(testRemoveFile) { + try { + // Temporarily make directory unreadable/inaccessible. + await IOUtils.setPermissions(expectedDir, 0); + // Remove should fail with Unix error 13 (EACCES). + await testRemoveFile(); + } finally { + await IOUtils.setPermissions(expectedDir, 0o777); + } + } + async function withUndeletableFileWin(testRemoveFile) { + // On Windows, a directory marked as read-only does not prevent the deletion + // of its content. So we need an alternative approach here. + let stream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + try { + // Open a file handle. The file cannot be deleted until it is closed. + stream.init(await IOUtils.getFile(expectedPath), -1, 0, 0); + // Remove should fail with Win error 32 (ERROR_SHARING_VIOLATION). + await testRemoveFile(); + } finally { + stream.close(); + } + } + + const withUndeletableFile = + AppConstants.platform === "win" + ? withUndeletableFileWin + : withUndeletableFileUnix; + + let consoleOutput; + await withUndeletableFile(async () => { + consoleOutput = await promiseConsoleOutput(async () => { + msg = await runInExtension("removeFile", id); + }); + }); + + equal(msg.status, "error", "removeFile() fails due to missing dir perms"); + // Verify that an unexpected error is redacted, with a useful error message + // logged to the console. + // Note: if we ever decide to make the error for permission failures more + // useful, try to add a new test case for unexpected errors, even if + // completely artificial such as mocking + breaking an internal API. + equal(msg.errmsg, "An unexpected error occurred", "Error message redacted"); + + AddonTestUtils.checkMessages(consoleOutput.messages, { + expected: [{ message: /NotAllowedError/ }], + }); + + ok(await IOUtils.exists(expectedPath), "File exists before removeFile()"); + + msg = await runInExtension("removeFile", id); + equal(msg.status, "success", "removeFile() succeeded"); + + equal(await IOUtils.exists(expectedPath), false, "File was really removed"); + + // As a bonus: check that the re-created file can be deleted without issues. + await IOUtils.writeUTF8(expectedPath, "content here"); + + msg = await runInExtension("removeFile", id); + equal(msg.status, "success", "removeFile() succeeded after recreation"); + + equal(await IOUtils.exists(expectedPath), false, "File was removed again"); +}); + +add_task(async function test_removal_of_incomplete_download() { + const filename = "remove-incomplete.html"; + let url = getInterruptibleUrl(filename); + let msg = await runInExtension("download", { url }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + let progressPromise = waitForProgress(url, bytes => bytes == INT_PARTIAL_LEN); + + msg = await runInExtension("waitForEvents", [ + { type: "onCreated", data: { id } }, + ]); + equal(msg.status, "success", "got created and changed events"); + + await progressPromise; + info(`download reached ${INT_PARTIAL_LEN} bytes`); + + // Prevent intermittent timeouts due to the part file not yet created + // (e.g. see Bug 1573360). + await waitForCreatedPartFile(filename); + + msg = await runInExtension("pause", id); + equal(msg.status, "success", "pause() succeeded"); + + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "interrupted", + }, + paused: { + previous: false, + current: true, + }, + canResume: { + previous: false, + current: true, + }, + }, + }, + { + type: "onChanged", + data: { + id, + error: { + previous: null, + current: "USER_CANCELED", + }, + }, + }, + ]); + equal(msg.status, "success", "got onChanged event corresponding to pause"); + + msg = await runInExtension("removeFile", id); + equal(msg.status, "error", "removeFile() on paused download failed"); + + ok( + /Cannot remove incomplete download/.test(msg.errmsg), + "removeFile() failed due to download being incomplete" + ); + + msg = await runInExtension("resume", id); + equal(msg.status, "success", "resume() succeeded"); + + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + id, + state: { + previous: "interrupted", + current: "in_progress", + }, + paused: { + previous: true, + current: false, + }, + canResume: { + previous: true, + current: false, + }, + error: { + previous: "USER_CANCELED", + current: null, + }, + }, + }, + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "complete", + }, + }, + }, + ]); + equal(msg.status, "success", "got onChanged events for resume and complete"); + + msg = await runInExtension("removeFile", id); + equal( + msg.status, + "success", + "removeFile() succeeded following completion of resumed download." + ); +}); + +// Test erase(). We don't do elaborate testing of the query handling +// since it uses the exact same engine as search() which is tested +// more thoroughly in test_chrome_ext_downloads_search.html +add_task(async function test_erase() { + await clearDownloads(); + + await runInExtension("clearEvents"); + + async function download() { + let msg = await runInExtension("download", { url: TXT_URL }); + equal(msg.status, "success", "download succeeded"); + let id = msg.result; + + msg = await runInExtension( + "waitForEvents", + [ + { + type: "onChanged", + data: { id, state: { current: "complete" } }, + }, + ], + { exact: false } + ); + equal(msg.status, "success", "download finished"); + + return id; + } + + let ids = {}; + ids.dl1 = await download(); + ids.dl2 = await download(); + ids.dl3 = await download(); + + let msg = await runInExtension("search", {}); + equal(msg.status, "success", "search succeeded"); + equal(msg.result.length, 3, "search found 3 downloads"); + + msg = await runInExtension("clearEvents"); + + msg = await runInExtension("erase", { id: ids.dl1 }); + equal(msg.status, "success", "erase by id succeeded"); + + msg = await runInExtension("waitForEvents", [ + { type: "onErased", data: ids.dl1 }, + ]); + equal(msg.status, "success", "received onErased event"); + + msg = await runInExtension("search", {}); + equal(msg.status, "success", "search succeeded"); + equal(msg.result.length, 2, "search found 2 downloads"); + + msg = await runInExtension("erase", {}); + equal(msg.status, "success", "erase everything succeeded"); + + msg = await runInExtension( + "waitForEvents", + [ + { type: "onErased", data: ids.dl2 }, + { type: "onErased", data: ids.dl3 }, + ], + { inorder: false } + ); + equal(msg.status, "success", "received 2 onErased events"); + + msg = await runInExtension("search", {}); + equal(msg.status, "success", "search succeeded"); + equal(msg.result.length, 0, "search found 0 downloads"); +}); + +function loadImage(img, data) { + return new Promise(resolve => { + img.src = data; + img.onload = resolve; + }); +} + +add_task(async function test_getFileIcon() { + let webNav = Services.appShell.createWindowlessBrowser(false); + let docShell = webNav.docShell; + + let system = Services.scriptSecurityManager.getSystemPrincipal(); + docShell.createAboutBlankDocumentViewer(system, system); + + let img = webNav.document.createElement("img"); + + let msg = await runInExtension("download", { url: TXT_URL }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + msg = await runInExtension("getFileIcon", id); + equal(msg.status, "success", "getFileIcon() succeeded"); + await loadImage(img, msg.result); + equal(img.height, 32, "returns an icon with the right height"); + equal(img.width, 32, "returns an icon with the right width"); + + msg = await runInExtension("waitForEvents", [ + { type: "onCreated", data: { id, url: TXT_URL } }, + { type: "onChanged" }, + ]); + equal(msg.status, "success", "got events"); + + msg = await runInExtension("getFileIcon", id); + equal(msg.status, "success", "getFileIcon() succeeded"); + await loadImage(img, msg.result); + equal(img.height, 32, "returns an icon with the right height after download"); + equal(img.width, 32, "returns an icon with the right width after download"); + + msg = await runInExtension("getFileIcon", id + 100); + equal(msg.status, "error", "getFileIcon() failed"); + ok(msg.errmsg.includes("Invalid download id"), "download id is invalid"); + + msg = await runInExtension("getFileIcon", id, { size: 127 }); + equal(msg.status, "success", "getFileIcon() succeeded"); + await loadImage(img, msg.result); + equal(img.height, 127, "returns an icon with the right custom height"); + equal(img.width, 127, "returns an icon with the right custom width"); + + msg = await runInExtension("getFileIcon", id, { size: 1 }); + equal(msg.status, "success", "getFileIcon() succeeded"); + await loadImage(img, msg.result); + equal(img.height, 1, "returns an icon with the right custom height"); + equal(img.width, 1, "returns an icon with the right custom width"); + + msg = await runInExtension("getFileIcon", id, { size: "foo" }); + equal(msg.status, "error", "getFileIcon() fails"); + ok(msg.errmsg.includes("Error processing size"), "size is not a number"); + + msg = await runInExtension("getFileIcon", id, { size: 0 }); + equal(msg.status, "error", "getFileIcon() fails"); + ok(msg.errmsg.includes("Error processing size"), "size is too small"); + + msg = await runInExtension("getFileIcon", id, { size: 128 }); + equal(msg.status, "error", "getFileIcon() fails"); + ok(msg.errmsg.includes("Error processing size"), "size is too big"); + + webNav.close(); +}); + +add_task(async function test_estimatedendtime() { + // Note we are not testing the actual value calculation of estimatedEndTime, + // only whether it is null/non-null at the appropriate times. + + let url = `${getInterruptibleUrl()}&stream=1`; + let msg = await runInExtension("download", { url }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + let previousBytes = await waitForProgress(url, bytes => bytes > 0); + await waitForProgress(url, bytes => bytes > previousBytes); + + msg = await runInExtension("search", { id }); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + ok(msg.result[0].estimatedEndTime, "download.estimatedEndTime is correct"); + Assert.greater( + msg.result[0].bytesReceived, + 0, + "download.bytesReceived is correct" + ); + + msg = await runInExtension("cancel", id); + + msg = await runInExtension("search", { id }); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + ok(!msg.result[0].estimatedEndTime, "download.estimatedEndTime is correct"); +}); + +add_task(async function test_byExtension() { + let msg = await runInExtension("download", { url: TXT_URL }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + msg = await runInExtension("search", { id }); + + equal(msg.result.length, 1, "search() found 1 download"); + equal( + msg.result[0].byExtensionName, + "Generated extension", + "download.byExtensionName is correct" + ); + equal( + msg.result[0].byExtensionId, + extension.id, + "download.byExtensionId is correct" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_partitionKey.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_partitionKey.js new file mode 100644 index 0000000000..3326ed0ce9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_partitionKey.js @@ -0,0 +1,199 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { Downloads } = ChromeUtils.importESModule( + "resource://gre/modules/Downloads.sys.mjs" +); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE = `http://localhost:${server.identity.primaryPort}/data`; +const TEST_FILE = "file_download.txt"; +const TEST_URL = BASE + "/" + TEST_FILE; + +// We use different cookieBehaviors so that we can verify if we use the correct +// cookieBehavior if option.incognito is set. Note that we need to set a +// non-default value to the private cookieBehavior because the private +// cookieBehavior will mirror the regular cookieBehavior if the private pref is +// default value and the regular pref is non-default value. To avoid affecting +// the test by mirroring, we set the private cookieBehavior to a non-default +// value. +const TEST_REGULAR_COOKIE_BEHAVIOR = + Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER; +const TEST_PRIVATE_COOKIE_BEHAVIOR = Ci.nsICookieService.BEHAVIOR_LIMIT_FOREIGN; + +let downloadDir; + +function observeDownloadChannel(uri, partitionKey, isPrivate) { + return new Promise(resolve => { + let observer = { + observe(subject, topic, data) { + if (topic === "http-on-modify-request") { + let httpChannel = subject.QueryInterface(Ci.nsIHttpChannel); + if (httpChannel.URI.spec != uri) { + return; + } + + let reqLoadInfo = httpChannel.loadInfo; + let cookieJarSettings = reqLoadInfo.cookieJarSettings; + + // Check the partitionKey of the cookieJarSettings. + equal( + cookieJarSettings.partitionKey, + partitionKey, + "The loadInfo has the correct paritionKey" + ); + + // Check the cookieBehavior of the cookieJarSettings. + equal( + cookieJarSettings.cookieBehavior, + isPrivate + ? TEST_PRIVATE_COOKIE_BEHAVIOR + : TEST_REGULAR_COOKIE_BEHAVIOR, + "The loadInfo has the correct cookieBehavior" + ); + + Services.obs.removeObserver(observer, "http-on-modify-request"); + resolve(); + } + }, + }; + + Services.obs.addObserver(observer, "http-on-modify-request"); + }); +} + +async function waitForDownloads() { + let list = await Downloads.getList(Downloads.ALL); + let downloads = await list.getAll(); + + let inprogress = downloads.filter(dl => !dl.stopped); + return Promise.all(inprogress.map(dl => dl.whenSucceeded())); +} + +function backgroundScript() { + browser.test.onMessage.addListener(async (msg, ...args) => { + if (msg == "download.request") { + let options = args[0]; + + try { + let id = await browser.downloads.download(options); + browser.test.sendMessage("download.done", { status: "success", id }); + } catch (error) { + browser.test.sendMessage("download.done", { + status: "error", + errmsg: error.message, + }); + } + } + }); + + browser.test.sendMessage("ready"); +} + +// Remove a file in the downloads directory. +function remove(filename, recursive = false) { + let file = downloadDir.clone(); + file.append(filename); + file.remove(recursive); +} + +add_task(function setup() { + downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique( + Ci.nsIFile.DIRECTORY_TYPE, + FileUtils.PERMS_DIRECTORY + ); + info(`Using download directory ${downloadDir.path}`); + + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue( + "browser.download.dir", + Ci.nsIFile, + downloadDir + ); + Services.prefs.setBoolPref("privacy.partition.network_state", true); + + Services.prefs.setIntPref( + "network.cookie.cookieBehavior", + TEST_REGULAR_COOKIE_BEHAVIOR + ); + Services.prefs.setIntPref( + "network.cookie.cookieBehavior.pbmode", + TEST_PRIVATE_COOKIE_BEHAVIOR + ); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + Services.prefs.clearUserPref("privacy.partition.network_state"); + Services.prefs.clearUserPref("network.cookie.cookieBehavior"); + Services.prefs.clearUserPref("network.cookie.cookieBehavior.pbmode"); + + let entries = downloadDir.directoryEntries; + while (entries.hasMoreElements()) { + let entry = entries.nextFile; + info(`Leftover file ${entry.path} in download directory`); + ok(false, `Leftover file ${entry.path} in download directory`); + entry.remove(false); + } + + downloadDir.remove(false); + }); +}); + +add_task(async function test() { + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["downloads"], + }, + incognitoOverride: "spanning", + }); + + function download(options) { + extension.sendMessage("download.request", options); + return extension.awaitMessage("download.done"); + } + + async function testDownload(url, partitionKey, isPrivate) { + let options = { url, incognito: isPrivate }; + + let promiseObserveDownloadChannel = observeDownloadChannel( + url, + partitionKey, + isPrivate + ); + + let msg = await download(options); + equal(msg.status, "success", `downloads.download() works`); + + await promiseObserveDownloadChannel; + await waitForDownloads(); + } + + await extension.startup(); + await extension.awaitMessage("ready"); + info("extension started"); + + // Call download() to check partitionKey of the download channel for the + // regular browsing mode. + await testDownload( + TEST_URL, + `(http,localhost,${server.identity.primaryPort})`, + false + ); + remove(TEST_FILE); + + // Call download again for the private browsing mode. + await testDownload( + TEST_URL, + `(http,localhost,${server.identity.primaryPort})`, + true + ); + remove(TEST_FILE); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_private.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_private.js new file mode 100644 index 0000000000..caf664fb86 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_private.js @@ -0,0 +1,306 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE = `http://localhost:${server.identity.primaryPort}/data`; +const TXT_FILE = "file_download.txt"; +const TXT_URL = BASE + "/" + TXT_FILE; + +add_task(function setup() { + let downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique( + Ci.nsIFile.DIRECTORY_TYPE, + FileUtils.PERMS_DIRECTORY + ); + info(`Using download directory ${downloadDir.path}`); + + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue( + "browser.download.dir", + Ci.nsIFile, + downloadDir + ); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + + let entries = downloadDir.directoryEntries; + while (entries.hasMoreElements()) { + let entry = entries.nextFile; + ok(false, `Leftover file ${entry.path} in download directory`); + entry.remove(false); + } + + downloadDir.remove(false); + }); +}); + +add_task(async function test_private_download() { + let pb_extension = ExtensionTestUtils.loadExtension({ + background: async function () { + function promiseEvent(eventTarget, accept) { + return new Promise(resolve => { + eventTarget.addListener(function listener(data) { + if (accept && !accept(data)) { + return; + } + eventTarget.removeListener(listener); + resolve(data); + }); + }); + } + let startTestPromise = promiseEvent(browser.test.onMessage); + let removeTestPromise = promiseEvent( + browser.test.onMessage, + msg => msg == "remove" + ); + let onCreatedPromise = promiseEvent(browser.downloads.onCreated); + let onDonePromise = promiseEvent( + browser.downloads.onChanged, + delta => delta.state && delta.state.current === "complete" + ); + + browser.test.sendMessage("ready"); + let { url, filename } = await startTestPromise; + + browser.test.log("Starting private download"); + let downloadId = await browser.downloads.download({ + url, + filename, + incognito: true, + }); + browser.test.sendMessage("downloadId", downloadId); + + browser.test.log("Waiting for downloads.onCreated"); + let createdItem = await onCreatedPromise; + + browser.test.log("Waiting for completion notification"); + await onDonePromise; + + // test_ext_downloads_download.js already tests whether the file exists + // in the file system. Here we will only verify that the downloads API + // behaves in a meaningful way. + + let [downloadItem] = await browser.downloads.search({ id: downloadId }); + browser.test.assertEq(url, createdItem.url, "onCreated url should match"); + browser.test.assertEq(url, downloadItem.url, "download url should match"); + browser.test.assertTrue( + createdItem.incognito, + "created download should be private" + ); + browser.test.assertTrue( + downloadItem.incognito, + "stored download should be private" + ); + + await removeTestPromise; + browser.test.log("Removing downloaded file"); + browser.test.assertTrue(downloadItem.exists, "downloaded file exists"); + await browser.downloads.removeFile(downloadId); + + // Disabled because the assertion fails - https://bugzil.la/1381031 + // let [downloadItem2] = await browser.downloads.search({id: downloadId}); + // browser.test.assertFalse(downloadItem2.exists, "file should be deleted"); + + browser.test.log("Erasing private download from history"); + let erasePromise = promiseEvent(browser.downloads.onErased); + await browser.downloads.erase({ id: downloadId }); + browser.test.assertEq( + downloadId, + await erasePromise, + "onErased should be fired for the erased private download" + ); + + browser.test.notifyPass("private download test done"); + }, + manifest: { + browser_specific_settings: { gecko: { id: "@spanning" } }, + permissions: ["downloads"], + }, + incognitoOverride: "spanning", + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: "@not_allowed" } }, + permissions: ["downloads", "downloads.open"], + }, + background: async function () { + browser.downloads.onCreated.addListener(() => { + browser.test.fail("download-onCreated"); + }); + browser.downloads.onChanged.addListener(() => { + browser.test.fail("download-onChanged"); + }); + browser.downloads.onErased.addListener(() => { + browser.test.fail("download-onErased"); + }); + browser.test.onMessage.addListener(async (msg, data) => { + if (msg == "download") { + let { url, filename, downloadId } = data; + await browser.test.assertRejects( + browser.downloads.download({ + url, + filename, + incognito: true, + }), + /private browsing access not allowed/, + "cannot download using incognito without permission." + ); + + let downloads = await browser.downloads.search({ id: downloadId }); + browser.test.assertEq( + downloads.length, + 0, + "cannot search for incognito downloads" + ); + let erasing = await browser.downloads.erase({ id: downloadId }); + browser.test.assertEq( + erasing.length, + 0, + "cannot erase incognito download" + ); + + await browser.test.assertRejects( + browser.downloads.removeFile(downloadId), + /Invalid download id/, + "cannot remove incognito download" + ); + await browser.test.assertRejects( + browser.downloads.pause(downloadId), + /Invalid download id/, + "cannot pause incognito download" + ); + await browser.test.assertRejects( + browser.downloads.resume(downloadId), + /Invalid download id/, + "cannot resume incognito download" + ); + await browser.test.assertRejects( + browser.downloads.cancel(downloadId), + /Invalid download id/, + "cannot cancel incognito download" + ); + await browser.test.assertRejects( + browser.downloads.removeFile(downloadId), + /Invalid download id/, + "cannot remove incognito download" + ); + await browser.test.assertRejects( + browser.downloads.show(downloadId), + /Invalid download id/, + "cannot show incognito download" + ); + await browser.test.assertRejects( + browser.downloads.getFileIcon(downloadId), + /Invalid download id/, + "cannot show incognito download" + ); + } + if (msg == "download.open") { + let { downloadId } = data; + await browser.test.assertRejects( + browser.downloads.open(downloadId), + /Invalid download id/, + "cannot open incognito download" + ); + } + browser.test.sendMessage("continue"); + }); + }, + }); + + await extension.startup(); + await pb_extension.startup(); + await pb_extension.awaitMessage("ready"); + pb_extension.sendMessage({ + url: TXT_URL, + filename: TXT_FILE, + }); + let downloadId = await pb_extension.awaitMessage("downloadId"); + extension.sendMessage("download", { + url: TXT_URL, + filename: TXT_FILE, + downloadId, + }); + await extension.awaitMessage("continue"); + await withHandlingUserInput(extension, async () => { + extension.sendMessage("download.open", { downloadId }); + await extension.awaitMessage("continue"); + }); + pb_extension.sendMessage("remove"); + + await pb_extension.awaitFinish("private download test done"); + await pb_extension.unload(); + await extension.unload(); +}); + +// Regression test for https://bugzilla.mozilla.org/show_bug.cgi?id=1649463 +add_task(async function download_blob_in_perma_private_browsing() { + Services.prefs.setBoolPref("browser.privatebrowsing.autostart", true); + + // This script creates a blob:-URL and checks that the URL can be downloaded. + async function testScript() { + const blobUrl = URL.createObjectURL(new Blob(["data here"])); + const downloadId = await new Promise(resolve => { + browser.downloads.onChanged.addListener(delta => { + browser.test.log(`downloads.onChanged = ${JSON.stringify(delta)}`); + if (delta.state && delta.state.current !== "in_progress") { + resolve(delta.id); + } + }); + browser.downloads.download({ + url: blobUrl, + filename: "some-blob-download.txt", + }); + }); + + let [downloadItem] = await browser.downloads.search({ id: downloadId }); + browser.test.log(`Downloaded ${JSON.stringify(downloadItem)}`); + browser.test.assertEq(downloadItem.url, blobUrl, "expected blob URL"); + // TODO bug 1653636: should be true because of perma-private browsing. + // browser.test.assertTrue(downloadItem.incognito, "download is private"); + browser.test.assertFalse( + downloadItem.incognito, + "download is private [skipped - to be fixed in bug 1653636]" + ); + browser.test.assertTrue(downloadItem.exists, "download exists"); + await browser.downloads.removeFile(downloadId); + + browser.test.sendMessage("downloadDone"); + } + let pb_extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: "@private-download-ext" } }, + permissions: ["downloads"], + }, + background: testScript, + incognitoOverride: "spanning", + files: { + "test_part2.html": ` + <!DOCTYPE html><meta charset="utf-8"> + <script src="test_part2.js"></script> + `, + "test_part2.js": testScript, + }, + }); + await pb_extension.startup(); + + info("Testing download of blob:-URL from extension's background page"); + await pb_extension.awaitMessage("downloadDone"); + + info("Testing download of blob:-URL with different userContextId"); + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${pb_extension.uuid}/test_part2.html`, + { extension: pb_extension, userContextId: 2 } + ); + await pb_extension.awaitMessage("downloadDone"); + await contentPage.close(); + + await pb_extension.unload(); + Services.prefs.clearUserPref("browser.privatebrowsing.autostart"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_search.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_search.js new file mode 100644 index 0000000000..37c497a9b6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_search.js @@ -0,0 +1,682 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { Downloads } = ChromeUtils.importESModule( + "resource://gre/modules/Downloads.sys.mjs" +); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE = `http://localhost:${server.identity.primaryPort}/data`; +const TXT_FILE = "file_download.txt"; +const TXT_URL = BASE + "/" + TXT_FILE; +const TXT_LEN = 46; +const HTML_FILE = "file_download.html"; +const HTML_URL = BASE + "/" + HTML_FILE; +const HTML_LEN = 117; +const EMPTY_FILE = "empty_file_download.txt"; +const EMPTY_URL = BASE + "/" + EMPTY_FILE; +const EMPTY_LEN = 0; +const BIG_LEN = 1000; // something bigger both TXT_LEN and HTML_LEN + +function backgroundScript() { + let complete = new Map(); + + function waitForComplete(id) { + if (complete.has(id)) { + return complete.get(id).promise; + } + + let promise = new Promise(resolve => { + complete.set(id, { resolve }); + }); + complete.get(id).promise = promise; + return promise; + } + + browser.downloads.onChanged.addListener(change => { + if (change.state && change.state.current == "complete") { + // Make sure we have a promise. + waitForComplete(change.id); + complete.get(change.id).resolve(); + } + }); + + browser.test.onMessage.addListener(async (msg, ...args) => { + if (msg == "download.request") { + try { + let id = await browser.downloads.download(args[0]); + browser.test.sendMessage("download.done", { status: "success", id }); + } catch (error) { + browser.test.sendMessage("download.done", { + status: "error", + errmsg: error.message, + }); + } + } else if (msg == "search.request") { + try { + let downloads = await browser.downloads.search(args[0]); + browser.test.sendMessage("search.done", { + status: "success", + downloads, + }); + } catch (error) { + browser.test.sendMessage("search.done", { + status: "error", + errmsg: error.message, + }); + } + } else if (msg == "waitForComplete.request") { + await waitForComplete(args[0]); + browser.test.sendMessage("waitForComplete.done"); + } + }); + + browser.test.sendMessage("ready"); +} + +async function clearDownloads(callback) { + let list = await Downloads.getList(Downloads.ALL); + let downloads = await list.getAll(); + + await Promise.all(downloads.map(download => list.remove(download))); + + return downloads; +} + +add_task(async function test_search() { + const nsIFile = Ci.nsIFile; + let downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + info(`downloadDir ${downloadDir.path}`); + + function downloadPath(filename) { + let path = downloadDir.clone(); + path.append(filename); + return path.path; + } + + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir); + Services.prefs.setBoolPref("privacy.reduceTimerPrecision", false); + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + Services.prefs.clearUserPref("privacy.reduceTimerPrecision"); + await cleanupDir(downloadDir); + await clearDownloads(); + }); + + await clearDownloads().then(downloads => { + info(`removed ${downloads.length} pre-existing downloads from history`); + }); + + let extension = ExtensionTestUtils.loadExtension({ + background: backgroundScript, + manifest: { + permissions: ["downloads"], + }, + }); + + async function download(options) { + extension.sendMessage("download.request", options); + let result = await extension.awaitMessage("download.done"); + + if (result.status == "success") { + info(`wait for onChanged event to indicate ${result.id} is complete`); + extension.sendMessage("waitForComplete.request", result.id); + + await extension.awaitMessage("waitForComplete.done"); + } + + return result; + } + + function search(query) { + extension.sendMessage("search.request", query); + return extension.awaitMessage("search.done"); + } + + await extension.startup(); + await extension.awaitMessage("ready"); + + // Do some downloads... + const time1 = new Date(); + + let downloadIds = {}; + let msg = await download({ url: TXT_URL }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.txt1 = msg.id; + + const TXT_FILE2 = "NewFile.txt"; + msg = await download({ url: TXT_URL, filename: TXT_FILE2 }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.txt2 = msg.id; + + msg = await download({ url: EMPTY_URL }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.txt3 = msg.id; + + const time2 = new Date(); + + msg = await download({ url: HTML_URL }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.html1 = msg.id; + + const HTML_FILE2 = "renamed.html"; + msg = await download({ url: HTML_URL, filename: HTML_FILE2 }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.html2 = msg.id; + + const time3 = new Date(); + + // Search for each individual download and check + // the corresponding DownloadItem. + async function checkDownloadItem(id, expect) { + let item = await search({ id }); + equal(item.status, "success", "search() succeeded"); + equal(item.downloads.length, 1, "search() found exactly 1 download"); + + Object.keys(expect).forEach(function (field) { + equal( + item.downloads[0][field], + expect[field], + `DownloadItem.${field} is correct"` + ); + }); + } + await checkDownloadItem(downloadIds.txt1, { + url: TXT_URL, + filename: downloadPath(TXT_FILE), + mime: "text/plain", + state: "complete", + bytesReceived: TXT_LEN, + totalBytes: TXT_LEN, + fileSize: TXT_LEN, + exists: true, + }); + + await checkDownloadItem(downloadIds.txt2, { + url: TXT_URL, + filename: downloadPath(TXT_FILE2), + mime: "text/plain", + state: "complete", + bytesReceived: TXT_LEN, + totalBytes: TXT_LEN, + fileSize: TXT_LEN, + exists: true, + }); + + await checkDownloadItem(downloadIds.txt3, { + url: EMPTY_URL, + filename: downloadPath(EMPTY_FILE), + mime: "text/plain", + state: "complete", + bytesReceived: EMPTY_LEN, + totalBytes: EMPTY_LEN, + fileSize: EMPTY_LEN, + exists: true, + }); + + await checkDownloadItem(downloadIds.html1, { + url: HTML_URL, + filename: downloadPath(HTML_FILE), + mime: "text/html", + state: "complete", + bytesReceived: HTML_LEN, + totalBytes: HTML_LEN, + fileSize: HTML_LEN, + exists: true, + }); + + await checkDownloadItem(downloadIds.html2, { + url: HTML_URL, + filename: downloadPath(HTML_FILE2), + mime: "text/html", + state: "complete", + bytesReceived: HTML_LEN, + totalBytes: HTML_LEN, + fileSize: HTML_LEN, + exists: true, + }); + + async function checkSearch(query, expected, description, exact) { + let item = await search(query); + equal(item.status, "success", "search() succeeded"); + equal( + item.downloads.length, + expected.length, + `search() for ${description} found exactly ${expected.length} downloads` + ); + + let receivedIds = item.downloads.map(i => i.id); + if (exact) { + receivedIds.forEach((id, idx) => { + equal( + id, + downloadIds[expected[idx]], + `search() for ${description} returned ${expected[idx]} in position ${idx}` + ); + }); + } else { + Object.keys(downloadIds).forEach(key => { + const id = downloadIds[key]; + const thisExpected = expected.includes(key); + equal( + receivedIds.includes(id), + thisExpected, + `search() for ${description} ${ + thisExpected ? "includes" : "does not include" + } ${key}` + ); + }); + } + } + + // Check that search with an invalid id returns nothing. + // NB: for now ids are not persistent and we start numbering them at 1 + // so a sufficiently large number will be unused. + const INVALID_ID = 1000; + await checkSearch({ id: INVALID_ID }, [], "invalid id"); + + // Check that search on url works. + await checkSearch({ url: TXT_URL }, ["txt1", "txt2"], "url"); + + // Check that regexp on url works. + const HTML_REGEX = "[download]{8}.html+$"; + await checkSearch({ urlRegex: HTML_REGEX }, ["html1", "html2"], "url regexp"); + + // Check that compatible url+regexp works + await checkSearch( + { url: HTML_URL, urlRegex: HTML_REGEX }, + ["html1", "html2"], + "compatible url+urlRegex" + ); + + // Check that incompatible url+regexp works + await checkSearch( + { url: TXT_URL, urlRegex: HTML_REGEX }, + [], + "incompatible url+urlRegex" + ); + + // Check that search on filename works. + await checkSearch({ filename: downloadPath(TXT_FILE) }, ["txt1"], "filename"); + + // Check that regexp on filename works. + await checkSearch({ filenameRegex: HTML_REGEX }, ["html1"], "filename regex"); + + // Check that compatible filename+regexp works + await checkSearch( + { filename: downloadPath(HTML_FILE), filenameRegex: HTML_REGEX }, + ["html1"], + "compatible filename+filename regex" + ); + + // Check that incompatible filename+regexp works + await checkSearch( + { filename: downloadPath(TXT_FILE), filenameRegex: HTML_REGEX }, + [], + "incompatible filename+filename regex" + ); + + // Check that simple positive search terms work. + await checkSearch( + { query: ["file_download"] }, + ["txt1", "txt2", "txt3", "html1", "html2"], + "term file_download" + ); + await checkSearch({ query: ["NewFile"] }, ["txt2"], "term NewFile"); + + // Check that positive search terms work case-insensitive. + await checkSearch({ query: ["nEwfILe"] }, ["txt2"], "term nEwfiLe"); + + // Check that negative search terms work. + await checkSearch({ query: ["-txt"] }, ["html1", "html2"], "term -txt"); + + // Check that positive and negative search terms together work. + await checkSearch( + { query: ["html", "-renamed"] }, + ["html1"], + "positive and negative terms" + ); + + async function checkSearchWithDate(query, expected, description) { + const fields = Object.keys(query); + if (fields.length != 1 || !(query[fields[0]] instanceof Date)) { + throw new Error("checkSearchWithDate expects exactly one Date field"); + } + const field = fields[0]; + const date = query[field]; + + let newquery = {}; + + // Check as a Date + newquery[field] = date; + await checkSearch(newquery, expected, `${description} as Date`); + + // Check as numeric milliseconds + newquery[field] = date.valueOf(); + await checkSearch(newquery, expected, `${description} as numeric ms`); + + // Check as stringified milliseconds + newquery[field] = date.valueOf().toString(); + await checkSearch(newquery, expected, `${description} as string ms`); + + // Check as ISO string + newquery[field] = date.toISOString(); + await checkSearch(newquery, expected, `${description} as iso string`); + } + + // Check startedBefore + await checkSearchWithDate({ startedBefore: time1 }, [], "before time1"); + await checkSearchWithDate( + { startedBefore: time2 }, + ["txt1", "txt2", "txt3"], + "before time2" + ); + await checkSearchWithDate( + { startedBefore: time3 }, + ["txt1", "txt2", "txt3", "html1", "html2"], + "before time3" + ); + + // Check startedAfter + await checkSearchWithDate( + { startedAfter: time1 }, + ["txt1", "txt2", "txt3", "html1", "html2"], + "after time1" + ); + await checkSearchWithDate( + { startedAfter: time2 }, + ["html1", "html2"], + "after time2" + ); + await checkSearchWithDate({ startedAfter: time3 }, [], "after time3"); + + // Check simple search on totalBytes + await checkSearch({ totalBytes: TXT_LEN }, ["txt1", "txt2"], "totalBytes"); + await checkSearch({ totalBytes: HTML_LEN }, ["html1", "html2"], "totalBytes"); + + // Check simple test on totalBytes{Greater,Less} + // (NB: TXT_LEN < HTML_LEN < BIG_LEN) + await checkSearch( + { totalBytesGreater: 0 }, + ["txt1", "txt2", "html1", "html2"], + "totalBytesGreater than 0" + ); + await checkSearch( + { totalBytesGreater: TXT_LEN }, + ["html1", "html2"], + `totalBytesGreater than ${TXT_LEN}` + ); + await checkSearch( + { totalBytesGreater: HTML_LEN }, + [], + `totalBytesGreater than ${HTML_LEN}` + ); + await checkSearch( + { totalBytesLess: TXT_LEN }, + ["txt3"], + `totalBytesLess than ${TXT_LEN}` + ); + await checkSearch( + { totalBytesLess: HTML_LEN }, + ["txt1", "txt2", "txt3"], + `totalBytesLess than ${HTML_LEN}` + ); + await checkSearch( + { totalBytesLess: BIG_LEN }, + ["txt1", "txt2", "txt3", "html1", "html2"], + `totalBytesLess than ${BIG_LEN}` + ); + + // Bug 1503760 check if 0 byte files with no search query are returned. + await checkSearch( + {}, + ["txt1", "txt2", "txt3", "html1", "html2"], + "totalBytesGreater than -1" + ); + + // Check good combinations of totalBytes*. + await checkSearch( + { totalBytes: HTML_LEN, totalBytesGreater: TXT_LEN }, + ["html1", "html2"], + "totalBytes and totalBytesGreater" + ); + await checkSearch( + { totalBytes: TXT_LEN, totalBytesLess: HTML_LEN }, + ["txt1", "txt2"], + "totalBytes and totalBytesGreater" + ); + await checkSearch( + { totalBytes: HTML_LEN, totalBytesLess: BIG_LEN, totalBytesGreater: 0 }, + ["html1", "html2"], + "totalBytes and totalBytesLess and totalBytesGreater" + ); + + // Check bad combination of totalBytes*. + await checkSearch( + { totalBytesLess: TXT_LEN, totalBytesGreater: HTML_LEN }, + [], + "bad totalBytesLess, totalBytesGreater combination" + ); + await checkSearch( + { totalBytes: TXT_LEN, totalBytesGreater: HTML_LEN }, + [], + "bad totalBytes, totalBytesGreater combination" + ); + await checkSearch( + { totalBytes: HTML_LEN, totalBytesLess: TXT_LEN }, + [], + "bad totalBytes, totalBytesLess combination" + ); + + // Check mime. + await checkSearch( + { mime: "text/plain" }, + ["txt1", "txt2", "txt3"], + "mime text/plain" + ); + await checkSearch( + { mime: "text/html" }, + ["html1", "html2"], + "mime text/htmlplain" + ); + await checkSearch({ mime: "video/webm" }, [], "mime video/webm"); + + // Check fileSize. + await checkSearch({ fileSize: TXT_LEN }, ["txt1", "txt2"], "fileSize"); + await checkSearch({ fileSize: HTML_LEN }, ["html1", "html2"], "fileSize"); + + // Fields like bytesReceived, paused, state, exists are meaningful + // for downloads that are in progress but have not yet completed. + // todo: add tests for these when we have better support for in-progress + // downloads (e.g., after pause(), resume() and cancel() are implemented) + + // Check multiple query properties. + // We could make this testing arbitrarily complicated... + // We already tested combining fields with obvious interactions above + // (e.g., filename and filenameRegex or startTime and startedBefore/After) + // so now just throw as many fields as we can at a single search and + // make sure a simple case still works. + await checkSearch( + { + url: TXT_URL, + urlRegex: "download", + filename: downloadPath(TXT_FILE), + filenameRegex: "download", + query: ["download"], + startedAfter: time1.valueOf().toString(), + startedBefore: time2.valueOf().toString(), + totalBytes: TXT_LEN, + totalBytesGreater: 0, + totalBytesLess: BIG_LEN, + mime: "text/plain", + fileSize: TXT_LEN, + }, + ["txt1"], + "many properties" + ); + + // Check simple orderBy (forward and backward). + await checkSearch( + { orderBy: ["startTime"] }, + ["txt1", "txt2", "txt3", "html1", "html2"], + "orderBy startTime", + true + ); + await checkSearch( + { orderBy: ["-startTime"] }, + ["html2", "html1", "txt3", "txt2", "txt1"], + "orderBy -startTime", + true + ); + + // Check orderBy with multiple fields. + // NB: TXT_URL and HTML_URL differ only in extension and .html precedes .txt + // EMPTY_URL begins with e which precedes f + await checkSearch( + { orderBy: ["url", "-startTime"] }, + ["txt3", "html2", "html1", "txt2", "txt1"], + "orderBy with multiple fields", + true + ); + + // Check orderBy with limit. + await checkSearch( + { orderBy: ["url"], limit: 1 }, + ["txt3"], + "orderBy with limit", + true + ); + + // Check bad arguments. + async function checkBadSearch(query, pattern, description) { + let item = await search(query); + equal(item.status, "error", "search() failed"); + ok( + pattern.test(item.errmsg), + `error message for ${description} was correct (${item.errmsg}).` + ); + } + + await checkBadSearch( + "myquery", + /Incorrect argument type/, + "query is not an object" + ); + await checkBadSearch( + { bogus: "boo" }, + /Unexpected property/, + "query contains an unknown field" + ); + await checkBadSearch( + { query: "query string" }, + /Expected array/, + "query.query is a string" + ); + await checkBadSearch( + { startedBefore: "i am not a time" }, + /Type error/, + "query.startedBefore is not a valid time" + ); + await checkBadSearch( + { startedAfter: "i am not a time" }, + /Type error/, + "query.startedAfter is not a valid time" + ); + await checkBadSearch( + { endedBefore: "i am not a time" }, + /Type error/, + "query.endedBefore is not a valid time" + ); + await checkBadSearch( + { endedAfter: "i am not a time" }, + /Type error/, + "query.endedAfter is not a valid time" + ); + await checkBadSearch( + { urlRegex: "[" }, + /Invalid urlRegex/, + "query.urlRegexp is not a valid regular expression" + ); + await checkBadSearch( + { filenameRegex: "[" }, + /Invalid filenameRegex/, + "query.filenameRegexp is not a valid regular expression" + ); + await checkBadSearch( + { orderBy: "startTime" }, + /Expected array/, + "query.orderBy is not an array" + ); + await checkBadSearch( + { orderBy: ["bogus"] }, + /Invalid orderBy field/, + "query.orderBy references a non-existent field" + ); + + await extension.unload(); +}); + +// Test that downloads with totalBytes of -1 (ie, that have not yet started) +// work properly. See bug 1519762 for details of a past regression in +// this area. +add_task(async function test_inprogress() { + let resume, + resumePromise = new Promise(resolve => { + resume = resolve; + }); + let hit = false; + server.registerPathHandler("/data/slow", async (request, response) => { + hit = true; + response.processAsync(); + await resumePromise; + response.setHeader("Content-type", "text/plain"); + response.write(""); + response.finish(); + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["downloads"], + }, + background() { + browser.test.onMessage.addListener(async (msg, url) => { + let id = await browser.downloads.download({ url }); + let full = await browser.downloads.search({ id }); + + browser.test.assertEq( + full.length, + 1, + "Found new download in search results" + ); + browser.test.assertEq( + full[0].totalBytes, + -1, + "New download still has totalBytes == -1" + ); + + browser.downloads.onChanged.addListener(info => { + if (info.id == id && info.state && info.state.current == "complete") { + browser.test.notifyPass("done"); + } + }); + + browser.test.sendMessage("started"); + }); + }, + }); + + await extension.startup(); + extension.sendMessage("go", `${BASE}/slow`); + await extension.awaitMessage("started"); + resume(); + await extension.awaitFinish("done"); + await extension.unload(); + Assert.ok(hit, "slow path was actually hit"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_urlencoded.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_urlencoded.js new file mode 100644 index 0000000000..03288fb5d5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_urlencoded.js @@ -0,0 +1,257 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { Downloads } = ChromeUtils.importESModule( + "resource://gre/modules/Downloads.sys.mjs" +); + +function backgroundScript() { + let complete = new Map(); + + function waitForComplete(id) { + if (complete.has(id)) { + return complete.get(id).promise; + } + + let promise = new Promise(resolve => { + complete.set(id, { resolve }); + }); + complete.get(id).promise = promise; + return promise; + } + + browser.downloads.onChanged.addListener(change => { + if (change.state && change.state.current == "complete") { + // Make sure we have a promise. + waitForComplete(change.id); + complete.get(change.id).resolve(); + } + }); + + browser.test.onMessage.addListener(async (msg, ...args) => { + if (msg == "download.request") { + try { + let id = await browser.downloads.download(args[0]); + browser.test.sendMessage("download.done", { status: "success", id }); + } catch (error) { + browser.test.sendMessage("download.done", { + status: "error", + errmsg: error.message, + }); + } + } else if (msg == "search.request") { + try { + let downloads = await browser.downloads.search(args[0]); + browser.test.sendMessage("search.done", { + status: "success", + downloads, + }); + } catch (error) { + browser.test.sendMessage("search.done", { + status: "error", + errmsg: error.message, + }); + } + } else if (msg == "waitForComplete.request") { + await waitForComplete(args[0]); + browser.test.sendMessage("waitForComplete.done"); + } + }); + + browser.test.sendMessage("ready"); +} + +async function clearDownloads(callback) { + let list = await Downloads.getList(Downloads.ALL); + let downloads = await list.getAll(); + + await Promise.all(downloads.map(download => list.remove(download))); + + return downloads; +} + +add_task(async function test_decoded_filename_download() { + const server = createHttpServer(); + server.registerPrefixHandler("/data/", (_, res) => res.write("length=8")); + + const BASE = `http://localhost:${server.identity.primaryPort}/data`; + const FILE_NAME_ENCODED_1 = "file%2Fencode.txt"; + const FILE_NAME_DECODED_1 = "file_encode.txt"; + const FILE_NAME_ENCODED_URL_1 = BASE + "/" + FILE_NAME_ENCODED_1; + const FILE_NAME_ENCODED_2 = "file%F0%9F%9A%B2encoded.txt"; + const FILE_NAME_DECODED_2 = "file\u{0001F6B2}encoded.txt"; + const FILE_NAME_ENCODED_URL_2 = BASE + "/" + FILE_NAME_ENCODED_2; + const FILE_NAME_ENCODED_3 = "file%X%20encode.txt"; + const FILE_NAME_DECODED_3 = "file%X encode.txt"; + const FILE_NAME_ENCODED_URL_3 = BASE + "/" + FILE_NAME_ENCODED_3; + const FILE_NAME_ENCODED_4 = "file%E3%80%82encode.txt"; + const FILE_NAME_DECODED_4 = "file\u3002encode.txt"; + const FILE_NAME_ENCODED_URL_4 = BASE + "/" + FILE_NAME_ENCODED_4; + const FILE_ENCODED_LEN = 8; + + const nsIFile = Ci.nsIFile; + let downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + info(`downloadDir ${downloadDir.path}`); + + function downloadPath(filename) { + let path = downloadDir.clone(); + path.append(filename); + return path.path; + } + + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir); + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + await cleanupDir(downloadDir); + await clearDownloads(); + }); + + await clearDownloads().then(downloads => { + info(`removed ${downloads.length} pre-existing downloads from history`); + }); + + let extension = ExtensionTestUtils.loadExtension({ + background: backgroundScript, + manifest: { + permissions: ["downloads"], + }, + }); + + async function download(options) { + extension.sendMessage("download.request", options); + let result = await extension.awaitMessage("download.done"); + + if (result.status == "success") { + info(`wait for onChanged event to indicate ${result.id} is complete`); + extension.sendMessage("waitForComplete.request", result.id); + + await extension.awaitMessage("waitForComplete.done"); + } + + return result; + } + + function search(query) { + extension.sendMessage("search.request", query); + return extension.awaitMessage("search.done"); + } + + await extension.startup(); + await extension.awaitMessage("ready"); + + let downloadIds = {}; + let msg = await download({ url: FILE_NAME_ENCODED_URL_1 }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.fileEncoded1 = msg.id; + + msg = await download({ url: FILE_NAME_ENCODED_URL_2 }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.fileEncoded2 = msg.id; + + msg = await download({ url: FILE_NAME_ENCODED_URL_3 }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.fileEncoded3 = msg.id; + + msg = await download({ url: FILE_NAME_ENCODED_URL_4 }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.fileEncoded4 = msg.id; + + // Search for each individual download and check + // the corresponding DownloadItem. + async function checkDownloadItem(id, expect) { + let item = await search({ id }); + equal(item.status, "success", "search() succeeded"); + equal(item.downloads.length, 1, "search() found exactly 1 download"); + Object.keys(expect).forEach(function (field) { + equal( + item.downloads[0][field], + expect[field], + `DownloadItem.${field} is correct"` + ); + }); + } + + await checkDownloadItem(downloadIds.fileEncoded1, { + url: FILE_NAME_ENCODED_URL_1, + filename: downloadPath(FILE_NAME_DECODED_1), + state: "complete", + bytesReceived: FILE_ENCODED_LEN, + totalBytes: FILE_ENCODED_LEN, + fileSize: FILE_ENCODED_LEN, + exists: true, + }); + + await checkDownloadItem(downloadIds.fileEncoded2, { + url: FILE_NAME_ENCODED_URL_2, + filename: downloadPath(FILE_NAME_DECODED_2), + state: "complete", + bytesReceived: FILE_ENCODED_LEN, + totalBytes: FILE_ENCODED_LEN, + fileSize: FILE_ENCODED_LEN, + exists: true, + }); + + await checkDownloadItem(downloadIds.fileEncoded3, { + url: FILE_NAME_ENCODED_URL_3, + filename: downloadPath(FILE_NAME_DECODED_3), + state: "complete", + bytesReceived: FILE_ENCODED_LEN, + totalBytes: FILE_ENCODED_LEN, + fileSize: FILE_ENCODED_LEN, + exists: true, + }); + + await checkDownloadItem(downloadIds.fileEncoded4, { + url: FILE_NAME_ENCODED_URL_4, + filename: downloadPath(FILE_NAME_DECODED_4), + state: "complete", + bytesReceived: FILE_ENCODED_LEN, + totalBytes: FILE_ENCODED_LEN, + fileSize: FILE_ENCODED_LEN, + exists: true, + }); + + // Searching for downloads by the decoded filename works correctly. + async function checkSearch(query, expected, description) { + let item = await search(query); + equal(item.status, "success", "search() succeeded"); + equal( + item.downloads.length, + expected.length, + `search() for ${description} found exactly ${expected.length} downloads` + ); + equal( + item.downloads[0].id, + downloadIds[expected[0]], + `search() for ${description} returned ${expected[0]} in position ${0}` + ); + } + + await checkSearch( + { filename: downloadPath(FILE_NAME_DECODED_1) }, + ["fileEncoded1"], + "filename" + ); + await checkSearch( + { filename: downloadPath(FILE_NAME_DECODED_2) }, + ["fileEncoded2"], + "filename" + ); + await checkSearch( + { filename: downloadPath(FILE_NAME_DECODED_3) }, + ["fileEncoded3"], + "filename" + ); + await checkSearch( + { filename: downloadPath(FILE_NAME_DECODED_4) }, + ["fileEncoded4"], + "filename" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_error_location.js b/toolkit/components/extensions/test/xpcshell/test_ext_error_location.js new file mode 100644 index 0000000000..ab18c9c371 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_error_location.js @@ -0,0 +1,48 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_error_location() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + let { fileName } = new Error(); + + browser.test.sendMessage("fileName", fileName); + + browser.runtime.sendMessage("Meh.", () => {}); + + await browser.test.assertRejects( + browser.runtime.sendMessage("Meh"), + error => { + return error.fileName === fileName && error.lineNumber === 9; + } + ); + + browser.test.notifyPass("error-location"); + }, + }); + + let fileName; + const { messages } = await promiseConsoleOutput(async () => { + await extension.startup(); + + fileName = await extension.awaitMessage("fileName"); + + await extension.awaitFinish("error-location"); + + await extension.unload(); + }); + + let [msg] = messages.filter(m => m.message.includes("Unchecked lastError")); + + equal(msg.sourceName, fileName, "Message source"); + equal(msg.lineNumber, 6, "Message line"); + + let frame = msg.stack; + if (frame) { + equal(frame.source, fileName, "Frame source"); + equal(frame.line, 6, "Frame line"); + equal(frame.column, 23, "Frame column"); + equal(frame.functionDisplayName, "background", "Frame function name"); + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_idle.js b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_idle.js new file mode 100644 index 0000000000..46d34f1bcb --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_idle.js @@ -0,0 +1,774 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionPreferencesManager: + "resource://gre/modules/ExtensionPreferencesManager.sys.mjs", +}); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +Services.prefs.setBoolPref("extensions.eventPages.enabled", true); +// Set minimum idle timeout for testing +Services.prefs.setIntPref("extensions.background.idle.timeout", 0); + +// Expected rejection from the test cases defined in this file. +PromiseTestUtils.allowMatchingRejectionsGlobally(/expected-test-rejection/); +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Actor 'Conduits' destroyed before query 'RunListener' was resolved/ +); + +add_setup(async () => { + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_eventpage_idle() { + const { GleanCustomDistribution } = globalThis; + + resetTelemetryData(); + + assertHistogramEmpty(WEBEXT_EVENTPAGE_RUNNING_TIME_MS); + assertKeyedHistogramEmpty(WEBEXT_EVENTPAGE_RUNNING_TIME_MS_BY_ADDONID); + assertHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT); + assertKeyedHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID); + assertGleanMetricsNoSamples({ + metricId: "eventPageRunningTime", + gleanMetric: Glean.extensionsTiming.eventPageRunningTime, + gleanMetricConstructor: GleanCustomDistribution, + }); + assertGleanLabeledCounterEmpty({ + metricId: "eventPageIdleResult", + gleanMetric: Glean.extensionsCounters.eventPageIdleResult, + gleanMetricLabels: GLEAN_EVENTPAGE_IDLE_RESULT_CATEGORIES, + }); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["browserSettings"], + background: { persistent: false }, + }, + background() { + browser.browserSettings.allowPopupsForUserEvents.onChange.addListener( + () => { + browser.test.sendMessage("allowPopupsForUserEvents"); + } + ); + browser.runtime.onSuspend.addListener(async () => { + let setting = + await browser.browserSettings.allowPopupsForUserEvents.get({}); + browser.test.sendMessage("suspended", setting); + }); + }, + }); + await extension.startup(); + assertPersistentListeners( + extension, + "browserSettings", + "allowPopupsForUserEvents", + { + primed: false, + } + ); + + info(`test idle timeout after startup`); + await extension.awaitMessage("suspended"); + await promiseExtensionEvent(extension, "shutdown-background-script"); + assertPersistentListeners( + extension, + "browserSettings", + "allowPopupsForUserEvents", + { + primed: true, + } + ); + ExtensionPreferencesManager.setSetting( + extension.id, + "allowPopupsForUserEvents", + "click" + ); + await extension.awaitMessage("allowPopupsForUserEvents"); + ok(true, "allowPopupsForUserEvents.onChange fired"); + + // again after the event is fired + info(`test idle timeout after wakeup`); + let setting = await extension.awaitMessage("suspended"); + equal(setting.value, true, "verify simple async wait works in onSuspend"); + + await promiseExtensionEvent(extension, "shutdown-background-script"); + assertPersistentListeners( + extension, + "browserSettings", + "allowPopupsForUserEvents", + { + primed: true, + } + ); + ExtensionPreferencesManager.setSetting( + extension.id, + "allowPopupsForUserEvents", + false + ); + await extension.awaitMessage("allowPopupsForUserEvents"); + ok(true, "allowPopupsForUserEvents.onChange fired"); + + const { id } = extension; + await extension.unload(); + + info("Verify eventpage telemetry recorded"); + + assertHistogramSnapshot( + WEBEXT_EVENTPAGE_RUNNING_TIME_MS, + { + keyed: false, + processSnapshot: snapshot => snapshot.sum > 0, + expectedValue: true, + }, + `Expect stored values in the eventpage running time non-keyed histogram snapshot` + ); + + assertHistogramSnapshot( + WEBEXT_EVENTPAGE_RUNNING_TIME_MS_BY_ADDONID, + { + keyed: true, + processSnapshot: snapshot => snapshot[id]?.sum > 0, + expectedValue: true, + }, + `Expect stored values for addon with id ${id} in the eventpage running time keyed histogram snapshot` + ); + + assertHistogramCategoryNotEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT, { + category: "suspend", + categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + }); + assertGleanLabeledCounterNotEmpty({ + metricId: "eventPageIdleResult", + gleanMetric: Glean.extensionsCounters.eventPageIdleResult, + expectedNotEmptyLabels: ["suspend"], + }); + + assertHistogramCategoryNotEmpty( + WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID, + { + keyed: true, + key: id, + category: "suspend", + categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + } + ); + + Assert.greater( + Glean.extensionsTiming.eventPageRunningTime.testGetValue()?.sum, + 0, + `Expect stored values in the eventPageRunningTime Glean metric` + ); +}); + +add_task( + { pref_set: [["extensions.background.idle.timeout", 500]] }, + async function test_eventpage_runtime_parentApiCall_resets_timeout() { + resetTelemetryData(); + + assertHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT); + assertKeyedHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID); + assertGleanLabeledCounterEmpty({ + metricId: "eventPageIdleResult", + gleanMetric: Glean.extensionsCounters.eventPageIdleResult, + gleanMetricLabels: GLEAN_EVENTPAGE_IDLE_RESULT_CATEGORIES, + }); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + background: { persistent: false }, + }, + async background() { + let start = Date.now(); + + browser.runtime.onSuspend.addListener(() => { + browser.test.sendMessage("done", Date.now() - start); + }); + + browser.runtime.getBrowserInfo(); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(() => browser.runtime.getBrowserInfo(), 50); + }, + }); + + await extension.startup(); + let [, resetData] = await promiseExtensionEvent( + extension, + "background-script-reset-idle" + ); + + equal(resetData.reason, "parentApiCall", "Got the expected idle reset."); + + await promiseExtensionEvent(extension, "shutdown-background-script"); + + let time = await extension.awaitMessage("done"); + Assert.greater(time, 100, `Background script suspended after ${time}ms.`); + + // Disabled because the telemetry is too chatty, see bug 1868960. + // assertHistogramCategoryNotEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT, { + // category: "reset_parentapicall", + // categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + // }); + + // assertHistogramCategoryNotEmpty( + // WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID, + // { + // keyed: true, + // key: extension.id, + // category: "reset_parentapicall", + // categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + // } + // ); + + // assertGleanLabeledCounterNotEmpty({ + // metricId: "eventPageIdleResult", + // gleanMetric: Glean.extensionsCounters.eventPageIdleResult, + // expectedNotEmptyLabels: ["reset_parentapicall"], + // }); + + await extension.unload(); + } +); + +add_task( + { pref_set: [["extensions.background.idle.timeout", 500]] }, + async function test_extension_page_reset_idle() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + background: { persistent: false }, + }, + background() { + browser.test.log("background script start"); + browser.runtime.onSuspend.addListener(() => { + browser.test.sendMessage("suspended"); + }); + browser.test.sendMessage("ready"); + }, + files: { + "page.html": "<meta charset=utf-8><script src=page.js></script>", + async "page.js"() { + await browser.runtime.getBrowserInfo(); + browser.test.sendMessage("page-done"); + }, + }, + }); + + await extension.startup(); + + // Need to set up the listener as early as possible. + let closed = promiseExtensionEvent(extension, "shutdown-background-script"); + + await extension.awaitMessage("ready"); + info("Background script ready."); + + extension.extension.once("background-script-reset-idle", () => { + ok(false, "background-script-reset-idle emitted from an extension page."); + }); + + let page = await ExtensionTestUtils.loadContentPage( + extension.extension.baseURI.resolve("page.html") + ); + await extension.awaitMessage("page-done"); + info("Test page loaded."); + + await closed; + await extension.awaitMessage("suspended"); + + ok(true, "API call from extension page did not reset idle timeout."); + + await page.close(); + await extension.unload(); + } +); + +add_task(async function test_persistent_background_reset_idle() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + background: { persistent: true }, + }, + background() { + browser.test.onMessage.addListener(async () => { + await browser.runtime.getBrowserInfo(); + browser.test.sendMessage("done"); + }); + browser.test.sendMessage("ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.extension.once("background-script-reset-idle", () => { + ok(false, "background-script-reset-idle from persistent background page."); + }); + + extension.sendMessage("call-parent-api"); + ok(true, "API call from persistent background did not reset idle timeout."); + + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task( + { pref_set: [["extensions.webextensions.runtime.timeout", 500]] }, + async function test_eventpage_runtime_onSuspend_timeout() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + background: { persistent: false }, + }, + background() { + browser.runtime.onSuspend.addListener(() => { + // return a promise that never resolves + return new Promise(() => {}); + }); + }, + }); + await extension.startup(); + await promiseExtensionEvent(extension, "shutdown-background-script"); + ok(true, "onSuspend did not block background shutdown"); + await extension.unload(); + } +); + +add_task( + { pref_set: [["extensions.webextensions.runtime.timeout", 500]] }, + async function test_eventpage_runtime_onSuspend_reject() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + background: { persistent: false }, + }, + background() { + browser.runtime.onSuspend.addListener(() => { + // Raise an error to test error handling in onSuspend + return Promise.reject("testing reject"); + }); + }, + }); + await extension.startup(); + await promiseExtensionEvent(extension, "shutdown-background-script"); + ok(true, "onSuspend did not block background shutdown"); + await extension.unload(); + } +); + +add_task( + { pref_set: [["extensions.webextensions.runtime.timeout", 1000]] }, + async function test_eventpage_runtime_onSuspend_canceled() { + resetTelemetryData(); + + assertHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT); + assertKeyedHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID); + assertGleanLabeledCounterEmpty({ + metricId: "eventPageIdleResult", + gleanMetric: Glean.extensionsCounters.eventPageIdleResult, + gleanMetricLabels: GLEAN_EVENTPAGE_IDLE_RESULT_CATEGORIES, + }); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["browserSettings"], + background: { persistent: false }, + }, + background() { + let resolveSuspend; + browser.browserSettings.allowPopupsForUserEvents.onChange.addListener( + () => { + browser.test.sendMessage("allowPopupsForUserEvents"); + } + ); + browser.runtime.onSuspend.addListener(() => { + browser.test.sendMessage("suspending"); + return new Promise(resolve => { + resolveSuspend = resolve; + }); + }); + browser.runtime.onSuspendCanceled.addListener(() => { + browser.test.sendMessage("suspendCanceled"); + }); + browser.test.onMessage.addListener(() => { + resolveSuspend(); + }); + }, + }); + await extension.startup(); + await extension.awaitMessage("suspending"); + // While suspending, cause an event + ExtensionPreferencesManager.setSetting( + extension.id, + "allowPopupsForUserEvents", + "click" + ); + extension.sendMessage("resolveSuspend"); + await extension.awaitMessage("allowPopupsForUserEvents"); + await extension.awaitMessage("suspendCanceled"); + ok(true, "event caused suspend-canceled"); + + // Disabled because the telemetry is too chatty, see bug 1868960. + // assertHistogramCategoryNotEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT, { + // category: "reset_event", + // categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + // }); + // assertGleanLabeledCounterNotEmpty({ + // metricId: "eventPageIdleResult", + // gleanMetric: Glean.extensionsCounters.eventPageIdleResult, + // expectedNotEmptyLabels: ["reset_event"], + // }); + + // assertHistogramCategoryNotEmpty( + // WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID, + // { + // keyed: true, + // key: extension.id, + // category: "reset_event", + // categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + // } + // ); + + await extension.awaitMessage("suspending"); + await promiseExtensionEvent(extension, "shutdown-background-script"); + await extension.unload(); + } +); + +add_task(async function test_terminateBackground_after_extension_hasShutdown() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + background: { persistent: false }, + }, + async background() { + browser.runtime.onSuspend.addListener(() => { + browser.test.fail( + `runtime.onSuspend listener should have not been called` + ); + }); + + // Call an API method implemented in the parent process (to be sure runtime.onSuspend + // listener is going to be fully registered from a parent process perspective by the + // time we will send the "bg-ready" test message). + await browser.runtime.getBrowserInfo(); + + browser.test.sendMessage("bg-ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("bg-ready"); + + // Fake suspending event page on idle while the extension was being shutdown by manually + // setting the hasShutdown flag to true on the Extension class instance object. + extension.extension.hasShutdown = true; + await extension.terminateBackground(); + extension.extension.hasShutdown = false; + + await extension.unload(); +}); + +add_task(async function test_wakeupBackground_after_extension_hasShutdown() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + background: { persistent: false }, + }, + async background() { + browser.test.sendMessage("bg-ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("bg-ready"); + await extension.terminateBackground(); + + // Fake suspending event page on idle while the extension was being shutdown by manually + // setting the hasShutdown flag to true on the Extension class instance object. + extension.extension.hasShutdown = true; + await Assert.rejects( + extension.wakeupBackground(), + /wakeupBackground called while the extension was already shutting down/, + "Got the expected rejection when wakeupBackground is called after extension shutdown" + ); + extension.extension.hasShutdown = false; + + await extension.unload(); +}); + +async function testSuspendShutdownRace({ manifest_version }) { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version, + background: manifest_version === 2 ? { persistent: false } : {}, + permissions: ["webRequest", "webRequestBlocking"], + host_permissions: ["*://example.com/*"], + granted_host_permissions: true, + }, + // Define an empty background script. + background() {}, + }); + + await extension.startup(); + await extension.extension.promiseBackgroundStarted(); + const promiseTerminateBackground = extension.extension.terminateBackground(); + // Wait one tick to leave to terminateBackground async method time to get + // past the first check that returns earlier if extension.hasShutdown is true. + await Promise.resolve(); + const promiseUnload = extension.unload(); + + await promiseUnload; + try { + await promiseTerminateBackground; + ok(true, "extension.terminateBackground should not have been rejected"); + } catch (err) { + ok( + false, + `extension.terminateBackground should not have been rejected: ${err} :: ${err.stack}` + ); + } +} + +add_task(function test_mv2_suspend_shutdown_race() { + return testSuspendShutdownRace({ manifest_version: 2 }); +}); + +add_task( + { + pref_set: [["extensions.manifestV3.enabled", true]], + }, + function test_mv3_suspend_shutdown_race() { + return testSuspendShutdownRace({ manifest_version: 3 }); + } +); + +function createPendingListenerTestExtension() { + return ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["browserSettings"], + background: { persistent: false }, + }, + background() { + let idx = 0; + browser.browserSettings.allowPopupsForUserEvents.onChange.addListener( + async () => { + const currIdx = idx++; + await new Promise((resolve, reject) => { + browser.test.onMessage.addListener(msg => { + switch (`${msg}-${currIdx}`) { + case "unblock-promise-0": + resolve(); + browser.test.sendMessage("allowPopupsForUserEvents:resolved"); + break; + case "unblock-promise-1": + reject(new Error("expected-test-rejection")); + browser.test.sendMessage("allowPopupsForUserEvents:rejected"); + break; + default: + browser.test.fail(`Unexpected test message: ${msg}`); + } + }); + browser.test.sendMessage("allowPopupsForUserEvents:awaiting"); + }); + } + ); + + browser.runtime.onSuspend.addListener(() => { + // Raise an error to test error handling in onSuspend + return browser.test.sendMessage("runtime-on-suspend"); + }); + + browser.test.sendMessage("bg-script-ready"); + }, + }); +} + +add_task( + { pref_set: [["extensions.background.idle.timeout", 500]] }, + async function test_eventpage_idle_reset_on_async_listener_unresolved() { + resetTelemetryData(); + + assertHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT); + assertKeyedHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID); + assertGleanLabeledCounterEmpty({ + metricId: "eventPageIdleResult", + gleanMetric: Glean.extensionsCounters.eventPageIdleResult, + gleanMetricLabels: GLEAN_EVENTPAGE_IDLE_RESULT_CATEGORIES, + }); + + let extension = createPendingListenerTestExtension(); + await extension.startup(); + await extension.awaitMessage("bg-script-ready"); + + info("Trigger the first API event listener call"); + ExtensionPreferencesManager.setSetting( + extension.id, + "allowPopupsForUserEvents", + "click" + ); + + await extension.awaitMessage("allowPopupsForUserEvents:awaiting"); + + info("Trigger the second API event listener call"); + ExtensionPreferencesManager.setSetting( + extension.id, + "allowPopupsForUserEvents", + "click" + ); + + await extension.awaitMessage("allowPopupsForUserEvents:awaiting"); + + info("Wait for suspend on idle to be reset"); + const [, resetIdleData] = await promiseExtensionEvent( + extension, + "background-script-reset-idle" + ); + + Assert.deepEqual( + resetIdleData, + { + reason: "pendingListeners", + pendingListeners: 2, + }, + "Got the expected idle reset reason and pendingListeners count" + ); + + assertHistogramCategoryNotEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT, { + category: "reset_listeners", + categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + }); + + assertGleanLabeledCounter({ + metricId: "eventPageIdleResult", + gleanMetric: Glean.extensionsCounters.eventPageIdleResult, + gleanMetricLabels: GLEAN_EVENTPAGE_IDLE_RESULT_CATEGORIES, + ignoreNonExpectedLabels: true, // Only check values on the labels listed below. + expectedLabelsValue: { + reset_listeners: 1, + }, + }); + + assertHistogramCategoryNotEmpty( + WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID, + { + keyed: true, + key: extension.id, + category: "reset_listeners", + categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + } + ); + + info( + "Resolve the async listener pending on a promise and expect the event page to suspend after the idle timeout" + ); + extension.sendMessage("unblock-promise"); + // Expect the two promises to be resolved and rejected respectively. + await extension.awaitMessage("allowPopupsForUserEvents:resolved"); + await extension.awaitMessage("allowPopupsForUserEvents:rejected"); + + info("Await for the runtime.onSuspend event to be emitted"); + await extension.awaitMessage("runtime-on-suspend"); + await extension.unload(); + } +); + +add_task( + { pref_set: [["extensions.background.idle.timeout", 500]] }, + async function test_pending_async_listeners_promises_rejected_on_shutdown() { + let extension = createPendingListenerTestExtension(); + await extension.startup(); + await extension.awaitMessage("bg-script-ready"); + + info("Trigger the API event listener call"); + ExtensionPreferencesManager.setSetting( + extension.id, + "allowPopupsForUserEvents", + "click" + ); + + await extension.awaitMessage("allowPopupsForUserEvents:awaiting"); + + const { runListenerPromises } = extension.extension.backgroundContext; + equal( + runListenerPromises.size, + 1, + "Got the expected number of pending runListener promises" + ); + + const pendingPromise = Array.from(runListenerPromises)[0]; + + // Shutdown the extension while there is still a pending promises being tracked + // to verify they gets rejected as expected when the background page browser element + // is going to be destroyed. + await extension.unload(); + + await Assert.rejects( + pendingPromise, + /Actor 'Conduits' destroyed before query 'RunListener' was resolved/, + "Previously pending runListener promise rejected with the expected error" + ); + + equal( + runListenerPromises.size, + 0, + "Expect no remaining pending runListener promises" + ); + } +); + +add_task( + { pref_set: [["extensions.background.idle.timeout", 500]] }, + async function test_eventpage_idle_reset_once_on_pending_async_listeners() { + let extension = createPendingListenerTestExtension(); + await extension.startup(); + await extension.awaitMessage("bg-script-ready"); + + info("Trigger the API event listener call"); + ExtensionPreferencesManager.setSetting( + extension.id, + "allowPopupsForUserEvents", + "click" + ); + + await extension.awaitMessage("allowPopupsForUserEvents:awaiting"); + + info("Wait for suspend on the first idle timeout to be reset"); + const [, resetIdleData] = await promiseExtensionEvent( + extension, + "background-script-reset-idle" + ); + + Assert.deepEqual( + resetIdleData, + { + reason: "pendingListeners", + pendingListeners: 1, + }, + "Got the expected idle reset reason and pendingListeners count" + ); + + info( + "Await for the runtime.onSuspend event to be emitted on the second idle timeout hit" + ); + // We expect this part of the test to trigger a uncaught rejection for the + // "Actor 'Conduits' destroyed before query 'RunListener' was resolved" error, + // due to the listener left purposely pending in this test + // and so that expected rejection is ignored using PromiseTestUtils in the preamble + // of this test file. + await extension.awaitMessage("runtime-on-suspend"); + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_messaging.js b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_messaging.js new file mode 100644 index 0000000000..c343f19a5c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_messaging.js @@ -0,0 +1,225 @@ +"use strict"; + +// This test checks that the suspension of the event page is delayed when the +// runtime.onConnect / runtime.onMessage events are involved. +// +// Another test (test_ext_eventpage_messaging_wakeup.js) verifies that the event +// page wakes up when these events are to be triggered. + +const { ExtensionTestCommon } = ChromeUtils.importESModule( + "resource://testing-common/ExtensionTestCommon.sys.mjs" +); + +add_setup(() => { + // In this test, we want to verify that an idle timeout is reset when + // extension messages are set. To avoid waiting for too long, reduce the + // default timeout to a short value. To avoid premature test termination, + // this timeout should be sufficiently large to run the relevant logic that + // is supposed to postpone event page termination: + // - The idle timer starts when the background has loaded. + // - The idle timer should reset when expected by tests. + Services.prefs.setIntPref("extensions.background.idle.timeout", 1000); +}); + +async function loadEventPageAndExtensionPage({ + backgroundScript, + extensionPageScript, +}) { + let extension = ExtensionTestUtils.loadExtension({ + // Delay startup, to ensure that the event page does not suspend until we + // have started the extension page that runs extensionPageScript. + startupReason: "APP_STARTUP", + // APP_STARTUP is not enough, delayedStartup is needed (bug 1756225). + delayedStartup: true, + manifest: { + background: { persistent: false }, + }, + background: backgroundScript, + files: { + "page.html": `<!DOCTYPE html><script src="page.js"></script>`, + "page.js": extensionPageScript, + }, + }); + + // Delay event page startup until notifyEarlyStartup+notifyLateStartup below. + await ExtensionTestCommon.resetStartupPromises(); + await extension.startup(); + + // Start extension page first, so that it can register runtime.onSuspend + // before there is any chance of encountering a suspended event page. + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/page.html` + ); + + info("Extension page loaded, permitting event page to start up"); + await ExtensionTestCommon.notifyEarlyStartup(); + await ExtensionTestCommon.notifyLateStartup(); + + await extension.awaitMessage("FINAL_SUSPEND"); + info("Received FINAL_SUSPEND, awaiting full event page shutdown."); + await promiseExtensionEvent(extension, "shutdown-background-script"); + await contentPage.close(); + await extension.unload(); +} + +add_task(async function test_runtime_onMessage_cancels_suspend() { + await loadEventPageAndExtensionPage({ + backgroundScript() { + // This background script registers listeners without calling any other + // extension API. This ensures that if the event page suspend is canceled, + // that it was intentionally done by the listener, and not as a side + // effect of an unrelated extension API call. + browser.runtime.onMessage.addListener(msg => { + return Promise.resolve(`bg_pong:${msg}`); + }); + }, + extensionPageScript() { + let cancelCount = 0; + browser.runtime.onSuspendCanceled.addListener(() => { + browser.test.assertEq(1, ++cancelCount, "onSuspendCanceled 1x"); + }); + let suspendCount = 0; + let firstSuspendTestCompleted = false; + browser.runtime.onSuspend.addListener(async () => { + // We expect 2x suspend: first one we cancel, second one is final. + if (++suspendCount === 1) { + // First suspend attempt. + browser.test.assertEq(0, cancelCount, "Not suspended yet"); + let res = await browser.runtime.sendMessage("ping"); + browser.test.assertEq(1, cancelCount, "onMessage cancels suspend"); + browser.test.assertEq("bg_pong:ping", res, "onMessage result"); + firstSuspendTestCompleted = true; + } else { + browser.test.assertTrue(firstSuspendTestCompleted, "First test done"); + browser.test.assertEq(2, suspendCount, "Second onSuspend"); + browser.test.sendMessage("FINAL_SUSPEND"); + } + }); + browser.test.log("Waiting for background to be suspended"); + }, + }); +}); + +add_task(async function test_runtime_onConnect_cancels_suspend() { + await loadEventPageAndExtensionPage({ + backgroundScript() { + // This background script registers listeners without calling any other + // extension API. This ensures that if the event page suspend is canceled, + // that it was intentionally done by the listener, and not as a side + // effect of an unrelated extension API call. + browser.runtime.onConnect.addListener(port => { + // Set by extensionPageScript before runtime.connect(): + globalThis.notify_extensionPage_got_onConnect(); + }); + }, + extensionPageScript() { + let cancelCount = 0; + browser.runtime.onSuspendCanceled.addListener(() => { + browser.test.assertEq(1, ++cancelCount, "onSuspendCanceled 1x"); + }); + let suspendCount = 0; + let firstSuspendTestCompleted = false; + let port; // Prevent port from being gc'd during test. + browser.runtime.onSuspend.addListener(async () => { + // We expect 2x suspend: first one we cancel, second one is final. + if (++suspendCount === 1) { + // First suspend attempt. + browser.test.assertEq(0, cancelCount, "Not suspended yet"); + // Call runtime.connect() twice: + // 1. First connect() should be triggering the reset. + // 2. We are immediately notified when runtime.onConnect is called. + // 2. We call connect() again to have another page->parent->background + // roundtrip. This ensures that enough time to have been passed to + // allow the first runtime.onConnect handling to have finished, + // and to have triggeres onSuspendCanceled as desired. + for (let i = 0; i < 2; ++i) { + await new Promise(resolve => { + let bgGlobal = browser.extension.getBackgroundPage(); + browser.test.assertTrue(!!bgGlobal, "Event page still running"); + bgGlobal.notify_extensionPage_got_onConnect = resolve; + port = browser.runtime.connect({}); + }); + } + browser.test.assertEq(1, cancelCount, "onConnect cancels suspend"); + firstSuspendTestCompleted = true; + } else { + browser.test.assertTrue(firstSuspendTestCompleted, "First test done"); + browser.test.assertEq(2, suspendCount, "Second onSuspend"); + browser.test.assertEq(null, port.error, "port has no error"); + browser.test.sendMessage("FINAL_SUSPEND"); + } + }); + browser.test.log("Waiting for background to be suspended"); + }, + }); +}); + +add_task(async function test_runtime_Port_onMessage_cancels_suspend() { + await loadEventPageAndExtensionPage({ + backgroundScript() { + // This background script registers listeners without calling any other + // extension API. This ensures that if the event page suspend is canceled, + // that it was intentionally done by the listener, and not as a side + // effect of an unrelated extension API call. + browser.runtime.onConnect.addListener(port => { + port.onMessage.addListener(msg => { + // Set by extensionPageScript before runtime.connect(): + globalThis.notify_extensionPage_got_port_onMessage(); + }); + }); + }, + extensionPageScript() { + let cancelCount = 0; + browser.runtime.onSuspendCanceled.addListener(() => { + browser.test.assertEq(1, ++cancelCount, "onSuspendCanceled 1x"); + }); + let suspendCount = 0; + let firstSuspendTestCompleted = false; + let port; + browser.runtime.onSuspend.addListener(async () => { + // We expect 2x suspend: first one we cancel, second one is final. + if (++suspendCount === 1) { + // First suspend attempt. + browser.test.assertEq(0, cancelCount, "Not suspended yet"); + browser.test.assertTrue(!!port, "Should run after we opened a port"); + // Call port.postMessage() twice: + // 1. First port.postMessage() should be triggering the reset. + // 2. We are immediately notified when runtime.onMessage is called. + // 2. We postMessage() again to have another page->parent->background + // roundtrip. This ensures that enough time to have been passed to + // allow the first port.onMessage handling to have finished, + // and to have triggeres onSuspendCanceled as desired. + for (let i = 0; i < 2; ++i) { + await new Promise(resolve => { + let bgGlobal = browser.extension.getBackgroundPage(); + browser.test.assertTrue(!!bgGlobal, "Event page still running"); + bgGlobal.notify_extensionPage_got_port_onMessage = resolve; + port.postMessage(""); + }); + } + browser.test.assertEq( + 1, + cancelCount, + "port.onMessage cancels suspend" + ); + firstSuspendTestCompleted = true; + } else { + browser.test.assertTrue(firstSuspendTestCompleted, "First test done"); + browser.test.assertEq(2, suspendCount, "Second onSuspend"); + browser.test.sendMessage("FINAL_SUSPEND"); + } + }); + browser.runtime.getBackgroundPage(bgGlobal => { + browser.test.assertTrue(!!bgGlobal, "Event page has started"); + // Since the event page has started, this should trigger onConnect in + // the event page. If somehow the event page has suspended in the + // meantime, then we will detect that in runtime.onSuspend (and fail). + port = browser.runtime.connect({}); + // Assuming that runtime.onConnect in the event page has received the + // port and started listening, we should now wait for an attempt to + // suspend the event page (and try to cancel that via port.onMessage). + browser.test.log("Waiting for background to be suspended"); + }); + }, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_messaging_wakeup.js b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_messaging_wakeup.js new file mode 100644 index 0000000000..dc0c9d051a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_messaging_wakeup.js @@ -0,0 +1,329 @@ +"use strict"; + +// This test checks that the event page is awakened as expected when a +// extension messaging event is triggered. +// - onMessage / onConnect: triggered by the extension's own extension page. +// - onMessage / onConnect: triggered by the extension's own content script. +// - onMessageExternal / onConnectExternal: triggered by another extension. +// +// Note: the behavior in persistent background scripts (at browser startup) is +// covered by test_ext_messaging_startup.js. +// +// These events delay suspend of the event page, which is partially verified by +// test_ext_eventpage_messaging.js. + +const { ExtensionTestCommon } = ChromeUtils.importESModule( + "resource://testing-common/ExtensionTestCommon.sys.mjs" +); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); +}); + +async function testEventPageWakeup({ + backgroundScript, + triggerScript, + triggerFromContentScript = false, // default to calling from extension page. + triggerFromOtherExtension = false, // default to calling from own extension. + skipInitialPriming = false, // default to start+suspend before starting test. +}) { + function loadExtension(id, withBackground, withTrigger) { + let extensionData = { + manifest: { browser_specific_settings: { gecko: { id } } }, + }; + if (withBackground) { + extensionData.manifest.background = { persistent: false }; + extensionData.background = backgroundScript; + if (skipInitialPriming) { + // Delay event page startup until an explicit event. + extensionData.startupReason = "APP_STARTUP"; + // APP_STARTUP is not enough, delayedStartup is needed (bug 1756225). + extensionData.delayedStartup = true; + } + } + if (withTrigger) { + extensionData.files = { + "trigger.html": `<!DOCTYPE html><script src="trigger.js"></script>`, + "trigger.js": triggerScript, + }; + if (triggerFromContentScript) { + // trigger.html unused, use content script instead: + extensionData.manifest.content_scripts = [ + { + js: ["trigger.js"], + run_at: "document_end", + matches: ["http://example.com/dummy"], + }, + ]; + } + } + return ExtensionTestUtils.loadExtension(extensionData); + } + + await ExtensionTestCommon.resetStartupPromises(); + // Event-triggered wakeup is blocked on browserPaintedPromise, so unblock it: + await ExtensionTestCommon.notifyEarlyStartup(); + + let extension = loadExtension("@ext", true, !triggerFromOtherExtension); + await extension.startup(); + if (!skipInitialPriming) { + // Default test behavior is ADDON_INSTALL, which starts up the background. + Assert.equal(extension.extension.backgroundState, "running", "Bg started"); + // Not strictly needed because the background has already started. But to + // more closely match reality, flag as fully started. + await ExtensionTestCommon.notifyLateStartup(); + // Suspend event page so we can verify that it wakes up by triggerScript. + await extension.terminateBackground(); + Assert.equal(extension.extension.backgroundState, "stopped", "Bg closed"); + } else { + // Event page initially suspended, waiting for event. + Assert.equal(extension.extension.backgroundState, "stopped", "Bg inactive"); + } + + let extension2; + if (triggerFromOtherExtension) { + extension2 = loadExtension("@other-ext", false, true); + await extension2.startup(); + } + + let url; + if (triggerFromContentScript) { + url = "http://example.com/dummy"; + } else if (triggerFromOtherExtension) { + url = `moz-extension://${extension2.uuid}/trigger.html`; + } else { + url = `moz-extension://${extension.uuid}/trigger.html`; + } + let contentPage = await ExtensionTestUtils.loadContentPage(url); + info("Waiting for event page to be awakened by event"); + await extension.awaitMessage("TRIGGER_TEST_DONE"); + // Unload test extensions first to avoid an issue on Windows platforms. + await extension.unload(); + if (triggerFromOtherExtension) { + await extension2.unload(); + } + await contentPage.close(); +} + +add_task(async function test_sendMessage_without_onMessage() { + await testEventPageWakeup({ + backgroundScript() { + // No runtime.onMessage listener here. + }, + async triggerScript() { + browser.test.assertTrue( + !browser.extension.getBackgroundPage(), + "Event page suspended before sendMessage()" + ); + await browser.test.assertRejects( + browser.runtime.sendMessage(""), + "Could not establish connection. Receiving end does not exist.", + "sendMessage without onMessage should reject" + ); + browser.test.assertTrue( + // TODO bug 1852317: This should be "!" instead of "!!", i.e. the event + // page should not wake up. + !!browser.extension.getBackgroundPage(), + "Existence of event page after sendMessage()" + ); + browser.test.sendMessage("TRIGGER_TEST_DONE"); + }, + // For completeness, start background before suspending. This makes sure + // that if the persistent listener mechanism is used for runtime.onMessage, + // that we have started the background at least once to enable the + // framework to detect (the lack of) persistent listeners, so that it can + // know that waking the event page doesn't make a difference. + // (note: persistent listeners are currently not used here: bug 1852317) + skipInitialPriming: false, + }); +}); + +add_task(async function test_connect_without_onConnect() { + await testEventPageWakeup({ + backgroundScript() { + // No runtime.onConnect listener here. + }, + triggerScript() { + browser.test.assertTrue( + !browser.extension.getBackgroundPage(), + "Event page suspended before sendMessage()" + ); + let port = browser.runtime.connect({}); + port.onDisconnect.addListener(() => { + browser.test.assertEq( + "Could not establish connection. Receiving end does not exist.", + port.error?.message, + "connect() without onConnect should disconnect port with an error" + ); + browser.test.assertTrue( + // TODO bug 1852317: This should be "!" instead of "!!", i.e. the + // event page should not wake up. + !!browser.extension.getBackgroundPage(), + "Existence of event page after connect()" + ); + browser.test.sendMessage("TRIGGER_TEST_DONE"); + }); + }, + // For completeness, start background before suspending. This makes sure + // that if the persistent listener mechanism is used for runtime.onConnect, + // that we have started the background at least once to enable the + // framework to detect (the lack of) persistent listeners, so that it can + // know that waking the event page doesn't make a difference. + // (note: persistent listeners are currently not used here: bug 1852317) + skipInitialPriming: false, + }); +}); + +async function testEventPageWakeupWithSendMessage({ + triggerFromContentScript, + triggerFromOtherExtension, + skipInitialPriming, +}) { + let backgroundScript, triggerScript; + // Note: using dump() instead of browser.test.log for logging, to rule out + // any side effects from extension API calls. + if (triggerFromOtherExtension) { + backgroundScript = () => { + dump("Event page started, listening to onMessageExternal\n"); + browser.runtime.onMessageExternal.addListener(() => { + browser.test.sendMessage("TRIGGER_TEST_DONE"); + }); + }; + triggerScript = () => { + dump("Calling sendMessage, expecting onMessageExternal\n"); + browser.runtime.sendMessage("@ext", "msg"); + }; + } else { + backgroundScript = () => { + dump("Event page started, listening to onMessage\n"); + browser.runtime.onMessage.addListener(() => { + browser.test.sendMessage("TRIGGER_TEST_DONE"); + }); + }; + triggerScript = () => { + dump("Calling sendMessage, expecting onMessage\n"); + browser.runtime.sendMessage("msg"); + }; + } + await testEventPageWakeup({ + backgroundScript, + triggerScript, + triggerFromContentScript, + triggerFromOtherExtension, + skipInitialPriming, + }); +} + +add_task(async function test_wakeup_onMessage() { + await testEventPageWakeupWithSendMessage({ + triggerFromContentScript: false, + triggerFromOtherExtension: false, + }); +}); + +add_task(async function test_wakeup_onMessage_on_first_run() { + await testEventPageWakeupWithSendMessage({ + triggerFromContentScript: false, + triggerFromOtherExtension: false, + skipInitialPriming: true, + }); +}); + +add_task(async function test_wakeup_onMessage_by_content_script() { + await testEventPageWakeupWithSendMessage({ + triggerFromContentScript: true, + triggerFromOtherExtension: false, + }); +}); + +add_task(async function test_wakeup_onMessageExternal() { + await testEventPageWakeupWithSendMessage({ + triggerFromContentScript: false, + triggerFromOtherExtension: true, + }); +}); + +add_task(async function test_wakeup_onMessageExternal_by_content_script() { + await testEventPageWakeupWithSendMessage({ + triggerFromContentScript: true, + triggerFromOtherExtension: true, + }); +}); + +async function testEventPageWakeupWithConnect({ + triggerFromContentScript, + triggerFromOtherExtension, + skipInitialPriming, +}) { + let backgroundScript, triggerScript; + // Note: using dump() instead of browser.test.log for logging, to rule out + // any side effects from extension API calls. + if (triggerFromOtherExtension) { + backgroundScript = () => { + dump("Event page started, listening to onConnectExternal\n"); + browser.runtime.onConnectExternal.addListener(() => { + browser.test.sendMessage("TRIGGER_TEST_DONE"); + }); + }; + triggerScript = () => { + dump("Calling connect, expecting onConnectExternal\n"); + browser.runtime.connect("@ext", {}); + }; + } else { + backgroundScript = () => { + dump("Event page started, listening to onConnect\n"); + browser.runtime.onConnect.addListener(() => { + browser.test.sendMessage("TRIGGER_TEST_DONE"); + }); + }; + triggerScript = () => { + dump("Calling connect, expecting onConnect\n"); + browser.runtime.connect({}); + }; + } + await testEventPageWakeup({ + backgroundScript, + triggerScript, + triggerFromContentScript, + triggerFromOtherExtension, + skipInitialPriming, + }); +} + +add_task(async function test_wakeup_onConnect() { + await testEventPageWakeupWithConnect({ + triggerFromContentScript: false, + triggerFromOtherExtension: false, + }); +}); + +add_task(async function test_wakeup_onConnect_on_first_run() { + await testEventPageWakeupWithConnect({ + triggerFromContentScript: false, + triggerFromOtherExtension: false, + skipInitialPriming: true, + }); +}); + +add_task(async function test_wakeup_onConnect_by_content_script() { + await testEventPageWakeupWithConnect({ + triggerFromContentScript: true, + triggerFromOtherExtension: false, + }); +}); + +add_task(async function test_wakeup_onConnectExternal() { + await testEventPageWakeupWithConnect({ + triggerFromContentScript: false, + triggerFromOtherExtension: true, + }); +}); + +add_task(async function test_wakeup_onConnectExternal_by_content_script() { + await testEventPageWakeupWithConnect({ + triggerFromContentScript: true, + triggerFromOtherExtension: true, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_settings.js b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_settings.js new file mode 100644 index 0000000000..8c36f4ef03 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_settings.js @@ -0,0 +1,161 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs", + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +Services.prefs.setBoolPref("extensions.eventPages.enabled", true); + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + + // Create an object to hold the values to which we will initialize the prefs. + const PREFS = { + "browser.cache.disk.enable": true, + "browser.cache.memory.enable": true, + }; + + // Set prefs to our initial values. + for (let pref in PREFS) { + Preferences.set(pref, PREFS[pref]); + } + + registerCleanupFunction(() => { + // Reset the prefs. + for (let pref in PREFS) { + Preferences.reset(pref); + } + }); +}); + +// Other tests exist for all the settings, this smoke tests that the +// settings will startup an event page. +add_task(async function test_browser_settings() { + let setExt = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["browserSettings", "privacy"], + }, + background() { + browser.test.onMessage.addListener(async (msg, apiName, value) => { + let apiObj = apiName.split(".").reduce((o, i) => o[i], browser); + let result = await apiObj.set({ value }); + if (msg === "set") { + browser.test.assertTrue(result, "set returns true."); + } else { + browser.test.assertFalse(result, "set returns false for a no-op."); + } + }); + }, + }); + await setExt.startup(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["browserSettings", "privacy"], + background: { persistent: false }, + }, + background() { + browser.browserSettings.cacheEnabled.onChange.addListener(() => { + browser.test.log("cacheEnabled received"); + browser.test.sendMessage("cacheEnabled"); + }); + browser.browserSettings.homepageOverride.onChange.addListener(() => { + browser.test.sendMessage("homepageOverride"); + }); + browser.browserSettings.newTabPageOverride.onChange.addListener(() => { + browser.test.sendMessage("newTabPageOverride"); + }); + browser.privacy.services.passwordSavingEnabled.onChange.addListener( + () => { + browser.test.sendMessage("passwordSavingEnabled"); + } + ); + }, + }); + await extension.startup(); + + await extension.terminateBackground({ disableResetIdleForTest: true }); + assertPersistentListeners(extension, "browserSettings", "cacheEnabled", { + primed: true, + }); + + info(`testing cacheEnabled`); + setExt.sendMessage("set", "browserSettings.cacheEnabled", false); + await extension.awaitMessage("cacheEnabled"); + ok(true, "cacheEnabled.onChange fired"); + + await extension.terminateBackground({ disableResetIdleForTest: true }); + assertPersistentListeners(extension, "browserSettings", "homepageOverride", { + primed: true, + }); + + info(`testing homepageOverride`); + Preferences.set("browser.startup.homepage", "http://homepage.example.com"); + await extension.awaitMessage("homepageOverride"); + ok(true, "homepageOverride.onChange fired"); + + if ( + AppConstants.platform !== "android" && + AppConstants.MOZ_APP_NAME !== "thunderbird" + ) { + await extension.terminateBackground({ disableResetIdleForTest: true }); + assertPersistentListeners( + extension, + "browserSettings", + "newTabPageOverride", + { + primed: true, + } + ); + + info(`testing newTabPageOverride`); + AboutNewTab.newTabURL = "http://homepage.example.com"; + await extension.awaitMessage("newTabPageOverride"); + ok(true, "newTabPageOverride.onChange fired"); + } + + await extension.terminateBackground({ disableResetIdleForTest: true }); + assertPersistentListeners( + extension, + "privacy", + "services.passwordSavingEnabled", + { + primed: true, + } + ); + + info(`testing passwordSavingEnabled`); + setExt.sendMessage("set", "privacy.services.passwordSavingEnabled", true); + await extension.awaitMessage("passwordSavingEnabled"); + ok(true, "passwordSavingEnabled.onChange fired"); + + await AddonTestUtils.promiseRestartManager(); + await setExt.awaitStartup(); + await extension.awaitStartup(); + Services.obs.notifyObservers(null, "browser-delayed-startup-finished"); + + assertPersistentListeners(extension, "browserSettings", "homepageOverride", { + primed: true, + }); + + info(`testing homepageOverride after AOM restart`); + Preferences.set("browser.startup.homepage", "http://test.example.com"); + await extension.awaitMessage("homepageOverride"); + ok(true, "homepageOverride.onChange fired"); + + await extension.unload(); + await setExt.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_warning.js b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_warning.js new file mode 100644 index 0000000000..82c18e7015 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_warning.js @@ -0,0 +1,99 @@ +"use strict"; + +AddonTestUtils.init(this); +// This test expects and checks deprecation warnings. +ExtensionTestUtils.failOnSchemaWarnings(false); + +function createEventPageExtension(eventPage) { + return ExtensionTestUtils.loadExtension({ + manifest: { + background: eventPage, + }, + files: { + "event_page_script.js"() { + browser.test.log("running event page as background script"); + browser.test.sendMessage("running", 1); + }, + "event-page.html": `<!DOCTYPE html> + <html><head> + <meta charset="utf-8"> + <script src="event_page_script.js"><\/script> + </head></html>`, + }, + }); +} + +add_task( + { + // This test case covers expected warnings emitted when the + // event page support is disabled by prefs. + pref_set: [["extensions.eventPages.enabled", false]], + }, + async function test_eventpages() { + let testCases = [ + { + message: "testing event page running as a background page", + eventPage: { + page: "event-page.html", + persistent: false, + }, + }, + { + message: "testing event page scripts running as a background page", + eventPage: { + scripts: ["event_page_script.js"], + persistent: false, + }, + }, + { + message: + "testing additional unrecognized properties on background page", + eventPage: { + scripts: ["event_page_script.js"], + nonExistentProp: true, + }, + }, + { + message: "testing persistent background page", + eventPage: { + page: "event-page.html", + persistent: true, + }, + }, + { + message: + "testing scripts with persistent background running as a background page", + eventPage: { + scripts: ["event_page_script.js"], + persistent: true, + }, + }, + ]; + + let { messages } = await promiseConsoleOutput(async () => { + for (let test of testCases) { + info(test.message); + + let extension = createEventPageExtension(test.eventPage); + await extension.startup(); + let x = await extension.awaitMessage("running"); + equal(x, 1, "got correct value from extension"); + await extension.unload(); + } + }); + AddonTestUtils.checkMessages( + messages, + { + expected: [ + { message: /Event pages are not currently supported./ }, + { message: /Event pages are not currently supported./ }, + { + message: + /Reading manifest: Warning processing background.nonExistentProp: An unexpected property was found/, + }, + ], + }, + true + ); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_experiments.js b/toolkit/components/extensions/test/xpcshell/test_ext_experiments.js new file mode 100644 index 0000000000..cd2eb4dbb7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_experiments.js @@ -0,0 +1,451 @@ +"use strict"; + +/* globals browser */ +const { AddonSettings } = ChromeUtils.importESModule( + "resource://gre/modules/addons/AddonSettings.sys.mjs" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_task(async function setup() { + AddonTestUtils.overrideCertDB(); + await ExtensionTestUtils.startAddonManager(); +}); + +let fooExperimentAPIs = { + foo: { + schema: "schema.json", + parent: { + scopes: ["addon_parent"], + script: "parent.js", + paths: [["experiments", "foo", "parent"]], + }, + child: { + scopes: ["addon_child"], + script: "child.js", + paths: [ + ["experiments", "foo", "child"], + ["experiments", "foo", "onChildEvent"], + ], + }, + }, +}; + +let fooExperimentFiles = { + "schema.json": JSON.stringify([ + { + namespace: "experiments.foo", + types: [ + { + id: "Meh", + type: "object", + properties: {}, + }, + ], + functions: [ + { + name: "parent", + type: "function", + async: true, + parameters: [], + }, + { + name: "child", + type: "function", + parameters: [], + returns: { type: "string" }, + }, + ], + events: [ + { + name: "onChildEvent", + type: "function", + parameters: [], + }, + ], + }, + ]), + + /* globals ExtensionAPI */ + "parent.js": () => { + this.foo = class extends ExtensionAPI { + getAPI(context) { + return { + experiments: { + foo: { + parent() { + return Promise.resolve("parent"); + }, + }, + }, + }; + } + }; + }, + + "child.js": () => { + this.foo = class extends ExtensionAPI { + getAPI(context) { + const EventManagerWithAssertions = class extends ExtensionCommon.EventManager { + constructor(...args) { + super(...args); + this.assertResetOnIdleOnEvent(); + } + + assertResetOnIdleOnEvent() { + const expectResetIdleOnEventFalse = + this.context.extension.persistentBackground; + if (expectResetIdleOnEventFalse && this.resetIdleOnEvent) { + const details = { + eventManagerName: this.name, + resetIdleOnEvent: this.resetIdleOnEvent, + envType: this.context.envType, + viewType: this.context.viewType, + isBackgroundContext: this.context.isBackgroundContext, + persistentBackground: + this.context.extension.persistentBackground, + }; + throw new Error( + `EventManagerWithAssertions: resetIdleOnEvent should be forcefully set to false - ${JSON.stringify( + details + )}` + ); + } + } + }; + return { + experiments: { + foo: { + child() { + return "child"; + }, + onChildEvent: new EventManagerWithAssertions({ + context, + name: `experiments.foo.onChildEvent`, + register: fire => { + return () => {}; + }, + }).api(), + }, + }, + }; + } + }; + }, +}; + +async function testFooExperiment() { + browser.test.assertEq( + "object", + typeof browser.experiments, + "typeof browser.experiments" + ); + + browser.test.assertEq( + "object", + typeof browser.experiments.foo, + "typeof browser.experiments.foo" + ); + + browser.test.assertEq( + "function", + typeof browser.experiments.foo.child, + "typeof browser.experiments.foo.child" + ); + + browser.test.assertEq( + "function", + typeof browser.experiments.foo.parent, + "typeof browser.experiments.foo.parent" + ); + + browser.test.assertEq( + "child", + browser.experiments.foo.child(), + "foo.child()" + ); + + browser.test.assertEq( + "parent", + await browser.experiments.foo.parent(), + "await foo.parent()" + ); +} + +async function testFooFailExperiment() { + browser.test.assertEq( + "object", + typeof browser.experiments, + "typeof browser.experiments" + ); + + browser.test.assertEq( + "undefined", + typeof browser.experiments.foo, + "typeof browser.experiments.foo" + ); +} + +add_task(async function test_bundled_experiments() { + let testCases = [ + { isSystem: true, temporarilyInstalled: true, shouldHaveExperiments: true }, + { + isSystem: true, + temporarilyInstalled: false, + shouldHaveExperiments: true, + }, + { + isPrivileged: true, + temporarilyInstalled: true, + shouldHaveExperiments: true, + }, + { + isPrivileged: true, + temporarilyInstalled: false, + shouldHaveExperiments: true, + }, + { + isPrivileged: false, + temporarilyInstalled: true, + shouldHaveExperiments: AddonSettings.EXPERIMENTS_ENABLED, + }, + { + isPrivileged: false, + temporarilyInstalled: false, + shouldHaveExperiments: AppConstants.MOZ_APP_NAME == "thunderbird", + }, + ]; + + async function background(shouldHaveExperiments) { + if (shouldHaveExperiments) { + await testFooExperiment(); + } else { + await testFooFailExperiment(); + } + + browser.test.notifyPass("background.experiments.foo"); + } + + for (let testCase of testCases) { + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged: testCase.isPrivileged, + isSystem: testCase.isSystem, + temporarilyInstalled: testCase.temporarilyInstalled, + + manifest: { + experiment_apis: fooExperimentAPIs, + }, + + background: ` + ${testFooExperiment} + ${testFooFailExperiment} + (${background})(${testCase.shouldHaveExperiments}); + `, + + files: fooExperimentFiles, + }); + + if (testCase.temporarilyInstalled && !testCase.shouldHaveExperiments) { + ExtensionTestUtils.failOnSchemaWarnings(false); + await Assert.rejects( + extension.startup(), + /Using 'experiment_apis' requires a privileged add-on/, + "startup failed without experimental api access" + ); + ExtensionTestUtils.failOnSchemaWarnings(true); + } else { + await extension.startup(); + + await extension.awaitFinish("background.experiments.foo"); + + await extension.unload(); + } + } +}); + +add_task(async function test_unbundled_experiments() { + async function background() { + await testFooExperiment(); + + browser.test.assertEq( + "object", + typeof browser.experiments.crunk, + "typeof browser.experiments.crunk" + ); + + browser.test.assertEq( + "function", + typeof browser.experiments.crunk.child, + "typeof browser.experiments.crunk.child" + ); + + browser.test.assertEq( + "function", + typeof browser.experiments.crunk.parent, + "typeof browser.experiments.crunk.parent" + ); + + browser.test.assertEq( + "crunk-child", + browser.experiments.crunk.child(), + "crunk.child()" + ); + + browser.test.assertEq( + "crunk-parent", + await browser.experiments.crunk.parent(), + "await crunk.parent()" + ); + + browser.test.notifyPass("background.experiments.crunk"); + } + + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + + manifest: { + experiment_apis: fooExperimentAPIs, + + permissions: ["experiments.crunk"], + }, + + background: ` + ${testFooExperiment} + (${background})(); + `, + + files: fooExperimentFiles, + }); + + let apiExtension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + + manifest: { + browser_specific_settings: { + gecko: { id: "crunk@experiments.addons.mozilla.org" }, + }, + + experiment_apis: { + crunk: { + schema: "schema.json", + parent: { + scopes: ["addon_parent"], + script: "parent.js", + paths: [["experiments", "crunk", "parent"]], + }, + child: { + scopes: ["addon_child"], + script: "child.js", + paths: [["experiments", "crunk", "child"]], + }, + }, + }, + }, + + files: { + "schema.json": JSON.stringify([ + { + namespace: "experiments.crunk", + types: [ + { + id: "Meh", + type: "object", + properties: {}, + }, + ], + functions: [ + { + name: "parent", + type: "function", + async: true, + parameters: [], + }, + { + name: "child", + type: "function", + parameters: [], + returns: { type: "string" }, + }, + ], + }, + ]), + + "parent.js": () => { + this.crunk = class extends ExtensionAPI { + getAPI(context) { + return { + experiments: { + crunk: { + parent() { + return Promise.resolve("crunk-parent"); + }, + }, + }, + }; + } + }; + }, + + "child.js": () => { + this.crunk = class extends ExtensionAPI { + getAPI(context) { + return { + experiments: { + crunk: { + child() { + return "crunk-child"; + }, + }, + }, + }; + } + }; + }, + }, + }); + + await apiExtension.startup(); + await extension.startup(); + + await extension.awaitFinish("background.experiments.crunk"); + + await extension.unload(); + await apiExtension.unload(); +}); + +add_task(async function test_eventpage_with_experiments_resetOnIdleAssert() { + async function event_page() { + browser.test.log("EventPage startup"); + // We expect EventManagerWithAssertions instance to throw + // here if the resetIdleOnEvent didn't got forcefully + // set to false for the EventManager instantiated in + // the child process. + browser.experiments.foo.onChildEvent.addListener(() => {}); + browser.test.sendMessage("eventpage:ready"); + } + + const extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + manifest: { + experiment_apis: fooExperimentAPIs, + background: { persistent: false }, + }, + + background: event_page, + + files: fooExperimentFiles, + }); + + await extension.startup(); + + await extension.awaitMessage("eventpage:ready"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extension.js b/toolkit/components/extensions/test/xpcshell/test_ext_extension.js new file mode 100644 index 0000000000..b50d8cd734 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension.js @@ -0,0 +1,74 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_is_allowed_incognito_access() { + async function background() { + let allowed = await browser.extension.isAllowedIncognitoAccess(); + + browser.test.assertEq(true, allowed, "isAllowedIncognitoAccess is true"); + browser.test.notifyPass("isAllowedIncognitoAccess"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + incognitoOverride: "spanning", + }); + + await extension.startup(); + await extension.awaitFinish("isAllowedIncognitoAccess"); + await extension.unload(); +}); + +add_task(async function test_is_denied_incognito_access() { + async function background() { + let allowed = await browser.extension.isAllowedIncognitoAccess(); + + browser.test.assertEq(false, allowed, "isAllowedIncognitoAccess is false"); + browser.test.notifyPass("isNotAllowedIncognitoAccess"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + }); + + await extension.startup(); + await extension.awaitFinish("isNotAllowedIncognitoAccess"); + await extension.unload(); +}); + +add_task(async function test_in_incognito_context_false() { + function background() { + browser.test.assertEq( + false, + browser.extension.inIncognitoContext, + "inIncognitoContext returned false" + ); + browser.test.notifyPass("inIncognitoContext"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + }); + + await extension.startup(); + await extension.awaitFinish("inIncognitoContext"); + await extension.unload(); +}); + +add_task(async function test_is_allowed_file_scheme_access() { + async function background() { + let allowed = await browser.extension.isAllowedFileSchemeAccess(); + + browser.test.assertEq(false, allowed, "isAllowedFileSchemeAccess is false"); + browser.test.notifyPass("isAllowedFileSchemeAccess"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + }); + + await extension.startup(); + await extension.awaitFinish("isAllowedFileSchemeAccess"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js b/toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js new file mode 100644 index 0000000000..75b0112142 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js @@ -0,0 +1,873 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionPreferencesManager: + "resource://gre/modules/ExtensionPreferencesManager.sys.mjs", + ExtensionSettingsStore: + "resource://gre/modules/ExtensionSettingsStore.sys.mjs", + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +const { createAppInfo, promiseShutdownManager, promiseStartupManager } = + AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +let lastSetPref; + +const STORE_TYPE = "prefs"; + +// Test settings to use with the preferences manager. +const SETTINGS = { + multiple_prefs: { + prefNames: ["my.pref.1", "my.pref.2", "my.pref.3"], + + initalValues: ["value1", "value2", "value3"], + + valueFn(pref, value) { + return `${pref}-${value}`; + }, + + setCallback(value) { + let prefs = {}; + for (let pref of this.prefNames) { + prefs[pref] = this.valueFn(pref, value); + } + return prefs; + }, + }, + + singlePref: { + prefNames: ["my.single.pref"], + + initalValues: ["value1"], + + onPrefsChanged(item) { + lastSetPref = item; + }, + + valueFn(pref, value) { + return value; + }, + + setCallback(value) { + return { [this.prefNames[0]]: this.valueFn(null, value) }; + }, + }, +}; + +ExtensionPreferencesManager.addSetting( + "multiple_prefs", + SETTINGS.multiple_prefs +); +ExtensionPreferencesManager.addSetting("singlePref", SETTINGS.singlePref); + +// Set initial values for prefs. +for (let setting in SETTINGS) { + setting = SETTINGS[setting]; + for (let i = 0; i < setting.prefNames.length; i++) { + Preferences.set(setting.prefNames[i], setting.initalValues[i]); + } +} + +function checkPrefs(settingObj, value, msg) { + for (let pref of settingObj.prefNames) { + equal(Preferences.get(pref), settingObj.valueFn(pref, value), msg); + } +} + +function checkOnPrefsChanged(setting, value, msg) { + if (value) { + deepEqual(lastSetPref, value, msg); + lastSetPref = null; + } else { + ok(!lastSetPref, msg); + } +} + +add_task(async function test_preference_manager() { + await promiseStartupManager(); + + // Create an array of test framework extension wrappers to install. + let testExtensions = [ + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: {}, + }), + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: {}, + }), + ]; + + for (let extension of testExtensions) { + await extension.startup(); + } + + // Create an array actual Extension objects which correspond to the + // test framework extension wrappers. + let extensions = testExtensions.map(extension => extension.extension); + + for (let setting in SETTINGS) { + let settingObj = SETTINGS[setting]; + let newValue1 = "newValue1"; + let levelOfControl = await ExtensionPreferencesManager.getLevelOfControl( + extensions[1].id, + setting + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + null, + "onPrefsChanged has not been called yet" + ); + } + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl with no settings set." + ); + + let prefsChanged = await ExtensionPreferencesManager.setSetting( + extensions[1].id, + setting, + newValue1 + ); + ok(prefsChanged, "setSetting returns true when the pref(s) have been set."); + checkPrefs( + settingObj, + newValue1, + "setSetting sets the prefs for the first extension." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + { id: extensions[1].id, value: newValue1, key: setting }, + "onPrefsChanged is called when pref changes" + ); + } + levelOfControl = await ExtensionPreferencesManager.getLevelOfControl( + extensions[1].id, + setting + ); + equal( + levelOfControl, + "controlled_by_this_extension", + "getLevelOfControl returns correct levelOfControl when a pref has been set." + ); + + let checkSetting = await ExtensionPreferencesManager.getSetting(setting); + equal( + checkSetting.value, + newValue1, + "getSetting returns the expected value." + ); + + let newValue2 = "newValue2"; + prefsChanged = await ExtensionPreferencesManager.setSetting( + extensions[0].id, + setting, + newValue2 + ); + ok( + !prefsChanged, + "setSetting returns false when the pref(s) have not been set." + ); + checkPrefs( + settingObj, + newValue1, + "setSetting does not set the pref(s) for an earlier extension." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + null, + "onPrefsChanged isn't called without control change" + ); + } + + prefsChanged = await ExtensionPreferencesManager.disableSetting( + extensions[0].id, + setting + ); + ok( + !prefsChanged, + "disableSetting returns false when the pref(s) have not been set." + ); + checkPrefs( + settingObj, + newValue1, + "disableSetting does not change the pref(s) for the non-top extension." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + null, + "onPrefsChanged isn't called without control change on disable" + ); + } + + prefsChanged = await ExtensionPreferencesManager.enableSetting( + extensions[0].id, + setting + ); + ok( + !prefsChanged, + "enableSetting returns false when the pref(s) have not been set." + ); + checkPrefs( + settingObj, + newValue1, + "enableSetting does not change the pref(s) for the non-top extension." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + null, + "onPrefsChanged isn't called without control change on enable" + ); + } + + prefsChanged = await ExtensionPreferencesManager.removeSetting( + extensions[0].id, + setting + ); + ok( + !prefsChanged, + "removeSetting returns false when the pref(s) have not been set." + ); + checkPrefs( + settingObj, + newValue1, + "removeSetting does not change the pref(s) for the non-top extension." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + null, + "onPrefsChanged isn't called without control change on remove" + ); + } + + prefsChanged = await ExtensionPreferencesManager.setSetting( + extensions[0].id, + setting, + newValue2 + ); + ok( + !prefsChanged, + "setSetting returns false when the pref(s) have not been set." + ); + checkPrefs( + settingObj, + newValue1, + "setSetting does not set the pref(s) for an earlier extension." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + null, + "onPrefsChanged isn't called without control change again" + ); + } + + prefsChanged = await ExtensionPreferencesManager.disableSetting( + extensions[1].id, + setting + ); + ok( + prefsChanged, + "disableSetting returns true when the pref(s) have been set." + ); + checkPrefs( + settingObj, + newValue2, + "disableSetting sets the pref(s) to the next value when disabling the top extension." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + { id: extensions[0].id, key: setting, value: newValue2 }, + "onPrefsChanged is called when control changes on disable" + ); + } + + prefsChanged = await ExtensionPreferencesManager.enableSetting( + extensions[1].id, + setting + ); + ok( + prefsChanged, + "enableSetting returns true when the pref(s) have been set." + ); + checkPrefs( + settingObj, + newValue1, + "enableSetting sets the pref(s) to the previous value(s)." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + { id: extensions[1].id, key: setting, value: newValue1 }, + "onPrefsChanged is called when control changes on enable" + ); + } + + prefsChanged = await ExtensionPreferencesManager.removeSetting( + extensions[1].id, + setting + ); + ok( + prefsChanged, + "removeSetting returns true when the pref(s) have been set." + ); + checkPrefs( + settingObj, + newValue2, + "removeSetting sets the pref(s) to the next value when removing the top extension." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + { id: extensions[0].id, key: setting, value: newValue2 }, + "onPrefsChanged is called when control changes on remove" + ); + } + + prefsChanged = await ExtensionPreferencesManager.removeSetting( + extensions[0].id, + setting + ); + ok( + prefsChanged, + "removeSetting returns true when the pref(s) have been set." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + { key: setting, initialValue: { "my.single.pref": "value1" } }, + "onPrefsChanged is called when control is entirely removed" + ); + } + for (let i = 0; i < settingObj.prefNames.length; i++) { + equal( + Preferences.get(settingObj.prefNames[i]), + settingObj.initalValues[i], + "removeSetting sets the pref(s) to the initial value(s) when removing the last extension." + ); + } + + checkSetting = await ExtensionPreferencesManager.getSetting(setting); + equal( + checkSetting, + null, + "getSetting returns null when nothing has been set." + ); + } + + // Tests for unsetAll. + let newValue3 = "newValue3"; + for (let setting in SETTINGS) { + let settingObj = SETTINGS[setting]; + await ExtensionPreferencesManager.setSetting( + extensions[0].id, + setting, + newValue3 + ); + checkPrefs(settingObj, newValue3, "setSetting set the pref."); + } + + let setSettings = await ExtensionSettingsStore.getAllForExtension( + extensions[0].id, + STORE_TYPE + ); + deepEqual( + setSettings, + Object.keys(SETTINGS), + "Expected settings were set for extension." + ); + await ExtensionPreferencesManager.disableAll(extensions[0].id); + + for (let setting in SETTINGS) { + let settingObj = SETTINGS[setting]; + for (let i = 0; i < settingObj.prefNames.length; i++) { + equal( + Preferences.get(settingObj.prefNames[i]), + settingObj.initalValues[i], + "disableAll unset the pref." + ); + } + } + + setSettings = await ExtensionSettingsStore.getAllForExtension( + extensions[0].id, + STORE_TYPE + ); + deepEqual( + setSettings, + Object.keys(SETTINGS), + "disableAll retains the settings." + ); + + await ExtensionPreferencesManager.enableAll(extensions[0].id); + for (let setting in SETTINGS) { + let settingObj = SETTINGS[setting]; + checkPrefs(settingObj, newValue3, "enableAll re-set the pref."); + } + + await ExtensionPreferencesManager.removeAll(extensions[0].id); + + for (let setting in SETTINGS) { + let settingObj = SETTINGS[setting]; + for (let i = 0; i < settingObj.prefNames.length; i++) { + equal( + Preferences.get(settingObj.prefNames[i]), + settingObj.initalValues[i], + "removeAll unset the pref." + ); + } + } + + setSettings = await ExtensionSettingsStore.getAllForExtension( + extensions[0].id, + STORE_TYPE + ); + deepEqual(setSettings, [], "removeAll removed all settings."); + + // Tests for preventing automatic changes to manually edited prefs. + for (let setting in SETTINGS) { + let apiValue = "newValue"; + let manualValue = "something different"; + let settingObj = SETTINGS[setting]; + let extension = extensions[1]; + await ExtensionPreferencesManager.setSetting( + extension.id, + setting, + apiValue + ); + + let checkResetPrefs = method => { + let prefNames = settingObj.prefNames; + for (let i = 0; i < prefNames.length; i++) { + if (i === 0) { + equal( + Preferences.get(prefNames[0]), + manualValue, + `${method} did not change a manually set pref.` + ); + } else { + equal( + Preferences.get(prefNames[i]), + settingObj.valueFn(prefNames[i], apiValue), + `${method} did not change another pref when a pref was manually set.` + ); + } + } + }; + + // Manually set the preference to a different value. + Preferences.set(settingObj.prefNames[0], manualValue); + + await ExtensionPreferencesManager.disableAll(extension.id); + checkResetPrefs("disableAll"); + + await ExtensionPreferencesManager.enableAll(extension.id); + checkResetPrefs("enableAll"); + + await ExtensionPreferencesManager.removeAll(extension.id); + checkResetPrefs("removeAll"); + } + + // Test with an uninitialized pref. + let setting = "singlePref"; + let settingObj = SETTINGS[setting]; + let pref = settingObj.prefNames[0]; + let newValue = "newValue"; + Preferences.reset(pref); + await ExtensionPreferencesManager.setSetting( + extensions[1].id, + setting, + newValue + ); + equal( + Preferences.get(pref), + settingObj.valueFn(pref, newValue), + "Uninitialized pref is set." + ); + await ExtensionPreferencesManager.removeSetting(extensions[1].id, setting); + ok(!Preferences.has(pref), "removeSetting removed the pref."); + + // Test levelOfControl with a locked pref. + setting = "multiple_prefs"; + let prefToLock = SETTINGS[setting].prefNames[0]; + Preferences.lock(prefToLock, 1); + ok(Preferences.locked(prefToLock), `Preference ${prefToLock} is locked.`); + let levelOfControl = await ExtensionPreferencesManager.getLevelOfControl( + extensions[1].id, + setting + ); + equal( + levelOfControl, + "not_controllable", + "getLevelOfControl returns correct levelOfControl when a pref is locked." + ); + + for (let extension of testExtensions) { + await extension.unload(); + } + + await promiseShutdownManager(); +}); + +add_task(async function test_preference_manager_set_when_disabled() { + await promiseStartupManager(); + + let id = "@set-disabled-pref"; + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { gecko: { id } }, + }, + }); + + await extension.startup(); + + // We test both a default pref and a user-set pref. Get the default + // value off the pref we'll use. We fake the default pref by setting + // a value on it before creating the setting. + Services.prefs.setBoolPref("bar", true); + + function isUndefinedPref(pref) { + try { + Services.prefs.getStringPref(pref); + return false; + } catch (e) { + return true; + } + } + ok(isUndefinedPref("foo"), "test pref is not set"); + + await ExtensionSettingsStore.initialize(); + let lastItemChange = Promise.withResolvers(); + ExtensionPreferencesManager.addSetting("some-pref", { + prefNames: ["foo", "bar"], + onPrefsChanged(item) { + lastItemChange.resolve(item); + lastItemChange = Promise.withResolvers(); + }, + setCallback(value) { + return { [this.prefNames[0]]: value, [this.prefNames[1]]: false }; + }, + }); + + await ExtensionPreferencesManager.setSetting(id, "some-pref", "my value"); + + let item = ExtensionSettingsStore.getSetting("prefs", "some-pref"); + equal(item.value, "my value", "The value has been set"); + equal( + Services.prefs.getStringPref("foo"), + "my value", + "The user pref has been set" + ); + equal( + Services.prefs.getBoolPref("bar"), + false, + "The default pref has been set" + ); + + await ExtensionPreferencesManager.disableSetting(id, "some-pref"); + + // test that a disabled setting has been returned to the default value. In this + // case the pref is not a default pref, so it will be undefined. + item = ExtensionSettingsStore.getSetting("prefs", "some-pref"); + equal(item.value, undefined, "The value is back to default"); + equal(item.initialValue.foo, undefined, "The initialValue is correct"); + ok(isUndefinedPref("foo"), "user pref is not set"); + equal( + Services.prefs.getBoolPref("bar"), + true, + "The default pref has been restored to the default" + ); + + // test that setSetting() will enable a disabled setting + await ExtensionPreferencesManager.setSetting(id, "some-pref", "new value"); + + item = ExtensionSettingsStore.getSetting("prefs", "some-pref"); + equal(item.value, "new value", "The value is set again"); + equal( + Services.prefs.getStringPref("foo"), + "new value", + "The user pref is set again" + ); + equal( + Services.prefs.getBoolPref("bar"), + false, + "The default pref has been set again" + ); + + // Force settings to be serialized and reloaded to mimick what happens + // with settings through a restart of Firefox. Bug 1576266. + await ExtensionSettingsStore._reloadFile(true); + + // Now unload the extension to test prefs are reset properly. + let promise = lastItemChange.promise; + await extension.unload(); + + // Test that the pref is unset when an extension is uninstalled. + item = await promise; + deepEqual( + item, + { key: "some-pref", initialValue: { bar: true } }, + "The value has been reset" + ); + ok(isUndefinedPref("foo"), "user pref is not set"); + equal( + Services.prefs.getBoolPref("bar"), + true, + "The default pref has been restored to the default" + ); + Services.prefs.clearUserPref("bar"); + + await promiseShutdownManager(); +}); + +add_task(async function test_preference_default_upgraded() { + await promiseStartupManager(); + + let id = "@upgrade-pref"; + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { gecko: { id } }, + }, + }); + + await extension.startup(); + + // We set the default value for a pref here so it will be + // picked up by EPM. + let defaultPrefs = Services.prefs.getDefaultBranch(null); + defaultPrefs.setStringPref("bar", "initial default"); + + await ExtensionSettingsStore.initialize(); + ExtensionPreferencesManager.addSetting("some-pref", { + prefNames: ["bar"], + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + }); + + await ExtensionPreferencesManager.setSetting(id, "some-pref", "new value"); + let item = ExtensionSettingsStore.getSetting("prefs", "some-pref"); + equal(item.value, "new value", "The value is set"); + + defaultPrefs.setStringPref("bar", "new default"); + + item = ExtensionSettingsStore.getSetting("prefs", "some-pref"); + equal(item.value, "new value", "The value is still set"); + + let prefsChanged = await ExtensionPreferencesManager.removeSetting( + id, + "some-pref" + ); + ok(prefsChanged, "pref changed on removal of setting."); + equal(Preferences.get("bar"), "new default", "default value is correct"); + + await extension.unload(); + await promiseShutdownManager(); +}); + +add_task(async function test_preference_select() { + await promiseStartupManager(); + + let extensionData = { + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { gecko: { id: "@one" } }, + }, + }; + let one = ExtensionTestUtils.loadExtension(extensionData); + + await one.startup(); + + // We set the default value for a pref here so it will be + // picked up by EPM. + let defaultPrefs = Services.prefs.getDefaultBranch(null); + defaultPrefs.setStringPref("bar", "initial default"); + + await ExtensionSettingsStore.initialize(); + ExtensionPreferencesManager.addSetting("some-pref", { + prefNames: ["bar"], + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + }); + + ok( + await ExtensionPreferencesManager.setSetting( + one.id, + "some-pref", + "new value" + ), + "setting was changed" + ); + let item = await ExtensionPreferencesManager.getSetting("some-pref"); + equal(item.value, "new value", "The value is set"); + + // User-set the setting. + await ExtensionPreferencesManager.selectSetting(null, "some-pref"); + item = await ExtensionPreferencesManager.getSetting("some-pref"); + deepEqual( + item, + { key: "some-pref", initialValue: {} }, + "The value is user-set" + ); + + // Extensions installed before cannot gain control again. + let levelOfControl = await ExtensionPreferencesManager.getLevelOfControl( + one.id, + "some-pref" + ); + equal( + levelOfControl, + "not_controllable", + "getLevelOfControl returns correct levelOfControl when user-set." + ); + + // Enabling the top-precedence addon does not take over a user-set setting. + await ExtensionPreferencesManager.disableSetting(one.id, "some-pref"); + await ExtensionPreferencesManager.enableSetting(one.id, "some-pref"); + item = await ExtensionPreferencesManager.getSetting("some-pref"); + deepEqual( + item, + { key: "some-pref", initialValue: {} }, + "The value is user-set" + ); + + // Upgrading does not override the user-set setting. + extensionData.manifest.version = "2.0"; + extensionData.manifest.incognito = "not_allowed"; + await one.upgrade(extensionData); + levelOfControl = await ExtensionPreferencesManager.getLevelOfControl( + one.id, + "some-pref" + ); + equal( + levelOfControl, + "not_controllable", + "getLevelOfControl returns correct levelOfControl when user-set after addon upgrade." + ); + + // We can re-select the extension. + await ExtensionPreferencesManager.selectSetting(one.id, "some-pref"); + item = await ExtensionPreferencesManager.getSetting("some-pref"); + deepEqual(item.value, "new value", "The value is extension set"); + + // An extension installed after user-set can take over the setting. + await ExtensionPreferencesManager.selectSetting(null, "some-pref"); + item = await ExtensionPreferencesManager.getSetting("some-pref"); + deepEqual( + item, + { key: "some-pref", initialValue: {} }, + "The value is user-set" + ); + + let two = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { gecko: { id: "@two" } }, + }, + }); + + await two.startup(); + levelOfControl = await ExtensionPreferencesManager.getLevelOfControl( + two.id, + "some-pref" + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl when user-set after addon install." + ); + + await ExtensionPreferencesManager.setSetting( + two.id, + "some-pref", + "another value" + ); + item = ExtensionSettingsStore.getSetting("prefs", "some-pref"); + equal(item.value, "another value", "The value is set"); + + // A new installed extension can override a user selected extension. + let three = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { gecko: { id: "@three" } }, + }, + }); + + // user selects specific extension to take control + await ExtensionPreferencesManager.selectSetting(one.id, "some-pref"); + + // two cannot control + levelOfControl = await ExtensionPreferencesManager.getLevelOfControl( + two.id, + "some-pref" + ); + equal( + levelOfControl, + "not_controllable", + "getLevelOfControl returns correct levelOfControl when user-set after addon install." + ); + + // three can control after install + await three.startup(); + levelOfControl = await ExtensionPreferencesManager.getLevelOfControl( + three.id, + "some-pref" + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl when user-set after addon install." + ); + + await ExtensionPreferencesManager.setSetting( + three.id, + "some-pref", + "third value" + ); + item = ExtensionSettingsStore.getSetting("prefs", "some-pref"); + equal(item.value, "third value", "The value is set"); + + // We have returned to precedence based settings. + await ExtensionPreferencesManager.removeSetting(three.id, "some-pref"); + await ExtensionPreferencesManager.removeSetting(two.id, "some-pref"); + item = await ExtensionPreferencesManager.getSetting("some-pref"); + equal(item.value, "new value", "The value is extension set"); + + await one.unload(); + await two.unload(); + await three.unload(); + await promiseShutdownManager(); +}); + +add_task(async function test_preference_select() { + let prefNames = await ExtensionPreferencesManager.getManagedPrefDetails(); + // Just check a subset of settings that are in this test file. + Assert.ok(prefNames.size > 0, "some prefs exist"); + for (let settingName in SETTINGS) { + let setting = SETTINGS[settingName]; + for (let prefName of setting.prefNames) { + Assert.equal( + prefNames.get(prefName), + settingName, + "setting retrieved prefNames" + ); + } + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js b/toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js new file mode 100644 index 0000000000..720c7f539a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js @@ -0,0 +1,1085 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionSettingsStore: + "resource://gre/modules/ExtensionSettingsStore.sys.mjs", +}); + +const { createAppInfo, promiseShutdownManager, promiseStartupManager } = + AddonTestUtils; + +AddonTestUtils.init(this); + +// Allow for unsigned addons. +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +const ITEMS = { + key1: [ + { key: "key1", value: "val1", id: "@first" }, + { key: "key1", value: "val2", id: "@second" }, + { key: "key1", value: "val3", id: "@third" }, + ], + key2: [ + { key: "key2", value: "val1-2", id: "@first" }, + { key: "key2", value: "val2-2", id: "@second" }, + { key: "key2", value: "val3-2", id: "@third" }, + ], +}; +const KEY_LIST = Object.keys(ITEMS); +const TEST_TYPE = "myType"; + +let callbackCount = 0; + +function initialValue(key) { + callbackCount++; + return `key:${key}`; +} + +add_task(async function test_settings_store() { + await promiseStartupManager(); + + // Create an array of test framework extension wrappers to install. + let testExtensions = [ + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { gecko: { id: "@first" } }, + }, + }), + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { gecko: { id: "@second" } }, + }, + }), + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { gecko: { id: "@third" } }, + }, + }), + ]; + + for (let extension of testExtensions) { + await extension.startup(); + } + + // Create an array actual Extension objects which correspond to the + // test framework extension wrappers. + let extensions = testExtensions.map(extension => extension.extension); + + let expectedCallbackCount = 0; + + await Assert.rejects( + ExtensionSettingsStore.getLevelOfControl(1, TEST_TYPE, "key"), + /The ExtensionSettingsStore was accessed before the initialize promise resolved/, + "Accessing the SettingsStore before it is initialized throws an error." + ); + + // Initialize the SettingsStore. + await ExtensionSettingsStore.initialize(); + + // Add a setting for the second oldest extension, where it is the only setting for a key. + for (let key of KEY_LIST) { + let extensionIndex = 1; + let itemToAdd = ITEMS[key][extensionIndex]; + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl with no settings set for a key." + ); + let item = await ExtensionSettingsStore.addSetting( + extensions[extensionIndex].id, + TEST_TYPE, + itemToAdd.key, + itemToAdd.value, + initialValue + ); + expectedCallbackCount++; + equal( + callbackCount, + expectedCallbackCount, + "initialValueCallback called the expected number of times." + ); + deepEqual( + item, + itemToAdd, + "Adding initial item for a key returns that item." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + itemToAdd, + "getSetting returns correct item with only one item in the list." + ); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_this_extension", + "getLevelOfControl returns correct levelOfControl with only one item in the list." + ); + ok( + ExtensionSettingsStore.hasSetting( + extensions[extensionIndex].id, + TEST_TYPE, + key + ), + "hasSetting returns the correct value when an extension has a setting set." + ); + item = await ExtensionSettingsStore.getSetting( + TEST_TYPE, + key, + extensions[extensionIndex].id + ); + deepEqual( + item, + itemToAdd, + "getSetting with id returns correct item with only one item in the list." + ); + } + + // Add a setting for the oldest extension. + for (let key of KEY_LIST) { + let extensionIndex = 0; + let itemToAdd = ITEMS[key][extensionIndex]; + let item = await ExtensionSettingsStore.addSetting( + extensions[extensionIndex].id, + TEST_TYPE, + itemToAdd.key, + itemToAdd.value, + initialValue + ); + equal( + callbackCount, + expectedCallbackCount, + "initialValueCallback called the expected number of times." + ); + equal( + item, + null, + "An older extension adding a setting for a key returns null" + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][1], + "getSetting returns correct item with more than one item in the list." + ); + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_other_extensions", + "getLevelOfControl returns correct levelOfControl when another extension is in control." + ); + item = await ExtensionSettingsStore.getSetting( + TEST_TYPE, + key, + extensions[extensionIndex].id + ); + deepEqual( + item, + itemToAdd, + "getSetting with id returns correct item with more than one item in the list." + ); + } + + // Reload the settings store to emulate a browser restart. + await ExtensionSettingsStore._reloadFile(); + + // Add a setting for the newest extension. + for (let key of KEY_LIST) { + let extensionIndex = 2; + let itemToAdd = ITEMS[key][extensionIndex]; + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl for a more recent extension." + ); + let item = await ExtensionSettingsStore.addSetting( + extensions[extensionIndex].id, + TEST_TYPE, + itemToAdd.key, + itemToAdd.value, + initialValue + ); + equal( + callbackCount, + expectedCallbackCount, + "initialValueCallback called the expected number of times." + ); + deepEqual( + item, + itemToAdd, + "Adding item for most recent extension returns that item." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + itemToAdd, + "getSetting returns correct item with more than one item in the list." + ); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_this_extension", + "getLevelOfControl returns correct levelOfControl when this extension is in control." + ); + item = await ExtensionSettingsStore.getSetting( + TEST_TYPE, + key, + extensions[extensionIndex].id + ); + deepEqual( + item, + itemToAdd, + "getSetting with id returns correct item with more than one item in the list." + ); + } + + for (let extension of extensions) { + let items = await ExtensionSettingsStore.getAllForExtension( + extension.id, + TEST_TYPE + ); + deepEqual(items, KEY_LIST, "getAllForExtension returns expected keys."); + } + + // Attempting to remove a setting that has not been set should *not* throw an exception. + let removeResult = await ExtensionSettingsStore.removeSetting( + extensions[0].id, + "myType", + "unset_key" + ); + equal( + removeResult, + null, + "Removing a setting that was not previously set returns null." + ); + + // Attempting to disable a setting that has not been set should throw an exception. + Assert.throws( + () => + ExtensionSettingsStore.disable(extensions[0].id, "myType", "unset_key"), + /Cannot alter the setting for myType:unset_key as it does not exist/, + "disable rejects with an unset key." + ); + + // Attempting to enable a setting that has not been set should throw an exception. + Assert.throws( + () => + ExtensionSettingsStore.enable(extensions[0].id, "myType", "unset_key"), + /Cannot alter the setting for myType:unset_key as it does not exist/, + "enable rejects with an unset key." + ); + + let expectedKeys = KEY_LIST; + // Disable the non-top item for a key. + for (let key of KEY_LIST) { + let extensionIndex = 0; + let item = await ExtensionSettingsStore.addSetting( + extensions[extensionIndex].id, + TEST_TYPE, + key, + "new value", + initialValue + ); + equal( + callbackCount, + expectedCallbackCount, + "initialValueCallback called the expected number of times." + ); + equal(item, null, "Updating non-top item for a key returns null"); + item = await ExtensionSettingsStore.disable( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal(item, null, "Disabling non-top item for a key returns null."); + let allForExtension = await ExtensionSettingsStore.getAllForExtension( + extensions[extensionIndex].id, + TEST_TYPE + ); + deepEqual( + allForExtension, + expectedKeys, + "getAllForExtension returns expected keys after a disable." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][2], + "getSetting returns correct item after a disable." + ); + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_other_extensions", + "getLevelOfControl returns correct levelOfControl after disabling of non-top item." + ); + } + + // Re-enable the non-top item for a key. + for (let key of KEY_LIST) { + let extensionIndex = 0; + let item = await ExtensionSettingsStore.enable( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal(item, null, "Enabling non-top item for a key returns null."); + let allForExtension = await ExtensionSettingsStore.getAllForExtension( + extensions[extensionIndex].id, + TEST_TYPE + ); + deepEqual( + allForExtension, + expectedKeys, + "getAllForExtension returns expected keys after an enable." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][2], + "getSetting returns correct item after an enable." + ); + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_other_extensions", + "getLevelOfControl returns correct levelOfControl after enabling of non-top item." + ); + } + + // Remove the non-top item for a key. + for (let key of KEY_LIST) { + let extensionIndex = 0; + let item = await ExtensionSettingsStore.removeSetting( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal(item, null, "Removing non-top item for a key returns null."); + expectedKeys = expectedKeys.filter(expectedKey => expectedKey != key); + let allForExtension = await ExtensionSettingsStore.getAllForExtension( + extensions[extensionIndex].id, + TEST_TYPE + ); + deepEqual( + allForExtension, + expectedKeys, + "getAllForExtension returns expected keys after a removal." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][2], + "getSetting returns correct item after a removal." + ); + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_other_extensions", + "getLevelOfControl returns correct levelOfControl after removal of non-top item." + ); + ok( + !ExtensionSettingsStore.hasSetting( + extensions[extensionIndex].id, + TEST_TYPE, + key + ), + "hasSetting returns the correct value when an extension does not have a setting set." + ); + } + + for (let key of KEY_LIST) { + // Disable the top item for a key. + let item = await ExtensionSettingsStore.disable( + extensions[2].id, + TEST_TYPE, + key + ); + deepEqual( + item, + ITEMS[key][1], + "Disabling top item for a key returns the new top item." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][1], + "getSetting returns correct item after a disable." + ); + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[2].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl after disabling of top item." + ); + + // Re-enable the top item for a key. + item = await ExtensionSettingsStore.enable( + extensions[2].id, + TEST_TYPE, + key + ); + deepEqual( + item, + ITEMS[key][2], + "Re-enabling top item for a key returns the old top item." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][2], + "getSetting returns correct item after an enable." + ); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[2].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_this_extension", + "getLevelOfControl returns correct levelOfControl after re-enabling top item." + ); + + // Remove the top item for a key. + item = await ExtensionSettingsStore.removeSetting( + extensions[2].id, + TEST_TYPE, + key + ); + deepEqual( + item, + ITEMS[key][1], + "Removing top item for a key returns the new top item." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][1], + "getSetting returns correct item after a removal." + ); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[2].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl after removal of top item." + ); + + // Add a setting for the current top item. + let itemToAdd = { key, value: `new-${key}`, id: "@second" }; + item = await ExtensionSettingsStore.addSetting( + extensions[1].id, + TEST_TYPE, + itemToAdd.key, + itemToAdd.value, + initialValue + ); + equal( + callbackCount, + expectedCallbackCount, + "initialValueCallback called the expected number of times." + ); + deepEqual( + item, + itemToAdd, + "Updating top item for a key returns that item." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + itemToAdd, + "getSetting returns correct item after updating." + ); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[1].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_this_extension", + "getLevelOfControl returns correct levelOfControl after updating." + ); + + // Disable the last remaining item for a key. + let expectedItem = { key, initialValue: initialValue(key) }; + // We're using the callback to set the expected value, so we need to increment the + // expectedCallbackCount. + expectedCallbackCount++; + item = await ExtensionSettingsStore.disable( + extensions[1].id, + TEST_TYPE, + key + ); + deepEqual( + item, + expectedItem, + "Disabling last item for a key returns the initial value." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + expectedItem, + "getSetting returns the initial value after all are disabled." + ); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[1].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl after all are disabled." + ); + + // Re-enable the last remaining item for a key. + item = await ExtensionSettingsStore.enable( + extensions[1].id, + TEST_TYPE, + key + ); + deepEqual( + item, + itemToAdd, + "Re-enabling last item for a key returns the old value." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + itemToAdd, + "getSetting returns expected value after re-enabling." + ); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[1].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_this_extension", + "getLevelOfControl returns correct levelOfControl after re-enabling." + ); + + // Remove the last remaining item for a key. + item = await ExtensionSettingsStore.removeSetting( + extensions[1].id, + TEST_TYPE, + key + ); + deepEqual( + item, + expectedItem, + "Removing last item for a key returns the initial value." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual(item, null, "getSetting returns null after all are removed."); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[1].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl after all are removed." + ); + + // Attempting to remove a setting that has had all extensions removed should *not* throw an exception. + removeResult = await ExtensionSettingsStore.removeSetting( + extensions[1].id, + TEST_TYPE, + key + ); + equal( + removeResult, + null, + "Removing a setting that has had all extensions removed returns null." + ); + } + + // Test adding a setting with a value in callbackArgument. + let extensionIndex = 0; + let testKey = "callbackArgumentKey"; + let callbackArgumentValue = Date.now(); + // Add the setting. + let item = await ExtensionSettingsStore.addSetting( + extensions[extensionIndex].id, + TEST_TYPE, + testKey, + 1, + initialValue, + callbackArgumentValue + ); + expectedCallbackCount++; + equal( + callbackCount, + expectedCallbackCount, + "initialValueCallback called the expected number of times." + ); + // Remove the setting which should return the initial value. + let expectedItem = { + key: testKey, + initialValue: initialValue(callbackArgumentValue), + }; + // We're using the callback to set the expected value, so we need to increment the + // expectedCallbackCount. + expectedCallbackCount++; + item = await ExtensionSettingsStore.removeSetting( + extensions[extensionIndex].id, + TEST_TYPE, + testKey + ); + deepEqual( + item, + expectedItem, + "Removing last item for a key returns the initial value." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, testKey); + deepEqual(item, null, "getSetting returns null after all are removed."); + + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, "not a key"); + equal( + item, + null, + "getSetting returns a null item if the setting does not have any records." + ); + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[1].id, + TEST_TYPE, + "not a key" + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl if the setting does not have any records." + ); + + for (let extension of testExtensions) { + await extension.unload(); + } + + await promiseShutdownManager(); +}); + +add_task(async function test_settings_store_setByUser() { + await promiseStartupManager(); + + // Create an array of test framework extension wrappers to install. + let testExtensions = [ + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { gecko: { id: "@first" } }, + }, + }), + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { gecko: { id: "@second" } }, + }, + }), + ]; + + let type = "some_type"; + let key = "some_key"; + + for (let extension of testExtensions) { + await extension.startup(); + } + + // Create an array actual Extension objects which correspond to the + // test framework extension wrappers. + let [one, two] = testExtensions.map(extension => extension.extension); + let initialCallback = () => "initial"; + + // Initialize the SettingsStore. + await ExtensionSettingsStore.initialize(); + + equal( + null, + ExtensionSettingsStore.getSetting(type, key), + "getSetting is initially null" + ); + + let item = await ExtensionSettingsStore.addSetting( + one.id, + type, + key, + "one", + initialCallback + ); + deepEqual( + { key, value: "one", id: one.id }, + item, + "addSetting returns the first set item" + ); + + item = await ExtensionSettingsStore.addSetting( + two.id, + type, + key, + "two", + initialCallback + ); + deepEqual( + { key, value: "two", id: two.id }, + item, + "addSetting returns the second set item" + ); + + // a user-set selection reverts to precedence order when new + // extension sets the setting. + ExtensionSettingsStore.select( + ExtensionSettingsStore.SETTING_USER_SET, + type, + key + ); + deepEqual( + { key, initialValue: "initial" }, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns the initial value after being set by user" + ); + + let three = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { gecko: { id: "@third" } }, + }, + }); + await three.startup(); + + item = await ExtensionSettingsStore.addSetting( + three.id, + type, + key, + "three", + initialCallback + ); + deepEqual( + { key, value: "three", id: three.id }, + item, + "addSetting returns the third set item" + ); + deepEqual( + item, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns the third set item" + ); + + ExtensionSettingsStore.select( + ExtensionSettingsStore.SETTING_USER_SET, + type, + key + ); + deepEqual( + { key, initialValue: "initial" }, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns the initial value after being set by user" + ); + + item = ExtensionSettingsStore.select(one.id, type, key); + deepEqual( + { key, value: "one", id: one.id }, + item, + "selecting an extension returns the first set item after enable" + ); + + // Disabling a selected item returns to precedence order + ExtensionSettingsStore.disable(one.id, type, key); + deepEqual( + { key, value: "three", id: three.id }, + ExtensionSettingsStore.getSetting(type, key), + "returning to precedence order sets the third set item" + ); + + // Test that disabling all then enabling one does not take over a user-set setting. + ExtensionSettingsStore.select( + ExtensionSettingsStore.SETTING_USER_SET, + type, + key + ); + deepEqual( + { key, initialValue: "initial" }, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns the initial value after being set by user" + ); + + ExtensionSettingsStore.disable(three.id, type, key); + ExtensionSettingsStore.disable(two.id, type, key); + deepEqual( + { key, initialValue: "initial" }, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns the initial value after disabling all extensions" + ); + + ExtensionSettingsStore.enable(three.id, type, key); + deepEqual( + { key, initialValue: "initial" }, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns the initial value after enabling one extension" + ); + + // Ensure that calling addSetting again will not reset a user-set value when + // the extension install date is older than the user-set date. + item = await ExtensionSettingsStore.addSetting( + three.id, + type, + key, + "three", + initialCallback + ); + deepEqual( + { key, initialValue: "initial" }, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns the initial value after calling addSetting for old addon" + ); + + item = ExtensionSettingsStore.enable(three.id, type, key); + equal(undefined, item, "enabling the active item does not return an item"); + deepEqual( + { key, initialValue: "initial" }, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns the initial value after enabling one extension" + ); + + ExtensionSettingsStore.removeSetting(three.id, type, key); + ExtensionSettingsStore.removeSetting(two.id, type, key); + ExtensionSettingsStore.removeSetting(one.id, type, key); + + equal( + null, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns null after removing all settings" + ); + + for (let extension of testExtensions) { + await extension.unload(); + } + + await promiseShutdownManager(); +}); + +add_task(async function test_settings_store_add_disabled() { + await promiseStartupManager(); + + let id = "@add-on-disable"; + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { gecko: { id } }, + }, + }); + + await extension.startup(); + await ExtensionSettingsStore.initialize(); + + await ExtensionSettingsStore.addSetting( + id, + "foo", + "bar", + "set", + () => "not set" + ); + + let item = ExtensionSettingsStore.getSetting("foo", "bar"); + equal(item.id, id, "The add-on is in control"); + equal(item.value, "set", "The value is set"); + + ExtensionSettingsStore.disable(id, "foo", "bar"); + item = ExtensionSettingsStore.getSetting("foo", "bar"); + equal(item.id, undefined, "The add-on is not in control"); + equal(item.initialValue, "not set", "The value is not set"); + + await ExtensionSettingsStore.addSetting( + id, + "foo", + "bar", + "set", + () => "not set" + ); + item = ExtensionSettingsStore.getSetting("foo", "bar"); + equal(item.id, id, "The add-on is in control"); + equal(item.value, "set", "The value is set"); + + await extension.unload(); + + await promiseShutdownManager(); +}); + +add_task(async function test_settings_uninstall_remove() { + await promiseStartupManager(); + + let id = "@add-on-uninstall"; + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { gecko: { id } }, + }, + }); + + await extension.startup(); + await ExtensionSettingsStore.initialize(); + + await ExtensionSettingsStore.addSetting( + id, + "foo", + "bar", + "set", + () => "not set" + ); + + let item = ExtensionSettingsStore.getSetting("foo", "bar"); + equal(item.id, id, "The add-on is in control"); + equal(item.value, "set", "The value is set"); + + await extension.unload(); + + await promiseShutdownManager(); + + item = ExtensionSettingsStore.getSetting("foo", "bar"); + equal(item, null, "The add-on setting was removed"); +}); + +add_task(async function test_exceptions() { + await ExtensionSettingsStore.initialize(); + + await Assert.rejects( + ExtensionSettingsStore.addSetting( + 1, + TEST_TYPE, + "key_not_a_function", + "val1", + "not a function" + ), + /initialValueCallback must be a function/, + "addSetting rejects with a callback that is not a function." + ); +}); + +add_task(async function test_get_all_settings() { + await promiseStartupManager(); + + let testExtensions = [ + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { gecko: { id: "@first" } }, + }, + }), + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { gecko: { id: "@second" } }, + }, + }), + ]; + + for (let extension of testExtensions) { + await extension.startup(); + } + + await ExtensionSettingsStore.initialize(); + + let items = ExtensionSettingsStore.getAllSettings("foo", "bar"); + equal(items.length, 0, "There are no addons controlling this setting yet"); + + await ExtensionSettingsStore.addSetting( + "@first", + "foo", + "bar", + "set", + () => "not set" + ); + + items = ExtensionSettingsStore.getAllSettings("foo", "bar"); + equal(items.length, 1, "The add-on setting has 1 addon trying to control it"); + + await ExtensionSettingsStore.addSetting( + "@second", + "foo", + "bar", + "setting", + () => "not set" + ); + + let item = ExtensionSettingsStore.getSetting("foo", "bar"); + equal(item.id, "@second", "The second add-on is in control"); + equal(item.value, "setting", "The second value is set"); + + items = ExtensionSettingsStore.getAllSettings("foo", "bar"); + equal( + items.length, + 2, + "The add-on setting has 2 addons trying to control it" + ); + + await ExtensionSettingsStore.removeSetting("@first", "foo", "bar"); + + items = ExtensionSettingsStore.getAllSettings("foo", "bar"); + equal(items.length, 1, "There is only 1 addon controlling this setting"); + + await ExtensionSettingsStore.removeSetting("@second", "foo", "bar"); + + items = ExtensionSettingsStore.getAllSettings("foo", "bar"); + equal( + items.length, + 0, + "There is no longer any addon controlling this setting" + ); + + for (let extension of testExtensions) { + await extension.unload(); + } + + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extension_content_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_extension_content_telemetry.js new file mode 100644 index 0000000000..8d5ec1d0f6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension_content_telemetry.js @@ -0,0 +1,220 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const HISTOGRAM = "WEBEXT_CONTENT_SCRIPT_INJECTION_MS"; +const HISTOGRAM_KEYED = "WEBEXT_CONTENT_SCRIPT_INJECTION_MS_BY_ADDONID"; +const GLEAN_METRIC_ID = "contentScriptInjection"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function test_telemetry() { + const { GleanTimingDistribution } = globalThis; + + function contentScript() { + browser.test.sendMessage("content-script-run"); + } + + let extension1 = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + run_at: "document_end", + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); + let extension2 = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + run_at: "document_end", + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); + + // Make sure to force flushing glean fog data from child processes before + // resetting the already collected data. + await Services.fog.testFlushAllChildren(); + resetTelemetryData(); + + let process = "content"; + + // Assert unified telemetry data. + if (AppConstants.platform != "android") { + ok( + !(HISTOGRAM in getSnapshots(process)), + `No data recorded for histogram: ${HISTOGRAM}.` + ); + ok( + !(HISTOGRAM_KEYED in getKeyedSnapshots(process)), + `No data recorded for keyed histogram: ${HISTOGRAM_KEYED}.` + ); + } + + // Assert glean telemetry data. + assertGleanMetricsNoSamples({ + metricId: GLEAN_METRIC_ID, + gleanMetric: Glean.extensionsTiming[GLEAN_METRIC_ID], + gleanMetricConstructor: GleanTimingDistribution, + }); + + await extension1.startup(); + let extensionId = extension1.extension.id; + + info(`Started extension with id ${extensionId}`); + + // Assert unified telemetry data. + if (AppConstants.platform != "android") { + ok( + !(HISTOGRAM in getSnapshots(process)), + `No data recorded for histogram after startup: ${HISTOGRAM}.` + ); + ok( + !(HISTOGRAM_KEYED in getKeyedSnapshots(process)), + `No data recorded for keyed histogram: ${HISTOGRAM_KEYED}.` + ); + } + + // Assert glean telemetry data. + assertGleanMetricsNoSamples({ + metricId: GLEAN_METRIC_ID, + gleanMetric: Glean.extensionsTiming[GLEAN_METRIC_ID], + gleanMetricConstructor: GleanTimingDistribution, + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + await extension1.awaitMessage("content-script-run"); + + // Assert unified telemetry data. + if (AppConstants.platform != "android") { + await promiseTelemetryRecorded(HISTOGRAM, process, 1); + await promiseKeyedTelemetryRecorded( + HISTOGRAM_KEYED, + process, + extensionId, + 1 + ); + + equal( + valueSum(getSnapshots(process)[HISTOGRAM].values), + 1, + `Data recorded for histogram: ${HISTOGRAM}.` + ); + equal( + valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId].values), + 1, + `Data recorded for histogram: ${HISTOGRAM_KEYED} with key ${extensionId}.` + ); + } + + // Assert glean telemetry data. + await Services.fog.testFlushAllChildren(); + assertGleanMetricsSamplesCount({ + metricId: GLEAN_METRIC_ID, + gleanMetric: Glean.extensionsTiming[GLEAN_METRIC_ID], + gleanMetricConstructor: GleanTimingDistribution, + expectedSamplesCount: 1, + }); + + await contentPage.close(); + await extension1.unload(); + + await extension2.startup(); + let extensionId2 = extension2.extension.id; + + info(`Started extension with id ${extensionId2}`); + + // Assert unified telemetry data. + if (AppConstants.platform != "android") { + equal( + valueSum(getSnapshots(process)[HISTOGRAM].values), + 1, + `No new data recorded for histogram after extension2 startup: ${HISTOGRAM}.` + ); + equal( + valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId].values), + 1, + `No new data recorded for histogram after extension2 startup: ${HISTOGRAM_KEYED} with key ${extensionId}.` + ); + ok( + !(extensionId2 in getKeyedSnapshots(process)[HISTOGRAM_KEYED]), + `No data recorded for histogram after startup: ${HISTOGRAM_KEYED} with key ${extensionId2}.` + ); + } + + // Assert glean telemetry data. + await Services.fog.testFlushAllChildren(); + assertGleanMetricsSamplesCount({ + metricId: GLEAN_METRIC_ID, + gleanMetric: Glean.extensionsTiming[GLEAN_METRIC_ID], + gleanMetricConstructor: GleanTimingDistribution, + expectedSamplesCount: 1, + message: "No new data recorded yet after extension 2 startup", + }); + + contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + await extension2.awaitMessage("content-script-run"); + + // Assert unified telemetry data. + if (AppConstants.platform != "android") { + await promiseTelemetryRecorded(HISTOGRAM, process, 2); + await promiseKeyedTelemetryRecorded( + HISTOGRAM_KEYED, + process, + extensionId2, + 1 + ); + + equal( + valueSum(getSnapshots(process)[HISTOGRAM].values), + 2, + `Data recorded for histogram: ${HISTOGRAM}.` + ); + equal( + valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId].values), + 1, + `No new data recorded for histogram: ${HISTOGRAM_KEYED} with key ${extensionId}.` + ); + equal( + valueSum( + getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId2].values + ), + 1, + `Data recorded for histogram: ${HISTOGRAM_KEYED} with key ${extensionId2}.` + ); + } + + // Assert glean telemetry data. + await Services.fog.testFlushAllChildren(); + assertGleanMetricsSamplesCount({ + metricId: GLEAN_METRIC_ID, + gleanMetric: Glean.extensionsTiming[GLEAN_METRIC_ID], + gleanMetricConstructor: GleanTimingDistribution, + expectedSamplesCount: 2, + message: "New data recorded after extension 2 content script injection", + }); + + await contentPage.close(); + await extension2.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extension_page_navigated.js b/toolkit/components/extensions/test/xpcshell/test_ext_extension_page_navigated.js new file mode 100644 index 0000000000..e573595afb --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension_page_navigated.js @@ -0,0 +1,341 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +const server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/", (request, response) => { + response.write(`<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <title>test webpage</title> + </head> + </html> + `); +}); + +function createTestExtPage({ script }) { + return `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="${script}"></script> + </head> + </html> + `; +} + +function createTestExtPageScript(name) { + return `(${async function (pageName) { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.log( + `${pageName} got a webRequest.onBeforeRequest event: ${details.url}` + ); + browser.test.sendMessage(`event-received:${pageName}`); + }, + { urls: ["http://example.com/request*"] } + ); + + // Calling an API implemented in the parent process to make sure + // the webRequest.onBeforeRequest listener is got registered in + // the parent process by the time the test is going to expect that + // listener to intercept a test web request. + await browser.runtime.getBrowserInfo(); + browser.test.sendMessage(`page-loaded:${pageName}`); + }})("${name}");`; +} + +const getExtensionContextIdAndURL = extensionId => { + const { ExtensionProcessScript } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionProcessScript.sys.mjs" + ); + let extWindow = this.content.window; + let extChild = ExtensionProcessScript.getExtensionChild(extensionId); + + let contextIds = []; + let contextURLs = []; + for (let ctx of extChild.views) { + if (ctx.contentWindow === extWindow) { + // Only one is expected, but we collect details from all + // the ones that match to make sure the test will fails + // in case there are unexpected multiple extension contexts + // associated to the same contentWindow. + contextIds.push(ctx.contextId); + contextURLs.push(ctx.contentWindow.location.href); + } + } + return { contextIds, contextURLs }; +}; + +const getExtensionContextStatusByContextId = ( + extensionId, + extPageContextId +) => { + const { ExtensionProcessScript } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionProcessScript.sys.mjs" + ); + let extChild = ExtensionProcessScript.getExtensionChild(extensionId); + + let context; + for (let ctx of extChild.views) { + if (ctx.contextId === extPageContextId) { + context = ctx; + } + } + return context?.active; +}; + +add_task(async function test_extension_page_sameprocess_navigation() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "http://example.com/*"], + }, + files: { + "extpage1.html": createTestExtPage({ script: "extpage1.js" }), + "extpage1.js": createTestExtPageScript("extpage1"), + "extpage2.html": createTestExtPage({ script: "extpage2.js" }), + "extpage2.js": createTestExtPageScript("extpage2"), + }, + }); + + await extension.startup(); + + const policy = WebExtensionPolicy.getByID(extension.id); + + const extPageURL1 = policy.extension.baseURI.resolve("extpage1.html"); + const extPageURL2 = policy.extension.baseURI.resolve("extpage2.html"); + + info("Opening extension page in a first browser element"); + const extPage = await ExtensionTestUtils.loadContentPage(extPageURL1); + await extension.awaitMessage("page-loaded:extpage1"); + + const { contextIds, contextURLs } = await extPage.spawn( + [extension.id], + getExtensionContextIdAndURL + ); + + Assert.deepEqual( + contextURLs, + [extPageURL1], + `Found an extension context with the expected page url` + ); + + ok( + contextIds[0], + `Found an extension context with contextId ${contextIds[0]}` + ); + ok( + contextIds.length, + `There should be only one extension context for a given content window, found ${contextIds.length}` + ); + + const [contextId] = contextIds; + + await ExtensionTestUtils.fetch( + "http://example.com", + "http://example.com/request1" + ); + await extension.awaitMessage("event-received:extpage1"); + + info("Load a second extension page in the same browser element"); + await extPage.loadURL(extPageURL2); + await extension.awaitMessage("page-loaded:extpage2"); + + let active; + + let { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + // We only expect extpage2 to be able to receive API events. + await ExtensionTestUtils.fetch( + "http://example.com", + "http://example.com/request2" + ); + await extension.awaitMessage("event-received:extpage2"); + + active = await extPage.spawn( + [extension.id, contextId], + getExtensionContextStatusByContextId + ); + }); + + if ( + Services.appinfo.sessionHistoryInParent && + WebExtensionPolicy.isExtensionProcess + ) { + // When the extension are running in the main process while the webpages run + // in a separate child process, the extension page doesn't enter the BFCache + // because nsFrameLoader::changeRemotenessCommon bails out due to retainPaint + // being computed as true (see + // https://searchfox.org/mozilla-central/rev/24c1cdc33ccce692612276cd0d3e9a44f6c22fd3/dom/base/nsFrameLoaderOwner.cpp#185-196 + // ). + equal(active, undefined, "extension page context should not exist anymore"); + } else { + equal( + active, + false, + "extension page context is expected to be inactive while moved into the BFCache" + ); + } + + if (typeof active === "boolean") { + AddonTestUtils.checkMessages( + messages, + { + forbidden: [ + // We should not have tried to deserialize the event data for the extension page + // that got moved into the BFCache (See Bug 1499129). + { + message: + /StructureCloneHolder.deserialize: Argument 1 is not an object/, + }, + ], + expected: [ + // If the extension page is expected to be in the BFCache, then we expect to see + // a warning message logged for the ignored listener. + { + message: + /Ignored listener for inactive context .* path=webRequest.onBeforeRequest/, + }, + ], + }, + "Expect no StructureCloneHolder error due to trying to send the event to inactive context" + ); + } + + await extPage.close(); + await extension.unload(); +}); + +add_task(async function test_extension_page_context_navigated_to_web_page() { + const extension = ExtensionTestUtils.loadExtension({ + files: { + "extpage.html": createTestExtPage({ script: "extpage.js" }), + "extpage.js": function () { + dump("loaded extension page\n"); + window.addEventListener( + "pageshow", + () => { + browser.test.log("Extension page got a pageshow event"); + browser.test.sendMessage("extpage:pageshow"); + }, + { once: true } + ); + window.addEventListener( + "pagehide", + () => { + browser.test.log("Extension page got a pagehide event"); + browser.test.sendMessage("extpage:pagehide"); + }, + { once: true } + ); + }, + }, + }); + + await extension.startup(); + + const policy = WebExtensionPolicy.getByID(extension.id); + + const extPageURL = policy.extension.baseURI.resolve("extpage.html"); + const webPageURL = "http://example.com/"; + + info("Opening extension page in a browser element"); + const extPage = await ExtensionTestUtils.loadContentPage(extPageURL); + await extension.awaitMessage("extpage:pageshow"); + + const { contextIds, contextURLs } = await extPage.spawn( + [extension.id], + getExtensionContextIdAndURL + ); + + Assert.deepEqual( + contextURLs, + [extPageURL], + `Found an extension context with the expected page url` + ); + + ok( + contextIds[0], + `Found an extension context with contextId ${contextIds[0]}` + ); + ok( + contextIds.length, + `There should be only one extension context for a given content window, found ${contextIds.length}` + ); + + const [contextId] = contextIds; + + info("Load a webpage in the same browser element"); + await extPage.loadURL(webPageURL); + await extension.awaitMessage("extpage:pagehide"); + + info("Open extension page in a second browser element"); + const extPage2 = await ExtensionTestUtils.loadContentPage(extPageURL); + await extension.awaitMessage("extpage:pageshow"); + + let active = await extPage2.spawn( + [extension.id, contextId], + getExtensionContextStatusByContextId + ); + + if (WebExtensionPolicy.isExtensionProcess) { + // When the extension are running in the main process while the webpages run + // in a separate child process, the extension page doesn't enter the BFCache + // because nsFrameLoader::changeRemotenessCommon bails out due to retainPaint + // being computed as true (see + // https://searchfox.org/mozilla-central/rev/24c1cdc33ccce692612276cd0d3e9a44f6c22fd3/dom/base/nsFrameLoaderOwner.cpp#185-196 + // ). + equal(active, undefined, "extension page context should not exist anymore"); + } else if (Services.appinfo.sessionHistoryInParent) { + // When SHIP is enabled and the extensions runs in their own child extension + // process, the BFCache is managed entirely from the parent process and the + // extension page is expected to be able to enter the BFCache. + equal( + active, + false, + "extension page context is expected to be inactive while moved into the BFCache" + ); + } else { + // With the extension running in a separate child process but fission disabled, + // we expect the extension page to don't enter the BFCache. + equal(active, undefined, "extension page context should not exist anymore"); + } + + if (active === false) { + info( + "Navigating to more web pages to confirm the extension page have been evicted from the BFCache" + ); + for (let i = 2; i < 5; i++) { + const url = `${webPageURL}/page${i}`; + info(`Navigating to ${url}`); + await extPage.loadURL(url); + } + equal( + await extPage2.spawn( + [extension.id, contextId], + getExtensionContextStatusByContextId + ), + undefined, + "extension page context should have been evicted" + ); + } + + info("Cleanup and exit test"); + + await Promise.all([ + extPage.close(), + extPage2.close(), + extension.awaitMessage("extpage:pagehide"), + ]); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_failure.js b/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_failure.js new file mode 100644 index 0000000000..abd81a7ce3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_failure.js @@ -0,0 +1,46 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { ExtensionTestCommon } = ChromeUtils.importESModule( + "resource://testing-common/ExtensionTestCommon.sys.mjs" +); + +add_task(async function extension_startup_early_error() { + const EXTENSION_ID = "@extension-with-package-error"; + let extension = ExtensionTestCommon.generate({ + manifest: { + browser_specific_settings: { gecko: { id: EXTENSION_ID } }, + }, + }); + + extension.initLocale = async function () { + // Simulate error that happens during startup. + extension.packagingError("dummy error"); + }; + + let startupPromise = extension.startup(); + + let policy = WebExtensionPolicy.getByID(EXTENSION_ID); + ok(policy, "WebExtensionPolicy instantiated at startup"); + let readyPromise = policy.readyPromise; + ok(readyPromise, "WebExtensionPolicy.readyPromise is set"); + + await Assert.rejects( + startupPromise, + /dummy error/, + "Extension with packaging error should fail to load" + ); + + Assert.equal( + WebExtensionPolicy.getByID(EXTENSION_ID), + null, + "WebExtensionPolicy should be unregistered" + ); + + Assert.equal( + await readyPromise, + null, + "policy.readyPromise should be resolved with null" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_telemetry.js new file mode 100644 index 0000000000..0cd2361deb --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_telemetry.js @@ -0,0 +1,107 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const HISTOGRAM = "WEBEXT_EXTENSION_STARTUP_MS"; +const HISTOGRAM_KEYED = "WEBEXT_EXTENSION_STARTUP_MS_BY_ADDONID"; + +function processSnapshot(snapshot) { + return snapshot.sum > 0; +} + +function processKeyedSnapshot(snapshot) { + let res = {}; + for (let key of Object.keys(snapshot)) { + res[key] = snapshot[key].sum > 0; + } + return res; +} + +add_task(async function test_telemetry() { + const { GleanTimingDistribution } = globalThis; + let extension1 = ExtensionTestUtils.loadExtension({}); + let extension2 = ExtensionTestUtils.loadExtension({}); + + resetTelemetryData(); + + assertHistogramEmpty(HISTOGRAM); + assertKeyedHistogramEmpty(HISTOGRAM_KEYED); + assertGleanMetricsNoSamples({ + metricId: "extensionStartup", + gleanMetric: Glean.extensionsTiming.extensionStartup, + gleanMetricConstructor: GleanTimingDistribution, + }); + + await extension1.startup(); + + assertHistogramSnapshot( + HISTOGRAM, + { processSnapshot, expectedValue: true }, + `Data recorded for first extension for histogram: ${HISTOGRAM}.` + ); + + assertHistogramSnapshot( + HISTOGRAM_KEYED, + { + keyed: true, + processSnapshot: processKeyedSnapshot, + expectedValue: { + [extension1.extension.id]: true, + }, + }, + `Data recorded for first extension for histogram ${HISTOGRAM_KEYED}` + ); + + assertGleanMetricsSamplesCount({ + metricId: "extensionStartup", + gleanMetric: Glean.extensionsTiming.extensionStartup, + gleanMetricConstructor: GleanTimingDistribution, + expectedSamplesCount: 1, + }); + + let histogram = Services.telemetry.getHistogramById(HISTOGRAM); + let histogramKeyed = + Services.telemetry.getKeyedHistogramById(HISTOGRAM_KEYED); + let histogramSum = histogram.snapshot().sum; + let histogramSumExt1 = histogramKeyed.snapshot()[extension1.extension.id].sum; + + await extension2.startup(); + + assertHistogramSnapshot( + HISTOGRAM, + { + processSnapshot: snapshot => snapshot.sum > histogramSum, + expectedValue: true, + }, + `Data recorded for second extension for histogram: ${HISTOGRAM}.` + ); + + assertHistogramSnapshot( + HISTOGRAM_KEYED, + { + keyed: true, + processSnapshot: processKeyedSnapshot, + expectedValue: { + [extension1.extension.id]: true, + [extension2.extension.id]: true, + }, + }, + `Data recorded for second extension for histogram ${HISTOGRAM_KEYED}` + ); + + equal( + histogramKeyed.snapshot()[extension1.extension.id].sum, + histogramSumExt1, + `Data recorder for first extension is unchanged on the keyed histogram ${HISTOGRAM_KEYED}` + ); + + assertGleanMetricsSamplesCount({ + metricId: "extensionStartup", + gleanMetric: Glean.extensionsTiming.extensionStartup, + gleanMetricConstructor: GleanTimingDistribution, + expectedSamplesCount: 2, + }); + + await extension1.unload(); + await extension2.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_file_access.js b/toolkit/components/extensions/test/xpcshell/test_ext_file_access.js new file mode 100644 index 0000000000..c05188cd38 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_file_access.js @@ -0,0 +1,193 @@ +"use strict"; + +const FILE_DUMMY_URL = Services.io.newFileURI( + do_get_file("data/dummy_page.html") +).spec; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +// XHR/fetch from content script to the page itself is allowed. +add_task(async function content_script_xhr_to_self() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["file:///*"], + js: ["content_script.js"], + }, + ], + }, + files: { + "content_script.js": async () => { + let response = await fetch(document.URL); + browser.test.assertEq(200, response.status, "expected load"); + let responseText = await response.text(); + browser.test.assertTrue( + responseText.includes("<p>Page</p>"), + `expected file content in response of ${response.url}` + ); + + // Now with content.fetch: + response = await content.fetch(document.URL); + browser.test.assertEq(200, response.status, "expected load (content)"); + + browser.test.sendMessage("done"); + }, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage(FILE_DUMMY_URL); + await extension.awaitMessage("done"); + await contentPage.close(); + + await extension.unload(); +}); + +// XHR/fetch for other file is not allowed, even with file://-permissions. +add_task(async function content_script_xhr_to_other_file_not_allowed() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["file:///*"], + content_scripts: [ + { + matches: ["file:///*"], + js: ["content_script.js"], + }, + ], + }, + files: { + "content_script.js": async () => { + let otherFileUrl = document.URL.replace( + "dummy_page.html", + "file_sample.html" + ); + let x = new XMLHttpRequest(); + x.open("GET", otherFileUrl); + await new Promise(resolve => { + x.onloadend = resolve; + x.send(); + }); + browser.test.assertEq(0, x.status, "expected error"); + browser.test.assertEq("", x.responseText, "request should fail"); + + // Now with content.XMLHttpRequest. + x = new content.XMLHttpRequest(); + x.open("GET", otherFileUrl); + x.onloadend = () => { + browser.test.assertEq(0, x.status, "expected error (content)"); + browser.test.sendMessage("done"); + }; + x.send(); + }, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage(FILE_DUMMY_URL); + await extension.awaitMessage("done"); + await contentPage.close(); + + await extension.unload(); +}); + +// "file://" permission does not grant access to files in the extension page. +add_task(async function file_access_from_extension_page_not_allowed() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["file:///*"], + description: FILE_DUMMY_URL, + }, + async background() { + const FILE_DUMMY_URL = browser.runtime.getManifest().description; + + await browser.test.assertRejects( + fetch(FILE_DUMMY_URL), + /NetworkError when attempting to fetch resource/, + "block request to file from background page despite file permission" + ); + + // Regression test for bug 1420296 . + await browser.test.assertRejects( + fetch(FILE_DUMMY_URL, { mode: "same-origin" }), + /NetworkError when attempting to fetch resource/, + "block request to file from background page despite 'same-origin' mode" + ); + + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + + await extension.awaitMessage("done"); + + await extension.unload(); +}); + +// webRequest listeners should see subresource requests from file:-principals. +add_task(async function webRequest_script_request_from_file_principals() { + // Extension without file:-permission should not see the request. + let extensionWithoutFilePermission = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://example.net/", "webRequest"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.fail(`Unexpected request from ${details.originUrl}`); + }, + { urls: ["http://example.net/intercept_by_webRequest.js"] } + ); + }, + }); + + // Extension with <all_urls> (which matches the resource URL at example.net + // and the origin at file://*/*) can see the request. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["<all_urls>", "webRequest", "webRequestBlocking"], + web_accessible_resources: ["testDONE.html"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener( + ({ originUrl }) => { + browser.test.assertTrue( + /^file:.*file_do_load_script_subresource.html/.test(originUrl), + `expected script to be loaded from a local file (${originUrl})` + ); + let redirectUrl = browser.runtime.getURL("testDONE.html"); + return { + redirectUrl: `data:text/javascript,location.href='${redirectUrl}';`, + }; + }, + { urls: ["http://example.net/intercept_by_webRequest.js"] }, + ["blocking"] + ); + }, + files: { + "testDONE.html": `<!DOCTYPE html><script src="testDONE.js"></script>`, + "testDONE.js"() { + browser.test.sendMessage("webRequest_redirect_completed"); + }, + }, + }); + + await extensionWithoutFilePermission.startup(); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + Services.io.newFileURI( + do_get_file("data/file_do_load_script_subresource.html") + ).spec + ); + await extension.awaitMessage("webRequest_redirect_completed"); + await contentPage.close(); + + await extension.unload(); + await extensionWithoutFilePermission.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_control.js b/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_control.js new file mode 100644 index 0000000000..fa2759b7f0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_control.js @@ -0,0 +1,205 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +let getExtension = () => { + return ExtensionTestUtils.loadExtension({ + background: async function () { + const runningListener = isRunning => { + if (isRunning) { + browser.test.sendMessage("started"); + } else { + browser.test.sendMessage("stopped"); + } + }; + + browser.test.onMessage.addListener(async (message, data) => { + let result; + switch (message) { + case "start": + result = await browser.geckoProfiler.start({ + bufferSize: 10000, + windowLength: 20, + interval: 0.5, + features: ["js"], + threads: ["GeckoMain"], + }); + browser.test.assertEq(undefined, result, "start returns nothing."); + break; + case "stop": + result = await browser.geckoProfiler.stop(); + browser.test.assertEq(undefined, result, "stop returns nothing."); + break; + case "pause": + result = await browser.geckoProfiler.pause(); + browser.test.assertEq(undefined, result, "pause returns nothing."); + browser.test.sendMessage("paused"); + break; + case "resume": + result = await browser.geckoProfiler.resume(); + browser.test.assertEq(undefined, result, "resume returns nothing."); + browser.test.sendMessage("resumed"); + break; + case "test profile": + result = await browser.geckoProfiler.getProfile(); + browser.test.assertTrue( + "libs" in result, + "The profile contains libs." + ); + browser.test.assertTrue( + "meta" in result, + "The profile contains meta." + ); + browser.test.assertTrue( + "threads" in result, + "The profile contains threads." + ); + browser.test.assertTrue( + result.threads.some(t => t.name == "GeckoMain"), + "The profile contains a GeckoMain thread." + ); + browser.test.sendMessage("tested profile"); + break; + case "test dump to file": + try { + await browser.geckoProfiler.dumpProfileToFile(data.fileName); + browser.test.sendMessage("tested dump to file", {}); + } catch (e) { + browser.test.sendMessage("tested dump to file", { + error: e.message, + }); + } + break; + case "test profile as array buffer": + let arrayBuffer = + await browser.geckoProfiler.getProfileAsArrayBuffer(); + browser.test.assertTrue( + arrayBuffer.byteLength >= 2, + "The profile array buffer contains data." + ); + let textDecoder = new TextDecoder(); + let profile = JSON.parse(textDecoder.decode(arrayBuffer)); + browser.test.assertTrue( + "libs" in profile, + "The profile contains libs." + ); + browser.test.assertTrue( + "meta" in profile, + "The profile contains meta." + ); + browser.test.assertTrue( + "threads" in profile, + "The profile contains threads." + ); + browser.test.assertTrue( + profile.threads.some(t => t.name == "GeckoMain"), + "The profile contains a GeckoMain thread." + ); + browser.test.sendMessage("tested profile as array buffer"); + break; + case "remove runningListener": + browser.geckoProfiler.onRunning.removeListener(runningListener); + browser.test.sendMessage("removed runningListener"); + break; + } + }); + + browser.test.sendMessage("ready"); + + browser.geckoProfiler.onRunning.addListener(runningListener); + }, + + manifest: { + permissions: ["geckoProfiler"], + browser_specific_settings: { + gecko: { + id: "profilertest@mozilla.com", + }, + }, + }, + }); +}; + +let verifyProfileData = profile => { + ok("libs" in profile, "The profile contains libs."); + ok("meta" in profile, "The profile contains meta."); + ok("threads" in profile, "The profile contains threads."); + ok( + profile.threads.some(t => t.name == "GeckoMain"), + "The profile contains a GeckoMain thread." + ); +}; + +add_task(async function testProfilerControl() { + const acceptedExtensionIdsPref = + "extensions.geckoProfiler.acceptedExtensionIds"; + Services.prefs.setCharPref( + acceptedExtensionIdsPref, + "profilertest@mozilla.com" + ); + + let extension = getExtension(); + await extension.startup(); + await extension.awaitMessage("ready"); + await extension.awaitMessage("stopped"); + + extension.sendMessage("start"); + await extension.awaitMessage("started"); + + extension.sendMessage("test profile"); + await extension.awaitMessage("tested profile"); + + const profilerPath = PathUtils.join(PathUtils.profileDir, "profiler"); + let data, fileName, targetPath; + + // test with file name only + fileName = "bar.profile"; + targetPath = PathUtils.join(profilerPath, fileName); + extension.sendMessage("test dump to file", { fileName }); + data = await extension.awaitMessage("tested dump to file"); + equal(data.error, undefined, "No error thrown"); + ok(await IOUtils.exists(targetPath), "Saved gecko profile exists."); + verifyProfileData(await IOUtils.readJSON(targetPath)); + + // test overwriting the formerly created file + extension.sendMessage("test dump to file", { fileName }); + data = await extension.awaitMessage("tested dump to file"); + equal(data.error, undefined, "No error thrown"); + ok(await IOUtils.exists(targetPath), "Saved gecko profile exists."); + verifyProfileData(await IOUtils.readJSON(targetPath)); + + // test with a POSIX path, which is not allowed + fileName = "foo/bar.profile"; + targetPath = PathUtils.join(profilerPath, ...fileName.split("/")); + extension.sendMessage("test dump to file", { fileName }); + data = await extension.awaitMessage("tested dump to file"); + equal(data.error, "Path cannot contain a subdirectory."); + ok(!(await IOUtils.exists(targetPath)), "Gecko profile hasn't been saved."); + + // test with a non POSIX path which is not allowed + fileName = "foo\\bar.profile"; + targetPath = PathUtils.join(profilerPath, ...fileName.split("\\")); + extension.sendMessage("test dump to file", { fileName }); + data = await extension.awaitMessage("tested dump to file"); + equal(data.error, "Path cannot contain a subdirectory."); + ok(!(await IOUtils.exists(targetPath)), "Gecko profile hasn't been saved."); + + extension.sendMessage("test profile as array buffer"); + await extension.awaitMessage("tested profile as array buffer"); + + extension.sendMessage("pause"); + await extension.awaitMessage("paused"); + + extension.sendMessage("resume"); + await extension.awaitMessage("resumed"); + + extension.sendMessage("stop"); + await extension.awaitMessage("stopped"); + + extension.sendMessage("remove runningListener"); + await extension.awaitMessage("removed runningListener"); + + await extension.unload(); + + Services.prefs.clearUserPref(acceptedExtensionIdsPref); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_schema.js b/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_schema.js new file mode 100644 index 0000000000..e02b3f4d66 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_schema.js @@ -0,0 +1,69 @@ +"use strict"; + +add_task(async function () { + // The startupCache is removed whenever the buildid changes by code that runs + // during Firefox startup but not during xpcshell startup, remove it by hand + // before running this test to avoid failures with --conditioned-profile + let file = PathUtils.join( + Services.dirsvc.get("ProfLD", Ci.nsIFile).path, + "startupCache", + "webext.sc.lz4" + ); + await IOUtils.remove(file, { ignoreAbsent: true }); + + const acceptedExtensionIdsPref = + "extensions.geckoProfiler.acceptedExtensionIds"; + Services.prefs.setCharPref( + acceptedExtensionIdsPref, + "profilertest@mozilla.com" + ); + + let extension = ExtensionTestUtils.loadExtension({ + background: () => { + browser.test.sendMessage( + "features", + Object.values(browser.geckoProfiler.ProfilerFeature) + ); + }, + manifest: { + permissions: ["geckoProfiler"], + browser_specific_settings: { + gecko: { + id: "profilertest@mozilla.com", + }, + }, + }, + }); + + await extension.startup(); + let acceptedFeatures = await extension.awaitMessage("features"); + await extension.unload(); + + Services.prefs.clearUserPref(acceptedExtensionIdsPref); + + const allFeaturesAcceptedByProfiler = Services.profiler.GetAllFeatures(); + Assert.greaterOrEqual( + allFeaturesAcceptedByProfiler.length, + 2, + "Either we've massively reduced the profiler's feature set, or something is wrong." + ); + + // Check that the list of available values in the ProfilerFeature enum + // matches the list of features supported by the profiler. + for (const feature of allFeaturesAcceptedByProfiler) { + // If this fails, check the lists in {,Base}ProfilerState.h and geckoProfiler.json. + ok( + acceptedFeatures.includes(feature), + `The schema of the geckoProfiler.start() method should accept the "${feature}" feature.` + ); + } + for (const feature of acceptedFeatures) { + // If this fails, check the lists in {,Base}ProfilerState.h and geckoProfiler.json. + ok( + // Bug 1594566 - ignore Responsiveness until the extension is updated + allFeaturesAcceptedByProfiler.includes(feature) || + feature == "responsiveness", + `The schema of the geckoProfiler.start() method mentions a "${feature}" feature which is not supported by the profiler.` + ); + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_geturl.js b/toolkit/components/extensions/test/xpcshell/test_ext_geturl.js new file mode 100644 index 0000000000..b9048787d5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_geturl.js @@ -0,0 +1,64 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_contentscript() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.runtime.onMessage.addListener(([url1, url2]) => { + let url3 = browser.runtime.getURL("test_file.html"); + let url4 = browser.extension.getURL("test_file.html"); + + browser.test.assertTrue(url1 !== undefined, "url1 defined"); + + browser.test.assertTrue( + url1.startsWith("moz-extension://"), + "url1 has correct scheme" + ); + browser.test.assertTrue( + url1.endsWith("test_file.html"), + "url1 has correct leaf name" + ); + + browser.test.assertEq(url1, url2, "url2 matches"); + browser.test.assertEq(url1, url3, "url3 matches"); + browser.test.assertEq(url1, url4, "url4 matches"); + + browser.test.notifyPass("geturl"); + }); + }, + + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + }, + + files: { + "content_script.js"() { + let url1 = browser.runtime.getURL("test_file.html"); + let url2 = browser.extension.getURL("test_file.html"); + browser.runtime.sendMessage([url1, url2]); + }, + }, + }); + // Turn off warning as errors to pass for deprecated APIs + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + await extension.awaitFinish("geturl"); + + await contentPage.close(); + + await extension.unload(); + ExtensionTestUtils.failOnSchemaWarnings(true); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_i18n.js b/toolkit/components/extensions/test/xpcshell/test_ext_i18n.js new file mode 100644 index 0000000000..048e675a3e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_i18n.js @@ -0,0 +1,571 @@ +"use strict"; + +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +var originalReqLocales = Services.locale.requestedLocales; + +registerCleanupFunction(() => { + Preferences.reset("intl.accept_languages"); + Services.locale.requestedLocales = originalReqLocales; +}); + +add_task(async function test_i18n() { + function runTests(assertEq) { + let _ = browser.i18n.getMessage.bind(browser.i18n); + + let url = browser.runtime.getURL("/"); + assertEq( + url, + `moz-extension://${_("@@extension_id")}/`, + "@@extension_id builtin message" + ); + + assertEq("Foo.", _("Foo"), "Simple message in selected locale."); + + assertEq("(bar)", _("bar"), "Simple message fallback in default locale."); + + assertEq("", _("some-unknown-locale-string"), "Unknown locale string."); + + assertEq("", _("@@unknown_builtin_string"), "Unknown built-in string."); + assertEq( + "", + _("@@bidi_unknown_builtin_string"), + "Unknown built-in bidi string." + ); + + assertEq("Føo.", _("Föo"), "Multi-byte message in selected locale."); + + let substitutions = []; + substitutions[4] = "5"; + substitutions[13] = "14"; + + assertEq( + "'$0' '14' '' '5' '$$$$' '$'.", + _("basic_substitutions", substitutions), + "Basic numeric substitutions" + ); + + assertEq( + "'$0' '' 'just a string' '' '$$$$' '$'.", + _("basic_substitutions", "just a string"), + "Basic numeric substitutions, with non-array value" + ); + + let values = _("named_placeholder_substitutions", [ + "(subst $1 $2)", + "(2 $1 $2)", + ]).split("\n"); + + assertEq( + "_foo_ (subst $1 $2) _bar_", + values[0], + "Named and numeric substitution" + ); + + assertEq( + "(2 $1 $2)", + values[1], + "Numeric substitution amid named placeholders" + ); + + assertEq("$bad name$", values[2], "Named placeholder with invalid key"); + + assertEq("", values[3], "Named placeholder with an invalid value"); + + assertEq( + "Accepted, but shouldn't break.", + values[4], + "Named placeholder with a strange content value" + ); + + assertEq("$foo", values[5], "Non-placeholder token that should be ignored"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + default_locale: "jp", + + content_scripts: [ + { matches: ["http://*/*/file_sample.html"], js: ["content.js"] }, + ], + }, + + files: { + "_locales/en_US/messages.json": { + foo: { + message: "Foo.", + description: "foo", + }, + + föo: { + message: "Føo.", + description: "foo", + }, + + basic_substitutions: { + message: "'$0' '$14' '$1' '$5' '$$$$$' '$$'.", + description: "foo", + }, + + Named_placeholder_substitutions: { + message: + "$Foo$\n$2\n$bad name$\n$bad_value$\n$bad_content_value$\n$foo", + description: "foo", + placeholders: { + foO: { + content: "_foo_ $1 _bar_", + description: "foo", + }, + + "bad name": { + content: "Nope.", + description: "bad name", + }, + + bad_value: "Nope.", + + bad_content_value: { + content: ["Accepted, but shouldn't break."], + description: "bad value", + }, + }, + }, + + broken_placeholders: { + message: "$broken$", + description: "broken placeholders", + placeholders: "foo.", + }, + }, + + "_locales/jp/messages.json": { + foo: { + message: "(foo)", + description: "foo", + }, + + bar: { + message: "(bar)", + description: "bar", + }, + }, + + "content.js": + "new " + + function (runTestsFn) { + runTestsFn((...args) => { + browser.runtime.sendMessage(["assertEq", ...args]); + }); + + browser.runtime.sendMessage(["content-script-finished"]); + } + + `(${runTests})`, + }, + + background: + "new " + + function (runTestsFn) { + browser.runtime.onMessage.addListener(([msg, ...args]) => { + if (msg == "assertEq") { + browser.test.assertEq(...args); + } else { + browser.test.sendMessage(msg, ...args); + } + }); + + runTestsFn(browser.test.assertEq.bind(browser.test)); + } + + `(${runTests})`, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + await extension.awaitMessage("content-script-finished"); + await contentPage.close(); + + await extension.unload(); +}); + +add_task(async function test_i18n_negotiation() { + function runTests(expected) { + let _ = browser.i18n.getMessage.bind(browser.i18n); + + browser.test.assertEq(expected, _("foo"), "Got expected message"); + } + + let extensionData = { + manifest: { + default_locale: "en_US", + + content_scripts: [ + { matches: ["http://*/*/file_sample.html"], js: ["content.js"] }, + ], + }, + + files: { + "_locales/en_US/messages.json": { + foo: { + message: "English.", + description: "foo", + }, + }, + + "_locales/jp/messages.json": { + foo: { + message: "\u65e5\u672c\u8a9e", + description: "foo", + }, + }, + + "content.js": + "new " + + function (runTestsFn) { + browser.test.onMessage.addListener(expected => { + runTestsFn(expected); + + browser.test.sendMessage("content-script-finished"); + }); + browser.test.sendMessage("content-ready"); + } + + `(${runTests})`, + }, + + background: + "new " + + function (runTestsFn) { + browser.test.onMessage.addListener(expected => { + runTestsFn(expected); + + browser.test.sendMessage("background-script-finished"); + }); + } + + `(${runTests})`, + }; + + // At the moment extension language negotiation is tied to Firefox language + // negotiation result. That means that to test an extension in `fr`, we need + // to mock `fr` being available in Firefox and then request it. + // + // In the future, we should provide some way for tests to decouple their + // language selection from that of Firefox. + Services.locale.availableLocales = ["en-US", "fr", "jp"]; + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + for (let [lang, msg] of [ + ["en-US", "English."], + ["jp", "\u65e5\u672c\u8a9e"], + ]) { + Services.locale.requestedLocales = [lang]; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("content-ready"); + + extension.sendMessage(msg); + await extension.awaitMessage("background-script-finished"); + await extension.awaitMessage("content-script-finished"); + + await extension.unload(); + } + Services.locale.requestedLocales = originalReqLocales; + + await contentPage.close(); +}); + +add_task(async function test_get_accept_languages() { + function checkResults(source, results, expected) { + browser.test.assertEq( + expected.length, + results.length, + `got expected number of languages in ${source}` + ); + results.forEach((lang, index) => { + browser.test.assertEq( + expected[index], + lang, + `got expected language in ${source}` + ); + }); + } + + function background(checkResultsFn) { + browser.test.onMessage.addListener(([msg, expected]) => { + browser.i18n.getAcceptLanguages().then(results => { + checkResultsFn("background", results, expected); + + browser.test.sendMessage("background-done"); + }); + }); + } + + function content(checkResultsFn) { + browser.test.onMessage.addListener(([msg, expected]) => { + browser.i18n.getAcceptLanguages().then(results => { + checkResultsFn("contentScript", results, expected); + + browser.test.sendMessage("content-done"); + }); + }); + browser.test.sendMessage("content-loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + run_at: "document_start", + js: ["content_script.js"], + }, + ], + }, + + background: `(${background})(${checkResults})`, + + files: { + "content_script.js": `(${content})(${checkResults})`, + }, + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.startup(); + await extension.awaitMessage("content-loaded"); + + // TODO bug 1765375: ", en" is missing on Android. + let expectedLangs = + AppConstants.platform == "android" ? ["en-US"] : ["en-US", "en"]; + extension.sendMessage(["expect-results", expectedLangs]); + await extension.awaitMessage("background-done"); + await extension.awaitMessage("content-done"); + + expectedLangs = ["en-US", "en", "fr-CA", "fr"]; + Preferences.set("intl.accept_languages", expectedLangs.toString()); + extension.sendMessage(["expect-results", expectedLangs]); + await extension.awaitMessage("background-done"); + await extension.awaitMessage("content-done"); + Preferences.reset("intl.accept_languages"); + + await contentPage.close(); + + await extension.unload(); +}); + +add_task(async function test_get_ui_language() { + function getResults() { + return { + getUILanguage: browser.i18n.getUILanguage(), + getMessage: browser.i18n.getMessage("@@ui_locale"), + }; + } + + function checkResults(source, results, expected) { + browser.test.assertEq( + expected, + results.getUILanguage, + `Got expected getUILanguage result in ${source}` + ); + browser.test.assertEq( + expected, + results.getMessage, + `Got expected getMessage result in ${source}` + ); + } + + function background(getResultsFn, checkResultsFn) { + browser.test.onMessage.addListener(([msg, expected]) => { + checkResultsFn("background", getResultsFn(), expected); + + browser.test.sendMessage("background-done"); + }); + } + + function content(getResultsFn, checkResultsFn) { + browser.test.onMessage.addListener(([msg, expected]) => { + checkResultsFn("contentScript", getResultsFn(), expected); + + browser.test.sendMessage("content-done"); + }); + browser.test.sendMessage("content-loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + run_at: "document_start", + js: ["content_script.js"], + }, + ], + }, + + background: `(${background})(${getResults}, ${checkResults})`, + + files: { + "content_script.js": `(${content})(${getResults}, ${checkResults})`, + }, + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.startup(); + await extension.awaitMessage("content-loaded"); + + extension.sendMessage(["expect-results", "en-US"]); + + await extension.awaitMessage("background-done"); + await extension.awaitMessage("content-done"); + + // We don't currently have a good way to mock this. + if (false) { + Services.locale.requestedLocales = ["he"]; + + extension.sendMessage(["expect-results", "he"]); + + await extension.awaitMessage("background-done"); + await extension.awaitMessage("content-done"); + } + + await contentPage.close(); + + await extension.unload(); +}); + +add_task(async function test_detect_language() { + const af_string = + " aam skukuza die naam beteken hy wat skoonvee of hy wat alles onderstebo keer wysig " + + "bosveldkampe boskampe is kleiner afgeleë ruskampe wat oor min fasiliteite beskik daar is geen restaurante " + + "of winkels nie en slegs oornagbesoekers word toegelaat bateleur"; + // String with intermixed French/English text + const fr_en_string = + "France is the largest country in Western Europe and the third-largest in Europe as a whole. " + + "A accès aux chiens et aux frontaux qui lui ont été il peut consulter et modifier ses collections et exporter " + + "Cet article concerne le pays européen aujourd’hui appelé République française. Pour d’autres usages du nom France, " + + "Pour une aide rapide et effective, veuiller trouver votre aide dans le menu ci-dessus." + + "Motoring events began soon after the construction of the first successful gasoline-fueled automobiles. The quick brown fox jumped over the lazy dog"; + + function checkResult(source, result, expected) { + browser.test.assertEq( + expected.isReliable, + result.isReliable, + "result.confident is true" + ); + browser.test.assertEq( + expected.languages.length, + result.languages.length, + `result.languages contains the expected number of languages in ${source}` + ); + expected.languages.forEach((lang, index) => { + browser.test.assertEq( + lang.percentage, + result.languages[index].percentage, + `element ${index} of result.languages array has the expected percentage in ${source}` + ); + browser.test.assertEq( + lang.language, + result.languages[index].language, + `element ${index} of result.languages array has the expected language in ${source}` + ); + }); + } + + function backgroundScript(checkResultFn) { + browser.test.onMessage.addListener(([msg, expected]) => { + browser.i18n.detectLanguage(msg).then(result => { + checkResultFn("background", result, expected); + browser.test.sendMessage("background-done"); + }); + }); + } + + function content(checkResultFn) { + browser.test.onMessage.addListener(([msg, expected]) => { + browser.i18n.detectLanguage(msg).then(result => { + checkResultFn("contentScript", result, expected); + browser.test.sendMessage("content-done"); + }); + }); + browser.test.sendMessage("content-loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + run_at: "document_start", + js: ["content_script.js"], + }, + ], + }, + + background: `(${backgroundScript})(${checkResult})`, + + files: { + "content_script.js": `(${content})(${checkResult})`, + }, + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.startup(); + await extension.awaitMessage("content-loaded"); + + let expected = { + isReliable: true, + languages: [ + { + language: "fr", + percentage: 67, + }, + { + language: "en", + percentage: 32, + }, + ], + }; + extension.sendMessage([fr_en_string, expected]); + await extension.awaitMessage("background-done"); + await extension.awaitMessage("content-done"); + + expected = { + isReliable: true, + languages: [ + { + language: "af", + percentage: 99, + }, + ], + }; + extension.sendMessage([af_string, expected]); + await extension.awaitMessage("background-done"); + await extension.awaitMessage("content-done"); + + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_i18n_css.js b/toolkit/components/extensions/test/xpcshell/test_ext_i18n_css.js new file mode 100644 index 0000000000..e02ae09e11 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_i18n_css.js @@ -0,0 +1,194 @@ +"use strict"; + +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +const { createAppInfo, promiseShutdownManager, promiseStartupManager } = + AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +// Some multibyte characters. This sample was taken from the encoding/api-basics.html web platform test. +const MULTIBYTE_STRING = "z\xA2\u6C34\uD834\uDD1E\uF8FF\uDBFF\uDFFD\uFFFE"; +let getCSS = (a, b) => `a { content: '${a}'; } b { content: '${b}'; }`; + +let extensionData = { + background: function () { + function backgroundFetch(url) { + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + xhr.overrideMimeType("text/plain"); + xhr.open("GET", url); + xhr.onload = () => { + resolve(xhr.responseText); + }; + xhr.onerror = reject; + xhr.send(); + }); + } + + Promise.all([ + backgroundFetch("foo.css"), + backgroundFetch("bar.CsS?x#y"), + backgroundFetch("foo.txt"), + ]).then(results => { + browser.test.assertEq( + "body { max-width: 42px; }", + results[0], + "CSS file localized" + ); + browser.test.assertEq( + "body { max-width: 42px; }", + results[1], + "CSS file localized" + ); + + browser.test.assertEq( + "body { __MSG_foo__; }", + results[2], + "Text file not localized" + ); + + browser.test.notifyPass("i18n-css"); + }); + + browser.test.sendMessage("ready", browser.runtime.getURL("/")); + }, + + manifest: { + browser_specific_settings: { + gecko: { + id: "i18n_css@mochi.test", + }, + }, + + web_accessible_resources: [ + "foo.css", + "foo.txt", + "locale.css", + "multibyte.css", + ], + + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + css: ["foo.css"], + run_at: "document_start", + }, + { + matches: ["http://*/*/file_sample.html"], + js: ["content.js"], + }, + ], + + default_locale: "en", + }, + + files: { + "_locales/en/messages.json": JSON.stringify({ + foo: { + message: "max-width: 42px", + description: "foo", + }, + multibyteKey: { + message: MULTIBYTE_STRING, + }, + }), + + "content.js": function () { + let style = getComputedStyle(document.body); + browser.test.sendMessage("content-maxWidth", style.maxWidth); + }, + + "foo.css": "body { __MSG_foo__; }", + "bar.CsS": "body { __MSG_foo__; }", + "foo.txt": "body { __MSG_foo__; }", + "locale.css": + '* { content: "__MSG_@@ui_locale__ __MSG_@@bidi_dir__ __MSG_@@bidi_reversed_dir__ __MSG_@@bidi_start_edge__ __MSG_@@bidi_end_edge__" }', + "multibyte.css": getCSS("__MSG_multibyteKey__", MULTIBYTE_STRING), + }, +}; + +async function test_i18n_css(options = {}) { + extensionData.useAddonManager = options.useAddonManager; + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + let baseURL = await extension.awaitMessage("ready"); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + let css = await contentPage.fetch(baseURL + "foo.css"); + + equal( + css, + "body { max-width: 42px; }", + "CSS file localized in mochitest scope" + ); + + let maxWidth = await extension.awaitMessage("content-maxWidth"); + + equal(maxWidth, "42px", "stylesheet correctly applied"); + + css = await contentPage.fetch(baseURL + "locale.css"); + equal( + css, + '* { content: "en-US ltr rtl left right" }', + "CSS file localized in mochitest scope" + ); + + css = await contentPage.fetch(baseURL + "multibyte.css"); + equal( + css, + getCSS(MULTIBYTE_STRING, MULTIBYTE_STRING), + "CSS file contains multibyte string" + ); + + await contentPage.close(); + + // We don't currently have a good way to mock this. + if (false) { + const DIR = "intl.l10n.pseudo"; + + // We don't wind up actually switching the chrome registry locale, since we + // don't have a chrome package for Hebrew. So just override it, and force + // RTL directionality. + const origReqLocales = Services.locale.requestedLocales; + Services.locale.requestedLocales = ["he"]; + Preferences.set(DIR, "bidi"); + + css = await fetch(baseURL + "locale.css"); + equal( + css, + '* { content: "he rtl ltr right left" }', + "CSS file localized in mochitest scope" + ); + + Services.locale.requestedLocales = origReqLocales; + Preferences.reset(DIR); + } + + await extension.awaitFinish("i18n-css"); + await extension.unload(); +} + +add_task(async function startup() { + await promiseStartupManager(); +}); +add_task(test_i18n_css); +add_task(async function test_i18n_css_xpi() { + await test_i18n_css({ useAddonManager: "temporary" }); +}); +add_task(async function startup() { + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_idle.js b/toolkit/components/extensions/test/xpcshell/test_ext_idle.js new file mode 100644 index 0000000000..6398224f1a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_idle.js @@ -0,0 +1,361 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +let idleService = { + _observers: new Set(), + _activity: { + addCalls: [], + removeCalls: [], + observerFires: [], + }, + _reset: function () { + this._observers.clear(); + this._activity.addCalls = []; + this._activity.removeCalls = []; + this._activity.observerFires = []; + }, + _fireObservers: function (state) { + for (let observer of this._observers.values()) { + observer.observe(observer, state, null); + this._activity.observerFires.push(state); + } + }, + QueryInterface: ChromeUtils.generateQI(["nsIUserIdleService"]), + idleTime: 19999, + addIdleObserver: function (observer, time) { + this._observers.add(observer); + this._activity.addCalls.push(time); + }, + removeIdleObserver: function (observer, time) { + this._observers.delete(observer); + this._activity.removeCalls.push(time); + }, +}; + +function checkActivity(expectedActivity) { + let { expectedAdd, expectedRemove, expectedFires } = expectedActivity; + let { addCalls, removeCalls, observerFires } = idleService._activity; + equal( + expectedAdd.length, + addCalls.length, + "idleService.addIdleObserver was called the expected number of times" + ); + equal( + expectedRemove.length, + removeCalls.length, + "idleService.removeIdleObserver was called the expected number of times" + ); + equal( + expectedFires.length, + observerFires.length, + "idle observer was fired the expected number of times" + ); + deepEqual( + addCalls, + expectedAdd, + "expected interval passed to idleService.addIdleObserver" + ); + deepEqual( + removeCalls, + expectedRemove, + "expected interval passed to idleService.removeIdleObserver" + ); + deepEqual( + observerFires, + expectedFires, + "expected topic passed to idle observer" + ); +} + +add_task(async function setup() { + let fakeIdleService = MockRegistrar.register( + "@mozilla.org/widget/useridleservice;1", + idleService + ); + registerCleanupFunction(() => { + MockRegistrar.unregister(fakeIdleService); + }); +}); + +add_task(async function testQueryStateActive() { + function background() { + browser.idle.queryState(20).then( + status => { + browser.test.assertEq("active", status, "Idle status is active"); + browser.test.notifyPass("idle"); + }, + err => { + browser.test.fail(`Error: ${err} :: ${err.stack}`); + browser.test.notifyFail("idle"); + } + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("idle"); + await extension.unload(); +}); + +add_task(async function testQueryStateIdle() { + function background() { + browser.idle.queryState(15).then( + status => { + browser.test.assertEq("idle", status, "Idle status is idle"); + browser.test.notifyPass("idle"); + }, + err => { + browser.test.fail(`Error: ${err} :: ${err.stack}`); + browser.test.notifyFail("idle"); + } + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("idle"); + await extension.unload(); +}); + +add_task(async function testOnlySetDetectionInterval() { + function background() { + browser.idle.setDetectionInterval(99); + browser.test.sendMessage("detectionIntervalSet"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + idleService._reset(); + await extension.startup(); + await extension.awaitMessage("detectionIntervalSet"); + idleService._fireObservers("idle"); + checkActivity({ expectedAdd: [], expectedRemove: [], expectedFires: [] }); + await extension.unload(); +}); + +add_task(async function testSetDetectionIntervalBeforeAddingListener() { + function background() { + browser.idle.setDetectionInterval(99); + browser.idle.onStateChanged.addListener(newState => { + browser.test.assertEq( + "idle", + newState, + "listener fired with the expected state" + ); + browser.test.sendMessage("listenerFired"); + }); + browser.test.sendMessage("listenerAdded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + idleService._reset(); + await extension.startup(); + await extension.awaitMessage("listenerAdded"); + idleService._fireObservers("idle"); + await extension.awaitMessage("listenerFired"); + checkActivity({ + expectedAdd: [99], + expectedRemove: [], + expectedFires: ["idle"], + }); + // Defer unloading the extension so the asynchronous event listener + // reply finishes. + await new Promise(resolve => setTimeout(resolve, 0)); + await extension.unload(); +}); + +add_task(async function testSetDetectionIntervalAfterAddingListener() { + function background() { + browser.idle.onStateChanged.addListener(newState => { + browser.test.assertEq( + "idle", + newState, + "listener fired with the expected state" + ); + browser.test.sendMessage("listenerFired"); + }); + browser.idle.setDetectionInterval(99); + browser.test.sendMessage("detectionIntervalSet"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + idleService._reset(); + await extension.startup(); + await extension.awaitMessage("detectionIntervalSet"); + idleService._fireObservers("idle"); + await extension.awaitMessage("listenerFired"); + checkActivity({ + expectedAdd: [60, 99], + expectedRemove: [60], + expectedFires: ["idle"], + }); + + // Defer unloading the extension so the asynchronous event listener + // reply finishes. + await new Promise(resolve => setTimeout(resolve, 0)); + await extension.unload(); +}); + +add_task(async function testOnlyAddingListener() { + function background() { + browser.idle.onStateChanged.addListener(newState => { + browser.test.assertEq( + "active", + newState, + "listener fired with the expected state" + ); + browser.test.sendMessage("listenerFired"); + }); + browser.test.sendMessage("listenerAdded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + idleService._reset(); + await extension.startup(); + await extension.awaitMessage("listenerAdded"); + idleService._fireObservers("active"); + await extension.awaitMessage("listenerFired"); + // check that "idle-daily" topic does not cause a listener to fire + idleService._fireObservers("idle-daily"); + checkActivity({ + expectedAdd: [60], + expectedRemove: [], + expectedFires: ["active", "idle-daily"], + }); + + // Defer unloading the extension so the asynchronous event listener + // reply finishes. + await new Promise(resolve => setTimeout(resolve, 0)); + await extension.unload(); +}); + +add_task( + { pref_set: [["extensions.eventPages.enabled", true]] }, + async function test_idle_event_page() { + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["idle"], + background: { persistent: false }, + }, + background() { + browser.idle.setDetectionInterval(99); + browser.idle.onStateChanged.addListener(newState => { + browser.test.assertEq( + "active", + newState, + "listener fired with the expected state" + ); + browser.test.sendMessage("listenerFired"); + }); + }, + }); + + idleService._reset(); + await extension.startup(); + assertPersistentListeners(extension, "idle", "onStateChanged", { + primed: false, + }); + checkActivity({ + expectedAdd: [99], + expectedRemove: [], + expectedFires: [], + }); + + idleService._reset(); + await extension.terminateBackground(); + assertPersistentListeners(extension, "idle", "onStateChanged", { + primed: true, + }); + checkActivity({ + expectedAdd: [99], + expectedRemove: [99], + expectedFires: [], + }); + + // Fire an idle notification to wake up the background. + idleService._fireObservers("active"); + await extension.awaitMessage("listenerFired"); + checkActivity({ + expectedAdd: [99], + expectedRemove: [99], + expectedFires: ["active"], + }); + + // Verify the set idle time is used with the persisted listener. + idleService._reset(); + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + assertPersistentListeners(extension, "idle", "onStateChanged", { + primed: true, + }); + checkActivity({ + expectedAdd: [99], // 99 should have been persisted + expectedRemove: [99], // remove is from AOM shutdown + expectedFires: [], + }); + + // Fire an idle notification to wake up the background. + idleService._fireObservers("active"); + await extension.awaitMessage("listenerFired"); + checkActivity({ + expectedAdd: [99], + expectedRemove: [99], + expectedFires: ["active"], + }); + + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_incognito.js b/toolkit/components/extensions/test/xpcshell/test_ext_incognito.js new file mode 100644 index 0000000000..b4b00e7db4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_incognito.js @@ -0,0 +1,127 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); +AddonTestUtils.usePrivilegedSignatures = id => id.startsWith("privileged"); + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); +}); + +async function runIncognitoTest(extensionData, privateBrowsingAllowed) { + let wrapper = ExtensionTestUtils.loadExtension(extensionData); + await wrapper.startup(); + let { extension } = wrapper; + + equal( + extension.permissions.has("internal:privateBrowsingAllowed"), + privateBrowsingAllowed, + "privateBrowsingAllowed in serialized extension" + ); + equal( + extension.privateBrowsingAllowed, + privateBrowsingAllowed, + "privateBrowsingAllowed in extension" + ); + equal( + extension.policy.privateBrowsingAllowed, + privateBrowsingAllowed, + "privateBrowsingAllowed on policy" + ); + + await wrapper.unload(); +} + +add_task(async function test_extension_incognito_spanning() { + await runIncognitoTest({}, false); +}); + +// Test that when we are restricted, we can override the restriction for tests. +add_task(async function test_extension_incognito_override_spanning() { + let extensionData = { + incognitoOverride: "spanning", + }; + await runIncognitoTest(extensionData, true); +}); + +// This tests that a privileged extension will always have private browsing. +add_task(async function test_extension_incognito_privileged() { + let extensionData = { + isPrivileged: true, + }; + await runIncognitoTest(extensionData, true); +}); + +add_task(async function test_extension_privileged_not_allowed() { + let addonId = "privileged_not_allowed@mochi.test"; + let extensionData = { + manifest: { + version: "1.0", + browser_specific_settings: { gecko: { id: addonId } }, + incognito: "not_allowed", + }, + useAddonManager: "permanent", + isPrivileged: true, + }; + let wrapper = ExtensionTestUtils.loadExtension(extensionData); + await wrapper.startup(); + let policy = WebExtensionPolicy.getByID(addonId); + equal( + policy.extension.isPrivileged, + true, + "The test extension is privileged" + ); + equal( + policy.privateBrowsingAllowed, + false, + "privateBrowsingAllowed is false" + ); + + await wrapper.unload(); +}); + +// Test that we remove pb permission if an extension is updated to not_allowed. +add_task(async function test_extension_upgrade_not_allowed() { + let addonId = "upgrade@mochi.test"; + let extensionData = { + manifest: { + version: "1.0", + browser_specific_settings: { gecko: { id: addonId } }, + incognito: "spanning", + }, + useAddonManager: "permanent", + incognitoOverride: "spanning", + }; + let wrapper = ExtensionTestUtils.loadExtension(extensionData); + await wrapper.startup(); + + let policy = WebExtensionPolicy.getByID(addonId); + + equal( + policy.privateBrowsingAllowed, + true, + "privateBrowsingAllowed in extension" + ); + + extensionData.manifest.version = "2.0"; + extensionData.manifest.incognito = "not_allowed"; + await wrapper.upgrade(extensionData); + + equal(wrapper.version, "2.0", "Expected extension version"); + policy = WebExtensionPolicy.getByID(addonId); + equal( + policy.privateBrowsingAllowed, + false, + "privateBrowsingAllowed is false" + ); + + await wrapper.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_indexedDB_principal.js b/toolkit/components/extensions/test/xpcshell/test_ext_indexedDB_principal.js new file mode 100644 index 0000000000..f355b1d43a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_indexedDB_principal.js @@ -0,0 +1,147 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_task(async function test_indexedDB_principal() { + Services.prefs.setBoolPref("privacy.firstparty.isolate", true); + + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: {}, + async background() { + browser.test.onMessage.addListener(async msg => { + if (msg == "create-storage") { + let request = window.indexedDB.open("TestDatabase"); + request.onupgradeneeded = function (e) { + let db = e.target.result; + db.createObjectStore("TestStore"); + }; + request.onsuccess = function (e) { + let db = e.target.result; + let tx = db.transaction("TestStore", "readwrite"); + let store = tx.objectStore("TestStore"); + tx.oncomplete = () => browser.test.sendMessage("storage-created"); + store.add("foo", "bar"); + tx.onerror = function (e) { + browser.test.fail(`Failed with error ${tx.error.message}`); + // Don't wait for timeout + browser.test.sendMessage("storage-created"); + }; + }; + request.onerror = function (e) { + browser.test.fail(`Failed with error ${request.error.message}`); + // Don't wait for timeout + browser.test.sendMessage("storage-created"); + }; + return; + } + if (msg == "check-storage") { + let dbRequest = window.indexedDB.open("TestDatabase"); + dbRequest.onupgradeneeded = function () { + browser.test.fail("Database should exist"); + browser.test.notifyFail("done"); + }; + dbRequest.onsuccess = function (e) { + let db = e.target.result; + let transaction = db.transaction("TestStore"); + transaction.onerror = function (e) { + browser.test.fail( + `Failed with error ${transaction.error.message}` + ); + browser.test.notifyFail("done"); + }; + let objectStore = transaction.objectStore("TestStore"); + let request = objectStore.get("bar"); + request.onsuccess = function (event) { + browser.test.assertEq( + request.result, + "foo", + "Got the expected data" + ); + browser.test.notifyPass("done"); + }; + request.onerror = function (e) { + browser.test.fail(`Failed with error ${request.error.message}`); + browser.test.notifyFail("done"); + }; + }; + dbRequest.onerror = function (e) { + browser.test.fail(`Failed with error ${dbRequest.error.message}`); + browser.test.notifyFail("done"); + }; + } + }); + }, + }); + + await extension.startup(); + extension.sendMessage("create-storage"); + await extension.awaitMessage("storage-created"); + + await extension.addon.disable(); + + Services.prefs.setBoolPref("privacy.firstparty.isolate", false); + + await extension.addon.enable(); + await extension.awaitStartup(); + + extension.sendMessage("check-storage"); + await extension.awaitFinish("done"); + + await extension.unload(); + Services.prefs.clearUserPref("privacy.firstparty.isolate"); +}); + +add_task(async function test_indexedDB_ext_privateBrowsing() { + Services.prefs.setBoolPref("dom.indexedDB.privateBrowsing.enabled", true); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + incognitoOverride: "spanning", + files: { + "extpage.html": `<!DOCTYPE><script src="extpage.js"></script>`, + "extpage.js": async function () { + try { + const request = window.indexedDB.open("TestDatabasePrivateBrowsing"); + await new Promise((resolve, reject) => { + request.onupgradeneeded = resolve; + request.onsuccess = resolve; + request.onerror = () => { + reject(request.error); + }; + }); + browser.test.notifyFail("indexedDB-expect-error-on-open"); + } catch (err) { + browser.test.assertEq( + "InvalidStateError", + err.name, + "Expect an error raised on openeing indexedDB" + ); + browser.test.notifyPass("indexedDB-expect-error-on-open"); + } + }, + }, + }); + + await extension.startup(); + + const page = await ExtensionTestUtils.loadContentPage( + extension.extension.baseURI.resolve("extpage.html"), + { privateBrowsing: true } + ); + + await extension.awaitFinish("indexedDB-expect-error-on-open"); + + await page.close(); + await extension.unload(); + + Services.prefs.clearUserPref("dom.indexedDB.privateBrowsing.enabled"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_ipcBlob.js b/toolkit/components/extensions/test/xpcshell/test_ext_ipcBlob.js new file mode 100644 index 0000000000..dd90d9bbc8 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_ipcBlob.js @@ -0,0 +1,150 @@ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +add_task(async function test_parent_to_child() { + async function background() { + const dbName = "broken-blob"; + const dbStore = "blob-store"; + const dbVersion = 1; + const blobContent = "Hello World!"; + + let db = await new Promise((resolve, reject) => { + let dbOpen = indexedDB.open(dbName, dbVersion); + dbOpen.onerror = event => { + browser.test.fail(`Error opening the DB: ${event.target.error}`); + browser.test.notifyFail("test-completed"); + reject(); + }; + dbOpen.onsuccess = event => { + resolve(event.target.result); + }; + dbOpen.onupgradeneeded = event => { + let dbobj = event.target.result; + dbobj.onerror = error => { + browser.test.fail(`Error updating the DB: ${error.target.error}`); + browser.test.notifyFail("test-completed"); + reject(); + }; + dbobj.createObjectStore(dbStore); + }; + }); + + async function save(blob) { + let txn = db.transaction([dbStore], "readwrite"); + let store = txn.objectStore(dbStore); + let req = store.put(blob, "key"); + + return new Promise((resolve, reject) => { + req.onsuccess = () => { + resolve(); + }; + req.onerror = event => { + browser.test.fail( + `Error saving the blob into the DB: ${event.target.error}` + ); + browser.test.notifyFail("test-completed"); + reject(); + }; + }); + } + + async function load() { + let txn = db.transaction([dbStore], "readonly"); + let store = txn.objectStore(dbStore); + let req = store.getAll(); + + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }) + .then(loadDetails => { + let blobs = []; + loadDetails.forEach(details => { + blobs.push(details); + }); + return blobs[0]; + }) + .catch(err => { + browser.test.fail( + `Error loading the blob from the DB: ${err} :: ${err.stack}` + ); + browser.test.notifyFail("test-completed"); + }); + } + + browser.test.log("Blob creation"); + await save(new Blob([blobContent])); + let blob = await load(); + + db.close(); + + browser.runtime.onMessage.addListener(([msg, what]) => { + browser.test.log("Message received from content: " + msg); + if (msg == "script-ready") { + return Promise.resolve({ blob }); + } + + if (msg == "script-value") { + browser.test.assertEq(blobContent, what, "blob content matches"); + browser.test.notifyPass("test-completed"); + return; + } + + browser.test.fail(`Unexpected test message received: ${msg}`); + }); + + browser.test.sendMessage("bg-ready"); + } + + function contentScriptStart() { + browser.runtime.sendMessage(["script-ready"], response => { + let reader = new FileReader(); + reader.addEventListener( + "load", + () => { + browser.runtime.sendMessage(["script-value", reader.result]); + }, + { once: true } + ); + reader.readAsText(response.blob); + }); + } + + let extensionData = { + background, + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script_start.js"], + run_at: "document_start", + }, + ], + }, + files: { + "content_script_start.js": contentScriptStart, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitMessage("bg-ready"); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.awaitFinish("test-completed"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_json_parser.js b/toolkit/components/extensions/test/xpcshell/test_ext_json_parser.js new file mode 100644 index 0000000000..aba25173d7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_json_parser.js @@ -0,0 +1,108 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_json_parser() { + const ID = "json@test.web.extension"; + + let xpi = AddonTestUtils.createTempWebExtensionFile({ + files: { + "manifest.json": String.raw`{ + // This is a manifest. + "manifest_version": 2, + "browser_specific_settings": {"gecko": {"id": "${ID}"}}, + "name": "This \" is // not a comment", + "version": "0.1\\" // , "description": "This is not a description" + }`, + }, + }); + + let expectedManifest = { + manifest_version: 2, + browser_specific_settings: { gecko: { id: ID } }, + name: 'This " is // not a comment', + version: "0.1\\", + }; + + let fileURI = Services.io.newFileURI(xpi); + let uri = NetUtil.newURI(`jar:${fileURI.spec}!/`); + + let extension = new ExtensionData(uri, false); + + await extension.parseManifest(); + + Assert.deepEqual( + extension.rawManifest, + expectedManifest, + "Manifest with correctly-filtered comments" + ); + + Services.obs.notifyObservers(xpi, "flush-cache-entry"); +}); + +add_task(async function test_getExtensionVersionWithoutValidation() { + let xpi = AddonTestUtils.createTempWebExtensionFile({ + files: { + "manifest.json": String.raw`{ + // This is valid JSON but not a valid manifest. + "version": ["This is not a valid version"] + }`, + }, + }); + let fileURI = Services.io.newFileURI(xpi); + let uri = NetUtil.newURI(`jar:${fileURI.spec}!/`); + let extension = new ExtensionData(uri, false); + + let rawVersion = await extension.getExtensionVersionWithoutValidation(); + Assert.deepEqual( + rawVersion, + ["This is not a valid version"], + "Got the raw value of the 'version' key from an (invalid) manifest file" + ); + + // The manifest lacks several required properties and manifest_version is + // invalid. The exact error here doesn't matter, as long as it shows that the + // manifest is invalid. + await Assert.rejects( + extension.parseManifest(), + /Unexpected params.manifestVersion value: undefined/, + "parseManifest() should reject an invalid manifest" + ); + + Services.obs.notifyObservers(xpi, "flush-cache-entry"); +}); + +add_task( + { + pref_set: [ + ["extensions.manifestV3.enabled", true], + ["extensions.webextensions.warnings-as-errors", false], + ], + }, + async function test_applications_no_longer_valid_in_mv3() { + let id = "some@id"; + let xpi = AddonTestUtils.createTempWebExtensionFile({ + files: { + "manifest.json": JSON.stringify({ + manifest_version: 3, + name: "some name", + version: "0.1", + applications: { gecko: { id } }, + }), + }, + }); + + let fileURI = Services.io.newFileURI(xpi); + let uri = NetUtil.newURI(`jar:${fileURI.spec}!/`); + + let extension = new ExtensionData(uri, false); + + const { manifest } = await extension.parseManifest(); + ok( + !Object.keys(manifest).includes("applications"), + "expected no applications key in manifest" + ); + + Services.obs.notifyObservers(xpi, "flush-cache-entry"); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_l10n.js b/toolkit/components/extensions/test/xpcshell/test_ext_l10n.js new file mode 100644 index 0000000000..96cc124348 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_l10n.js @@ -0,0 +1,165 @@ +"use strict"; + +const { FileUtils } = ChromeUtils.importESModule( + "resource://gre/modules/FileUtils.sys.mjs" +); + +add_setup(async function setup() { + // Add a test .ftl file + // (Note: other tests do this by patching L10nRegistry.load() but in + // this test L10nRegistry is also loaded in the extension process -- + // just adding a new resource is easier than trying to patch + // L10nRegistry in all processes) + let dir = FileUtils.getDir("TmpD", ["l10ntest"]); + dir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + + await IOUtils.writeUTF8( + PathUtils.join(dir.path, "test.ftl"), + "key = value\n" + ); + + let target = Services.io.newFileURI(dir); + let resProto = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + + resProto.setSubstitution("l10ntest", target); + + const source = new L10nFileSource( + "test", + "app", + Services.locale.requestedLocales, + "resource://l10ntest/" + ); + L10nRegistry.getInstance().registerSources([source]); +}); + +// Test that privileged extensions can use fluent to get strings from +// language packs (and that unprivileged extensions cannot) +add_task(async function test_l10n_dom() { + const PAGE = `<!DOCTYPE html> + <html><head> + <meta charset="utf8"> + <link rel="localization" href="test.ftl"/> + <script src="page.js"></script> + </head></html>`; + + function SCRIPT() { + window.addEventListener( + "load", + async () => { + try { + await document.l10n.ready; + let result = await document.l10n.formatValue("key"); + browser.test.sendMessage("result", { success: true, result }); + } catch (err) { + browser.test.sendMessage("result", { + success: false, + msg: err.message, + }); + } + }, + { once: true } + ); + } + + async function runTest(isPrivileged) { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("ready", browser.runtime.getURL("page.html")); + }, + manifest: { + web_accessible_resources: ["page.html"], + }, + isPrivileged, + files: { + "page.html": PAGE, + "page.js": SCRIPT, + }, + }); + + await extension.startup(); + let url = await extension.awaitMessage("ready"); + let page = await ExtensionTestUtils.loadContentPage(url, { extension }); + let results = await extension.awaitMessage("result"); + await page.close(); + await extension.unload(); + + return results; + } + + // Everything should work for a privileged extension + let results = await runTest(true); + equal(results.success, true, "Translation succeeded in privileged extension"); + equal(results.result, "value", "Translation got the right value"); + + // In an unprivileged extension, document.l10n shouldn't show up + results = await runTest(false); + equal(results.success, false, "Translation failed in unprivileged extension"); + equal( + results.msg.endsWith("document.l10n is undefined"), + true, + "Translation failed due to missing document.l10n" + ); +}); + +add_task(async function test_l10n_manifest() { + // Fluent can't be used to localize properties that the AddonManager + // reads (see comment inside ExtensionData.parseManifest for details) + // so test by localizing a property that only the extension framework + // cares about: page_action. This means we can only do this test from + // browser. + if (AppConstants.MOZ_BUILD_APP != "browser") { + return; + } + + AddonTestUtils.initializeURLPreloader(); + + async function runTest({ + isPrivileged = false, + temporarilyInstalled = false, + } = {}) { + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged, + temporarilyInstalled, + manifest: { + l10n_resources: ["test.ftl"], + page_action: { + default_title: "__MSG_key__", + }, + }, + }); + + if (temporarilyInstalled && !isPrivileged) { + ExtensionTestUtils.failOnSchemaWarnings(false); + await Assert.rejects( + extension.startup(), + /Using 'l10n_resources' requires a privileged add-on/, + "startup failed without privileged api access" + ); + ExtensionTestUtils.failOnSchemaWarnings(true); + return; + } + await extension.startup(); + let title = extension.extension.manifest.page_action.default_title; + await extension.unload(); + return title; + } + + let title = await runTest({ isPrivileged: true }); + equal( + title, + "value", + "Manifest key localized with fluent in privileged extension" + ); + + title = await runTest(); + equal( + title, + "__MSG_key__", + "Manifest key not localized in unprivileged extension" + ); + + title = await runTest({ temporarilyInstalled: true }); + equal(title, undefined, "Startup fails with temporarilyInstalled extension"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_localStorage.js b/toolkit/components/extensions/test/xpcshell/test_ext_localStorage.js new file mode 100644 index 0000000000..9adb549afe --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_localStorage.js @@ -0,0 +1,50 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function backgroundScript() { + let hasRun = localStorage.getItem("has-run"); + let result; + if (!hasRun) { + localStorage.setItem("has-run", "yup"); + localStorage.setItem("test-item", "item1"); + result = "item1"; + } else { + let data = localStorage.getItem("test-item"); + if (data == "item1") { + localStorage.setItem("test-item", "item2"); + result = "item2"; + } else if (data == "item2") { + localStorage.removeItem("test-item"); + result = "deleted"; + } else if (!data) { + localStorage.clear(); + result = "cleared"; + } + } + browser.test.sendMessage("result", result); + browser.test.notifyPass("localStorage"); +} + +const ID = "test-webextension@mozilla.com"; +let extensionData = { + manifest: { browser_specific_settings: { gecko: { id: ID } } }, + background: backgroundScript, +}; + +add_task(async function test_localStorage() { + const RESULTS = ["item1", "item2", "deleted", "cleared", "item1"]; + + for (let expected of RESULTS) { + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + let actual = await extension.awaitMessage("result"); + + await extension.awaitFinish("localStorage"); + await extension.unload(); + + equal(actual, expected, "got expected localStorage data"); + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_management.js b/toolkit/components/extensions/test/xpcshell/test_ext_management.js new file mode 100644 index 0000000000..8fb6b0d9a1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_management.js @@ -0,0 +1,339 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +add_task(async function setup() { + Services.prefs.setBoolPref( + "extensions.webextOptionalPermissionPrompts", + false + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts"); + }); + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_management_permission() { + async function background() { + const permObj = { permissions: ["management"] }; + + let hasPerm = await browser.permissions.contains(permObj); + browser.test.assertTrue(!hasPerm, "does not have management permission"); + browser.test.assertTrue( + !!browser.management, + "management namespace exists" + ); + // These require permission + let requires_permission = [ + "getAll", + "get", + "install", + "setEnabled", + "onDisabled", + "onEnabled", + "onInstalled", + "onUninstalled", + ]; + + async function testAvailable() { + // These are always available regardless of permission. + for (let fn of ["getSelf", "uninstallSelf"]) { + browser.test.assertTrue( + !!browser.management[fn], + `management.${fn} exists` + ); + } + + let hasPerm = await browser.permissions.contains(permObj); + for (let fn of requires_permission) { + browser.test.assertEq( + hasPerm, + !!browser.management[fn], + `management.${fn} does not exist` + ); + } + } + + await testAvailable(); + + browser.test.onMessage.addListener(async msg => { + browser.test.log("test with permission"); + + // get permission + await browser.permissions.request(permObj); + let hasPerm = await browser.permissions.contains(permObj); + browser.test.assertTrue( + hasPerm, + "management permission.request accepted" + ); + await testAvailable(); + + browser.management.onInstalled.addListener(() => { + browser.test.fail("onInstalled listener invoked"); + }); + + browser.test.log("test without permission"); + // remove permission + await browser.permissions.remove(permObj); + hasPerm = await browser.permissions.contains(permObj); + browser.test.assertFalse( + hasPerm, + "management permission.request removed" + ); + await testAvailable(); + + browser.test.sendMessage("done"); + }); + + browser.test.sendMessage("started"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "management@test", + }, + }, + optional_permissions: ["management"], + }, + background, + useAddonManager: "temporary", + }); + + await extension.startup(); + await extension.awaitMessage("started"); + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request"); + }); + await extension.awaitMessage("done"); + + // Verify the onInstalled listener does not get used. + // The listener will make the test fail if fired. + let ext2 = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "on-installed@test", + }, + }, + optional_permissions: ["management"], + }, + useAddonManager: "temporary", + }); + await ext2.startup(); + await ext2.unload(); + + await extension.unload(); +}); + +add_task(async function test_management_getAll() { + const id1 = "get_all_test1@tests.mozilla.com"; + const id2 = "get_all_test2@tests.mozilla.com"; + + function getManifest(id) { + return { + browser_specific_settings: { + gecko: { + id, + }, + }, + name: id, + version: "1.0", + short_name: id, + permissions: ["management"], + }; + } + + async function background() { + browser.test.onMessage.addListener(async (msg, id) => { + let addon = await browser.management.get(id); + browser.test.sendMessage("addon", addon); + }); + + let addons = await browser.management.getAll(); + browser.test.assertEq( + 2, + addons.length, + "management.getAll returned correct number of add-ons." + ); + browser.test.sendMessage("addons", addons); + } + + let extension1 = ExtensionTestUtils.loadExtension({ + manifest: getManifest(id1), + useAddonManager: "temporary", + }); + + let extension2 = ExtensionTestUtils.loadExtension({ + manifest: getManifest(id2), + background, + useAddonManager: "temporary", + }); + + await extension1.startup(); + await extension2.startup(); + + let addons = await extension2.awaitMessage("addons"); + for (let id of [id1, id2]) { + let addon = addons.find(a => { + return a.id === id; + }); + equal( + addon.name, + id, + `The extension with id ${id} was returned by getAll.` + ); + equal(addon.shortName, id, "Additional extension metadata was correct"); + } + + extension2.sendMessage("getAddon", id1); + let addon = await extension2.awaitMessage("addon"); + equal(addon.name, id1, `The extension with id ${id1} was returned by get.`); + equal(addon.shortName, id1, "Additional extension metadata was correct"); + + extension2.sendMessage("getAddon", id2); + addon = await extension2.awaitMessage("addon"); + equal(addon.name, id2, `The extension with id ${id2} was returned by get.`); + equal(addon.shortName, id2, "Additional extension metadata was correct"); + + await extension2.unload(); + await extension1.unload(); +}); + +add_task( + { pref_set: [["extensions.eventPages.enabled", true]] }, + async function test_management_event_page() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["management"], + background: { persistent: false }, + }, + background() { + browser.management.onInstalled.addListener(details => { + browser.test.sendMessage("onInstalled", details); + }); + browser.management.onUninstalled.addListener(details => { + browser.test.sendMessage("onUninstalled", details); + }); + browser.management.onEnabled.addListener(() => { + browser.test.sendMessage("onEnabled"); + }); + browser.management.onDisabled.addListener(() => { + browser.test.sendMessage("onDisabled"); + }); + }, + }); + + await extension.startup(); + let events = ["onInstalled", "onUninstalled", "onEnabled", "onDisabled"]; + for (let event of events) { + assertPersistentListeners(extension, "management", event, { + primed: false, + }); + } + + await extension.terminateBackground(); + for (let event of events) { + assertPersistentListeners(extension, "management", event, { + primed: true, + }); + } + + let testExt = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: "test-ext@mochitest" } }, + }, + background() {}, + }); + await testExt.startup(); + + let details = await extension.awaitMessage("onInstalled"); + equal(testExt.id, details.id, "got onInstalled event"); + + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + await testExt.awaitStartup(); + + for (let event of events) { + assertPersistentListeners(extension, "management", event, { + primed: true, + }); + } + + // Test uninstalling an addon wakes up the watching extension. + let uninstalled = testExt.unload(); + + details = await extension.awaitMessage("onUninstalled"); + equal(testExt.id, details.id, "got onUninstalled event"); + + await extension.unload(); + await uninstalled; + } +); + +// Sanity check that Addon listeners are removed on context close. +add_task( + { + // __AddonManagerInternal__ is exposed for debug builds only. + skip_if: () => !AppConstants.DEBUG, + }, + async function test_management_unregister_listener() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["management"], + }, + files: { + "extpage.html": `<!DOCTYPE html><script src="extpage.js"></script>`, + "extpage.js": function () { + browser.management.onInstalled.addListener(() => {}); + }, + }, + }); + + await extension.startup(); + + const page = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/extpage.html` + ); + + const { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" + ); + function assertManagementAPIAddonListener(expect) { + let found = false; + for (const addonListener of AddonManager.__AddonManagerInternal__ + ?.addonListeners || []) { + if ( + Object.getPrototypeOf(addonListener).constructor.name === + "ManagementAddonListener" + ) { + found = true; + } + } + equal( + found, + expect, + `${ + expect ? "Should" : "Should not" + } have found an AOM addonListener registered by the management API` + ); + } + + assertManagementAPIAddonListener(true); + await page.close(); + assertManagementAPIAddonListener(false); + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_management_uninstall_self.js b/toolkit/components/extensions/test/xpcshell/test_ext_management_uninstall_self.js new file mode 100644 index 0000000000..45c981811b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_management_uninstall_self.js @@ -0,0 +1,146 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" +); +const { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" +); + +const id = "uninstall_self_test@tests.mozilla.com"; + +const manifest = { + browser_specific_settings: { + gecko: { + id, + }, + }, + name: "test extension name", + version: "1.0", +}; + +const waitForUninstalled = () => + new Promise(resolve => { + const listener = { + onUninstalled: async addon => { + equal(addon.id, id, "The expected add-on has been uninstalled"); + let checkedAddon = await AddonManager.getAddonByID(addon.id); + equal(checkedAddon, null, "Add-on no longer exists"); + AddonManager.removeAddonListener(listener); + resolve(); + }, + }; + AddonManager.addAddonListener(listener); + }); + +let promptService = { + _response: null, + QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]), + confirmEx: function (...args) { + this._confirmExArgs = args; + return this._response; + }, +}; + +AddonTestUtils.init(this); + +add_task(async function setup() { + let fakePromptService = MockRegistrar.register( + "@mozilla.org/prompter;1", + promptService + ); + registerCleanupFunction(() => { + MockRegistrar.unregister(fakePromptService); + }); + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_management_uninstall_no_prompt() { + function background() { + browser.test.onMessage.addListener(msg => { + browser.management.uninstallSelf(); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest, + background, + useAddonManager: "temporary", + }); + + await extension.startup(); + let addon = await AddonManager.getAddonByID(id); + notEqual(addon, null, "Add-on is installed"); + extension.sendMessage("uninstall"); + await waitForUninstalled(); + Services.obs.notifyObservers(extension.extension.file, "flush-cache-entry"); +}); + +add_task(async function test_management_uninstall_prompt_uninstall() { + promptService._response = 0; + + function background() { + browser.test.onMessage.addListener(msg => { + browser.management.uninstallSelf({ showConfirmDialog: true }); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest, + background, + useAddonManager: "temporary", + }); + + await extension.startup(); + let addon = await AddonManager.getAddonByID(id); + notEqual(addon, null, "Add-on is installed"); + extension.sendMessage("uninstall"); + await waitForUninstalled(); + + // Test localization strings + equal(promptService._confirmExArgs[1], `Uninstall ${manifest.name}`); + equal( + promptService._confirmExArgs[2], + `The extension “${manifest.name}” is requesting to be uninstalled. What would you like to do?` + ); + equal(promptService._confirmExArgs[4], "Uninstall"); + equal(promptService._confirmExArgs[5], "Keep Installed"); + Services.obs.notifyObservers(extension.extension.file, "flush-cache-entry"); +}); + +add_task(async function test_management_uninstall_prompt_keep() { + promptService._response = 1; + + function background() { + browser.test.onMessage.addListener(async msg => { + await browser.test.assertRejects( + browser.management.uninstallSelf({ showConfirmDialog: true }), + "User cancelled uninstall of extension", + "Expected rejection when user declines uninstall" + ); + + browser.test.sendMessage("uninstall-rejected"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest, + background, + useAddonManager: "temporary", + }); + + await extension.startup(); + + let addon = await AddonManager.getAddonByID(id); + notEqual(addon, null, "Add-on is installed"); + + extension.sendMessage("uninstall"); + await extension.awaitMessage("uninstall-rejected"); + + addon = await AddonManager.getAddonByID(id); + notEqual(addon, null, "Add-on remains installed"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest.js new file mode 100644 index 0000000000..e627512d9b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest.js @@ -0,0 +1,488 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", +}); + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +async function testManifest(manifest, expectedError) { + ExtensionTestUtils.failOnSchemaWarnings(false); + let normalized = await ExtensionTestUtils.normalizeManifest(manifest); + ExtensionTestUtils.failOnSchemaWarnings(true); + + if (expectedError) { + ok( + expectedError.test(normalized.error), + `Should have an error for ${JSON.stringify(manifest)}, got ${ + normalized.error + }` + ); + } else { + ok( + !normalized.error, + `Should not have an error ${JSON.stringify(manifest)}, ${ + normalized.error + }` + ); + } + return normalized.errors; +} + +async function testIconPaths(icon, manifest, expectedError) { + let normalized = await ExtensionTestUtils.normalizeManifest(manifest); + + if (expectedError) { + ok( + expectedError.test(normalized.error), + `Should have an error for ${JSON.stringify(icon)}` + ); + } else { + ok(!normalized.error, `Should not have an error ${JSON.stringify(icon)}`); + } +} + +add_setup(async () => { + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_manifest() { + let badpaths = ["", " ", "\t", "http://foo.com/icon.png"]; + for (let path of badpaths) { + await testIconPaths( + path, + { + icons: path, + }, + /Error processing icons/ + ); + + await testIconPaths( + path, + { + icons: { + 16: path, + }, + }, + /Error processing icons/ + ); + } + + let paths = [ + "icon.png", + "/icon.png", + "./icon.png", + "path to an icon.png", + " icon.png", + ]; + for (let path of paths) { + // manifest.icons is an object + await testIconPaths( + path, + { + icons: path, + }, + /Error processing icons/ + ); + + await testIconPaths(path, { + icons: { + 16: path, + }, + }); + } +}); + +add_task(async function test_manifest_warnings_on_unexpected_props() { + let extension = await ExtensionTestUtils.loadExtension({ + manifest: { + background: { + scripts: ["bg.js"], + wrong_prop: true, + }, + }, + files: { + "bg.js": "", + }, + }); + + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + + // Retrieve the warning message collected by the Extension class + // packagingWarning method. + const { warnings } = extension.extension; + equal(warnings.length, 1, "Got the expected number of manifest warnings"); + + const expectedMessage = + "Reading manifest: Warning processing background.wrong_prop"; + ok( + warnings[0].startsWith(expectedMessage), + "Got the expected warning message format" + ); + + await extension.unload(); +}); + +add_task(async function test_mv2_scripting_permission_always_enabled() { + let warnings = await testManifest({ + manifest_version: 2, + permissions: ["scripting"], + }); + + Assert.deepEqual(warnings, [], "Got no warnings"); +}); + +add_task( + { + pref_set: [["extensions.manifestV3.enabled", true]], + }, + async function test_mv3_scripting_permission_always_enabled() { + let warnings = await testManifest({ + manifest_version: 3, + permissions: ["scripting"], + }); + + Assert.deepEqual(warnings, [], "Got no warnings"); + } +); + +add_task(async function test_simpler_version_format() { + const TEST_CASES = [ + // Valid cases + { version: "0", expectWarning: false }, + { version: "0.0", expectWarning: false }, + { version: "0.0.0", expectWarning: false }, + { version: "0.0.0.0", expectWarning: false }, + { version: "0.0.0.1", expectWarning: false }, + { version: "0.0.0.999999999", expectWarning: false }, + { version: "0.0.1.0", expectWarning: false }, + { version: "0.0.999999999", expectWarning: false }, + { version: "0.1.0.0", expectWarning: false }, + { version: "0.999999999", expectWarning: false }, + { version: "1", expectWarning: false }, + { version: "1.0", expectWarning: false }, + { version: "1.0.0", expectWarning: false }, + { version: "1.0.0.0", expectWarning: false }, + { version: "1.2.3.4", expectWarning: false }, + { version: "999999999", expectWarning: false }, + { + version: "999999999.999999999.999999999.999999999", + expectWarning: false, + }, + // Invalid cases + { version: ".", expectWarning: true }, + { version: ".999999999", expectWarning: true }, + { version: "0.0.0.0.0", expectWarning: true }, + { version: "0.0.0.00001", expectWarning: true }, + { version: "0.0.0.0010", expectWarning: true }, + { version: "0.0.00001", expectWarning: true }, + { version: "0.0.001", expectWarning: true }, + { version: "0.0.01.0", expectWarning: true }, + { version: "0.01.0", expectWarning: true }, + { version: "00001", expectWarning: true }, + { version: "0001", expectWarning: true }, + { version: "001", expectWarning: true }, + { version: "01", expectWarning: true }, + { version: "01.0", expectWarning: true }, + { version: "099999", expectWarning: true }, + { version: "0999999999", expectWarning: true }, + { version: "1.00000", expectWarning: true }, + { version: "1.1.-1", expectWarning: true }, + { version: "1.1000000000", expectWarning: true }, + { version: "1.1pre1aa", expectWarning: true }, + { version: "1.2.1000000000", expectWarning: true }, + { version: "1.2.3.4-a", expectWarning: true }, + { version: "1.2.3.4.5", expectWarning: true }, + { version: "1000000000", expectWarning: true }, + { version: "1000000000.0.0.0", expectWarning: true }, + { version: "999999999.", expectWarning: true }, + ]; + + for (const { version, expectWarning } of TEST_CASES) { + const normalized = await ExtensionTestUtils.normalizeManifest({ version }); + + if (expectWarning) { + Assert.deepEqual( + normalized.errors, + [ + `Warning processing version: version must be a version string ` + + `consisting of at most 4 integers of at most 9 digits without ` + + `leading zeros, and separated with dots`, + ], + `expected warning for version: ${version}` + ); + } else { + Assert.deepEqual( + normalized.errors, + [], + `expected no warning for version: ${version}` + ); + } + } +}); + +add_task(async function test_applications() { + const id = "some@id"; + const updateURL = "https://example.com/updates/"; + + let extension = await ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 2, + applications: { + gecko: { id, update_url: updateURL }, + }, + }, + useAddonManager: "temporary", + }); + + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + + Assert.deepEqual(extension.extension.warnings, [], "expected no warnings"); + + const addon = await AddonManager.getAddonByID(extension.id); + ok(addon, "got an add-on"); + equal(addon.id, id, "got expected ID"); + equal(addon.updateURL, updateURL, "got expected update URL"); + + await extension.unload(); +}); + +add_task( + { + pref_set: [["extensions.manifestV3.enabled", true]], + }, + async function test_applications_key_mv3() { + let warnings = await testManifest({ + manifest_version: 3, + applications: {}, + }); + + Assert.deepEqual( + warnings, + [`Property "applications" is unsupported in Manifest Version 3`], + `Manifest v3 with "applications" key logs an error.` + ); + } +); + +add_task(async function test_bss_gecko_android() { + const addonId = "some@id"; + const isAndroid = AppConstants.platform == "android"; + + const TEST_CASES = [ + { + title: "gecko_android overrides gecko", + browser_specific_settings: { + gecko: { + id: addonId, + strict_min_version: "2", + strict_max_version: "2", + }, + gecko_android: { + strict_min_version: "1", + strict_max_version: "1", + }, + }, + expectedError: isAndroid + ? `Add-on ${addonId} is not compatible with application version. add-on minVersion: 1. add-on maxVersion: 1.` + : `Add-on ${addonId} is not compatible with application version. add-on minVersion: 2. add-on maxVersion: 2.`, + }, + { + title: + "strict_min_version in gecko_android overrides gecko.strict_min_version", + browser_specific_settings: { + gecko: { + id: addonId, + strict_min_version: "2", + strict_max_version: "3", + }, + gecko_android: { + strict_min_version: "3", + }, + }, + expectedError: isAndroid + ? `Add-on ${addonId} is not compatible with application version. add-on minVersion: 3. add-on maxVersion: 3.` + : `Add-on ${addonId} is not compatible with application version. add-on minVersion: 2. add-on maxVersion: 3.`, + }, + { + title: + "strict_max_version in gecko_android overrides gecko.strict_max_version", + browser_specific_settings: { + gecko: { + id: addonId, + strict_min_version: "2", + strict_max_version: "2", + }, + gecko_android: { + strict_max_version: "3", + }, + }, + expectedError: isAndroid + ? `Add-on ${addonId} is not compatible with application version. add-on minVersion: 2. add-on maxVersion: 3.` + : `Add-on ${addonId} is not compatible with application version. add-on minVersion: 2. add-on maxVersion: 2.`, + }, + { + title: "no gecko_android", + browser_specific_settings: { + gecko: { + id: addonId, + strict_min_version: "2", + strict_max_version: "2", + }, + }, + expectedError: `Add-on ${addonId} is not compatible with application version. add-on minVersion: 2. add-on maxVersion: 2.`, + }, + { + title: "empty gecko_android", + browser_specific_settings: { + gecko: { + id: addonId, + strict_min_version: "2", + strict_max_version: "2", + }, + gecko_android: {}, + }, + expectedError: `Add-on ${addonId} is not compatible with application version. add-on minVersion: 2. add-on maxVersion: 2.`, + }, + { + title: "empty strict min/max versions in gecko_android", + browser_specific_settings: { + gecko: { + id: addonId, + strict_min_version: "2", + strict_max_version: "2", + }, + gecko_android: { + strict_min_version: "", + strict_max_version: "", + }, + }, + expectedError: `Add-on ${addonId} is not compatible with application version. add-on minVersion: 2. add-on maxVersion: 2.`, + }, + { + title: "unsupported prop in gecko_android", + browser_specific_settings: { + gecko: { + id: addonId, + strict_min_version: "2", + strict_max_version: "2", + }, + gecko_android: { + aPropThatIsNotSupported: "aPropThatIsNotSupported", + }, + }, + expectedError: `Add-on ${addonId} is not compatible with application version. add-on minVersion: 2. add-on maxVersion: 2.`, + }, + { + title: "only strict min/max version in gecko_android", + browser_specific_settings: { + gecko: { + id: addonId, + }, + gecko_android: { + strict_min_version: "3", + strict_max_version: "4", + }, + }, + expectedError: isAndroid + ? `Add-on ${addonId} is not compatible with application version. add-on minVersion: 3. add-on maxVersion: 4.` + : null, + }, + { + title: "only strict_min_version in gecko_android", + browser_specific_settings: { + gecko: { + id: addonId, + }, + gecko_android: { + // The app version is set to `42` at the top of the file. + strict_min_version: "100", + }, + }, + expectedError: isAndroid + ? `Add-on ${addonId} is not compatible with application version. add-on minVersion: 100.` + : null, + }, + ]; + + for (const { + title, + browser_specific_settings, + expectedError, + } of TEST_CASES) { + info(`verifying: ${title}`); + + // This task is mainly about verifying `bss.gecko_android` and some test + // cases require a "valid" compatibility range by default, which would + // break the assumption below (that the install is going to fail). This is + // why we skip null errors, but only on non-Android builds. + if (expectedError === null) { + notEqual( + AppConstants.platform, + "android", + `${title} - expected no error on a non-Android build` + ); + continue; + } + + const manifest = { + manifest_version: 2, + version: "1.0", + browser_specific_settings, + }; + + const extension = ExtensionTestUtils.loadExtension({ + manifest, + useAddonManager: "temporary", + }); + + await Assert.rejects( + extension.startup(), + new RegExp(expectedError), + `${title} - expected error: ${expectedError}` + ); + + const addon = await AddonManager.getAddonByID(addonId); + equal(addon, null, "add-on is not installed"); + } +}); + +add_task( + { skip_if: AppConstants.platform !== "android" }, + async function test_bss_gecko_android_only() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 2, + version: "1.0", + browser_specific_settings: { + gecko_android: { + strict_min_version: "0", + }, + }, + }, + }); + + await extension.startup(); + + const { manifest } = extension.extension; + equal( + manifest.browser_specific_settings.gecko_android.strict_min_version, + "0", + "expected strict min version" + ); + + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js new file mode 100644 index 0000000000..a6e3f91a6b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js @@ -0,0 +1,114 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +add_task(async function test_manifest_csp() { + let normalized = await ExtensionTestUtils.normalizeManifest({ + content_security_policy: "script-src 'self'; object-src 'none'", + }); + + equal(normalized.error, undefined, "Should not have an error"); + equal(normalized.errors.length, 0, "Should not have warnings"); + equal( + normalized.value.content_security_policy, + "script-src 'self'; object-src 'none'", + "Should have the expected policy string" + ); + + ExtensionTestUtils.failOnSchemaWarnings(false); + normalized = await ExtensionTestUtils.normalizeManifest({ + content_security_policy: "object-src 'none'", + }); + ExtensionTestUtils.failOnSchemaWarnings(true); + + equal(normalized.error, undefined, "Should not have an error"); + + Assert.deepEqual( + normalized.errors, + [ + "Error processing content_security_policy: Policy is missing a required ‘script-src’ directive", + ], + "Should have the expected warning" + ); + + equal( + normalized.value.content_security_policy, + null, + "Invalid policy string should be omitted" + ); + + ExtensionTestUtils.failOnSchemaWarnings(false); + normalized = await ExtensionTestUtils.normalizeManifest({ + manifest_version: 2, + content_security_policy: { + extension_pages: "script-src 'self'; object-src 'none'", + }, + }); + ExtensionTestUtils.failOnSchemaWarnings(true); + + Assert.deepEqual( + normalized.errors, + [ + `Error processing content_security_policy: Expected string instead of {"extension_pages":"script-src 'self'; object-src 'none'"}`, + ], + "Should have the expected warning" + ); +}); + +add_task(async function test_manifest_csp_v3() { + ExtensionTestUtils.failOnSchemaWarnings(false); + let normalized = await ExtensionTestUtils.normalizeManifest({ + manifest_version: 3, + content_security_policy: "script-src 'self'; object-src 'none'", + }); + ExtensionTestUtils.failOnSchemaWarnings(true); + + Assert.deepEqual( + normalized.errors, + [ + `Error processing content_security_policy: Expected object instead of "script-src 'self'; object-src 'none'"`, + ], + "Should have the expected warning" + ); + + normalized = await ExtensionTestUtils.normalizeManifest({ + manifest_version: 3, + content_security_policy: { + extension_pages: "script-src 'self' 'unsafe-eval'; object-src 'none'", + }, + }); + + Assert.deepEqual( + normalized.errors, + [ + "Error processing content_security_policy.extension_pages: ‘script-src’ directive contains a forbidden 'unsafe-eval' keyword", + ], + "Should have the expected warning" + ); + equal( + normalized.value.content_security_policy.extension_pages, + null, + "Should have the expected policy string" + ); + + ExtensionTestUtils.failOnSchemaWarnings(false); + normalized = await ExtensionTestUtils.normalizeManifest({ + manifest_version: 3, + content_security_policy: { + extension_pages: "object-src 'none'", + }, + }); + ExtensionTestUtils.failOnSchemaWarnings(true); + + equal(normalized.error, undefined, "Should not have an error"); + equal(normalized.errors.length, 1, "Should have warnings"); + Assert.deepEqual( + normalized.errors, + [ + "Error processing content_security_policy.extension_pages: Policy is missing a required ‘script-src’ directive", + ], + "Should have the expected warning for extension_pages CSP" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_incognito.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_incognito.js new file mode 100644 index 0000000000..4330e1b681 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_incognito.js @@ -0,0 +1,45 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_manifest_incognito() { + let normalized = await ExtensionTestUtils.normalizeManifest({ + incognito: "spanning", + }); + + equal(normalized.error, undefined, "Should not have an error"); + equal(normalized.errors.length, 0, "Should not have warnings"); + equal( + normalized.value.incognito, + "spanning", + "Should have the expected incognito string" + ); + + normalized = await ExtensionTestUtils.normalizeManifest({ + incognito: "not_allowed", + }); + + equal(normalized.error, undefined, "Should not have an error"); + equal(normalized.errors.length, 0, "Should not have warnings"); + equal( + normalized.value.incognito, + "not_allowed", + "Should have the expected incognito string" + ); + + normalized = await ExtensionTestUtils.normalizeManifest({ + incognito: "split", + }); + + equal( + normalized.error, + 'Error processing incognito: Invalid enumeration value "split"', + "Should have an error" + ); + Assert.deepEqual(normalized.errors, [], "Should not have a warning"); + equal( + normalized.value, + undefined, + "Invalid incognito string should be undefined" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_chrome_version.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_chrome_version.js new file mode 100644 index 0000000000..39119513fb --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_chrome_version.js @@ -0,0 +1,12 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_manifest_minimum_chrome_version() { + let normalized = await ExtensionTestUtils.normalizeManifest({ + minimum_chrome_version: "42", + }); + + equal(normalized.error, undefined, "Should not have an error"); + equal(normalized.errors.length, 0, "Should not have warnings"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_opera_version.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_opera_version.js new file mode 100644 index 0000000000..943e8b7270 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_opera_version.js @@ -0,0 +1,12 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_manifest_minimum_opera_version() { + let normalized = await ExtensionTestUtils.normalizeManifest({ + minimum_opera_version: "48", + }); + + equal(normalized.error, undefined, "Should not have an error"); + equal(normalized.errors.length, 0, "Should not have warnings"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_themes.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_themes.js new file mode 100644 index 0000000000..8cd44f06dc --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_themes.js @@ -0,0 +1,35 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function test_theme_property(property) { + let normalized = await ExtensionTestUtils.normalizeManifest( + { + theme: { + [property]: {}, + }, + }, + "manifest.ThemeManifest" + ); + + if (property === "unrecognized_key") { + const expectedWarning = `Warning processing theme.${property}`; + ok( + normalized.errors[0].includes(expectedWarning), + `The manifest warning ${JSON.stringify( + normalized.errors[0] + )} must contain ${JSON.stringify(expectedWarning)}` + ); + } else { + equal(normalized.errors.length, 0, "Should have a warning"); + } + equal(normalized.error, undefined, "Should not have an error"); +} + +add_task(async function test_manifest_themes() { + await test_theme_property("images"); + await test_theme_property("colors"); + ExtensionTestUtils.failOnSchemaWarnings(false); + await test_theme_property("unrecognized_key"); + ExtensionTestUtils.failOnSchemaWarnings(true); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_messaging_startup.js b/toolkit/components/extensions/test/xpcshell/test_ext_messaging_startup.js new file mode 100644 index 0000000000..120bebb431 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_messaging_startup.js @@ -0,0 +1,277 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +let { promiseRestartManager, promiseShutdownManager, promiseStartupManager } = + AddonTestUtils; + +const PAGE_HTML = `<!DOCTYPE html><meta charset="utf-8"><script src="script.js"></script>`; + +function trackEvents(wrapper) { + let events = new Map(); + for (let event of ["background-script-event", "start-background-script"]) { + events.set(event, false); + wrapper.extension.once(event, () => events.set(event, true)); + } + return events; +} + +async function test(what, background, script) { + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + + manifest: { + content_scripts: [ + { + matches: ["http://example.com/*"], + js: ["script.js"], + }, + ], + }, + + files: { + "page.html": PAGE_HTML, + "script.js": script, + }, + + background, + }); + + info(`Set up ${what} listener`); + await extension.startup(); + await extension.awaitMessage("bg-ran"); + + info(`Test wakeup for ${what} from an extension page`); + await promiseRestartManager({ earlyStartup: false }); + await extension.awaitStartup(); + + function awaitBgEvent() { + return new Promise(resolve => + extension.extension.once("background-script-event", resolve) + ); + } + + let events = trackEvents(extension); + + let url = extension.extension.baseURI.resolve("page.html"); + + let [, page] = await Promise.all([ + awaitBgEvent(), + ExtensionTestUtils.loadContentPage(url, { extension }), + ]); + + equal( + events.get("background-script-event"), + true, + "Should have gotten a background page event" + ); + equal( + events.get("start-background-script"), + false, + "Background page should not be started" + ); + + equal(extension.messageQueue.size, 0, "Have not yet received bg-ran message"); + + let promise = extension.awaitMessage("bg-ran"); + AddonTestUtils.notifyEarlyStartup(); + await promise; + + equal( + events.get("start-background-script"), + true, + "Should have gotten start-background-script event" + ); + + await extension.awaitFinish("messaging-test"); + ok(true, "Background page loaded and received message from extension page"); + + await page.close(); + + info(`Test wakeup for ${what} from a content script`); + await promiseRestartManager({ earlyStartup: false }); + await extension.awaitStartup(); + + events = trackEvents(extension); + + [, page] = await Promise.all([ + awaitBgEvent(), + ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ), + ]); + + equal( + events.get("background-script-event"), + true, + "Should have gotten a background script event" + ); + equal( + events.get("start-background-script"), + false, + "Background script should not be started" + ); + + equal(extension.messageQueue.size, 0, "Have not yet received bg-ran message"); + + promise = extension.awaitMessage("bg-ran"); + AddonTestUtils.notifyEarlyStartup(); + await promise; + + equal( + events.get("start-background-script"), + true, + "Should have gotten start-background-script event" + ); + + await extension.awaitFinish("messaging-test"); + ok(true, "Background page loaded and received message from content script"); + + await page.close(); + await extension.unload(); + + await promiseShutdownManager(); +} + +add_task(function test_onMessage() { + function script() { + browser.runtime.sendMessage("ping").then(reply => { + browser.test.assertEq( + reply, + "pong", + "Extension page received pong reply" + ); + browser.test.notifyPass("messaging-test"); + }); + } + + async function background() { + browser.runtime.onMessage.addListener((msg, sender) => { + browser.test.assertEq( + msg, + "ping", + "Background page received ping message" + ); + return Promise.resolve("pong"); + }); + + // addListener() returns right away but make a round trip to the + // main process to ensure the persistent onMessage listener is recorded. + await browser.runtime.getBrowserInfo(); + browser.test.sendMessage("bg-ran"); + } + + return test("onMessage", background, script); +}); + +add_task(function test_onConnect() { + function script() { + let port = browser.runtime.connect(); + port.onMessage.addListener(msg => { + browser.test.assertEq(msg, "pong", "Extension page received pong reply"); + browser.test.notifyPass("messaging-test"); + }); + port.postMessage("ping"); + } + + async function background() { + browser.runtime.onConnect.addListener(port => { + port.onMessage.addListener(msg => { + browser.test.assertEq( + msg, + "ping", + "Background page received ping message" + ); + port.postMessage("pong"); + }); + }); + + // addListener() returns right away but make a round trip to the + // main process to ensure the persistent onMessage listener is recorded. + await browser.runtime.getBrowserInfo(); + browser.test.sendMessage("bg-ran"); + } + + return test("onConnect", background, script); +}); + +// Test that messaging works if the background page is started before +// any messages are exchanged. (See bug 1467136 for an example of how +// this broke at one point). +add_task(async function test_other_startup() { + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + + async background() { + browser.runtime.onMessage.addListener(msg => { + browser.test.notifyPass("startup"); + }); + + // addListener() returns right away but make a round trip to the + // main process to ensure the persistent onMessage listener is recorded. + await browser.runtime.getBrowserInfo(); + browser.test.sendMessage("bg-ran"); + }, + + files: { + "page.html": PAGE_HTML, + "script.js"() { + browser.runtime.sendMessage("ping"); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("bg-ran"); + + await promiseRestartManager({ lateStartup: false }); + await extension.awaitStartup(); + let events = trackEvents(extension); + + equal( + events.get("background-script-event"), + false, + "Should not have gotten a background page event" + ); + equal( + events.get("start-background-script"), + false, + "Background page should not be started" + ); + + // Start the background page. No message have been sent at this point. + await AddonTestUtils.notifyLateStartup(); + equal( + events.get("start-background-script"), + true, + "Background page should be started" + ); + + await extension.awaitMessage("bg-ran"); + + // Now that the background page is fully started, load a new page that + // sends a message to the background page. + let url = extension.extension.baseURI.resolve("page.html"); + let page = await ExtensionTestUtils.loadContentPage(url, { extension }); + + await extension.awaitFinish("startup"); + + await page.close(); + await extension.unload(); + + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js new file mode 100644 index 0000000000..cb08a70151 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js @@ -0,0 +1,1111 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* globals chrome */ + +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +const PREF_MAX_READ = "webextensions.native-messaging.max-input-message-bytes"; +const PREF_MAX_WRITE = + "webextensions.native-messaging.max-output-message-bytes"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +const ECHO_BODY = String.raw` + import struct + import sys + + stdin = getattr(sys.stdin, 'buffer', sys.stdin) + stdout = getattr(sys.stdout, 'buffer', sys.stdout) + + while True: + rawlen = stdin.read(4) + if len(rawlen) == 0: + sys.exit(0) + msglen = struct.unpack('@I', rawlen)[0] + msg = stdin.read(msglen) + + stdout.write(struct.pack('@I', msglen)) + stdout.write(msg) +`; + +const INFO_BODY = String.raw` + import json + import os + import struct + import sys + + msg = json.dumps({"args": sys.argv, "cwd": os.getcwd()}) + if sys.version_info >= (3,): + sys.stdout.buffer.write(struct.pack('@I', len(msg))) + else: + sys.stdout.write(struct.pack('@I', len(msg))) + sys.stdout.write(msg) + sys.exit(0) +`; + +const DELAYED_ECHO_BODY = String.raw` + import atexit + import json + import os + import struct + import sys + import time + + stdin = getattr(sys.stdin, 'buffer', sys.stdin) + stdout = getattr(sys.stdout, 'buffer', sys.stdout) + pid = os.getpid() + + sys.stderr.write("nativeapp with pid %d is running\n" % pid) + + def onexit(): + sys.stderr.write("nativeapp with pid %d is exiting\n" % pid) + + atexit.register(onexit) + + while True: + rawlen = stdin.read(4) + if len(rawlen) == 0: + sys.exit(0) + msglen = struct.unpack('@I', rawlen)[0] + msg = stdin.read(msglen) + + sys.stderr.write( + "nativeapp with pid %d delaying echoing message '%s'\n" % + (pid, str(msg, 'utf-8')) + ) + + time.sleep(5) + stdout.write(struct.pack('@I', msglen)) + stdout.write(msg) + + sys.stderr.write( + "nativeapp with pid %d replied to message '%s'\n" % + (pid, str(msg, 'utf-8')) + ) +`; + +const STDERR_LINES = ["hello stderr", "this should be a separate line"]; +let STDERR_MSG = STDERR_LINES.join("\\n"); + +const STDERR_BODY = String.raw` + import sys + sys.stderr.write("${STDERR_MSG}") +`; + +const PLATFORM_PATH_SEP = AppConstants.platform == "win" ? "\\" : "/"; + +let SCRIPTS = [ + { + name: "echo", + description: "a native app that echoes back messages it receives", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + }, + { + name: "relative.echo", + description: "a native app that echoes; relative path instead of absolute", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + _hookModifyManifest(manifest) { + manifest.path = PathUtils.filename(manifest.path); + }, + }, + { + name: "relative_dotdot.echo", + description: "a native app that echos; relative path with dot dot", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + _hookModifyManifest(manifest) { + // Set to ..\NativeMessagingHosts\relative_dotdot.bat (Windows) + manifest.path = [ + "..", + ...manifest.path.split(PLATFORM_PATH_SEP).slice(-2), + ].join(PLATFORM_PATH_SEP); + }, + }, + { + name: "renamed.echo", + description: "invalid manifest due to name mismatch", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + _hookModifyManifest(manifest) { + manifest.name = "renamed_name_mismatch"; + }, + }, + { + name: "nonstdio.echo", + description: "invalid manifest due to non-stdio type", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + _hookModifyManifest(manifest) { + // schema only permits "stdio" or "pkcs11". Change from "stdio": + manifest.type = "pkcs11"; + }, + }, + { + name: "forwardslash.echo", + description: "a native app that echos; with forward slash in path", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + _hookModifyManifest(manifest) { + // On Linux/macOS, this doesn't change anything. + // On Windows, this turns C:\Program Files\... in C:/Program Files/... + manifest.path = manifest.path.replaceAll("\\", "/"); + }, + }, + { + name: "dot.echo", + description: "a native app that echos; with dot slash in path", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + _hookModifyManifest(manifest) { + // Replace / with /./ (or \ with \.\ on Windows). + manifest.path = manifest.path.replaceAll( + PLATFORM_PATH_SEP, + PLATFORM_PATH_SEP + "." + PLATFORM_PATH_SEP + ); + }, + }, + { + name: "dotdot.echo", + description: "a native app that echos; with dot dot slash in path", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + _hookModifyManifest(manifest) { + // The binary is in a directory called "TYPE_SLUG". Turn + // /TYPE_SLUG/ in /TYPE_SLUG/../TYPE_SLUG/ to have equivalent directories + // that ought to be considered a valid absolute path. + const dirWithSlashes = PLATFORM_PATH_SEP + TYPE_SLUG + PLATFORM_PATH_SEP; + manifest.path = manifest.path.replace( + dirWithSlashes, + dirWithSlashes + ".." + dirWithSlashes + ); + }, + }, + { + name: "delayedecho", + description: + "a native app that echo messages received with a small artificial delay", + script: DELAYED_ECHO_BODY.replace(/^ {2}/gm, ""), + }, + { + name: "info", + description: "a native app that gives some info about how it was started", + script: INFO_BODY.replace(/^ {2}/gm, ""), + }, + { + name: "stderr", + description: "a native app that writes to stderr and then exits", + script: STDERR_BODY.replace(/^ {2}/gm, ""), + }, +]; + +if (AppConstants.platform == "win") { + SCRIPTS.push({ + name: "echocmd", + description: "echo but using a .cmd file", + scriptExtension: "cmd", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + }); +} + +add_setup(async function setup() { + optionalPermissionsPromptHandler.init(); + optionalPermissionsPromptHandler.acceptPrompt = true; + await AddonTestUtils.promiseStartupManager(); + + await setupHosts(SCRIPTS); +}); + +// Test the basic operation of native messaging with a simple +// script that echoes back whatever message is sent to it. +add_task(async function test_happy_path() { + async function background() { + let port; + browser.test.onMessage.addListener(async (what, payload) => { + if (what == "request") { + await browser.permissions.request({ permissions: ["nativeMessaging"] }); + // connectNative requires permission + port = browser.runtime.connectNative("echo"); + port.onMessage.addListener(msg => { + browser.test.sendMessage("message", msg); + }); + browser.test.sendMessage("ready"); + } else if (what == "send") { + if (payload._json) { + let json = payload._json; + payload.toJSON = () => json; + delete payload._json; + } + port.postMessage(payload); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + optional_permissions: ["nativeMessaging"], + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request"); + await extension.awaitMessage("ready"); + }); + const tests = [ + { + data: "this is a string", + what: "simple string", + }, + { + data: "Это юникода", + what: "unicode string", + }, + { + data: { test: "hello" }, + what: "simple object", + }, + { + data: { + what: "An object with a few properties", + number: 123, + bool: true, + nested: { what: "another object" }, + }, + what: "object with several properties", + }, + + { + data: { + ignoreme: true, + _json: { data: "i have a tojson method" }, + }, + expected: { data: "i have a tojson method" }, + what: "object with toJSON() method", + }, + ]; + for (let test of tests) { + extension.sendMessage("send", test.data); + let response = await extension.awaitMessage("message"); + let expected = test.expected || test.data; + deepEqual(response, expected, `Echoed a message of type ${test.what}`); + } + + let procCount = await getSubprocessCount(); + equal(procCount, 1, "subprocess is still running"); + let exitPromise = waitForSubprocessExit(); + await extension.unload(); + await exitPromise; +}); + +// Just test that the given app (which should be the echo script above) +// can be started. Used to test corner cases in how the native application +// is located/launched. +async function simpleTest(app) { + function background(appname) { + let port = browser.runtime.connectNative(appname); + let MSG = "test"; + port.onMessage.addListener(msg => { + browser.test.assertEq(MSG, msg, "Got expected message back"); + browser.test.sendMessage("done"); + }); + port.postMessage(MSG); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${background})(${JSON.stringify(app)});`, + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + + let procCount = await getSubprocessCount(); + equal(procCount, 1, "subprocess is still running"); + let exitPromise = waitForSubprocessExit(); + await extension.unload(); + await exitPromise; +} + +async function testBrokenApp({ + extensionId = ID, + appname, + expectedError, + expectedConsoleMessages, +}) { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.onMessage.addListener(async (appname, expectedError) => { + await browser.test.assertRejects( + browser.runtime.sendNativeMessage(appname, "dummymsg"), + expectedError, + "Expected sendNativeMessage error" + ); + browser.test.sendMessage("done"); + }); + }, + manifest: { + browser_specific_settings: { gecko: { id: extensionId } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + let { messages } = await promiseConsoleOutput(async () => { + extension.sendMessage(appname, expectedError); + await extension.awaitMessage("done"); + }); + await extension.unload(); + + let procCount = await getSubprocessCount(); + equal(procCount, 0, "No child process was started"); + + // Because we're using forbidUnexpected:true below, we have to account for + // all logged messages. RemoteSettings may try (and fail) to load remote + // settings - ignore the "NetworkError: Network request failed" error. + // To avoid having to update this filter all the time, select the specific + // modules relevant to native messaging from where we expect errors. + messages = messages.filter(m => { + return /NativeMessaging|NativeManifests|Subprocess/.test(m.message); + }); + + // On Linux/macOS, the setupHosts helper registers the same manifest file in + // multiple locations, which can result in the same error being printed + // multiple times. We de-duplicate that here. + let deduplicatedMessages = messages.filter( + (msg, i) => i === messages.findIndex(m => m.message === msg.message) + ); + + // Now check that all the log messages exist, in the expected order too. + AddonTestUtils.checkMessages( + deduplicatedMessages, + { + expected: expectedConsoleMessages.map(message => ({ message })), + forbidUnexpected: true, + }, + "Expected messages in the console" + ); +} + +if (AppConstants.platform == "win") { + // "relative.echo" has a relative path in the host manifest. + add_task(function test_relative_path() { + // Note: relative paths only supported on Windows. + // For non-Windows, see test_relative_path_unsupported instead. + return simpleTest("relative.echo"); + }); + + add_task(function test_relative_dotdot_path() { + // Note: relative paths only supported on Windows. + // For non-Windows, see test_relative_dotdot_path_unsupported instead. + return simpleTest("relative_dotdot.echo"); + }); + + // "echocmd" uses a .cmd file instead of a .bat file + add_task(function test_cmd_file() { + return simpleTest("echocmd"); + }); +} else { + // On non-Windows, relative paths are not supported. + add_task(function test_relative_path_unsupported() { + return testBrokenApp({ + appname: "relative.echo", + expectedError: "An unexpected error occurred", + expectedConsoleMessages: [ + /NativeApp requires absolute path to command on this platform/, + ], + }); + }); + add_task(function test_relative_dotdot_path_unsupported() { + return testBrokenApp({ + appname: "relative_dotdot.echo", + expectedError: "An unexpected error occurred", + expectedConsoleMessages: [ + /NativeApp requires absolute path to command on this platform/, + ], + }); + }); +} + +add_task(async function test_absolute_path_dot_one() { + return simpleTest("dot.echo"); +}); + +add_task(async function test_absolute_path_dotdot() { + return simpleTest("dotdot.echo"); +}); + +add_task(async function test_error_name_mismatch() { + await testBrokenApp({ + appname: "renamed.echo", + expectedError: "No such native application renamed.echo", + expectedConsoleMessages: [ + /Native manifest .+ has name property renamed_name_mismatch \(expected renamed\.echo\)/, + /No such native application renamed\.echo/, + ], + }); +}); + +add_task(async function test_invalid_manifest_type_not_stdio() { + await testBrokenApp({ + appname: "nonstdio.echo", + expectedError: "No such native application nonstdio.echo", + expectedConsoleMessages: [ + /Native manifest .+ has type property pkcs11 \(expected stdio\)/, + /No such native application nonstdio\.echo/, + ], + }); +}); + +add_task(async function test_forward_slashes_in_path_works() { + await simpleTest("forwardslash.echo"); +}); + +// Test sendNativeMessage() +add_task(async function test_sendNativeMessage() { + async function background() { + let MSG = { test: "hello world" }; + + // Check error handling + await browser.test.assertRejects( + browser.runtime.sendNativeMessage("nonexistent", MSG), + "No such native application nonexistent", + "sendNativeMessage() to a nonexistent app failed" + ); + + // Check regular message exchange + let reply = await browser.runtime.sendNativeMessage("echo", MSG); + + let expected = JSON.stringify(MSG); + let received = JSON.stringify(reply); + browser.test.assertEq(expected, received, "Received echoed native message"); + + browser.test.sendMessage("finished"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("finished"); + + // With sendNativeMessage(), the subprocess should be disconnected + // after exchanging a single message. + await waitForSubprocessExit(); + + await extension.unload(); +}); + +// Test calling Port.disconnect() +add_task(async function test_disconnect() { + function background() { + let port = browser.runtime.connectNative("echo"); + port.onMessage.addListener((msg, msgPort) => { + browser.test.assertEq( + port, + msgPort, + "onMessage handler should receive the port as the second argument" + ); + browser.test.sendMessage("message", msg); + }); + port.onDisconnect.addListener(msgPort => { + browser.test.fail("onDisconnect should not be called for disconnect()"); + }); + browser.test.onMessage.addListener((what, payload) => { + if (what == "send") { + if (payload._json) { + let json = payload._json; + payload.toJSON = () => json; + delete payload._json; + } + port.postMessage(payload); + } else if (what == "disconnect") { + try { + port.disconnect(); + browser.test.assertThrows( + () => port.postMessage("void"), + "Attempt to postMessage on disconnected port" + ); + browser.test.sendMessage("disconnect-result", { success: true }); + } catch (err) { + browser.test.sendMessage("disconnect-result", { + success: false, + errmsg: err.message, + }); + } + } + }); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage("send", "test"); + let response = await extension.awaitMessage("message"); + equal(response, "test", "Echoed a string"); + + let procCount = await getSubprocessCount(); + equal(procCount, 1, "subprocess is running"); + + extension.sendMessage("disconnect"); + response = await extension.awaitMessage("disconnect-result"); + equal(response.success, true, "disconnect succeeded"); + + info("waiting for subprocess to exit"); + await waitForSubprocessExit(); + procCount = await getSubprocessCount(); + equal(procCount, 0, "subprocess is no longer running"); + + extension.sendMessage("disconnect"); + response = await extension.awaitMessage("disconnect-result"); + equal(response.success, true, "second call to disconnect silently ignored"); + + await extension.unload(); +}); + +// Test the limit on message size for writing +add_task(async function test_write_limit() { + Services.prefs.setIntPref(PREF_MAX_WRITE, 10); + function clearPref() { + Services.prefs.clearUserPref(PREF_MAX_WRITE); + } + registerCleanupFunction(clearPref); + + function background() { + const PAYLOAD = "0123456789A"; + let port = browser.runtime.connectNative("echo"); + try { + port.postMessage(PAYLOAD); + browser.test.sendMessage("result", null); + } catch (ex) { + browser.test.sendMessage("result", ex.message); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + + let errmsg = await extension.awaitMessage("result"); + notEqual( + errmsg, + null, + "native postMessage() failed for overly large message" + ); + + await extension.unload(); + await waitForSubprocessExit(); + + clearPref(); +}); + +// Test the limit on message size for reading +add_task(async function test_read_limit() { + Services.prefs.setIntPref(PREF_MAX_READ, 10); + function clearPref() { + Services.prefs.clearUserPref(PREF_MAX_READ); + } + registerCleanupFunction(clearPref); + + function background() { + const PAYLOAD = "0123456789A"; + let port = browser.runtime.connectNative("echo"); + port.onDisconnect.addListener(msgPort => { + browser.test.assertEq( + port, + msgPort, + "onDisconnect handler should receive the port as the first argument" + ); + browser.test.assertEq( + "Native application tried to send a message of 13 bytes, which exceeds the limit of 10 bytes.", + port.error && port.error.message + ); + browser.test.sendMessage("result", "disconnected"); + }); + port.onMessage.addListener(msg => { + browser.test.sendMessage("result", "message"); + }); + port.postMessage(PAYLOAD); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + + let result = await extension.awaitMessage("result"); + equal( + result, + "disconnected", + "native port disconnected on receiving large message" + ); + + await extension.unload(); + await waitForSubprocessExit(); + + clearPref(); +}); + +// Test that an extension without the nativeMessaging permission cannot +// use native messaging. +add_task(async function test_ext_permission() { + function background() { + browser.test.assertEq( + chrome.runtime.connectNative, + undefined, + "chrome.runtime.connectNative does not exist without nativeMessaging permission" + ); + browser.test.assertEq( + browser.runtime.connectNative, + undefined, + "browser.runtime.connectNative does not exist without nativeMessaging permission" + ); + browser.test.assertEq( + chrome.runtime.sendNativeMessage, + undefined, + "chrome.runtime.sendNativeMessage does not exist without nativeMessaging permission" + ); + browser.test.assertEq( + browser.runtime.sendNativeMessage, + undefined, + "browser.runtime.sendNativeMessage does not exist without nativeMessaging permission" + ); + browser.test.sendMessage("finished"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: {}, + }); + + await extension.startup(); + await extension.awaitMessage("finished"); + await extension.unload(); +}); + +// Test that an extension that is not listed in allowed_extensions for +// a native application cannot use that application. +add_task(async function test_app_permission() { + await testBrokenApp({ + extensionId: "@id-that-is-not-in-the-allowed_extensions-list", + appname: "echo", + expectedError: "No such native application echo", + expectedConsoleMessages: [ + /This extension does not have permission to use native manifest .+echo\.json/, + /No such native application echo/, + ], + }); +}); + +// Test that the command-line arguments and working directory for the +// native application are as expected. +add_task(async function test_child_process() { + function background() { + let port = browser.runtime.connectNative("info"); + port.onMessage.addListener(msg => { + browser.test.sendMessage("result", msg); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + + let msg = await extension.awaitMessage("result"); + equal(msg.args.length, 3, "Received two command line arguments"); + equal( + msg.args[1], + getPath("info.json"), + "Command line argument is the path to the native host manifest" + ); + equal( + msg.args[2], + ID, + "Second command line argument is the ID of the calling extension" + ); + equal( + msg.cwd.replace(/^\/private\//, "/"), + PathUtils.join(tmpDir.path, TYPE_SLUG), + "Working directory is the directory containing the native appliation" + ); + + let exitPromise = waitForSubprocessExit(); + await extension.unload(); + await exitPromise; +}); + +add_task(async function test_stderr() { + function background() { + let port = browser.runtime.connectNative("stderr"); + port.onDisconnect.addListener(msgPort => { + browser.test.assertEq( + port, + msgPort, + "onDisconnect handler should receive the port as the first argument" + ); + browser.test.assertEq( + null, + port.error, + "Normal application exit is not an error" + ); + browser.test.sendMessage("finished"); + }); + } + + let { messages } = await promiseConsoleOutput(async function () { + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("finished"); + await extension.unload(); + + await waitForSubprocessExit(); + }); + + let lines = STDERR_LINES.map(line => + messages.findIndex(msg => msg.message.includes(line)) + ); + notEqual(lines[0], -1, "Saw first line of stderr output on the console"); + notEqual(lines[1], -1, "Saw second line of stderr output on the console"); + notEqual( + lines[0], + lines[1], + "Stderr output lines are separated in the console" + ); +}); + +// Test that calling connectNative() multiple times works +// (see bug 1313980 for a previous regression in this area) +add_task(async function test_multiple_connects() { + async function background() { + function once() { + return new Promise(resolve => { + let MSG = "hello"; + let port = browser.runtime.connectNative("echo"); + + port.onMessage.addListener(msg => { + browser.test.assertEq(MSG, msg, "Got expected message back"); + port.disconnect(); + resolve(); + }); + port.postMessage(MSG); + }); + } + + await once(); + await once(); + browser.test.notifyPass("multiple-connect"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("multiple-connect"); + await extension.unload(); +}); + +// Test that native messaging is always rejected on content scripts +add_task(async function test_connect_native_from_content_script() { + async function testScript() { + let port = browser.runtime.connectNative("echo"); + port.onDisconnect.addListener(msgPort => { + browser.test.assertEq( + port, + msgPort, + "onDisconnect handler should receive the port as the first argument" + ); + browser.test.assertEq( + "An unexpected error occurred", + port.error && port.error.message + ); + browser.test.sendMessage("result", "disconnected"); + }); + port.onMessage.addListener(msg => { + browser.test.sendMessage("result", "message"); + }); + port.postMessage({ test: "test" }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + run_at: "document_end", + js: ["test.js"], + matches: ["http://example.com/dummy"], + }, + ], + browser_specific_settings: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + files: { + "test.js": testScript, + }, + }); + + await extension.startup(); + + const page = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + + let result = await extension.awaitMessage("result"); + equal(result, "disconnected", "connectNative() failed from content script"); + + await page.close(); + await extension.unload(); + + let procCount = await getSubprocessCount(); + equal(procCount, 0, "No child process was started"); +}); + +// Testing native app messaging against idle timeout. +async function startupExtensionAndRequestPermission() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + optional_permissions: ["nativeMessaging"], + background: { persistent: false }, + }, + async background() { + browser.runtime.onSuspend.addListener(() => { + browser.test.sendMessage("bgpage:suspending"); + }); + + let port; + browser.test.onMessage.addListener(async (msg, ...args) => { + switch (msg) { + case "request-permission": { + await browser.permissions.request({ + permissions: ["nativeMessaging"], + }); + break; + } + case "delayedecho-sendmessage": { + browser.runtime + .sendNativeMessage("delayedecho", args[0]) + .then(msg => + browser.test.sendMessage( + `delayedecho-sendmessage:got-reply`, + msg + ) + ); + break; + } + case "connectNative": { + if (port) { + browser.test.fail(`Unexpected already connected NativeApp port`); + } else { + port = browser.runtime.connectNative("echo"); + } + break; + } + case "disconnectNative": { + if (!port) { + browser.test.fail(`Unexpected undefined NativeApp port`); + } + port?.disconnect(); + break; + } + default: + browser.test.fail(`Got an unexpected test message: ${msg}`); + } + + browser.test.sendMessage(`${msg}:done`); + }); + browser.test.sendMessage("bg:ready"); + }, + }); + await extension.startup(); + await extension.awaitMessage("bg:ready"); + const contextId = extension.extension.backgroundContext.contextId; + notEqual(contextId, undefined, "Got a contextId for the background context"); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request-permission"); + await extension.awaitMessage("request-permission:done"); + }); + + return { extension, contextId }; +} + +async function expectTerminateBackgroundToResetIdle({ extension, contextId }) { + info("Wait for hasActiveNativeAppPorts to become true"); + await TestUtils.waitForCondition( + () => extension.extension.backgroundContext, + "Parent proxy context should be active" + ); + + await TestUtils.waitForCondition( + () => extension.extension.backgroundContext?.hasActiveNativeAppPorts, + "Parent proxy context should have active native app ports tracked" + ); + + clearHistograms(); + assertHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT); + assertKeyedHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID); + + info("Trigger background script idle timeout and expect to be reset"); + const promiseResetIdle = promiseExtensionEvent( + extension, + "background-script-reset-idle" + ); + await extension.terminateBackground(); + info("Wait for 'background-script-reset-idle' event to be emitted"); + await promiseResetIdle; + equal( + extension.extension.backgroundContext.contextId, + contextId, + "Initial background context is still available as expected" + ); + + assertHistogramCategoryNotEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT, { + category: "reset_nativeapp", + categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + }); + + assertHistogramCategoryNotEmpty( + WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID, + { + keyed: true, + key: extension.id, + category: "reset_nativeapp", + categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + } + ); +} + +async function testSendNativeMessage({ extension, contextId }) { + extension.sendMessage("delayedecho-sendmessage", "delayed-echo"); + await extension.awaitMessage("delayedecho-sendmessage:done"); + + await expectTerminateBackgroundToResetIdle({ extension, contextId }); + + // We expect exactly two replies (one for the previous queued message + // and one more for the last message sent right above). + equal( + await extension.awaitMessage("delayedecho-sendmessage:got-reply"), + "delayed-echo", + "Got the expected reply for the first message sent" + ); + + await TestUtils.waitForCondition( + () => !extension.extension.backgroundContext?.hasActiveNativeAppPorts, + "Parent proxy context should not have any active native app ports tracked" + ); + + info("terminating the background script"); + await extension.terminateBackground(); + info("wait for runtime.onSuspend listener to have been called"); + await extension.awaitMessage("bgpage:suspending"); +} + +async function testConnectNative({ extension, contextId }) { + extension.sendMessage("connectNative"); + await extension.awaitMessage("connectNative:done"); + + await expectTerminateBackgroundToResetIdle({ extension, contextId }); + + // Disconnect the NativeApp and confirm that the background page + // will be suspending as expected. + extension.sendMessage("disconnectNative"); + await extension.awaitMessage("disconnectNative:done"); + + await TestUtils.waitForCondition( + () => !extension.extension.backgroundContext?.hasActiveNativeAppPorts, + "Parent proxy context should not have any active native app ports tracked" + ); + + info("terminating the background script"); + await extension.terminateBackground(); + info("wait for runtime.onSuspend listener to have been called"); + await extension.awaitMessage("bgpage:suspending"); +} + +add_task( + { + pref_set: [["extensions.eventPages.enabled", true]], + }, + async function test_pending_sendNativeMessageReply_resets_bgscript_idle_timeout() { + const { extension, contextId } = + await startupExtensionAndRequestPermission(); + await testSendNativeMessage({ extension, contextId }); + await waitForSubprocessExit(); + await extension.unload(); + } +); + +add_task( + { + pref_set: [["extensions.eventPages.enabled", true]], + }, + async function test_open_connectNativePort_resets_bgscript_idle_timeout() { + const { extension, contextId } = + await startupExtensionAndRequestPermission(); + await testConnectNative({ extension, contextId }); + await waitForSubprocessExit(); + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_perf.js b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_perf.js new file mode 100644 index 0000000000..1771fec124 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_perf.js @@ -0,0 +1,131 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const MAX_ROUND_TRIP_TIME_MS = + AppConstants.DEBUG || AppConstants.ASAN ? 60 : 30; +const MAX_RETRIES = 5; + +const ECHO_BODY = String.raw` + import struct + import sys + + stdin = getattr(sys.stdin, 'buffer', sys.stdin) + stdout = getattr(sys.stdout, 'buffer', sys.stdout) + + while True: + rawlen = stdin.read(4) + if len(rawlen) == 0: + sys.exit(0) + + msglen = struct.unpack('@I', rawlen)[0] + msg = stdin.read(msglen) + + stdout.write(struct.pack('@I', msglen)) + stdout.write(msg) +`; + +const SCRIPTS = [ + { + name: "echo", + description: "A native app that echoes back messages it receives", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + }, +]; + +add_task(async function setup() { + await setupHosts(SCRIPTS); +}); + +add_task(async function test_round_trip_perf() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.onMessage.addListener(msg => { + if (msg != "run-tests") { + return; + } + + let port = browser.runtime.connectNative("echo"); + + function next() { + port.postMessage({ + Lorem: { + ipsum: { + dolor: [ + "sit amet", + "consectetur adipiscing elit", + "sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + ], + "Ut enim": [ + "ad minim veniam", + "quis nostrud exercitation ullamco", + "laboris nisi ut aliquip ex ea commodo consequat.", + ], + Duis: [ + "aute irure dolor in reprehenderit in", + "voluptate velit esse cillum dolore eu", + "fugiat nulla pariatur.", + ], + Excepteur: [ + "sint occaecat cupidatat non proident", + "sunt in culpa qui officia deserunt", + "mollit anim id est laborum.", + ], + }, + }, + }); + } + + const COUNT = 1000; + let now; + function finish() { + let roundTripTime = (Date.now() - now) / COUNT; + + port.disconnect(); + browser.test.sendMessage("result", roundTripTime); + } + + let count = 0; + port.onMessage.addListener(() => { + if (count == 0) { + // Skip the first round, since it includes the time it takes + // the app to start up. + now = Date.now(); + } + + if (count++ <= COUNT) { + next(); + } else { + finish(); + } + }); + + next(); + }); + }, + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + + let roundTripTime = Infinity; + for ( + let i = 0; + i < MAX_RETRIES && roundTripTime > MAX_ROUND_TRIP_TIME_MS; + i++ + ) { + extension.sendMessage("run-tests"); + roundTripTime = await extension.awaitMessage("result"); + } + + await extension.unload(); + + Assert.lessOrEqual( + roundTripTime, + MAX_ROUND_TRIP_TIME_MS, + `Expected round trip time (${roundTripTime}ms) to be less than ${MAX_ROUND_TRIP_TIME_MS}ms` + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_unresponsive.js b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_unresponsive.js new file mode 100644 index 0000000000..5b30a06a23 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_unresponsive.js @@ -0,0 +1,85 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const WONTDIE_BODY = String.raw` + import signal + import struct + import sys + import time + + signal.signal(signal.SIGTERM, signal.SIG_IGN) + + stdin = getattr(sys.stdin, 'buffer', sys.stdin) + stdout = getattr(sys.stdout, 'buffer', sys.stdout) + + def spin(): + while True: + try: + signal.pause() + except AttributeError: + time.sleep(5) + + while True: + rawlen = stdin.read(4) + if len(rawlen) == 0: + spin() + + msglen = struct.unpack('@I', rawlen)[0] + msg = stdin.read(msglen) + + stdout.write(struct.pack('@I', msglen)) + stdout.write(msg) +`; + +const SCRIPTS = [ + { + name: "wontdie", + description: + "a native app that does not exit when stdin closes or on SIGTERM", + script: WONTDIE_BODY.replace(/^ {2}/gm, ""), + }, +]; + +add_task(async function setup() { + await setupHosts(SCRIPTS); +}); + +// Test that an unresponsive native application still gets killed eventually +add_task(async function test_unresponsive_native_app() { + // XXX expose GRACEFUL_SHUTDOWN_TIME as a pref and reduce it + // just for this test? + + function background() { + let port = browser.runtime.connectNative("wontdie"); + + const MSG = "echo me"; + // bounce a message to make sure the process actually starts + port.onMessage.addListener(msg => { + browser.test.assertEq(msg, MSG, "Received echoed message"); + browser.test.sendMessage("ready"); + }); + port.postMessage(MSG); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + let procCount = await getSubprocessCount(); + equal(procCount, 1, "subprocess is running"); + + let exitPromise = waitForSubprocessExit(); + await extension.unload(); + await exitPromise; + + procCount = await getSubprocessCount(); + equal(procCount, 0, "subprocess was successfully killed"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_networkStatus.js b/toolkit/components/extensions/test/xpcshell/test_ext_networkStatus.js new file mode 100644 index 0000000000..5d94a1534d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_networkStatus.js @@ -0,0 +1,209 @@ +"use strict"; + +const Cm = Components.manager; + +const uuidGenerator = Services.uuid; + +AddonTestUtils.init(this); + +var mockNetworkStatusService = { + contractId: "@mozilla.org/network/network-link-service;1", + + _mockClassId: uuidGenerator.generateUUID(), + + _originalClassId: "", + + QueryInterface: ChromeUtils.generateQI(["nsINetworkLinkService"]), + + createInstance(iiD) { + return this.QueryInterface(iiD); + }, + + register() { + let registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar); + if (!registrar.isCIDRegistered(this._mockClassId)) { + this._originalClassId = registrar.contractIDToCID(this.contractId); + registrar.registerFactory( + this._mockClassId, + "Unregister after testing", + this.contractId, + this + ); + } + }, + + unregister() { + let registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar); + registrar.unregisterFactory(this._mockClassId, this); + registrar.registerFactory(this._originalClassId, "", this.contractId, null); + }, + + _isLinkUp: true, + _linkStatusKnown: false, + _linkType: Ci.nsINetworkLinkService.LINK_TYPE_UNKNOWN, + + get isLinkUp() { + return this._isLinkUp; + }, + + get linkStatusKnown() { + return this._linkStatusKnown; + }, + + setLinkStatus(status) { + switch (status) { + case "up": + this._isLinkUp = true; + this._linkStatusKnown = true; + this._networkID = "foo"; + break; + case "down": + this._isLinkUp = false; + this._linkStatusKnown = true; + this._linkType = Ci.nsINetworkLinkService.LINK_TYPE_UNKNOWN; + this._networkID = undefined; + break; + case "changed": + this._linkStatusKnown = true; + this._networkID = "foo"; + break; + case "unknown": + this._linkStatusKnown = false; + this._linkType = Ci.nsINetworkLinkService.LINK_TYPE_UNKNOWN; + this._networkID = undefined; + break; + } + Services.obs.notifyObservers(null, "network:link-status-changed", status); + }, + + get linkType() { + return this._linkType; + }, + + setLinkType(val) { + this._linkType = val; + this._linkStatusKnown = true; + this._isLinkUp = true; + this._networkID = "bar"; + Services.obs.notifyObservers( + null, + "network:link-type-changed", + this._linkType + ); + }, + + get networkID() { + return this._networkID; + }, +}; + +// nsINetworkLinkService is not directly testable. With the mock service above, +// we just exercise a couple small things here to validate the api works somewhat. +add_task(async function test_networkStatus() { + mockNetworkStatusService.register(); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id: "networkstatus@tests.mozilla.org" }, + }, + permissions: ["networkStatus"], + }, + isPrivileged: true, + async background() { + browser.networkStatus.onConnectionChanged.addListener(async details => { + browser.test.log(`connection status ${JSON.stringify(details)}`); + browser.test.sendMessage("connect-changed", { + details, + linkInfo: await browser.networkStatus.getLinkInfo(), + }); + }); + browser.test.sendMessage( + "linkdata", + await browser.networkStatus.getLinkInfo() + ); + }, + }); + + async function test(expected, change) { + if (change.status) { + info(`test link change status to ${change.status}`); + mockNetworkStatusService.setLinkStatus(change.status); + } else if (change.link) { + info(`test link change type to ${change.link}`); + mockNetworkStatusService.setLinkType(change.link); + } + let { details, linkInfo } = await extension.awaitMessage("connect-changed"); + equal(details.type, expected.type, "network type is correct"); + equal(details.status, expected.status, `network status is correct`); + equal(details.id, expected.id, "network id"); + Assert.deepEqual( + linkInfo, + details, + "getLinkInfo should resolve to the same details received from onConnectionChanged" + ); + } + + await extension.startup(); + + let data = await extension.awaitMessage("linkdata"); + equal(data.type, "unknown", "network type is unknown"); + equal(data.status, "unknown", `network status is ${data.status}`); + equal(data.id, undefined, "network id"); + + await test( + { type: "unknown", status: "up", id: "foo" }, + { status: "changed" } + ); + + await test( + { type: "wifi", status: "up", id: "bar" }, + { link: Ci.nsINetworkLinkService.LINK_TYPE_WIFI } + ); + + await test({ type: "unknown", status: "down" }, { status: "down" }); + + await test({ type: "unknown", status: "unknown" }, { status: "unknown" }); + + await extension.unload(); + mockNetworkStatusService.unregister(); +}); + +add_task( + { + // Some builds (e.g. thunderbird) have experiments enabled by default. + pref_set: [["extensions.experiments.enabled", false]], + }, + async function test_networkStatus_permission() { + let extension = ExtensionTestUtils.loadExtension({ + temporarilyInstalled: true, + isPrivileged: false, + manifest: { + browser_specific_settings: { + gecko: { id: "networkstatus-permission@tests.mozilla.org" }, + }, + permissions: ["networkStatus"], + }, + }); + ExtensionTestUtils.failOnSchemaWarnings(false); + let { messages } = await promiseConsoleOutput(async () => { + await Assert.rejects( + extension.startup(), + /Using the privileged permission/, + "Startup failed with privileged permission" + ); + }); + ExtensionTestUtils.failOnSchemaWarnings(true); + AddonTestUtils.checkMessages( + messages, + { + expected: [ + { + message: + /Using the privileged permission 'networkStatus' requires a privileged add-on/, + }, + ], + }, + true + ); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_notifications_incognito.js b/toolkit/components/extensions/test/xpcshell/test_ext_notifications_incognito.js new file mode 100644 index 0000000000..fda60c3a82 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_notifications_incognito.js @@ -0,0 +1,105 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const ALERTS_SERVICE_CONTRACT_ID = "@mozilla.org/alerts-service;1"; + +const createdAlerts = []; + +const mockAlertsService = { + showPersistentNotification(persistentData, alert, alertListener) { + this.showAlert(alert, alertListener); + }, + + showAlert(alert, listener) { + createdAlerts.push(alert); + listener.observe(null, "alertfinished", alert.cookie); + }, + + showAlertNotification( + imageUrl, + title, + text, + textClickable, + cookie, + alertListener, + name, + dir, + lang, + data, + principal, + privateBrowsing + ) { + this.showAlert({ cookie, title, text, privateBrowsing }, alertListener); + }, + + closeAlert(name) { + // This mock immediately close the alert on show, so this is empty. + }, + + QueryInterface: ChromeUtils.generateQI(["nsIAlertsService"]), + + createInstance(iid) { + return this.QueryInterface(iid); + }, +}; + +const registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar); +registrar.registerFactory( + Components.ID("{173a036a-d678-4415-9cff-0baff6bfe554}"), + "alerts service", + ALERTS_SERVICE_CONTRACT_ID, + mockAlertsService +); + +add_task(async function test_notification_privateBrowsing_flag() { + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["notifications"], + }, + files: { + "page.html": `<meta charset="utf-8"><script src="page.js"></script>`, + async "page.js"() { + let closedPromise = new Promise(resolve => { + browser.notifications.onClosed.addListener(resolve); + }); + let createdId = await browser.notifications.create("notifid", { + type: "basic", + title: "titl", + message: "msg", + }); + let closedId = await closedPromise; + browser.test.assertEq(createdId, closedId, "ID of closed notification"); + browser.test.assertEq( + "{}", + JSON.stringify(await browser.notifications.getAll()), + "no notifications left" + ); + browser.test.sendMessage("notification_closed"); + }, + }, + }); + await extension.startup(); + + async function checkPrivateBrowsingFlag(privateBrowsing) { + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/page.html`, + { extension, remote: extension.extension.remote, privateBrowsing } + ); + await extension.awaitMessage("notification_closed"); + await contentPage.close(); + + Assert.equal(createdAlerts.length, 1, "expected one alert"); + let notification = createdAlerts.shift(); + Assert.equal(notification.cookie, "notifid", "notification id"); + Assert.equal(notification.title, "titl", "notification title"); + Assert.equal(notification.text, "msg", "notification text"); + Assert.equal(notification.privateBrowsing, privateBrowsing, "pbm flag"); + } + + await checkPrivateBrowsingFlag(false); + await checkPrivateBrowsingFlag(true); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_notifications_unsupported.js b/toolkit/components/extensions/test/xpcshell/test_ext_notifications_unsupported.js new file mode 100644 index 0000000000..1213ae4f23 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_notifications_unsupported.js @@ -0,0 +1,41 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const ALERTS_SERVICE_CONTRACT_ID = "@mozilla.org/alerts-service;1"; +const registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar); +registrar.registerFactory( + Components.ID("{18f25bb4-ab12-4e24-b3b0-69215056160b}"), + "unsupported alerts service", + ALERTS_SERVICE_CONTRACT_ID, + {} // This object lacks an implementation of nsIAlertsService. +); + +add_task(async function test_notification_unsupported_backend() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["notifications"], + }, + async background() { + let closedPromise = new Promise(resolve => { + browser.notifications.onClosed.addListener(resolve); + }); + let createdId = await browser.notifications.create("notifid", { + type: "basic", + title: "titl", + message: "msg", + }); + let closedId = await closedPromise; + browser.test.assertEq(createdId, closedId, "ID of closed notification"); + browser.test.assertEq( + "{}", + JSON.stringify(await browser.notifications.getAll()), + "no notifications left" + ); + browser.test.sendMessage("notification_closed"); + }, + }); + await extension.startup(); + await extension.awaitMessage("notification_closed"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_onmessage_removelistener.js b/toolkit/components/extensions/test/xpcshell/test_ext_onmessage_removelistener.js new file mode 100644 index 0000000000..c6d258c96c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_onmessage_removelistener.js @@ -0,0 +1,30 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function backgroundScript() { + function listener() { + browser.test.notifyFail("listener should not be invoked"); + } + + browser.runtime.onMessage.addListener(listener); + browser.runtime.onMessage.removeListener(listener); + browser.runtime.sendMessage("hello"); + + // Make sure that, if we somehow fail to remove the listener, then we'll run + // the listener before the test is marked as passing. + setTimeout(function () { + browser.test.notifyPass("onmessage_removelistener"); + }, 0); +} + +let extensionData = { + background: backgroundScript, +}; + +add_task(async function test_contentscript() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("onmessage_removelistener"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permission_warnings.js b/toolkit/components/extensions/test/xpcshell/test_ext_permission_warnings.js new file mode 100644 index 0000000000..ab3f20f12e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permission_warnings.js @@ -0,0 +1,870 @@ +"use strict"; + +let { ExtensionTestCommon } = ChromeUtils.importESModule( + "resource://testing-common/ExtensionTestCommon.sys.mjs" +); + +const { + PERMISSION_L10N, + PERMISSION_L10N_ID_OVERRIDES, + PERMISSIONS_WITH_MESSAGE, + permissionToL10nId, +} = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissionMessages.sys.mjs" +); + +const EXTENSION_L10N_PATHS = [ + "toolkit/global/extensions.ftl", + "toolkit/global/extensionPermissions.ftl", + "branding/brand.ftl", +]; + +// For Android, these strings are only used in tests. In the actual UI, the +// warnings are in Android-Components, as explained in bug 1671453. +const l10n = new Localization(EXTENSION_L10N_PATHS, true); + +// nativeMessaging is in PRIVILEGED_PERMS on Android. +const IS_NATIVE_MESSAGING_PRIVILEGED = AppConstants.platform == "android"; + +const { createAppInfo } = AddonTestUtils; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.usePrivilegedSignatures = id => id.startsWith("privileged"); +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +async function getManifestPermissions(extensionData) { + let extension = ExtensionTestCommon.generate(extensionData); + // Some tests contain invalid permissions; ignore the warnings about their invalidity. + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.loadManifest(); + ExtensionTestUtils.failOnSchemaWarnings(true); + let result = extension.manifestPermissions; + + if (extension.manifest.manifest_version >= 3) { + // In MV3, host permissions are optional by default. + deepEqual(result.origins, [], "No origins by default in MV3"); + let optional = extension.manifestOptionalPermissions; + deepEqual(optional.permissions, [], "No tests use optional_permissions"); + result.origins = optional.origins; + } + + await extension.cleanupGeneratedFile(); + return result; +} + +function getPermissionWarnings(permissions, options) { + let { msgs } = ExtensionData.formatPermissionStrings( + { permissions }, + options + ); + return msgs; +} + +async function getPermissionWarningsForUpdate( + oldExtensionData, + newExtensionData +) { + let oldPerms = await getManifestPermissions(oldExtensionData); + let newPerms = await getManifestPermissions(newExtensionData); + let difference = Extension.comparePermissions(oldPerms, newPerms); + return getPermissionWarnings(difference); +} + +// Tests that ExtensionData.formatPermissionStrings supports customized mappings +// between permission names and related localized strings. Also test registering +// additional fluent files so ExtensionData.formatPermissionStrings works for +// permissions of APIs defined outside of toolkit. +add_task(async function customized_permission_keys_mapping() { + // Mock a fluent file. + const l10nReg = L10nRegistry.getInstance(); + const source = L10nFileSource.createMock( + "mock", + "app", + ["en-US"], + "/localization/", + [ + { + path: "/localization/mock.ftl", + source: ` +webext-perms-description-test-downloads = Custom description for the downloads permission + +webext-perms-description-test-proxy = Custom description for the proxy permission +`, + }, + ] + ); + l10nReg.registerSources([source]); + + // Add the mocked fluent file to PERMISSION_L10N and override downloads and + // proxy permission to use the alternative string. In a real world use-case, + // this would be used to be able to change a localized string after release + // or add non-toolkit fluent files with permission strings of APIs defined + // outside of toolkit. + PERMISSION_L10N.addResourceIds(["mock.ftl"]); + PERMISSION_L10N_ID_OVERRIDES.set( + "downloads", + "webext-perms-description-test-downloads" + ); + PERMISSION_L10N_ID_OVERRIDES.set( + "proxy", + "webext-perms-description-test-proxy" + ); + + let mockCleanup = () => { + // Make sure cleanup is executed only once. + mockCleanup = () => {}; + + // Remove the permission string mapping. + PERMISSION_L10N.removeResourceIds(["mock.ftl"]); + PERMISSION_L10N_ID_OVERRIDES.delete("downloads"); + PERMISSION_L10N_ID_OVERRIDES.delete("proxy"); + l10nReg.removeSources(["mock"]); + }; + registerCleanupFunction(mockCleanup); + + const manifest = { + permissions: ["downloads", "proxy"], + }; + + const manifestPermissions = await getManifestPermissions({ manifest }); + let expectedWarnings = [ + "Custom description for the downloads permission", + "Custom description for the proxy permission", + ]; + const warnings = getPermissionWarnings(manifestPermissions); + deepEqual( + warnings, + expectedWarnings, + "Got the expected string from customized permission mapping" + ); + + mockCleanup(); +}); + +// Tests that permission description data is internally consistent +add_task(async function permission_message_consistence() { + for (let perm of PERMISSIONS_WITH_MESSAGE) { + ok(permissionToL10nId(perm), `Message is provided for ${perm}`); + } + for (let [perm] of PERMISSION_L10N_ID_OVERRIDES) { + ok(permissionToL10nId(perm), `Message is provided for ${perm}`); + } +}); + +// Tests that the expected permission warnings are generated for various +// combinations of host permissions. +add_task(async function host_permissions() { + let permissionTestCases = [ + { + description: "Empty manifest without permissions", + manifest: {}, + expectedOrigins: [], + expectedWarnings: [], + }, + { + description: "Invalid match patterns", + manifest: { + permissions: [ + "https:///", + "https://", + "https://*", + "about:ugh", + "about:*", + "about://*/", + "resource://*/", + ], + }, + expectedOrigins: [], + expectedWarnings: [], + }, + { + description: "moz-extension: permissions", + manifest: { + permissions: ["moz-extension://*/*", "moz-extension://uuid/"], + }, + // moz-extension:-origin does not appear in the permission list, + // but it is implicitly granted anyway. + expectedOrigins: [], + expectedWarnings: [], + }, + { + description: "*. host permission", + manifest: { + // This permission is rejected by the manifest and ignored. + permissions: ["http://*./"], + }, + expectedOrigins: [], + expectedWarnings: [], + }, + { + description: "<all_urls> permission", + manifest: { + permissions: ["<all_urls>"], + }, + expectedOrigins: ["<all_urls>"], + expectedWarnings: [ + l10n.formatValueSync("webext-perms-host-description-all-urls"), + ], + }, + { + description: "file: permissions", + manifest: { + permissions: ["file://*/"], + }, + expectedOrigins: ["file://*/"], + expectedWarnings: [ + l10n.formatValueSync("webext-perms-host-description-all-urls"), + ], + }, + { + description: "http: permission", + manifest: { + permissions: ["http://*/"], + }, + expectedOrigins: ["http://*/"], + expectedWarnings: [ + l10n.formatValueSync("webext-perms-host-description-all-urls"), + ], + }, + { + description: "*://*/ permission", + manifest: { + permissions: ["*://*/"], + }, + expectedOrigins: ["*://*/"], + expectedWarnings: [ + l10n.formatValueSync("webext-perms-host-description-all-urls"), + ], + }, + { + description: "content_script[*].matches", + manifest: { + content_scripts: [ + { + // This test uses the manifest file without loading the content script + // file, so we can use a non-existing dummy file. + js: ["dummy.js"], + matches: ["https://*/"], + }, + ], + }, + expectedOrigins: ["https://*/"], + expectedWarnings: [ + l10n.formatValueSync("webext-perms-host-description-all-urls"), + ], + }, + { + description: "A few host permissions", + manifest: { + permissions: ["http://a/", "http://*.b/", "http://c/*"], + }, + expectedOrigins: ["http://a/", "http://*.b/", "http://c/*"], + expectedWarnings: l10n.formatValuesSync([ + // Wildcard hosts take precedence in the permission list. + { + id: "webext-perms-host-description-wildcard", + args: { domain: "b" }, + }, + { + id: "webext-perms-host-description-one-site", + args: { domain: "a" }, + }, + { + id: "webext-perms-host-description-one-site", + args: { domain: "c" }, + }, + ]), + }, + { + description: "many host permission", + manifest: { + permissions: [ + "http://a/", + "http://b/", + "http://c/", + "http://d/", + "http://e/*", + "http://*.1/", + "http://*.2/", + "http://*.3/", + "http://*.4/", + ], + }, + expectedOrigins: [ + "http://a/", + "http://b/", + "http://c/", + "http://d/", + "http://e/*", + "http://*.1/", + "http://*.2/", + "http://*.3/", + "http://*.4/", + ], + expectedWarnings: l10n.formatValuesSync([ + // Wildcard hosts take precedence in the permission list. + { + id: "webext-perms-host-description-wildcard", + args: { domain: "1" }, + }, + { + id: "webext-perms-host-description-wildcard", + args: { domain: "2" }, + }, + { + id: "webext-perms-host-description-wildcard", + args: { domain: "3" }, + }, + { + id: "webext-perms-host-description-wildcard", + args: { domain: "4" }, + }, + { + id: "webext-perms-host-description-one-site", + args: { domain: "a" }, + }, + { + id: "webext-perms-host-description-one-site", + args: { domain: "b" }, + }, + { + id: "webext-perms-host-description-one-site", + args: { domain: "c" }, + }, + { + id: "webext-perms-host-description-too-many-sites", + args: { domainCount: 2 }, + }, + ]), + options: { + collapseOrigins: true, + }, + }, + { + description: + "many host permissions without item limit in the warning list", + manifest: { + permissions: [ + "http://a/", + "http://b/", + "http://c/", + "http://d/", + "http://e/*", + "http://*.1/", + "http://*.2/", + "http://*.3/", + "http://*.4/", + "http://*.5/", + ], + }, + expectedOrigins: [ + "http://a/", + "http://b/", + "http://c/", + "http://d/", + "http://e/*", + "http://*.1/", + "http://*.2/", + "http://*.3/", + "http://*.4/", + "http://*.5/", + ], + expectedWarnings: l10n.formatValuesSync([ + { id: "webext-perms-host-description-wildcard", args: { domain: "1" } }, + { id: "webext-perms-host-description-wildcard", args: { domain: "2" } }, + { id: "webext-perms-host-description-wildcard", args: { domain: "3" } }, + { id: "webext-perms-host-description-wildcard", args: { domain: "4" } }, + { id: "webext-perms-host-description-wildcard", args: { domain: "5" } }, + { id: "webext-perms-host-description-one-site", args: { domain: "a" } }, + { id: "webext-perms-host-description-one-site", args: { domain: "b" } }, + { id: "webext-perms-host-description-one-site", args: { domain: "c" } }, + { id: "webext-perms-host-description-one-site", args: { domain: "d" } }, + { id: "webext-perms-host-description-one-site", args: { domain: "e" } }, + ]), + }, + ]; + for (let manifest_version of [2, 3]) { + for (let { + description, + manifest, + expectedOrigins, + expectedWarnings, + options, + } of permissionTestCases) { + manifest = Object.assign({}, manifest, { manifest_version }); + if (manifest_version > 2) { + manifest.host_permissions = manifest.permissions; + manifest.permissions = []; + } + + let manifestPermissions = await getManifestPermissions({ manifest }); + + deepEqual( + manifestPermissions.origins, + expectedOrigins, + `Expected origins (${description})` + ); + deepEqual( + manifestPermissions.permissions, + [], + `Expected no non-host permissions (${description})` + ); + + let warnings = getPermissionWarnings(manifestPermissions, options); + deepEqual( + warnings, + expectedWarnings, + `Expected warnings (${description})` + ); + } + } +}); + +// Tests that the expected permission warnings are generated for a mix of host +// permissions and API permissions. +add_task(async function api_permissions() { + let manifestPermissions = await getManifestPermissions({ + isPrivileged: IS_NATIVE_MESSAGING_PRIVILEGED, + manifest: { + permissions: [ + "activeTab", + "webNavigation", + "tabs", + "nativeMessaging", + "http://x/", + "http://*.x/", + "http://*.tld/", + ], + }, + }); + + deepEqual( + manifestPermissions, + { + origins: ["http://x/", "http://*.x/", "http://*.tld/"], + permissions: ["activeTab", "webNavigation", "tabs", "nativeMessaging"], + }, + "Expected origins and permissions" + ); + + deepEqual( + getPermissionWarnings(manifestPermissions), + l10n.formatValuesSync([ + // Host permissions first, with wildcards on top. + { id: "webext-perms-host-description-wildcard", args: { domain: "x" } }, + { id: "webext-perms-host-description-wildcard", args: { domain: "tld" } }, + { id: "webext-perms-host-description-one-site", args: { domain: "x" } }, + // nativeMessaging permission warning first of all permissions. + "webext-perms-description-nativeMessaging", + // Other permissions in alphabetical order. + // Note: activeTab has no permission warning string. + "webext-perms-description-tabs", + "webext-perms-description-webNavigation", + ]), + "Expected warnings" + ); +}); + +add_task(async function nativeMessaging_permission() { + let manifestPermissions = await getManifestPermissions({ + // isPrivileged: false, by default. + manifest: { + permissions: ["nativeMessaging"], + }, + }); + + if (IS_NATIVE_MESSAGING_PRIVILEGED) { + // The behavior of nativeMessaging for unprivileged extensions on Android + // is covered in + // mobile/android/components/extensions/test/xpcshell/test_ext_native_messaging_permissions.js + deepEqual( + manifestPermissions, + { origins: [], permissions: [] }, + "nativeMessaging perm ignored for unprivileged extensions on Android" + ); + } else { + deepEqual( + manifestPermissions, + { origins: [], permissions: ["nativeMessaging"] }, + "nativeMessaging permission recognized for unprivileged extensions" + ); + } +}); + +add_task( + { pref_set: [["extensions.dnr.enabled", true]] }, + async function declarativeNetRequest_permission_with_warning() { + let manifestPermissions = await getManifestPermissions({ + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"], + }, + }); + + deepEqual( + manifestPermissions, + { + origins: [], + permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"], + }, + "Expected origins and permissions" + ); + + deepEqual( + getPermissionWarnings(manifestPermissions), + l10n.formatValuesSync([ + "webext-perms-description-declarativeNetRequest", + "webext-perms-description-declarativeNetRequestFeedback", + ]), + "Expected warnings" + ); + } +); + +add_task( + { pref_set: [["extensions.dnr.enabled", true]] }, + async function declarativeNetRequest_permission_without_warning() { + let manifestPermissions = await getManifestPermissions({ + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequestWithHostAccess"], + }, + }); + + deepEqual( + manifestPermissions, + { origins: [], permissions: ["declarativeNetRequestWithHostAccess"] }, + "Expected origins and permissions" + ); + + deepEqual(getPermissionWarnings(manifestPermissions), [], "No warnings"); + } +); + +// Tests that the expected permission warnings are generated for a mix of host +// permissions and API permissions, for a privileged extension that uses the +// mozillaAddons permission. +add_task(async function privileged_with_mozillaAddons() { + let manifestPermissions = await getManifestPermissions({ + isPrivileged: true, + manifest: { + permissions: [ + "mozillaAddons", + "mozillaAddons", + "mozillaAddons", + "resource://x/*", + "http://a/", + "about:reader*", + ], + }, + }); + deepEqual( + manifestPermissions, + { + origins: ["resource://x/*", "http://a/", "about:reader*"], + permissions: ["mozillaAddons"], + }, + "Expected origins and permissions for privileged add-on with mozillaAddons" + ); + + deepEqual( + getPermissionWarnings(manifestPermissions), + [l10n.formatValueSync("webext-perms-host-description-all-urls")], + "Expected warnings for privileged add-on with mozillaAddons permission." + ); +}); + +// Similar to the privileged_with_mozillaAddons test, except the test extension +// is unprivileged and not allowed to use the mozillaAddons permission. +add_task(async function unprivileged_with_mozillaAddons() { + let manifestPermissions = await getManifestPermissions({ + manifest: { + permissions: [ + "mozillaAddons", + "mozillaAddons", + "mozillaAddons", + "resource://x/*", + "http://a/", + "about:reader*", + ], + }, + }); + deepEqual( + manifestPermissions, + { + origins: ["http://a/"], + permissions: [], + }, + "Expected origins and permissions for unprivileged add-on with mozillaAddons" + ); + + deepEqual( + getPermissionWarnings(manifestPermissions), + [ + l10n.formatValueSync("webext-perms-host-description-one-site", { + domain: "a", + }), + ], + "Expected warnings for unprivileged add-on with mozillaAddons permission." + ); +}); + +// Tests that an update with less permissions has no warning. +add_task(async function update_drop_permission() { + let warnings = await getPermissionWarningsForUpdate( + { + manifest: { + permissions: ["<all_urls>", "https://a/", "http://b/"], + }, + }, + { + manifest: { + permissions: [ + "https://a/", + "http://b/", + "ftp://host_matching_all_urls/", + ], + }, + } + ); + deepEqual( + warnings, + [], + "An update with fewer permissions should not have any warnings" + ); +}); + +// Tests that an update that switches from "*://*/*" to "<all_urls>" does not +// result in additional permission warnings. +add_task(async function update_all_urls_permission() { + let warnings = await getPermissionWarningsForUpdate( + { + manifest: { + permissions: ["*://*/*"], + }, + }, + { + manifest: { + permissions: ["<all_urls>"], + }, + } + ); + deepEqual( + warnings, + [], + "An update from a wildcard host to <all_urls> should not have any warnings" + ); +}); + +// Tests that an update where a new permission whose domain overlaps with +// an existing permission does not result in additional permission warnings. +add_task(async function update_change_permissions() { + let warnings = await getPermissionWarningsForUpdate( + { + manifest: { + permissions: ["https://a/", "http://*.b/", "http://c/", "http://f/"], + }, + }, + { + manifest: { + permissions: [ + // (no new warning) Unchanged permission from old extension. + "https://a/", + // (no new warning) Different schemes, host should match "*.b" wildcard. + "ftp://ftp.b/", + "ws://ws.b/", + "wss://wss.b", + "https://https.b/", + "http://http.b/", + "*://*.b/", + "http://b/", + + // (expect warning) Wildcard was added. + "http://*.c/", + // (no new warning) file:-scheme, but host "f" is same as "http://f/". + "file://f/", + // (expect warning) New permission was added. + "proxy", + ], + }, + } + ); + deepEqual( + warnings, + l10n.formatValuesSync([ + { id: "webext-perms-host-description-wildcard", args: { domain: "c" } }, + "webext-perms-description-proxy", + ]), + "Expected permission warnings for new permissions only" + ); +}); + +// Tests that a privileged extension with the mozillaAddons permission can be +// updated without errors. +add_task(async function update_privileged_with_mozillaAddons() { + let warnings = await getPermissionWarningsForUpdate( + { + isPrivileged: true, + manifest: { + permissions: ["mozillaAddons", "resource://a/"], + }, + }, + { + isPrivileged: true, + manifest: { + permissions: ["mozillaAddons", "resource://a/", "resource://b/"], + }, + } + ); + deepEqual( + warnings, + [ + l10n.formatValueSync("webext-perms-host-description-one-site", { + domain: "b", + }), + ], + "Expected permission warnings for new host only" + ); +}); + +// Tests that an unprivileged extension cannot get privileged permissions +// through an update. +add_task(async function update_unprivileged_with_mozillaAddons() { + // Unprivileged + let warnings = await getPermissionWarningsForUpdate( + { + manifest: { + permissions: ["mozillaAddons", "resource://a/"], + }, + }, + { + manifest: { + permissions: ["mozillaAddons", "resource://a/", "resource://b/"], + }, + } + ); + deepEqual( + warnings, + [], + "resource:-scheme is unsupported for unprivileged extensions" + ); +}); + +// Tests that invalid permission warning for privileged permissions requested +// are not emitted for privileged extensions, only for unprivileged extensions. +add_task( + async function test_invalid_permission_warning_on_privileged_permission() { + await AddonTestUtils.promiseStartupManager(); + + const MANIFEST_WARNINGS = [ + "Reading manifest: Invalid extension permission: mozillaAddons", + "Reading manifest: Invalid extension permission: resource://x/", + "Reading manifest: Invalid extension permission: about:reader*", + ]; + + async function testInvalidPermissionWarning({ isPrivileged }) { + let id = isPrivileged + ? "privileged-addon@mochi.test" + : "nonprivileged-addon@mochi.test"; + + let expectedWarnings = isPrivileged ? [] : MANIFEST_WARNINGS; + + const ext = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["mozillaAddons", "resource://x/", "about:reader*"], + browser_specific_settings: { gecko: { id } }, + }, + background() {}, + }); + + await ext.startup(); + const { warnings } = ext.extension; + Assert.deepEqual( + warnings, + expectedWarnings, + `Got the expected warning for ${id}` + ); + await ext.unload(); + } + + await testInvalidPermissionWarning({ isPrivileged: false }); + await testInvalidPermissionWarning({ isPrivileged: true }); + + info("Test invalid permission warning on ExtensionData instance"); + // Generate an extension (just to be able to reuse its rootURI for the + // ExtensionData instance created below). + let generatedExt = ExtensionTestCommon.generate({ + manifest: { + permissions: ["mozillaAddons", "resource://x/", "about:reader*"], + browser_specific_settings: { + gecko: { id: "extension-data@mochi.test" }, + }, + }, + }); + + // Verify that XPIInstall.sys.mjs will not collect the warning for the + // privileged permission as expected. + async function getWarningsFromExtensionData({ isPrivileged }) { + let extData; + if (typeof isPrivileged == "function") { + // isPrivileged expected to be computed asynchronously. + extData = await ExtensionData.constructAsync({ + rootURI: generatedExt.rootURI, + checkPrivileged: isPrivileged, + }); + } else { + extData = new ExtensionData(generatedExt.rootURI, isPrivileged); + } + await extData.loadManifest(); + + // This assertion is just meant to prevent the test to pass if there were + // no warnings because some errors prevented the warnings to be + // collected). + Assert.deepEqual( + extData.errors, + [], + "No errors collected by the ExtensionData instance" + ); + return extData.warnings; + } + + Assert.deepEqual( + await getWarningsFromExtensionData({ isPrivileged: undefined }), + MANIFEST_WARNINGS, + "Got warnings about privileged permissions by default" + ); + + Assert.deepEqual( + await getWarningsFromExtensionData({ isPrivileged: false }), + MANIFEST_WARNINGS, + "Got warnings about privileged permissions for non-privileged extensions" + ); + + Assert.deepEqual( + await getWarningsFromExtensionData({ isPrivileged: true }), + [], + "No warnings about privileged permissions on privileged extensions" + ); + + Assert.deepEqual( + await getWarningsFromExtensionData({ isPrivileged: async () => false }), + MANIFEST_WARNINGS, + "Got warnings about privileged permissions for non-privileged extensions (async)" + ); + + Assert.deepEqual( + await getWarningsFromExtensionData({ isPrivileged: async () => true }), + [], + "No warnings about privileged permissions on privileged extensions (async)" + ); + + // Cleanup the generated xpi file. + await generatedExt.cleanupGeneratedFile(); + + await AddonTestUtils.promiseShutdownManager(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permission_xhr.js b/toolkit/components/extensions/test/xpcshell/test_ext_permission_xhr.js new file mode 100644 index 0000000000..88afd36dcc --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permission_xhr.js @@ -0,0 +1,240 @@ +"use strict"; + +// This file tests the behavior of fetch/XMLHttpRequest in content scripts, in +// relation to permissions, in MV2. +// In MV3, the expectations are different, test coverage for that is in +// test_ext_xhr_cors.js (along with CORS tests that also apply to MV2). + +const server = createHttpServer({ + hosts: ["xpcshell.test", "example.com", "example.org"], +}); +server.registerDirectory("/data/", do_get_file("data")); + +server.registerPathHandler("/example.txt", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +server.registerPathHandler("/return_headers.sjs", (request, response) => { + response.setHeader("Content-Type", "text/plain", false); + + let headers = {}; + for (let { data: header } of request.headers) { + headers[header.toLowerCase()] = request.getHeader(header); + } + + response.write(JSON.stringify(headers)); +}); + +/* eslint-disable mozilla/balanced-listeners */ + +add_task(async function test_simple() { + async function runTests(cx) { + function xhr(XMLHttpRequest) { + return url => { + return new Promise((resolve, reject) => { + let req = new XMLHttpRequest(); + req.open("GET", url); + req.addEventListener("load", resolve); + req.addEventListener("error", reject); + req.send(); + }); + }; + } + + function run(shouldFail, fetch) { + function passListener() { + browser.test.succeed(`${cx}.${fetch.name} pass listener`); + } + + function failListener() { + browser.test.fail(`${cx}.${fetch.name} fail listener`); + } + + /* eslint-disable no-else-return */ + if (shouldFail) { + return fetch("http://example.org/example.txt").then( + failListener, + passListener + ); + } else { + return fetch("http://example.com/example.txt").then( + passListener, + failListener + ); + } + /* eslint-enable no-else-return */ + } + + try { + await run(true, xhr(XMLHttpRequest)); + await run(false, xhr(XMLHttpRequest)); + await run(true, xhr(window.XMLHttpRequest)); + await run(false, xhr(window.XMLHttpRequest)); + await run(true, fetch); + await run(false, fetch); + await run(true, window.fetch); + await run(false, window.fetch); + } catch (err) { + browser.test.fail(`Error: ${err} :: ${err.stack}`); + browser.test.notifyFail("permission_xhr"); + } + } + + async function background(runTestsFn) { + await runTestsFn("bg"); + browser.test.notifyPass("permission_xhr"); + } + + let extensionData = { + background: `(${background})(${runTests})`, + manifest: { + permissions: ["http://example.com/"], + content_scripts: [ + { + matches: ["http://xpcshell.test/data/file_permission_xhr.html"], + js: ["content.js"], + }, + ], + }, + files: { + "content.js": `(${async runTestsFn => { + await runTestsFn("content"); + + window.wrappedJSObject.privilegedFetch = fetch; + window.wrappedJSObject.privilegedXHR = XMLHttpRequest; + + window.addEventListener("message", function rcv({ data }) { + switch (data.msg) { + case "test": + break; + + case "assertTrue": + browser.test.assertTrue(data.condition, data.description); + break; + + case "finish": + window.removeEventListener("message", rcv); + browser.test.sendMessage("content-script-finished"); + break; + } + }); + window.postMessage("test", "*"); + }})(${runTests})`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://xpcshell.test/data/file_permission_xhr.html" + ); + await extension.awaitMessage("content-script-finished"); + await contentPage.close(); + + await extension.awaitFinish("permission_xhr"); + await extension.unload(); +}); + +// This test case ensures that a WebExtension content script can still use the same +// XMLHttpRequest and fetch APIs that the webpage can use and be recognized from +// the target server with the same origin and referer headers of the target webpage +// (see Bug 1295660 for a rationale). +add_task(async function test_page_xhr() { + async function contentScript() { + const content = this.content; + + const { webpageFetchResult, webpageXhrResult } = await new Promise( + resolve => { + const listenPageMessage = event => { + if (!event.data || event.data.type !== "testPageGlobals") { + return; + } + + window.removeEventListener("message", listenPageMessage); + + browser.test.assertEq( + true, + !!content.XMLHttpRequest, + "The content script should have access to content.XMLHTTPRequest" + ); + browser.test.assertEq( + true, + !!content.fetch, + "The content script should have access to window.pageFetch" + ); + + resolve(event.data); + }; + + window.addEventListener("message", listenPageMessage); + + window.postMessage({}, "*"); + } + ); + + const url = new URL("/return_headers.sjs", location).href; + + await Promise.all([ + new Promise((resolve, reject) => { + const req = new content.XMLHttpRequest(); + req.open("GET", url); + req.addEventListener("load", () => + resolve(JSON.parse(req.responseText)) + ); + req.addEventListener("error", reject); + req.send(); + }), + content.fetch(url).then(res => res.json()), + ]) + .then(async ([xhrResult, fetchResult]) => { + browser.test.assertEq( + webpageFetchResult.referer, + fetchResult.referer, + "window.pageFetch referrer is the same of a webpage fetch request" + ); + browser.test.assertEq( + webpageFetchResult.origin, + fetchResult.origin, + "window.pageFetch origin is the same of a webpage fetch request" + ); + + browser.test.assertEq( + webpageXhrResult.referer, + xhrResult.referer, + "content.XMLHttpRequest referrer is the same of a webpage fetch request" + ); + }) + .catch(error => { + browser.test.fail(`Unexpected error: ${error}`); + }); + + browser.test.notifyPass("content-script-page-xhr"); + } + + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://xpcshell.test/*"], + js: ["content.js"], + }, + ], + }, + files: { + "content.js": `(${contentScript})()`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://xpcshell.test/data/file_page_xhr.html" + ); + await extension.awaitFinish("content-script-page-xhr"); + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js b/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js new file mode 100644 index 0000000000..7fb8d4ca07 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js @@ -0,0 +1,1034 @@ +"use strict"; + +const { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" +); +const { permissionToL10nId } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissionMessages.sys.mjs" +); +const { ExtensionPermissions } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" +); + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +// ExtensionParent.jsm is being imported lazily because when it is imported Services.appinfo will be +// retrieved and cached (as a side-effect of Schemas.jsm being imported), and so Services.appinfo +// will not be returning the version set by AddonTestUtils.createAppInfo and this test will +// fail on non-nightly builds (because the cached appinfo.version will be undefined and +// AddonManager startup will fail). +ChromeUtils.defineESModuleGetters(this, { + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", +}); + +const l10n = new Localization([ + "toolkit/global/extensions.ftl", + "toolkit/global/extensionPermissions.ftl", + "branding/brand.ftl", +]); +// Localization resources need to be first iterated outside a test +l10n.formatValue("webext-perms-sideload-text"); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_setup(async () => { + // Bug 1646182: Force ExtensionPermissions to run in rkv mode, the legacy + // storage mode will run in xpcshell-legacy-ep.toml + await ExtensionPermissions._uninit(); + + optionalPermissionsPromptHandler.init(); + + await AddonTestUtils.promiseStartupManager(); + AddonTestUtils.usePrivilegedSignatures = false; +}); + +add_task(async function test_permissions_on_startup() { + let extensionId = "@permissionTest"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id: extensionId }, + }, + permissions: ["tabs"], + }, + useAddonManager: "permanent", + async background() { + let perms = await browser.permissions.getAll(); + browser.test.sendMessage("permissions", perms); + }, + }); + let adding = { + permissions: ["internal:privateBrowsingAllowed"], + origins: [], + }; + await extension.startup(); + let perms = await extension.awaitMessage("permissions"); + equal(perms.permissions.length, 1, "one permission"); + equal(perms.permissions[0], "tabs", "internal permission not present"); + + const { StartupCache } = ExtensionParent; + + // StartupCache.permissions will not contain the extension permissions. + let manifestData = await StartupCache.permissions.get(extensionId, () => { + return { permissions: [], origins: [] }; + }); + equal(manifestData.permissions.length, 0, "no permission"); + + perms = await ExtensionPermissions.get(extensionId); + equal(perms.permissions.length, 0, "no permissions"); + await ExtensionPermissions.add(extensionId, adding); + + // Restart the extension and re-test the permissions. + await ExtensionPermissions._uninit(); + await AddonTestUtils.promiseRestartManager(); + let restarted = extension.awaitMessage("permissions"); + await extension.awaitStartup(); + perms = await restarted; + + manifestData = await StartupCache.permissions.get(extensionId, () => { + return { permissions: [], origins: [] }; + }); + deepEqual( + manifestData.permissions, + adding.permissions, + "StartupCache.permissions contains permission" + ); + + equal(perms.permissions.length, 1, "one permission"); + equal(perms.permissions[0], "tabs", "internal permission not present"); + let added = await ExtensionPermissions._get(extensionId); + deepEqual(added, adding, "permissions were retained"); + + await extension.unload(); +}); + +async function test_permissions({ + manifest_version, + granted_host_permissions, + useAddonManager, + expectAllGranted, +}) { + const REQUIRED_PERMISSIONS = ["downloads"]; + const REQUIRED_ORIGINS = ["*://site.com/", "*://*.domain.com/"]; + const REQUIRED_ORIGINS_EXPECTED = expectAllGranted + ? ["*://site.com/*", "*://*.domain.com/*"] + : []; + + const OPTIONAL_PERMISSIONS = ["idle", "clipboardWrite"]; + const OPTIONAL_ORIGINS = [ + "http://optionalsite.com/", + "https://*.optionaldomain.com/", + ]; + const OPTIONAL_ORIGINS_NORMALIZED = [ + "http://optionalsite.com/*", + "https://*.optionaldomain.com/*", + ]; + + function background() { + browser.test.onMessage.addListener(async (method, arg) => { + if (method == "getAll") { + let perms = await browser.permissions.getAll(); + let url = browser.runtime.getURL("*"); + perms.origins = perms.origins.filter(i => i != url); + browser.test.sendMessage("getAll.result", perms); + } else if (method == "contains") { + let result = await browser.permissions.contains(arg); + browser.test.sendMessage("contains.result", result); + } else if (method == "request") { + try { + let result = await browser.permissions.request(arg); + browser.test.sendMessage("request.result", { + status: "success", + result, + }); + } catch (err) { + browser.test.sendMessage("request.result", { + status: "error", + message: err.message, + }); + } + } else if (method == "remove") { + let result = await browser.permissions.remove(arg); + browser.test.sendMessage("remove.result", result); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + manifest_version, + permissions: REQUIRED_PERMISSIONS, + host_permissions: REQUIRED_ORIGINS, + optional_permissions: [...OPTIONAL_PERMISSIONS, ...OPTIONAL_ORIGINS], + granted_host_permissions, + }, + useAddonManager, + }); + + await extension.startup(); + + function call(method, arg) { + extension.sendMessage(method, arg); + return extension.awaitMessage(`${method}.result`); + } + + let result = await call("getAll"); + deepEqual(result.permissions, REQUIRED_PERMISSIONS); + deepEqual(result.origins, REQUIRED_ORIGINS_EXPECTED); + + for (let perm of REQUIRED_PERMISSIONS) { + result = await call("contains", { permissions: [perm] }); + equal(result, true, `contains() returns true for fixed permission ${perm}`); + } + for (let origin of REQUIRED_ORIGINS) { + result = await call("contains", { origins: [origin] }); + equal( + result, + expectAllGranted, + `contains() returns true for fixed origin ${origin}` + ); + } + + // None of the optional permissions should be available yet + for (let perm of OPTIONAL_PERMISSIONS) { + result = await call("contains", { permissions: [perm] }); + equal(result, false, `contains() returns false for permission ${perm}`); + } + for (let origin of OPTIONAL_ORIGINS) { + result = await call("contains", { origins: [origin] }); + equal(result, false, `contains() returns false for origin ${origin}`); + } + + result = await call("contains", { + permissions: [...REQUIRED_PERMISSIONS, ...OPTIONAL_PERMISSIONS], + }); + equal( + result, + false, + "contains() returns false for a mix of available and unavailable permissions" + ); + + let perm = OPTIONAL_PERMISSIONS[0]; + result = await call("request", { permissions: [perm] }); + equal( + result.status, + "error", + "request() fails if not called from an event handler" + ); + ok( + /request may only be called from a user input handler/.test(result.message), + "error message for calling request() outside an event handler is reasonable" + ); + result = await call("contains", { permissions: [perm] }); + equal( + result, + false, + "Permission requested outside an event handler was not granted" + ); + + await withHandlingUserInput(extension, async () => { + result = await call("request", { permissions: ["notifications"] }); + equal( + result.status, + "error", + "request() for permission not in optional_permissions should fail" + ); + ok( + /since it was not declared in optional_permissions/.test(result.message), + "error message for undeclared optional_permission is reasonable" + ); + + // Check request() when the prompt is canceled. + optionalPermissionsPromptHandler.acceptPrompt = false; + result = await call("request", { permissions: [perm] }); + equal(result.status, "success", "request() returned cleanly"); + equal( + result.result, + false, + "request() returned false for rejected permission" + ); + + result = await call("contains", { permissions: [perm] }); + equal(result, false, "Rejected permission was not granted"); + + // Call request() and accept the prompt + optionalPermissionsPromptHandler.acceptPrompt = true; + let allOptional = { + permissions: OPTIONAL_PERMISSIONS, + origins: OPTIONAL_ORIGINS, + }; + result = await call("request", allOptional); + equal(result.status, "success", "request() returned cleanly"); + equal( + result.result, + true, + "request() returned true for accepted permissions" + ); + + // Verify that requesting a permission/origin in the wrong field fails + let originsAsPerms = { + permissions: OPTIONAL_ORIGINS, + }; + let permsAsOrigins = { + origins: OPTIONAL_PERMISSIONS, + }; + + result = await call("request", originsAsPerms); + equal( + result.status, + "error", + "Requesting an origin as a permission should fail" + ); + ok( + /Type error for parameter permissions \(Error processing permissions/.test( + result.message + ), + "Error message for origin as permission is reasonable" + ); + + result = await call("request", permsAsOrigins); + equal( + result.status, + "error", + "Requesting a permission as an origin should fail" + ); + ok( + /Type error for parameter permissions \(Error processing origins/.test( + result.message + ), + "Error message for permission as origin is reasonable" + ); + }); + + let allPermissions = { + permissions: [...REQUIRED_PERMISSIONS, ...OPTIONAL_PERMISSIONS], + origins: [...REQUIRED_ORIGINS_EXPECTED, ...OPTIONAL_ORIGINS_NORMALIZED], + }; + + result = await call("getAll"); + deepEqual( + result, + allPermissions, + "getAll() returns required and runtime requested permissions" + ); + + result = await call("contains", allPermissions); + equal( + result, + true, + "contains() returns true for runtime requested permissions" + ); + + async function restart() { + if (useAddonManager === "permanent") { + await AddonTestUtils.promiseRestartManager(); + } else { + // Manually reload for temporarily loaded. + await extension.addon.reload(); + } + await extension.awaitBackgroundStarted(); + } + + // Restart extension, verify permissions are still present. + await restart(); + + result = await call("getAll"); + deepEqual( + result, + allPermissions, + "Runtime requested permissions are still present after restart" + ); + + // Check remove() + result = await call("remove", { permissions: OPTIONAL_PERMISSIONS }); + equal(result, true, "remove() succeeded"); + + let perms = { + permissions: REQUIRED_PERMISSIONS, + origins: [...REQUIRED_ORIGINS_EXPECTED, ...OPTIONAL_ORIGINS_NORMALIZED], + }; + + result = await call("getAll"); + deepEqual(result, perms, "Expected permissions remain after removing some"); + + result = await call("remove", { origins: OPTIONAL_ORIGINS }); + equal(result, true, "remove() succeeded"); + + perms.origins = REQUIRED_ORIGINS_EXPECTED; + result = await call("getAll"); + deepEqual(result, perms, "Back to default permissions after removing more"); + + if (granted_host_permissions && expectAllGranted) { + // Check that all (granted) host permissions in MV3 can be revoked. + + result = await call("remove", { origins: REQUIRED_ORIGINS }); + equal(result, true, "remove() succeeded"); + perms.origins = []; + + result = await call("getAll"); + deepEqual( + result, + perms, + "Expected only api permissions remain after removing all origins in mv3." + ); + } + + // Clear cache to confirm same result after rebuilding it (after an update). + await ExtensionParent.StartupCache.clearAddonData(extension.id); + + // Restart again, verify optional permissions state is still preserved. + await restart(); + + result = await call("getAll"); + deepEqual(result, perms, "Expected the same permissions after restart."); + + await extension.unload(); +} + +add_task(function test_normal_mv2() { + return test_permissions({ + manifest_version: 2, + useAddonManager: "permanent", + expectAllGranted: true, + }); +}); + +add_task(function test_normal_mv3() { + return test_permissions({ + manifest_version: 3, + useAddonManager: "permanent", + expectAllGranted: false, + }); +}); + +add_task(function test_granted_for_temporary_mv3() { + return test_permissions({ + manifest_version: 3, + granted_host_permissions: true, + useAddonManager: "temporary", + expectAllGranted: true, + }); +}); + +add_task(async function test_granted_only_for_privileged_mv3() { + try { + // For permanent non-privileged, granted_host_permissions does nothing. + await test_permissions({ + manifest_version: 3, + granted_host_permissions: true, + useAddonManager: "permanent", + expectAllGranted: false, + }); + + // Make extensions loaded with addon manager privileged. + AddonTestUtils.usePrivilegedSignatures = true; + + await test_permissions({ + manifest_version: 3, + granted_host_permissions: true, + useAddonManager: "permanent", + expectAllGranted: true, + }); + } finally { + AddonTestUtils.usePrivilegedSignatures = false; + } +}); + +add_task(async function test_startup() { + async function background() { + browser.test.onMessage.addListener(async perms => { + await browser.permissions.request(perms); + browser.test.sendMessage("requested"); + }); + + let all = await browser.permissions.getAll(); + let url = browser.runtime.getURL("*"); + all.origins = all.origins.filter(i => i != url); + browser.test.sendMessage("perms", all); + } + + const PERMS1 = { + permissions: ["clipboardRead", "tabs"], + }; + const PERMS2 = { + origins: ["https://site2.com/*"], + }; + + let extension1 = ExtensionTestUtils.loadExtension({ + background, + manifest: { + optional_permissions: PERMS1.permissions, + }, + useAddonManager: "permanent", + }); + let extension2 = ExtensionTestUtils.loadExtension({ + background, + manifest: { + optional_permissions: PERMS2.origins, + }, + useAddonManager: "permanent", + }); + + await extension1.startup(); + await extension2.startup(); + + let perms = await extension1.awaitMessage("perms"); + perms = await extension2.awaitMessage("perms"); + + await withHandlingUserInput(extension1, async () => { + extension1.sendMessage(PERMS1); + await extension1.awaitMessage("requested"); + }); + + await withHandlingUserInput(extension2, async () => { + extension2.sendMessage(PERMS2); + await extension2.awaitMessage("requested"); + }); + + // Restart everything, and force the permissions store to be + // re-read on startup + await ExtensionPermissions._uninit(); + await AddonTestUtils.promiseRestartManager(); + await extension1.awaitStartup(); + await extension2.awaitStartup(); + + async function checkPermissions(extension, permissions) { + perms = await extension.awaitMessage("perms"); + let expect = Object.assign({ permissions: [], origins: [] }, permissions); + deepEqual(perms, expect, "Extension got correct permissions on startup"); + } + + await checkPermissions(extension1, PERMS1); + await checkPermissions(extension2, PERMS2); + + await extension1.unload(); + await extension2.unload(); +}); + +// Test that we don't prompt for permissions an extension already has. +async function test_alreadyGranted(manifest_version) { + const REQUIRED_PERMISSIONS = ["geolocation"]; + const REQUIRED_ORIGINS = [ + "*://required-host.com/", + "*://*.required-domain.com/", + ]; + const OPTIONAL_PERMISSIONS = [ + ...REQUIRED_PERMISSIONS, + ...REQUIRED_ORIGINS, + "clipboardRead", + "*://optional-host.com/", + "*://*.optional-domain.com/", + ]; + + function pageScript() { + browser.test.onMessage.addListener(async (msg, arg) => { + if (msg == "request") { + let result = await browser.permissions.request(arg); + browser.test.sendMessage("request.result", result); + } else if (msg == "remove") { + let result = await browser.permissions.remove(arg); + browser.test.sendMessage("remove.result", result); + } else if (msg == "close") { + window.close(); + } + }); + + browser.test.sendMessage("page-ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("ready", browser.runtime.getURL("page.html")); + }, + + manifest: { + manifest_version, + permissions: REQUIRED_PERMISSIONS, + host_permissions: REQUIRED_ORIGINS, + optional_permissions: OPTIONAL_PERMISSIONS, + granted_host_permissions: true, + }, + temporarilyInstalled: true, + startupReason: "ADDON_INSTALL", + files: { + "page.html": `<html><head> + <script src="page.js"><\/script> + </head></html>`, + + "page.js": pageScript, + }, + }); + + await extension.startup(); + + await withHandlingUserInput(extension, async () => { + let url = await extension.awaitMessage("ready"); + let page = await ExtensionTestUtils.loadContentPage(url, { extension }); + await extension.awaitMessage("page-ready"); + + async function checkRequest(arg, expectPrompt, msg) { + optionalPermissionsPromptHandler.sawPrompt = false; + extension.sendMessage("request", arg); + let result = await extension.awaitMessage("request.result"); + ok(result, "request() call succeeded"); + equal( + optionalPermissionsPromptHandler.sawPrompt, + expectPrompt, + `Got ${expectPrompt ? "" : "no "}permission prompt for ${msg}` + ); + } + + await checkRequest( + { permissions: ["geolocation"] }, + false, + "required permission from manifest" + ); + await checkRequest( + { origins: ["http://required-host.com/"] }, + false, + "origin permission from manifest" + ); + await checkRequest( + { origins: ["http://host.required-domain.com/"] }, + false, + "wildcard origin permission from manifest" + ); + + await checkRequest( + { permissions: ["clipboardRead"] }, + true, + "optional permission" + ); + await checkRequest( + { permissions: ["clipboardRead"] }, + false, + "already granted optional permission" + ); + + await checkRequest( + { origins: ["http://optional-host.com/"] }, + true, + "optional origin" + ); + await checkRequest( + { origins: ["http://optional-host.com/"] }, + false, + "already granted origin permission" + ); + + await checkRequest( + { origins: ["http://*.optional-domain.com/"] }, + true, + "optional wildcard origin" + ); + await checkRequest( + { origins: ["http://*.optional-domain.com/"] }, + false, + "already granted optional wildcard origin" + ); + await checkRequest( + { origins: ["http://host.optional-domain.com/"] }, + false, + "host matching optional wildcard origin" + ); + await page.close(); + }); + + await extension.unload(); +} +add_task(async function test_alreadyGranted_mv2() { + return test_alreadyGranted(2); +}); +add_task(async function test_alreadyGranted_mv3() { + return test_alreadyGranted(3); +}); + +// IMPORTANT: Do not change this list without review from a Web Extensions peer! + +const GRANTED_WITHOUT_USER_PROMPT = [ + "activeTab", + "activityLog", + "alarms", + "captivePortal", + "contextMenus", + "contextualIdentities", + "cookies", + "declarativeNetRequestWithHostAccess", + "dns", + "geckoProfiler", + "identity", + "idle", + "menus", + "menus.overrideContext", + "mozillaAddons", + "networkStatus", + "normandyAddonStudy", + "scripting", + "search", + "storage", + "telemetry", + "theme", + "unlimitedStorage", + "webRequest", + "webRequestBlocking", + "webRequestFilterResponse", + "webRequestFilterResponse.serviceWorkerScript", +]; + +add_task(async function test_permissions_have_localization_strings() { + let noPromptNames = Schemas.getPermissionNames([ + "PermissionNoPrompt", + "OptionalPermissionNoPrompt", + "PermissionPrivileged", + ]); + Assert.deepEqual( + GRANTED_WITHOUT_USER_PROMPT, + noPromptNames, + "List of no-prompt permissions is correct." + ); + + for (const perm of Schemas.getPermissionNames()) { + const permId = permissionToL10nId(perm); + if (permId) { + const str = await l10n.formatValue(permId); + ok(str.length, `Found localization string for '${perm}' permission`); + } else { + ok( + GRANTED_WITHOUT_USER_PROMPT.includes(perm), + `Permission '${perm}' intentionally granted without prompting the user` + ); + } + } +}); + +// Check <all_urls> used as an optional API permission. +add_task(async function test_optional_all_urls() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + optional_permissions: ["<all_urls>"], + }, + + background() { + browser.test.onMessage.addListener(async () => { + let before = !!browser.tabs.captureVisibleTab; + let granted = await browser.permissions.request({ + origins: ["<all_urls>"], + }); + let after = !!browser.tabs.captureVisibleTab; + + browser.test.sendMessage("results", [before, granted, after]); + }); + }, + }); + + await extension.startup(); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request"); + let [before, granted, after] = await extension.awaitMessage("results"); + + equal( + before, + false, + "captureVisibleTab() unavailable before optional permission request()" + ); + equal(granted, true, "request() for optional permissions granted"); + equal( + after, + true, + "captureVisibleTab() available after optional permission request()" + ); + }); + + await extension.unload(); +}); + +// Check when content_script match patterns are treated as optional origins. +async function test_content_script_is_optional(manifest_version) { + function background() { + browser.test.onMessage.addListener(async (msg, arg) => { + if (msg == "request") { + try { + let result = await browser.permissions.request(arg); + browser.test.sendMessage("result", result); + } catch (e) { + browser.test.sendMessage("result", e.message); + } + } + if (msg === "getAll") { + let result = await browser.permissions.getAll(arg); + browser.test.sendMessage("granted", result); + } + }); + } + + const CS_ORIGIN = "https://test2.example.com/*"; + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + manifest_version, + content_scripts: [ + { + matches: [CS_ORIGIN], + js: [], + }, + ], + }, + }); + + await extension.startup(); + + extension.sendMessage("getAll"); + let initial = await extension.awaitMessage("granted"); + deepEqual(initial.origins, [], "Nothing granted on install."); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request", { + permissions: [], + origins: [CS_ORIGIN], + }); + let result = await extension.awaitMessage("result"); + if (manifest_version < 3) { + equal( + result, + `Cannot request origin permission for ${CS_ORIGIN} since it was not declared in the manifest`, + "Content script match pattern is not a requestable optional origin in MV2" + ); + } else { + equal(result, true, "request() for optional permissions succeeded"); + } + }); + + extension.sendMessage("getAll"); + let granted = await extension.awaitMessage("granted"); + deepEqual( + granted.origins, + manifest_version < 3 ? [] : [CS_ORIGIN], + "Granted content script origin in MV3." + ); + + await extension.unload(); +} +add_task(() => test_content_script_is_optional(2)); +add_task(() => test_content_script_is_optional(3)); + +// Check that optional permissions are not included in update prompts +async function test_permissions_prompt(manifest_version) { + function background() { + browser.test.onMessage.addListener(async (msg, arg) => { + if (msg == "request") { + let result = await browser.permissions.request(arg); + browser.test.sendMessage("result", result); + } + if (msg === "getAll") { + let result = await browser.permissions.getAll(arg); + browser.test.sendMessage("granted", result); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + name: "permissions test", + description: "permissions test", + manifest_version, + version: "1.0", + + permissions: ["tabs"], + host_permissions: ["https://test1.example.com/*"], + optional_permissions: ["clipboardWrite", "<all_urls>"], + + content_scripts: [ + { + matches: ["https://test2.example.com/*"], + js: [], + }, + ], + }, + useAddonManager: "permanent", + }); + + await extension.startup(); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request", { + permissions: ["clipboardWrite"], + origins: ["https://test2.example.com/*"], + }); + let result = await extension.awaitMessage("result"); + equal(result, true, "request() for optional permissions succeeded"); + }); + + if (manifest_version >= 3) { + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request", { + origins: ["https://test1.example.com/*"], + }); + let result = await extension.awaitMessage("result"); + equal(result, true, "request() for host_permissions in mv3 succeeded"); + }); + } + + const PERMS = ["history", "tabs"]; + const ORIGINS = ["https://test1.example.com/*", "https://test3.example.com/"]; + let xpi = AddonTestUtils.createTempWebExtensionFile({ + background, + manifest: { + name: "permissions test", + description: "permissions test", + manifest_version, + version: "2.0", + + browser_specific_settings: { gecko: { id: extension.id } }, + + permissions: PERMS, + host_permissions: ORIGINS, + optional_permissions: ["clipboardWrite", "<all_urls>"], + }, + }); + + let install = await AddonManager.getInstallForFile(xpi); + + let perminfo; + install.promptHandler = info => { + perminfo = info; + return Promise.resolve(); + }; + + await AddonTestUtils.promiseCompleteInstall(install); + await extension.awaitStartup(); + + notEqual(perminfo, undefined, "Permission handler was invoked"); + let perms = perminfo.addon.userPermissions; + deepEqual( + perms.permissions, + PERMS, + "Update details includes only manifest api permissions" + ); + deepEqual( + perms.origins, + manifest_version < 3 ? ORIGINS : [], + "Update details includes only manifest origin permissions" + ); + + let EXPECTED = ["https://test1.example.com/*", "https://test2.example.com/*"]; + if (manifest_version < 3) { + EXPECTED.push("https://test3.example.com/*"); + } + + extension.sendMessage("getAll"); + let granted = await extension.awaitMessage("granted"); + deepEqual( + granted.origins.sort(), + EXPECTED, + "Granted origins persisted after update." + ); + + await extension.unload(); +} +add_task(async function test_permissions_prompt_mv2() { + return test_permissions_prompt(2); +}); +add_task(async function test_permissions_prompt_mv3() { + return test_permissions_prompt(3); +}); + +// Check that internal permissions can not be set and are not returned by the API. +add_task(async function test_internal_permissions() { + function background() { + browser.test.onMessage.addListener(async (method, arg) => { + try { + if (method == "getAll") { + let perms = await browser.permissions.getAll(); + browser.test.sendMessage("getAll.result", perms); + } else if (method == "contains") { + let result = await browser.permissions.contains(arg); + browser.test.sendMessage("contains.result", { + status: "success", + result, + }); + } else if (method == "request") { + let result = await browser.permissions.request(arg); + browser.test.sendMessage("request.result", { + status: "success", + result, + }); + } else if (method == "remove") { + let result = await browser.permissions.remove(arg); + browser.test.sendMessage("remove.result", result); + } + } catch (err) { + browser.test.sendMessage(`${method}.result`, { + status: "error", + message: err.message, + }); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + name: "permissions test", + description: "permissions test", + manifest_version: 2, + version: "1.0", + permissions: [], + }, + useAddonManager: "permanent", + incognitoOverride: "spanning", + }); + + let perm = "internal:privateBrowsingAllowed"; + + await extension.startup(); + + function call(method, arg) { + extension.sendMessage(method, arg); + return extension.awaitMessage(`${method}.result`); + } + + let result = await call("getAll"); + ok(!result.permissions.includes(perm), "internal not returned"); + + result = await call("contains", { permissions: [perm] }); + ok( + /Type error for parameter permissions \(Error processing permissions/.test( + result.message + ), + `Unable to check for internal permission: ${result.message}` + ); + + result = await call("remove", { permissions: [perm] }); + ok( + /Type error for parameter permissions \(Error processing permissions/.test( + result.message + ), + `Unable to remove for internal permission ${result.message}` + ); + + await withHandlingUserInput(extension, async () => { + result = await call("request", { + permissions: [perm], + origins: [], + }); + ok( + /Type error for parameter permissions \(Error processing permissions/.test( + result.message + ), + `Unable to request internal permission ${result.message}` + ); + }); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permissions_api.js b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_api.js new file mode 100644 index 0000000000..0211787fee --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_api.js @@ -0,0 +1,464 @@ +"use strict"; + +const { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" +); +const { ExtensionPermissions } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", +}); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +let OptionalPermissions; + +add_task(async function setup() { + // Bug 1646182: Force ExtensionPermissions to run in rkv mode, the legacy + // storage mode will run in xpcshell-legacy-ep.toml + await ExtensionPermissions._uninit(); + + Services.prefs.setBoolPref( + "extensions.webextOptionalPermissionPrompts", + false + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts"); + }); + await AddonTestUtils.promiseStartupManager(); + AddonTestUtils.usePrivilegedSignatures = false; + + // We want to get a list of optional permissions prior to loading an extension, + // so we'll get ExtensionParent to do that for us. + await ExtensionParent.apiManager.lazyInit(); + + // These permissions have special behaviors and/or are not mapped directly to an + // api namespace. They will have their own tests for specific behavior. + let ignore = [ + "activeTab", + "clipboardRead", + "clipboardWrite", + "declarativeNetRequestFeedback", + "devtools", + "downloads.open", + "geolocation", + "management", + "menus.overrideContext", + "nativeMessaging", + "scripting", + "search", + "tabHide", + "tabs", + "webRequestBlocking", + "webRequestFilterResponse", + "webRequestFilterResponse.serviceWorkerScript", + ]; + OptionalPermissions = Schemas.getPermissionNames([ + "OptionalPermission", + "OptionalPermissionNoPrompt", + ]).filter(n => !ignore.includes(n)); +}); + +add_task(async function test_api_on_permissions_changed() { + async function background() { + let manifest = browser.runtime.getManifest(); + let permObj = { permissions: manifest.optional_permissions, origins: [] }; + + function verifyPermissions(enabled) { + for (let perm of manifest.optional_permissions) { + browser.test.assertEq( + enabled, + !!browser[perm], + `${perm} API is ${ + enabled ? "injected" : "removed" + } after permission request` + ); + } + } + + browser.permissions.onAdded.addListener(details => { + browser.test.assertEq( + JSON.stringify(details.permissions), + JSON.stringify(manifest.optional_permissions), + "expected permissions added" + ); + verifyPermissions(true); + browser.test.sendMessage("added"); + }); + + browser.permissions.onRemoved.addListener(details => { + browser.test.assertEq( + JSON.stringify(details.permissions), + JSON.stringify(manifest.optional_permissions), + "expected permissions removed" + ); + verifyPermissions(false); + browser.test.sendMessage("removed"); + }); + + browser.test.onMessage.addListener((msg, enabled) => { + if (msg === "request") { + browser.permissions.request(permObj); + } else if (msg === "verify_access") { + verifyPermissions(enabled); + browser.test.sendMessage("verified"); + } else if (msg === "revoke") { + browser.permissions.remove(permObj); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + optional_permissions: OptionalPermissions, + }, + useAddonManager: "permanent", + }); + await extension.startup(); + + function addPermissions() { + extension.sendMessage("request"); + return extension.awaitMessage("added"); + } + + function removePermissions() { + extension.sendMessage("revoke"); + return extension.awaitMessage("removed"); + } + + function verifyPermissions(enabled) { + extension.sendMessage("verify_access", enabled); + return extension.awaitMessage("verified"); + } + + await withHandlingUserInput(extension, async () => { + await addPermissions(); + await removePermissions(); + await addPermissions(); + }); + + // reset handlingUserInput for the restart + extensionHandlers.delete(extension); + + // Verify access on restart + await AddonTestUtils.promiseRestartManager(); + await extension.awaitBackgroundStarted(); + await verifyPermissions(true); + + await withHandlingUserInput(extension, async () => { + await removePermissions(); + }); + + // Add private browsing to be sure it doesn't come through. + let permObj = { + permissions: OptionalPermissions.concat("internal:privateBrowsingAllowed"), + origins: [], + }; + + // enable the permissions while the addon is running + await ExtensionPermissions.add(extension.id, permObj, extension.extension); + await extension.awaitMessage("added"); + await verifyPermissions(true); + + // disable the permissions while the addon is running + await ExtensionPermissions.remove(extension.id, permObj, extension.extension); + await extension.awaitMessage("removed"); + await verifyPermissions(false); + + // Add private browsing to test internal permission. If it slips through, + // we would get an error for an additional added message. + await ExtensionPermissions.add( + extension.id, + { permissions: ["internal:privateBrowsingAllowed"], origins: [] }, + extension.extension + ); + + // disable the addon and re-test revoking permissions. + await withHandlingUserInput(extension, async () => { + await addPermissions(); + }); + let addon = await AddonManager.getAddonByID(extension.id); + await addon.disable(); + await ExtensionPermissions.remove(extension.id, permObj); + await addon.enable(); + await extension.awaitStartup(); + + await verifyPermissions(false); + let perms = await ExtensionPermissions.get(extension.id); + equal(perms.permissions.length, 0, "no permissions on startup"); + + await extension.unload(); +}); + +add_task(async function test_geo_permissions() { + async function background() { + const permObj = { permissions: ["geolocation"] }; + browser.test.onMessage.addListener(async msg => { + if (msg === "request") { + await browser.permissions.request(permObj); + } else if (msg === "remove") { + await browser.permissions.remove(permObj); + } + let result = await browser.permissions.contains(permObj); + browser.test.sendMessage("done", result); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_specific_settings: { gecko: { id: "geo-test@test" } }, + optional_permissions: ["geolocation"], + }, + useAddonManager: "permanent", + }); + await extension.startup(); + + let policy = WebExtensionPolicy.getByID(extension.id); + let principal = policy.extension.principal; + equal( + Services.perms.testPermissionFromPrincipal(principal, "geo"), + Services.perms.UNKNOWN_ACTION, + "geolocation not allowed on install" + ); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request"); + ok(await extension.awaitMessage("done"), "permission granted"); + equal( + Services.perms.testPermissionFromPrincipal(principal, "geo"), + Services.perms.ALLOW_ACTION, + "geolocation allowed after requested" + ); + + extension.sendMessage("remove"); + ok(!(await extension.awaitMessage("done")), "permission revoked"); + + equal( + Services.perms.testPermissionFromPrincipal(principal, "geo"), + Services.perms.UNKNOWN_ACTION, + "geolocation not allowed after removed" + ); + + // re-grant to test update removal + extension.sendMessage("request"); + ok(await extension.awaitMessage("done"), "permission granted"); + equal( + Services.perms.testPermissionFromPrincipal(principal, "geo"), + Services.perms.ALLOW_ACTION, + "geolocation allowed after re-requested" + ); + }); + + // We should not have geo permission after this upgrade. + await extension.upgrade({ + manifest: { + browser_specific_settings: { gecko: { id: "geo-test@test" } }, + }, + useAddonManager: "permanent", + }); + + equal( + Services.perms.testPermissionFromPrincipal(principal, "geo"), + Services.perms.UNKNOWN_ACTION, + "geolocation not allowed after upgrade" + ); + + await extension.unload(); +}); + +add_task(async function test_browserSetting_permissions() { + async function background() { + const permObj = { permissions: ["browserSettings"] }; + browser.test.onMessage.addListener(async msg => { + if (msg === "request") { + await browser.permissions.request(permObj); + await browser.browserSettings.cacheEnabled.set({ value: false }); + } else if (msg === "remove") { + await browser.permissions.remove(permObj); + } + browser.test.sendMessage("done"); + }); + } + + function cacheIsEnabled() { + return ( + Services.prefs.getBoolPref("browser.cache.disk.enable") && + Services.prefs.getBoolPref("browser.cache.memory.enable") + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + optional_permissions: ["browserSettings"], + }, + useAddonManager: "permanent", + }); + await extension.startup(); + ok(cacheIsEnabled(), "setting is not set after startup"); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request"); + await extension.awaitMessage("done"); + ok(!cacheIsEnabled(), "setting was set after request"); + + extension.sendMessage("remove"); + await extension.awaitMessage("done"); + ok(cacheIsEnabled(), "setting is reset after remove"); + + extension.sendMessage("request"); + await extension.awaitMessage("done"); + ok(!cacheIsEnabled(), "setting was set after request"); + }); + + await ExtensionPermissions._uninit(); + extensionHandlers.delete(extension); + await AddonTestUtils.promiseRestartManager(); + await extension.awaitBackgroundStarted(); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("remove"); + await extension.awaitMessage("done"); + ok(cacheIsEnabled(), "setting is reset after remove"); + }); + + await extension.unload(); +}); + +add_task(async function test_privacy_permissions() { + async function background() { + const permObj = { permissions: ["privacy"] }; + browser.test.onMessage.addListener(async msg => { + if (msg === "request") { + await browser.permissions.request(permObj); + await browser.privacy.websites.trackingProtectionMode.set({ + value: "always", + }); + } else if (msg === "remove") { + await browser.permissions.remove(permObj); + } + browser.test.sendMessage("done"); + }); + } + + function hasSetting() { + return Services.prefs.getBoolPref("privacy.trackingprotection.enabled"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + optional_permissions: ["privacy"], + }, + useAddonManager: "permanent", + }); + await extension.startup(); + ok(!hasSetting(), "setting is not set after startup"); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request"); + await extension.awaitMessage("done"); + ok(hasSetting(), "setting was set after request"); + + extension.sendMessage("remove"); + await extension.awaitMessage("done"); + ok(!hasSetting(), "setting is reset after remove"); + + extension.sendMessage("request"); + await extension.awaitMessage("done"); + ok(hasSetting(), "setting was set after request"); + }); + + await ExtensionPermissions._uninit(); + extensionHandlers.delete(extension); + await AddonTestUtils.promiseRestartManager(); + await extension.awaitBackgroundStarted(); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("remove"); + await extension.awaitMessage("done"); + ok(!hasSetting(), "setting is reset after remove"); + }); + + await extension.unload(); +}); + +add_task( + { pref_set: [["extensions.eventPages.enabled", true]] }, + async function test_permissions_event_page() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + optional_permissions: ["privacy"], + background: { persistent: false }, + }, + background() { + browser.permissions.onAdded.addListener(details => { + browser.test.sendMessage("added", details); + }); + + browser.permissions.onRemoved.addListener(details => { + browser.test.sendMessage("removed", details); + }); + }, + }); + + await extension.startup(); + let events = ["onAdded", "onRemoved"]; + for (let event of events) { + assertPersistentListeners(extension, "permissions", event, { + primed: false, + }); + } + + await extension.terminateBackground(); + for (let event of events) { + assertPersistentListeners(extension, "permissions", event, { + primed: true, + }); + } + + let permObj = { + permissions: ["privacy"], + origins: [], + }; + + // enable the permissions while the background is stopped + await ExtensionPermissions.add(extension.id, permObj, extension.extension); + let details = await extension.awaitMessage("added"); + Assert.deepEqual(permObj, details, "got added event"); + + // Restart and test that permission removal wakes the background. + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + for (let event of events) { + assertPersistentListeners(extension, "permissions", event, { + primed: true, + }); + } + + // remove the permissions while the background is stopped + await ExtensionPermissions.remove( + extension.id, + permObj, + extension.extension + ); + + details = await extension.awaitMessage("removed"); + Assert.deepEqual(permObj, details, "got removed event"); + + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permissions_migrate.js b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_migrate.js new file mode 100644 index 0000000000..fff4fd43d0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_migrate.js @@ -0,0 +1,268 @@ +"use strict"; + +const { ExtensionPermissions } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_task(async function setup() { + // Bug 1646182: Force ExtensionPermissions to run in rkv mode, the legacy + // storage mode will run in xpcshell-legacy-ep.toml + await ExtensionPermissions._uninit(); + + Services.prefs.setBoolPref( + "extensions.webextOptionalPermissionPrompts", + false + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts"); + }); + await AddonTestUtils.promiseStartupManager(); + AddonTestUtils.usePrivilegedSignatures = false; +}); + +async function test_migrated_permission_to_optional({ manifest_version }) { + let id = "permission-upgrade@test"; + let extensionData = { + manifest: { + version: "1.0", + browser_specific_settings: { gecko: { id } }, + permissions: [ + "webRequest", + "tabs", + "http://example.net/*", + "http://example.com/*", + ], + }, + useAddonManager: "permanent", + }; + + function checkPermissions() { + let policy = WebExtensionPolicy.getByID(id); + ok(policy.hasPermission("webRequest"), "addon has webRequest permission"); + ok(policy.hasPermission("tabs"), "addon has tabs permission"); + ok( + policy.canAccessURI(Services.io.newURI("http://example.net/")), + "addon has example.net host permission" + ); + ok( + policy.canAccessURI(Services.io.newURI("http://example.com/")), + "addon has example.com host permission" + ); + ok( + !policy.canAccessURI(Services.io.newURI("http://other.com/")), + "addon does not have other.com host permission" + ); + } + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + checkPermissions(); + + extensionData.manifest.manifest_version = manifest_version; + + // Move to using optional permission + extensionData.manifest.version = "2.0"; + extensionData.manifest.permissions = ["tabs"]; + + // The ExtensionTestCommon.generateFiles() test helper will normalize (move) + // host permissions into the `permissions` key for the MV2 test. + extensionData.manifest.host_permissions = ["http://example.net/*"]; + + extensionData.manifest.optional_permissions = [ + "webRequest", + "http://example.com/*", + "http://other.com/*", + ]; + + // Restart the addon manager to flush the AddonInternal instance created + // when installing the addon above. See bug 1622117. + await AddonTestUtils.promiseRestartManager(); + await extension.upgrade(extensionData); + + equal(extension.version, "2.0", "Expected extension version"); + checkPermissions(); + + await extension.unload(); +} + +add_task(function test_migrated_permission_to_optional_mv2() { + return test_migrated_permission_to_optional({ manifest_version: 2 }); +}); + +// Test migration of mv2 (required) to mv3 (optional) host permissions. +add_task(function test_migrated_permission_to_optional_mv3() { + return test_migrated_permission_to_optional({ manifest_version: 3 }); +}); + +// This tests that settings are removed if a required permission is removed. +// We use two settings APIs to make sure the one we keep permission to is not +// removed inadvertantly. +add_task(async function test_required_permissions_removed() { + function cacheIsEnabled() { + return ( + Services.prefs.getBoolPref("browser.cache.disk.enable") && + Services.prefs.getBoolPref("browser.cache.memory.enable") + ); + } + + let extData = { + background() { + if (browser.browserSettings) { + browser.browserSettings.cacheEnabled.set({ value: false }); + } + browser.privacy.services.passwordSavingEnabled.set({ value: false }); + }, + manifest: { + browser_specific_settings: { gecko: { id: "pref-test@test" } }, + permissions: ["tabs", "browserSettings", "privacy", "http://test.com/*"], + }, + useAddonManager: "permanent", + }; + let extension = ExtensionTestUtils.loadExtension(extData); + ok( + Services.prefs.getBoolPref("signon.rememberSignons"), + "privacy setting intial value as expected" + ); + await extension.startup(); + ok(!cacheIsEnabled(), "setting is set after startup"); + + extData.manifest.permissions = ["tabs"]; + extData.manifest.optional_permissions = ["privacy"]; + await extension.upgrade(extData); + ok(cacheIsEnabled(), "setting is reset after upgrade"); + ok( + !Services.prefs.getBoolPref("signon.rememberSignons"), + "privacy setting is still set after upgrade" + ); + + await extension.unload(); +}); + +// This tests that settings are removed if a granted permission is removed. +// We use two settings APIs to make sure the one we keep permission to is not +// removed inadvertantly. +add_task(async function test_granted_permissions_removed() { + function cacheIsEnabled() { + return ( + Services.prefs.getBoolPref("browser.cache.disk.enable") && + Services.prefs.getBoolPref("browser.cache.memory.enable") + ); + } + + let extData = { + async background() { + browser.test.onMessage.addListener(async msg => { + await browser.permissions.request({ permissions: msg.permissions }); + if (browser.browserSettings) { + browser.browserSettings.cacheEnabled.set({ value: false }); + } + browser.privacy.services.passwordSavingEnabled.set({ value: false }); + browser.test.sendMessage("done"); + }); + }, + // "tabs" is never granted, it is included to exercise the removal code + // that called during the upgrade. + manifest: { + browser_specific_settings: { gecko: { id: "pref-test@test" } }, + optional_permissions: [ + "tabs", + "browserSettings", + "privacy", + "http://test.com/*", + ], + }, + useAddonManager: "permanent", + }; + let extension = ExtensionTestUtils.loadExtension(extData); + ok( + Services.prefs.getBoolPref("signon.rememberSignons"), + "privacy setting intial value as expected" + ); + await extension.startup(); + await withHandlingUserInput(extension, async () => { + extension.sendMessage({ permissions: ["browserSettings", "privacy"] }); + await extension.awaitMessage("done"); + }); + ok(!cacheIsEnabled(), "setting is set after startup"); + + extData.manifest.permissions = ["privacy"]; + delete extData.manifest.optional_permissions; + await extension.upgrade(extData); + ok(cacheIsEnabled(), "setting is reset after upgrade"); + ok( + !Services.prefs.getBoolPref("signon.rememberSignons"), + "privacy setting is still set after upgrade" + ); + + await extension.unload(); +}); + +// Test an update where an add-on becomes a theme. +add_task(async function test_addon_to_theme_update() { + let id = "theme-test@test"; + let extData = { + manifest: { + browser_specific_settings: { gecko: { id } }, + version: "1.0", + optional_permissions: ["tabs"], + }, + async background() { + browser.test.onMessage.addListener(async msg => { + await browser.permissions.request({ permissions: msg.permissions }); + browser.test.sendMessage("done"); + }); + }, + useAddonManager: "permanent", + }; + let extension = ExtensionTestUtils.loadExtension(extData); + await extension.startup(); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage({ permissions: ["tabs"] }); + await extension.awaitMessage("done"); + }); + + let policy = WebExtensionPolicy.getByID(id); + ok(policy.hasPermission("tabs"), "addon has tabs permission"); + + await extension.upgrade({ + manifest: { + browser_specific_settings: { gecko: { id } }, + version: "2.0", + theme: { + images: { + theme_frame: "image1.png", + }, + }, + }, + useAddonManager: "permanent", + }); + // When a theme is installed, it starts off in disabled mode, as seen in + // toolkit/mozapps/extensions/test/xpcshell/test_update_theme.js . + // But if we upgrade from an enabled extension, the theme is enabled. + equal(extension.addon.userDisabled, false, "Theme is enabled"); + + policy = WebExtensionPolicy.getByID(id); + ok(!policy.hasPermission("tabs"), "addon tabs permission was removed"); + let perms = await ExtensionPermissions._get(id); + ok(!perms?.permissions?.length, "no retained permissions"); + + extData.manifest.version = "3.0"; + extData.manifest.permissions = ["privacy"]; + await extension.upgrade(extData); + + policy = WebExtensionPolicy.getByID(id); + ok(!policy.hasPermission("tabs"), "addon tabs permission not added"); + ok(policy.hasPermission("privacy"), "addon privacy permission added"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permissions_uninstall.js b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_uninstall.js new file mode 100644 index 0000000000..b305ef1453 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_uninstall.js @@ -0,0 +1,157 @@ +"use strict"; + +const { ExtensionPermissions } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +// This test doesn't need the test extensions to be detected as privileged, +// disabling it to avoid having to keep the list of expected "internal:*" +// permissions that are added automatically to privileged extensions +// and already covered by other tests. +AddonTestUtils.usePrivilegedSignatures = false; + +// Look up the cached permissions, if any. +async function getCachedPermissions(extensionId) { + const NotFound = Symbol("extension ID not found in permissions cache"); + try { + return await ExtensionParent.StartupCache.permissions.get( + extensionId, + () => { + // Throw error to prevent the key from being created. + throw NotFound; + } + ); + } catch (e) { + if (e === NotFound) { + return null; + } + throw e; + } +} + +// Look up the permissions from the file. Internal methods are used to avoid +// inadvertently changing the permissions in the cache or the database. +async function getStoredPermissions(extensionId) { + if (await ExtensionPermissions._has(extensionId)) { + return ExtensionPermissions._get(extensionId); + } + return null; +} + +add_task(async function setup() { + // Bug 1646182: Force ExtensionPermissions to run in rkv mode, the legacy + // storage mode will run in xpcshell-legacy-ep.toml + await ExtensionPermissions._uninit(); + + optionalPermissionsPromptHandler.init(); + optionalPermissionsPromptHandler.acceptPrompt = true; + + await AddonTestUtils.promiseStartupManager(); + registerCleanupFunction(async () => { + await AddonTestUtils.promiseShutdownManager(); + }); +}); + +// This test must run before any restart of the addonmanager so the +// ExtensionAddonObserver works. +add_task(async function test_permissions_removed() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + optional_permissions: ["idle"], + }, + background() { + browser.test.onMessage.addListener(async (msg, arg) => { + if (msg == "request") { + try { + let result = await browser.permissions.request(arg); + browser.test.sendMessage("request.result", result); + } catch (err) { + browser.test.sendMessage("request.result", err.message); + } + } + }); + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request", { permissions: ["idle"], origins: [] }); + let result = await extension.awaitMessage("request.result"); + equal(result, true, "request() for optional permissions succeeded"); + }); + + let id = extension.id; + let perms = await ExtensionPermissions.get(id); + equal( + perms.permissions.length, + 1, + `optional permission added (${JSON.stringify(perms.permissions)})` + ); + + Assert.deepEqual( + await getCachedPermissions(id), + { + permissions: ["idle"], + origins: [], + }, + "Optional permission added to cache" + ); + Assert.deepEqual( + await getStoredPermissions(id), + { + permissions: ["idle"], + origins: [], + }, + "Optional permission added to persistent file" + ); + + await extension.unload(); + + // Directly read from the internals instead of using ExtensionPermissions.get, + // because the latter will lazily cache the extension ID. + Assert.deepEqual( + await getCachedPermissions(id), + null, + "Cached permissions removed" + ); + Assert.deepEqual( + await getStoredPermissions(id), + null, + "Stored permissions removed" + ); + + perms = await ExtensionPermissions.get(id); + equal( + perms.permissions.length, + 0, + `no permissions after uninstall (${JSON.stringify(perms.permissions)})` + ); + equal( + perms.origins.length, + 0, + `no origin permissions after uninstall (${JSON.stringify(perms.origins)})` + ); + + // The public ExtensionPermissions.get method should not store (empty) + // permissions in the persistent database. Polluting the cache is not ideal, + // but acceptable since the cache will eventually be cleared, and non-test + // code is not likely to call ExtensionPermissions.get() for non-installed + // extensions anyway. + Assert.deepEqual(await getCachedPermissions(id), perms, "Permissions cached"); + Assert.deepEqual( + await getStoredPermissions(id), + null, + "Permissions not saved" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_persistent_events.js b/toolkit/components/extensions/test/xpcshell/test_ext_persistent_events.js new file mode 100644 index 0000000000..07cc29bfe2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_persistent_events.js @@ -0,0 +1,1718 @@ +"use strict"; + +// Delay loading until createAppInfo is called and setup. +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", +}); + +const { ExtensionAPI } = ExtensionCommon; + +// The code in this class does not actually run in this test scope, it is +// serialized into a string which is later loaded by the WebExtensions +// framework in the same context as other extension APIs. By writing it +// this way rather than as a big string constant we get lint coverage. +// But eslint doesn't understand that this code runs in a different context +// where the EventManager class is available so just tell it here: +/* global EventManager */ +const API = class extends ExtensionAPI { + static namespace = undefined; + primeListener(event, fire, params, isInStartup) { + if (isInStartup && event == "nonBlockingEvent") { + return; + } + // eslint-disable-next-line no-undef + let { eventName, throwError, ignoreListener } = + this.constructor.testOptions || {}; + let { namespace } = this.constructor; + + if (eventName == event) { + if (throwError) { + throw new Error(throwError); + } + if (ignoreListener) { + return; + } + } + + Services.obs.notifyObservers( + { namespace, event, fire, params }, + "prime-event-listener" + ); + + const FIRE_TOPIC = `fire-${namespace}.${event}`; + + async function listener(subject, topic, data) { + try { + if (subject.wrappedJSObject.waitForBackground) { + await fire.wakeup(); + } + await fire.async(subject.wrappedJSObject.listenerArgs); + } catch (err) { + let errSubject = { namespace, event, errorMessage: err.toString() }; + Services.obs.notifyObservers(errSubject, "listener-callback-exception"); + } + } + Services.obs.addObserver(listener, FIRE_TOPIC); + + return { + unregister() { + Services.obs.notifyObservers( + { namespace, event, params }, + "unregister-primed-listener" + ); + Services.obs.removeObserver(listener, FIRE_TOPIC); + }, + convert(_fire) { + Services.obs.notifyObservers( + { namespace, event, params }, + "convert-event-listener" + ); + fire = _fire; + }, + }; + } + + getAPI(context) { + let self = this; + let { namespace } = this.constructor; + + // TODO: split into their own test tasks the expected value to be set on + // EventManager resetIdleOnEvent in the following cases: + // - an EventManager instance in the parent process + // - for an event page + // - for a persistent background page + // - for an extension context that isn't a background context + // - an EventManager instance in the child process + // (for the same 3 kinds of contexts) + const EventManagerWithAssertions = class extends EventManager { + constructor(...args) { + super(...args); + this.assertResetOnIdleOnEvent(); + } + + assertResetOnIdleOnEvent() { + const expectResetIdleOnEventFalse = + this.context.extension.persistentBackground; + if (expectResetIdleOnEventFalse && this.resetIdleOnEvent) { + const details = { + eventManagerName: this.name, + resetIdleOnEvent: this.resetIdleOnEvent, + envType: this.context.envType, + viewType: this.context.viewType, + isBackgroundContext: this.context.isBackgroundContext, + persistentBackground: this.context.extension.persistentBackground, + }; + throw new Error( + `EventManagerWithAssertions: resetIdleOnEvent should be forcefully set to false - ${JSON.stringify( + details + )}` + ); + } + } + }; + return { + [namespace]: { + testOptions(options) { + // We want to be able to test errors on startup. + // We use a global here because we test restarting AOM, + // which causes the instance of this class to be destroyed. + // eslint-disable-next-line no-undef + self.constructor.testOptions = options; + }, + onEvent1: new EventManagerWithAssertions({ + context, + module: namespace, + event: "onEvent1", + register: (fire, ...params) => { + let data = { namespace, event: "onEvent1", params }; + Services.obs.notifyObservers(data, "register-event-listener"); + return () => { + Services.obs.notifyObservers(data, "unregister-event-listener"); + }; + }, + }).api(), + + onEvent2: new EventManagerWithAssertions({ + context, + module: namespace, + event: "onEvent2", + register: (fire, ...params) => { + let data = { namespace, event: "onEvent2", params }; + Services.obs.notifyObservers(data, "register-event-listener"); + return () => { + Services.obs.notifyObservers(data, "unregister-event-listener"); + }; + }, + }).api(), + + onEvent3: new EventManagerWithAssertions({ + context, + module: namespace, + event: "onEvent3", + register: (fire, ...params) => { + let data = { namespace, event: "onEvent3", params }; + Services.obs.notifyObservers(data, "register-event-listener"); + return () => { + Services.obs.notifyObservers(data, "unregister-event-listener"); + }; + }, + }).api(), + + nonBlockingEvent: new EventManagerWithAssertions({ + context, + module: namespace, + event: "nonBlockingEvent", + register: (fire, ...params) => { + let data = { namespace, event: "nonBlockingEvent", params }; + Services.obs.notifyObservers(data, "register-event-listener"); + return () => { + Services.obs.notifyObservers(data, "unregister-event-listener"); + }; + }, + }).api(), + }, + }; + } +}; + +function makeModule(namespace, options = {}) { + const SCHEMA = [ + { + namespace, + functions: [ + { + name: "testOptions", + type: "function", + async: true, + parameters: [ + { + name: "options", + type: "object", + additionalProperties: { + type: "any", + }, + }, + ], + }, + ], + events: [ + { + name: "onEvent1", + type: "function", + extraParameters: [{ type: "any", optional: true }], + }, + { + name: "onEvent2", + type: "function", + extraParameters: [{ type: "any", optional: true }], + }, + { + name: "onEvent3", + type: "function", + extraParameters: [ + { type: "object", optional: true, additionalProperties: true }, + { type: "any", optional: true }, + ], + }, + { + name: "nonBlockingEvent", + type: "function", + extraParameters: [{ type: "any", optional: true }], + }, + ], + }, + ]; + + const API_SCRIPT = ` + this.${namespace} = ${API.toString()}; + this.${namespace}.namespace = "${namespace}"; + `; + + // MODULE_INFO for registerModules + let { startupBlocking } = options; + return { + schema: `data:,${JSON.stringify(SCHEMA)}`, + scopes: ["addon_parent"], + paths: [[namespace]], + startupBlocking, + url: URL.createObjectURL(new Blob([API_SCRIPT])), + }; +} + +// Two modules, primary test module is startupBlocking +const MODULE_INFO = { + startupBlocking: makeModule("startupBlocking", { startupBlocking: true }), + nonStartupBlocking: makeModule("nonStartupBlocking"), +}; + +const global = this; + +// Wait for the given event (topic) to occur a specific number of times +// (count). If fn is not supplied, the Promise returned from this function +// resolves as soon as that many instances of the event have been observed. +// If fn is supplied, this function also waits for the Promise that fn() +// returns to complete and ensures that the given event does not occur more +// than `count` times before then. On success, resolves with an array +// of the subjects from each of the observed events. +async function promiseObservable(topic, count, fn = null) { + let _countResolve; + let results = []; + function listener(subject, _topic, data) { + const eventDetails = subject.wrappedJSObject; + results.push(eventDetails); + if (results.length > count) { + ok( + false, + `Got unexpected ${topic} event with ${JSON.stringify(eventDetails)}` + ); + } else if (results.length == count) { + _countResolve(); + } + } + Services.obs.addObserver(listener, topic); + + try { + await Promise.all([ + new Promise(resolve => { + _countResolve = resolve; + }), + fn && fn(), + ]); + } finally { + Services.obs.removeObserver(listener, topic); + } + + return results; +} + +function trackEvents(wrapper) { + let events = new Map(); + for (let event of ["background-script-event", "start-background-script"]) { + events.set(event, false); + wrapper.extension.once(event, () => events.set(event, true)); + } + return events; +} + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_setup(async function setup() { + // The blob:-URL registered above in MODULE_INFO gets loaded at + // https://searchfox.org/mozilla-central/rev/0fec57c05d3996cc00c55a66f20dd5793a9bfb5d/toolkit/components/extensions/ExtensionCommon.jsm#1649 + Services.prefs.setBoolPref( + "security.allow_parent_unrestricted_js_loads", + true + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("security.allow_parent_unrestricted_js_loads"); + }); + + AddonTestUtils.init(global); + AddonTestUtils.overrideCertDB(); + AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" + ); + + ExtensionParent.apiManager.registerModules(MODULE_INFO); +}); + +add_task(async function test_persistent_events() { + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background() { + let register1 = true, + register2 = true; + if (localStorage.getItem("skip1")) { + register1 = false; + } + if (localStorage.getItem("skip2")) { + register2 = false; + } + + let listener1 = arg => browser.test.sendMessage("listener1", arg); + let listener2 = arg => browser.test.sendMessage("listener2", arg); + let listener3 = arg => browser.test.sendMessage("listener3", arg); + + if (register1) { + browser.startupBlocking.onEvent1.addListener(listener1, "listener1"); + } + if (register2) { + browser.startupBlocking.onEvent1.addListener(listener2, "listener2"); + browser.startupBlocking.onEvent2.addListener(listener3, "listener3"); + } + + browser.test.onMessage.addListener(msg => { + if (msg == "unregister2") { + browser.startupBlocking.onEvent2.removeListener(listener3); + localStorage.setItem("skip2", true); + } else if (msg == "unregister1") { + localStorage.setItem("skip1", true); + browser.test.sendMessage("unregistered"); + } + }); + + browser.test.sendMessage("ready"); + }, + }); + + function check( + info, + what, + { listener1 = true, listener2 = true, listener3 = true } = {} + ) { + let count = (listener1 ? 1 : 0) + (listener2 ? 1 : 0) + (listener3 ? 1 : 0); + equal(info.length, count, `Got ${count} ${what} events`); + + let i = 0; + if (listener1) { + equal(info[i].event, "onEvent1", `Got ${what} on event1 for listener 1`); + deepEqual( + info[i].params, + ["listener1"], + `Got event1 ${what} args for listener 1` + ); + ++i; + } + + if (listener2) { + equal(info[i].event, "onEvent1", `Got ${what} on event1 for listener 2`); + deepEqual( + info[i].params, + ["listener2"], + `Got event1 ${what} args for listener 2` + ); + ++i; + } + + if (listener3) { + equal(info[i].event, "onEvent2", `Got ${what} on event2 for listener 3`); + deepEqual( + info[i].params, + ["listener3"], + `Got event2 ${what} args for listener 3` + ); + ++i; + } + } + + // Check that the regular event registration process occurs when + // the extension is installed. + let [observed] = await Promise.all([ + promiseObservable("register-event-listener", 3), + extension.startup(), + ]); + check(observed, "register"); + + await extension.awaitMessage("ready"); + + // Check that the regular unregister process occurs when + // the browser shuts down. + [observed] = await Promise.all([ + promiseObservable("unregister-event-listener", 3), + new Promise(resolve => extension.extension.once("shutdown", resolve)), + AddonTestUtils.promiseShutdownManager(), + ]); + check(observed, "unregister"); + + // Check that listeners are primed at the next browser startup. + [observed] = await Promise.all([ + promiseObservable("prime-event-listener", 3), + AddonTestUtils.promiseStartupManager(), + ]); + check(observed, "prime"); + + assertPersistentListeners(extension, "startupBlocking", "onEvent1", { + primed: true, + primedListenersCount: 2, + }); + + assertPersistentListeners(extension, "startupBlocking", "onEvent2", { + primed: true, + primedListenersCount: 1, + }); + + // Check that primed listeners are converted to regular listeners + // when the background page is started after browser startup. + let p = promiseObservable("convert-event-listener", 3); + AddonTestUtils.notifyLateStartup(); + observed = await p; + + check(observed, "convert"); + + await extension.awaitMessage("ready"); + + // Check that when the event is triggered, all the plumbing worked + // correctly for the primed-then-converted listener. + let listenerArgs = { test: "kaboom" }; + Services.obs.notifyObservers( + { listenerArgs }, + "fire-startupBlocking.onEvent1" + ); + + let details = await extension.awaitMessage("listener1"); + deepEqual(details, listenerArgs, "Listener 1 fired"); + details = await extension.awaitMessage("listener2"); + deepEqual(details, listenerArgs, "Listener 2 fired"); + + // Check that the converted listener is properly unregistered at + // browser shutdown. + [observed] = await Promise.all([ + promiseObservable("unregister-primed-listener", 3), + AddonTestUtils.promiseShutdownManager(), + ]); + check(observed, "unregister"); + + // Start up again, listener should be primed + [observed] = await Promise.all([ + promiseObservable("prime-event-listener", 3), + AddonTestUtils.promiseStartupManager(), + ]); + check(observed, "prime"); + + // Check that triggering the event before the listener has been converted + // causes the background page to be loaded and the listener to be converted, + // and the listener is invoked. + p = promiseObservable("convert-event-listener", 3); + listenerArgs.test = "startup event"; + Services.obs.notifyObservers( + { listenerArgs }, + "fire-startupBlocking.onEvent2" + ); + observed = await p; + + check(observed, "convert"); + + details = await extension.awaitMessage("listener3"); + deepEqual(details, listenerArgs, "Listener 3 fired for event during startup"); + + await extension.awaitMessage("ready"); + + // Check that triggering onEvent1 emits calls to both listener1 and listener2 + // (See Bug 1795801). + [observed] = await Promise.all([ + promiseObservable("unregister-primed-listener", 3), + AddonTestUtils.promiseShutdownManager(), + ]); + check(observed, "unregister"); + [observed] = await Promise.all([ + promiseObservable("prime-event-listener", 3), + AddonTestUtils.promiseStartupManager(), + ]); + check(observed, "prime"); + assertPersistentListeners(extension, "startupBlocking", "onEvent1", { + primed: true, + primedListenersCount: 2, + }); + assertPersistentListeners(extension, "startupBlocking", "onEvent2", { + primed: true, + primedListenersCount: 1, + }); + + p = promiseObservable("convert-event-listener", 3); + listenerArgs.test = "startup event"; + Services.obs.notifyObservers( + { listenerArgs }, + "fire-startupBlocking.onEvent1" + ); + observed = await p; + + check(observed, "convert"); + + const [detailsListener1Call, detailsListener2Call] = await Promise.all([ + extension.awaitMessage("listener1"), + extension.awaitMessage("listener2"), + ]); + deepEqual( + detailsListener1Call, + listenerArgs, + "Listener 1 fired for event during startup" + ); + deepEqual( + detailsListener2Call, + listenerArgs, + "Listener 2 fired for event during startup" + ); + + await extension.awaitMessage("ready"); + + // Check that the unregister process works when we manually remove + // a listener. + p = promiseObservable("unregister-primed-listener", 1); + extension.sendMessage("unregister2"); + observed = await p; + check(observed, "unregister", { listener1: false, listener2: false }); + + // Check that we only get unregisters for the remaining events after + // one listener has been removed. + observed = await promiseObservable("unregister-primed-listener", 2, () => + AddonTestUtils.promiseShutdownManager() + ); + check(observed, "unregister", { listener3: false }); + + // Check that after restart, only listeners that were present at + // the end of the last session are primed. + observed = await promiseObservable("prime-event-listener", 2, () => + AddonTestUtils.promiseStartupManager() + ); + check(observed, "prime", { listener3: false }); + + // Check that if the background script does not re-register listeners, + // the primed listeners are unregistered after the background page + // starts up. + p = promiseObservable("unregister-primed-listener", 1, () => + extension.awaitMessage("ready") + ); + + AddonTestUtils.notifyLateStartup(); + observed = await p; + check(observed, "unregister", { listener1: false, listener3: false }); + + // Just listener1 should be registered now, fire event1 to confirm. + listenerArgs.test = "third time"; + Services.obs.notifyObservers( + { listenerArgs }, + "fire-startupBlocking.onEvent1" + ); + details = await extension.awaitMessage("listener1"); + deepEqual(details, listenerArgs, "Listener 1 fired"); + + // Tell the extension not to re-register listener1 on the next startup + extension.sendMessage("unregister1"); + await extension.awaitMessage("unregistered"); + + // Shut down, start up + observed = await promiseObservable("unregister-primed-listener", 1, () => + AddonTestUtils.promiseShutdownManager() + ); + check(observed, "unregister", { listener2: false, listener3: false }); + + observed = await promiseObservable("prime-event-listener", 1, () => + AddonTestUtils.promiseStartupManager() + ); + check(observed, "register", { listener2: false, listener3: false }); + + // Check that firing event1 causes the listener fire callback to + // reject. + p = promiseObservable("listener-callback-exception", 1); + Services.obs.notifyObservers( + { listenerArgs, waitForBackground: true }, + "fire-startupBlocking.onEvent1" + ); + equal( + (await p)[0].errorMessage, + "Error: primed listener startupBlocking.onEvent1 not re-registered", + "Primed listener that was not re-registered received an error when event was triggered during startup" + ); + + await extension.awaitMessage("ready"); + + await extension.unload(); + + await AddonTestUtils.promiseShutdownManager(); +}); + +// This test checks whether primed listeners are correctly unregistered when +// a background page load is interrupted. In particular, it verifies that the +// fire.wakeup() and fire.async() promises settle eventually. +add_task(async function test_shutdown_before_background_loaded() { + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background() { + let listener = arg => browser.test.sendMessage("triggered", arg); + browser.startupBlocking.onEvent1.addListener(listener, "triggered"); + browser.test.sendMessage("bg_started"); + }, + }); + await Promise.all([ + promiseObservable("register-event-listener", 1), + extension.startup(), + ]); + await extension.awaitMessage("bg_started"); + + await Promise.all([ + promiseObservable("unregister-event-listener", 1), + new Promise(resolve => extension.extension.once("shutdown", resolve)), + AddonTestUtils.promiseShutdownManager(), + ]); + + let primeListenerPromise = promiseObservable("prime-event-listener", 1); + let fire; + let fireWakeupBeforeBgFail; + let fireAsyncBeforeBgFail; + + let bgAbortedPromise = new Promise(resolve => { + let Management = ExtensionParent.apiManager; + Management.once("extension-browser-inserted", (eventName, browser) => { + browser.fixupAndLoadURIString = async () => { + // The fire.wakeup/fire.async promises created while loading the + // background page should settle when the page fails to load. + fire = (await primeListenerPromise)[0].fire; + fireWakeupBeforeBgFail = fire.wakeup(); + fireAsyncBeforeBgFail = fire.async(); + + extension.extension.once("background-script-aborted", resolve); + info("Forcing the background load to fail"); + browser.remove(); + }; + }); + }); + + let unregisterPromise = promiseObservable("unregister-primed-listener", 1); + + await Promise.all([ + primeListenerPromise, + AddonTestUtils.promiseStartupManager(), + ]); + await bgAbortedPromise; + info("Loaded extension and aborted load of background page"); + + await unregisterPromise; + info("Primed listener has been unregistered"); + + await fireWakeupBeforeBgFail; + info("fire.wakeup() before background load failure should settle"); + + await Assert.rejects( + fireAsyncBeforeBgFail, + /Error: listener not re-registered/, + "fire.async before background load failure should be rejected" + ); + + await fire.wakeup(); + info("fire.wakeup() after background load failure should settle"); + + await Assert.rejects( + fire.async(), + /Error: primed listener startupBlocking.onEvent1 not re-registered/, + "fire.async after background load failure should be rejected" + ); + + info( + "Expect fire.wakeup call after load failure to restart the background page" + ); + await extension.awaitMessage("bg_started"); + + await AddonTestUtils.promiseShutdownManager(); + + // End of the abnormal shutdown test. Now restart the extension to verify + // that the persistent listeners have not been unregistered. + + // Suppress background page start until an explicit notification. + await Promise.all([ + promiseObservable("prime-event-listener", 1), + AddonTestUtils.promiseStartupManager({ earlyStartup: false }), + ]); + info("Triggering persistent event to force the background page to start"); + Services.obs.notifyObservers( + { listenerArgs: 123 }, + "fire-startupBlocking.onEvent1" + ); + AddonTestUtils.notifyEarlyStartup(); + await extension.awaitMessage("bg_started"); + equal(await extension.awaitMessage("triggered"), 123, "triggered event"); + + await Promise.all([ + promiseObservable("unregister-primed-listener", 1), + AddonTestUtils.promiseShutdownManager(), + ]); + + // And lastly, verify that a primed listener is correctly removed when the + // extension unloads normally before the delayed background page can load. + await Promise.all([ + promiseObservable("prime-event-listener", 1), + AddonTestUtils.promiseStartupManager({ earlyStartup: false }), + ]); + + info("Unloading extension before background page has loaded"); + await Promise.all([ + promiseObservable("unregister-primed-listener", 1), + extension.unload(), + ]); + + await AddonTestUtils.promiseShutdownManager(); +}); + +// This test checks whether primed listeners are correctly primed to +// restart the background once the background has been shutdown or +// put to sleep. +add_task(async function test_background_restarted() { + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background() { + let listener = arg => browser.test.sendMessage("triggered", arg); + browser.startupBlocking.onEvent1.addListener(listener, "triggered"); + browser.test.sendMessage("bg_started"); + }, + }); + await Promise.all([ + promiseObservable("register-event-listener", 1), + extension.startup(), + ]); + await extension.awaitMessage("bg_started"); + assertPersistentListeners(extension, "startupBlocking", "onEvent1", { + primed: false, + }); + + // Shutdown the background page + await Promise.all([ + promiseObservable("unregister-event-listener", 1), + extension.terminateBackground(), + ]); + // When sleeping the background, its events should become persisted + assertPersistentListeners(extension, "startupBlocking", "onEvent1", { + primed: true, + }); + + info("Triggering persistent event to force the background page to start"); + Services.obs.notifyObservers( + { listenerArgs: 123 }, + "fire-startupBlocking.onEvent1" + ); + await extension.awaitMessage("bg_started"); + equal(await extension.awaitMessage("triggered"), 123, "triggered event"); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); +}); + +// This test checks whether primed listeners are correctly primed to +// restart the background once the background has been shutdown or +// put to sleep. +add_task( + { pref_set: [["extensions.eventPages.enabled", true]] }, + async function test_eventpage_startup() { + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: "eventpage@test" } }, + background: { persistent: false }, + }, + background() { + let listener = arg => browser.test.sendMessage("triggered", arg); + browser.startupBlocking.onEvent1.addListener(listener, "triggered"); + let listenerNs = arg => browser.test.sendMessage("triggered-et2", arg); + browser.nonStartupBlocking.onEvent1.addListener( + listenerNs, + "triggered-et2" + ); + browser.test.onMessage.addListener(() => { + let listener = arg => browser.test.sendMessage("triggered2", arg); + browser.startupBlocking.onEvent2.addListener(listener, "triggered2"); + browser.test.sendMessage("async-registered-listener"); + }); + browser.test.sendMessage("bg_started"); + }, + }); + await Promise.all([ + promiseObservable("register-event-listener", 2), + extension.startup(), + ]); + await extension.awaitMessage("bg_started"); + extension.sendMessage("async-register-listener"); + await extension.awaitMessage("async-registered-listener"); + + async function testAfterRestart() { + assertPersistentListeners(extension, "startupBlocking", "onEvent1", { + primed: true, + }); + // async registration should not be primed or persisted + assertPersistentListeners(extension, "startupBlocking", "onEvent2", { + primed: false, + persisted: false, + }); + + let events = trackEvents(extension); + ok( + !events.get("background-script-event"), + "Should not have received a background script event" + ); + ok( + !events.get("start-background-script"), + "Background script should not be started" + ); + + info("Triggering persistent event to force the background page to start"); + let converted = promiseObservable("convert-event-listener", 1); + Services.obs.notifyObservers( + { listenerArgs: 123 }, + "fire-startupBlocking.onEvent1" + ); + await extension.awaitMessage("bg_started"); + await converted; + equal(await extension.awaitMessage("triggered"), 123, "triggered event"); + ok( + events.get("background-script-event"), + "Should have received a background script event" + ); + ok( + events.get("start-background-script"), + "Background script should be started" + ); + } + + // Shutdown the background page + await Promise.all([ + promiseObservable("unregister-event-listener", 3), + new Promise(resolve => extension.extension.once("shutdown", resolve)), + AddonTestUtils.promiseShutdownManager(), + ]); + await AddonTestUtils.promiseStartupManager({ lateStartup: false }); + await extension.awaitStartup(); + assertPersistentListeners(extension, "nonStartupBlocking", "onEvent1", { + primed: false, + persisted: true, + }); + await testAfterRestart(); + + extension.sendMessage("async-register-listener"); + await extension.awaitMessage("async-registered-listener"); + + // We sleep twice to ensure startup and shutdown work correctly + info("test event listener registration during termination"); + let registrationEvents = Promise.all([ + promiseObservable("unregister-event-listener", 2), + promiseObservable("unregister-primed-listener", 1), + promiseObservable("prime-event-listener", 2), + ]); + await extension.terminateBackground(); + await registrationEvents; + + assertPersistentListeners(extension, "nonStartupBlocking", "onEvent1", { + primed: true, + persisted: true, + }); + + // Ensure onEvent2 does not fire, testAfterRestart will fail otherwise. + Services.obs.notifyObservers( + { listenerArgs: 123 }, + "fire-startupBlocking.onEvent2" + ); + await testAfterRestart(); + + registrationEvents = Promise.all([ + promiseObservable("unregister-primed-listener", 2), + promiseObservable("prime-event-listener", 2), + ]); + await extension.terminateBackground(); + await registrationEvents; + await testAfterRestart(); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + } +); + +// This test verifies primeListener behavior for errors or ignored listeners. +add_task(async function test_background_primeListener_errors() { + await AddonTestUtils.promiseStartupManager(); + + // The internal APIs to shutdown the background work with any + // background, and in the shutdown case, events will be persisted + // and primed for a restart. + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background() { + // Listen for options being set so a restart will have them. + browser.test.onMessage.addListener(async (message, options) => { + if (message == "set-options") { + await browser.startupBlocking.testOptions(options); + browser.test.sendMessage("set-options:done"); + } + }); + let listener = arg => browser.test.sendMessage("triggered", arg); + browser.startupBlocking.onEvent1.addListener(listener, "triggered"); + let listener2 = arg => browser.test.sendMessage("triggered", arg); + browser.startupBlocking.onEvent2.addListener(listener2, "triggered"); + browser.test.sendMessage("bg_started"); + }, + }); + await Promise.all([ + promiseObservable("register-event-listener", 1), + extension.startup(), + ]); + await extension.awaitMessage("bg_started"); + assertPersistentListeners(extension, "startupBlocking", "onEvent1", { + primed: false, + }); + + // If an event is removed from an api, a permission is removed, + // or some other option prevents priming, ensure that + // primelistener works correctly. + // In this scenario we are testing that an event is not renewed + // on startup because the API does not re-prime it. The result + // is that the event is also not persisted. However the other + // events that are renewed should still be primed and persisted. + extension.sendMessage("set-options", { + eventName: "onEvent1", + ignoreListener: true, + }); + await extension.awaitMessage("set-options:done"); + + // Shutdown the background page + await Promise.all([ + promiseObservable("unregister-event-listener", 2), + extension.terminateBackground(), + ]); + // startupBlocking.onEvent1 was not re-primed and should not be persisted, but + // onEvent2 should still be primed and persisted. + assertPersistentListeners(extension, "startupBlocking", "onEvent1", { + primed: false, + persisted: false, + }); + assertPersistentListeners(extension, "startupBlocking", "onEvent2", { + primed: true, + }); + + info("Triggering persistent event to force the background page to start"); + Services.obs.notifyObservers( + { listenerArgs: 123 }, + "fire-startupBlocking.onEvent2" + ); + await extension.awaitMessage("bg_started"); + equal(await extension.awaitMessage("triggered"), 123, "triggered event"); + + // On restart, test an exception, it should not be re-primed. + extension.sendMessage("set-options", { + eventName: "onEvent1", + throwError: "error", + }); + await extension.awaitMessage("set-options:done"); + + // Shutdown the background page + await Promise.all([ + promiseObservable("unregister-event-listener", 1), + extension.terminateBackground(), + ]); + // startupBlocking.onEvent1 failed and should not be persisted + assertPersistentListeners(extension, "startupBlocking", "onEvent1", { + primed: false, + persisted: false, + }); + + info("Triggering event to verify background starts after prior error"); + Services.obs.notifyObservers( + { listenerArgs: 123 }, + "fire-startupBlocking.onEvent2" + ); + await extension.awaitMessage("bg_started"); + equal(await extension.awaitMessage("triggered"), 123, "triggered event"); + + info("reset options for next test"); + extension.sendMessage("set-options", {}); + await extension.awaitMessage("set-options:done"); + + // Test errors on app restart + info("Test errors during app startup"); + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + await extension.awaitMessage("bg_started"); + + info("restart AOM and verify primed listener"); + await AddonTestUtils.promiseRestartManager({ earlyStartup: false }); + await extension.awaitStartup(); + assertPersistentListeners(extension, "startupBlocking", "onEvent1", { + primed: true, + persisted: true, + }); + AddonTestUtils.notifyEarlyStartup(); + + Services.obs.notifyObservers( + { listenerArgs: 123 }, + "fire-startupBlocking.onEvent1" + ); + await extension.awaitMessage("bg_started"); + equal(await extension.awaitMessage("triggered"), 123, "triggered event"); + + // Test that an exception happening during priming clears the + // event from being persisted when restarting the browser, and that + // the background correctly starts. + info("test exception during primeListener on startup"); + extension.sendMessage("set-options", { + eventName: "onEvent1", + throwError: "error", + }); + await extension.awaitMessage("set-options:done"); + + await AddonTestUtils.promiseRestartManager({ earlyStartup: false }); + await extension.awaitStartup(); + AddonTestUtils.notifyEarlyStartup(); + + // At this point, the exception results in the persisted entry + // being cleared. + assertPersistentListeners(extension, "startupBlocking", "onEvent1", { + primed: false, + persisted: false, + }); + + AddonTestUtils.notifyLateStartup(); + + await extension.awaitMessage("bg_started"); + + // The background added the listener back during top level execution, + // verify it is in the persisted list. + assertPersistentListeners(extension, "startupBlocking", "onEvent1", { + primed: false, + persisted: true, + }); + + // reset options + extension.sendMessage("set-options", {}); + await extension.awaitMessage("set-options:done"); + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); +}); + +add_task(async function test_non_background_context_listener_not_persisted() { + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background() { + let listener = arg => browser.test.sendMessage("triggered", arg); + browser.startupBlocking.onEvent1.addListener(listener, "triggered"); + browser.test.sendMessage( + "bg_started", + browser.runtime.getURL("extpage.html") + ); + }, + files: { + "extpage.html": `<script src="extpage.js"></script>`, + "extpage.js": function () { + let listener = arg => + browser.test.sendMessage("extpage-triggered", arg); + browser.startupBlocking.onEvent2.addListener( + listener, + "extpage-triggered" + ); + // Send a message to signal the extpage has registered the listener, + // after calling an async method and wait it to be resolved to make sure + // the addListener call to have been handled in the parent process by + // the time we will assert the persisted listeners. + browser.runtime.getPlatformInfo().then(() => { + browser.test.sendMessage("extpage_started"); + }); + }, + }, + }); + + await extension.startup(); + const extpage_url = await extension.awaitMessage("bg_started"); + + assertPersistentListeners(extension, "startupBlocking", "onEvent1", { + persisted: true, + primed: false, + }); + + assertPersistentListeners(extension, "startupBlocking", "onEvent2", { + persisted: false, + }); + + const page = await ExtensionTestUtils.loadContentPage(extpage_url); + await extension.awaitMessage("extpage_started"); + + // Expect the onEvent2 listener subscribed by the extpage to not be persisted. + assertPersistentListeners(extension, "startupBlocking", "onEvent2", { + persisted: false, + }); + + await page.close(); + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); +}); + +// Test support for event page tests +const background = async function () { + let listener2 = () => + browser.test.sendMessage("triggered:non-startupblocking"); + browser.startupBlocking.onEvent1.addListener(() => {}); + browser.startupBlocking.nonBlockingEvent.addListener(() => {}); + browser.nonStartupBlocking.onEvent2.addListener(listener2); + browser.test.sendMessage("bg_started"); +}; + +const background_update = async function () { + browser.startupBlocking.onEvent1.addListener(() => {}); + browser.nonStartupBlocking.onEvent2.addListener(() => {}); + browser.test.sendMessage("updated_bg_started"); +}; + +function testPersistentListeners(extension, expect) { + for (let [ns, event, persisted, primed] of expect) { + assertPersistentListeners(extension, ns, event, { + persisted, + primed, + }); + } +} + +add_task( + { pref_set: [["extensions.eventPages.enabled", true]] }, + async function test_startupblocking_behavior() { + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + background: { persistent: false }, + }, + background, + }); + + await extension.startup(); + await extension.awaitMessage("bg_started"); + + // All are persisted on startup + testPersistentListeners(extension, [ + ["startupBlocking", "onEvent1", true, false], + ["startupBlocking", "nonBlockingEvent", true, false], + ["nonStartupBlocking", "onEvent2", true, false], + ]); + + info("Test after mocked browser restart"); + await Promise.all([ + new Promise(resolve => extension.extension.once("shutdown", resolve)), + AddonTestUtils.promiseShutdownManager(), + ]); + await AddonTestUtils.promiseStartupManager({ lateStartup: false }); + await extension.awaitStartup(); + + testPersistentListeners(extension, [ + // Startup blocking event is expected to be persisted and primed. + ["startupBlocking", "onEvent1", true, true], + // A non-startup-blocking event shouldn't be primed yet. + ["startupBlocking", "nonBlockingEvent", true, false], + // Non "Startup blocking" event is expected to be persisted but not primed yet. + ["nonStartupBlocking", "onEvent2", true, false], + ]); + + // Complete the browser startup and fire the startup blocking event + // to let the backgrund script to run. + AddonTestUtils.notifyLateStartup(); + Services.obs.notifyObservers({}, "fire-startupBlocking.onEvent1"); + await extension.awaitMessage("bg_started"); + + info("Test after terminate background script"); + await extension.terminateBackground(); + + // After the background is terminated, all are persisted and primed. + testPersistentListeners(extension, [ + ["startupBlocking", "onEvent1", true, true], + ["startupBlocking", "nonBlockingEvent", true, true], + ["nonStartupBlocking", "onEvent2", true, true], + ]); + + info("Notify event for the non-startupBlocking API event"); + Services.obs.notifyObservers({}, "fire-nonStartupBlocking.onEvent2"); + await extension.awaitMessage("bg_started"); + await extension.awaitMessage("triggered:non-startupblocking"); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + } +); + +add_task( + { pref_set: [["extensions.eventPages.enabled", true]] }, + async function test_startupblocking_behavior_upgrade() { + let id = "persistent-upgrade@test"; + await AddonTestUtils.promiseStartupManager(); + + let extensionData = { + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { id }, + }, + background: { persistent: false }, + }, + background, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("bg_started"); + + // All are persisted on startup + testPersistentListeners(extension, [ + ["startupBlocking", "onEvent1", true, false], + ["startupBlocking", "nonBlockingEvent", true, false], + ["nonStartupBlocking", "onEvent2", true, false], + ]); + + // Prepare the extension that will be updated. + extensionData.manifest.version = "2.0"; + extensionData.background = background_update; + + info("Test after a upgrade"); + await extension.upgrade(extensionData); + // upgrade should start the background + await extension.awaitMessage("updated_bg_started"); + + // Nothing should be primed at this point after the background + // has started. We look specifically for nonBlockingEvent to + // no longer be a part of the persisted listeners. + testPersistentListeners(extension, [ + ["startupBlocking", "onEvent1", true, false], + ["startupBlocking", "nonBlockingEvent", false, false], + ["nonStartupBlocking", "onEvent2", true, false], + ]); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + } +); + +add_task( + { pref_set: [["extensions.eventPages.enabled", true]] }, + async function test_startupblocking_behavior_staged_upgrade() { + AddonManager.checkUpdateSecurity = false; + let id = "persistent-staged-upgrade@test"; + + // register an update file. + AddonTestUtils.registerJSON(server, "/test_update.json", { + addons: { + [id]: { + updates: [ + { + version: "2.0", + update_link: + "http://example.com/addons/test_settings_staged_restart.xpi", + }, + ], + }, + }, + }); + + let extensionData = { + useAddonManager: "permanent", + manifest: { + version: "2.0", + browser_specific_settings: { + gecko: { id, update_url: `http://example.com/test_update.json` }, + }, + background: { persistent: false }, + }, + background: background_update, + }; + + // Prepare the update first. + server.registerFile( + `/addons/test_settings_staged_restart.xpi`, + AddonTestUtils.createTempWebExtensionFile(extensionData) + ); + + // Prepare the extension that will be updated. + extensionData.manifest.version = "1.0"; + extensionData.background = async function () { + // we're testing persistence, not operation, so no action in listeners. + browser.startupBlocking.onEvent1.addListener(() => {}); + // nonBlockingEvent will be removed on upgrade + browser.startupBlocking.nonBlockingEvent.addListener(() => {}); + browser.nonStartupBlocking.onEvent2.addListener(() => {}); + + // Force a staged updated. + browser.runtime.onUpdateAvailable.addListener(async details => { + // This should be the version of the pending update. + browser.test.assertEq("2.0", details.version, "correct version"); + browser.test.sendMessage("delay"); + }); + + browser.test.sendMessage("bg_started"); + }; + + await AddonTestUtils.promiseStartupManager(); + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + await extension.awaitMessage("bg_started"); + + // All are persisted but not primed on startup + testPersistentListeners(extension, [ + ["startupBlocking", "onEvent1", true, false], + ["startupBlocking", "nonBlockingEvent", true, false], + ["nonStartupBlocking", "onEvent2", true, false], + ]); + + info("Test after a staged update"); + // first, deal with getting and staging an upgrade + let addon = await AddonManager.getAddonByID(id); + Assert.equal(addon.version, "1.0", "1.0 is loaded"); + + let update = await AddonTestUtils.promiseFindAddonUpdates(addon); + let install = update.updateAvailable; + Assert.ok(install, `install is available ${update.error}`); + + await AddonTestUtils.promiseCompleteAllInstalls([install]); + + Assert.equal( + install.state, + AddonManager.STATE_POSTPONED, + "update is staged for install" + ); + await extension.awaitMessage("delay"); + + await AddonTestUtils.promiseShutdownManager(); + + // restarting allows upgrade to proceed + await AddonTestUtils.promiseStartupManager(); + // upgrade should always start the background + await extension.awaitMessage("updated_bg_started"); + + // Since this is an upgraded addon, the background will have started + // and we no longer have primed listeners. Check only the persisted + // values, and that nonBlockingEvent is not persisted. + testPersistentListeners(extension, [ + ["startupBlocking", "onEvent1", true, false], + ["startupBlocking", "nonBlockingEvent", false, false], + ["nonStartupBlocking", "onEvent2", true, false], + ]); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + } +); + +// Regression test for Bug 1795801: +// - verifies that multiple listeners sharing the same event and set of extra +// params are being stored in the startupData and then all primed on the next +// startup +// - verifies behaviors expected when startupData stored from an older +// Firefox version (one that didn't include Bug 1795801 changes) is +// loaded from a new Firefox version +// - a small smoke test to also verify the behaviors when startupData stored +// by a newer version is being loaded by an older one (where Bug 1795801 +// changes have not been introduced yet). +add_task(async function test_migrate_startupData_to_new_format() { + await AddonTestUtils.promiseStartupManager(); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + background: { persistent: false }, + }, + background() { + const eventParams = [ + { fromCustomParam1: "value1" }, + ["fromCustomParam2"], + ]; + const otherEventParams = [ + { fromCustomParam1: "value2" }, + ["fromCustomParam2Other"], + ]; + browser.nonStartupBlocking.onEvent3.addListener(function listener1(arg) { + browser.test.log("listener1 called on nonStartupBlocking.onEvent3"); + browser.test.sendMessage("listener1", arg); + }, ...eventParams); + browser.nonStartupBlocking.onEvent3.addListener(function listener2(arg) { + browser.test.log("listener2 called on nonStartupBlocking.onEvent3"); + browser.test.sendMessage("listener2", arg); + }, ...eventParams); + browser.nonStartupBlocking.onEvent3.addListener(function listener3(arg) { + browser.test.log("listener3 called on nonStartupBlocking.onEvent3"); + browser.test.sendMessage("listener3", arg); + }, ...otherEventParams); + browser.test.sendMessage("ready"); + }, + }); + + // Data expected to be stored in the extension startupData with the new + // format and old format. + const STARTUP_DATA = { + newPersistentListenersFormat: { + nonStartupBlocking: { + onEvent3: [ + // 2 listeners registered with the same set of extra params + [{ fromCustomParam1: "value1" }, ["fromCustomParam2"]], + [{ fromCustomParam1: "value1" }, ["fromCustomParam2"]], + // 1 listener registered with different set of extra params + [{ fromCustomParam1: "value2" }, ["fromCustomParam2Other"]], + ], + }, + }, + oldPersistentListenersFormat: { + nonStartupBlocking: { + onEvent3: [ + [{ fromCustomParam1: "value1" }, ["fromCustomParam2"]], + [{ fromCustomParam1: "value2" }, ["fromCustomParam2Other"]], + ], + }, + }, + }; + + function getXPIStatesFilePath() { + let { path } = ChromeUtils.importESModule( + "resource://gre/modules/addons/XPIExports.sys.mjs" + ).XPIExports.XPIInternal.XPIStates._jsonFile; + ok( + typeof path === "string" && !!path.length, + `Found XPIStates file path: ${path}` + ); + return path; + } + + async function tamperStartupData(testExtensionWrapper) { + const { startupData } = testExtensionWrapper.extension; + Assert.deepEqual( + startupData.persistentListeners, + STARTUP_DATA.newPersistentListenersFormat, + "Got data stored from extension.startupData.persistentListeners" + ); + + startupData.persistentListeners = STARTUP_DATA.oldPersistentListenersFormat; + + // Force the data to be stored on disk (by requesting AddonTestUtils to flush + // the XPIStates after having tampered them to make sure they are in the + // format we expect from older Firefox versions). + testExtensionWrapper.extension.saveStartupData(); + await AddonTestUtils.loadAddonsList(/* flush */ true); + const { XPIExports } = ChromeUtils.importESModule( + "resource://gre/modules/addons/XPIExports.sys.mjs" + ); + XPIExports.XPIInternal.XPIStates.save(); + await XPIExports.XPIInternal.XPIStates._jsonFile._save(); + return getXPIStatesFilePath(); + } + + async function assertDiskStoredPersistentListeners( + extensionId, + xpiStatesPath, + expectedData + ) { + const xpiStatesData = await IOUtils.readJSON(xpiStatesPath, { + decompress: true, + }); + const startupData = + xpiStatesData["app-profile"]?.addons[extensionId]?.startupData; + ok(startupData, `Found startupData for test extension ${extensionId}`); + Assert.deepEqual( + startupData.persistentListeners, + expectedData, + "Got the expected tampered addon startupData stored on disk" + ); + } + + await extension.startup(); + + await extension.awaitMessage("ready"); + assertPersistentListeners(extension, "nonStartupBlocking", "onEvent3", { + persisted: true, + }); + + info( + "Manually tampering startupData.persistentListeners to match the format older Firefox format" + ); + const xpiStatesFilePath = await tamperStartupData(extension); + await AddonTestUtils.promiseShutdownManager(); + await assertDiskStoredPersistentListeners( + extension.id, + xpiStatesFilePath, + STARTUP_DATA.oldPersistentListenersFormat + ); + + info( + "Confirm that the expected listeners have been primed and the startupData migrated to the new format" + ); + + { + await AddonTestUtils.promiseStartupManager(); + await extension.awaitStartup; + + assertPersistentListeners(extension, "nonStartupBlocking", "onEvent3", { + primed: true, + // Old format of startupData.persistentListeners did not have a listenersCount + // property and so only two primed listeners are expected on the first startup + // after the addon startupData have been tampered to match the format expected + // by an older Firefox version. + primedListenersCount: 2, + }); + + const promiseListenersConverted = promiseObservable( + "convert-event-listener", + 2 + ); + Services.obs.notifyObservers( + { listenerArgs: "test-startup" }, + "fire-nonStartupBlocking.onEvent3" + ); + await promiseListenersConverted; + + deepEqual( + await extension.awaitMessage("listener1"), + "test-startup", + "Listener1 fired for event during startup" + ); + + deepEqual( + await extension.awaitMessage("listener3"), + "test-startup", + "Listener3 fired for event during startup" + ); + + await extension.awaitMessage("ready"); + + Assert.deepEqual( + extension.extension.startupData.persistentListeners, + STARTUP_DATA.newPersistentListenersFormat, + "Got startupData.persistentListeners migrated to the new format" + ); + } + + info( + "Confirm that the startupData written on disk have been migrated to the new format" + ); + + await AddonTestUtils.promiseShutdownManager(); + await assertDiskStoredPersistentListeners( + extension.id, + xpiStatesFilePath, + STARTUP_DATA.newPersistentListenersFormat + ); + + info( + "Verify that both listeners are called after migrating to the new format" + ); + { + await AddonTestUtils.promiseStartupManager(); + await extension.awaitStartup; + + assertPersistentListeners(extension, "nonStartupBlocking", "onEvent3", { + primed: true, + primedListenersCount: 3, + }); + + const promiseListenersConverted = promiseObservable( + "convert-event-listener", + 2 + ); + Services.obs.notifyObservers( + { listenerArgs: "test-startup" }, + "fire-nonStartupBlocking.onEvent3" + ); + await promiseListenersConverted; + + // Now we expect both the listeners to have been called. + deepEqual( + await extension.awaitMessage("listener1"), + "test-startup", + "Listener1 fired for event during startup" + ); + + deepEqual( + await extension.awaitMessage("listener2"), + "test-startup", + "Listener2 fired for event during startup" + ); + + deepEqual( + await extension.awaitMessage("listener3"), + "test-startup", + "Listener3 fired for event during startup" + ); + + await extension.awaitMessage("ready"); + } + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + + // The additional assertions below are meant to provide a smoke test covering + // the behavior we would expect if an older Firefox versions (one that would + // expect the old format) is loading persistentListeners from startupData + // using the new format. + info("Verify backward compatibility with old format"); + + const { ExtensionUtils } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionUtils.sys.mjs" + ); + const { DefaultMap } = ExtensionUtils; + const loadedListeners = new DefaultMap(() => new DefaultMap(() => new Map())); + + // Logic from older Firefox versions expecting the old format + // (https://searchfox.org/mozilla-central/rev/cd2121e7d8/toolkit/components/extensions/ExtensionCommon.jsm#2360-2371) + let found = false; + for (let [module, entry] of Object.entries( + STARTUP_DATA.newPersistentListenersFormat + )) { + for (let [event, paramlists] of Object.entries(entry)) { + for (let paramlist of paramlists) { + let key = uneval(paramlist); + loadedListeners.get(module).get(event).set(key, { params: paramlist }); + found = true; + } + } + } + + Assert.ok( + found, + "Expect persistentListeners to have been found from the old loading logic" + ); + + // We expect the older Firefox version to don't choke on loading + // the new format, a primed listener is still expected to be + // found because the old Firefox version will be overriding a single + // entry in the inmemory Map with the multiple entries from the + // ondisk format listing the same extra params for multiple listeners, + // Bug 1795801 would still be hit, but no other change in behavior is + // expected to be hit with the old logic. + Assert.ok( + loadedListeners + .get("nonStartupBlocking") + .get("onEvent3") + .has(uneval([{ fromCustomParam1: "value1" }, ["fromCustomParam2"]])), + "Expect the listener params key to be found in older Firefox versions" + ); +}); + +add_task( + { pref_set: [["extensions.eventPages.enabled", true]] }, + async function test_resetOnIdleOnEvent_false_on_other_extpages() { + await AddonTestUtils.promiseStartupManager(); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + files: { + "extpage.html": `<!DOCTYPE html><script src="extpage.js"></script>`, + "extpage.js": function () { + // We expect this to throw if the EventManagerWithAssertions constructor + // throws when asserting that resetIdleOnEvent was forcefully set to + // false for a non-event page context. + browser.nonStartupBlocking.onEvent2.addListener(() => {}); + browser.test.sendMessage("extpage:loaded"); + }, + }, + }); + + await extension.startup(); + + const awaitRegisteredEventListener = promiseObservable( + "register-event-listener", + 1 + ); + const page = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/extpage.html` + ); + + info("Wait for the extension page script to complete"); + + await Promise.all([ + extension.awaitMessage("extpage:loaded"), + awaitRegisteredEventListener, + ]); + await page.close(); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_privacy.js b/toolkit/components/extensions/test/xpcshell/test_ext_privacy.js new file mode 100644 index 0000000000..1d5f2a8b30 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_privacy.js @@ -0,0 +1,979 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionPreferencesManager: + "resource://gre/modules/ExtensionPreferencesManager.sys.mjs", + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +const { createAppInfo, promiseShutdownManager, promiseStartupManager } = + AddonTestUtils; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +// Currently security.tls.version.min has a different default +// value in Nightly and Beta as opposed to Release builds. +const tlsMinPref = Services.prefs.getIntPref("security.tls.version.min"); +if (tlsMinPref != 1 && tlsMinPref != 3) { + ok(false, "This test expects security.tls.version.min set to 1 or 3."); +} +const tlsMinVer = tlsMinPref === 3 ? "TLSv1.2" : "TLSv1"; +const READ_ONLY = true; + +add_task(async function test_privacy() { + // Create an object to hold the values to which we will initialize the prefs. + const SETTINGS = { + "network.networkPredictionEnabled": { + "network.predictor.enabled": true, + "network.prefetch-next": true, + // This pref starts with a numerical value and we need to use whatever the + // default is or we encounter issues when the pref is reset during the test. + "network.http.speculative-parallel-limit": + ExtensionPreferencesManager.getDefaultValue( + "network.http.speculative-parallel-limit" + ), + "network.dns.disablePrefetch": false, + }, + "websites.hyperlinkAuditingEnabled": { + "browser.send_pings": true, + }, + }; + + async function background() { + browser.test.onMessage.addListener(async (msg, data, setting) => { + // The second argument is the end of the api name, + // e.g., "network.networkPredictionEnabled". + let apiObj = setting.split(".").reduce((o, i) => o[i], browser.privacy); + let settingData; + switch (msg) { + case "get": + settingData = await apiObj.get(data); + browser.test.sendMessage("gotData", settingData); + break; + + case "set": + await apiObj.set(data); + settingData = await apiObj.get({}); + browser.test.sendMessage("afterSet", settingData); + break; + + case "clear": + await apiObj.clear(data); + settingData = await apiObj.get({}); + browser.test.sendMessage("afterClear", settingData); + break; + } + }); + } + + // Set prefs to our initial values. + for (let setting in SETTINGS) { + for (let pref in SETTINGS[setting]) { + Preferences.set(pref, SETTINGS[setting][pref]); + } + } + + registerCleanupFunction(() => { + // Reset the prefs. + for (let setting in SETTINGS) { + for (let pref in SETTINGS[setting]) { + Preferences.reset(pref); + } + } + }); + + await promiseStartupManager(); + + // Create an array of extensions to install. + let testExtensions = [ + ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["privacy"], + }, + useAddonManager: "temporary", + }), + + ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["privacy"], + }, + useAddonManager: "temporary", + }), + ]; + + for (let extension of testExtensions) { + await extension.startup(); + } + + for (let setting in SETTINGS) { + testExtensions[0].sendMessage("get", {}, setting); + let data = await testExtensions[0].awaitMessage("gotData"); + ok(data.value, "get returns expected value."); + equal( + data.levelOfControl, + "controllable_by_this_extension", + "get returns expected levelOfControl." + ); + + testExtensions[0].sendMessage("get", { incognito: true }, setting); + data = await testExtensions[0].awaitMessage("gotData"); + ok(data.value, "get returns expected value with incognito."); + equal( + data.levelOfControl, + "not_controllable", + "get returns expected levelOfControl with incognito." + ); + + // Change the value to false. + testExtensions[0].sendMessage("set", { value: false }, setting); + data = await testExtensions[0].awaitMessage("afterSet"); + ok(!data.value, "get returns expected value after setting."); + equal( + data.levelOfControl, + "controlled_by_this_extension", + "get returns expected levelOfControl after setting." + ); + + // Verify the prefs have been set to match the "false" setting. + for (let pref in SETTINGS[setting]) { + let msg = `${pref} set correctly for ${setting}`; + if (pref === "network.http.speculative-parallel-limit") { + equal(Preferences.get(pref), 0, msg); + } else { + equal(Preferences.get(pref), !SETTINGS[setting][pref], msg); + } + } + + // Change the value with a newer extension. + testExtensions[1].sendMessage("set", { value: true }, setting); + data = await testExtensions[1].awaitMessage("afterSet"); + ok( + data.value, + "get returns expected value after setting via newer extension." + ); + equal( + data.levelOfControl, + "controlled_by_this_extension", + "get returns expected levelOfControl after setting." + ); + + // Verify the prefs have been set to match the "true" setting. + for (let pref in SETTINGS[setting]) { + let msg = `${pref} set correctly for ${setting}`; + if (pref === "network.http.speculative-parallel-limit") { + equal( + Preferences.get(pref), + ExtensionPreferencesManager.getDefaultValue(pref), + msg + ); + } else { + equal(Preferences.get(pref), SETTINGS[setting][pref], msg); + } + } + + // Change the value with an older extension. + testExtensions[0].sendMessage("set", { value: false }, setting); + data = await testExtensions[0].awaitMessage("afterSet"); + ok(data.value, "Newer extension remains in control."); + equal( + data.levelOfControl, + "controlled_by_other_extensions", + "get returns expected levelOfControl when controlled by other." + ); + + // Clear the value of the newer extension. + testExtensions[1].sendMessage("clear", {}, setting); + data = await testExtensions[1].awaitMessage("afterClear"); + ok(!data.value, "Older extension gains control."); + equal( + data.levelOfControl, + "controllable_by_this_extension", + "Expected levelOfControl returned after clearing." + ); + + testExtensions[0].sendMessage("get", {}, setting); + data = await testExtensions[0].awaitMessage("gotData"); + ok(!data.value, "Current, older extension has control."); + equal( + data.levelOfControl, + "controlled_by_this_extension", + "Expected levelOfControl returned after clearing." + ); + + // Set the value again with the newer extension. + testExtensions[1].sendMessage("set", { value: true }, setting); + data = await testExtensions[1].awaitMessage("afterSet"); + ok( + data.value, + "get returns expected value after setting via newer extension." + ); + equal( + data.levelOfControl, + "controlled_by_this_extension", + "get returns expected levelOfControl after setting." + ); + + // Unload the newer extension. Expect the older extension to regain control. + await testExtensions[1].unload(); + testExtensions[0].sendMessage("get", {}, setting); + data = await testExtensions[0].awaitMessage("gotData"); + ok(!data.value, "Older extension regained control."); + equal( + data.levelOfControl, + "controlled_by_this_extension", + "Expected levelOfControl returned after unloading." + ); + + // Reload the extension for the next iteration of the loop. + testExtensions[1] = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["privacy"], + }, + useAddonManager: "temporary", + }); + await testExtensions[1].startup(); + + // Clear the value of the older extension. + testExtensions[0].sendMessage("clear", {}, setting); + data = await testExtensions[0].awaitMessage("afterClear"); + ok(data.value, "Setting returns to original value when all are cleared."); + equal( + data.levelOfControl, + "controllable_by_this_extension", + "Expected levelOfControl returned after clearing." + ); + + // Verify that our initial values were restored. + for (let pref in SETTINGS[setting]) { + equal( + Preferences.get(pref), + SETTINGS[setting][pref], + `${pref} was reset to its initial value.` + ); + } + } + + for (let extension of testExtensions) { + await extension.unload(); + } + + await promiseShutdownManager(); +}); + +add_task(async function test_privacy_other_prefs() { + registerCleanupFunction(() => { + Services.prefs.clearUserPref("security.tls.version.min"); + Services.prefs.clearUserPref("security.tls.version.max"); + }); + + const cookieSvc = Ci.nsICookieService; + + // Create an object to hold the values to which we will initialize the prefs. + const SETTINGS = { + "network.webRTCIPHandlingPolicy": { + "media.peerconnection.ice.default_address_only": false, + "media.peerconnection.ice.no_host": false, + "media.peerconnection.ice.proxy_only_if_behind_proxy": false, + "media.peerconnection.ice.proxy_only": false, + }, + "network.tlsVersionRestriction": { + "security.tls.version.min": tlsMinPref, + "security.tls.version.max": 4, + }, + "network.peerConnectionEnabled": { + "media.peerconnection.enabled": true, + }, + "services.passwordSavingEnabled": { + "signon.rememberSignons": true, + }, + "websites.referrersEnabled": { + "network.http.sendRefererHeader": 2, + }, + "websites.resistFingerprinting": { + "privacy.resistFingerprinting": true, + }, + "websites.firstPartyIsolate": { + "privacy.firstparty.isolate": false, + }, + "websites.cookieConfig": { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_ACCEPT, + }, + }; + + let defaultPrefs = new Preferences({ defaultBranch: true }); + let defaultCookieBehavior = defaultPrefs.get("network.cookie.cookieBehavior"); + let defaultBehavior; + switch (defaultCookieBehavior) { + case cookieSvc.BEHAVIOR_ACCEPT: + defaultBehavior = "allow_all"; + break; + case cookieSvc.BEHAVIOR_REJECT_FOREIGN: + defaultBehavior = "reject_third_party"; + break; + case cookieSvc.BEHAVIOR_REJECT: + defaultBehavior = "reject_all"; + break; + case cookieSvc.BEHAVIOR_LIMIT_FOREIGN: + defaultBehavior = "allow_visited"; + break; + case cookieSvc.BEHAVIOR_REJECT_TRACKER: + defaultBehavior = "reject_trackers"; + break; + case cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN: + defaultBehavior = "reject_trackers_and_partition_foreign"; + break; + default: + ok( + false, + `Unexpected cookie behavior encountered: ${defaultCookieBehavior}` + ); + break; + } + + async function background() { + let listeners = new Set([]); + browser.test.onMessage.addListener(async (msg, data, setting, readOnly) => { + // The second argument is the end of the api name, + // e.g., "network.webRTCIPHandlingPolicy". + let apiObj = setting.split(".").reduce((o, i) => o[i], browser.privacy); + if (msg == "get") { + browser.test.sendMessage("gettingData", await apiObj.get({})); + return; + } + + // Don't add more than one listener per apiName. We leave the + // listener to ensure we do not get more calls than we expect. + if (!listeners.has(setting)) { + apiObj.onChange.addListener(details => { + browser.test.sendMessage("settingData", details); + }); + listeners.add(setting); + } + try { + await apiObj.set(data); + } catch (e) { + browser.test.sendMessage("settingThrowsException", { + message: e.message, + }); + } + // Readonly settings will not trigger onChange, return the setting now. + if (readOnly) { + browser.test.sendMessage("settingData", await apiObj.get({})); + } + }); + } + + // Set prefs to our initial values. + for (let setting in SETTINGS) { + for (let pref in SETTINGS[setting]) { + Preferences.set(pref, SETTINGS[setting][pref]); + } + } + + registerCleanupFunction(() => { + // Reset the prefs. + for (let setting in SETTINGS) { + for (let pref in SETTINGS[setting]) { + Preferences.reset(pref); + } + } + }); + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["privacy"], + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + + async function testSetting(setting, value, expected, expectedValue = value) { + extension.sendMessage("set", { value: value }, setting); + let data = await extension.awaitMessage("settingData"); + deepEqual( + data.value, + expectedValue, + `Got expected result on setting ${setting} to ${uneval(value)}` + ); + for (let pref in expected) { + equal( + Preferences.get(pref), + expected[pref], + `${pref} set correctly for ${expected[pref]}` + ); + } + } + + async function testSettingException(setting, value, expected) { + extension.sendMessage("set", { value: value }, setting); + let data = await extension.awaitMessage("settingThrowsException"); + equal(data.message, expected); + } + + async function testGetting(getting, expected, expectedValue) { + extension.sendMessage("get", null, getting); + let data = await extension.awaitMessage("gettingData"); + deepEqual( + data.value, + expectedValue, + `Got expected result on getting ${getting}` + ); + for (let pref in expected) { + equal( + Preferences.get(pref), + expected[pref], + `${pref} get correctly for ${expected[pref]}` + ); + } + } + + await testSetting( + "network.webRTCIPHandlingPolicy", + "default_public_and_private_interfaces", + { + "media.peerconnection.ice.default_address_only": true, + "media.peerconnection.ice.no_host": false, + "media.peerconnection.ice.proxy_only_if_behind_proxy": false, + "media.peerconnection.ice.proxy_only": false, + } + ); + await testSetting( + "network.webRTCIPHandlingPolicy", + "default_public_interface_only", + { + "media.peerconnection.ice.default_address_only": true, + "media.peerconnection.ice.no_host": true, + "media.peerconnection.ice.proxy_only_if_behind_proxy": false, + "media.peerconnection.ice.proxy_only": false, + } + ); + await testSetting( + "network.webRTCIPHandlingPolicy", + "disable_non_proxied_udp", + { + "media.peerconnection.ice.default_address_only": true, + "media.peerconnection.ice.no_host": true, + "media.peerconnection.ice.proxy_only_if_behind_proxy": true, + "media.peerconnection.ice.proxy_only": false, + } + ); + await testSetting("network.webRTCIPHandlingPolicy", "proxy_only", { + "media.peerconnection.ice.default_address_only": false, + "media.peerconnection.ice.no_host": false, + "media.peerconnection.ice.proxy_only_if_behind_proxy": false, + "media.peerconnection.ice.proxy_only": true, + }); + await testSetting("network.webRTCIPHandlingPolicy", "default", { + "media.peerconnection.ice.default_address_only": false, + "media.peerconnection.ice.no_host": false, + "media.peerconnection.ice.proxy_only_if_behind_proxy": false, + "media.peerconnection.ice.proxy_only": false, + }); + + await testSetting("network.peerConnectionEnabled", false, { + "media.peerconnection.enabled": false, + }); + await testSetting("network.peerConnectionEnabled", true, { + "media.peerconnection.enabled": true, + }); + + await testSetting("websites.referrersEnabled", false, { + "network.http.sendRefererHeader": 0, + }); + await testSetting("websites.referrersEnabled", true, { + "network.http.sendRefererHeader": 2, + }); + + await testSetting("websites.resistFingerprinting", false, { + "privacy.resistFingerprinting": false, + }); + await testSetting("websites.resistFingerprinting", true, { + "privacy.resistFingerprinting": true, + }); + + await testSetting("websites.trackingProtectionMode", "always", { + "privacy.trackingprotection.enabled": true, + "privacy.trackingprotection.pbmode.enabled": true, + }); + await testSetting("websites.trackingProtectionMode", "never", { + "privacy.trackingprotection.enabled": false, + "privacy.trackingprotection.pbmode.enabled": false, + }); + await testSetting("websites.trackingProtectionMode", "private_browsing", { + "privacy.trackingprotection.enabled": false, + "privacy.trackingprotection.pbmode.enabled": true, + }); + + await testSetting("services.passwordSavingEnabled", false, { + "signon.rememberSignons": false, + }); + await testSetting("services.passwordSavingEnabled", true, { + "signon.rememberSignons": true, + }); + + await testSetting( + "websites.cookieConfig", + { behavior: "reject_third_party", nonPersistentCookies: true }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT_FOREIGN, + }, + { behavior: "reject_third_party", nonPersistentCookies: false } + ); + // A missing nonPersistentCookies property should default to false. + await testSetting( + "websites.cookieConfig", + { behavior: "reject_third_party" }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT_FOREIGN, + }, + { behavior: "reject_third_party", nonPersistentCookies: false } + ); + // A missing behavior property should reset the pref. + await testSetting( + "websites.cookieConfig", + { nonPersistentCookies: true }, + { + "network.cookie.cookieBehavior": defaultCookieBehavior, + }, + { behavior: defaultBehavior, nonPersistentCookies: false } + ); + await testSetting( + "websites.cookieConfig", + { behavior: "reject_all" }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT, + }, + { behavior: "reject_all", nonPersistentCookies: false } + ); + await testSetting( + "websites.cookieConfig", + { behavior: "allow_visited" }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_LIMIT_FOREIGN, + }, + { behavior: "allow_visited", nonPersistentCookies: false } + ); + await testSetting( + "websites.cookieConfig", + { behavior: "allow_all" }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_ACCEPT, + }, + { behavior: "allow_all", nonPersistentCookies: false } + ); + await testSetting( + "websites.cookieConfig", + { nonPersistentCookies: true }, + { + "network.cookie.cookieBehavior": defaultCookieBehavior, + }, + { behavior: defaultBehavior, nonPersistentCookies: false } + ); + await testSetting( + "websites.cookieConfig", + { nonPersistentCookies: false }, + { + "network.cookie.cookieBehavior": defaultCookieBehavior, + }, + { behavior: defaultBehavior, nonPersistentCookies: false } + ); + await testSetting( + "websites.cookieConfig", + { behavior: "reject_trackers" }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT_TRACKER, + }, + { behavior: "reject_trackers", nonPersistentCookies: false } + ); + await testSetting( + "websites.cookieConfig", + { behavior: "reject_trackers_and_partition_foreign" }, + { + "network.cookie.cookieBehavior": + cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN, + }, + { + behavior: "reject_trackers_and_partition_foreign", + nonPersistentCookies: false, + } + ); + + // 1. Can't enable FPI when cookie behavior is "reject_trackers_and_partition_foreign" + await testSettingException( + "websites.firstPartyIsolate", + true, + "Can't enable firstPartyIsolate when cookieBehavior is 'reject_trackers_and_partition_foreign'" + ); + + // 2. Change cookieConfig to reject_trackers should work normally. + await testSetting( + "websites.cookieConfig", + { behavior: "reject_trackers" }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT_TRACKER, + }, + { behavior: "reject_trackers", nonPersistentCookies: false } + ); + + // 3. Enable FPI + await testSetting("websites.firstPartyIsolate", true, { + "privacy.firstparty.isolate": true, + }); + + // 4. When FPI is enabled, change setting to "reject_trackers_and_partition_foreign" is invalid + await testSettingException( + "websites.cookieConfig", + { behavior: "reject_trackers_and_partition_foreign" }, + "Invalid cookieConfig 'reject_trackers_and_partition_foreign' when firstPartyIsolate is enabled" + ); + + // 5. Set conflict settings manually and check prefs. + Preferences.set("network.cookie.cookieBehavior", 5); + await testGetting( + "websites.firstPartyIsolate", + { "privacy.firstparty.isolate": true }, + true + ); + await testGetting( + "websites.cookieConfig", + { + "network.cookie.cookieBehavior": + cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN, + }, + { + behavior: "reject_trackers_and_partition_foreign", + nonPersistentCookies: false, + } + ); + + // 6. It is okay to set current saved value. + await testSetting("websites.firstPartyIsolate", true, { + "privacy.firstparty.isolate": true, + }); + await testSetting( + "websites.cookieConfig", + { behavior: "reject_trackers_and_partition_foreign" }, + { + "network.cookie.cookieBehavior": + cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN, + }, + { + behavior: "reject_trackers_and_partition_foreign", + nonPersistentCookies: false, + } + ); + + await testSetting("websites.firstPartyIsolate", false, { + "privacy.firstparty.isolate": false, + }); + + await testSetting( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.2", + maximum: "TLSv1.3", + }, + { + "security.tls.version.min": 3, + "security.tls.version.max": 4, + } + ); + + // Single values + await testSetting( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.3", + }, + { + "security.tls.version.min": 4, + "security.tls.version.max": 4, + }, + { + minimum: "TLSv1.3", + maximum: "TLSv1.3", + } + ); + + // Single values + await testSetting( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.3", + }, + { + "security.tls.version.min": 4, + "security.tls.version.max": 4, + }, + { + minimum: "TLSv1.3", + maximum: "TLSv1.3", + } + ); + + // Invalid values. + await testSettingException( + "network.tlsVersionRestriction", + { + minimum: "invalid", + maximum: "invalid", + }, + "Setting TLS version invalid is not allowed for security reasons." + ); + + // Invalid values. + await testSettingException( + "network.tlsVersionRestriction", + { + minimum: "invalid2", + }, + "Setting TLS version invalid2 is not allowed for security reasons." + ); + + // Invalid values. + await testSettingException( + "network.tlsVersionRestriction", + { + maximum: "invalid3", + }, + "Setting TLS version invalid3 is not allowed for security reasons." + ); + + await testSetting( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.2", + }, + { + "security.tls.version.min": 3, + "security.tls.version.max": 4, + }, + { + minimum: "TLSv1.2", + maximum: "TLSv1.3", + } + ); + + await testSetting( + "network.tlsVersionRestriction", + { + maximum: "TLSv1.2", + }, + { + "security.tls.version.min": tlsMinPref, + "security.tls.version.max": 3, + }, + { + minimum: tlsMinVer, + maximum: "TLSv1.2", + } + ); + + // Not supported version. + if (tlsMinPref === 3) { + await testSettingException( + "network.tlsVersionRestriction", + { + minimum: "TLSv1", + }, + "Setting TLS version TLSv1 is not allowed for security reasons." + ); + + await testSettingException( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.1", + }, + "Setting TLS version TLSv1.1 is not allowed for security reasons." + ); + + await testSettingException( + "network.tlsVersionRestriction", + { + maximum: "TLSv1", + }, + "Setting TLS version TLSv1 is not allowed for security reasons." + ); + + await testSettingException( + "network.tlsVersionRestriction", + { + maximum: "TLSv1.1", + }, + "Setting TLS version TLSv1.1 is not allowed for security reasons." + ); + } + + // Min vs Max + await testSettingException( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.3", + maximum: "TLSv1.2", + }, + "Setting TLS min version grater than the max version is not allowed." + ); + + // Min vs Max (with default max) + await testSetting( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.2", + maximum: "TLSv1.2", + }, + { + "security.tls.version.min": 3, + "security.tls.version.max": 3, + } + ); + await testSettingException( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.3", + }, + "Setting TLS min version grater than the max version is not allowed." + ); + + // Max vs Min + await testSetting( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.3", + maximum: "TLSv1.3", + }, + { + "security.tls.version.min": 4, + "security.tls.version.max": 4, + } + ); + await testSettingException( + "network.tlsVersionRestriction", + { + maximum: "TLSv1.2", + }, + "Setting TLS max version lower than the min version is not allowed." + ); + + // Empty value. + await testSetting( + "network.tlsVersionRestriction", + {}, + { + "security.tls.version.min": tlsMinPref, + "security.tls.version.max": 4, + }, + { + minimum: tlsMinVer, + maximum: "TLSv1.3", + } + ); + + const GLOBAL_PRIVACY_CONTROL_PREF_NAME = + "privacy.globalprivacycontrol.enabled"; + + Preferences.set(GLOBAL_PRIVACY_CONTROL_PREF_NAME, false); + await testGetting("network.globalPrivacyControl", {}, false); + + Preferences.set(GLOBAL_PRIVACY_CONTROL_PREF_NAME, true); + await testGetting("network.globalPrivacyControl", {}, true); + + // trying to "set" should have no effect when readonly! + extension.sendMessage( + "set", + { value: !Preferences.get(GLOBAL_PRIVACY_CONTROL_PREF_NAME) }, + "network.globalPrivacyControl", + READ_ONLY + ); + let readOnlyGPCData = await extension.awaitMessage("settingData"); + equal( + readOnlyGPCData.value, + Preferences.get(GLOBAL_PRIVACY_CONTROL_PREF_NAME), + "extension cannot set globalPrivacyControl" + ); + + equal(Preferences.get(GLOBAL_PRIVACY_CONTROL_PREF_NAME), true); + + const HTTPS_ONLY_PREF_NAME = "dom.security.https_only_mode"; + const HTTPS_ONLY_PBM_PREF_NAME = "dom.security.https_only_mode_pbm"; + + Preferences.set(HTTPS_ONLY_PREF_NAME, false); + Preferences.set(HTTPS_ONLY_PBM_PREF_NAME, false); + await testGetting("network.httpsOnlyMode", {}, "never"); + + Preferences.set(HTTPS_ONLY_PREF_NAME, true); + Preferences.set(HTTPS_ONLY_PBM_PREF_NAME, false); + await testGetting("network.httpsOnlyMode", {}, "always"); + + Preferences.set(HTTPS_ONLY_PREF_NAME, false); + Preferences.set(HTTPS_ONLY_PBM_PREF_NAME, true); + await testGetting("network.httpsOnlyMode", {}, "private_browsing"); + + // Please note that if https_only_mode = true, then + // https_only_mode_pbm has no effect. + Preferences.set(HTTPS_ONLY_PREF_NAME, true); + Preferences.set(HTTPS_ONLY_PBM_PREF_NAME, true); + await testGetting("network.httpsOnlyMode", {}, "always"); + + // trying to "set" should have no effect when readonly! + extension.sendMessage( + "set", + { value: "never" }, + "network.httpsOnlyMode", + READ_ONLY + ); + let readOnlyData = await extension.awaitMessage("settingData"); + equal(readOnlyData.value, "always"); + + equal(Preferences.get(HTTPS_ONLY_PREF_NAME), true); + equal(Preferences.get(HTTPS_ONLY_PBM_PREF_NAME), true); + + await extension.unload(); + + await promiseShutdownManager(); +}); + +add_task(async function test_exceptions() { + async function background() { + await browser.test.assertRejects( + browser.privacy.network.networkPredictionEnabled.set({ + value: true, + scope: "regular_only", + }), + "Firefox does not support the regular_only settings scope.", + "Expected rejection calling set with invalid scope." + ); + + await browser.test.assertRejects( + browser.privacy.network.networkPredictionEnabled.clear({ + scope: "incognito_persistent", + }), + "Firefox does not support the incognito_persistent settings scope.", + "Expected rejection calling clear with invalid scope." + ); + + browser.test.notifyPass("exceptionTests"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["privacy"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("exceptionTests"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_privacy_disable.js b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_disable.js new file mode 100644 index 0000000000..637751f473 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_disable.js @@ -0,0 +1,180 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + ExtensionPreferencesManager: + "resource://gre/modules/ExtensionPreferencesManager.sys.mjs", + Management: "resource://gre/modules/Extension.sys.mjs", + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +const { createAppInfo, promiseShutdownManager, promiseStartupManager } = + AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +function awaitEvent(eventName) { + return new Promise(resolve => { + let listener = (_eventName, ...args) => { + if (_eventName === eventName) { + Management.off(eventName, listener); + resolve(...args); + } + }; + + Management.on(eventName, listener); + }); +} + +function awaitPrefChange(prefName) { + return new Promise(resolve => { + let listener = args => { + Preferences.ignore(prefName, listener); + resolve(); + }; + + Preferences.observe(prefName, listener); + }); +} + +add_task(async function test_disable() { + const OLD_ID = "old_id@tests.mozilla.org"; + const NEW_ID = "new_id@tests.mozilla.org"; + + const PREF_TO_WATCH = "network.http.speculative-parallel-limit"; + + // Create an object to hold the values to which we will initialize the prefs. + const PREFS = { + "network.predictor.enabled": true, + "network.prefetch-next": true, + "network.http.speculative-parallel-limit": 10, + "network.dns.disablePrefetch": false, + }; + + // Set prefs to our initial values. + for (let pref in PREFS) { + Preferences.set(pref, PREFS[pref]); + } + + registerCleanupFunction(() => { + // Reset the prefs. + for (let pref in PREFS) { + Preferences.reset(pref); + } + }); + + function checkPrefs(expected) { + for (let pref in PREFS) { + let msg = `${pref} set correctly.`; + let expectedValue = expected ? PREFS[pref] : !PREFS[pref]; + if (pref === "network.http.speculative-parallel-limit") { + expectedValue = expected + ? ExtensionPreferencesManager.getDefaultValue(pref) + : 0; + } + equal(Preferences.get(pref), expectedValue, msg); + } + } + + async function background() { + browser.test.onMessage.addListener(async (msg, data) => { + await browser.privacy.network.networkPredictionEnabled.set(data); + let settingData = + await browser.privacy.network.networkPredictionEnabled.get({}); + browser.test.sendMessage("privacyData", settingData); + }); + } + + await promiseStartupManager(); + + let testExtensions = [ + ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_specific_settings: { + gecko: { + id: OLD_ID, + }, + }, + permissions: ["privacy"], + }, + useAddonManager: "temporary", + }), + + ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_specific_settings: { + gecko: { + id: NEW_ID, + }, + }, + permissions: ["privacy"], + }, + useAddonManager: "temporary", + }), + ]; + + for (let extension of testExtensions) { + await extension.startup(); + } + + // Set the value to true for the older extension. + testExtensions[0].sendMessage("set", { value: true }); + let data = await testExtensions[0].awaitMessage("privacyData"); + ok(data.value, "Value set to true for the older extension."); + + // Set the value to false for the newest extension. + testExtensions[1].sendMessage("set", { value: false }); + data = await testExtensions[1].awaitMessage("privacyData"); + ok(!data.value, "Value set to false for the newest extension."); + + // Verify the prefs have been set to match the "false" setting. + checkPrefs(false); + + // Disable the newest extension. + let disabledPromise = awaitPrefChange(PREF_TO_WATCH); + let newAddon = await AddonManager.getAddonByID(NEW_ID); + await newAddon.disable(); + await disabledPromise; + + // Verify the prefs have been set to match the "true" setting. + checkPrefs(true); + + // Disable the older extension. + disabledPromise = awaitPrefChange(PREF_TO_WATCH); + let oldAddon = await AddonManager.getAddonByID(OLD_ID); + await oldAddon.disable(); + await disabledPromise; + + // Verify the prefs have reverted back to their initial values. + for (let pref in PREFS) { + equal(Preferences.get(pref), PREFS[pref], `${pref} reset correctly.`); + } + + // Re-enable the newest extension. + let enabledPromise = awaitEvent("ready"); + await newAddon.enable(); + await enabledPromise; + + // Verify the prefs have been set to match the "false" setting. + checkPrefs(false); + + // Re-enable the older extension. + enabledPromise = awaitEvent("ready"); + await oldAddon.enable(); + await enabledPromise; + + // Verify the prefs have remained set to match the "false" setting. + checkPrefs(false); + + for (let extension of testExtensions) { + await extension.unload(); + } + + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_privacy_nonPersistentCookies.js b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_nonPersistentCookies.js new file mode 100644 index 0000000000..91f0feaa82 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_nonPersistentCookies.js @@ -0,0 +1,54 @@ +"use strict"; + +const { promiseShutdownManager, promiseStartupManager } = AddonTestUtils; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_task(async function test_nonPersistentCookies_is_deprecated() { + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["privacy"], + }, + useAddonManager: "temporary", + async background() { + for (const nonPersistentCookies of [true, false]) { + await browser.privacy.websites.cookieConfig.set({ + value: { + behavior: "reject_third_party", + nonPersistentCookies, + }, + }); + } + + browser.test.sendMessage("background-done"); + }, + }); + + let { messages } = await promiseConsoleOutput(async () => { + await extension.startup(); + await extension.awaitMessage("background-done"); + await extension.unload(); + }); + + const expectedMessage = + /"'nonPersistentCookies' has been deprecated and it has no effect anymore."/; + + AddonTestUtils.checkMessages( + messages, + { + expected: [{ message: expectedMessage }, { message: expectedMessage }], + }, + true + ); + + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_privacy_update.js b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_update.js new file mode 100644 index 0000000000..5dcbca9d63 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_update.js @@ -0,0 +1,163 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +const { + createAppInfo, + createTempWebExtensionFile, + promiseCompleteAllInstalls, + promiseFindAddonUpdates, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); + +// Allow for unsigned addons. +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42"); + +add_task(async function test_privacy_update() { + // Create a object to hold the values to which we will initialize the prefs. + const PREFS = { + "network.predictor.enabled": true, + "network.prefetch-next": true, + "network.http.speculative-parallel-limit": 10, + "network.dns.disablePrefetch": false, + }; + + const EXTENSION_ID = "test_privacy_addon_update@tests.mozilla.org"; + const PREF_EM_CHECK_UPDATE_SECURITY = "extensions.checkUpdateSecurity"; + + // Set prefs to our initial values. + for (let pref in PREFS) { + Preferences.set(pref, PREFS[pref]); + } + + registerCleanupFunction(() => { + // Reset the prefs. + for (let pref in PREFS) { + Preferences.reset(pref); + } + }); + + async function background() { + browser.test.onMessage.addListener(async (msg, data) => { + let settingData; + switch (msg) { + case "get": + settingData = + await browser.privacy.network.networkPredictionEnabled.get({}); + browser.test.sendMessage("privacyData", settingData); + break; + + case "set": + await browser.privacy.network.networkPredictionEnabled.set(data); + settingData = + await browser.privacy.network.networkPredictionEnabled.get({}); + browser.test.sendMessage("privacyData", settingData); + break; + } + }); + } + + const testServer = createHttpServer(); + const port = testServer.identity.primaryPort; + + // The test extension uses an insecure update url. + Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false); + + testServer.registerPathHandler("/test_update.json", (request, response) => { + response.write(`{ + "addons": { + "${EXTENSION_ID}": { + "updates": [ + { + "version": "2.0", + "update_link": "http://localhost:${port}/addons/test_privacy-2.0.xpi" + } + ] + } + } + }`); + }); + + let webExtensionFile = createTempWebExtensionFile({ + manifest: { + version: "2.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + permissions: ["privacy"], + }, + background, + }); + + testServer.registerFile("/addons/test_privacy-2.0.xpi", webExtensionFile); + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + update_url: `http://localhost:${port}/test_update.json`, + }, + }, + permissions: ["privacy"], + }, + background, + }); + + await extension.startup(); + + // Change the value to false. + extension.sendMessage("set", { value: false }); + let data = await extension.awaitMessage("privacyData"); + ok(!data.value, "get returns expected value after setting."); + + equal( + extension.version, + "1.0", + "The installed addon has the expected version." + ); + + let update = await promiseFindAddonUpdates(extension.addon); + let install = update.updateAvailable; + + await promiseCompleteAllInstalls([install]); + + await extension.awaitStartup(); + + equal( + extension.version, + "2.0", + "The updated addon has the expected version." + ); + + extension.sendMessage("get"); + data = await extension.awaitMessage("privacyData"); + ok(!data.value, "get returns expected value after updating."); + + // Verify the prefs are still set to match the "false" setting. + for (let pref in PREFS) { + let msg = `${pref} set correctly.`; + let expectedValue = + pref === "network.http.speculative-parallel-limit" ? 0 : !PREFS[pref]; + equal(Preferences.get(pref), expectedValue, msg); + } + + await extension.unload(); + + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_authorization_via_proxyinfo.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_authorization_via_proxyinfo.js new file mode 100644 index 0000000000..27f537b73b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_authorization_via_proxyinfo.js @@ -0,0 +1,116 @@ +"use strict"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "authManager", + "@mozilla.org/network/http-auth-manager;1", + "nsIHttpAuthManager" +); + +const proxy = createHttpServer(); +const proxyToken = "this_is_my_pass"; + +// accept proxy connections for mozilla.org +proxy.identity.add("http", "mozilla.org", 80); + +proxy.registerPathHandler("/", (request, response) => { + if (request.hasHeader("Proxy-Authorization")) { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/plain", false); + response.write(request.getHeader("Proxy-Authorization")); + } else { + response.setStatusLine( + request.httpVersion, + 407, + "Proxy authentication required" + ); + response.setHeader("Content-Type", "text/plain", false); + response.setHeader("Proxy-Authenticate", "UnknownMeantToFail", false); + response.write("auth required"); + } +}); + +function getExtension(background) { + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["proxy", "webRequest", "webRequestBlocking", "<all_urls>"], + }, + background: `(${background})(${proxy.identity.primaryPort}, "${proxyToken}")`, + }); +} + +add_task(async function test_webRequest_auth_proxy() { + function background(port, proxyToken) { + browser.webRequest.onCompleted.addListener( + details => { + browser.test.log(`onCompleted ${JSON.stringify(details)}\n`); + browser.test.assertEq( + "localhost", + details.proxyInfo.host, + "proxy host" + ); + browser.test.assertEq(port, details.proxyInfo.port, "proxy port"); + browser.test.assertEq("http", details.proxyInfo.type, "proxy type"); + browser.test.assertEq( + "", + details.proxyInfo.username, + "proxy username not set" + ); + browser.test.assertEq( + proxyToken, + details.proxyInfo.proxyAuthorizationHeader, + "proxy authorization header" + ); + browser.test.assertEq( + proxyToken, + details.proxyInfo.connectionIsolationKey, + "proxy connection isolation" + ); + + browser.test.notifyPass("requestCompleted"); + }, + { urls: ["<all_urls>"] } + ); + + browser.webRequest.onAuthRequired.addListener( + details => { + // Using proxyAuthorizationHeader should prevent an auth request coming to us in the extension. + browser.test.fail("onAuthRequired"); + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + + // Handle the proxy request. + browser.proxy.onRequest.addListener( + details => { + browser.test.log(`onRequest ${JSON.stringify(details)}`); + return [ + { + host: "localhost", + port, + type: "http", + proxyAuthorizationHeader: proxyToken, + connectionIsolationKey: proxyToken, + }, + ]; + }, + { urls: ["<all_urls>"] }, + ["requestHeaders"] + ); + } + + let extension = getExtension(background); + + await extension.startup(); + + authManager.clearAll(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `http://mozilla.org/` + ); + + await extension.awaitFinish("requestCompleted"); + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_config.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_config.js new file mode 100644 index 0000000000..968f76ee28 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_config.js @@ -0,0 +1,620 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +const { ExtensionPermissions } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +// Start a server for `pac.example.com` to intercept attempts to connect to it +// to load a PAC URL. We won't serve anything, but this prevents attempts at +// non-local connections if this domain is registered. +AddonTestUtils.createHttpServer({ hosts: ["pac.example.com"] }); + +add_setup(async function () { + // Bug 1646182: Force ExtensionPermissions to run in rkv mode, the legacy + // storage mode will run in xpcshell-legacy-ep.toml + await ExtensionPermissions._uninit(); + + Services.prefs.setBoolPref( + "extensions.webextOptionalPermissionPrompts", + false + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts"); + }); + + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_browser_settings() { + // TODO bug 1725981: proxy.settings is not supported on Android. + if (AppConstants.platform === "android") { + info("proxy.settings not supported on Android; skipping"); + return; + } + + const proxySvc = Ci.nsIProtocolProxyService; + + // Create an object to hold the values to which we will initialize the prefs. + const PREFS = { + "network.proxy.type": proxySvc.PROXYCONFIG_SYSTEM, + "network.proxy.http": "", + "network.proxy.http_port": 0, + "network.proxy.share_proxy_settings": false, + "network.proxy.ssl": "", + "network.proxy.ssl_port": 0, + "network.proxy.socks": "", + "network.proxy.socks_port": 0, + "network.proxy.socks_version": 5, + "network.proxy.socks_remote_dns": false, + "network.proxy.no_proxies_on": "", + "network.proxy.autoconfig_url": "", + "signon.autologin.proxy": false, + }; + + async function background() { + browser.test.onMessage.addListener(async (msg, value) => { + let apiObj = browser.proxy.settings; + let result = await apiObj.set({ value }); + if (msg === "set") { + browser.test.assertTrue(result, "set returns true."); + browser.test.sendMessage("settingData", await apiObj.get({})); + } else { + browser.test.assertFalse(result, "set returns false for a no-op."); + browser.test.sendMessage("no-op set"); + } + }); + } + + // Set prefs to our initial values. + for (let pref in PREFS) { + Preferences.set(pref, PREFS[pref]); + } + + registerCleanupFunction(() => { + // Reset the prefs. + for (let pref in PREFS) { + Preferences.reset(pref); + } + }); + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["proxy"], + }, + incognitoOverride: "spanning", + useAddonManager: "temporary", + }); + + await extension.startup(); + + async function testSetting(value, expected, expectedValue = value) { + extension.sendMessage("set", value); + let data = await extension.awaitMessage("settingData"); + deepEqual(data.value, expectedValue, `The setting has the expected value.`); + equal( + data.levelOfControl, + "controlled_by_this_extension", + `The setting has the expected levelOfControl.` + ); + for (let pref in expected) { + equal( + Preferences.get(pref), + expected[pref], + `${pref} set correctly for ${value}` + ); + } + } + + async function testProxy(config, expectedPrefs, expectedConfig = config) { + let proxyConfig = { + proxyType: "none", + autoConfigUrl: "", + autoLogin: false, + proxyDNS: false, + httpProxyAll: false, + socksVersion: 5, + passthrough: "", + http: "", + ssl: "", + socks: "", + respectBeConservative: true, + }; + + expectedConfig.proxyType = expectedConfig.proxyType || "system"; + + return testSetting( + config, + expectedPrefs, + Object.assign(proxyConfig, expectedConfig) + ); + } + + await testProxy( + { proxyType: "none" }, + { "network.proxy.type": proxySvc.PROXYCONFIG_DIRECT } + ); + + await testProxy( + { + proxyType: "autoDetect", + autoLogin: true, + proxyDNS: true, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_WPAD, + "signon.autologin.proxy": true, + "network.proxy.socks_remote_dns": true, + } + ); + + await testProxy( + { + proxyType: "system", + autoLogin: false, + proxyDNS: false, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_SYSTEM, + "signon.autologin.proxy": false, + "network.proxy.socks_remote_dns": false, + } + ); + + // Verify that proxyType is optional and it defaults to "system". + await testProxy( + { + autoLogin: false, + proxyDNS: false, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_SYSTEM, + "signon.autologin.proxy": false, + "network.proxy.socks_remote_dns": false, + "network.http.proxy.respect-be-conservative": true, + } + ); + + await testProxy( + { + proxyType: "autoConfig", + autoConfigUrl: "http://pac.example.com", + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_PAC, + "network.proxy.autoconfig_url": "http://pac.example.com", + "network.http.proxy.respect-be-conservative": true, + } + ); + + await testProxy( + { + proxyType: "manual", + http: "http://www.mozilla.org", + autoConfigUrl: "", + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL, + "network.proxy.http": "www.mozilla.org", + "network.proxy.http_port": 80, + "network.proxy.autoconfig_url": "", + }, + { + proxyType: "manual", + http: "www.mozilla.org:80", + autoConfigUrl: "", + } + ); + + // When using proxyAll, we expect all proxies to be set to + // be the same as http. + await testProxy( + { + proxyType: "manual", + http: "http://www.mozilla.org:8080", + httpProxyAll: true, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL, + "network.proxy.http": "www.mozilla.org", + "network.proxy.http_port": 8080, + "network.proxy.ssl": "www.mozilla.org", + "network.proxy.ssl_port": 8080, + "network.proxy.share_proxy_settings": true, + }, + { + proxyType: "manual", + http: "www.mozilla.org:8080", + ssl: "www.mozilla.org:8080", + socks: "", + httpProxyAll: true, + } + ); + + await testProxy( + { + proxyType: "manual", + http: "www.mozilla.org:8080", + httpProxyAll: false, + ftp: "www.mozilla.org:8081", + ssl: "www.mozilla.org:8082", + socks: "mozilla.org:8083", + socksVersion: 4, + passthrough: ".mozilla.org", + respectBeConservative: true, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL, + "network.proxy.http": "www.mozilla.org", + "network.proxy.http_port": 8080, + "network.proxy.share_proxy_settings": false, + "network.proxy.ssl": "www.mozilla.org", + "network.proxy.ssl_port": 8082, + "network.proxy.socks": "mozilla.org", + "network.proxy.socks_port": 8083, + "network.proxy.socks_version": 4, + "network.proxy.no_proxies_on": ".mozilla.org", + "network.http.proxy.respect-be-conservative": true, + }, + { + proxyType: "manual", + http: "www.mozilla.org:8080", + httpProxyAll: false, + // ftp: "www.mozilla.org:8081", // This line should not be sent back + ssl: "www.mozilla.org:8082", + socks: "mozilla.org:8083", + socksVersion: 4, + passthrough: ".mozilla.org", + respectBeConservative: true, + } + ); + + await testProxy( + { + proxyType: "manual", + http: "http://www.mozilla.org", + ssl: "https://www.mozilla.org", + socks: "mozilla.org", + socksVersion: 4, + passthrough: ".mozilla.org", + respectBeConservative: false, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL, + "network.proxy.http": "www.mozilla.org", + "network.proxy.http_port": 80, + "network.proxy.share_proxy_settings": false, + "network.proxy.ssl": "www.mozilla.org", + "network.proxy.ssl_port": 443, + "network.proxy.socks": "mozilla.org", + "network.proxy.socks_port": 1080, + "network.proxy.socks_version": 4, + "network.proxy.no_proxies_on": ".mozilla.org", + "network.http.proxy.respect-be-conservative": false, + }, + { + proxyType: "manual", + http: "www.mozilla.org:80", + httpProxyAll: false, + ssl: "www.mozilla.org:443", + socks: "mozilla.org:1080", + socksVersion: 4, + passthrough: ".mozilla.org", + respectBeConservative: false, + } + ); + + await testProxy( + { + proxyType: "manual", + http: "http://www.mozilla.org:80", + ssl: "https://www.mozilla.org:443", + socks: "mozilla.org:1080", + socksVersion: 4, + passthrough: ".mozilla.org", + respectBeConservative: true, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL, + "network.proxy.http": "www.mozilla.org", + "network.proxy.http_port": 80, + "network.proxy.share_proxy_settings": false, + "network.proxy.ssl": "www.mozilla.org", + "network.proxy.ssl_port": 443, + "network.proxy.socks": "mozilla.org", + "network.proxy.socks_port": 1080, + "network.proxy.socks_version": 4, + "network.proxy.no_proxies_on": ".mozilla.org", + "network.http.proxy.respect-be-conservative": true, + }, + { + proxyType: "manual", + http: "www.mozilla.org:80", + httpProxyAll: false, + ssl: "www.mozilla.org:443", + socks: "mozilla.org:1080", + socksVersion: 4, + passthrough: ".mozilla.org", + respectBeConservative: true, + } + ); + + await testProxy( + { + proxyType: "manual", + http: "http://www.mozilla.org:80", + ssl: "https://www.mozilla.org:80", + socks: "mozilla.org:80", + socksVersion: 4, + passthrough: ".mozilla.org", + respectBeConservative: false, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL, + "network.proxy.http": "www.mozilla.org", + "network.proxy.http_port": 80, + "network.proxy.share_proxy_settings": false, + "network.proxy.ssl": "www.mozilla.org", + "network.proxy.ssl_port": 80, + "network.proxy.socks": "mozilla.org", + "network.proxy.socks_port": 80, + "network.proxy.socks_version": 4, + "network.proxy.no_proxies_on": ".mozilla.org", + "network.http.proxy.respect-be-conservative": false, + }, + { + proxyType: "manual", + http: "www.mozilla.org:80", + httpProxyAll: false, + ssl: "www.mozilla.org:80", + socks: "mozilla.org:80", + socksVersion: 4, + passthrough: ".mozilla.org", + respectBeConservative: false, + } + ); + + // Test resetting values. + await testProxy( + { + proxyType: "none", + http: "", + ssl: "", + socks: "", + socksVersion: 5, + passthrough: "", + respectBeConservative: true, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_DIRECT, + "network.proxy.http": "", + "network.proxy.http_port": 0, + "network.proxy.ssl": "", + "network.proxy.ssl_port": 0, + "network.proxy.socks": "", + "network.proxy.socks_port": 0, + "network.proxy.socks_version": 5, + "network.proxy.no_proxies_on": "", + "network.http.proxy.respect-be-conservative": true, + } + ); + + await extension.unload(); +}); + +add_task(async function test_bad_value_proxy_config() { + let background = + AppConstants.platform === "android" + ? async () => { + await browser.test.assertRejects( + browser.proxy.settings.set({ + value: { + proxyType: "none", + }, + }), + /proxy.settings is not supported on android/, + "proxy.settings.set rejects on Android." + ); + + await browser.test.assertRejects( + browser.proxy.settings.get({}), + /proxy.settings is not supported on android/, + "proxy.settings.get rejects on Android." + ); + + await browser.test.assertRejects( + browser.proxy.settings.clear({}), + /proxy.settings is not supported on android/, + "proxy.settings.clear rejects on Android." + ); + + browser.test.sendMessage("done"); + } + : async () => { + await browser.test.assertRejects( + browser.proxy.settings.set({ + value: { + proxyType: "abc", + }, + }), + /abc is not a valid value for proxyType/, + "proxy.settings.set rejects with an invalid proxyType value." + ); + + await browser.test.assertRejects( + browser.proxy.settings.set({ + value: { + proxyType: "autoConfig", + }, + }), + /undefined is not a valid value for autoConfigUrl/, + "proxy.settings.set for type autoConfig rejects with an empty autoConfigUrl value." + ); + + await browser.test.assertRejects( + browser.proxy.settings.set({ + value: { + proxyType: "autoConfig", + autoConfigUrl: "abc", + }, + }), + /abc is not a valid value for autoConfigUrl/, + "proxy.settings.set rejects with an invalid autoConfigUrl value." + ); + + await browser.test.assertRejects( + browser.proxy.settings.set({ + value: { + proxyType: "manual", + socksVersion: "abc", + }, + }), + /abc is not a valid value for socksVersion/, + "proxy.settings.set rejects with an invalid socksVersion value." + ); + + await browser.test.assertRejects( + browser.proxy.settings.set({ + value: { + proxyType: "manual", + socksVersion: 3, + }, + }), + /3 is not a valid value for socksVersion/, + "proxy.settings.set rejects with an invalid socksVersion value." + ); + + browser.test.sendMessage("done"); + }; + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["proxy"], + }, + incognitoOverride: "spanning", + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +// Verify proxy prefs are unset on permission removal. +add_task(async function test_proxy_settings_permissions() { + // TODO bug 1725981: proxy.settings is not supported on Android. + if (AppConstants.platform === "android") { + info("proxy.settings not supported on Android; skipping"); + return; + } + async function background() { + const permObj = { permissions: ["proxy"] }; + browser.test.onMessage.addListener(async (msg, value) => { + if (msg === "request") { + browser.test.log("requesting proxy permission"); + await browser.permissions.request(permObj); + browser.test.log("setting proxy values"); + await browser.proxy.settings.set({ value }); + browser.test.sendMessage("set"); + } else if (msg === "remove") { + await browser.permissions.remove(permObj); + browser.test.sendMessage("removed"); + } + }); + } + + let prefNames = [ + "network.proxy.type", + "network.proxy.http", + "network.proxy.http_port", + "network.proxy.ssl", + "network.proxy.ssl_port", + "network.proxy.socks", + "network.proxy.socks_port", + "network.proxy.socks_version", + "network.proxy.no_proxies_on", + ]; + + function checkSettings(msg, expectUserValue = false) { + info(msg); + for (let pref of prefNames) { + equal( + expectUserValue, + Services.prefs.prefHasUserValue(pref), + `${pref} set as expected ${Preferences.get(pref)}` + ); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + optional_permissions: ["proxy"], + }, + incognitoOverride: "spanning", + useAddonManager: "permanent", + }); + await extension.startup(); + checkSettings("setting is not set after startup"); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request", { + proxyType: "manual", + http: "www.mozilla.org:8080", + httpProxyAll: false, + ssl: "www.mozilla.org:8082", + socks: "mozilla.org:8083", + socksVersion: 4, + passthrough: ".mozilla.org", + }); + await extension.awaitMessage("set"); + checkSettings("setting was set after request", true); + + extension.sendMessage("remove"); + await extension.awaitMessage("removed"); + checkSettings("setting is reset after remove"); + + // Set again to test after restart + extension.sendMessage("request", { + proxyType: "manual", + http: "www.mozilla.org:8080", + httpProxyAll: false, + ssl: "www.mozilla.org:8082", + socks: "mozilla.org:8083", + socksVersion: 4, + passthrough: ".mozilla.org", + }); + await extension.awaitMessage("set"); + checkSettings("setting was set after request", true); + }); + + // force the permissions store to be re-read on startup + await ExtensionPermissions._uninit(); + resetHandlingUserInput(); + await AddonTestUtils.promiseRestartManager(); + await extension.awaitBackgroundStarted(); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("remove"); + await extension.awaitMessage("removed"); + checkSettings("setting is reset after remove"); + }); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_containerIsolation.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_containerIsolation.js new file mode 100644 index 0000000000..9a375f68a9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_containerIsolation.js @@ -0,0 +1,59 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +add_task(async function test_userContextId_proxy() { + Services.prefs.setBoolPref("extensions.userContextIsolation.enabled", true); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + background() { + browser.proxy.onRequest.addListener( + async details => { + browser.test.assertEq( + "firefox-container-2", + details.cookieStoreId, + "cookieStoreId is set" + ); + browser.test.notifyPass("allowed"); + }, + { urls: ["http://example.com/dummy"] } + ); + }, + }); + + Services.prefs.setCharPref( + "extensions.userContextIsolation.defaults.restricted", + "[1]" + ); + await extension.startup(); + + let restrictedPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy", + { userContextId: 1 } + ); + + let allowedPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy", + { + userContextId: 2, + } + ); + await extension.awaitFinish("allowed"); + + await extension.unload(); + await restrictedPage.close(); + await allowedPage.close(); + + Services.prefs.clearUserPref("extensions.userContextIsolation.enabled"); + Services.prefs.clearUserPref( + "extensions.userContextIsolation.defaults.restricted" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_onauthrequired.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_onauthrequired.js new file mode 100644 index 0000000000..db041d20d0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_onauthrequired.js @@ -0,0 +1,302 @@ +"use strict"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "authManager", + "@mozilla.org/network/http-auth-manager;1", + "nsIHttpAuthManager" +); + +const proxy = createHttpServer(); + +// accept proxy connections for mozilla.org +proxy.identity.add("http", "mozilla.org", 80); +proxy.identity.add("https", "407.example.com", 443); + +proxy.registerPathHandler("CONNECT", (request, response) => { + Assert.equal(request.method, "CONNECT"); + switch (request.host) { + case "407.example.com": + response.setStatusLine(request.httpVersion, 407, "Authenticate"); + response.setHeader("Content-Type", "text/plain", false); + response.setHeader("Proxy-Authenticate", 'Basic realm="foobar"', false); + response.write("auth required"); + break; + default: + response.setStatusLine(request.httpVersion, 500, "I am dumb"); + } +}); + +proxy.registerPathHandler("/", (request, response) => { + if (request.hasHeader("Proxy-Authorization")) { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/plain", false); + response.write("ok, got proxy auth"); + } else { + response.setStatusLine( + request.httpVersion, + 407, + "Proxy authentication required" + ); + response.setHeader("Content-Type", "text/plain", false); + response.setHeader("Proxy-Authenticate", 'Basic realm="foobar"', false); + response.write("auth required"); + } +}); + +function getExtension(background) { + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["proxy", "webRequest", "webRequestBlocking", "<all_urls>"], + }, + background: `(${background})(${proxy.identity.primaryPort})`, + }); +} + +add_task(async function test_webRequest_auth_proxy() { + async function background(port) { + let expecting = [ + "onBeforeSendHeaders", + "onSendHeaders", + "onAuthRequired", + "onBeforeSendHeaders", + "onSendHeaders", + "onCompleted", + ]; + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + browser.test.log(`onBeforeSendHeaders ${JSON.stringify(details)}\n`); + browser.test.assertEq( + "onBeforeSendHeaders", + expecting.shift(), + "got expected event" + ); + browser.test.assertEq( + "localhost", + details.proxyInfo.host, + "proxy host" + ); + browser.test.assertEq(port, details.proxyInfo.port, "proxy port"); + browser.test.assertEq("http", details.proxyInfo.type, "proxy type"); + browser.test.assertEq( + "", + details.proxyInfo.username, + "proxy username not set" + ); + }, + { urls: ["<all_urls>"] } + ); + + browser.webRequest.onSendHeaders.addListener( + details => { + browser.test.log(`onSendHeaders ${JSON.stringify(details)}\n`); + browser.test.assertEq( + "onSendHeaders", + expecting.shift(), + "got expected event" + ); + }, + { urls: ["<all_urls>"] } + ); + + browser.webRequest.onAuthRequired.addListener( + details => { + browser.test.log(`onAuthRequired ${JSON.stringify(details)}\n`); + browser.test.assertEq( + "onAuthRequired", + expecting.shift(), + "got expected event" + ); + browser.test.assertTrue(details.isProxy, "proxied request"); + browser.test.assertEq( + "localhost", + details.proxyInfo.host, + "proxy host" + ); + browser.test.assertEq(port, details.proxyInfo.port, "proxy port"); + browser.test.assertEq("http", details.proxyInfo.type, "proxy type"); + browser.test.assertEq( + "localhost", + details.challenger.host, + "proxy host" + ); + browser.test.assertEq(port, details.challenger.port, "proxy port"); + return { authCredentials: { username: "puser", password: "ppass" } }; + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + + browser.webRequest.onCompleted.addListener( + details => { + browser.test.log(`onCompleted ${JSON.stringify(details)}\n`); + browser.test.assertEq( + "onCompleted", + expecting.shift(), + "got expected event" + ); + browser.test.assertEq( + "localhost", + details.proxyInfo.host, + "proxy host" + ); + browser.test.assertEq(port, details.proxyInfo.port, "proxy port"); + browser.test.assertEq("http", details.proxyInfo.type, "proxy type"); + browser.test.assertEq( + "", + details.proxyInfo.username, + "proxy username not set by onAuthRequired" + ); + browser.test.assertEq( + undefined, + details.proxyInfo.password, + "no proxy password" + ); + browser.test.assertEq(expecting.length, 0, "got all expected events"); + browser.test.sendMessage("done"); + }, + { urls: ["<all_urls>"] } + ); + + // Handle the proxy request. + browser.proxy.onRequest.addListener( + details => { + browser.test.log(`onRequest ${JSON.stringify(details)}`); + return [{ host: "localhost", port, type: "http" }]; + }, + { urls: ["<all_urls>"] }, + ["requestHeaders"] + ); + browser.test.sendMessage("ready"); + } + + let handlingExt = getExtension(background); + + await handlingExt.startup(); + await handlingExt.awaitMessage("ready"); + + authManager.clearAll(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `http://mozilla.org/` + ); + + await handlingExt.awaitMessage("done"); + await contentPage.close(); + await handlingExt.unload(); +}); + +add_task(async function test_webRequest_auth_proxy_https() { + async function background(port) { + let authReceived = false; + + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + if (authReceived) { + browser.test.sendMessage("done"); + return { cancel: true }; + } + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + + browser.webRequest.onAuthRequired.addListener( + details => { + authReceived = true; + return { authCredentials: { username: "puser", password: "ppass" } }; + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + + // Handle the proxy request. + browser.proxy.onRequest.addListener( + details => { + browser.test.log(`onRequest ${JSON.stringify(details)}`); + return [{ host: "localhost", port, type: "http" }]; + }, + { urls: ["<all_urls>"] }, + ["requestHeaders"] + ); + browser.test.sendMessage("ready"); + } + + let handlingExt = getExtension(background); + + await handlingExt.startup(); + await handlingExt.awaitMessage("ready"); + + authManager.clearAll(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `https://407.example.com/` + ); + + await handlingExt.awaitMessage("done"); + await contentPage.close(); + await handlingExt.unload(); +}); + +add_task(async function test_webRequest_auth_proxy_system() { + async function background(port) { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.fail("onBeforeRequest"); + }, + { urls: ["<all_urls>"] } + ); + + browser.webRequest.onAuthRequired.addListener( + details => { + browser.test.sendMessage("onAuthRequired"); + // cancel is silently ignored, if it were not (e.g someone messes up in + // WebRequest.jsm and allows cancel) this test would fail. + return { + cancel: true, + authCredentials: { username: "puser", password: "ppass" }, + }; + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + + // Handle the proxy request. + browser.proxy.onRequest.addListener( + details => { + browser.test.log(`onRequest ${JSON.stringify(details)}`); + return { host: "localhost", port, type: "http" }; + }, + { urls: ["<all_urls>"] } + ); + browser.test.sendMessage("ready"); + } + + let handlingExt = getExtension(background); + + await handlingExt.startup(); + await handlingExt.awaitMessage("ready"); + + authManager.clearAll(); + + function fetch(url) { + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + xhr.mozBackgroundRequest = true; + xhr.open("GET", url); + xhr.onload = () => { + resolve(xhr.responseText); + }; + xhr.onerror = () => { + reject(xhr.status); + }; + xhr.send(); + }); + } + + await Promise.all([ + handlingExt.awaitMessage("onAuthRequired"), + fetch("http://mozilla.org"), + ]); + await handlingExt.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_settings.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_settings.js new file mode 100644 index 0000000000..33c91309f0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_settings.js @@ -0,0 +1,102 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + HttpServer: "resource://testing-common/httpd.sys.mjs", +}); + +const { createAppInfo, promiseShutdownManager, promiseStartupManager } = + AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +// We cannot use createHttpServer because it also messes with proxies. We want +// httpChannel to pick up the prefs we set and use those to proxy to our server. +// If this were to fail, we would get an error about making a request out to +// the network. +const proxy = new HttpServer(); +proxy.start(-1); +proxy.registerPathHandler("/fubar", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); +registerCleanupFunction(() => { + return new Promise(resolve => { + proxy.stop(resolve); + }); +}); + +add_task(async function test_proxy_settings() { + async function background(host, port) { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertEq( + host, + details.proxyInfo.host, + "proxy host matched" + ); + browser.test.assertEq( + port, + details.proxyInfo.port, + "proxy port matched" + ); + }, + { urls: ["http://example.com/*"] } + ); + browser.webRequest.onCompleted.addListener( + details => { + browser.test.notifyPass("proxytest"); + }, + { urls: ["http://example.com/*"] } + ); + browser.webRequest.onErrorOccurred.addListener( + details => { + browser.test.notifyFail("proxytest"); + }, + { urls: ["http://example.com/*"] } + ); + + // Wait for the settings before testing a request. + await browser.proxy.settings.set({ + value: { + proxyType: "manual", + http: `${host}:${port}`, + }, + }); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: "proxy.settings@mochi.test" } }, + permissions: ["proxy", "webRequest", "<all_urls>"], + }, + incognitoOverride: "spanning", + useAddonManager: "temporary", + background: `(${background})("${proxy.identity.primaryHost}", ${proxy.identity.primaryPort})`, + }); + + await promiseStartupManager(); + await extension.startup(); + await extension.awaitMessage("ready"); + equal( + Services.prefs.getStringPref("network.proxy.http"), + proxy.identity.primaryHost, + "proxy address is set" + ); + equal( + Services.prefs.getIntPref("network.proxy.http_port"), + proxy.identity.primaryPort, + "proxy port is set" + ); + let ok = extension.awaitFinish("proxytest"); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/fubar" + ); + await ok; + + await contentPage.close(); + await extension.unload(); + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_socks.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_socks.js new file mode 100644 index 0000000000..6ebd9fbfcc --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_socks.js @@ -0,0 +1,660 @@ +"use strict"; + +/* globals TCPServerSocket */ + +const CC = Components.Constructor; + +const BinaryInputStream = CC( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" +); + +const currentThread = + Cc["@mozilla.org/thread-manager;1"].getService().currentThread; + +// Most of the socks logic here is copied and upgraded to support authentication +// for socks5. The original test is from netwerk/test/unit/test_socks.js + +// Socks 4 support was left in place for future tests. + +const STATE_WAIT_GREETING = 1; +const STATE_WAIT_SOCKS4_REQUEST = 2; +const STATE_WAIT_SOCKS4_USERNAME = 3; +const STATE_WAIT_SOCKS4_HOSTNAME = 4; +const STATE_WAIT_SOCKS5_GREETING = 5; +const STATE_WAIT_SOCKS5_REQUEST = 6; +const STATE_WAIT_SOCKS5_AUTH = 7; +const STATE_WAIT_INPUT = 8; +const STATE_FINISHED = 9; + +/** + * A basic socks proxy setup that handles a single http response page. This + * is used for testing socks auth with webrequest. We don't bother making + * sure we buffer ondata, etc., we'll never get anything but tiny chunks here. + */ +class SocksClient { + constructor(server, socket) { + this.server = server; + this.type = ""; + this.username = ""; + this.dest_name = ""; + this.dest_addr = []; + this.dest_port = []; + + this.inbuf = []; + this.state = STATE_WAIT_GREETING; + this.socket = socket; + + socket.onclose = event => { + this.server.requestCompleted(this); + }; + socket.ondata = event => { + let len = event.data.byteLength; + + if (len == 0 && this.state == STATE_FINISHED) { + this.close(); + this.server.requestCompleted(this); + return; + } + + this.inbuf = new Uint8Array(event.data); + Promise.resolve().then(() => { + this.callState(); + }); + }; + } + + callState() { + switch (this.state) { + case STATE_WAIT_GREETING: + this.checkSocksGreeting(); + break; + case STATE_WAIT_SOCKS4_REQUEST: + this.checkSocks4Request(); + break; + case STATE_WAIT_SOCKS4_USERNAME: + this.checkSocks4Username(); + break; + case STATE_WAIT_SOCKS4_HOSTNAME: + this.checkSocks4Hostname(); + break; + case STATE_WAIT_SOCKS5_GREETING: + this.checkSocks5Greeting(); + break; + case STATE_WAIT_SOCKS5_REQUEST: + this.checkSocks5Request(); + break; + case STATE_WAIT_SOCKS5_AUTH: + this.checkSocks5Auth(); + break; + case STATE_WAIT_INPUT: + this.checkRequest(); + break; + default: + do_throw("server: read in invalid state!"); + } + } + + write(buf) { + this.socket.send(new Uint8Array(buf).buffer); + } + + checkSocksGreeting() { + if (!this.inbuf.length) { + return; + } + + if (this.inbuf[0] == 4) { + this.type = "socks4"; + this.state = STATE_WAIT_SOCKS4_REQUEST; + this.checkSocks4Request(); + } else if (this.inbuf[0] == 5) { + this.type = "socks"; + this.state = STATE_WAIT_SOCKS5_GREETING; + this.checkSocks5Greeting(); + } else { + do_throw("Unknown socks protocol!"); + } + } + + checkSocks4Request() { + if (this.inbuf.length < 8) { + return; + } + + this.dest_port = this.inbuf.slice(2, 4); + this.dest_addr = this.inbuf.slice(4, 8); + + this.inbuf = this.inbuf.slice(8); + this.state = STATE_WAIT_SOCKS4_USERNAME; + this.checkSocks4Username(); + } + + readString() { + let i = this.inbuf.indexOf(0); + let str = null; + + if (i >= 0) { + let decoder = new TextDecoder(); + str = decoder.decode(this.inbuf.slice(0, i)); + this.inbuf = this.inbuf.slice(i + 1); + } + + return str; + } + + checkSocks4Username() { + let str = this.readString(); + + if (str == null) { + return; + } + + this.username = str; + if ( + this.dest_addr[0] == 0 && + this.dest_addr[1] == 0 && + this.dest_addr[2] == 0 && + this.dest_addr[3] != 0 + ) { + this.state = STATE_WAIT_SOCKS4_HOSTNAME; + this.checkSocks4Hostname(); + } else { + this.sendSocks4Response(); + } + } + + checkSocks4Hostname() { + let str = this.readString(); + + if (str == null) { + return; + } + + this.dest_name = str; + this.sendSocks4Response(); + } + + sendSocks4Response() { + this.state = STATE_WAIT_INPUT; + this.inbuf = []; + this.write([0, 0x5a, 0, 0, 0, 0, 0, 0]); + } + + /** + * checks authentication information. + * + * buf[0] socks version + * buf[1] number of auth methods supported + * buf[2+nmethods] value for each auth method + * + * Response is + * byte[0] socks version + * byte[1] desired auth method + * + * For whatever reason, Firefox does not present auth method 0x02 however + * responding with that does cause Firefox to send authentication if + * the nsIProxyInfo instance has the data. IUUC Firefox should send + * supported methods, but I'm no socks expert. + */ + checkSocks5Greeting() { + if (this.inbuf.length < 2) { + return; + } + let nmethods = this.inbuf[1]; + if (this.inbuf.length < 2 + nmethods) { + return; + } + + // See comment above, keeping for future update. + // let methods = this.inbuf.slice(2, 2 + nmethods); + + this.inbuf = []; + if (this.server.password || this.server.username) { + this.state = STATE_WAIT_SOCKS5_AUTH; + this.write([5, 2]); + } else { + this.state = STATE_WAIT_SOCKS5_REQUEST; + this.write([5, 0]); + } + } + + checkSocks5Auth() { + equal(this.inbuf[0], 0x01, "subnegotiation version"); + let uname_len = this.inbuf[1]; + let pass_len = this.inbuf[2 + uname_len]; + let unnamebuf = this.inbuf.slice(2, 2 + uname_len); + let pass_start = 2 + uname_len + 1; + let pwordbuf = this.inbuf.slice(pass_start, pass_start + pass_len); + let decoder = new TextDecoder(); + let username = decoder.decode(unnamebuf); + let password = decoder.decode(pwordbuf); + this.inbuf = []; + equal(username, this.server.username, "socks auth username"); + equal(password, this.server.password, "socks auth password"); + if (username == this.server.username && password == this.server.password) { + this.state = STATE_WAIT_SOCKS5_REQUEST; + // x00 is success, any other value closes the connection + this.write([1, 0]); + return; + } + this.state = STATE_FINISHED; + this.write([1, 1]); + } + + checkSocks5Request() { + if (this.inbuf.length < 4) { + return; + } + + let atype = this.inbuf[3]; + let len; + let name = false; + + switch (atype) { + case 0x01: + len = 4; + break; + case 0x03: + len = this.inbuf[4]; + name = true; + break; + case 0x04: + len = 16; + break; + default: + do_throw("Unknown address type " + atype); + } + + if (name) { + if (this.inbuf.length < 4 + len + 1 + 2) { + return; + } + + let buf = this.inbuf.slice(5, 5 + len); + let decoder = new TextDecoder(); + this.dest_name = decoder.decode(buf); + len += 1; + } else { + if (this.inbuf.length < 4 + len + 2) { + return; + } + + this.dest_addr = this.inbuf.slice(4, 4 + len); + } + + len += 4; + this.dest_port = this.inbuf.slice(len, len + 2); + this.inbuf = this.inbuf.slice(len + 2); + this.sendSocks5Response(); + } + + sendSocks5Response() { + let buf; + if (this.dest_addr.length == 16) { + // send a successful response with the address, [::1]:80 + buf = [5, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 80]; + } else { + // send a successful response with the address, 127.0.0.1:80 + buf = [5, 0, 0, 1, 127, 0, 0, 1, 0, 80]; + } + this.state = STATE_WAIT_INPUT; + this.inbuf = []; + this.write(buf); + } + + checkRequest() { + let decoder = new TextDecoder(); + let request = decoder.decode(this.inbuf); + + if (request == "PING!") { + this.state = STATE_FINISHED; + this.socket.send("PONG!"); + } else if (request.startsWith("GET / HTTP/1.1")) { + this.socket.send( + "HTTP/1.1 200 OK\r\n" + + "Content-Length: 2\r\n" + + "Content-Type: text/html\r\n" + + "\r\nOK" + ); + this.state = STATE_FINISHED; + } + } + + close() { + this.socket.close(); + } +} + +class SocksTestServer { + constructor() { + this.client_connections = new Set(); + this.listener = new TCPServerSocket(-1, { binaryType: "arraybuffer" }, -1); + this.listener.onconnect = event => { + let client = new SocksClient(this, event.socket); + this.client_connections.add(client); + }; + } + + requestCompleted(client) { + this.client_connections.delete(client); + } + + close() { + for (let client of this.client_connections) { + client.close(); + } + this.client_connections = new Set(); + if (this.listener) { + this.listener.close(); + this.listener = null; + } + } + + setUserPass(username, password) { + this.username = username; + this.password = password; + } +} + +/** + * Tests the basic socks logic using a simple socket connection and the + * protocol proxy service. Before 902346, TCPSocket has no way to tie proxy + * data to it, so we go old school here. + */ +class SocksTestClient { + constructor(socks, dest, resolve, reject) { + let pps = Cc["@mozilla.org/network/protocol-proxy-service;1"].getService( + Ci.nsIProtocolProxyService + ); + let sts = Cc["@mozilla.org/network/socket-transport-service;1"].getService( + Ci.nsISocketTransportService + ); + + let pi_flags = 0; + if (socks.dns == "remote") { + pi_flags = Ci.nsIProxyInfo.TRANSPARENT_PROXY_RESOLVES_HOST; + } + + let pi = pps.newProxyInfoWithAuth( + socks.version, + socks.host, + socks.port, + socks.username, + socks.password, + "", + "", + pi_flags, + -1, + null + ); + + this.trans = sts.createTransport([], dest.host, dest.port, pi, null); + this.input = this.trans.openInputStream( + Ci.nsITransport.OPEN_BLOCKING, + 0, + 0 + ); + this.output = this.trans.openOutputStream( + Ci.nsITransport.OPEN_BLOCKING, + 0, + 0 + ); + this.outbuf = String(); + this.resolve = resolve; + this.reject = reject; + + this.write("PING!"); + this.input.asyncWait(this, 0, 0, currentThread); + } + + onInputStreamReady(stream) { + let len = 0; + try { + len = stream.available(); + } catch (e) { + // This will happen on auth failure. + this.reject(e); + return; + } + let bin = new BinaryInputStream(stream); + let data = bin.readByteArray(len); + let decoder = new TextDecoder(); + let result = decoder.decode(data); + if (result == "PONG!") { + this.resolve(result); + } else { + this.reject(); + } + } + + write(buf) { + this.outbuf += buf; + this.output.asyncWait(this, 0, 0, currentThread); + } + + onOutputStreamReady(stream) { + let len = stream.write(this.outbuf, this.outbuf.length); + if (len != this.outbuf.length) { + this.outbuf = this.outbuf.substring(len); + stream.asyncWait(this, 0, 0, currentThread); + } else { + this.outbuf = String(); + } + } + + close() { + this.output.close(); + } +} + +const socksServer = new SocksTestServer(); +socksServer.setUserPass("foo", "bar"); +registerCleanupFunction(() => { + socksServer.close(); +}); + +// A simple ping/pong to test the socks server. +add_task(async function test_socks_server() { + let socks = { + version: "socks", + host: "127.0.0.1", + port: socksServer.listener.localPort, + username: "foo", + password: "bar", + dns: false, + }; + let dest = { + host: "localhost", + port: 8888, + }; + + new Promise((resolve, reject) => { + new SocksTestClient(socks, dest, resolve, reject); + }) + .then(result => { + equal("PONG!", result, "socks test ok"); + }) + .catch(result => { + ok(false, `socks test failed ${result}`); + }); +}); + +// Register a proxy to be used by TCPSocket connections later. +function registerProxy(socks) { + let pps = Cc["@mozilla.org/network/protocol-proxy-service;1"].getService( + Ci.nsIProtocolProxyService + ); + let filter = { + QueryInterface: ChromeUtils.generateQI(["nsIProtocolProxyFilter"]), + applyFilter(uri, proxyInfo, callback) { + callback.onProxyFilterResult( + pps.newProxyInfoWithAuth( + socks.version, + socks.host, + socks.port, + socks.username, + socks.password, + "", + "", + socks.dns == "remote" + ? Ci.nsIProxyInfo.TRANSPARENT_PROXY_RESOLVES_HOST + : 0, + -1, + null + ) + ); + }, + }; + pps.registerFilter(filter, 0); + registerCleanupFunction(() => { + pps.unregisterFilter(filter); + }); +} + +// A simple ping/pong to test the socks server with TCPSocket. +add_task(async function test_tcpsocket_proxy() { + let socks = { + version: "socks", + host: "127.0.0.1", + port: socksServer.listener.localPort, + username: "foo", + password: "bar", + dns: false, + }; + let dest = { + host: "localhost", + port: 8888, + }; + + registerProxy(socks); + await new Promise((resolve, reject) => { + let client = new TCPSocket(dest.host, dest.port); + client.onopen = () => { + client.send("PING!"); + }; + client.ondata = e => { + equal("PONG!", e.data, "socks test ok"); + resolve(); + }; + client.onerror = () => reject(); + }); +}); + +add_task(async function test_webRequest_socks_proxy() { + async function background(port) { + function checkProxyData(details) { + browser.test.assertEq("127.0.0.1", details.proxyInfo.host, "proxy host"); + browser.test.assertEq(port, details.proxyInfo.port, "proxy port"); + browser.test.assertEq("socks", details.proxyInfo.type, "proxy type"); + browser.test.assertEq( + "foo", + details.proxyInfo.username, + "proxy username not set" + ); + browser.test.assertEq( + undefined, + details.proxyInfo.password, + "no proxy password passed to webrequest" + ); + } + browser.webRequest.onBeforeRequest.addListener( + details => { + checkProxyData(details); + }, + { urls: ["<all_urls>"] } + ); + browser.webRequest.onAuthRequired.addListener( + details => { + // We should never get onAuthRequired for socks proxy + browser.test.fail("onAuthRequired"); + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + browser.webRequest.onCompleted.addListener( + details => { + checkProxyData(details); + browser.test.sendMessage("done"); + }, + { urls: ["<all_urls>"] } + ); + browser.proxy.onRequest.addListener( + () => { + return [ + { + type: "socks", + host: "127.0.0.1", + port, + username: "foo", + password: "bar", + }, + ]; + }, + { urls: ["<all_urls>"] } + ); + } + + let handlingExt = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["proxy", "webRequest", "webRequestBlocking", "<all_urls>"], + }, + background: `(${background})(${socksServer.listener.localPort})`, + }); + + // proxy.register is deprecated - bug 1443259. + ExtensionTestUtils.failOnSchemaWarnings(false); + await handlingExt.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `http://localhost/` + ); + + await handlingExt.awaitMessage("done"); + await contentPage.close(); + await handlingExt.unload(); +}); + +add_task(async function test_onRequest_tcpsocket_proxy() { + async function background(port) { + browser.proxy.onRequest.addListener( + () => { + return [ + { + type: "socks", + host: "127.0.0.1", + port, + username: "foo", + password: "bar", + }, + ]; + }, + { urls: ["<all_urls>"] } + ); + } + + let handlingExt = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["proxy", "webRequest", "webRequestBlocking", "<all_urls>"], + }, + background: `(${background})(${socksServer.listener.localPort})`, + }); + + await handlingExt.startup(); + + await new Promise((resolve, reject) => { + let client = new TCPSocket("localhost", 8888); + client.onopen = () => { + client.send("PING!"); + }; + client.ondata = e => { + equal("PONG!", e.data, "socks test ok"); + resolve(); + }; + client.onerror = () => reject(); + }); + + await handlingExt.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_speculative.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_speculative.js new file mode 100644 index 0000000000..25b7030671 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_speculative.js @@ -0,0 +1,53 @@ +"use strict"; + +const { ExtensionUtils } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionUtils.sys.mjs" +); + +const proxy = createHttpServer(); + +add_task(async function test_speculative_connect() { + function background() { + // Handle the proxy request. + browser.proxy.onRequest.addListener( + details => { + browser.test.log(`onRequest ${JSON.stringify(details)}`); + browser.test.assertEq( + details.type, + "speculative", + "Should have seen a speculative proxy request." + ); + return [{ type: "direct" }]; + }, + { urls: ["<all_urls>"], types: ["speculative"] } + ); + } + + let handlingExt = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + background: `(${background})()`, + }); + + Services.prefs.setBoolPref("network.http.debug-observations", true); + + await handlingExt.startup(); + + let notificationPromise = ExtensionUtils.promiseObserved( + "speculative-connect-request" + ); + + let uri = Services.io.newURI( + `http://${proxy.identity.primaryHost}:${proxy.identity.primaryPort}` + ); + Services.io.speculativeConnect( + uri, + Services.scriptSecurityManager.getSystemPrincipal(), + null, + false + ); + await notificationPromise; + + await handlingExt.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_startup.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_startup.js new file mode 100644 index 0000000000..4130d407b7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_startup.js @@ -0,0 +1,247 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +let { promiseRestartManager, promiseShutdownManager, promiseStartupManager } = + AddonTestUtils; + +let nonProxiedRequests = 0; +const nonProxiedServer = createHttpServer({ hosts: ["example.com"] }); +nonProxiedServer.registerPathHandler("/", (request, response) => { + nonProxiedRequests++; + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +// No hosts defined to avoid proxy filter setup. +let proxiedRequests = 0; +const server = createHttpServer(); +server.identity.add("http", "proxied.example.com", 80); +server.registerPathHandler("/", (request, response) => { + proxiedRequests++; + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +function trackEvents(wrapper) { + let events = new Map(); + for (let event of ["background-script-event", "start-background-script"]) { + events.set(event, false); + wrapper.extension.once(event, () => events.set(event, true)); + } + return events; +} + +add_setup(() => { + // In case the prefs have a different value by default. + Services.prefs.setBoolPref( + "extensions.webextensions.early_background_wakeup_on_request", + false + ); +}); + +// Test that a proxy listener during startup does not immediately +// start the background page, but the event is queued until the background +// page is started. +add_task(async function test_proxy_startup() { + await promiseStartupManager(); + + function background(proxyInfo) { + browser.proxy.onRequest.addListener( + details => { + // ignore speculative requests + if (details.type == "xmlhttprequest") { + browser.test.sendMessage("saw-request"); + } + return proxyInfo; + }, + { urls: ["<all_urls>"] } + ); + } + + let proxyInfo = { + host: server.identity.primaryHost, + port: server.identity.primaryPort, + type: "http", + }; + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["proxy", "http://proxied.example.com/*"], + }, + background: `(${background})(${JSON.stringify(proxyInfo)})`, + }); + + await extension.startup(); + + // Initial requests to test the proxy and non-proxied servers. + await Promise.all([ + extension.awaitMessage("saw-request"), + ExtensionTestUtils.fetch("http://proxied.example.com/?a=0"), + ]); + equal(1, proxiedRequests, "proxied request ok"); + equal(0, nonProxiedRequests, "non proxied request ok"); + + await ExtensionTestUtils.fetch("http://example.com/?a=0"); + equal(1, proxiedRequests, "proxied request ok"); + equal(1, nonProxiedRequests, "non proxied request ok"); + + await promiseRestartManager({ earlyStartup: false }); + await extension.awaitStartup(); + + let events = trackEvents(extension); + + // Initiate a non-proxied request to make sure the startup listeners are using + // the extensions filters/etc. + await ExtensionTestUtils.fetch("http://example.com/?a=1"); + equal(1, proxiedRequests, "proxied request ok"); + equal(2, nonProxiedRequests, "non proxied request ok"); + + equal( + events.get("background-script-event"), + false, + "Should not have gotten a background script event" + ); + + // Make a request that the extension will proxy once it is started. + let request = Promise.all([ + extension.awaitMessage("saw-request"), + ExtensionTestUtils.fetch("http://proxied.example.com/?a=1"), + ]); + + await promiseExtensionEvent(extension, "background-script-event"); + equal( + events.get("background-script-event"), + true, + "Should have gotten a background script event" + ); + + // Test the background page startup. + equal( + events.get("start-background-script"), + false, + "Should not have started the background page yet" + ); + AddonTestUtils.notifyEarlyStartup(); + await new Promise(executeSoon); + + equal( + events.get("start-background-script"), + true, + "Should have gotten a background script event" + ); + + // Verify our proxied request finishes properly and that the + // request was not handled via our non-proxied server. + await request; + equal(2, proxiedRequests, "proxied request ok"); + equal(2, nonProxiedRequests, "non proxied requests ok"); + + // Retry, but now with early_background_wakeup_on_request=true. + Services.prefs.setBoolPref( + "extensions.webextensions.early_background_wakeup_on_request", + true + ); + await promiseRestartManager({ earlyStartup: false }); + await extension.awaitStartup(); + let request2 = Promise.all([ + extension.awaitMessage("saw-request"), + ExtensionTestUtils.fetch("http://proxied.example.com/?a=2"), + ]); + info("Expecting background page to be awakened by the proxy event"); + await extension.awaitBackgroundStarted(); + await request2; + equal(3, proxiedRequests, "proxied request ok"); + + await extension.unload(); + + await promiseShutdownManager(); +}); + +add_task(async function webRequest_before_proxy() { + Services.prefs.setBoolPref( + "extensions.webextensions.early_background_wakeup_on_request", + true + ); + await promiseStartupManager(); + + function background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + return { redirectUrl: "data:,response_from_webRequest" }; + }, + { + urls: ["*://example.com/wr"], + types: ["xmlhttprequest"], + }, + ["blocking"] + ); + browser.proxy.onRequest.addListener( + details => { + browser.test.sendMessage("seen_proxy_request"); + }, + { + urls: ["*://example.com/?proxy"], + types: ["xmlhttprequest"], + } + ); + } + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: [ + "proxy", + "webRequest", + "webRequestBlocking", + "http://example.com/*", + ], + }, + background, + }); + await extension.startup(); + let res = await ExtensionTestUtils.fetch( + "http://example.com", + "http://example.com/wr" + ); + Assert.equal(res, "response_from_webRequest", "Request succeeded"); + await promiseRestartManager({ earlyStartup: false }); + + let wrPromise = ExtensionTestUtils.fetch( + "http://example.com", + "http://example.com/wr" + ); + await promiseExtensionEvent(extension, "background-script-event"); + await new Promise(executeSoon); + Assert.equal( + extension.extension.backgroundState, + "stopped", + "Request intercepted by webRequest should not trigger early startup" + ); + let proxyPromise = ExtensionTestUtils.fetch( + "http://example.com", + "http://example.com/?proxy" + ); + await promiseExtensionEvent(extension, "background-script-event"); + await new Promise(executeSoon); + Assert.notEqual( + extension.extension.backgroundState, + "stopped", + `Request intercepted by proxy.onRequest should trigger early startup` + ); + info("Expecting background page to be awakened by the proxy event"); + await extension.awaitBackgroundStarted(); + Assert.equal(await proxyPromise, "ok", "Got /?proxy response"); + Assert.equal(await wrPromise, "response_from_webRequest", "Got /wr reply"); + await extension.awaitMessage("seen_proxy_request"); + await extension.unload(); + + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_redirects.js b/toolkit/components/extensions/test/xpcshell/test_ext_redirects.js new file mode 100644 index 0000000000..7b950355f3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_redirects.js @@ -0,0 +1,660 @@ +"use strict"; + +// Tests whether we can redirect to a moz-extension: url. +ChromeUtils.defineESModuleGetters(this, { + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +const server = createHttpServer(); +const gServerUrl = `http://localhost:${server.identity.primaryPort}`; + +server.registerPathHandler("/redirect", (request, response) => { + let params = new URLSearchParams(request.queryString); + response.setStatusLine(request.httpVersion, 302, "Moved Temporarily"); + response.setHeader("Location", params.get("redirect_uri")); + response.write("redirecting"); +}); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +server.registerPathHandler("/dummy-2", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +function onStopListener(channel) { + return new Promise(resolve => { + let orig = channel.QueryInterface(Ci.nsITraceableChannel).setNewListener({ + QueryInterface: ChromeUtils.generateQI([ + "nsIRequestObserver", + "nsIStreamListener", + ]), + getFinalURI(request) { + let { loadInfo } = request; + return (loadInfo && loadInfo.resultPrincipalURI) || request.originalURI; + }, + onDataAvailable(...args) { + orig.onDataAvailable(...args); + }, + onStartRequest(request) { + orig.onStartRequest(request); + }, + onStopRequest(request, statusCode) { + orig.onStopRequest(request, statusCode); + let URI = this.getFinalURI(request.QueryInterface(Ci.nsIChannel)); + resolve(URI && URI.spec); + }, + }); + }); +} + +async function onModifyListener(originUrl, redirectToUrl) { + return TestUtils.topicObserved("http-on-modify-request", (subject, data) => { + let channel = subject.QueryInterface(Ci.nsIHttpChannel); + return channel.URI && channel.URI.spec == originUrl; + }).then(([subject, data]) => { + let channel = subject.QueryInterface(Ci.nsIHttpChannel); + if (redirectToUrl) { + channel.redirectTo(Services.io.newURI(redirectToUrl)); + } + return channel; + }); +} + +function getExtension( + accessible = false, + background = undefined, + blocking = true +) { + let manifest = { + permissions: ["webRequest", "<all_urls>"], + }; + if (blocking) { + manifest.permissions.push("webRequestBlocking"); + } + if (accessible) { + manifest.web_accessible_resources = ["finished.html"]; + } + if (!background) { + background = () => { + // send the extensions public uri to the test. + let exturi = browser.runtime.getURL("finished.html"); + browser.test.sendMessage("redirectURI", exturi); + }; + } + return ExtensionTestUtils.loadExtension({ + manifest, + files: { + "finished.html": ` + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>redirected!</h1> + </body> + </html> + `.trim(), + }, + background, + }); +} + +async function redirection_test(url, channelRedirectUrl) { + // setup our observer + let watcher = onModifyListener(url, channelRedirectUrl).then(channel => { + return onStopListener(channel); + }); + let xhr = new XMLHttpRequest(); + xhr.open("GET", url); + xhr.send(); + return watcher; +} + +// This test verifies failure without web_accessible_resources. +add_task(async function test_redirect_to_non_accessible_resource() { + let extension = getExtension(); + await extension.startup(); + let redirectUrl = await extension.awaitMessage("redirectURI"); + let url = `${gServerUrl}/redirect?redirect_uri=${redirectUrl}`; + let result = await redirection_test(url); + equal(result, url, `expected no redirect`); + await extension.unload(); +}); + +// This test makes a request against a server that redirects with a 302. +add_task(async function test_302_redirect_to_extension() { + let extension = getExtension(true); + await extension.startup(); + let redirectUrl = await extension.awaitMessage("redirectURI"); + let url = `${gServerUrl}/redirect?redirect_uri=${redirectUrl}`; + let result = await redirection_test(url); + equal(result, redirectUrl, "redirect request is finished"); + await extension.unload(); +}); + +// This test uses channel.redirectTo during http-on-modify to redirect to the +// moz-extension url. +add_task(async function test_channel_redirect_to_extension() { + let extension = getExtension(true); + await extension.startup(); + let redirectUrl = await extension.awaitMessage("redirectURI"); + let url = `${gServerUrl}/dummy?r=${Math.random()}`; + let result = await redirection_test(url, redirectUrl); + equal(result, redirectUrl, "redirect request is finished"); + await extension.unload(); +}); + +// This test verifies failure without web_accessible_resources. +add_task(async function test_content_redirect_to_non_accessible_resource() { + let extension = getExtension(); + await extension.startup(); + let redirectUrl = await extension.awaitMessage("redirectURI"); + let url = `${gServerUrl}/redirect?redirect_uri=${redirectUrl}`; + let watcher = onModifyListener(url).then(channel => { + return onStopListener(channel); + }); + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl: "about:blank", + }); + equal( + contentPage.browser.documentURI.spec, + "about:blank", + `expected no redirect` + ); + equal(await watcher, url, "expected no redirect"); + await contentPage.close(); + await extension.unload(); +}); + +// This test makes a request against a server that redirects with a 302. +add_task(async function test_content_302_redirect_to_extension() { + let extension = getExtension(true); + await extension.startup(); + let redirectUrl = await extension.awaitMessage("redirectURI"); + let url = `${gServerUrl}/redirect?redirect_uri=${redirectUrl}`; + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl, + }); + equal(contentPage.browser.documentURI.spec, redirectUrl, `expected redirect`); + await contentPage.close(); + await extension.unload(); +}); + +// This test uses channel.redirectTo during http-on-modify to redirect to the +// moz-extension url. +add_task(async function test_content_channel_redirect_to_extension() { + let extension = getExtension(true); + await extension.startup(); + let redirectUrl = await extension.awaitMessage("redirectURI"); + let url = `${gServerUrl}/dummy?r=${Math.random()}`; + onModifyListener(url, redirectUrl); + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl, + }); + equal(contentPage.browser.documentURI.spec, redirectUrl, `expected redirect`); + await contentPage.close(); + await extension.unload(); +}); + +// This test makes a request against a server and tests redirect to another server page. +add_task(async function test_extension_302_redirect_web() { + function background(serverUrl) { + let expectedUrls = ["/redirect", "/dummy"]; + let expected = [ + "onBeforeRequest", + "onHeadersReceived", + "onBeforeRedirect", + "onBeforeRequest", + "onHeadersReceived", + "onResponseStarted", + "onCompleted", + ]; + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertTrue( + details.url.includes(expectedUrls.shift()), + "onBeforeRequest url matches" + ); + browser.test.assertEq( + expected.shift(), + "onBeforeRequest", + "onBeforeRequest matches" + ); + }, + { urls: [serverUrl] } + ); + browser.webRequest.onHeadersReceived.addListener( + details => { + browser.test.assertEq( + expected.shift(), + "onHeadersReceived", + "onHeadersReceived matches" + ); + }, + { urls: [serverUrl] } + ); + browser.webRequest.onResponseStarted.addListener( + details => { + browser.test.assertEq( + expected.shift(), + "onResponseStarted", + "onResponseStarted matches" + ); + }, + { urls: [serverUrl] } + ); + browser.webRequest.onBeforeRedirect.addListener( + details => { + browser.test.assertTrue( + details.redirectUrl.includes("/dummy"), + "onBeforeRedirect matches redirectUrl" + ); + browser.test.assertEq( + expected.shift(), + "onBeforeRedirect", + "onBeforeRedirect matches" + ); + }, + { urls: [serverUrl] } + ); + browser.webRequest.onCompleted.addListener( + details => { + browser.test.assertTrue( + details.url.includes("/dummy"), + "onCompleted expected url received" + ); + browser.test.assertEq( + expected.shift(), + "onCompleted", + "onCompleted matches" + ); + browser.test.notifyPass("requestCompleted"); + }, + { urls: [serverUrl] } + ); + browser.webRequest.onErrorOccurred.addListener( + details => { + browser.test.log(`onErrorOccurred ${JSON.stringify(details)}`); + browser.test.notifyFail("requestCompleted"); + }, + { urls: [serverUrl] } + ); + } + let extension = getExtension( + false, + `(${background})("*://${server.identity.primaryHost}/*")`, + false + ); + await extension.startup(); + let redirectUrl = `${gServerUrl}/dummy`; + let completed = extension.awaitFinish("requestCompleted"); + let url = `${gServerUrl}/redirect?r=${Math.random()}&redirect_uri=${redirectUrl}`; + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl, + }); + equal( + contentPage.browser.documentURI.spec, + redirectUrl, + `expected content redirect` + ); + await completed; + await contentPage.close(); + await extension.unload(); +}); + +// This test makes a request against a server and tests redirect to another server page, without +// onBeforeRedirect. Bug 1448599 +add_task(async function test_extension_302_redirect_opening() { + let redirectUrl = `${gServerUrl}/dummy`; + let expectData = [ + { + event: "onBeforeRequest", + url: `${gServerUrl}/redirect`, + }, + { + event: "onBeforeRequest", + url: redirectUrl, + }, + ]; + function background(serverUrl, expected) { + browser.webRequest.onBeforeRequest.addListener( + details => { + let expect = expected.shift(); + browser.test.assertEq( + expect.event, + "onBeforeRequest", + "onBeforeRequest event matches" + ); + browser.test.assertTrue( + details.url.startsWith(expect.url), + "onBeforeRequest url matches" + ); + if (expected.length === 0) { + browser.test.notifyPass("requestCompleted"); + } + }, + { urls: [serverUrl] } + ); + } + let extension = getExtension( + false, + `(${background})("*://${server.identity.primaryHost}/*", ${JSON.stringify( + expectData + )})`, + false + ); + await extension.startup(); + let completed = extension.awaitFinish("requestCompleted"); + let url = `${gServerUrl}/redirect?r=${Math.random()}&redirect_uri=${redirectUrl}`; + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl, + }); + equal( + contentPage.browser.documentURI.spec, + redirectUrl, + `expected content redirect` + ); + await completed; + await contentPage.close(); + await extension.unload(); +}); + +// This test makes a request against a server and tests redirect to another server page, without +// onBeforeRedirect. Bug 1448599 +add_task(async function test_extension_302_redirect_modify() { + let redirectUrl = `${gServerUrl}/dummy`; + let expectData = [ + { + event: "onHeadersReceived", + url: `${gServerUrl}/redirect`, + }, + { + event: "onHeadersReceived", + url: redirectUrl, + }, + ]; + function background(serverUrl, expected) { + browser.webRequest.onHeadersReceived.addListener( + details => { + let expect = expected.shift(); + browser.test.assertEq( + expect.event, + "onHeadersReceived", + "onHeadersReceived event matches" + ); + browser.test.assertTrue( + details.url.startsWith(expect.url), + "onHeadersReceived url matches" + ); + if (expected.length === 0) { + browser.test.notifyPass("requestCompleted"); + } + }, + { urls: ["<all_urls>"] } + ); + } + let extension = getExtension( + false, + `(${background})("*://${server.identity.primaryHost}/*", ${JSON.stringify( + expectData + )})`, + false + ); + await extension.startup(); + let completed = extension.awaitFinish("requestCompleted"); + let url = `${gServerUrl}/redirect?r=${Math.random()}&redirect_uri=${redirectUrl}`; + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl, + }); + equal( + contentPage.browser.documentURI.spec, + redirectUrl, + `expected content redirect` + ); + await completed; + await contentPage.close(); + await extension.unload(); +}); + +// This test makes a request against a server and tests redirect to another server page, without +// onBeforeRedirect. Bug 1448599 +add_task(async function test_extension_302_redirect_tracing() { + let redirectUrl = `${gServerUrl}/dummy`; + let expectData = [ + { + event: "onCompleted", + url: redirectUrl, + }, + ]; + function background(serverUrl, expected) { + browser.webRequest.onCompleted.addListener( + details => { + let expect = expected.shift(); + browser.test.assertEq( + expect.event, + "onCompleted", + "onCompleted event matches" + ); + browser.test.assertTrue( + details.url.startsWith(expect.url), + "onCompleted url matches" + ); + if (expected.length === 0) { + browser.test.notifyPass("requestCompleted"); + } + }, + { urls: [serverUrl] } + ); + } + let extension = getExtension( + false, + `(${background})("*://${server.identity.primaryHost}/*", ${JSON.stringify( + expectData + )})`, + false + ); + await extension.startup(); + let completed = extension.awaitFinish("requestCompleted"); + let url = `${gServerUrl}/redirect?r=${Math.random()}&redirect_uri=${redirectUrl}`; + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl, + }); + equal( + contentPage.browser.documentURI.spec, + redirectUrl, + `expected content redirect` + ); + await completed; + await contentPage.close(); + await extension.unload(); +}); + +// This test makes a request against a server and tests webrequest. Currently +// disabled due to NS_BINDING_ABORTED happening. +add_task(async function test_extension_302_redirect() { + let extension = getExtension(true, () => { + let myuri = browser.runtime.getURL("*"); + let exturi = browser.runtime.getURL("finished.html"); + browser.webRequest.onBeforeRedirect.addListener( + details => { + browser.test.assertEq(details.redirectUrl, exturi, "redirect matches"); + }, + { urls: ["<all_urls>", myuri] } + ); + browser.webRequest.onCompleted.addListener( + details => { + browser.test.assertEq(details.url, exturi, "expected url received"); + browser.test.notifyPass("requestCompleted"); + }, + { urls: ["<all_urls>", myuri] } + ); + browser.webRequest.onErrorOccurred.addListener( + details => { + browser.test.log(`onErrorOccurred ${JSON.stringify(details)}`); + browser.test.notifyFail("requestCompleted"); + }, + { urls: ["<all_urls>", myuri] } + ); + // send the extensions public uri to the test. + browser.test.sendMessage("redirectURI", exturi); + }); + await extension.startup(); + let redirectUrl = await extension.awaitMessage("redirectURI"); + let completed = extension.awaitFinish("requestCompleted"); + let url = `${gServerUrl}/redirect?r=${Math.random()}&redirect_uri=${redirectUrl}`; + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl, + }); + equal( + contentPage.browser.documentURI.spec, + redirectUrl, + `expected content redirect` + ); + await completed; + await contentPage.close(); + await extension.unload(); +}).skip(); + +// This test makes a request and uses onBeforeRequet to redirect to moz-ext. +// Currently disabled due to NS_BINDING_ABORTED happening. +add_task(async function test_extension_redirect() { + let extension = getExtension(true, () => { + let myuri = browser.runtime.getURL("*"); + let exturi = browser.runtime.getURL("finished.html"); + browser.webRequest.onBeforeRequest.addListener( + details => { + return { redirectUrl: exturi }; + }, + { urls: ["<all_urls>", myuri] }, + ["blocking"] + ); + browser.webRequest.onBeforeRedirect.addListener( + details => { + browser.test.assertEq(details.redirectUrl, exturi, "redirect matches"); + }, + { urls: ["<all_urls>", myuri] } + ); + browser.webRequest.onCompleted.addListener( + details => { + browser.test.assertEq(details.url, exturi, "expected url received"); + browser.test.notifyPass("requestCompleted"); + }, + { urls: ["<all_urls>", myuri] } + ); + browser.webRequest.onErrorOccurred.addListener( + details => { + browser.test.log(`onErrorOccurred ${JSON.stringify(details)}`); + browser.test.notifyFail("requestCompleted"); + }, + { urls: ["<all_urls>", myuri] } + ); + // send the extensions public uri to the test. + browser.test.sendMessage("redirectURI", exturi); + }); + await extension.startup(); + let redirectUrl = await extension.awaitMessage("redirectURI"); + let completed = extension.awaitFinish("requestCompleted"); + let url = `${gServerUrl}/dummy?r=${Math.random()}`; + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl, + }); + equal(contentPage.browser.documentURI.spec, redirectUrl, `expected redirect`); + await completed; + await contentPage.close(); + await extension.unload(); +}).skip(); + +add_task(async function test_redirect_with_onHeadersReceived() { + let redirectUrl = `${gServerUrl}/dummy-2`; + + function background(initialUrl, redirectUrl) { + browser.webRequest.onCompleted.addListener( + () => { + browser.test.notifyPass("requestCompleted"); + }, + { urls: ["<all_urls>"] } + ); + + browser.webRequest.onHeadersReceived.addListener( + () => { + // Redirect to a different URL when we receive the headers of the + // initial request. + return { redirectUrl }; + }, + { urls: [initialUrl] }, + ["blocking"] + ); + } + let extension = getExtension( + false, + `(${background})("*://${server.identity.primaryHost}/dummy", "${redirectUrl}")` + ); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${gServerUrl}/dummy` + ); + await extension.awaitFinish("requestCompleted"); + equal(contentPage.browser.documentURI.spec, redirectUrl, "expected redirect"); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_no_redirect_with_location_in_onHeadersReceived() { + function background(initialUrl, redirectUrl) { + browser.webRequest.onCompleted.addListener( + ({ responseHeaders }) => { + // Make sure that the `Location` header is set by `onHeadersReceived`. + browser.test.assertTrue( + responseHeaders.some(({ name, value }) => { + return name.toLowerCase() === "location" && value === redirectUrl; + }), + "Location header is set" + ); + + browser.test.notifyPass("requestCompleted"); + }, + { urls: ["<all_urls>"] }, + ["responseHeaders"] + ); + + browser.webRequest.onHeadersReceived.addListener( + ({ responseHeaders }) => { + return { + responseHeaders: [ + ...responseHeaders, + // Although we set a Location header here, the request shouldn't be + // redirected to `redirectUrl` because the status code hasn't been + // change (and cannot be changed from there). + { name: "Location", value: redirectUrl }, + ], + }; + }, + { urls: [initialUrl] }, + ["blocking", "responseHeaders"] + ); + } + let extension = getExtension( + false, + `(${background})("*://${server.identity.primaryHost}/dummy", "${gServerUrl}/dummy-2")` + ); + await extension.startup(); + + let initialUrl = `${gServerUrl}/dummy`; + let contentPage = await ExtensionTestUtils.loadContentPage(initialUrl); + await extension.awaitFinish("requestCompleted"); + equal( + contentPage.browser.documentURI.spec, + initialUrl, + "expected no redirect" + ); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_connect_no_receiver.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_connect_no_receiver.js new file mode 100644 index 0000000000..e42f45c019 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_connect_no_receiver.js @@ -0,0 +1,26 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_connect_without_listener() { + function background() { + let port = browser.runtime.connect(); + port.onDisconnect.addListener(() => { + browser.test.assertEq( + "Could not establish connection. Receiving end does not exist.", + port.error && port.error.message + ); + browser.test.notifyPass("port.onDisconnect was called"); + }); + } + let extensionData = { + background, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitFinish("port.onDisconnect was called"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBackgroundPage.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBackgroundPage.js new file mode 100644 index 0000000000..5af0bab639 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBackgroundPage.js @@ -0,0 +1,172 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + + Services.prefs.setBoolPref("dom.serviceWorkers.testing.enabled", true); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("dom.serviceWorkers.testing.enabled"); + Services.prefs.clearUserPref("dom.serviceWorkers.idle_timeout"); + }); +}); + +add_task(async function test_getBackgroundPage_noBackground() { + async function testBackground() { + let page = await browser.runtime.getBackgroundPage(); + browser.test.assertEq( + page, + null, + "getBackgroundPage returned null as expected" + ); + browser.test.sendMessage("page-ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + files: { + "page.html": ` + <!DOCTYPE html> + <html> + <head><meta charset="utf-8"></head> + <body> + <script src="page.js"></script> + </body></html> + `, + + "page.js": testBackground, + }, + }); + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}//page.html` + ); + await extension.awaitMessage("page-ready"); + await contentPage.close(); + + await extension.unload(); +}); + +add_task( + { + pref_set: [["extensions.eventPages.enabled", true]], + skip_if: () => + Services.prefs.getBoolPref( + "extensions.backgroundServiceWorker.forceInTestExtension", + false + ), + }, + async function test_getBackgroundPage_eventpage() { + async function wakeupBackground() { + let page = await browser.runtime.getBackgroundPage(); + page.hello(); + browser.test.sendMessage("page-ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", // To automatically show sidebar on load. + manifest: { + background: { persistent: false }, + }, + + files: { + "page.html": ` + <!DOCTYPE html> + <html> + <head><meta charset="utf-8"></head> + <body> + <script src="page.js"></script> + </body></html> + `, + + "page.js": wakeupBackground, + }, + async background() { + // eslint-disable-next-line no-unused-vars + window.hello = () => { + browser.test.sendMessage("hello"); + }; + + browser.test.sendMessage("ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + await extension.terminateBackground(); + + // wake up the background + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}//page.html` + ); + await extension.awaitMessage("ready"); + await extension.awaitMessage("hello"); + await extension.awaitMessage("page-ready"); + await contentPage.close(); + + ok(true, "getBackgroundPage wakes up background"); + + await extension.unload(); + } +); + +add_task( + { + skip_if: () => { + return !WebExtensionPolicy.backgroundServiceWorkerEnabled; + }, + }, + async function test_getBackgroundPage_serviceWorker() { + async function testBackground() { + let page = await browser.runtime.getBackgroundPage(); + browser.test.assertEq( + page, + null, + "getBackgroundPage returned null as expected" + ); + browser.test.sendMessage("page-ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + version: "1.0", + background: { + service_worker: "sw.js", + }, + browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } }, + }, + + files: { + "sw.js": "dump('Background ServiceWorker - executed\\n');", + "page.html": ` + <!DOCTYPE html> + <html> + <head><meta charset="utf-8"></head> + <body> + <script src="page.js"></script> + </body></html> + `, + + "page.js": testBackground, + }, + }); + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}//page.html` + ); + await extension.awaitMessage("page-ready"); + await contentPage.close(); + + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBrowserInfo.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBrowserInfo.js new file mode 100644 index 0000000000..3f3b8f8e95 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBrowserInfo.js @@ -0,0 +1,26 @@ +/* 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"; + +add_task(async function setup() { + ExtensionTestUtils.mockAppInfo(); +}); + +add_task(async function test_getBrowserInfo() { + async function background() { + let info = await browser.runtime.getBrowserInfo(); + + browser.test.assertEq(info.name, "XPCShell", "name is valid"); + browser.test.assertEq(info.vendor, "Mozilla", "vendor is Mozilla"); + browser.test.assertEq(info.version, "48", "version is correct"); + browser.test.assertEq(info.buildID, "20160315", "buildID is correct"); + + browser.test.notifyPass("runtime.getBrowserInfo"); + } + + const extension = ExtensionTestUtils.loadExtension({ background }); + await extension.startup(); + await extension.awaitFinish("runtime.getBrowserInfo"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getPlatformInfo.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getPlatformInfo.js new file mode 100644 index 0000000000..7d0dde2f8a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getPlatformInfo.js @@ -0,0 +1,36 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function backgroundScript() { + browser.runtime.getPlatformInfo(info => { + let validOSs = ["mac", "win", "android", "cros", "linux", "openbsd"]; + let validArchs = [ + "aarch64", + "arm", + "ppc64", + "s390x", + "sparc64", + "x86-32", + "x86-64", + ]; + + browser.test.assertTrue(validOSs.includes(info.os), "OS is valid"); + browser.test.assertTrue( + validArchs.includes(info.arch), + "Architecture is valid" + ); + browser.test.notifyPass("runtime.getPlatformInfo"); + }); +} + +let extensionData = { + background: backgroundScript, +}; + +add_task(async function () { + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("runtime.getPlatformInfo"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_id.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_id.js new file mode 100644 index 0000000000..6967e81232 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_id.js @@ -0,0 +1,46 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_runtime_id() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + }, + ], + }, + + background() { + browser.test.sendMessage("background-id", browser.runtime.id); + }, + + files: { + "content_script.js"() { + browser.test.sendMessage("content-id", browser.runtime.id); + }, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + let backgroundId = await extension.awaitMessage("background-id"); + equal( + backgroundId, + extension.id, + "runtime.id from background script is correct" + ); + + let contentId = await extension.awaitMessage("content-id"); + equal(contentId, extension.id, "runtime.id from content script is correct"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_messaging_self.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_messaging_self.js new file mode 100644 index 0000000000..254387dc6b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_messaging_self.js @@ -0,0 +1,84 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task( + async function test_messaging_to_self_should_not_trigger_onMessage_onConnect() { + async function background() { + browser.runtime.onMessage.addListener(msg => { + browser.test.assertEq("msg from child", msg); + browser.test.sendMessage( + "sendMessage did not call same-frame onMessage" + ); + }); + + browser.test.onMessage.addListener(msg => { + browser.test.assertEq( + "sendMessage with a listener in another frame", + msg + ); + browser.runtime.sendMessage("should only reach another frame"); + }); + + await browser.test.assertRejects( + browser.runtime.sendMessage("should not trigger same-frame onMessage"), + "Could not establish connection. Receiving end does not exist." + ); + + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq("from-frame", port.name); + browser.runtime.connect({ name: "from-bg-2" }); + }); + + await new Promise(resolve => { + let port = browser.runtime.connect({ name: "from-bg-1" }); + port.onDisconnect.addListener(() => { + browser.test.assertEq( + "Could not establish connection. Receiving end does not exist.", + port.error.message + ); + resolve(); + }); + }); + + let anotherFrame = document.createElement("iframe"); + anotherFrame.src = browser.runtime.getURL("extensionpage.html"); + document.body.appendChild(anotherFrame); + } + + function lastScript() { + browser.runtime.onMessage.addListener(msg => { + browser.test.assertEq("should only reach another frame", msg); + browser.runtime.sendMessage("msg from child"); + }); + browser.test.sendMessage("sendMessage callback called"); + + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq("from-bg-2", port.name); + browser.test.sendMessage("connect did not call same-frame onConnect"); + }); + browser.runtime.connect({ name: "from-frame" }); + } + + let extensionData = { + background, + files: { + "lastScript.js": lastScript, + "extensionpage.html": `<!DOCTYPE html><meta charset="utf-8"><script src="lastScript.js"></script>`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitMessage("sendMessage callback called"); + extension.sendMessage("sendMessage with a listener in another frame"); + + await Promise.all([ + extension.awaitMessage("connect did not call same-frame onConnect"), + extension.awaitMessage("sendMessage did not call same-frame onMessage"), + ]); + + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_onInstalled_and_onStartup.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_onInstalled_and_onStartup.js new file mode 100644 index 0000000000..c330aaafde --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_onInstalled_and_onStartup.js @@ -0,0 +1,599 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" +); +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); + +const { + createAppInfo, + createTempWebExtensionFile, + promiseAddonEvent, + promiseCompleteAllInstalls, + promiseFindAddonUpdates, + promiseRestartManager, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); + +// Allow for unsigned addons. +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42"); + +function background() { + let onInstalledDetails = null; + let onStartupFired = false; + let eventPage = browser.runtime.getManifest().background.persistent === false; + + browser.runtime.onInstalled.addListener(details => { + onInstalledDetails = details; + }); + + browser.runtime.onStartup.addListener(() => { + onStartupFired = true; + }); + + browser.test.onMessage.addListener(message => { + if (message === "get-on-installed-details") { + onInstalledDetails = onInstalledDetails || { fired: false }; + browser.test.sendMessage("on-installed-details", onInstalledDetails); + } else if (message === "did-on-startup-fire") { + browser.test.sendMessage("on-startup-fired", onStartupFired); + } else if (message === "reload-extension") { + browser.runtime.reload(); + } + }); + + browser.runtime.onUpdateAvailable.addListener(details => { + browser.test.sendMessage("reloading"); + browser.runtime.reload(); + }); + + if (eventPage) { + browser.runtime.onSuspend.addListener(() => { + browser.test.sendMessage("suspended"); + }); + // an event we use to restart the background + browser.browserSettings.homepageOverride.onChange.addListener(() => { + browser.test.sendMessage("homepageOverride"); + }); + } +} + +async function expectEvents( + extension, + { + onStartupFired, + onInstalledFired, + onInstalledReason, + onInstalledTemporary, + onInstalledPrevious, + } +) { + extension.sendMessage("get-on-installed-details"); + let details = await extension.awaitMessage("on-installed-details"); + if (onInstalledFired) { + equal( + details.reason, + onInstalledReason, + "runtime.onInstalled fired with the correct reason" + ); + equal( + details.temporary, + onInstalledTemporary, + "runtime.onInstalled fired with the correct temporary flag" + ); + if (onInstalledPrevious) { + equal( + details.previousVersion, + onInstalledPrevious, + "runtime.onInstalled after update with correct previousVersion" + ); + } + } else { + equal( + details.fired, + onInstalledFired, + "runtime.onInstalled should not have fired" + ); + } + + extension.sendMessage("did-on-startup-fire"); + let fired = await extension.awaitMessage("on-startup-fired"); + equal( + fired, + onStartupFired, + `Expected runtime.onStartup to ${onStartupFired ? "" : "not "} fire` + ); +} + +add_task(async function test_should_fire_on_addon_update() { + Preferences.set("extensions.logging.enabled", false); + + await promiseStartupManager(); + + const EXTENSION_ID = + "test_runtime_on_installed_addon_update@tests.mozilla.org"; + + const PREF_EM_CHECK_UPDATE_SECURITY = "extensions.checkUpdateSecurity"; + + // The test extension uses an insecure update url. + Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false); + + const testServer = createHttpServer(); + const port = testServer.identity.primaryPort; + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + update_url: `http://localhost:${port}/test_update.json`, + }, + }, + }, + background, + }); + + testServer.registerPathHandler("/test_update.json", (request, response) => { + response.write(`{ + "addons": { + "${EXTENSION_ID}": { + "updates": [ + { + "version": "2.0", + "update_link": "http://localhost:${port}/addons/test_runtime_on_installed-2.0.xpi" + } + ] + } + } + }`); + }); + + let webExtensionFile = createTempWebExtensionFile({ + manifest: { + version: "2.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + }); + + testServer.registerFile( + "/addons/test_runtime_on_installed-2.0.xpi", + webExtensionFile + ); + + await extension.startup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "install", + }); + + let addon = await AddonManager.getAddonByID(EXTENSION_ID); + equal(addon.version, "1.0", "The installed addon has the correct version"); + + let update = await promiseFindAddonUpdates(addon); + let install = update.updateAvailable; + + let promiseInstalled = promiseAddonEvent("onInstalled"); + await promiseCompleteAllInstalls([install]); + + await extension.awaitMessage("reloading"); + + let [updated_addon] = await promiseInstalled; + equal( + updated_addon.version, + "2.0", + "The updated addon has the correct version" + ); + + await extension.awaitStartup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "update", + onInstalledPrevious: "1.0", + }); + + await extension.unload(); + + await promiseShutdownManager(); +}); + +add_task(async function test_should_fire_on_browser_update() { + const EXTENSION_ID = + "test_runtime_on_installed_browser_update@tests.mozilla.org"; + + await promiseStartupManager("1"); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + }); + + await extension.startup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "install", + }); + + // Restart the browser. + await promiseRestartManager("1"); + await extension.awaitBackgroundStarted(); + + await expectEvents(extension, { + onStartupFired: true, + onInstalledFired: false, + }); + + // Update the browser. + await promiseRestartManager("2"); + await extension.awaitBackgroundStarted(); + + await expectEvents(extension, { + onStartupFired: true, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "browser_update", + }); + + // Restart the browser. + await promiseRestartManager("2"); + await extension.awaitBackgroundStarted(); + + await expectEvents(extension, { + onStartupFired: true, + onInstalledFired: false, + }); + + // Update the browser again. + await promiseRestartManager("3"); + await extension.awaitBackgroundStarted(); + + await expectEvents(extension, { + onStartupFired: true, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "browser_update", + }); + + await extension.unload(); + + await promiseShutdownManager(); +}); + +add_task(async function test_should_not_fire_on_reload() { + const EXTENSION_ID = "test_runtime_on_installed_reload@tests.mozilla.org"; + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + }); + + await extension.startup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "install", + }); + + extension.sendMessage("reload-extension"); + extension.setRestarting(); + await extension.awaitStartup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: false, + }); + + await extension.unload(); + await promiseShutdownManager(); +}); + +add_task(async function test_should_not_fire_on_restart() { + const EXTENSION_ID = "test_runtime_on_installed_restart@tests.mozilla.org"; + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + }); + + await extension.startup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "install", + }); + + let addon = await AddonManager.getAddonByID(EXTENSION_ID); + await addon.disable(); + await addon.enable(); + await extension.awaitStartup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: false, + }); + + await extension.unload(); + await promiseShutdownManager(); +}); + +add_task(async function test_temporary_installation() { + const EXTENSION_ID = + "test_runtime_on_installed_addon_temporary@tests.mozilla.org"; + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + }); + + await extension.startup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledReason: "install", + onInstalledTemporary: true, + }); + + await extension.unload(); + await promiseShutdownManager(); +}); + +add_task( + { + pref_set: [["extensions.eventPages.enabled", true]], + }, + async function test_runtime_eventpage() { + const EXTENSION_ID = "test_runtime_eventpage@tests.mozilla.org"; + + await promiseStartupManager("1"); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + permissions: ["browserSettings"], + background: { + persistent: false, + }, + }, + background, + }); + + await extension.startup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledReason: "install", + onInstalledTemporary: false, + }); + + info(`test onInstall does not fire after suspend`); + // we do enough here that idle timeout causes intermittent failure. + // using terminateBackground results in the same code path tested. + extension.terminateBackground(); + await extension.awaitMessage("suspended"); + await promiseExtensionEvent(extension, "shutdown-background-script"); + + Services.prefs.setStringPref( + "browser.startup.homepage", + "http://test.example.com" + ); + await extension.awaitMessage("homepageOverride"); + // onStartup remains persisted, but not primed + assertPersistentListeners(extension, "runtime", "onStartup", { + primed: false, + persisted: true, + }); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: false, + }); + + info("test onStartup is not primed but background starts automatically"); + await promiseRestartManager(); + // onStartup is a bit special. During APP_STARTUP we do not + // prime this, we just start since it needs to. + assertPersistentListeners(extension, "runtime", "onStartup", { + primed: false, + persisted: true, + }); + await extension.awaitBackgroundStarted(); + + info("test expectEvents"); + await expectEvents(extension, { + onStartupFired: true, + onInstalledFired: false, + }); + + info("test onInstalled fired during browser update"); + await promiseRestartManager("2"); + assertPersistentListeners(extension, "runtime", "onStartup", { + primed: false, + persisted: true, + }); + await extension.awaitBackgroundStarted(); + + await expectEvents(extension, { + onStartupFired: true, + onInstalledFired: true, + onInstalledReason: "browser_update", + onInstalledTemporary: false, + }); + + info(`test onStarted does not fire after suspend`); + extension.terminateBackground(); + await extension.awaitMessage("suspended"); + await promiseExtensionEvent(extension, "shutdown-background-script"); + + Services.prefs.setStringPref( + "browser.startup.homepage", + "http://homepage.example.com" + ); + await extension.awaitMessage("homepageOverride"); + // onStartup remains persisted, but not primed + assertPersistentListeners(extension, "runtime", "onStartup", { + primed: false, + persisted: true, + }); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: false, + }); + + await extension.unload(); + await promiseShutdownManager(); + } +); + +// Verify we don't regress the issue related to runtime.onStartup persistent +// listener being cleared from the startup data as part of priming all listeners +// while terminating the event page on idle timeout (Bug 1796586). +add_task( + { + pref_set: [["extensions.eventPages.enabled", true]], + }, + async function test_runtime_onStartup_eventpage() { + const EXTENSION_ID = "test_eventpage_onStartup@tests.mozilla.org"; + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + permissions: ["browserSettings"], + background: { + persistent: false, + }, + }, + background, + }); + + await extension.startup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledReason: "install", + onInstalledTemporary: false, + }); + + info("Simulated idle timeout"); + extension.terminateBackground(); + await extension.awaitMessage("suspended"); + await promiseExtensionEvent(extension, "shutdown-background-script"); + + // onStartup remains persisted, but not primed + assertPersistentListeners(extension, "runtime", "onStartup", { + primed: false, + persisted: true, + }); + + info(`test onStartup after restart`); + await promiseRestartManager(); + + // onStartup is a bit special. During APP_STARTUP we do not + // prime this, we just start since it needs to. + assertPersistentListeners(extension, "runtime", "onStartup", { + primed: false, + persisted: true, + }); + await extension.awaitBackgroundStarted(); + + info("test expectEvents"); + await expectEvents(extension, { + onStartupFired: true, + onInstalledFired: false, + }); + + extension.terminateBackground(); + await extension.awaitMessage("suspended"); + await promiseExtensionEvent(extension, "shutdown-background-script"); + assertPersistentListeners(extension, "runtime", "onStartup", { + primed: false, + persisted: true, + }); + + await extension.unload(); + await promiseShutdownManager(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports.js new file mode 100644 index 0000000000..7365a13f93 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports.js @@ -0,0 +1,69 @@ +"use strict"; + +add_task(async function test_port_disconnected_from_wrong_window() { + let extensionData = { + background() { + let num = 0; + let ports = {}; + let done = false; + + browser.runtime.onConnect.addListener(port => { + num++; + ports[num] = port; + + port.onMessage.addListener(msg => { + browser.test.assertEq(msg, "port-2-response", "Got port 2 response"); + browser.test.sendMessage(msg + "-received"); + done = true; + }); + + port.onDisconnect.addListener(err => { + if (port === ports[1]) { + browser.test.log("Port 1 disconnected, sending message via port 2"); + ports[2].postMessage("port-2-msg"); + } else { + browser.test.assertTrue( + done, + "Port 2 disconnected only after a full roundtrip received" + ); + } + }); + + browser.test.sendMessage("port-connect-" + num); + }); + }, + files: { + "page.html": ` + <!DOCTYPE html><meta charset="utf8"> + <script src="script.js"></script> + `, + "script.js"() { + let port = browser.runtime.connect(); + port.onMessage.addListener(msg => { + browser.test.assertEq(msg, "port-2-msg", "Got message via port 2"); + port.postMessage("port-2-response"); + }); + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + let url = `moz-extension://${extension.uuid}/page.html`; + await extension.startup(); + + let page1 = await ExtensionTestUtils.loadContentPage(url, { extension }); + await extension.awaitMessage("port-connect-1"); + info("First page opened port 1"); + + let page2 = await ExtensionTestUtils.loadContentPage(url, { extension }); + await extension.awaitMessage("port-connect-2"); + info("Second page opened port 2"); + + info("Closing the first page should not close port 2"); + await page1.close(); + await extension.awaitMessage("port-2-response-received"); + info("Roundtrip message through port 2 received"); + + await page2.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports_gc.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports_gc.js new file mode 100644 index 0000000000..dd47744a97 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports_gc.js @@ -0,0 +1,170 @@ +"use strict"; + +let gcExperimentAPIs = { + gcHelper: { + schema: "schema.json", + child: { + scopes: ["addon_child"], + script: "child.js", + paths: [["gcHelper"]], + }, + }, +}; + +let gcExperimentFiles = { + "schema.json": JSON.stringify([ + { + namespace: "gcHelper", + functions: [ + { + name: "forceGarbageCollect", + type: "function", + parameters: [], + async: true, + }, + { + name: "registerWitness", + type: "function", + parameters: [ + { + name: "obj", + // Expected type is "object", but using "any" here to ensure that + // the parameter is untouched (not normalized). + type: "any", + }, + ], + returns: { type: "number" }, + }, + { + name: "isGarbageCollected", + type: "function", + parameters: [ + { + name: "witnessId", + description: "return value of registerWitness", + type: "number", + }, + ], + returns: { type: "boolean" }, + }, + ], + }, + ]), + "child.js": () => { + let { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" + ); + /* globals ExtensionAPI */ + this.gcHelper = class extends ExtensionAPI { + getAPI(context) { + let witnesses = new Map(); + return { + gcHelper: { + async forceGarbageCollect() { + // Logic copied from test_ext_contexts_gc.js + for (let i = 0; i < 3; ++i) { + Cu.forceShrinkingGC(); + Cu.forceCC(); + Cu.forceGC(); + await new Promise(resolve => setTimeout(resolve, 0)); + } + }, + registerWitness(obj) { + let witnessId = witnesses.size; + witnesses.set(witnessId, Cu.getWeakReference(obj)); + return witnessId; + }, + isGarbageCollected(witnessId) { + return witnesses.get(witnessId).get() === null; + }, + }, + }; + } + }; + }, +}; + +// Verify that the experiment is working as intended before using it in tests. +add_task(async function test_gc_experiment() { + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + manifest: { + experiment_apis: gcExperimentAPIs, + }, + files: gcExperimentFiles, + async background() { + let obj1 = {}; + let obj2 = {}; + let witness1 = browser.gcHelper.registerWitness(obj1); + let witness2 = browser.gcHelper.registerWitness(obj2); + obj1 = null; + await browser.gcHelper.forceGarbageCollect(); + browser.test.assertTrue( + browser.gcHelper.isGarbageCollected(witness1), + "obj1 should have been garbage-collected" + ); + browser.test.assertFalse( + browser.gcHelper.isGarbageCollected(witness2), + "obj2 should not have been garbage-collected" + ); + + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function test_port_gc() { + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + manifest: { + experiment_apis: gcExperimentAPIs, + }, + files: gcExperimentFiles, + async background() { + let witnessPortSender; + let witnessPortReceiver; + + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq("daName", port.name, "expected port"); + witnessPortReceiver = browser.gcHelper.registerWitness(port); + port.disconnect(); + }); + + // runtime.connect() only triggers onConnect for different contexts, + // so create a frame to have a different context. + // A blank frame in a moz-extension:-document will have access to the + // extension APIs. + let frameWindow = await new Promise(resolve => { + let f = document.createElement("iframe"); + f.onload = () => resolve(f.contentWindow); + document.body.append(f); + }); + await new Promise(resolve => { + let port = frameWindow.browser.runtime.connect({ name: "daName" }); + witnessPortSender = browser.gcHelper.registerWitness(port); + port.onDisconnect.addListener(() => resolve()); + }); + + await browser.gcHelper.forceGarbageCollect(); + + browser.test.assertTrue( + browser.gcHelper.isGarbageCollected(witnessPortSender), + "runtime.connect() port should have been garbage-collected" + ); + browser.test.assertTrue( + browser.gcHelper.isGarbageCollected(witnessPortReceiver), + "runtime.onConnect port should have been garbage-collected" + ); + + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage.js new file mode 100644 index 0000000000..2bbc9864d7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage.js @@ -0,0 +1,462 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function runtimeSendMessageReply() { + function background() { + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "respond-now") { + respond(msg); + } else if (msg == "respond-soon") { + setTimeout(() => { + respond(msg); + }, 0); + return true; + } else if (msg == "respond-promise") { + return Promise.resolve(msg); + } else if (msg == "respond-promise-false") { + return Promise.resolve(false); + } else if (msg == "respond-false") { + // return false means that respond() is not expected to be called. + setTimeout(() => respond("should be ignored")); + return false; + } else if (msg == "respond-never") { + return undefined; + } else if (msg == "respond-error") { + return Promise.reject(new Error(msg)); + } else if (msg == "throw-error") { + throw new Error(msg); + } else if (msg === "respond-uncloneable") { + return Promise.resolve(window); + } else if (msg === "reject-uncloneable") { + return Promise.reject(window); + } else if (msg == "reject-undefined") { + return Promise.reject(); + } else if (msg == "throw-undefined") { + throw undefined; // eslint-disable-line no-throw-literal + } + }); + + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "respond-now") { + respond("hello"); + } else if (msg == "respond-now-2") { + respond(msg); + } + }); + + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "respond-now") { + // If a response from another listener is received first, this + // exception should be ignored. Test fails if it is not. + + // All this is of course stupid, but some extensions depend on it. + msg.blah.this.throws(); + } + }); + + let childFrame = document.createElement("iframe"); + childFrame.src = "extensionpage.html"; + document.body.appendChild(childFrame); + } + + function senderScript() { + Promise.all([ + browser.runtime.sendMessage("respond-now"), + browser.runtime.sendMessage("respond-now-2"), + new Promise(resolve => + browser.runtime.sendMessage("respond-soon", resolve) + ), + browser.runtime.sendMessage("respond-promise"), + browser.runtime.sendMessage("respond-promise-false"), + browser.runtime.sendMessage("respond-false"), + browser.runtime.sendMessage("respond-never"), + new Promise(resolve => { + browser.runtime.sendMessage("respond-never", response => { + resolve(response); + }); + }), + + browser.runtime + .sendMessage("respond-error") + .catch(error => Promise.resolve({ error })), + browser.runtime + .sendMessage("throw-error") + .catch(error => Promise.resolve({ error })), + + browser.runtime + .sendMessage("respond-uncloneable") + .catch(error => Promise.resolve({ error })), + browser.runtime + .sendMessage("reject-uncloneable") + .catch(error => Promise.resolve({ error })), + browser.runtime + .sendMessage("reject-undefined") + .catch(error => Promise.resolve({ error })), + browser.runtime + .sendMessage("throw-undefined") + .catch(error => Promise.resolve({ error })), + ]) + .then( + ([ + respondNow, + respondNow2, + respondSoon, + respondPromise, + respondPromiseFalse, + respondFalse, + respondNever, + respondNever2, + respondError, + throwError, + respondUncloneable, + rejectUncloneable, + rejectUndefined, + throwUndefined, + ]) => { + browser.test.assertEq( + "respond-now", + respondNow, + "Got the expected immediate response" + ); + browser.test.assertEq( + "respond-now-2", + respondNow2, + "Got the expected immediate response from the second listener" + ); + browser.test.assertEq( + "respond-soon", + respondSoon, + "Got the expected delayed response" + ); + browser.test.assertEq( + "respond-promise", + respondPromise, + "Got the expected promise response" + ); + browser.test.assertEq( + false, + respondPromiseFalse, + "Got the expected false value as a promise result" + ); + browser.test.assertEq( + undefined, + respondFalse, + "Got the expected no-response when onMessage returns false" + ); + browser.test.assertEq( + undefined, + respondNever, + "Got the expected no-response resolution" + ); + browser.test.assertEq( + undefined, + respondNever2, + "Got the expected no-response resolution" + ); + + browser.test.assertEq( + "respond-error", + respondError.error.message, + "Got the expected error response" + ); + browser.test.assertEq( + "throw-error", + throwError.error.message, + "Got the expected thrown error response" + ); + + browser.test.assertEq( + "Could not establish connection. Receiving end does not exist.", + respondUncloneable.error.message, + "An uncloneable response should be ignored" + ); + browser.test.assertEq( + "An unexpected error occurred", + rejectUncloneable.error.message, + "Got the expected error for a rejection with an uncloneable value" + ); + browser.test.assertEq( + "An unexpected error occurred", + rejectUndefined.error.message, + "Got the expected error for a void rejection" + ); + browser.test.assertEq( + "An unexpected error occurred", + throwUndefined.error.message, + "Got the expected error for a void throw" + ); + + browser.test.notifyPass("sendMessage"); + } + ) + .catch(e => { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("sendMessage"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + files: { + "senderScript.js": senderScript, + "extensionpage.html": `<!DOCTYPE html><meta charset="utf-8"><script src="senderScript.js"></script>`, + }, + }); + + await extension.startup(); + await extension.awaitFinish("sendMessage"); + await extension.unload(); +}); + +add_task(async function runtimeSendMessageBlob() { + function background() { + browser.runtime.onMessage.addListener(msg => { + // eslint-disable-next-line mozilla/use-isInstance -- this function runs in an extension + browser.test.assertTrue(msg.blob instanceof Blob, "Message is a blob"); + return Promise.resolve(msg); + }); + + let childFrame = document.createElement("iframe"); + childFrame.src = "extensionpage.html"; + document.body.appendChild(childFrame); + } + + function senderScript() { + browser.runtime + .sendMessage({ blob: new Blob(["hello"]) }) + .then(response => { + browser.test.assertTrue( + // eslint-disable-next-line mozilla/use-isInstance -- this function runs in an extension + response.blob instanceof Blob, + "Response is a blob" + ); + browser.test.notifyPass("sendBlob"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + files: { + "senderScript.js": senderScript, + "extensionpage.html": `<!DOCTYPE html><meta charset="utf-8"><script src="senderScript.js"></script>`, + }, + }); + + await extension.startup(); + await extension.awaitFinish("sendBlob"); + await extension.unload(); +}); + +add_task(async function sendMessageResponseGC() { + function background() { + let savedResolve, savedRespond; + + browser.runtime.onMessage.addListener((msg, _, respond) => { + browser.test.log(`Got request: ${msg}`); + switch (msg) { + case "ping": + respond("pong"); + return; + + case "promise-save": + return new Promise(resolve => { + savedResolve = resolve; + }); + case "promise-resolve": + savedResolve("saved-resolve"); + return; + case "promise-never": + return new Promise(r => {}); + + case "callback-save": + savedRespond = respond; + return true; + case "callback-call": + savedRespond("saved-respond"); + return; + case "callback-never": + return true; + } + }); + + const frame = document.createElement("iframe"); + frame.src = "page.html"; + document.body.appendChild(frame); + } + + function page() { + browser.test.onMessage.addListener(msg => { + browser.runtime.sendMessage(msg).then( + response => { + if (response) { + browser.test.log(`Got response: ${response}`); + browser.test.sendMessage(response); + } + }, + error => { + browser.test.assertEq( + "Promised response from onMessage listener went out of scope", + error.message, + `Promise rejected with the correct error message` + ); + + browser.test.assertTrue( + /^moz-extension:\/\/[\w-]+\/%7B[\w-]+%7D\.js/.test(error.fileName), + `Promise rejected with the correct error filename: ${error.fileName}` + ); + + browser.test.assertEq( + 4, + error.lineNumber, + `Promise rejected with the correct error line number` + ); + + browser.test.assertTrue( + /moz-extension:\/\/[\w-]+\/%7B[\w-]+%7D\.js:4/.test(error.stack), + `Promise rejected with the correct error stack: ${error.stack}` + ); + browser.test.sendMessage("rejected"); + } + ); + }); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + files: { + "page.html": + "<!DOCTYPE html><meta charset=utf-8><script src=page.js></script>", + "page.js": page, + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + // Setup long-running tasks before GC. + extension.sendMessage("promise-save"); + extension.sendMessage("callback-save"); + + // Test returning a Promise that can never resolve. + extension.sendMessage("promise-never"); + + extension.sendMessage("ping"); + await extension.awaitMessage("pong"); + + Services.prefs.setBoolPref( + "security.allow_parent_unrestricted_js_loads", + true + ); + Services.ppmm.loadProcessScript("data:,Components.utils.forceGC()", false); + await extension.awaitMessage("rejected"); + + // Test returning `true` without holding the response handle. + extension.sendMessage("callback-never"); + + extension.sendMessage("ping"); + await extension.awaitMessage("pong"); + + Services.ppmm.loadProcessScript("data:,Components.utils.forceGC()", false); + Services.prefs.setBoolPref( + "security.allow_parent_unrestricted_js_loads", + false + ); + await extension.awaitMessage("rejected"); + + // Test that promises from long-running tasks didn't get GCd. + extension.sendMessage("promise-resolve"); + await extension.awaitMessage("saved-resolve"); + + extension.sendMessage("callback-call"); + await extension.awaitMessage("saved-respond"); + + ok("Long running tasks responded"); + await extension.unload(); +}); + +add_task(async function sendMessage_async_response_multiple_contexts() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.runtime.onMessage.addListener((msg, _, respond) => { + browser.test.log(`Background got request: ${msg}`); + + switch (msg) { + case "ask-bg-fast": + respond("bg-respond"); + return true; + + case "ask-bg-slow": + return new Promise(r => setTimeout(() => r("bg-promise")), 1000); + } + }); + browser.test.sendMessage("bg-ready"); + }, + + manifest: { + content_scripts: [ + { + matches: ["http://localhost/*/file_sample.html"], + js: ["cs.js"], + }, + ], + }, + + files: { + "page.html": + "<!DOCTYPE html><meta charset=utf-8><script src=page.js></script>", + "page.js"() { + browser.runtime.onMessage.addListener((msg, _, respond) => { + browser.test.log(`Page got request: ${msg}`); + + switch (msg) { + case "ask-page-fast": + respond("page-respond"); + return true; + + case "ask-page-slow": + return new Promise(r => setTimeout(() => r("page-promise")), 500); + } + }); + browser.test.sendMessage("page-ready"); + }, + + "cs.js"() { + Promise.all([ + browser.runtime.sendMessage("ask-bg-fast"), + browser.runtime.sendMessage("ask-bg-slow"), + browser.runtime.sendMessage("ask-page-fast"), + browser.runtime.sendMessage("ask-page-slow"), + ]).then(responses => { + browser.test.assertEq( + responses.join(), + ["bg-respond", "bg-promise", "page-respond", "page-promise"].join(), + "Got all expected responses from correct contexts" + ); + browser.test.notifyPass("cs-done"); + }); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("bg-ready"); + + let url = `moz-extension://${extension.uuid}/page.html`; + let page = await ExtensionTestUtils.loadContentPage(url, { extension }); + await extension.awaitMessage("page-ready"); + + let content = await ExtensionTestUtils.loadContentPage( + BASE_URL + "/file_sample.html" + ); + await extension.awaitFinish("cs-done"); + await content.close(); + + await page.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_args.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_args.js new file mode 100644 index 0000000000..2c0b889ba3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_args.js @@ -0,0 +1,118 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + const ID1 = "sendMessage1@tests.mozilla.org"; + const ID2 = "sendMessage2@tests.mozilla.org"; + + let extension1 = ExtensionTestUtils.loadExtension({ + background() { + browser.test.onMessage.addListener((...args) => { + browser.runtime.sendMessage(...args); + }); + + let frame = document.createElement("iframe"); + frame.src = "page.html"; + document.body.appendChild(frame); + }, + manifest: { browser_specific_settings: { gecko: { id: ID1 } } }, + files: { + "page.js": function () { + browser.runtime.onMessage.addListener((msg, sender) => { + browser.test.sendMessage("received-page", { msg, sender }); + }); + // Let them know we're done loading the page. + browser.test.sendMessage("page-ready"); + }, + "page.html": `<!DOCTYPE html><meta charset="utf-8"><script src="page.js"></script>`, + }, + }); + + let extension2 = ExtensionTestUtils.loadExtension({ + background() { + browser.runtime.onMessageExternal.addListener((msg, sender) => { + browser.test.sendMessage("received-external", { msg, sender }); + }); + }, + manifest: { browser_specific_settings: { gecko: { id: ID2 } } }, + }); + + await Promise.all([extension1.startup(), extension2.startup()]); + await extension1.awaitMessage("page-ready"); + + // Check that a message was sent within extension1. + async function checkLocalMessage(msg) { + let result = await extension1.awaitMessage("received-page"); + deepEqual(result.msg, msg, "Received internal message"); + equal(result.sender.id, ID1, "Received correct sender id"); + } + + // Check that a message was sent from extension1 to extension2. + async function checkRemoteMessage(msg) { + let result = await extension2.awaitMessage("received-external"); + deepEqual(result.msg, msg, "Received cross-extension message"); + equal(result.sender.id, ID1, "Received correct sender id"); + } + + // sendMessage() takes 3 arguments: + // optional extensionID + // mandatory message + // optional options + // Due to this insane design we parse its arguments manually. This + // test is meant to cover all the combinations. + + // A single null or undefined argument is allowed, and represents the message + extension1.sendMessage(null); + await checkLocalMessage(null); + + // With one argument, it must be just the message + extension1.sendMessage("message"); + await checkLocalMessage("message"); + + // With two arguments, these cases should be treated as (extensionID, message) + extension1.sendMessage(ID2, "message"); + await checkRemoteMessage("message"); + + extension1.sendMessage(ID2, { msg: "message" }); + await checkRemoteMessage({ msg: "message" }); + + // And these should be (message, options) + extension1.sendMessage("message", {}); + await checkLocalMessage("message"); + + // or (message, non-callback), pick your poison + extension1.sendMessage("message", undefined); + await checkLocalMessage("message"); + + // With three arguments, we send a cross-extension message + extension1.sendMessage(ID2, "message", {}); + await checkRemoteMessage("message"); + + // Even when the last one is null or undefined + extension1.sendMessage(ID2, "message", undefined); + await checkRemoteMessage("message"); + + // The four params case is unambigous, so we allow null as a (non-) callback + extension1.sendMessage(ID2, "message", {}, null); + await checkRemoteMessage("message"); + + await Promise.all([extension1.unload(), extension2.unload()]); +}); + +add_task(async function test_sendMessage_to_badid() { + const extension = ExtensionTestUtils.loadExtension({ + async background() { + await browser.test.assertRejects( + browser.runtime.sendMessage("badid@test-extension", "fake-message"), + /Could not establish connection. Receiving end does not exist./, + "Got the expected error message on sendMessage to badid ext" + ); + browser.test.sendMessage("test-done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("test-done"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_errors.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_errors.js new file mode 100644 index 0000000000..d78197f9e4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_errors.js @@ -0,0 +1,66 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_sendMessage_error() { + async function background() { + let circ = {}; + circ.circ = circ; + let testCases = [ + // [arguments, expected error string], + [[], "runtime.sendMessage's message argument is missing"], + [ + [null, null, null, 42], + "runtime.sendMessage's last argument is not a function", + ], + [[null, null, 1], "runtime.sendMessage's options argument is invalid"], + [ + [1, null, null], + "runtime.sendMessage's extensionId argument is invalid", + ], + [ + [null, null, null, null, null], + "runtime.sendMessage received too many arguments", + ], + + // Even when the parameters are accepted, we still expect an error + // because there is no onMessage listener. + [ + [null, null, null], + "Could not establish connection. Receiving end does not exist.", + ], + + // Structured cloning doesn't work with DOM objects + [[null, location, null], "Location object could not be cloned."], + [[null, [circ, location], null], "Location object could not be cloned."], + ]; + + // Repeat all tests with the undefined value instead of null. + for (let [args, expectedError] of testCases.slice()) { + args = args.map(arg => (arg === null ? undefined : arg)); + testCases.push([args, expectedError]); + } + + for (let [args, expectedError] of testCases) { + let description = `runtime.sendMessage(${args.map(String).join(", ")})`; + + await browser.test.assertRejects( + browser.runtime.sendMessage(...args), + expectedError, + `expected error message for ${description}` + ); + } + + browser.test.notifyPass("sendMessage parameter validation"); + } + let extensionData = { + background, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitFinish("sendMessage parameter validation"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_multiple.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_multiple.js new file mode 100644 index 0000000000..9827a329e3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_multiple.js @@ -0,0 +1,67 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Regression test for bug 1655624: When there are multiple onMessage receivers +// that both handle the response asynchronously, destroying the context of one +// recipient should not prevent the other recipient from sending a reply. +add_task(async function onMessage_ignores_destroyed_contexts() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.onMessage.addListener(async msg => { + if (msg !== "startTest") { + return; + } + try { + let res = await browser.runtime.sendMessage("msg_from_bg"); + browser.test.assertEq(0, res, "Result from onMessage"); + browser.test.notifyPass("handled_onMessage"); + } catch (e) { + browser.test.fail(`Unexpected error: ${e.message} :: ${e.stack}`); + browser.test.notifyFail("handled_onMessage"); + } + }); + }, + files: { + "tab.html": ` + <!DOCTYPE html><meta charset="utf-8"> + <script src="tab.js"></script> + `, + "tab.js": () => { + let where = location.search.slice(1); + let resolveOnMessage; + browser.runtime.onMessage.addListener(async msg => { + browser.test.assertEq("msg_from_bg", msg, `onMessage at ${where}`); + browser.test.sendMessage(`received:${where}`); + return new Promise(resolve => { + resolveOnMessage = resolve; + }); + }); + browser.test.onMessage.addListener(msg => { + if (msg === `resolveOnMessage:${where}`) { + resolveOnMessage(0); + } + }); + }, + }, + }); + await extension.startup(); + let tabToCloseEarly = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/tab.html?tabToCloseEarly`, + { extension } + ); + let tabToRespond = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/tab.html?tabToRespond`, + { extension } + ); + extension.sendMessage("startTest"); + await Promise.all([ + extension.awaitMessage("received:tabToCloseEarly"), + extension.awaitMessage("received:tabToRespond"), + ]); + await tabToCloseEarly.close(); + extension.sendMessage("resolveOnMessage:tabToRespond"); + await extension.awaitFinish("handled_onMessage"); + await tabToRespond.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_no_receiver.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_no_receiver.js new file mode 100644 index 0000000000..23d8b05f83 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_no_receiver.js @@ -0,0 +1,93 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_sendMessage_without_listener() { + async function background() { + await browser.test.assertRejects( + browser.runtime.sendMessage("msg"), + "Could not establish connection. Receiving end does not exist.", + "Correct error when there are no receivers from background" + ); + + browser.test.sendMessage("sendMessage-error-bg"); + } + let extensionData = { + background, + files: { + "page.html": `<!doctype><meta charset=utf-8><script src="page.js"></script>`, + async "page.js"() { + await browser.test.assertRejects( + browser.runtime.sendMessage("msg"), + "Could not establish connection. Receiving end does not exist.", + "Correct error when there are no receivers from extension page" + ); + + browser.test.notifyPass("sendMessage-error-page"); + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("sendMessage-error-bg"); + + let url = `moz-extension://${extension.uuid}/page.html`; + let page = await ExtensionTestUtils.loadContentPage(url, { extension }); + await extension.awaitFinish("sendMessage-error-page"); + await page.close(); + + await extension.unload(); +}); + +add_task(async function test_chrome_sendMessage_without_listener() { + function background() { + /* globals chrome */ + browser.test.assertEq( + null, + chrome.runtime.lastError, + "no lastError before call" + ); + let retval = chrome.runtime.sendMessage("msg"); + browser.test.assertEq( + null, + chrome.runtime.lastError, + "no lastError after call" + ); + browser.test.assertEq( + undefined, + retval, + "return value of chrome.runtime.sendMessage without callback" + ); + + let isAsyncCall = false; + retval = chrome.runtime.sendMessage("msg", reply => { + browser.test.assertEq(undefined, reply, "no reply"); + browser.test.assertTrue( + isAsyncCall, + "chrome.runtime.sendMessage's callback must be called asynchronously" + ); + browser.test.assertEq( + undefined, + retval, + "return value of chrome.runtime.sendMessage with callback" + ); + browser.test.assertEq( + "Could not establish connection. Receiving end does not exist.", + chrome.runtime.lastError.message + ); + browser.test.notifyPass("finished chrome.runtime.sendMessage"); + }); + isAsyncCall = true; + } + let extensionData = { + background, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitFinish("finished chrome.runtime.sendMessage"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_same_site_cookies.js b/toolkit/components/extensions/test/xpcshell/test_ext_same_site_cookies.js new file mode 100644 index 0000000000..7d768b47c4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_same_site_cookies.js @@ -0,0 +1,131 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +const WIN = `<html><body>dummy page setting a same-site cookie</body></html>`; + +// Small red image. +const IMG_BYTES = atob( + "iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12" + + "P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==" +); + +server.registerPathHandler("/same_site_cookies", (request, response) => { + // avoid confusing cache behaviors + response.setHeader("Cache-Control", "no-cache", false); + + if (request.queryString === "loadWin") { + response.write(WIN); + return; + } + + // using startsWith and discard the math random + if (request.queryString.startsWith("loadImage")) { + response.setHeader( + "Set-Cookie", + "myKey=mySameSiteExtensionCookie; samesite=strict", + true + ); + response.setHeader("Content-Type", "image/png"); + response.write(IMG_BYTES); + return; + } + + if (request.queryString === "loadXHR") { + let cookie = "noCookie"; + if (request.hasHeader("Cookie")) { + cookie = request.getHeader("Cookie"); + } + response.setHeader("Content-Type", "text/plain"); + response.write(cookie); + return; + } + + // We should never get here, but just in case return something unexpected. + response.write("D'oh"); +}); + +/* Description of the test: + * (1) We load an image from mochi.test which sets a same site cookie + * (2) We have the web extension perform an XHR request to mochi.test + * (3) We verify the web-extension can access the same-site cookie + */ + +add_task(async function test_webRequest_same_site_cookie_access() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://example.com/*"], + content_scripts: [ + { + matches: ["http://example.com/*"], + run_at: "document_end", + js: ["content_script.js"], + }, + ], + }, + + background() { + browser.test.onMessage.addListener(msg => { + if (msg === "verify-same-site-cookie-moz-extension") { + let xhr = new XMLHttpRequest(); + try { + xhr.open( + "GET", + "http://example.com/same_site_cookies?loadXHR", + true + ); + xhr.onload = function () { + browser.test.assertEq( + "myKey=mySameSiteExtensionCookie", + xhr.responseText, + "cookie should be accessible from moz-extension context" + ); + browser.test.sendMessage("same-site-cookie-test-done"); + }; + xhr.onerror = function () { + browser.test.fail("xhr onerror"); + browser.test.sendMessage("same-site-cookie-test-done"); + }; + } catch (e) { + browser.test.fail("xhr failure: " + e); + } + xhr.send(); + } + }); + }, + + files: { + "content_script.js": function () { + let myImage = document.createElement("img"); + // Set the src via wrappedJSObject so the load is triggered with the + // content page's principal rather than ours. + myImage.wrappedJSObject.setAttribute( + "src", + "http://example.com/same_site_cookies?loadImage" + Math.random() + ); + myImage.onload = function () { + browser.test.log("image onload"); + browser.test.sendMessage("image-loaded-and-same-site-cookie-set"); + }; + myImage.onerror = function () { + browser.test.log("image onerror"); + }; + document.body.appendChild(myImage); + }, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/same_site_cookies?loadWin" + ); + + await extension.awaitMessage("image-loaded-and-same-site-cookie-set"); + + extension.sendMessage("verify-same-site-cookie-moz-extension"); + await extension.awaitMessage("same-site-cookie-test-done"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_same_site_redirects.js b/toolkit/components/extensions/test/xpcshell/test_ext_same_site_redirects.js new file mode 100644 index 0000000000..a3000e4e1f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_same_site_redirects.js @@ -0,0 +1,239 @@ +"use strict"; + +/** + * This test tests various redirection scenarios, and checks whether sameSite + * cookies are sent. + * + * The file has the following tests: + * - verify_firstparty_web_behavior - base case, confirms normal web behavior. + * - samesite_is_foreign_without_host_permissions + * - wildcard_host_permissions_enable_samesite_cookies + * - explicit_host_permissions_enable_samesite_cookies + * - some_host_permissions_enable_some_samesite_cookies + */ + +// This simulates a common pattern used for sites that require authentication. +// After logging in, there may be multiple redirects, HTTP and scripted. +const SITE_START = "start.example.net"; +// set "start" cookies + 302 redirects to found. +const SITE_FOUND = "found.example.net"; +// set "found" cookies + uses a HTML redirect to redir. +const SITE_REDIR = "redir.example.net"; +// set "redir" cookies + 302 redirects to final. +const SITE_FINAL = "final.example.net"; + +const SITE = "example.net"; + +const URL_START = `http://${SITE_START}/start`; + +const server = createHttpServer({ + hosts: [SITE_START, SITE_FOUND, SITE_REDIR, SITE_FINAL], +}); + +function getCookies(request) { + return request.hasHeader("Cookie") ? request.getHeader("Cookie") : ""; +} + +function sendCookies(response, prefix, suffix = "") { + const cookies = [ + prefix + "-none=1; sameSite=none; domain=" + SITE + suffix, + prefix + "-lax=1; sameSite=lax; domain=" + SITE + suffix, + prefix + "-strict=1; sameSite=strict; domain=" + SITE + suffix, + ]; + for (let cookie of cookies) { + response.setHeader("Set-Cookie", cookie, true); + } +} + +function deleteCookies(response, prefix) { + sendCookies(response, prefix, "; expires=Thu, 01 Jan 1970 00:00:00 GMT"); +} + +var receivedCookies = []; + +server.registerPathHandler("/start", (request, response) => { + Assert.equal(request.host, SITE_START); + Assert.equal(getCookies(request), "", "No cookies at start of test"); + + response.setStatusLine(request.httpVersion, 302, "Found"); + sendCookies(response, "start"); + response.setHeader("Location", `http://${SITE_FOUND}/found`); +}); + +server.registerPathHandler("/found", (request, response) => { + Assert.equal(request.host, SITE_FOUND); + receivedCookies.push(getCookies(request)); + + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + deleteCookies(response, "start"); + sendCookies(response, "found"); + response.write(`<script>location = "http://${SITE_REDIR}/redir";</script>`); +}); + +server.registerPathHandler("/redir", (request, response) => { + Assert.equal(request.host, SITE_REDIR); + receivedCookies.push(getCookies(request)); + + response.setStatusLine(request.httpVersion, 302, "Found"); + deleteCookies(response, "found"); + sendCookies(response, "redir"); + response.setHeader("Location", `http://${SITE_FINAL}/final`); +}); + +server.registerPathHandler("/final", (request, response) => { + Assert.equal(request.host, SITE_FINAL); + receivedCookies.push(getCookies(request)); + + response.setStatusLine(request.httpVersion, 302, "Found"); + deleteCookies(response, "redir"); + // In test some_host_permissions_enable_some_samesite_cookies, the cookies + // from the start haven't been cleared due to the lack of host permissions. + // Do that here instead. + deleteCookies(response, "start"); + response.setHeader("Location", "/final_and_clean"); +}); + +// Should be called before any request is made. +function promiseFinalResponse() { + Assert.deepEqual(receivedCookies, [], "Test starts without observed cookies"); + return new Promise(resolve => { + server.registerPathHandler("/final_and_clean", (request, response) => { + Assert.equal(request.host, SITE_FINAL); + Assert.equal(getCookies(request), "", "Cookies cleaned up"); + resolve(receivedCookies.splice(0)); + }); + }); +} + +// Load the page as a child frame of an extension, for the given permissions. +async function getCookiesForLoadInExtension({ permissions }) { + // embedder.html loads http:-frame. + allow_unsafe_parent_loads_when_extensions_not_remote(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions, + }, + files: { + "embedder.html": `<iframe src="${URL_START}"></iframe>`, + }, + }); + await extension.startup(); + let cookiesPromise = promiseFinalResponse(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/embedder.html`, + { extension } + ); + let cookies = await cookiesPromise; + await contentPage.close(); + await extension.unload(); + + revert_allow_unsafe_parent_loads_when_extensions_not_remote(); + + return cookies; +} + +add_task(async function setup() { + Services.prefs.setIntPref("network.cookie.cookieBehavior", 0); + Services.prefs.setBoolPref("network.cookie.sameSite.laxByDefault", true); + + // Test server runs on http, so disable Secure requirement of sameSite=none. + Services.prefs.setBoolPref( + "network.cookie.sameSite.noneRequiresSecure", + false + ); +}); + +// First verify that our expectations match with the actual behavior on the web. +add_task(async function verify_firstparty_web_behavior() { + let cookiesPromise = promiseFinalResponse(); + let contentPage = await ExtensionTestUtils.loadContentPage(URL_START); + let cookies = await cookiesPromise; + await contentPage.close(); + Assert.deepEqual( + cookies, + // Same expectations as in host_permissions_enable_samesite_cookies + [ + "start-none=1; start-lax=1; start-strict=1", + "found-none=1; found-lax=1; found-strict=1", + "redir-none=1; redir-lax=1; redir-strict=1", + ], + "Expected cookies from a first-party load on the web" + ); +}); + +// Verify that an extension without permission behaves like a third-party page. +add_task(async function samesite_is_foreign_without_host_permissions() { + let cookies = await getCookiesForLoadInExtension({ + permissions: [], + }); + + Assert.deepEqual( + cookies, + ["start-none=1", "found-none=1", "redir-none=1"], + "SameSite cookies excluded without permissions" + ); +}); + +// When an extension has permissions for the site, cookies should be included. +add_task(async function wildcard_host_permissions_enable_samesite_cookies() { + let cookies = await getCookiesForLoadInExtension({ + permissions: ["*://*.example.net/*"], // = *.SITE + }); + + Assert.deepEqual( + cookies, + // Same expectations as in verify_firstparty_web_behavior. + [ + "start-none=1; start-lax=1; start-strict=1", + "found-none=1; found-lax=1; found-strict=1", + "redir-none=1; redir-lax=1; redir-strict=1", + ], + "Expected cookies from a load in an extension frame" + ); +}); + +// When an extension has permissions for the site, cookies should be included. +add_task(async function explicit_host_permissions_enable_samesite_cookies() { + let cookies = await getCookiesForLoadInExtension({ + permissions: [ + "*://start.example.net/*", + "*://found.example.net/*", + "*://redir.example.net/*", + "*://final.example.net/*", + ], + }); + + Assert.deepEqual( + cookies, + // Same expectations as in verify_firstparty_web_behavior. + [ + "start-none=1; start-lax=1; start-strict=1", + "found-none=1; found-lax=1; found-strict=1", + "redir-none=1; redir-lax=1; redir-strict=1", + ], + "Expected cookies from a load in an extension frame" + ); +}); + +// When an extension does not have host permissions for all sites, but only +// some, then same-site cookies are only included in requests with the right +// permissions. +add_task(async function some_host_permissions_enable_some_samesite_cookies() { + let cookies = await getCookiesForLoadInExtension({ + permissions: ["*://start.example.net/*", "*://final.example.net/*"], + }); + + Assert.deepEqual( + cookies, + [ + // Missing permission for "found.example.net": + "start-none=1", + // Missing permission for "redir.example.net": + "found-none=1", + // "final.example.net" can see cookies from "start.example.net": + "start-lax=1; start-strict=1; redir-none=1", + ], + "Expected some cookies from a load in an extension frame" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_sandbox_var.js b/toolkit/components/extensions/test/xpcshell/test_ext_sandbox_var.js new file mode 100644 index 0000000000..0a8a5acdef --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_sandbox_var.js @@ -0,0 +1,42 @@ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +function contentScript() { + window.x = 12; + browser.test.assertEq(window.x, 12, "x is 12"); + browser.test.notifyPass("background test passed"); +} + +let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://localhost/*/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, +}; + +add_task(async function test_contentscript() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.awaitFinish(); + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_sandboxed_resource.js b/toolkit/components/extensions/test/xpcshell/test_ext_sandboxed_resource.js new file mode 100644 index 0000000000..05489d753d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_sandboxed_resource.js @@ -0,0 +1,55 @@ +"use strict"; + +// Test that an extension page which is sandboxed may load resources +// from itself without relying on web acessible resources. +add_task(async function test_webext_background_sandbox_privileges() { + function backgroundSubframeScript() { + window.parent.postMessage(typeof browser, "*"); + } + + function backgroundScript() { + /* eslint-disable-next-line mozilla/balanced-listeners */ + window.addEventListener("message", event => { + if (event.data == "undefined") { + browser.test.notifyPass("webext-background-sandbox-privileges"); + } else { + browser.test.notifyFail("webext-background-sandbox-privileges"); + } + }); + } + + let extensionData = { + manifest: { + background: { + page: "background.html", + }, + }, + files: { + "background.html": `<!DOCTYPE> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <script src="background.js"><\/script> + <iframe src="background-subframe.html" sandbox="allow-scripts"></iframe> + </body> + </html>`, + "background-subframe.html": `<!DOCTYPE> + <html> + <head> + <meta charset="utf-8"> + <script src="background-subframe.js"><\/script> + </head> + </html>`, + "background-subframe.js": backgroundSubframeScript, + "background.js": backgroundScript, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + await extension.awaitFinish("webext-background-sandbox-privileges"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schema.js b/toolkit/components/extensions/test/xpcshell/test_ext_schema.js new file mode 100644 index 0000000000..913aa4f9ab --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schema.js @@ -0,0 +1,80 @@ +"use strict"; + +AddonTestUtils.init(this); + +add_task(async function testEmptySchema() { + function background() { + browser.test.assertEq( + undefined, + browser.manifest, + "browser.manifest is not defined" + ); + browser.test.assertTrue( + !!browser.storage, + "browser.storage should be defined" + ); + browser.test.assertEq( + undefined, + browser.contextMenus, + "browser.contextMenus should not be defined" + ); + browser.test.notifyPass("schema"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["storage"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("schema"); + await extension.unload(); +}); + +add_task(async function test_warnings_as_errors() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { unrecognized_property_that_should_be_treated_as_a_warning: 1 }, + }); + + // Tests should be run with extensions.webextensions.warnings-as-errors=true + // by default, and prevent extensions with manifest warnings from loading. + await Assert.rejects( + extension.startup(), + /unrecognized_property_that_should_be_treated_as_a_warning/, + "extension with invalid manifest should not load if warnings-as-errors=true" + ); + // When ExtensionTestUtils.failOnSchemaWarnings(false) is called, startup is + // expected to succeed, as shown by the next "testUnknownProperties" test. +}); + +add_task(async function testUnknownProperties() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["unknownPermission"], + + unknown_property: {}, + }, + + background() {}, + }); + + let { messages } = await promiseConsoleOutput(async () => { + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + }); + + AddonTestUtils.checkMessages(messages, { + expected: [ + { message: /processing permissions\.0: Value "unknownPermission"/ }, + { + message: + /processing unknown_property: An unexpected property was found in the WebExtension manifest/, + }, + ], + }); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js new file mode 100644 index 0000000000..a89ddf0728 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js @@ -0,0 +1,2118 @@ +"use strict"; + +const global = this; + +let json = [ + { + namespace: "testing", + + properties: { + PROP1: { value: 20 }, + prop2: { type: "string" }, + prop3: { + $ref: "submodule", + }, + prop4: { + $ref: "submodule", + unsupported: true, + }, + }, + + types: [ + { + id: "type1", + type: "string", + enum: ["value1", "value2", "value3"], + }, + + { + id: "type2", + type: "object", + properties: { + prop1: { type: "integer" }, + prop2: { type: "array", items: { $ref: "type1" } }, + }, + }, + + { + id: "basetype1", + type: "object", + properties: { + prop1: { type: "string" }, + }, + }, + + { + id: "basetype2", + choices: [{ type: "integer" }], + }, + + { + $extend: "basetype1", + properties: { + prop2: { type: "string" }, + }, + }, + + { + $extend: "basetype2", + choices: [{ type: "string" }], + }, + + { + id: "basetype3", + type: "object", + properties: { + baseprop: { type: "string" }, + }, + }, + + { + id: "derivedtype1", + type: "object", + $import: "basetype3", + properties: { + derivedprop: { type: "string" }, + }, + }, + + { + id: "derivedtype2", + type: "object", + $import: "basetype3", + properties: { + derivedprop: { type: "integer" }, + }, + }, + + { + id: "submodule", + type: "object", + functions: [ + { + name: "sub_foo", + type: "function", + parameters: [], + returns: { type: "integer" }, + }, + ], + }, + ], + + functions: [ + { + name: "foo", + type: "function", + parameters: [ + { name: "arg1", type: "integer", optional: true, default: 99 }, + { name: "arg2", type: "boolean", optional: true }, + ], + }, + + { + name: "bar", + type: "function", + parameters: [ + { name: "arg1", type: "integer", optional: true }, + { name: "arg2", type: "boolean" }, + ], + }, + + { + name: "baz", + type: "function", + parameters: [ + { + name: "arg1", + type: "object", + properties: { + prop1: { type: "string" }, + prop2: { type: "integer", optional: true }, + prop3: { type: "integer", unsupported: true }, + }, + }, + ], + }, + + { + name: "qux", + type: "function", + parameters: [{ name: "arg1", $ref: "type1" }], + }, + + { + name: "quack", + type: "function", + parameters: [{ name: "arg1", $ref: "type2" }], + }, + + { + name: "quora", + type: "function", + parameters: [{ name: "arg1", type: "function" }], + }, + + { + name: "quileute", + type: "function", + parameters: [ + { name: "arg1", type: "integer", optional: true }, + { name: "arg2", type: "integer" }, + ], + }, + + { + name: "queets", + type: "function", + unsupported: true, + parameters: [], + }, + + { + name: "quintuplets", + type: "function", + parameters: [ + { + name: "obj", + type: "object", + properties: [], + additionalProperties: { type: "integer" }, + }, + ], + }, + + { + name: "quasar", + type: "function", + parameters: [ + { + name: "abc", + type: "object", + properties: { + func: { + type: "function", + parameters: [{ name: "x", type: "integer" }], + }, + }, + }, + ], + }, + + { + name: "quosimodo", + type: "function", + parameters: [ + { + name: "xyz", + type: "object", + additionalProperties: { type: "any" }, + }, + ], + }, + + { + name: "patternprop", + type: "function", + parameters: [ + { + name: "obj", + type: "object", + properties: { prop1: { type: "string", pattern: "^\\d+$" } }, + patternProperties: { + "(?i)^prop\\d+$": { type: "string" }, + "^foo\\d+$": { type: "string" }, + }, + }, + ], + }, + + { + name: "pattern", + type: "function", + parameters: [ + { name: "arg", type: "string", pattern: "(?i)^[0-9a-f]+$" }, + ], + }, + + { + name: "format", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + properties: { + hostname: { type: "string", format: "hostname", optional: true }, + canonicalDomain: { + type: "string", + format: "canonicalDomain", + optional: "omit-key-if-missing", + }, + url: { type: "string", format: "url", optional: true }, + origin: { type: "string", format: "origin", optional: true }, + relativeUrl: { + type: "string", + format: "relativeUrl", + optional: true, + }, + strictRelativeUrl: { + type: "string", + format: "strictRelativeUrl", + optional: true, + }, + imageDataOrStrictRelativeUrl: { + type: "string", + format: "imageDataOrStrictRelativeUrl", + optional: true, + }, + }, + }, + ], + }, + + { + name: "formatDate", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + properties: { + date: { type: "string", format: "date", optional: true }, + }, + }, + ], + }, + + { + name: "deep", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + properties: { + foo: { + type: "object", + properties: { + bar: { + type: "array", + items: { + type: "object", + properties: { + baz: { + type: "object", + properties: { + required: { type: "integer" }, + optional: { type: "string", optional: true }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + + { + name: "errors", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + properties: { + warn: { + type: "string", + pattern: "^\\d+$", + optional: true, + onError: "warn", + }, + ignore: { + type: "string", + pattern: "^\\d+$", + optional: true, + onError: "ignore", + }, + default: { + type: "string", + pattern: "^\\d+$", + optional: true, + }, + }, + }, + ], + }, + + { + name: "localize", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + properties: { + foo: { type: "string", preprocess: "localize", optional: true }, + bar: { type: "string", optional: true }, + url: { + type: "string", + preprocess: "localize", + format: "url", + optional: true, + }, + }, + }, + ], + }, + + { + name: "extended1", + type: "function", + parameters: [{ name: "val", $ref: "basetype1" }], + }, + + { + name: "extended2", + type: "function", + parameters: [{ name: "val", $ref: "basetype2" }], + }, + + { + name: "callderived1", + type: "function", + parameters: [{ name: "value", $ref: "derivedtype1" }], + }, + + { + name: "callderived2", + type: "function", + parameters: [{ name: "value", $ref: "derivedtype2" }], + }, + ], + + events: [ + { + name: "onFoo", + type: "function", + }, + + { + name: "onBar", + type: "function", + extraParameters: [ + { + name: "filter", + type: "integer", + optional: true, + default: 1, + }, + ], + }, + ], + }, + { + namespace: "foreign", + properties: { + foreignRef: { $ref: "testing.submodule" }, + }, + }, + { + namespace: "inject", + properties: { + PROP1: { value: "should inject" }, + }, + }, + { + namespace: "do-not-inject", + properties: { + PROP1: { value: "should not inject" }, + }, + }, +]; + +add_task(async function () { + let wrapper = getContextWrapper(); + let url = "data:," + JSON.stringify(json); + Schemas._rootSchema = null; + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, wrapper); + + Assert.equal(root.testing.PROP1, 20, "simple value property"); + Assert.equal(root.testing.type1.VALUE1, "value1", "enum type"); + Assert.equal(root.testing.type1.VALUE2, "value2", "enum type"); + + Assert.equal("inject" in root, true, "namespace 'inject' should be injected"); + Assert.equal( + root["do-not-inject"], + undefined, + "namespace 'do-not-inject' should not be injected" + ); + + root.testing.foo(11, true); + wrapper.verify("call", "testing", "foo", [11, true]); + + root.testing.foo(true); + wrapper.verify("call", "testing", "foo", [99, true]); + + root.testing.foo(null, true); + wrapper.verify("call", "testing", "foo", [99, true]); + + root.testing.foo(undefined, true); + wrapper.verify("call", "testing", "foo", [99, true]); + + root.testing.foo(11); + wrapper.verify("call", "testing", "foo", [11, null]); + + Assert.throws( + () => root.testing.bar(11), + /Incorrect argument types/, + "should throw without required arg" + ); + + Assert.throws( + () => root.testing.bar(11, true, 10), + /Incorrect argument types/, + "should throw with too many arguments" + ); + + root.testing.bar(true); + wrapper.verify("call", "testing", "bar", [null, true]); + + root.testing.baz({ prop1: "hello", prop2: 22 }); + wrapper.verify("call", "testing", "baz", [{ prop1: "hello", prop2: 22 }]); + + root.testing.baz({ prop1: "hello" }); + wrapper.verify("call", "testing", "baz", [{ prop1: "hello", prop2: null }]); + + root.testing.baz({ prop1: "hello", prop2: null }); + wrapper.verify("call", "testing", "baz", [{ prop1: "hello", prop2: null }]); + + Assert.throws( + () => root.testing.baz({ prop2: 12 }), + /Property "prop1" is required/, + "should throw without required property" + ); + + Assert.throws( + () => root.testing.baz({ prop1: "hi", prop3: 12 }), + /Property "prop3" is unsupported by Firefox/, + "should throw with unsupported property" + ); + + Assert.throws( + () => root.testing.baz({ prop1: "hi", prop4: 12 }), + /Unexpected property "prop4"/, + "should throw with unexpected property" + ); + + Assert.throws( + () => root.testing.baz({ prop1: 12 }), + /Expected string instead of 12/, + "should throw with wrong type" + ); + + root.testing.qux("value2"); + wrapper.verify("call", "testing", "qux", ["value2"]); + + Assert.throws( + () => root.testing.qux("value4"), + /Invalid enumeration value "value4"/, + "should throw for invalid enum value" + ); + + root.testing.quack({ prop1: 12, prop2: ["value1", "value3"] }); + wrapper.verify("call", "testing", "quack", [ + { prop1: 12, prop2: ["value1", "value3"] }, + ]); + + Assert.throws( + () => + root.testing.quack({ prop1: 12, prop2: ["value1", "value3", "value4"] }), + /Invalid enumeration value "value4"/, + "should throw for invalid array type" + ); + + function f() {} + root.testing.quora(f); + Assert.equal( + JSON.stringify(wrapper.tallied.slice(0, -1)), + JSON.stringify(["call", "testing", "quora"]) + ); + Assert.equal(wrapper.tallied[3][0], f); + wrapper.tallied = null; + + let g = () => 0; + root.testing.quora(g); + Assert.equal( + JSON.stringify(wrapper.tallied.slice(0, -1)), + JSON.stringify(["call", "testing", "quora"]) + ); + Assert.equal(wrapper.tallied[3][0], g); + wrapper.tallied = null; + + root.testing.quileute(10); + wrapper.verify("call", "testing", "quileute", [null, 10]); + + Assert.throws( + () => root.testing.queets(), + /queets is not a function/, + "should throw for unsupported functions" + ); + + root.testing.quintuplets({ a: 10, b: 20, c: 30 }); + wrapper.verify("call", "testing", "quintuplets", [{ a: 10, b: 20, c: 30 }]); + + Assert.throws( + () => root.testing.quintuplets({ a: 10, b: 20, c: 30, d: "hi" }), + /Expected integer instead of "hi"/, + "should throw for wrong additionalProperties type" + ); + + root.testing.quasar({ func: f }); + Assert.equal( + JSON.stringify(wrapper.tallied.slice(0, -1)), + JSON.stringify(["call", "testing", "quasar"]) + ); + Assert.equal(wrapper.tallied[3][0].func, f); + + root.testing.quosimodo({ a: 10, b: 20, c: 30 }); + wrapper.verify("call", "testing", "quosimodo", [{ a: 10, b: 20, c: 30 }]); + + Assert.throws( + () => root.testing.quosimodo(10), + /Incorrect argument types/, + "should throw for wrong type" + ); + + root.testing.patternprop({ + prop1: "12", + prop2: "42", + Prop3: "43", + foo1: "x", + }); + wrapper.verify("call", "testing", "patternprop", [ + { prop1: "12", prop2: "42", Prop3: "43", foo1: "x" }, + ]); + + root.testing.patternprop({ prop1: "12" }); + wrapper.verify("call", "testing", "patternprop", [{ prop1: "12" }]); + + Assert.throws( + () => root.testing.patternprop({ prop1: "12", foo1: null }), + /Expected string instead of null/, + "should throw for wrong property type" + ); + + Assert.throws( + () => root.testing.patternprop({ prop1: "xx", prop2: "yy" }), + /String "xx" must match \/\^\\d\+\$\//, + "should throw for wrong property type" + ); + + Assert.throws( + () => root.testing.patternprop({ prop1: "12", prop2: 42 }), + /Expected string instead of 42/, + "should throw for wrong property type" + ); + + Assert.throws( + () => root.testing.patternprop({ prop1: "12", prop2: null }), + /Expected string instead of null/, + "should throw for wrong property type" + ); + + Assert.throws( + () => root.testing.patternprop({ prop1: "12", propx: "42" }), + /Unexpected property "propx"/, + "should throw for unexpected property" + ); + + Assert.throws( + () => root.testing.patternprop({ prop1: "12", Foo1: "x" }), + /Unexpected property "Foo1"/, + "should throw for unexpected property" + ); + + root.testing.pattern("DEADbeef"); + wrapper.verify("call", "testing", "pattern", ["DEADbeef"]); + + Assert.throws( + () => root.testing.pattern("DEADcow"), + /String "DEADcow" must match \/\^\[0-9a-f\]\+\$\/i/, + "should throw for non-match" + ); + + root.testing.format({ hostname: "foo" }); + wrapper.verify("call", "testing", "format", [ + { + hostname: "foo", + imageDataOrStrictRelativeUrl: null, + origin: null, + relativeUrl: null, + strictRelativeUrl: null, + url: null, + }, + ]); + + for (let invalid of ["", " ", "http://foo", "foo/bar", "foo.com/", "foo?"]) { + Assert.throws( + () => root.testing.format({ hostname: invalid }), + /Invalid hostname/, + "should throw for invalid hostname" + ); + Assert.throws( + () => root.testing.format({ canonicalDomain: invalid }), + /Invalid domain /, + `should throw for invalid canonicalDomain (${invalid})` + ); + } + + for (let invalid of [ + "%61", // ASCII should not be URL-encoded. + "foo:12345", // It is a common mistake to use .host instead of .hostname. + "2", // Single digit is an IPv4 address, but should be written as 0.0.0.2. + "::1", // IPv6 addresses should have brackets. + "[::1A]", // not lowercase. + "[::ffff:127.0.0.1]", // not a canonical IPv6 representation. + "UPPERCASE", // not lowercase. + "straß.de", // not punycode. + ]) { + Assert.throws( + () => root.testing.format({ canonicalDomain: invalid }), + /Invalid domain /, + `should throw for invalid canonicalDomain (${invalid})` + ); + } + + for (let valid of ["0.0.0.2", "[::1]", "[::1a]", "lowercase", "."]) { + root.testing.format({ canonicalDomain: valid }); + wrapper.verify("call", "testing", "format", [ + { + canonicalDomain: valid, + hostname: null, + imageDataOrStrictRelativeUrl: null, + origin: null, + relativeUrl: null, + strictRelativeUrl: null, + url: null, + }, + ]); + } + + for (let valid of [ + "https://example.com", + "http://example.com", + "https://foo.bar.栃木.jp", + ]) { + root.testing.format({ origin: valid }); + } + + for (let invalid of [ + "https://example.com/testing", + "file:/foo/bar", + "file:///foo/bar", + "", + " ", + "https://foo.bar.栃木.jp/", + "https://user:pass@example.com", + "https://*.example.com", + "https://example.com#test", + "https://example.com?test", + ]) { + Assert.throws( + () => root.testing.format({ origin: invalid }), + /Invalid origin/, + "should throw for invalid origin" + ); + } + + root.testing.format({ url: "http://foo/bar", relativeUrl: "http://foo/bar" }); + wrapper.verify("call", "testing", "format", [ + { + hostname: null, + imageDataOrStrictRelativeUrl: null, + origin: null, + relativeUrl: "http://foo/bar", + strictRelativeUrl: null, + url: "http://foo/bar", + }, + ]); + + root.testing.format({ + relativeUrl: "foo.html", + strictRelativeUrl: "foo.html", + }); + wrapper.verify("call", "testing", "format", [ + { + hostname: null, + imageDataOrStrictRelativeUrl: null, + origin: null, + relativeUrl: `${wrapper.url}foo.html`, + strictRelativeUrl: `${wrapper.url}foo.html`, + url: null, + }, + ]); + + root.testing.format({ + imageDataOrStrictRelativeUrl: "data:image/png;base64,A", + }); + wrapper.verify("call", "testing", "format", [ + { + hostname: null, + imageDataOrStrictRelativeUrl: "data:image/png;base64,A", + origin: null, + relativeUrl: null, + strictRelativeUrl: null, + url: null, + }, + ]); + + root.testing.format({ + imageDataOrStrictRelativeUrl: "data:image/jpeg;base64,A", + }); + wrapper.verify("call", "testing", "format", [ + { + hostname: null, + imageDataOrStrictRelativeUrl: "data:image/jpeg;base64,A", + origin: null, + relativeUrl: null, + strictRelativeUrl: null, + url: null, + }, + ]); + + root.testing.format({ imageDataOrStrictRelativeUrl: "foo.html" }); + wrapper.verify("call", "testing", "format", [ + { + hostname: null, + imageDataOrStrictRelativeUrl: `${wrapper.url}foo.html`, + origin: null, + relativeUrl: null, + strictRelativeUrl: null, + url: null, + }, + ]); + + for (let format of ["url", "relativeUrl"]) { + Assert.throws( + () => root.testing.format({ [format]: "chrome://foo/content/" }), + /Access denied/, + "should throw for access denied" + ); + } + + for (let urlString of ["//foo.html", "http://foo/bar.html"]) { + Assert.throws( + () => root.testing.format({ strictRelativeUrl: urlString }), + /must be a relative URL/, + "should throw for non-relative URL" + ); + } + + Assert.throws( + () => + root.testing.format({ + imageDataOrStrictRelativeUrl: "data:image/svg+xml;utf8,A", + }), + /must be a relative or PNG or JPG data:image URL/, + "should throw for non-relative or non PNG/JPG data URL" + ); + + const dates = [ + "2016-03-04", + "2016-03-04T08:00:00Z", + "2016-03-04T08:00:00.000Z", + "2016-03-04T08:00:00-08:00", + "2016-03-04T08:00:00.000-08:00", + "2016-03-04T08:00:00+08:00", + "2016-03-04T08:00:00.000+08:00", + "2016-03-04T08:00:00+0800", + "2016-03-04T08:00:00-0800", + ]; + dates.forEach(str => { + root.testing.formatDate({ date: str }); + wrapper.verify("call", "testing", "formatDate", [{ date: str }]); + }); + + // Make sure that a trivial change to a valid date invalidates it. + dates.forEach(str => { + Assert.throws( + () => root.testing.formatDate({ date: "0" + str }), + /Invalid date string/, + "should throw for invalid iso date string" + ); + Assert.throws( + () => root.testing.formatDate({ date: str + "0" }), + /Invalid date string/, + "should throw for invalid iso date string" + ); + }); + + const badDates = [ + "I do not look anything like a date string", + "2016-99-99", + "2016-03-04T25:00:00Z", + ]; + badDates.forEach(str => { + Assert.throws( + () => root.testing.formatDate({ date: str }), + /Invalid date string/, + "should throw for invalid iso date string" + ); + }); + + root.testing.deep({ + foo: { bar: [{ baz: { required: 12, optional: "42" } }] }, + }); + wrapper.verify("call", "testing", "deep", [ + { foo: { bar: [{ baz: { optional: "42", required: 12 } }] } }, + ]); + + Assert.throws( + () => root.testing.deep({ foo: { bar: [{ baz: { optional: "42" } }] } }), + /Type error for parameter arg \(Error processing foo\.bar\.0\.baz: Property "required" is required\) for testing\.deep/, + "should throw with the correct object path" + ); + + Assert.throws( + () => + root.testing.deep({ + foo: { bar: [{ baz: { optional: 42, required: 12 } }] }, + }), + /Type error for parameter arg \(Error processing foo\.bar\.0\.baz\.optional: Expected string instead of 42\) for testing\.deep/, + "should throw with the correct object path" + ); + + wrapper.talliedErrors.length = 0; + + root.testing.errors({ default: "0123", ignore: "0123", warn: "0123" }); + wrapper.verify("call", "testing", "errors", [ + { default: "0123", ignore: "0123", warn: "0123" }, + ]); + wrapper.checkErrors([]); + + root.testing.errors({ default: "0123", ignore: "x123", warn: "0123" }); + wrapper.verify("call", "testing", "errors", [ + { default: "0123", ignore: null, warn: "0123" }, + ]); + wrapper.checkErrors([]); + + ExtensionTestUtils.failOnSchemaWarnings(false); + root.testing.errors({ default: "0123", ignore: "0123", warn: "x123" }); + ExtensionTestUtils.failOnSchemaWarnings(true); + wrapper.verify("call", "testing", "errors", [ + { default: "0123", ignore: "0123", warn: null }, + ]); + wrapper.checkErrors(['String "x123" must match /^\\d+$/']); + + root.testing.onFoo.addListener(f); + Assert.equal( + JSON.stringify(wrapper.tallied.slice(0, -1)), + JSON.stringify(["addListener", "testing", "onFoo"]) + ); + Assert.equal(wrapper.tallied[3][0], f); + Assert.equal(JSON.stringify(wrapper.tallied[3][1]), JSON.stringify([])); + wrapper.tallied = null; + + root.testing.onFoo.removeListener(f); + Assert.equal( + JSON.stringify(wrapper.tallied.slice(0, -1)), + JSON.stringify(["removeListener", "testing", "onFoo"]) + ); + Assert.equal(wrapper.tallied[3][0], f); + wrapper.tallied = null; + + root.testing.onFoo.hasListener(f); + Assert.equal( + JSON.stringify(wrapper.tallied.slice(0, -1)), + JSON.stringify(["hasListener", "testing", "onFoo"]) + ); + Assert.equal(wrapper.tallied[3][0], f); + wrapper.tallied = null; + + Assert.throws( + () => root.testing.onFoo.addListener(10), + /Invalid listener/, + "addListener with non-function should throw" + ); + + root.testing.onBar.addListener(f, 10); + Assert.equal( + JSON.stringify(wrapper.tallied.slice(0, -1)), + JSON.stringify(["addListener", "testing", "onBar"]) + ); + Assert.equal(wrapper.tallied[3][0], f); + Assert.equal(JSON.stringify(wrapper.tallied[3][1]), JSON.stringify([10])); + wrapper.tallied = null; + + root.testing.onBar.addListener(f); + Assert.equal( + JSON.stringify(wrapper.tallied.slice(0, -1)), + JSON.stringify(["addListener", "testing", "onBar"]) + ); + Assert.equal(wrapper.tallied[3][0], f); + Assert.equal(JSON.stringify(wrapper.tallied[3][1]), JSON.stringify([1])); + wrapper.tallied = null; + + Assert.throws( + () => root.testing.onBar.addListener(f, "hi"), + /Incorrect argument types/, + "addListener with wrong extra parameter should throw" + ); + + let target = { prop1: 12, prop2: ["value1", "value3"] }; + let proxy = new Proxy(target, {}); + Assert.throws( + () => root.testing.quack(proxy), + /Expected a plain JavaScript object, got a Proxy/, + "should throw when passing a Proxy" + ); + + if (Symbol.toStringTag) { + let stringTarget = { prop1: 12, prop2: ["value1", "value3"] }; + stringTarget[Symbol.toStringTag] = () => "[object Object]"; + let stringProxy = new Proxy(stringTarget, {}); + Assert.throws( + () => root.testing.quack(stringProxy), + /Expected a plain JavaScript object, got a Proxy/, + "should throw when passing a Proxy" + ); + } + + root.testing.localize({ + foo: "__MSG_foo__", + bar: "__MSG_foo__", + url: "__MSG_http://example.com/__", + }); + wrapper.verify("call", "testing", "localize", [ + { bar: "__MSG_foo__", foo: "FOO", url: "http://example.com/" }, + ]); + + Assert.throws( + () => root.testing.localize({ url: "__MSG_/foo/bar__" }), + /\/FOO\/BAR is not a valid URL\./, + "should throw for invalid URL" + ); + + root.testing.extended1({ prop1: "foo", prop2: "bar" }); + wrapper.verify("call", "testing", "extended1", [ + { prop1: "foo", prop2: "bar" }, + ]); + + Assert.throws( + () => root.testing.extended1({ prop1: "foo", prop2: 12 }), + /Expected string instead of 12/, + "should throw for wrong property type" + ); + + Assert.throws( + () => root.testing.extended1({ prop1: "foo" }), + /Property "prop2" is required/, + "should throw for missing property" + ); + + Assert.throws( + () => root.testing.extended1({ prop1: "foo", prop2: "bar", prop3: "xxx" }), + /Unexpected property "prop3"/, + "should throw for extra property" + ); + + root.testing.extended2("foo"); + wrapper.verify("call", "testing", "extended2", ["foo"]); + + root.testing.extended2(12); + wrapper.verify("call", "testing", "extended2", [12]); + + Assert.throws( + () => root.testing.extended2(true), + /Incorrect argument types/, + "should throw for wrong argument type" + ); + + root.testing.prop3.sub_foo(); + wrapper.verify("call", "testing.prop3", "sub_foo", []); + + Assert.throws( + () => root.testing.prop4.sub_foo(), + /root.testing.prop4 is undefined/, + "should throw for unsupported submodule" + ); + + root.foreign.foreignRef.sub_foo(); + wrapper.verify("call", "foreign.foreignRef", "sub_foo", []); + + root.testing.callderived1({ baseprop: "s1", derivedprop: "s2" }); + wrapper.verify("call", "testing", "callderived1", [ + { baseprop: "s1", derivedprop: "s2" }, + ]); + + Assert.throws( + () => root.testing.callderived1({ baseprop: "s1", derivedprop: 42 }), + /Error processing derivedprop: Expected string/, + "Two different objects may $import the same base object" + ); + Assert.throws( + () => root.testing.callderived1({ baseprop: "s1" }), + /Property "derivedprop" is required/, + "Object using $import has its local properites" + ); + Assert.throws( + () => root.testing.callderived1({ derivedprop: "s2" }), + /Property "baseprop" is required/, + "Object using $import has imported properites" + ); + + root.testing.callderived2({ baseprop: "s1", derivedprop: 42 }); + wrapper.verify("call", "testing", "callderived2", [ + { baseprop: "s1", derivedprop: 42 }, + ]); + + Assert.throws( + () => root.testing.callderived2({ baseprop: "s1", derivedprop: "s2" }), + /Error processing derivedprop: Expected integer/, + "Two different objects may $import the same base object" + ); + Assert.throws( + () => root.testing.callderived2({ baseprop: "s1" }), + /Property "derivedprop" is required/, + "Object using $import has its local properites" + ); + Assert.throws( + () => root.testing.callderived2({ derivedprop: 42 }), + /Property "baseprop" is required/, + "Object using $import has imported properites" + ); +}); + +let deprecatedJson = [ + { + namespace: "deprecated", + + properties: { + accessor: { + type: "string", + writable: true, + deprecated: "This is not the property you are looking for", + }, + }, + + types: [ + { + id: "Type", + type: "string", + }, + ], + + functions: [ + { + name: "property", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + properties: { + foo: { + type: "string", + }, + }, + additionalProperties: { + type: "any", + deprecated: "Unknown property", + }, + }, + ], + }, + + { + name: "value", + type: "function", + parameters: [ + { + name: "arg", + choices: [ + { + type: "integer", + }, + { + type: "string", + deprecated: "Please use an integer, not ${value}", + }, + ], + }, + ], + }, + + { + name: "choices", + type: "function", + parameters: [ + { + name: "arg", + deprecated: "You have no choices", + choices: [ + { + type: "integer", + }, + ], + }, + ], + }, + + { + name: "ref", + type: "function", + parameters: [ + { + name: "arg", + choices: [ + { + $ref: "Type", + deprecated: "Deprecated alias", + }, + ], + }, + ], + }, + + { + name: "method", + type: "function", + deprecated: "Do not call this method", + parameters: [], + }, + ], + + events: [ + { + name: "onDeprecated", + type: "function", + deprecated: "This event does not work", + }, + ], + }, +]; + +add_task(async function testDeprecation() { + let wrapper = getContextWrapper(); + // This whole test expects deprecation warnings. + ExtensionTestUtils.failOnSchemaWarnings(false); + + let url = "data:," + JSON.stringify(deprecatedJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, wrapper); + + root.deprecated.property({ foo: "bar", xxx: "any", yyy: "property" }); + wrapper.verify("call", "deprecated", "property", [ + { foo: "bar", xxx: "any", yyy: "property" }, + ]); + wrapper.checkErrors([ + "Warning processing xxx: Unknown property", + "Warning processing yyy: Unknown property", + ]); + + root.deprecated.value(12); + wrapper.verify("call", "deprecated", "value", [12]); + wrapper.checkErrors([]); + + root.deprecated.value("12"); + wrapper.verify("call", "deprecated", "value", ["12"]); + wrapper.checkErrors(['Please use an integer, not "12"']); + + root.deprecated.choices(12); + wrapper.verify("call", "deprecated", "choices", [12]); + wrapper.checkErrors(["You have no choices"]); + + root.deprecated.ref("12"); + wrapper.verify("call", "deprecated", "ref", ["12"]); + wrapper.checkErrors(["Deprecated alias"]); + + root.deprecated.method(); + wrapper.verify("call", "deprecated", "method", []); + wrapper.checkErrors(["Do not call this method"]); + + void root.deprecated.accessor; + wrapper.verify("get", "deprecated", "accessor", null); + wrapper.checkErrors(["This is not the property you are looking for"]); + + root.deprecated.accessor = "x"; + wrapper.verify("set", "deprecated", "accessor", "x"); + wrapper.checkErrors(["This is not the property you are looking for"]); + + root.deprecated.onDeprecated.addListener(() => {}); + wrapper.checkErrors(["This event does not work"]); + + root.deprecated.onDeprecated.removeListener(() => {}); + wrapper.checkErrors(["This event does not work"]); + + root.deprecated.onDeprecated.hasListener(() => {}); + wrapper.checkErrors(["This event does not work"]); + + ExtensionTestUtils.failOnSchemaWarnings(true); + + Assert.throws( + () => root.deprecated.onDeprecated.hasListener(() => {}), + /This event does not work/, + "Deprecation warning with extensions.webextensions.warnings-as-errors=true" + ); +}); + +let choicesJson = [ + { + namespace: "choices", + + types: [], + + functions: [ + { + name: "meh", + type: "function", + parameters: [ + { + name: "arg", + choices: [ + { + type: "string", + enum: ["foo", "bar", "baz"], + }, + { + type: "string", + pattern: "florg.*meh", + }, + { + type: "integer", + minimum: 12, + maximum: 42, + }, + ], + }, + ], + }, + + { + name: "foo", + type: "function", + parameters: [ + { + name: "arg", + choices: [ + { + type: "object", + properties: { + blurg: { + type: "string", + unsupported: true, + optional: true, + }, + }, + additionalProperties: { + type: "string", + }, + }, + { + type: "string", + }, + { + type: "array", + minItems: 2, + maxItems: 3, + items: { + type: "integer", + }, + }, + ], + }, + ], + }, + + { + name: "bar", + type: "function", + parameters: [ + { + name: "arg", + choices: [ + { + type: "object", + properties: { + baz: { + type: "string", + }, + }, + }, + { + type: "array", + items: { + type: "integer", + }, + }, + ], + }, + ], + }, + ], + }, +]; + +add_task(async function testChoices() { + let wrapper = getContextWrapper(); + let url = "data:," + JSON.stringify(choicesJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, wrapper); + + Assert.throws( + () => root.choices.meh("frog"), + /Value "frog" must either: be one of \["foo", "bar", "baz"\], match the pattern \/florg\.\*meh\/, or be an integer value/ + ); + + Assert.throws( + () => root.choices.meh(4), + /be a string value, or be at least 12/ + ); + + Assert.throws( + () => root.choices.meh(43), + /be a string value, or be no greater than 42/ + ); + + Assert.throws( + () => root.choices.foo([]), + /be an object value, be a string value, or have at least 2 items/ + ); + + Assert.throws( + () => root.choices.foo([1, 2, 3, 4]), + /be an object value, be a string value, or have at most 3 items/ + ); + + Assert.throws( + () => root.choices.foo({ foo: 12 }), + /.foo must be a string value, be a string value, or be an array value/ + ); + + Assert.throws( + () => root.choices.foo({ blurg: "foo" }), + /not contain an unsupported "blurg" property, be a string value, or be an array value/ + ); + + Assert.throws( + () => root.choices.bar({}), + /contain the required "baz" property, or be an array value/ + ); + + Assert.throws( + () => root.choices.bar({ baz: "x", quux: "y" }), + /not contain an unexpected "quux" property, or be an array value/ + ); + + Assert.throws( + () => root.choices.bar({ baz: "x", quux: "y", foo: "z" }), + /not contain the unexpected properties \[foo, quux\], or be an array value/ + ); +}); + +let permissionsJson = [ + { + namespace: "noPerms", + + types: [], + + functions: [ + { + name: "noPerms", + type: "function", + parameters: [], + }, + + { + name: "fooPerm", + type: "function", + permissions: ["foo"], + parameters: [], + }, + ], + }, + + { + namespace: "fooPerm", + + permissions: ["foo"], + + types: [], + + functions: [ + { + name: "noPerms", + type: "function", + parameters: [], + }, + + { + name: "fooBarPerm", + type: "function", + permissions: ["foo.bar"], + parameters: [], + }, + ], + }, +]; + +add_task(async function testPermissions() { + let url = "data:," + JSON.stringify(permissionsJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + let wrapper = getContextWrapper(); + + let root = {}; + Schemas.inject(root, wrapper); + + equal(typeof root.noPerms, "object", "noPerms namespace should exist"); + equal( + typeof root.noPerms.noPerms, + "function", + "noPerms.noPerms method should exist" + ); + + equal( + root.noPerms.fooPerm, + undefined, + "noPerms.fooPerm should not method exist" + ); + + equal(root.fooPerm, undefined, "fooPerm namespace should not exist"); + + info('Add "foo" permission'); + wrapper.permissions.add("foo"); + + root = {}; + Schemas.inject(root, wrapper); + + equal(typeof root.noPerms, "object", "noPerms namespace should exist"); + equal( + typeof root.noPerms.noPerms, + "function", + "noPerms.noPerms method should exist" + ); + equal( + typeof root.noPerms.fooPerm, + "function", + "noPerms.fooPerm method should exist" + ); + + equal(typeof root.fooPerm, "object", "fooPerm namespace should exist"); + equal( + typeof root.fooPerm.noPerms, + "function", + "noPerms.noPerms method should exist" + ); + + equal( + root.fooPerm.fooBarPerm, + undefined, + "fooPerm.fooBarPerm method should not exist" + ); + + info('Add "foo.bar" permission'); + wrapper.permissions.add("foo.bar"); + + root = {}; + Schemas.inject(root, wrapper); + + equal(typeof root.noPerms, "object", "noPerms namespace should exist"); + equal( + typeof root.noPerms.noPerms, + "function", + "noPerms.noPerms method should exist" + ); + equal( + typeof root.noPerms.fooPerm, + "function", + "noPerms.fooPerm method should exist" + ); + + equal(typeof root.fooPerm, "object", "fooPerm namespace should exist"); + equal( + typeof root.fooPerm.noPerms, + "function", + "noPerms.noPerms method should exist" + ); + equal( + typeof root.fooPerm.fooBarPerm, + "function", + "noPerms.fooBarPerm method should exist" + ); +}); + +let nestedNamespaceJson = [ + { + namespace: "nested.namespace", + types: [ + { + id: "CustomType", + type: "object", + events: [ + { + name: "onEvent", + type: "function", + }, + ], + properties: { + url: { + type: "string", + }, + }, + functions: [ + { + name: "functionOnCustomType", + type: "function", + parameters: [ + { + name: "title", + type: "string", + }, + ], + }, + ], + }, + ], + properties: { + instanceOfCustomType: { + $ref: "CustomType", + }, + }, + functions: [ + { + name: "create", + type: "function", + parameters: [ + { + name: "title", + type: "string", + }, + ], + }, + ], + }, +]; + +add_task(async function testNestedNamespace() { + let url = "data:," + JSON.stringify(nestedNamespaceJson); + let wrapper = getContextWrapper(); + + Schemas._rootSchema = null; + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, wrapper); + + ok(root.nested, "The root object contains the first namespace level"); + ok( + root.nested.namespace, + "The first level object contains the second namespace level" + ); + + ok( + root.nested.namespace.create, + "Got the expected function in the nested namespace" + ); + equal( + typeof root.nested.namespace.create, + "function", + "The property is a function as expected" + ); + + let { instanceOfCustomType } = root.nested.namespace; + + ok( + instanceOfCustomType, + "Got the expected instance of the CustomType defined in the schema" + ); + ok( + instanceOfCustomType.functionOnCustomType, + "Got the expected method in the CustomType instance" + ); + ok( + instanceOfCustomType.onEvent && + instanceOfCustomType.onEvent.addListener && + typeof instanceOfCustomType.onEvent.addListener == "function", + "Got the expected event defined in the CustomType instance" + ); + + instanceOfCustomType.functionOnCustomType("param_value"); + wrapper.verify( + "call", + "nested.namespace.instanceOfCustomType", + "functionOnCustomType", + ["param_value"] + ); + + let fakeListener = () => {}; + instanceOfCustomType.onEvent.addListener(fakeListener); + wrapper.verify( + "addListener", + "nested.namespace.instanceOfCustomType", + "onEvent", + [fakeListener, []] + ); + instanceOfCustomType.onEvent.removeListener(fakeListener); + wrapper.verify( + "removeListener", + "nested.namespace.instanceOfCustomType", + "onEvent", + [fakeListener] + ); + + // TODO: test support properties in a SubModuleType defined in the schema, + // once implemented, e.g.: + // ok("url" in instanceOfCustomType, + // "Got the expected property defined in the CustomType instance"); +}); + +let $importJson = [ + { + namespace: "from_the", + $import: "future", + }, + { + namespace: "future", + properties: { + PROP1: { value: "original value" }, + PROP2: { value: "second original" }, + }, + types: [ + { + id: "Colour", + type: "string", + enum: ["red", "white", "blue"], + }, + ], + functions: [ + { + name: "dye", + type: "function", + parameters: [{ name: "arg", $ref: "Colour" }], + }, + ], + }, + { + namespace: "embrace", + $import: "future", + properties: { + PROP2: { value: "overridden value" }, + }, + types: [ + { + id: "Colour", + type: "string", + enum: ["blue", "orange"], + }, + ], + }, +]; + +add_task(async function test_$import() { + let wrapper = getContextWrapper(); + let url = "data:," + JSON.stringify($importJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, wrapper); + + equal(root.from_the.PROP1, "original value", "imported property"); + equal(root.from_the.PROP2, "second original", "second imported property"); + equal(root.from_the.Colour.RED, "red", "imported enum type"); + equal(typeof root.from_the.dye, "function", "imported function"); + + root.from_the.dye("white"); + wrapper.verify("call", "from_the", "dye", ["white"]); + + Assert.throws( + () => root.from_the.dye("orange"), + /Invalid enumeration value/, + "original imported argument type Colour doesn't include 'orange'" + ); + + equal(root.embrace.PROP1, "original value", "imported property"); + equal(root.embrace.PROP2, "overridden value", "overridden property"); + equal(root.embrace.Colour.ORANGE, "orange", "overridden enum type"); + equal(typeof root.embrace.dye, "function", "imported function"); + + root.embrace.dye("orange"); + wrapper.verify("call", "embrace", "dye", ["orange"]); + + Assert.throws( + () => root.embrace.dye("white"), + /Invalid enumeration value/, + "overridden argument type Colour doesn't include 'white'" + ); +}); + +add_task(async function testLocalAPIImplementation() { + let countGet2 = 0; + let countProp3 = 0; + let countProp3SubFoo = 0; + + let testingApiObj = { + get PROP1() { + // PROP1 is a schema-defined constant. + throw new Error("Unexpected get PROP1"); + }, + get prop2() { + ++countGet2; + return "prop2 val"; + }, + get prop3() { + throw new Error("Unexpected get prop3"); + }, + set prop3(v) { + // prop3 is a submodule, defined as a function, so the API should not pass + // through assignment to prop3. + throw new Error("Unexpected set prop3"); + }, + }; + let submoduleApiObj = { + get sub_foo() { + ++countProp3; + return () => { + return ++countProp3SubFoo; + }; + }, + }; + + let localWrapper = { + manifestVersion: 2, + cloneScope: global, + shouldInject(ns, name) { + return name == "testing" || ns == "testing" || ns == "testing.prop3"; + }, + getImplementation(ns, name) { + Assert.ok(ns == "testing" || ns == "testing.prop3"); + if (ns == "testing.prop3" && name == "sub_foo") { + // It is fine to use `null` here because we don't call async functions. + return new LocalAPIImplementation(submoduleApiObj, name, null); + } + // It is fine to use `null` here because we don't call async functions. + return new LocalAPIImplementation(testingApiObj, name, null); + }, + }; + + let root = {}; + Schemas.inject(root, localWrapper); + Assert.equal(countGet2, 0); + Assert.equal(countProp3, 0); + Assert.equal(countProp3SubFoo, 0); + + Assert.equal(root.testing.PROP1, 20); + + Assert.equal(root.testing.prop2, "prop2 val"); + Assert.equal(countGet2, 1); + + Assert.equal(root.testing.prop2, "prop2 val"); + Assert.equal(countGet2, 2); + + info(JSON.stringify(root.testing)); + Assert.equal(root.testing.prop3.sub_foo(), 1); + Assert.equal(countProp3, 1); + Assert.equal(countProp3SubFoo, 1); + + Assert.equal(root.testing.prop3.sub_foo(), 2); + Assert.equal(countProp3, 2); + Assert.equal(countProp3SubFoo, 2); + + root.testing.prop3.sub_foo = () => { + return "overwritten"; + }; + Assert.equal(root.testing.prop3.sub_foo(), "overwritten"); + + root.testing.prop3 = { + sub_foo() { + return "overwritten again"; + }, + }; + Assert.equal(root.testing.prop3.sub_foo(), "overwritten again"); + Assert.equal(countProp3SubFoo, 2); +}); + +let defaultsJson = [ + { + namespace: "defaultsJson", + + types: [], + + functions: [ + { + name: "defaultFoo", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + optional: true, + properties: { + prop1: { type: "integer", optional: true }, + }, + default: { prop1: 1 }, + }, + ], + returns: { + type: "object", + additionalProperties: true, + }, + }, + ], + }, +]; + +add_task(async function testDefaults() { + let url = "data:," + JSON.stringify(defaultsJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + let testingApiObj = { + defaultFoo: function (arg) { + if (Object.keys(arg) != "prop1") { + throw new Error( + `Received the expected default object, default: ${JSON.stringify( + arg + )}` + ); + } + arg.newProp = 1; + return arg; + }, + }; + + let localWrapper = { + manifestVersion: 2, + cloneScope: global, + shouldInject(ns) { + return true; + }, + getImplementation(ns, name) { + return new LocalAPIImplementation(testingApiObj, name, null); + }, + }; + + let root = {}; + Schemas.inject(root, localWrapper); + + deepEqual(root.defaultsJson.defaultFoo(), { prop1: 1, newProp: 1 }); + deepEqual(root.defaultsJson.defaultFoo({ prop1: 2 }), { + prop1: 2, + newProp: 1, + }); + deepEqual(root.defaultsJson.defaultFoo(), { prop1: 1, newProp: 1 }); +}); + +let returnsJson = [ + { + namespace: "returns", + types: [ + { + id: "Widget", + type: "object", + properties: { + size: { type: "integer" }, + colour: { type: "string", optional: true }, + }, + }, + ], + functions: [ + { + name: "complete", + type: "function", + returns: { $ref: "Widget" }, + parameters: [], + }, + { + name: "optional", + type: "function", + returns: { $ref: "Widget" }, + parameters: [], + }, + { + name: "invalid", + type: "function", + returns: { $ref: "Widget" }, + parameters: [], + }, + ], + }, +]; + +add_task(async function testReturns() { + const url = "data:," + JSON.stringify(returnsJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + const apiObject = { + complete() { + return { size: 3, colour: "orange" }; + }, + optional() { + return { size: 4 }; + }, + invalid() { + return {}; + }, + }; + + const localWrapper = { + manifestVersion: 2, + cloneScope: global, + shouldInject(ns) { + return true; + }, + getImplementation(ns, name) { + return new LocalAPIImplementation(apiObject, name, null); + }, + }; + + const root = {}; + Schemas.inject(root, localWrapper); + + deepEqual(root.returns.complete(), { size: 3, colour: "orange" }); + deepEqual( + root.returns.optional(), + { size: 4 }, + "Missing optional properties is allowed" + ); + + if (AppConstants.DEBUG) { + Assert.throws( + () => root.returns.invalid(), + /Type error for result value \(Property "size" is required\)/, + "Should throw for invalid result in DEBUG builds" + ); + } else { + deepEqual( + root.returns.invalid(), + {}, + "Doesn't throw for invalid result value in release builds" + ); + } +}); + +let booleanEnumJson = [ + { + namespace: "booleanEnum", + + types: [ + { + id: "enumTrue", + type: "boolean", + enum: [true], + }, + ], + functions: [ + { + name: "paramMustBeTrue", + type: "function", + parameters: [{ name: "arg", $ref: "enumTrue" }], + }, + ], + }, +]; + +add_task(async function testBooleanEnum() { + let wrapper = getContextWrapper(); + + let url = "data:," + JSON.stringify(booleanEnumJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, wrapper); + + ok(root.booleanEnum, "namespace exists"); + root.booleanEnum.paramMustBeTrue(true); + wrapper.verify("call", "booleanEnum", "paramMustBeTrue", [true]); + Assert.throws( + () => root.booleanEnum.paramMustBeTrue(false), + /Type error for parameter arg \(Invalid value false\) for booleanEnum\.paramMustBeTrue\./, + "should throw because enum of the type restricts parameter to true" + ); +}); + +let xoriginJson = [ + { + namespace: "xorigin", + types: [], + functions: [ + { + name: "foo", + type: "function", + parameters: [ + { + name: "arg", + type: "any", + }, + ], + }, + { + name: "crossFoo", + type: "function", + allowCrossOriginArguments: true, + parameters: [ + { + name: "arg", + type: "any", + }, + ], + }, + ], + }, +]; + +add_task(async function testCrossOriginArguments() { + let url = "data:," + JSON.stringify(xoriginJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + let sandbox = new Cu.Sandbox("http://test.com"); + + let testingApiObj = { + foo(arg) { + sandbox.result = JSON.stringify(arg); + }, + crossFoo(arg) { + sandbox.xResult = JSON.stringify(arg); + }, + }; + + let localWrapper = { + manifestVersion: 2, + cloneScope: sandbox, + shouldInject(ns) { + return true; + }, + getImplementation(ns, name) { + return new LocalAPIImplementation(testingApiObj, name, null); + }, + }; + + let root = {}; + Schemas.inject(root, localWrapper); + + Assert.throws( + () => root.xorigin.foo({ key: 13 }), + /Permission denied to pass object/ + ); + equal(sandbox.result, undefined, "Foo can't read cross origin object."); + + root.xorigin.crossFoo({ answer: 42 }); + equal(sandbox.xResult, '{"answer":42}', "Can read cross origin object."); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_allowed_contexts.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_allowed_contexts.js new file mode 100644 index 0000000000..16743049d0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_allowed_contexts.js @@ -0,0 +1,160 @@ +"use strict"; + +const { Schemas } = ChromeUtils.importESModule( + "resource://gre/modules/Schemas.sys.mjs" +); + +const global = this; + +let schemaJson = [ + { + namespace: "noAllowedContexts", + properties: { + prop1: { type: "object" }, + prop2: { type: "object", allowedContexts: ["test_zero", "test_one"] }, + prop3: { type: "number", value: 1 }, + prop4: { type: "number", value: 1, allowedContexts: ["numeric_one"] }, + }, + }, + { + namespace: "defaultContexts", + defaultContexts: ["test_two"], + properties: { + prop1: { type: "object" }, + prop2: { type: "object", allowedContexts: ["test_three"] }, + prop3: { type: "number", value: 1 }, + prop4: { type: "number", value: 1, allowedContexts: ["numeric_two"] }, + }, + }, + { + namespace: "withAllowedContexts", + allowedContexts: ["test_four"], + properties: { + prop1: { type: "object" }, + prop2: { type: "object", allowedContexts: ["test_five"] }, + prop3: { type: "number", value: 1 }, + prop4: { type: "number", value: 1, allowedContexts: ["numeric_three"] }, + }, + }, + { + namespace: "withAllowedContextsAndDefault", + allowedContexts: ["test_six"], + defaultContexts: ["test_seven"], + properties: { + prop1: { type: "object" }, + prop2: { type: "object", allowedContexts: ["test_eight"] }, + prop3: { type: "number", value: 1 }, + prop4: { type: "number", value: 1, allowedContexts: ["numeric_four"] }, + }, + }, + { + namespace: "with_submodule", + defaultContexts: ["test_nine"], + types: [ + { + id: "subtype", + type: "object", + functions: [ + { + name: "noAllowedContexts", + type: "function", + parameters: [], + }, + { + name: "allowedContexts", + allowedContexts: ["test_ten"], + type: "function", + parameters: [], + }, + ], + }, + ], + properties: { + prop1: { $ref: "subtype" }, + prop2: { $ref: "subtype", allowedContexts: ["test_eleven"] }, + }, + }, +]; + +add_task(async function testRestrictions() { + let url = "data:," + JSON.stringify(schemaJson); + await Schemas.load(url); + let results = {}; + let localWrapper = { + manifestVersion: 2, + cloneScope: global, + shouldInject(ns, name, allowedContexts) { + name = ns ? ns + "." + name : name; + results[name] = allowedContexts.join(","); + return true; + }, + getImplementation() { + // The actual implementation is not significant for this test. + // Let's take this opportunity to see if schema generation is free of + // exceptions even when somehow getImplementation does not return an + // implementation. + }, + }; + + let root = {}; + Schemas.inject(root, localWrapper); + + function verify(path, expected) { + let obj = root; + for (let thing of path.split(".")) { + try { + obj = obj[thing]; + } catch (e) { + // Blech. + } + } + + let result = results[path]; + equal(result, expected, path); + } + + verify("noAllowedContexts", ""); + verify("noAllowedContexts.prop1", ""); + verify("noAllowedContexts.prop2", "test_zero,test_one"); + verify("noAllowedContexts.prop3", ""); + verify("noAllowedContexts.prop4", "numeric_one"); + + verify("defaultContexts", ""); + verify("defaultContexts.prop1", "test_two"); + verify("defaultContexts.prop2", "test_three"); + verify("defaultContexts.prop3", "test_two"); + verify("defaultContexts.prop4", "numeric_two"); + + verify("withAllowedContexts", "test_four"); + verify("withAllowedContexts.prop1", ""); + verify("withAllowedContexts.prop2", "test_five"); + verify("withAllowedContexts.prop3", ""); + verify("withAllowedContexts.prop4", "numeric_three"); + + verify("withAllowedContextsAndDefault", "test_six"); + verify("withAllowedContextsAndDefault.prop1", "test_seven"); + verify("withAllowedContextsAndDefault.prop2", "test_eight"); + verify("withAllowedContextsAndDefault.prop3", "test_seven"); + verify("withAllowedContextsAndDefault.prop4", "numeric_four"); + + verify("with_submodule", ""); + verify("with_submodule.prop1", "test_nine"); + verify("with_submodule.prop1.noAllowedContexts", "test_nine"); + verify("with_submodule.prop1.allowedContexts", "test_ten"); + verify("with_submodule.prop2", "test_eleven"); + // Note: test_nine inherits allowed contexts from the namespace, not from + // submodule. There is no "defaultContexts" for submodule types to not + // complicate things. + verify("with_submodule.prop1.noAllowedContexts", "test_nine"); + verify("with_submodule.prop1.allowedContexts", "test_ten"); + + // This is a constant, so it does not matter that getImplementation does not + // return an implementation since the API injector should take care of it. + equal(root.noAllowedContexts.prop3, 1); + + Assert.throws( + () => root.noAllowedContexts.prop1, + /undefined/, + "Should throw when the implementation is absent." + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_async.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_async.js new file mode 100644 index 0000000000..2613593771 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_async.js @@ -0,0 +1,352 @@ +"use strict"; + +const { Schemas } = ChromeUtils.importESModule( + "resource://gre/modules/Schemas.sys.mjs" +); + +let { BaseContext, LocalAPIImplementation } = ExtensionCommon; + +let schemaJson = [ + { + namespace: "testnamespace", + types: [ + { + id: "Widget", + type: "object", + properties: { + size: { type: "integer" }, + colour: { type: "string", optional: true }, + }, + }, + ], + functions: [ + { + name: "one_required", + type: "function", + parameters: [ + { + name: "first", + type: "function", + parameters: [], + }, + ], + }, + { + name: "one_optional", + type: "function", + parameters: [ + { + name: "first", + type: "function", + parameters: [], + optional: true, + }, + ], + }, + { + name: "async_required", + type: "function", + async: "first", + parameters: [ + { + name: "first", + type: "function", + parameters: [], + }, + ], + }, + { + name: "async_optional", + type: "function", + async: "first", + parameters: [ + { + name: "first", + type: "function", + parameters: [], + optional: true, + }, + ], + }, + { + name: "async_result", + type: "function", + async: "callback", + parameters: [ + { + name: "callback", + type: "function", + parameters: [ + { + name: "widget", + $ref: "Widget", + }, + ], + }, + ], + }, + ], + }, +]; + +const global = this; +class StubContext extends BaseContext { + constructor() { + let fakeExtension = { id: "test@web.extension" }; + super("testEnv", fakeExtension); + this.sandbox = Cu.Sandbox(global); + } + + get cloneScope() { + return this.sandbox; + } + + get principal() { + return Cu.getObjectPrincipal(this.sandbox); + } +} + +let context; + +function generateAPIs(extraWrapper, apiObj) { + context = new StubContext(); + let localWrapper = { + manifestVersion: 2, + cloneScope: global, + shouldInject() { + return true; + }, + getImplementation(namespace, name) { + return new LocalAPIImplementation(apiObj, name, context); + }, + }; + Object.assign(localWrapper, extraWrapper); + + let root = {}; + Schemas.inject(root, localWrapper); + return root.testnamespace; +} + +add_task(async function testParameterValidation() { + await Schemas.load("data:," + JSON.stringify(schemaJson)); + + let testnamespace; + function assertThrows(name, ...args) { + Assert.throws( + () => testnamespace[name](...args), + /Incorrect argument types/, + `Expected testnamespace.${name}(${args.map(String).join(", ")}) to throw.` + ); + } + function assertNoThrows(name, ...args) { + try { + testnamespace[name](...args); + } catch (e) { + info( + `testnamespace.${name}(${args + .map(String) + .join(", ")}) unexpectedly threw.` + ); + throw new Error(e); + } + } + let cb = () => {}; + + for (let isChromeCompat of [true, false]) { + info(`Testing API validation with isChromeCompat=${isChromeCompat}`); + testnamespace = generateAPIs( + { + isChromeCompat, + }, + { + one_required() {}, + one_optional() {}, + async_required() {}, + async_optional() {}, + } + ); + + assertThrows("one_required"); + assertThrows("one_required", null); + assertNoThrows("one_required", cb); + assertThrows("one_required", cb, null); + assertThrows("one_required", cb, cb); + + assertNoThrows("one_optional"); + assertNoThrows("one_optional", null); + assertNoThrows("one_optional", cb); + assertThrows("one_optional", cb, null); + assertThrows("one_optional", cb, cb); + + // Schema-based validation happens before an async method is called, so + // errors should be thrown synchronously. + + // The parameter was declared as required, but there was also an "async" + // attribute with the same value as the parameter name, so the callback + // parameter is actually optional. + assertNoThrows("async_required"); + assertNoThrows("async_required", null); + assertNoThrows("async_required", cb); + assertThrows("async_required", cb, null); + assertThrows("async_required", cb, cb); + + assertNoThrows("async_optional"); + assertNoThrows("async_optional", null); + assertNoThrows("async_optional", cb); + assertThrows("async_optional", cb, null); + assertThrows("async_optional", cb, cb); + } +}); + +add_task(async function testCheckAsyncResults() { + await Schemas.load("data:," + JSON.stringify(schemaJson)); + + const complete = generateAPIs( + {}, + { + async_result: async () => ({ size: 5, colour: "green" }), + } + ); + + const optional = generateAPIs( + {}, + { + async_result: async () => ({ size: 6 }), + } + ); + + const invalid = generateAPIs( + {}, + { + async_result: async () => ({}), + } + ); + + deepEqual(await complete.async_result(), { size: 5, colour: "green" }); + + deepEqual( + await optional.async_result(), + { size: 6 }, + "Missing optional properties is allowed" + ); + + if (AppConstants.DEBUG) { + await Assert.rejects( + invalid.async_result(), + /Type error for widget value \(Property "size" is required\)/, + "Should throw for invalid callback argument in DEBUG builds" + ); + } else { + deepEqual( + await invalid.async_result(), + {}, + "Invalid callback argument doesn't throw in release builds" + ); + } +}); + +add_task(async function testAsyncResults() { + await Schemas.load("data:," + JSON.stringify(schemaJson)); + function runWithCallback(func) { + info(`Calling testnamespace.${func.name}, expecting callback with result`); + return new Promise(resolve => { + let result = "uninitialized value"; + let returnValue = func(reply => { + result = reply; + resolve(result); + }); + // When a callback is given, the return value must be missing. + Assert.equal(returnValue, undefined); + // Callback must be called asynchronously. + Assert.equal(result, "uninitialized value"); + }); + } + + function runFailCallback(func) { + info(`Calling testnamespace.${func.name}, expecting callback with error`); + return new Promise(resolve => { + func(reply => { + Assert.equal(reply, undefined); + resolve(context.lastError.message); // eslint-disable-line no-undef + }); + }); + } + + for (let isChromeCompat of [true, false]) { + info(`Testing API invocation with isChromeCompat=${isChromeCompat}`); + let testnamespace = generateAPIs( + { + isChromeCompat, + }, + { + async_required(cb) { + Assert.equal(cb, undefined); + return Promise.resolve(1); + }, + async_optional(cb) { + Assert.equal(cb, undefined); + return Promise.resolve(2); + }, + } + ); + if (!isChromeCompat) { + // No promises for chrome. + info("testnamespace.async_required should be a Promise"); + let promise = testnamespace.async_required(); + Assert.ok(promise instanceof context.cloneScope.Promise); + Assert.equal(await promise, 1); + + info("testnamespace.async_optional should be a Promise"); + promise = testnamespace.async_optional(); + Assert.ok(promise instanceof context.cloneScope.Promise); + Assert.equal(await promise, 2); + } + + Assert.equal(await runWithCallback(testnamespace.async_required), 1); + Assert.equal(await runWithCallback(testnamespace.async_optional), 2); + + let otherSandbox = Cu.Sandbox(null, {}); + let errorFactories = [ + msg => { + throw new context.cloneScope.Error(msg); + }, + msg => context.cloneScope.Promise.reject({ message: msg }), + msg => Cu.evalInSandbox(`throw new Error("${msg}")`, otherSandbox), + msg => + Cu.evalInSandbox(`Promise.reject({message: "${msg}"})`, otherSandbox), + ]; + for (let makeError of errorFactories) { + info(`Testing callback/promise with error caused by: ${makeError}`); + testnamespace = generateAPIs( + { + isChromeCompat, + }, + { + async_required() { + return makeError("ONE"); + }, + async_optional() { + return makeError("TWO"); + }, + } + ); + + if (!isChromeCompat) { + // No promises for chrome. + await Assert.rejects( + testnamespace.async_required(), + /ONE/, + "should reject testnamespace.async_required()" + ); + await Assert.rejects( + testnamespace.async_optional(), + /TWO/, + "should reject testnamespace.async_optional()" + ); + } + + Assert.equal(await runFailCallback(testnamespace.async_required), "ONE"); + Assert.equal(await runFailCallback(testnamespace.async_optional), "TWO"); + } + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_interactive.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_interactive.js new file mode 100644 index 0000000000..986dc74bc5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_interactive.js @@ -0,0 +1,173 @@ +"use strict"; + +const { ExtensionProcessScript } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionProcessScript.sys.mjs" +); + +let experimentAPIs = { + userinputtest: { + schema: "schema.json", + parent: { + scopes: ["addon_parent"], + script: "parent.js", + paths: [["userinputtest"]], + }, + child: { + scopes: ["addon_child"], + script: "child.js", + paths: [["userinputtest", "child"]], + }, + }, +}; + +let experimentFiles = { + "schema.json": JSON.stringify([ + { + namespace: "userinputtest", + functions: [ + { + name: "test", + type: "function", + async: true, + requireUserInput: true, + parameters: [], + }, + { + name: "child", + type: "function", + async: true, + requireUserInput: true, + parameters: [], + }, + ], + }, + ]), + + /* globals ExtensionAPI */ + "parent.js": () => { + this.userinputtest = class extends ExtensionAPI { + getAPI(context) { + return { + userinputtest: { + test() {}, + }, + }; + } + }; + }, + + "child.js": () => { + this.userinputtest = class extends ExtensionAPI { + getAPI(context) { + return { + userinputtest: { + child() {}, + }, + }; + } + }; + }, +}; + +// Set the "handlingUserInput" flag for the given extension's background page. +// Returns an RAIIHelper that should be destruct()ed eventually. +function setHandlingUserInput(extension) { + let extensionChild = ExtensionProcessScript.getExtensionChild(extension.id); + let bgwin = null; + for (let view of extensionChild.views) { + if (view.viewType == "background") { + bgwin = view.contentWindow; + break; + } + } + notEqual(bgwin, null, "Found background window for the test extension"); + let winutils = bgwin.windowUtils; + return winutils.setHandlingUserInput(true); +} + +// Test that the schema requireUserInput flag works correctly for +// proxied api implementations. +add_task(async function test_proxy() { + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + background() { + browser.test.onMessage.addListener(async () => { + try { + await browser.userinputtest.test(); + browser.test.sendMessage("result", null); + } catch (err) { + browser.test.sendMessage("result", err.message); + } + }); + }, + manifest: { + permissions: ["experiments.userinputtest"], + experiment_apis: experimentAPIs, + }, + files: experimentFiles, + }); + + await extension.startup(); + + extension.sendMessage("test"); + let result = await extension.awaitMessage("result"); + ok( + /test may only be called from a user input handler/.test(result), + `function failed when not called from a user input handler: ${result}` + ); + + let handle = setHandlingUserInput(extension); + extension.sendMessage("test"); + result = await extension.awaitMessage("result"); + equal( + result, + null, + "function succeeded when called from a user input handler" + ); + handle.destruct(); + + await extension.unload(); +}); + +// Test that the schema requireUserInput flag works correctly for +// non-proxied api implementations. +add_task(async function test_local() { + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + background() { + browser.test.onMessage.addListener(async () => { + try { + await browser.userinputtest.child(); + browser.test.sendMessage("result", null); + } catch (err) { + browser.test.sendMessage("result", err.message); + } + }); + }, + manifest: { + experiment_apis: experimentAPIs, + }, + files: experimentFiles, + }); + + await extension.startup(); + + extension.sendMessage("test"); + let result = await extension.awaitMessage("result"); + ok( + /child may only be called from a user input handler/.test(result), + `function failed when not called from a user input handler: ${result}` + ); + + let handle = setHandlingUserInput(extension); + extension.sendMessage("test"); + result = await extension.awaitMessage("result"); + equal( + result, + null, + "function succeeded when called from a user input handler" + ); + handle.destruct(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_manifest_permissions.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_manifest_permissions.js new file mode 100644 index 0000000000..562ab5c36d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_manifest_permissions.js @@ -0,0 +1,171 @@ +"use strict"; + +const { ExtensionAPI } = ExtensionCommon; + +add_task(async function () { + const schema = [ + { + namespace: "manifest", + types: [ + { + $extend: "WebExtensionManifest", + properties: { + a_manifest_property: { + type: "object", + optional: true, + properties: { + nested: { + optional: true, + type: "any", + }, + }, + additionalProperties: { $ref: "UnrecognizedProperty" }, + }, + }, + }, + ], + }, + { + namespace: "testManifestPermission", + permissions: ["manifest:a_manifest_property"], + functions: [ + { + name: "testMethod", + type: "function", + async: true, + parameters: [], + permissions: ["manifest:a_manifest_property.nested"], + }, + ], + }, + ]; + + class FakeAPI extends ExtensionAPI { + getAPI(context) { + return { + testManifestPermission: { + get testProperty() { + return "value"; + }, + testMethod() { + return Promise.resolve("value"); + }, + }, + }; + } + } + + const modules = { + testNamespace: { + url: URL.createObjectURL(new Blob([FakeAPI.toString()])), + schema: `data:,${JSON.stringify(schema)}`, + scopes: ["addon_parent", "addon_child"], + paths: [["testManifestPermission"]], + }, + }; + + Services.catMan.addCategoryEntry( + "webextension-modules", + "test-manifest-permission", + `data:,${JSON.stringify(modules)}`, + false, + false + ); + + async function testExtension(extensionDef, assertFn) { + let extension = ExtensionTestUtils.loadExtension(extensionDef); + + await extension.startup(); + await assertFn(extension); + await extension.unload(); + } + + await testExtension( + { + manifest: { + a_manifest_property: {}, + }, + background() { + // Test hasPermission method implemented in ExtensionChild.jsm. + browser.test.assertTrue( + "testManifestPermission" in browser, + "The API namespace is defined as expected" + ); + browser.test.assertEq( + undefined, + browser.testManifestPermission && + browser.testManifestPermission.testMethod, + "The property with nested manifest property permission should not be available " + ); + browser.test.notifyPass("test-extension-manifest-without-nested-prop"); + }, + }, + async extension => { + await extension.awaitFinish( + "test-extension-manifest-without-nested-prop" + ); + + // Test hasPermission method implemented in Extension.jsm. + equal( + extension.extension.hasPermission("manifest:a_manifest_property"), + true, + "Got the expected Extension's hasPermission result on existing property" + ); + equal( + extension.extension.hasPermission( + "manifest:a_manifest_property.nested" + ), + false, + "Got the expected Extension's hasPermission result on existing subproperty" + ); + } + ); + + await testExtension( + { + manifest: { + a_manifest_property: { + nested: {}, + }, + }, + background() { + // Test hasPermission method implemented in ExtensionChild.jsm. + browser.test.assertTrue( + "testManifestPermission" in browser, + "The API namespace is defined as expected" + ); + browser.test.assertEq( + "function", + browser.testManifestPermission && + typeof browser.testManifestPermission.testMethod, + "The property with nested manifest property permission should be available " + ); + browser.test.notifyPass("test-extension-manifest-with-nested-prop"); + }, + }, + async extension => { + await extension.awaitFinish("test-extension-manifest-with-nested-prop"); + + // Test hasPermission method implemented in Extension.jsm. + equal( + extension.extension.hasPermission("manifest:a_manifest_property"), + true, + "Got the expected Extension's hasPermission result on existing property" + ); + equal( + extension.extension.hasPermission( + "manifest:a_manifest_property.nested" + ), + true, + "Got the expected Extension's hasPermission result on existing subproperty" + ); + equal( + extension.extension.hasPermission( + "manifest:a_manifest_property.unexisting" + ), + false, + "Got the expected Extension's hasPermission result on non existing subproperty" + ); + } + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_privileged.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_privileged.js new file mode 100644 index 0000000000..e2da7e5a74 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_privileged.js @@ -0,0 +1,161 @@ +"use strict"; + +const { ExtensionAPI } = ExtensionCommon; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.usePrivilegedSignatures = false; +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_setup(async () => { + const schema = [ + { + namespace: "privileged", + permissions: ["mozillaAddons"], + properties: { + test: { + type: "any", + }, + }, + }, + ]; + + class API extends ExtensionAPI { + getAPI(context) { + return { + privileged: { + test: "hello", + }, + }; + } + } + + const modules = { + privileged: { + url: URL.createObjectURL(new Blob([API.toString()])), + schema: `data:,${JSON.stringify(schema)}`, + scopes: ["addon_parent"], + paths: [["privileged"]], + }, + }; + + Services.catMan.addCategoryEntry( + "webextension-modules", + "test-privileged", + `data:,${JSON.stringify(modules)}`, + false, + false + ); + + await AddonTestUtils.promiseStartupManager(); + + registerCleanupFunction(async () => { + await AddonTestUtils.promiseShutdownManager(); + Services.catMan.deleteCategoryEntry( + "webextension-modules", + "test-privileged", + false + ); + }); +}); + +add_task( + { + // Some builds (e.g. thunderbird) have experiments enabled by default. + pref_set: [["extensions.experiments.enabled", false]], + }, + async function test_privileged_namespace_disallowed() { + // Try accessing the privileged namespace. + async function testOnce({ + isPrivileged = false, + temporarilyInstalled = false, + } = {}) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["mozillaAddons", "tabs"], + }, + background() { + browser.test.sendMessage( + "result", + browser.privileged instanceof Object + ); + }, + isPrivileged, + temporarilyInstalled, + }); + + if (temporarilyInstalled && !isPrivileged) { + ExtensionTestUtils.failOnSchemaWarnings(false); + let { messages } = await promiseConsoleOutput(async () => { + await Assert.rejects( + extension.startup(), + /Using the privileged permission/, + "Startup failed with privileged permission" + ); + }); + ExtensionTestUtils.failOnSchemaWarnings(true); + AddonTestUtils.checkMessages( + messages, + { + expected: [ + { + message: + /Using the privileged permission 'mozillaAddons' requires a privileged add-on/, + }, + ], + }, + true + ); + return null; + } + await extension.startup(); + let result = await extension.awaitMessage("result"); + await extension.unload(); + return result; + } + + // Prevents startup + let result = await testOnce({ temporarilyInstalled: true }); + equal( + result, + null, + "Privileged namespace should not be accessible to a regular webextension" + ); + + result = await testOnce({ isPrivileged: true }); + equal( + result, + true, + "Privileged namespace should be accessible to a webextension signed with Mozilla Extensions" + ); + + // Allows startup, no access + result = await testOnce(); + equal( + result, + false, + "Privileged namespace should not be accessible to a regular webextension" + ); + } +); + +// Test that Extension.jsm and schema correctly match. +add_task(function test_privileged_permissions_match() { + const { PRIVILEGED_PERMS } = ChromeUtils.importESModule( + "resource://gre/modules/Extension.sys.mjs" + ); + let perms = Schemas.getPermissionNames(["PermissionPrivileged"]); + if (AppConstants.platform == "android") { + perms.push("nativeMessaging"); + } + Assert.deepEqual( + Array.from(PRIVILEGED_PERMS).sort(), + perms.sort(), + "List of privileged permissions is correct." + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_revoke.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_revoke.js new file mode 100644 index 0000000000..5b4df1e671 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_revoke.js @@ -0,0 +1,507 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { Schemas } = ChromeUtils.importESModule( + "resource://gre/modules/Schemas.sys.mjs" +); + +let { SchemaAPIInterface } = ExtensionCommon; + +const global = this; + +let json = [ + { + namespace: "revokableNs", + + permissions: ["revokableNs"], + + properties: { + stringProp: { + type: "string", + writable: true, + }, + + revokableStringProp: { + type: "string", + permissions: ["revokableProp"], + writable: true, + }, + + submoduleProp: { + $ref: "submodule", + }, + + revokableSubmoduleProp: { + $ref: "submodule", + permissions: ["revokableProp"], + }, + }, + + types: [ + { + id: "submodule", + type: "object", + functions: [ + { + name: "sub_foo", + type: "function", + parameters: [], + returns: { type: "integer" }, + }, + ], + }, + ], + + functions: [ + { + name: "func", + type: "function", + parameters: [], + }, + + { + name: "revokableFunc", + type: "function", + parameters: [], + permissions: ["revokableFunc"], + }, + ], + + events: [ + { + name: "onEvent", + type: "function", + }, + + { + name: "onRevokableEvent", + type: "function", + permissions: ["revokableEvent"], + }, + ], + }, +]; + +let recorded = []; + +function record(...args) { + recorded.push(args); +} + +function verify(expected) { + for (let [i, rec] of expected.entries()) { + Assert.deepEqual(recorded[i], rec, `Record ${i} matches`); + } + + equal(recorded.length, expected.length, "Got expected number of records"); + + recorded.length = 0; +} + +registerCleanupFunction(() => { + equal(recorded.length, 0, "No unchecked recorded events at shutdown"); +}); + +let permissions = new Set(); + +class APIImplementation extends SchemaAPIInterface { + constructor(namespace, name) { + super(); + this.namespace = namespace; + this.name = name; + } + + record(method, args) { + record(method, this.namespace, this.name, args); + } + + revoke(...args) { + this.record("revoke", args); + } + + callFunction(...args) { + this.record("callFunction", args); + if (this.name === "sub_foo") { + return 13; + } + } + + callFunctionNoReturn(...args) { + this.record("callFunctionNoReturn", args); + } + + getProperty(...args) { + this.record("getProperty", args); + } + + setProperty(...args) { + this.record("setProperty", args); + } + + addListener(...args) { + this.record("addListener", args); + } + + removeListener(...args) { + this.record("removeListener", args); + } + + hasListener(...args) { + this.record("hasListener", args); + } +} + +let context = { + manifestVersion: 2, + cloneScope: global, + + permissionsChanged: null, + + setPermissionsChangedCallback(callback) { + this.permissionsChanged = callback; + }, + + hasPermission(permission) { + return permissions.has(permission); + }, + + isPermissionRevokable(permission) { + return permission.startsWith("revokable"); + }, + + getImplementation(namespace, name) { + return new APIImplementation(namespace, name); + }, + + shouldInject() { + return true; + }, +}; + +function ignoreError(fn) { + try { + fn(); + } catch (e) { + // Meh. + } +} + +add_task(async function () { + let url = "data:," + JSON.stringify(json); + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, context); + equal(recorded.length, 0, "No recorded events"); + + let listener = () => {}; + let captured = {}; + + function checkRecorded() { + let possible = [ + ["revokableNs", ["getProperty", "revokableNs", "stringProp", []]], + [ + "revokableProp", + ["getProperty", "revokableNs", "revokableStringProp", []], + ], + + [ + "revokableNs", + ["setProperty", "revokableNs", "stringProp", ["stringProp"]], + ], + [ + "revokableProp", + [ + "setProperty", + "revokableNs", + "revokableStringProp", + ["revokableStringProp"], + ], + ], + + ["revokableNs", ["callFunctionNoReturn", "revokableNs", "func", [[]]]], + [ + "revokableFunc", + ["callFunctionNoReturn", "revokableNs", "revokableFunc", [[]]], + ], + + [ + "revokableNs", + ["callFunction", "revokableNs.submoduleProp", "sub_foo", [[]]], + ], + [ + "revokableProp", + ["callFunction", "revokableNs.revokableSubmoduleProp", "sub_foo", [[]]], + ], + + [ + "revokableNs", + ["addListener", "revokableNs", "onEvent", [listener, []]], + ], + ["revokableNs", ["removeListener", "revokableNs", "onEvent", [listener]]], + ["revokableNs", ["hasListener", "revokableNs", "onEvent", [listener]]], + + [ + "revokableEvent", + ["addListener", "revokableNs", "onRevokableEvent", [listener, []]], + ], + [ + "revokableEvent", + ["removeListener", "revokableNs", "onRevokableEvent", [listener]], + ], + [ + "revokableEvent", + ["hasListener", "revokableNs", "onRevokableEvent", [listener]], + ], + ]; + + let expected = []; + if (permissions.has("revokableNs")) { + for (let [perm, recording] of possible) { + if (!perm || permissions.has(perm)) { + expected.push(recording); + } + } + } + + verify(expected); + } + + function check() { + info(`Check normal access (permissions: [${Array.from(permissions)}])`); + + let ns = root.revokableNs; + + void ns.stringProp; + void ns.revokableStringProp; + + ns.stringProp = "stringProp"; + ns.revokableStringProp = "revokableStringProp"; + + ns.func(); + + if (ns.revokableFunc) { + ns.revokableFunc(); + } + + ns.submoduleProp.sub_foo(); + if (ns.revokableSubmoduleProp) { + ns.revokableSubmoduleProp.sub_foo(); + } + + ns.onEvent.addListener(listener); + ns.onEvent.removeListener(listener); + ns.onEvent.hasListener(listener); + + if (ns.onRevokableEvent) { + ns.onRevokableEvent.addListener(listener); + ns.onRevokableEvent.removeListener(listener); + ns.onRevokableEvent.hasListener(listener); + } + + checkRecorded(); + } + + function capture() { + info("Capture values"); + + let ns = root.revokableNs; + + captured = { ns }; + captured.revokableStringProp = Object.getOwnPropertyDescriptor( + ns, + "revokableStringProp" + ); + + captured.revokableSubmoduleProp = ns.revokableSubmoduleProp; + if (ns.revokableSubmoduleProp) { + captured.sub_foo = ns.revokableSubmoduleProp.sub_foo; + } + + captured.revokableFunc = ns.revokableFunc; + + captured.onRevokableEvent = ns.onRevokableEvent; + if (ns.onRevokableEvent) { + captured.addListener = ns.onRevokableEvent.addListener; + captured.removeListener = ns.onRevokableEvent.removeListener; + captured.hasListener = ns.onRevokableEvent.hasListener; + } + } + + function checkCaptured() { + info( + `Check captured value access (permissions: [${Array.from(permissions)}])` + ); + + let { ns } = captured; + + void ns.stringProp; + ignoreError(() => captured.revokableStringProp.get()); + if (!permissions.has("revokableProp")) { + void ns.revokableStringProp; + } + + ns.stringProp = "stringProp"; + ignoreError(() => captured.revokableStringProp.set("revokableStringProp")); + if (!permissions.has("revokableProp")) { + ns.revokableStringProp = "revokableStringProp"; + } + + ignoreError(() => ns.func()); + ignoreError(() => captured.revokableFunc()); + if (!permissions.has("revokableFunc")) { + ignoreError(() => ns.revokableFunc()); + } + + ignoreError(() => ns.submoduleProp.sub_foo()); + + ignoreError(() => captured.sub_foo()); + if (!permissions.has("revokableProp")) { + ignoreError(() => captured.revokableSubmoduleProp.sub_foo()); + ignoreError(() => ns.revokableSubmoduleProp.sub_foo()); + } + + ignoreError(() => ns.onEvent.addListener(listener)); + ignoreError(() => ns.onEvent.removeListener(listener)); + ignoreError(() => ns.onEvent.hasListener(listener)); + + ignoreError(() => captured.addListener(listener)); + ignoreError(() => captured.removeListener(listener)); + ignoreError(() => captured.hasListener(listener)); + if (!permissions.has("revokableEvent")) { + ignoreError(() => captured.onRevokableEvent.addListener(listener)); + ignoreError(() => captured.onRevokableEvent.removeListener(listener)); + ignoreError(() => captured.onRevokableEvent.hasListener(listener)); + + ignoreError(() => ns.onRevokableEvent.addListener(listener)); + ignoreError(() => ns.onRevokableEvent.removeListener(listener)); + ignoreError(() => ns.onRevokableEvent.hasListener(listener)); + } + + checkRecorded(); + } + + permissions.add("revokableNs"); + permissions.add("revokableProp"); + permissions.add("revokableFunc"); + permissions.add("revokableEvent"); + + check(); + capture(); + checkCaptured(); + + permissions.delete("revokableProp"); + context.permissionsChanged(); + verify([ + ["revoke", "revokableNs", "revokableStringProp", []], + ["revoke", "revokableNs.revokableSubmoduleProp", "sub_foo", []], + ]); + + check(); + checkCaptured(); + + permissions.delete("revokableFunc"); + context.permissionsChanged(); + verify([["revoke", "revokableNs", "revokableFunc", []]]); + + check(); + checkCaptured(); + + permissions.delete("revokableEvent"); + context.permissionsChanged(); + + verify([["revoke", "revokableNs", "onRevokableEvent", []]]); + + check(); + checkCaptured(); + + permissions.delete("revokableNs"); + context.permissionsChanged(); + verify([ + ["revoke", "revokableNs", "stringProp", []], + ["revoke", "revokableNs", "func", []], + ["revoke", "revokableNs.submoduleProp", "sub_foo", []], + ["revoke", "revokableNs", "onEvent", []], + ]); + + checkCaptured(); + + permissions.add("revokableNs"); + permissions.add("revokableProp"); + permissions.add("revokableFunc"); + permissions.add("revokableEvent"); + context.permissionsChanged(); + + check(); + capture(); + checkCaptured(); + + permissions.delete("revokableProp"); + permissions.delete("revokableFunc"); + permissions.delete("revokableEvent"); + context.permissionsChanged(); + verify([ + ["revoke", "revokableNs", "revokableStringProp", []], + ["revoke", "revokableNs", "revokableFunc", []], + ["revoke", "revokableNs.revokableSubmoduleProp", "sub_foo", []], + ["revoke", "revokableNs", "onRevokableEvent", []], + ]); + + check(); + checkCaptured(); + + permissions.add("revokableProp"); + permissions.add("revokableFunc"); + permissions.add("revokableEvent"); + context.permissionsChanged(); + + check(); + capture(); + checkCaptured(); + + permissions.delete("revokableNs"); + context.permissionsChanged(); + verify([ + ["revoke", "revokableNs", "stringProp", []], + ["revoke", "revokableNs", "revokableStringProp", []], + ["revoke", "revokableNs", "func", []], + ["revoke", "revokableNs", "revokableFunc", []], + ["revoke", "revokableNs.submoduleProp", "sub_foo", []], + ["revoke", "revokableNs.revokableSubmoduleProp", "sub_foo", []], + ["revoke", "revokableNs", "onEvent", []], + ["revoke", "revokableNs", "onRevokableEvent", []], + ]); + + equal(root.revokableNs, undefined, "Namespace is not defined"); + checkCaptured(); +}); + +add_task(async function test_neuter() { + context.permissionsChanged = null; + + let root = {}; + Schemas.inject(root, context); + equal(recorded.length, 0, "No recorded events"); + + permissions.add("revokableNs"); + permissions.add("revokableProp"); + permissions.add("revokableFunc"); + permissions.add("revokableEvent"); + + let ns = root.revokableNs; + let { submoduleProp } = ns; + + let lazyGetter = Object.getOwnPropertyDescriptor(submoduleProp, "sub_foo"); + + permissions.delete("revokableNs"); + context.permissionsChanged(); + verify([]); + + equal(root.revokableNs, undefined, "Should have no revokableNs"); + equal(ns.submoduleProp, undefined, "Should have no ns.submoduleProp"); + + equal(submoduleProp.sub_foo, undefined, "No sub_foo"); + lazyGetter.get.call(submoduleProp); + equal(submoduleProp.sub_foo, undefined, "No sub_foo"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_roots.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_roots.js new file mode 100644 index 0000000000..21434228a3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_roots.js @@ -0,0 +1,242 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { SchemaRoot } = ChromeUtils.importESModule( + "resource://gre/modules/Schemas.sys.mjs" +); + +let { SchemaAPIInterface } = ExtensionCommon; + +const global = this; + +let baseSchemaJSON = [ + { + namespace: "base", + + properties: { + PROP1: { value: 42 }, + }, + + types: [ + { + id: "type1", + type: "string", + enum: ["value1", "value2", "value3"], + }, + ], + + functions: [ + { + name: "foo", + type: "function", + parameters: [{ name: "arg1", $ref: "type1" }], + }, + ], + }, +]; + +let experimentFooJSON = [ + { + namespace: "experiments.foo", + types: [ + { + id: "typeFoo", + type: "string", + enum: ["foo1", "foo2", "foo3"], + }, + ], + + functions: [ + { + name: "foo", + type: "function", + parameters: [ + { name: "arg1", $ref: "typeFoo" }, + { name: "arg2", $ref: "base.type1" }, + ], + }, + ], + }, +]; + +let experimentBarJSON = [ + { + namespace: "experiments.bar", + types: [ + { + id: "typeBar", + type: "string", + enum: ["bar1", "bar2", "bar3"], + }, + ], + + functions: [ + { + name: "bar", + type: "function", + parameters: [ + { name: "arg1", $ref: "typeBar" }, + { name: "arg2", $ref: "base.type1" }, + ], + }, + ], + }, +]; + +let tallied = null; + +function tally(kind, ns, name, args) { + tallied = [kind, ns, name, args]; +} + +function verify(...args) { + equal(JSON.stringify(tallied), JSON.stringify(args)); + tallied = null; +} + +let talliedErrors = []; + +let permissions = new Set(); + +class TallyingAPIImplementation extends SchemaAPIInterface { + constructor(namespace, name) { + super(); + this.namespace = namespace; + this.name = name; + } + + callFunction(args) { + tally("call", this.namespace, this.name, args); + if (this.name === "sub_foo") { + return 13; + } + } + + callFunctionNoReturn(args) { + tally("call", this.namespace, this.name, args); + } + + getProperty() { + tally("get", this.namespace, this.name); + } + + setProperty(value) { + tally("set", this.namespace, this.name, value); + } + + addListener(listener, args) { + tally("addListener", this.namespace, this.name, [listener, args]); + } + + removeListener(listener) { + tally("removeListener", this.namespace, this.name, [listener]); + } + + hasListener(listener) { + tally("hasListener", this.namespace, this.name, [listener]); + } +} + +let wrapper = { + url: "moz-extension://b66e3509-cdb3-44f6-8eb8-c8b39b3a1d27/", + manifestVersion: 2, + + cloneScope: global, + + checkLoadURL(url) { + return !url.startsWith("chrome:"); + }, + + preprocessors: { + localize(value, context) { + return value.replace(/__MSG_(.*?)__/g, (m0, m1) => `${m1.toUpperCase()}`); + }, + }, + + logError(message) { + talliedErrors.push(message); + }, + + hasPermission(permission) { + return permissions.has(permission); + }, + + shouldInject(ns, name) { + return name != "do-not-inject"; + }, + + getImplementation(namespace, name) { + return new TallyingAPIImplementation(namespace, name); + }, +}; + +add_task(async function () { + let baseSchemas = new Map([["resource://schemas/base.json", baseSchemaJSON]]); + let experimentSchemas = new Map([ + ["resource://experiment-foo/schema.json", experimentFooJSON], + ["resource://experiment-bar/schema.json", experimentBarJSON], + ]); + + let baseSchema = new SchemaRoot(null, baseSchemas); + let schema = new SchemaRoot(baseSchema, experimentSchemas); + + baseSchema.parseSchemas(); + schema.parseSchemas(); + + let root = {}; + let base = {}; + + tallied = null; + + baseSchema.inject(base, wrapper); + schema.inject(root, wrapper); + + equal(typeof base.base, "object", "base.base exists"); + equal(typeof root.base, "object", "root.base exists"); + equal(typeof base.experiments, "undefined", "base.experiments exists not"); + equal(typeof root.experiments, "object", "root.experiments exists"); + equal(typeof root.experiments.foo, "object", "root.experiments.foo exists"); + equal(typeof root.experiments.bar, "object", "root.experiments.bar exists"); + + equal(tallied, null); + + equal(root.base.PROP1, 42, "root.base.PROP1"); + equal(base.base.PROP1, 42, "root.base.PROP1"); + + root.base.foo("value2"); + verify("call", "base", "foo", ["value2"]); + + base.base.foo("value3"); + verify("call", "base", "foo", ["value3"]); + + root.experiments.foo.foo("foo2", "value1"); + verify("call", "experiments.foo", "foo", ["foo2", "value1"]); + + root.experiments.bar.bar("bar2", "value1"); + verify("call", "experiments.bar", "bar", ["bar2", "value1"]); + + Assert.throws( + () => root.base.foo("Meh."), + /Type error for parameter arg1/, + "root.base.foo()" + ); + + Assert.throws( + () => base.base.foo("Meh."), + /Type error for parameter arg1/, + "base.base.foo()" + ); + + Assert.throws( + () => root.experiments.foo.foo("Meh."), + /Incorrect argument types/, + "root.experiments.foo.foo()" + ); + + Assert.throws( + () => root.experiments.bar.bar("Meh."), + /Incorrect argument types/, + "root.experiments.bar.bar()" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_versioned.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_versioned.js new file mode 100644 index 0000000000..3dddbbc41b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_versioned.js @@ -0,0 +1,714 @@ +"use strict"; + +let json = [ + { + namespace: "MV2", + max_manifest_version: 2, + + properties: { + PROP1: { value: 20 }, + }, + }, + { + namespace: "MV3", + min_manifest_version: 3, + properties: { + PROP1: { value: 20 }, + }, + }, + { + namespace: "mixed", + + properties: { + PROP_any: { value: 20 }, + PROP_mv3: { + $ref: "submodule", + }, + }, + types: [ + { + id: "manifestTest", + type: "object", + properties: { + // An example of extending the base type for permissions + permissions: { + type: "array", + items: { + $ref: "BaseType", + }, + optional: true, + default: [], + }, + // An example of differentiating versions of a manifest entry + multiple_choice: { + optional: true, + choices: [ + { + max_manifest_version: 2, + type: "array", + items: { + type: "string", + }, + }, + { + min_manifest_version: 3, + type: "array", + items: { + type: "boolean", + }, + }, + { + type: "array", + items: { + type: "object", + properties: { + value: { type: "boolean" }, + }, + }, + }, + ], + }, + accepting_unrecognized_props: { + optional: true, + type: "object", + properties: { + mv2_only_prop: { + type: "string", + optional: true, + max_manifest_version: 2, + }, + mv3_only_prop: { + type: "string", + optional: true, + min_manifest_version: 3, + }, + mv2_only_prop_with_default: { + type: "string", + optional: true, + default: "only in MV2", + max_manifest_version: 2, + }, + mv3_only_prop_with_default: { + type: "string", + optional: true, + default: "only in MV3", + min_manifest_version: 3, + }, + }, + additionalProperties: { $ref: "UnrecognizedProperty" }, + }, + }, + }, + { + id: "submodule", + type: "object", + min_manifest_version: 3, + functions: [ + { + name: "sub_foo", + type: "function", + parameters: [], + returns: { type: "integer" }, + }, + { + name: "sub_no_match", + type: "function", + max_manifest_version: 2, + parameters: [], + returns: { type: "integer" }, + }, + ], + }, + { + id: "BaseType", + choices: [ + { + type: "string", + enum: ["base"], + }, + ], + }, + { + id: "type_any", + type: "string", + enum: ["value1", "value2", "value3"], + }, + { + id: "type_mv2", + max_manifest_version: 2, + type: "string", + enum: ["value1", "value2", "value3"], + }, + { + id: "type_mv3", + min_manifest_version: 3, + type: "string", + enum: ["value1", "value2", "value3"], + }, + { + id: "param_type_changed", + type: "array", + items: { + choices: [ + { max_manifest_version: 2, type: "string" }, + { + min_manifest_version: 3, + type: "boolean", + }, + ], + }, + }, + { + id: "object_type_changed", + type: "object", + properties: { + prop_mv2: { + type: "string", + max_manifest_version: 2, + }, + prop_mv3: { + type: "string", + min_manifest_version: 3, + }, + prop_any: { + type: "string", + }, + }, + }, + { + id: "no_valid_choices", + type: "array", + items: { + choices: [ + { max_manifest_version: 1, type: "string" }, + { + min_manifest_version: 4, + type: "boolean", + }, + ], + }, + }, + ], + + functions: [ + { + name: "fun_param_type_versioned", + type: "function", + parameters: [{ name: "arg1", $ref: "param_type_changed" }], + }, + { + name: "fun_mv2", + max_manifest_version: 2, + type: "function", + parameters: [ + { name: "arg1", type: "integer", optional: true, default: 99 }, + { name: "arg2", type: "boolean", optional: true }, + ], + }, + { + name: "fun_mv3", + min_manifest_version: 3, + type: "function", + parameters: [ + { name: "arg1", type: "integer", optional: true, default: 99 }, + { name: "arg2", type: "boolean", optional: true }, + ], + }, + { + name: "fun_param_change", + type: "function", + parameters: [{ name: "arg1", $ref: "object_type_changed" }], + }, + { + name: "fun_no_valid_param", + type: "function", + parameters: [{ name: "arg1", $ref: "no_valid_choices" }], + }, + ], + events: [ + { + name: "onEvent_any", + type: "function", + }, + { + name: "onEvent_mv2", + max_manifest_version: 2, + type: "function", + }, + { + name: "onEvent_mv3", + min_manifest_version: 3, + type: "function", + }, + ], + }, + { + namespace: "mixed", + types: [ + { + $extend: "BaseType", + choices: [ + { + min_manifest_version: 3, + type: "string", + enum: ["extended"], + }, + ], + }, + ], + }, + { + namespace: "mixed", + types: [ + { + $extend: "manifestTest", + properties: { + versioned_extend: { + optional: true, + // just a simple type here + type: "string", + max_manifest_version: 2, + }, + }, + }, + ], + }, +]; + +add_task(async function setup() { + let url = "data:," + JSON.stringify(json); + Schemas._rootSchema = null; + await Schemas.load(url); + + // We want the actual errors thrown here, and not warnings recast as errors. + ExtensionTestUtils.failOnSchemaWarnings(false); +}); + +add_task(async function test_inject_V2() { + // Test injecting into a V2 context. + let wrapper = getContextWrapper(2); + + let root = {}; + Schemas.inject(root, wrapper); + + // Test elements available to both + Assert.equal(root.mixed.type_any.VALUE1, "value1", "type_any exists"); + Assert.equal(root.mixed.PROP_any, 20, "mixed value property"); + + // Test elements available to MV2 + Assert.equal(root.MV2.PROP1, 20, "MV2 value property"); + Assert.equal(root.mixed.type_mv2.VALUE2, "value2", "type_mv2 exists"); + + // Test MV3 elements not available + Assert.equal(root.MV3, undefined, "MV3 not injected"); + Assert.ok(!("MV3" in root), "MV3 not enumerable"); + Assert.equal( + root.mixed.PROP_mv3, + undefined, + "mixed submodule property does not exist" + ); + Assert.ok( + !("PROP_mv3" in root.mixed), + "mixed submodule property not enumerable" + ); + Assert.equal(root.mixed.type_mv3, undefined, "type_mv3 does not exist"); + + // Function tests + Assert.ok( + "fun_param_type_versioned" in root.mixed, + "fun_param_type_versioned exists" + ); + Assert.ok( + !!root.mixed.fun_param_type_versioned, + "fun_param_type_versioned exists" + ); + Assert.ok("fun_mv2" in root.mixed, "fun_mv2 exists"); + Assert.ok(!!root.mixed.fun_mv2, "fun_mv2 exists"); + Assert.ok(!("fun_mv3" in root.mixed), "fun_mv3 does not exist"); + Assert.ok(!root.mixed.fun_mv3, "fun_mv3 does not exist"); + + // Event tests + Assert.ok("onEvent_any" in root.mixed, "onEvent_any exists"); + Assert.ok(!!root.mixed.onEvent_any, "onEvent_any exists"); + Assert.ok("onEvent_mv2" in root.mixed, "onEvent_mv2 exists"); + Assert.ok(!!root.mixed.onEvent_mv2, "onEvent_mv2 exists"); + Assert.ok(!("onEvent_mv3" in root.mixed), "onEvent_mv3 does not exist"); + Assert.ok(!root.mixed.onEvent_mv3, "onEvent_mv3 does not exist"); + + // Function call tests + root.mixed.fun_param_type_versioned(["hello"]); + wrapper.verify("call", "mixed", "fun_param_type_versioned", [["hello"]]); + Assert.throws( + () => root.mixed.fun_param_type_versioned([true]), + /Expected string instead of true/, + "fun_param_type_versioned should throw for invalid type" + ); + + let propObj = { prop_any: "prop_any", prop_mv2: "prop_mv2" }; + root.mixed.fun_param_change(propObj); + wrapper.verify("call", "mixed", "fun_param_change", [propObj]); + + // Still throw same error as we did before we knew of the MV3 property. + Assert.throws( + () => root.mixed.fun_param_change({ prop_mv3: "prop_mv3", ...propObj }), + /Type error for parameter arg1 \(Unexpected property "prop_mv3"\)/, + "generic unexpected property message for MV3 property in MV2 extension" + ); + + // But print the more specific and descriptive warning message to console. + wrapper.checkErrors([ + `Property "prop_mv3" is unsupported in Manifest Version 2`, + ]); + + Assert.throws( + () => root.mixed.fun_no_valid_param("anything"), + /Incorrect argument types for mixed.fun_no_valid_param/, + "fun_no_valid_param should throw for versioned type" + ); +}); + +function normalizeTest(manifest, test, wrapper) { + let normalized = Schemas.normalize(manifest, "mixed.manifestTest", wrapper); + test(normalized); + // The test function should call wrapper.checkErrors if it expected errors. + // Here we call checkErrors again to ensure that there are not any unexpected + // errors left. + wrapper.checkErrors([]); +} + +add_task(async function test_normalize_V2() { + let wrapper = getContextWrapper(2); + + // Test normalize additions to the manifest structure + normalizeTest( + { + versioned_extend: "test", + }, + normalized => { + Assert.equal( + normalized.value.versioned_extend, + "test", + "resources normalized" + ); + }, + wrapper + ); + + // Test normalizing baseType + normalizeTest( + { + permissions: ["base"], + }, + normalized => { + Assert.equal( + normalized.value.permissions[0], + "base", + "resources normalized" + ); + }, + wrapper + ); + + normalizeTest( + { + permissions: ["extended"], + }, + normalized => { + Assert.ok( + normalized.error.startsWith("Error processing permissions.0"), + `manifest normalized error ${normalized.error}` + ); + }, + wrapper + ); + + // Test normalizing a value + normalizeTest( + { + multiple_choice: ["foo.html"], + }, + normalized => { + Assert.equal( + normalized.value.multiple_choice[0], + "foo.html", + "resources normalized" + ); + }, + wrapper + ); + + normalizeTest( + { + multiple_choice: [true], + }, + normalized => { + Assert.ok( + normalized.error.startsWith("Error processing multiple_choice"), + "manifest error" + ); + }, + wrapper + ); + + normalizeTest( + { + multiple_choice: [ + { + value: true, + }, + ], + }, + normalized => { + Assert.ok( + normalized.value.multiple_choice[0].value, + "resources normalized" + ); + }, + wrapper + ); + + // Tests that object definitions including additionalProperties can + // successfully accept objects from another manifest version, while ignoring + // the actual value from the non-matching manifest value. + normalizeTest( + { + accepting_unrecognized_props: { + mv2_only_prop: "mv2 here", + mv3_only_prop: "mv3 here", + }, + }, + normalized => { + equal(normalized.error, undefined, "no normalization error"); + Assert.deepEqual( + normalized.value.accepting_unrecognized_props, + { + mv2_only_prop: "mv2 here", + mv2_only_prop_with_default: "only in MV2", + }, + "Normalized object for MV2, without MV3-specific props" + ); + wrapper.checkErrors([ + `Property "mv3_only_prop" is unsupported in Manifest Version 2`, + ]); + }, + wrapper + ); +}); + +add_task(async function test_inject_V3() { + // Test injecting into a V3 context. + let wrapper = getContextWrapper(3); + + let root = {}; + Schemas.inject(root, wrapper); + + // Test elements available to both + Assert.equal(root.mixed.type_any.VALUE1, "value1", "type_any exists"); + Assert.equal(root.mixed.PROP_any, 20, "mixed value property"); + + // Test elements available to MV2 + Assert.equal(root.MV2, undefined, "MV2 value property"); + Assert.ok(!("MV2" in root), "MV2 not enumerable"); + Assert.equal(root.mixed.type_mv2, undefined, "type_mv2 does not exist"); + Assert.ok(!("type_mv2" in root.mixed), "type_mv2 not enumerable"); + + // Test MV3 elements not available + Assert.equal(root.MV3.PROP1, 20, "MV3 injected"); + Assert.ok(!!root.mixed.PROP_mv3, "mixed submodule property exists"); + Assert.equal(root.mixed.type_mv3.VALUE3, "value3", "type_mv3 exists"); + + // Versioned submodule + Assert.ok(!!root.mixed.PROP_mv3.sub_foo, "mixed submodule sub_foo exists"); + Assert.ok( + !root.mixed.PROP_mv3.sub_no_match, + "mixed submodule sub_no_match does not exist" + ); + Assert.ok( + !("sub_no_match" in root.mixed.PROP_mv3), + "mixed submodule sub_no_match is not enumerable" + ); + + // Function tests + Assert.ok( + "fun_param_type_versioned" in root.mixed, + "fun_param_type_versioned exists" + ); + Assert.ok( + !!root.mixed.fun_param_type_versioned, + "fun_param_type_versioned exists" + ); + Assert.ok(!("fun_mv2" in root.mixed), "fun_mv2 does not exist"); + Assert.ok(!root.mixed.fun_mv2, "fun_mv2 does not exist"); + Assert.ok("fun_mv3" in root.mixed, "fun_mv3 exists"); + Assert.ok(!!root.mixed.fun_mv3, "fun_mv3 exists"); + + // Event tests + Assert.ok("onEvent_any" in root.mixed, "onEvent_any exists"); + Assert.ok(!!root.mixed.onEvent_any, "onEvent_any exists"); + Assert.ok(!("onEvent_mv2" in root.mixed), "onEvent_mv2 not enumerable"); + Assert.ok(!root.mixed.onEvent_mv2, "onEvent_mv2 does not exist"); + Assert.ok("onEvent_mv3" in root.mixed, "onEvent_mv3 exists"); + Assert.ok(!!root.mixed.onEvent_mv3, "onEvent_mv3 exists"); + + // Function call tests + root.mixed.fun_param_type_versioned([true]); + wrapper.verify("call", "mixed", "fun_param_type_versioned", [[true]]); + Assert.throws( + () => root.mixed.fun_param_type_versioned(["hello"]), + /Expected boolean instead of "hello"/, + "should throw for invalid type" + ); + + let propObj = { prop_any: "prop_any", prop_mv3: "prop_mv3" }; + root.mixed.fun_param_change(propObj); + wrapper.verify("call", "mixed", "fun_param_change", [propObj]); + Assert.throws( + () => root.mixed.fun_param_change({ prop_mv2: "prop_mv2", ...propObj }), + /Unexpected property "prop_mv2"/, + "should throw for versioned type" + ); + wrapper.checkErrors([ + `Property "prop_mv2" is unsupported in Manifest Version 3`, + ]); + + root.mixed.PROP_mv3.sub_foo(); + wrapper.verify("call", "mixed.PROP_mv3", "sub_foo", []); + Assert.throws( + () => root.mixed.PROP_mv3.sub_no_match(), + /TypeError: root.mixed.PROP_mv3.sub_no_match is not a function/, + "sub_no_match should throw" + ); +}); + +add_task(async function test_normalize_V3() { + let wrapper = getContextWrapper(3); + + // Test normalize additions to the manifest structure + normalizeTest( + { + versioned_extend: "test", + }, + normalized => { + Assert.equal( + normalized.error, + `Unexpected property "versioned_extend"`, + "expected manifest error" + ); + wrapper.checkErrors([ + `Property "versioned_extend" is unsupported in Manifest Version 3`, + ]); + }, + wrapper + ); + + // Test normalizing baseType + normalizeTest( + { + permissions: ["base"], + }, + normalized => { + Assert.equal( + normalized.value.permissions[0], + "base", + "resources normalized" + ); + }, + wrapper + ); + + normalizeTest( + { + permissions: ["extended"], + }, + normalized => { + Assert.equal( + normalized.value.permissions[0], + "extended", + "resources normalized" + ); + }, + wrapper + ); + + // Test normalizing a value + normalizeTest( + { + multiple_choice: ["foo.html"], + }, + normalized => { + Assert.ok( + normalized.error.startsWith("Error processing multiple_choice"), + "manifest error" + ); + }, + wrapper + ); + + normalizeTest( + { + multiple_choice: [true], + }, + normalized => { + Assert.equal( + normalized.value.multiple_choice[0], + true, + "resources normalized" + ); + }, + wrapper + ); + + normalizeTest( + { + multiple_choice: [ + { + value: true, + }, + ], + }, + normalized => { + Assert.ok( + normalized.value.multiple_choice[0].value, + "resources normalized" + ); + }, + wrapper + ); + + wrapper.tallied = null; + + normalizeTest( + {}, + normalized => { + ok(!normalized.error, "manifest normalized"); + }, + wrapper + ); + + // Tests that object definitions including additionalProperties can + // successfully accept objects from another manifest version, while ignoring + // the actual value from the non-matching manifest value. + normalizeTest( + { + accepting_unrecognized_props: { + mv2_only_prop: "mv2 here", + mv3_only_prop: "mv3 here", + }, + }, + normalized => { + equal(normalized.error, undefined, "no normalization error"); + Assert.deepEqual( + normalized.value.accepting_unrecognized_props, + { + mv3_only_prop: "mv3 here", + mv3_only_prop_with_default: "only in MV3", + }, + "Normalized object for MV3, without MV2-specific props" + ); + wrapper.checkErrors([ + `Property "mv2_only_prop" is unsupported in Manifest Version 3`, + ]); + }, + wrapper + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_script_filenames.js b/toolkit/components/extensions/test/xpcshell/test_ext_script_filenames.js new file mode 100644 index 0000000000..8e2f6c7b0d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_script_filenames.js @@ -0,0 +1,366 @@ +"use strict"; + +// There is a rejection emitted when a JS file fails to load. On Android, +// extensions run on the main process and this rejection causes test failures, +// which is essentially why we need to allow the rejection below. +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Unable to load script.*content_script/ +); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +function computeSHA256Hash(text) { + const hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + hasher.init(Ci.nsICryptoHash.SHA256); + hasher.update( + text.split("").map(c => c.charCodeAt(0)), + text.length + ); + return hasher.finish(true); +} + +// This function represents a dummy content or background script that the test +// cases below should attempt to load but it shouldn't be loaded because we +// check the extensions of JavaScript files in `nsJARChannel`. +function scriptThatShouldNotBeLoaded() { + browser.test.fail("this should not be executed"); +} + +function scriptThatAlwaysRuns() { + browser.test.sendMessage("content-script-loaded"); +} + +// We use these variables in combination with `scriptThatAlwaysRuns()` to send a +// signal to the extension and avoid the page to be closed too soon. +const alwaysRunsFileName = "always_run.js"; +const alwaysRunsContentScript = { + matches: ["<all_urls>"], + js: [alwaysRunsFileName], + run_at: "document_start", +}; + +add_task(async function test_content_script_filename_without_extension() { + // Filenames without any extension should not be loaded. + let invalidFileName = "content_script"; + let extensionData = { + manifest: { + content_scripts: [ + alwaysRunsContentScript, + { + matches: ["<all_urls>"], + js: [invalidFileName], + }, + ], + }, + files: { + [invalidFileName]: scriptThatShouldNotBeLoaded, + [alwaysRunsFileName]: scriptThatAlwaysRuns, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.awaitMessage("content-script-loaded"); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_content_script_filename_with_invalid_extension() { + let validFileName = "content_script.js"; + let invalidFileName = "content_script.xyz"; + let extensionData = { + manifest: { + content_scripts: [ + alwaysRunsContentScript, + { + matches: ["<all_urls>"], + js: [validFileName, invalidFileName], + }, + ], + }, + files: { + // This makes sure that, when one of the content scripts fails to load, + // none of the content scripts are executed. + [validFileName]: scriptThatShouldNotBeLoaded, + [invalidFileName]: scriptThatShouldNotBeLoaded, + [alwaysRunsFileName]: scriptThatAlwaysRuns, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.awaitMessage("content-script-loaded"); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_bg_script_injects_script_with_invalid_ext() { + function backgroundScript() { + browser.test.sendMessage("background-script-loaded"); + } + + let validFileName = "background.js"; + let invalidFileName = "invalid_background.xyz"; + let extensionData = { + background() { + const script = document.createElement("script"); + script.src = "./invalid_background.xyz"; + document.head.appendChild(script); + + const validScript = document.createElement("script"); + validScript.src = "./background.js"; + document.head.appendChild(validScript); + }, + files: { + [invalidFileName]: scriptThatShouldNotBeLoaded, + [validFileName]: backgroundScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitMessage("background-script-loaded"); + + await extension.unload(); +}); + +add_task(async function test_background_scripts() { + function backgroundScript() { + browser.test.sendMessage("background-script-loaded"); + } + + let validFileName = "background.js"; + let invalidFileName = "invalid_background.xyz"; + let extensionData = { + manifest: { + background: { + scripts: [invalidFileName, validFileName], + }, + }, + files: { + [invalidFileName]: scriptThatShouldNotBeLoaded, + [validFileName]: backgroundScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitMessage("background-script-loaded"); + + await extension.unload(); +}); + +add_task(async function test_background_page_injects_scripts_inline() { + function injectedBackgroundScript() { + browser.test.log( + "inline script injectedBackgroundScript has been executed" + ); + browser.test.sendMessage("background-script-loaded"); + } + + let backgroundHtmlPage = "background_page.html"; + let validFileName = "injected_background.js"; + let invalidFileName = "invalid_background.xyz"; + + let inlineScript = `(${function () { + const script = document.createElement("script"); + script.src = "./invalid_background.xyz"; + document.head.appendChild(script); + const validScript = document.createElement("script"); + validScript.src = "./injected_background.js"; + document.head.appendChild(validScript); + }})()`; + + const inlineScriptSHA256 = computeSHA256Hash(inlineScript); + + info( + `Computed sha256 for the inline script injectedBackgroundScript: ${inlineScriptSHA256}` + ); + + let extensionData = { + manifest: { + background: { page: backgroundHtmlPage }, + content_security_policy: [ + "script-src", + "'self'", + `'sha256-${inlineScriptSHA256}'`, + ";", + ].join(" "), + }, + files: { + [invalidFileName]: scriptThatShouldNotBeLoaded, + [validFileName]: injectedBackgroundScript, + "pre-script.js": () => { + window.onsecuritypolicyviolation = evt => { + const { violatedDirective, originalPolicy } = evt; + browser.test.fail( + `Unexpected csp violation: ${JSON.stringify({ + violatedDirective, + originalPolicy, + })}` + ); + // Let the test to fail immediately when an unexpected csp violation + // prevented the inline script from being executed successfully. + browser.test.sendMessage("background-script-loaded"); + }; + }, + [backgroundHtmlPage]: ` + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"></head> + <script src="pre-script.js"></script> + <script>${inlineScript}</script> + </head> + </html>`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitMessage("background-script-loaded"); + + await extension.unload(); +}); + +add_task(async function test_background_page_injects_scripts() { + // This is the initial background script loaded in the HTML page. + function backgroundScript() { + const script = document.createElement("script"); + script.src = "./invalid_background.xyz"; + document.head.appendChild(script); + + const validScript = document.createElement("script"); + validScript.src = "./injected_background.js"; + document.head.appendChild(validScript); + } + + // This is the script injected by the script defined in `backgroundScript()`. + function injectedBackgroundScript() { + browser.test.sendMessage("background-script-loaded"); + } + + let backgroundHtmlPage = "background_page.html"; + let validFileName = "injected_background.js"; + let invalidFileName = "invalid_background.xyz"; + let extensionData = { + manifest: { + background: { page: backgroundHtmlPage }, + }, + files: { + [invalidFileName]: scriptThatShouldNotBeLoaded, + [validFileName]: injectedBackgroundScript, + [backgroundHtmlPage]: ` + <html> + <head> + <meta charset="utf-8"></head> + <script src="./background.js"></script> + </head> + </html>`, + "background.js": backgroundScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitMessage("background-script-loaded"); + + await extension.unload(); +}); + +add_task(async function test_background_script_registers_content_script() { + let invalidFileName = "content_script"; + let extensionData = { + manifest: { + permissions: ["<all_urls>"], + }, + async background() { + await browser.contentScripts.register({ + js: [{ file: "/content_script" }], + matches: ["<all_urls>"], + }); + browser.test.sendMessage("background-script-loaded"); + }, + files: { + [invalidFileName]: scriptThatShouldNotBeLoaded, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.awaitMessage("background-script-loaded"); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_web_accessible_resources() { + function contentScript() { + const script = document.createElement("script"); + script.src = browser.runtime.getURL("content_script.css"); + script.onerror = () => { + browser.test.sendMessage("second-content-script-loaded"); + }; + + document.head.appendChild(script); + } + + let contentScriptFileName = "content_script.js"; + let invalidFileName = "content_script.css"; + let extensionData = { + manifest: { + web_accessible_resources: [invalidFileName], + content_scripts: [ + alwaysRunsContentScript, + { + matches: ["<all_urls>"], + js: [contentScriptFileName], + }, + ], + }, + files: { + [invalidFileName]: scriptThatShouldNotBeLoaded, + [contentScriptFileName]: contentScript, + [alwaysRunsFileName]: scriptThatAlwaysRuns, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.awaitMessage("content-script-loaded"); + await extension.awaitMessage("second-content-script-loaded"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts.js b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts.js new file mode 100644 index 0000000000..a06f34a1b4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts.js @@ -0,0 +1,540 @@ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, to use +// the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const makeExtension = ({ manifest: manifestProps, ...otherProps }) => { + return ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + permissions: ["scripting"], + host_permissions: ["http://localhost/*"], + granted_host_permissions: true, + ...manifestProps, + }, + temporarilyInstalled: true, + ...otherProps, + }); +}; + +add_task(async function test_registerContentScripts_runAt() { + let extension = makeExtension({ + async background() { + const TEST_CASES = [ + { + title: "runAt: document_idle", + params: [ + { + id: "script-idle", + js: ["script-idle.js"], + matches: ["http://*/*/file_sample.html"], + runAt: "document_idle", + persistAcrossSessions: false, + }, + ], + }, + { + title: "no runAt specified", + params: [ + { + id: "script-idle-default", + js: ["script-idle-default.js"], + matches: ["http://*/*/file_sample.html"], + // `runAt` defaults to `document_idle`. + persistAcrossSessions: false, + }, + ], + }, + { + title: "runAt: document_end", + params: [ + { + id: "script-end", + js: ["script-end.js"], + matches: ["http://*/*/file_sample.html"], + runAt: "document_end", + persistAcrossSessions: false, + }, + ], + }, + { + title: "runAt: document_start", + params: [ + { + id: "script-start", + js: ["script-start.js"], + matches: ["http://*/*/file_sample.html"], + runAt: "document_start", + persistAcrossSessions: false, + }, + ], + }, + ]; + + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(0, scripts.length, "expected no registered script"); + + for (const { title, params } of TEST_CASES) { + const res = await browser.scripting.registerContentScripts(params); + browser.test.assertEq(undefined, res, `${title} - expected no result`); + } + + scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq( + TEST_CASES.length, + scripts.length, + `expected ${TEST_CASES.length} registered scripts` + ); + browser.test.assertEq( + JSON.stringify([ + { + id: "script-idle", + allFrames: false, + matches: ["http://*/*/file_sample.html"], + runAt: "document_idle", + persistAcrossSessions: false, + js: ["script-idle.js"], + }, + { + id: "script-idle-default", + allFrames: false, + matches: ["http://*/*/file_sample.html"], + runAt: "document_idle", + persistAcrossSessions: false, + js: ["script-idle-default.js"], + }, + { + id: "script-end", + allFrames: false, + matches: ["http://*/*/file_sample.html"], + runAt: "document_end", + persistAcrossSessions: false, + js: ["script-end.js"], + }, + { + id: "script-start", + allFrames: false, + matches: ["http://*/*/file_sample.html"], + runAt: "document_start", + persistAcrossSessions: false, + js: ["script-start.js"], + }, + ]), + JSON.stringify(scripts), + "got expected scripts" + ); + + browser.test.sendMessage("background-ready"); + }, + files: { + "script-start.js": () => { + browser.test.assertEq( + "loading", + document.readyState, + "expected state 'loading' at document_start" + ); + browser.test.sendMessage("script-ran", "script-start.js"); + }, + "script-end.js": () => { + browser.test.assertTrue( + ["interactive", "complete"].includes(document.readyState), + `expected state 'interactive' or 'complete' at document_end, got: ${document.readyState}` + ); + browser.test.sendMessage("script-ran", "script-end.js"); + }, + "script-idle.js": () => { + browser.test.assertEq( + "complete", + document.readyState, + "expected state 'complete' at document_idle" + ); + browser.test.sendMessage("script-ran", "script-idle.js"); + }, + "script-idle-default.js": () => { + browser.test.assertEq( + "complete", + document.readyState, + "expected state 'complete' at document_idle" + ); + browser.test.sendMessage("script-ran", "script-idle-default.js"); + }, + }, + }); + + let scriptsRan = []; + let completePromise = new Promise(resolve => { + extension.onMessage("script-ran", result => { + scriptsRan.push(result); + + // The value below should be updated when TEST_CASES above is changed. + if (scriptsRan.length === 4) { + resolve(); + } + }); + }); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await completePromise; + + Assert.deepEqual( + [ + "script-start.js", + "script-end.js", + "script-idle.js", + "script-idle-default.js", + ], + scriptsRan, + "got expected executed scripts" + ); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_register_and_unregister() { + let extension = makeExtension({ + async background() { + const script = { + id: "a-script", + js: ["script.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: false, + }; + + let results = await Promise.allSettled([ + browser.scripting.registerContentScripts([script]), + browser.scripting.unregisterContentScripts(), + ]); + + browser.test.assertEq( + 2, + results.filter(result => result.status === "fulfilled").length, + "got expected number of fulfilled promises" + ); + + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(0, scripts.length, "expected no registered script"); + + browser.test.sendMessage("background-done"); + }, + files: { + "script.js": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-done"); + + // Verify that the registered content scripts on the extension are correct. + let contentScripts = Array.from( + extension.extension.registeredContentScripts.values() + ); + Assert.equal(0, contentScripts.length, "expected no registered scripts"); + + await extension.unload(); +}); + +add_task(async function test_register_and_unregister_multiple_times() { + let extension = makeExtension({ + async background() { + // We use the same script `id` on purpose in this test. + let results = await Promise.allSettled([ + browser.scripting.registerContentScripts([ + { + id: "a-script", + js: ["script-1.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: false, + }, + ]), + browser.scripting.unregisterContentScripts(), + browser.scripting.registerContentScripts([ + { + id: "a-script", + js: ["script-2.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: false, + }, + ]), + browser.scripting.unregisterContentScripts(), + browser.scripting.registerContentScripts([ + { + id: "a-script", + js: ["script-3.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: false, + }, + ]), + ]); + + browser.test.assertEq( + 5, + results.filter(result => result.status === "fulfilled").length, + "got expected number of fulfilled promises" + ); + + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(1, scripts.length, "expected 1 registered script"); + + browser.test.sendMessage("background-done"); + }, + files: { + "script-1.js": "", + "script-2.js": "", + "script-3.js": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-done"); + + // Verify that the registered content scripts on the extension are correct. + let contentScripts = Array.from( + extension.extension.registeredContentScripts.values() + ); + Assert.equal(1, contentScripts.length, "expected 1 registered script"); + Assert.ok( + contentScripts[0].jsPaths[0].endsWith("script-3.js"), + "got expected js file" + ); + + await extension.unload(); +}); + +add_task(async function test_register_update_and_unregister() { + let extension = makeExtension({ + async background() { + const script = { + id: "a-script", + js: ["script-1.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: false, + }; + const updatedScript1 = { ...script, js: ["script-2.js"] }; + const updatedScript2 = { ...script, js: ["script-3.js"] }; + + let results = await Promise.allSettled([ + browser.scripting.registerContentScripts([script]), + browser.scripting.updateContentScripts([updatedScript1]), + browser.scripting.updateContentScripts([updatedScript2]), + browser.scripting.getRegisteredContentScripts(), + browser.scripting.unregisterContentScripts(), + browser.scripting.updateContentScripts([script]), + ]); + + browser.test.assertEq(6, results.length, "expected 6 results"); + browser.test.assertEq( + "fulfilled", + results[0].status, + "expected fulfilled promise (registeredContentScripts)" + ); + browser.test.assertEq( + "fulfilled", + results[1].status, + "expected fulfilled promise (updateContentScripts)" + ); + browser.test.assertEq( + "fulfilled", + results[2].status, + "expected fulfilled promise (updateContentScripts)" + ); + browser.test.assertEq( + "fulfilled", + results[3].status, + "expected fulfilled promise (getRegisteredContentScripts)" + ); + browser.test.assertEq( + JSON.stringify([ + { + id: "a-script", + allFrames: false, + matches: ["http://*/*/file_sample.html"], + runAt: "document_idle", + persistAcrossSessions: false, + js: ["script-3.js"], + }, + ]), + JSON.stringify(results[3].value), + "expected updated content script" + ); + browser.test.assertEq( + "fulfilled", + results[4].status, + "expected fulfilled promise (unregisterContentScripts)" + ); + browser.test.assertEq( + "rejected", + results[5].status, + "expected rejected promise because script should have been unregistered" + ); + browser.test.assertEq( + `Content script with id "${script.id}" does not exist.`, + results[5].reason.message, + "expected error message about script not found" + ); + + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(0, scripts.length, "expected no registered script"); + + browser.test.sendMessage("background-done"); + }, + files: { + "script-1.js": "", + "script-2.js": "", + "script-3.js": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-done"); + + // Verify that the registered content scripts on the extension are correct. + let contentScripts = Array.from( + extension.extension.registeredContentScripts.values() + ); + Assert.equal(0, contentScripts.length, "expected no registered scripts"); + + await extension.unload(); +}); + +// The following test case is a regression test for Bug 1851173. +add_task( + { + // contentScripts API not exposed to background service workers and + // Bug 1851173 can't be hit. + skip_if: () => ExtensionTestUtils.isInBackgroundServiceWorkerTests(), + }, + async function test_contentScriptsAPI_vs_scriptingGetRegistered() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 2, + permissions: ["scripting", "<all_urls>"], + }, + async background() { + await browser.contentScripts.register({ + runAt: "document_start", + js: [{ file: "testcs.js" }], + matches: ["<all_urls>"], + }); + + const scriptingScripts = + await browser.scripting.getRegisteredContentScripts(); + browser.test.assertDeepEq( + [], + scriptingScripts, + "Expect an empty array" + ); + browser.test.sendMessage("background-done"); + }, + files: { + "testcs.js": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-done"); + await extension.unload(); + } +); + +async function test_matchAboutBlank_registerContentScripts_default({ + extId, + expectedMatchAboutBlankValue, +}) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 2, + permissions: ["scripting", "<all_urls>"], + browser_specific_settings: { + gecko: { id: extId }, + }, + }, + async background() { + await browser.scripting.registerContentScripts([ + { + id: "test-content-script", + matches: ["http://example.com/*"], + js: ["cs.js"], + }, + ]); + browser.test.sendMessage("background-done"); + }, + file: { + "cs.js": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-done"); + + equal( + extension.extension.registeredContentScripts.size, + 1, + "Got the expected number of registered content scripts" + ); + const [{ id, matchAboutBlank }] = Array.from( + extension.extension.registeredContentScripts.values() + ); + + equal(id, "test-content-script", "Got the expected content script id"); + equal( + matchAboutBlank, + expectedMatchAboutBlankValue, + "Expect matchAboutBlank to be false" + ); + + await extension.unload(); +} + +// The following test cases are regressions test for Bug 1853412, and it is +// currently making sure that content scripts registered dynamically +// by the webcompat built-in have matchAboutBlank set to false. +add_task(async function test_webcompat_matchAboutBlank_default() { + await test_matchAboutBlank_registerContentScripts_default({ + // This test extension has to have the same id that we expect + // the webcompat built-in to have. + extId: "webcompat@mozilla.org", + expectedMatchAboutBlankValue: false, + }); +}); + +// The following test task asserts that for any extension matchAboutBlank +// is still set by default to true +// TODO(Bug 1853411): should change this to default to false for all extensions +// and this test should be adjusted accordingly. +add_task(async function test_matchAboutBlank_default() { + await test_matchAboutBlank_registerContentScripts_default({ + extId: "some-unknown-extension-id@test.extension", + expectedMatchAboutBlankValue: true, + }); +}); + +// The following test task asserts that when the hidden pref +// "extensions.scripting.matchAboutBlankDefaultFalse" is set +// to true, then matchAboutBlank gets forcefully set to false +// TODO(Bug 1853411): may remove the hidden pref and this test along with it. +add_task( + { + pref_set: [["extensions.scripting.matchAboutBlankDefaultFalse", true]], + }, + async function test_matchAboutBlank_defaultChange_by_hiddenPref() { + await test_matchAboutBlank_registerContentScripts_default({ + extId: "some-unknown-extension-id@test.extension", + expectedMatchAboutBlankValue: false, + }); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts_css.js b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts_css.js new file mode 100644 index 0000000000..21190d2d59 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts_css.js @@ -0,0 +1,331 @@ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, to use +// the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const makeExtension = ({ manifest: manifestProps, ...otherProps }) => { + return ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + permissions: ["scripting"], + host_permissions: ["http://localhost/*"], + granted_host_permissions: true, + ...manifestProps, + }, + allowInsecureRequests: true, + temporarilyInstalled: true, + ...otherProps, + }); +}; + +add_task(async function test_registerContentScripts_css() { + let extension = makeExtension({ + async background() { + // This script is injected in all frames after the styles so that we can + // verify the registered styles. + const checkAppliedStyleScript = { + id: "check-applied-styles", + allFrames: true, + matches: ["http://*/*/*.html"], + runAt: "document_idle", + persistAcrossSessions: false, + js: ["check-applied-styles.js"], + }; + + // Listen to the `load-test-case` message and unregister/register new + // content scripts. + browser.test.onMessage.addListener(async (msg, data) => { + switch (msg) { + case "load-test-case": + const { title, params, skipCheckScriptRegistration } = data; + const expectedScripts = []; + + await browser.scripting.unregisterContentScripts(); + + if (!skipCheckScriptRegistration) { + await browser.scripting.registerContentScripts([ + checkAppliedStyleScript, + ]); + + expectedScripts.push(checkAppliedStyleScript); + } + + expectedScripts.push(...params); + + const res = await browser.scripting.registerContentScripts(params); + browser.test.assertEq( + res, + undefined, + `${title} - expected no result` + ); + const scripts = + await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq( + expectedScripts.length, + scripts.length, + `${title} - expected ${expectedScripts.length} registered scripts` + ); + browser.test.assertEq( + JSON.stringify(expectedScripts), + JSON.stringify(scripts), + `${title} - got expected registered scripts` + ); + + browser.test.sendMessage(`${msg}-done`); + break; + default: + browser.test.fail(`received unexpected message: ${msg}`); + } + }); + + browser.test.sendMessage("background-ready"); + }, + files: { + "check-applied-styles.js": () => { + browser.test.sendMessage( + `background-color-${location.pathname.split("/").pop()}`, + getComputedStyle(document.querySelector("#test")).backgroundColor + ); + }, + "style-1.css": "#test { background-color: rgb(255, 0, 0); }", + "style-2.css": "#test { background-color: rgb(0, 0, 255); }", + "style-3.css": "html { background-color: rgb(0, 255, 0); }", + "script-document-start.js": async () => { + const testElement = document.querySelector("html"); + + browser.test.assertEq( + "rgb(0, 255, 0)", + getComputedStyle(testElement).backgroundColor, + "got expected style in script-document-start.js" + ); + + testElement.style.backgroundColor = "rgb(4, 4, 4)"; + }, + "check-applied-styles-document-start.js": () => { + browser.test.sendMessage( + `background-color-${location.pathname.split("/").pop()}`, + getComputedStyle(document.querySelector("html")).backgroundColor + ); + }, + "script-document-end-and-idle.js": () => { + const testElement = document.querySelector("#test"); + + browser.test.assertEq( + "rgb(255, 0, 0)", + getComputedStyle(testElement).backgroundColor, + "got expected style in script-document-end-and-idle.js" + ); + + testElement.style.backgroundColor = "rgb(5, 5, 5)"; + }, + }, + }); + + const TEST_CASES = [ + { + title: "a css file", + params: [ + { + id: "style-1", + allFrames: false, + matches: ["http://*/*/*.html"], + // TODO: Bug 1759117 - runAt should not affect css injection + runAt: "document_start", + persistAcrossSessions: false, + css: ["style-1.css"], + }, + ], + expected: ["rgb(255, 0, 0)", "rgba(0, 0, 0, 0)"], + }, + { + title: "css and allFrames: true", + params: [ + { + id: "style-1", + allFrames: true, + matches: ["http://*/*/*.html"], + // TODO: Bug 1759117 - runAt should not affect css injection + runAt: "document_start", + persistAcrossSessions: false, + css: ["style-1.css"], + }, + ], + expected: ["rgb(255, 0, 0)", "rgb(255, 0, 0)"], + }, + { + title: "css and allFrames: true but matches restricted to top frame", + params: [ + { + id: "style-1", + allFrames: true, + matches: ["http://*/*/file_with_iframe.html"], + // TODO: Bug 1759117 - runAt should not affect css injection + runAt: "document_start", + persistAcrossSessions: false, + css: ["style-1.css"], + }, + ], + expected: ["rgb(255, 0, 0)", "rgba(0, 0, 0, 0)"], + }, + { + title: "css and excludeMatches set", + params: [ + { + id: "style-1", + allFrames: true, + matches: ["http://*/*/*.html"], + // TODO: Bug 1759117 - runAt should not affect css injection + runAt: "document_start", + persistAcrossSessions: false, + css: ["style-1.css"], + excludeMatches: ["http://*/*/file_with_iframe.html"], + }, + ], + expected: ["rgba(0, 0, 0, 0)", "rgb(255, 0, 0)"], + }, + { + title: "two css files", + params: [ + { + id: "style-1-and-2", + allFrames: false, + matches: ["http://*/*/*.html"], + // TODO: Bug 1759117 - runAt should not affect css injection + runAt: "document_start", + persistAcrossSessions: false, + css: ["style-1.css", "style-2.css"], + }, + ], + expected: ["rgb(0, 0, 255)", "rgba(0, 0, 0, 0)"], + }, + { + title: "two scripts with css", + params: [ + { + id: "style-1", + allFrames: false, + matches: ["http://*/*/*.html"], + runAt: "document_end", + persistAcrossSessions: false, + css: ["style-1.css"], + }, + { + id: "style-2", + allFrames: false, + matches: ["http://*/*/*.html"], + runAt: "document_start", + persistAcrossSessions: false, + css: ["style-2.css"], + }, + ], + // TODO: Bug 1759117 - The expected value should be `rgb(0, 0, 255)` + // because runAt should not affect css injection and therefore the two + // styles should be applied one after the other. + expected: ["rgb(255, 0, 0)", "rgba(0, 0, 0, 0)"], + }, + { + title: "js and css with runAt: document_start", + params: [ + { + id: "js-and-style-start", + allFrames: true, + matches: ["http://*/*/*.html"], + runAt: "document_start", + persistAcrossSessions: false, + css: ["style-3.css"], + // Inject the check script last to be able to send a message back to + // the test case. This works with `skipCheckScriptRegistration: true` + // below. + js: [ + "script-document-start.js", + "check-applied-styles-document-start.js", + ], + }, + ], + expected: ["rgb(4, 4, 4)", "rgb(4, 4, 4)"], + skipCheckScriptRegistration: true, + }, + { + title: "js and css with runAt: document_end", + params: [ + { + id: "js-and-style-end", + allFrames: true, + matches: ["http://*/*/*.html"], + runAt: "document_end", + persistAcrossSessions: false, + css: ["style-1.css"], + // Inject the check script last to be able to send a message back to + // the test case. This works with `skipCheckScriptRegistration: true` + // below. + js: ["script-document-end-and-idle.js", "check-applied-styles.js"], + }, + ], + expected: ["rgb(5, 5, 5)", "rgb(5, 5, 5)"], + skipCheckScriptRegistration: true, + }, + { + title: "js and css with runAt: document_idle", + params: [ + { + id: "js-and-style-idle", + allFrames: true, + matches: ["http://*/*/*.html"], + runAt: "document_idle", + persistAcrossSessions: false, + css: ["style-1.css"], + // Inject the check script last to be able to send a message back to + // the test case. This works with `skipCheckScriptRegistration: true` + // below. + js: ["script-document-end-and-idle.js", "check-applied-styles.js"], + }, + ], + expected: ["rgb(5, 5, 5)", "rgb(5, 5, 5)"], + skipCheckScriptRegistration: true, + }, + ]; + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + for (const { + title, + params, + expected, + skipCheckScriptRegistration, + } of TEST_CASES) { + extension.sendMessage("load-test-case", { + title, + params, + skipCheckScriptRegistration, + }); + await extension.awaitMessage("load-test-case-done"); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_with_iframe.html` + ); + + const backgroundColors = [ + await extension.awaitMessage("background-color-file_with_iframe.html"), + await extension.awaitMessage("background-color-file_sample.html"), + ]; + + Assert.deepEqual( + expected, + backgroundColors, + `${title} - got expected colors` + ); + + await contentPage.close(); + } + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts_file.js b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts_file.js new file mode 100644 index 0000000000..3c806439ce --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts_file.js @@ -0,0 +1,77 @@ +"use strict"; + +const FILE_DUMMY_URL = Services.io.newFileURI( + do_get_file("data/dummy_page.html") +).spec; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, to use +// the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const makeExtension = ({ manifest: manifestProps, ...otherProps }) => { + return ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + permissions: ["scripting"], + host_permissions: ["<all_urls>"], + granted_host_permissions: true, + ...manifestProps, + }, + temporarilyInstalled: true, + ...otherProps, + }); +}; + +add_task(async function test_registered_content_script_with_files() { + let extension = makeExtension({ + async background() { + const MATCHES = [ + { id: "script-1", matches: ["<all_urls>"] }, + { id: "script-2", matches: ["file:///*"] }, + { id: "script-3", matches: ["file://*/*dummy_page.html"] }, + { id: "fail-if-executed", matches: ["*://*/*"] }, + ]; + + await browser.scripting.registerContentScripts( + MATCHES.map(({ id, matches }) => ({ + id, + js: [`${id}.js`], + matches, + persistAcrossSessions: false, + })) + ); + + browser.test.sendMessage("background-ready"); + }, + files: { + "script-1.js": () => { + browser.test.sendMessage("script-1-ran"); + }, + "script-2.js": () => { + browser.test.sendMessage("script-2-ran"); + }, + "script-3.js": () => { + browser.test.sendMessage("script-3-ran"); + }, + "fail-if-executed.js": () => { + browser.test.fail("this script should not be executed"); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + let contentPage = await ExtensionTestUtils.loadContentPage(FILE_DUMMY_URL); + + await Promise.all([ + extension.awaitMessage("script-1-ran"), + extension.awaitMessage("script-2-ran"), + extension.awaitMessage("script-3-ran"), + ]); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_scripting_mv2.js b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_mv2.js new file mode 100644 index 0000000000..53fb77c4da --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_mv2.js @@ -0,0 +1,23 @@ +"use strict"; + +add_task(async function test_scripting_enabled_in_mv2() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 2, + permissions: ["scripting"], + }, + background() { + browser.test.assertEq( + "object", + typeof browser.scripting, + "expected scripting namespace to be defined" + ); + + browser.test.sendMessage("background-done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-done"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_scripting_persistAcrossSessions.js b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_persistAcrossSessions.js new file mode 100644 index 0000000000..cae09b5d2e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_persistAcrossSessions.js @@ -0,0 +1,760 @@ +"use strict"; + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +const { ExtensionScriptingStore } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionScriptingStore.sys.mjs" +); +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +const makeExtension = ({ manifest: manifestProps, ...otherProps }) => { + return ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 2, + permissions: ["scripting"], + ...manifestProps, + }, + useAddonManager: "permanent", + ...otherProps, + }); +}; + +const assertNumScriptsInStore = async (extension, expectedNum) => { + // `registerContentScripts`/`updateContentScripts()`/`unregisterContentScripts` + // call `ExtensionScriptingStore.persistAll()` without awaiting it, which + // isn't a problem in practice but this becomes a problem in this test given + // that we should make sure the startup cache is updated before checking it. + await TestUtils.waitForCondition(async () => { + let scripts = + await ExtensionScriptingStore._getStoreForTesting().getByExtensionId( + extension.id + ); + return scripts.length === expectedNum; + }, "wait until the store is updated with the expected number of scripts"); + + let scripts = + await ExtensionScriptingStore._getStoreForTesting().getByExtensionId( + extension.id + ); + Assert.equal( + scripts.length, + expectedNum, + `expected ${expectedNum} script in store` + ); +}; + +const verifyRegisterContentScripts = async manifestVersion => { + await AddonTestUtils.promiseStartupManager(); + + let extension = makeExtension({ + manifest: { + manifest_version: manifestVersion, + }, + async background() { + let scripts = await browser.scripting.getRegisteredContentScripts(); + + // Only register the content script if it wasn't registered before. Since + // there is only one script, we don't check its ID. + if (!scripts.length) { + const script = { + id: "script", + js: ["script.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: true, + }; + + await browser.scripting.registerContentScripts([script]); + browser.test.sendMessage("script-registered"); + return; + } + + browser.test.assertEq(1, scripts.length, "expected 1 registered script"); + browser.test.sendMessage("script-already-registered"); + }, + files: { + "script.js": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("script-registered"); + await assertNumScriptsInStore(extension, 1); + + await AddonTestUtils.promiseRestartManager(); + await assertNumScriptsInStore(extension, 1); + + await extension.awaitStartup(); + await extension.awaitMessage("script-already-registered"); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + await assertNumScriptsInStore(extension, 0); +}; + +add_task(async function test_registerContentScripts_mv2() { + await verifyRegisterContentScripts(2); +}); + +add_task(async function test_registerContentScripts_mv3() { + await verifyRegisterContentScripts(3); +}); + +const verifyUpdateContentScripts = async manifestVersion => { + await AddonTestUtils.promiseStartupManager(); + + let extension = makeExtension({ + manifest: { + manifest_version: manifestVersion, + }, + async background() { + let scripts = await browser.scripting.getRegisteredContentScripts(); + + // Only register the content script if it wasn't registered before. Since + // there is only one script, we don't check its ID. + if (!scripts.length) { + const script = { + id: "script", + js: ["script.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: true, + }; + + await browser.scripting.registerContentScripts([script]); + browser.test.sendMessage("script-registered"); + return; + } + + browser.test.assertEq(1, scripts.length, "expected 1 registered script"); + await browser.scripting.updateContentScripts([ + { id: scripts[0].id, persistAcrossSessions: false }, + ]); + browser.test.sendMessage("script-updated"); + }, + files: { + "script.js": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("script-registered"); + await assertNumScriptsInStore(extension, 1); + + // Simulate a new session. + await AddonTestUtils.promiseRestartManager(); + await assertNumScriptsInStore(extension, 1); + + await extension.awaitStartup(); + await extension.awaitMessage("script-updated"); + await assertNumScriptsInStore(extension, 0); + + // Simulate another new session. + await AddonTestUtils.promiseRestartManager(); + + await extension.awaitStartup(); + await extension.awaitMessage("script-registered"); + await assertNumScriptsInStore(extension, 1); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + await assertNumScriptsInStore(extension, 0); +}; + +add_task(async function test_updateContentScripts() { + await verifyUpdateContentScripts(2); +}); + +add_task(async function test_updateContentScripts_mv3() { + await verifyUpdateContentScripts(3); +}); + +const verifyUnregisterContentScripts = async manifestVersion => { + await AddonTestUtils.promiseStartupManager(); + + let extension = makeExtension({ + manifest: { + manifest_version: manifestVersion, + }, + async background() { + let scripts = await browser.scripting.getRegisteredContentScripts(); + + // Only register the content script if it wasn't registered before. Since + // there is only one script, we don't check its ID. + if (!scripts.length) { + const script = { + id: "script", + js: ["script.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: true, + }; + + await browser.scripting.registerContentScripts([script]); + browser.test.sendMessage("script-registered"); + return; + } + + browser.test.assertEq(1, scripts.length, "expected 1 registered script"); + await browser.scripting.unregisterContentScripts(); + browser.test.sendMessage("script-unregistered"); + }, + files: { + "script.js": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("script-registered"); + await assertNumScriptsInStore(extension, 1); + + // Simulate a new session. + await AddonTestUtils.promiseRestartManager(); + + // Script should be still persisted... + await assertNumScriptsInStore(extension, 1); + await extension.awaitStartup(); + // ...and we should now enter the second branch of the background script. + await extension.awaitMessage("script-unregistered"); + await assertNumScriptsInStore(extension, 0); + + // Simulate another new session. + await AddonTestUtils.promiseRestartManager(); + + await extension.awaitStartup(); + await extension.awaitMessage("script-registered"); + await assertNumScriptsInStore(extension, 1); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + await assertNumScriptsInStore(extension, 0); +}; + +add_task(async function test_unregisterContentScripts() { + await verifyUnregisterContentScripts(2); +}); + +add_task(async function test_unregisterContentScripts_mv3() { + await verifyUnregisterContentScripts(3); +}); + +add_task(async function test_reload_extension() { + await AddonTestUtils.promiseStartupManager(); + + let extension = makeExtension({ + async background() { + browser.test.onMessage.addListener(msg => { + browser.test.assertEq("reload-extension", msg, `expected msg: ${msg}`); + browser.runtime.reload(); + }); + + let scripts = await browser.scripting.getRegisteredContentScripts(); + + // Only register the content script if it wasn't registered before. Since + // there is only one script, we don't check its ID. + if (!scripts.length) { + const script = { + id: "script", + js: ["script.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: true, + }; + + await browser.scripting.registerContentScripts([script]); + browser.test.sendMessage("script-registered"); + return; + } + + browser.test.assertEq(1, scripts.length, "expected 1 registered script"); + browser.test.sendMessage("script-already-registered"); + }, + files: { + "script.js": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("script-registered"); + await assertNumScriptsInStore(extension, 1); + + extension.sendMessage("reload-extension"); + // Wait for extension to restart, to make sure reloads works. + await AddonTestUtils.promiseWebExtensionStartup(extension.id); + await extension.awaitMessage("script-already-registered"); + await assertNumScriptsInStore(extension, 1); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + await assertNumScriptsInStore(extension, 0); +}); + +add_task(async function test_disable_and_reenable_extension() { + await AddonTestUtils.promiseStartupManager(); + + let extension = makeExtension({ + async background() { + let scripts = await browser.scripting.getRegisteredContentScripts(); + + // Only register the content script if it wasn't registered before. Since + // there is only one script, we don't check its ID. + if (!scripts.length) { + const script = { + id: "script", + js: ["script.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: true, + }; + + await browser.scripting.registerContentScripts([script]); + browser.test.sendMessage("script-registered"); + return; + } + + browser.test.assertEq(1, scripts.length, "expected 1 registered script"); + browser.test.sendMessage("script-already-registered"); + }, + files: { + "script.js": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("script-registered"); + await assertNumScriptsInStore(extension, 1); + + // Disable... + await extension.addon.disable(); + // then re-enable the extension. + await extension.addon.enable(); + + await extension.awaitMessage("script-already-registered"); + await assertNumScriptsInStore(extension, 1); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + await assertNumScriptsInStore(extension, 0); +}); + +add_task(async function test_updateContentScripts_persistAcrossSessions_true() { + await AddonTestUtils.promiseStartupManager(); + + let extension = makeExtension({ + async background() { + const script = { + id: "script", + js: ["script-1.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: false, + }; + + const scripts = await browser.scripting.getRegisteredContentScripts(); + + browser.test.onMessage.addListener(async msg => { + switch (msg) { + case "persist-script": + await browser.scripting.updateContentScripts([ + { id: script.id, persistAcrossSessions: true }, + ]); + browser.test.sendMessage(`${msg}-done`); + break; + + case "add-new-js": + await browser.scripting.updateContentScripts([ + { id: script.id, js: ["script-1.js", "script-2.js"] }, + ]); + browser.test.sendMessage(`${msg}-done`); + break; + + case "verify-script": + // We expect a single registered script, which is the one declared + // above but at this point we should have 2 JS files and the + // `persistAcrossSessions` option set to `true`. + browser.test.assertEq( + JSON.stringify([ + { + id: script.id, + allFrames: false, + matches: script.matches, + runAt: "document_idle", + persistAcrossSessions: true, + js: ["script-1.js", "script-2.js"], + }, + ]), + JSON.stringify(scripts), + "expected scripts" + ); + browser.test.sendMessage(`${msg}-done`); + break; + + default: + browser.test.fail(`unexpected message: ${msg}`); + } + }); + + // Only register the content script if it wasn't registered before. Since + // there is only one script, we don't check its ID. + if (!scripts.length) { + await browser.scripting.registerContentScripts([script]); + browser.test.sendMessage("script-registered"); + } else { + browser.test.sendMessage("script-already-registered"); + } + }, + files: { + "script-1.js": "", + "script-2.js": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("script-registered"); + await assertNumScriptsInStore(extension, 0); + + // Simulate a new session. + await AddonTestUtils.promiseRestartManager(); + await assertNumScriptsInStore(extension, 0); + + // We expect the script to be registered again because it isn't persisted. + await extension.awaitStartup(); + await extension.awaitMessage("script-registered"); + await assertNumScriptsInStore(extension, 0); + + // We now tell the background script to update the script to persist it + // across sessions. + extension.sendMessage("persist-script"); + await extension.awaitMessage("persist-script-done"); + + // Simulate another new session. We expect the content script to be already + // registered since it was persisted in the previous (simulated) session. + await AddonTestUtils.promiseRestartManager(); + await assertNumScriptsInStore(extension, 1); + + await extension.awaitStartup(); + await extension.awaitMessage("script-already-registered"); + await assertNumScriptsInStore(extension, 1); + + // We tell the background script to update the content script with a new JS + // file and we don't change the `persistAcrossSessions` option. + extension.sendMessage("add-new-js"); + await extension.awaitMessage("add-new-js-done"); + + // Simulate another new session. We expect the content script to have 2 JS + // files and to be registered since it was persisted in the previous + // (simulated) session and we didn't update the option. + await AddonTestUtils.promiseRestartManager(); + await assertNumScriptsInStore(extension, 1); + + await extension.awaitStartup(); + await extension.awaitMessage("script-already-registered"); + await assertNumScriptsInStore(extension, 1); + + // Let's verify that the script fetched by the background script is the one + // we expect at this point: it should have two JS files. + extension.sendMessage("verify-script"); + await extension.awaitMessage("verify-script-done"); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + await assertNumScriptsInStore(extension, 0); +}); + +add_task(async function test_multiple_extensions_and_scripts() { + await AddonTestUtils.promiseStartupManager(); + + let extension1 = makeExtension({ + async background() { + let scripts = await browser.scripting.getRegisteredContentScripts(); + + if (!scripts.length) { + await browser.scripting.registerContentScripts([ + { + id: "0", + js: ["script-1.js"], + matches: ["http://*/*/file_sample.html"], + // We should persist this script by default. + }, + { + id: "/", + js: ["script-2.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: true, + }, + { + id: "3", + js: ["script-3.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: false, + }, + ]); + browser.test.sendMessage("scripts-registered"); + return; + } + + browser.test.assertEq(2, scripts.length, "expected 2 registered scripts"); + browser.test.sendMessage("scripts-already-registered"); + }, + files: { + "script-1.js": "", + "script-2.js": "", + "script-3.js": "", + }, + }); + + let extension2 = makeExtension({ + async background() { + let scripts = await browser.scripting.getRegisteredContentScripts(); + + if (!scripts.length) { + await browser.scripting.registerContentScripts([ + { + id: "1", + js: ["script-1.js"], + matches: ["http://*/*/file_sample.html"], + // We should persist this script by default. + }, + { + id: "2", + js: ["script-2.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: false, + }, + { + id: "\uFFFD 🍕 Boö", + js: ["script-3.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: true, + }, + ]); + browser.test.sendMessage("scripts-registered"); + return; + } + + browser.test.assertEq(2, scripts.length, "expected 2 registered scripts"); + browser.test.assertEq( + JSON.stringify(["script-1.js"]), + JSON.stringify(scripts[0].js), + "expected a single 'script-1.js' js file" + ); + browser.test.assertEq( + "\uFFFD 🍕 Boö", + scripts[1].id, + "expected correct ID" + ); + browser.test.sendMessage("scripts-already-registered"); + }, + files: { + "script-1.js": "", + "script-2.js": "", + "script-3.js": "", + }, + }); + + await Promise.all([extension1.startup(), extension2.startup()]); + + await Promise.all([ + extension1.awaitMessage("scripts-registered"), + extension2.awaitMessage("scripts-registered"), + ]); + await assertNumScriptsInStore(extension1, 2); + await assertNumScriptsInStore(extension2, 2); + + await AddonTestUtils.promiseRestartManager(); + await assertNumScriptsInStore(extension1, 2); + await assertNumScriptsInStore(extension2, 2); + + await Promise.all([extension1.awaitStartup(), extension2.awaitStartup()]); + await Promise.all([ + extension1.awaitMessage("scripts-already-registered"), + extension2.awaitMessage("scripts-already-registered"), + ]); + + await Promise.all([extension1.unload(), extension2.unload()]); + await AddonTestUtils.promiseShutdownManager(); + await assertNumScriptsInStore(extension1, 0); + await assertNumScriptsInStore(extension2, 0); +}); + +add_task(async function test_persisted_scripts_cleared_on_addon_updates() { + await AddonTestUtils.promiseStartupManager(); + + function background() { + browser.test.onMessage.addListener(async (msg, ...args) => { + switch (msg) { + case "registerContentScripts": + await browser.scripting.registerContentScripts(...args); + break; + case "unregisterContentScripts": + await browser.scripting.unregisterContentScripts(...args); + break; + default: + browser.test.fail(`Unexpected test message: ${msg}`); + } + browser.test.sendMessage(`${msg}:done`); + }); + } + + async function registerContentScript(ext, scriptFileName) { + ext.sendMessage("registerContentScripts", [ + { + id: scriptFileName, + js: [scriptFileName], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: true, + }, + ]); + await ext.awaitMessage("registerContentScripts:done"); + } + + let extension1Data = { + manifest: { + manifest_version: 2, + permissions: ["scripting"], + version: "1.0", + browser_specific_settings: { + // Set an explicit extension id so that extension.upgrade + // will trigger the extension to be started with the expected + // "ADDON_UPGRADE" / "ADDON_DOWNGRADE" extension.startupReason. + gecko: { id: "extension1@mochi.test" }, + }, + }, + useAddonManager: "permanent", + background, + files: { + "script-1.js": "", + }, + }; + + let extension1 = ExtensionTestUtils.loadExtension(extension1Data); + + let extension2 = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 2, + permissions: ["scripting"], + browser_specific_settings: { + gecko: { id: "extension2@mochi.test" }, + }, + }, + useAddonManager: "permanent", + background, + files: { + "script-2.js": "", + }, + }); + + await extension1.startup(); + await assertNumScriptsInStore(extension1, 0); + await assertIsPersistentScriptsCachedFlag(extension1, false); + + await extension2.startup(); + await assertNumScriptsInStore(extension2, 0); + await assertIsPersistentScriptsCachedFlag(extension2, false); + + await registerContentScript(extension1, "script-1.js"); + await assertNumScriptsInStore(extension1, 1); + await assertIsPersistentScriptsCachedFlag(extension1, true); + + await registerContentScript(extension2, "script-2.js"); + await assertNumScriptsInStore(extension2, 1); + await assertIsPersistentScriptsCachedFlag(extension2, true); + + info("Verify that scripts are still registered on a browser startup"); + await AddonTestUtils.promiseRestartManager(); + await extension1.awaitStartup(); + await extension2.awaitStartup(); + equal( + extension1.extension.startupReason, + "APP_STARTUP", + "Got the expected startupReason on AOM restart" + ); + + await assertNumScriptsInStore(extension1, 1); + await assertIsPersistentScriptsCachedFlag(extension1, true); + await assertNumScriptsInStore(extension2, 1); + await assertIsPersistentScriptsCachedFlag(extension2, true); + + async function testOnAddonUpdates( + extensionUpdateData, + expectedStartupReason + ) { + await extension1.upgrade(extensionUpdateData); + equal( + extension1.extension.startupReason, + expectedStartupReason, + "Got the expected startupReason on upgrade" + ); + + await assertNumScriptsInStore(extension1, 0); + await assertIsPersistentScriptsCachedFlag(extension1, false); + await assertNumScriptsInStore(extension2, 1); + await assertIsPersistentScriptsCachedFlag(extension2, true); + } + + info("Verify that scripts are cleared on upgrade"); + await testOnAddonUpdates( + { + ...extension1Data, + manifest: { + ...extension1Data.manifest, + version: "2.0", + }, + }, + "ADDON_UPGRADE" + ); + + await registerContentScript(extension1, "script-1.js"); + await assertNumScriptsInStore(extension1, 1); + + info("Verify that scripts are cleared on downgrade"); + await testOnAddonUpdates(extension1Data, "ADDON_DOWNGRADE"); + + await registerContentScript(extension1, "script-1.js"); + await assertNumScriptsInStore(extension1, 1); + + info("Verify that scripts are cleared on upgrade to same version"); + await testOnAddonUpdates(extension1Data, "ADDON_UPGRADE"); + + await extension1.unload(); + await extension2.unload(); + + await assertNumScriptsInStore(extension1, 0); + await assertIsPersistentScriptsCachedFlag(extension1, undefined); + await assertNumScriptsInStore(extension2, 0); + await assertIsPersistentScriptsCachedFlag(extension2, undefined); + + info("Verify stale persisted scripts cleared on re-install"); + // Inject a stale persisted script into the store. + await ExtensionScriptingStore._getStoreForTesting().writeMany(extension1.id, [ + { + id: "script-1.js", + allFrames: false, + matches: ["http://*/*/file_sample.html"], + runAt: "document_idle", + persistAcrossSessions: true, + js: ["script-1.js"], + }, + ]); + await assertNumScriptsInStore(extension1, 1); + const extension1Reinstalled = + ExtensionTestUtils.loadExtension(extension1Data); + await extension1Reinstalled.startup(); + equal( + extension1Reinstalled.extension.startupReason, + "ADDON_INSTALL", + "Got the expected startupReason on re-install" + ); + await assertNumScriptsInStore(extension1Reinstalled, 0); + await assertIsPersistentScriptsCachedFlag(extension1Reinstalled, false); + await extension1Reinstalled.unload(); + await assertNumScriptsInStore(extension1Reinstalled, 0); + await assertIsPersistentScriptsCachedFlag(extension1Reinstalled, undefined); + + await AddonTestUtils.promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_scripting_startupCache.js b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_startupCache.js new file mode 100644 index 0000000000..07445dafbe --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_startupCache.js @@ -0,0 +1,167 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +const { ExtensionScriptingStore } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionScriptingStore.sys.mjs" +); +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +add_task(async function test_hasPersistedScripts_startup_cache() { + let extension1 = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 2, + permissions: ["scripting"], + }, + // Set the startup reason to "APP_STARTUP", used to be able to simulate + // the behavior expected on calls to `ExtensionScriptingStore.init(extension)` + // when the addon has not been just installed, but it is being loaded as part + // of the browser application starting up. + startupReason: "APP_STARTUP", + background() { + browser.test.onMessage.addListener(async (msg, ...args) => { + switch (msg) { + case "registerContentScripts": + await browser.scripting.registerContentScripts(...args); + break; + case "unregisterContentScripts": + await browser.scripting.unregisterContentScripts(...args); + break; + default: + browser.test.fail(`Unexpected test message: ${msg}`); + } + browser.test.sendMessage(`${msg}:done`); + }); + }, + files: { + "script-1.js": "", + }, + }); + + await extension1.startup(); + + info(`Checking StartupCache for ${extension1.id} ${extension1.version}`); + await assertHasPersistedScriptsCachedFlag(extension1); + await assertIsPersistentScriptsCachedFlag(extension1, false); + + const store = ExtensionScriptingStore._getStoreForTesting(); + + extension1.sendMessage("registerContentScripts", [ + { + id: "some-script-id", + js: ["script-1.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: true, + }, + ]); + await extension1.awaitMessage("registerContentScripts:done"); + + // `registerContentScripts()` calls `ExtensionScriptingStore.persistAll()` + // without await it, which isn't a problem in practice but this becomes a + // problem in this test given that we should make sure the startup cache + // is updated before checking it. + await TestUtils.waitForCondition(async () => { + const scripts = await store.getAll(extension1.id); + return !!scripts.length; + }, "Wait for stored scripts list to not be empty"); + await assertIsPersistentScriptsCachedFlag(extension1, true); + + extension1.sendMessage("unregisterContentScripts", { + ids: ["some-script-id"], + }); + await extension1.awaitMessage("unregisterContentScripts:done"); + + await TestUtils.waitForCondition(async () => { + const scripts = await store.getAll(extension1.id); + return !scripts.length; + }, "Wait for stored scripts list to be empty"); + await assertIsPersistentScriptsCachedFlag(extension1, false); + + const storeGetAllSpy = sinon.spy(store, "getAll"); + const cleanupSpies = () => { + storeGetAllSpy.restore(); + }; + + // NOTE: ExtensionScriptingStore.initExtension is usually only called once + // during the extension startup. + // + // This test calls the method after startup was completed, which does not + // happen in practice, but it allows us to simulate what happens under different + // store and startup cache conditions and more explicitly cover the expectation + // that store.getAll isn't going to be called more than once internally + // when the hasPersistedScripts boolean flag wasn't in the StartupCache + // and had to be recomputed. + equal( + extension1.extension.startupReason, + "APP_STARTUP", + "Got the expected extension.startupReason" + ); + await ExtensionScriptingStore.initExtension(extension1.extension); + equal(storeGetAllSpy.callCount, 0, "Expect store.getAll to not be called"); + + Services.obs.notifyObservers(null, "startupcache-invalidate"); + + await ExtensionScriptingStore.initExtension(extension1.extension); + equal(storeGetAllSpy.callCount, 1, "Expect store.getAll to be called once"); + + extension1.sendMessage("registerContentScripts", [ + { + id: "some-script-id", + js: ["script-1.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: true, + }, + ]); + await extension1.awaitMessage("registerContentScripts:done"); + + await TestUtils.waitForCondition(async () => { + const scripts = await store.getAll(extension1.id); + return !!scripts.length; + }, "Wait for stored scripts list to not be empty"); + await assertIsPersistentScriptsCachedFlag(extension1, true); + + // Make sure getAll is only called once when we don't have + // scripting.hasPersistedScripts flag cached. + storeGetAllSpy.resetHistory(); + Services.obs.notifyObservers(null, "startupcache-invalidate"); + await ExtensionScriptingStore.initExtension(extension1.extension); + equal(storeGetAllSpy.callCount, 1, "Expect store.getAll to be called once"); + + cleanupSpies(); + + const extId = extension1.id; + const extVersion = extension1.version; + await assertIsPersistentScriptsCachedFlag( + { id: extId, version: extVersion }, + true + ); + await extension1.unload(); + await assertIsPersistentScriptsCachedFlag( + { id: extId, version: extVersion }, + undefined + ); + + const { StartupCache } = ExtensionParent; + const allCachedGeneral = StartupCache._data.get("general"); + equal( + allCachedGeneral.has(extId), + false, + "Expect the extension to have been removed from the StartupCache" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_scripting_updateContentScripts.js b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_updateContentScripts.js new file mode 100644 index 0000000000..9d3bf1576c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_updateContentScripts.js @@ -0,0 +1,114 @@ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, to use +// the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const makeExtension = ({ manifest: manifestProps, ...otherProps }) => { + return ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + permissions: ["scripting"], + host_permissions: ["http://localhost/*"], + granted_host_permissions: true, + ...manifestProps, + }, + temporarilyInstalled: true, + ...otherProps, + }); +}; + +add_task(async function test_scripting_updateContentScripts() { + let extension = makeExtension({ + async background() { + const script = { + id: "a-script", + js: ["script-1.js"], + matches: ["http://*/*/*.html"], + persistAcrossSessions: false, + }; + + await browser.scripting.registerContentScripts([script]); + await browser.scripting.updateContentScripts([ + { + id: script.id, + js: ["script-2.js"], + }, + ]); + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(1, scripts.length, "expected 1 registered script"); + + browser.test.sendMessage("background-ready"); + }, + files: { + "script-1.js": () => { + browser.test.fail("script-1 should not be executed"); + }, + "script-2.js": () => { + browser.test.sendMessage( + `script-2 executed in ${location.pathname.split("/").pop()}` + ); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.awaitMessage("script-2 executed in file_sample.html"); + + await extension.unload(); + await contentPage.close(); +}); + +add_task( + async function test_scripting_updateContentScripts_non_default_values() { + let extension = makeExtension({ + async background() { + const script = { + id: "a-script", + allFrames: true, + matches: ["http://*/*/*.html"], + runAt: "document_start", + persistAcrossSessions: false, + css: ["style.js"], + excludeMatches: ["http://*/*/foobar.html"], + js: ["script.js"], + }; + + await browser.scripting.registerContentScripts([script]); + + // This should not modify the previously registered script. + await browser.scripting.updateContentScripts([{ id: script.id }]); + + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq( + JSON.stringify([script]), + JSON.stringify(scripts), + "expected unmodified registered script" + ); + + browser.test.sendMessage("background-done"); + }, + files: { + "script.js": "", + "style.css": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-done"); + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_secfetch.js b/toolkit/components/extensions/test/xpcshell/test_ext_secfetch.js new file mode 100644 index 0000000000..e8b3dcfca8 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_secfetch.js @@ -0,0 +1,352 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const server = createHttpServer({ + // We need the 127.0.0.1 proxy because the sec-fetch headers are not sent to + // "127.0.0.1:<any port other than 80 or 443>". + hosts: ["127.0.0.1", "127.0.0.2"], +}); + +server.registerPathHandler("/page.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Access-Control-Allow-Origin", "*"); +}); + +server.registerPathHandler("/return_headers", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/plain"); + response.setHeader("Access-Control-Allow-Origin", "*"); + if (request.method === "OPTIONS") { + // Handle CORS preflight request. + response.setHeader("Access-Control-Allow-Methods", "GET, PUT"); + return; + } + + let headers = {}; + for (let header of [ + "sec-fetch-site", + "sec-fetch-dest", + "sec-fetch-mode", + "sec-fetch-user", + ]) { + if (request.hasHeader(header)) { + headers[header] = request.getHeader(header); + } + } + + if (request.hasHeader("origin")) { + headers.origin = request + .getHeader("origin") + .replace(/moz-extension:\/\/[^\/]+/, "moz-extension://<placeholder>"); + } + + response.write(JSON.stringify(headers)); +}); + +async function contentScript() { + let content_fetch; + if (browser.runtime.getManifest().manifest_version === 2) { + content_fetch = content.fetch; + } else { + // In MV3, there is no content variable. + browser.test.assertEq(typeof content, "undefined", "no .content in MV3"); + // In MV3, window.fetch is the original fetch with the page's principal. + content_fetch = window.fetch.bind(window); + } + let results = await Promise.allSettled([ + // A cross-origin request from the content script. + fetch("http://127.0.0.1/return_headers").then(res => res.json()), + // A cross-origin request that behaves as if it was sent by the content it + // self. + content_fetch("http://127.0.0.1/return_headers").then(res => res.json()), + // A same-origin request that behaves as if it was sent by the content it + // self. + content_fetch("http://127.0.0.2/return_headers").then(res => res.json()), + // A same-origin request from the content script. + fetch("http://127.0.0.2/return_headers").then(res => res.json()), + // Non GET or HEAD request, triggers CORS preflight. + fetch("http://127.0.0.2/return_headers", { method: "PUT" }).then(res => + res.json() + ), + ]); + + results = results.map(({ value, reason }) => value ?? reason.message); + + browser.test.sendMessage("content_results", results); +} + +async function runSecFetchTest(test) { + let data = { + async background() { + let site = await new Promise(resolve => { + browser.test.onMessage.addListener(msg => { + resolve(msg); + }); + }); + + let results = await Promise.all([ + fetch(`${site}/return_headers`).then(res => res.json()), + // Non GET or HEAD request, triggers CORS preflight. + fetch(`${site}/return_headers`, { method: "PUT" }).then(res => + res.json() + ), + ]); + browser.test.sendMessage("background_results", results); + }, + manifest: { + manifest_version: test.manifest_version, + content_scripts: [ + { + matches: ["http://127.0.0.2/*"], + js: ["content_script.js"], + }, + ], + }, + files: { + "content_script.js": contentScript, + }, + }; + + if (data.manifest.manifest_version == 3) { + // Automatically grant permissions so that the content script can run. + data.manifest.granted_host_permissions = true; + // Needed to use granted_host_permissions in tests: + data.temporarilyInstalled = true; + // Work-around for bug 1766752: + data.manifest.host_permissions = ["http://127.0.0.2/*"]; + // (note: ^ host_permissions may be replaced/extended below). + } + + // The sec-fetch-* headers are only send to potentially trust worthy origins. + // We use 127.0.0.1 to avoid setting up an https server. + const site = "http://127.0.0.1"; + + if (test.permission) { + // MV3 requires permissions to be set in permissions. ExtensionTestCommon + // will replace host_permissions with permissions in MV2. + data.manifest.host_permissions = ["http://127.0.0.2/*", `${site}/*`]; + } + + let extension = ExtensionTestUtils.loadExtension(data); + await extension.startup(); + + extension.sendMessage(site); + let backgroundResults = await extension.awaitMessage("background_results"); + Assert.deepEqual(backgroundResults, test.expectedBackgroundHeaders); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `http://127.0.0.2/page.html` + ); + let contentResults = await extension.awaitMessage("content_results"); + Assert.deepEqual(contentResults, test.expectedContentHeaders); + await contentPage.close(); + + await extension.unload(); +} + +add_task(async function test_fetch_without_permissions_mv2() { + await runSecFetchTest({ + manifest_version: 2, + permission: false, + expectedBackgroundHeaders: [ + { + "sec-fetch-site": "cross-site", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "moz-extension://<placeholder>", + }, + { + "sec-fetch-site": "cross-site", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "moz-extension://<placeholder>", + }, + ], + expectedContentHeaders: [ + // TODO bug 1605197: Support cors without permissions in MV2. + "NetworkError when attempting to fetch resource.", + // Expectation: + // { + // "sec-fetch-site": "cross-site", + // "sec-fetch-mode": "cors", + // "sec-fetch-dest": "empty", + // }, + { + "sec-fetch-site": "cross-site", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "http://127.0.0.2", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + ], + }); +}); + +add_task(async function test_fetch_with_permissions_mv2() { + await runSecFetchTest({ + manifest_version: 2, + permission: true, + expectedBackgroundHeaders: [ + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "moz-extension://<placeholder>", + }, + ], + expectedContentHeaders: [ + { + "sec-fetch-site": "cross-site", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + { + "sec-fetch-site": "cross-site", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "http://127.0.0.2", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + ], + }); +}); + +add_task(async function test_fetch_without_permissions_mv3() { + await runSecFetchTest({ + manifest_version: 3, + permission: false, + expectedBackgroundHeaders: [ + // Same as in test_fetch_without_permissions_mv2. + { + "sec-fetch-site": "cross-site", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "moz-extension://<placeholder>", + }, + { + "sec-fetch-site": "cross-site", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "moz-extension://<placeholder>", + }, + ], + expectedContentHeaders: [ + { + "sec-fetch-site": "cross-site", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "http://127.0.0.2", + }, + { + "sec-fetch-site": "cross-site", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "http://127.0.0.2", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "http://127.0.0.2", + }, + ], + }); +}); + +add_task(async function test_fetch_with_permissions_mv3() { + await runSecFetchTest({ + manifest_version: 3, + permission: true, + expectedBackgroundHeaders: [ + { + // Same as in test_fetch_with_permissions_mv2. + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "moz-extension://<placeholder>", + }, + ], + expectedContentHeaders: [ + // All expectations the same as in test_fetch_without_permissions_mv3. + { + "sec-fetch-site": "cross-site", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "http://127.0.0.2", + }, + { + "sec-fetch-site": "cross-site", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "http://127.0.0.2", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "http://127.0.0.2", + }, + ], + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_shadowdom.js b/toolkit/components/extensions/test/xpcshell/test_ext_shadowdom.js new file mode 100644 index 0000000000..626d8de22d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_shadowdom.js @@ -0,0 +1,59 @@ +"use strict"; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function test_contentscript_shadowDOM() { + function backgroundScript() { + browser.test.assertTrue( + "openOrClosedShadowRoot" in document.documentElement, + "Should have openOrClosedShadowRoot in Element in background script." + ); + } + + function contentScript() { + let host = document.getElementById("host"); + browser.test.assertTrue( + "openOrClosedShadowRoot" in host, + "Should have openOrClosedShadowRoot in Element." + ); + let shadowRoot = host.openOrClosedShadowRoot; + browser.test.assertEq( + shadowRoot.mode, + "closed", + "Should have closed ShadowRoot." + ); + browser.test.sendMessage("contentScript"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_shadowdom.html"], + js: ["content_script.js"], + }, + ], + }, + background: backgroundScript, + files: { + "content_script.js": contentScript, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_shadowdom.html` + ); + await extension.awaitMessage("contentScript"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_shared_array_buffer.js b/toolkit/components/extensions/test/xpcshell/test_ext_shared_array_buffer.js new file mode 100644 index 0000000000..b2a9d81a27 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_shared_array_buffer.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_shared_array_buffer_worker() { + const extension_description = { + isPrivileged: null, + async background() { + browser.test.onMessage.addListener(async isPrivileged => { + const worker = new Worker("worker.js"); + worker.isPrivileged = isPrivileged; + worker.onmessage = function (e) { + const msg = `${ + this.isPrivileged + ? "privileged addon can" + : "non-privileged addon can't" + } instantiate a SharedArrayBuffer + in a worker`; + if (e.data === this.isPrivileged) { + browser.test.succeed(msg); + } else { + browser.test.fail(msg); + } + browser.test.sendMessage("test-sab-worker:done"); + }; + }); + }, + files: { + "worker.js": function () { + try { + new SharedArrayBuffer(1); + this.postMessage(true); + } catch (e) { + this.postMessage(false); + } + }, + }, + }; + + // This test attempts to verify that a worker inside a privileged addon + // is allowed to instantiate a SharedArrayBuffer + extension_description.isPrivileged = true; + let extension = ExtensionTestUtils.loadExtension(extension_description); + await extension.startup(); + extension.sendMessage(extension_description.isPrivileged); + await extension.awaitMessage("test-sab-worker:done"); + await extension.unload(); + + // This test attempts to verify that a worker inside a non privileged addon + // is not allowed to instantiate a SharedArrayBuffer + extension_description.isPrivileged = false; + extension = ExtensionTestUtils.loadExtension(extension_description); + await extension.startup(); + extension.sendMessage(extension_description.isPrivileged); + await extension.awaitMessage("test-sab-worker:done"); + await extension.unload(); +}); + +add_task(async function test_shared_array_buffer_content() { + let extension_description = { + isPrivileged: null, + async background() { + browser.test.onMessage.addListener(async isPrivileged => { + let succeed = null; + try { + new SharedArrayBuffer(1); + succeed = true; + } catch (e) { + succeed = false; + } finally { + const msg = `${ + isPrivileged ? "privileged addon can" : "non-privileged addon can't" + } instantiate a SharedArrayBuffer + in the main thread`; + if (succeed === isPrivileged) { + browser.test.succeed(msg); + } else { + browser.test.fail(msg); + } + browser.test.sendMessage("test-sab-content:done"); + } + }); + }, + }; + + // This test attempts to verify that a non privileged addon + // is allowed to instantiate a sharedarraybuffer + extension_description.isPrivileged = true; + let extension = ExtensionTestUtils.loadExtension(extension_description); + await extension.startup(); + extension.sendMessage(extension_description.isPrivileged); + await extension.awaitMessage("test-sab-content:done"); + await extension.unload(); + + // This test attempts to verify that a non privileged addon + // is not allowed to instantiate a sharedarraybuffer + extension_description.isPrivileged = false; + extension = ExtensionTestUtils.loadExtension(extension_description); + await extension.startup(); + extension.sendMessage(extension_description.isPrivileged); + await extension.awaitMessage("test-sab-content:done"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_shared_workers.js b/toolkit/components/extensions/test/xpcshell/test_ext_shared_workers.js new file mode 100644 index 0000000000..54c14e5524 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_shared_workers.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// This test attemps to verify that: +// - SharedWorkers can be created and successfully spawned by web extensions +// when web-extensions run in their own child process. +add_task(async function test_spawn_shared_worker() { + if (!WebExtensionPolicy.useRemoteWebExtensions) { + // Ensure RemoteWorkerService has been initialized in the main + // process. + Services.obs.notifyObservers(null, "profile-after-change"); + } + + const background = async function () { + const worker = new SharedWorker("worker.js"); + await new Promise(resolve => { + worker.port.onmessage = resolve; + worker.port.postMessage("bgpage->worker"); + }); + browser.test.sendMessage("test-shared-worker:done"); + }; + + const extension = ExtensionTestUtils.loadExtension({ + background, + files: { + "worker.js": function () { + self.onconnect = evt => { + const port = evt.ports[0]; + port.onmessage = () => port.postMessage("worker-reply"); + }; + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("test-shared-worker:done"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_shutdown_cleanup.js b/toolkit/components/extensions/test/xpcshell/test_ext_shutdown_cleanup.js new file mode 100644 index 0000000000..f423ccbe97 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_shutdown_cleanup.js @@ -0,0 +1,43 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +const { + ExtensionParent: { GlobalManager }, +} = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" +); + +add_task(async function test_global_manager_shutdown_cleanup() { + equal( + GlobalManager.initialized, + false, + "GlobalManager start as not initialized" + ); + + function background() { + browser.test.notifyPass("background page loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + }); + + await extension.startup(); + await extension.awaitFinish("background page loaded"); + + equal( + GlobalManager.initialized, + true, + "GlobalManager has been initialized once an extension is started" + ); + + await extension.unload(); + + equal( + GlobalManager.initialized, + false, + "GlobalManager has been uninitialized once all the webextensions have been stopped" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_simple.js b/toolkit/components/extensions/test/xpcshell/test_ext_simple.js new file mode 100644 index 0000000000..ad74d80ede --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_simple.js @@ -0,0 +1,208 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +add_task(async function test_simple() { + let extensionData = { + manifest: { + name: "Simple extension test", + version: "1.0", + manifest_version: 2, + description: "", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.unload(); +}); + +add_task(async function test_manifest_V3_disabled() { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", false); + let extensionData = { + manifest: { + manifest_version: 3, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await Assert.rejects( + extension.startup(), + /Unsupported manifest version: 3/, + "manifest V3 cannot be loaded" + ); + Services.prefs.clearUserPref("extensions.manifestV3.enabled"); +}); + +add_task(async function test_manifest_V3_enabled() { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + let extensionData = { + manifest: { + manifest_version: 3, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + equal(extension.extension.manifest.manifest_version, 3, "manifest V3 loads"); + await extension.unload(); + Services.prefs.clearUserPref("extensions.manifestV3.enabled"); +}); + +add_task(async function test_background() { + function background() { + browser.test.log("running background script"); + + browser.test.onMessage.addListener((x, y) => { + browser.test.assertEq(x, 10, "x is 10"); + browser.test.assertEq(y, 20, "y is 20"); + + browser.test.notifyPass("background test passed"); + }); + + browser.test.sendMessage("running", 1); + } + + let extensionData = { + background, + manifest: { + name: "Simple extension test", + version: "1.0", + manifest_version: 2, + description: "", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + let [, x] = await Promise.all([ + extension.startup(), + extension.awaitMessage("running"), + ]); + equal(x, 1, "got correct value from extension"); + + extension.sendMessage(10, 20); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task(async function test_extensionTypes() { + let extensionData = { + background: function () { + browser.test.assertEq( + typeof browser.extensionTypes, + "object", + "browser.extensionTypes exists" + ); + browser.test.assertEq( + typeof browser.extensionTypes.RunAt, + "object", + "browser.extensionTypes.RunAt exists" + ); + browser.test.notifyPass("extentionTypes test passed"); + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task(async function test_policy_temporarilyInstalled() { + await AddonTestUtils.promiseStartupManager(); + + let extensionData = { + manifest: { + manifest_version: 2, + }, + }; + + async function runTest(useAddonManager) { + let extension = ExtensionTestUtils.loadExtension({ + ...extensionData, + useAddonManager, + }); + + const expected = useAddonManager === "temporary"; + await extension.startup(); + const { temporarilyInstalled } = WebExtensionPolicy.getByID(extension.id); + equal( + temporarilyInstalled, + expected, + `Got the expected WebExtensionPolicy.temporarilyInstalled value on "${useAddonManager}"` + ); + await extension.unload(); + } + + await runTest("temporary"); + await runTest("permanent"); +}); + +add_task(async function test_manifest_allowInsecureRequests() { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + let extensionData = { + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + equal( + extension.extension.manifest.content_security_policy.extension_pages, + `script-src 'self'`, + "insecure allowed" + ); + await extension.unload(); + Services.prefs.clearUserPref("extensions.manifestV3.enabled"); +}); + +add_task(async function test_manifest_allowInsecureRequests_throws() { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + let extensionData = { + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self'`, + }, + }, + }; + + await Assert.throws( + () => ExtensionTestUtils.loadExtension(extensionData), + /allowInsecureRequests cannot be used with manifest.content_security_policy/, + "allowInsecureRequests with content_security_policy cannot be loaded" + ); + Services.prefs.clearUserPref("extensions.manifestV3.enabled"); +}); + +add_task(async function test_gecko_android_key_in_applications() { + const extensionData = { + manifest: { + manifest_version: 2, + applications: { + gecko_android: {}, + }, + }, + }; + const extension = ExtensionTestUtils.loadExtension(extensionData); + + await Assert.rejects( + extension.startup(), + /applications: Property "gecko_android" is unsupported by Firefox/, + "expected applications.gecko_android to be invalid" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_startupData.js b/toolkit/components/extensions/test/xpcshell/test_ext_startupData.js new file mode 100644 index 0000000000..df51fa9abf --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_startupData.js @@ -0,0 +1,55 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "1" +); + +// Tests that startupData is persisted and is available at startup +add_task(async function test_startupData() { + await AddonTestUtils.promiseStartupManager(); + + let wrapper = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + }); + await wrapper.startup(); + + let { extension } = wrapper; + + deepEqual( + extension.startupData, + {}, + "startupData for a new extension defaults to empty object" + ); + + const DATA = { test: "i am some startup data" }; + extension.startupData = DATA; + extension.saveStartupData(); + + await AddonTestUtils.promiseRestartManager(); + await wrapper.startupPromise; + + ({ extension } = wrapper); + deepEqual(extension.startupData, DATA, "startupData is present on restart"); + + const DATA2 = { other: "this is different data" }; + extension.startupData = DATA2; + extension.saveStartupData(); + + await AddonTestUtils.promiseRestartManager(); + await wrapper.startupPromise; + + ({ extension } = wrapper); + deepEqual( + extension.startupData, + DATA2, + "updated startupData is present on restart" + ); + + await wrapper.unload(); + await AddonTestUtils.promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache.js b/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache.js new file mode 100644 index 0000000000..ac815d6010 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache.js @@ -0,0 +1,178 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" +); +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); + +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +const ADDON_ID = "test-startup-cache@xpcshell.mozilla.org"; + +function makeExtension(opts) { + return { + useAddonManager: "permanent", + + manifest: { + version: opts.version, + browser_specific_settings: { gecko: { id: ADDON_ID } }, + + name: "__MSG_name__", + + default_locale: "en_US", + }, + + files: { + "_locales/en_US/messages.json": { + name: { + message: `en-US ${opts.version}`, + description: "Name.", + }, + }, + "_locales/fr/messages.json": { + name: { + message: `fr ${opts.version}`, + description: "Name.", + }, + }, + }, + + background() { + browser.test.onMessage.addListener(msg => { + if (msg === "get-manifest") { + browser.test.sendMessage("manifest", browser.runtime.getManifest()); + } + }); + }, + }; +} + +add_task(async function test_langpack_startup_cache() { + Preferences.set("extensions.logging.enabled", false); + await AddonTestUtils.promiseStartupManager(); + + // Install langpacks to get proper locale startup. + let langpack = { + "manifest.json": { + name: "test Language Pack", + version: "1.0", + manifest_version: 2, + browser_specific_settings: { + gecko: { + id: "@test-langpack", + strict_min_version: "42.0", + strict_max_version: "42.0", + }, + }, + langpack_id: "fr", + languages: { + fr: { + chrome_resources: { + global: "chrome/fr/locale/fr/global/", + }, + version: "20171001190118", + }, + }, + sources: { + browser: { + base_path: "browser/", + }, + }, + }, + }; + + let [, { addon }] = await Promise.all([ + TestUtils.topicObserved("webextension-langpack-startup"), + AddonTestUtils.promiseInstallXPI(langpack), + ]); + + let extension = ExtensionTestUtils.loadExtension( + makeExtension({ version: "1.0" }) + ); + + function getManifest() { + extension.sendMessage("get-manifest"); + return extension.awaitMessage("manifest"); + } + + // At the moment extension language negotiation is tied to Firefox language + // negotiation result. That means that to test an extension in `fr`, we need + // to mock `fr` being available in Firefox and then request it. + // + // In the future, we should provide some way for tests to decouple their + // language selection from that of Firefox. + ok(Services.locale.availableLocales.includes("fr"), "fr locale is avialable"); + + await extension.startup(); + + equal(extension.version, "1.0", "Expected extension version"); + let manifest = await getManifest(); + equal(manifest.name, "en-US 1.0", "Got expected manifest name"); + + info("Restart and re-check"); + await AddonTestUtils.promiseRestartManager(); + await extension.awaitBackgroundStarted(); + + equal(extension.version, "1.0", "Expected extension version"); + manifest = await getManifest(); + equal(manifest.name, "en-US 1.0", "Got expected manifest name"); + + info("Change locale to 'fr' and restart"); + Services.locale.requestedLocales = ["fr"]; + await AddonTestUtils.promiseRestartManager(); + await extension.awaitBackgroundStarted(); + + equal(extension.version, "1.0", "Expected extension version"); + manifest = await getManifest(); + equal(manifest.name, "fr 1.0", "Got expected manifest name"); + + info("Update to version 1.1"); + await extension.upgrade(makeExtension({ version: "1.1" })); + + equal(extension.version, "1.1", "Expected extension version"); + manifest = await getManifest(); + equal(manifest.name, "fr 1.1", "Got expected manifest name"); + + info("Change locale to 'en-US' and restart"); + Services.locale.requestedLocales = ["en-US"]; + await AddonTestUtils.promiseRestartManager(); + await extension.awaitBackgroundStarted(); + + equal(extension.version, "1.1", "Expected extension version"); + manifest = await getManifest(); + equal(manifest.name, "en-US 1.1", "Got expected manifest name"); + + info("Disable locale 'fr'"); + addon = await AddonManager.getAddonByID("@test-langpack"); + + // We disable the installed langpack instead of uninstalling it + // because the xpi file may technically be still in use by the + // time the XPIProvider will try to remove the file and will + // make this test to fail intermittently on windows. + // + // Disabling the addon is equivalent from the perspective of this + // test case, and the langpack xpi will be uninstalled automatically + // at the end of this test case by AddonTestUtils (from its + // cleanupTempXPIs method, which will also force a GC if the + // file fails to be removed after we flushed the jar cache). + await addon.disable(); + ok(!Services.locale.availableLocales.includes("fr"), "fr locale is removed"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache_telemetry.js new file mode 100644 index 0000000000..7ceec46679 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache_telemetry.js @@ -0,0 +1,173 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", +}); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +const ADDON_ID = "test-startup-cache-telemetry@xpcshell.mozilla.org"; + +add_setup(async () => { + // FOG needs a profile directory to put its data in. + do_get_profile(); + // FOG needs to be initialized in order for data to flow. + Services.fog.initializeFOG(); + + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_startupCache_write_byteLength() { + Services.fog.testResetFOG(); + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: ADDON_ID } }, + }, + }); + + await extension.startup(); + + const { StartupCache } = ExtensionParent; + + const aomStartup = Cc[ + "@mozilla.org/addons/addon-manager-startup;1" + ].getService(Ci.amIAddonManagerStartup); + + let expectedByteLength = new Uint8Array( + aomStartup.encodeBlob(StartupCache._data) + ).byteLength; + + equal( + typeof expectedByteLength, + "number", + "Got a numeric byteLength for the expected startupCache data" + ); + Assert.greater( + expectedByteLength, + 0, + "Got a non-zero byteLength as expected" + ); + await StartupCache._saveNow(); + + let scalars = TelemetryTestUtils.getProcessScalars("parent"); + equal( + scalars["extensions.startupCache.write_byteLength"], + expectedByteLength, + "Got the expected value set in the 'extensions.startupCache.write_byteLength' scalar" + ); + equal( + Glean.extensions.startupCacheWriteBytelength.testGetValue(), + expectedByteLength, + "Expected 'extensions.startupCache.write_byteLength' Glean metric." + ); + + await extension.unload(); +}); + +add_task(async function test_startupCache_read_errors() { + const { StartupCache } = ExtensionParent; + + // Clear any pre-existing keyed scalar. + TelemetryTestUtils.getProcessScalars("parent", /* keyed */ true, true); + Services.fog.testResetFOG(); + + // Temporarily point StartupCache._file to a path that is + // not going to exist for sure. + Assert.notEqual( + StartupCache.file, + null, + "Got a StartupCache._file non-null property as expected" + ); + const oldFile = StartupCache.file; + const restoreStartupCacheFile = () => (StartupCache.file = oldFile); + StartupCache.file = `${StartupCache.file}.non_existing_file.${Math.random()}`; + registerCleanupFunction(restoreStartupCacheFile); + + // Make sure the _readData has been called and we can expect + // the extensions.startupCache.read_errors scalar to have + // been recorded. + await StartupCache._readData(); + + let scalars = TelemetryTestUtils.getProcessScalars( + "parent", + /* keyed */ true + ); + Assert.deepEqual( + scalars["extensions.startupCache.read_errors"], + { + NotFoundError: 1, + }, + "Got the expected value set in the 'extensions.startupCache.read_errors' keyed scalar" + ); + Assert.deepEqual( + Glean.extensions.startupCacheReadErrors.NotFoundError.testGetValue(), + 1, + "Expected value for 'extensions.startupCache.read_errors' Glean metric." + ); + + restoreStartupCacheFile(); +}); + +add_task(async function test_startupCache_load_timestamps() { + const { StartupCache } = ExtensionParent; + + // Clear any pre-existing keyed scalar and Glean metrics data. + Services.telemetry.getSnapshotForScalars("main", true); + Services.fog.testResetFOG(); + + let gleanMetric = Glean.extensions.startupCacheLoadTime.testGetValue(); + equal( + gleanMetric, + null, + "Expect extensions.startup_cache_load_time Glean metric to be initially null" + ); + + // Make sure the _readData has been called and we can expect + // the startupCache load telemetry timestamps to have been + // recorded. + await StartupCache._readData(); + + info( + "Verify telemetry recorded for the 'extensions.startup_cache_load_time' Glean metric" + ); + + gleanMetric = Glean.extensions.startupCacheLoadTime.testGetValue(); + equal( + typeof gleanMetric, + "number", + "Expect extensions.startup_cache_load_time Glean metric to be set to a number" + ); + + info( + "Verify telemetry mirrored into the 'extensions.startupCache.load_time' scalar" + ); + + const scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + const mirror = scalars["extensions.startupCache.load_time"]; + + equal( + typeof mirror, + "number", + "Expect extensions.startupCache.load_time mirrored scalar to be set to a number" + ); + + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1865850. + Assert.lessOrEqual( + Math.abs(gleanMetric - mirror), + 1, + `Expect Glean metric ${gleanMetric} and mirrored scalar ${mirror} to be within 1ms of each other.` + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_startup_perf.js b/toolkit/components/extensions/test/xpcshell/test_ext_startup_perf.js new file mode 100644 index 0000000000..e7108ce100 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_startup_perf.js @@ -0,0 +1,70 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const STARTUP_APIS = ["backgroundPage"]; + +const STARTUP_MODULES = new Set([ + "resource://gre/modules/Extension.sys.mjs", + "resource://gre/modules/ExtensionCommon.sys.mjs", + "resource://gre/modules/ExtensionParent.sys.mjs", + // FIXME: This is only loaded at startup for new extension installs. + // Otherwise the data comes from the startup cache. We should test for + // this. + "resource://gre/modules/ExtensionPermissions.sys.mjs", + "resource://gre/modules/ExtensionProcessScript.sys.mjs", + "resource://gre/modules/ExtensionUtils.sys.mjs", + "resource://gre/modules/ExtensionTelemetry.sys.mjs", +]); + +if (!Services.prefs.getBoolPref("extensions.webextensions.remote")) { + STARTUP_MODULES.add("resource://gre/modules/ExtensionChild.sys.mjs"); + STARTUP_MODULES.add("resource://gre/modules/ExtensionPageChild.sys.mjs"); +} + +if (AppConstants.MOZ_APP_NAME == "thunderbird") { + // Imported via mail/components/extensions/processScript.js. + STARTUP_MODULES.add("resource://gre/modules/ExtensionChild.sys.mjs"); + STARTUP_MODULES.add("resource://gre/modules/ExtensionContent.sys.mjs"); + STARTUP_MODULES.add("resource://gre/modules/ExtensionPageChild.sys.mjs"); +} + +AddonTestUtils.init(this); + +// Tests that only the minimal set of API scripts and modules are loaded at +// startup for a simple extension. +add_task(async function test_loaded_scripts() { + await ExtensionTestUtils.startAddonManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + background() {}, + manifest: {}, + }); + + await extension.startup(); + + const { apiManager } = ExtensionParent; + + const loadedAPIs = Array.from(apiManager.modules.values()) + .filter(m => m.loaded || m.asyncLoaded) + .map(m => m.namespaceName); + + deepEqual( + loadedAPIs.sort(), + STARTUP_APIS, + "No extra APIs should be loaded at startup for a simple extension" + ); + + let loadedModules = Cu.loadedJSModules + .concat(Cu.loadedESModules) + .filter(url => url.startsWith("resource://gre/modules/Extension")); + + deepEqual( + loadedModules.sort(), + Array.from(STARTUP_MODULES).sort(), + "No extra extension modules should be loaded at startup for a simple extension" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_startup_request_handler.js b/toolkit/components/extensions/test/xpcshell/test_ext_startup_request_handler.js new file mode 100644 index 0000000000..d4a1db8ed2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_startup_request_handler.js @@ -0,0 +1,69 @@ +"use strict"; + +function delay(time) { + return new Promise(resolve => { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(resolve, time); + }); +} + +const { Extension } = ChromeUtils.importESModule( + "resource://gre/modules/Extension.sys.mjs" +); + +add_task(async function test_startup_request_handler() { + const ID = "request-startup@xpcshell.mozilla.org"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + }, + + files: { + "meh.txt": "Meh.", + }, + }); + + let ready = false; + let resolvePromise; + let promise = new Promise(resolve => { + resolvePromise = resolve; + }); + promise.then(() => { + ready = true; + }); + + let origInitLocale = Extension.prototype.initLocale; + Extension.prototype.initLocale = async function initLocale() { + await promise; + return origInitLocale.call(this); + }; + + let startupPromise = extension.startup(); + + await delay(0); + let policy = WebExtensionPolicy.getByID(ID); + let url = policy.getURL("meh.txt"); + + let contentPage; + let pagePromise = ExtensionTestUtils.loadContentPage(url, { extension }); + let resp = pagePromise.then(page => { + contentPage = page; + ok(ready, "Shouldn't get response before extension is ready"); + return page.fetch(url); + }); + + await delay(2000); + + resolvePromise(); + await startupPromise; + + let body = await resp; + equal(body, "Meh.", "Got the correct response"); + + await contentPage.close(); + + await extension.unload(); + + Extension.prototype.initLocale = origInitLocale; +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_local.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_local.js new file mode 100644 index 0000000000..4ba8cc596b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_local.js @@ -0,0 +1,39 @@ +"use strict"; + +const { ExtensionStorageIDB } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionStorageIDB.sys.mjs" +); + +PromiseTestUtils.allowMatchingRejectionsGlobally( + /WebExtension context not found/ +); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +// The storage API in content scripts should behave identical to the storage API +// in background pages. + +AddonTestUtils.init(this); + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_contentscript_storage_local_file_backend() { + return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]], () => + test_contentscript_storage("local") + ); +}); + +add_task(async function test_contentscript_storage_local_idb_backend() { + return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], () => + test_contentscript_storage("local") + ); +}); + +add_task(async function test_contentscript_storage_local_idb_no_bytes_in_use() { + return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], () => + test_contentscript_storage_area_no_bytes_in_use("local") + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync.js new file mode 100644 index 0000000000..6b1695417d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync.js @@ -0,0 +1,31 @@ +"use strict"; + +Services.prefs.setBoolPref("webextensions.storage.sync.kinto", false); + +PromiseTestUtils.allowMatchingRejectionsGlobally( + /WebExtension context not found/ +); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +// The storage API in content scripts should behave identical to the storage API +// in background pages. + +AddonTestUtils.init(this); + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_contentscript_storage_sync() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_contentscript_storage("sync") + ); +}); + +add_task(async function test_contentscript_bytes_in_use_sync() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_contentscript_storage_area_with_bytes_in_use("sync", true) + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync_kinto.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync_kinto.js new file mode 100644 index 0000000000..92ec405520 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync_kinto.js @@ -0,0 +1,31 @@ +"use strict"; + +Services.prefs.setBoolPref("webextensions.storage.sync.kinto", true); + +PromiseTestUtils.allowMatchingRejectionsGlobally( + /WebExtension context not found/ +); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +// The storage API in content scripts should behave identical to the storage API +// in background pages. + +AddonTestUtils.init(this); + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_contentscript_storage_sync() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_contentscript_storage("sync") + ); +}); + +add_task(async function test_contentscript_storage_no_bytes_in_use() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_contentscript_storage_area_with_bytes_in_use("sync", false) + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_idb_data_migration.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_idb_data_migration.js new file mode 100644 index 0000000000..8a6631f26b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_idb_data_migration.js @@ -0,0 +1,806 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// This test file verifies various scenarios related to the data migration +// from the JSONFile backend to the IDB backend. + +AddonTestUtils.init(this); + +// Create appInfo before importing any other jsm file, to prevent +// Services.appinfo to be cached before an appInfo.version is +// actually defined (which prevent failures to be triggered when +// the test run in a non nightly build). +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +const { getTrimmedString } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionTelemetry.sys.mjs" +); +const { ExtensionStorage } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionStorage.sys.mjs" +); +const { ExtensionStorageIDB } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionStorageIDB.sys.mjs" +); +const { TelemetryController } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryController.sys.mjs" +); +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +const { promiseShutdownManager, promiseStartupManager } = AddonTestUtils; + +const { IDB_MIGRATED_PREF_BRANCH, IDB_MIGRATE_RESULT_HISTOGRAM } = + ExtensionStorageIDB; +const CATEGORIES = ["success", "failure"]; +const EVENT_CATEGORY = "extensions.data"; +const EVENT_OBJECT = "storageLocal"; +const EVENT_METHOD = "migrateResult"; +const LEAVE_STORAGE_PREF = "extensions.webextensions.keepStorageOnUninstall"; +const LEAVE_UUID_PREF = "extensions.webextensions.keepUuidOnUninstall"; +const TELEMETRY_EVENTS_FILTER = { + category: "extensions.data", + method: "migrateResult", + object: "storageLocal", +}; + +add_setup(async function setup() { + do_get_profile(); + Services.fog.initializeFOG(); +}); + +async function createExtensionJSONFileWithData(extensionId, data) { + await ExtensionStorage.set(extensionId, data); + const jsonFile = await ExtensionStorage.getFile(extensionId); + await jsonFile._save(); + const oldStorageFilename = ExtensionStorage.getStorageFile(extensionId); + equal( + await IOUtils.exists(oldStorageFilename), + true, + "The old json file has been created" + ); + + return { jsonFile, oldStorageFilename }; +} + +function clearMigrationHistogram() { + const histogram = Services.telemetry.getHistogramById( + IDB_MIGRATE_RESULT_HISTOGRAM + ); + histogram.clear(); + equal( + histogram.snapshot().sum, + 0, + `No data recorded for histogram ${IDB_MIGRATE_RESULT_HISTOGRAM}` + ); +} + +function assertMigrationHistogramCount(category, expectedCount) { + const histogram = Services.telemetry.getHistogramById( + IDB_MIGRATE_RESULT_HISTOGRAM + ); + + equal( + histogram.snapshot().values[CATEGORIES.indexOf(category)], + expectedCount, + `Got the expected count on category "${category}" for histogram ${IDB_MIGRATE_RESULT_HISTOGRAM}` + ); +} + +// Note: for consistency with telemetry event format, this function also +// expects the addon_id to be passed in the event.value property. +function assertMigrateResultGleanEvents(expectedEvents) { + let glean = Glean.extensionsData.migrateResult.testGetValue() ?? []; + equal(glean.length, expectedEvents.length, "Correct number of events."); + + expectedEvents.forEach((expected, i) => + Assert.deepEqual( + glean[i].extra, + { addon_id: expected.value, ...expected.extra }, + "Correct addon_id and event extra properties." + ) + ); + Services.fog.testResetFOG(); +} + +function assertTelemetryEvents(expectedEvents) { + TelemetryTestUtils.assertEvents(expectedEvents, { + category: EVENT_CATEGORY, + method: EVENT_METHOD, + object: EVENT_OBJECT, + }); + assertMigrateResultGleanEvents(expectedEvents); +} + +add_setup(async function setup() { + Services.prefs.setBoolPref(ExtensionStorageIDB.BACKEND_ENABLED_PREF, true); + + await promiseStartupManager(); + + // Telemetry test setup needed to ensure that the builtin events are defined + // and they can be collected and verified. + await TelemetryController.testSetup(); + + // This is actually only needed on Android, because it does not properly support unified telemetry + // and so, if not enabled explicitly here, it would make these tests to fail when running on a + // non-Nightly build. + const oldCanRecordBase = Services.telemetry.canRecordBase; + Services.telemetry.canRecordBase = true; + registerCleanupFunction(() => { + Services.telemetry.canRecordBase = oldCanRecordBase; + }); + + // Clear any telemetry events collected so far. + Services.telemetry.clearEvents(); +}); + +// Test that for newly installed extension the IDB backend is enabled without +// any data migration. +add_task(async function test_no_migration_for_newly_installed_extensions() { + const EXTENSION_ID = "test-no-data-migration@mochi.test"; + + await createExtensionJSONFileWithData(EXTENSION_ID, { + test_old_data: "test_old_value", + }); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + permissions: ["storage"], + browser_specific_settings: { gecko: { id: EXTENSION_ID } }, + }, + async background() { + const data = await browser.storage.local.get(); + browser.test.assertEq( + Object.keys(data).length, + 0, + "Expect the storage.local store to be empty" + ); + browser.test.sendMessage("test-stored-data:done"); + }, + }); + + await extension.startup(); + equal( + ExtensionStorageIDB.isMigratedExtension(extension), + true, + "The newly installed test extension is marked as migrated" + ); + await extension.awaitMessage("test-stored-data:done"); + await extension.unload(); + + // Verify that no data migration have been needed on the newly installed + // extension, by asserting that no telemetry events has been collected. + await TelemetryTestUtils.assertEvents([], TELEMETRY_EVENTS_FILTER); + assertMigrateResultGleanEvents([]); +}); + +// Test that the data migration is still running for a newly installed extension +// if keepStorageOnUninstall is true. +add_task(async function test_data_migration_on_keep_storage_on_uninstall() { + Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, true); + + // Store some fake data in the storage.local file backend before starting the extension. + const EXTENSION_ID = "new-extension-on-keep-storage-on-uninstall@mochi.test"; + await createExtensionJSONFileWithData(EXTENSION_ID, { + test_key_string: "test_value", + }); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + permissions: ["storage"], + browser_specific_settings: { gecko: { id: EXTENSION_ID } }, + }, + async background() { + const storedData = await browser.storage.local.get(); + browser.test.assertEq( + "test_value", + storedData.test_key_string, + "Got the expected data after the storage.local data migration" + ); + browser.test.sendMessage("storage-local-data-migrated"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("storage-local-data-migrated"); + equal( + ExtensionStorageIDB.isMigratedExtension(extension), + true, + "The newly installed test extension is marked as migrated" + ); + await extension.unload(); + + // Verify that the expected telemetry has been recorded. + let expected = { + method: "migrateResult", + value: EXTENSION_ID, + extra: { + backend: "IndexedDB", + data_migrated: "y", + has_jsonfile: "y", + has_olddata: "y", + }, + }; + + await TelemetryTestUtils.assertEvents([expected], TELEMETRY_EVENTS_FILTER); + assertMigrateResultGleanEvents([expected]); + + Services.prefs.clearUserPref(LEAVE_STORAGE_PREF); +}); + +// Test that the old data is migrated successfully to the new storage backend +// and that the original JSONFile has been renamed. +add_task(async function test_storage_local_data_migration() { + const EXTENSION_ID = "extension-to-be-migrated@mozilla.org"; + + // Keep the extension storage and the uuid on uninstall, to verify that no telemetry events + // are being sent for an already migrated extension. + Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, true); + Services.prefs.setBoolPref(LEAVE_UUID_PREF, true); + + const data = { + test_key_string: "test_value1", + test_key_number: 1000, + test_nested_data: { + nested_key: true, + }, + }; + + // Store some fake data in the storage.local file backend before starting the extension. + const { oldStorageFilename } = await createExtensionJSONFileWithData( + EXTENSION_ID, + data + ); + + async function background() { + const storedData = await browser.storage.local.get(); + + browser.test.assertEq( + "test_value1", + storedData.test_key_string, + "Got the expected data after the storage.local data migration" + ); + browser.test.assertEq( + 1000, + storedData.test_key_number, + "Got the expected data after the storage.local data migration" + ); + browser.test.assertEq( + true, + storedData.test_nested_data.nested_key, + "Got the expected data after the storage.local data migration" + ); + + browser.test.sendMessage("storage-local-data-migrated"); + } + + clearMigrationHistogram(); + + let extensionDefinition = { + useAddonManager: "temporary", + manifest: { + permissions: ["storage"], + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionDefinition); + + // Install the extension while the storage.local IDB backend is disabled. + Services.prefs.setBoolPref(ExtensionStorageIDB.BACKEND_ENABLED_PREF, false); + await extension.startup(); + + ok( + !ExtensionStorageIDB.isMigratedExtension(extension), + "The test extension should be using the JSONFile backend" + ); + + // Enabled the storage.local IDB backend and upgrade the extension. + Services.prefs.setBoolPref(ExtensionStorageIDB.BACKEND_ENABLED_PREF, true); + await extension.upgrade({ + ...extensionDefinition, + background, + }); + + await extension.awaitMessage("storage-local-data-migrated"); + + ok( + ExtensionStorageIDB.isMigratedExtension(extension), + "The test extension should be using the IndexedDB backend" + ); + + const storagePrincipal = ExtensionStorageIDB.getStoragePrincipal( + extension.extension + ); + + const idbConn = await ExtensionStorageIDB.open(storagePrincipal); + + equal( + await idbConn.isEmpty(extension.extension), + false, + "Data stored in the ExtensionStorageIDB backend as expected" + ); + + equal( + await IOUtils.exists(oldStorageFilename), + false, + "The old json storage file name should not exist anymore" + ); + + equal( + await IOUtils.exists(`${oldStorageFilename}.migrated`), + true, + "The old json storage file name should have been renamed as .migrated" + ); + + equal( + Services.prefs.getBoolPref( + `${IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID}`, + false + ), + true, + `Got the ${IDB_MIGRATED_PREF_BRANCH} preference set to true as expected` + ); + + assertMigrationHistogramCount("success", 1); + assertMigrationHistogramCount("failure", 0); + + assertTelemetryEvents([ + { + method: "migrateResult", + value: EXTENSION_ID, + extra: { + backend: "IndexedDB", + data_migrated: "y", + has_jsonfile: "y", + has_olddata: "y", + }, + }, + ]); + + equal( + Services.prefs.getBoolPref( + `${IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID}`, + false + ), + true, + `${IDB_MIGRATED_PREF_BRANCH} should still be true on keepStorageOnUninstall=true` + ); + + // Upgrade the extension and check that no telemetry events are being sent + // for an already migrated extension. + await extension.upgrade({ + ...extensionDefinition, + background, + }); + + await extension.awaitMessage("storage-local-data-migrated"); + + // The histogram values are unmodified. + assertMigrationHistogramCount("success", 1); + assertMigrationHistogramCount("failure", 0); + + // No new telemetry events recorded for the extension. + const snapshot = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ); + const filterByCategory = ([timestamp, category]) => + category === EVENT_CATEGORY; + + ok( + !snapshot.parent || snapshot.parent.filter(filterByCategory).length === 0, + "No telemetry events should be recorded for an already migrated extension" + ); + + Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, false); + Services.prefs.setBoolPref(LEAVE_UUID_PREF, false); + + await extension.unload(); + + equal( + Services.prefs.getPrefType(`${IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID}`), + Services.prefs.PREF_INVALID, + `Got the ${IDB_MIGRATED_PREF_BRANCH} preference has been cleared on addon uninstall` + ); +}); + +// Test that the extensionId included in the telemetry event is being trimmed down to 80 chars +// as expected. +add_task(async function test_extensionId_trimmed_in_telemetry_event() { + // Generated extensionId in email-like format, longer than 80 chars. + const EXTENSION_ID = `long.extension.id@${Array(80).fill("a").join("")}`; + + const data = { test_key_string: "test_value" }; + + // Store some fake data in the storage.local file backend before starting the extension. + await createExtensionJSONFileWithData(EXTENSION_ID, data); + + async function background() { + const storedData = await browser.storage.local.get("test_key_string"); + + browser.test.assertEq( + "test_value", + storedData.test_key_string, + "Got the expected data after the storage.local data migration" + ); + + browser.test.sendMessage("storage-local-data-migrated"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + // We don't want the (default) startupReason ADDON_INSTALL because + // that automatically sets the migrated pref and skips migration. + startupReason: "APP_STARTUP", + }); + + await extension.startup(); + + await extension.awaitMessage("storage-local-data-migrated"); + + const expectedTrimmedExtensionId = getTrimmedString(EXTENSION_ID); + + equal( + expectedTrimmedExtensionId.length, + 80, + "The trimmed version of the extensionId should be 80 chars long" + ); + + assertTelemetryEvents([ + { + method: "migrateResult", + value: expectedTrimmedExtensionId, + extra: { + backend: "IndexedDB", + data_migrated: "y", + has_jsonfile: "y", + has_olddata: "y", + }, + }, + ]); + + await extension.unload(); +}); + +// Test that if the old JSONFile data file is corrupted and the old data +// can't be successfully migrated to the new storage backend, then: +// - the new storage backend for that extension is still initialized and enabled +// - any new data is being stored in the new backend +// - the old file is being renamed (with the `.corrupted` suffix that JSONFile.sys.mjs +// adds when it fails to load the data file) and still available on disk. +add_task(async function test_storage_local_corrupted_data_migration() { + const EXTENSION_ID = "extension-corrupted-data-migration@mozilla.org"; + + const invalidData = `{"test_key_string": "test_value1"`; + const oldStorageFilename = ExtensionStorage.getStorageFile(EXTENSION_ID); + + await IOUtils.makeDirectory( + PathUtils.join(PathUtils.profileDir, "browser-extension-data", EXTENSION_ID) + ); + + // Write the json file with some invalid data. + await IOUtils.writeUTF8(oldStorageFilename, invalidData, { flush: true }); + equal( + await IOUtils.readUTF8(oldStorageFilename), + invalidData, + "The old json file has been overwritten with invalid data" + ); + + async function background() { + const storedData = await browser.storage.local.get(); + + browser.test.assertEq( + Object.keys(storedData).length, + 0, + "No data should be found on invalid data migration" + ); + + await browser.storage.local.set({ + test_key_string_on_IDBBackend: "expected-value", + }); + + browser.test.sendMessage("storage-local-data-migrated-and-set"); + } + + clearMigrationHistogram(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + // We don't want the (default) startupReason ADDON_INSTALL because + // that automatically sets the migrated pref and skips migration. + startupReason: "APP_STARTUP", + }); + + await extension.startup(); + + await extension.awaitMessage("storage-local-data-migrated-and-set"); + + const storagePrincipal = ExtensionStorageIDB.getStoragePrincipal( + extension.extension + ); + + const idbConn = await ExtensionStorageIDB.open(storagePrincipal); + + equal( + await idbConn.isEmpty(extension.extension), + false, + "Data stored in the ExtensionStorageIDB backend as expected" + ); + + equal( + await IOUtils.exists(`${oldStorageFilename}.corrupt`), + true, + "The old json storage should still be available if failed to be read" + ); + + // The extension is still migrated successfully to the new backend if the file from the + // original json file was corrupted. + + equal( + Services.prefs.getBoolPref( + `${IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID}`, + false + ), + true, + `Got the ${IDB_MIGRATED_PREF_BRANCH} preference set to true as expected` + ); + + assertMigrationHistogramCount("success", 1); + assertMigrationHistogramCount("failure", 0); + + assertTelemetryEvents([ + { + method: "migrateResult", + value: EXTENSION_ID, + extra: { + backend: "IndexedDB", + data_migrated: "y", + has_jsonfile: "y", + has_olddata: "n", + }, + }, + ]); + + await extension.unload(); +}); + +// Test that if the data migration fails to store the old data into the IndexedDB backend +// then the expected telemetry histogram is being updated. +add_task(async function test_storage_local_data_migration_failure() { + const EXTENSION_ID = "extension-data-migration-failure@mozilla.org"; + + // Create the file under the expected directory tree. + const { jsonFile, oldStorageFilename } = + await createExtensionJSONFileWithData(EXTENSION_ID, {}); + + // Store a fake invalid value which is going to fail to be saved into IndexedDB + // (because it can't be cloned and it is going to raise a DataCloneError), which + // will trigger a data migration failure that we expect to increment the related + // telemetry histogram. + jsonFile.data.set("fake_invalid_key", function () {}); + + async function background() { + await browser.storage.local.set({ + test_key_string_on_JSONFileBackend: "expected-value", + }); + browser.test.sendMessage("storage-local-data-migrated-and-set"); + } + + clearMigrationHistogram(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + // We don't want the (default) startupReason ADDON_INSTALL because + // that automatically sets the migrated pref and skips migration. + startupReason: "APP_STARTUP", + }); + + await extension.startup(); + + await extension.awaitMessage("storage-local-data-migrated-and-set"); + + const storagePrincipal = ExtensionStorageIDB.getStoragePrincipal( + extension.extension + ); + + const idbConn = await ExtensionStorageIDB.open(storagePrincipal); + equal( + await idbConn.isEmpty(extension.extension), + true, + "No data stored in the ExtensionStorageIDB backend as expected" + ); + equal( + await IOUtils.exists(oldStorageFilename), + true, + "The old json storage should still be available if failed to be read" + ); + + await extension.unload(); + + assertTelemetryEvents([ + { + method: "migrateResult", + value: EXTENSION_ID, + extra: { + backend: "JSONFile", + data_migrated: "n", + error_name: "DataCloneError", + has_jsonfile: "y", + has_olddata: "y", + }, + }, + ]); + + assertMigrationHistogramCount("success", 0); + assertMigrationHistogramCount("failure", 1); +}); + +add_task(async function test_migration_aborted_on_shutdown() { + const EXTENSION_ID = "test-migration-aborted-on-shutdown@mochi.test"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + // We don't want the (default) startupReason ADDON_INSTALL because + // that automatically sets the migrated pref and skips migration. + startupReason: "APP_STARTUP", + }); + + await extension.startup(); + + equal( + extension.extension.hasShutdown, + false, + "The extension is still running" + ); + + await extension.unload(); + equal(extension.extension.hasShutdown, true, "The extension has shutdown"); + + // Trigger a data migration after the extension has been unloaded. + const result = await ExtensionStorageIDB.selectBackend({ + extension: extension.extension, + }); + Assert.deepEqual( + result, + { backendEnabled: false }, + "Expect migration to have been aborted" + ); + let expected = { + value: EXTENSION_ID, + extra: { + backend: "JSONFile", + error_name: "DataMigrationAbortedError", + }, + }; + + TelemetryTestUtils.assertEvents([expected], TELEMETRY_EVENTS_FILTER); + assertMigrateResultGleanEvents([expected]); +}); + +add_task(async function test_storage_local_data_migration_clear_pref() { + Services.prefs.clearUserPref(LEAVE_STORAGE_PREF); + Services.prefs.clearUserPref(LEAVE_UUID_PREF); + Services.prefs.clearUserPref(ExtensionStorageIDB.BACKEND_ENABLED_PREF); + await promiseShutdownManager(); + await TelemetryController.testShutdown(); +}); + +add_task(async function setup_quota_manager_testing_prefs() { + Services.prefs.setBoolPref("dom.quotaManager.testing", true); + Services.prefs.setIntPref( + "dom.quotaManager.temporaryStorage.fixedLimit", + 100 + ); + await promiseQuotaManagerServiceReset(); +}); + +add_task( + // TODO: temporarily disabled because it currently perma-fails on + // android builds (Bug 1564871) + { skip_if: () => AppConstants.platform === "android" }, + // eslint-disable-next-line no-use-before-define + test_quota_exceeded_while_migrating_data +); +async function test_quota_exceeded_while_migrating_data() { + const EXT_ID = "test-data-migration-stuck@mochi.test"; + const dataSize = 1000 * 1024; + + await createExtensionJSONFileWithData(EXT_ID, { + data: new Array(dataSize).fill("x").join(""), + }); + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + browser_specific_settings: { gecko: { id: EXT_ID } }, + }, + background() { + browser.test.onMessage.addListener(async (msg, dataSize) => { + if (msg !== "verify-stored-data") { + return; + } + const res = await browser.storage.local.get(); + browser.test.assertEq( + res.data && res.data.length, + dataSize, + "Got the expected data" + ); + browser.test.sendMessage("verify-stored-data:done"); + }); + + browser.test.sendMessage("bg-page:ready"); + }, + // We don't want the (default) startupReason ADDON_INSTALL because + // that automatically sets the migrated pref and skips migration. + startupReason: "APP_STARTUP", + }); + + await extension.startup(); + await extension.awaitMessage("bg-page:ready"); + + extension.sendMessage("verify-stored-data", dataSize); + await extension.awaitMessage("verify-stored-data:done"); + + await ok( + !ExtensionStorageIDB.isMigratedExtension(extension), + "The extension falls back to the JSONFile backend because of the migration failure" + ); + await extension.unload(); + + let expected = { + value: EXT_ID, + extra: { + backend: "JSONFile", + error_name: "QuotaExceededError", + }, + }; + TelemetryTestUtils.assertEvents([expected], TELEMETRY_EVENTS_FILTER); + assertMigrateResultGleanEvents([expected]); + + Services.prefs.clearUserPref("dom.quotaManager.temporaryStorage.fixedLimit"); + await promiseQuotaManagerServiceClear(); + Services.prefs.clearUserPref("dom.quotaManager.testing"); +} diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_local.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_local.js new file mode 100644 index 0000000000..c846494f0c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_local.js @@ -0,0 +1,83 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionStorageIDB: "resource://gre/modules/ExtensionStorageIDB.sys.mjs", +}); + +AddonTestUtils.init(this); + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_local_cache_invalidation() { + function background(checkGet) { + browser.test.onMessage.addListener(async msg => { + if (msg === "set-initial") { + await browser.storage.local.set({ + "test-prop1": "value1", + "test-prop2": "value2", + }); + browser.test.sendMessage("set-initial-done"); + } else if (msg === "check") { + await checkGet("local", "test-prop1", "value1"); + await checkGet("local", "test-prop2", "value2"); + browser.test.sendMessage("check-done"); + } + }); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + }, + background: `(${background})(${checkGetImpl})`, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage("set-initial"); + await extension.awaitMessage("set-initial-done"); + + Services.obs.notifyObservers(null, "extension-invalidate-storage-cache"); + + extension.sendMessage("check"); + await extension.awaitMessage("check-done"); + + await extension.unload(); +}); + +add_task(function test_storage_local_file_backend() { + return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]], () => + test_background_page_storage("local") + ); +}); + +add_task(function test_storage_local_idb_backend() { + return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], () => + test_background_page_storage("local") + ); +}); + +add_task(function test_storage_local_idb_bytes_in_use() { + return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], () => + test_background_storage_area_no_bytes_in_use("local") + ); +}); + +add_task(function test_storage_local_onChanged_event_page() { + return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], () => + test_storage_change_event_page("local") + ); +}); + +add_task(async function test_storage_local_empty_events() { + return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], () => + test_storage_empty_events("local") + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed.js new file mode 100644 index 0000000000..5bf6dcc3bc --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed.js @@ -0,0 +1,212 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + MockRegistry: "resource://testing-common/MockRegistry.sys.mjs", +}); + +const MANIFEST = { + name: "test-storage-managed@mozilla.com", + description: "", + type: "storage", + data: { + null: null, + str: "hello", + obj: { + a: [2, 3], + b: true, + }, + }, +}; + +AddonTestUtils.init(this); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); + + let tmpDir = FileUtils.getDir("TmpD", ["native-manifests"]); + tmpDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + + let dirProvider = { + getFile(property) { + if (property.endsWith("NativeManifests")) { + return tmpDir.clone(); + } + }, + }; + Services.dirsvc.registerProvider(dirProvider); + + let typeSlug = + AppConstants.platform === "linux" ? "managed-storage" : "ManagedStorage"; + await IOUtils.makeDirectory(PathUtils.join(tmpDir.path, typeSlug)); + + let path = PathUtils.join(tmpDir.path, typeSlug, `${MANIFEST.name}.json`); + await IOUtils.writeJSON(path, MANIFEST); + + let registry; + if (AppConstants.platform === "win") { + registry = new MockRegistry(); + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `Software\\\Mozilla\\\ManagedStorage\\${MANIFEST.name}`, + "", + path + ); + } + + registerCleanupFunction(() => { + Services.dirsvc.unregisterProvider(dirProvider); + tmpDir.remove(true); + if (registry) { + registry.shutdown(); + } + }); +}); + +add_task(async function test_storage_managed() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: MANIFEST.name } }, + permissions: ["storage"], + }, + + async background() { + await browser.test.assertRejects( + browser.storage.managed.set({ a: 1 }), + /storage.managed is read-only/, + "browser.storage.managed.set() rejects because it's read only" + ); + + await browser.test.assertRejects( + browser.storage.managed.remove("str"), + /storage.managed is read-only/, + "browser.storage.managed.remove() rejects because it's read only" + ); + + await browser.test.assertRejects( + browser.storage.managed.clear(), + /storage.managed is read-only/, + "browser.storage.managed.clear() rejects because it's read only" + ); + + browser.test.sendMessage( + "results", + await Promise.all([ + browser.storage.managed.get(), + browser.storage.managed.get("str"), + browser.storage.managed.get(["null", "obj"]), + browser.storage.managed.get({ str: "a", num: 2 }), + ]) + ); + }, + }); + + await extension.startup(); + deepEqual(await extension.awaitMessage("results"), [ + MANIFEST.data, + { str: "hello" }, + { null: null, obj: MANIFEST.data.obj }, + { str: "hello", num: 2 }, + ]); + await extension.unload(); +}); + +add_task(async function test_storage_managed_from_content_script() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: MANIFEST.name } }, + permissions: ["storage"], + content_scripts: [ + { + js: ["contentscript.js"], + matches: ["*://*/*"], + run_at: "document_end", + }, + ], + }, + + files: { + "contentscript.js": async function () { + browser.test.sendMessage( + "results", + await browser.storage.managed.get() + ); + }, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + deepEqual(await extension.awaitMessage("results"), MANIFEST.data); + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_manifest_not_found() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + }, + + async background() { + await browser.test.assertRejects( + browser.storage.managed.get({ a: 1 }), + /Managed storage manifest not found/, + "browser.storage.managed.get() rejects when without manifest" + ); + + browser.test.notifyPass(); + }, + }); + + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task(async function test_manifest_not_found() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + }, + + async background() { + const dummyListener = () => {}; + browser.storage.managed.onChanged.addListener(dummyListener); + browser.test.assertTrue( + browser.storage.managed.onChanged.hasListener(dummyListener), + "addListener works according to hasListener" + ); + browser.storage.managed.onChanged.removeListener(dummyListener); + + // We should get a warning for each registration. + browser.storage.managed.onChanged.addListener(() => {}); + browser.storage.managed.onChanged.addListener(() => {}); + browser.storage.managed.onChanged.addListener(() => {}); + + // Invoke the storage.managed API to make sure that we have made a + // round trip to the parent process and back. This is because event + // registration is async but we cannot await (bug 1300234). + await browser.test.assertRejects( + browser.storage.managed.get({ a: 1 }), + /Managed storage manifest not found/, + "browser.storage.managed.get() rejects when without manifest" + ); + + browser.test.notifyPass(); + }, + }); + + let { messages } = await promiseConsoleOutput(async () => { + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); + }); + const UNSUP_EVENT_WARNING = `attempting to use listener "storage.managed.onChanged", which is unimplemented`; + messages = messages.filter(msg => msg.message.includes(UNSUP_EVENT_WARNING)); + Assert.equal(messages.length, 4, "Expected msg for each addListener call"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed_policy.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed_policy.js new file mode 100644 index 0000000000..169bef4139 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed_policy.js @@ -0,0 +1,44 @@ +"use strict"; + +const { EnterprisePolicyTesting } = ChromeUtils.importESModule( + "resource://testing-common/EnterprisePolicyTesting.sys.mjs" +); + +// Load policy engine +Services.policies; // eslint-disable-line no-unused-expressions + +AddonTestUtils.init(this); + +add_task(async function test_storage_managed_policy() { + await ExtensionTestUtils.startAddonManager(); + + await EnterprisePolicyTesting.setupPolicyEngineWithJson({ + policies: { + "3rdparty": { + Extensions: { + "test-storage-managed-policy@mozilla.com": { + string: "value", + }, + }, + }, + }, + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id: "test-storage-managed-policy@mozilla.com" }, + }, + permissions: ["storage"], + }, + + async background() { + let str = await browser.storage.managed.get("string"); + browser.test.sendMessage("results", str); + }, + }); + + await extension.startup(); + deepEqual(await extension.awaitMessage("results"), { string: "value" }); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_quota_exceeded_errors.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_quota_exceeded_errors.js new file mode 100644 index 0000000000..b03646d939 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_quota_exceeded_errors.js @@ -0,0 +1,80 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionStorageIDB: "resource://gre/modules/ExtensionStorageIDB.sys.mjs", +}); + +const LEAVE_STORAGE_PREF = "extensions.webextensions.keepStorageOnUninstall"; +const LEAVE_UUID_PREF = "extensions.webextensions.keepUuidOnUninstall"; + +AddonTestUtils.init(this); + +add_task(async function setup() { + // Ensure that the IDB backend is enabled. + Services.prefs.setBoolPref("ExtensionStorageIDB.BACKEND_ENABLED_PREF", true); + + Services.prefs.setBoolPref("dom.quotaManager.testing", true); + Services.prefs.setIntPref( + "dom.quotaManager.temporaryStorage.fixedLimit", + 100 + ); + await promiseQuotaManagerServiceReset(); + + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_storage_local_set_quota_exceeded_error() { + const EXT_ID = "test-quota-exceeded@mochi.test"; + + const extensionDef = { + manifest: { + permissions: ["storage"], + browser_specific_settings: { gecko: { id: EXT_ID } }, + }, + async background() { + const data = new Array(1000 * 1024).fill("x").join(""); + await browser.test.assertRejects( + browser.storage.local.set({ data }), + /QuotaExceededError/, + "Got a rejection with the expected error message" + ); + browser.test.sendMessage("data-stored"); + }, + }; + + Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, true); + Services.prefs.setBoolPref(LEAVE_UUID_PREF, true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref(LEAVE_STORAGE_PREF); + Services.prefs.clearUserPref(LEAVE_UUID_PREF); + }); + + const extension = ExtensionTestUtils.loadExtension(extensionDef); + + // Run test on a test extension being migrated to the IDB backend. + await extension.startup(); + await extension.awaitMessage("data-stored"); + + ok( + ExtensionStorageIDB.isMigratedExtension(extension), + "The extension has been successfully migrated to the IDB backend" + ); + await extension.unload(); + + // Run again on a test extension already already migrated to the IDB backend. + const extensionUpdated = ExtensionTestUtils.loadExtension(extensionDef); + await extensionUpdated.startup(); + ok( + ExtensionStorageIDB.isMigratedExtension(extension), + "The extension has been successfully migrated to the IDB backend" + ); + await extensionUpdated.awaitMessage("data-stored"); + + await extensionUpdated.unload(); + + Services.prefs.clearUserPref("dom.quotaManager.temporaryStorage.fixedLimit"); + await promiseQuotaManagerServiceClear(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sanitizer.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sanitizer.js new file mode 100644 index 0000000000..6c69ad1a4c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sanitizer.js @@ -0,0 +1,107 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + Sanitizer: "resource:///modules/Sanitizer.sys.mjs", +}); + +async function test_sanitize_offlineApps(storageHelpersScript) { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + background: { + scripts: ["storageHelpers.js", "background.js"], + }, + }, + files: { + "storageHelpers.js": storageHelpersScript, + "background.js": function () { + browser.test.onMessage.addListener(async (msg, args) => { + let result = {}; + switch (msg) { + case "set-storage-data": + await window.testWriteKey(...args); + break; + case "get-storage-data": + const value = await window.testReadKey(args[0]); + browser.test.assertEq(args[1], value, "Got the expected value"); + break; + default: + browser.test.fail(`Unexpected test message received: ${msg}`); + } + + browser.test.sendMessage(`${msg}:done`, result); + }); + }, + }, + }); + + await extension.startup(); + + extension.sendMessage("set-storage-data", ["aKey", "aValue"]); + await extension.awaitMessage("set-storage-data:done"); + + await extension.sendMessage("get-storage-data", ["aKey", "aValue"]); + await extension.awaitMessage("get-storage-data:done"); + + info("Verify the extension data not cleared by offlineApps Sanitizer"); + await Sanitizer.sanitize(["offlineApps"]); + await extension.sendMessage("get-storage-data", ["aKey", "aValue"]); + await extension.awaitMessage("get-storage-data:done"); + + await extension.unload(); +} + +add_task(async function test_sanitize_offlineApps_extension_indexedDB() { + await test_sanitize_offlineApps(function indexedDBStorageHelpers() { + const getIDBStore = () => + new Promise(resolve => { + let dbreq = window.indexedDB.open("TestDB"); + dbreq.onupgradeneeded = () => + dbreq.result.createObjectStore("TestStore"); + dbreq.onsuccess = () => resolve(dbreq.result); + }); + + // Export writeKey and readKey storage test helpers. + window.testWriteKey = (k, v) => + getIDBStore().then(db => { + const tx = db.transaction("TestStore", "readwrite"); + const store = tx.objectStore("TestStore"); + return new Promise((resolve, reject) => { + tx.oncomplete = evt => resolve(evt.target.result); + tx.onerror = evt => reject(evt.target.error); + store.add(v, k); + }); + }); + window.testReadKey = k => + getIDBStore().then(db => { + const tx = db.transaction("TestStore"); + const store = tx.objectStore("TestStore"); + return new Promise((resolve, reject) => { + const req = store.get(k); + tx.oncomplete = evt => resolve(req.result); + tx.onerror = evt => reject(evt.target.error); + }); + }); + }); +}); + +add_task( + { + // Skip this test if LSNG is not enabled (because this test is only + // going to pass when nextgen local storage is being used). + skip_if: () => + Services.prefs.getBoolPref( + "dom.storage.enable_unsupported_legacy_implementation" + ), + }, + async function test_sanitize_offlineApps_extension_localStorage() { + await test_sanitize_offlineApps(function indexedDBStorageHelpers() { + // Export writeKey and readKey storage test helpers. + window.testWriteKey = (k, v) => window.localStorage.setItem(k, v); + window.testReadKey = k => window.localStorage.getItem(k); + }); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_session.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_session.js new file mode 100644 index 0000000000..631edc5492 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_session.js @@ -0,0 +1,165 @@ +"use strict"; + +AddonTestUtils.init(this); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_setup(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +const CAN_CRASH_EXTENSIONS = WebExtensionPolicy.useRemoteWebExtensions; + +add_setup( + // Crash dumps are only generated when MOZ_CRASHREPORTER is set. + // Crashes are only generated if tests can crash the extension process. + { skip_if: () => !AppConstants.MOZ_CRASHREPORTER || !CAN_CRASH_EXTENSIONS }, + setup_crash_reporter_override_and_cleaner +); + +add_task(async function test_storage_session() { + await test_background_page_storage("session"); +}); + +add_task(async function test_storage_session_onChanged_event_page() { + await test_storage_change_event_page("session"); +}); + +add_task(async function test_storage_session_persistance() { + await test_storage_after_reload("session", { expectPersistency: false }); +}); + +add_task(async function test_storage_session_empty_events() { + await test_storage_empty_events("session"); +}); + +add_task(async function test_storage_session_contentscript() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + }, + ], + permissions: ["storage"], + }, + background() { + let events = []; + browser.storage.onChanged.addListener((_, area) => { + events.push(area); + }); + browser.test.onMessage.addListener(_msg => { + browser.test.sendMessage("bg-events", events.join()); + }); + browser.runtime.onMessage.addListener(async _msg => { + await browser.storage.local.set({ foo: "local" }); + await browser.storage.session.set({ foo: "session" }); + await browser.storage.sync.set({ foo: "sync" }); + browser.test.sendMessage("done"); + }); + }, + files: { + "content_script.js"() { + let events = []; + browser.storage.onChanged.addListener((_, area) => { + events.push(area); + }); + browser.test.onMessage.addListener(_msg => { + browser.test.sendMessage("cs-events", events.join()); + }); + + browser.test.assertEq( + typeof browser.storage.session, + "undefined", + "Expect storage.session to not be available in content scripts" + ); + browser.runtime.sendMessage("ready"); + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + await extension.awaitMessage("done"); + extension.sendMessage("_getEvents"); + + equal( + "local,sync", + await extension.awaitMessage("cs-events"), + "Content script doesn't see storage.onChanged events from the session area." + ); + equal( + "local,session,sync", + await extension.awaitMessage("bg-events"), + "Background receives onChanged events from all storage areas." + ); + + await extension.unload(); + await contentPage.close(); +}); + +async function test_storage_session_after_crash({ persistent }) { + async function background() { + let before = await browser.storage.session.get(); + + browser.storage.session.set({ count: (before.count ?? 0) + 1 }); + + // Roundtrip the data through the parent process. + let after = await browser.storage.session.get(); + + browser.test.sendMessage("data", { before, after }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + background: { persistent }, + }, + background, + }); + + await extension.startup(); + + info(`Testing storage.session after crash with persistent=${persistent}`); + + { + let { before, after } = await extension.awaitMessage("data"); + + equal(JSON.stringify(before), "{}", "Initial before storage is empty."); + equal(after.count, 1, "After storage counter is correct."); + } + + info("Crashing the extension process."); + await crashExtensionBackground(extension); + await extension.wakeupBackground(); + + { + let { before, after } = await extension.awaitMessage("data"); + + equal(before.count, 1, "Before storage counter is correct."); + equal(after.count, 2, "After storage counter is correct."); + } + + await extension.unload(); +} + +add_task( + { skip_if: () => !CAN_CRASH_EXTENSIONS }, + function test_storage_session_after_crash_persistent() { + return test_storage_session_after_crash({ persistent: true }); + } +); + +add_task( + { skip_if: () => !CAN_CRASH_EXTENSIONS }, + function test_storage_session_after_crash_event_page() { + return test_storage_session_after_crash({ persistent: false }); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js new file mode 100644 index 0000000000..e28af80d0a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js @@ -0,0 +1,35 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +Services.prefs.setBoolPref("webextensions.storage.sync.kinto", false); + +AddonTestUtils.init(this); + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(test_config_flag_needed); + +add_task(test_sync_reloading_extensions_works); + +add_task(function test_storage_sync() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_background_page_storage("sync") + ); +}); + +add_task(test_storage_sync_requires_real_id); + +add_task(function test_bytes_in_use() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_background_storage_area_with_bytes_in_use("sync", true) + ); +}); + +add_task(function test_storage_onChanged_event_page() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_storage_change_event_page("sync") + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js new file mode 100644 index 0000000000..9e26eedfcf --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js @@ -0,0 +1,2320 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// This is a kinto-specific test... +Services.prefs.setBoolPref("webextensions.storage.sync.kinto", true); + +do_get_profile(); // so we can use FxAccounts + +const { HttpServer } = ChromeUtils.importESModule( + "resource://testing-common/httpd.sys.mjs" +); +const { CommonUtils } = ChromeUtils.importESModule( + "resource://services-common/utils.sys.mjs" +); +const { + ExtensionStorageSyncKinto: ExtensionStorageSync, + KintoStorageTestUtils: { + cleanUpForContext, + CollectionKeyEncryptionRemoteTransformer, + CryptoCollection, + idToKey, + keyToId, + KeyRingEncryptionRemoteTransformer, + }, +} = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionStorageSyncKinto.sys.mjs" +); +const { BulkKeyBundle } = ChromeUtils.importESModule( + "resource://services-sync/keys.sys.mjs" +); +const { FxAccountsKeys } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsKeys.sys.mjs" +); +const { Utils } = ChromeUtils.importESModule( + "resource://services-sync/util.sys.mjs" +); + +const { createAppInfo, promiseStartupManager } = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "69"); + +function handleCannedResponse(cannedResponse, request, response) { + response.setStatusLine( + null, + cannedResponse.status.status, + cannedResponse.status.statusText + ); + // send the headers + for (let headerLine of cannedResponse.sampleHeaders) { + let headerElements = headerLine.split(":"); + response.setHeader(headerElements[0], headerElements[1].trimLeft()); + } + response.setHeader("Date", new Date().toUTCString()); + + response.write(cannedResponse.responseBody); +} + +function collectionPath(collectionId) { + return `/buckets/default/collections/${collectionId}`; +} + +function collectionRecordsPath(collectionId) { + return `/buckets/default/collections/${collectionId}/records`; +} + +class KintoServer { + constructor() { + // Set up an HTTP Server + this.httpServer = new HttpServer(); + this.httpServer.start(-1); + + // Set<Object> corresponding to records that might be served. + // The format of these objects is defined in the documentation for #addRecord. + this.records = []; + + // Collections that we have set up access to (see `installCollection`). + this.collections = new Set(); + + // ETag to serve with responses + this.etag = 1; + + this.port = this.httpServer.identity.primaryPort; + + // POST requests we receive from the client go here + this.posts = []; + // DELETEd buckets will go here. + this.deletedBuckets = []; + // Anything in here will force the next POST to generate a conflict + this.conflicts = []; + // If this is true, reject the next request with a 401 + this.rejectNextAuthResponse = false; + this.failedAuths = []; + + this.installConfigPath(); + this.installBatchPath(); + this.installCatchAll(); + } + + clearPosts() { + this.posts = []; + } + + getPosts() { + return this.posts; + } + + getDeletedBuckets() { + return this.deletedBuckets; + } + + rejectNextAuthWith(response) { + this.rejectNextAuthResponse = response; + } + + checkAuth(request, response) { + equal(request.getHeader("Authorization"), "Bearer some-access-token"); + + if (this.rejectNextAuthResponse) { + response.setStatusLine(null, 401, "Unauthorized"); + response.write(this.rejectNextAuthResponse); + this.rejectNextAuthResponse = false; + this.failedAuths.push(request); + return true; + } + return false; + } + + installConfigPath() { + const configPath = "/v1/"; + const responseBody = JSON.stringify({ + settings: { batch_max_requests: 25 }, + url: `http://localhost:${this.port}/v1/`, + documentation: "https://kinto.readthedocs.org/", + version: "1.5.1", + commit: "cbc6f58", + hello: "kinto", + }); + const configResponse = { + sampleHeaders: [ + "Access-Control-Allow-Origin: *", + "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff", + "Content-Type: application/json; charset=UTF-8", + "Server: waitress", + ], + status: { status: 200, statusText: "OK" }, + responseBody: responseBody, + }; + + function handleGetConfig(request, response) { + if (request.method != "GET") { + dump(`ARGH, got ${request.method}\n`); + } + return handleCannedResponse(configResponse, request, response); + } + + this.httpServer.registerPathHandler(configPath, handleGetConfig); + } + + installBatchPath() { + const batchPath = "/v1/batch"; + + function handlePost(request, response) { + if (this.checkAuth(request, response)) { + return; + } + + let bodyStr = CommonUtils.readBytesFromInputStream( + request.bodyInputStream + ); + let body = JSON.parse(bodyStr); + let defaults = body.defaults; + for (let req of body.requests) { + let headers = Object.assign( + {}, + (defaults && defaults.headers) || {}, + req.headers + ); + this.posts.push(Object.assign({}, req, { headers })); + } + + response.setStatusLine(null, 200, "OK"); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("Date", new Date().toUTCString()); + + let postResponse = { + responses: body.requests.map(req => { + let oneBody; + if (req.method == "DELETE") { + let id = req.path.match( + /^\/buckets\/default\/collections\/.+\/records\/(.+)$/ + )[1]; + oneBody = { + data: { + deleted: true, + id: id, + last_modified: this.etag, + }, + }; + } else { + oneBody = { + data: Object.assign({}, req.body.data, { + last_modified: this.etag, + }), + permissions: [], + }; + } + + return { + path: req.path, + status: 201, // FIXME -- only for new posts?? + headers: { ETag: 3000 }, // FIXME??? + body: oneBody, + }; + }), + }; + + if (this.conflicts.length) { + const nextConflict = this.conflicts.shift(); + if (!nextConflict.transient) { + this.records.push(nextConflict); + } + const { data } = nextConflict; + postResponse = { + responses: body.requests.map(req => { + return { + path: req.path, + status: 412, + headers: { ETag: this.etag }, // is this correct?? + body: { + details: { + existing: data, + }, + }, + }; + }), + }; + } + + response.write(JSON.stringify(postResponse)); + + // "sampleHeaders": [ + // "Access-Control-Allow-Origin: *", + // "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff", + // "Server: waitress", + // "Etag: \"4000\"" + // ], + } + + this.httpServer.registerPathHandler(batchPath, handlePost.bind(this)); + } + + installCatchAll() { + this.httpServer.registerPathHandler("/", (request, response) => { + dump( + `got request: ${request.method}:${request.path}?${request.queryString}\n` + ); + dump( + `${CommonUtils.readBytesFromInputStream(request.bodyInputStream)}\n` + ); + }); + } + + /** + * Add a record to those that can be served by this server. + * + * @param {object} properties An object describing the record that + * should be served. The properties of this object are: + * - collectionId {string} This record should only be served if a + * request is for this collection. + * - predicate {Function} If present, this record should only be served if the + * predicate returns true. The predicate will be called with + * {request: Request, response: Response, since: number, server: KintoServer}. + * - data {string} The record to serve. + * - conflict {boolean} If present and true, this record is added to + * "conflicts" and won't be served, but will cause a conflict on + * the next push. + */ + addRecord(properties) { + if (!properties.conflict) { + this.records.push(properties); + } else { + this.conflicts.push(properties); + } + + this.installCollection(properties.collectionId); + } + + /** + * Tell the server to set up a route for this collection. + * + * This will automatically be called for any collection to which you `addRecord`. + * + * @param {string} collectionId the collection whose route we + * should set up. + */ + installCollection(collectionId) { + if (this.collections.has(collectionId)) { + return; + } + this.collections.add(collectionId); + const remoteCollectionPath = + "/v1" + collectionPath(encodeURIComponent(collectionId)); + this.httpServer.registerPathHandler( + remoteCollectionPath, + this.handleGetCollection.bind(this, collectionId) + ); + const remoteRecordsPath = + "/v1" + collectionRecordsPath(encodeURIComponent(collectionId)); + this.httpServer.registerPathHandler( + remoteRecordsPath, + this.handleGetRecords.bind(this, collectionId) + ); + } + + handleGetCollection(collectionId, request, response) { + if (this.checkAuth(request, response)) { + return; + } + + response.setStatusLine(null, 200, "OK"); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("Date", new Date().toUTCString()); + response.write( + JSON.stringify({ + data: { + id: collectionId, + }, + }) + ); + } + + handleGetRecords(collectionId, request, response) { + if (this.checkAuth(request, response)) { + return; + } + + if (request.method != "GET") { + do_throw(`only GET is supported on ${request.path}`); + } + + let sinceMatch = request.queryString.match(/(^|&)_since=(\d+)/); + let since = sinceMatch && parseInt(sinceMatch[2], 10); + + response.setStatusLine(null, 200, "OK"); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("Date", new Date().toUTCString()); + response.setHeader("ETag", this.etag.toString()); + + const records = this.records + .filter(properties => { + if (properties.collectionId != collectionId) { + return false; + } + + if (properties.predicate) { + const predAllowed = properties.predicate({ + request: request, + response: response, + since: since, + server: this, + }); + if (!predAllowed) { + return false; + } + } + + return true; + }) + .map(properties => properties.data); + + const body = JSON.stringify({ + data: records, + }); + response.write(body); + } + + installDeleteBucket() { + this.httpServer.registerPrefixHandler( + "/v1/buckets/", + (request, response) => { + if (request.method != "DELETE") { + dump( + `got a non-delete action on bucket: ${request.method} ${request.path}\n` + ); + return; + } + + const noPrefix = request.path.slice("/v1/buckets/".length); + const [bucket, afterBucket] = noPrefix.split("/", 1); + if (afterBucket && afterBucket != "") { + dump( + `got a delete for a non-bucket: ${request.method} ${request.path}\n` + ); + } + + this.deletedBuckets.push(bucket); + // Fake like this actually deletes the records. + this.records = []; + + response.write( + JSON.stringify({ + data: { + deleted: true, + last_modified: 1475161309026, + id: "b09f1618-d789-302d-696e-74ec53ee18a8", // FIXME + }, + }) + ); + } + ); + } + + // Utility function to install a keyring at the start of a test. + async installKeyRing(fxaService, keysData, salts, etag, properties) { + const keysRecord = { + id: "keys", + keys: keysData, + salts: salts, + last_modified: etag, + }; + this.etag = etag; + const transformer = new KeyRingEncryptionRemoteTransformer(fxaService); + return this.encryptAndAddRecord( + transformer, + Object.assign({}, properties, { + collectionId: "storage-sync-crypto", + data: keysRecord, + }) + ); + } + + encryptAndAddRecord(transformer, properties) { + return transformer.encode(properties.data).then(encrypted => { + this.addRecord(Object.assign({}, properties, { data: encrypted })); + }); + } + + stop() { + this.httpServer.stop(() => {}); + } +} + +/** + * Predicate that represents a record appearing at some time. + * Requests with "_since" before this time should see this record, + * unless the server itself isn't at this time yet (etag is before + * this time). + * + * Requests with _since after this time shouldn't see this record any + * more, since it hasn't changed after this time. + * + * @param {int} startTime the etag at which time this record should + * start being available (and thus, the predicate should start + * returning true) + * @returns {Function} + */ +function appearsAt(startTime) { + return function ({ since, server }) { + return since < startTime && startTime < server.etag; + }; +} + +// Run a block of code with access to a KintoServer. +async function withServer(f) { + let server = new KintoServer(); + // Point the sync.storage client to use the test server we've just started. + Services.prefs.setCharPref( + "webextensions.storage.sync.serverURL", + `http://localhost:${server.port}/v1` + ); + try { + await f(server); + } finally { + server.stop(); + } +} + +// Run a block of code with access to both a sync context and a +// KintoServer. This is meant as a workaround for eslint's refusal to +// let me have 5 nested callbacks. +async function withContextAndServer(f) { + await withSyncContext(async function (context) { + await withServer(async function (server) { + await f(context, server); + }); + }); +} + +// Run a block of code with fxa mocked out to return a specific user. +// Calls the given function with an ExtensionStorageSync instance that +// was constructed using a mocked FxAccounts instance. +async function withSignedInUser(user, f) { + let fxaServiceMock = { + getSignedInUser() { + return Promise.resolve({ uid: user.uid }); + }, + getOAuthToken() { + return Promise.resolve("some-access-token"); + }, + checkAccountStatus() { + return Promise.resolve(true); + }, + removeCachedOAuthToken() { + return Promise.resolve(); + }, + keys: { + getKeyForScope(scope) { + return Promise.resolve({ ...user.scopedKeys[scope] }); + }, + kidAsHex(jwk) { + return new FxAccountsKeys({}).kidAsHex(jwk); + }, + }, + }; + + let telemetryMock = { + _calls: [], + _histograms: {}, + scalarSet(name, value) { + this._calls.push({ method: "scalarSet", name, value }); + }, + keyedScalarSet(name, key, value) { + this._calls.push({ method: "keyedScalarSet", name, key, value }); + }, + getKeyedHistogramById(name) { + let self = this; + return { + add(key, value) { + if (!self._histograms[name]) { + self._histograms[name] = []; + } + self._histograms[name].push(value); + }, + }; + }, + }; + let extensionStorageSync = new ExtensionStorageSync( + fxaServiceMock, + telemetryMock + ); + await f(extensionStorageSync, fxaServiceMock); +} + +// Some assertions that make it easier to write tests about what was +// posted and when. + +// Assert that a post in a batch was made with the correct access token. +// This should be true of all requests, so this is usually called from +// another assertion. +function assertAuthenticatedPost(post) { + equal(post.headers.Authorization, "Bearer some-access-token"); +} + +// Assert that this post was made with the correct request headers to +// create a new resource while protecting against someone else +// creating it at the same time (in other words, "If-None-Match: *"). +// Also calls assertAuthenticatedPost(post). +function assertPostedNewRecord(post) { + assertAuthenticatedPost(post); + equal(post.headers["If-None-Match"], "*"); +} + +// Assert that this post was made with the correct request headers to +// update an existing resource while protecting against concurrent +// modification (in other words, `If-Match: "${etag}"`). +// Also calls assertAuthenticatedPost(post). +function assertPostedUpdatedRecord(post, since) { + assertAuthenticatedPost(post); + equal(post.headers["If-Match"], `"${since}"`); +} + +// Assert that this post was an encrypted keyring, and produce the +// decrypted body. Sanity check the body while we're here. +const assertPostedEncryptedKeys = async function (fxaService, post) { + equal(post.path, collectionRecordsPath("storage-sync-crypto") + "/keys"); + + let body = await new KeyRingEncryptionRemoteTransformer(fxaService).decode( + post.body.data + ); + ok(body.keys, `keys object should be present in decoded body`); + ok(body.keys.default, `keys object should have a default key`); + ok(body.salts, `salts object should be present in decoded body`); + return body; +}; + +// assertEqual, but for keyring[extensionId] == key. +function assertKeyRingKey(keyRing, extensionId, expectedKey, message) { + if (!message) { + message = `expected keyring's key for ${extensionId} to match ${expectedKey.keyPairB64}`; + } + ok( + keyRing.hasKeysFor([extensionId]), + `expected keyring to have a key for ${extensionId}\n` + ); + deepEqual( + keyRing.keyForCollection(extensionId).keyPairB64, + expectedKey.keyPairB64, + message + ); +} + +// Assert that this post was posted for a given extension. +const assertExtensionRecord = async function ( + fxaService, + post, + extension, + key +) { + const extensionId = extension.id; + const cryptoCollection = new CryptoCollection(fxaService); + const hashedId = + "id-" + + (await cryptoCollection.hashWithExtensionSalt(keyToId(key), extensionId)); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + const transformer = new CollectionKeyEncryptionRemoteTransformer( + cryptoCollection, + await cryptoCollection.getKeyRing(), + extensionId + ); + equal( + post.path, + `${collectionRecordsPath(collectionId)}/${hashedId}`, + "decrypted data should be posted to path corresponding to its key" + ); + let decoded = await transformer.decode(post.body.data); + equal( + decoded.key, + key, + "decrypted data should have a key attribute corresponding to the extension data key" + ); + return decoded; +}; + +// Tests using this ID will share keys in local storage, so be careful. +const defaultExtensionId = "{13bdde76-4dc7-11e6-9bdc-54ee758d6342}"; +const defaultExtension = { id: defaultExtensionId }; + +const loggedInUser = { + uid: "0123456789abcdef0123456789abcdef", + scopedKeys: { + "sync:addon_storage": { + kid: "1234567890123-I1DLqPztWi-647HxgLr4YPePZUK-975wn9qWzT49yAA", + k: "Y_kFdXfAS7u58MP9hbXUAytg4T7cH43TCb9DBdZvLMMS3eFs5GAhpJb3E5UNCmxWbOGBUhpEcm576Xz1d7MbMQ", + kty: "oct", + }, + }, + oauthTokens: { + "sync:addon_storage": { + token: "some-access-token", + }, + }, +}; + +function uuid() { + const uuidgen = Services.uuid; + return uuidgen.generateUUID().toString(); +} + +add_task(async function test_setup() { + await promiseStartupManager(); +}); + +add_task(async function test_single_initialization() { + // Check if we're calling openConnection too often. + const { FirefoxAdapter } = ChromeUtils.importESModule( + "resource://services-common/kinto-storage-adapter.sys.mjs" + ); + const origOpenConnection = FirefoxAdapter.openConnection; + let callCount = 0; + FirefoxAdapter.openConnection = function (...args) { + ++callCount; + return origOpenConnection.apply(this, args); + }; + function background() { + let promises = ["foo", "bar", "baz", "quux"].map(key => + browser.storage.sync.get(key) + ); + Promise.all(promises).then(() => + browser.test.notifyPass("initialize once") + ); + } + try { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + }, + background: `(${background})()`, + }); + + await extension.startup(); + await extension.awaitFinish("initialize once"); + await extension.unload(); + equal( + callCount, + 1, + "Initialized FirefoxAdapter connection and Kinto exactly once" + ); + } finally { + FirefoxAdapter.openConnection = origOpenConnection; + } +}); + +add_task(async function test_key_to_id() { + equal(keyToId("foo"), "key-foo"); + equal(keyToId("my-new-key"), "key-my_2D_new_2D_key"); + equal(keyToId(""), "key-"); + equal(keyToId("™"), "key-_2122_"); + equal(keyToId("\b"), "key-_8_"); + equal(keyToId("abc\ndef"), "key-abc_A_def"); + equal(keyToId("Kinto's fancy_string"), "key-Kinto_27_s_20_fancy_5F_string"); + + const KEYS = ["foo", "my-new-key", "", "Kinto's fancy_string", "™", "\b"]; + for (let key of KEYS) { + equal(idToKey(keyToId(key)), key); + } + + equal(idToKey("hi"), null); + equal(idToKey("-key-hi"), null); + equal(idToKey("key--abcd"), null); + equal(idToKey("key-%"), null); + equal(idToKey("key-_HI"), null); + equal(idToKey("key-_HI_"), null); + equal(idToKey("key-"), ""); + equal(idToKey("key-1"), "1"); + equal(idToKey("key-_2D_"), "-"); +}); + +add_task(async function test_extension_id_to_collection_id() { + const extensionId = "{9419cce6-5435-11e6-84bf-54ee758d6342}"; + // FIXME: this doesn't actually require the signed in user, but the + // extensionIdToCollectionId method exists on CryptoCollection, + // which needs an fxaService to be instantiated. + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + // Fake a static keyring since the server doesn't exist. + const salt = "Scgx8RJ8Y0rxMGFYArUiKeawlW+0zJyFmtTDvro9qPo="; + const cryptoCollection = new CryptoCollection(fxaService); + await cryptoCollection._setSalt(extensionId, salt); + + equal( + await cryptoCollection.extensionIdToCollectionId(extensionId), + "ext-0_QHA1P93_yJoj7ONisrR0lW6uN4PZ3Ii-rT-QOjtvo" + ); + } + ); +}); + +add_task(async function ensureCanSync_clearAll() { + // A test extension that will not have any active context around + // but it is returned from a call to AddonManager.getExtensionsByType. + const extensionId = "test-wipe-on-enabled-and-synced@mochi.test"; + const testExtension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + permissions: ["storage"], + browser_specific_settings: { gecko: { id: extensionId } }, + }, + }); + + await testExtension.startup(); + + // Retrieve the Extension class instance from the test extension. + const { extension } = testExtension; + + // Another test extension that will have an active extension context. + const extensionId2 = "test-wipe-on-active-context@mochi.test"; + const extension2 = { id: extensionId2 }; + + await withContextAndServer(async function (context, server) { + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + async function assertSetAndGetData(extension, data) { + await extensionStorageSync.set(extension, data, context); + let storedData = await extensionStorageSync.get( + extension, + Object.keys(data), + context + ); + const extId = extensionId; + deepEqual( + storedData, + data, + `${extId} should get back the data we set` + ); + } + + async function assertDataCleared(extension, keys) { + const storedData = await extensionStorageSync.get( + extension, + keys, + context + ); + deepEqual( + storedData, + {}, + `${extension.id} should have lost the data` + ); + } + + server.installCollection("storage-sync-crypto"); + server.etag = 1000; + + let newKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + extensionId2, + ]); + ok( + newKeys.hasKeysFor([extensionId]), + `key isn't present for ${extensionId}` + ); + ok( + newKeys.hasKeysFor([extensionId2]), + `key isn't present for ${extensionId2}` + ); + + let posts = server.getPosts(); + equal(posts.length, 1); + assertPostedNewRecord(posts[0]); + + await assertSetAndGetData(extension, { "my-key": 1 }); + await assertSetAndGetData(extension2, { "my-key": 2 }); + + // Call cleanup for the first extension, to double check it has + // been wiped out even without an active extension context. + cleanUpForContext(extension, context); + + // clear everything. + await extensionStorageSync.clearAll(); + + // Assert that the data is gone for both the extensions. + await assertDataCleared(extension, ["my-key"]); + await assertDataCleared(extension2, ["my-key"]); + + // should have been no posts caused by the clear. + posts = server.getPosts(); + equal(posts.length, 1); + } + ); + }); + + await testExtension.unload(); +}); + +add_task(async function ensureCanSync_posts_new_keys() { + const extensionId = uuid(); + await withContextAndServer(async function (context, server) { + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + server.installCollection("storage-sync-crypto"); + server.etag = 1000; + + let newKeys = await extensionStorageSync.ensureCanSync([extensionId]); + ok( + newKeys.hasKeysFor([extensionId]), + `key isn't present for ${extensionId}` + ); + + let posts = server.getPosts(); + equal(posts.length, 1); + const post = posts[0]; + assertPostedNewRecord(post); + const body = await assertPostedEncryptedKeys(fxaService, post); + const oldSalt = body.salts[extensionId]; + ok( + body.keys.collections[extensionId], + `keys object should have a key for ${extensionId}` + ); + ok(oldSalt, `salts object should have a salt for ${extensionId}`); + + // Try adding another key to make sure that the first post was + // OK, even on a new profile. + await extensionStorageSync.cryptoCollection._clear(); + server.clearPosts(); + // Restore the first posted keyring, but add a last_modified date + const firstPostedKeyring = Object.assign({}, post.body.data, { + last_modified: server.etag, + }); + server.addRecord({ + data: firstPostedKeyring, + collectionId: "storage-sync-crypto", + predicate: appearsAt(250), + }); + const extensionId2 = uuid(); + newKeys = await extensionStorageSync.ensureCanSync([extensionId2]); + ok( + newKeys.hasKeysFor([extensionId]), + `didn't forget key for ${extensionId}` + ); + ok( + newKeys.hasKeysFor([extensionId2]), + `new key generated for ${extensionId2}` + ); + + posts = server.getPosts(); + equal(posts.length, 1); + const newPost = posts[posts.length - 1]; + const newBody = await assertPostedEncryptedKeys(fxaService, newPost); + ok( + newBody.keys.collections[extensionId], + `keys object should have a key for ${extensionId}` + ); + ok( + newBody.keys.collections[extensionId2], + `keys object should have a key for ${extensionId2}` + ); + ok( + newBody.salts[extensionId], + `salts object should have a key for ${extensionId}` + ); + ok( + newBody.salts[extensionId2], + `salts object should have a key for ${extensionId2}` + ); + equal( + oldSalt, + newBody.salts[extensionId], + `old salt should be preserved in post` + ); + } + ); + }); +}); + +add_task(async function ensureCanSync_pulls_key() { + // ensureCanSync is implemented by adding a key to our local record + // and doing a sync. This means that if the same key exists + // remotely, we get a "conflict". Ensure that we handle this + // correctly -- we keep the server key (since presumably it's + // already been used to encrypt records) and we don't wipe out other + // collections' keys. + const extensionId = uuid(); + const extensionId2 = uuid(); + const extensionOnlyKey = uuid(); + const extensionOnlySalt = uuid(); + const DEFAULT_KEY = new BulkKeyBundle("[default]"); + await DEFAULT_KEY.generateRandom(); + const RANDOM_KEY = new BulkKeyBundle(extensionId); + await RANDOM_KEY.generateRandom(); + await withContextAndServer(async function (context, server) { + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + // FIXME: generating a random salt probably shouldn't require a CryptoCollection? + const cryptoCollection = new CryptoCollection(fxaService); + const RANDOM_SALT = cryptoCollection.getNewSalt(); + await extensionStorageSync.cryptoCollection._clear(); + const keysData = { + default: DEFAULT_KEY.keyPairB64, + collections: { + [extensionId]: RANDOM_KEY.keyPairB64, + }, + }; + const saltData = { + [extensionId]: RANDOM_SALT, + }; + await server.installKeyRing(fxaService, keysData, saltData, 950, { + predicate: appearsAt(900), + }); + + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + assertKeyRingKey(collectionKeys, extensionId, RANDOM_KEY); + + let posts = server.getPosts(); + equal( + posts.length, + 0, + "ensureCanSync shouldn't push when the server keyring has the right key" + ); + + // Another client generates a key for extensionId2 + const newKey = new BulkKeyBundle(extensionId2); + await newKey.generateRandom(); + keysData.collections[extensionId2] = newKey.keyPairB64; + saltData[extensionId2] = cryptoCollection.getNewSalt(); + await server.installKeyRing(fxaService, keysData, saltData, 1050, { + predicate: appearsAt(1000), + }); + + let newCollectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + extensionId2, + ]); + assertKeyRingKey(newCollectionKeys, extensionId2, newKey); + assertKeyRingKey( + newCollectionKeys, + extensionId, + RANDOM_KEY, + `ensureCanSync shouldn't lose the old key for ${extensionId}` + ); + + posts = server.getPosts(); + equal( + posts.length, + 0, + "ensureCanSync shouldn't push when updating keys" + ); + + // Another client generates a key, but not a salt, for extensionOnlyKey + const onlyKey = new BulkKeyBundle(extensionOnlyKey); + await onlyKey.generateRandom(); + keysData.collections[extensionOnlyKey] = onlyKey.keyPairB64; + await server.installKeyRing(fxaService, keysData, saltData, 1150, { + predicate: appearsAt(1100), + }); + + let withNewKey = await extensionStorageSync.ensureCanSync([ + extensionId, + extensionOnlyKey, + ]); + dump(`got ${JSON.stringify(withNewKey.asWBO().cleartext)}\n`); + assertKeyRingKey(withNewKey, extensionOnlyKey, onlyKey); + assertKeyRingKey( + withNewKey, + extensionId, + RANDOM_KEY, + `ensureCanSync shouldn't lose the old key for ${extensionId}` + ); + + posts = server.getPosts(); + equal( + posts.length, + 1, + "ensureCanSync should push when generating a new salt" + ); + const withNewKeyRecord = await assertPostedEncryptedKeys( + fxaService, + posts[0] + ); + // We don't a priori know what the new salt is + dump(`${JSON.stringify(withNewKeyRecord)}\n`); + ok( + withNewKeyRecord.salts[extensionOnlyKey], + `ensureCanSync should generate a salt for an extension that only had a key` + ); + + // Another client generates a key, but not a salt, for extensionOnlyKey + const newSalt = cryptoCollection.getNewSalt(); + saltData[extensionOnlySalt] = newSalt; + await server.installKeyRing(fxaService, keysData, saltData, 1250, { + predicate: appearsAt(1200), + }); + + let withOnlySaltKey = await extensionStorageSync.ensureCanSync([ + extensionId, + extensionOnlySalt, + ]); + assertKeyRingKey( + withOnlySaltKey, + extensionId, + RANDOM_KEY, + `ensureCanSync shouldn't lose the old key for ${extensionId}` + ); + // We don't a priori know what the new key is + ok( + withOnlySaltKey.hasKeysFor([extensionOnlySalt]), + `ensureCanSync generated a key for an extension that only had a salt` + ); + + posts = server.getPosts(); + equal( + posts.length, + 2, + "ensureCanSync should push when generating a new key" + ); + const withNewSaltRecord = await assertPostedEncryptedKeys( + fxaService, + posts[1] + ); + equal( + withNewSaltRecord.salts[extensionOnlySalt], + newSalt, + "ensureCanSync should keep the existing salt when generating only a key" + ); + } + ); + }); +}); + +add_task(async function ensureCanSync_handles_conflicts() { + // Syncing is done through a pull followed by a push of any merged + // changes. Accordingly, the only way to have a "true" conflict -- + // i.e. with the server rejecting a change -- is if + // someone pushes changes between our pull and our push. Ensure that + // if this happens, we still behave sensibly (keep the remote key). + const extensionId = uuid(); + const DEFAULT_KEY = new BulkKeyBundle("[default]"); + await DEFAULT_KEY.generateRandom(); + const RANDOM_KEY = new BulkKeyBundle(extensionId); + await RANDOM_KEY.generateRandom(); + await withContextAndServer(async function (context, server) { + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + // FIXME: generating salts probably shouldn't rely on a CryptoCollection + const cryptoCollection = new CryptoCollection(fxaService); + const RANDOM_SALT = cryptoCollection.getNewSalt(); + const keysData = { + default: DEFAULT_KEY.keyPairB64, + collections: { + [extensionId]: RANDOM_KEY.keyPairB64, + }, + }; + const saltData = { + [extensionId]: RANDOM_SALT, + }; + await server.installKeyRing(fxaService, keysData, saltData, 765, { + conflict: true, + }); + + await extensionStorageSync.cryptoCollection._clear(); + + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + assertKeyRingKey( + collectionKeys, + extensionId, + RANDOM_KEY, + `syncing keyring should keep the server key for ${extensionId}` + ); + + let posts = server.getPosts(); + equal( + posts.length, + 1, + "syncing keyring should have tried to post a keyring" + ); + const failedPost = posts[0]; + assertPostedNewRecord(failedPost); + let body = await assertPostedEncryptedKeys(fxaService, failedPost); + // This key will be the one the client generated locally, so + // we don't know what its value will be + ok( + body.keys.collections[extensionId], + `decrypted failed post should have a key for ${extensionId}` + ); + notEqual( + body.keys.collections[extensionId], + RANDOM_KEY.keyPairB64, + `decrypted failed post should have a randomly-generated key for ${extensionId}` + ); + } + ); + }); +}); + +add_task(async function ensureCanSync_handles_deleted_conflicts() { + // A keyring can be deleted, and this changes the format of the 412 + // Conflict response from the Kinto server. Make sure we handle it correctly. + const extensionId = uuid(); + const extensionId2 = uuid(); + await withContextAndServer(async function (context, server) { + server.installCollection("storage-sync-crypto"); + server.installDeleteBucket(); + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + server.etag = 700; + await extensionStorageSync.cryptoCollection._clear(); + + // Generate keys that we can check for later. + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + const extensionKey = collectionKeys.keyForCollection(extensionId); + server.clearPosts(); + + // This is the response that the Kinto server return when the + // keyring has been deleted. + server.addRecord({ + collectionId: "storage-sync-crypto", + conflict: true, + transient: true, + data: null, + etag: 765, + }); + + // Try to add a new extension to trigger a sync of the keyring. + let collectionKeys2 = await extensionStorageSync.ensureCanSync([ + extensionId2, + ]); + + assertKeyRingKey( + collectionKeys2, + extensionId, + extensionKey, + `syncing keyring should keep our local key for ${extensionId}` + ); + + deepEqual( + server.getDeletedBuckets(), + ["default"], + "Kinto server should have been wiped when keyring was thrown away" + ); + + let posts = server.getPosts(); + equal( + posts.length, + 2, + "syncing keyring should have tried to post a keyring twice" + ); + // The first post got a conflict. + const failedPost = posts[0]; + assertPostedUpdatedRecord(failedPost, 700); + let body = await assertPostedEncryptedKeys(fxaService, failedPost); + + deepEqual( + body.keys.collections[extensionId], + extensionKey.keyPairB64, + `decrypted failed post should have the key for ${extensionId}` + ); + + // The second post was after the wipe, and succeeded. + const afterWipePost = posts[1]; + assertPostedNewRecord(afterWipePost); + let afterWipeBody = await assertPostedEncryptedKeys( + fxaService, + afterWipePost + ); + + deepEqual( + afterWipeBody.keys.collections[extensionId], + extensionKey.keyPairB64, + `decrypted new post should have preserved the key for ${extensionId}` + ); + } + ); + }); +}); + +add_task(async function ensureCanSync_handles_flushes() { + // See Bug 1359879 and Bug 1350088. One of the ways that 1359879 presents is + // as 1350088. This seems to be the symptom that results when the user had + // two devices, one of which was not syncing at the time the keyring was + // lost. Ensure we can recover for these users as well. + const extensionId = uuid(); + const extensionId2 = uuid(); + await withContextAndServer(async function (context, server) { + server.installCollection("storage-sync-crypto"); + server.installDeleteBucket(); + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + server.etag = 700; + // Generate keys that we can check for later. + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + const extensionKey = collectionKeys.keyForCollection(extensionId); + server.clearPosts(); + + // last_modified is new, but there is no data. + server.etag = 800; + + // Try to add a new extension to trigger a sync of the keyring. + let collectionKeys2 = await extensionStorageSync.ensureCanSync([ + extensionId2, + ]); + + assertKeyRingKey( + collectionKeys2, + extensionId, + extensionKey, + `syncing keyring should keep our local key for ${extensionId}` + ); + + deepEqual( + server.getDeletedBuckets(), + ["default"], + "Kinto server should have been wiped when keyring was thrown away" + ); + + let posts = server.getPosts(); + equal( + posts.length, + 1, + "syncing keyring should have tried to post a keyring once" + ); + + const post = posts[0]; + assertPostedNewRecord(post); + let postBody = await assertPostedEncryptedKeys(fxaService, post); + + deepEqual( + postBody.keys.collections[extensionId], + extensionKey.keyPairB64, + `decrypted new post should have preserved the key for ${extensionId}` + ); + } + ); + }); +}); + +add_task(async function checkSyncKeyRing_reuploads_keys() { + // Verify that when keys are present, they are reuploaded with the + // new kbHash when we call touchKeys(). + const extensionId = uuid(); + let extensionKey, extensionSalt; + await withContextAndServer(async function (context, server) { + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + server.installCollection("storage-sync-crypto"); + server.etag = 765; + + await extensionStorageSync.cryptoCollection._clear(); + + // Do an `ensureCanSync` to generate some keys. + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + ok( + collectionKeys.hasKeysFor([extensionId]), + `ensureCanSync should return a keyring that has a key for ${extensionId}` + ); + extensionKey = collectionKeys.keyForCollection(extensionId).keyPairB64; + equal( + server.getPosts().length, + 1, + "generating a key that doesn't exist on the server should post it" + ); + const body = await assertPostedEncryptedKeys( + fxaService, + server.getPosts()[0] + ); + extensionSalt = body.salts[extensionId]; + } + ); + + // The user changes their password. This is their new kbHash, with + // the last character changed. + const newUser = Object.assign({}, loggedInUser, { + scopedKeys: { + "sync:addon_storage": { + kid: "1234567890123-I1DLqPztWi-647HxgLr4YPePZUK-975wn9qWzT49yAE", + k: "Y_kFdXfAS7u58MP9hbXUAytg4T7cH43TCb9DBdZvLMMS3eFs5GAhpJb3E5UNCmxWbOGBUhpEcm576Xz1d7MbMA", + kty: "oct", + }, + }, + }); + let postedKeys; + await withSignedInUser( + newUser, + async function (extensionStorageSync, fxaService) { + await extensionStorageSync.checkSyncKeyRing(); + + let posts = server.getPosts(); + equal( + posts.length, + 2, + "when kBHash changes, checkSyncKeyRing should post the keyring reencrypted with the new kBHash" + ); + postedKeys = posts[1]; + assertPostedUpdatedRecord(postedKeys, 765); + + let body = await assertPostedEncryptedKeys(fxaService, postedKeys); + deepEqual( + body.keys.collections[extensionId], + extensionKey, + `the posted keyring should have the same key for ${extensionId} as the old one` + ); + deepEqual( + body.salts[extensionId], + extensionSalt, + `the posted keyring should have the same salt for ${extensionId} as the old one` + ); + } + ); + + // Verify that with the old kBHash, we can't decrypt the record. + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + let error; + try { + await new KeyRingEncryptionRemoteTransformer(fxaService).decode( + postedKeys.body.data + ); + } catch (e) { + error = e; + } + ok(error, "decrypting the keyring with the old kBHash should fail"); + ok( + Utils.isHMACMismatch(error) || + KeyRingEncryptionRemoteTransformer.isOutdatedKB(error), + "decrypting the keyring with the old kBHash should throw an HMAC mismatch" + ); + } + ); + }); +}); + +add_task(async function checkSyncKeyRing_overwrites_on_conflict() { + // If there is already a record on the server that was encrypted + // with a different kbHash, we wipe the server, clear sync state, and + // overwrite it with our keys. + const extensionId = uuid(); + let extensionKey; + await withSyncContext(async function (context) { + await withServer(async function (server) { + // The old device has this kbHash, which is very similar to the + // current kbHash but with the last character changed. + const oldUser = Object.assign({}, loggedInUser, { + scopedKeys: { + "sync:addon_storage": { + kid: "1234567890123-I1DLqPztWi-647HxgLr4YPePZUK-975wn9qWzT49yAE", + k: "Y_kFdXfAS7u58MP9hbXUAytg4T7cH43TCb9DBdZvLMMS3eFs5GAhpJb3E5UNCmxWbOGBUhpEcm576Xz1d7MbMA", + kty: "oct", + }, + }, + }); + server.installDeleteBucket(); + await withSignedInUser( + oldUser, + async function (extensionStorageSync, fxaService) { + await server.installKeyRing(fxaService, {}, {}, 765); + } + ); + + // Now we have this new user with a different kbHash. + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + await extensionStorageSync.cryptoCollection._clear(); + + // Do an `ensureCanSync` to generate some keys. + // This will try to sync, notice that the record is + // undecryptable, and clear the server. + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + ok( + collectionKeys.hasKeysFor([extensionId]), + `ensureCanSync should always return a keyring with a key for ${extensionId}` + ); + extensionKey = + collectionKeys.keyForCollection(extensionId).keyPairB64; + + deepEqual( + server.getDeletedBuckets(), + ["default"], + "Kinto server should have been wiped when keyring was thrown away" + ); + + let posts = server.getPosts(); + equal(posts.length, 1, "new keyring should have been uploaded"); + const postedKeys = posts[0]; + // The POST was to an empty server, so etag shouldn't be respected + equal( + postedKeys.headers.Authorization, + "Bearer some-access-token", + "keyring upload should be authorized" + ); + equal( + postedKeys.headers["If-None-Match"], + "*", + "keyring upload should be to empty Kinto server" + ); + equal( + postedKeys.path, + collectionRecordsPath("storage-sync-crypto") + "/keys", + "keyring upload should be to keyring path" + ); + + let body = await new KeyRingEncryptionRemoteTransformer( + fxaService + ).decode(postedKeys.body.data); + ok(body.uuid, "new keyring should have a UUID"); + equal(typeof body.uuid, "string", "keyring UUIDs should be strings"); + notEqual( + body.uuid, + "abcd", + "new keyring should not have the same UUID as previous keyring" + ); + ok(body.keys, "new keyring should have a keys attribute"); + ok(body.keys.default, "new keyring should have a default key"); + // We should keep the extension key that was in our uploaded version. + deepEqual( + extensionKey, + body.keys.collections[extensionId], + "ensureCanSync should have returned keyring with the same key that was uploaded" + ); + + // This should be a no-op; the keys were uploaded as part of ensurekeysfor + await extensionStorageSync.checkSyncKeyRing(); + equal( + server.getPosts().length, + 1, + "checkSyncKeyRing should not need to post keys after they were reuploaded" + ); + } + ); + }); + }); +}); + +add_task(async function checkSyncKeyRing_flushes_on_uuid_change() { + // If we can decrypt the record, but the UUID has changed, that + // means another client has wiped the server and reuploaded a + // keyring, so reset sync state and reupload everything. + const extensionId = uuid(); + const extension = { id: extensionId }; + await withSyncContext(async function (context) { + await withServer(async function (server) { + server.installCollection("storage-sync-crypto"); + server.installDeleteBucket(); + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + const cryptoCollection = new CryptoCollection(fxaService); + const transformer = new KeyRingEncryptionRemoteTransformer( + fxaService + ); + await extensionStorageSync.cryptoCollection._clear(); + + // Do an `ensureCanSync` to get access to keys and salt. + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + server.installCollection(collectionId); + + ok( + collectionKeys.hasKeysFor([extensionId]), + `ensureCanSync should always return a keyring that has a key for ${extensionId}` + ); + const extensionKey = + collectionKeys.keyForCollection(extensionId).keyPairB64; + + // Set something to make sure that it gets re-uploaded when + // uuid changes. + await extensionStorageSync.set(extension, { "my-key": 5 }, context); + await extensionStorageSync.syncAll(); + + let posts = server.getPosts(); + equal( + posts.length, + 2, + "should have posted a new keyring and an extension datum" + ); + const postedKeys = posts[0]; + equal( + postedKeys.path, + collectionRecordsPath("storage-sync-crypto") + "/keys", + "should have posted keyring to /keys" + ); + + let body = await transformer.decode(postedKeys.body.data); + ok(body.uuid, "keyring should have a UUID"); + ok(body.keys, "keyring should have a keys attribute"); + ok(body.keys.default, "keyring should have a default key"); + ok( + body.salts[extensionId], + `keyring should have a salt for ${extensionId}` + ); + const extensionSalt = body.salts[extensionId]; + deepEqual( + extensionKey, + body.keys.collections[extensionId], + "new keyring should have the same key that we uploaded" + ); + + // Another client comes along and replaces the UUID. + // In real life, this would mean changing the keys too, but + // this test verifies that just changing the UUID is enough. + const newKeyRingData = Object.assign({}, body, { + uuid: "abcd", + // Technically, last_modified should be served outside the + // object, but the transformer will pass it through in + // either direction, so this is OK. + last_modified: 765, + }); + server.etag = 1000; + await server.encryptAndAddRecord(transformer, { + collectionId: "storage-sync-crypto", + data: newKeyRingData, + predicate: appearsAt(800), + }); + + // Fake adding another extension just so that the keyring will + // really get synced. + const newExtension = uuid(); + const newKeyRing = await extensionStorageSync.ensureCanSync([ + newExtension, + ]); + + // This should have detected the UUID change and flushed everything. + // The keyring should, however, be the same, since we just + // changed the UUID of the previously POSTed one. + deepEqual( + newKeyRing.keyForCollection(extensionId).keyPairB64, + extensionKey, + "ensureCanSync should have pulled down a new keyring with the same keys" + ); + + // Syncing should reupload the data for the extension. + await extensionStorageSync.syncAll(); + posts = server.getPosts(); + equal( + posts.length, + 4, + "should have posted keyring for new extension and reuploaded extension data" + ); + + const finalKeyRingPost = posts[2]; + const reuploadedPost = posts[3]; + + equal( + finalKeyRingPost.path, + collectionRecordsPath("storage-sync-crypto") + "/keys", + "keyring for new extension should have been posted to /keys" + ); + let finalKeyRing = await transformer.decode( + finalKeyRingPost.body.data + ); + equal( + finalKeyRing.uuid, + "abcd", + "newly uploaded keyring should preserve UUID from replacement keyring" + ); + deepEqual( + finalKeyRing.salts[extensionId], + extensionSalt, + "newly uploaded keyring should preserve salts from existing salts" + ); + + // Confirm that the data got reuploaded + let reuploadedData = await assertExtensionRecord( + fxaService, + reuploadedPost, + extension, + "my-key" + ); + equal( + reuploadedData.data, + 5, + "extension data should have a data attribute corresponding to the extension data value" + ); + } + ); + }); + }); +}); + +add_task(async function test_storage_sync_pulls_changes() { + const extensionId = defaultExtensionId; + const extension = defaultExtension; + await withContextAndServer(async function (context, server) { + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + const cryptoCollection = new CryptoCollection(fxaService); + server.installCollection("storage-sync-crypto"); + + let calls = []; + await extensionStorageSync.addOnChangedListener( + extension, + function () { + calls.push(arguments); + }, + context + ); + + await extensionStorageSync.ensureCanSync([extensionId]); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + let transformer = new CollectionKeyEncryptionRemoteTransformer( + cryptoCollection, + await cryptoCollection.getKeyRing(), + extensionId + ); + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 6, + }, + predicate: appearsAt(850), + }); + server.etag = 900; + + await extensionStorageSync.syncAll(); + const remoteValue = ( + await extensionStorageSync.get(extension, "remote-key", context) + )["remote-key"]; + equal( + remoteValue, + 6, + "ExtensionStorageSync.get() returns value retrieved from sync" + ); + + equal(calls.length, 1, "syncing calls on-changed listener"); + deepEqual(calls[0][0], { "remote-key": { newValue: 6 } }); + calls = []; + + // Syncing again doesn't do anything + await extensionStorageSync.syncAll(); + + equal( + calls.length, + 0, + "syncing again shouldn't call on-changed listener" + ); + + // Updating the server causes us to pull down the new value + server.etag = 1000; + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 7, + }, + predicate: appearsAt(950), + }); + + await extensionStorageSync.syncAll(); + const remoteValue2 = ( + await extensionStorageSync.get(extension, "remote-key", context) + )["remote-key"]; + equal( + remoteValue2, + 7, + "ExtensionStorageSync.get() returns value updated from sync" + ); + + equal(calls.length, 1, "syncing calls on-changed listener on update"); + deepEqual(calls[0][0], { "remote-key": { oldValue: 6, newValue: 7 } }); + } + ); + }); +}); + +// Tests that an enabled extension which have been synced before it is going +// to be synced on ExtensionStorageSync.syncAll even if there is no active +// context that is currently using the API. +add_task(async function test_storage_sync_on_no_active_context() { + const extensionId = "sync@mochi.test"; + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + permissions: ["storage"], + browser_specific_settings: { gecko: { id: extensionId } }, + }, + files: { + "ext-page.html": `<!DOCTYPE html> + <html> + <head> + <script src="ext-page.js"></script> + </head> + </html> + `, + "ext-page.js": function () { + const { browser } = this; + browser.test.onMessage.addListener(async msg => { + if (msg === "get-sync-data") { + browser.test.sendMessage( + "get-sync-data:done", + await browser.storage.sync.get(["remote-key"]) + ); + } + }); + }, + }, + }); + + await extension.startup(); + + await withServer(async server => { + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + const cryptoCollection = new CryptoCollection(fxaService); + server.installCollection("storage-sync-crypto"); + + await extensionStorageSync.ensureCanSync([extensionId]); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + let transformer = new CollectionKeyEncryptionRemoteTransformer( + cryptoCollection, + await cryptoCollection.getKeyRing(), + extensionId + ); + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 6, + }, + predicate: appearsAt(850), + }); + + server.etag = 1000; + await extensionStorageSync.syncAll(); + } + ); + }); + + const extPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/ext-page.html`, + { extension } + ); + + await extension.sendMessage("get-sync-data"); + const res = await extension.awaitMessage("get-sync-data:done"); + Assert.deepEqual(res, { "remote-key": 6 }, "Got the expected sync data"); + + await extPage.close(); + + await extension.unload(); +}); + +add_task(async function test_storage_sync_pushes_changes() { + // FIXME: This test relies on the fact that previous tests pushed + // keys and salts for the default extension ID + const extension = defaultExtension; + const extensionId = defaultExtensionId; + await withContextAndServer(async function (context, server) { + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + const cryptoCollection = new CryptoCollection(fxaService); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + server.installCollection(collectionId); + server.installCollection("storage-sync-crypto"); + server.etag = 1000; + + await extensionStorageSync.set(extension, { "my-key": 5 }, context); + + // install this AFTER we set the key to 5... + let calls = []; + extensionStorageSync.addOnChangedListener( + extension, + function () { + calls.push(arguments); + }, + context + ); + + await extensionStorageSync.syncAll(); + const localValue = ( + await extensionStorageSync.get(extension, "my-key", context) + )["my-key"]; + equal( + localValue, + 5, + "pushing an ExtensionStorageSync value shouldn't change local value" + ); + const hashedId = + "id-" + + (await cryptoCollection.hashWithExtensionSalt( + "key-my_2D_key", + extensionId + )); + + let posts = server.getPosts(); + // FIXME: Keys were pushed in a previous test + equal( + posts.length, + 1, + "pushing a value should cause a post to the server" + ); + const post = posts[0]; + assertPostedNewRecord(post); + equal( + post.path, + `${collectionRecordsPath(collectionId)}/${hashedId}`, + "pushing a value should have a path corresponding to its id" + ); + + const encrypted = post.body.data; + ok( + encrypted.ciphertext, + "pushing a value should post an encrypted record" + ); + ok( + !encrypted.data, + "pushing a value should not have any plaintext data" + ); + equal( + encrypted.id, + hashedId, + "pushing a value should use a kinto-friendly record ID" + ); + + const record = await assertExtensionRecord( + fxaService, + post, + extension, + "my-key" + ); + equal( + record.data, + 5, + "when decrypted, a pushed value should have a data field corresponding to its storage.sync value" + ); + equal( + record.id, + "key-my_2D_key", + "when decrypted, a pushed value should have an id field corresponding to its record ID" + ); + + equal( + calls.length, + 0, + "pushing a value shouldn't call the on-changed listener" + ); + + await extensionStorageSync.set(extension, { "my-key": 6 }, context); + await extensionStorageSync.syncAll(); + + // Doesn't push keys because keys were pushed by a previous test. + posts = server.getPosts(); + equal(posts.length, 2, "updating a value should trigger another push"); + const updatePost = posts[1]; + assertPostedUpdatedRecord(updatePost, 1000); + equal( + updatePost.path, + `${collectionRecordsPath(collectionId)}/${hashedId}`, + "pushing an updated value should go to the same path" + ); + + const updateEncrypted = updatePost.body.data; + ok( + updateEncrypted.ciphertext, + "pushing an updated value should still be encrypted" + ); + ok( + !updateEncrypted.data, + "pushing an updated value should not have any plaintext visible" + ); + equal( + updateEncrypted.id, + hashedId, + "pushing an updated value should maintain the same ID" + ); + } + ); + }); +}); + +add_task(async function test_storage_sync_retries_failed_auth() { + const extensionId = uuid(); + const extension = { id: extensionId }; + await withContextAndServer(async function (context, server) { + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + const cryptoCollection = new CryptoCollection(fxaService); + server.installCollection("storage-sync-crypto"); + + await extensionStorageSync.ensureCanSync([extensionId]); + await extensionStorageSync.set(extension, { "my-key": 5 }, context); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + let transformer = new CollectionKeyEncryptionRemoteTransformer( + cryptoCollection, + await cryptoCollection.getKeyRing(), + extensionId + ); + // Put a remote record just to verify that eventually we succeeded + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 6, + }, + predicate: appearsAt(850), + }); + server.etag = 900; + + // This is a typical response from a production stack if your + // bearer token is bad. + server.rejectNextAuthWith( + '{"code": 401, "errno": 104, "error": "Unauthorized", "message": "Please authenticate yourself to use this endpoint"}' + ); + await extensionStorageSync.syncAll(); + + equal(server.failedAuths.length, 1, "an auth was failed"); + + const remoteValue = ( + await extensionStorageSync.get(extension, "remote-key", context) + )["remote-key"]; + equal( + remoteValue, + 6, + "ExtensionStorageSync.get() returns value retrieved from sync" + ); + + // Try again with an emptier JSON body to make sure this still + // works with a less-cooperative server. + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 7, + }, + predicate: appearsAt(950), + }); + server.etag = 1000; + // Need to write a JSON response. + // kinto.js 9.0.2 doesn't throw unless there's json. + // See https://github.com/Kinto/kinto-http.js/issues/192. + server.rejectNextAuthWith("{}"); + + await extensionStorageSync.syncAll(); + + equal(server.failedAuths.length, 2, "an auth was failed"); + + const newRemoteValue = ( + await extensionStorageSync.get(extension, "remote-key", context) + )["remote-key"]; + equal( + newRemoteValue, + 7, + "ExtensionStorageSync.get() returns value retrieved from sync" + ); + } + ); + }); +}); + +add_task(async function test_storage_sync_pulls_conflicts() { + const extensionId = uuid(); + const extension = { id: extensionId }; + await withContextAndServer(async function (context, server) { + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + const cryptoCollection = new CryptoCollection(fxaService); + server.installCollection("storage-sync-crypto"); + + await extensionStorageSync.ensureCanSync([extensionId]); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + let transformer = new CollectionKeyEncryptionRemoteTransformer( + cryptoCollection, + await cryptoCollection.getKeyRing(), + extensionId + ); + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 6, + }, + predicate: appearsAt(850), + }); + server.etag = 900; + + await extensionStorageSync.set(extension, { "remote-key": 8 }, context); + + let calls = []; + await extensionStorageSync.addOnChangedListener( + extension, + function () { + calls.push(arguments); + }, + context + ); + + await extensionStorageSync.syncAll(); + const remoteValue = ( + await extensionStorageSync.get(extension, "remote-key", context) + )["remote-key"]; + equal(remoteValue, 8, "locally set value overrides remote value"); + + equal(calls.length, 1, "conflicts manifest in on-changed listener"); + deepEqual(calls[0][0], { "remote-key": { newValue: 8 } }); + calls = []; + + // Syncing again doesn't do anything + await extensionStorageSync.syncAll(); + + equal( + calls.length, + 0, + "syncing again shouldn't call on-changed listener" + ); + + // Updating the server causes us to pull down the new value + server.etag = 1000; + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 7, + }, + predicate: appearsAt(950), + }); + + await extensionStorageSync.syncAll(); + const remoteValue2 = ( + await extensionStorageSync.get(extension, "remote-key", context) + )["remote-key"]; + equal( + remoteValue2, + 7, + "conflicts do not prevent retrieval of new values" + ); + + equal(calls.length, 1, "syncing calls on-changed listener on update"); + deepEqual(calls[0][0], { "remote-key": { oldValue: 8, newValue: 7 } }); + } + ); + }); +}); + +add_task(async function test_storage_sync_pulls_deletes() { + const extension = defaultExtension; + await withContextAndServer(async function (context, server) { + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + const cryptoCollection = new CryptoCollection(fxaService); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + defaultExtensionId + ); + server.installCollection(collectionId); + server.installCollection("storage-sync-crypto"); + + await extensionStorageSync.set(extension, { "my-key": 5 }, context); + await extensionStorageSync.syncAll(); + server.clearPosts(); + + let calls = []; + await extensionStorageSync.addOnChangedListener( + extension, + function () { + calls.push(arguments); + }, + context + ); + + const transformer = new CollectionKeyEncryptionRemoteTransformer( + new CryptoCollection(fxaService), + await cryptoCollection.getKeyRing(), + extension.id + ); + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-my_2D_key", + data: 6, + _status: "deleted", + }, + }); + + await extensionStorageSync.syncAll(); + const remoteValues = await extensionStorageSync.get( + extension, + "my-key", + context + ); + ok( + !remoteValues["my-key"], + "ExtensionStorageSync.get() shows value was deleted by sync" + ); + + equal( + server.getPosts().length, + 0, + "pulling the delete shouldn't cause posts" + ); + + equal(calls.length, 1, "syncing calls on-changed listener"); + deepEqual(calls[0][0], { "my-key": { oldValue: 5 } }); + calls = []; + + // Syncing again doesn't do anything + await extensionStorageSync.syncAll(); + + equal( + calls.length, + 0, + "syncing again shouldn't call on-changed listener" + ); + } + ); + }); +}); + +add_task(async function test_storage_sync_pushes_deletes() { + const extensionId = uuid(); + const extension = { id: extensionId }; + await withContextAndServer(async function (context, server) { + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + const cryptoCollection = new CryptoCollection(fxaService); + await cryptoCollection._clear(); + await cryptoCollection._setSalt( + extensionId, + cryptoCollection.getNewSalt() + ); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + + server.installCollection(collectionId); + server.installCollection("storage-sync-crypto"); + server.etag = 1000; + + await extensionStorageSync.set(extension, { "my-key": 5 }, context); + + let calls = []; + extensionStorageSync.addOnChangedListener( + extension, + function () { + calls.push(arguments); + }, + context + ); + + await extensionStorageSync.syncAll(); + let posts = server.getPosts(); + equal( + posts.length, + 2, + "pushing a non-deleted value should post keys and post the value to the server" + ); + + await extensionStorageSync.remove(extension, ["my-key"], context); + equal( + calls.length, + 1, + "deleting a value should call the on-changed listener" + ); + + await extensionStorageSync.syncAll(); + equal( + calls.length, + 1, + "pushing a deleted value shouldn't call the on-changed listener" + ); + + // Doesn't push keys because keys were pushed by a previous test. + const hashedId = + "id-" + + (await cryptoCollection.hashWithExtensionSalt( + "key-my_2D_key", + extensionId + )); + posts = server.getPosts(); + equal(posts.length, 3, "deleting a value should trigger another push"); + const post = posts[2]; + assertPostedUpdatedRecord(post, 1000); + equal( + post.path, + `${collectionRecordsPath(collectionId)}/${hashedId}`, + "pushing a deleted value should go to the same path" + ); + ok(post.method, "PUT"); + ok( + post.body.data.ciphertext, + "deleting a value should have an encrypted body" + ); + const decoded = await new CollectionKeyEncryptionRemoteTransformer( + cryptoCollection, + await cryptoCollection.getKeyRing(), + extensionId + ).decode(post.body.data); + equal(decoded._status, "deleted"); + // Ideally, we'd check that decoded.deleted is not true, because + // the encrypted record shouldn't have it, but the decoder will + // add it when it sees _status == deleted + } + ); + }); +}); + +// Some sync tests shared between implementations. +add_task(test_config_flag_needed); + +add_task(test_sync_reloading_extensions_works); + +add_task(function test_storage_sync() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_background_page_storage("sync") + ); +}); + +add_task(test_storage_sync_requires_real_id); + +add_task(function test_storage_sync_with_bytes_in_use() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_background_storage_area_with_bytes_in_use("sync", false) + ); +}); + +add_task(function test_storage_onChanged_event_page() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_storage_change_event_page("sync") + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto_crypto.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto_crypto.js new file mode 100644 index 0000000000..89eac15937 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto_crypto.js @@ -0,0 +1,122 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// This is a kinto-specific test... +Services.prefs.setBoolPref("webextensions.storage.sync.kinto", true); + +const { + KintoStorageTestUtils: { EncryptionRemoteTransformer }, +} = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionStorageSyncKinto.sys.mjs" +); +const { CryptoUtils } = ChromeUtils.importESModule( + "resource://services-crypto/utils.sys.mjs" +); +const { Utils } = ChromeUtils.importESModule( + "resource://services-sync/util.sys.mjs" +); + +/** + * Like Assert.throws, but for generators. + * + * @param {string | object | Function} constraint + * What to use to check the exception. + * @param {Function} f + * The function to call. + */ +async function throwsGen(constraint, f) { + let threw = false; + let exception; + try { + await f(); + } catch (e) { + threw = true; + exception = e; + } + + ok(threw, "did not throw an exception"); + + const debuggingMessage = `got ${exception}, expected ${constraint}`; + + if (typeof constraint === "function") { + ok(constraint(exception), debuggingMessage); + } else { + let message = exception; + if (typeof exception === "object") { + message = exception.message; + } + Assert.strictEqual(constraint, message, debuggingMessage); + } +} + +/** + * An EncryptionRemoteTransformer that uses a fixed key bundle, + * suitable for testing. + */ +class StaticKeyEncryptionRemoteTransformer extends EncryptionRemoteTransformer { + constructor(keyBundle) { + super(); + this.keyBundle = keyBundle; + } + + getKeys() { + return Promise.resolve(this.keyBundle); + } +} +const BORING_KB = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +let transformer; +add_task(async function setup() { + const STRETCHED_KEY = await CryptoUtils.hkdfLegacy( + BORING_KB, + undefined, + `testing storage.sync encryption`, + 2 * 32 + ); + const KEY_BUNDLE = { + hmacKey: STRETCHED_KEY.slice(0, 32), + encryptionKeyB64: btoa(STRETCHED_KEY.slice(32, 64)), + }; + transformer = new StaticKeyEncryptionRemoteTransformer(KEY_BUNDLE); +}); + +add_task(async function test_encryption_transformer_roundtrip() { + const POSSIBLE_DATAS = [ + "string", + 2, // number + [1, 2, 3], // array + { key: "value" }, // object + ]; + + for (let data of POSSIBLE_DATAS) { + const record = { data, id: "key-some_2D_key", key: "some-key" }; + + deepEqual( + record, + await transformer.decode(await transformer.encode(record)) + ); + } +}); + +add_task(async function test_refuses_to_decrypt_tampered() { + const encryptedRecord = await transformer.encode({ + data: [1, 2, 3], + id: "key-some_2D_key", + key: "some-key", + }); + const tamperedHMAC = Object.assign({}, encryptedRecord, { + hmac: "0000000000000000000000000000000000000000000000000000000000000001", + }); + await throwsGen(Utils.isHMACMismatch, async function () { + await transformer.decode(tamperedHMAC); + }); + + const tamperedIV = Object.assign({}, encryptedRecord, { + IV: "aaaaaaaaaaaaaaaaaaaaaa==", + }); + await throwsGen(Utils.isHMACMismatch, async function () { + await transformer.decode(tamperedIV); + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_tab.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_tab.js new file mode 100644 index 0000000000..cfa49c334b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_tab.js @@ -0,0 +1,245 @@ +"use strict"; + +const { ExtensionStorageIDB } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionStorageIDB.sys.mjs" +); + +async function test_multiple_pages() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + function awaitMessage(expectedMsg, api = "test") { + return new Promise(resolve => { + browser[api].onMessage.addListener(function listener(msg) { + if (msg === expectedMsg) { + browser[api].onMessage.removeListener(listener); + resolve(); + } + }); + }); + } + + let tabReady = awaitMessage("tab-ready", "runtime"); + + try { + let storage = browser.storage.local; + + browser.test.sendMessage( + "load-page", + browser.runtime.getURL("tab.html") + ); + await awaitMessage("page-loaded"); + await tabReady; + + let result = await storage.get("key"); + browser.test.assertEq(undefined, result.key, "Key should be undefined"); + + await browser.runtime.sendMessage("tab-set-key"); + + result = await storage.get("key"); + browser.test.assertEq( + JSON.stringify({ foo: { bar: "baz" } }), + JSON.stringify(result.key), + "Key should be set to the value from the tab" + ); + + browser.test.sendMessage("remove-page"); + await awaitMessage("page-removed"); + + result = await storage.get("key"); + browser.test.assertEq( + JSON.stringify({ foo: { bar: "baz" } }), + JSON.stringify(result.key), + "Key should still be set to the value from the tab" + ); + + browser.test.notifyPass("storage-multiple"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("storage-multiple"); + } + }, + + files: { + "tab.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="tab.js"></script> + </head> + </html>`, + + "tab.js"() { + browser.test.log("tab"); + browser.runtime.onMessage.addListener(msg => { + if (msg == "tab-set-key") { + return browser.storage.local.set({ key: { foo: { bar: "baz" } } }); + } + }); + + browser.runtime.sendMessage("tab-ready"); + }, + }, + + manifest: { + permissions: ["storage"], + }, + }); + + let contentPage; + extension.onMessage("load-page", async url => { + contentPage = await ExtensionTestUtils.loadContentPage(url, { extension }); + extension.sendMessage("page-loaded"); + }); + extension.onMessage("remove-page", async url => { + await contentPage.close(); + extension.sendMessage("page-removed"); + }); + + await extension.startup(); + await extension.awaitFinish("storage-multiple"); + await extension.unload(); +} + +add_task(async function test_storage_local_file_backend_from_tab() { + return runWithPrefs( + [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]], + test_multiple_pages + ); +}); + +add_task(async function test_storage_local_idb_backend_from_tab() { + return runWithPrefs( + [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], + test_multiple_pages + ); +}); + +async function test_storage_local_call_from_destroying_context() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + let numberOfChanges = 0; + browser.storage.onChanged.addListener((changes, areaName) => { + if (areaName !== "local") { + browser.test.fail( + `Received unexpected storage changes for "${areaName}"` + ); + } + + numberOfChanges++; + }); + + browser.test.onMessage.addListener(async ({ msg, values }) => { + switch (msg) { + case "storage-set": { + await browser.storage.local.set(values); + browser.test.sendMessage("storage-set:done"); + break; + } + case "storage-get": { + const res = await browser.storage.local.get(); + browser.test.sendMessage("storage-get:done", res); + break; + } + case "storage-changes": { + browser.test.sendMessage("storage-changes-count", numberOfChanges); + break; + } + default: + browser.test.fail(`Received unexpected message: ${msg}`); + } + }); + + browser.test.sendMessage( + "ext-page-url", + browser.runtime.getURL("tab.html") + ); + }, + files: { + "tab.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="tab.js"></script> + </head> + </html>`, + + "tab.js"() { + browser.test.log("Extension tab - calling storage.local API method"); + // Call the storage.local API from a tab that is going to be quickly closed. + browser.storage.local.set({ + "test-key-from-destroying-context": "testvalue2", + }); + // Navigate away from the extension page, so that the storage.local API call will be unable + // to send the call to the caller context (because it has been destroyed in the meantime). + window.location = "about:blank"; + }, + }, + manifest: { + permissions: ["storage"], + }, + }); + + await extension.startup(); + const url = await extension.awaitMessage("ext-page-url"); + + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + extension, + }); + let expectedBackgroundPageData = { + "test-key-from-background-page": "test-value", + }; + let expectedTabData = { "test-key-from-destroying-context": "testvalue2" }; + + info( + "Call storage.local.set from the background page and wait it to be completed" + ); + extension.sendMessage({ + msg: "storage-set", + values: expectedBackgroundPageData, + }); + await extension.awaitMessage("storage-set:done"); + + info( + "Call storage.local.get from the background page and wait it to be completed" + ); + extension.sendMessage({ msg: "storage-get" }); + let res = await extension.awaitMessage("storage-get:done"); + + Assert.deepEqual( + res, + { + ...expectedBackgroundPageData, + ...expectedTabData, + }, + "Got the expected data set in the storage.local backend" + ); + + extension.sendMessage({ msg: "storage-changes" }); + equal( + await extension.awaitMessage("storage-changes-count"), + 2, + "Got the expected number of storage.onChanged event received" + ); + + contentPage.close(); + + await extension.unload(); +} + +add_task( + async function test_storage_local_file_backend_destroyed_context_promise() { + return runWithPrefs( + [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]], + test_storage_local_call_from_destroying_context + ); + } +); + +add_task( + async function test_storage_local_idb_backend_destroyed_context_promise() { + return runWithPrefs( + [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], + test_storage_local_call_from_destroying_context + ); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_telemetry.js new file mode 100644 index 0000000000..d0448b7b2e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_telemetry.js @@ -0,0 +1,458 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { ExtensionStorageIDB } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionStorageIDB.sys.mjs" +); +const { getTrimmedString } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionTelemetry.sys.mjs" +); +const { TelemetryController } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryController.sys.mjs" +); +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +const HISTOGRAM_JSON_IDS = [ + "WEBEXT_STORAGE_LOCAL_SET_MS", + "WEBEXT_STORAGE_LOCAL_GET_MS", +]; +const KEYED_HISTOGRAM_JSON_IDS = [ + "WEBEXT_STORAGE_LOCAL_SET_MS_BY_ADDONID", + "WEBEXT_STORAGE_LOCAL_GET_MS_BY_ADDONID", +]; + +const HISTOGRAM_IDB_IDS = [ + "WEBEXT_STORAGE_LOCAL_IDB_SET_MS", + "WEBEXT_STORAGE_LOCAL_IDB_GET_MS", +]; +const KEYED_HISTOGRAM_IDB_IDS = [ + "WEBEXT_STORAGE_LOCAL_IDB_SET_MS_BY_ADDONID", + "WEBEXT_STORAGE_LOCAL_IDB_GET_MS_BY_ADDONID", +]; + +const HISTOGRAM_IDS = [].concat(HISTOGRAM_JSON_IDS, HISTOGRAM_IDB_IDS); +const KEYED_HISTOGRAM_IDS = [].concat( + KEYED_HISTOGRAM_JSON_IDS, + KEYED_HISTOGRAM_IDB_IDS +); + +const EXTENSION_ID1 = "@test-extension1"; +const EXTENSION_ID2 = "@test-extension2"; + +async function test_telemetry_background() { + const { GleanTimingDistribution } = globalThis; + const expectedEmptyGleanMetrics = ExtensionStorageIDB.isBackendEnabled + ? ["storageLocalGetJson", "storageLocalSetJson"] + : ["storageLocalGetIdb", "storageLocalSetIdb"]; + const expectedNonEmptyGleanMetrics = ExtensionStorageIDB.isBackendEnabled + ? ["storageLocalGetIdb", "storageLocalSetIdb"] + : ["storageLocalGetJson", "storageLocalSetJson"]; + + const expectedEmptyHistograms = ExtensionStorageIDB.isBackendEnabled + ? HISTOGRAM_JSON_IDS + : HISTOGRAM_IDB_IDS; + const expectedEmptyKeyedHistograms = ExtensionStorageIDB.isBackendEnabled + ? KEYED_HISTOGRAM_JSON_IDS + : KEYED_HISTOGRAM_IDB_IDS; + + const expectedNonEmptyHistograms = ExtensionStorageIDB.isBackendEnabled + ? HISTOGRAM_IDB_IDS + : HISTOGRAM_JSON_IDS; + const expectedNonEmptyKeyedHistograms = ExtensionStorageIDB.isBackendEnabled + ? KEYED_HISTOGRAM_IDB_IDS + : KEYED_HISTOGRAM_JSON_IDS; + + const server = createHttpServer(); + server.registerDirectory("/data/", do_get_file("data")); + + const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + + async function contentScript() { + await browser.storage.local.set({ a: "b" }); + await browser.storage.local.get("a"); + browser.test.sendMessage("contentDone"); + } + + let baseManifest = { + permissions: ["storage"], + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + }, + ], + }; + + let baseExtInfo = { + async background() { + await browser.storage.local.set({ a: "b" }); + await browser.storage.local.get("a"); + browser.test.sendMessage("backgroundDone"); + }, + files: { + "content_script.js": contentScript, + }, + }; + + let extension1 = ExtensionTestUtils.loadExtension({ + ...baseExtInfo, + manifest: { + ...baseManifest, + browser_specific_settings: { + gecko: { id: EXTENSION_ID1 }, + }, + }, + }); + let extension2 = ExtensionTestUtils.loadExtension({ + ...baseExtInfo, + manifest: { + ...baseManifest, + browser_specific_settings: { + gecko: { id: EXTENSION_ID2 }, + }, + }, + }); + + // Make sure to force flushing glean fog data from child processes before + // resetting the already collected data. + await Services.fog.testFlushAllChildren(); + resetTelemetryData(); + + // Verify the telemetry data has been cleared. + + // Assert glean telemetry data. + for (let metricId of expectedNonEmptyGleanMetrics) { + assertGleanMetricsNoSamples({ + metricId, + gleanMetric: Glean.extensionsTiming[metricId], + gleanMetricConstructor: GleanTimingDistribution, + }); + } + + // Assert unified telemetry data. + let process = IS_OOP ? "extension" : "parent"; + let snapshots = getSnapshots(process); + let keyedSnapshots = getKeyedSnapshots(process); + + for (let id of HISTOGRAM_IDS) { + ok(!(id in snapshots), `No data recorded for histogram: ${id}.`); + } + + for (let id of KEYED_HISTOGRAM_IDS) { + Assert.deepEqual( + Object.keys(keyedSnapshots[id] || {}), + [], + `No data recorded for histogram: ${id}.` + ); + } + + await extension1.startup(); + await extension1.awaitMessage("backgroundDone"); + + // Assert glean telemetry data. + await Services.fog.testFlushAllChildren(); + for (let metricId of expectedNonEmptyGleanMetrics) { + assertGleanMetricsSamplesCount({ + metricId, + gleanMetric: Glean.extensionsTiming[metricId], + gleanMetricConstructor: GleanTimingDistribution, + expectedSamplesCount: 1, + }); + } + + // Assert unified telemetry data. + if (AppConstants.platform != "android") { + for (let id of expectedNonEmptyHistograms) { + await promiseTelemetryRecorded(id, process, 1); + } + for (let id of expectedNonEmptyKeyedHistograms) { + await promiseKeyedTelemetryRecorded(id, process, EXTENSION_ID1, 1); + } + + // Telemetry from extension1's background page should be recorded. + snapshots = getSnapshots(process); + keyedSnapshots = getKeyedSnapshots(process); + + for (let id of expectedNonEmptyHistograms) { + equal( + valueSum(snapshots[id].values), + 1, + `Data recorded for histogram: ${id}.` + ); + } + + for (let id of expectedNonEmptyKeyedHistograms) { + Assert.deepEqual( + Object.keys(keyedSnapshots[id]), + [EXTENSION_ID1], + `Data recorded for histogram: ${id}.` + ); + equal( + valueSum(keyedSnapshots[id][EXTENSION_ID1].values), + 1, + `Data recorded for histogram: ${id}.` + ); + } + } + + await extension2.startup(); + await extension2.awaitMessage("backgroundDone"); + + // Assert glean telemetry data. + await Services.fog.testFlushAllChildren(); + for (let metricId of expectedNonEmptyGleanMetrics) { + assertGleanMetricsSamplesCount({ + metricId, + gleanMetric: Glean.extensionsTiming[metricId], + gleanMetricConstructor: GleanTimingDistribution, + expectedSamplesCount: 2, + }); + } + + // Assert unified telemetry data. + if (AppConstants.platform != "android") { + for (let id of expectedNonEmptyHistograms) { + await promiseTelemetryRecorded(id, process, 2); + } + for (let id of expectedNonEmptyKeyedHistograms) { + await promiseKeyedTelemetryRecorded(id, process, EXTENSION_ID2, 1); + } + + // Telemetry from extension2's background page should be recorded. + snapshots = getSnapshots(process); + keyedSnapshots = getKeyedSnapshots(process); + + for (let id of expectedNonEmptyHistograms) { + equal( + valueSum(snapshots[id].values), + 2, + `Additional data recorded for histogram: ${id}.` + ); + } + + for (let id of expectedNonEmptyKeyedHistograms) { + Assert.deepEqual( + Object.keys(keyedSnapshots[id]).sort(), + [EXTENSION_ID1, EXTENSION_ID2], + `Additional data recorded for histogram: ${id}.` + ); + equal( + valueSum(keyedSnapshots[id][EXTENSION_ID2].values), + 1, + `Additional data recorded for histogram: ${id}.` + ); + } + } + + await extension2.unload(); + + await Services.fog.testFlushAllChildren(); + resetTelemetryData(); + + // Run a content script. + process = "content"; + // Expect only telemetry for the single extension content script + // that should be executed when loading the test webpage. + let expectedCount = 1; + let expectedKeyedCount = 1; + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + await extension1.awaitMessage("contentDone"); + + // Assert glean telemetry data. + await Services.fog.testFlushAllChildren(); + for (let metricId of expectedNonEmptyGleanMetrics) { + assertGleanMetricsSamplesCount({ + metricId, + gleanMetric: Glean.extensionsTiming[metricId], + gleanMetricConstructor: GleanTimingDistribution, + expectedSamplesCount: expectedCount, + }); + } + + // Assert unified telemetry data. + if (AppConstants.platform != "android") { + for (let id of expectedNonEmptyHistograms) { + await promiseTelemetryRecorded(id, process, expectedCount); + } + for (let id of expectedNonEmptyKeyedHistograms) { + await promiseKeyedTelemetryRecorded( + id, + process, + EXTENSION_ID1, + expectedKeyedCount + ); + } + + // Telemetry from extension1's content script should be recorded. + snapshots = getSnapshots(process); + keyedSnapshots = getKeyedSnapshots(process); + + for (let id of expectedNonEmptyHistograms) { + equal( + valueSum(snapshots[id].values), + expectedCount, + `Data recorded in content script for histogram: ${id}.` + ); + } + + for (let id of expectedNonEmptyKeyedHistograms) { + Assert.deepEqual( + Object.keys(keyedSnapshots[id]).sort(), + [EXTENSION_ID1], + `Additional data recorded for histogram: ${id}.` + ); + equal( + valueSum(keyedSnapshots[id][EXTENSION_ID1].values), + expectedKeyedCount, + `Additional data recorded for histogram: ${id}.` + ); + } + } + + await extension1.unload(); + + // Telemetry that we expect to be empty. + + // Assert glean telemetry data. + await Services.fog.testFlushAllChildren(); + for (let metricId of expectedEmptyGleanMetrics) { + assertGleanMetricsNoSamples({ + metricId, + gleanMetric: Glean.extensionsTiming[metricId], + gleanMetricConstructor: GleanTimingDistribution, + }); + } + + // Assert unified telemetry data. + if (AppConstants.platform != "android") { + for (let id of expectedEmptyHistograms) { + ok(!(id in snapshots), `No data recorded for histogram: ${id}.`); + } + + for (let id of expectedEmptyKeyedHistograms) { + Assert.deepEqual( + Object.keys(keyedSnapshots[id] || {}), + [], + `No data recorded for histogram: ${id}.` + ); + } + } + + await contentPage.close(); +} + +add_task(async function setup() { + // Telemetry test setup needed to ensure that the builtin events are defined + // and they can be collected and verified. + await TelemetryController.testSetup(); + + // This is actually only needed on Android, because it does not properly support unified telemetry + // and so, if not enabled explicitly here, it would make these tests to fail when running on a + // non-Nightly build. + const oldCanRecordBase = Services.telemetry.canRecordBase; + Services.telemetry.canRecordBase = true; + registerCleanupFunction(() => { + Services.telemetry.canRecordBase = oldCanRecordBase; + }); +}); + +add_task(function test_telemetry_background_file_backend() { + return runWithPrefs( + [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]], + test_telemetry_background + ); +}); + +add_task(function test_telemetry_background_idb_backend() { + return runWithPrefs( + [ + [ExtensionStorageIDB.BACKEND_ENABLED_PREF, true], + // Set the migrated preference for the two test extension, because the + // first storage.local call fallbacks to run in the parent process when we + // don't know which is the selected backend during the extension startup + // and so we can't choose the telemetry histogram to use. + [ + `${ExtensionStorageIDB.IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID1}`, + true, + ], + [ + `${ExtensionStorageIDB.IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID2}`, + true, + ], + ], + test_telemetry_background + ); +}); + +// This test verifies that we do record the expected telemetry event when we +// normalize the error message for an unexpected error (an error raised internally +// by the QuotaManager and/or IndexedDB, which it is being normalized into the generic +// "An unexpected error occurred" error message). +add_task(async function test_telemetry_storage_local_unexpected_error() { + // Clear any telemetry events collected so far. + Services.telemetry.clearEvents(); + + const methods = ["clear", "get", "remove", "set"]; + const veryLongErrorName = `VeryLongErrorName${Array(200).fill(0).join("")}`; + const otherError = new Error("an error recorded as OtherError"); + + const recordedErrors = [ + new DOMException("error message", "UnexpectedDOMException"), + new DOMException("error message", veryLongErrorName), + otherError, + ]; + + // We expect the following errors to not be recorded in telemetry (because they + // are raised on scenarios that we already expect). + const nonRecordedErrors = [ + new DOMException("error message", "QuotaExceededError"), + new DOMException("error message", "DataCloneError"), + ]; + + const expectedEvents = []; + + const errors = [].concat(recordedErrors, nonRecordedErrors); + + for (let i = 0; i < errors.length; i++) { + const error = errors[i]; + const storageMethod = methods[i] || "set"; + ExtensionStorageIDB.normalizeStorageError({ + error: errors[i], + extensionId: EXTENSION_ID1, + storageMethod, + }); + + if (recordedErrors.includes(error)) { + let error_name = + error === otherError ? "OtherError" : getTrimmedString(error.name); + + expectedEvents.push({ + value: EXTENSION_ID1, + object: storageMethod, + extra: { error_name }, + }); + } + } + + await TelemetryTestUtils.assertEvents(expectedEvents, { + category: "extensions.data", + method: "storageLocalError", + }); + + let glean = Glean.extensionsData.storageLocalError.testGetValue() ?? []; + equal(glean.length, expectedEvents.length, "Correct number of events."); + + for (let i = 0; i < expectedEvents.length; i++) { + let event = expectedEvents[i]; + equal(glean[i].extra.addon_id, event.value, "Correct addon_id."); + equal(glean[i].extra.method, event.object, "Correct method."); + equal(glean[i].extra.error_name, event.extra.error_name, "Correct error."); + } + Services.fog.testResetFOG(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_tab_teardown.js b/toolkit/components/extensions/test/xpcshell/test_ext_tab_teardown.js new file mode 100644 index 0000000000..538ce9d8fc --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_tab_teardown.js @@ -0,0 +1,97 @@ +"use strict"; + +add_task(async function test_extension_page_tabs_create_reload_and_close() { + let events = []; + { + const { Management } = ChromeUtils.importESModule( + "resource://gre/modules/Extension.sys.mjs" + ); + let record = (type, extensionContext) => { + let eventType = type == "proxy-context-load" ? "load" : "unload"; + let url = extensionContext.uri.spec; + let extensionId = extensionContext.extension.id; + events.push({ eventType, url, extensionId }); + }; + + Management.on("proxy-context-load", record); + Management.on("proxy-context-unload", record); + registerCleanupFunction(() => { + Management.off("proxy-context-load", record); + Management.off("proxy-context-unload", record); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("tab-url", browser.runtime.getURL("page.html")); + }, + files: { + "page.html": `<!DOCTYPE html><meta charset="utf-8"><script src="page.js"></script>`, + "page.js"() { + browser.test.sendMessage("extension page loaded", document.URL); + }, + }, + }); + + await extension.startup(); + let tabURL = await extension.awaitMessage("tab-url"); + events.splice(0); + + let contentPage = await ExtensionTestUtils.loadContentPage(tabURL, { + extension, + }); + let extensionPageURL = await extension.awaitMessage("extension page loaded"); + equal(extensionPageURL, tabURL, "Loaded the expected URL"); + + let contextEvents = events.splice(0); + equal(contextEvents.length, 1, "ExtensionContext change for opening a tab"); + equal(contextEvents[0].eventType, "load", "create ExtensionContext for tab"); + equal( + contextEvents[0].url, + extensionPageURL, + "ExtensionContext URL after tab creation should be tab URL" + ); + + await contentPage.spawn([], () => { + this.content.location.reload(); + }); + let extensionPageURL2 = await extension.awaitMessage("extension page loaded"); + + equal( + extensionPageURL, + extensionPageURL2, + "The tab's URL is expected to not change after a page reload" + ); + + contextEvents = events.splice(0); + equal(contextEvents.length, 2, "ExtensionContext change after tab reload"); + equal(contextEvents[0].eventType, "unload", "unload old ExtensionContext"); + equal( + contextEvents[0].url, + extensionPageURL, + "ExtensionContext URL before reload should be tab URL" + ); + equal( + contextEvents[1].eventType, + "load", + "create new ExtensionContext for tab" + ); + equal( + contextEvents[1].url, + extensionPageURL2, + "ExtensionContext URL after reload should be tab URL" + ); + + await contentPage.close(); + + contextEvents = events.splice(0); + equal(contextEvents.length, 1, "ExtensionContext after closing tab"); + equal(contextEvents[0].eventType, "unload", "unload tab's ExtensionContext"); + equal( + contextEvents[0].url, + extensionPageURL2, + "ExtensionContext URL at closing tab should be tab URL" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_telemetry.js new file mode 100644 index 0000000000..c651e46732 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_telemetry.js @@ -0,0 +1,917 @@ +"use strict"; + +const { TelemetryArchive } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryArchive.sys.mjs" +); +const { TelemetryUtils } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryUtils.sys.mjs" +); +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +const { TelemetryArchiveTesting } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryArchiveTesting.sys.mjs" +); + +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +// All tests run privileged unless otherwise specified not to. +function createExtension( + backgroundScript, + permissions, + isPrivileged = true, + telemetry +) { + let extensionData = { + background: backgroundScript, + manifest: { permissions, telemetry }, + isPrivileged, + }; + + return ExtensionTestUtils.loadExtension(extensionData); +} + +async function run(test) { + let extension = createExtension( + test.backgroundScript, + test.permissions || ["telemetry"], + test.isPrivileged, + test.telemetry + ); + await extension.startup(); + await extension.awaitFinish(test.doneSignal); + await extension.unload(); +} + +// Currently unsupported on Android: blocked on 1220177. +// See 1280234 c67 for discussion. +if (AppConstants.MOZ_BUILD_APP === "browser") { + add_task(async function test_telemetry_without_telemetry_permission() { + await run({ + backgroundScript: () => { + browser.test.assertTrue( + !browser.telemetry, + "'telemetry' permission is required" + ); + browser.test.notifyPass("telemetry_permission"); + }, + permissions: [], + doneSignal: "telemetry_permission", + isPrivileged: false, + }); + }); + + add_task( + async function test_telemetry_without_telemetry_permission_privileged() { + await run({ + backgroundScript: () => { + browser.test.assertTrue( + !browser.telemetry, + "'telemetry' permission is required" + ); + browser.test.notifyPass("telemetry_permission"); + }, + permissions: [], + doneSignal: "telemetry_permission", + }); + } + ); + + add_task(async function test_telemetry_scalar_add() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarAdd( + "telemetry.test.unsigned_int_kind", + 1 + ); + browser.test.notifyPass("scalar_add"); + }, + doneSignal: "scalar_add", + }); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent", false, true), + "telemetry.test.unsigned_int_kind", + 1 + ); + }); + + add_task(async function test_telemetry_scalar_add_unknown_name() { + let { messages } = await promiseConsoleOutput(async () => { + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarAdd("telemetry.test.does_not_exist", 1); + browser.test.notifyPass("scalar_add_unknown_name"); + }, + doneSignal: "scalar_add_unknown_name", + }); + }); + Assert.ok( + messages.find(({ message }) => message.includes("Unknown scalar")), + "Telemetry should warn if an unknown scalar is incremented" + ); + }); + + add_task(async function test_telemetry_scalar_add_illegal_value() { + await run({ + backgroundScript: () => { + browser.test.assertThrows( + () => + browser.telemetry.scalarAdd("telemetry.test.unsigned_int_kind", {}), + /Incorrect argument types for telemetry.scalarAdd/, + "The second 'value' argument to scalarAdd must be an integer, string, or boolean" + ); + browser.test.notifyPass("scalar_add_illegal_value"); + }, + doneSignal: "scalar_add_illegal_value", + }); + }); + + add_task(async function test_telemetry_scalar_add_invalid_keyed_scalar() { + let { messages } = await promiseConsoleOutput(async function () { + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarAdd( + "telemetry.test.keyed_unsigned_int", + 1 + ); + browser.test.notifyPass("scalar_add_invalid_keyed_scalar"); + }, + doneSignal: "scalar_add_invalid_keyed_scalar", + }); + }); + Assert.ok( + messages.find(({ message }) => + message.includes("Attempting to manage a keyed scalar as a scalar") + ), + "Telemetry should warn if a scalarAdd is called for a keyed scalar" + ); + }); + + add_task(async function test_telemetry_scalar_set_bool_true() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarSet("telemetry.test.boolean_kind", true); + browser.test.notifyPass("scalar_set_bool_true"); + }, + doneSignal: "scalar_set_bool_true", + }); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent", false, true), + "telemetry.test.boolean_kind", + true + ); + }); + + add_task(async function test_telemetry_scalar_set_bool_false() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarSet("telemetry.test.boolean_kind", false); + browser.test.notifyPass("scalar_set_bool_false"); + }, + doneSignal: "scalar_set_bool_false", + }); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent", false, true), + "telemetry.test.boolean_kind", + false + ); + }); + + add_task(async function test_telemetry_scalar_unset_bool() { + Services.telemetry.clearScalars(); + TelemetryTestUtils.assertScalarUnset( + TelemetryTestUtils.getProcessScalars("parent", false, true), + "telemetry.test.boolean_kind" + ); + }); + + add_task(async function test_telemetry_scalar_set_unknown_name() { + let { messages } = await promiseConsoleOutput(async function () { + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarSet( + "telemetry.test.does_not_exist", + true + ); + browser.test.notifyPass("scalar_set_unknown_name"); + }, + doneSignal: "scalar_set_unknown_name", + }); + }); + Assert.ok( + messages.find(({ message }) => message.includes("Unknown scalar")), + "Telemetry should warn if an unknown scalar is set" + ); + }); + + add_task(async function test_telemetry_scalar_set_zero() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarSet( + "telemetry.test.unsigned_int_kind", + 0 + ); + browser.test.notifyPass("scalar_set_zero"); + }, + doneSignal: "scalar_set_zero", + }); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent", false, true), + "telemetry.test.unsigned_int_kind", + 0 + ); + }); + + add_task(async function test_telemetry_scalar_set_maximum() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarSetMaximum( + "telemetry.test.unsigned_int_kind", + 123 + ); + browser.test.notifyPass("scalar_set_maximum"); + }, + doneSignal: "scalar_set_maximum", + }); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent", false, true), + "telemetry.test.unsigned_int_kind", + 123 + ); + }); + + add_task(async function test_telemetry_scalar_set_maximum_unknown_name() { + let { messages } = await promiseConsoleOutput(async function () { + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarSetMaximum( + "telemetry.test.does_not_exist", + 1 + ); + browser.test.notifyPass("scalar_set_maximum_unknown_name"); + }, + doneSignal: "scalar_set_maximum_unknown_name", + }); + }); + Assert.ok( + messages.find(({ message }) => message.includes("Unknown scalar")), + "Telemetry should warn if an unknown scalar is set" + ); + }); + + add_task(async function test_telemetry_scalar_set_maximum_illegal_value() { + await run({ + backgroundScript: () => { + browser.test.assertThrows( + () => + browser.telemetry.scalarSetMaximum( + "telemetry.test.unsigned_int_kind", + "string" + ), + /Incorrect argument types for telemetry.scalarSetMaximum/, + "The second 'value' argument to scalarSetMaximum must be a scalar" + ); + browser.test.notifyPass("scalar_set_maximum_illegal_value"); + }, + doneSignal: "scalar_set_maximum_illegal_value", + }); + }); + + add_task(async function test_telemetry_keyed_scalar_add() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarAdd( + "telemetry.test.keyed_unsigned_int", + "foo", + 1 + ); + browser.test.notifyPass("keyed_scalar_add"); + }, + doneSignal: "keyed_scalar_add", + }); + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "telemetry.test.keyed_unsigned_int", + "foo", + 1 + ); + }); + + add_task(async function test_telemetry_keyed_scalar_add_unknown_name() { + let { messages } = await promiseConsoleOutput(async () => { + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarAdd( + "telemetry.test.does_not_exist", + "foo", + 1 + ); + browser.test.notifyPass("keyed_scalar_add_unknown_name"); + }, + doneSignal: "keyed_scalar_add_unknown_name", + }); + }); + Assert.ok( + messages.find(({ message }) => message.includes("Unknown scalar")), + "Telemetry should warn if an unknown keyed scalar is incremented" + ); + }); + + add_task(async function test_telemetry_keyed_scalar_add_illegal_value() { + await run({ + backgroundScript: () => { + browser.test.assertThrows( + () => + browser.telemetry.keyedScalarAdd( + "telemetry.test.keyed_unsigned_int", + "foo", + {} + ), + /Incorrect argument types for telemetry.keyedScalarAdd/, + "The second 'value' argument to keyedScalarAdd must be an integer, string, or boolean" + ); + browser.test.notifyPass("keyed_scalar_add_illegal_value"); + }, + doneSignal: "keyed_scalar_add_illegal_value", + }); + }); + + add_task(async function test_telemetry_keyed_scalar_add_invalid_scalar() { + let { messages } = await promiseConsoleOutput(async function () { + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarAdd( + "telemetry.test.unsigned_int_kind", + "foo", + 1 + ); + browser.test.notifyPass("keyed_scalar_add_invalid_scalar"); + }, + doneSignal: "keyed_scalar_add_invalid_scalar", + }); + }); + Assert.ok( + messages.find(({ message }) => + message.includes( + "Attempting to manage a keyed scalar as a scalar (or vice-versa)" + ) + ), + "Telemetry should warn if a scalar is incremented as a keyed scalar" + ); + }); + + add_task(async function test_telemetry_keyed_scalar_add_long_key() { + let { messages } = await promiseConsoleOutput(async () => { + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarAdd( + "telemetry.test.keyed_unsigned_int", + "X".repeat(73), + 1 + ); + browser.test.notifyPass("keyed_scalar_add_long_key"); + }, + doneSignal: "keyed_scalar_add_long_key", + }); + }); + Assert.ok( + messages.find(({ message }) => + message.includes("The key length must be limited to 72 characters.") + ), + "Telemetry should warn if keyed scalar's key is too long" + ); + }); + + add_task(async function test_telemetry_keyed_scalar_set() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarSet( + "telemetry.test.keyed_boolean_kind", + "foo", + true + ); + browser.test.notifyPass("keyed_scalar_set"); + }, + doneSignal: "keyed_scalar_set", + }); + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "telemetry.test.keyed_boolean_kind", + "foo", + true + ); + }); + + add_task(async function test_telemetry_keyed_scalar_set_unknown_name() { + let { messages } = await promiseConsoleOutput(async function () { + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarSet( + "telemetry.test.does_not_exist", + "foo", + true + ); + browser.test.notifyPass("keyed_scalar_set_unknown_name"); + }, + doneSignal: "keyed_scalar_set_unknown_name", + }); + }); + Assert.ok( + messages.find(({ message }) => message.includes("Unknown scalar")), + "Telemetry should warn if an unknown keyed scalar is incremented" + ); + }); + + add_task(async function test_telemetry_keyed_scalar_set_long_key() { + let { messages } = await promiseConsoleOutput(async () => { + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarSet( + "telemetry.test.keyed_unsigned_int", + "X".repeat(73), + 1 + ); + browser.test.notifyPass("keyed_scalar_set_long_key"); + }, + doneSignal: "keyed_scalar_set_long_key", + }); + }); + Assert.ok( + messages.find(({ message }) => + message.includes("The key length must be limited to 72 characters") + ), + "Telemetry should warn if keyed scalar's key is too long" + ); + }); + + add_task(async function test_telemetry_keyed_scalar_set_maximum() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarSetMaximum( + "telemetry.test.keyed_unsigned_int", + "foo", + 123 + ); + browser.test.notifyPass("keyed_scalar_set_maximum"); + }, + doneSignal: "keyed_scalar_set_maximum", + }); + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "telemetry.test.keyed_unsigned_int", + "foo", + 123 + ); + }); + + add_task( + async function test_telemetry_keyed_scalar_set_maximum_unknown_name() { + let { messages } = await promiseConsoleOutput(async function () { + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarSetMaximum( + "telemetry.test.does_not_exist", + "foo", + 1 + ); + browser.test.notifyPass("keyed_scalar_set_maximum_unknown_name"); + }, + doneSignal: "keyed_scalar_set_maximum_unknown_name", + }); + }); + Assert.ok( + messages.find(({ message }) => message.includes("Unknown scalar")), + "Telemetry should warn if an unknown keyed scalar is set" + ); + } + ); + + add_task( + async function test_telemetry_keyed_scalar_set_maximum_illegal_value() { + await run({ + backgroundScript: () => { + browser.test.assertThrows( + () => + browser.telemetry.keyedScalarSetMaximum( + "telemetry.test.keyed_unsigned_int", + "foo", + "string" + ), + /Incorrect argument types for telemetry.keyedScalarSetMaximum/, + "The third 'value' argument to keyedScalarSetMaximum must be a scalar" + ); + browser.test.notifyPass("keyed_scalar_set_maximum_illegal_value"); + }, + doneSignal: "keyed_scalar_set_maximum_illegal_value", + }); + } + ); + + add_task(async function test_telemetry_keyed_scalar_set_maximum_long_key() { + let { messages } = await promiseConsoleOutput(async () => { + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarSetMaximum( + "telemetry.test.keyed_unsigned_int", + "X".repeat(73), + 1 + ); + browser.test.notifyPass("keyed_scalar_set_maximum_long_key"); + }, + doneSignal: "keyed_scalar_set_maximum_long_key", + }); + }); + Assert.ok( + messages.find(({ message }) => + message.includes("The key length must be limited to 72 characters") + ), + "Telemetry should warn if keyed scalar's key is too long" + ); + }); + + add_task(async function test_telemetry_record_event() { + Services.telemetry.clearEvents(); + Services.telemetry.setEventRecordingEnabled("telemetry.test", true); + + await run({ + backgroundScript: async () => { + await browser.telemetry.recordEvent( + "telemetry.test", + "test1", + "object1" + ); + browser.test.notifyPass("record_event_ok"); + }, + doneSignal: "record_event_ok", + }); + + TelemetryTestUtils.assertEvents( + [ + { + category: "telemetry.test", + method: "test1", + object: "object1", + }, + ], + { category: "telemetry.test" } + ); + + Services.telemetry.setEventRecordingEnabled("telemetry.test", false); + Services.telemetry.clearEvents(); + }); + + // Bug 1536877 + add_task(async function test_telemetry_record_event_value_must_be_string() { + Services.telemetry.clearEvents(); + Services.telemetry.setEventRecordingEnabled("telemetry.test", true); + + await run({ + backgroundScript: async () => { + try { + await browser.telemetry.recordEvent( + "telemetry.test", + "test1", + "object1", + "value1" + ); + browser.test.notifyPass("record_event_string_value"); + } catch (ex) { + browser.test.fail( + `Unexpected exception raised during record_event_value_must_be_string: ${ex}` + ); + browser.test.notifyPass("record_event_string_value"); + throw ex; + } + }, + doneSignal: "record_event_string_value", + }); + + TelemetryTestUtils.assertEvents( + [ + { + category: "telemetry.test", + method: "test1", + object: "object1", + value: "value1", + }, + ], + { category: "telemetry.test" } + ); + + Services.telemetry.setEventRecordingEnabled("telemetry.test", false); + Services.telemetry.clearEvents(); + }); + + add_task(async function test_telemetry_register_scalars_string() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.registerScalars("telemetry.test.dynamic", { + webext_string: { + kind: browser.telemetry.ScalarType.STRING, + keyed: false, + record_on_release: true, + }, + }); + await browser.telemetry.scalarSet( + "telemetry.test.dynamic.webext_string", + "hello" + ); + browser.test.notifyPass("register_scalars_string"); + }, + doneSignal: "register_scalars_string", + }); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("dynamic", false, true), + "telemetry.test.dynamic.webext_string", + "hello" + ); + }); + + add_task(async function test_telemetry_register_scalars_multiple() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.registerScalars("telemetry.test.dynamic", { + webext_string: { + kind: browser.telemetry.ScalarType.STRING, + keyed: false, + record_on_release: true, + }, + webext_string_too: { + kind: browser.telemetry.ScalarType.STRING, + keyed: false, + record_on_release: true, + }, + }); + await browser.telemetry.scalarSet( + "telemetry.test.dynamic.webext_string", + "hello" + ); + await browser.telemetry.scalarSet( + "telemetry.test.dynamic.webext_string_too", + "world" + ); + browser.test.notifyPass("register_scalars_multiple"); + }, + doneSignal: "register_scalars_multiple", + }); + const scalars = TelemetryTestUtils.getProcessScalars( + "dynamic", + false, + true + ); + TelemetryTestUtils.assertScalar( + scalars, + "telemetry.test.dynamic.webext_string", + "hello" + ); + TelemetryTestUtils.assertScalar( + scalars, + "telemetry.test.dynamic.webext_string_too", + "world" + ); + }); + + add_task(async function test_telemetry_register_scalars_boolean() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.registerScalars("telemetry.test.dynamic", { + webext_boolean: { + kind: browser.telemetry.ScalarType.BOOLEAN, + keyed: false, + record_on_release: true, + }, + }); + await browser.telemetry.scalarSet( + "telemetry.test.dynamic.webext_boolean", + true + ); + browser.test.notifyPass("register_scalars_boolean"); + }, + doneSignal: "register_scalars_boolean", + }); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("dynamic", false, true), + "telemetry.test.dynamic.webext_boolean", + true + ); + }); + + add_task(async function test_telemetry_register_scalars_count() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.registerScalars("telemetry.test.dynamic", { + webext_count: { + kind: browser.telemetry.ScalarType.COUNT, + keyed: false, + record_on_release: true, + }, + }); + await browser.telemetry.scalarSet( + "telemetry.test.dynamic.webext_count", + 123 + ); + browser.test.notifyPass("register_scalars_count"); + }, + doneSignal: "register_scalars_count", + }); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("dynamic", false, true), + "telemetry.test.dynamic.webext_count", + 123 + ); + }); + + add_task(async function test_telemetry_register_events() { + Services.telemetry.clearEvents(); + + await run({ + backgroundScript: async () => { + await browser.telemetry.registerEvents("telemetry.test.dynamic", { + test1: { + methods: ["test1"], + objects: ["object1"], + extra_keys: [], + }, + }); + await browser.telemetry.recordEvent( + "telemetry.test.dynamic", + "test1", + "object1" + ); + browser.test.notifyPass("register_events"); + }, + doneSignal: "register_events", + }); + + TelemetryTestUtils.assertEvents( + [ + { + category: "telemetry.test.dynamic", + method: "test1", + object: "object1", + }, + ], + { category: "telemetry.test.dynamic" }, + { process: "dynamic" } + ); + }); + + add_task(async function test_telemetry_submit_ping() { + let archiveTester = new TelemetryArchiveTesting.Checker(); + await archiveTester.promiseInit(); + + await run({ + backgroundScript: async () => { + await browser.telemetry.submitPing("webext-test", {}, {}); + browser.test.notifyPass("submit_ping"); + }, + doneSignal: "submit_ping", + }); + + await TestUtils.waitForCondition( + () => archiveTester.promiseFindPing("webext-test", []), + "Failed to find the webext-test ping" + ); + }); + + add_task(async function test_telemetry_submit_encrypted_ping() { + await run({ + backgroundScript: async () => { + try { + await browser.telemetry.submitEncryptedPing( + { payload: "encrypted-webext-test" }, + { + schemaName: "schema-name", + schemaVersion: 123, + } + ); + browser.test.fail( + "Expected exception without required manifest entries set." + ); + } catch (e) { + browser.test.assertTrue( + e, + /Encrypted telemetry pings require ping_type and public_key to be set in manifest./ + ); + browser.test.notifyPass("submit_encrypted_ping_fail"); + } + }, + doneSignal: "submit_encrypted_ping_fail", + }); + + const telemetryManifestEntries = { + ping_type: "encrypted-webext-ping", + schemaNamespace: "schema-namespace", + public_key: { + id: "pioneer-dev-20200423", + key: { + crv: "P-256", + kty: "EC", + x: "Qqihp7EryDN2-qQ-zuDPDpy5mJD5soFBDZmzPWTmjwk", + y: "PiEQVUlywi2bEsA3_5D0VFrCHClCyUlLW52ajYs-5uc", + }, + }, + }; + + await run({ + backgroundScript: async () => { + await browser.telemetry.submitEncryptedPing( + { + payload: "encrypted-webext-test", + }, + { + schemaName: "schema-name", + schemaVersion: 123, + } + ); + browser.test.notifyPass("submit_encrypted_ping_pass"); + }, + permissions: ["telemetry"], + doneSignal: "submit_encrypted_ping_pass", + isPrivileged: true, + telemetry: telemetryManifestEntries, + }); + + telemetryManifestEntries.pioneer_id = true; + telemetryManifestEntries.study_name = "test123"; + Services.prefs.setStringPref("toolkit.telemetry.pioneerId", "test123"); + + await run({ + backgroundScript: async () => { + await browser.telemetry.submitEncryptedPing( + { payload: "encrypted-webext-test" }, + { + schemaName: "schema-name", + schemaVersion: 123, + } + ); + browser.test.notifyPass("submit_encrypted_ping_pass"); + }, + permissions: ["telemetry"], + doneSignal: "submit_encrypted_ping_pass", + isPrivileged: true, + telemetry: telemetryManifestEntries, + }); + + let pings; + await TestUtils.waitForCondition(async function () { + pings = await TelemetryArchive.promiseArchivedPingList(); + return pings.length >= 3; + }, "Wait until we have at least 3 pings in the telemetry archive"); + + equal(pings.length, 3); + equal(pings[1].type, "encrypted-webext-ping"); + equal(pings[2].type, "encrypted-webext-ping"); + }); + + add_task(async function test_telemetry_can_upload_enabled() { + Services.prefs.setBoolPref( + TelemetryUtils.Preferences.FhrUploadEnabled, + true + ); + + await run({ + backgroundScript: async () => { + const result = await browser.telemetry.canUpload(); + browser.test.assertTrue(result); + browser.test.notifyPass("can_upload_enabled"); + }, + doneSignal: "can_upload_enabled", + }); + + Services.prefs.clearUserPref(TelemetryUtils.Preferences.FhrUploadEnabled); + }); + + add_task(async function test_telemetry_can_upload_disabled() { + Services.prefs.setBoolPref( + TelemetryUtils.Preferences.FhrUploadEnabled, + false + ); + + await run({ + backgroundScript: async () => { + const result = await browser.telemetry.canUpload(); + browser.test.assertFalse(result); + browser.test.notifyPass("can_upload_disabled"); + }, + doneSignal: "can_upload_disabled", + }); + + Services.prefs.clearUserPref(TelemetryUtils.Preferences.FhrUploadEnabled); + }); +} diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_test_mock.js b/toolkit/components/extensions/test/xpcshell/test_ext_test_mock.js new file mode 100644 index 0000000000..81e07d9a9b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_test_mock.js @@ -0,0 +1,55 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// This test verifies that the extension mocks behave consistently, regardless +// of test type (xpcshell vs browser test). +// See also toolkit/components/extensions/test/browser/browser_ext_test_mock.js + +// Check the state of the extension object. This should be consistent between +// browser tests and xpcshell tests. +async function checkExtensionStartupAndUnload(ext) { + await ext.startup(); + Assert.ok(ext.id, "Extension ID should be available"); + Assert.ok(ext.uuid, "Extension UUID should be available"); + await ext.unload(); + // Once set nothing clears the UUID. + Assert.ok(ext.uuid, "Extension UUID exists after unload"); +} + +AddonTestUtils.init(this); + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_MockExtension() { + let ext = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: {}, + }); + + Assert.equal(ext.constructor.name, "InstallableWrapper", "expected class"); + Assert.ok(!ext.id, "Extension ID is initially unavailable"); + Assert.ok(!ext.uuid, "Extension UUID is initially unavailable"); + await checkExtensionStartupAndUnload(ext); + // When useAddonManager is set, AOMExtensionWrapper clears the ID upon unload. + // TODO: Fix AOMExtensionWrapper to not clear the ID after unload, and move + // this assertion inside |checkExtensionStartupAndUnload| (since then the + // behavior will be consistent across all test types). + Assert.ok(!ext.id, "Extension ID is cleared after unload"); +}); + +add_task(async function test_generated_Extension() { + let ext = ExtensionTestUtils.loadExtension({ + manifest: {}, + }); + + Assert.equal(ext.constructor.name, "ExtensionWrapper", "expected class"); + // Without "useAddonManager", an Extension is generated and their IDs are + // immediately available. + Assert.ok(ext.id, "Extension ID is initially available"); + Assert.ok(ext.uuid, "Extension UUID is initially available"); + await checkExtensionStartupAndUnload(ext); + Assert.ok(ext.id, "Extension ID exists after unload"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_test_wrapper.js b/toolkit/components/extensions/test/xpcshell/test_ext_test_wrapper.js new file mode 100644 index 0000000000..b4b1b87ee5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_test_wrapper.js @@ -0,0 +1,60 @@ +"use strict"; + +Services.prefs.setBoolPref("extensions.blocklist.enabled", false); + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", +}); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +const TEST_ADDON_ID = "@some-permanent-test-addon"; + +// Load a permanent extension that eventually unloads the extension immediately +// after add-on startup, to set the stage as a regression test for bug 1575190. +add_task(async function setup_wrapper() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: TEST_ADDON_ID } }, + }, + background() { + browser.test.sendMessage("started_up"); + }, + }); + + await AddonTestUtils.promiseStartupManager(); + await extension.startup(); + await extension.awaitBackgroundStarted(); + await AddonTestUtils.promiseShutdownManager(); + + // Check message because it is expected to be received while `startup()` was + // pending resolution. + info("Awaiting expected started_up message 1"); + await extension.awaitMessage("started_up"); + + // Load AddonManager, and unload the extension as soon as it has started. + await AddonTestUtils.promiseStartupManager(); + await extension.awaitBackgroundStarted(); + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + + // Confirm that the extension has started when promiseStartupManager returned. + info("Awaiting expected started_up message 2"); + await extension.awaitMessage("started_up"); +}); + +// Check that the add-on from the previous test has indeed been uninstalled. +add_task(async function restart_addon_manager_after_extension_unload() { + await AddonTestUtils.promiseStartupManager(); + let addon = await AddonManager.getAddonByID(TEST_ADDON_ID); + equal(addon, null, "Test add-on should have been removed"); + await AddonTestUtils.promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_theme_experiments.js b/toolkit/components/extensions/test/xpcshell/test_ext_theme_experiments.js new file mode 100644 index 0000000000..4c3bf7b4d9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_theme_experiments.js @@ -0,0 +1,109 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.usePrivilegedSignatures = id => id.startsWith("privileged"); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +// This test checks whether the theme experiments work for privileged static themes +// and are ignored for unprivileged static themes. +async function test_experiment_static_theme({ privileged }) { + let extensionManifest = { + theme: { + colors: {}, + images: {}, + properties: {}, + }, + theme_experiment: { + colors: {}, + images: {}, + properties: {}, + }, + }; + + const addonId = `${ + privileged ? "privileged" : "unprivileged" + }-static-theme@test-extension`; + const themeFiles = { + "manifest.json": { + name: "test theme", + version: "1.0", + manifest_version: 2, + browser_specific_settings: { + gecko: { id: addonId }, + }, + ...extensionManifest, + }, + }; + + const promiseThemeUpdated = TestUtils.topicObserved( + "lightweight-theme-styling-update" + ); + + let themeAddon; + const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + let { addon } = await AddonTestUtils.promiseInstallXPI(themeFiles); + // Enable the newly installed static theme. + await addon.enable(); + themeAddon = addon; + }); + + const themeExperimentNotAllowed = { + message: /This extension is not allowed to run theme experiments/, + }; + AddonTestUtils.checkMessages(messages, { + forbidden: privileged ? [themeExperimentNotAllowed] : [], + expected: privileged ? [] : [themeExperimentNotAllowed], + }); + + if (privileged) { + // ext-theme.js Theme class constructor doesn't call Theme.prototype.load + // if the static theme includes theme_experiment but isn't allowed to. + info("Wait for theme updated observer service topic to be notified"); + const [topicSubject] = await promiseThemeUpdated; + let themeData = topicSubject.wrappedJSObject; + ok( + themeData.experiment, + "Expect theme experiment property to be defined in theme update data" + ); + } + + const policy = WebExtensionPolicy.getByID(themeAddon.id); + equal( + policy.extension.isPrivileged, + privileged, + `The static theme should be ${privileged ? "privileged" : "unprivileged"}` + ); + + await themeAddon.uninstall(); +} + +add_task(function test_privileged_theme() { + return test_experiment_static_theme({ privileged: true }); +}); + +add_task( + { + // Some builds (e.g. thunderbird) have experiments enabled by default. + pref_set: [["extensions.experiments.enabled", false]], + }, + function test_unprivileged_theme() { + return test_experiment_static_theme({ privileged: false }); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_trustworthy_origin.js b/toolkit/components/extensions/test/xpcshell/test_ext_trustworthy_origin.js new file mode 100644 index 0000000000..f509ae1749 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_trustworthy_origin.js @@ -0,0 +1,20 @@ +"use strict"; + +/** + * This test is asserting that moz-extension: URLs are recognized as trustworthy local origins + */ + +add_task( + function test_isOriginPotentiallyTrustworthnsIContentSecurityManagery() { + let uri = NetUtil.newURI("moz-extension://foobar/something.html"); + let principal = Services.scriptSecurityManager.createContentPrincipal( + uri, + {} + ); + Assert.equal( + principal.isOriginPotentiallyTrustworthy, + true, + "it is potentially trustworthy" + ); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_unknown_permissions.js b/toolkit/components/extensions/test/xpcshell/test_ext_unknown_permissions.js new file mode 100644 index 0000000000..aa4a309a52 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_unknown_permissions.js @@ -0,0 +1,60 @@ +"use strict"; + +// This test expects and checks warnings for unknown permissions. +ExtensionTestUtils.failOnSchemaWarnings(false); + +add_task(async function test_unknown_permissions() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "activeTab", + "fooUnknownPermission", + "http://*/", + "chrome://favicon/", + ], + optional_permissions: ["chrome://favicon/", "https://example.com/"], + }, + }); + + let { messages } = await promiseConsoleOutput(() => extension.startup()); + + const { WebExtensionPolicy } = Cu.getGlobalForObject( + ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs") + ); + + let policy = WebExtensionPolicy.getByID(extension.id); + Assert.deepEqual(Array.from(policy.permissions).sort(), ["activeTab"]); + + Assert.deepEqual(extension.extension.manifest.optional_permissions, [ + "https://example.com/", + ]); + + ok( + messages.some(message => + /Error processing permissions\.1: Value "fooUnknownPermission" must/.test( + message + ) + ), + 'Got expected error for "fooUnknownPermission"' + ); + + ok( + messages.some(message => + /Error processing permissions\.3: Value "chrome:\/\/favicon\/" must/.test( + message + ) + ), + 'Got expected error for "chrome://favicon/"' + ); + + ok( + messages.some(message => + /Error processing optional_permissions\.0: Value "chrome:\/\/favicon\/" must/.test( + message + ) + ), + "Got expected error from optional_permissions" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_unlimitedStorage.js b/toolkit/components/extensions/test/xpcshell/test_ext_unlimitedStorage.js new file mode 100644 index 0000000000..77eb0c89f7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_unlimitedStorage.js @@ -0,0 +1,211 @@ +"use strict"; + +const { + createAppInfo, + promiseStartupManager, + promiseRestartManager, + promiseWebExtensionStartup, +} = AddonTestUtils; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42"); + +const STORAGE_SITE_PERMISSIONS = [ + "WebExtensions-unlimitedStorage", + "persistent-storage", +]; + +function checkSitePermissions(principal, expectedPermAction, assertMessage) { + for (const permName of STORAGE_SITE_PERMISSIONS) { + const actualPermAction = Services.perms.testPermissionFromPrincipal( + principal, + permName + ); + + equal( + actualPermAction, + expectedPermAction, + `The extension "${permName}" SitePermission ${assertMessage} as expected` + ); + } +} + +add_task(async function test_unlimitedStorage_restored_on_app_startup() { + const id = "test-unlimitedStorage-removed-on-app-shutdown@mozilla"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["unlimitedStorage"], + browser_specific_settings: { + gecko: { id }, + }, + }, + + useAddonManager: "permanent", + }); + + await promiseStartupManager(); + await extension.startup(); + + const policy = WebExtensionPolicy.getByID(extension.id); + const principal = policy.extension.principal; + + checkSitePermissions( + principal, + Services.perms.ALLOW_ACTION, + "has been allowed" + ); + + // Remove site permissions as it would happen if Firefox is shutting down + // with the "clear site permissions" setting. + + Services.perms.removeFromPrincipal( + principal, + "WebExtensions-unlimitedStorage" + ); + Services.perms.removeFromPrincipal(principal, "persistent-storage"); + + checkSitePermissions(principal, Services.perms.UNKNOWN_ACTION, "is not set"); + + const onceExtensionStarted = promiseWebExtensionStartup(id); + await promiseRestartManager(); + await onceExtensionStarted; + + // The site permissions should have been granted again. + checkSitePermissions( + principal, + Services.perms.ALLOW_ACTION, + "has been allowed" + ); + + await extension.unload(); +}); + +add_task(async function test_unlimitedStorage_removed_on_update() { + const id = "test-unlimitedStorage-removed-on-update@mozilla"; + + function background() { + browser.test.onMessage.addListener(async msg => { + switch (msg) { + case "set-storage": + browser.test.log(`storing data in storage.local`); + await browser.storage.local.set({ akey: "somevalue" }); + browser.test.log(`data stored in storage.local successfully`); + break; + case "has-storage": { + browser.test.log(`checking data stored in storage.local`); + const data = await browser.storage.local.get(["akey"]); + browser.test.assertEq( + data.akey, + "somevalue", + "Got storage.local data" + ); + break; + } + default: + browser.test.fail(`Unexpected test message: ${msg}`); + } + + browser.test.sendMessage(`${msg}:done`); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["unlimitedStorage", "storage"], + browser_specific_settings: { gecko: { id } }, + version: "1", + }, + useAddonManager: "permanent", + }); + + await extension.startup(); + + const policy = WebExtensionPolicy.getByID(extension.id); + const principal = policy.extension.principal; + + checkSitePermissions( + principal, + Services.perms.ALLOW_ACTION, + "has been allowed" + ); + + extension.sendMessage("set-storage"); + await extension.awaitMessage("set-storage:done"); + extension.sendMessage("has-storage"); + await extension.awaitMessage("has-storage:done"); + + // Simulate an update which do not require the unlimitedStorage permission. + await extension.upgrade({ + background, + manifest: { + permissions: ["storage"], + browser_specific_settings: { gecko: { id } }, + version: "2", + }, + useAddonManager: "permanent", + }); + + const newPolicy = WebExtensionPolicy.getByID(extension.id); + const newPrincipal = newPolicy.extension.principal; + + equal( + principal.spec, + newPrincipal.spec, + "upgraded extension has the expected principal" + ); + + checkSitePermissions( + principal, + Services.perms.UNKNOWN_ACTION, + "has been cleared" + ); + + // Verify that the previously stored data has not been + // removed as a side effect of removing the unlimitedStorage + // permission. + extension.sendMessage("has-storage"); + await extension.awaitMessage("has-storage:done"); + + await extension.unload(); +}); + +add_task(async function test_unlimitedStorage_origin_attributes() { + Services.prefs.setBoolPref("privacy.firstparty.isolate", true); + + const id = "test-unlimitedStorage-origin-attributes@mozilla"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["unlimitedStorage"], + browser_specific_settings: { gecko: { id } }, + }, + }); + + await extension.startup(); + + let policy = WebExtensionPolicy.getByID(extension.id); + let principal = policy.extension.principal; + + ok( + !principal.firstPartyDomain, + "extension principal has no firstPartyDomain" + ); + + let perm = Services.perms.testExactPermissionFromPrincipal( + principal, + "persistent-storage" + ); + equal( + perm, + Services.perms.ALLOW_ACTION, + "Should have the correct permission without OAs" + ); + + await extension.unload(); + + Services.prefs.clearUserPref("privacy.firstparty.isolate"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_unload_frame.js b/toolkit/components/extensions/test/xpcshell/test_ext_unload_frame.js new file mode 100644 index 0000000000..fee33e8815 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_unload_frame.js @@ -0,0 +1,230 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +// Background and content script for testSendMessage_* +function sendMessage_background(delayedNotifyPass) { + browser.runtime.onMessage.addListener((msg, sender, sendResponse) => { + browser.test.assertEq("from frame", msg, "Expected message from frame"); + sendResponse("msg from back"); // Should not throw or anything like that. + delayedNotifyPass("Received sendMessage from closing frame"); + }); +} +function sendMessage_contentScript(testType) { + browser.runtime.sendMessage("from frame", reply => { + // The frame has been removed, so we should not get this callback! + browser.test.fail(`Unexpected reply: ${reply}`); + }); + if (testType == "frame") { + frameElement.remove(); + } else { + browser.test.sendMessage("close-window"); + } +} + +// Background and content script for testConnect_* +function connect_background(delayedNotifyPass) { + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq("port from frame", port.name); + + let disconnected = false; + let hasMessage = false; + port.onDisconnect.addListener(() => { + browser.test.assertFalse(disconnected, "onDisconnect should fire once"); + disconnected = true; + browser.test.assertTrue( + hasMessage, + "Expected onMessage before onDisconnect" + ); + browser.test.assertEq( + null, + port.error, + "The port is implicitly closed without errors when the other context unloads" + ); + delayedNotifyPass("Received onDisconnect from closing frame"); + }); + port.onMessage.addListener(msg => { + browser.test.assertFalse(hasMessage, "onMessage should fire once"); + hasMessage = true; + browser.test.assertFalse( + disconnected, + "Should get message before disconnect" + ); + browser.test.assertEq("from frame", msg, "Expected message from frame"); + }); + + port.postMessage("reply to closing frame"); + }); +} +function connect_contentScript(testType) { + let isUnloading = false; + addEventListener( + "pagehide", + () => { + isUnloading = true; + }, + { once: true } + ); + + let port = browser.runtime.connect({ name: "port from frame" }); + port.onMessage.addListener(msg => { + // The background page sends a reply as soon as we call runtime.connect(). + // It is possible that the reply reaches this frame before the + // window.close() request has been processed. + if (!isUnloading) { + browser.test.log( + `Ignorting unexpected reply ("${msg}") because the page is not being unloaded.` + ); + return; + } + + // The frame has been removed, so we should not get a reply. + browser.test.fail(`Unexpected reply: ${msg}`); + }); + port.postMessage("from frame"); + + // Removing the frame or window should disconnect the port. + if (testType == "frame") { + frameElement.remove(); + } else { + browser.test.sendMessage("close-window"); + } +} + +// `testType` is "window" or "frame". +function createTestExtension(testType, backgroundScript, contentScript) { + // Make a roundtrip between the background page and the test runner (which is + // in the same process as the content script) to make sure that we record a + // failure in case the content script's sendMessage or onMessage handlers are + // called even after the frame or window was removed. + function delayedNotifyPass(msg) { + browser.test.onMessage.addListener((type, echoMsg) => { + if (type == "pong") { + browser.test.assertEq(msg, echoMsg, "Echoed reply should be the same"); + browser.test.notifyPass(msg); + } + }); + browser.test.log("Starting ping-pong to flush messages..."); + browser.test.sendMessage("ping", msg); + } + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})(${delayedNotifyPass});`, + manifest: { + content_scripts: [ + { + js: ["contentscript.js"], + all_frames: testType == "frame", + matches: ["http://example.com/data/file_sample.html"], + }, + ], + }, + files: { + "contentscript.js": `(${contentScript})("${testType}");`, + }, + }); + extension.awaitMessage("ping").then(msg => { + extension.sendMessage("pong", msg); + }); + return extension; +} + +add_task(async function testSendMessage_and_remove_frame() { + let extension = createTestExtension( + "frame", + sendMessage_background, + sendMessage_contentScript + ); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + + await contentPage.spawn([], () => { + let { document } = this.content; + let frame = document.createElement("iframe"); + frame.src = "/data/file_sample.html"; + document.body.appendChild(frame); + }); + + await extension.awaitFinish("Received sendMessage from closing frame"); + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function testConnect_and_remove_frame() { + let extension = createTestExtension( + "frame", + connect_background, + connect_contentScript + ); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + + await contentPage.spawn([], () => { + let { document } = this.content; + let frame = document.createElement("iframe"); + frame.src = "/data/file_sample.html"; + document.body.appendChild(frame); + }); + + await extension.awaitFinish("Received onDisconnect from closing frame"); + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function testSendMessage_and_remove_window() { + if (AppConstants.MOZ_BUILD_APP !== "browser") { + // We can't rely on this timing on Android. + return; + } + + let extension = createTestExtension( + "window", + sendMessage_background, + sendMessage_contentScript + ); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + await extension.awaitMessage("close-window"); + await contentPage.close(); + + await extension.awaitFinish("Received sendMessage from closing frame"); + await extension.unload(); +}); + +add_task(async function testConnect_and_remove_window() { + if (AppConstants.MOZ_BUILD_APP !== "browser") { + // We can't rely on this timing on Android. + return; + } + + let extension = createTestExtension( + "window", + connect_background, + connect_contentScript + ); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + await extension.awaitMessage("close-window"); + await contentPage.close(); + + await extension.awaitFinish("Received onDisconnect from closing frame"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js new file mode 100644 index 0000000000..3108c7b9b4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js @@ -0,0 +1,709 @@ +"use strict"; + +const PROCESS_COUNT_PREF = "dom.ipc.processCount"; + +const { createAppInfo } = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49"); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function setup_test_environment() { + // Start with one content process so that we can increase the number + // later and test the behavior of a fresh content process. + Services.prefs.setIntPref(PROCESS_COUNT_PREF, 1); + + // Grant the optional permissions requested, without prompting. + Services.prefs.setBoolPref( + "extensions.webextOptionalPermissionPrompts", + false + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts"); + }); +}); + +// Test that there is no userScripts API namespace when the manifest doesn't include a user_scripts +// property. +add_task(async function test_userScripts_manifest_property_required() { + function background() { + browser.test.assertEq( + undefined, + browser.userScripts, + "userScripts API namespace should be undefined in the extension page" + ); + browser.test.sendMessage("background-page:done"); + } + + async function contentScript() { + browser.test.assertEq( + undefined, + browser.userScripts, + "userScripts API namespace should be undefined in the content script" + ); + browser.test.sendMessage("content-script:done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["http://*/*/file_sample.html"], + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + run_at: "document_start", + }, + ], + }, + files: { + "content_script.js": contentScript, + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-page:done"); + + let url = `${BASE_URL}/file_sample.html`; + let contentPage = await ExtensionTestUtils.loadContentPage(url); + + await extension.awaitMessage("content-script:done"); + + await extension.unload(); + await contentPage.close(); +}); + +// Test that userScripts can only matches origins that are subsumed by the extension permissions, +// and that more origins can be allowed by requesting an optional permission. +add_task(async function test_userScripts_matches_denied() { + async function background() { + async function registerUserScriptWithMatches(matches) { + const scripts = await browser.userScripts.register({ + js: [{ code: "" }], + matches, + }); + await scripts.unregister(); + } + + // These matches are supposed to be denied until the extension has been granted the + // <all_urls> origin permission. + const testMatches = [ + "<all_urls>", + "file://*/*", + "https://localhost/*", + "http://example.com/*", + ]; + + browser.test.onMessage.addListener(async msg => { + if (msg === "test-denied-matches") { + for (let testMatch of testMatches) { + await browser.test.assertRejects( + registerUserScriptWithMatches([testMatch]), + /Permission denied to register a user script for/, + "Got the expected rejection when the extension permission does not subsume the userScript matches" + ); + } + } else if (msg === "grant-all-urls") { + await browser.permissions.request({ origins: ["<all_urls>"] }); + } else if (msg === "test-allowed-matches") { + for (let testMatch of testMatches) { + try { + await registerUserScriptWithMatches([testMatch]); + } catch (err) { + browser.test.fail( + `Unexpected rejection ${err} on matching ${JSON.stringify( + testMatch + )}` + ); + } + } + } else { + browser.test.fail(`Received an unexpected ${msg} test message`); + } + + browser.test.sendMessage(`${msg}:done`); + }); + + browser.test.sendMessage("background-ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://localhost/*"], + optional_permissions: ["<all_urls>"], + user_scripts: {}, + }, + background, + }); + + await extension.startup(); + + await extension.awaitMessage("background-ready"); + + // Test that the matches not subsumed by the extension permissions are being denied. + extension.sendMessage("test-denied-matches"); + await extension.awaitMessage("test-denied-matches:done"); + + // Grant the optional <all_urls> permission. + await withHandlingUserInput(extension, async () => { + extension.sendMessage("grant-all-urls"); + await extension.awaitMessage("grant-all-urls:done"); + }); + + // Test that all the matches are now subsumed by the extension permissions. + extension.sendMessage("test-allowed-matches"); + await extension.awaitMessage("test-allowed-matches:done"); + + await extension.unload(); +}); + +// Test that userScripts sandboxes: +// - can be registered/unregistered from an extension page (and they are registered on both new and +// existing processes). +// - have no WebExtensions APIs available +// - are able to access the target window and document +add_task(async function test_userScripts_no_webext_apis() { + async function background() { + const matches = ["http://localhost/*/file_sample.html*"]; + + const sharedCode = { + code: 'console.log("js code shared by multiple userScripts");', + }; + + const userScriptOptions = { + js: [ + sharedCode, + { + code: ` + window.addEventListener("load", () => { + const webextAPINamespaces = this.browser ? Object.keys(this.browser) : undefined; + document.body.innerHTML = "userScript loaded - " + JSON.stringify(webextAPINamespaces); + }, {once: true}); + `, + }, + ], + runAt: "document_start", + matches, + scriptMetadata: { + name: "test-user-script", + arrayProperty: ["el1"], + objectProperty: { nestedProp: "nestedValue" }, + nullProperty: null, + }, + }; + + let script = await browser.userScripts.register(userScriptOptions); + + // Unregister and then register the same js code again, to verify that the last registered + // userScript doesn't get assigned a revoked blob url (otherwise Extensioncontent.jsm + // ScriptCache raises an error because it fails to compile the revoked blob url and the user + // script will never be loaded). + script.unregister(); + script = await browser.userScripts.register(userScriptOptions); + + browser.test.onMessage.addListener(async msg => { + if (msg !== "register-new-script") { + return; + } + + await script.unregister(); + await browser.userScripts.register({ + ...userScriptOptions, + scriptMetadata: { name: "test-new-script" }, + js: [ + sharedCode, + { + code: ` + window.addEventListener("load", () => { + const webextAPINamespaces = this.browser ? Object.keys(this.browser) : undefined; + document.body.innerHTML = "new userScript loaded - " + JSON.stringify(webextAPINamespaces); + }, {once: true}); + `, + }, + ], + }); + + browser.test.sendMessage("script-registered"); + }); + + const scriptToRemove = await browser.userScripts.register({ + js: [ + sharedCode, + { + code: ` + window.addEventListener("load", () => { + document.body.innerHTML = "unexpected unregistered userScript loaded"; + }, {once: true}); + `, + }, + ], + runAt: "document_start", + matches, + scriptMetadata: { + name: "user-script-to-remove", + }, + }); + + browser.test.assertTrue( + "unregister" in script, + "Got an unregister method on the userScript API object" + ); + + // Remove the last registered user script. + await scriptToRemove.unregister(); + + browser.test.sendMessage("background-ready"); + } + + let extensionData = { + manifest: { + permissions: ["http://localhost/*/file_sample.html"], + user_scripts: {}, + }, + background, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + await extension.awaitMessage("background-ready"); + + let url = `${BASE_URL}/file_sample.html?testpage=1`; + let contentPage = await ExtensionTestUtils.loadContentPage(url); + let result = await contentPage.spawn([], async () => { + return { + textContent: this.content.document.body.textContent, + url: this.content.location.href, + readyState: this.content.document.readyState, + }; + }); + Assert.deepEqual( + result, + { + textContent: "userScript loaded - undefined", + url, + readyState: "complete", + }, + "The userScript executed on the expected url and no access to the WebExtensions APIs" + ); + + info("Test content script are correctly created on a newly created process"); + + await extension.sendMessage("register-new-script"); + await extension.awaitMessage("script-registered"); + + // Update the process count preference, so that we can test that the newly registered user script + // is propagated as expected into the newly created process. + Services.prefs.setIntPref(PROCESS_COUNT_PREF, 2); + + const url2 = `${BASE_URL}/file_sample.html?testpage=2`; + let contentPage2 = await ExtensionTestUtils.loadContentPage(url2, { + remote: true, + }); + let result2 = await contentPage2.spawn([], async () => { + return { + textContent: this.content.document.body.textContent, + url: this.content.location.href, + readyState: this.content.document.readyState, + }; + }); + Assert.deepEqual( + result2, + { + textContent: "new userScript loaded - undefined", + url: url2, + readyState: "complete", + }, + "The userScript executed on the expected url and no access to the WebExtensions APIs" + ); + + await contentPage.close(); + + await contentPage2.close(); + + await extension.unload(); +}); + +// This test verify that a cached script is still able to catch the document +// while it is still loading (when we do not block the document parsing as +// we do for a non cached script). +add_task(async function test_cached_userScript_on_document_start() { + function apiScript() { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + sendTestMessage(name, params) { + return browser.test.sendMessage(name, params); + }, + }); + }); + } + + async function background() { + function userScript() { + this.sendTestMessage("user-script-loaded", { + url: window.location.href, + documentReadyState: document.readyState, + }); + } + + await browser.userScripts.register({ + js: [ + { + code: `(${userScript})();`, + }, + ], + runAt: "document_start", + matches: ["http://localhost/*/file_sample.html"], + }); + + browser.test.sendMessage("user-script-registered"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://localhost/*/file_sample.html"], + user_scripts: { + api_script: "api-script.js", + // The following is an unexpected manifest property, that we expect to be ignored and + // to not prevent the test extension from being installed and run as expected. + unexpected_manifest_key: "test-unexpected-key", + }, + }, + background, + files: { + "api-script.js": apiScript, + }, + }); + + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + await extension.awaitMessage("user-script-registered"); + + let url = `${BASE_URL}/file_sample.html`; + let contentPage = await ExtensionTestUtils.loadContentPage(url); + + let msg = await extension.awaitMessage("user-script-loaded"); + Assert.deepEqual( + msg, + { + url, + documentReadyState: "loading", + }, + "Got the expected url and document.readyState from a non cached user script" + ); + + // Reload the page and check that the cached content script is still able to + // run on document_start. + await contentPage.loadURL(url); + + let msgFromCached = await extension.awaitMessage("user-script-loaded"); + Assert.deepEqual( + msgFromCached, + { + url, + documentReadyState: "loading", + }, + "Got the expected url and document.readyState from a cached user script" + ); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_userScripts_pref_disabled() { + async function run_userScript_on_pref_disabled_test() { + async function background() { + let promise = (async () => { + await browser.userScripts.register({ + js: [ + { + code: "throw new Error('This userScripts should not be registered')", + }, + ], + runAt: "document_start", + matches: ["<all_urls>"], + }); + })(); + + await browser.test.assertRejects( + promise, + /userScripts APIs are currently experimental/, + "Got the expected error from userScripts.register when the userScripts API is disabled" + ); + + browser.test.sendMessage("background-page:done"); + } + + async function contentScript() { + let promise = (async () => { + browser.userScripts.onBeforeScript.addListener(() => {}); + })(); + await browser.test.assertRejects( + promise, + /userScripts APIs are currently experimental/, + "Got the expected error from userScripts.onBeforeScript when the userScripts API is disabled" + ); + + browser.test.sendMessage("content-script:done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["http://*/*/file_sample.html"], + user_scripts: { api_script: "" }, + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + run_at: "document_start", + }, + ], + }, + files: { + "content_script.js": contentScript, + }, + }); + + await extension.startup(); + + await extension.awaitMessage("background-page:done"); + + let url = `${BASE_URL}/file_sample.html`; + let contentPage = await ExtensionTestUtils.loadContentPage(url); + + await extension.awaitMessage("content-script:done"); + + await extension.unload(); + await contentPage.close(); + } + + await runWithPrefs( + [["extensions.webextensions.userScripts.enabled", false]], + run_userScript_on_pref_disabled_test + ); +}); + +// This test verify that userScripts.onBeforeScript API Event is not available without +// a "user_scripts.api_script" property in the manifest. +add_task(async function test_user_script_api_script_required() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://localhost/*/file_sample.html"], + js: ["content_script.js"], + run_at: "document_start", + }, + ], + user_scripts: {}, + }, + files: { + "content_script.js": function () { + browser.test.assertEq( + undefined, + browser.userScripts && browser.userScripts.onBeforeScript, + "Got an undefined onBeforeScript property as expected" + ); + browser.test.sendMessage("no-onBeforeScript:done"); + }, + }, + }); + + await extension.startup(); + + let url = `${BASE_URL}/file_sample.html`; + let contentPage = await ExtensionTestUtils.loadContentPage(url); + + await extension.awaitMessage("no-onBeforeScript:done"); + + await extension.unload(); + await contentPage.close(); +}); + +add_task(async function test_scriptMetaData() { + function getTestCases(isUserScriptsRegister) { + return [ + // When scriptMetadata is not set (or undefined), it is treated as if it were null. + // In the API script, the metadata is then expected to be null. + isUserScriptsRegister ? undefined : null, + + // Falsey + null, + "", + false, + 0, + + // Truthy + true, + 1, + "non-empty string", + + // Objects + ["some array with value"], + { "some object": "with value" }, + ]; + } + + async function background() { + for (let scriptMetadata of getTestCases(true)) { + await browser.userScripts.register({ + js: [{ file: "userscript.js" }], + runAt: "document_end", + matches: ["http://localhost/*/file_sample.html"], + scriptMetadata, + }); + } + + browser.test.sendMessage("background-page:done"); + } + + function apiScript() { + let testCases = getTestCases(false); + let i = 0; + + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + checkMetadata() { + let expectation = testCases[i]; + let metadata = script.metadata; + if (typeof expectation === "object" && expectation !== null) { + // Non-primitive values cannot be compared with assertEq, + // so serialize both and just verify that they are equal. + expectation = JSON.stringify(expectation); + metadata = JSON.stringify(script.metadata); + } + + browser.test.assertEq( + expectation, + metadata, + `Expected metadata at call ${i}` + ); + if (++i === testCases.length) { + browser.test.sendMessage("apiscript:done"); + } + }, + }); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `${getTestCases};(${background})()`, + manifest: { + permissions: ["http://*/*/file_sample.html"], + user_scripts: { + api_script: "apiscript.js", + }, + }, + files: { + "apiscript.js": `${getTestCases};(${apiScript})()`, + "userscript.js": "checkMetadata();", + }, + }); + + await extension.startup(); + + await extension.awaitMessage("background-page:done"); + + const pageUrl = `${BASE_URL}/file_sample.html`; + info(`Load content page: ${pageUrl}`); + const page = await ExtensionTestUtils.loadContentPage(pageUrl); + + await extension.awaitMessage("apiscript:done"); + + await page.close(); + + await extension.unload(); +}); + +add_task(async function test_userScriptOptions_js_property_required() { + function background() { + const userScriptOptions = { + runAt: "document_start", + matches: ["http://*/*/file_sample.html"], + }; + + browser.test.assertThrows( + () => browser.userScripts.register(userScriptOptions), + /Type error for parameter userScriptOptions \(Property \"js\" is required\)/, + "Got the expected error from userScripts.register when js property is missing" + ); + + browser.test.sendMessage("done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["http://*/*/file_sample.html"], + user_scripts: {}, + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function test_userScripts_are_unregistered_on_unload() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://*/*/file_sample.html"], + user_scripts: { + api_script: "api_script.js", + }, + }, + files: { + "userscript.js": "", + "extpage.html": `<!DOCTYPE html><script src="extpage.js"></script>`, + "extpage.js": async function extPage() { + await browser.userScripts.register({ + js: [{ file: "userscript.js" }], + matches: ["http://localhost/*/file_sample.html"], + }); + + browser.test.sendMessage("user-script-registered"); + }, + }, + }); + + await extension.startup(); + + equal( + // In order to read the `registeredContentScripts` map, we need to access + // the extension embedded in the `ExtensionWrapper` first. + extension.extension.registeredContentScripts.size, + 0, + "no user scripts registered yet" + ); + + const url = `moz-extension://${extension.uuid}/extpage.html`; + info(`loading extension page: ${url}`); + const page = await ExtensionTestUtils.loadContentPage(url); + + info("waiting for the user script to be registered"); + await extension.awaitMessage("user-script-registered"); + + equal( + extension.extension.registeredContentScripts.size, + 1, + "got registered user scripts in the extension content scripts map" + ); + + await page.close(); + + equal( + extension.extension.registeredContentScripts.size, + 0, + "user scripts unregistered from the extension content scripts map" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_exports.js b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_exports.js new file mode 100644 index 0000000000..5950377f85 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_exports.js @@ -0,0 +1,1108 @@ +"use strict"; + +const { createAppInfo } = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49"); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +// A small utility function used to test the expected behaviors of the userScripts API method +// wrapper. +async function test_userScript_APIMethod({ + apiScript, + userScript, + userScriptMetadata, + testFn, + runtimeMessageListener, +}) { + async function backgroundScript( + userScriptFn, + scriptMetadata, + messageListener + ) { + await browser.userScripts.register({ + js: [ + { + code: `(${userScriptFn})();`, + }, + ], + runAt: "document_end", + matches: ["http://localhost/*/file_sample.html"], + scriptMetadata, + }); + + if (messageListener) { + browser.runtime.onMessage.addListener(messageListener); + } + + browser.test.sendMessage("background-ready"); + } + + function notifyFinish(failureReason) { + browser.test.assertEq( + undefined, + failureReason, + "should be completed without errors" + ); + browser.test.sendMessage("test_userScript_APIMethod:done"); + } + + function assertTrue(val, message) { + browser.test.assertTrue(val, message); + if (!val) { + browser.test.sendMessage("test_userScript_APIMethod:done"); + throw message; + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://localhost/*/file_sample.html"], + user_scripts: { + api_script: "api-script.js", + }, + }, + // Defines a background script that receives all the needed test parameters. + background: ` + const metadata = ${JSON.stringify(userScriptMetadata)}; + (${backgroundScript})(${userScript}, metadata, ${runtimeMessageListener}) + `, + files: { + "api-script.js": `(${apiScript})({ + assertTrue: ${assertTrue}, + notifyFinish: ${notifyFinish} + })`, + }, + }); + + // Load a page in a content process, register the user script and then load a + // new page in the existing content process. + let url = `${BASE_URL}/file_sample.html`; + let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + await contentPage.loadURL(url); + + // Run any additional test-specific assertions. + if (testFn) { + await testFn({ extension, contentPage, url }); + } + + await extension.awaitMessage("test_userScript_APIMethod:done"); + + await extension.unload(); + await contentPage.close(); +} + +add_task(async function test_apiScript_exports_simple_sync_method() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + const scriptMetadata = script.metadata; + + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod( + stringParam, + numberParam, + boolParam, + nullParam, + undefinedParam, + arrayParam + ) { + browser.test.assertEq( + "test-user-script-exported-apis", + scriptMetadata.name, + "Got the expected value for a string scriptMetadata property" + ); + browser.test.assertEq( + null, + scriptMetadata.nullProperty, + "Got the expected value for a null scriptMetadata property" + ); + browser.test.assertTrue( + scriptMetadata.arrayProperty && + scriptMetadata.arrayProperty.length === 1 && + scriptMetadata.arrayProperty[0] === "el1", + "Got the expected value for an array scriptMetadata property" + ); + browser.test.assertTrue( + scriptMetadata.objectProperty && + scriptMetadata.objectProperty.nestedProp === "nestedValue", + "Got the expected value for an object scriptMetadata property" + ); + + browser.test.assertEq( + "param1", + stringParam, + "Got the expected string parameter value" + ); + browser.test.assertEq( + 123, + numberParam, + "Got the expected number parameter value" + ); + browser.test.assertEq( + true, + boolParam, + "Got the expected boolean parameter value" + ); + browser.test.assertEq( + null, + nullParam, + "Got the expected null parameter value" + ); + browser.test.assertEq( + undefined, + undefinedParam, + "Got the expected undefined parameter value" + ); + + browser.test.assertEq( + 3, + arrayParam.length, + "Got the expected length on the array param" + ); + browser.test.assertTrue( + arrayParam.includes(1), + "Got the expected result when calling arrayParam.includes" + ); + + return "returned_value"; + }, + }); + }); + } + + function userScript() { + const { assertTrue, notifyFinish, testAPIMethod } = this; + + // Redefine the includes method on the Array prototype, to explicitly verify that the method + // redefined in the userScript is not used when accessing arrayParam.includes from the API script. + // eslint-disable-next-line no-extend-native + Array.prototype.includes = () => { + throw new Error("Unexpected prototype leakage"); + }; + const arrayParam = new Array(1, 2, 3); // eslint-disable-line no-array-constructor + const result = testAPIMethod( + "param1", + 123, + true, + null, + undefined, + arrayParam + ); + + assertTrue( + result === "returned_value", + `userScript got an unexpected result value: ${result}` + ); + + notifyFinish(); + } + + const userScriptMetadata = { + name: "test-user-script-exported-apis", + arrayProperty: ["el1"], + objectProperty: { nestedProp: "nestedValue" }, + nullProperty: null, + }; + + await test_userScript_APIMethod({ + userScript, + apiScript, + userScriptMetadata, + }); +}); + +add_task(async function test_apiScript_async_method() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod(param, cb, cb2, objWithCb) { + browser.test.assertEq( + "function", + typeof cb, + "Got a callback function parameter" + ); + browser.test.assertTrue( + cb === cb2, + "Got the same cloned function for the same function parameter" + ); + + browser.runtime.sendMessage(param).then(bgPageRes => { + const cbResult = cb(script.export(bgPageRes)); + browser.test.sendMessage("user-script-callback-return", cbResult); + }); + + return "resolved_value"; + }, + }); + }); + } + + async function userScript() { + // Redefine Promise to verify that it doesn't break the WebExtensions internals + // that are going to use them. + const { Promise } = this; + Promise.resolve = function () { + throw new Error("Promise.resolve poisoning"); + }; + this.Promise = function () { + throw new Error("Promise constructor poisoning"); + }; + + const { assertTrue, notifyFinish, testAPIMethod } = this; + + const cb = cbParam => { + return `callback param: ${JSON.stringify(cbParam)}`; + }; + const cb2 = cb; + const asyncAPIResult = await testAPIMethod("param3", cb, cb2); + + assertTrue( + asyncAPIResult === "resolved_value", + `userScript got an unexpected resolved value: ${asyncAPIResult}` + ); + + notifyFinish(); + } + + async function runtimeMessageListener(param) { + if (param !== "param3") { + browser.test.fail(`Got an unexpected message: ${param}`); + } + + return { bgPageReply: true }; + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + runtimeMessageListener, + async testFn({ extension }) { + const res = await extension.awaitMessage("user-script-callback-return"); + equal( + res, + `callback param: ${JSON.stringify({ bgPageReply: true })}`, + "Got the expected userScript callback return value" + ); + }, + }); +}); + +add_task(async function test_apiScript_method_with_webpage_objects_params() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod(windowParam, documentParam) { + browser.test.assertEq( + window, + windowParam, + "Got a reference to the native window as first param" + ); + browser.test.assertEq( + window.document, + documentParam, + "Got a reference to the native document as second param" + ); + + // Return an uncloneable webpage object, which checks that if the returned object is from a principal + // that is subsumed by the userScript sandbox principal, it is returned without being cloned. + return windowParam; + }, + }); + }); + } + + async function userScript() { + const { assertTrue, notifyFinish, testAPIMethod } = this; + + const result = testAPIMethod(window, document); + + // We expect the returned value to be the uncloneable window object. + assertTrue( + result === window, + `userScript got an unexpected returned value: ${result}` + ); + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); + +add_task(async function test_apiScript_method_got_param_with_methods() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + const scriptGlobal = script.global; + const ScriptFunction = scriptGlobal.Function; + + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod(objWithMethods) { + browser.test.assertEq( + "objPropertyValue", + objWithMethods && objWithMethods.objProperty, + "Got the expected property on the object passed as a parameter" + ); + browser.test.assertEq( + undefined, + objWithMethods?.objMethod, + "XrayWrapper should deny access to a callable property" + ); + + browser.test.assertTrue( + objWithMethods && + objWithMethods.wrappedJSObject && + objWithMethods.wrappedJSObject.objMethod instanceof + ScriptFunction.wrappedJSObject, + "The callable property is accessible on the wrappedJSObject" + ); + + browser.test.assertEq( + "objMethodResult: p1", + objWithMethods && + objWithMethods.wrappedJSObject && + objWithMethods.wrappedJSObject.objMethod("p1"), + "Got the expected result when calling the method on the wrappedJSObject" + ); + return true; + }, + }); + }); + } + + async function userScript() { + const { assertTrue, notifyFinish, testAPIMethod } = this; + + let result = testAPIMethod({ + objProperty: "objPropertyValue", + objMethod(param) { + return `objMethodResult: ${param}`; + }, + }); + + assertTrue( + result === true, + `userScript got an unexpected returned value: ${result}` + ); + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); + +add_task(async function test_apiScript_method_throws_errors() { + function apiScript({ notifyFinish }) { + let proxyTrapsCount = 0; + + browser.userScripts.onBeforeScript.addListener(script => { + const scriptGlobals = { + Error: script.global.Error, + TypeError: script.global.TypeError, + Proxy: script.global.Proxy, + }; + + script.defineGlobals({ + notifyFinish, + testAPIMethod(errorTestName, returnRejectedPromise) { + let err; + + switch (errorTestName) { + case "apiScriptError": + err = new Error(`${errorTestName} message`); + break; + case "apiScriptThrowsPlainString": + err = `${errorTestName} message`; + break; + case "apiScriptThrowsNull": + err = null; + break; + case "userScriptError": + err = new scriptGlobals.Error(`${errorTestName} message`); + break; + case "userScriptTypeError": + err = new scriptGlobals.TypeError(`${errorTestName} message`); + break; + case "userScriptProxyObject": + let proxyTarget = script.export({ + name: "ProxyObject", + message: "ProxyObject message", + }); + let proxyHandlers = script.export({ + get(target, prop) { + proxyTrapsCount++; + switch (prop) { + case "name": + return "ProxyObjectGetName"; + case "message": + return "ProxyObjectGetMessage"; + } + return undefined; + }, + getPrototypeOf() { + proxyTrapsCount++; + return scriptGlobals.TypeError; + }, + }); + err = new scriptGlobals.Proxy(proxyTarget, proxyHandlers); + break; + default: + browser.test.fail(`Unknown ${errorTestName} error testname`); + return undefined; + } + + if (returnRejectedPromise) { + return Promise.reject(err); + } + + throw err; + }, + assertNoProxyTrapTriggered() { + browser.test.assertEq( + 0, + proxyTrapsCount, + "Proxy traps should not be triggered" + ); + }, + resetProxyTrapCounter() { + proxyTrapsCount = 0; + }, + sendResults(results) { + browser.test.sendMessage("test-results", results); + }, + }); + }); + } + + async function userScript() { + const { + assertNoProxyTrapTriggered, + notifyFinish, + resetProxyTrapCounter, + sendResults, + testAPIMethod, + } = this; + + let apiThrowResults = {}; + let apiThrowTestCases = [ + "apiScriptError", + "apiScriptThrowsPlainString", + "apiScriptThrowsNull", + "userScriptError", + "userScriptTypeError", + "userScriptProxyObject", + ]; + for (let errorTestName of apiThrowTestCases) { + try { + testAPIMethod(errorTestName); + } catch (err) { + // We expect that no proxy traps have been triggered by the WebExtensions internals. + if (errorTestName === "userScriptProxyObject") { + assertNoProxyTrapTriggered(); + } + + if (err instanceof Error) { + apiThrowResults[errorTestName] = { + name: err.name, + message: err.message, + }; + } else { + apiThrowResults[errorTestName] = { + name: err && err.name, + message: err && err.message, + typeOf: typeof err, + value: err, + }; + } + } + } + + sendResults(apiThrowResults); + + resetProxyTrapCounter(); + + let apiRejectsResults = {}; + for (let errorTestName of apiThrowTestCases) { + try { + await testAPIMethod(errorTestName, true); + } catch (err) { + // We expect that no proxy traps have been triggered by the WebExtensions internals. + if (errorTestName === "userScriptProxyObject") { + assertNoProxyTrapTriggered(); + } + + if (err instanceof Error) { + apiRejectsResults[errorTestName] = { + name: err.name, + message: err.message, + }; + } else { + apiRejectsResults[errorTestName] = { + name: err && err.name, + message: err && err.message, + typeOf: typeof err, + value: err, + }; + } + } + } + + sendResults(apiRejectsResults); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + async testFn({ extension }) { + const expectedResults = { + // Any error not explicitly raised as a userScript objects or error instance is + // expected to be turned into a generic error message. + apiScriptError: { + name: "Error", + message: "An unexpected apiScript error occurred", + }, + + // When the api script throws a primitive value, we expect to receive it unmodified on + // the userScript side. + apiScriptThrowsPlainString: { + typeOf: "string", + value: "apiScriptThrowsPlainString message", + name: undefined, + message: undefined, + }, + apiScriptThrowsNull: { + typeOf: "object", + value: null, + name: undefined, + message: undefined, + }, + + // Error messages that the apiScript has explicitly created as userScript's Error + // global instances are expected to be passing through unmodified. + userScriptError: { name: "Error", message: "userScriptError message" }, + userScriptTypeError: { + name: "TypeError", + message: "userScriptTypeError message", + }, + + // Error raised from the apiScript as userScript proxy objects are expected to + // be passing through unmodified. + userScriptProxyObject: { + typeOf: "object", + name: "ProxyObjectGetName", + message: "ProxyObjectGetMessage", + }, + }; + + info( + "Checking results from errors raised from an apiScript exported function" + ); + + const apiThrowResults = await extension.awaitMessage("test-results"); + + for (let [key, expected] of Object.entries(expectedResults)) { + Assert.deepEqual( + apiThrowResults[key], + expected, + `Got the expected error object for test case "${key}"` + ); + } + + Assert.deepEqual( + Object.keys(expectedResults).sort(), + Object.keys(apiThrowResults).sort(), + "the expected and actual test case names matches" + ); + + info( + "Checking expected results from errors raised from an apiScript exported function" + ); + + // Verify expected results from rejected promises returned from an apiScript exported function. + const apiThrowRejections = await extension.awaitMessage("test-results"); + + for (let [key, expected] of Object.entries(expectedResults)) { + Assert.deepEqual( + apiThrowRejections[key], + expected, + `Got the expected rejected object for test case "${key}"` + ); + } + + Assert.deepEqual( + Object.keys(expectedResults).sort(), + Object.keys(apiThrowRejections).sort(), + "the expected and actual test case names matches" + ); + }, + }); +}); + +add_task( + async function test_apiScript_method_ensure_xraywrapped_proxy_in_params() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod(...args) { + // Proxies are opaque when wrapped in Xrays, and the proto of an opaque object + // is supposed to be Object.prototype. + browser.test.assertEq( + script.global.Object.prototype, + Object.getPrototypeOf(args[0]), + "Calling getPrototypeOf on the XrayWrapped proxy object doesn't run the proxy trap" + ); + + browser.test.assertTrue( + Array.isArray(args[0]), + "Got an array object for the XrayWrapped proxy object param" + ); + browser.test.assertEq( + undefined, + args[0].length, + "XrayWrappers deny access to the length property" + ); + browser.test.assertEq( + undefined, + args[0][0], + "Got the expected item in the array object" + ); + return true; + }, + }); + }); + } + + async function userScript() { + const { assertTrue, notifyFinish, testAPIMethod } = this; + + let proxy = new Proxy(["expectedArrayValue"], { + getPrototypeOf() { + throw new Error("Proxy's getPrototypeOf trap"); + }, + get(target, prop, receiver) { + throw new Error("Proxy's get trap"); + }, + }); + + let result = testAPIMethod(proxy); + + assertTrue( + result, + `userScript got an unexpected returned value: ${result}` + ); + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); + } +); + +add_task(async function test_apiScript_method_return_proxy_object() { + function apiScript(sharedTestAPIMethods) { + let proxyTrapsCount = 0; + let scriptTrapsCount = 0; + + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethodError() { + return new Proxy(["expectedArrayValue"], { + getPrototypeOf(target) { + proxyTrapsCount++; + return Object.getPrototypeOf(target); + }, + }); + }, + testAPIMethodOk() { + return new script.global.Proxy( + script.export(["expectedArrayValue"]), + script.export({ + getPrototypeOf(target) { + scriptTrapsCount++; + return script.global.Object.getPrototypeOf(target); + }, + }) + ); + }, + assertNoProxyTrapTriggered() { + browser.test.assertEq( + 0, + proxyTrapsCount, + "Proxy traps should not be triggered" + ); + }, + assertScriptProxyTrapsCount(expected) { + browser.test.assertEq( + expected, + scriptTrapsCount, + "Script Proxy traps should have been triggered" + ); + }, + }); + }); + } + + async function userScript() { + const { + assertTrue, + assertNoProxyTrapTriggered, + assertScriptProxyTrapsCount, + notifyFinish, + testAPIMethodError, + testAPIMethodOk, + } = this; + + let error; + try { + let result = testAPIMethodError(); + notifyFinish( + `Unexpected returned value while expecting error: ${result}` + ); + return; + } catch (err) { + error = err; + } + + assertTrue( + error && + error.message.includes("Return value not accessible to the userScript"), + `Got an unexpected error message: ${error}` + ); + + error = undefined; + try { + let result = testAPIMethodOk(); + assertScriptProxyTrapsCount(0); + if (!(result instanceof Array)) { + notifyFinish(`Got an unexpected result: ${result}`); + return; + } + assertScriptProxyTrapsCount(1); + } catch (err) { + error = err; + } + + assertTrue(!error, `Got an unexpected error: ${error}`); + + assertNoProxyTrapTriggered(); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); + +add_task(async function test_apiScript_returns_functions() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIReturnsFunction() { + // Return a function with provides the same kind of behavior + // of the API methods exported as globals. + return script.export(() => window); + }, + testAPIReturnsObjWithMethod() { + return script.export({ + getWindow() { + return window; + }, + }); + }, + }); + }); + } + + async function userScript() { + const { + assertTrue, + notifyFinish, + testAPIReturnsFunction, + testAPIReturnsObjWithMethod, + } = this; + + let resultFn = testAPIReturnsFunction(); + assertTrue( + typeof resultFn === "function", + `userScript got an unexpected returned value: ${typeof resultFn}` + ); + + let fnRes = resultFn(); + assertTrue( + fnRes === window, + `Got an unexpected value from the returned function: ${fnRes}` + ); + + let resultObj = testAPIReturnsObjWithMethod(); + let actualTypeof = resultObj && typeof resultObj.getWindow; + assertTrue( + actualTypeof === "function", + `Returned object does not have the expected getWindow method: ${actualTypeof}` + ); + + let methodRes = resultObj.getWindow(); + assertTrue( + methodRes === window, + `Got an unexpected value from the returned method: ${methodRes}` + ); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); + +add_task( + async function test_apiScript_method_clone_non_subsumed_returned_values() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethodReturnOk() { + return script.export({ + objKey1: { + nestedProp: "nestedvalue", + }, + window, + }); + }, + testAPIMethodExplicitlyClonedError() { + let result = script.export({ apiScopeObject: undefined }); + + browser.test.assertThrows( + () => { + result.apiScopeObject = { disallowedProp: "disallowedValue" }; + }, + /Not allowed to define cross-origin object as property on .* XrayWrapper/, + "Assigning a property to a xRayWrapper is expected to throw" + ); + + // Let the exception to be raised, so that we check that the actual underlying + // error message is not leaking in the userScript (replaced by the generic + // "An unexpected apiScript error occurred" error message). + result.apiScopeObject = { disallowedProp: "disallowedValue" }; + }, + }); + }); + } + + async function userScript() { + const { + assertTrue, + notifyFinish, + testAPIMethodReturnOk, + testAPIMethodExplicitlyClonedError, + } = this; + + let result = testAPIMethodReturnOk(); + + assertTrue( + result && + "objKey1" in result && + result.objKey1.nestedProp === "nestedvalue", + `userScript got an unexpected returned value: ${result}` + ); + + assertTrue( + result.window === window, + `userScript should have access to the window property: ${result.window}` + ); + + let error; + try { + result = testAPIMethodExplicitlyClonedError(); + notifyFinish( + `Unexpected returned value while expecting error: ${result}` + ); + return; + } catch (err) { + error = err; + } + + // We expect the generic "unexpected apiScript error occurred" to be raised to the + // userScript code. + assertTrue( + error && + error.message.includes("An unexpected apiScript error occurred"), + `Got an unexpected error message: ${error}` + ); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); + } +); + +add_task(async function test_apiScript_method_export_primitive_types() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod(typeToExport) { + switch (typeToExport) { + case "boolean": + return script.export(true); + case "number": + return script.export(123); + case "string": + return script.export("a string"); + case "symbol": + return script.export(Symbol("a symbol")); + } + return undefined; + }, + }); + }); + } + + async function userScript() { + const { assertTrue, notifyFinish, testAPIMethod } = this; + + let v = testAPIMethod("boolean"); + assertTrue(v === true, `Should export a boolean`); + + v = testAPIMethod("number"); + assertTrue(v === 123, `Should export a number`); + + v = testAPIMethod("string"); + assertTrue(v === "a string", `Should export a string`); + + v = testAPIMethod("symbol"); + assertTrue(typeof v === "symbol", `Should export a symbol`); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); + +add_task( + async function test_apiScript_method_avoid_unnecessary_params_cloning() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethodReturnsParam(param) { + return param; + }, + testAPIMethodReturnsUnwrappedParam(param) { + return param.wrappedJSObject; + }, + }); + }); + } + + async function userScript() { + const { + assertTrue, + notifyFinish, + testAPIMethodReturnsParam, + testAPIMethodReturnsUnwrappedParam, + } = this; + + let obj = {}; + + let result = testAPIMethodReturnsParam(obj); + + assertTrue( + result === obj, + `Expect returned value to be strictly equal to the API method parameter` + ); + + result = testAPIMethodReturnsUnwrappedParam(obj); + + assertTrue( + result === obj, + `Expect returned value to be strictly equal to the unwrapped API method parameter` + ); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); + } +); + +add_task(async function test_apiScript_method_export_sparse_arrays() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod() { + const sparseArray = []; + sparseArray[3] = "third-element"; + sparseArray[5] = "fifth-element"; + return script.export(sparseArray); + }, + }); + }); + } + + async function userScript() { + const { assertTrue, notifyFinish, testAPIMethod } = this; + + const result = testAPIMethod(window, document); + + // We expect the returned value to be the uncloneable window object. + assertTrue( + result && result.length === 6, + `the returned value should be an array of the expected length: ${result}` + ); + assertTrue( + result[3] === "third-element", + `the third array element should have the expected value: ${result[3]}` + ); + assertTrue( + result[5] === "fifth-element", + `the fifth array element should have the expected value: ${result[5]}` + ); + assertTrue( + result[0] === undefined, + `the first array element should have the expected value: ${result[0]}` + ); + assertTrue(!("0" in result), "Holey array should still be holey"); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_register.js b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_register.js new file mode 100644 index 0000000000..fd57cd2736 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_register.js @@ -0,0 +1,142 @@ +"use strict"; + +const { createAppInfo } = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49"); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function test_userscripts_register_cookieStoreId() { + async function background() { + const matches = ["<all_urls>"]; + + await browser.test.assertRejects( + browser.userScripts.register({ + js: [{ code: "" }], + matches, + cookieStoreId: "not_a_valid_cookieStoreId", + }), + /Invalid cookieStoreId/, + "userScript.register with an invalid cookieStoreId" + ); + + await browser.test.assertRejects( + browser.userScripts.register({ + js: [{ code: "" }], + matches, + cookieStoreId: "", + }), + /Invalid cookieStoreId/, + "userScripts.register with an invalid cookieStoreId" + ); + + let cookieStoreIdJSArray = [ + { + id: "firefox-container-1", + code: `document.body.textContent += "1"`, + }, + { + id: ["firefox-container-2", "firefox-container-3"], + code: `document.body.textContent += "2-3"`, + }, + { + id: "firefox-private", + code: `document.body.textContent += "private"`, + }, + { + id: "firefox-default", + code: `document.body.textContent += "default"`, + }, + ]; + + for (let { id, code } of cookieStoreIdJSArray) { + await browser.userScripts.register({ + js: [{ code }], + matches, + runAt: "document_end", + cookieStoreId: id, + }); + } + + await browser.contentScripts.register({ + js: [ + { + code: `browser.test.sendMessage("last-content-script");`, + }, + ], + matches, + runAt: "document_end", + }); + + browser.test.sendMessage("background_ready"); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["<all_urls>"], + user_scripts: {}, + }, + background, + incognitoOverride: "spanning", + }); + + await extension.startup(); + await extension.awaitMessage("background_ready"); + + registerCleanupFunction(() => extension.unload()); + + let testCases = [ + { + contentPageOptions: { userContextId: 0 }, + expectedTextContent: "default", + }, + { + contentPageOptions: { userContextId: 1 }, + expectedTextContent: "1", + }, + { + contentPageOptions: { userContextId: 2 }, + expectedTextContent: "2-3", + }, + { + contentPageOptions: { userContextId: 3 }, + expectedTextContent: "2-3", + }, + { + contentPageOptions: { userContextId: 4 }, + expectedTextContent: "", + }, + { + contentPageOptions: { privateBrowsing: true }, + expectedTextContent: "private", + }, + ]; + + for (let test of testCases) { + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html`, + test.contentPageOptions + ); + + await extension.awaitMessage("last-content-script"); + + let result = await contentPage.spawn([], () => { + let textContent = content.document.body.textContent; + // Omit the default content from file_sample.html. + return textContent.replace("\n\nSample text\n\n\n\n", ""); + }); + + await contentPage.close(); + + equal( + result, + test.expectedTextContent, + `Expected textContent on content page` + ); + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_wasm.js b/toolkit/components/extensions/test/xpcshell/test_ext_wasm.js new file mode 100644 index 0000000000..1a41361491 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_wasm.js @@ -0,0 +1,135 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +// Common code snippet of background script in this test. +function background() { + globalThis.onsecuritypolicyviolation = event => { + browser.test.assertEq("wasm-eval", event.blockedURI, "blockedURI"); + if (browser.runtime.getManifest().version === 2) { + // In MV2, wasm eval violations are advisory only, as a transition tool. + browser.test.assertEq(event.disposition, "report", "MV2 disposition"); + } else { + browser.test.assertEq(event.disposition, "enforce", "MV3 disposition"); + } + browser.test.sendMessage("violated_csp", event.originalPolicy); + }; + try { + let wasm = new WebAssembly.Module( + new Uint8Array([0, 0x61, 0x73, 0x6d, 0x1, 0, 0, 0]) + ); + browser.test.assertEq(wasm.toString(), "[object WebAssembly.Module]"); + browser.test.sendMessage("result", "allowed"); + } catch (e) { + browser.test.assertEq( + "call to WebAssembly.Module() blocked by CSP", + e.message, + "Expected error when blocked" + ); + browser.test.sendMessage("result", "blocked"); + } +} + +add_task(async function test_wasm_v2() { + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + manifest_version: 2, + }, + }); + + await extension.startup(); + equal(await extension.awaitMessage("result"), "allowed"); + await extension.unload(); +}); + +add_task(async function test_wasm_v2_explicit() { + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + manifest_version: 2, + content_security_policy: `object-src; script-src 'self' 'wasm-unsafe-eval'`, + }, + }); + + await extension.startup(); + equal(await extension.awaitMessage("result"), "allowed"); + await extension.unload(); +}); + +// MV3 counterpart is test_wasm_v3_blocked_by_custom_csp. +add_task(async function test_wasm_v2_blocked_in_report_only_mode() { + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + manifest_version: 2, + content_security_policy: `object-src; script-src 'self'`, + }, + }); + + await extension.startup(); + // "allowed" because wasm-unsafe-eval in MV2 is in report-only mode. + equal(await extension.awaitMessage("result"), "allowed"); + equal( + await extension.awaitMessage("violated_csp"), + "object-src 'none'; script-src 'self'" + ); + await extension.unload(); +}); + +add_task(async function test_wasm_v3_blocked_by_default() { + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + manifest_version: 3, + }, + }); + + await extension.startup(); + equal(await extension.awaitMessage("result"), "blocked"); + equal( + await extension.awaitMessage("violated_csp"), + "script-src 'self'; upgrade-insecure-requests", + "WASM usage violates default CSP in MV3" + ); + await extension.unload(); +}); + +// MV2 counterpart is test_wasm_v2_blocked_in_report_only_mode. +add_task(async function test_wasm_v3_blocked_by_custom_csp() { + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: "object-src; script-src 'self'", + }, + }, + }); + + await extension.startup(); + equal(await extension.awaitMessage("result"), "blocked"); + equal( + await extension.awaitMessage("violated_csp"), + "object-src 'none'; script-src 'self'" + ); + await extension.unload(); +}); + +add_task(async function test_wasm_v3_allowed() { + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' 'wasm-unsafe-eval'; object-src 'self'`, + }, + }, + }); + + await extension.startup(); + equal(await extension.awaitMessage("result"), "allowed"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_auth.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_auth.js new file mode 100644 index 0000000000..c616d162a5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_auth.js @@ -0,0 +1,425 @@ +"use strict"; + +const HOSTS = new Set(["example.com"]); + +const server = createHttpServer({ hosts: HOSTS }); + +const BASE_URL = "http://example.com"; + +// Save seen realms for cache checking. +let realms = new Set([]); + +server.registerPathHandler("/authenticate.sjs", (request, response) => { + let url = new URL(`${BASE_URL}${request.path}?${request.queryString}`); + let realm = url.searchParams.get("realm") || "mochitest"; + let proxy_realm = url.searchParams.get("proxy_realm"); + + function checkAuthorization(authorization) { + let expected_user = url.searchParams.get("user"); + if (!expected_user) { + return true; + } + let expected_pass = url.searchParams.get("pass"); + let actual_user, actual_pass; + let authHeader = request.getHeader("Authorization"); + let match = /Basic (.+)/.exec(authHeader); + if (match.length != 2) { + throw new Error("Couldn't parse auth header: " + authHeader); + } + let userpass = atob(match[1]); // no atob() :-( + match = /(.*):(.*)/.exec(userpass); + if (match.length != 3) { + throw new Error("Couldn't decode auth header: " + userpass); + } + actual_user = match[1]; + actual_pass = match[2]; + return expected_user === actual_user && expected_pass === actual_pass; + } + + response.setHeader("Content-Type", "text/plain; charset=UTF-8", false); + if (proxy_realm && !request.hasHeader("Proxy-Authorization")) { + // We're not testing anything that requires checking the proxy auth user/password. + response.setStatusLine("1.0", 407, "Proxy authentication required"); + response.setHeader( + "Proxy-Authenticate", + `basic realm="${proxy_realm}"`, + true + ); + response.write("proxy auth required"); + } else if ( + !( + realms.has(realm) && + request.hasHeader("Authorization") && + checkAuthorization() + ) + ) { + realms.add(realm); + response.setStatusLine(request.httpVersion, 401, "Authentication required"); + response.setHeader("WWW-Authenticate", `basic realm="${realm}"`, true); + response.write("auth required"); + } else { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok, got authorization"); + } +}); + +function getExtension(bgConfig) { + function background(config) { + let path = config.path; + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.log( + `onBeforeRequest called with ${details.requestId} ${details.url}` + ); + browser.test.sendMessage("onBeforeRequest"); + return ( + config.onBeforeRequest.hasOwnProperty("result") && + config.onBeforeRequest.result + ); + }, + { urls: [path] }, + config.onBeforeRequest.hasOwnProperty("extra") + ? config.onBeforeRequest.extra + : [] + ); + browser.webRequest.onAuthRequired.addListener( + details => { + browser.test.log( + `onAuthRequired called with ${details.requestId} ${details.url}` + ); + browser.test.assertEq( + config.realm, + details.realm, + "providing www authorization" + ); + browser.test.sendMessage("onAuthRequired"); + return ( + config.onAuthRequired.hasOwnProperty("result") && + config.onAuthRequired.result + ); + }, + { urls: [path] }, + config.onAuthRequired.hasOwnProperty("extra") + ? config.onAuthRequired.extra + : [] + ); + browser.webRequest.onCompleted.addListener( + details => { + browser.test.log( + `onCompleted called with ${details.requestId} ${details.url}` + ); + browser.test.sendMessage("onCompleted"); + }, + { urls: [path] } + ); + browser.webRequest.onErrorOccurred.addListener( + details => { + browser.test.log( + `onErrorOccurred called with ${JSON.stringify(details)}` + ); + browser.test.sendMessage("onErrorOccurred"); + }, + { urls: [path] } + ); + } + + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", bgConfig.path], + }, + background: `(${background})(${JSON.stringify(bgConfig)})`, + }); +} + +add_task(async function test_webRequest_auth() { + let config = { + path: `${BASE_URL}/*`, + realm: `webRequest_auth${Math.random()}`, + onBeforeRequest: { + extra: ["blocking"], + }, + onAuthRequired: { + extra: ["blocking"], + result: { + authCredentials: { + username: "testuser", + password: "testpass", + }, + }, + }, + }; + + let extension = getExtension(config); + await extension.startup(); + + let requestUrl = `${BASE_URL}/authenticate.sjs?realm=${config.realm}`; + let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl); + await Promise.all([ + extension.awaitMessage("onBeforeRequest"), + extension.awaitMessage("onAuthRequired").then(() => { + return Promise.all([ + extension.awaitMessage("onBeforeRequest"), + extension.awaitMessage("onCompleted"), + ]); + }), + ]); + await contentPage.close(); + + // Second time around to test cached credentials + contentPage = await ExtensionTestUtils.loadContentPage(requestUrl); + await Promise.all([ + extension.awaitMessage("onBeforeRequest"), + extension.awaitMessage("onCompleted"), + ]); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_webRequest_auth_cancelled() { + // Test that any auth listener can cancel. + let config = { + path: `${BASE_URL}/*`, + realm: `webRequest_auth${Math.random()}`, + onBeforeRequest: { + extra: ["blocking"], + }, + onAuthRequired: { + extra: ["blocking"], + result: { + authCredentials: { + username: "testuser", + password: "testpass", + }, + }, + }, + }; + + let ex1 = getExtension(config); + config.onAuthRequired.result = { cancel: true }; + let ex2 = getExtension(config); + await ex1.startup(); + await ex2.startup(); + + let requestUrl = `${BASE_URL}/authenticate.sjs?realm=${config.realm}`; + let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl); + await Promise.all([ + ex1.awaitMessage("onBeforeRequest"), + ex1.awaitMessage("onAuthRequired"), + ex1.awaitMessage("onErrorOccurred"), + ex2.awaitMessage("onBeforeRequest"), + ex2.awaitMessage("onAuthRequired"), + ex2.awaitMessage("onErrorOccurred"), + ]); + + await contentPage.close(); + await ex1.unload(); + await ex2.unload(); +}); + +add_task(async function test_webRequest_auth_nonblocking() { + let config = { + path: `${BASE_URL}/*`, + realm: `webRequest_auth${Math.random()}`, + onBeforeRequest: { + extra: ["blocking"], + }, + onAuthRequired: { + extra: ["blocking"], + result: { + authCredentials: { + username: "testuser", + password: "testpass", + }, + }, + }, + }; + + let ex1 = getExtension(config); + // non-blocking ext tries to cancel but cannot. + delete config.onBeforeRequest.extra; + delete config.onAuthRequired.extra; + config.onAuthRequired.result = { cancel: true }; + let ex2 = getExtension(config); + await ex1.startup(); + await ex2.startup(); + + let requestUrl = `${BASE_URL}/authenticate.sjs?realm=${config.realm}`; + let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl); + await Promise.all([ + ex1.awaitMessage("onBeforeRequest"), + ex1.awaitMessage("onAuthRequired").then(() => { + return Promise.all([ + ex1.awaitMessage("onBeforeRequest"), + ex1.awaitMessage("onCompleted"), + ]); + }), + ex2.awaitMessage("onBeforeRequest"), + ex2.awaitMessage("onAuthRequired").then(() => { + return Promise.all([ + ex2.awaitMessage("onBeforeRequest"), + ex2.awaitMessage("onCompleted"), + ]); + }), + ]); + + await contentPage.close(); + Services.obs.notifyObservers(null, "net:clear-active-logins"); + await ex1.unload(); + await ex2.unload(); +}); + +add_task(async function test_webRequest_auth_blocking_noreturn() { + // The first listener is blocking but doesn't return anything. The second + // listener cancels the request. + let config = { + path: `${BASE_URL}/*`, + realm: `webRequest_auth${Math.random()}`, + onBeforeRequest: { + extra: ["blocking"], + }, + onAuthRequired: { + extra: ["blocking"], + }, + }; + + let ex1 = getExtension(config); + config.onAuthRequired.result = { cancel: true }; + let ex2 = getExtension(config); + await ex1.startup(); + await ex2.startup(); + + let requestUrl = `${BASE_URL}/authenticate.sjs?realm=${config.realm}`; + let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl); + await Promise.all([ + ex1.awaitMessage("onBeforeRequest"), + ex1.awaitMessage("onAuthRequired"), + ex1.awaitMessage("onErrorOccurred"), + ex2.awaitMessage("onBeforeRequest"), + ex2.awaitMessage("onAuthRequired"), + ex2.awaitMessage("onErrorOccurred"), + ]); + + await contentPage.close(); + await ex1.unload(); + await ex2.unload(); +}); + +add_task(async function test_webRequest_duelingAuth() { + let config = { + path: `${BASE_URL}/*`, + realm: `webRequest_auth${Math.random()}`, + onBeforeRequest: { + extra: ["blocking"], + }, + onAuthRequired: { + extra: ["blocking"], + }, + }; + let exNone = getExtension(config); + await exNone.startup(); + + let authCredentials = { + username: `testuser_da1${Math.random()}`, + password: `testpass_da1${Math.random()}`, + }; + config.onAuthRequired.result = { authCredentials }; + let ex1 = getExtension(config); + await ex1.startup(); + + config.onAuthRequired.result = {}; + let exEmpty = getExtension(config); + await exEmpty.startup(); + + config.onAuthRequired.result = { + authCredentials: { + username: `testuser_da2${Math.random()}`, + password: `testpass_da2${Math.random()}`, + }, + }; + let ex2 = getExtension(config); + await ex2.startup(); + + let requestUrl = `${BASE_URL}/authenticate.sjs?realm=${config.realm}&user=${authCredentials.username}&pass=${authCredentials.password}`; + let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl); + await Promise.all([ + exNone.awaitMessage("onBeforeRequest"), + exNone.awaitMessage("onAuthRequired").then(() => { + return Promise.all([ + exNone.awaitMessage("onBeforeRequest"), + exNone.awaitMessage("onCompleted"), + ]); + }), + exEmpty.awaitMessage("onBeforeRequest"), + exEmpty.awaitMessage("onAuthRequired").then(() => { + return Promise.all([ + exEmpty.awaitMessage("onBeforeRequest"), + exEmpty.awaitMessage("onCompleted"), + ]); + }), + ex1.awaitMessage("onBeforeRequest"), + ex1.awaitMessage("onAuthRequired").then(() => { + return Promise.all([ + ex1.awaitMessage("onBeforeRequest"), + ex1.awaitMessage("onCompleted"), + ]); + }), + ex2.awaitMessage("onBeforeRequest"), + ex2.awaitMessage("onAuthRequired").then(() => { + return Promise.all([ + ex2.awaitMessage("onBeforeRequest"), + ex2.awaitMessage("onCompleted"), + ]); + }), + ]); + + await Promise.all([ + await contentPage.close(), + exNone.unload(), + exEmpty.unload(), + ex1.unload(), + ex2.unload(), + ]); +}); + +add_task(async function test_webRequest_auth_proxy() { + function background(permissionPath) { + let proxyOk = false; + browser.webRequest.onAuthRequired.addListener( + details => { + browser.test.log( + `handlingExt onAuthRequired called with ${details.requestId} ${details.url}` + ); + if (details.isProxy) { + browser.test.succeed("providing proxy authorization"); + proxyOk = true; + return { authCredentials: { username: "puser", password: "ppass" } }; + } + browser.test.assertTrue( + proxyOk, + "providing www authorization after proxy auth" + ); + browser.test.sendMessage("done"); + return { authCredentials: { username: "auser", password: "apass" } }; + }, + { urls: [permissionPath] }, + ["blocking"] + ); + } + + let handlingExt = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", `${BASE_URL}/*`], + }, + background: `(${background})("${BASE_URL}/*")`, + }); + + await handlingExt.startup(); + + let requestUrl = `${BASE_URL}/authenticate.sjs?realm=webRequest_auth${Math.random()}&proxy_realm=proxy_auth${Math.random()}`; + let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl); + + await handlingExt.awaitMessage("done"); + await contentPage.close(); + await handlingExt.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cached.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cached.js new file mode 100644 index 0000000000..c18c75a580 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cached.js @@ -0,0 +1,311 @@ +"use strict"; + +const BASE_URL = "http://example.com"; +const FETCH_ORIGIN = "http://example.com/data/file_sample.html"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); +server.registerPathHandler("/status", (request, response) => { + let IfNoneMatch = request.hasHeader("If-None-Match") + ? request.getHeader("If-None-Match") + : ""; + + switch (IfNoneMatch) { + case "1234567890": + response.setStatusLine("1.1", 304, "Not Modified"); + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Etag", "1234567890", false); + break; + case "": + response.setStatusLine("1.1", 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Etag", "1234567890", false); + response.write("ok"); + break; + default: + throw new Error(`Unexpected If-None-Match: ${IfNoneMatch}`); + } +}); + +// This test initialises a cache entry with a CSP header, then +// loads the cached entry and replaces the CSP header with +// a new one. We test in onResponseStarted that the header +// is what we expect. +add_task(async function test_replaceResponseHeaders() { + Services.prefs.setBoolPref("network.http.rcwn.enabled", false); + + let extension = ExtensionTestUtils.loadExtension({ + background() { + function replaceHeader(headers, newHeader) { + headers = headers.filter(header => header.name !== newHeader.name); + headers.push(newHeader); + return headers; + } + let testHeaders = [ + { + name: "Content-Security-Policy", + value: "object-src 'none'; script-src 'none'", + }, + { + name: "Content-Security-Policy", + value: "object-src 'none'; script-src https:", + }, + ]; + browser.webRequest.onHeadersReceived.addListener( + details => { + if (!details.fromCache) { + // Add a CSP header on the initial request + details.responseHeaders.push(testHeaders[0]); + return { + responseHeaders: details.responseHeaders, + }; + } + // Test that the header added during the initial request is + // now in the cached response. + let header = details.responseHeaders.filter(header => { + browser.test.log(`header ${header.name} = ${header.value}`); + return header.name == "Content-Security-Policy"; + }); + browser.test.assertEq( + header[0].value, + testHeaders[0].value, + "pre-cached header exists" + ); + // Replace the cached value so we can test overriding the header that was cached. + return { + responseHeaders: replaceHeader( + details.responseHeaders, + testHeaders[1] + ), + }; + }, + { + urls: ["http://example.com/*/file_sample.html?r=*"], + }, + ["blocking", "responseHeaders"] + ); + browser.webRequest.onResponseStarted.addListener( + details => { + let needle = details.fromCache ? testHeaders[1] : testHeaders[0]; + let header = details.responseHeaders.filter(header => { + browser.test.log(`header ${header.name} = ${header.value}`); + return header.name == needle.name && header.value == needle.value; + }); + browser.test.assertEq( + header.length, + 1, + "header exists with correct value" + ); + if (details.fromCache) { + browser.test.sendMessage("from-cache"); + } + }, + { + urls: ["http://example.com/*/file_sample.html?r=*"], + }, + ["responseHeaders"] + ); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + + let url = `${BASE_URL}/data/file_sample.html?r=${Math.random()}`; + await ExtensionTestUtils.fetch(FETCH_ORIGIN, url); + await ExtensionTestUtils.fetch(FETCH_ORIGIN, url); + await extension.awaitMessage("from-cache"); + + await extension.unload(); +}); + +// This test initialises a cache entry with a CSP header, then +// loads the cached entry and adds a second CSP header. We also +// test that the browser has the CSP entries we expect. +add_task(async function test_addCSPHeaders() { + Services.prefs.setBoolPref("network.http.rcwn.enabled", false); + + let extension = ExtensionTestUtils.loadExtension({ + background() { + let testHeaders = [ + { + name: "Content-Security-Policy", + value: "object-src 'none'; script-src 'none'", + }, + { + name: "Content-Security-Policy", + value: "object-src 'none'; script-src https:", + }, + ]; + browser.webRequest.onHeadersReceived.addListener( + details => { + if (!details.fromCache) { + details.responseHeaders.push(testHeaders[0]); + return { + responseHeaders: details.responseHeaders, + }; + } + browser.test.log("cached request received"); + details.responseHeaders.push(testHeaders[1]); + return { + responseHeaders: details.responseHeaders, + }; + }, + { + urls: ["http://example.com/*/file_sample.html?r=*"], + }, + ["blocking", "responseHeaders"] + ); + browser.webRequest.onCompleted.addListener( + details => { + let { name, value } = testHeaders[0]; + if (details.fromCache) { + value = `${value}, ${testHeaders[1].value}`; + } + let header = details.responseHeaders.filter(header => { + browser.test.log(`header ${header.name} = ${header.value}`); + return header.name == name && header.value == value; + }); + browser.test.assertEq( + header.length, + 1, + "header exists with correct value" + ); + if (details.fromCache) { + browser.test.sendMessage("from-cache"); + } + }, + { + urls: ["http://example.com/*/file_sample.html?r=*"], + }, + ["responseHeaders"] + ); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + + let url = `${BASE_URL}/data/file_sample.html?r=${Math.random()}`; + let contentPage = await ExtensionTestUtils.loadContentPage(url); + equal(contentPage.browser.csp.policyCount, 1, "expected 1 policy"); + equal( + contentPage.browser.csp.getPolicy(0), + "object-src 'none'; script-src 'none'", + "expected policy" + ); + await contentPage.close(); + + contentPage = await ExtensionTestUtils.loadContentPage(url); + equal(contentPage.browser.csp.policyCount, 2, "expected 2 policies"); + equal( + contentPage.browser.csp.getPolicy(0), + "object-src 'none'; script-src 'none'", + "expected first policy" + ); + equal( + contentPage.browser.csp.getPolicy(1), + "object-src 'none'; script-src https:", + "expected second policy" + ); + + await extension.awaitMessage("from-cache"); + await contentPage.close(); + + await extension.unload(); +}); + +// This test verifies that a content type changed during +// onHeadersReceived is cached. We initialize the cache, +// then load against a url that will specifically return +// a 304 status code. +add_task(async function test_addContentTypeHeaders() { + Services.prefs.setBoolPref("network.http.rcwn.enabled", false); + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + browser.test.log(`onBeforeSendHeaders ${JSON.stringify(details)}\n`); + }, + { + urls: ["http://example.com/status*"], + }, + ["blocking", "requestHeaders"] + ); + browser.webRequest.onHeadersReceived.addListener( + details => { + browser.test.log(`onHeadersReceived ${JSON.stringify(details)}\n`); + if (!details.fromCache) { + browser.test.sendMessage("statusCode", details.statusCode); + const mime = details.responseHeaders.find(header => { + return header.value && header.name === "content-type"; + }); + if (mime) { + mime.value = "text/plain"; + } else { + details.responseHeaders.push({ + name: "content-type", + value: "text/plain", + }); + } + return { + responseHeaders: details.responseHeaders, + }; + } + }, + { + urls: ["http://example.com/status*"], + }, + ["blocking", "responseHeaders"] + ); + browser.webRequest.onCompleted.addListener( + details => { + browser.test.log(`onCompleted ${JSON.stringify(details)}\n`); + const mime = details.responseHeaders.find(header => { + return header.value && header.name === "content-type"; + }); + browser.test.sendMessage("contentType", mime.value); + }, + { + urls: ["http://example.com/status*"], + }, + ["responseHeaders"] + ); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/status` + ); + equal(await extension.awaitMessage("statusCode"), "200", "status OK"); + equal( + await extension.awaitMessage("contentType"), + "text/plain", + "plain text header" + ); + await contentPage.close(); + + contentPage = await ExtensionTestUtils.loadContentPage(`${BASE_URL}/status`); + equal(await extension.awaitMessage("statusCode"), "304", "not modified"); + equal( + await extension.awaitMessage("contentType"), + "text/plain", + "plain text header" + ); + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cancelWithReason.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cancelWithReason.js new file mode 100644 index 0000000000..a8405e5962 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cancelWithReason.js @@ -0,0 +1,68 @@ +"use strict"; + +const server = createHttpServer(); +const gServerUrl = `http://localhost:${server.identity.primaryPort}`; + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +add_task(async function test_cancel_with_reason() { + let ext = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: "cancel@test" } }, + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + () => { + return { cancel: true }; + }, + { urls: ["*://*/*"] }, + ["blocking"] + ); + }, + }); + await ext.startup(); + + let data = await new Promise(resolve => { + let ssm = Services.scriptSecurityManager; + + let channel = NetUtil.newChannel({ + uri: `${gServerUrl}/dummy`, + loadingPrincipal: + ssm.createContentPrincipalFromOrigin("http://localhost"), + contentPolicyType: Ci.nsIContentPolicy.TYPE_XMLHTTPREQUEST, + securityFlags: Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + }); + + channel.asyncOpen({ + QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]), + + onStartRequest(request) {}, + + onStopRequest(request, statusCode) { + let properties = request.QueryInterface(Ci.nsIPropertyBag); + let id = properties.getProperty("cancelledByExtension"); + let reason = request.loadInfo.requestBlockingReason; + resolve({ reason, id }); + }, + + onDataAvailable() {}, + }); + }); + + Assert.equal( + Ci.nsILoadInfo.BLOCKING_REASON_EXTENSION_WEBREQUEST, + data.reason, + "extension cancelled request" + ); + Assert.equal( + ext.id, + data.id, + "extension id attached to channel property bag" + ); + await ext.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_containerIsolation.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_containerIsolation.js new file mode 100644 index 0000000000..53a23fc149 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_containerIsolation.js @@ -0,0 +1,59 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +add_task(async function test_userContextId_webRequest() { + Services.prefs.setBoolPref("extensions.userContextIsolation.enabled", true); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "<all_urls>"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener( + async details => { + browser.test.assertEq( + "firefox-container-2", + details.cookieStoreId, + "cookieStoreId is set" + ); + browser.test.notifyPass("allowed"); + }, + { urls: ["http://example.com/dummy"] } + ); + }, + }); + + Services.prefs.setCharPref( + "extensions.userContextIsolation.defaults.restricted", + "[1]" + ); + await extension.startup(); + + let restrictedPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy", + { userContextId: 1 } + ); + + let allowedPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy", + { + userContextId: 2, + } + ); + await extension.awaitFinish("allowed"); + + await extension.unload(); + await restrictedPage.close(); + await allowedPage.close(); + + Services.prefs.clearUserPref("extensions.userContextIsolation.enabled"); + Services.prefs.clearUserPref( + "extensions.userContextIsolation.defaults.restricted" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_download.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_download.js new file mode 100644 index 0000000000..3485996f56 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_download.js @@ -0,0 +1,44 @@ +"use strict"; + +// Test for Bug 1579911: Check that download requests created by the +// downloads.download API can be observed by extensions. +// The DNR version is in test_ext_dnr_download.js. +add_task(async function testDownload() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "downloads", + "https://example.com/*", + ], + }, + background: async function () { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("request_intercepted"); + return { cancel: true }; + }, + { + urls: ["https://example.com/downloadtest"], + }, + ["blocking"] + ); + + browser.downloads.onChanged.addListener(delta => { + browser.test.assertEq(delta.state.current, "interrupted"); + browser.test.sendMessage("done"); + }); + + await browser.downloads.download({ + url: "https://example.com/downloadtest", + filename: "example.txt", + }); + }, + }); + + await extension.startup(); + await extension.awaitMessage("request_intercepted"); + await extension.awaitMessage("done"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_eventPage_StreamFilter.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_eventPage_StreamFilter.js new file mode 100644 index 0000000000..23c29aa155 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_eventPage_StreamFilter.js @@ -0,0 +1,350 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +const server = createHttpServer({ hosts: ["example.com"] }); + +let clearLastPendingRequest; + +server.registerPathHandler("/pending_request", (request, response) => { + response.processAsync(); + response.setHeader("Content-Length", "10000", false); + response.write("somedata\n"); + let intervalID = setInterval(() => response.write("continue\n"), 50); + + const clearPendingRequest = () => { + try { + clearInterval(intervalID); + response.finish(); + } catch (e) { + // This will throw, but we don't care at this point. + } + }; + + clearLastPendingRequest = clearPendingRequest; + registerCleanupFunction(clearPendingRequest); +}); + +server.registerPathHandler("/completed_request", (request, response) => { + response.write("somedata\n"); +}); + +add_setup(async () => { + await AddonTestUtils.promiseStartupManager(); +}); + +async function test_idletimeout_on_streamfilter({ + manifest_version, + expectResetIdle, + expectStreamFilterStop, + requestUrlPath, +}) { + const extension = ExtensionTestUtils.loadExtension({ + background: `(${async function (urlPath) { + browser.webRequest.onBeforeRequest.addListener( + request => { + browser.test.log(`webRequest request intercepted: ${request.url}`); + const filter = browser.webRequest.filterResponseData( + request.requestId + ); + const decoder = new TextDecoder("utf-8"); + const encoder = new TextEncoder(); + filter.onstart = () => { + browser.test.sendMessage("streamfilter:started"); + }; + filter.ondata = event => { + let str = decoder.decode(event.data, { stream: true }); + filter.write(encoder.encode(str)); + }; + filter.onstop = () => { + filter.close(); + browser.test.sendMessage("streamfilter:stopped"); + }; + }, + { + urls: [`http://example.com/${urlPath}`], + }, + ["blocking"] + ); + browser.test.sendMessage("bg:ready"); + }})("${requestUrlPath}")`, + + useAddonManager: "temporary", + manifest: { + manifest_version, + background: manifest_version >= 3 ? {} : { persistent: false }, + granted_host_permissions: manifest_version >= 3, + permissions: + manifest_version >= 3 + ? ["webRequest", "webRequestBlocking", "webRequestFilterResponse"] + : ["webRequest", "webRequestBlocking"], + // host_permissions are merged with permissions on a MV2 test extension. + host_permissions: ["http://example.com/*"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("bg:ready"); + const { contextId } = extension.extension.backgroundContext; + notEqual(contextId, undefined, "Got a contextId for the background context"); + + info("Trigger a webRequest"); + const testURL = `http://example.com/${requestUrlPath}`; + const promiseRequestCompleted = ExtensionTestUtils.fetch( + "http://example.com/", + testURL + ).catch(err => { + // This request is expected to be aborted when cleared after the test is exiting, + // otherwise rethrow the error to trigger an explicit failure. + if (/The operation was aborted/.test(err.message)) { + info(`Test webRequest fetching "${testURL}" aborted`); + } else { + ok( + false, + `Unexpected rejection triggered by the test webRequest fetching "${testURL}": ${err.message}` + ); + throw err; + } + }); + + info("Wait for the stream filter to be started"); + await extension.awaitMessage("streamfilter:started"); + + if (expectStreamFilterStop) { + await extension.awaitMessage("streamfilter:stopped"); + } + + info("Terminate the background script (simulated idle timeout)"); + + if (expectResetIdle) { + const promiseResetIdle = promiseExtensionEvent( + extension, + "background-script-reset-idle" + ); + + clearHistograms(); + assertHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT); + assertKeyedHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID); + + await extension.terminateBackground(); + info("Wait for 'background-script-reset-idle' event to be emitted"); + await promiseResetIdle; + equal( + extension.extension.backgroundContext.contextId, + contextId, + "Initial background context is still available as expected" + ); + + assertHistogramCategoryNotEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT, { + category: "reset_streamfilter", + categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + }); + + assertHistogramCategoryNotEmpty( + WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID, + { + keyed: true, + key: extension.id, + category: "reset_streamfilter", + categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + } + ); + } else { + const { Management } = ChromeUtils.importESModule( + "resource://gre/modules/Extension.sys.mjs" + ); + const promiseProxyContextUnloaded = new Promise(resolve => { + function listener(evt, context) { + if (context.extension.id === extension.id) { + Management.off("proxy-context-unload", listener); + resolve(); + } + } + Management.on("proxy-context-unload", listener); + }); + await extension.terminateBackground(); + await promiseProxyContextUnloaded; + equal( + extension.extension.backgroundContext, + undefined, + "Initial background context should have been terminated as expected" + ); + } + + await extension.unload(); + clearLastPendingRequest(); + await promiseRequestCompleted; +} + +add_task( + { + pref_set: [["extensions.eventPages.enabled", true]], + }, + async function test_idletimeout_on_active_streamfilter_mv2_eventpage() { + await test_idletimeout_on_streamfilter({ + manifest_version: 2, + requestUrlPath: "pending_request", + expectStreamFilterStop: false, + expectResetIdle: true, + }); + } +); + +add_task( + { + pref_set: [["extensions.manifestV3.enabled", true]], + }, + async function test_idletimeout_on_active_streamfilter_mv3() { + await test_idletimeout_on_streamfilter({ + manifest_version: 3, + requestUrlPath: "pending_request", + expectStreamFilterStop: false, + expectResetIdle: true, + }); + } +); + +add_task( + { + pref_set: [["extensions.eventPages.enabled", true]], + }, + async function test_idletimeout_on_inactive_streamfilter_mv2_eventpage() { + await test_idletimeout_on_streamfilter({ + manifest_version: 2, + requestUrlPath: "completed_request", + expectStreamFilterStop: true, + expectResetIdle: false, + }); + } +); + +add_task( + { + pref_set: [["extensions.manifestV3.enabled", true]], + }, + async function test_idletimeout_on_inactive_streamfilter_mv3() { + await test_idletimeout_on_streamfilter({ + manifest_version: 3, + requestUrlPath: "completed_request", + expectStreamFilterStop: true, + expectResetIdle: false, + }); + } +); + +async function test_create_new_streamfilter_while_suspending({ + manifest_version, +}) { + const extension = ExtensionTestUtils.loadExtension({ + async background() { + let interceptedRequestId; + let resolvePendingWebRequest; + + browser.runtime.onSuspend.addListener(async () => { + await browser.test.assertThrows( + () => browser.webRequest.filterResponseData(interceptedRequestId), + /forbidden while background extension global is suspending/, + "Got the expected exception raised from filterResponseData calls while suspending" + ); + browser.test.sendMessage("suspend-listener"); + }); + + browser.runtime.onSuspendCanceled.addListener(async () => { + // Once onSuspendCanceled is emitted, filterResponseData + // is expected to don't throw. + const filter = + browser.webRequest.filterResponseData(interceptedRequestId); + resolvePendingWebRequest(); + filter.onstop = () => { + filter.disconnect(); + browser.test.sendMessage("suspend-canceled-listener"); + }; + }); + + browser.webRequest.onBeforeRequest.addListener( + request => { + browser.test.log(`webRequest request intercepted: ${request.url}`); + interceptedRequestId = request.requestId; + return new Promise(resolve => { + resolvePendingWebRequest = resolve; + browser.test.sendMessage("webrequest-listener:done"); + }); + }, + { + urls: [`http://example.com/completed_request`], + }, + ["blocking"] + ); + browser.test.sendMessage("bg:ready"); + }, + + useAddonManager: "temporary", + manifest: { + manifest_version, + background: manifest_version >= 3 ? {} : { persistent: false }, + granted_host_permissions: manifest_version >= 3, + permissions: + manifest_version >= 3 + ? ["webRequest", "webRequestBlocking", "webRequestFilterResponse"] + : ["webRequest", "webRequestBlocking"], + // host_permissions are merged with permissions on a MV2 test extension. + host_permissions: ["http://example.com/*"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("bg:ready"); + const { contextId } = extension.extension.backgroundContext; + notEqual(contextId, undefined, "Got a contextId for the background context"); + + info("Trigger a webRequest"); + ExtensionTestUtils.fetch( + "http://example.com/", + `http://example.com/completed_request` + ); + + info("Wait for the web request to be intercepted and suspended"); + await extension.awaitMessage("webrequest-listener:done"); + + info("Terminate the background script (simulated idle timeout)"); + + extension.terminateBackground({ disableResetIdleForTest: true }); + await extension.awaitMessage("suspend-listener"); + + info("Simulated idle timeout canceled"); + extension.extension.emit("background-script-reset-idle"); + await extension.awaitMessage("suspend-canceled-listener"); + + await extension.unload(); +} + +add_task( + { + pref_set: [["extensions.eventPages.enabled", true]], + }, + async function test_error_creating_new_streamfilter_while_suspending_mv2_eventpage() { + await test_create_new_streamfilter_while_suspending({ + manifest_version: 2, + }); + } +); + +add_task( + { + pref_set: [["extensions.manifestV3.enabled", true]], + }, + async function test_error_creating_new_streamfilter_while_suspending_mv3() { + await test_create_new_streamfilter_while_suspending({ + manifest_version: 3, + }); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterResponseData.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterResponseData.js new file mode 100644 index 0000000000..0b826be08f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterResponseData.js @@ -0,0 +1,607 @@ +"use strict"; + +const HOSTS = new Set(["example.com", "example.org", "example.net"]); + +const server = createHttpServer({ hosts: HOSTS }); + +const FETCH_ORIGIN = "http://example.com/dummy"; + +server.registerDirectory("/data/", do_get_file("data")); + +server.registerPathHandler("/redirect", (request, response) => { + let params = new URLSearchParams(request.queryString); + response.setStatusLine(request.httpVersion, 302, "Moved Temporarily"); + response.setHeader("Location", params.get("redirect_uri")); + response.setHeader("Access-Control-Allow-Origin", "*"); +}); + +server.registerPathHandler("/redirect301", (request, response) => { + let params = new URLSearchParams(request.queryString); + response.setStatusLine(request.httpVersion, 301, "Moved Permanently"); + response.setHeader("Location", params.get("redirect_uri")); + response.setHeader("Access-Control-Allow-Origin", "*"); +}); + +server.registerPathHandler("/script302.js", (request, response) => { + response.setStatusLine(request.httpVersion, 302, "Moved Temporarily"); + response.setHeader("Location", "http://example.com/script.js"); +}); + +server.registerPathHandler("/script.js", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "application/javascript"); + response.write(String.raw`console.log("HELLO!");`); +}); + +server.registerPathHandler("/302.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html"); + response.write(String.raw` + <script type="application/javascript" src="http://example.com/script302.js"></script> + `); +}); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Access-Control-Allow-Origin", "*"); + response.write("ok"); +}); + +server.registerPathHandler("/dummy.xhtml", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "application/xhtml+xml"); + response.write(String.raw`<?xml version="1.0"?> + <html xml:lang="en" xmlns="http://www.w3.org/1999/xhtml"> + <head/> + <body/> + </html> + `); +}); + +server.registerPathHandler("/lorem.html.gz", async (request, response) => { + response.processAsync(); + + response.setHeader( + "Content-Type", + "Content-Type: text/html; charset=utf-8", + false + ); + response.setHeader("Content-Encoding", "gzip", false); + + let data = await IOUtils.read(do_get_file("data/lorem.html.gz").path); + response.write(String.fromCharCode(...data)); + + response.finish(); +}); + +// Test re-encoding the data stream for bug 1590898. +add_task(async function test_stream_encoding_data() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.webRequest.onBeforeRequest.addListener( + request => { + let filter = browser.webRequest.filterResponseData(request.requestId); + let decoder = new TextDecoder("utf-8"); + let encoder = new TextEncoder(); + + filter.ondata = event => { + let str = decoder.decode(event.data, { stream: true }); + filter.write(encoder.encode(str)); + filter.disconnect(); + }; + }, + { + urls: ["http://example.com/lorem.html.gz"], + types: ["main_frame"], + }, + ["blocking"] + ); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/lorem.html.gz" + ); + + let content = await contentPage.spawn([], () => { + return this.content.document.body.textContent; + }); + + ok( + content.includes("Lorem ipsum dolor sit amet"), + `expected content received` + ); + + await contentPage.close(); + await extension.unload(); +}); + +// Tests that the stream filter request is added to the document's load +// group, and blocks an XML document's load event until after the filter +// stops sending data. +add_task(async function test_xml_document_loadgroup_blocking() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.webRequest.onBeforeRequest.addListener( + request => { + let filter = browser.webRequest.filterResponseData(request.requestId); + + let data = []; + filter.ondata = event => { + data.push(event.data); + }; + filter.onstop = async () => { + browser.test.sendMessage("phase", "original-onstop"); + + // Make a few trips through the event loop. + for (let i = 0; i < 10; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + + for (let buffer of data) { + filter.write(buffer); + } + browser.test.sendMessage("phase", "filter-onstop"); + filter.close(); + }; + }, + { + urls: ["http://example.com/dummy.xhtml"], + }, + ["blocking"] + ); + }, + + files: { + "content_script.js"() { + browser.test.sendMessage("phase", "content-script-start"); + window.addEventListener( + "DOMContentLoaded", + () => { + browser.test.sendMessage("phase", "content-script-domload"); + }, + { once: true } + ); + window.addEventListener( + "load", + () => { + browser.test.sendMessage("phase", "content-script-load"); + }, + { once: true } + ); + }, + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + + content_scripts: [ + { + matches: ["http://example.com/dummy.xhtml"], + run_at: "document_start", + js: ["content_script.js"], + }, + ], + }, + }); + + await extension.startup(); + + const EXPECTED = [ + "original-onstop", + "filter-onstop", + "content-script-start", + "content-script-domload", + "content-script-load", + ]; + + let done = new Promise(resolve => { + let phases = []; + extension.onMessage("phase", phase => { + phases.push(phase); + if (phases.length === EXPECTED.length) { + resolve(phases); + } + }); + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy.xhtml" + ); + + deepEqual(await done, EXPECTED, "Things happened, and in the right order"); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_filter_content_fetch() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + let pending = []; + + browser.webRequest.onBeforeRequest.addListener( + data => { + let filter = browser.webRequest.filterResponseData(data.requestId); + + let url = new URL(data.url); + + if (url.searchParams.get("redirect_uri")) { + pending.push( + new Promise(resolve => { + filter.onerror = resolve; + }).then(() => { + browser.test.assertEq( + "Channel redirected", + filter.error, + "Got correct error for redirected filter" + ); + }) + ); + } + + filter.onstart = () => { + filter.write(new TextEncoder().encode(data.url)); + }; + filter.ondata = event => { + let str = new TextDecoder().decode(event.data); + browser.test.assertEq( + "ok", + str, + `Got unfiltered data for ${data.url}` + ); + }; + filter.onstop = () => { + filter.close(); + }; + }, + { + urls: ["<all_urls>"], + }, + ["blocking"] + ); + + browser.test.onMessage.addListener(async msg => { + if (msg === "done") { + await Promise.all(pending); + browser.test.notifyPass("stream-filter"); + } + }); + }, + + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "http://example.com/", + "http://example.org/", + ], + }, + }); + + await extension.startup(); + + let results = [ + ["http://example.com/dummy", "http://example.com/dummy"], + ["http://example.org/dummy", "http://example.org/dummy"], + ["http://example.net/dummy", "ok"], + [ + "http://example.com/redirect?redirect_uri=http://example.com/dummy", + "http://example.com/dummy", + ], + [ + "http://example.com/redirect?redirect_uri=http://example.org/dummy", + "http://example.org/dummy", + ], + ["http://example.com/redirect?redirect_uri=http://example.net/dummy", "ok"], + [ + "http://example.net/redirect?redirect_uri=http://example.com/dummy", + "http://example.com/dummy", + ], + ].map(async ([url, expectedResponse]) => { + let text = await ExtensionTestUtils.fetch(FETCH_ORIGIN, url); + equal(text, expectedResponse, `Expected response for ${url}`); + }); + + await Promise.all(results); + + extension.sendMessage("done"); + await extension.awaitFinish("stream-filter"); + await extension.unload(); +}); + +add_task(async function test_filter_301() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.webRequest.onHeadersReceived.addListener( + data => { + if (data.statusCode !== 200) { + return; + } + let filter = browser.webRequest.filterResponseData(data.requestId); + + filter.onstop = () => { + filter.close(); + browser.test.notifyPass("stream-filter"); + }; + filter.onerror = () => { + browser.test.fail(`unexpected ${filter.error}`); + }; + }, + { + urls: ["<all_urls>"], + }, + ["blocking"] + ); + }, + + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "http://example.com/", + "http://example.org/", + ], + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/redirect301?redirect_uri=http://example.org/dummy" + ); + + await extension.awaitFinish("stream-filter"); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_filter_302() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + let filter = browser.webRequest.filterResponseData(details.requestId); + browser.test.sendMessage("filter-created"); + + filter.ondata = event => { + const script = "forceError();"; + filter.write(new Uint8Array(new TextEncoder().encode(script))); + filter.close(); + browser.test.sendMessage("filter-ondata"); + }; + + filter.onerror = () => { + browser.test.assertEq(filter.error, "Channel redirected"); + browser.test.sendMessage("filter-redirect"); + }; + }, + { + urls: ["http://example.com/*.js"], + }, + ["blocking"] + ); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + + let { messages } = await promiseConsoleOutput(async () => { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/302.html" + ); + + await extension.awaitMessage("filter-created"); + await extension.awaitMessage("filter-redirect"); + await extension.awaitMessage("filter-created"); + await extension.awaitMessage("filter-ondata"); + await contentPage.close(); + }); + AddonTestUtils.checkMessages(messages, { + expected: [{ message: /forceError is not defined/ }], + }); + + await extension.unload(); +}); + +add_task(async function test_alternate_cached_data() { + Services.prefs.setBoolPref("dom.script_loader.bytecode_cache.enabled", true); + Services.prefs.setIntPref("dom.script_loader.bytecode_cache.strategy", -1); + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + let filter = browser.webRequest.filterResponseData(details.requestId); + let decoder = new TextDecoder("utf-8"); + let encoder = new TextEncoder(); + + filter.ondata = event => { + let str = decoder.decode(event.data, { stream: true }); + filter.write(encoder.encode(str)); + filter.disconnect(); + browser.test.assertTrue( + str.startsWith(`"use strict";`), + "ondata received decoded data" + ); + browser.test.sendMessage("onBeforeRequest"); + }; + + filter.onerror = () => { + // onBeforeRequest will always beat the cache race, so we should always + // get valid data in ondata. + browser.test.fail("error-received", filter.error); + }; + }, + { + urls: ["http://example.com/data/file_script_good.js"], + }, + ["blocking"] + ); + browser.webRequest.onHeadersReceived.addListener( + details => { + let filter = browser.webRequest.filterResponseData(details.requestId); + let decoder = new TextDecoder("utf-8"); + let encoder = new TextEncoder(); + + // Because cache is always a race, intermittently we will succesfully + // beat the cache, in which case we pass in ondata. If cache wins, + // we pass in onerror. + // Running the test with --verify hits this cache race issue, as well + // it seems that the cache primarily looses on linux1804. + let gotone = false; + filter.ondata = event => { + browser.test.assertFalse(gotone, "cache lost the race"); + gotone = true; + let str = decoder.decode(event.data, { stream: true }); + filter.write(encoder.encode(str)); + filter.disconnect(); + browser.test.assertTrue( + str.startsWith(`"use strict";`), + "ondata received decoded data" + ); + browser.test.sendMessage("onHeadersReceived"); + }; + + filter.onerror = () => { + browser.test.assertFalse(gotone, "cache won the race"); + gotone = true; + browser.test.assertEq( + filter.error, + "Channel is delivering cached alt-data" + ); + browser.test.sendMessage("onHeadersReceived"); + }; + }, + { + urls: ["http://example.com/data/file_script_bad.js"], + }, + ["blocking"] + ); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/*"], + }, + }); + + // Prime the cache so we have the script byte-cached. + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_script.html" + ); + await contentPage.close(); + + await extension.startup(); + + let page_cached = await await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_script.html" + ); + await Promise.all([ + extension.awaitMessage("onBeforeRequest"), + extension.awaitMessage("onHeadersReceived"), + ]); + await page_cached.close(); + await extension.unload(); + + Services.prefs.clearUserPref("dom.script_loader.bytecode_cache.enabled"); + Services.prefs.clearUserPref("dom.script_loader.bytecode_cache.strategy"); +}); + +add_task(async function test_webRequestFilterResponse_permission() { + function background() { + browser.test.onMessage.addListener(async (msg, ...args) => { + if (msg !== "testFilterResponseData") { + browser.test.fail(`Unexpected test message: ${msg}`); + return; + } + + const [{ expectMissingPermissionError }] = args; + + if (expectMissingPermissionError) { + browser.test.assertThrows( + () => browser.webRequest.filterResponseData("fake-response-id"), + /Missing required "webRequestFilterResponse" permission/, + "Expected missing webRequestFilterResponse permission error" + ); + } else { + // Expect the generic error raised on invalid response id + // if the missing permission error isn't expected. + browser.test.assertTrue( + browser.webRequest.filterResponseData("fake-response-id"), + "Expected no missing webRequestFilterResponse permission error" + ); + } + + browser.test.notifyPass(); + }); + } + + info( + "Verify MV2 extension does not require webRequestFilterResponse permission" + ); + const extMV2 = ExtensionTestUtils.loadExtension({ + background, + manifest: { + manifest_version: 2, + permissions: ["webRequest", "webRequestBlocking"], + }, + }); + + await extMV2.startup(); + extMV2.sendMessage("testFilterResponseData", { + expectMissingPermissionError: false, + }); + await extMV2.awaitFinish(); + await extMV2.unload(); + + info( + "Verify filterResponseData throws on MV3 extension without webRequestFilterResponse permission" + ); + const extMV3NoPerm = ExtensionTestUtils.loadExtension({ + background, + manifest: { + manifest_version: 3, + permissions: ["webRequest", "webRequestBlocking"], + }, + }); + + await extMV3NoPerm.startup(); + extMV3NoPerm.sendMessage("testFilterResponseData", { + expectMissingPermissionError: true, + }); + await extMV3NoPerm.awaitFinish(); + await extMV3NoPerm.unload(); + + info( + "Verify filterResponseData does not throw on MV3 extension without webRequestFilterResponse permission" + ); + const extMV3WithPerm = ExtensionTestUtils.loadExtension({ + background, + manifest: { + manifest_version: 3, + permissions: [ + "webRequest", + "webRequestBlocking", + "webRequestFilterResponse", + ], + }, + }); + + await extMV3WithPerm.startup(); + extMV3WithPerm.sendMessage("testFilterResponseData", { + expectMissingPermissionError: false, + }); + await extMV3WithPerm.awaitFinish(); + await extMV3WithPerm.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterTypes.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterTypes.js new file mode 100644 index 0000000000..be5b5ec9bf --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterTypes.js @@ -0,0 +1,87 @@ +"use strict"; + +AddonTestUtils.init(this); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/", (request, response) => { + response.setHeader("Content-Tpe", "text/plain", false); + response.write("OK"); +}); + +add_task(async function test_all_webRequest_ResourceTypes() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "*://example.com/*"], + }, + background() { + browser.test.onMessage.addListener(async msg => { + browser.webRequest[msg.event].addListener( + () => {}, + { urls: ["*://example.com/*"], ...msg.filter }, + ["blocking"] + ); + // Call an API method implemented in the parent process to + // be sure that the webRequest listener has been registered + // in the parent process as well. + await browser.runtime.getBrowserInfo(); + browser.test.sendMessage(`webRequest-listener-registered`); + }); + }, + }); + + await extension.startup(); + + const { Schemas } = ChromeUtils.importESModule( + "resource://gre/modules/Schemas.sys.mjs" + ); + const webRequestSchema = Schemas.privilegedSchemaJSON + .get("chrome://extensions/content/schemas/web_request.json") + .deserialize({}); + const ResourceType = webRequestSchema[1].types.filter( + type => type.id == "ResourceType" + )[0]; + ok( + ResourceType && ResourceType.enum, + "Found ResourceType in the web_request.json schema" + ); + info( + "Register webRequest.onBeforeRequest event listener for all supported ResourceType" + ); + + let { messages } = await promiseConsoleOutput(async () => { + ExtensionTestUtils.failOnSchemaWarnings(false); + extension.sendMessage({ + event: "onBeforeRequest", + filter: { + // Verify that the resourceType not supported is going to be ignored + // and all the ones supported does not trigger a ChannelWrapper.matches + // exception once the listener is being triggered. + types: [].concat(ResourceType.enum, "not-supported-resource-type"), + }, + }); + await extension.awaitMessage("webRequest-listener-registered"); + ExtensionTestUtils.failOnSchemaWarnings(); + + await ExtensionTestUtils.fetch( + "http://example.com/dummy", + "http://example.com" + ); + }); + + AddonTestUtils.checkMessages(messages, { + expected: [ + { message: /Warning processing types: .* "not-supported-resource-type"/ }, + ], + forbidden: [{ message: /JavaScript Error: "ChannelWrapper.matches/ }], + }); + info("No ChannelWrapper.matches errors have been logged"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filter_urls.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filter_urls.js new file mode 100644 index 0000000000..af0d8594f4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filter_urls.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +AddonTestUtils.init(this); + +add_task(async function test_invalid_urls_in_webRequest_filter() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "https://example.com/*"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener(() => {}, { + urls: ["htt:/example.com/*"], + types: ["main_frame"], + }); + }, + }); + let { messages } = await promiseConsoleOutput(async () => { + await extension.startup(); + await extension.unload(); + }); + AddonTestUtils.checkMessages( + messages, + { + expected: [ + { + message: /ExtensionError: Invalid url pattern: htt:\/example.com\/*/, + }, + ], + }, + true + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_from_extension_page.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_from_extension_page.js new file mode 100644 index 0000000000..7a648d7e31 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_from_extension_page.js @@ -0,0 +1,57 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/HELLO", (req, res) => { + res.write("BYE"); +}); + +add_task(async function request_from_extension_page() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://example.com/", "webRequest", "webRequestBlocking"], + }, + files: { + "tab.html": `<!DOCTYPE html><script src="tab.js"></script>`, + "tab.js": async function () { + browser.webRequest.onHeadersReceived.addListener( + details => { + let { responseHeaders } = details; + responseHeaders.push({ + name: "X-Added-by-Test", + value: "TheValue", + }); + return { responseHeaders }; + }, + { + urls: ["http://example.com/HELLO"], + }, + ["blocking", "responseHeaders"] + ); + + // Ensure that listener is registered (workaround for bug 1300234). + await browser.runtime.getPlatformInfo(); + + let response = await fetch("http://example.com/HELLO"); + browser.test.assertEq( + "TheValue", + response.headers.get("X-added-by-test"), + "expected response header from webRequest listener" + ); + browser.test.assertEq( + await response.text(), + "BYE", + "Expected response from server" + ); + browser.test.sendMessage("done"); + }, + }, + }); + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/tab.html`, + { extension } + ); + await extension.awaitMessage("done"); + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_host.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_host.js new file mode 100644 index 0000000000..425d83560d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_host.js @@ -0,0 +1,99 @@ +"use strict"; + +const HOSTS = new Set(["example.com", "example.org"]); + +const server = createHttpServer({ hosts: HOSTS }); + +const BASE_URL = "http://example.com"; +const FETCH_ORIGIN = "http://example.com/dummy"; + +server.registerPathHandler("/return_headers.sjs", (request, response) => { + response.setHeader("Content-Type", "text/plain", false); + + let headers = {}; + for (let { data: header } of request.headers) { + headers[header] = request.getHeader(header); + } + + response.write(JSON.stringify(headers)); +}); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +function getExtension(permission = "<all_urls>") { + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", permission], + }, + background() { + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + details.requestHeaders.push({ name: "Host", value: "example.org" }); + return { requestHeaders: details.requestHeaders }; + }, + { urls: ["<all_urls>"] }, + ["blocking", "requestHeaders"] + ); + }, + }); +} + +add_task(async function test_host_header_accepted() { + let extension = getExtension(); + await extension.startup(); + let headers = JSON.parse( + await ExtensionTestUtils.fetch( + FETCH_ORIGIN, + `${BASE_URL}/return_headers.sjs` + ) + ); + + equal(headers.host, "example.org", "Host header was set on request"); + + await extension.unload(); +}); + +add_task(async function test_host_header_denied() { + let extension = getExtension(`${BASE_URL}/`); + + await extension.startup(); + + let headers = JSON.parse( + await ExtensionTestUtils.fetch( + FETCH_ORIGIN, + `${BASE_URL}/return_headers.sjs` + ) + ); + + equal(headers.host, "example.com", "Host header was not set on request"); + + await extension.unload(); +}); + +add_task(async function test_host_header_restricted() { + Services.prefs.setCharPref( + "extensions.webextensions.restrictedDomains", + "example.org" + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("extensions.webextensions.restrictedDomains"); + }); + + let extension = getExtension(); + + await extension.startup(); + + let headers = JSON.parse( + await ExtensionTestUtils.fetch( + FETCH_ORIGIN, + `${BASE_URL}/return_headers.sjs` + ) + ); + + equal(headers.host, "example.com", "Host header was not set on request"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_incognito.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_incognito.js new file mode 100644 index 0000000000..fe3b6a8cf8 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_incognito.js @@ -0,0 +1,88 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +add_task(async function test_incognito_webrequest_access() { + let pb_extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener( + async details => { + browser.test.assertTrue(details.incognito, "incognito flag is set"); + }, + { urls: ["<all_urls>"], incognito: true }, + ["blocking"] + ); + + browser.webRequest.onBeforeRequest.addListener( + async details => { + browser.test.assertFalse( + details.incognito, + "incognito flag is not set" + ); + browser.test.notifyPass("webRequest.spanning"); + }, + { urls: ["<all_urls>"], incognito: false }, + ["blocking"] + ); + }, + }); + + // Bug 1715801: Re-enable pbm portion on GeckoView + if (AppConstants.platform == "android") { + Services.prefs.setBoolPref("dom.security.https_first_pbm", false); + } + + await pb_extension.startup(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener( + async details => { + browser.test.assertFalse( + details.incognito, + "incognito flag is not set" + ); + browser.test.notifyPass("webRequest"); + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + }, + }); + // Load non-incognito extension to check that private requests are invisible to it. + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy", + { privateBrowsing: true } + ); + await contentPage.close(); + + contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitFinish("webRequest"); + await pb_extension.awaitFinish("webRequest.spanning"); + await contentPage.close(); + + await pb_extension.unload(); + await extension.unload(); + + // Bug 1715801: Re-enable pbm portion on GeckoView + if (AppConstants.platform == "android") { + Services.prefs.clearUserPref("dom.security.https_first_pbm"); + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_mergecsp.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_mergecsp.js new file mode 100644 index 0000000000..402f54ca5e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_mergecsp.js @@ -0,0 +1,545 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +const server = createHttpServer({ + hosts: ["example.net", "example.com"], +}); +server.registerDirectory("/data/", do_get_file("data")); + +const pageContent = `<!DOCTYPE html> + <script id="script1" src="/data/file_script_good.js"></script> + <script id="script3" src="//example.com/data/file_script_bad.js"></script> + <img id="img1" src='/data/file_image_good.png'> + <img id="img3" src='//example.com/data/file_image_good.png'> +`; + +server.registerPathHandler("/", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html"); + if (request.queryString) { + response.setHeader( + "Content-Security-Policy", + decodeURIComponent(request.queryString) + ); + } + response.write(pageContent); +}); + +let extensionData = { + manifest: { + permissions: ["webRequest", "webRequestBlocking", "*://example.net/*"], + }, + background() { + let csp_value = undefined; + browser.test.onMessage.addListener(function (msg) { + csp_value = msg; + browser.test.sendMessage("csp-set"); + }); + browser.webRequest.onHeadersReceived.addListener( + e => { + browser.test.log(`onHeadersReceived ${e.requestId} ${e.url}`); + if (csp_value === undefined) { + browser.test.assertTrue(false, "extension called before CSP was set"); + } + if (csp_value !== null) { + e.responseHeaders = e.responseHeaders.filter( + i => i.name.toLowerCase() != "content-security-policy" + ); + if (csp_value !== "") { + e.responseHeaders.push({ + name: "Content-Security-Policy", + value: csp_value, + }); + } + } + return { responseHeaders: e.responseHeaders }; + }, + { urls: ["*://example.net/*"] }, + ["blocking", "responseHeaders"] + ); + }, +}; + +/** + * @typedef {object} ExpectedResourcesToLoad + * @property {object} img1_loaded image from a first party origin. + * @property {object} img3_loaded image from a third party origin. + * @property {object} script1_loaded script from a first party origin. + * @property {object} script3_loaded script from a third party origin. + * @property {object} [cspJSON] expected final document CSP (in JSON format, See dom/webidl/CSPDictionaries.webidl). + */ + +/** + * Test a combination of Content Security Policies against first/third party images/scripts. + * + * @param {object} opts + * @param {string} opts.site_csp The CSP to be sent by the site, or null. + * @param {string} opts.ext1_csp The CSP to be sent by the first extension, + * "" to remove the header, or null to not modify it. + * @param {string} opts.ext2_csp The CSP to be sent by the first extension, + * "" to remove the header, or null to not modify it. + * @param {ExpectedResourcesToLoad} opts.expect + * Object containing information which resources are expected to be loaded. + * @param {object} [opts.ext1_data] first test extension definition data (defaults to extensionData). + * @param {object} [opts.ext2_data] second test extension definition data (defaults to extensionData). + */ +async function test_csp({ + site_csp, + ext1_csp, + ext2_csp, + expect, + ext1_data = extensionData, + ext2_data = extensionData, +}) { + let extension1 = await ExtensionTestUtils.loadExtension(ext1_data); + let extension2 = await ExtensionTestUtils.loadExtension(ext2_data); + await extension1.startup(); + await extension2.startup(); + extension1.sendMessage(ext1_csp); + extension2.sendMessage(ext2_csp); + await extension1.awaitMessage("csp-set"); + await extension2.awaitMessage("csp-set"); + + let csp_value = encodeURIComponent(site_csp || ""); + let contentPage = await ExtensionTestUtils.loadContentPage( + `http://example.net/?${csp_value}` + ); + let results = await contentPage.spawn([], async () => { + let img1 = this.content.document.getElementById("img1"); + let img3 = this.content.document.getElementById("img3"); + let cspJSON = JSON.parse(this.content.document.cspJSON); + return { + img1_loaded: img1.complete && img1.naturalWidth > 0, + img3_loaded: img3.complete && img3.naturalWidth > 0, + // Note: "good" and "bad" are just placeholders; they don't mean anything. + script1_loaded: !!this.content.document.getElementById("good"), + script3_loaded: !!this.content.document.getElementById("bad"), + cspJSON, + }; + }); + + await contentPage.close(); + await extension1.unload(); + await extension2.unload(); + + let action = { + true: "loaded", + false: "blocked", + }; + + info( + `test_csp: From "${site_csp}" to ${JSON.stringify( + ext1_csp + )} to ${JSON.stringify(ext2_csp)}` + ); + + equal( + expect.img1_loaded, + results.img1_loaded, + `expected first party image to be ${action[expect.img1_loaded]}` + ); + equal( + expect.img3_loaded, + results.img3_loaded, + `expected third party image to be ${action[expect.img3_loaded]}` + ); + equal( + expect.script1_loaded, + results.script1_loaded, + `expected first party script to be ${action[expect.script1_loaded]}` + ); + equal( + expect.script3_loaded, + results.script3_loaded, + `expected third party script to be ${action[expect.script3_loaded]}` + ); + + if (expect.cspJSON) { + Assert.deepEqual( + expect.cspJSON, + results.cspJSON["csp-policies"], + `Got the expected final CSP set on the content document` + ); + } +} + +add_setup(async () => { + await AddonTestUtils.promiseStartupManager(); +}); + +// Test that merging csp header on both mv2 and mv3 extensions +// (and combination of both). +add_task(async function test_webRequest_mergecsp() { + const testCases = [ + { + site_csp: "default-src *", + ext1_csp: "script-src 'none'", + ext2_csp: null, + expect: { + img1_loaded: true, + img3_loaded: true, + script1_loaded: false, + script3_loaded: false, + }, + }, + { + site_csp: null, + ext1_csp: "script-src 'none'", + ext2_csp: null, + expect: { + img1_loaded: true, + img3_loaded: true, + script1_loaded: false, + script3_loaded: false, + }, + }, + { + site_csp: "default-src *", + ext1_csp: "script-src 'none'", + ext2_csp: "img-src 'none'", + expect: { + img1_loaded: false, + img3_loaded: false, + script1_loaded: false, + script3_loaded: false, + }, + }, + { + site_csp: null, + ext1_csp: "script-src 'none'", + ext2_csp: "img-src 'none'", + expect: { + img1_loaded: false, + img3_loaded: false, + script1_loaded: false, + script3_loaded: false, + }, + }, + { + site_csp: "default-src *", + ext1_csp: "img-src example.com", + ext2_csp: "img-src example.org", + expect: { + img1_loaded: false, + img3_loaded: false, + script1_loaded: true, + script3_loaded: true, + }, + }, + ]; + + const extMV2Data = { ...extensionData }; + const extMV3Data = { + ...extensionData, + useAddonManager: "temporary", + manifest: { + ...extensionData.manifest, + manifest_version: 3, + permissions: ["webRequest", "webRequestBlocking"], + host_permissions: ["*://example.net/*"], + granted_host_permissions: true, + }, + }; + + info("Run all test cases on ext1 MV2 and ext2 MV2"); + for (const testCase of testCases) { + await test_csp({ + ...testCase, + ext1_data: extMV2Data, + ext2_data: extMV2Data, + }); + } + + info("Run all test cases on ext1 MV3 and ext2 MV3"); + for (const testCase of testCases) { + await test_csp({ + ...testCase, + ext1_data: extMV3Data, + ext2_data: extMV3Data, + }); + } + + info("Run all test cases on ext1 MV3 and ext2 MV2"); + for (const testCase of testCases) { + await test_csp({ + ...testCase, + ext1_data: extMV3Data, + ext2_data: extMV2Data, + }); + } + + info("Run all test cases on ext1 MV2 and ext2 MV3"); + for (const testCase of testCases) { + await test_csp({ + ...testCase, + ext1_data: extMV2Data, + ext2_data: extMV3Data, + }); + } +}); + +add_task(async function test_remove_and_replace_csp_mv2() { + // CSP removed, CSP added. + await test_csp({ + site_csp: "img-src 'self'", + ext1_csp: "", + ext2_csp: "img-src example.com", + expect: { + img1_loaded: false, + img3_loaded: true, + script1_loaded: true, + script3_loaded: true, + }, + }); + + // CSP removed, CSP added. + await test_csp({ + site_csp: "default-src 'none'", + ext1_csp: "", + ext2_csp: "img-src example.com", + expect: { + img1_loaded: false, + img3_loaded: true, + script1_loaded: true, + script3_loaded: true, + }, + }); + + // CSP replaced - regression test for bug 1635781. + await test_csp({ + site_csp: "default-src 'none'", + ext1_csp: "img-src example.com", + ext2_csp: null, + expect: { + img1_loaded: false, + img3_loaded: true, + script1_loaded: true, + script3_loaded: true, + }, + }); + + // CSP unchanged, CSP replaced - regression test for bug 1635781. + await test_csp({ + site_csp: "default-src 'none'", + ext1_csp: null, + ext2_csp: "img-src example.com", + expect: { + img1_loaded: false, + img3_loaded: true, + script1_loaded: true, + script3_loaded: true, + }, + }); + + // CSP replaced, CSP removed. + await test_csp({ + site_csp: "default-src 'none'", + ext1_csp: "img-src example.com", + ext2_csp: "", + expect: { + img1_loaded: true, + img3_loaded: true, + script1_loaded: true, + script3_loaded: true, + }, + }); +}); + +// Test that fully replace the website csp header from an mv3 extension +// isn't allowed and it is considered a no-op. +add_task(async function test_remove_and_replace_csp_mv3() { + const extMV2Data = { ...extensionData }; + + const extMV3Data = { + ...extensionData, + useAddonManager: "temporary", + manifest: { + ...extensionData.manifest, + manifest_version: 3, + permissions: ["webRequest", "webRequestBlocking"], + host_permissions: ["*://example.net/*"], + granted_host_permissions: true, + }, + }; + + await test_csp({ + // site: CSP strict on images, lax on default and script src. + site_csp: "img-src 'self'", + // ext1: MV3 extension which return an empty CSP header (which is a no-op). + ext1_csp: "", + // ext2: MV3 extension which return a CSP header (which is expected to be merged). + ext2_csp: "img-src example.com", + expect: { + img1_loaded: false, + img3_loaded: false, + script1_loaded: true, + script3_loaded: true, + cspJSON: [ + { "img-src": ["'self'"], "report-only": false }, + { "img-src": ["http://example.com"], "report-only": false }, + ], + }, + ext1_data: extMV3Data, + ext2_data: extMV3Data, + }); + + await test_csp({ + // site: CSP strict on default-src. + site_csp: "default-src 'none'", + // ext1: MV3 extension which return an empty CSP header (which is a no-op). + ext1_csp: "", + // ext2: MV3 extension which return a CSP header (which is expected to be merged). + ext2_csp: "img-src example.com", + expect: { + img1_loaded: false, + img3_loaded: false, + script1_loaded: false, + script3_loaded: false, + cspJSON: [ + { "default-src": ["'none'"], "report-only": false }, + { "img-src": ["http://example.com"], "report-only": false }, + ], + }, + ext1_data: extMV3Data, + ext2_data: extMV3Data, + }); + + await test_csp({ + // site: CSP strict on default-src. + site_csp: "default-src 'none'", + // ext1: MV3 extension which return a CSP header (which is expected to be merged and to + // not be able to make it less strict). + ext1_csp: "img-src example.com", + // ext2: MV3 extension which leaves the header unmodified. + ext2_csp: null, + expect: { + img1_loaded: false, + img3_loaded: false, + script1_loaded: false, + script3_loaded: false, + cspJSON: [ + { "default-src": ["'none'"], "report-only": false }, + { "img-src": ["http://example.com"], "report-only": false }, + ], + }, + ext1_data: extMV3Data, + ext2_data: extMV3Data, + }); + + await test_csp({ + // site: CSP strict on default-src. + site_csp: "default-src 'none'", + // ext1: MV3 extension which merges additional directive into the site csp (and can't make + // it less strict). + ext1_csp: "img-src example.com", + // ext2: MV3 extension which merges an empty CSP header (which is a no-op, unlike with MV2). + ext2_csp: "", + expect: { + img1_loaded: false, + img3_loaded: false, + script1_loaded: false, + script3_loaded: false, + cspJSON: [ + { "default-src": ["'none'"], "report-only": false }, + { "img-src": ["http://example.com"], "report-only": false }, + ], + }, + ext1_data: extMV3Data, + ext2_data: extMV3Data, + }); + + await test_csp({ + // site: lax CSP (which is expected to be made stricted by the ext1 extension). + site_csp: "default-src *", + // ext1: MV3 extension which wants to set a stricter CSP (expected to work fine with the MV3 extension) + ext1_csp: "default-src 'none'", + // ext2: MV3 extension which leaves it unchanged. + ext2_csp: null, + expect: { + img1_loaded: false, + img3_loaded: false, + script1_loaded: false, + script3_loaded: false, + cspJSON: [ + { "default-src": ["*"], "report-only": false }, + { "default-src": ["'none'"], "report-only": false }, + ], + }, + ext1_data: extMV3Data, + ext2_data: extMV3Data, + }); + + await test_csp({ + // site: CSP strict on default-src. + site_csp: "default-src 'none'", + // ext1: MV3 extension and tries to replace the strict site csp with this lax one + // (but as an MV3 extension that is going to be merged to the site csp and the + // resulting site CSP is expected to stay strict). + ext1_csp: "default-src *", + // ext2: MV3 extension which leaves it unchanged. + ext2_csp: null, + expect: { + // strict site csp merged with the lax one from ext1 stays strict. + img1_loaded: false, + img3_loaded: false, + script1_loaded: false, + script3_loaded: false, + cspJSON: [ + { "default-src": ["'none'"], "report-only": false }, + { "default-src": ["*"], "report-only": false }, + ], + }, + ext1_data: extMV3Data, + ext2_data: extMV3Data, + }); + + await test_csp({ + // site: CSP strict on default-src. + site_csp: "default-src 'none'", + // ext1: MV3 extension which return an empty CSP (expected to be a no-op for an MV3 extension). + ext1_csp: "", + // ext2: MV2 exension which wants to replace the site csp with a lax one (and still be allowed to + // because the empty one from the MV3 extension is expected to be a no-op). + ext2_csp: "default-src *", + expect: { + img1_loaded: true, + img3_loaded: true, + script1_loaded: true, + script3_loaded: true, + cspJSON: [{ "default-src": ["*"], "report-only": false }], + }, + ext1_data: extMV3Data, + ext2_data: extMV2Data, + }); + + await test_csp({ + // site: CSP strict on default-src. + site_csp: "default-src 'none'", + // ext1: MV3 extension which return an empty CSP (which is expected to be a no-op). + ext1_csp: "", + // ext2: MV2 extension which also returns an empty CSP (which for an MV2 extension is expected + // to clear the CSP). + ext2_csp: "", + expect: { + img1_loaded: true, + img3_loaded: true, + script1_loaded: true, + script3_loaded: true, + // Expect the resulting final document CSP to be empty (due to the MV2 extension clearing it). + cspJSON: [], + }, + ext1_data: extMV3Data, + ext2_data: extMV2Data, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_permission.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_permission.js new file mode 100644 index 0000000000..bfb4b55856 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_permission.js @@ -0,0 +1,153 @@ +"use strict"; + +const HOSTS = new Set(["example.com", "example.org"]); + +const server = createHttpServer({ hosts: HOSTS }); + +const BASE_URL = "http://example.com"; + +server.registerDirectory("/data/", do_get_file("data")); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +function sendMessage(page, msg, data) { + return MessageChannel.sendMessage(page.browser.messageManager, msg, data); +} + +add_task(async function test_permissions() { + function background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + if (details.url.includes("_original")) { + let redirectUrl = details.url + .replace("example.org", "example.com") + .replace("_original", "_redirected"); + return { redirectUrl }; + } + return {}; + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + } + + let extensionData = { + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + background, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + const frameScript = () => { + const messageListener = { + async receiveMessage({ target, messageName, recipient, data, name }) { + /* globals content */ + let doc = content.document; + let iframe = doc.createElement("iframe"); + doc.body.appendChild(iframe); + + let promise = new Promise(resolve => { + let listener = event => { + content.removeEventListener("message", listener); + resolve(event.data); + }; + content.addEventListener("message", listener); + }); + + iframe.setAttribute( + "src", + "http://example.com/data/file_WebRequest_permission_original.html" + ); + let result = await promise; + doc.body.removeChild(iframe); + return result; + }, + }; + + const { MessageChannel } = ChromeUtils.importESModule( + "resource://testing-common/MessageChannel.sys.mjs" + ); + MessageChannel.addListener(this, "Test:Check", messageListener); + }; + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/dummy` + ); + await contentPage.loadFrameScript(frameScript); + + let results = await sendMessage(contentPage, "Test:Check", {}); + equal( + results.page, + "redirected", + "Regular webRequest redirect works on an unprivileged page" + ); + equal( + results.script, + "redirected", + "Regular webRequest redirect works from an unprivileged page" + ); + + Services.prefs.setBoolPref("extensions.webapi.testing", true); + Services.prefs.setBoolPref("extensions.webapi.testing.http", true); + + results = await sendMessage(contentPage, "Test:Check", {}); + equal( + results.page, + "original", + "webRequest redirect fails on a privileged page" + ); + equal( + results.script, + "original", + "webRequest redirect fails from a privileged page" + ); + + await extension.unload(); + await contentPage.close(); +}); + +add_task(async function test_no_webRequestBlocking_error() { + function background() { + const expectedError = + "Using webRequest.addListener with the blocking option " + + "requires the 'webRequestBlocking' permission."; + + const blockingEvents = [ + "onBeforeRequest", + "onBeforeSendHeaders", + "onHeadersReceived", + "onAuthRequired", + ]; + + for (let eventName of blockingEvents) { + browser.test.assertThrows( + () => { + browser.webRequest[eventName].addListener( + details => {}, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + }, + expectedError, + `Got the expected exception for a blocking webRequest.${eventName} listener` + ); + } + } + + const extensionData = { + manifest: { permissions: ["webRequest", "<all_urls>"] }, + background, + }; + + const extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirectProperty.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirectProperty.js new file mode 100644 index 0000000000..5a448abb2a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirectProperty.js @@ -0,0 +1,64 @@ +"use strict"; + +const server = createHttpServer(); +const gServerUrl = `http://localhost:${server.identity.primaryPort}`; + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +add_task(async function test_redirect_property() { + function background(serverUrl) { + browser.webRequest.onBeforeRequest.addListener( + () => { + return { redirectUrl: `${serverUrl}/dummy` }; + }, + { urls: ["*://localhost/*"] }, + ["blocking"] + ); + } + + let ext = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: "redirect@test" } }, + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + background: `(${background})("${gServerUrl}")`, + }); + await ext.startup(); + + let data = await new Promise(resolve => { + let ssm = Services.scriptSecurityManager; + + let channel = NetUtil.newChannel({ + uri: `${gServerUrl}/redirect`, + loadingPrincipal: + ssm.createContentPrincipalFromOrigin("http://localhost"), + contentPolicyType: Ci.nsIContentPolicy.TYPE_XMLHTTPREQUEST, + securityFlags: Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + }); + + channel.asyncOpen({ + QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]), + + onStartRequest(request) {}, + + onStopRequest(request, statusCode) { + let properties = request.QueryInterface(Ci.nsIPropertyBag); + let id = properties.getProperty("redirectedByExtension"); + resolve({ id, url: request.QueryInterface(Ci.nsIChannel).URI.spec }); + }, + + onDataAvailable() {}, + }); + }); + + Assert.equal(`${gServerUrl}/dummy`, data.url, "request redirected"); + Assert.equal( + ext.id, + data.id, + "extension id attached to channel property bag" + ); + await ext.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_StreamFilter.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_StreamFilter.js new file mode 100644 index 0000000000..8153a596a3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_StreamFilter.js @@ -0,0 +1,129 @@ +"use strict"; + +// StreamFilters should be closed upon a redirect. +// +// Some redirects are already tested in other tests: +// - test_ext_webRequest_filterResponseData.js tests fetch requests. +// - test_ext_webRequest_viewsource_StreamFilter.js tests view-source documents. +// +// Usually, redirects are caught in StreamFilterParent::OnStartRequest, but due +// to the fact that AttachStreamFilter is deferred for document requests, OSR is +// not called and the cleanup is triggered from nsHttpChannel::ReleaseListeners. + +const server = createHttpServer({ hosts: ["example.com", "example.org"] }); + +server.registerPathHandler("/redir", (request, response) => { + response.setStatusLine(request.httpVersion, 302, "Found"); + response.setHeader("Location", "/target"); +}); +server.registerPathHandler("/target", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); +server.registerPathHandler("/RedirectToRedir.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8"); + response.write("<script>location.href='http://example.com/redir';</script>"); +}); +server.registerPathHandler("/iframeWithRedir.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8"); + response.write("<iframe src='http://example.com/redir'></iframe>"); +}); + +function loadRedirectCatcherExtension() { + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "*://*/*"], + }, + background() { + const closeCounts = {}; + browser.webRequest.onBeforeRequest.addListener( + details => { + let expectedError = "Channel redirected"; + if (details.type === "main_frame" || details.type === "sub_frame") { + // Message differs for the reason stated at the top of this file. + // TODO bug 1683862: Make error message more accurate. + expectedError = "Invalid request ID"; + } + + closeCounts[details.requestId] = 0; + + let filter = browser.webRequest.filterResponseData(details.requestId); + filter.onstart = () => { + filter.disconnect(); + browser.test.fail("Unexpected filter.onstart"); + }; + filter.onerror = function () { + closeCounts[details.requestId]++; + browser.test.assertEq(expectedError, filter.error, "filter.error"); + }; + }, + { urls: ["*://*/redir"] }, + ["blocking"] + ); + browser.webRequest.onCompleted.addListener( + details => { + // filter.onerror from the redirect request should be called before + // webRequest.onCompleted of the redirection target. Regression test + // for bug 1683189. + browser.test.assertEq( + 1, + closeCounts[details.requestId], + "filter from initial, redirected request should have been closed" + ); + browser.test.log("Intentionally canceling view-source request"); + browser.test.sendMessage("req_end", details.type); + }, + { urls: ["*://*/target"] } + ); + }, + }); +} + +add_task(async function redirect_document() { + let extension = loadRedirectCatcherExtension(); + await extension.startup(); + + { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/redir" + ); + equal(await extension.awaitMessage("req_end"), "main_frame", "is top doc"); + await contentPage.close(); + } + + { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/iframeWithRedir.html" + ); + equal(await extension.awaitMessage("req_end"), "sub_frame", "is sub doc"); + await contentPage.close(); + } + + await extension.unload(); +}); + +// Cross-origin redirect = process switch. +add_task(async function redirect_document_cross_origin() { + let extension = loadRedirectCatcherExtension(); + await extension.startup(); + + { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.org/RedirectToRedir.html" + ); + equal(await extension.awaitMessage("req_end"), "main_frame", "is top doc"); + await contentPage.close(); + } + + { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.org/iframeWithRedir.html" + ); + equal(await extension.awaitMessage("req_end"), "sub_frame", "is sub doc"); + await contentPage.close(); + } + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_mozextension.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_mozextension.js new file mode 100644 index 0000000000..e390e3348e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_mozextension.js @@ -0,0 +1,47 @@ +"use strict"; + +// See: https://bugzilla.mozilla.org/show_bug.cgi?id=1573456 +add_task(async function test_mozextension_page_loaded_in_extension_process() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "https://example.com/*", + ], + web_accessible_resources: ["test.html"], + }, + files: { + "test.html": '<!DOCTYPE html><script src="test.js"></script>', + "test.js": () => { + browser.test.assertTrue( + browser.webRequest, + "webRequest API should be available" + ); + + browser.test.sendMessage("test_done"); + }, + }, + background: () => { + browser.webRequest.onBeforeRequest.addListener( + () => { + return { + redirectUrl: browser.runtime.getURL("test.html"), + }; + }, + { urls: ["*://*/redir"] }, + ["blocking"] + ); + }, + }); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "https://example.com/redir" + ); + + await extension.awaitMessage("test_done"); + + await extension.unload(); + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_requestSize.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_requestSize.js new file mode 100644 index 0000000000..69238fb057 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_requestSize.js @@ -0,0 +1,57 @@ +"use strict"; + +const server = createHttpServer(); +const gServerUrl = `http://localhost:${server.identity.primaryPort}`; + +const EXTENSION_DATA = { + manifest: { + name: "Simple extension test", + version: "1.0", + manifest_version: 2, + description: "", + + permissions: ["webRequest", "<all_urls>"], + }, + + async background() { + browser.test.log("background script running"); + + browser.webRequest.onBeforeSendHeaders.addListener( + async details => { + browser.test.assertTrue(details.requestSize == 0, "no requestSize"); + browser.test.assertTrue(details.responseSize == 0, "no responseSize"); + browser.test.log(`details.requestSize: ${details.requestSize}`); + browser.test.log(`details.responseSize: ${details.responseSize}`); + browser.test.sendMessage("check"); + }, + { urls: ["*://*/*"] } + ); + + browser.webRequest.onCompleted.addListener( + async details => { + browser.test.assertTrue(details.requestSize > 100, "have requestSize"); + browser.test.assertTrue( + details.responseSize > 100, + "have responseSize" + ); + browser.test.log(`details.requestSize: ${details.requestSize}`); + browser.test.log(`details.responseSize: ${details.responseSize}`); + browser.test.sendMessage("done"); + }, + { urls: ["*://*/*"] } + ); + }, +}; + +add_task(async function test_request_response_size() { + let ext = ExtensionTestUtils.loadExtension(EXTENSION_DATA); + await ext.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${gServerUrl}/dummy` + ); + await ext.awaitMessage("check"); + await ext.awaitMessage("done"); + await contentPage.close(); + await ext.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_responseBody.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_responseBody.js new file mode 100644 index 0000000000..8995870ba6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_responseBody.js @@ -0,0 +1,764 @@ +"use strict"; + +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +/* eslint-disable no-shadow */ + +const { ExtensionTestCommon } = ChromeUtils.importESModule( + "resource://testing-common/ExtensionTestCommon.sys.mjs" +); + +const HOSTS = new Set(["example.com"]); + +const server = createHttpServer({ hosts: HOSTS }); + +const BASE_URL = "http://example.com"; +const FETCH_ORIGIN = "http://example.com/data/file_sample.html"; + +const SEQUENTIAL = false; + +const PARTS = [ + `<!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="UTF-8"> + <title></title> + </head> + <body>`, + "Lorem ipsum dolor sit amet, <br>", + "consectetur adipiscing elit, <br>", + "sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. <br>", + "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. <br>", + "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <br>", + "Excepteur sint occaecat cupidatat non proident, <br>", + "sunt in culpa qui officia deserunt mollit anim id est laborum.<br>", + ` + </body> + </html>`, +].map(part => `${part}\n`); + +const TIMEOUT = AppConstants.DEBUG ? 4000 : 800; + +function delay(timeout = TIMEOUT) { + return new Promise(resolve => setTimeout(resolve, timeout)); +} + +server.registerPathHandler("/slow_response.sjs", async (request, response) => { + response.processAsync(); + + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Cache-Control", "no-cache", false); + + await delay(); + + for (let part of PARTS) { + try { + response.write(part); + } catch (e) { + // This fails if we attempt to write data after the connection has + // been closed. + break; + } + await delay(); + } + + response.finish(); +}); + +server.registerPathHandler("/lorem.html.gz", async (request, response) => { + response.processAsync(); + + response.setHeader( + "Content-Type", + "Content-Type: text/html; charset=utf-8", + false + ); + response.setHeader("Content-Encoding", "gzip", false); + + let data = await IOUtils.read(do_get_file("data/lorem.html.gz").path); + response.write(String.fromCharCode(...data)); + + response.finish(); +}); + +server.registerPathHandler("/multipart", async (request, response) => { + response.processAsync(); + + response.setHeader( + "Content-Type", + 'Content-Type: multipart/x-mixed-replace; boundary="testingtesting"', + false + ); + + response.write("--testingtesting\n"); + response.write(PARTS.join("")); + response.write("--testingtesting--\n"); + + response.finish(); +}); + +server.registerPathHandler("/multipart2", async (request, response) => { + response.processAsync(); + + response.setHeader( + "Content-Type", + 'Content-Type: multipart/x-mixed-replace; boundary="testingtesting"', + false + ); + + response.write("--testingtesting\n"); + response.write(PARTS.join("")); + response.write("--testingtesting\n"); + response.write(PARTS.join("")); + response.write("--testingtesting--\n"); + + response.finish(); +}); + +server.registerDirectory("/data/", do_get_file("data")); + +const TASKS = [ + { + url: "slow_response.sjs", + task(filter, resolve, num) { + let decoder = new TextDecoder("utf-8"); + + browser.test.assertEq( + "uninitialized", + filter.status, + `(${num}): Got expected initial status` + ); + + filter.onstart = event => { + browser.test.assertEq( + "transferringdata", + filter.status, + `(${num}): Got expected onStart status` + ); + }; + + filter.onstop = event => { + browser.test.fail( + `(${num}): Got unexpected onStop event while disconnected` + ); + }; + + let n = 0; + filter.ondata = async event => { + let str = decoder.decode(event.data, { stream: true }); + + if (n < 3) { + browser.test.assertEq( + JSON.stringify(PARTS[n]), + JSON.stringify(str), + `(${num}): Got expected part` + ); + } + n++; + + filter.write(event.data); + + if (n == 3) { + filter.suspend(); + + browser.test.assertEq( + "suspended", + filter.status, + `(${num}): Got expected suspended status` + ); + + let fail = () => { + browser.test.fail( + `(${num}): Got unexpected data event while suspended` + ); + }; + filter.addEventListener("data", fail); + + await delay(TIMEOUT * 3); + + browser.test.assertEq( + "suspended", + filter.status, + `(${num}): Got expected suspended status` + ); + + filter.removeEventListener("data", fail); + filter.resume(); + browser.test.assertEq( + "transferringdata", + filter.status, + `(${num}): Got expected resumed status` + ); + } else if (n > 4) { + filter.disconnect(); + + filter.addEventListener("data", () => { + browser.test.fail( + `(${num}): Got unexpected data event while disconnected` + ); + }); + + browser.test.assertEq( + "disconnected", + filter.status, + `(${num}): Got expected disconnected status` + ); + + resolve(); + } + }; + + filter.onerror = event => { + browser.test.fail( + `(${num}): Got unexpected error event: ${filter.error}` + ); + }; + }, + verify(response) { + equal(response, PARTS.join(""), "Got expected final HTML"); + }, + }, + { + url: "slow_response.sjs", + task(filter, resolve, num) { + let decoder = new TextDecoder("utf-8"); + + filter.onstop = event => { + browser.test.fail( + `(${num}): Got unexpected onStop event while disconnected` + ); + }; + + let n = 0; + filter.ondata = async event => { + let str = decoder.decode(event.data, { stream: true }); + + if (n < 3) { + browser.test.assertEq( + JSON.stringify(PARTS[n]), + JSON.stringify(str), + `(${num}): Got expected part` + ); + } + n++; + + filter.write(event.data); + + if (n == 3) { + filter.suspend(); + + await delay(TIMEOUT * 3); + + filter.disconnect(); + + resolve(); + } + }; + + filter.onerror = event => { + browser.test.fail( + `(${num}): Got unexpected error event: ${filter.error}` + ); + }; + }, + verify(response) { + equal(response, PARTS.join(""), "Got expected final HTML"); + }, + }, + { + url: "slow_response.sjs", + task(filter, resolve, num) { + let encoder = new TextEncoder(); + + filter.onstop = event => { + browser.test.fail( + `(${num}): Got unexpected onStop event while disconnected` + ); + }; + + let n = 0; + filter.ondata = async event => { + n++; + + filter.write(event.data); + + function checkState(state) { + browser.test.assertEq( + state, + filter.status, + `(${num}): Got expected status` + ); + } + if (n == 3) { + filter.resume(); + checkState("transferringdata"); + filter.suspend(); + checkState("suspended"); + filter.suspend(); + checkState("suspended"); + filter.resume(); + checkState("transferringdata"); + filter.suspend(); + checkState("suspended"); + + await delay(TIMEOUT * 3); + + checkState("suspended"); + filter.disconnect(); + checkState("disconnected"); + + for (let method of ["suspend", "resume", "close"]) { + browser.test.assertThrows( + () => { + filter[method](); + }, + /.*/, + `(${num}): ${method}() should throw while disconnected` + ); + } + + browser.test.assertThrows( + () => { + filter.write(encoder.encode("Foo bar")); + }, + /.*/, + `(${num}): write() should throw while disconnected` + ); + + filter.disconnect(); + + resolve(); + } + }; + + filter.onerror = event => { + browser.test.fail( + `(${num}): Got unexpected error event: ${filter.error}` + ); + }; + }, + verify(response) { + equal(response, PARTS.join(""), "Got expected final HTML"); + }, + }, + { + url: "slow_response.sjs", + task(filter, resolve, num) { + let encoder = new TextEncoder(); + let decoder = new TextDecoder("utf-8"); + + filter.onstop = event => { + browser.test.fail(`(${num}): Got unexpected onStop event while closed`); + }; + + browser.test.assertThrows( + () => { + filter.write(encoder.encode("Foo bar")); + }, + /.*/, + `(${num}): write() should throw prior to connection` + ); + + let n = 0; + filter.ondata = async event => { + n++; + + filter.write(event.data); + + browser.test.log( + `(${num}): Got part ${n}: ${JSON.stringify( + decoder.decode(event.data) + )}` + ); + + function checkState(state) { + browser.test.assertEq( + state, + filter.status, + `(${num}): Got expected status` + ); + } + if (n == 3) { + filter.close(); + + checkState("closed"); + + for (let method of ["suspend", "resume", "disconnect"]) { + browser.test.assertThrows( + () => { + filter[method](); + }, + /.*/, + `(${num}): ${method}() should throw while closed` + ); + } + + browser.test.assertThrows( + () => { + filter.write(encoder.encode("Foo bar")); + }, + /.*/, + `(${num}): write() should throw while closed` + ); + + filter.close(); + + resolve(); + } + }; + + filter.onerror = event => { + browser.test.fail( + `(${num}): Got unexpected error event: ${filter.error}` + ); + }; + }, + verify(response) { + equal(response, PARTS.slice(0, 3).join(""), "Got expected final HTML"); + }, + }, + { + url: "lorem.html.gz", + task(filter, resolve, num) { + let response = ""; + let decoder = new TextDecoder("utf-8"); + + filter.onstart = event => { + browser.test.log(`(${num}): Request start`); + }; + + filter.onstop = event => { + browser.test.assertEq( + "finishedtransferringdata", + filter.status, + `(${num}): Got expected onStop status` + ); + + filter.close(); + browser.test.assertEq( + "closed", + filter.status, + `Got expected closed status` + ); + + browser.test.assertEq( + JSON.stringify(PARTS.join("")), + JSON.stringify(response), + `(${num}): Got expected response` + ); + + resolve(); + }; + + filter.ondata = event => { + let str = decoder.decode(event.data, { stream: true }); + response += str; + + filter.write(event.data); + }; + + filter.onerror = event => { + browser.test.fail( + `(${num}): Got unexpected error event: ${filter.error}` + ); + }; + }, + verify(response) { + equal(response, PARTS.join(""), "Got expected final HTML"); + }, + }, + { + url: "multipart", + task(filter, resolve, num) { + filter.onstart = event => { + browser.test.log(`(${num}): Request start`); + }; + + filter.onstop = event => { + filter.disconnect(); + resolve(); + }; + + filter.ondata = event => { + filter.write(event.data); + }; + + filter.onerror = event => { + browser.test.fail( + `(${num}): Got unexpected error event: ${filter.error}` + ); + }; + }, + verify(response) { + equal( + response, + "--testingtesting\n" + PARTS.join("") + "--testingtesting--\n", + "Got expected final HTML" + ); + }, + }, + { + url: "multipart2", + task(filter, resolve, num) { + filter.onstart = event => { + browser.test.log(`(${num}): Request start`); + }; + + filter.onstop = event => { + filter.disconnect(); + resolve(); + }; + + filter.ondata = event => { + filter.write(event.data); + }; + + filter.onerror = event => { + browser.test.fail( + `(${num}): Got unexpected error event: ${filter.error}` + ); + }; + }, + verify(response) { + equal( + response, + "--testingtesting\n" + + PARTS.join("") + + "--testingtesting\n" + + PARTS.join("") + + "--testingtesting--\n", + "Got expected final HTML" + ); + }, + }, +]; + +function serializeTest(test, num) { + let url = `${test.url}?test_num=${num}`; + let task = ExtensionTestCommon.serializeFunction(test.task); + + return `{url: ${JSON.stringify(url)}, task: ${task}}`; +} + +add_task(async function () { + function background(TASKS) { + async function runTest(test, num, details) { + browser.test.log(`Running test #${num}: ${details.url}`); + + let filter = browser.webRequest.filterResponseData(details.requestId); + + try { + await new Promise(resolve => { + test.task(filter, resolve, num, details); + }); + } catch (e) { + browser.test.fail( + `Task #${num} threw an unexpected exception: ${e} :: ${e.stack}` + ); + } + + browser.test.log(`Finished test #${num}: ${details.url}`); + browser.test.sendMessage(`finished-${num}`); + } + + browser.webRequest.onBeforeRequest.addListener( + details => { + for (let [num, test] of TASKS.entries()) { + if (details.url.endsWith(test.url)) { + runTest(test, num, details); + break; + } + } + }, + { + urls: ["http://example.com/*?test_num=*"], + }, + ["blocking"] + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: ` + const PARTS = ${JSON.stringify(PARTS)}; + const TIMEOUT = ${TIMEOUT}; + + ${delay} + + (${background})([${TASKS.map(serializeTest)}]) + `, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + + async function runTest(test, num) { + let url = `${BASE_URL}/${test.url}?test_num=${num}`; + + let body = await ExtensionTestUtils.fetch(FETCH_ORIGIN, url); + + await extension.awaitMessage(`finished-${num}`); + + info(`Verifying test #${num}: ${url}`); + await test.verify(body); + } + + if (SEQUENTIAL) { + for (let [num, test] of TASKS.entries()) { + await runTest(test, num); + } + } else { + await Promise.all(TASKS.map(runTest)); + } + + await extension.unload(); +}); + +// Test that registering a listener for a cached response does not cause a crash. +add_task(async function test_cachedResponse() { + if (AppConstants.platform === "android") { + return; + } + Services.prefs.setBoolPref("network.http.rcwn.enabled", false); + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.webRequest.onHeadersReceived.addListener( + data => { + let filter = browser.webRequest.filterResponseData(data.requestId); + + filter.onstop = event => { + filter.close(); + }; + filter.ondata = event => { + filter.write(event.data); + }; + + if (data.fromCache) { + browser.test.sendMessage("from-cache"); + } + }, + { + urls: ["http://example.com/*/file_sample.html?r=*"], + }, + ["blocking"] + ); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + + let url = `${BASE_URL}/data/file_sample.html?r=${Math.random()}`; + await ExtensionTestUtils.fetch(FETCH_ORIGIN, url); + await ExtensionTestUtils.fetch(FETCH_ORIGIN, url); + await extension.awaitMessage("from-cache"); + + await extension.unload(); +}); + +// Test that finishing transferring data doesn't overwrite an existing closing/closed state. +add_task(async function test_late_close() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.webRequest.onBeforeRequest.addListener( + data => { + let filter = browser.webRequest.filterResponseData(data.requestId); + + filter.onstop = event => { + browser.test.fail("Should not receive onstop after close()"); + browser.test.assertEq( + "closed", + filter.status, + "Filter status should still be 'closed'" + ); + browser.test.assertThrows(() => { + filter.close(); + }); + }; + filter.ondata = event => { + filter.write(event.data); + filter.close(); + + browser.test.sendMessage(`done-${data.url}`); + }; + }, + { + urls: ["http://example.com/*/file_sample.html?*"], + }, + ["blocking"] + ); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + + // This issue involves a race, so several requests in parallel to increase + // the chances of triggering it. + let urls = []; + for (let i = 0; i < 32; i++) { + urls.push(`${BASE_URL}/data/file_sample.html?r=${Math.random()}`); + } + + await Promise.all( + urls.map(url => ExtensionTestUtils.fetch(FETCH_ORIGIN, url)) + ); + await Promise.all(urls.map(url => extension.awaitMessage(`done-${url}`))); + + await extension.unload(); +}); + +add_task(async function test_permissions() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.assertEq( + undefined, + browser.webRequest.filterResponseData, + "filterResponseData is undefined without blocking permissions" + ); + }, + + manifest: { + permissions: ["webRequest", "http://example.com/"], + }, + }); + + await extension.startup(); + await extension.unload(); +}); + +add_task(async function test_invalidId() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + let filter = browser.webRequest.filterResponseData("34159628"); + + await new Promise(resolve => { + filter.onerror = resolve; + }); + + browser.test.assertEq( + "Invalid request ID", + filter.error, + "Got expected error" + ); + + browser.test.notifyPass("invalid-request-id"); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("invalid-request-id"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_restrictedHeaders.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_restrictedHeaders.js new file mode 100644 index 0000000000..471bae0493 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_restrictedHeaders.js @@ -0,0 +1,252 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +const server = createHttpServer({ + hosts: ["example.net"], +}); +server.registerPathHandler("/test/response-header", (req, res) => { + let headerName; + let headerValue; + if (req.queryString) { + let params = new URLSearchParams(req.queryString); + headerName = params.get("name"); + headerValue = params.get("value"); + res.setHeader(headerName, headerValue, false); + res.setHeader("test", `${headerName}=${headerValue}`, false); + } + res.write(""); +}); + +const extensionData = { + useAddonManager: "temporary", + background() { + const { manifest_version } = browser.runtime.getManifest(); + let headerToSet = undefined; + browser.test.onMessage.addListener(function (msg, arg) { + if (msg !== "header-to-set") { + return; + } + headerToSet = arg; + browser.test.sendMessage("header-to-set:done"); + }); + browser.webRequest.onHeadersReceived.addListener( + e => { + browser.test.log(`onHeadersReceived ${e.requestId} ${e.url}`); + if (headerToSet === undefined) { + browser.test.fail( + "extension called before headerToSet option was set" + ); + } + if (typeof headerToSet?.name == "string") { + const existingHeader = e.responseHeaders.filter( + i => i.name.toLowerCase() === headerToSet.name + )[0]; + e.responseHeaders = e.responseHeaders.filter( + i => i.name.toLowerCase() != headerToSet.name + ); + // Omit the header if the value isn't set, change the header otherwise. + if (headerToSet.value != null) { + e.responseHeaders.push({ + name: headerToSet.name, + value: headerToSet.value, + }); + } + browser.test.log( + `Test Extension MV${manifest_version} (${browser.runtime.id}) sets responseHeader: "${headerToSet.name}"="${headerToSet.value}" (was originally set to "${existingHeader?.value})"` + ); + } + return { responseHeaders: e.responseHeaders }; + }, + { urls: ["*://example.net/test/*"] }, + ["blocking", "responseHeaders"] + ); + browser.webRequest.onCompleted.addListener( + e => { + browser.test.log(`onCompletedReceived ${e.requestId} ${e.url}`); + const responseHeaders = e.responseHeaders.filter( + i => i.name.toLowerCase() === headerToSet.name + ); + + browser.test.sendMessage( + "on-completed:response-headers", + responseHeaders + ); + }, + { urls: ["*://example.net/test/*"] }, + ["responseHeaders"] + ); + browser.test.sendMessage("bgpage:ready"); + }, +}; + +const extDataMV2 = { + ...extensionData, + manifest: { + manifest_version: 2, + permissions: ["webRequest", "webRequestBlocking", "*://example.net/test/*"], + }, +}; + +const extDataMV3 = { + ...extensionData, + manifest: { + manifest_version: 3, + permissions: ["webRequest", "webRequestBlocking"], + host_permissions: ["*://example.net/test/*"], + granted_host_permissions: true, + }, +}; + +add_setup(async () => { + await AddonTestUtils.promiseStartupManager(); +}); + +async function test_restricted_response_headers_changes({ + firstExtData, + secondExtData, + headerName, + firstExtHeaderChange, + secondExtHeaderChange, + siteHeaderValue, + expectedHeaderValue, +}) { + const ext1 = ExtensionTestUtils.loadExtension(firstExtData); + const ext2 = secondExtData && ExtensionTestUtils.loadExtension(secondExtData); + + await ext1.startup(); + await ext1.awaitMessage("bgpage:ready"); + + await ext2?.startup(); + await ext2?.awaitMessage("bgpage:ready"); + + ext1.sendMessage("header-to-set", { + name: headerName, + value: firstExtHeaderChange, + }); + await ext1.awaitMessage("header-to-set:done"); + ext2?.sendMessage("header-to-set", { + name: headerName, + value: secondExtHeaderChange, + }); + await ext2?.awaitMessage("header-to-set:done"); + + if (siteHeaderValue) { + await ExtensionTestUtils.fetch( + "http://example.net/", + `http://example.net/test/response-header?name=${headerName}&value=${siteHeaderValue}` + ); + } else { + await ExtensionTestUtils.fetch( + "http://example.net/", + "http://example.net/test/response-header" + ); + } + + const [finalSiteHeaders] = await Promise.all([ + ext1.awaitMessage("on-completed:response-headers"), + ext2?.awaitMessage("on-completed:response-headers"), + ]); + + Assert.deepEqual( + finalSiteHeaders, + expectedHeaderValue + ? [{ name: headerName, value: expectedHeaderValue }] + : [], + "Got the expected response header" + ); + + await ext1.unload(); + await ext2?.unload(); +} + +add_task(async function test_changes_to_restricted_response_headers() { + const testCases = [ + { + headerName: "cross-origin-embedder-policy", + siteHeaderValue: "require-corp", + firstExtHeaderChange: "credentialless", + secondExtHeaderChange: "unsafe-none", + }, + { + headerName: "cross-origin-opener-policy", + siteHeaderValue: "same-origin", + firstExtHeaderChange: "same-origin-allow-popups", + secondExtHeaderChange: "unsafe-none", + }, + { + headerName: "cross-origin-resource-policy", + siteHeaderValue: "same-origin", + firstExtHeaderChange: "same-site", + secondExtHeaderChange: "cross-origin", + }, + { + headerName: "x-frame-options", + siteHeaderValue: "deny", + firstExtHeaderChange: "sameorigin", + secondExtHeaderChange: "allow-from=http://example.com", + }, + { + headerName: "access-control-allow-credentials", + siteHeaderValue: "true", + firstExtHeaderChange: "false", + secondExtHeaderChange: "false", + }, + { + headerName: "access-control-allow-methods", + siteHeaderValue: "*", + firstExtHeaderChange: "", + secondExtHeaderChange: "GET", + }, + ]; + + for (const testCase of testCases) { + info( + `Test MV3 extension disallowed to change restricted header if already set by the website: "${testCase.headerName}"="${testCase.siteHeaderValue}` + ); + await test_restricted_response_headers_changes({ + ...testCase, + firstExtData: extDataMV3, + // Expect the value set by the server to be preserved. + expectedHeaderValue: testCase.siteHeaderValue, + }); + } + + for (const testCase of testCases) { + info( + `Test MV3 extension disallowed to change restricted header also if not set by the website: "${testCase.headerName}` + ); + await test_restricted_response_headers_changes({ + ...testCase, + siteHeaderValue: null, + firstExtData: extDataMV3, + // Expect the value set by the server to be preserved. + expectedHeaderValue: null, + }); + } + + for (const testCase of testCases) { + info( + `Test MV2 extension allowed to change restricted header if already set by the website: ${JSON.stringify( + testCase.siteHeader + )}` + ); + await test_restricted_response_headers_changes({ + ...testCase, + firstExtData: extDataMV3, + secondExtData: extDataMV2, + // Expect the value set by the server to be preserved. + expectedHeaderValue: testCase.secondExtHeaderChange, + }); + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_set_cookie.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_set_cookie.js new file mode 100644 index 0000000000..e40bc4f8b4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_set_cookie.js @@ -0,0 +1,308 @@ +"use strict"; + +const HOSTS = new Set(["example.com"]); + +const server = createHttpServer({ hosts: HOSTS }); + +server.registerDirectory("/data/", do_get_file("data")); + +server.registerPathHandler( + "/file_webrequestblocking_set_cookie.html", + (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Set-Cookie", "reqcookie=reqvalue", false); + response.write("<!DOCTYPE html><html></html>"); + } +); + +add_task(async function test_modifying_cookies_from_onHeadersReceived() { + async function background() { + /** + * Check that all the cookies described by `prefixes` are in the cookie jar. + * + * @param {Array.string} prefixes + * Zero or more prefixes, describing cookies that are expected to be set + * in the current cookie jar. Each prefix describes both a cookie + * name and corresponding value. For example, if the string "ext" + * is passed as an argument, then this function expects to see + * a cookie called "extcookie" and corresponding value of "extvalue". + */ + async function checkCookies(prefixes) { + const numPrefixes = prefixes.length; + const currentCookies = await browser.cookies.getAll({}); + browser.test.assertEq( + numPrefixes, + currentCookies.length, + `${numPrefixes} cookies were set` + ); + + for (let cookiePrefix of prefixes) { + let cookieName = `${cookiePrefix}cookie`; + let expectedCookieValue = `${cookiePrefix}value`; + let fetchedCookie = await browser.cookies.getAll({ name: cookieName }); + browser.test.assertEq( + 1, + fetchedCookie.length, + `Found 1 cookie with name "${cookieName}"` + ); + browser.test.assertEq( + expectedCookieValue, + fetchedCookie[0] && fetchedCookie[0].value, + `Cookie "${cookieName}" has expected value of "${expectedCookieValue}"` + ); + } + } + + function awaitMessage(expectedMsg) { + return new Promise(resolve => { + browser.test.onMessage.addListener(function listener(msg) { + if (msg === expectedMsg) { + browser.test.onMessage.removeListener(listener); + resolve(); + } + }); + }); + } + + /** + * Opens the given test file as a content page. + * + * @param {string} filename + * The name of a html file relative to the test server root. + * + * @returns {Promise} + */ + function openContentPage(filename) { + let promise = awaitMessage("url-loaded"); + browser.test.sendMessage( + "load-url", + `http://example.com/${filename}?nocache=${Math.random()}` + ); + return promise; + } + + /** + * Tests that expected cookies are in the cookie jar after opening a file. + * + * @param {string} filename + * The name of a html file in the + * "toolkit/components/extensions/test/mochitest" directory. + * @param {?Array.string} prefixes + * Zero or more prefixes, describing cookies that are expected to be set + * in the current cookie jar. Each prefix describes both a cookie + * name and corresponding value. For example, if the string "ext" + * is passed as an argument, then this function expects to see + * a cookie called "extcookie" and corresponding value of "extvalue". + * If undefined, then no checks are automatically performed, and the + * caller should provide a callback to perform the checks. + * @param {?Function} callback + * An optional async callback function that, if provided, will be called + * with an object that contains windowId and tabId parameters. + * Callers can use this callback to apply extra tests about the state of + * the cookie jar, or to query the state of the opened page. + */ + async function testCookiesWithFile(filename, prefixes, callback) { + await browser.browsingData.removeCookies({}); + await openContentPage(filename); + + if (prefixes !== undefined) { + await checkCookies(prefixes); + } + + if (callback !== undefined) { + await callback(); + } + let promise = awaitMessage("url-unloaded"); + browser.test.sendMessage("unload-url"); + await promise; + } + + const filter = { + urls: ["<all_urls>"], + types: ["main_frame", "sub_frame"], + }; + + const headersReceivedInfoSpec = ["blocking", "responseHeaders"]; + + const onHeadersReceived = details => { + details.responseHeaders.push({ + name: "Set-Cookie", + value: "extcookie=extvalue", + }); + + return { + responseHeaders: details.responseHeaders, + }; + }; + browser.webRequest.onHeadersReceived.addListener( + onHeadersReceived, + filter, + headersReceivedInfoSpec + ); + + // First, perform a request that should not set any cookies, and check + // that the cookie the extension sets is the only cookie in the + // cookie jar. + await testCookiesWithFile("data/file_sample.html", ["ext"]); + + // Next, perform a request that will set on cookie (reqcookie=reqvalue) + // and check that two cookies wind up in the cookie jar (the request + // set cookie, and the extension set cookie). + await testCookiesWithFile("file_webrequestblocking_set_cookie.html", [ + "ext", + "req", + ]); + + // Third, register another onHeadersReceived handler that also + // sets a cookie (thirdcookie=thirdvalue), to make sure modifications from + // multiple onHeadersReceived listeners are merged correctly. + const thirdOnHeadersRecievedListener = details => { + details.responseHeaders.push({ + name: "Set-Cookie", + value: "thirdcookie=thirdvalue", + }); + + browser.test.log(JSON.stringify(details.responseHeaders)); + + return { + responseHeaders: details.responseHeaders, + }; + }; + browser.webRequest.onHeadersReceived.addListener( + thirdOnHeadersRecievedListener, + filter, + headersReceivedInfoSpec + ); + await testCookiesWithFile("file_webrequestblocking_set_cookie.html", [ + "ext", + "req", + "third", + ]); + browser.webRequest.onHeadersReceived.removeListener(onHeadersReceived); + browser.webRequest.onHeadersReceived.removeListener( + thirdOnHeadersRecievedListener + ); + + // Fourth, test to make sure that extensions can remove cookies + // using onHeadersReceived too, by 1. making a request that + // sets a cookie (reqcookie=reqvalue), 2. having the extension remove + // that cookie by removing that header, and 3. adding a new cookie + // (extcookie=extvalue). + const fourthOnHeadersRecievedListener = details => { + // Remove the cookie set by the request (reqcookie=reqvalue). + const newHeaders = details.responseHeaders.filter( + cookie => cookie.name !== "set-cookie" + ); + + // And then add a new cookie in its place (extcookie=extvalue). + newHeaders.push({ + name: "Set-Cookie", + value: "extcookie=extvalue", + }); + + return { + responseHeaders: newHeaders, + }; + }; + browser.webRequest.onHeadersReceived.addListener( + fourthOnHeadersRecievedListener, + filter, + headersReceivedInfoSpec + ); + await testCookiesWithFile("file_webrequestblocking_set_cookie.html", [ + "ext", + ]); + browser.webRequest.onHeadersReceived.removeListener( + fourthOnHeadersRecievedListener + ); + + // Fifth, check that extensions are able to overwrite headers set by + // pages. In this test, make a request that will set "reqcookie=reqvalue", + // and add a listener that sets "reqcookie=changedvalue". Check + // to make sure that the cookie jar contains "reqcookie=changedvalue" + // and not "reqcookie=reqvalue". + const fifthOnHeadersRecievedListener = details => { + // Remove the cookie set by the request (reqcookie=reqvalue). + const newHeaders = details.responseHeaders.filter( + cookie => cookie.name !== "set-cookie" + ); + + // And then add a new cookie in its place (reqcookie=changedvalue). + newHeaders.push({ + name: "Set-Cookie", + value: "reqcookie=changedvalue", + }); + + return { + responseHeaders: newHeaders, + }; + }; + browser.webRequest.onHeadersReceived.addListener( + fifthOnHeadersRecievedListener, + filter, + headersReceivedInfoSpec + ); + + await testCookiesWithFile( + "file_webrequestblocking_set_cookie.html", + undefined, + async () => { + const currentCookies = await browser.cookies.getAll({}); + browser.test.assertEq(1, currentCookies.length, `1 cookie was set`); + + const cookieName = "reqcookie"; + const expectedCookieValue = "changedvalue"; + const fetchedCookie = await browser.cookies.getAll({ + name: cookieName, + }); + + browser.test.assertEq( + 1, + fetchedCookie.length, + `Found 1 cookie with name "${cookieName}"` + ); + browser.test.assertEq( + expectedCookieValue, + fetchedCookie[0] && fetchedCookie[0].value, + `Cookie "${cookieName}" has expected value of "${expectedCookieValue}"` + ); + } + ); + browser.webRequest.onHeadersReceived.removeListener( + fifthOnHeadersRecievedListener + ); + + browser.test.notifyPass("cookie modifying extension"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "browsingData", + "cookies", + "webNavigation", + "webRequest", + "webRequestBlocking", + "<all_urls>", + ], + }, + background, + }); + + let contentPage = null; + extension.onMessage("load-url", async url => { + ok(!contentPage, "Should have no content page to unload"); + contentPage = await ExtensionTestUtils.loadContentPage(url); + extension.sendMessage("url-loaded"); + }); + extension.onMessage("unload-url", async () => { + await contentPage.close(); + contentPage = null; + extension.sendMessage("url-unloaded"); + }); + + await extension.startup(); + await extension.awaitFinish("cookie modifying extension"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup.js new file mode 100644 index 0000000000..616dc1fb50 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup.js @@ -0,0 +1,751 @@ +"use strict"; + +// Delay loading until createAppInfo is called and setup. +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", +}); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +// The app and platform version here should be >= of the version set in the extensions.webExtensionsMinPlatformVersion preference, +// otherwise test_persistent_listener_after_staged_update will fail because no compatible updates will be found. +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +let { promiseShutdownManager, promiseStartupManager, promiseRestartManager } = + AddonTestUtils; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +let scopes = AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION; +Services.prefs.setIntPref("extensions.enabledScopes", scopes); + +function trackEvents(wrapper) { + let events = new Map(); + for (let event of ["background-script-event", "start-background-script"]) { + events.set(event, false); + wrapper.extension.once(event, () => events.set(event, true)); + } + return events; +} + +/** + * That that we get the expected events + * + * @param {Extension} extension + * @param {Map} events + * @param {object} expect + * @param {boolean} expect.background delayed startup event expected + * @param {boolean} expect.started background has already started + * @param {boolean} expect.delayedStart startup is delayed, notify start and + * expect the starting event + * @param {boolean} expect.request wait for the request event + */ +async function testPersistentRequestStartup(extension, events, expect = {}) { + equal( + events.get("background-script-event"), + !!expect.background, + "Should have gotten a background script event" + ); + equal( + events.get("start-background-script"), + !!expect.started, + "Background script should be started" + ); + + if (!expect.started) { + AddonTestUtils.notifyEarlyStartup(); + await ExtensionParent.browserPaintedPromise; + + equal( + events.get("start-background-script"), + !!expect.delayedStart, + "Should have gotten start-background-script event" + ); + } + + if (expect.request) { + await extension.awaitMessage("got-request"); + ok(true, "Background page loaded and received webRequest event"); + } +} + +// Test that a non-blocking listener does not start the background on +// startup, but that it does work after startup. +add_task(async function test_nonblocking() { + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["webRequest", "http://example.com/"], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("got-request"); + }, + { urls: ["http://example.com/data/file_sample.html"] } + ); + browser.test.sendMessage("ready"); + }, + }); + + // First install runs background immediately, this sets persistent listeners + await extension.startup(); + await extension.awaitMessage("ready"); + + // Restart to get APP_STARTUP, the background should not start + await promiseRestartManager({ lateStartup: false }); + await extension.awaitStartup(); + assertPersistentListeners(extension, "webRequest", "onBeforeRequest", { + primed: false, + }); + + // Test an early startup event + let events = trackEvents(extension); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + + await testPersistentRequestStartup(extension, events, { + background: false, + delayedStart: false, + request: false, + }); + + AddonTestUtils.notifyLateStartup(); + await extension.awaitMessage("ready"); + assertPersistentListeners(extension, "webRequest", "onBeforeRequest", { + primed: false, + }); + + // Test an event after startup + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + + await testPersistentRequestStartup(extension, events, { + background: false, + started: true, + request: true, + }); + + await extension.unload(); + + await promiseShutdownManager(); +}); + +// Test that a non-blocking listener does not start the background on +// startup, but that it does work after startup. +add_task(async function test_eventpage_nonblocking() { + Services.prefs.setBoolPref("extensions.eventPages.enabled", true); + await promiseStartupManager(); + + let id = "event-nonblocking@test"; + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id } }, + permissions: ["webRequest", "http://example.com/"], + background: { persistent: false }, + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("got-request"); + }, + { urls: ["http://example.com/data/file_sample.html"] } + ); + }, + }); + + // First install runs background immediately, this sets persistent listeners + await extension.startup(); + + // Restart to get APP_STARTUP, the background should not start + await promiseRestartManager({ lateStartup: false }); + await extension.awaitStartup(); + assertPersistentListeners(extension, "webRequest", "onBeforeRequest", { + primed: false, + }); + + // Test an early startup event + let events = trackEvents(extension); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + + await testPersistentRequestStartup(extension, events); + + await AddonTestUtils.notifyLateStartup(); + // After late startup, event page listeners should be primed. + assertPersistentListeners(extension, "webRequest", "onBeforeRequest", { + primed: true, + }); + + // We should not have seen any events yet. + await testPersistentRequestStartup(extension, events); + + // Test an event after startup + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + + // Now the event page should be started and we'll see the request. + await testPersistentRequestStartup(extension, events, { + background: true, + started: true, + request: true, + }); + + await extension.unload(); + + await promiseShutdownManager(); + Services.prefs.setBoolPref("extensions.eventPages.enabled", false); +}); + +// Tests that filters are handled properly: if we have a blocking listener +// with a filter, a request that does not match the filter does not get +// suspended and does not start the background page. +add_task(async function test_persistent_blocking() { + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "http://test1.example.com/", + ], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.fail("Listener should not have been called"); + }, + { urls: ["http://test1.example.com/*"] }, + ["blocking"] + ); + }, + }); + + await extension.startup(); + assertPersistentListeners(extension, "webRequest", "onBeforeRequest", { + primed: false, + }); + + await promiseRestartManager({ lateStartup: false }); + await extension.awaitStartup(); + assertPersistentListeners(extension, "webRequest", "onBeforeRequest", { + primed: true, + }); + + let events = trackEvents(extension); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + + await testPersistentRequestStartup(extension, events, { + background: false, + delayedStart: false, + request: false, + }); + + AddonTestUtils.notifyLateStartup(); + + await extension.unload(); + await promiseShutdownManager(); +}); + +// Tests that moving permission to optional retains permission and that the +// persistent listeners are used as expected. +add_task(async function test_persistent_listener_after_sideload_upgrade() { + let id = "permission-sideload-upgrade@test"; + let extensionData = { + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { gecko: { id } }, + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("got-request"); + }, + { urls: ["http://example.com/data/file_sample.html"] }, + ["blocking"] + ); + }, + }; + let xpi = AddonTestUtils.createTempWebExtensionFile(extensionData); + + let extension = ExtensionTestUtils.expectExtension(id); + await AddonTestUtils.manuallyInstall(xpi); + await promiseStartupManager(); + await extension.awaitStartup(); + // Sideload install does not prime listeners + assertPersistentListeners(extension, "webRequest", "onBeforeRequest", { + primed: false, + }); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + await extension.awaitMessage("got-request"); + + await promiseShutdownManager(); + + // Prepare a sideload update for the extension. + extensionData.manifest.version = "2.0"; + extensionData.manifest.permissions = ["http://example.com/"]; + extensionData.manifest.optional_permissions = [ + "webRequest", + "webRequestBlocking", + ]; + xpi = AddonTestUtils.createTempWebExtensionFile(extensionData); + await AddonTestUtils.manuallyInstall(xpi); + + await promiseStartupManager(); + await extension.awaitStartup(); + // Upgrades start the background when the extension is loaded, so + // primed listeners are cleared already and background events are + // already completed. + assertPersistentListeners(extension, "webRequest", "onBeforeRequest", { + primed: false, + persisted: true, + }); + + await extension.unload(); + await promiseShutdownManager(); +}); + +// Utility to install builtin addon +async function installBuiltinExtension(extensionData) { + let xpi = await AddonTestUtils.createTempWebExtensionFile(extensionData); + + // The built-in location requires a resource: URL that maps to a + // jar: or file: URL. This would typically be something bundled + // into omni.ja but for testing we just use a temp file. + let base = Services.io.newURI(`jar:file:${xpi.path}!/`); + let resProto = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + resProto.setSubstitution("ext-test", base); + return AddonManager.installBuiltinAddon("resource://ext-test/"); +} + +function promisePostponeInstall(install) { + return new Promise((resolve, reject) => { + let listener = { + onInstallFailed: () => { + install.removeListener(listener); + reject(new Error("extension installation should not have failed")); + }, + onInstallEnded: () => { + install.removeListener(listener); + reject( + new Error( + `extension installation should not have ended for ${install.addon.id}` + ) + ); + }, + onInstallPostponed: () => { + install.removeListener(listener); + resolve(); + }, + }; + + install.addListener(listener); + install.install(); + }); +} + +// Tests that moving permission to optional retains permission and that the +// persistent listeners are used as expected. +add_task( + async function test_persistent_listener_after_builtin_location_upgrade() { + let id = "permission-builtin-upgrade@test"; + let extensionData = { + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { gecko: { id } }, + permissions: [ + "webRequest", + "webRequestBlocking", + "http://example.com/", + ], + }, + + async background() { + browser.runtime.onUpdateAvailable.addListener(() => { + browser.test.sendMessage("postponed"); + }); + + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("got-request"); + }, + { urls: ["http://example.com/data/file_sample.html"] }, + ["blocking"] + ); + }, + }; + await promiseStartupManager(); + // If we use an extension wrapper via ExtensionTestUtils.expectExtension + // it will continue to handle messages even after the update, resulting + // in errors when it receives additional messages without any awaitMessage. + let promiseExtension = AddonTestUtils.promiseWebExtensionStartup(id); + await installBuiltinExtension(extensionData); + let extv1 = await promiseExtension; + assertPersistentListeners( + { extension: extv1 }, + "webRequest", + "onBeforeRequest", + { + primed: false, + } + ); + + // Prepare an update for the extension. + extensionData.manifest.version = "2.0"; + let xpi = AddonTestUtils.createTempWebExtensionFile(extensionData); + let install = await AddonManager.getInstallForFile(xpi); + + // Install the update and wait for the onUpdateAvailable event to complete. + let promiseUpdate = new Promise(resolve => + extv1.once("test-message", (kind, msg) => { + if (msg == "postponed") { + resolve(); + } + }) + ); + await Promise.all([promisePostponeInstall(install), promiseUpdate]); + await promiseShutdownManager(); + + // restarting allows upgrade to proceed + let extension = ExtensionTestUtils.expectExtension(id); + await promiseStartupManager(); + await extension.awaitStartup(); + // Upgrades start the background when the extension is loaded, so + // primed listeners are cleared already and background events are + // already completed. + assertPersistentListeners(extension, "webRequest", "onBeforeRequest", { + primed: false, + persisted: true, + }); + + await extension.unload(); + + // remove the builtin addon which will have restarted now. + let addon = await AddonManager.getAddonByID(id); + await addon.uninstall(); + + await promiseShutdownManager(); + } +); + +// Tests that moving permission to optional during a staged upgrade retains permission +// and that the persistent listeners are used as expected. +add_task(async function test_persistent_listener_after_staged_upgrade() { + AddonManager.checkUpdateSecurity = false; + let id = "persistent-staged-upgrade@test"; + + // register an update file. + AddonTestUtils.registerJSON(server, "/test_update.json", { + addons: { + "persistent-staged-upgrade@test": { + updates: [ + { + version: "2.0", + update_link: + "http://example.com/addons/test_settings_staged_restart.xpi", + }, + ], + }, + }, + }); + + let extensionData = { + useAddonManager: "permanent", + manifest: { + version: "2.0", + browser_specific_settings: { + gecko: { id, update_url: `http://example.com/test_update.json` }, + }, + permissions: ["http://example.com/"], + optional_permissions: ["webRequest", "webRequestBlocking"], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("got-request"); + }, + { urls: ["http://example.com/data/file_sample.html"] }, + ["blocking"] + ); + browser.webRequest.onSendHeaders.addListener( + details => { + browser.test.sendMessage("got-sendheaders"); + }, + { urls: ["http://example.com/data/file_sample.html"] } + ); + // Force a staged updated. + browser.runtime.onUpdateAvailable.addListener(async details => { + if (details && details.version) { + // This should be the version of the pending update. + browser.test.assertEq("2.0", details.version, "correct version"); + browser.test.sendMessage("delay"); + } + }); + }, + }; + + // Prepare the update first. + server.registerFile( + `/addons/test_settings_staged_restart.xpi`, + AddonTestUtils.createTempWebExtensionFile(extensionData) + ); + + // Prepare the extension that will be updated. + extensionData.manifest.version = "1.0"; + extensionData.manifest.permissions = [ + "webRequest", + "webRequestBlocking", + "http://example.com/", + ]; + delete extensionData.manifest.optional_permissions; + extensionData.background = function () { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("got-request"); + }, + { urls: ["http://example.com/data/file_sample.html"] }, + ["blocking"] + ); + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + browser.test.sendMessage("got-beforesendheaders"); + }, + { urls: ["http://example.com/data/file_sample.html"] } + ); + browser.webRequest.onSendHeaders.addListener( + details => { + browser.test.sendMessage("got-sendheaders"); + }, + { urls: ["http://example.com/data/file_sample.html"] } + ); + // Force a staged updated. + browser.runtime.onUpdateAvailable.addListener(async details => { + if (details && details.version) { + // This should be the version of the pending update. + browser.test.assertEq("2.0", details.version, "correct version"); + browser.test.sendMessage("delay"); + } + }); + }; + + await promiseStartupManager(); + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + assertPersistentListeners(extension, "webRequest", "onBeforeRequest", { + primed: false, + }); + assertPersistentListeners(extension, "webRequest", "onBeforeSendHeaders", { + primed: false, + }); + assertPersistentListeners(extension, "webRequest", "onSendHeaders", { + primed: false, + }); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + await extension.awaitMessage("got-request"); + await extension.awaitMessage("got-beforesendheaders"); + await extension.awaitMessage("got-sendheaders"); + ok(true, "Initial version received webRequest event"); + + let addon = await AddonManager.getAddonByID(id); + Assert.equal(addon.version, "1.0", "1.0 is loaded"); + + let update = await AddonTestUtils.promiseFindAddonUpdates(addon); + let install = update.updateAvailable; + Assert.ok(install, `install is available ${update.error}`); + + await AddonTestUtils.promiseCompleteAllInstalls([install]); + + Assert.equal( + install.state, + AddonManager.STATE_POSTPONED, + "update is staged for install" + ); + await extension.awaitMessage("delay"); + + await promiseShutdownManager(); + + // restarting allows upgrade to proceed + await promiseStartupManager(); + await extension.awaitStartup(); + + // Upgrades start the background when the extension is loaded, so + // primed listeners are cleared already and background events are + // already completed. + assertPersistentListeners(extension, "webRequest", "onBeforeRequest", { + primed: false, + persisted: true, + }); + // this was removed in the upgrade background, should not be persisted. + assertPersistentListeners(extension, "webRequest", "onBeforeSendHeaders", { + primed: false, + persisted: false, + }); + assertPersistentListeners(extension, "webRequest", "onSendHeaders", { + primed: false, + persisted: true, + }); + + await extension.unload(); + await promiseShutdownManager(); + AddonManager.checkUpdateSecurity = true; +}); + +// Tests that removing the permission releases the persistent listener. +add_task(async function test_persistent_listener_after_permission_removal() { + AddonManager.checkUpdateSecurity = false; + let id = "persistent-staged-remove@test"; + + // register an update file. + AddonTestUtils.registerJSON(server, "/test_remove.json", { + addons: { + "persistent-staged-remove@test": { + updates: [ + { + version: "2.0", + update_link: + "http://example.com/addons/test_settings_staged_remove.xpi", + }, + ], + }, + }, + }); + + let extensionData = { + useAddonManager: "permanent", + manifest: { + version: "2.0", + browser_specific_settings: { + gecko: { id, update_url: `http://example.com/test_remove.json` }, + }, + permissions: ["tabs", "http://example.com/"], + }, + + background() { + browser.test.sendMessage("loaded"); + }, + }; + + // Prepare the update first. + server.registerFile( + `/addons/test_settings_staged_remove.xpi`, + AddonTestUtils.createTempWebExtensionFile(extensionData) + ); + + await promiseStartupManager(); + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { id, update_url: `http://example.com/test_remove.json` }, + }, + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("got-request"); + }, + { urls: ["http://example.com/data/file_sample.html"] }, + ["blocking"] + ); + // Force a staged updated. + browser.runtime.onUpdateAvailable.addListener(async details => { + if (details && details.version) { + // This should be the version of the pending update. + browser.test.assertEq("2.0", details.version, "correct version"); + browser.test.sendMessage("delay"); + } + }); + }, + }); + + await extension.startup(); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + await extension.awaitMessage("got-request"); + ok(true, "Initial version received webRequest event"); + + let addon = await AddonManager.getAddonByID(id); + Assert.equal(addon.version, "1.0", "1.0 is loaded"); + + let update = await AddonTestUtils.promiseFindAddonUpdates(addon); + let install = update.updateAvailable; + Assert.ok(install, `install is available ${update.error}`); + + await AddonTestUtils.promiseCompleteAllInstalls([install]); + + Assert.equal( + install.state, + AddonManager.STATE_POSTPONED, + "update is staged for install" + ); + await extension.awaitMessage("delay"); + + await promiseShutdownManager(); + + // restarting allows upgrade to proceed + await promiseStartupManager({ lateStartup: false }); + await extension.awaitStartup(); + await extension.awaitMessage("loaded"); + + // Upgrades start the background when the extension is loaded, so + // primed listeners are cleared already and background events are + // already completed. + assertPersistentListeners(extension, "webRequest", "onBeforeRequest", { + primed: false, + persisted: false, + }); + + await extension.unload(); + await promiseShutdownManager(); + AddonManager.checkUpdateSecurity = true; +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup_StreamFilter.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup_StreamFilter.js new file mode 100644 index 0000000000..4972719b43 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup_StreamFilter.js @@ -0,0 +1,76 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +let { promiseRestartManager, promiseShutdownManager, promiseStartupManager } = + AddonTestUtils; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +// Test that a blocking listener that uses filterResponseData() works +// properly (i.e., that the delayed call to registerTraceableChannel +// works properly). +add_task(async function test_StreamFilter_at_restart() { + const DATA = `<!DOCTYPE html> +<html> +<body> + <h1>This is a modified page</h1> +</body> +</html>`; + + function background(data) { + browser.webRequest.onBeforeRequest.addListener( + details => { + let filter = browser.webRequest.filterResponseData(details.requestId); + filter.onstop = () => { + let encoded = new TextEncoder().encode(data); + filter.write(encoded); + filter.close(); + }; + }, + { urls: ["http://example.com/data/file_sample.html"] }, + ["blocking"] + ); + } + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + + background: `(${background})(${uneval(DATA)})`, + }); + + await extension.startup(); + + await promiseRestartManager(); + await extension.awaitStartup(); + + let dataPromise = ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + + Services.obs.notifyObservers(null, "browser-delayed-startup-finished"); + let data = await dataPromise; + + equal( + data, + DATA, + "Stream filter was properly installed for a load during startup" + ); + + await extension.unload(); + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_style_cache.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_style_cache.js new file mode 100644 index 0000000000..296bee3685 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_style_cache.js @@ -0,0 +1,49 @@ +"use strict"; + +const BASE = "http://example.com/data/"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_stylesheet_cache() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + background() { + const SHEET_URI = "http://example.com/data/file_stylesheet_cache.css"; + let firstFound = false; + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertEq( + details.url, + firstFound ? SHEET_URI + "?2" : SHEET_URI + ); + firstFound = true; + browser.test.sendMessage("stylesheet found"); + }, + { urls: ["<all_urls>"], types: ["stylesheet"] }, + ["blocking"] + ); + }, + }); + + await extension.startup(); + + let cp = await ExtensionTestUtils.loadContentPage( + BASE + "file_stylesheet_cache.html" + ); + + await extension.awaitMessage("stylesheet found"); + + // Need to use the same ContentPage so that the remote process the page ends + // up in is the same. + await cp.loadURL(BASE + "file_stylesheet_cache_2.html"); + + await extension.awaitMessage("stylesheet found"); + + await cp.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_suspend.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_suspend.js new file mode 100644 index 0000000000..f8116aced0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_suspend.js @@ -0,0 +1,289 @@ +"use strict"; + +const HOSTS = new Set(["example.com"]); + +const server = createHttpServer({ hosts: HOSTS }); + +const BASE_URL = "http://example.com"; +const FETCH_ORIGIN = "http://example.com/dummy"; + +server.registerPathHandler("/return_headers.sjs", (request, response) => { + response.setHeader("Content-Type", "text/plain", false); + + let headers = {}; + for (let { data: header } of request.headers) { + headers[header.toLowerCase()] = request.getHeader(header); + } + + response.write(JSON.stringify(headers)); +}); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +add_task(async function test_suspend() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", `${BASE_URL}/`], + }, + + background() { + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + // Make sure that returning undefined or a promise that resolves to + // undefined does not break later handlers. + }, + { urls: ["<all_urls>"] }, + ["blocking", "requestHeaders"] + ); + + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + return Promise.resolve(); + }, + { urls: ["<all_urls>"] }, + ["blocking", "requestHeaders"] + ); + + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + let requestHeaders = details.requestHeaders.concat({ + name: "Foo", + value: "Bar", + }); + + return new Promise(resolve => { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(resolve, 500); + }).then(() => { + return { requestHeaders }; + }); + }, + { urls: ["<all_urls>"] }, + ["blocking", "requestHeaders"] + ); + }, + }); + + await extension.startup(); + + let headers = JSON.parse( + await ExtensionTestUtils.fetch( + FETCH_ORIGIN, + `${BASE_URL}/return_headers.sjs` + ) + ); + + equal( + headers.foo, + "Bar", + "Request header was correctly set on suspended request" + ); + + await extension.unload(); +}); + +// Test that requests that were canceled while suspended for a blocking +// listener are correctly resumed. +add_task(async function test_error_resume() { + let observer = channel => { + if ( + channel instanceof Ci.nsIHttpChannel && + channel.URI.spec === "http://example.com/dummy" + ) { + Services.obs.removeObserver(observer, "http-on-before-connect"); + + // Wait until the next tick to make sure this runs after WebRequest observers. + Promise.resolve().then(() => { + channel.cancel(Cr.NS_BINDING_ABORTED); + }); + } + }; + + Services.obs.addObserver(observer, "http-on-before-connect"); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", `${BASE_URL}/`], + }, + + background() { + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + browser.test.log(`onBeforeSendHeaders({url: ${details.url}})`); + + if (details.url === "http://example.com/dummy") { + browser.test.sendMessage("got-before-send-headers"); + } + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + + browser.webRequest.onErrorOccurred.addListener( + details => { + browser.test.log(`onErrorOccurred({url: ${details.url}})`); + + if (details.url === "http://example.com/dummy") { + browser.test.sendMessage("got-error-occurred"); + } + }, + { urls: ["<all_urls>"] } + ); + }, + }); + + await extension.startup(); + + try { + await ExtensionTestUtils.fetch(FETCH_ORIGIN, `${BASE_URL}/dummy`); + ok(false, "Fetch should have failed."); + } catch (e) { + ok(true, "Got expected error."); + } + + await extension.awaitMessage("got-before-send-headers"); + await extension.awaitMessage("got-error-occurred"); + + // Wait for the next tick so the onErrorRecurred response can be + // processed before shutting down the extension. + await new Promise(resolve => executeSoon(resolve)); + + await extension.unload(); +}); + +// Test that response header modifications take effect before onStartRequest fires. +add_task(async function test_set_responseHeaders() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + + background() { + browser.webRequest.onHeadersReceived.addListener( + details => { + browser.test.log(`onHeadersReceived({url: ${details.url}})`); + + details.responseHeaders.push({ name: "foo", value: "bar" }); + + return { responseHeaders: details.responseHeaders }; + }, + { urls: ["http://example.com/?modify_headers"] }, + ["blocking", "responseHeaders"] + ); + }, + }); + + await extension.startup(); + + await new Promise(resolve => setTimeout(resolve, 0)); + + let resolveHeaderPromise; + let headerPromise = new Promise(resolve => { + resolveHeaderPromise = resolve; + }); + { + let ssm = Services.scriptSecurityManager; + + let channel = NetUtil.newChannel({ + uri: "http://example.com/?modify_headers", + loadingPrincipal: + ssm.createContentPrincipalFromOrigin("http://example.com"), + contentPolicyType: Ci.nsIContentPolicy.TYPE_XMLHTTPREQUEST, + securityFlags: Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + }); + + channel.asyncOpen({ + QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]), + + onStartRequest(request) { + request.QueryInterface(Ci.nsIHttpChannel); + + try { + resolveHeaderPromise(request.getResponseHeader("foo")); + } catch (e) { + resolveHeaderPromise(null); + } + request.cancel(Cr.NS_BINDING_ABORTED); + }, + + onStopRequest() {}, + + onDataAvailable() { + throw new Components.Exception("", Cr.NS_ERROR_FAILURE); + }, + }); + } + + let headerValue = await headerPromise; + equal(headerValue, "bar", "Expected Foo header value"); + + await extension.unload(); +}); + +// Test that exceptions raised from a blocking webRequest listener that returns +// a promise are logged as expected. +add_task(async function test_logged_error_on_promise_result() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", `${BASE_URL}/`], + }, + + background() { + async function onBeforeRequest() { + throw new Error("Expected webRequest exception from a promise result"); + } + + let exceptionRaised = false; + + browser.webRequest.onBeforeRequest.addListener( + () => { + if (exceptionRaised) { + return; + } + + // We only need to raise the exception once. + exceptionRaised = true; + return onBeforeRequest(); + }, + { + urls: ["http://example.com/*"], + types: ["main_frame"], + }, + ["blocking"] + ); + + browser.webRequest.onBeforeRequest.addListener( + () => { + browser.test.sendMessage("web-request-event-received"); + }, + { + urls: ["http://example.com/*"], + types: ["main_frame"], + }, + ["blocking"] + ); + }, + }); + + let { messages } = await promiseConsoleOutput(async () => { + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/dummy` + ); + await extension.awaitMessage("web-request-event-received"); + await contentPage.close(); + }); + + ok( + messages.some(msg => + /Expected webRequest exception from a promise result/.test(msg.message) + ), + "Got expected console message" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_urlclassification.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_urlclassification.js new file mode 100644 index 0000000000..de2d059d96 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_urlclassification.js @@ -0,0 +1,45 @@ +"use strict"; + +const { Schemas } = ChromeUtils.importESModule( + "resource://gre/modules/Schemas.sys.mjs" +); + +/** + * If this test fails, likely nsIClassifiedChannel has added or changed a + * CLASSIFIED_* flag. Those changes must be in sync with + * ChannelWrapper.webidl/cpp and the web_request.json schema file. + */ +add_task(async function test_webrequest_url_classification_enum() { + // The startupCache is removed whenever the buildid changes by code that runs + // during Firefox startup but not during xpcshell startup, remove it by hand + // before running this test to avoid failures with --conditioned-profile + let file = PathUtils.join( + Services.dirsvc.get("ProfLD", Ci.nsIFile).path, + "startupCache", + "webext.sc.lz4" + ); + await IOUtils.remove(file, { ignoreAbsent: true }); + + // use normalizeManifest to get the schema loaded. + await ExtensionTestUtils.normalizeManifest({ permissions: ["webRequest"] }); + + let ns = Schemas.getNamespace("webRequest"); + let schema_enum = ns.get("UrlClassificationFlags").enumeration; + ok( + !!schema_enum.length, + `UrlClassificationFlags: ${JSON.stringify(schema_enum)}` + ); + + let prefix = /^(?:CLASSIFIED_)/; + let entries = 0; + for (let c of Object.keys(Ci.nsIClassifiedChannel).filter(name => + prefix.test(name) + )) { + let entry = c.replace(prefix, "").toLowerCase(); + if (!entry.startsWith("socialtracking")) { + ok(schema_enum.includes(entry), `schema ${entry} is in IDL`); + entries++; + } + } + equal(schema_enum.length, entries, "same number of entries"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_userContextId.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_userContextId.js new file mode 100644 index 0000000000..9710aa5990 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_userContextId.js @@ -0,0 +1,41 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +add_task(async function test_userContextId_webrequest() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener( + async details => { + browser.test.assertEq( + details.cookieStoreId, + "firefox-container-2", + "cookieStoreId is set" + ); + browser.test.notifyPass("webRequest"); + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + }, + }); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy", + { userContextId: 2 } + ); + await extension.awaitFinish("webRequest"); + + await extension.unload(); + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource.js new file mode 100644 index 0000000000..35b713e59b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource.js @@ -0,0 +1,95 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +add_task(async function test_webRequest_viewsource() { + function background(serverPort) { + browser.proxy.onRequest.addListener( + details => { + if (details.url === `http://example.com:${serverPort}/dummy`) { + browser.test.assertTrue( + true, + "viewsource protocol worked in proxy request" + ); + browser.test.sendMessage("proxied"); + } + }, + { urls: ["<all_urls>"] } + ); + + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertEq( + `http://example.com:${serverPort}/redirect`, + details.url, + "viewsource protocol worked in webRequest" + ); + browser.test.sendMessage("viewed"); + return { redirectUrl: `http://example.com:${serverPort}/dummy` }; + }, + { urls: ["http://example.com/redirect"] }, + ["blocking"] + ); + + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertEq( + `http://example.com:${serverPort}/dummy`, + details.url, + "viewsource protocol worked in webRequest" + ); + browser.test.sendMessage("redirected"); + return { cancel: true }; + }, + { urls: ["http://example.com/dummy"] }, + ["blocking"] + ); + + browser.webRequest.onCompleted.addListener( + details => { + // If cancel fails we get onCompleted. + browser.test.fail("onCompleted received"); + }, + { urls: ["http://example.com/dummy"] } + ); + + browser.webRequest.onErrorOccurred.addListener( + details => { + browser.test.assertEq( + details.error, + "NS_ERROR_ABORT", + "request cancelled" + ); + browser.test.sendMessage("cancelled"); + }, + { urls: ["http://example.com/dummy"] } + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["proxy", "webRequest", "webRequestBlocking", "<all_urls>"], + }, + background: `(${background})(${server.identity.primaryPort})`, + }); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `view-source:http://example.com:${server.identity.primaryPort}/redirect` + ); + + await Promise.all([ + extension.awaitMessage("proxied"), + extension.awaitMessage("viewed"), + extension.awaitMessage("redirected"), + extension.awaitMessage("cancelled"), + ]); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource_StreamFilter.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource_StreamFilter.js new file mode 100644 index 0000000000..c624de4280 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource_StreamFilter.js @@ -0,0 +1,144 @@ +"use strict"; + +const server = createHttpServer(); +const BASE_URL = `http://127.0.0.1:${server.identity.primaryPort}`; + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +server.registerPathHandler("/redir", (request, response) => { + response.setStatusLine(request.httpVersion, 303, "See Other"); + response.setHeader("Location", `${BASE_URL}/dummy`); +}); + +async function testViewSource(viewSourceUrl) { + function background(BASE_URL) { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertEq(`${BASE_URL}/dummy`, details.url, "expected URL"); + browser.test.assertEq("main_frame", details.type, "details.type"); + + let filter = browser.webRequest.filterResponseData(details.requestId); + filter.onstart = () => { + filter.write(new TextEncoder().encode("PREFIX_")); + }; + filter.ondata = event => { + filter.write(event.data); + }; + filter.onstop = () => { + filter.write(new TextEncoder().encode("_SUFFIX")); + filter.disconnect(); + browser.test.notifyPass("filter_end"); + }; + filter.onerror = () => { + browser.test.fail(`Unexpected error: ${filter.error}`); + browser.test.notifyFail("filter_end"); + }; + }, + { urls: ["*://*/dummy"] }, + ["blocking"] + ); + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertEq(`${BASE_URL}/redir`, details.url, "Got redirect"); + + let filter = browser.webRequest.filterResponseData(details.requestId); + filter.onstop = () => { + filter.disconnect(); + browser.test.fail("Unexpected onstop for redirect"); + browser.test.sendMessage("redirect_done"); + }; + filter.onerror = () => { + browser.test.assertEq( + // TODO bug 1683862: must be "Channel redirected", but it is not + // because document requests are handled differently compared to + // other requests, see the comment at the top of + // test_ext_webRequest_redirect_StreamFilter.js. + "Invalid request ID", + filter.error, + "Expected error in filter.onerror" + ); + browser.test.sendMessage("redirect_done"); + }; + }, + { urls: ["*://*/redir"] }, + ["blocking"] + ); + } + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "*://*/*"], + }, + background: `(${background})(${JSON.stringify(BASE_URL)})`, + }); + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage(viewSourceUrl); + if (viewSourceUrl.includes("/redir")) { + info("Awaiting observed completion of redirection request"); + await extension.awaitMessage("redirect_done"); + } + info("Awaiting completion of StreamFilter on request"); + await extension.awaitFinish("filter_end"); + let contentText = await contentPage.spawn([], () => { + return this.content.document.body.textContent; + }); + equal(contentText, "PREFIX_ok_SUFFIX", "view-source response body"); + await contentPage.close(); + await extension.unload(); +} + +add_task(async function test_StreamFilter_viewsource() { + await testViewSource(`view-source:${BASE_URL}/dummy`); +}); + +add_task(async function test_StreamFilter_viewsource_redirect_target() { + await testViewSource(`view-source:${BASE_URL}/redir`); +}); + +// Sanity check: nothing bad happens if the underlying response is aborted. +add_task(async function test_StreamFilter_viewsource_cancel() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "*://*/*"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + let filter = browser.webRequest.filterResponseData(details.requestId); + filter.onstart = () => { + filter.disconnect(); + browser.test.fail("Unexpected filter.onstart"); + browser.test.notifyFail("filter_end"); + }; + filter.onerror = () => { + browser.test.assertEq("Invalid request ID", filter.error, "Error?"); + browser.test.notifyPass("filter_end"); + }; + }, + { urls: ["*://*/dummy"] }, + ["blocking"] + ); + browser.webRequest.onHeadersReceived.addListener( + () => { + browser.test.log("Intentionally canceling view-source request"); + return { cancel: true }; + }, + { urls: ["*://*/dummy"] }, + ["blocking"] + ); + }, + }); + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/dummy` + ); + await extension.awaitFinish("filter_end"); + let contentText = await contentPage.spawn([], () => { + return this.content.document.body.textContent; + }); + equal(contentText, "", "view-source request should have been canceled"); + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_webSocket.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_webSocket.js new file mode 100644 index 0000000000..7e34d2b0b3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_webSocket.js @@ -0,0 +1,55 @@ +"use strict"; + +const HOSTS = new Set(["example.com"]); + +const server = createHttpServer({ hosts: HOSTS }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +add_task(async function test_webSocket() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertEq( + "ws:", + new URL(details.url).protocol, + "ws protocol worked" + ); + browser.test.notifyPass("websocket"); + }, + { urls: ["ws://example.com/*"] }, + ["blocking"] + ); + + browser.test.onMessage.addListener(msg => { + let ws = new WebSocket("ws://example.com/dummy"); + ws.onopen = e => { + ws.send("data"); + }; + ws.onclose = e => {}; + ws.onerror = e => {}; + ws.onmessage = e => { + ws.close(); + }; + }); + browser.test.sendMessage("ready"); + }, + }); + await extension.startup(); + await extension.awaitMessage("ready"); + extension.sendMessage("go"); + await extension.awaitFinish("websocket"); + + // Wait until the next tick so that listener responses are processed + // before we unload. + await new Promise(executeSoon); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webSocket.js b/toolkit/components/extensions/test/xpcshell/test_ext_webSocket.js new file mode 100644 index 0000000000..d5aab3c7f6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webSocket.js @@ -0,0 +1,162 @@ +"use strict"; + +const HOSTS = new Set(["example.com"]); + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const server = createHttpServer({ hosts: HOSTS }); + +const BASE_URL = `http://example.com`; +const pageURL = `${BASE_URL}/plain.html`; + +server.registerPathHandler("/plain.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html"); + response.setHeader("Content-Security-Policy", "upgrade-insecure-requests;"); + response.write("<!DOCTYPE html><html></html>"); +}); + +async function testWebSocketInFrameUpgraded() { + const frame = document.createElement("iframe"); + frame.src = browser.runtime.getURL("frame.html"); + document.documentElement.appendChild(frame); +} + +// testIframe = true: open WebSocket from iframe (original test case). +// testIframe = false: open WebSocket from content script. +async function test_webSocket({ + manifest_version, + useIframe, + content_security_policy, + expectUpgrade, +}) { + let web_accessible_resources = + manifest_version == 2 + ? ["frame.html"] + : [{ resources: ["frame.html"], matches: ["*://example.com/*"] }]; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version, + permissions: ["webRequest", "webRequestBlocking"], + host_permissions: ["<all_urls>"], + granted_host_permissions: true, + web_accessible_resources, + content_security_policy, + content_scripts: [ + { + matches: ["http://*/plain.html"], + run_at: "document_idle", + js: [useIframe ? "content_script.js" : "load_WebSocket.js"], + }, + ], + }, + temporarilyInstalled: true, + background() { + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + let header = details.requestHeaders.find(h => h.name === "Origin"); + browser.test.sendMessage("ws_request", { + ws_scheme: new URL(details.url).protocol, + originHeader: header?.value, + }); + }, + { urls: ["wss://example.com/*", "ws://example.com/*"] }, + ["requestHeaders", "blocking"] + ); + }, + files: { + "frame.html": ` +<html> + <head> + <meta charset="utf-8"/> + <script src="load_WebSocket.js"></script> + </head> + <body> + </body> +</html> + `, + "load_WebSocket.js": `new WebSocket("ws://example.com/ws_dummy");`, + "content_script.js": ` + (${testWebSocketInFrameUpgraded})() + `, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage(pageURL); + let { ws_scheme, originHeader } = await extension.awaitMessage("ws_request"); + + if (expectUpgrade) { + Assert.equal(ws_scheme, "wss:", "ws:-request should have been upgraded"); + } else { + Assert.equal(ws_scheme, "ws:", "ws:-request should not have been upgraded"); + } + + if (useIframe) { + Assert.equal( + originHeader, + `moz-extension://${extension.uuid}`, + "Origin header of WebSocket request from extension page" + ); + } else { + Assert.equal( + originHeader, + manifest_version == 2 ? "null" : "http://example.com", + "Origin header of WebSocket request from content script" + ); + } + await contentPage.close(); + await extension.unload(); +} + +// Page CSP does not affect extension iframes. +add_task(async function test_webSocket_upgrade_iframe_mv2() { + await test_webSocket({ + manifest_version: 2, + useIframe: true, + expectUpgrade: false, + }); +}); + +// Page CSP does not affect extension iframes, however upgrade-insecure-requests causes this +// request to be upgraded in the iframe. +add_task(async function test_webSocket_upgrade_iframe_mv3() { + await test_webSocket({ + manifest_version: 3, + useIframe: true, + expectUpgrade: true, + }); +}); + +// Test that removing upgrade-insecure-requests allows http request in the iframe. +add_task(async function test_webSocket_noupgrade_iframe_mv3() { + let content_security_policy = { + extension_pages: `script-src 'self'`, + }; + await test_webSocket({ + manifest_version: 3, + content_security_policy, + useIframe: true, + expectUpgrade: false, + }); +}); + +// Page CSP does not affect MV2 in the content script. +add_task(async function test_webSocket_upgrade_in_contentscript_mv2() { + await test_webSocket({ + manifest_version: 2, + useIframe: false, + expectUpgrade: false, + }); +}); + +// Page CSP affects MV3 in the content script. +add_task(async function test_webSocket_upgrade_in_contentscript_mv3() { + await test_webSocket({ + manifest_version: 3, + useIframe: false, + expectUpgrade: true, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources.js b/toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources.js new file mode 100644 index 0000000000..0b34dd8127 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources.js @@ -0,0 +1,148 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com", "example.org"] }); +server.registerDirectory("/data/", do_get_file("data")); + +let image = atob( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" + + "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII=" +); +const IMAGE_ARRAYBUFFER = Uint8Array.from(image, byte => + byte.charCodeAt(0) +).buffer; + +async function testImageLoading(src, expectedAction) { + let imageLoadingPromise = new Promise((resolve, reject) => { + let cleanupListeners; + let testImage = document.createElement("img"); + // Set the src via wrappedJSObject so the load is triggered with the + // content page's principal rather than ours. + testImage.wrappedJSObject.setAttribute("src", src); + + let loadListener = () => { + cleanupListeners(); + resolve(expectedAction === "loaded"); + }; + + let errorListener = () => { + cleanupListeners(); + resolve(expectedAction === "blocked"); + }; + + cleanupListeners = () => { + testImage.removeEventListener("load", loadListener); + testImage.removeEventListener("error", errorListener); + }; + + testImage.addEventListener("load", loadListener); + testImage.addEventListener("error", errorListener); + + document.body.appendChild(testImage); + }); + + let success = await imageLoadingPromise; + browser.runtime.sendMessage({ + name: "image-loading", + expectedAction, + success, + }); +} + +add_task(async function test_web_accessible_resources_csp() { + function background() { + browser.runtime.onMessage.addListener((msg, sender) => { + if (msg.name === "image-loading") { + browser.test.assertTrue(msg.success, `Image was ${msg.expectedAction}`); + browser.test.sendMessage(`image-${msg.expectedAction}`); + } else { + browser.test.sendMessage(msg); + } + }); + + browser.test.sendMessage("background-ready"); + } + + function content() { + window.addEventListener("message", function rcv(event) { + browser.runtime.sendMessage("script-ran"); + window.removeEventListener("message", rcv); + }); + + testImageLoading(browser.runtime.getURL("image.png"), "loaded"); + + let testScriptElement = document.createElement("script"); + // Set the src via wrappedJSObject so the load is triggered with the + // content page's principal rather than ours. + testScriptElement.wrappedJSObject.setAttribute( + "src", + browser.runtime.getURL("test_script.js") + ); + document.head.appendChild(testScriptElement); + browser.runtime.sendMessage("script-loaded"); + } + + function testScript() { + window.postMessage("test-script-loaded", "*"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/*/file_csp.html"], + run_at: "document_end", + js: ["content_script_helper.js", "content_script.js"], + }, + ], + web_accessible_resources: ["image.png", "test_script.js"], + }, + background, + files: { + "content_script_helper.js": `${testImageLoading}`, + "content_script.js": content, + "test_script.js": testScript, + "image.png": IMAGE_ARRAYBUFFER, + }, + }); + + await Promise.all([ + extension.startup(), + extension.awaitMessage("background-ready"), + ]); + + let page = await ExtensionTestUtils.loadContentPage( + `http://example.com/data/file_sample.html` + ); + await page.legacySpawn(null, () => { + this.obs = { + events: [], + observe(subject, topic, data) { + this.events.push(subject.QueryInterface(Ci.nsIURI).spec); + }, + done() { + Services.obs.removeObserver(this, "csp-on-violate-policy"); + return this.events; + }, + }; + Services.obs.addObserver(this.obs, "csp-on-violate-policy"); + content.location.href = "http://example.com/data/file_csp.html"; + }); + + await Promise.all([ + extension.awaitMessage("image-loaded"), + extension.awaitMessage("script-loaded"), + extension.awaitMessage("script-ran"), + ]); + + let events = await page.legacySpawn(null, () => this.obs.done()); + equal(events.length, 2, "Two items were rejected by CSP"); + for (let url of events) { + ok( + url.includes("file_image_bad.png") || url.includes("file_script_bad.js"), + `Expected file: ${url} rejected by CSP` + ); + } + + await page.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources_matches.js b/toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources_matches.js new file mode 100644 index 0000000000..1cd5074780 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources_matches.js @@ -0,0 +1,546 @@ +"use strict"; + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const server = createHttpServer({ hosts: ["example.com", "example.org"] }); +server.registerDirectory("/data/", do_get_file("data")); + +let image = atob( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" + + "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII=" +); +const IMAGE_ARRAYBUFFER = Uint8Array.from(image, byte => + byte.charCodeAt(0) +).buffer; + +add_task(async function test_web_accessible_resources_matching() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + web_accessible_resources: [ + { + resources: ["/accessible.html"], + }, + ], + }, + }); + + await Assert.rejects( + extension.startup(), + /web_accessible_resources requires one of "matches" or "extension_ids"/, + "web_accessible_resources object format incorrect" + ); + + extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + web_accessible_resources: [ + { + resources: ["/accessible.html"], + matches: ["http://example.com/data/*"], + }, + ], + }, + }); + + await extension.startup(); + ok(true, "web_accessible_resources with matches loads"); + await extension.unload(); + + extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + web_accessible_resources: [ + { + resources: ["/accessible.html"], + extension_ids: ["foo@mochitest"], + }, + ], + }, + }); + + await extension.startup(); + ok(true, "web_accessible_resources with extensions loads"); + await extension.unload(); + + extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + web_accessible_resources: [ + { + resources: ["/accessible.html"], + matches: ["http://example.com/data/*"], + extension_ids: ["foo@mochitest"], + }, + ], + }, + }); + + await extension.startup(); + ok(true, "web_accessible_resources with matches and extensions loads"); + await extension.unload(); + + extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + web_accessible_resources: [ + { + resources: ["/accessible.html"], + extension_ids: [], + }, + ], + }, + }); + + await extension.startup(); + ok(true, "web_accessible_resources with empty extensions loads"); + await extension.unload(); + + extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + web_accessible_resources: [ + { + resources: ["/accessible.html"], + matches: ["http://example.com/data/*"], + extension_ids: [], + }, + ], + }, + }); + + await extension.startup(); + ok(true, "web_accessible_resources with matches and empty extensions loads"); + await extension.unload(); +}); + +add_task(async function test_web_accessible_resources() { + async function contentScript() { + let canLoad = window.location.href.startsWith("http://example.com"); + let urls = [ + { + name: "iframe", + path: "accessible.html", + shouldLoad: canLoad, + }, + { + name: "iframe", + path: "inaccessible.html", + shouldLoad: false, + }, + { + name: "img", + path: "image.png", + shouldLoad: true, + }, + { + name: "script", + path: "script.js", + shouldLoad: canLoad, + }, + ]; + + function test_element_src(name, url) { + return new Promise(resolve => { + let elem = document.createElement(name); + // Set the src via wrappedJSObject so the load is triggered with the + // content page's principal rather than ours. + elem.wrappedJSObject.setAttribute("src", url); + elem.addEventListener( + "load", + () => { + resolve(true); + }, + { once: true } + ); + elem.addEventListener( + "error", + () => { + resolve(false); + }, + { once: true } + ); + document.body.appendChild(elem); + }); + } + for (let test of urls) { + let loaded = await test_element_src( + test.name, + browser.runtime.getURL(test.path) + ); + browser.test.assertEq( + loaded, + test.shouldLoad, + `resource loaded ${test.path} in ${window.location.href}` + ); + } + browser.test.sendMessage("complete"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + content_scripts: [ + { + matches: ["http://example.com/data/*", "http://example.org/data/*"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + host_permissions: ["http://example.com/*", "http://example.org/*"], + granted_host_permissions: true, + + web_accessible_resources: [ + { + resources: ["/accessible.html", "/script.js"], + matches: ["http://example.com/data/*"], + }, + { + resources: ["/image.png"], + matches: ["<all_urls>"], + }, + ], + }, + temporarilyInstalled: true, + + files: { + "content_script.js": contentScript, + + "accessible.html": `<html><head> + <meta charset="utf-8"> + </head></html>`, + + "inaccessible.html": `<html><head> + <meta charset="utf-8"> + </head></html>`, + + "image.png": IMAGE_ARRAYBUFFER, + "script.js": () => { + // empty script + }, + }, + }); + + await extension.startup(); + + let page = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/" + ); + + await extension.awaitMessage("complete"); + await page.close(); + + // None of the test resources are loadable in example.org + page = await ExtensionTestUtils.loadContentPage("http://example.org/data/"); + + await extension.awaitMessage("complete"); + + await page.close(); + await extension.unload(); +}); + +async function pageScript() { + function test_element_src(data) { + return new Promise(resolve => { + let elem = document.createElement(data.elem); + let elemContext = + data.content_context && elem.wrappedJSObject + ? elem.wrappedJSObject + : elem; + elemContext.setAttribute("src", data.url); + elem.addEventListener( + "load", + () => { + browser.test.log(`got load event for ${data.url}`); + resolve(true); + }, + { once: true } + ); + elem.addEventListener( + "error", + () => { + browser.test.log(`got error event for ${data.url}`); + resolve(false); + }, + { once: true } + ); + document.body.appendChild(elem); + }); + } + browser.test.onMessage.addListener(async msg => { + browser.test.log(`testing ${JSON.stringify(msg)}`); + let loaded = await test_element_src(msg); + browser.test.assertEq(loaded, msg.shouldLoad, `${msg.name} loaded`); + browser.test.sendMessage("web-accessible-resources"); + }); + browser.test.sendMessage("page-loaded"); +} + +add_task(async function test_web_accessible_resources_extensions() { + let other = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: "other@mochitest" } }, + }, + files: { + "page.js": pageScript, + + "page.html": `<html><head> + <meta charset="utf-8"> + <script src="page.js"></script> + </head></html>`, + }, + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + browser_specific_settings: { gecko: { id: "this@mochitest" } }, + web_accessible_resources: [ + { + resources: ["/image.png"], + extension_ids: ["other@mochitest"], + }, + ], + }, + + files: { + "image.png": IMAGE_ARRAYBUFFER, + "inaccessible.png": IMAGE_ARRAYBUFFER, + "page.js": pageScript, + + "page.html": `<html><head> + <meta charset="utf-8"> + <script src="page.js"></script> + </head></html>`, + }, + }); + + await extension.startup(); + let extensionUrl = `moz-extension://${extension.uuid}/`; + + await other.startup(); + let pageUrl = `moz-extension://${other.uuid}/page.html`; + + let page = await ExtensionTestUtils.loadContentPage(pageUrl); + await other.awaitMessage("page-loaded"); + + other.sendMessage({ + name: "accessible resource", + elem: "img", + url: `${extensionUrl}image.png`, + shouldLoad: true, + }); + await other.awaitMessage("web-accessible-resources"); + + other.sendMessage({ + name: "inaccessible resource", + elem: "img", + url: `${extensionUrl}inaccessible.png`, + shouldLoad: false, + }); + await other.awaitMessage("web-accessible-resources"); + + await page.close(); + + // test that the extension may load it's own web accessible resource + page = await ExtensionTestUtils.loadContentPage(`${extensionUrl}page.html`); + await extension.awaitMessage("page-loaded"); + + extension.sendMessage({ + name: "accessible resource", + elem: "img", + url: `${extensionUrl}image.png`, + shouldLoad: true, + }); + await extension.awaitMessage("web-accessible-resources"); + + await page.close(); + await extension.unload(); + await other.unload(); +}); + +// test that a web page not in matches cannot load the resource +add_task(async function test_web_accessible_resources_inaccessible() { + let extension = ExtensionTestUtils.loadExtension({ + temporarilyInstalled: true, + manifest: { + manifest_version: 3, + browser_specific_settings: { gecko: { id: "web@mochitest" } }, + content_scripts: [ + { + matches: ["http://example.com/data/*"], + js: ["page.js"], + run_at: "document_idle", + }, + ], + web_accessible_resources: [ + { + resources: ["/image.png"], + extension_ids: ["some_other_ext@mochitest"], + }, + ], + host_permissions: ["*://example.com/*"], + granted_host_permissions: true, + }, + + files: { + "image.png": IMAGE_ARRAYBUFFER, + "page.js": pageScript, + + "page.html": `<html><head> + <meta charset="utf-8"> + <script src="page.js"></script> + </head></html>`, + }, + }); + + await extension.startup(); + let extensionUrl = `moz-extension://${extension.uuid}/`; + let page = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/" + ); + await extension.awaitMessage("page-loaded"); + + extension.sendMessage({ + name: "cannot access resource", + elem: "img", + url: `${extensionUrl}image.png`, + content_context: true, + shouldLoad: false, + }); + await extension.awaitMessage("web-accessible-resources"); + + await page.close(); + await extension.unload(); +}); + +add_task(async function test_web_accessible_resources_empty_extension_ids() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + web_accessible_resources: [ + { + resources: ["/file.txt"], + matches: ["http://example.com/data/*"], + extension_ids: [], + }, + ], + }, + + files: { + "file.txt": "some content", + }, + }); + let secondExtension = ExtensionTestUtils.loadExtension({ + files: { + "page.html": "", + }, + }); + + await extension.startup(); + await secondExtension.startup(); + + const fileURL = extension.extension.baseURI.resolve("file.txt"); + Assert.equal( + await ExtensionTestUtils.fetch("http://example.com/data/", fileURL), + "some content", + "expected access to the extension's resource" + ); + + await Assert.rejects( + ExtensionTestUtils.fetch( + secondExtension.extension.baseURI.resolve("page.html"), + fileURL + ), + e => e?.message === "NetworkError when attempting to fetch resource.", + "other extension should not be able to fetch when extension_ids is empty" + ); + + await extension.unload(); + await secondExtension.unload(); +}); + +add_task(async function test_web_accessible_resources_empty_array() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + web_accessible_resources: [], + }, + }); + await extension.startup(); + ok(true, "empty web_accessible_resources loads"); + await extension.unload(); +}); + +add_task(async function test_web_accessible_resources_empty_resources() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + web_accessible_resources: [{ resources: [], matches: ["*://*/*"] }], + }, + }); + await extension.startup(); + ok(true, "empty web_accessible_resources[0].resources loads"); + await extension.unload(); +}); + +add_task(async function test_web_accessible_resources_empty_everything() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + web_accessible_resources: [ + { resources: [], matches: [], extension_ids: [] }, + ], + }, + }); + await extension.startup(); + ok(true, "empty resources, matches & extension_ids loads"); + await extension.unload(); +}); + +add_task(async function test_web_accessible_resources_empty_matches() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + web_accessible_resources: [{ resources: ["file.txt"], matches: [] }], + }, + files: { + "file.txt": "some content", + }, + }); + await extension.startup(); + ok(true, "empty web_accessible_resources[0].matches loads"); + + const fileURL = extension.extension.baseURI.resolve("file.txt"); + await Assert.rejects( + ExtensionTestUtils.fetch("http://example.com", fileURL), + e => e?.message === "NetworkError when attempting to fetch resource.", + "empty matches[] = not web-accessible" + ); + await extension.unload(); +}); + +add_task(async function test_web_accessible_resources_unknown_property() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + web_accessible_resources: [{ resources: [], matches: [], idk: null }], + }, + }); + + let { messages } = await promiseConsoleOutput(async () => { + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + }); + + AddonTestUtils.checkMessages(messages, { + expected: [ + { + message: + /Reading manifest: Warning processing web_accessible_resources.0.idk: An unexpected property was found in the WebExtension manifest./, + }, + ], + }); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_xhr_capabilities.js b/toolkit/components/extensions/test/xpcshell/test_ext_xhr_capabilities.js new file mode 100644 index 0000000000..0728946817 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_xhr_capabilities.js @@ -0,0 +1,72 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_xhr_capabilities() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + let xhr = new XMLHttpRequest(); + xhr.open("GET", browser.runtime.getURL("bad.xml")); + + browser.test.sendMessage("result", { + name: "Background script XHRs should not be privileged", + result: xhr.channel === undefined, + }); + + xhr.onload = () => { + browser.test.sendMessage("result", { + name: "Background script XHRs should not yield <parsererrors>", + result: xhr.responseXML === null, + }); + }; + xhr.send(); + }, + + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + }, + ], + web_accessible_resources: ["bad.xml"], + }, + + files: { + "bad.xml": "<xml", + "content_script.js"() { + let xhr = new XMLHttpRequest(); + xhr.open("GET", browser.runtime.getURL("bad.xml")); + + browser.test.sendMessage("result", { + name: "Content script XHRs should not be privileged", + result: xhr.channel === undefined, + }); + + xhr.onload = () => { + browser.test.sendMessage("result", { + name: "Content script XHRs should not yield <parsererrors>", + result: xhr.responseXML === null, + }); + }; + xhr.send(); + }, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + // We expect four test results from the content/background scripts. + for (let i = 0; i < 4; ++i) { + let result = await extension.awaitMessage("result"); + ok(result.result, result.name); + } + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_xhr_cors.js b/toolkit/components/extensions/test/xpcshell/test_ext_xhr_cors.js new file mode 100644 index 0000000000..983fe1c542 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_xhr_cors.js @@ -0,0 +1,223 @@ +"use strict"; + +// The purpose of this test is to show that the XMLHttpRequest API behaves +// similarly in MV2 and MV3, except for intentional differences related to +// permission handling. + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const server = createHttpServer({ + hosts: ["example.com", "example.net", "example.org"], +}); +server.registerPathHandler("/dummy", (req, res) => { + res.setStatusLine(req.httpVersion, 200, "OK"); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + + // A very strict CSP. + res.setHeader( + "Content-Security-Policy", + "default-src; script-src 'nonce-kindasecret'; connect-src http:" + ); + + res.write( + `<script id="id_of_some_element" nonce="kindasecret"> + // Clobber XMLHttpRequest API to allow us to verify that the page's value + // for it does not affect the XMLHttpRequest API in the content script. + window.XMLHttpRequest = "This is not XMLHttpRequest"; + </script> + ` + ); +}); +server.registerPathHandler("/dummy.json", (req, res) => { + res.write(`{"mykey": "kvalue"}`); +}); +server.registerPathHandler("/nocors", (req, res) => { + res.write("no cors"); +}); +server.registerPathHandler("/cors-enabled", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "http://example.com"); + res.write("cors_response"); +}); +server.registerPathHandler("/return-origin", (req, res) => { + res.setHeader("Content-Type", "text/plain"); + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "*"); + res.write(req.hasHeader("Origin") ? req.getHeader("Origin") : "undefined"); +}); + +// We just need to test XHR; fetch is already covered by test_ext_secfetch.js. +async function test_xhr({ manifest_version }) { + async function contentScript(manifest_version) { + function runXHR(url, extraXHRProps, method = "GET") { + return new Promise(resolve => { + let x = new XMLHttpRequest(); + x.open(method, url); + Object.assign(x, extraXHRProps); + x.onloadend = () => resolve(x); + x.send(); + }); + } + async function checkXHR({ + description, + url, + extraXHRProps, + method, + expected, + }) { + let { status, response } = expected; + let x = await runXHR(url, extraXHRProps, method); + browser.test.assertEq(status, x.status, `${description} - status`); + browser.test.assertEq(response, x.response, `${description} - body`); + } + + await checkXHR({ + description: "Same-origin", + url: "http://example.com/nocors", + expected: { status: 200, response: "no cors" }, + }); + + await checkXHR({ + description: "Cross-origin without CORS", + url: "http://example.org/nocors", + expected: { status: 0, response: "" }, + }); + + await checkXHR({ + description: "Cross-origin with CORS", + url: "http://example.org/cors-enabled", + expected: + manifest_version === 2 + ? // Bug 1605197: MV2 cannot fall back to CORS. + { status: 0, response: "" } + : { status: 200, response: "cors_response" }, + }); + + // MV2 allowed cross-origin requests in content scripts with host + // permissions, but MV3 does not. + await checkXHR({ + description: "Cross-origin without CORS, with permission", + url: "http://example.net/nocors", + expected: + manifest_version === 2 + ? { status: 200, response: "no cors" } + : { status: 0, response: "" }, + }); + + await checkXHR({ + description: "Cross-origin with CORS (and permission)", + url: "http://example.net/cors-enabled", + expected: { status: 200, response: "cors_response" }, + }); + + // MV2 has a XMLHttpRequest instance specific to the sandbox. + // MV3 uses the page's XMLHttpRequest and currently enforces the page's CSP. + // TODO bug 1766813: Enforce content script CSP instead. + await checkXHR({ + description: "data:-URL while page blocks data: via CSP", + url: "data:,data-url", + expected: + // Should be "data-url" in MV3 too. + manifest_version === 2 + ? { status: 200, response: "data-url" } + : { status: 0, response: "" }, + }); + + { + let x = await runXHR("http://example.com/dummy.json", { + responseType: "json", + }); + browser.test.assertTrue(x.response instanceof Object, "is JSON object"); + browser.test.assertEq(x.response.mykey, "kvalue", "can read parsed JSON"); + } + + { + let x = await runXHR("http://example.com/dummy", { + responseType: "document", + }); + browser.test.assertTrue(HTMLDocument.isInstance(x.response), "is doc"); + browser.test.assertTrue( + x.response.querySelector("#id_of_some_element"), + "got parsed document" + ); + } + + await checkXHR({ + description: "Same-origin Origin header", + url: "http://example.com/return-origin", + expected: { status: 200, response: "undefined" }, + }); + + await checkXHR({ + description: "Same-origin POST Origin header", + url: "http://example.com/return-origin", + method: "POST", + expected: + manifest_version === 2 + ? { status: 200, response: "undefined" } + : { status: 200, response: "http://example.com" }, + }); + + await checkXHR({ + description: "Cross-origin (CORS) Origin header", + url: "http://example.org/return-origin", + expected: + manifest_version === 2 + ? // Bug 1605197: MV2 cannot fall back to CORS. + { status: 0, response: "" } + : { status: 200, response: "http://example.com" }, + }); + + await checkXHR({ + description: "Cross-origin (CORS) POST Origin header", + url: "http://example.org/return-origin", + method: "POST", + expected: + manifest_version === 2 + ? // Bug 1605197: MV2 cannot fall back to CORS. + { status: 0, response: "" } + : { status: 200, response: "http://example.com" }, + }); + + browser.test.sendMessage("done"); + } + let extension = ExtensionTestUtils.loadExtension({ + temporarilyInstalled: true, // Needed for granted_host_permissions + manifest: { + manifest_version, + granted_host_permissions: true, // Test-only: grant permissions in MV3. + host_permissions: [ + "http://example.net/", + // Work-around for bug 1766752. + "http://example.com/", + // "http://example.org/" is intentionally missing. + ], + content_scripts: [ + { + matches: ["http://example.com/dummy"], + run_at: "document_end", + js: ["contentscript.js"], + }, + ], + }, + files: { + "contentscript.js": `(${contentScript})(${manifest_version})`, + }, + }); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitMessage("done"); + await contentPage.close(); + + await extension.unload(); +} + +add_task(async function test_XHR_MV2() { + await test_xhr({ manifest_version: 2 }); +}); + +add_task(async function test_XHR_MV3() { + await test_xhr({ manifest_version: 3 }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_extension_permissions_migrate_kvstore_path.js b/toolkit/components/extensions/test/xpcshell/test_extension_permissions_migrate_kvstore_path.js new file mode 100644 index 0000000000..5f1c73dd3b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_extension_permissions_migrate_kvstore_path.js @@ -0,0 +1,234 @@ +"use strict"; + +const { + ExtensionPermissions, + OLD_RKV_DIRNAME, + RKV_DIRNAME, + VERSION_KEY, + VERSION_VALUE, +} = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" +); +const { FileTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/FileTestUtils.sys.mjs" +); +const { FileUtils } = ChromeUtils.importESModule( + "resource://gre/modules/FileUtils.sys.mjs" +); +const { KeyValueService } = ChromeUtils.importESModule( + "resource://gre/modules/kvstore.sys.mjs" +); + +add_setup(async () => { + // Bug 1646182: Force ExtensionPermissions to run in rkv mode, because this + // test does not make sense with the legacy method (which will be removed in + // the above bug). + ExtensionPermissions._useLegacyStorageBackend = false; + await ExtensionPermissions._uninit(); +}); + +// NOTE: this test lives in its own test file to make sure it is isolated +// from other tests that would be creating the kvstore instance and +// would prevent this test to properly simulate the kvstore path migration. +add_task(async function test_migrate_to_separate_kvstore_store_path() { + const ADDON_ID_01 = "test-addon-01@test-extension"; + const ADDON_ID_02 = "test-addon-02@test-extension"; + // This third test extension is only used as the one that should + // have some content scripts stored in ExtensionScriptingStore. + const ADDON_ID_03 = "test-addon-03@test-extension"; + + const oldStorePath = FileUtils.getDir("ProfD", [OLD_RKV_DIRNAME]).path; + const newStorePath = FileUtils.getDir("ProfD", [RKV_DIRNAME]).path; + + // Verify that we are going to be using the expected backend, and that + // the rkv path migration is only enabled by default in Nightly builds. + info("Verify test environment match the expected pre-conditions"); + + const permsStore = ExtensionPermissions._getStore(); + equal( + permsStore.constructor.name, + "PermissionStore", + "active ExtensionPermissions store should be an instance of PermissionStore" + ); + + equal( + permsStore._shouldMigrateFromOldKVStorePath, + AppConstants.NIGHTLY_BUILD, + "ExtensionPermissions rkv migration expected to be enabled by default only in Nightly" + ); + + info( + "Uninitialize ExtensionPermissions and make sure no existing kvstore dir" + ); + await ExtensionPermissions._uninit({ recreateStore: false }); + equal( + ExtensionPermissions._getStore(), + null, + "PermissionStore has been nullified" + ); + await IOUtils.remove(oldStorePath, { ignoreAbsent: true, recursive: true }); + await IOUtils.remove(newStorePath, { ignoreAbsent: true, recursive: true }); + + info("Create an existing kvstore dir on the old path"); + + // Populated the kvstore with some expected permissions. + const expectedPermsAddon01 = { + permissions: ["tabs"], + origins: ["http://*/*"], + }; + const expectedPermsAddon02 = { + permissions: ["proxy"], + origins: ["https://*/*"], + }; + + const expectedScriptAddon01 = { + id: "script-addon-01", + allFrames: false, + matches: ["<all_urls>"], + js: ["/test-script-addon-01.js"], + persistAcrossSessions: true, + runAt: "document_end", + }; + + const expectedScriptAddon02 = { + id: "script-addon-02", + allFrames: false, + matches: ["<all_urls"], + css: ["/test-script-addon-02.css"], + persistAcrossSessions: true, + runAt: "document_start", + }; + + { + // Make sure the folder exists + await IOUtils.makeDirectory(oldStorePath, { ignoreExisting: true }); + // Create a permission kvstore dir on the old file path. + const kvstore = await KeyValueService.getOrCreate( + oldStorePath, + "permissions" + ); + await kvstore.writeMany([ + ["_version", 1], + [`id-${ADDON_ID_01}`, JSON.stringify(expectedPermsAddon01)], + [`id-${ADDON_ID_02}`, JSON.stringify(expectedPermsAddon02)], + ]); + } + + { + // Add also scripting kvstore data into the same temp dir path. + const kvstore = await KeyValueService.getOrCreate( + oldStorePath, + "scripting-contentScripts" + ); + await kvstore.writeMany([ + [ + `${ADDON_ID_03}/${expectedScriptAddon01.id}`, + JSON.stringify(expectedScriptAddon01), + ], + [ + `${ADDON_ID_03}/${expectedScriptAddon02.id}`, + JSON.stringify(expectedScriptAddon02), + ], + ]); + } + + ok( + await IOUtils.exists(oldStorePath), + "Found kvstore dir for the old store path" + ); + ok( + !(await IOUtils.exists(newStorePath)), + "Expect kvstore dir for the new store path to don't exist yet" + ); + + info("Re-initialize the ExtensionPermission store and assert migrated data"); + await ExtensionPermissions._uninit({ recreateStore: true }); + + // Explicitly enable migration (needed to make sure we hit the migration code + // that is only enabled by default on Nightly). + if (!AppConstants.NIGHTLY_BUILD) { + info("Enable ExtensionPermissions rkv migration on non-nightly channel"); + const newStoreInstance = ExtensionPermissions._getStore(); + newStoreInstance._shouldMigrateFromOldKVStorePath = true; + } + + const permsAddon01 = await ExtensionPermissions._get(ADDON_ID_01); + const permsAddon02 = await ExtensionPermissions._get(ADDON_ID_02); + + Assert.deepEqual( + { permsAddon01, permsAddon02 }, + { + permsAddon01: expectedPermsAddon01, + permsAddon02: expectedPermsAddon02, + }, + "Got the expected permissions migrated to the new store file path" + ); + + await ExtensionPermissions._uninit({ recreateStore: false }); + + ok( + await IOUtils.exists(newStorePath), + "Found kvstore dir for the new store path" + ); + + { + const newKVStore = await KeyValueService.getOrCreate( + newStorePath, + "permissions" + ); + Assert.equal( + await newKVStore.get(VERSION_KEY), + VERSION_VALUE, + "Got the expected value set on the kvstore _version key" + ); + } + + // kvstore internally caching behavior doesn't make it easy to make sure + // we would be hitting a failure if the ExtensionPermissions kvstore migration + // would be mistakenly removing the old kvstore dir as part of that migration, + // and so the test case is explicitly verifying that the directory does still + // exist and then it copies it into a new path to confirm that the expected + // data have been kept in the old kvstore dir. + ok( + await IOUtils.exists(oldStorePath), + "Found kvstore dir for the old store path" + ); + const oldStoreCopiedPath = FileTestUtils.getTempFile("kvstore-dir").path; + await IOUtils.copy(oldStorePath, oldStoreCopiedPath, { recursive: true }); + + // Confirm that the content scripts have not been copied into + // the new kvstore path. + async function assertStoredContentScripts(storePath, expectedKeys) { + const kvstore = await KeyValueService.getOrCreate( + storePath, + "scripting-contentScripts" + ); + const enumerator = await kvstore.enumerate(); + const keys = []; + while (enumerator.hasMoreElements()) { + keys.push(enumerator.getNext().key); + } + Assert.deepEqual( + keys, + expectedKeys, + `Got the expected scripts in the kvstore path ${storePath}` + ); + } + + info( + "Verify that no content scripts are stored in the new kvstore dir reserved for permissions" + ); + await assertStoredContentScripts(newStorePath, []); + info( + "Verify that existing content scripts have been not been removed old kvstore dir" + ); + await assertStoredContentScripts(oldStoreCopiedPath, [ + `${ADDON_ID_03}/${expectedScriptAddon01.id}`, + `${ADDON_ID_03}/${expectedScriptAddon02.id}`, + ]); + + await ExtensionPermissions._uninit({ recreateStore: true }); + + await IOUtils.remove(newStorePath, { recursive: true }); + await IOUtils.remove(oldStorePath, { recursive: true }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_extension_permissions_migration.js b/toolkit/components/extensions/test/xpcshell/test_extension_permissions_migration.js new file mode 100644 index 0000000000..aa05377e0e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_extension_permissions_migration.js @@ -0,0 +1,112 @@ +"use strict"; + +const { + ExtensionPermissions, + OLD_JSON_FILENAME, + OLD_RKV_DIRNAME, + RKV_DIRNAME, +} = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" +); + +const GOOD_JSON_FILE = { + "wikipedia@search.mozilla.org": { + permissions: ["internal:privateBrowsingAllowed"], + origins: [], + }, + "amazon@search.mozilla.org": { + permissions: ["internal:privateBrowsingAllowed"], + origins: [], + }, + "doh-rollout@mozilla.org": { + permissions: ["internal:privateBrowsingAllowed"], + origins: [], + }, +}; + +const BAD_JSON_FILE = { + "test@example.org": "what", +}; + +const BAD_FILE = "what is this { } {"; + +const gOldJSONPath = FileUtils.getDir("ProfD", [OLD_JSON_FILENAME]).path; +const gOldRkvPath = FileUtils.getDir("ProfD", [OLD_RKV_DIRNAME]).path; +const gNewRkvPath = FileUtils.getDir("ProfD", [RKV_DIRNAME]).path; + +async function test_file(json, extensionIds, expected, fileDeleted) { + await ExtensionPermissions._resetVersion(); + await ExtensionPermissions._uninit(); + + await IOUtils.writeUTF8(gOldJSONPath, json); + + for (let extensionId of extensionIds) { + let permissions = await ExtensionPermissions.get(extensionId); + Assert.deepEqual(permissions, expected, "permissions match"); + } + + Assert.equal( + await IOUtils.exists(gOldJSONPath), + !fileDeleted, + "old file was deleted" + ); + + Assert.ok( + await IOUtils.exists(gNewRkvPath), + "found the store at the new rkv path" + ); + + Assert.ok( + !(await IOUtils.exists(gOldRkvPath)), + "expect old rkv path to not exist" + ); +} + +add_setup(async () => { + // Bug 1646182: Force ExtensionPermissions to run in rkv mode, because this + // test does not make sense with the legacy method (which will be removed in + // the above bug). + await ExtensionPermissions._uninit(); +}); + +add_task(async function test_migrate_good_json() { + let expected = { + permissions: ["internal:privateBrowsingAllowed"], + origins: [], + }; + + await test_file( + JSON.stringify(GOOD_JSON_FILE), + [ + "wikipedia@search.mozilla.org", + "amazon@search.mozilla.org", + "doh-rollout@mozilla.org", + ], + expected, + /* fileDeleted */ true + ); +}); + +add_task(async function test_migrate_bad_json() { + let expected = { permissions: [], origins: [] }; + + await test_file( + BAD_FILE, + ["test@example.org"], + expected, + /* fileDeleted */ false + ); + await IOUtils.remove(gOldJSONPath); +}); + +add_task(async function test_migrate_bad_file() { + let expected = { permissions: [], origins: [] }; + + await test_file( + JSON.stringify(BAD_JSON_FILE), + ["test2@example.org"], + expected, + /* fileDeleted */ false + ); + await IOUtils.remove(gOldJSONPath); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_extension_process_alive.js b/toolkit/components/extensions/test/xpcshell/test_extension_process_alive.js new file mode 100644 index 0000000000..fb82f85140 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_extension_process_alive.js @@ -0,0 +1,312 @@ +"use strict"; + +// Helper to observe process shutdowns. Used to detect when extension processes +// have shut down. For simplicity, this helper does not filter by extension +// processes because the callers knowingly pass extension process childIDs only. +class ProcessWatcher { + constructor() { + // Map of childID to boolean (whether process ended abnormally) + this.seenChildIDs = new Map(); + this.onShutdownCallbacks = new Set(); + Services.obs.addObserver(this, "ipc:content-shutdown"); + + // See setExtProcessTerminationDeadline and waitAndCheckIsProcessAlive. + // We measure the duration of an earlier test to determine the reasonable + // duration during which a terminated extension process should stay alive. + // Use a high default in case that task was skipped, e.g. by .only(). + this.deadDeadline = 5000; + } + + unregister() { + Services.obs.removeObserver(this, "ipc:content-shutdown"); + } + + observe(subject, topic, data) { + const childID = parseInt(data, 10); + const abnormal = subject.QueryInterface(Ci.nsIPropertyBag2).get("abnormal"); + info(`Observed content shutdown, childID=${childID}, abnormal=${abnormal}`); + this.seenChildIDs.set(childID, !!abnormal); + for (let onShutdownCallback of this.onShutdownCallbacks) { + onShutdownCallback(childID); + } + } + + isProcessAlive(childID) { + return !this.seenChildIDs.has(childID); + } + + async waitForTermination(childID, expectAbnormal = false) { + // We only expect content processes, so childID should never be zero. + Assert.ok(childID, `waitForTermination: ${childID}`); + + if (!this.isProcessAlive(childID)) { + info(`Process has already shut down: ${childID}`); + } else { + info(`Waiting for process to shut down: ${childID}`); + await new Promise(resolve => { + const onShutdownCallback = _childID => { + if (_childID === childID) { + info(`Process has shut down: ${childID}`); + this.onShutdownCallbacks.delete(onShutdownCallback); + resolve(); + } + }; + this.onShutdownCallbacks.add(onShutdownCallback); + }); + } + + // When we get here, !isProcessAlive or onShutdownCallback was called, + // which implies that childID is a key in the seenChildIDs Map. + const abnormal = this.seenChildIDs.get(childID); + if (expectAbnormal) { + Assert.ok(abnormal, "Process should have ended abnormally."); + } else if (AppConstants.platform === "android" && abnormal) { + // On Android, the implementation sometimes triggers abnormal shutdowns + // when we expect normal shutdown. This is undesired, but as it happens + // intermittently, pretend that everything is OK and log a message. + Assert.ok(true, "Process should have ended normally, but did not."); + } else { + Assert.ok(!abnormal, "Process should have ended normally."); + } + } + + // Set the deadline as used by "waitAndCheckIsProcessAlive". The deadline is + // the time by which an unexpected process termination should happen to catch + // unexpected process termination. + setExtProcessTerminationDeadline(deadline) { + // Have some reasonably small minimum deadline, in case the caller + // experiences a drifted timer that results in negative value. + const MIN_DEADLINE = 1000; + // Tests time out after 30 seconds. Enforce a maximum deadline below that + // limit, e.g. in case a process is being debugged. + const MAX_DEADLINE = 20000; + if (deadline < MIN_DEADLINE) { + this.deadDeadline = MIN_DEADLINE; + } else if (deadline > MAX_DEADLINE) { + this.deadDeadline = MAX_DEADLINE; + } else { + this.deadDeadline = deadline; + } + } + + async waitAndCheckIsProcessAlive(childID) { + Assert.ok(this.isProcessAlive(childID), `Process ${childID} is alive`); + + // We want to verify that the extension process does not shut down too soon. + // There is no great way to verify this, other than waiting for a bit and + // verifying that the process is still around. + info(`Waiting for ${this.deadDeadline} ms and process ${childID}`); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, this.deadDeadline)); + + Assert.ok(this.isProcessAlive(childID), `Process ${childID} still alive`); + } +} + +// Register early so we catch all terminations. +const processWatcher = new ProcessWatcher(); +registerCleanupFunction(() => processWatcher.unregister()); + +function pidOfContentPage(contentPage) { + return contentPage.browsingContext.currentWindowGlobal.domProcess.childID; +} + +function pidOfBackgroundPage(extension) { + return extension.extension.backgroundContext.xulBrowser.browsingContext + .currentWindowGlobal.domProcess.childID; +} + +async function loadExtensionAndGetPid() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("bg_loaded"); + }, + }); + await extension.startup(); + await extension.awaitMessage("bg_loaded"); + let pid = pidOfBackgroundPage(extension); + await extension.unload(); + return pid; +} + +add_setup(async function setup_start_and_quit_addon_manager() { + // None of this setup is strictly required for the test file to pass, but + // exists to trigger conditions that were historically associated with bugs + // and test failures. + + // As a regression test for bug 1845352: Verify that (simulating) shut down + // of the AddonManager does not break the behavior of extension process + // spawning. For details see bug 1845352 and bug 1845778. + ExtensionTestUtils.mockAppInfo(); + AddonTestUtils.init(globalThis); + await AddonTestUtils.promiseStartupManager(); + info("Starting an extension to load the extension process"); + let extension = ExtensionTestUtils.loadExtension({ + background() { + window.onload = () => browser.test.sendMessage("first_run"); + }, + }); + await extension.startup(); + await extension.awaitMessage("first_run"); + info("Fully loaded initial extension and its process, shutting down now"); + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + // Bug 1845352 regression test: the above call broke the test that verified + // process reuse, because unexpectedly the extension process was shut down + // when promiseShutdownManager triggered "quit-application-granted". +}); + +add_task( + { + // Here we confirm the usual default behavior. We explicitly set the pref + // to 0 because head_remote.js sets the value to 1. + pref_set: [["dom.ipc.keepProcessesAlive.extension", 0]], + }, + async function shutdown_extension_process_on_extension_background_unload() { + info("Starting and unloading first extension"); + let pid1 = await loadExtensionAndGetPid(); + + info("Extension process should end after unloading the only extension doc"); + await processWatcher.waitForTermination(pid1); + } +); + +add_task( + { + // This test verifies that dom.ipc.keepProcessesAlive.extension=1 works, + // because we rely on it in unit tests, mainly to minimize overhead. + pref_set: [["dom.ipc.keepProcessesAlive.extension", 1]], + }, + async function extension_process_reused_between_background_page_restarts() { + info("Starting and unloading first extension"); + let pid1 = await loadExtensionAndGetPid(); + + info("Process should be alive after unloading the only extension (1)"); + await processWatcher.waitAndCheckIsProcessAlive(pid1); + + info("Starting and unloading second extension"); + let pid2 = await loadExtensionAndGetPid(); + Assert.equal(pid1, pid2, "Extension process was reused"); + + info("Process should be alive after unloading the only extension (2)"); + await processWatcher.waitAndCheckIsProcessAlive(pid1); + + // Try again repeatedly for many times to verify that this is not a fluke. + // The number of attempts is arbitrarily chosen. + for (let i = 1; i <= 9; ++i) { + let pid3 = await loadExtensionAndGetPid(); + Assert.equal(pid1, pid3, `Extension process was reused at attempt ${i}`); + } + + info("Process should be alive after unloading the only extension (3)"); + await processWatcher.waitAndCheckIsProcessAlive(pid1); + + // Note: while this task started without extension process, we end this + // task with an extension process still running. + } +); + +add_task( + { + // Here we confirm the usual default behavior. We explicitly set the pref + // to 0 because head_remote.js sets the value to 1. + pref_set: [["dom.ipc.keepProcessesAlive.extension", 0]], + }, + async function shutdown_extension_process_on_last_extension_page_unload() { + let extension = ExtensionTestUtils.loadExtension({ + files: { + "page.html": `<!DOCTYPE html><script src="page.js"></script>`, + "page.js": () => browser.test.sendMessage("page_loaded"), + }, + }); + + await extension.startup(); + const EXT_PAGE = `moz-extension://${extension.uuid}/page.html`; + async function openOnlyExtensionPageAndGetPid() { + let contentPage = await ExtensionTestUtils.loadContentPage(EXT_PAGE); + await extension.awaitMessage("page_loaded"); + let pid = pidOfContentPage(contentPage); + await contentPage.close(); + return pid; + } + + const timeStart = Date.now(); + info("Opening first page"); + let contentPage = await ExtensionTestUtils.loadContentPage(EXT_PAGE); + await extension.awaitMessage("page_loaded"); + let pid1 = pidOfContentPage(contentPage); + + info("Opening and closing second page while the first is open"); + let pid2 = await openOnlyExtensionPageAndGetPid(); + Assert.equal(pid1, pid2, "Second page should re-use first page's process"); + Assert.ok(processWatcher.isProcessAlive(pid1), "Process not dead"); + await contentPage.close(); + info("Closed last page - extension process should terminate"); + // pid1 should have died when we closed ContentPage. But in case shut down + // is not immediate, wait a little bit. + await processWatcher.waitForTermination(pid1); + + let pid3 = await openOnlyExtensionPageAndGetPid(); + Assert.notEqual(pid2, pid3, "Should get a new extension process"); + + await extension.unload(); + await processWatcher.waitForTermination(pid3); + + // By now, we have witnessed: + // 1. extension process spawned. + // 2. first extension tab loaded. + // 3. second extension tab loaded. + // 4. extension process terminated after closing tabs. + // 5. extension process spawned + terminated after opening and closing tab. + // This should be plenty of time for any unexpected process termination to + // have been observed. So wait for that time and not longer. + processWatcher.setExtProcessTerminationDeadline(Date.now() - timeStart); + } +); + +add_task( + { + // This test verifies that dom.ipc.keepProcessesAlive.extension=1 works, + // because we rely on it in unit tests, mainly to minimize overhead. + pref_set: [["dom.ipc.keepProcessesAlive.extension", 1]], + }, + async function keep_extension_process_on_last_extension_page_unload() { + let extension = ExtensionTestUtils.loadExtension({ + files: { + "page.html": `<!DOCTYPE html><script src="page.js"></script>`, + "page.js": () => browser.test.sendMessage("page_loaded"), + }, + }); + + await extension.startup(); + const EXT_PAGE = `moz-extension://${extension.uuid}/page.html`; + async function openOnlyExtensionPageAndGetPid() { + let contentPage = await ExtensionTestUtils.loadContentPage(EXT_PAGE); + await extension.awaitMessage("page_loaded"); + let pid = pidOfContentPage(contentPage); + await contentPage.close(); + return pid; + } + + info("Opening and closing first page"); + let pid1 = await openOnlyExtensionPageAndGetPid(); + + info("No extension pages, but extension process should still be alive (1)"); + await processWatcher.waitAndCheckIsProcessAlive(pid1); + + let pid2 = await openOnlyExtensionPageAndGetPid(); + Assert.equal(pid1, pid2, "Extension process is reused by second page"); + + info("No extension pages, but extension process should still be alive (2)"); + await processWatcher.waitAndCheckIsProcessAlive(pid1); + + await extension.unload(); + info("No extensions around, but extension process should still be alive"); + + await processWatcher.waitAndCheckIsProcessAlive(pid1); + + // Note: while this task started without extension process, we end this + // task with an extension process still running. + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_load_all_api_modules.js b/toolkit/components/extensions/test/xpcshell/test_load_all_api_modules.js new file mode 100644 index 0000000000..32299fb04e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_load_all_api_modules.js @@ -0,0 +1,171 @@ +"use strict"; + +const { Schemas } = ChromeUtils.importESModule( + "resource://gre/modules/Schemas.sys.mjs" +); + +const BASE_SCHEMA = "chrome://extensions/content/schemas/manifest.json"; + +const CATEGORY_EXTENSION_MODULES = "webextension-modules"; +const CATEGORY_EXTENSION_SCHEMAS = "webextension-schemas"; +const CATEGORY_EXTENSION_SCRIPTS = "webextension-scripts"; + +const CATEGORY_EXTENSION_SCRIPTS_ADDON = "webextension-scripts-addon"; +const CATEGORY_EXTENSION_SCRIPTS_CONTENT = "webextension-scripts-content"; +const CATEGORY_EXTENSION_SCRIPTS_DEVTOOLS = "webextension-scripts-devtools"; + +let schemaURLs = new Set(); +schemaURLs.add("chrome://extensions/content/schemas/experiments.json"); + +// Helper class used to load the API modules similarly to the apiManager +// defined in ExtensionParent.jsm. +class FakeAPIManager extends ExtensionCommon.SchemaAPIManager { + constructor(processType = "main") { + super(processType, Schemas); + this.initialized = false; + } + + getModuleJSONURLs() { + return Array.from( + Services.catMan.enumerateCategory(CATEGORY_EXTENSION_MODULES), + ({ value }) => value + ); + } + + async lazyInit() { + if (this.initialized) { + return; + } + + this.initialized = true; + + let modulesPromise = this.loadModuleJSON(this.getModuleJSONURLs()); + + let scriptURLs = []; + for (let { value } of Services.catMan.enumerateCategory( + CATEGORY_EXTENSION_SCRIPTS + )) { + scriptURLs.push(value); + } + + let scripts = await Promise.all( + scriptURLs.map(url => ChromeUtils.compileScript(url)) + ); + + this.initModuleData(await modulesPromise); + + this.initGlobal(); + for (let script of scripts) { + script.executeInGlobal(this.global); + } + + // Load order matters here. The base manifest defines types which are + // extended by other schemas, so needs to be loaded first. + await Schemas.load(BASE_SCHEMA).then(() => { + let promises = []; + for (let { value } of Services.catMan.enumerateCategory( + CATEGORY_EXTENSION_SCHEMAS + )) { + promises.push(Schemas.load(value)); + } + for (let [url, { content }] of this.schemaURLs) { + promises.push(Schemas.load(url, content)); + } + for (let url of schemaURLs) { + promises.push(Schemas.load(url)); + } + return Promise.all(promises).then(() => { + Schemas.updateSharedSchemas(); + }); + }); + } + + async loadAllModules(reverseOrder = false) { + await this.lazyInit(); + + let apiModuleNames = Array.from(this.modules.keys()) + .filter(moduleName => { + let moduleDesc = this.modules.get(moduleName); + return moduleDesc && !!moduleDesc.url; + }) + .sort(); + + apiModuleNames = reverseOrder ? apiModuleNames.reverse() : apiModuleNames; + + for (let apiModule of apiModuleNames) { + info( + `Loading apiModule ${apiModule}: ${this.modules.get(apiModule).url}` + ); + await this.asyncLoadModule(apiModule); + } + } +} + +// Specialized helper class used to test loading "child process" modules (similarly to the +// SchemaAPIManagers sub-classes defined in ExtensionPageChild.jsm and ExtensionContent.jsm). +class FakeChildProcessAPIManager extends FakeAPIManager { + constructor({ processType, categoryScripts }) { + super(processType, Schemas); + + this.categoryScripts = categoryScripts; + } + + async lazyInit() { + if (!this.initialized) { + this.initialized = true; + this.initGlobal(); + for (let { value } of Services.catMan.enumerateCategory( + this.categoryScripts + )) { + await this.loadScript(value); + } + } + } +} + +async function test_loading_api_modules(createAPIManager) { + let fakeAPIManager; + + info("Load API modules in alphabetic order"); + + fakeAPIManager = createAPIManager(); + await fakeAPIManager.loadAllModules(); + + info("Load API modules in reverse order"); + + fakeAPIManager = createAPIManager(); + await fakeAPIManager.loadAllModules(true); +} + +add_task(function test_loading_main_process_api_modules() { + return test_loading_api_modules(() => { + return new FakeAPIManager(); + }); +}); + +add_task(function test_loading_extension_process_modules() { + return test_loading_api_modules(() => { + return new FakeChildProcessAPIManager({ + processType: "addon", + categoryScripts: CATEGORY_EXTENSION_SCRIPTS_ADDON, + }); + }); +}); + +add_task(function test_loading_devtools_modules() { + return test_loading_api_modules(() => { + return new FakeChildProcessAPIManager({ + processType: "devtools", + categoryScripts: CATEGORY_EXTENSION_SCRIPTS_DEVTOOLS, + }); + }); +}); + +add_task(async function test_loading_content_process_modules() { + return test_loading_api_modules(() => { + return new FakeChildProcessAPIManager({ + processType: "content", + categoryScripts: CATEGORY_EXTENSION_SCRIPTS_CONTENT, + }); + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_locale_converter.js b/toolkit/components/extensions/test/xpcshell/test_locale_converter.js new file mode 100644 index 0000000000..2c57b6bf31 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_locale_converter.js @@ -0,0 +1,146 @@ +"use strict"; + +const convService = Cc["@mozilla.org/streamConverters;1"].getService( + Ci.nsIStreamConverterService +); + +const UUID = "72b61ee3-aceb-476c-be1b-0822b036c9f1"; +const ADDON_ID = "test@web.extension"; +const URI = NetUtil.newURI(`moz-extension://${UUID}/file.css`); + +const FROM_TYPE = "application/vnd.mozilla.webext.unlocalized"; +const TO_TYPE = "text/css"; + +function StringStream(string) { + let stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance( + Ci.nsIStringInputStream + ); + + stream.data = string; + return stream; +} + +// Initialize the policy service with a stub localizer for our +// add-on ID. +add_task(async function init() { + let policy = new WebExtensionPolicy({ + id: ADDON_ID, + mozExtensionHostname: UUID, + baseURL: "file:///", + + allowedOrigins: new MatchPatternSet([]), + + localizeCallback(string) { + return string.replace(/__MSG_(.*?)__/g, "<localized-$1>"); + }, + }); + + policy.active = true; + + registerCleanupFunction(() => { + policy.active = false; + }); +}); + +// Test that the synchronous converter works as expected with a +// simple string. +add_task(async function testSynchronousConvert() { + let stream = StringStream("Foo __MSG_xxx__ bar __MSG_yyy__ baz"); + + let resultStream = convService.convert(stream, FROM_TYPE, TO_TYPE, URI); + + let result = NetUtil.readInputStreamToString( + resultStream, + resultStream.available() + ); + + equal(result, "Foo <localized-xxx> bar <localized-yyy> baz"); +}); + +// Test that the asynchronous converter works as expected with input +// split into multiple chunks, and a boundary in the middle of a +// replacement token. +add_task(async function testAsyncConvert() { + let listener; + let awaitResult = new Promise((resolve, reject) => { + listener = { + QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]), + + onDataAvailable(request, inputStream, offset, count) { + this.resultParts.push( + NetUtil.readInputStreamToString(inputStream, count) + ); + }, + + onStartRequest() { + ok(!("resultParts" in this)); + this.resultParts = []; + }, + + onStopRequest(request, context, statusCode) { + if (!Components.isSuccessCode(statusCode)) { + reject(new Error(statusCode)); + } + + resolve(this.resultParts.join("\n")); + }, + }; + }); + + let parts = ["Foo __MSG_x", "xx__ bar __MSG_yyy__ baz"]; + + let converter = convService.asyncConvertData( + FROM_TYPE, + TO_TYPE, + listener, + URI + ); + converter.onStartRequest(null, null); + + for (let part of parts) { + converter.onDataAvailable(null, StringStream(part), 0, part.length); + } + + converter.onStopRequest(null, null, Cr.NS_OK); + + let result = await awaitResult; + equal(result, "Foo <localized-xxx> bar <localized-yyy> baz"); +}); + +// Test that attempting to initialize a converter with the URI of a +// nonexistent WebExtension fails. +add_task(async function testInvalidUUID() { + let uri = NetUtil.newURI( + "moz-extension://eb4f3be8-41c9-4970-aa6d-b84d1ecc02b2/file.css" + ); + let stream = StringStream("Foo __MSG_xxx__ bar __MSG_yyy__ baz"); + + // Assert.throws raise a TypeError exception when the expected param + // is an arrow function. (See Bug 1237961 for rationale) + let expectInvalidContextException = function (e) { + return e.result === Cr.NS_ERROR_INVALID_ARG && /Invalid context/.test(e); + }; + + Assert.throws(() => { + convService.convert(stream, FROM_TYPE, TO_TYPE, uri); + }, expectInvalidContextException); + + Assert.throws(() => { + let listener = { + QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]), + }; + + convService.asyncConvertData(FROM_TYPE, TO_TYPE, listener, uri); + }, expectInvalidContextException); +}); + +// Test that an empty stream does not throw an NS_ERROR_ILLEGAL_VALUE. +add_task(async function testEmptyStream() { + let stream = StringStream(""); + let resultStream = convService.convert(stream, FROM_TYPE, TO_TYPE, URI); + equal( + resultStream.available(), + 0, + "Size of output stream should match size of input stream" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_locale_data.js b/toolkit/components/extensions/test/xpcshell/test_locale_data.js new file mode 100644 index 0000000000..31468e91d3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_locale_data.js @@ -0,0 +1,221 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +const { ExtensionData } = ChromeUtils.importESModule( + "resource://gre/modules/Extension.sys.mjs" +); + +async function generateAddon(data) { + let xpi = AddonTestUtils.createTempWebExtensionFile(data); + + let fileURI = Services.io.newFileURI(xpi); + let jarURI = NetUtil.newURI(`jar:${fileURI.spec}!/`); + + let extension = new ExtensionData(jarURI, false); + await extension.loadManifest(); + + return extension; +} + +add_task(async function testMissingDefaultLocale() { + let extension = await generateAddon({ + files: { + "_locales/en_US/messages.json": {}, + }, + }); + + equal(extension.errors.length, 0, "No errors reported"); + + await extension.initAllLocales(); + + equal(extension.errors.length, 1, "One error reported"); + + info(`Got error: ${extension.errors[0]}`); + + ok( + extension.errors[0].includes('"default_locale" property is required'), + "Got missing default_locale error" + ); +}); + +add_task(async function testInvalidDefaultLocale() { + let extension = await generateAddon({ + manifest: { + default_locale: "en", + }, + + files: { + "_locales/en_US/messages.json": {}, + }, + }); + + equal(extension.errors.length, 1, "One error reported"); + + info(`Got error: ${extension.errors[0]}`); + + ok( + extension.errors[0].includes( + "Loading locale file _locales/en/messages.json" + ), + "Got invalid default_locale error" + ); + + await extension.initAllLocales(); + + equal(extension.errors.length, 2, "Two errors reported"); + + info(`Got error: ${extension.errors[1]}`); + + ok( + extension.errors[1].includes('"default_locale" property must correspond'), + "Got invalid default_locale error" + ); +}); + +add_task(async function testUnexpectedDefaultLocale() { + let extension = await generateAddon({ + manifest: { + default_locale: "en_US", + }, + }); + + equal(extension.errors.length, 1, "One error reported"); + + info(`Got error: ${extension.errors[0]}`); + + ok( + extension.errors[0].includes( + "Loading locale file _locales/en-US/messages.json" + ), + "Got invalid default_locale error" + ); + + await extension.initAllLocales(); + + equal(extension.errors.length, 2, "One error reported"); + + info(`Got error: ${extension.errors[1]}`); + + ok( + extension.errors[1].includes('"default_locale" property must correspond'), + "Got unexpected default_locale error" + ); +}); + +add_task(async function testInvalidSyntax() { + let extension = await generateAddon({ + manifest: { + default_locale: "en_US", + }, + + files: { + "_locales/en_US/messages.json": + '{foo: {message: "bar", description: "baz"}}', + }, + }); + + equal(extension.errors.length, 1, "No errors reported"); + + info(`Got error: ${extension.errors[0]}`); + + ok( + extension.errors[0].includes( + "Loading locale file _locales/en_US/messages.json: SyntaxError" + ), + "Got syntax error" + ); + + await extension.initAllLocales(); + + equal(extension.errors.length, 2, "One error reported"); + + info(`Got error: ${extension.errors[1]}`); + + ok( + extension.errors[1].includes( + "Loading locale file _locales/en_US/messages.json: SyntaxError" + ), + "Got syntax error" + ); +}); + +add_task(async function testExtractLocalizedManifest() { + let extension = await generateAddon({ + manifest: { + name: "__MSG_extensionName__", + default_locale: "en_US", + icons: { + 16: "__MSG_extensionIcon__", + }, + }, + + files: { + "_locales/en_US/messages.json": `{ + "extensionName": {"message": "foo"}, + "extensionIcon": {"message": "icon-en.png"} + }`, + "_locales/de_DE/messages.json": `{ + "extensionName": {"message": "bar"}, + "extensionIcon": {"message": "icon-de.png"} + }`, + }, + }); + + await extension.loadManifest(); + equal(extension.manifest.name, "foo", "name localized"); + equal(extension.manifest.icons["16"], "icon-en.png", "icons localized"); + + let manifest = await extension.getLocalizedManifest("de-DE"); + ok(extension.localeData.has("de-DE"), "has de_DE locale"); + equal(manifest.name, "bar", "name localized"); + equal(manifest.icons["16"], "icon-de.png", "icons localized"); + + await Assert.rejects( + extension.getLocalizedManifest("xx-XX"), + /does not contain the locale xx-XX/, + "xx-XX does not exist" + ); +}); + +add_task(async function testRestartThenExtractLocalizedManifest() { + await AddonTestUtils.promiseStartupManager(); + + let wrapper = ExtensionTestUtils.loadExtension({ + manifest: { + name: "__MSG_extensionName__", + default_locale: "en_US", + }, + useAddonManager: "permanent", + files: { + "_locales/en_US/messages.json": '{"extensionName": {"message": "foo"}}', + "_locales/de_DE/messages.json": '{"extensionName": {"message": "bar"}}', + }, + }); + + await wrapper.startup(); + + await AddonTestUtils.promiseRestartManager(); + await wrapper.startupPromise; + + let { extension } = wrapper; + let manifest = await extension.getLocalizedManifest("de-DE"); + ok(extension.localeData.has("de-DE"), "has de_DE locale"); + equal(manifest.name, "bar", "name localized"); + + await Assert.rejects( + extension.getLocalizedManifest("xx-XX"), + /does not contain the locale xx-XX/, + "xx-XX does not exist" + ); + + await wrapper.unload(); + await AddonTestUtils.promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_native_manifests.js b/toolkit/components/extensions/test/xpcshell/test_native_manifests.js new file mode 100644 index 0000000000..6a6fb91e3f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_native_manifests.js @@ -0,0 +1,543 @@ +"use strict"; + +const { AsyncShutdown } = ChromeUtils.importESModule( + "resource://gre/modules/AsyncShutdown.sys.mjs" +); +const { NativeManifests } = ChromeUtils.importESModule( + "resource://gre/modules/NativeManifests.sys.mjs" +); +const { FileUtils } = ChromeUtils.importESModule( + "resource://gre/modules/FileUtils.sys.mjs" +); +const { Schemas } = ChromeUtils.importESModule( + "resource://gre/modules/Schemas.sys.mjs" +); +const { Subprocess } = ChromeUtils.importESModule( + "resource://gre/modules/Subprocess.sys.mjs" +); +const { NativeApp } = ChromeUtils.importESModule( + "resource://gre/modules/NativeMessaging.sys.mjs" +); + +let registry = null; +if (AppConstants.platform == "win") { + var { MockRegistry } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistry.sys.mjs" + ); + registry = new MockRegistry(); + registerCleanupFunction(() => { + registry.shutdown(); + }); + ChromeUtils.defineESModuleGetters(this, { + SubprocessImpl: "resource://gre/modules/subprocess/subprocess_win.sys.mjs", + }); +} else { + ChromeUtils.defineESModuleGetters(this, { + SubprocessImpl: "resource://gre/modules/subprocess/subprocess_unix.sys.mjs", + }); +} + +const REGPATH = "Software\\Mozilla\\NativeMessagingHosts"; + +const BASE_SCHEMA = "chrome://extensions/content/schemas/manifest.json"; + +const TYPE_SLUG = + AppConstants.platform === "linux" + ? "native-messaging-hosts" + : "NativeMessagingHosts"; + +let dir = FileUtils.getDir("TmpD", ["NativeManifests"]); +dir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + +let userDir = dir.clone(); +userDir.append("user"); +userDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + +let globalDir = dir.clone(); +globalDir.append("global"); +globalDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + +add_setup(async function setup() { + await IOUtils.makeDirectory(PathUtils.join(userDir.path, TYPE_SLUG)); + await IOUtils.makeDirectory(PathUtils.join(globalDir.path, TYPE_SLUG)); +}); + +let dirProvider = { + getFile(property) { + if (property == "XREUserNativeManifests") { + return userDir.clone(); + } else if (property == "XRESysNativeManifests") { + return globalDir.clone(); + } + return null; + }, +}; + +Services.dirsvc.registerProvider(dirProvider); + +registerCleanupFunction(() => { + Services.dirsvc.unregisterProvider(dirProvider); + dir.remove(true); +}); + +function writeManifest(path, manifest) { + if (typeof manifest != "string") { + manifest = JSON.stringify(manifest); + } + return IOUtils.writeUTF8(path, manifest); +} + +let PYTHON; +add_task(async function setup() { + await Schemas.load(BASE_SCHEMA); + + try { + PYTHON = await Subprocess.pathSearch(Services.env.get("PYTHON")); + } catch (e) { + notEqual( + PYTHON, + null, + `Can't find a suitable python interpreter ${e.message}` + ); + } +}); + +let global = this; + +// Test of NativeManifests.lookupApplication() begin here... +let context = { + extension: { + id: "extension@tests.mozilla.org", + }, + manifestVersion: 2, + envType: "addon_parent", + url: null, + jsonStringify(...args) { + return JSON.stringify(...args); + }, + cloneScope: global, + logError() {}, + preprocessors: {}, + callOnClose: () => {}, + forgetOnClose: () => {}, +}; + +class MockContext extends ExtensionCommon.BaseContext { + constructor(extensionId) { + let fakeExtension = { id: extensionId, manifestVersion: 2 }; + super("addon_parent", fakeExtension); + this.sandbox = Cu.Sandbox(global); + } + + get cloneScope() { + return global; + } + + get principal() { + return Cu.getObjectPrincipal(this.sandbox); + } +} + +let templateManifest = { + name: "test", + description: "this is only a test", + path: "/bin/cat", + type: "stdio", + allowed_extensions: ["extension@tests.mozilla.org"], +}; + +function lookupApplication(app, ctx) { + return NativeManifests.lookupManifest("stdio", app, ctx); +} + +add_task(async function test_nonexistent_manifest() { + let result = await lookupApplication("test", context); + equal( + result, + null, + "lookupApplication returns null for non-existent application" + ); +}); + +const USER_TEST_JSON = PathUtils.join(userDir.path, TYPE_SLUG, "test.json"); + +add_task(async function test_nonexistent_manifest_with_registry_entry() { + if (registry) { + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGPATH}\\test`, + "", + USER_TEST_JSON + ); + } + + await IOUtils.remove(USER_TEST_JSON); + let { messages, result } = await promiseConsoleOutput(() => + lookupApplication("test", context) + ); + equal( + result, + null, + "lookupApplication returns null for non-existent manifest" + ); + + let noSuchFileErrors = messages.filter(logMessage => + logMessage.message.includes( + "file is referenced in the registry but does not exist" + ) + ); + + if (registry) { + equal( + noSuchFileErrors.length, + 1, + "lookupApplication logs a non-existent manifest file pointed to by the registry" + ); + } else { + equal( + noSuchFileErrors.length, + 0, + "lookupApplication does not log about registry on non-windows platforms" + ); + } +}); + +add_task(async function test_good_manifest() { + await writeManifest(USER_TEST_JSON, templateManifest); + if (registry) { + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGPATH}\\test`, + "", + USER_TEST_JSON + ); + } + + let result = await lookupApplication("test", context); + notEqual(result, null, "lookupApplication finds a good manifest"); + equal( + result.path, + USER_TEST_JSON, + "lookupApplication returns the correct path" + ); + deepEqual( + result.manifest, + templateManifest, + "lookupApplication returns the manifest contents" + ); +}); + +add_task( + { skip_if: () => AppConstants.platform != "win" }, + async function test_forward_slashes_instead_of_backslashes_in_registry() { + Assert.ok(USER_TEST_JSON.includes("\\"), `Path has \\: ${USER_TEST_JSON}`); + const manifest = { ...templateManifest, name: "testslash" }; + await writeManifest(USER_TEST_JSON, manifest); + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGPATH}\\testslash`, + "", + USER_TEST_JSON.replaceAll("\\", "/") + ); + + let result = await lookupApplication("testslash", context); + notEqual(result, null, "lookupApplication finds the manifest despite /"); + equal( + result.path, + USER_TEST_JSON, + "lookupApplication returns the correct path with platform-native slash" + ); + // Side note: manifest.path does not contain a platform-native path, + // but it is normalized when used in NativeMessaging.jsm. + deepEqual( + result.manifest, + manifest, + "lookupApplication returns the manifest contents" + ); + } +); + +add_task(async function test_manifest_with_utf8_byte_order_mark() { + const manifest = { ...templateManifest, description: "had BOM at start" }; + const manifestString = JSON.stringify(manifest); + + // "123" to have a placeholder where we'll fill in the 3 BOM bytes. + const manifestBytes = new TextEncoder().encode("123" + manifestString); + manifestBytes.set([0xef, 0xbb, 0xbf]); + + // Sanity check: verify that the bytes prepended above have the special + // meaning of being a UTF-8 BOM. That is, when parsed as UTF-8, the bytes can + // be removed without loss of meaning. + equal( + new TextDecoder().decode(manifestBytes), + manifestString, + "Sanity check: input bytes has UTF-8 BOM that is ordinarily stripped" + ); + + await IOUtils.write(USER_TEST_JSON, manifestBytes); + if (registry) { + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGPATH}\\test`, + "", + USER_TEST_JSON + ); + } + let result = await lookupApplication("test", context); + notEqual(result, null, "lookupApplication finds a good manifest despite BOM"); + deepEqual( + result.manifest, + manifest, + "lookupApplication returns the manifest contents" + ); +}); + +add_task(async function test_manifest_with_invalid_utf_8() { + const manifest = { ...templateManifest, description: "bad bytes" }; + const manifestString = JSON.stringify(manifest); + const manifestBytes = Uint8Array.from(manifestString, c => c.charCodeAt(0)); + // manifestString ends with `bad bytes"}`. Replace the `s` with a bad byte: + manifestBytes.set([0xff], manifestBytes.byteLength - 3); + + await IOUtils.write(USER_TEST_JSON, manifestBytes); + if (registry) { + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGPATH}\\test`, + "", + USER_TEST_JSON + ); + } + let { messages, result } = await promiseConsoleOutput(() => + lookupApplication("test", context) + ); + equal(result, null, "lookupApplication should reject file with invalid UTF8"); + let errorPattern = + /NotReadableError: Could not read file.* because it is not UTF-8 encoded/; + let utf8Errors = messages.filter(({ message }) => errorPattern.test(message)); + equal(utf8Errors.length, 1, "lookupApplication logs error about UTF-8"); +}); + +add_task(async function test_invalid_json() { + await writeManifest(USER_TEST_JSON, "this is not valid json"); + let { messages, result } = await promiseConsoleOutput(() => + lookupApplication("test", context) + ); + equal(result, null, "lookupApplication ignores bad json"); + let errorPattern = /Error parsing native manifest .*test.json: JSON\.parse:/; + let jsonErrors = messages.filter(({ message }) => errorPattern.test(message)); + equal(jsonErrors.length, 1, "lookupApplication logs JSON error"); +}); + +add_task(async function test_invalid_name() { + let manifest = Object.assign({}, templateManifest); + manifest.name = "../test"; + await writeManifest(USER_TEST_JSON, manifest); + let result = await lookupApplication("test", context); + equal(result, null, "lookupApplication ignores an invalid name"); +}); + +add_task(async function test_name_mismatch() { + let manifest = Object.assign({}, templateManifest); + manifest.name = "not test"; + await writeManifest(USER_TEST_JSON, manifest); + let result = await lookupApplication("test", context); + let what = AppConstants.platform == "win" ? "registry key" : "json filename"; + equal( + result, + null, + `lookupApplication ignores mistmatch between ${what} and name property` + ); +}); + +add_task(async function test_missing_props() { + const PROPS = ["name", "description", "path", "type", "allowed_extensions"]; + for (let prop of PROPS) { + let manifest = Object.assign({}, templateManifest); + delete manifest[prop]; + + await writeManifest(USER_TEST_JSON, manifest); + let result = await lookupApplication("test", context); + equal(result, null, `lookupApplication ignores missing ${prop}`); + } +}); + +add_task(async function test_invalid_type() { + let manifest = Object.assign({}, templateManifest); + manifest.type = "bogus"; + await writeManifest(USER_TEST_JSON, manifest); + let result = await lookupApplication("test", context); + equal(result, null, "lookupApplication ignores invalid type"); +}); + +add_task(async function test_no_allowed_extensions() { + let manifest = Object.assign({}, templateManifest); + manifest.allowed_extensions = []; + await writeManifest(USER_TEST_JSON, manifest); + let result = await lookupApplication("test", context); + equal( + result, + null, + "lookupApplication ignores manifest with no allowed_extensions" + ); +}); + +const GLOBAL_TEST_JSON = PathUtils.join(globalDir.path, TYPE_SLUG, "test.json"); +let globalManifest = Object.assign({}, templateManifest); +globalManifest.description = "This manifest is from the systemwide directory"; + +add_task(async function good_manifest_system_dir() { + await IOUtils.remove(USER_TEST_JSON); + await writeManifest(GLOBAL_TEST_JSON, globalManifest); + if (registry) { + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGPATH}\\test`, + "", + null + ); + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE, + `${REGPATH}\\test`, + "", + GLOBAL_TEST_JSON + ); + } + + let where = + AppConstants.platform == "win" ? "registry location" : "directory"; + let result = await lookupApplication("test", context); + notEqual( + result, + null, + `lookupApplication finds a manifest in the system-wide ${where}` + ); + equal( + result.path, + GLOBAL_TEST_JSON, + `lookupApplication returns path in the system-wide ${where}` + ); + deepEqual( + result.manifest, + globalManifest, + `lookupApplication returns manifest contents from the system-wide ${where}` + ); +}); + +add_task(async function test_user_dir_precedence() { + await writeManifest(USER_TEST_JSON, templateManifest); + if (registry) { + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGPATH}\\test`, + "", + USER_TEST_JSON + ); + } + // global test.json and LOCAL_MACHINE registry key on windows are + // still present from the previous test + + let result = await lookupApplication("test", context); + notEqual( + result, + null, + "lookupApplication finds a manifest when entries exist in both user-specific and system-wide locations" + ); + equal( + result.path, + USER_TEST_JSON, + "lookupApplication returns the user-specific path when user-specific and system-wide entries both exist" + ); + deepEqual( + result.manifest, + templateManifest, + "lookupApplication returns user-specific manifest contents with user-specific and system-wide entries both exist" + ); +}); + +// Test shutdown handling in NativeApp +add_task(async function test_native_app_shutdown() { + const SCRIPT = String.raw` +import signal +import struct +import sys + +signal.signal(signal.SIGTERM, signal.SIG_IGN) + +stdin = getattr(sys.stdin, 'buffer', sys.stdin) +stdout = getattr(sys.stdout, 'buffer', sys.stdout) + +while True: + rawlen = stdin.read(4) + if len(rawlen) == 0: + signal.pause() + msglen = struct.unpack('@I', rawlen)[0] + msg = stdin.read(msglen) + + stdout.write(struct.pack('@I', msglen)) + stdout.write(msg) +`; + + let scriptPath = PathUtils.join(userDir.path, TYPE_SLUG, "wontdie.py"); + let manifestPath = PathUtils.join(userDir.path, TYPE_SLUG, "wontdie.json"); + + const ID = "native@tests.mozilla.org"; + let manifest = { + name: "wontdie", + description: "test async shutdown of native apps", + type: "stdio", + allowed_extensions: [ID], + }; + + if (AppConstants.platform == "win") { + await IOUtils.writeUTF8(scriptPath, SCRIPT); + + let batPath = PathUtils.join(userDir.path, TYPE_SLUG, "wontdie.bat"); + let batBody = `@ECHO OFF\n${PYTHON} -u "${scriptPath}" %*\n`; + await IOUtils.writeUTF8(batPath, batBody); + await IOUtils.setPermissions(batPath, 0o755); + + manifest.path = batPath; + await writeManifest(manifestPath, manifest); + + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGPATH}\\wontdie`, + "", + manifestPath + ); + } else { + await IOUtils.writeUTF8(scriptPath, `#!${PYTHON} -u\n${SCRIPT}`); + await IOUtils.setPermissions(scriptPath, 0o755); + manifest.path = scriptPath; + await writeManifest(manifestPath, manifest); + } + + let mockContext = new MockContext(ID); + let app = new NativeApp(mockContext, "wontdie"); + + // send a message and wait for the reply to make sure the app is running + let MSG = "test"; + let recvPromise = new Promise(resolve => { + let listener = (what, msg) => { + equal(msg, MSG, "Received test message"); + app.off("message", listener); + resolve(); + }; + app.on("message", listener); + }); + + let buffer = NativeApp.encodeMessage(mockContext, MSG); + app.send(new StructuredCloneHolder("", null, buffer)); + await recvPromise; + + app._cleanup(); + + info("waiting for async shutdown"); + Services.prefs.setBoolPref("toolkit.asyncshutdown.testing", true); + AsyncShutdown.profileBeforeChange._trigger(); + Services.prefs.clearUserPref("toolkit.asyncshutdown.testing"); + + let procs = await SubprocessImpl.Process.getWorker().call("getProcesses", []); + equal(procs.size, 0, "native process exited"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_process_crash_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_process_crash_telemetry.js new file mode 100644 index 0000000000..151fce579e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_process_crash_telemetry.js @@ -0,0 +1,126 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { ExtensionProcessCrashObserver } = ChromeUtils.importESModule( + "resource://gre/modules/Extension.sys.mjs" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +add_setup(() => { + // FOG needs a profile directory to put its data in. + do_get_profile(); + // FOG needs to be initialized in order for data to flow + // NOTE: in mobile builds the test will pass without this call, + // but in Desktop build the xpcshell test would get stuck on + // the call to testGetValue. + Services.fog.initializeFOG(); + Services.fog.testResetFOG(); + + Assert.equal( + ExtensionProcessCrashObserver.appInForeground, + AppConstants.platform !== "android", + "Expect appInForeground to be initially true on desktop and false on android builds" + ); + + // For Android build we mock the app moving in the foreground for the first time + // (which, in a real Fenix instance, happens when the application receives the first + // call to the onPause lifecycle callback and the geckoview-initial-foreground + // topic is being notified to Gecko as a side-effect of that). + // + // We have to mock the app moving in the foreground before any of the test extension + // startup, so that both Desktop and Mobile builds are in the same initial foreground + // state for the rest of the test file. + if (AppConstants.platform === "android") { + info("Mock geckoview-initial-foreground observer service topic"); + ExtensionProcessCrashObserver.observe(null, "geckoview-initial-foreground"); + Assert.equal( + ExtensionProcessCrashObserver.appInForeground, + true, + "Expect appInForeground to be true after geckoview-initial-foreground topic" + ); + } +}); + +add_task(async function test_process_crash_telemetry() { + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background() { + browser.test.sendMessage("bg:loaded"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("bg:loaded"); + + let { currentProcessChildID } = ExtensionProcessCrashObserver; + + Assert.notEqual( + currentProcessChildID, + undefined, + "Expect ExtensionProcessCrashObserver.currentProcessChildID to be set" + ); + + Assert.equal( + ChromeUtils.getAllDOMProcesses().find( + pp => pp.childID == currentProcessChildID + )?.remoteType, + "extension", + "Expect a child process with remoteType extension to be found for the process childID set" + ); + + Assert.ok( + Glean.extensions.processEvent.created_fg.testGetValue() > 0, + "Expect glean processEvent.created_fg to be set." + ); + Assert.equal( + undefined, + Glean.extensions.processEvent.created_bg.testGetValue(), + "Expect glean processEvent.created_bg to be not set." + ); + + info("Mock application-background observer service topic"); + ExtensionProcessCrashObserver.observe(null, "application-background"); + + Assert.equal( + ExtensionProcessCrashObserver.appInForeground, + // Only in desktop builds we expect the flag to be always true + !ExtensionProcessCrashObserver._isAndroid, + "Got expected value set on ExtensionProcessCrashObserver.appInForeground" + ); + + info("Mock a process crash being notified"); + let propertyBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag2 + ); + propertyBag.setPropertyAsBool("abnormal", true); + ExtensionProcessCrashObserver.observe( + propertyBag, + "ipc:content-shutdown", + currentProcessChildID + ); + + if (ExtensionProcessCrashObserver._isAndroid) { + Assert.ok( + Glean.extensions.processEvent.crashed_bg.testGetValue() > 0, + "Expect glean processEvent.crashed_bg to be set on Android builds." + ); + } else { + Assert.ok( + Glean.extensions.processEvent.crashed_fg.testGetValue() > 0, + "Expect glean processEvent.crashed_fg to be set on desktop." + ); + } + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_proxy_failover.js b/toolkit/components/extensions/test/xpcshell/test_proxy_failover.js new file mode 100644 index 0000000000..bca71df63e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_proxy_failover.js @@ -0,0 +1,325 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +// Necessary for the pac script to proxy localhost requests +Services.prefs.setBoolPref("network.proxy.allow_hijacking_localhost", true); + +// Pref is not builtin if direct failover is disabled in compile config. +ChromeUtils.defineLazyGetter(this, "directFailoverDisabled", () => { + return ( + Services.prefs.getPrefType("network.proxy.failover_direct") == + Ci.nsIPrefBranch.PREF_INVALID + ); +}); + +const { ServiceRequest } = ChromeUtils.importESModule( + "resource://gre/modules/ServiceRequest.sys.mjs" +); + +// Prevent the request from reaching out to the network. +const { HttpServer } = ChromeUtils.importESModule( + "resource://testing-common/httpd.sys.mjs" +); + +// No hosts defined to avoid the default proxy filter setup. +const nonProxiedServer = createHttpServer(); +nonProxiedServer.registerPathHandler("/", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok!"); +}); +const { primaryHost, primaryPort } = nonProxiedServer.identity; + +function getProxyData(channel) { + if (!(channel instanceof Ci.nsIProxiedChannel) || !channel.proxyInfo) { + return; + } + let { type, host, port, sourceId } = channel.proxyInfo; + return { type, host, port, sourceId }; +} + +// Get a free port with no listener to use in the proxyinfo. +function getBadProxyPort() { + let server = new HttpServer(); + server.start(-1); + const badPort = server.identity.primaryPort; + server.stop(); + return badPort; +} + +function xhr(url, options = { beConservative: true, bypassProxy: false }) { + return new Promise((resolve, reject) => { + let req = new XMLHttpRequest(); + req.open("GET", `${url}?t=${Math.random()}`); + req.channel.QueryInterface(Ci.nsIHttpChannelInternal).beConservative = + options.beConservative; + req.channel.QueryInterface(Ci.nsIHttpChannelInternal).bypassProxy = + options.bypassProxy; + req.onload = () => { + resolve({ text: req.responseText, proxy: getProxyData(req.channel) }); + }; + req.onerror = () => { + reject({ status: req.status, proxy: getProxyData(req.channel) }); + }; + req.send(); + }); +} + +// Same as the above xhr call, but ServiceRequest is always beConservative. +// This is here to specifically test bypassProxy with ServiceRequest. +function serviceRequest(url, options = { bypassProxy: false }) { + return new Promise((resolve, reject) => { + let req = new ServiceRequest(); + req.open("GET", `${url}?t=${Math.random()}`, options); + req.onload = () => { + resolve({ text: req.responseText, proxy: getProxyData(req.channel) }); + }; + req.onerror = () => { + reject({ status: req.status, proxy: getProxyData(req.channel) }); + }; + req.send(); + }); +} + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); +}); + +async function getProxyExtension(proxyDetails) { + async function background(proxyDetails) { + browser.proxy.onRequest.addListener( + details => { + return proxyDetails; + }, + { urls: ["<all_urls>"] } + ); + + browser.test.sendMessage("proxied"); + } + let extensionData = { + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + background: `(${background})(${JSON.stringify(proxyDetails)})`, + incognitoOverride: "spanning", + useAddonManager: "temporary", + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("proxied"); + return extension; +} + +add_task(async function test_failover_content_direct() { + // load a content page for fetch and non-system principal, expect + // failover to direct will work. + const proxyDetails = [ + { type: "http", host: "127.0.0.1", port: getBadProxyPort() }, + { type: "direct" }, + ]; + + // We need to load the content page before loading the proxy extension + // to ensure that we have a valid content page to run fetch from. + let contentUrl = `http://${primaryHost}:${primaryPort}/`; + let page = await ExtensionTestUtils.loadContentPage(contentUrl); + + let extension = await getProxyExtension(proxyDetails); + + await ExtensionTestUtils.fetch(contentUrl, `${contentUrl}?t=${Math.random()}`) + .then(text => { + equal(text, "ok!", "fetch completed"); + }) + .catch(() => { + ok(false, "fetch failed"); + }); + + await extension.unload(); + await page.close(); +}); + +add_task( + { skip_if: () => directFailoverDisabled }, + async function test_failover_content() { + // load a content page for fetch and non-system principal, expect + // no failover + const proxyDetails = [ + { type: "http", host: "127.0.0.1", port: getBadProxyPort() }, + ]; + + // We need to load the content page before loading the proxy extension + // to ensure that we have a valid content page to run fetch from. + let contentUrl = `http://${primaryHost}:${primaryPort}/`; + let page = await ExtensionTestUtils.loadContentPage(contentUrl); + + let extension = await getProxyExtension(proxyDetails); + + await ExtensionTestUtils.fetch( + contentUrl, + `${contentUrl}?t=${Math.random()}` + ) + .then(text => { + ok(false, "xhr unexpectedly completed"); + }) + .catch(e => { + equal( + e.message, + "NetworkError when attempting to fetch resource.", + "fetch failed" + ); + }); + + await extension.unload(); + await page.close(); + } +); + +add_task( + { skip_if: () => directFailoverDisabled }, + async function test_failover_system() { + const proxyDetails = [ + { type: "http", host: "127.0.0.1", port: getBadProxyPort() }, + { type: "http", host: "127.0.0.1", port: getBadProxyPort() }, + ]; + + let extension = await getProxyExtension(proxyDetails); + + await xhr(`http://${primaryHost}:${primaryPort}/`) + .then(req => { + equal(req.proxy.type, "direct", "proxy failover to direct"); + equal(req.text, "ok!", "xhr completed"); + }) + .catch(req => { + ok(false, "xhr failed"); + }); + + await extension.unload(); + } +); + +add_task( + { + skip_if: () => + AppConstants.platform === "android" || directFailoverDisabled, + }, + async function test_failover_pac() { + const badPort = getBadProxyPort(); + + async function background(badPort) { + let pac = `function FindProxyForURL(url, host) { return "PROXY 127.0.0.1:${badPort}"; }`; + let proxySettings = { + proxyType: "autoConfig", + autoConfigUrl: `data:application/x-ns-proxy-autoconfig;charset=utf-8,${encodeURIComponent( + pac + )}`, + }; + + await browser.proxy.settings.set({ value: proxySettings }); + browser.test.sendMessage("proxied"); + } + let extensionData = { + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + background: `(${background})(${badPort})`, + incognitoOverride: "spanning", + useAddonManager: "temporary", + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("proxied"); + equal( + Services.prefs.getIntPref("network.proxy.type"), + 2, + "autoconfig type set" + ); + ok( + Services.prefs.getStringPref("network.proxy.autoconfig_url"), + "autoconfig url set" + ); + + await xhr(`http://${primaryHost}:${primaryPort}/`) + .then(req => { + equal(req.proxy.type, "direct", "proxy failover to direct"); + equal(req.text, "ok!", "xhr completed"); + }) + .catch(() => { + ok(false, "xhr failed"); + }); + + await extension.unload(); + } +); + +add_task(async function test_bypass_proxy() { + const proxyDetails = [ + { type: "http", host: "127.0.0.1", port: getBadProxyPort() }, + ]; + + let extension = await getProxyExtension(proxyDetails); + + await xhr(`http://${primaryHost}:${primaryPort}/`, { bypassProxy: true }) + .then(req => { + equal(req.proxy, undefined, "no proxy used"); + ok(true, "xhr completed"); + }) + .catch(req => { + equal(req.proxy, undefined, "no proxy used"); + ok(false, "xhr error"); + }); + + await extension.unload(); +}); + +add_task(async function test_bypass_proxy() { + const proxyDetails = [ + { type: "http", host: "127.0.0.1", port: getBadProxyPort() }, + ]; + + let extension = await getProxyExtension(proxyDetails); + + await serviceRequest(`http://${primaryHost}:${primaryPort}/`, { + bypassProxy: true, + }) + .then(req => { + equal(req.proxy, undefined, "no proxy used"); + ok(true, "xhr completed"); + }) + .catch(req => { + equal(req.proxy, undefined, "no proxy used"); + ok(false, "xhr error"); + }); + + await extension.unload(); +}); + +add_task(async function test_failover_system_off() { + // Test test failover failures, uncomment and set pref to false + Services.prefs.setBoolPref("network.proxy.failover_direct", false); + + const proxyDetails = [ + { type: "http", host: "127.0.0.1", port: getBadProxyPort() }, + ]; + + let extension = await getProxyExtension(proxyDetails); + + await xhr(`http://${primaryHost}:${primaryPort}/`) + .then(req => { + equal(req.proxy.sourceId, extension.id, "extension matches"); + ok(false, "xhr completed"); + }) + .catch(req => { + equal(req.proxy.sourceId, extension.id, "extension matches"); + equal(req.status, 0, "xhr failed"); + }); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_proxy_incognito.js b/toolkit/components/extensions/test/xpcshell/test_proxy_incognito.js new file mode 100644 index 0000000000..a37996c221 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_proxy_incognito.js @@ -0,0 +1,95 @@ +"use strict"; + +/* eslint no-unused-vars: ["error", {"args": "none", "varsIgnorePattern": "^(FindProxyForURL)$"}] */ + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +add_task(async function test_incognito_proxy_onRequest_access() { + // This extension will fail if it gets a private request. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + async background() { + browser.proxy.onRequest.addListener( + async details => { + browser.test.assertFalse( + details.incognito, + "incognito flag is not set" + ); + browser.test.notifyPass("proxy.onRequest"); + }, + { urls: ["<all_urls>"], types: ["main_frame"] } + ); + + // Actual call arguments do not matter here. + await browser.test.assertRejects( + browser.proxy.settings.set({ + value: { + proxyType: "none", + }, + }), + /proxy.settings requires private browsing permission/, + "proxy.settings requires private browsing permission." + ); + + browser.test.sendMessage("ready"); + }, + }); + await extension.startup(); + await extension.awaitMessage("ready"); + + let pextension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + background() { + browser.proxy.onRequest.addListener( + async details => { + browser.test.assertTrue( + details.incognito, + "incognito flag is set with filter" + ); + browser.test.sendMessage("proxy.onRequest.private"); + }, + { urls: ["<all_urls>"], types: ["main_frame"], incognito: true } + ); + + browser.proxy.onRequest.addListener( + async details => { + browser.test.assertFalse( + details.incognito, + "incognito flag is not set with filter" + ); + browser.test.notifyPass("proxy.onRequest.spanning"); + }, + { urls: ["<all_urls>"], types: ["main_frame"], incognito: false } + ); + }, + }); + await pextension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "https://example.com/dummy", + { privateBrowsing: true } + ); + await pextension.awaitMessage("proxy.onRequest.private"); + await contentPage.close(); + + contentPage = await ExtensionTestUtils.loadContentPage( + "https://example.com/dummy" + ); + await extension.awaitFinish("proxy.onRequest"); + await pextension.awaitFinish("proxy.onRequest.spanning"); + await contentPage.close(); + + await pextension.unload(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_proxy_info_results.js b/toolkit/components/extensions/test/xpcshell/test_proxy_info_results.js new file mode 100644 index 0000000000..0a7e1422d2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_proxy_info_results.js @@ -0,0 +1,462 @@ +"use strict"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "gProxyService", + "@mozilla.org/network/protocol-proxy-service;1", + "nsIProtocolProxyService" +); + +const TRANSPARENT_PROXY_RESOLVES_HOST = + Ci.nsIProxyInfo.TRANSPARENT_PROXY_RESOLVES_HOST; + +let extension; +add_task(async function setup() { + let extensionData = { + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + background() { + let settings = { proxy: null }; + + browser.proxy.onError.addListener(error => { + browser.test.log(`error received ${error.message}`); + browser.test.sendMessage("proxy-error-received", error); + }); + browser.test.onMessage.addListener((message, data) => { + if (message === "set-proxy") { + settings.proxy = data.proxy; + browser.test.sendMessage("proxy-set", settings.proxy); + } + }); + browser.proxy.onRequest.addListener( + () => { + return settings.proxy; + }, + { urls: ["<all_urls>"] } + ); + }, + }; + extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); +}); + +async function setupProxyResult(proxy) { + extension.sendMessage("set-proxy", { proxy }); + let proxyInfoSent = await extension.awaitMessage("proxy-set"); + deepEqual( + proxyInfoSent, + proxy, + "got back proxy data from the proxy listener" + ); +} + +async function testProxyResolution(test) { + let { uri, proxy, expected } = test; + let errorMsg; + if (expected.error) { + errorMsg = extension.awaitMessage("proxy-error-received"); + } + let proxyInfo = await new Promise((resolve, reject) => { + let channel = NetUtil.newChannel({ + uri, + loadUsingSystemPrincipal: true, + }); + + gProxyService.asyncResolve(channel, 0, { + onProxyAvailable(req, uri, pi, status) { + resolve(pi && pi.QueryInterface(Ci.nsIProxyInfo)); + }, + }); + }); + + let expectedProxyInfo = expected.proxyInfo; + if (expected.error) { + equal(proxyInfo, null, "Expected proxyInfo to be null"); + equal((await errorMsg).message, expected.error, "error received"); + } else if (proxy == null) { + equal(proxyInfo, expectedProxyInfo, "proxy is direct"); + } else { + for ( + let proxyUsed = proxyInfo; + proxyUsed; + proxyUsed = proxyUsed.failoverProxy + ) { + let { type, host, port, username, password, proxyDNS, failoverTimeout } = + expectedProxyInfo; + equal(proxyUsed.host, host, `Expected proxy host to be ${host}`); + equal(proxyUsed.port, port, `Expected proxy port to be ${port}`); + equal(proxyUsed.type, type, `Expected proxy type to be ${type}`); + // May be null or undefined depending on use of newProxyInfoWithAuth or newProxyInfo + equal( + proxyUsed.username || "", + username || "", + `Expected proxy username to be ${username}` + ); + equal( + proxyUsed.password || "", + password || "", + `Expected proxy password to be ${password}` + ); + equal( + proxyUsed.flags, + proxyDNS == undefined ? 0 : proxyDNS, + `Expected proxyDNS to be ${proxyDNS}` + ); + // Default timeout is 10 + equal( + proxyUsed.failoverTimeout, + failoverTimeout || 10, + `Expected failoverTimeout to be ${failoverTimeout}` + ); + expectedProxyInfo = expectedProxyInfo.failoverProxy; + } + } +} + +add_task(async function test_proxyInfo_results() { + let tests = [ + { + proxy: 5, + expected: { + error: "ProxyInfoData: proxyData must be an object or array of objects", + }, + }, + { + proxy: "INVALID", + expected: { + error: "ProxyInfoData: proxyData must be an object or array of objects", + }, + }, + { + proxy: { + type: "socks", + }, + expected: { + error: 'ProxyInfoData: Invalid proxy server host: "undefined"', + }, + }, + { + proxy: [ + { + type: "pptp", + host: "foo.bar", + port: 1080, + username: "mungosantamaria", + password: "pass123", + proxyDNS: true, + failoverTimeout: 3, + }, + { + type: "http", + host: "192.168.1.1", + port: 1128, + username: "mungosantamaria", + password: "word321", + }, + ], + expected: { + error: 'ProxyInfoData: Invalid proxy server type: "pptp"', + }, + }, + { + proxy: [ + { + type: "http", + host: "foo.bar", + port: 65536, + username: "mungosantamaria", + password: "pass123", + proxyDNS: true, + failoverTimeout: 3, + }, + { + type: "http", + host: "192.168.1.1", + port: 3128, + username: "mungosantamaria", + password: "word321", + }, + ], + expected: { + error: + "ProxyInfoData: Proxy server port 65536 outside range 1 to 65535", + }, + }, + { + proxy: [ + { + type: "http", + host: "foo.bar", + port: 3128, + proxyAuthorizationHeader: "test", + }, + ], + expected: { + error: 'ProxyInfoData: ProxyAuthorizationHeader requires type "https"', + }, + }, + { + proxy: [ + { + type: "http", + host: "foo.bar", + port: 3128, + connectionIsolationKey: 1234, + }, + ], + expected: { + error: 'ProxyInfoData: Invalid proxy connection isolation key: "1234"', + }, + }, + { + proxy: [{ type: "direct" }], + expected: { + proxyInfo: null, + }, + }, + { + proxy: { + host: "1.2.3.4", + port: "8080", + type: "http", + failoverProxy: null, + }, + expected: { + proxyInfo: { + host: "1.2.3.4", + port: "8080", + type: "http", + failoverProxy: null, + }, + }, + }, + { + uri: "ftp://mozilla.org", + proxy: { + host: "1.2.3.4", + port: "8180", + type: "http", + failoverProxy: null, + }, + expected: { + proxyInfo: { + host: "1.2.3.4", + port: "8180", + type: "http", + failoverProxy: null, + }, + }, + }, + { + proxy: { + host: "2.3.4.5", + port: "8181", + type: "http", + failoverProxy: null, + }, + expected: { + proxyInfo: { + host: "2.3.4.5", + port: "8181", + type: "http", + failoverProxy: null, + }, + }, + }, + { + proxy: { + host: "1.2.3.4", + port: "8080", + type: "http", + failoverProxy: { + host: "4.4.4.4", + port: "9000", + type: "socks", + failoverProxy: { + type: "direct", + host: null, + port: -1, + }, + }, + }, + expected: { + proxyInfo: { + host: "1.2.3.4", + port: "8080", + type: "http", + failoverProxy: { + host: "4.4.4.4", + port: "9000", + type: "socks", + failoverProxy: { + type: "direct", + host: null, + port: -1, + }, + }, + }, + }, + }, + { + proxy: [{ type: "http", host: "foo.bar", port: 3128 }], + expected: { + proxyInfo: { + host: "foo.bar", + port: "3128", + type: "http", + }, + }, + }, + { + proxy: { + host: "foo.bar", + port: "1080", + type: "socks", + }, + expected: { + proxyInfo: { + host: "foo.bar", + port: "1080", + type: "socks", + }, + }, + }, + { + proxy: { + host: "foo.bar", + port: "1080", + type: "socks4", + }, + expected: { + proxyInfo: { + host: "foo.bar", + port: "1080", + type: "socks4", + }, + }, + }, + { + proxy: [{ type: "https", host: "foo.bar", port: 3128 }], + expected: { + proxyInfo: { + host: "foo.bar", + port: "3128", + type: "https", + }, + }, + }, + { + proxy: [ + { + type: "socks", + host: "foo.bar", + port: 1080, + username: "mungo", + password: "santamaria123", + proxyDNS: true, + failoverTimeout: 5, + }, + ], + expected: { + proxyInfo: { + type: "socks", + host: "foo.bar", + port: 1080, + username: "mungo", + password: "santamaria123", + failoverTimeout: 5, + failoverProxy: null, + proxyDNS: TRANSPARENT_PROXY_RESOLVES_HOST, + }, + }, + }, + { + proxy: [ + { + type: "socks", + host: "foo.bar", + port: 1080, + username: "johnsmith", + password: "pass123", + proxyDNS: true, + failoverTimeout: 3, + }, + { type: "http", host: "192.168.1.1", port: 3128 }, + { type: "https", host: "192.168.1.2", port: 1121, failoverTimeout: 1 }, + { + type: "socks", + host: "192.168.1.3", + port: 1999, + proxyDNS: true, + username: "mungosantamaria", + password: "foobar", + }, + ], + expected: { + proxyInfo: { + type: "socks", + host: "foo.bar", + port: 1080, + proxyDNS: true, + username: "johnsmith", + password: "pass123", + failoverTimeout: 3, + failoverProxy: { + host: "192.168.1.1", + port: 3128, + type: "http", + failoverProxy: { + host: "192.168.1.2", + port: 1121, + type: "https", + failoverTimeout: 1, + failoverProxy: { + host: "192.168.1.3", + port: 1999, + type: "socks", + proxyDNS: TRANSPARENT_PROXY_RESOLVES_HOST, + username: "mungosantamaria", + password: "foobar", + failoverProxy: { + type: "direct", + }, + }, + }, + }, + }, + }, + }, + { + proxy: [ + { + type: "https", + host: "foo.bar", + port: 3128, + proxyAuthorizationHeader: "test", + connectionIsolationKey: "key", + }, + ], + expected: { + proxyInfo: { + host: "foo.bar", + port: "3128", + type: "https", + proxyAuthorizationHeader: "test", + connectionIsolationKey: "key", + }, + }, + }, + ]; + for (let test of tests) { + await setupProxyResult(test.proxy); + if (!test.uri) { + test.uri = "http://www.mozilla.org/"; + } + await testProxyResolution(test); + } +}); + +add_task(async function shutdown() { + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_proxy_listener.js b/toolkit/components/extensions/test/xpcshell/test_proxy_listener.js new file mode 100644 index 0000000000..8cc46d45e7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_proxy_listener.js @@ -0,0 +1,298 @@ +"use strict"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "gProxyService", + "@mozilla.org/network/protocol-proxy-service;1", + "nsIProtocolProxyService" +); + +const TRANSPARENT_PROXY_RESOLVES_HOST = + Ci.nsIProxyInfo.TRANSPARENT_PROXY_RESOLVES_HOST; + +function getProxyInfo(url = "http://www.mozilla.org/") { + return new Promise((resolve, reject) => { + let channel = NetUtil.newChannel({ + uri: url, + loadUsingSystemPrincipal: true, + }); + + gProxyService.asyncResolve(channel, 0, { + onProxyAvailable(req, uri, pi, status) { + resolve(pi); + }, + }); + }); +} + +const testData = [ + { + // An ExtensionError is thrown for this, but we are unable to catch it as we + // do with the PAC script api. In this case, we expect null for proxyInfo. + proxyInfo: "not_defined", + expected: { + proxyInfo: null, + }, + }, + { + proxyInfo: 1, + expected: { + error: { + message: + "ProxyInfoData: proxyData must be an object or array of objects", + }, + }, + }, + { + proxyInfo: [ + { + type: "socks", + host: "foo.bar", + port: 1080, + username: "johnsmith", + password: "pass123", + proxyDNS: true, + failoverTimeout: 3, + }, + { type: "http", host: "192.168.1.1", port: 3128 }, + { type: "https", host: "192.168.1.2", port: 1121, failoverTimeout: 1 }, + { + type: "socks", + host: "192.168.1.3", + port: 1999, + proxyDNS: true, + username: "mungosantamaria", + password: "foobar", + }, + { type: "direct" }, + ], + expected: { + proxyInfo: { + type: "socks", + host: "foo.bar", + port: 1080, + proxyDNS: true, + username: "johnsmith", + password: "pass123", + failoverTimeout: 3, + failoverProxy: { + host: "192.168.1.1", + port: 3128, + type: "http", + failoverProxy: { + host: "192.168.1.2", + port: 1121, + type: "https", + failoverTimeout: 1, + failoverProxy: { + host: "192.168.1.3", + port: 1999, + type: "socks", + proxyDNS: TRANSPARENT_PROXY_RESOLVES_HOST, + username: "mungosantamaria", + password: "foobar", + failoverProxy: { + type: "direct", + }, + }, + }, + }, + }, + }, + }, +]; + +add_task(async function test_proxy_listener() { + let extensionData = { + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + background() { + // Some tests generate multiple errors, we'll just rely on the first. + let seenError = false; + let proxyInfo; + browser.proxy.onError.addListener(error => { + if (!seenError) { + browser.test.sendMessage("proxy-error-received", error); + seenError = true; + } + }); + + browser.proxy.onRequest.addListener( + details => { + browser.test.log(`onRequest ${JSON.stringify(details)}`); + if (proxyInfo == "not_defined") { + return not_defined; // eslint-disable-line no-undef + } + return proxyInfo; + }, + { urls: ["<all_urls>"] } + ); + + browser.test.onMessage.addListener((message, data) => { + if (message === "set-proxy") { + seenError = false; + proxyInfo = data.proxyInfo; + } + }); + + browser.test.sendMessage("ready"); + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("ready"); + + for (let test of testData) { + extension.sendMessage("set-proxy", test); + let testError = test.expected.error; + let errorWait = testError && extension.awaitMessage("proxy-error-received"); + + let proxyInfo = await getProxyInfo(); + let expectedProxyInfo = test.expected.proxyInfo; + + if (testError) { + info("waiting for error data"); + let error = await errorWait; + equal(error.message, testError.message, "Correct error message received"); + equal(proxyInfo, null, "no proxyInfo received"); + } else if (expectedProxyInfo === null) { + equal(proxyInfo, null, "no proxyInfo received"); + } else { + for ( + let proxyUsed = proxyInfo; + proxyUsed; + proxyUsed = proxyUsed.failoverProxy + ) { + let { + type, + host, + port, + username, + password, + proxyDNS, + failoverTimeout, + } = expectedProxyInfo; + equal(proxyUsed.host, host, `Expected proxy host to be ${host}`); + equal(proxyUsed.port, port || -1, `Expected proxy port to be ${port}`); + equal(proxyUsed.type, type, `Expected proxy type to be ${type}`); + // May be null or undefined depending on use of newProxyInfoWithAuth or newProxyInfo + equal( + proxyUsed.username || "", + username || "", + `Expected proxy username to be ${username}` + ); + equal( + proxyUsed.password || "", + password || "", + `Expected proxy password to be ${password}` + ); + equal( + proxyUsed.flags, + proxyDNS == undefined ? 0 : proxyDNS, + `Expected proxyDNS to be ${proxyDNS}` + ); + // Default timeout is 10 + equal( + proxyUsed.failoverTimeout, + failoverTimeout || 10, + `Expected failoverTimeout to be ${failoverTimeout}` + ); + expectedProxyInfo = expectedProxyInfo.failoverProxy; + } + ok(!expectedProxyInfo, "no left over failoverProxy"); + } + } + + await extension.unload(); +}); + +async function getExtension(expectedProxyInfo) { + function background(proxyInfo) { + browser.test.log( + `testing proxy.onRequest with proxyInfo = ${JSON.stringify(proxyInfo)}` + ); + browser.proxy.onRequest.addListener( + details => { + return proxyInfo; + }, + { urls: ["<all_urls>"] } + ); + } + let extensionData = { + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + background: `(${background})(${JSON.stringify(expectedProxyInfo)})`, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + return extension; +} + +add_task(async function test_passthrough() { + let ext1 = await getExtension(null); + let ext2 = await getExtension({ host: "1.2.3.4", port: 8888, type: "https" }); + + // Also use a restricted url to test the ability to proxy those. + let proxyInfo = await getProxyInfo("https://addons.mozilla.org/"); + + equal(proxyInfo.host, "1.2.3.4", `second extension won`); + equal(proxyInfo.port, "8888", `second extension won`); + equal(proxyInfo.type, "https", `second extension won`); + + await ext2.unload(); + + proxyInfo = await getProxyInfo(); + equal(proxyInfo, null, `expected no proxy`); + await ext1.unload(); +}); + +add_task(async function test_ftp_disabled() { + let extension = await getExtension({ + host: "1.2.3.4", + port: 8888, + type: "http", + }); + + let proxyInfo = await getProxyInfo("ftp://somewhere.mozilla.org/"); + + equal( + proxyInfo, + null, + `proxy of ftp request is not available when ftp is disabled` + ); + + await extension.unload(); +}); + +add_task(async function test_ws() { + let proxyRequestCount = 0; + let proxy = createHttpServer(); + proxy.registerPathHandler("CONNECT", (request, response) => { + response.setStatusLine(request.httpVersion, 404, "Proxy not found"); + ++proxyRequestCount; + }); + + let extension = await getExtension({ + host: proxy.identity.primaryHost, + port: proxy.identity.primaryPort, + type: "http", + }); + + // We need a page to use the WebSocket constructor, so let's use an extension. + let dummy = ExtensionTestUtils.loadExtension({ + background() { + // The connection will not be upgraded to WebSocket, so it will close. + let ws = new WebSocket("wss://example.net/"); + ws.onclose = () => browser.test.sendMessage("websocket_closed"); + }, + }); + await dummy.startup(); + await dummy.awaitMessage("websocket_closed"); + await dummy.unload(); + + equal(proxyRequestCount, 1, "Expected one proxy request"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_proxy_userContextId.js b/toolkit/components/extensions/test/xpcshell/test_proxy_userContextId.js new file mode 100644 index 0000000000..5dea560e02 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_proxy_userContextId.js @@ -0,0 +1,43 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +add_task(async function test_userContextId_proxy_onRequest() { + // This extension will succeed if it gets a request + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + background() { + browser.proxy.onRequest.addListener( + async details => { + if (details.url != "http://example.com/dummy") { + return; + } + browser.test.assertEq( + details.cookieStoreId, + "firefox-container-2", + "cookieStoreId is set" + ); + browser.test.notifyPass("proxy.onRequest"); + }, + { urls: ["<all_urls>"] } + ); + }, + }); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy", + { userContextId: 2 } + ); + await extension.awaitFinish("proxy.onRequest"); + await extension.unload(); + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_resistfingerprinting_exempt.js b/toolkit/components/extensions/test/xpcshell/test_resistfingerprinting_exempt.js new file mode 100644 index 0000000000..ff4c9bd74b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_resistfingerprinting_exempt.js @@ -0,0 +1,40 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function queryBuildID() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("result", { buildID: navigator.buildID }); + }, + }); + await extension.startup(); + let { buildID } = await extension.awaitMessage("result"); + await extension.unload(); + return buildID; +} + +const BUILDID_OVERRIDE = "Overridden buildID"; + +add_task( + { + pref_set: [["general.buildID.override", BUILDID_OVERRIDE]], + }, + async function test_buildID_normal() { + let buildID = await queryBuildID(); + Assert.equal(buildID, BUILDID_OVERRIDE); + } +); + +add_task( + { + pref_set: [ + ["general.buildID.override", BUILDID_OVERRIDE], + ["privacy.resistFingerprinting", true], + ], + }, + async function test_buildID_resistFingerprinting() { + let buildID = await queryBuildID(); + Assert.equal(buildID, BUILDID_OVERRIDE); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_site_permissions.js b/toolkit/components/extensions/test/xpcshell/test_site_permissions.js new file mode 100644 index 0000000000..652c9ae3ac --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_site_permissions.js @@ -0,0 +1,385 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// TODO(Bug 1789718): adapt to synthetic addon type implemented by the SitePermAddonProvider +// or remove if redundant, after the deprecated XPIProvider-based implementation is also removed. + +const { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" +); +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +const { TelemetryController } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryController.sys.mjs" +); +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +const l10n = new Localization([ + "toolkit/global/extensions.ftl", + "toolkit/global/extensionPermissions.ftl", + "branding/brand.ftl", +]); +// Localization resources need to be first iterated outside a test +l10n.formatValue("webext-perms-sideload-text"); + +// Lazily import ExtensionParent to allow AddonTestUtils.createAppInfo to +// override Services.appinfo. +ChromeUtils.defineESModuleGetters(this, { + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", +}); + +async function _test_manifest(manifest, expectedError) { + ExtensionTestUtils.failOnSchemaWarnings(false); + let normalized = await ExtensionTestUtils.normalizeManifest( + manifest, + "manifest.WebExtensionSitePermissionsManifest" + ); + ExtensionTestUtils.failOnSchemaWarnings(true); + + if (expectedError) { + ok( + normalized.error.includes(expectedError), + `The manifest error ${JSON.stringify( + normalized.error + )} must contain ${JSON.stringify(expectedError)}` + ); + } else { + equal(normalized.error, undefined, "Should not have an error"); + } + equal(normalized.errors.length, 0, "Should have no warning"); +} + +add_setup(async () => { + // Telemetry test setup needed to ensure that the builtin events are defined + // and they can be collected and verified. + await TelemetryController.testSetup(); + + // This is actually only needed on Android, because it does not properly support unified telemetry + // and so, if not enabled explicitly here, it would make these tests to fail when running on + // release builds. + const oldCanRecordBase = Services.telemetry.canRecordBase; + Services.telemetry.canRecordBase = true; + registerCleanupFunction(() => { + Services.telemetry.canRecordBase = oldCanRecordBase; + }); +}); + +add_task(async function test_manifest_site_permissions() { + await _test_manifest({ + site_permissions: ["midi"], + install_origins: ["http://example.com"], + }); + await _test_manifest({ + site_permissions: ["midi-sysex"], + install_origins: ["http://example.com"], + }); + await _test_manifest( + { + site_permissions: ["unknown_site_permission"], + install_origins: ["http://example.com"], + }, + `Error processing site_permissions.0: Invalid enumeration value "unknown_site_permission"` + ); + await _test_manifest( + { + site_permissions: ["unknown_site_permission"], + install_origins: [], + }, + `Error processing install_origins: Array requires at least 1 items;` + ); + await _test_manifest( + { + site_permissions: ["unknown_site_permission"], + }, + `Property "install_origins" is required` + ); + await _test_manifest( + { + install_origins: ["http://example.com"], + }, + `Property "site_permissions" is required` + ); + // test any extra manifest entries not part of a site permissions addon will cause an error. + await _test_manifest( + { + site_permissions: ["midi"], + install_origins: ["http://example.com"], + permissions: ["webRequest"], + }, + `Unexpected property` + ); +}); + +add_task(async function test_sitepermission_telemetry() { + await AddonTestUtils.promiseStartupManager(); + + Services.telemetry.clearEvents(); + + const addon_id = "webmidi@test"; + const origin = "https://example.com"; + const permName = "midi"; + + let site_permission = { + "manifest.json": { + name: "test Site Permission", + version: "1.0", + manifest_version: 2, + browser_specific_settings: { + gecko: { id: addon_id }, + }, + install_origins: [origin], + site_permissions: [permName], + }, + }; + + let [, { addon }] = await Promise.all([ + TestUtils.topicObserved("webextension-sitepermissions-startup"), + AddonTestUtils.promiseInstallXPI(site_permission), + ]); + + await addon.uninstall(); + + await TelemetryTestUtils.assertEvents( + [ + [ + "addonsManager", + "install", + "siteperm_deprecated", + /.*/, + { + step: "started", + addon_id, + }, + ], + [ + "addonsManager", + "install", + "siteperm_deprecated", + /.*/, + { + step: "completed", + addon_id, + }, + ], + ["addonsManager", "uninstall", "siteperm_deprecated", addon_id], + ], + { + category: "addonsManager", + method: /^install|uninstall$/, + } + ); + + await AddonTestUtils.promiseShutdownManager(); +}); + +async function _test_ext_site_permissions(site_permissions, install_origins) { + ExtensionTestUtils.failOnSchemaWarnings(false); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + install_origins, + site_permissions, + }, + }); + await extension.startup(); + await extension.unload(); + ExtensionTestUtils.failOnSchemaWarnings(true); +} + +add_task(async function test_ext_site_permissions() { + await _test_ext_site_permissions(["midi"], ["http://example.com"]); + + await _test_ext_site_permissions( + ["midi"], + ["http://example.com", "http://foo.com"] + ).catch(e => { + Assert.ok( + e.message.includes( + "Error processing install_origins: Array requires at most 1 items; you have 2" + ), + "Site permissions can only contain one install origin: " + ); + }); +}); + +add_task(async function test_sitepermission_type() { + await AddonTestUtils.promiseStartupManager(); + + // Test more than one perm to make sure both are added. + // While this is allowed, midi-sysex overrides. + let perms = ["midi", "midi-sysex"]; + let id = "@test-permission"; + let origin = "http://example.com"; + let uri = Services.io.newURI(origin); + let principal = Services.scriptSecurityManager.createContentPrincipal( + uri, + {} + ); + + // give the site some other permission (geo) + Services.perms.addFromPrincipal( + principal, + "geo", + Services.perms.ALLOW_ACTION, + Services.perms.EXPIRE_NEVER + ); + + let assertGeo = () => { + Assert.equal( + Services.perms.testExactPermissionFromPrincipal(principal, "geo"), + Ci.nsIPermissionManager.ALLOW_ACTION, + "site still has geo permission" + ); + }; + + let checkPerms = (perms, action, msg) => { + for (let permName of perms) { + let permission = Services.perms.testExactPermissionFromPrincipal( + principal, + permName + ); + Assert.equal(permission, action, `${permName}: ${msg}`); + } + }; + + checkPerms( + perms, + Ci.nsIPermissionManager.UNKNOWN_ACTION, + "no permission for site" + ); + + let site_permission = { + "manifest.json": { + name: "test Site Permission", + version: "1.0", + manifest_version: 2, + browser_specific_settings: { + gecko: { + id, + }, + }, + install_origins: [origin], + site_permissions: perms, + }, + }; + + let [, { addon }] = await Promise.all([ + TestUtils.topicObserved("webextension-sitepermissions-startup"), + AddonTestUtils.promiseInstallXPI(site_permission), + ]); + + checkPerms( + perms, + Ci.nsIPermissionManager.ALLOW_ACTION, + "extension enabled permission for site" + ); + assertGeo(); + + // Test the permission is retained on restart. + await AddonTestUtils.promiseRestartManager(); + addon = await AddonManager.getAddonByID(id); + + checkPerms( + perms, + Ci.nsIPermissionManager.ALLOW_ACTION, + "extension enabled permission for site" + ); + assertGeo(); + + // Test that a removed permission is added on restart + Services.perms.removeFromPrincipal(principal, perms[0]); + await AddonTestUtils.promiseRestartManager(); + addon = await AddonManager.getAddonByID(id); + + checkPerms( + perms, + Ci.nsIPermissionManager.ALLOW_ACTION, + "extension enabled permission for site" + ); + assertGeo(); + + // Test that a changed permission is not changed on restart + Services.perms.addFromPrincipal( + principal, + perms[0], + Services.perms.DENY_ACTION, + Services.perms.EXPIRE_NEVER + ); + + await AddonTestUtils.promiseRestartManager(); + addon = await AddonManager.getAddonByID(id); + + checkPerms( + [perms[0]], + Ci.nsIPermissionManager.DENY_ACTION, + "extension enabled permission for site" + ); + checkPerms( + [perms[1]], + Ci.nsIPermissionManager.ALLOW_ACTION, + "extension enabled permission for site" + ); + assertGeo(); + + // Test permission removal when addon disabled + await addon.disable(); + + checkPerms( + perms, + Ci.nsIPermissionManager.UNKNOWN_ACTION, + "no permission for site" + ); + assertGeo(); + + // Enabling an addon will always force ALLOW_ACTION + await addon.enable(); + + checkPerms( + perms, + Ci.nsIPermissionManager.ALLOW_ACTION, + "extension enabled permission for site" + ); + assertGeo(); + + // Test permission removal when addon uninstalled + await addon.uninstall(); + + checkPerms( + perms, + Ci.nsIPermissionManager.UNKNOWN_ACTION, + "no permission for site" + ); + assertGeo(); +}); + +add_task(async function test_site_permissions_have_localization_strings() { + await ExtensionParent.apiManager.lazyInit(); + const SCHEMA_SITE_PERMISSIONS = Schemas.getPermissionNames([ + "SitePermission", + ]); + ok(SCHEMA_SITE_PERMISSIONS.length, "we have site permissions"); + + for (const perm of SCHEMA_SITE_PERMISSIONS) { + const l10nId = `webext-site-perms-${perm}`; + try { + const str = await l10n.formatValue(l10nId); + + ok(str.length, `Found localization string for '${perm}' site permission`); + } catch (e) { + ok(false, `Site permission missing '${perm}'`); + } + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_webRequest_ancestors.js b/toolkit/components/extensions/test/xpcshell/test_webRequest_ancestors.js new file mode 100644 index 0000000000..509f821828 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_webRequest_ancestors.js @@ -0,0 +1,81 @@ +"use strict"; + +var { WebRequest } = ChromeUtils.importESModule( + "resource://gre/modules/WebRequest.sys.mjs" +); +var { ExtensionParent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" +); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function setup() { + // When WebRequest.jsm is used directly instead of through ext-webRequest.js, + // ExtensionParent.apiManager is not automatically initialized. Do it here. + await ExtensionParent.apiManager.lazyInit(); +}); + +add_task(async function test_ancestors_exist() { + let deferred = Promise.withResolvers(); + function onBeforeRequest(details) { + info(`onBeforeRequest ${details.url}`); + Assert.strictEqual( + typeof details.frameAncestors, + "object", + `ancestors exists [${typeof details.frameAncestors}]` + ); + deferred.resolve(); + } + + WebRequest.onBeforeRequest.addListener( + onBeforeRequest, + { urls: new MatchPatternSet(["http://example.com/*"]) }, + ["blocking"] + ); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + await deferred.promise; + await contentPage.close(); + + WebRequest.onBeforeRequest.removeListener(onBeforeRequest); +}); + +add_task(async function test_ancestors_null() { + let deferred = Promise.withResolvers(); + function onBeforeRequest(details) { + info(`onBeforeRequest ${details.url}`); + Assert.strictEqual( + details.frameAncestors, + undefined, + "ancestors do not exist" + ); + deferred.resolve(); + } + + WebRequest.onBeforeRequest.addListener(onBeforeRequest, null, ["blocking"]); + + function fetch(url) { + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + xhr.mozBackgroundRequest = true; + xhr.open("GET", url); + xhr.onload = () => { + resolve(xhr.responseText); + }; + xhr.onerror = () => { + reject(xhr.status); + }; + // use a different contextId to avoid auth cache. + xhr.setOriginAttributes({ userContextId: 1 }); + xhr.send(); + }); + } + + await fetch("http://example.com/data/file_sample.html"); + await deferred.promise; + + WebRequest.onBeforeRequest.removeListener(onBeforeRequest); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_webRequest_cookies.js b/toolkit/components/extensions/test/xpcshell/test_webRequest_cookies.js new file mode 100644 index 0000000000..53ed465786 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_webRequest_cookies.js @@ -0,0 +1,102 @@ +"use strict"; + +var { WebRequest } = ChromeUtils.importESModule( + "resource://gre/modules/WebRequest.sys.mjs" +); + +var { ExtensionParent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" +); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + if (request.hasHeader("Cookie")) { + let value = request.getHeader("Cookie"); + if (value == "blinky=1") { + response.setHeader("Set-Cookie", "dinky=1", false); + } + response.write("cookie-present"); + } else { + response.setHeader("Set-Cookie", "foopy=1", false); + response.write("cookie-not-present"); + } +}); + +const URL = "http://example.com/"; + +var countBefore = 0; +var countAfter = 0; + +function onBeforeSendHeaders(details) { + if (details.url != URL) { + return undefined; + } + + countBefore++; + + info(`onBeforeSendHeaders ${details.url}`); + let found = false; + let headers = []; + for (let { name, value } of details.requestHeaders) { + info(`Saw header ${name} '${value}'`); + if (name == "Cookie") { + equal(value, "foopy=1", "Cookie is correct"); + headers.push({ name, value: "blinky=1" }); + found = true; + } else { + headers.push({ name, value }); + } + } + ok(found, "Saw cookie header"); + equal(countBefore, 1, "onBeforeSendHeaders hit once"); + + return { requestHeaders: headers }; +} + +function onResponseStarted(details) { + if (details.url != URL) { + return; + } + + countAfter++; + + info(`onResponseStarted ${details.url}`); + let found = false; + for (let { name, value } of details.responseHeaders) { + info(`Saw header ${name} '${value}'`); + if (name == "set-cookie") { + equal(value, "dinky=1", "Cookie is correct"); + found = true; + } + } + ok(found, "Saw cookie header"); + equal(countAfter, 1, "onResponseStarted hit once"); +} + +add_task(async function setup() { + // When WebRequest.jsm is used directly instead of through ext-webRequest.js, + // ExtensionParent.apiManager is not automatically initialized. Do it here. + await ExtensionParent.apiManager.lazyInit(); +}); + +add_task(async function filter_urls() { + // First load the URL so that we set cookie foopy=1. + let contentPage = await ExtensionTestUtils.loadContentPage(URL); + await contentPage.close(); + + // Now load with WebRequest set up. + WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, null, [ + "blocking", + "requestHeaders", + ]); + WebRequest.onResponseStarted.addListener(onResponseStarted, null, [ + "responseHeaders", + ]); + + contentPage = await ExtensionTestUtils.loadContentPage(URL); + await contentPage.close(); + + WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders); + WebRequest.onResponseStarted.removeListener(onResponseStarted); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_webRequest_filtering.js b/toolkit/components/extensions/test/xpcshell/test_webRequest_filtering.js new file mode 100644 index 0000000000..a7157f19a4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_webRequest_filtering.js @@ -0,0 +1,182 @@ +"use strict"; + +var { WebRequest } = ChromeUtils.importESModule( + "resource://gre/modules/WebRequest.sys.mjs" +); + +var { ExtensionParent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" +); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE = "http://example.com/data/"; +const URL = BASE + "/file_WebRequest_page2.html"; + +var requested = []; + +function onBeforeRequest(details) { + info(`onBeforeRequest ${details.url}`); + if (details.url.startsWith(BASE)) { + requested.push(details.url); + } +} + +var sendHeaders = []; + +function onBeforeSendHeaders(details) { + info(`onBeforeSendHeaders ${details.url}`); + if (details.url.startsWith(BASE)) { + sendHeaders.push(details.url); + } +} + +var completed = []; + +function onResponseStarted(details) { + if (details.url.startsWith(BASE)) { + completed.push(details.url); + } +} + +const expected_urls = [ + BASE + "/file_style_good.css", + BASE + "/file_style_bad.css", + BASE + "/file_style_redirect.css", +]; + +function resetExpectations() { + requested.length = 0; + sendHeaders.length = 0; + completed.length = 0; +} + +function removeDupes(list) { + let j = 0; + for (let i = 1; i < list.length; i++) { + if (list[i] != list[j]) { + j++; + if (i != j) { + list[j] = list[i]; + } + } + } + list.length = j + 1; +} + +function compareLists(list1, list2, kind) { + list1.sort(); + removeDupes(list1); + list2.sort(); + removeDupes(list2); + equal(String(list1), String(list2), `${kind} URLs correct`); +} + +async function openAndCloseContentPage(url) { + let contentPage = await ExtensionTestUtils.loadContentPage(URL); + // Clear the sheet cache so that it doesn't interact with following tests: A + // stylesheet with the same URI loaded from the same origin doesn't otherwise + // guarantee that onBeforeRequest and so on happen, because it may not need + // to go through necko at all. + await contentPage.spawn([], () => + content.windowUtils.clearSharedStyleSheetCache() + ); + await contentPage.close(); +} + +add_task(async function setup() { + // Disable rcwn to make cache behavior deterministic. + Services.prefs.setBoolPref("network.http.rcwn.enabled", false); + + // When WebRequest.jsm is used directly instead of through ext-webRequest.js, + // ExtensionParent.apiManager is not automatically initialized. Do it here. + await ExtensionParent.apiManager.lazyInit(); +}); + +add_task(async function filter_urls() { + let filter = { urls: new MatchPatternSet(["*://*/*_style_*"]) }; + + WebRequest.onBeforeRequest.addListener(onBeforeRequest, filter, ["blocking"]); + WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, filter, [ + "blocking", + ]); + WebRequest.onResponseStarted.addListener(onResponseStarted, filter); + + await openAndCloseContentPage(URL); + + compareLists(requested, expected_urls, "requested"); + compareLists(sendHeaders, expected_urls, "sendHeaders"); + compareLists(completed, expected_urls, "completed"); + + WebRequest.onBeforeRequest.removeListener(onBeforeRequest); + WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders); + WebRequest.onResponseStarted.removeListener(onResponseStarted); +}); + +add_task(async function filter_types() { + resetExpectations(); + let filter = { types: ["stylesheet"] }; + + WebRequest.onBeforeRequest.addListener(onBeforeRequest, filter, ["blocking"]); + WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, filter, [ + "blocking", + ]); + WebRequest.onResponseStarted.addListener(onResponseStarted, filter); + + await openAndCloseContentPage(URL); + + compareLists(requested, expected_urls, "requested"); + compareLists(sendHeaders, expected_urls, "sendHeaders"); + compareLists(completed, expected_urls, "completed"); + + WebRequest.onBeforeRequest.removeListener(onBeforeRequest); + WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders); + WebRequest.onResponseStarted.removeListener(onResponseStarted); +}); + +add_task(async function filter_windowId() { + resetExpectations(); + // Check that adding windowId will exclude non-matching requests. + // test_ext_webrequest_filter.html provides coverage for matching requests. + let filter = { urls: new MatchPatternSet(["*://*/*_style_*"]), windowId: 0 }; + + WebRequest.onBeforeRequest.addListener(onBeforeRequest, filter, ["blocking"]); + WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, filter, [ + "blocking", + ]); + WebRequest.onResponseStarted.addListener(onResponseStarted, filter); + + await openAndCloseContentPage(URL); + + compareLists(requested, [], "requested"); + compareLists(sendHeaders, [], "sendHeaders"); + compareLists(completed, [], "completed"); + + WebRequest.onBeforeRequest.removeListener(onBeforeRequest); + WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders); + WebRequest.onResponseStarted.removeListener(onResponseStarted); +}); + +add_task(async function filter_tabId() { + resetExpectations(); + // Check that adding tabId will exclude non-matching requests. + // test_ext_webrequest_filter.html provides coverage for matching requests. + let filter = { urls: new MatchPatternSet(["*://*/*_style_*"]), tabId: 0 }; + + WebRequest.onBeforeRequest.addListener(onBeforeRequest, filter, ["blocking"]); + WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, filter, [ + "blocking", + ]); + WebRequest.onResponseStarted.addListener(onResponseStarted, filter); + + await openAndCloseContentPage(URL); + + compareLists(requested, [], "requested"); + compareLists(sendHeaders, [], "sendHeaders"); + compareLists(completed, [], "completed"); + + WebRequest.onBeforeRequest.removeListener(onBeforeRequest); + WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders); + WebRequest.onResponseStarted.removeListener(onResponseStarted); +}); diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/.eslintrc.js b/toolkit/components/extensions/test/xpcshell/webidl-api/.eslintrc.js new file mode 100644 index 0000000000..3622fff4f6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/.eslintrc.js @@ -0,0 +1,9 @@ +"use strict"; + +module.exports = { + env: { + // The tests in this folder are testing based on WebExtensions, so lets + // just define the webextensions environment here. + webextensions: true, + }, +}; diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/head_webidl_api.js b/toolkit/components/extensions/test/xpcshell/webidl-api/head_webidl_api.js new file mode 100644 index 0000000000..3e8e094a20 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/head_webidl_api.js @@ -0,0 +1,306 @@ +/* import-globals-from ../head.js */ + +/* exported getBackgroundServiceWorkerRegistration, waitForTerminatedWorkers, + * runExtensionAPITest */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +add_setup(function checkExtensionsWebIDLEnabled() { + equal( + AppConstants.MOZ_WEBEXT_WEBIDL_ENABLED, + true, + "WebExtensions WebIDL bindings build time flag should be enabled" + ); +}); + +function getBackgroundServiceWorkerRegistration(extension) { + const swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + + const swRegs = swm.getAllRegistrations(); + const scope = `moz-extension://${extension.uuid}/`; + + for (let i = 0; i < swRegs.length; i++) { + let regInfo = swRegs.queryElementAt(i, Ci.nsIServiceWorkerRegistrationInfo); + if (regInfo.scope === scope) { + return regInfo; + } + } +} + +function waitForTerminatedWorkers(swRegInfo) { + info(`Wait all ${swRegInfo.scope} workers to be terminated`); + return TestUtils.waitForCondition(() => { + const { evaluatingWorker, installingWorker, waitingWorker, activeWorker } = + swRegInfo; + return !( + evaluatingWorker || + installingWorker || + waitingWorker || + activeWorker + ); + }, `wait workers for scope ${swRegInfo.scope} to be terminated`); +} + +function unmockHandleAPIRequest(extPage) { + return extPage.spawn([], () => { + const { ExtensionAPIRequestHandler } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionProcessScript.sys.mjs" + ); + + // Unmock ExtensionAPIRequestHandler. + if (ExtensionAPIRequestHandler._handleAPIRequest_orig) { + ExtensionAPIRequestHandler.handleAPIRequest = + ExtensionAPIRequestHandler._handleAPIRequest_orig; + delete ExtensionAPIRequestHandler._handleAPIRequest_orig; + } + }); +} + +function mockHandleAPIRequest(extPage, mockHandleAPIRequest) { + mockHandleAPIRequest = + mockHandleAPIRequest || + ((policy, request) => { + const ExtError = request.window?.Error || Error; + return { + type: Ci.mozIExtensionAPIRequestResult.EXTENSION_ERROR, + value: new ExtError( + "mockHandleAPIRequest not defined by this test case" + ), + }; + }); + + return extPage.legacySpawn( + [ExtensionTestCommon.serializeFunction(mockHandleAPIRequest)], + mockFnText => { + const { ExtensionAPIRequestHandler } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionProcessScript.sys.mjs" + ); + + mockFnText = `(() => { + return (${mockFnText}); + })();`; + // eslint-disable-next-line no-eval + const mockFn = eval(mockFnText); + + // Mock ExtensionAPIRequestHandler. + if (!ExtensionAPIRequestHandler._handleAPIRequest_orig) { + ExtensionAPIRequestHandler._handleAPIRequest_orig = + ExtensionAPIRequestHandler.handleAPIRequest; + } + + ExtensionAPIRequestHandler.handleAPIRequest = function (policy, request) { + if (request.apiNamespace === "test") { + return this._handleAPIRequest_orig(policy, request); + } + return mockFn.call(this, policy, request); + }; + } + ); +} + +/** + * An helper function used to run unit test that are meant to test the + * Extension API webidl bindings helpers shared by all the webextensions + * API namespaces. + * + * @param {string} testDescription + * Brief description of the test. + * @param {object} [options] + * @param {Function} options.backgroundScript + * Test function running in the extension global. This function + * does receive a parameter of type object with the following + * properties: + * - testLog(message): log a message on the terminal + * - testAsserts: + * - isErrorInstance(err): throw if err is not an Error instance + * - isInstanceOf(value, globalContructorName): throws if value + * is not an instance of global[globalConstructorName] + * - equal(val, exp, msg): throw an error including msg if + * val is not strictly equal to exp. + * @param {Function} options.assertResults + * Function to be provided to assert the result returned by + * `backgroundScript`, or assert the error if it did throw. + * This function does receive a parameter of type object with + * the following properties: + * - testResult: the result returned (and resolved if the return + * value was a promise) from the call to `backgroundScript` + * - testError: the error raised (or rejected if the return value + * value was a promise) from the call to `backgroundScript` + * - extension: the extension wrapper created by this helper. + * @param {Function} options.mockAPIRequestHandler + * Function to be used to mock mozIExtensionAPIRequestHandler.handleAPIRequest + * for the purpose of the test. + * This function received the same parameter that are listed in the idl + * definition (mozIExtensionAPIRequestHandling.webidl). + * @param {string} [options.extensionId] + * Optional extension id for the test extension. + */ +async function runExtensionAPITest( + testDescription, + { + backgroundScript, + assertResults, + mockAPIRequestHandler, + extensionId = "test-ext-api-request-forward@mochitest", + } +) { + // Wraps the `backgroundScript` function to be execute in the target + // extension global (currently only in a background service worker, + // in follow-ups the same function should also be execute in + // other supported extension globals, e.g. an extension page and + // a content script). + // + // The test wrapper does also provide to `backgroundScript` some + // helpers to be used as part of the test, these tests are meant to + // only cover internals shared by all webidl API bindings through a + // mock API namespace only available in tests (and so none of the tests + // written with this helpers should be using the browser.test API namespace). + function backgroundScriptWrapper(testParams, testFn) { + const testLog = msg => { + // console messages emitted by workers are not visible in the test logs if not + // explicitly collected, and so this testLog helper method does use dump for now + // (this way the logs will be visibile as part of the test logs). + dump(`"${testParams.extensionId}": ${msg}\n`); + }; + + const testAsserts = { + isErrorInstance(err) { + if (!(err instanceof Error)) { + throw new Error("Unexpected error: not an instance of Error"); + } + return true; + }, + isInstanceOf(value, globalConstructorName) { + if (!(value instanceof self[globalConstructorName])) { + throw new Error( + `Unexpected error: expected instance of ${globalConstructorName}` + ); + } + return true; + }, + equal(val, exp, msg) { + if (val !== exp) { + throw new Error( + `Unexpected error: expected ${exp} but got ${val}. ${msg}` + ); + } + }, + }; + + testLog(`Evaluating - test case "${testParams.testDescription}"`); + self.onmessage = async evt => { + testLog(`Running test case "${testParams.testDescription}"`); + + let testError = null; + let testResult; + try { + testResult = await testFn({ testLog, testAsserts }); + } catch (err) { + testError = { message: err.message, stack: err.stack }; + testLog(`Unexpected test error: ${err} :: ${err.stack}\n`); + } + + evt.ports[0].postMessage({ success: !testError, testError, testResult }); + + testLog(`Test case "${testParams.testDescription}" executed`); + }; + testLog(`Wait onmessage event - test case "${testParams.testDescription}"`); + } + + async function assertTestResult(result) { + if (assertResults) { + await assertResults(result); + } else { + equal(result.testError, undefined, "Expect no errors"); + ok(result.success, "Test completed successfully"); + } + } + + async function runTestCaseInWorker({ page, extension }) { + info(`*** Run test case in an extension service worker`); + const result = await page.legacySpawn([], async () => { + const { active } = await content.navigator.serviceWorker.ready; + const { port1, port2 } = new MessageChannel(); + + return new Promise(resolve => { + port1.onmessage = evt => resolve(evt.data); + active.postMessage("run-test", [port2]); + }); + }); + info(`*** Assert test case results got from extension service worker`); + await assertTestResult({ ...result, extension }); + } + + // NOTE: prefixing this with `function ` is needed because backgroundScript + // is an object property and so it is going to be stringified as + // `backgroundScript() { ... }` (which would be detected as a syntax error + // on the worker script evaluation phase). + const scriptFnParam = ExtensionTestCommon.serializeFunction(backgroundScript); + const testOptsParam = `${JSON.stringify({ testDescription, extensionId })}`; + + const testExtData = { + useAddonManager: "temporary", + manifest: { + version: "1", + background: { + service_worker: "test-sw.js", + }, + browser_specific_settings: { + gecko: { id: extensionId }, + }, + }, + files: { + "page.html": `<!DOCTYPE html> + <head><meta charset="utf-8"></head> + <body> + <script src="test-sw.js"></script> + </body>`, + "test-sw.js": ` + (${backgroundScriptWrapper})(${testOptsParam}, ${scriptFnParam}); + `, + }, + }; + + let cleanupCalled = false; + let extension; + let page; + let swReg; + + async function testCleanup() { + if (cleanupCalled) { + return; + } + + cleanupCalled = true; + await unmockHandleAPIRequest(page); + await page.close(); + await extension.unload(); + await waitForTerminatedWorkers(swReg); + } + + info(`Start test case "${testDescription}"`); + extension = ExtensionTestUtils.loadExtension(testExtData); + await extension.startup(); + + swReg = getBackgroundServiceWorkerRegistration(extension); + ok(swReg, "Extension background.service_worker should be registered"); + + page = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/page.html`, + { extension } + ); + + registerCleanupFunction(testCleanup); + + await mockHandleAPIRequest(page, mockAPIRequestHandler); + await runTestCaseInWorker({ page, extension }); + await testCleanup(); + info(`End test case "${testDescription}"`); +} diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api.js b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api.js new file mode 100644 index 0000000000..489cc3a754 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api.js @@ -0,0 +1,486 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_ext_context_does_have_webidl_bindings() { + await runExtensionAPITest("should have a browser global object", { + backgroundScript() { + const { browser, chrome } = self; + + return { + hasExtensionAPI: !!browser, + hasExtensionMockAPI: !!browser?.mockExtensionAPI, + hasChromeCompatGlobal: !!chrome, + hasChromeMockAPI: !!chrome?.mockExtensionAPI, + }; + }, + assertResults({ testResult, testError }) { + Assert.deepEqual(testError, undefined); + Assert.deepEqual( + testResult, + { + hasExtensionAPI: true, + hasExtensionMockAPI: true, + hasChromeCompatGlobal: true, + hasChromeMockAPI: true, + }, + "browser and browser.test WebIDL API bindings found" + ); + }, + }); +}); + +add_task(async function test_propagated_extension_error() { + await runExtensionAPITest( + "should throw an extension error on ResultType::EXTENSION_ERROR", + { + backgroundScript({ testAsserts }) { + try { + const api = self.browser.mockExtensionAPI; + api.methodSyncWithReturn("arg0", 1, { value: "arg2" }); + } catch (err) { + testAsserts.isErrorInstance(err); + throw err; + } + }, + mockAPIRequestHandler(policy, request) { + return { + type: Ci.mozIExtensionAPIRequestResult.EXTENSION_ERROR, + value: new Error("Fake Extension Error"), + }; + }, + assertResults({ testError }) { + Assert.deepEqual(testError?.message, "Fake Extension Error"); + }, + } + ); +}); + +add_task(async function test_system_errors_donot_leak() { + function assertResults({ testError }) { + ok( + testError?.message?.match(/An unexpected error occurred/), + `Got the general unexpected error as expected: ${testError?.message}` + ); + } + + function mockAPIRequestHandler(policy, request) { + throw new Error("Fake handleAPIRequest exception"); + } + + const msg = + "should throw an unexpected error occurred if handleAPIRequest throws"; + + await runExtensionAPITest(`sync method ${msg}`, { + backgroundScript({ testAsserts }) { + try { + self.browser.mockExtensionAPI.methodSyncWithReturn("arg0"); + } catch (err) { + testAsserts.isErrorInstance(err); + throw err; + } + }, + mockAPIRequestHandler, + assertResults, + }); + + await runExtensionAPITest(`async method ${msg}`, { + backgroundScript({ testAsserts }) { + try { + self.browser.mockExtensionAPI.methodAsync("arg0"); + } catch (err) { + testAsserts.isErrorInstance(err); + throw err; + } + }, + mockAPIRequestHandler, + assertResults, + }); + + await runExtensionAPITest(`no return method ${msg}`, { + backgroundScript({ testAsserts }) { + try { + self.browser.mockExtensionAPI.methodNoReturn("arg0"); + } catch (err) { + testAsserts.isErrorInstance(err); + throw err; + } + }, + mockAPIRequestHandler, + assertResults, + }); +}); + +add_task(async function test_call_sync_function_result() { + await runExtensionAPITest( + "sync API methods should support structured clonable return values", + { + backgroundScript({ testAsserts }) { + const api = self.browser.mockExtensionAPI; + const results = { + string: api.methodSyncWithReturn("string-result"), + nested_prop: api.methodSyncWithReturn({ + string: "123", + number: 123, + date: new Date("2020-09-20"), + map: new Map([ + ["a", 1], + ["b", 2], + ]), + }), + }; + + testAsserts.isInstanceOf(results.nested_prop.date, "Date"); + testAsserts.isInstanceOf(results.nested_prop.map, "Map"); + return results; + }, + mockAPIRequestHandler(policy, request) { + if (request.apiName === "methodSyncWithReturn") { + // Return the first argument unmodified, which will be checked in the + // resultAssertFn above. + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: request.args[0], + }; + } + throw new Error("Unexpected API method"); + }, + assertResults({ testResult, testError }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual(testResult, { + string: "string-result", + nested_prop: { + string: "123", + number: 123, + date: new Date("2020-09-20"), + map: new Map([ + ["a", 1], + ["b", 2], + ]), + }, + }); + }, + } + ); +}); + +add_task(async function test_call_sync_fn_missing_return() { + await runExtensionAPITest( + "should throw an unexpected error occurred on missing return value", + { + backgroundScript() { + self.browser.mockExtensionAPI.methodSyncWithReturn("arg0"); + }, + mockAPIRequestHandler(policy, request) { + return undefined; + }, + assertResults({ testError }) { + ok( + testError?.message?.match(/An unexpected error occurred/), + `Got the general unexpected error as expected: ${testError?.message}` + ); + }, + } + ); +}); + +add_task(async function test_call_async_throw_extension_error() { + await runExtensionAPITest( + "an async function can throw an error occurred for param validation errors", + { + backgroundScript({ testAsserts }) { + try { + self.browser.mockExtensionAPI.methodAsync("arg0"); + } catch (err) { + testAsserts.isErrorInstance(err); + throw err; + } + }, + mockAPIRequestHandler(policy, request) { + return { + type: Ci.mozIExtensionAPIRequestResult.EXTENSION_ERROR, + value: new Error("Fake Param Validation Error"), + }; + }, + assertResults({ testError }) { + Assert.deepEqual(testError?.message, "Fake Param Validation Error"); + }, + } + ); +}); + +add_task(async function test_call_async_reject_error() { + await runExtensionAPITest( + "an async function rejected promise should propagate extension errors", + { + async backgroundScript({ testAsserts }) { + try { + await self.browser.mockExtensionAPI.methodAsync("arg0"); + } catch (err) { + testAsserts.isErrorInstance(err); + throw err; + } + }, + mockAPIRequestHandler(policy, request) { + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: Promise.reject(new Error("Fake API rejected error object")), + }; + }, + assertResults({ testError }) { + Assert.deepEqual(testError?.message, "Fake API rejected error object"); + }, + } + ); +}); + +add_task(async function test_call_async_function_result() { + await runExtensionAPITest( + "async API methods should support structured clonable resolved values", + { + async backgroundScript({ testAsserts }) { + const api = self.browser.mockExtensionAPI; + const results = { + string: await api.methodAsync("string-result"), + nested_prop: await api.methodAsync({ + string: "123", + number: 123, + date: new Date("2020-09-20"), + map: new Map([ + ["a", 1], + ["b", 2], + ]), + }), + }; + + testAsserts.isInstanceOf(results.nested_prop.date, "Date"); + testAsserts.isInstanceOf(results.nested_prop.map, "Map"); + return results; + }, + mockAPIRequestHandler(policy, request) { + if (request.apiName === "methodAsync") { + // Return the first argument unmodified, which will be checked in the + // resultAssertFn above. + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: Promise.resolve(request.args[0]), + }; + } + throw new Error("Unexpected API method"); + }, + assertResults({ testResult, testError }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual(testResult, { + string: "string-result", + nested_prop: { + string: "123", + number: 123, + date: new Date("2020-09-20"), + map: new Map([ + ["a", 1], + ["b", 2], + ]), + }, + }); + }, + } + ); +}); + +add_task(async function test_call_no_return_throw_extension_error() { + await runExtensionAPITest( + "no return function call throw an error occurred for param validation errors", + { + backgroundScript({ testAsserts }) { + try { + self.browser.mockExtensionAPI.methodNoReturn("arg0"); + } catch (err) { + testAsserts.isErrorInstance(err); + throw err; + } + }, + mockAPIRequestHandler(policy, request) { + return { + type: Ci.mozIExtensionAPIRequestResult.EXTENSION_ERROR, + value: new Error("Fake Param Validation Error"), + }; + }, + assertResults({ testError }) { + Assert.deepEqual(testError?.message, "Fake Param Validation Error"); + }, + } + ); +}); + +add_task(async function test_call_no_return_without_errors() { + await runExtensionAPITest( + "handleAPIHandler can return undefined on api calls to methods with no return", + { + backgroundScript() { + self.browser.mockExtensionAPI.methodNoReturn("arg0"); + }, + mockAPIRequestHandler(policy, request) { + return undefined; + }, + assertResults({ testError }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + }, + } + ); +}); + +add_task(async function test_async_method_chrome_compatible_callback() { + function mockAPIRequestHandler(policy, request) { + if (request.args[0] === "fake-async-method-failure") { + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: Promise.reject("this-should-not-be-passed-to-cb-as-parameter"), + }; + } + + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: Promise.resolve(request.args), + }; + } + + await runExtensionAPITest( + "async method should support an optional chrome-compatible callback", + { + mockAPIRequestHandler, + async backgroundScript({ testAsserts }) { + const api = self.browser.mockExtensionAPI; + const success_cb_params = await new Promise(resolve => { + const res = api.methodAsync( + { prop: "fake-async-method-success" }, + (...results) => { + resolve(results); + } + ); + testAsserts.equal(res, undefined, "no promise should be returned"); + }); + const error_cb_params = await new Promise(resolve => { + const res = api.methodAsync( + "fake-async-method-failure", + (...results) => { + resolve(results); + } + ); + testAsserts.equal(res, undefined, "no promise should be returned"); + }); + return { success_cb_params, error_cb_params }; + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual( + testResult, + { + success_cb_params: [[{ prop: "fake-async-method-success" }]], + error_cb_params: [], + }, + "Got the expected results from the chrome compatible callbacks" + ); + }, + } + ); + + await runExtensionAPITest( + "async method with ambiguous args called with a chrome-compatible callback", + { + mockAPIRequestHandler, + async backgroundScript({ testAsserts }) { + const api = self.browser.mockExtensionAPI; + const success_cb_params = await new Promise(resolve => { + const res = api.methodAmbiguousArgsAsync( + "arg0", + { prop: "arg1" }, + 3, + (...results) => { + resolve(results); + } + ); + testAsserts.equal(res, undefined, "no promise should be returned"); + }); + const error_cb_params = await new Promise(resolve => { + const res = api.methodAmbiguousArgsAsync( + "fake-async-method-failure", + (...results) => { + resolve(results); + } + ); + testAsserts.equal(res, undefined, "no promise should be returned"); + }); + return { success_cb_params, error_cb_params }; + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual( + testResult, + { + success_cb_params: [["arg0", { prop: "arg1" }, 3]], + error_cb_params: [], + }, + "Got the expected results from the chrome compatible callbacks" + ); + }, + } + ); +}); + +add_task(async function test_get_property() { + await runExtensionAPITest( + "getProperty API request does return a value synchrously", + { + backgroundScript() { + return self.browser.mockExtensionAPI.propertyAsString; + }, + mockAPIRequestHandler(policy, request) { + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: "property-value", + }; + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual( + testResult, + "property-value", + "Got the expected result" + ); + }, + } + ); + + await runExtensionAPITest( + "getProperty API request can return an error object", + { + backgroundScript({ testAsserts }) { + const errObj = self.browser.mockExtensionAPI.propertyAsErrorObject; + testAsserts.isErrorInstance(errObj); + testAsserts.equal(errObj.message, "fake extension error"); + }, + mockAPIRequestHandler(policy, request) { + let savedFrame = request.calledSavedFrame; + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: ChromeUtils.createError("fake extension error", savedFrame), + }; + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + }, + } + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_event_callback.js b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_event_callback.js new file mode 100644 index 0000000000..576ec760d3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_event_callback.js @@ -0,0 +1,575 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* import-globals-from ../head_service_worker.js */ + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_api_event_manager_methods() { + await runExtensionAPITest("extension event manager methods", { + backgroundScript({ testAsserts, testLog }) { + const api = browser.mockExtensionAPI; + const listener = () => {}; + + function assertHasListener(expect) { + testAsserts.equal( + api.onTestEvent.hasListeners(), + expect, + `onTestEvent.hasListeners should return {expect}` + ); + testAsserts.equal( + api.onTestEvent.hasListener(listener), + expect, + `onTestEvent.hasListeners should return {expect}` + ); + } + + assertHasListener(false); + api.onTestEvent.addListener(listener); + assertHasListener(true); + api.onTestEvent.removeListener(listener); + assertHasListener(false); + }, + mockAPIRequestHandler(policy, request) { + if (!request.eventListener) { + throw new Error( + "Unexpected Error: missing ExtensionAPIRequest.eventListener" + ); + } + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + }, + }); +}); + +add_task(async function test_api_event_eventListener_call() { + await runExtensionAPITest( + "extension event eventListener wrapper does forward calls parameters", + { + backgroundScript({ testAsserts, testLog }) { + const api = browser.mockExtensionAPI; + let listener; + + return new Promise((resolve, reject) => { + testLog("addListener and wait for event to be fired"); + listener = (...args) => { + testLog("onTestEvent"); + // Make sure the extension code can access the arguments. + try { + testAsserts.equal(args[1], "arg1"); + resolve(args); + } catch (err) { + reject(err); + } + }; + api.onTestEvent.addListener(listener); + }); + }, + mockAPIRequestHandler(policy, request) { + if (!request.eventListener) { + throw new Error( + "Unexpected Error: missing ExtensionAPIRequest.eventListener" + ); + } + if (request.requestType === "addListener") { + let args = [{ arg: 0 }, "arg1"]; + request.eventListener.callListener(args); + } + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual( + testResult, + [{ arg: 0 }, "arg1"], + "Got the expected result" + ); + }, + } + ); +}); + +add_task(async function test_api_event_eventListener_call_with_result() { + await runExtensionAPITest( + "extension event eventListener wrapper forwarded call result", + { + backgroundScript({ testAsserts, testLog }) { + const api = browser.mockExtensionAPI; + let listener; + + return new Promise((resolve, reject) => { + testLog("addListener and wait for event to be fired"); + listener = (msg, value) => { + testLog(`onTestEvent received: ${msg}`); + switch (msg) { + case "test-result-value": + return value; + case "test-promise-resolve": + return Promise.resolve(value); + case "test-promise-reject": + return Promise.reject(new Error("test-reject")); + case "test-done": + resolve(value); + break; + default: + reject(new Error(`Unexpected onTestEvent message: ${msg}`)); + } + }; + api.onTestEvent.addListener(listener); + }); + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual( + testResult?.resSync, + { prop: "retval" }, + "Got result from eventListener returning a plain return value" + ); + Assert.deepEqual( + testResult?.resAsync, + { prop: "promise" }, + "Got result from eventListener returning a resolved promise" + ); + Assert.deepEqual( + testResult?.resAsyncReject, + { + isInstanceOfError: true, + errorMessage: "test-reject", + }, + "got result from eventListener returning a rejected promise" + ); + }, + mockAPIRequestHandler(policy, request) { + if (!request.eventListener) { + throw new Error( + "Unexpected Error: missing ExtensionAPIRequest.eventListener" + ); + } + + if (request.requestType === "addListener") { + Promise.resolve().then(async () => { + try { + dump(`calling listener, expect a plain return value\n`); + const resSync = await request.eventListener.callListener([ + "test-result-value", + { prop: "retval" }, + ]); + + dump( + `calling listener, expect a resolved promise return value\n` + ); + const resAsync = await request.eventListener.callListener([ + "test-promise-resolve", + { prop: "promise" }, + ]); + + dump( + `calling listener, expect a rejected promise return value\n` + ); + const resAsyncReject = await request.eventListener + .callListener(["test-promise-reject"]) + .catch(err => err); + + // call API listeners once more to complete the test + let args = { + resSync, + resAsync, + resAsyncReject: { + isInstanceOfError: resAsyncReject instanceof Error, + errorMessage: resAsyncReject?.message, + }, + }; + request.eventListener.callListener(["test-done", args]); + } catch (err) { + dump(`Unexpected error: ${err} :: ${err.stack}\n`); + throw err; + } + }); + } + }, + } + ); +}); + +add_task(async function test_api_event_eventListener_result_rejected() { + await runExtensionAPITest( + "extension event eventListener throws (mozIExtensionCallback.call)", + { + backgroundScript({ testAsserts, testLog }) { + const api = browser.mockExtensionAPI; + let listener; + + return new Promise((resolve, reject) => { + testLog("addListener and wait for event to be fired"); + listener = (msg, arg1) => { + if (msg === "test-done") { + testLog(`Resolving result: ${JSON.stringify(arg1)}`); + resolve(arg1); + return; + } + throw new Error("FAKE eventListener exception"); + }; + api.onTestEvent.addListener(listener); + }); + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual( + testResult, + { + isPromise: true, + rejectIsError: true, + errorMessage: "FAKE eventListener exception", + }, + "Got the expected rejected promise" + ); + }, + mockAPIRequestHandler(policy, request) { + if (!request.eventListener) { + throw new Error( + "Unexpected Error: missing ExtensionAPIRequest.eventListener" + ); + } + + if (request.requestType === "addListener") { + Promise.resolve().then(async () => { + const promiseResult = request.eventListener.callListener([]); + const isPromise = promiseResult instanceof Promise; + const err = await promiseResult.catch(e => e); + const rejectIsError = err instanceof Error; + request.eventListener.callListener([ + "test-done", + { isPromise, rejectIsError, errorMessage: err?.message }, + ]); + }); + } + }, + } + ); +}); + +add_task(async function test_api_event_eventListener_throws_on_call() { + await runExtensionAPITest( + "extension event eventListener throws (mozIExtensionCallback.call)", + { + backgroundScript({ testAsserts, testLog }) { + const api = browser.mockExtensionAPI; + let listener; + + return new Promise(resolve => { + testLog("addListener and wait for event to be fired"); + listener = (msg, arg1) => { + if (msg === "test-done") { + testLog(`Resolving result: ${JSON.stringify(arg1)}`); + resolve(); + return; + } + throw new Error("FAKE eventListener exception"); + }; + api.onTestEvent.addListener(listener); + }); + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + }, + mockAPIRequestHandler(policy, request) { + if (!request.eventListener) { + throw new Error( + "Unexpected Error: missing ExtensionAPIRequest.eventListener" + ); + } + + if (request.requestType === "addListener") { + Promise.resolve().then(async () => { + request.eventListener.callListener([]); + request.eventListener.callListener(["test-done"]); + }); + } + }, + } + ); +}); + +add_task(async function test_send_response_eventListener() { + await runExtensionAPITest( + "extension event eventListener sendResponse eventListener argument", + { + backgroundScript({ testAsserts, testLog }) { + const api = browser.mockExtensionAPI; + let listener; + + return new Promise(resolve => { + testLog("addListener and wait for event to be fired"); + listener = (msg, sendResponse) => { + if (msg === "call-sendResponse") { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(() => sendResponse("sendResponse-value"), 20); + return true; + } + + resolve(msg); + }; + api.onTestEvent.addListener(listener); + }); + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.equal(testResult, "sendResponse-value", "Got expected value"); + }, + mockAPIRequestHandler(policy, request) { + if (!request.eventListener) { + throw new Error( + "Unexpected Error: missing ExtensionAPIRequest.eventListener" + ); + } + + if (request.requestType === "addListener") { + Promise.resolve().then(async () => { + const res = await request.eventListener.callListener( + ["call-sendResponse"], + { + callbackType: + Ci.mozIExtensionListenerCallOptions.CALLBACK_SEND_RESPONSE, + } + ); + request.eventListener.callListener([res]); + }); + } + }, + } + ); +}); + +add_task(async function test_send_response_multiple_eventListener() { + await runExtensionAPITest("multiple extension event eventListeners", { + backgroundScript({ testAsserts, testLog }) { + const api = browser.mockExtensionAPI; + let listenerNoReply; + let listenerSendResponseReply; + + return new Promise(resolve => { + testLog("addListener and wait for event to be fired"); + listenerNoReply = (msg, sendResponse) => { + return false; + }; + listenerSendResponseReply = (msg, sendResponse) => { + if (msg === "call-sendResponse") { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(() => sendResponse("sendResponse-value"), 20); + return true; + } + + resolve(msg); + }; + api.onTestEvent.addListener(listenerNoReply); + api.onTestEvent.addListener(listenerSendResponseReply); + }); + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.equal(testResult, "sendResponse-value", "Got expected value"); + }, + mockAPIRequestHandler(policy, request) { + if (!request.eventListener) { + throw new Error( + "Unexpected Error: missing ExtensionAPIRequest.eventListener" + ); + } + + if (request.requestType === "addListener") { + this._listeners = this._listeners || []; + this._listeners.push(request.eventListener); + if (this._listeners.length === 2) { + Promise.resolve().then(async () => { + const { _listeners } = this; + this._listeners = undefined; + + // Reference to the listener to which we should send the + // final message to complete the test. + const replyListener = _listeners[1]; + + const res = await Promise.race( + _listeners.map(l => + l.callListener(["call-sendResponse"], { + callbackType: + Ci.mozIExtensionListenerCallOptions.CALLBACK_SEND_RESPONSE, + }) + ) + ); + replyListener.callListener([res]); + }); + } + } + }, + }); +}); + +// Unit test nsIServiceWorkerManager.wakeForExtensionAPIEvent method. +add_task(async function test_serviceworkermanager_wake_for_api_event_helper() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + version: "1.0", + background: { + service_worker: "sw.js", + }, + browser_specific_settings: { + gecko: { id: "test-bg-sw-wakeup@mochi.test" }, + }, + }, + files: { + "sw.js": ` + dump("Background ServiceWorker - executing\\n"); + const lifecycleEvents = []; + self.oninstall = () => { + dump('Background ServiceWorker - oninstall\\n'); + lifecycleEvents.push("install"); + }; + self.onactivate = () => { + dump('Background ServiceWorker - onactivate\\n'); + lifecycleEvents.push("activate"); + }; + browser.test.onMessage.addListener(msg => { + if (msg === "bgsw-getSWEvents") { + browser.test.sendMessage("bgsw-gotSWEvents", lifecycleEvents); + return; + } + + browser.test.fail("Got unexpected test message: " + msg); + }); + + const fakeListener01 = () => {}; + const fakeListener02 = () => {}; + + // Adding and removing the same listener, and so we expect + // ExtensionEventWakeupMap to not have any wakeup listener + // for the runtime.onInstalled event. + browser.runtime.onInstalled.addListener(fakeListener01); + browser.runtime.onInstalled.removeListener(fakeListener01); + // Removing the same listener more than ones should make any + // difference, and it shouldn't trigger any assertion in + // debug builds. + browser.runtime.onInstalled.removeListener(fakeListener01); + + browser.runtime.onStartup.addListener(fakeListener02); + // Removing an unrelated listener, runtime.onStartup is expected to + // still have one wakeup listener tracked by ExtensionEventWakeupMap. + browser.runtime.onStartup.removeListener(fakeListener01); + + browser.test.sendMessage("bgsw-executed"); + dump("Background ServiceWorker - executed\\n"); + `, + }, + }); + + const testWorkerWatcher = new TestWorkerWatcher("../data"); + let watcher = await testWorkerWatcher.watchExtensionServiceWorker(extension); + + await extension.startup(); + + info("Wait for the background service worker to be spawned"); + ok( + await watcher.promiseWorkerSpawned, + "The extension service worker has been spawned as expected" + ); + + await extension.awaitMessage("bgsw-executed"); + + extension.sendMessage("bgsw-getSWEvents"); + let lifecycleEvents = await extension.awaitMessage("bgsw-gotSWEvents"); + Assert.deepEqual( + lifecycleEvents, + ["install", "activate"], + "Got install and activate lifecycle events as expected" + ); + + info("Wait for the background service worker to be terminated"); + ok( + await watcher.terminate(), + "The extension service worker has been terminated as expected" + ); + + const swReg = testWorkerWatcher.getRegistration(extension); + ok(swReg, "Got a service worker registration"); + ok(swReg?.activeWorker, "Got an active worker"); + + watcher = await testWorkerWatcher.watchExtensionServiceWorker(extension); + + const extensionBaseURL = extension.extension.baseURI.spec; + + async function testWakeupOnAPIEvent(eventName, expectedResult) { + const result = await testWorkerWatcher.swm.wakeForExtensionAPIEvent( + extensionBaseURL, + "runtime", + eventName + ); + equal( + result, + expectedResult, + `Got expected result from wakeForExtensionAPIEvent for ${eventName}` + ); + info( + `Wait for the background service worker to be spawned for ${eventName}` + ); + ok( + await watcher.promiseWorkerSpawned, + "The extension service worker has been spawned as expected" + ); + await extension.awaitMessage("bgsw-executed"); + } + + info("Wake up active worker for API event"); + // Extension API event listener has been added and removed synchronously by + // the worker script, and so we expect the promise to resolve successfully + // to `false`. + await testWakeupOnAPIEvent("onInstalled", false); + + extension.sendMessage("bgsw-getSWEvents"); + lifecycleEvents = await extension.awaitMessage("bgsw-gotSWEvents"); + Assert.deepEqual( + lifecycleEvents, + [], + "No install and activate lifecycle events expected on spawning active worker" + ); + + info("Wait for the background service worker to be terminated"); + ok( + await watcher.terminate(), + "The extension service worker has been terminated as expected" + ); + + info("Wakeup again with an API event that has been subscribed"); + // Extension API event listener has been added synchronously (and not removed) + // by the worker script, and so we expect the promise to resolve successfully + // to `true`. + await testWakeupOnAPIEvent("onStartup", true); + + info("Wait for the background service worker to be terminated"); + ok( + await watcher.terminate(), + "The extension service worker has been terminated as expected" + ); + + await extension.unload(); + + await Assert.rejects( + testWorkerWatcher.swm.wakeForExtensionAPIEvent( + extensionBaseURL, + "runtime", + "onStartup" + ), + /Not an extension principal or extension disabled/, + "Got the expected rejection on wakeForExtensionAPIEvent called for an uninstalled extension" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_request_handler.js b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_request_handler.js new file mode 100644 index 0000000000..070a45fa95 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_request_handler.js @@ -0,0 +1,443 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); +}); + +// Verify ExtensionAPIRequestHandler handling API requests for +// an ext-*.js API module running in the local process +// (toolkit/components/extensions/child/ext-test.js). +add_task(async function test_sw_api_request_handling_local_process_api() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + background: { + service_worker: "sw.js", + }, + browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } }, + }, + files: { + "page.html": "<!DOCTYPE html><body></body>", + "sw.js": async function () { + browser.test.onMessage.addListener(async msg => { + browser.test.succeed("call to test.succeed"); + browser.test.assertTrue(true, "call to test.assertTrue"); + browser.test.assertFalse(false, "call to test.assertFalse"); + // Smoke test assertEq (more complete coverage of the behavior expected + // by the test API will be introduced in test_ext_test.html as part of + // Bug 1723785). + const errorObject = new Error("fake_error_message"); + browser.test.assertEq( + errorObject, + errorObject, + "call to test.assertEq" + ); + + // Smoke test for assertThrows/assertRejects. + const errorMatchingTestCases = [ + ["expected error instance", errorObject], + ["expected error message string", "fake_error_message"], + ["expected regexp", /fake_error/], + ["matching function", error => errorObject === error], + ["matching Constructor", Error], + ]; + + browser.test.log("run assertThrows smoke tests"); + + const throwFn = () => { + throw errorObject; + }; + for (const [msg, expected] of errorMatchingTestCases) { + browser.test.assertThrows( + throwFn, + expected, + `call to assertThrow with ${msg}` + ); + } + + browser.test.log("run assertRejects smoke tests"); + + const rejectedPromise = Promise.reject(errorObject); + for (const [msg, expected] of errorMatchingTestCases) { + await browser.test.assertRejects( + rejectedPromise, + expected, + `call to assertRejects with ${msg}` + ); + } + + browser.test.notifyPass("test-completed"); + }); + browser.test.sendMessage("bgsw-ready"); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("bgsw-ready"); + extension.sendMessage("test-message-ok"); + await extension.awaitFinish(); + await extension.unload(); +}); + +// Verify ExtensionAPIRequestHandler handling API requests for +// an ext-*.js API module running in the main process +// (toolkit/components/extensions/parent/ext-alarms.js). +add_task(async function test_sw_api_request_handling_main_process_api() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + background: { + service_worker: "sw.js", + }, + permissions: ["alarms"], + browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } }, + }, + files: { + "page.html": "<!DOCTYPE html><body></body>", + "sw.js": async function () { + browser.alarms.create("test-alarm", { when: Date.now() + 2000000 }); + const all = await browser.alarms.getAll(); + if (all.length === 1 && all[0].name === "test-alarm") { + browser.test.succeed("Got the expected alarms"); + } else { + browser.test.fail( + `browser.alarms.create didn't create the expected alarm: ${JSON.stringify( + all + )}` + ); + } + + browser.alarms.onAlarm.addListener(alarm => { + if (alarm.name === "test-onAlarm") { + browser.test.succeed("Got the expected onAlarm event"); + } else { + browser.test.fail(`Got unexpected onAlarm event: ${alarm.name}`); + } + browser.test.sendMessage("test-completed"); + }); + + browser.alarms.create("test-onAlarm", { when: Date.now() + 1000 }); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("test-completed"); + await extension.unload(); +}); + +add_task(async function test_sw_api_request_bgsw_runtime_onMessage() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + background: { + service_worker: "sw.js", + }, + permissions: [], + browser_specific_settings: { + gecko: { id: "test-bg-sw-on-message@mochi.test" }, + }, + }, + files: { + "page.html": '<!DOCTYPE html><script src="page.js"></script>', + "page.js": async function () { + browser.test.onMessage.addListener(msg => { + if (msg !== "extpage-send-message") { + browser.test.fail(`Unexpected message received: ${msg}`); + return; + } + browser.runtime.sendMessage("extpage-send-message"); + }); + }, + "sw.js": async function () { + browser.runtime.onMessage.addListener(msg => { + browser.test.sendMessage("bgsw-on-message", msg); + }); + const extURL = browser.runtime.getURL("/"); + browser.test.sendMessage("ext-url", extURL); + }, + }, + }); + + await extension.startup(); + const extURL = await extension.awaitMessage("ext-url"); + equal( + extURL, + `moz-extension://${extension.uuid}/`, + "Got the expected extension url" + ); + + const extPage = await ExtensionTestUtils.loadContentPage( + `${extURL}/page.html`, + { extension } + ); + extension.sendMessage("extpage-send-message"); + + const msg = await extension.awaitMessage("bgsw-on-message"); + equal(msg, "extpage-send-message", "Got the expected message"); + await extPage.close(); + await extension.unload(); +}); + +add_task(async function test_sw_api_request_bgsw_runtime_sendMessage() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + background: { + service_worker: "sw.js", + }, + permissions: [], + browser_specific_settings: { + gecko: { id: "test-bg-sw-sendMessage@mochi.test" }, + }, + }, + files: { + "page.html": '<!DOCTYPE html><script src="page.js"></script>', + "page.js": async function () { + browser.runtime.onMessage.addListener(msg => { + browser.test.sendMessage("extpage-on-message", msg); + }); + + browser.test.sendMessage("extpage-ready"); + }, + "sw.js": async function () { + browser.test.onMessage.addListener(msg => { + if (msg !== "bgsw-send-message") { + browser.test.fail(`Unexpected message received: ${msg}`); + return; + } + browser.runtime.sendMessage("bgsw-send-message"); + }); + const extURL = browser.runtime.getURL("/"); + browser.test.sendMessage("ext-url", extURL); + }, + }, + }); + + await extension.startup(); + const extURL = await extension.awaitMessage("ext-url"); + equal( + extURL, + `moz-extension://${extension.uuid}/`, + "Got the expected extension url" + ); + + const extPage = await ExtensionTestUtils.loadContentPage( + `${extURL}/page.html`, + { extension } + ); + await extension.awaitMessage("extpage-ready"); + extension.sendMessage("bgsw-send-message"); + + const msg = await extension.awaitMessage("extpage-on-message"); + equal(msg, "bgsw-send-message", "Got the expected message"); + await extPage.close(); + await extension.unload(); +}); + +// Verify ExtensionAPIRequestHandler handling API requests that +// returns a runtinme.Port API object. +add_task(async function test_sw_api_request_bgsw_connnect_runtime_port() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + background: { + service_worker: "sw.js", + }, + permissions: [], + browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } }, + }, + files: { + "page.html": '<!DOCTYPE html><script src="page.js"></script>', + "page.js": async function () { + browser.runtime.onConnect.addListener(port => { + browser.test.sendMessage("page-got-port-from-sw"); + port.postMessage("page-to-sw"); + }); + browser.test.sendMessage("page-waiting-port"); + }, + "sw.js": async function () { + browser.test.onMessage.addListener(msg => { + if (msg !== "connect-port") { + return; + } + const port = browser.runtime.connect(); + if (!port) { + browser.test.fail("Got an undefined port"); + } + port.onMessage.addListener((msg, portArgument) => { + browser.test.assertTrue( + port === portArgument, + "Got the expected runtime.Port instance" + ); + browser.test.sendMessage("test-done", msg); + }); + browser.test.sendMessage("sw-waiting-port-message"); + }); + + const portWithError = browser.runtime.connect(); + portWithError.onDisconnect.addListener(() => { + const portError = portWithError.error; + browser.test.sendMessage("port-error", { + isError: portError instanceof Error, + message: portError?.message, + }); + }); + + const extURL = browser.runtime.getURL("/"); + browser.test.sendMessage("ext-url", extURL); + browser.test.sendMessage("ext-id", browser.runtime.id); + }, + }, + }); + + await extension.startup(); + const extURL = await extension.awaitMessage("ext-url"); + equal( + extURL, + `moz-extension://${extension.uuid}/`, + "Got the expected extension url" + ); + + const extId = await extension.awaitMessage("ext-id"); + equal(extId, extension.id, "Got the expected extension id"); + + const lastError = await extension.awaitMessage("port-error"); + Assert.deepEqual( + lastError, + { + isError: true, + message: "Could not establish connection. Receiving end does not exist.", + }, + "Got the expected lastError value" + ); + + const extPage = await ExtensionTestUtils.loadContentPage( + `${extURL}/page.html`, + { extension } + ); + await extension.awaitMessage("page-waiting-port"); + + info("bgsw connect port"); + extension.sendMessage("connect-port"); + await extension.awaitMessage("sw-waiting-port-message"); + info("bgsw waiting port message"); + await extension.awaitMessage("page-got-port-from-sw"); + info("page got port from sw, wait to receive event"); + const msg = await extension.awaitMessage("test-done"); + equal(msg, "page-to-sw", "Got the expected message"); + await extPage.close(); + await extension.unload(); +}); + +// Verify ExtensionAPIRequestHandler handling API events that should +// get a runtinme.Port API object as an event argument. +add_task(async function test_sw_api_request_bgsw_runtime_onConnect() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + background: { + service_worker: "sw.js", + }, + permissions: [], + browser_specific_settings: { + gecko: { id: "test-bg-sw-onConnect@mochi.test" }, + }, + }, + files: { + "page.html": '<!DOCTYPE html><script src="page.js"></script>', + "page.js": async function () { + browser.test.onMessage.addListener(msg => { + if (msg !== "connect-port") { + return; + } + const port = browser.runtime.connect(); + port.onMessage.addListener(msg => { + browser.test.sendMessage("test-done", msg); + }); + browser.test.sendMessage("page-waiting-port-message"); + }); + }, + "sw.js": async function () { + try { + const extURL = browser.runtime.getURL("/"); + browser.test.sendMessage("ext-url", extURL); + + browser.runtime.onConnect.addListener(port => { + browser.test.sendMessage("bgsw-got-port-from-page"); + port.postMessage("sw-to-page"); + }); + browser.test.sendMessage("bgsw-waiting-port"); + } catch (err) { + browser.test.fail(`Error on runtime.onConnect: ${err}`); + } + }, + }, + }); + + await extension.startup(); + const extURL = await extension.awaitMessage("ext-url"); + equal( + extURL, + `moz-extension://${extension.uuid}/`, + "Got the expected extension url" + ); + await extension.awaitMessage("bgsw-waiting-port"); + + const extPage = await ExtensionTestUtils.loadContentPage( + `${extURL}/page.html`, + { extension } + ); + info("ext page connect port"); + extension.sendMessage("connect-port"); + + await extension.awaitMessage("page-waiting-port-message"); + info("page waiting port message"); + await extension.awaitMessage("bgsw-got-port-from-page"); + info("bgsw got port from page, page wait to receive event"); + const msg = await extension.awaitMessage("test-done"); + equal(msg, "sw-to-page", "Got the expected message"); + await extPage.close(); + await extension.unload(); +}); + +add_task(async function test_sw_runtime_lastError() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + background: { + service_worker: "sw.js", + }, + browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } }, + }, + files: { + "page.html": "<!DOCTYPE html><body></body>", + "sw.js": async function () { + browser.runtime.sendMessage(() => { + const lastError = browser.runtime.lastError; + if (!(lastError instanceof Error)) { + browser.test.fail( + `lastError isn't an Error instance: ${lastError}` + ); + } + browser.test.sendMessage("test-lastError-completed"); + }); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("test-lastError-completed"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_errors.js b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_errors.js new file mode 100644 index 0000000000..d8684c1574 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_errors.js @@ -0,0 +1,202 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { ExtensionAPI } = ExtensionCommon; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +// Because the `mockExtensionAPI` is currently the only "mock" API that has +// WebIDL bindings, this is the only namespace we can use in our tests. There +// is no JSON schema for this namespace so we add one here that is tailored for +// our testing needs. +const API = class extends ExtensionAPI { + getAPI(context) { + return { + mockExtensionAPI: { + methodAsync: () => { + return "some-value"; + }, + }, + }; + } +}; + +const SCHEMA = [ + { + namespace: "mockExtensionAPI", + functions: [ + { + name: "methodAsync", + type: "function", + async: true, + parameters: [ + { + name: "arg", + type: "string", + enum: ["THE_ONLY_VALUE_ALLOWED"], + }, + ], + }, + ], + }, +]; + +add_setup(async function () { + await AddonTestUtils.promiseStartupManager(); + + // The blob:-URL registered in `registerModules()` below gets loaded at: + // https://searchfox.org/mozilla-central/rev/0fec57c05d3996cc00c55a66f20dd5793a9bfb5d/toolkit/components/extensions/ExtensionCommon.jsm#1649 + Services.prefs.setBoolPref( + "security.allow_parent_unrestricted_js_loads", + true + ); + + ExtensionParent.apiManager.registerModules({ + mockExtensionAPI: { + schema: `data:,${JSON.stringify(SCHEMA)}`, + scopes: ["addon_parent"], + paths: [["mockExtensionAPI"]], + url: URL.createObjectURL( + new Blob([`this.mockExtensionAPI = ${API.toString()}`]) + ), + }, + }); +}); + +add_task(async function test_schema_error_is_propagated_to_extension() { + await runExtensionAPITest("should throw an extension error", { + backgroundScript() { + return browser.mockExtensionAPI.methodAsync("UNEXPECTED_VALUE"); + }, + mockAPIRequestHandler(policy, request) { + return this._handleAPIRequest_orig(policy, request); + }, + assertResults({ testError }) { + Assert.ok( + /Invalid enumeration value "UNEXPECTED_VALUE"/.test(testError.message) + ); + }, + }); +}); + +add_task(async function test_schema_error_no_error_with_expected_value() { + await runExtensionAPITest("should not throw any error", { + backgroundScript() { + return browser.mockExtensionAPI.methodAsync("THE_ONLY_VALUE_ALLOWED"); + }, + mockAPIRequestHandler(policy, request) { + return this._handleAPIRequest_orig(policy, request); + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, undefined); + Assert.deepEqual(testResult, "some-value"); + }, + }); +}); + +add_task(async function test_schema_data_not_found_or_unexpected_schema_type() { + const { Schemas } = ChromeUtils.importESModule( + "resource://gre/modules/Schemas.sys.mjs" + ); + + const mockSchemaExtContext = {}; + + const testSchemasErrorOnWebIDLRequest = testCase => { + if (testCase.expectedExceptions) { + const expectedExceptions = Array.isArray(testCase.expectedExceptions) + ? testCase.expectedExceptions + : [testCase.expectedExceptions]; + expectedExceptions.forEach(expectedException => + Assert.throws( + () => + Schemas.checkWebIDLRequestParameters( + testCase.mockSchemaExtContext, + testCase.mockWebIDLAPIRequest + ), + expectedException, + `Got the expected error on ${testCase.description}` + ) + ); + } else { + throw new Error( + `Test case ${testCase.description} is missing mandatory expectedExceptions test case property` + ); + } + }; + + const TEST_CASES = [ + { + description: + "callFunction API request for non existing nested API namespace", + mockSchemaExtContext, + mockWebIDLAPIRequest: { + apiNamespace: "browserSettings.unknownNamespace", + apiName: "get", + requestType: "callFunction", + }, + expectedExceptions: + /API Schema not found for browserSettings\.unknownNamespace/, + }, + { + description: + "addListener API request for non existing API event property", + mockSchemaExtContext, + mockWebIDLAPIRequest: { + apiNamespace: "browserSettings.nonExistingSetting", + apiName: "onChange", + requestType: "addListener", + }, + expectedExceptions: + /API Schema not found for browserSettings\.nonExistingSetting/, + }, + { + description: + "callFunction on non existing method from existing nested API namespace", + mockSchemaExtContext, + mockWebIDLAPIRequest: { + apiNamespace: "browserSettings.colorManagement.mode", + apiName: "nonExistingMethod", + requestType: "callFunction", + }, + expectedExceptions: [ + /API Schema for "nonExistingMethod" not found in browserSettings\.colorManagement\.mode/, + /\(browserSettings\.colorManagement\.mode type is SubModuleProperty\)/, + ], + }, + { + description: + "callFunction on non existing method from existing API namespace", + mockSchemaExtContext, + mockWebIDLAPIRequest: { + apiNamespace: "browserSettings", + apiName: "nonExistingMethod", + requestType: "callFunction", + }, + expectedExceptions: + /API Schema not found for browserSettings\.nonExistingMethod/, + }, + { + description: + "callFunction on existing property but unexpected schema type", + mockSchemaExtContext, + mockWebIDLAPIRequest: { + apiNamespace: "tabs", + apiName: "TAB_ID_NONE", + requestType: "callFunction", + }, + expectedExceptions: [ + /Unexpected API Schema type for tabs.TAB_ID_NONE/, + /tabs.TAB_ID_NONE type is ValueProperty/, + ], + }, + ]; + + TEST_CASES.forEach(testSchemasErrorOnWebIDLRequest); +}); diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_formatters.js b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_formatters.js new file mode 100644 index 0000000000..a7310f345e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_formatters.js @@ -0,0 +1,99 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { ExtensionAPI } = ExtensionCommon; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +// Because the `mockExtensionAPI` is currently the only "mock" API that has +// WebIDL bindings, this is the only namespace we can use in our tests. There +// is no JSON schema for this namespace so we add one here that is tailored for +// our testing needs. +const API = class extends ExtensionAPI { + getAPI(context) { + return { + mockExtensionAPI: { + methodAsync: files => { + return files; + }, + }, + }; + } +}; + +const SCHEMA = [ + { + namespace: "mockExtensionAPI", + functions: [ + { + name: "methodAsync", + type: "function", + async: true, + parameters: [ + { + name: "files", + type: "array", + items: { $ref: "manifest.ExtensionURL" }, + }, + ], + }, + ], + }, +]; + +add_setup(async function () { + await AddonTestUtils.promiseStartupManager(); + + // The blob:-URL registered in `registerModules()` below gets loaded at: + // https://searchfox.org/mozilla-central/rev/0fec57c05d3996cc00c55a66f20dd5793a9bfb5d/toolkit/components/extensions/ExtensionCommon.jsm#1649 + Services.prefs.setBoolPref( + "security.allow_parent_unrestricted_js_loads", + true + ); + + ExtensionParent.apiManager.registerModules({ + mockExtensionAPI: { + schema: `data:,${JSON.stringify(SCHEMA)}`, + scopes: ["addon_parent"], + paths: [["mockExtensionAPI"]], + url: URL.createObjectURL( + new Blob([`this.mockExtensionAPI = ${API.toString()}`]) + ), + }, + }); +}); + +add_task(async function test_relative_urls() { + await runExtensionAPITest( + "should format arguments with the relativeUrl formatter", + { + backgroundScript() { + return browser.mockExtensionAPI.methodAsync([ + "script-1.js", + "script-2.js", + ]); + }, + mockAPIRequestHandler(policy, request) { + return this._handleAPIRequest_orig(policy, request); + }, + assertResults({ testResult, testError, extension }) { + Assert.deepEqual( + testResult, + [ + `moz-extension://${extension.uuid}/script-1.js`, + `moz-extension://${extension.uuid}/script-2.js`, + ], + "expected correct url" + ); + Assert.deepEqual(testError, undefined, "expected no error"); + }, + } + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_runtime_port.js b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_runtime_port.js new file mode 100644 index 0000000000..0d88014f32 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_runtime_port.js @@ -0,0 +1,220 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_method_return_runtime_port() { + await runExtensionAPITest("API method returns an ExtensionPort instance", { + backgroundScript({ testAsserts, testLog }) { + try { + browser.mockExtensionAPI.methodReturnsPort("port-create-error"); + throw new Error("methodReturnsPort should have raised an exception"); + } catch (err) { + testAsserts.equal( + err?.message, + "An unexpected error occurred", + "Got the expected error" + ); + } + const port = browser.mockExtensionAPI.methodReturnsPort( + "port-create-success" + ); + testAsserts.equal(!!port, true, "Got a port"); + testAsserts.equal( + typeof port.name, + "string", + "port.name should be a string" + ); + testAsserts.equal( + typeof port.sender, + "object", + "port.sender should be an object" + ); + testAsserts.equal( + typeof port.disconnect, + "function", + "port.disconnect method" + ); + testAsserts.equal( + typeof port.postMessage, + "function", + "port.postMessage method" + ); + testAsserts.equal( + typeof port.onDisconnect?.addListener, + "function", + "port.onDisconnect.addListener method" + ); + testAsserts.equal( + typeof port.onMessage?.addListener, + "function", + "port.onDisconnect.addListener method" + ); + return new Promise(resolve => { + let messages = []; + port.onDisconnect.addListener(() => resolve(messages)); + port.onMessage.addListener((...args) => { + messages.push(args); + }); + }); + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual( + testResult, + [ + [1, 2], + [3, 4], + [5, 6], + ], + "Got the expected results" + ); + }, + mockAPIRequestHandler(policy, request) { + if (request.apiName == "methodReturnsPort") { + if (request.args[0] == "port-create-error") { + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: "not-a-valid-port", + }; + } + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: { + portId: "port-id-1", + name: "a-port-name", + }, + }; + } else if (request.requestType == "addListener") { + if (request.apiObjectType !== "Port") { + throw new Error(`Unexpected objectType ${request}`); + } + + switch (request.apiName) { + case "onDisconnect": + this._onDisconnectCb = request.eventListener; + return; + case "onMessage": + Promise.resolve().then(async () => { + await request.eventListener.callListener([1, 2]); + await request.eventListener.callListener([3, 4]); + await request.eventListener.callListener([5, 6]); + this._onDisconnectCb.callListener([]); + }); + return; + } + } else if ( + request.requestType == "getProperty" && + request.apiObjectType == "Port" && + request.apiName == "sender" + ) { + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: { id: "fake-sender-id-prop" }, + }; + } + + throw new Error(`Unexpected request: ${request}`); + }, + }); +}); + +add_task(async function test_port_as_event_listener_eventListener_param() { + await runExtensionAPITest( + "API event eventListener received an ExtensionPort parameter", + { + backgroundScript({ testAsserts, testLog }) { + const api = browser.mockExtensionAPI; + let listener; + + return new Promise((resolve, reject) => { + testLog("addListener and wait for event to be fired"); + listener = port => { + try { + testAsserts.equal(!!port, true, "Got a port parameter"); + testAsserts.equal( + port.name, + "a-port-name-2", + "Got expected port.name value" + ); + testAsserts.equal( + typeof port.disconnect, + "function", + "port.disconnect method" + ); + testAsserts.equal( + typeof port.postMessage, + "function", + "port.disconnect method" + ); + port.onMessage.addListener((msg, portArg) => { + if (msg === "test-done") { + testLog("Got a port.onMessage event"); + testAsserts.equal( + portArg?.name, + "a-port-name-2", + "Got port as last argument" + ); + testAsserts.equal( + portArg === port, + true, + "Got the same port instance as expected" + ); + resolve(); + } else { + reject( + new Error( + `port.onMessage got an unexpected message: ${msg}` + ) + ); + } + }); + } catch (err) { + reject(err); + } + }; + api.onTestEvent.addListener(listener); + }); + }, + assertResults({ testError }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + }, + mockAPIRequestHandler(policy, request) { + if ( + request.requestType == "addListener" && + request.apiName == "onTestEvent" + ) { + request.eventListener.callListener(["arg0", "arg1"], { + apiObjectType: Ci.mozIExtensionListenerCallOptions.RUNTIME_PORT, + apiObjectDescriptor: { portId: "port-id-2", name: "a-port-name-2" }, + apiObjectPrepended: true, + }); + return; + } else if ( + request.requestType == "addListener" && + request.apiObjectType == "Port" && + request.apiObjectId == "port-id-2" + ) { + request.eventListener.callListener(["test-done"], { + apiObjectType: Ci.mozIExtensionListenerCallOptions.RUNTIME_PORT, + apiObjectDescriptor: { portId: "port-id-2", name: "a-port-name-2" }, + }); + return; + } + + throw new Error(`Unexpected request: ${request}`); + }, + } + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/xpcshell.toml b/toolkit/components/extensions/test/xpcshell/webidl-api/xpcshell.toml new file mode 100644 index 0000000000..170494f325 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/xpcshell.toml @@ -0,0 +1,34 @@ +[DEFAULT] +head = "../head.js ../head_remote.js ../head_service_worker.js head_webidl_api.js" +firefox-appdir = "browser" +tags = "webextensions webextensions-webidl-api" +# NOTE: these tests seems to be timing out because it takes too much time to +# run all tests and then fully exiting the test. +skip-if = ["os == 'android' && verify"] +prefs = [ + "extensions.backgroundServiceWorker.enabled=true", # Enable support for the extension background service worker. + # Enable Extensions API WebIDL bindings for extension windows. + "extensions.webidl-api.enabled=true", + # Enable ExtensionMockAPI WebIDL bindings used for unit tests + # related to the API request forwarding and not tied to a particular + # extension API. + "extensions.webidl-api.expose_mock_interface=true", + # Make sure that loading the default settings for url-classifier-skip-urls + # doesn't interfere with running our tests while IDB operations are in + # flight by overriding the remote settings server URL to + # ensure that the IDB database isn't created in the first place. + "services.settings.server='data:,#remote-settings-dummy/v1'", +] + +["test_ext_webidl_api.js"] + +["test_ext_webidl_api_event_callback.js"] +skip-if = ["os == 'android' && processor == 'x86_64' && debug"] # Bug 1716308 + +["test_ext_webidl_api_request_handler.js"] + +["test_ext_webidl_api_schema_errors.js"] + +["test_ext_webidl_api_schema_formatters.js"] + +["test_ext_webidl_runtime_port.js"] diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-common-e10s.toml b/toolkit/components/extensions/test/xpcshell/xpcshell-common-e10s.toml new file mode 100644 index 0000000000..c22232f892 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-common-e10s.toml @@ -0,0 +1,26 @@ +[DEFAULT] + +["test_ext_webRequest_eventPage_StreamFilter.js"] + +["test_ext_webRequest_filterResponseData.js"] +skip-if = [ + "tsan", # tsan failure is for test_filter_301 timing out, bug 1674773 + "os == 'android' && debug", + "apple_silicon", # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs + "fission", # Bug 1762638 +] + +["test_ext_webRequest_redirect_StreamFilter.js"] + +["test_ext_webRequest_responseBody.js"] +skip-if = ["os == 'android' && debug"] + +["test_ext_webRequest_startup_StreamFilter.js"] +skip-if = ["os == 'android' && debug"] + +["test_ext_webRequest_viewsource_StreamFilter.js"] +skip-if = [ + "tsan", # Bug 1683730 + "apple_silicon", # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs + "fission", # Bug 1762638 +] diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-common.toml b/toolkit/components/extensions/test/xpcshell/xpcshell-common.toml new file mode 100644 index 0000000000..7cf8d79409 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-common.toml @@ -0,0 +1,682 @@ +[DEFAULT] +# Some tests of downloads.download() expect a file picker, which is only shown +# by default when the browser.download.useDownloadDir pref is set to true. This +# is the case on desktop Firefox, but not on Thunderbird. +# Force pref value to true to get download tests to pass on Thunderbird. +prefs = ["browser.download.useDownloadDir=true"] + +["test_QuarantinedDomains.js"] + +["test_QuarantinedDomains_telemetry.js"] + +["test_change_backgroundServiceWorker_enabled_pref_false.js"] +# This is a mirror:once pref and needs to be set before startup. +prefs = ["extensions.backgroundServiceWorker.enabled=false"] + +["test_change_backgroundServiceWorker_enabled_pref_true.js"] +# This is a mirror:once pref and needs to be set before startup. +prefs = ["extensions.backgroundServiceWorker.enabled=true"] + +["test_change_remote_mode.js"] + +["test_ext_MessageManagerProxy.js"] +skip-if = ["os == 'android'"] # Bug 1545439 + +["test_ext_activityLog.js"] + +["test_ext_alarms.js"] + +["test_ext_alarms_does_not_fire.js"] + +["test_ext_alarms_periodic.js"] + +["test_ext_alarms_replaces.js"] + +["test_ext_api_events_listener_calls_exceptions.js"] + +["test_ext_api_permissions.js"] + +["test_ext_asyncAPICall_isHandlingUserInput.js"] + +["test_ext_background_api_injection.js"] + +["test_ext_background_early_shutdown.js"] + +["test_ext_background_generated_load_events.js"] + +["test_ext_background_generated_reload.js"] + +["test_ext_background_global_history.js"] +skip-if = ["os == 'android'"] # Android does not use Places for history. + +["test_ext_background_iframe.js"] + +["test_ext_background_private_browsing.js"] + +["test_ext_background_runtime_connect_params.js"] + +["test_ext_background_script_and_service_worker.js"] + +["test_ext_background_sub_windows.js"] + +["test_ext_background_teardown.js"] + +["test_ext_background_telemetry.js"] + +["test_ext_background_type_module.js"] + +["test_ext_background_window_properties.js"] +skip-if = ["os == 'android'"] + +["test_ext_brokenlinks.js"] + +["test_ext_browserSettings.js"] + +["test_ext_browserSettings_homepage.js"] +skip-if = [ + "appname == 'thunderbird'", + "os == 'android'", +] + +["test_ext_browser_style_deprecation.js"] +skip-if = ["appname == 'thunderbird'"] + +["test_ext_browsingData.js"] + +["test_ext_browsingData_cookies_cache.js"] + +["test_ext_browsingData_cookies_cookieStoreId.js"] + +["test_ext_cache_api.js"] + +["test_ext_captivePortal.js"] +# As with test_captive_portal_service.js, we use the same limits here. +skip-if = [ + "appname == 'thunderbird'", + "os == 'android'", # CP service is disabled on Android + "os == 'mac' && debug", # macosx1014/debug due to 1564534 +] +run-sequentially = "node server exceptions dont replay well" + +["test_ext_captivePortal_url.js"] +# As with test_captive_portal_service.js, we use the same limits here. +skip-if = [ + "appname == 'thunderbird'", + "os == 'android'", # CP service is disabled on Android, + "os == 'mac' && debug", # macosx1014/debug due to 1564534 +] +run-sequentially = "node server exceptions dont replay well" + +["test_ext_content_security_policy.js"] +skip-if = ["os == 'win'"] # Bug 1762638 + +["test_ext_contentscript_api_injection.js"] + +["test_ext_contentscript_async_loading.js"] +skip-if = [ + "os == 'android' && debug", # The generated script takes too long to load on Android debug + "fission", # Bug 1762638 +] + +["test_ext_contentscript_context.js"] +skip-if = [ + "tsan", # Bug 1683730 + "apple_silicon", # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs + "sessionHistoryInParent", # Bug 1762638 +] + +["test_ext_contentscript_context_isolation.js"] +skip-if = [ + "tsan", # Bug 1683730 + "apple_silicon", # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs + "sessionHistoryInParent", # Bug 1762638 +] + +["test_ext_contentscript_create_iframe.js"] + +["test_ext_contentscript_csp.js"] +run-sequentially = "very high failure rate in parallel" + +["test_ext_contentscript_css.js"] +skip-if = [ + "os == 'linux' && fission", # Bug 1762638 + "os == 'mac' && debug", # Bug 1762638 +] + +["test_ext_contentscript_dynamic_registration.js"] + +["test_ext_contentscript_exporthelpers.js"] + +["test_ext_contentscript_importmap.js"] + +["test_ext_contentscript_in_background.js"] + +["test_ext_contentscript_json_api.js"] + +["test_ext_contentscript_module_import.js"] + +["test_ext_contentscript_restrictSchemes.js"] + +["test_ext_contentscript_teardown.js"] +skip-if = ["tsan"] # Bug 1683730 + +["test_ext_contentscript_unregister_during_loadContentScript.js"] + +["test_ext_contentscript_xml_prettyprint.js"] + +["test_ext_contextual_identities.js"] +skip-if = [ + "appname == 'thunderbird'", + "os == 'android'", # Containers are not exposed to android. +] + +["test_ext_contextual_identities_move.js"] +skip-if = [ + "appname == 'thunderbird'", + "os == 'android'", # Containers are not exposed to android. +] + +["test_ext_cookieBehaviors.js"] +skip-if = [ + "appname == 'thunderbird'", + "apple_silicon", # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs + "tsan", # Bug 1683730 +] + +["test_ext_cookies_errors.js"] + +["test_ext_cookies_firstParty.js"] +skip-if = [ + "appname == 'thunderbird'", + "tsan", # Bug 1683730 +] + +["test_ext_cookies_onChanged.js"] + +["test_ext_cookies_partitionKey.js"] + +["test_ext_cookies_samesite.js"] + +["test_ext_cors_mozextension.js"] + +["test_ext_csp_frame_ancestors.js"] + +["test_ext_csp_upgrade_requests.js"] + +["test_ext_debugging_utils.js"] + +["test_ext_dnr_allowAllRequests.js"] + +["test_ext_dnr_api.js"] + +["test_ext_dnr_download.js"] +skip-if = ["os == 'android'"] # Android: downloads.download goes through the embedder app instead of Gecko, and cannot be intercepted. + +["test_ext_dnr_dynamic_rules.js"] + +["test_ext_dnr_modifyHeaders.js"] + +["test_ext_dnr_private_browsing.js"] + +["test_ext_dnr_redirect_transform.js"] + +["test_ext_dnr_regexFilter.js"] + +["test_ext_dnr_regexFilter_limits.js"] + +["test_ext_dnr_session_rules.js"] + +["test_ext_dnr_startup_cache.js"] + +["test_ext_dnr_static_rules.js"] + +["test_ext_dnr_system_restrictions.js"] + +["test_ext_dnr_tabIds.js"] + +["test_ext_dnr_testMatchOutcome.js"] + +["test_ext_dnr_urlFilter.js"] + +["test_ext_dnr_webrequest.js"] + +["test_ext_dnr_without_webrequest.js"] + +["test_ext_dns.js"] +skip-if = ["os == 'android'"] # Android needs alternative for proxy.settings - bug 1723523 + +["test_ext_downloads.js"] + +["test_ext_downloads_cookieStoreId.js"] +skip-if = ["os == 'android'"] + +["test_ext_downloads_cookies.js"] +skip-if = [ + "os == 'android'", # downloads API needs to be implemented in GeckoView - bug 1538348 + "win11_2009", # Bug 1797751 +] + +["test_ext_downloads_download.js"] +skip-if = [ + "tsan", # Bug 1683730 + "appname == 'thunderbird'", + "os == 'android'", +] + +["test_ext_downloads_eventpage.js"] +skip-if = ["os == 'android'"] + +["test_ext_downloads_misc.js"] +skip-if = [ + "os == 'android'", + "tsan", # Bug 1683730 +] + +["test_ext_downloads_partitionKey.js"] +skip-if = ["os == 'android'"] + +["test_ext_downloads_private.js"] +skip-if = ["os == 'android'"] + +["test_ext_downloads_search.js"] +skip-if = [ + "os == 'android'", + "tsan", # tsan: bug 1612707 +] + +["test_ext_downloads_urlencoded.js"] +skip-if = ["os == 'android'"] + +["test_ext_error_location.js"] + +["test_ext_eventpage_idle.js"] + +["test_ext_eventpage_messaging.js"] + +["test_ext_eventpage_messaging_wakeup.js"] + +["test_ext_eventpage_settings.js"] + +["test_ext_eventpage_warning.js"] + +["test_ext_experiments.js"] + +["test_ext_extension.js"] + +["test_ext_extensionPreferencesManager.js"] + +["test_ext_extensionSettingsStore.js"] + +["test_ext_extension_content_telemetry.js"] + +["test_ext_extension_page_navigated.js"] + +["test_ext_extension_startup_failure.js"] + +["test_ext_extension_startup_telemetry.js"] + +["test_ext_file_access.js"] + +["test_ext_geckoProfiler_control.js"] +skip-if = [ + "os == 'android'", # Not shipped on Android. + "tsan", # tsan: bug 1612707 +] + +["test_ext_geturl.js"] + +["test_ext_idle.js"] + +["test_ext_incognito.js"] +skip-if = ["appname == 'thunderbird'"] + +["test_ext_l10n.js"] + +["test_ext_localStorage.js"] + +["test_ext_management.js"] +skip-if = ["os == 'win' && !debug"] # Bug 1419183 disable on Windows + +["test_ext_management_uninstall_self.js"] + +["test_ext_messaging_startup.js"] +skip-if = [ + "appname == 'thunderbird'", + "os == 'android' && debug", +] + +["test_ext_networkStatus.js"] + +["test_ext_notifications_incognito.js"] +skip-if = ["appname == 'thunderbird'"] + +["test_ext_notifications_unsupported.js"] + +["test_ext_onmessage_removelistener.js"] +skip-if = ["true"] # This test no longer tests what it is meant to test. + +["test_ext_permission_xhr.js"] + +["test_ext_permissions.js"] +skip-if = [ + "appname == 'thunderbird'", + "os == 'android'", # Bug 1350559 +] + +["test_ext_permissions_api.js"] +skip-if = [ + "appname == 'thunderbird'", + "os == 'android'", # Bug 1350559 +] + +["test_ext_permissions_migrate.js"] +skip-if = [ + "appname == 'thunderbird'", + "os == 'android'", # Bug 1350559 +] + +["test_ext_permissions_uninstall.js"] +skip-if = [ + "appname == 'thunderbird'", + "os == 'android'", # Bug 1350559 +] + +["test_ext_persistent_events.js"] + +["test_ext_privacy.js"] +skip-if = [ + "appname == 'thunderbird'", + "os == 'android' && debug", + "os == 'linux' && !debug", # Bug 1625455 +] + +["test_ext_privacy_disable.js"] +skip-if = ["appname == 'thunderbird'"] + +["test_ext_privacy_nonPersistentCookies.js"] + +["test_ext_privacy_update.js"] + +["test_ext_proxy_authorization_via_proxyinfo.js"] +skip-if = ["true"] # Bug 1622433 needs h2 proxy implementation + +["test_ext_proxy_config.js"] +skip-if = ["appname == 'thunderbird'"] + +["test_ext_proxy_containerIsolation.js"] + +["test_ext_proxy_onauthrequired.js"] + +["test_ext_proxy_settings.js"] +skip-if = [ + "appname == 'thunderbird'", + "os == 'android'", # Bug 1725981: proxy settings are not supported on android +] + +["test_ext_proxy_socks.js"] +skip-if = ["socketprocess_networking"] +run-sequentially = "TCPServerSocket fails otherwise" + +["test_ext_proxy_speculative.js"] +skip-if = ["ccov && os == 'linux'"] # bug 1607581 + +["test_ext_proxy_startup.js"] +skip-if = ["ccov && os == 'linux'"] # bug 1607581 + +["test_ext_redirects.js"] +skip-if = ["os == 'android' && debug"] + +["test_ext_runtime_connect_no_receiver.js"] + +["test_ext_runtime_getBackgroundPage.js"] + +["test_ext_runtime_getBrowserInfo.js"] + +["test_ext_runtime_getPlatformInfo.js"] + +["test_ext_runtime_id.js"] +skip-if = ["ccov && os == 'linux'"] # bug 1607581 + +["test_ext_runtime_messaging_self.js"] + +["test_ext_runtime_onInstalled_and_onStartup.js"] + +["test_ext_runtime_ports.js"] + +["test_ext_runtime_ports_gc.js"] + +["test_ext_runtime_sendMessage.js"] + +["test_ext_runtime_sendMessage_errors.js"] + +["test_ext_runtime_sendMessage_multiple.js"] + +["test_ext_runtime_sendMessage_no_receiver.js"] + +["test_ext_same_site_cookies.js"] + +["test_ext_same_site_redirects.js"] + +["test_ext_sandbox_var.js"] + +["test_ext_sandboxed_resource.js"] + +["test_ext_schema.js"] + +["test_ext_script_filenames.js"] +run-sequentially = "very high failure rate in parallel" + +["test_ext_scripting_contentScripts.js"] + +["test_ext_scripting_contentScripts_css.js"] +skip-if = [ + "os == 'linux' && debug && fission", # Bug 1762638 + "os == 'mac' && debug && fission", # Bug 1762638 +] +run-sequentially = "very high failure rate in parallel" + +["test_ext_scripting_contentScripts_file.js"] + +["test_ext_scripting_mv2.js"] + +["test_ext_scripting_persistAcrossSessions.js"] + +["test_ext_scripting_startupCache.js"] + +["test_ext_scripting_updateContentScripts.js"] + +["test_ext_shared_workers.js"] + +["test_ext_shutdown_cleanup.js"] + +["test_ext_simple.js"] + +["test_ext_startupData.js"] + +["test_ext_startup_cache.js"] +skip-if = ["os == 'android'"] + +["test_ext_startup_perf.js"] + +["test_ext_startup_request_handler.js"] + +["test_ext_storage_content_local.js"] +skip-if = ["os == 'android' && debug"] + +["test_ext_storage_content_sync.js"] +skip-if = ["os == 'android'"] # Bug 1625257 - support non-Kinto storage.sync on Android + +["test_ext_storage_content_sync_kinto.js"] +skip-if = ["os == 'android' && debug"] + +["test_ext_storage_idb_data_migration.js"] +skip-if = [ + "appname == 'thunderbird'", + "os == 'android' && debug", +] + +["test_ext_storage_local.js"] +skip-if = ["os == 'android' && debug"] + +["test_ext_storage_managed.js"] +skip-if = ["os == 'android'"] + +["test_ext_storage_managed_policy.js"] +skip-if = [ + "appname == 'thunderbird'", + "os == 'android'", +] + +["test_ext_storage_quota_exceeded_errors.js"] +skip-if = ["os == 'android'"] # Bug 1564871 + +["test_ext_storage_sanitizer.js"] +skip-if = [ + "appname == 'thunderbird'", + "os == 'android'", # Sanitizer.jsm is not in toolkit. +] + +["test_ext_storage_session.js"] + +["test_ext_storage_sync.js"] +skip-if = ["os == 'android'"] # Bug 1625257 - support non-Kinto storage.sync on Android + +["test_ext_storage_sync_kinto.js"] +skip-if = [ + "appname == 'thunderbird'", + "os == 'android'", # resource://services-sync not bundled with Android +] + +["test_ext_storage_sync_kinto_crypto.js"] +skip-if = [ + "appname == 'thunderbird'", + "os == 'android'", # resource://services-sync not bundled with Android +] + +["test_ext_storage_tab.js"] + +["test_ext_storage_telemetry.js"] + +["test_ext_tab_teardown.js"] +skip-if = ["os == 'android'"] # Bug 1258975 on android. + +["test_ext_telemetry.js"] + +["test_ext_theme_experiments.js"] +skip-if = ["os == 'android'"] # Themes aren't supported on android. + +["test_ext_trustworthy_origin.js"] + +["test_ext_unlimitedStorage.js"] + +["test_ext_unload_frame.js"] +skip-if = ["true"] # Too frequent intermittent failures + +["test_ext_userScripts.js"] +run-sequentially = "very high failure rate in parallel" + +["test_ext_userScripts_exports.js"] +run-sequentially = "very high failure rate in parallel" + +["test_ext_userScripts_register.js"] +skip-if = [ + "os == 'linux' && !fission", # Bug 1763197 + "os == 'android'", # Bug 1763197 +] + +["test_ext_wasm.js"] + +["test_ext_webRequest_auth.js"] +skip-if = ["os == 'android' && debug"] + +["test_ext_webRequest_cached.js"] +skip-if = ["os == 'android'"] # Bug 1573511 + +["test_ext_webRequest_cancelWithReason.js"] +skip-if = ["os == 'android' && processor == 'x86_64'"] # Bug 1683253 + +["test_ext_webRequest_containerIsolation.js"] + +["test_ext_webRequest_download.js"] +skip-if = ["os == 'android'"] # Android: downloads.download goes through the embedder app instead of Gecko. + +["test_ext_webRequest_filterTypes.js"] + +["test_ext_webRequest_filter_urls.js"] + +["test_ext_webRequest_from_extension_page.js"] + +["test_ext_webRequest_host.js"] +skip-if = ["os == 'android' && debug"] + +["test_ext_webRequest_incognito.js"] +skip-if = ["os == 'android' && debug"] + +["test_ext_webRequest_mergecsp.js"] +skip-if = ["tsan"] # Bug 1683730 + +["test_ext_webRequest_permission.js"] +skip-if = ["os == 'android' && debug"] + +["test_ext_webRequest_redirectProperty.js"] +skip-if = ["os == 'android' && processor == 'x86_64'"] # Bug 1683253 + +["test_ext_webRequest_redirect_mozextension.js"] + +["test_ext_webRequest_requestSize.js"] + +["test_ext_webRequest_restrictedHeaders.js"] + +["test_ext_webRequest_set_cookie.js"] +skip-if = ["appname == 'thunderbird'"] + +["test_ext_webRequest_startup.js"] +skip-if = ["os == 'android'"] # bug 1683159 + +["test_ext_webRequest_style_cache.js"] +skip-if = ["os == 'android'"] # bug 1848398 - style cache miss on Android. + +["test_ext_webRequest_suspend.js"] + +["test_ext_webRequest_userContextId.js"] + +["test_ext_webRequest_viewsource.js"] + +["test_ext_webRequest_webSocket.js"] +skip-if = ["appname == 'thunderbird'"] + +["test_ext_webSocket.js"] +run-sequentially = "very high failure rate in parallel" + +["test_ext_xhr_capabilities.js"] + +["test_ext_xhr_cors.js"] +run-sequentially = "very high failure rate in parallel" + +["test_native_manifests.js"] +subprocess = true +skip-if = ["os == 'android'"] + +["test_proxy_failover.js"] + +["test_proxy_incognito.js"] +skip-if = ["os == 'android'"] # incognito not supported on android + +["test_proxy_info_results.js"] +skip-if = ["os == 'win'"] # bug 1802704 + +["test_proxy_listener.js"] +skip-if = ["appname == 'thunderbird'"] + +["test_proxy_userContextId.js"] + +["test_resistfingerprinting_exempt.js"] + +["test_site_permissions.js"] +skip-if = ["os == 'android'"] # Site permissions aren't supported on android. + +["test_webRequest_ancestors.js"] + +["test_webRequest_cookies.js"] + +["test_webRequest_filtering.js"] diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-content.toml b/toolkit/components/extensions/test/xpcshell/xpcshell-content.toml new file mode 100644 index 0000000000..df81772ae9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-content.toml @@ -0,0 +1,88 @@ +[DEFAULT] + +["test_ext_adoption_with_private_field_xrays.js"] +skip-if = [ + "!nightly_build", + "os == 'linux' && socketprocess_networking && !fission && debug", # Bug 1759035 +] +run-sequentially = "very high failure rate in parallel" + +["test_ext_adoption_with_xrays.js"] +skip-if = ["os == 'linux' && socketprocess_networking && !fission && debug"] # Bug 1759035 + +["test_ext_contentScripts_register.js"] +skip-if = [ + "os == 'linux' && socketprocess_networking && !fission && debug", # Bug 1759035 + "fission", # Bug 1762638 +] + +["test_ext_contentscript.js"] +skip-if = ["socketprocess_networking"] # Bug 1759035 +run-sequentially = "very high failure rate in parallel" + +["test_ext_contentscript_about_blank_start.js"] + +["test_ext_contentscript_canvas_tainting.js"] +skip-if = ["os == 'linux' && socketprocess_networking && !fission && debug"] # Bug 1759035 +run-sequentially = "very high failure rate in parallel" + +["test_ext_contentscript_errors.js"] +skip-if = [ + "socketprocess_networking", # Bug 1759035 +] +run-sequentially = "very high failure rate in parallel" + +["test_ext_contentscript_permissions_change.js"] +skip-if = [ + "os == 'linux' && socketprocess_networking && !fission && debug", # Bug 1759035 + "os == 'linux' && tsan && fission", # bug 1762638 +] + +["test_ext_contentscript_permissions_fetch.js"] +skip-if = ["os == 'linux' && socketprocess_networking && !fission && debug"] # Bug 1759035 + +["test_ext_contentscript_scriptCreated.js"] +skip-if = ["os == 'linux' && socketprocess_networking && !fission && debug"] # Bug 1759035 + +["test_ext_contentscript_triggeringPrincipal.js"] +skip-if = [ + "(os == 'win' && debug)", # Bug 1438796 + "tsan", # Bug 1612707 + "os == 'linux' && socketprocess_networking && !fission && debug", # Bug 1759035 + "os == 'linux' && fission && debug", # Bug 1762638 +] + +["test_ext_contentscript_xrays.js"] +skip-if = ["os == 'linux' && socketprocess_networking && !fission && debug"] # Bug 1759035 +run-sequentially = "very high failure rate in parallel" + +["test_ext_contexts_gc.js"] +skip-if = ["os == 'linux' && socketprocess_networking && !fission && debug"] # Bug 1759035 +run-sequentially = "very high failure rate in parallel" + +["test_ext_i18n.js"] +skip-if = ["(os == 'win' && debug) || (os == 'linux')"] + +["test_ext_i18n_css.js"] +skip-if = [ + "os == 'mac' && debug && fission", # Bug 1762638 + "(socketprocess_networking || fission) && (os == 'linux' && debug)", # Bug 1759035 +] +run-sequentially = "very high failure rate in parallel" + +["test_ext_shadowdom.js"] +skip-if = [ + "ccov && os == 'linux'", # bug 1607581 + "os == 'linux' && socketprocess_networking && !fission && debug", # Bug 1759035 +] + +["test_ext_web_accessible_resources.js"] +skip-if = [ + "apple_silicon", # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs + "os == 'linux' && socketprocess_networking && !fission && debug", # Bug 1759035 + "sessionHistoryInParent", # Bug 1762638 +] + +["test_ext_web_accessible_resources_matches.js"] +skip-if = ["os == 'linux' && socketprocess_networking && !fission && debug"] # Bug 1759035 +run-sequentially = "very high failure rate in parallel" diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-e10s.toml b/toolkit/components/extensions/test/xpcshell/xpcshell-e10s.toml new file mode 100644 index 0000000000..6764ab421e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-e10s.toml @@ -0,0 +1,32 @@ +[DEFAULT] +head = "head.js head_telemetry.js" +firefox-appdir = "browser" +skip-if = [ + "appname == 'thunderbird'", + "os == 'android'", + "os == 'mac' && debug", # Bug 1814779 +] +dupe-manifest = "" +support-files = [ + "data/**", + "xpcshell-content.toml", +] +tags = "webextensions webextensions-e10s" + +# Make sure that loading the default settings for url-classifier-skip-urls +# doesn't interfere with running our tests while IDB operations are in +# flight by overriding the remote settings server URL to +# ensure that the IDB database isn't created in the first place. +prefs = ["services.settings.server='data:,#remote-settings-dummy/v1'"] + +["include:xpcshell-common-e10s.toml"] +skip-if = ["socketprocess_networking"] # Bug 1759035 + +["include:xpcshell-content.toml"] +skip-if = ["socketprocess_networking && fission"] # Bug 1759035 + +# Tests that need to run with e10s only must NOT be placed here, +# but in xpcshell-common-e10s.toml. +# A test here will only run on one configuration, e10s + in-process extensions, +# while the primary target is e10s + out-of-process extensions. +# xpcshell-common-e10s.toml runs in both configurations. diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-legacy-ep.toml b/toolkit/components/extensions/test/xpcshell/xpcshell-legacy-ep.toml new file mode 100644 index 0000000000..c713e0a87b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-legacy-ep.toml @@ -0,0 +1,28 @@ +[DEFAULT] +head = "head.js head_remote.js head_legacy_ep.js" +tail = "" +firefox-appdir = "browser" +skip-if = [ + "appname == 'thunderbird'", + "os == 'android'", +] +dupe-manifest = "" + +# Make sure that loading the default settings for url-classifier-skip-urls +# doesn't interfere with running our tests while IDB operations are in +# flight by overriding the remote settings server URL to +# ensure that the IDB database isn't created in the first place. +prefs = ["services.settings.server='data:,#remote-settings-dummy/v1'"] + +# Bug 1646182: Test the legacy ExtensionPermission backend until we fully +# migrate to rkv + +["test_ext_permissions.js"] + +["test_ext_permissions_api.js"] + +["test_ext_permissions_migrate.js"] + +["test_ext_permissions_uninstall.js"] + +["test_ext_proxy_config.js"] diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-remote.toml b/toolkit/components/extensions/test/xpcshell/xpcshell-remote.toml new file mode 100644 index 0000000000..5208783393 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-remote.toml @@ -0,0 +1,52 @@ +[DEFAULT] +head = "head.js head_remote.js head_telemetry.js head_sync.js head_storage.js" +firefox-appdir = "browser" +skip-if = [ + "os == 'win' && socketprocess_networking && fission", # Bug 1759035 + "os == 'mac' && socketprocess_networking && fission", # Bug 1759035 + "os == 'mac' && debug", # Bug 1814779 +] +# I would put linux here, but debug has too many chunks and only runs this manifest, so I need 1 test to pass +dupe-manifest = "" +support-files = [ + "data/**", + "head_dnr.js", + "xpcshell-content.toml", +] +tags = "webextensions remote-webextensions" + +# Make sure that loading the default settings for url-classifier-skip-urls +# doesn't interfere with running our tests while IDB operations are in +# flight by overriding the remote settings server URL to +# ensure that the IDB database isn't created in the first place. +prefs = ["services.settings.server='data:,#remote-settings-dummy/v1'"] + +["include:xpcshell-common-e10s.toml"] +skip-if = ["os == 'linux' && socketprocess_networking"] # Bug 1759035 + +["include:xpcshell-common.toml"] +skip-if = ["os == 'linux' && socketprocess_networking"] # Bug 1759035 + +["include:xpcshell-content.toml"] +skip-if = ["os == 'linux' && socketprocess_networking"] # Bug 1759035 + +["test_WebExtensionContentScript.js"] + +["test_ext_contentscript_perf_observers.js"] # Inexplicably, PerformanceObserver used in the test doesn't fire in non-e10s mode. +skip-if = [ + "tsan", + "os == 'linux' && socketprocess_networking", # Bug 1759035 +] + +["test_ext_contentscript_xorigin_frame.js"] +skip-if = ["os == 'linux' && socketprocess_networking"] # Bug 1759035 + +["test_ext_ipcBlob.js"] +skip-if = [ + "os == 'android' && processor == 'x86_64'", + "os == 'linux' && socketprocess_networking", # Bug 1759035 +] + +["test_extension_process_alive.js"] + +["test_process_crash_telemetry.js"] diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-serviceworker.toml b/toolkit/components/extensions/test/xpcshell/xpcshell-serviceworker.toml new file mode 100644 index 0000000000..b300959970 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-serviceworker.toml @@ -0,0 +1,51 @@ +["DEFAULT"] +head = "head.js head_remote.js head_telemetry.js head_sync.js head_storage.js head_service_worker.js" +firefox-appdir = "browser" +skip-if = ["os == 'android'"] +dupe-manifest = true +support-files = "data/**" +tags = "webextensions sw-webextensions" +run-sequentially = "Bug 1760041 pass logged after tests when running multiple ini files" + +prefs = [ + "extensions.backgroundServiceWorker.enabled=true", + "extensions.backgroundServiceWorker.forceInTestExtension=true", + "extensions.webextensions.remote=true", +] + +["test_ext_alarms.js"] + +["test_ext_alarms_does_not_fire.js"] + +["test_ext_alarms_periodic.js"] + +["test_ext_alarms_replaces.js"] + +["test_ext_background_service_worker.js"] + +["test_ext_browserSettings.js"] + +["test_ext_browserSettings_homepage.js"] +skip-if = [ + "appname == 'thunderbird'", + "os == 'android'", +] + +["test_ext_contentscript_dynamic_registration.js"] + +["test_ext_dns.js"] + +["test_ext_runtime_getBackgroundPage.js"] + +["test_ext_scripting_contentScripts.js"] + +["test_ext_scripting_contentScripts_css.js"] +skip-if = [ + "os == 'linux' && debug && fission", # Bug 1762638 + "os == 'mac' && debug && fission", # Bug 1762638 +] +run-sequentially = "very high failure rate in parallel" + +["test_ext_scripting_contentScripts_file.js"] + +["test_ext_scripting_updateContentScripts.js"] diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell.toml b/toolkit/components/extensions/test/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..1d08d855f0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell.toml @@ -0,0 +1,137 @@ +[DEFAULT] +head = "head.js head_telemetry.js head_sync.js head_storage.js" +firefox-appdir = "browser" +dupe-manifest = "" +support-files = [ + "data/**", + "head_dnr.js", + "xpcshell-content.toml", +] +tags = "webextensions in-process-webextensions condprof" + +# Make sure that loading the default settings for url-classifier-skip-urls +# doesn't interfere with running our tests while IDB operations are in +# flight by overriding the remote settings server URL to +# ensure that the IDB database isn't created in the first place. +prefs = ["services.settings.server='data:,#remote-settings-dummy/v1'"] + +# This file contains tests which are not affected by multi-process +# configuration, or do not support out-of-process content or extensions +# for one reason or another. +# +# Tests which are affected by remote content or remote extensions should +# go in one of: +# +# - xpcshell-common.toml +# For tests which should run in all configurations. +# - xpcshell-common-e10s.toml +# For tests which should run in all configurations where e10s is enabled. +# - xpcshell-remote.toml +# For tests which should only run with both remote extensions and remote content. +# - xpcshell-content.toml +# For tests which rely on content pages, and should run in all configurations. +# - xpcshell-e10s.toml +# For tests which rely on content pages, and should only run with remote content +# but in-process extensions. + +["include:xpcshell-common.toml"] +run-if = ["os == 'android'"] # Android has no remote extensions, Bug 1535365 + +["include:xpcshell-content.toml"] +run-if = ["os == 'android'"] # Android has no remote extensions, Bug 1535365 + +["test_ExtensionShortcutKeyMap.js"] + +["test_ExtensionStorageSync_migration_kinto.js"] +skip-if = [ + "os == 'android'", # Not shipped on Android + "condprof", # Bug 1769184 - by design for now +] + +["test_MatchPattern.js"] + +["test_StorageSyncService.js"] +skip-if = ["os == 'android' && processor == 'x86_64'"] + +["test_WebExtensionPolicy.js"] + +["test_csp_custom_policies.js"] + +["test_csp_validator.js"] + +["test_ext_clear_cached_resources.js"] + +["test_ext_contexts.js"] + +["test_ext_geckoProfiler_schema.js"] +skip-if = ["os == 'android'"] # Not shipped on Android + +["test_ext_indexedDB_principal.js"] + +["test_ext_json_parser.js"] + +["test_ext_manifest.js"] + +["test_ext_manifest_content_security_policy.js"] + +["test_ext_manifest_incognito.js"] + +["test_ext_manifest_minimum_chrome_version.js"] + +["test_ext_manifest_minimum_opera_version.js"] + +["test_ext_manifest_themes.js"] + +["test_ext_permission_warnings.js"] + +["test_ext_runtime_sendMessage_args.js"] + +["test_ext_schemas.js"] +head = "head.js head_schemas.js" + +["test_ext_schemas_allowed_contexts.js"] + +["test_ext_schemas_async.js"] + +["test_ext_schemas_interactive.js"] + +["test_ext_schemas_manifest_permissions.js"] +skip-if = ["condprof"] # Bug 1769184 - by design for now + +["test_ext_schemas_privileged.js"] +skip-if = ["condprof"] # Bug 1769184 - by design for now + +["test_ext_schemas_revoke.js"] + +["test_ext_schemas_roots.js"] + +["test_ext_schemas_versioned.js"] +head = "head.js head_schemas.js" + +["test_ext_secfetch.js"] +skip-if = ["socketprocess_networking"] # Bug 1759035 +run-sequentially = "very high failure rate in parallel" + +["test_ext_shared_array_buffer.js"] + +["test_ext_startup_cache_telemetry.js"] + +["test_ext_test_mock.js"] + +["test_ext_test_wrapper.js"] + +["test_ext_unknown_permissions.js"] + +["test_ext_webRequest_urlclassification.js"] + +["test_extension_permissions_migrate_kvstore_path.js"] +skip-if = ["condprof"] # Bug 1769184 - by design for now + +["test_extension_permissions_migration.js"] +skip-if = ["condprof"] # Bug 1769184 - by design for now + +["test_load_all_api_modules.js"] + +["test_locale_converter.js"] + +["test_locale_data.js"] diff --git a/toolkit/components/extensions/tsconfig.json b/toolkit/components/extensions/tsconfig.json new file mode 100644 index 0000000000..d569ba9eca --- /dev/null +++ b/toolkit/components/extensions/tsconfig.json @@ -0,0 +1,56 @@ +{ + "include": ["*.mjs", "types/globals.ts"], + "exclude": [], + + "compilerOptions": { + "checkJs": true, + "target": "ESNEXT", + + "declaration": true, + "outDir": "./types", + "typeRoots": [], + "noEmit": true, + + // prettier-ignore + "paths": { + "resource://gre/modules/ConduitsParent.sys.mjs": ["./ConduitsParent.sys.mjs"], + "resource://gre/modules/ConduitsChild.sys.mjs": ["./ConduitsChild.sys.mjs"], + "resource://gre/modules/Extension.sys.mjs": ["./Extension.sys.mjs"], + "resource://gre/modules/ExtensionActivityLog.sys.mjs": ["./ExtensionActivityLog.sys.mjs"], + "resource://gre/modules/ExtensionChild.sys.mjs": ["./ExtensionChild.sys.mjs"], + "resource://gre/modules/ExtensionCommon.sys.mjs": ["./ExtensionCommon.sys.mjs"], + "resource://gre/modules/ExtensionContent.sys.mjs": ["./ExtensionContent.sys.mjs"], + "resource://gre/modules/ExtensionDNR.sys.mjs": ["./ExtensionDNR.sys.mjs"], + "resource://gre/modules/ExtensionDNRLimits.sys.mjs": ["./ExtensionDNRLimits.sys.mjs"], + "resource://gre/modules/ExtensionDNRStore.sys.mjs": ["./ExtensionDNRStore.sys.mjs"], + "resource://gre/modules/ExtensionPageChild.sys.mjs": ["./ExtensionPageChild.sys.mjs"], + "resource://gre/modules/ExtensionParent.sys.mjs": ["./ExtensionParent.sys.mjs"], + "resource://gre/modules/ExtensionPermissionMessages.sys.mjs": ["./ExtensionPermissionMessages.sys.mjs"], + "resource://gre/modules/ExtensionPermissions.sys.mjs": ["./ExtensionPermissions.sys.mjs"], + "resource://gre/modules/ExtensionStorage.sys.mjs": ["./ExtensionStorage.sys.mjs"], + "resource://gre/modules/ExtensionStorageIDB.sys.mjs": ["./ExtensionStorageIDB.sys.mjs"], + "resource://gre/modules/ExtensionStorageSync.sys.mjs": ["./ExtensionStorageSync.sys.mjs"], + "resource://gre/modules/ExtensionTelemetry.sys.mjs": ["./ExtensionTelemetry.sys.mjs"], + "resource://gre/modules/ExtensionUtils.sys.mjs": ["./ExtensionUtils.sys.mjs"], + "resource://gre/modules/ExtensionWorkerChild.sys.mjs": ["./ExtensionWorkerChild.sys.mjs"], + "resource://gre/modules/MessageManagerProxy.sys.mjs": ["./MessageManagerProxy.sys.mjs"], + "resource://gre/modules/NativeManifests.sys.mjs": ["./NativeManifests.sys.mjs"], + "resource://gre/modules/NativeMessaging.sys.mjs": ["./NativeMessaging.sys.mjs"], + "resource://gre/modules/Schemas.sys.mjs": ["./Schemas.sys.mjs"], + "resource://gre/modules/WebNavigationFrames.sys.mjs": ["./WebNavigationFrames.sys.mjs"], + "resource://gre/modules/WebRequest.sys.mjs": ["./webrequest/WebRequest.sys.mjs"], + + // External. + "resource://gre/modules/addons/crypto-utils.sys.mjs": ["../../mozapps/extensions/internal/crypto-utils.sys.mjs"], + "resource://gre/modules/XPCOMUtils.sys.mjs": ["../../../js/xpconnect/loader/XPCOMUtils.sys.mjs"], + "resource://testing-common/ExtensionTestCommon.sys.mjs": ["./ExtensionTestCommon.sys.mjs"], + + // Types for external modules which need fixing, but we don't wanna touch. + "resource://testing-common/XPCShellContentUtils.sys.mjs": ["./types/XPCShellContentUtils.sys.d.mts"], + + // Catch-all redirect for all other modules. + "resource://gre/modules/*.sys.mjs": ["./types/globals.ts"], + "./*": ["./"] + } + } +} diff --git a/toolkit/components/extensions/types/README.md b/toolkit/components/extensions/types/README.md new file mode 100644 index 0000000000..ebd01dec60 --- /dev/null +++ b/toolkit/components/extensions/types/README.md @@ -0,0 +1,86 @@ +# PoC Type-Checking JavaScript Using JSDocs + +## Intro + +TypeScript can be used on plain JavaScript files documented with JSDoc comments +to check types without a build step. This is a proof of concept to show +viability and benefits of doing this for a "typical" component. + +* [Handbook: Type Checking JavaScript Files][handbook] +* [JSDoc Reference][jsdoc] + +## New files + + * `tsconfig.json`: at the root of a TypeScript "project" configures how to find + and check files. + + * `types/globals.ts`: defines available globals, types and various utilities. + + * `types/XPCShellContentUtils.sys.mts`: an example of type definitions file for + an external module which was automatically generated by `tsc`. + +## How to use and expectations + +Use [npm or yarn to install][download] TypeScript. +Then run `tsc` in the extensions directory to check types: + +``` +mozilla-central/toolkit/components/extensions $ tsc +``` + +You can also use an editor which supports the [language server][langserv]. +VSCode should pick it up automatically, but others like Vim might need +[some configuring][nvim]. + +Other than continuing to use JSDocs, trying to follow guidelines below, and +hopefully remembering to run `tsc` occasionally, for now there is explicitly +*no expectation* that all new code must be fully typed, or pass typecheck +on every commit. + +If you are in a hurry, or run into a confusing type problem, feel free to +slap a @ts-ignore and/or ask for help from someone more familiar. Hopefully +as workflow gets better integrations, we all learn some typescript along +the way, and the whole process remains non-disruptive. + +## Guidelines for type-friendly code + +Using more modern and idiomatic code patterns can enable better type inference +by the TypeScript compiler. + +These fall under 5 main categories: + + 1) Declare and/or initialize all class fields in the constructor. + * (general good practice) + + 2) Use real getters and redefineGetter instead of defineLazyGetter. + * (also keeps related code closer together) + + 3) When extending, don't override class fields with getters, or vice versa. + * https://github.com/microsoft/TypeScript/pull/33509 + + 4) Declare and assign object literals at the same time, not separately. + * (don't use `let foo;` at the top of the file, use `var foo = {`) + + 5) Don't re-use local variables unnecessarily with different types. + * (general good practice, local variables are "free") + +### @ts-ignore recommendations + +*Don't* use `@ts-ignore` for class fields and function or method signatures. + +*Feel free* to use it: + * locally inside methods, + * when the alternative is more noise than signal, + * when doing so isn't preventing passthrough of type-inference through other + parts of the codebase. + + +[handbook]: https://www.typescriptlang.org/docs/handbook/type-checking-javascript-files.html + +[jsdoc]: https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html + +[download]: https://www.typescriptlang.org/download + +[langserv]: https://github.com/typescript-language-server/typescript-language-server + +[nvim]: https://www.reddit.com/r/neovim/comments/131l9cw/theres_another_typescript_lsp_that_wraps_the/ diff --git a/toolkit/components/extensions/types/XPCShellContentUtils.sys.d.mts b/toolkit/components/extensions/types/XPCShellContentUtils.sys.d.mts new file mode 100644 index 0000000000..a2c90c4be1 --- /dev/null +++ b/toolkit/components/extensions/types/XPCShellContentUtils.sys.d.mts @@ -0,0 +1,87 @@ +// @ts-nocheck + +export namespace XPCShellContentUtils { + const currentScope: any; + const fetchScopes: Map<any, any>; + function initCommon(scope: any): void; + function init(scope: any): void; + function initMochitest(scope: any): void; + function ensureInitialized(scope: any): void; + /** + * Creates a new HttpServer for testing, and begins listening on the + * specified port. Automatically shuts down the server when the test + * unit ends. + * + * @param {object} [options = {}] + * The options object. + * @param {integer} [options.port = -1] + * The port to listen on. If omitted, listen on a random + * port. The latter is the preferred behavior. + * @param {sequence<string>?} [options.hosts = null] + * A set of hosts to accept connections to. Support for this is + * implemented using a proxy filter. + * + * @returns {HttpServer} + * The HTTP server instance. + */ + function createHttpServer({ port, hosts }?: { + port?: number; + hosts?: sequence<string>; + }): HttpServer; + + var remoteContentScripts: boolean; + type ContentPage = ContentPage; + + function registerJSON(server: any, path: any, obj: any): void; + function fetch(origin: any, url: any, options: any): Promise<any>; + /** + * Loads a content page into a hidden docShell. + * + * @param {string} url + * The URL to load. + * @param {object} [options = {}] + * @param {ExtensionWrapper} [options.extension] + * If passed, load the URL as an extension page for the given + * extension. + * @param {boolean} [options.remote] + * If true, load the URL in a content process. If false, load + * it in the parent process. + * @param {boolean} [options.remoteSubframes] + * If true, load cross-origin frames in separate content processes. + * This is ignored if |options.remote| is false. + * @param {string} [options.redirectUrl] + * An optional URL that the initial page is expected to + * redirect to. + * + * @returns {ContentPage} + */ + function loadContentPage(url: string, { extension, remote, remoteSubframes, redirectUrl, privateBrowsing, userContextId, }?: { + extension?: any; + remote?: boolean; + remoteSubframes?: boolean; + redirectUrl?: string; + }): ContentPage; +} +declare class ContentPage { + constructor(remote?: any, remoteSubframes?: any, extension?: any, privateBrowsing?: boolean, userContextId?: any); + remote: any; + remoteSubframes: any; + extension: any; + privateBrowsing: boolean; + userContextId: any; + browserReady: Promise<Element>; + _initBrowser(): Promise<Element>; + windowlessBrowser: any; + browser: Element; + get browsingContext(): any; + get SpecialPowers(): any; + loadFrameScript(func: any): void; + addFrameScriptHelper(func: any): void; + didChangeBrowserRemoteness(event: any): void; + loadURL(url: any, redirectUrl?: any): Promise<any>; + fetch(...args: any[]): Promise<any>; + spawn(params: any, task: any): any; + legacySpawn(params: any, task: any): any; + close(): Promise<void>; +} +export {}; diff --git a/toolkit/components/extensions/types/extensions.ts b/toolkit/components/extensions/types/extensions.ts new file mode 100644 index 0000000000..8f9555421b --- /dev/null +++ b/toolkit/components/extensions/types/extensions.ts @@ -0,0 +1,80 @@ +/** + * Type declarations for WebExtensions framework code. + */ + +// This has every possible property we import from all modules, which is not +// great, but should be manageable and easy to generate for each component. +// ESLint warns if we use one which is not actually defined, so still safe. +type LazyAll = { + BroadcastConduit: typeof import("ConduitsParent.sys.mjs").BroadcastConduit, + Extension: typeof import("Extension.sys.mjs").Extension, + ExtensionActivityLog: typeof import("ExtensionActivityLog.sys.mjs").ExtensionActivityLog, + ExtensionChild: typeof import("ExtensionChild.sys.mjs").ExtensionChild, + ExtensionCommon: typeof import("ExtensionCommon.sys.mjs").ExtensionCommon, + ExtensionContent: typeof import("ExtensionContent.sys.mjs").ExtensionContent, + ExtensionDNR: typeof import("ExtensionDNR.sys.mjs").ExtensionDNR, + ExtensionDNRLimits: typeof import("ExtensionDNRLimits.sys.mjs").ExtensionDNRLimits, + ExtensionDNRStore: typeof import("ExtensionDNRStore.sys.mjs").ExtensionDNRStore, + ExtensionData: typeof import("Extension.sys.mjs").ExtensionData, + ExtensionPageChild: typeof import("ExtensionPageChild.sys.mjs").ExtensionPageChild, + ExtensionParent: typeof import("ExtensionParent.sys.mjs").ExtensionParent, + ExtensionPermissions: typeof import("ExtensionPermissions.sys.mjs").ExtensionPermissions, + ExtensionStorage: typeof import("ExtensionStorage.sys.mjs").ExtensionStorage, + ExtensionStorageIDB: typeof import("ExtensionStorageIDB.sys.mjs").ExtensionStorageIDB, + ExtensionTelemetry: typeof import("ExtensionTelemetry.sys.mjs").ExtensionTelemetry, + ExtensionTestCommon: typeof import("resource://testing-common/ExtensionTestCommon.sys.mjs").ExtensionTestCommon, + ExtensionUtils: typeof import("ExtensionUtils.sys.mjs").ExtensionUtils, + ExtensionWorkerChild: typeof import("ExtensionWorkerChild.sys.mjs").ExtensionWorkerChild, + GeckoViewConnection: typeof import("resource://gre/modules/GeckoViewWebExtension.sys.mjs").GeckoViewConnection, + JSONFile: typeof import("resource://gre/modules/JSONFile.sys.mjs").JSONFile, + Management: typeof import("Extension.sys.mjs").Management, + MessageManagerProxy: typeof import("MessageManagerProxy.sys.mjs").MessageManagerProxy, + NativeApp: typeof import("NativeMessaging.sys.mjs").NativeApp, + NativeManifests: typeof import("NativeManifests.sys.mjs").NativeManifests, + PERMISSION_L10N: typeof import("ExtensionPermissionMessages.sys.mjs").PERMISSION_L10N, + QuarantinedDomains: typeof import("ExtensionPermissions.sys.mjs").QuarantinedDomains, + SchemaRoot: typeof import("Schemas.sys.mjs").SchemaRoot, + Schemas: typeof import("Schemas.sys.mjs").Schemas, + WebNavigationFrames: typeof import("WebNavigationFrames.sys.mjs").WebNavigationFrames, + WebRequest: typeof import("webrequest/WebRequest.sys.mjs").WebRequest, + extensionStorageSync: typeof import("ExtensionStorageSync.sys.mjs").extensionStorageSync, + getErrorNameForTelemetry: typeof import("ExtensionTelemetry.sys.mjs").getErrorNameForTelemetry, + getTrimmedString: typeof import("ExtensionTelemetry.sys.mjs").getTrimmedString, +}; + +// Utility type to extract all strings from a const array, to use as keys. +type Items<A> = A extends ReadonlyArray<infer U extends string> ? U : never; + +declare global { + type Lazy<Keys extends keyof LazyAll = keyof LazyAll> = Pick<LazyAll, Keys> & { [k: string]: any }; + + // Export JSDoc types, and make other classes available globally. + type ConduitAddress = import("ConduitsParent.sys.mjs").ConduitAddress; + type ConduitID = import("ConduitsParent.sys.mjs").ConduitID; + type Extension = import("Extension.sys.mjs").Extension; + + // Something about Class type not being exported when nested in a namespace? + type BaseContext = InstanceType<typeof import("ExtensionCommon.sys.mjs").ExtensionCommon.BaseContext>; + type BrowserExtensionContent = InstanceType<typeof import("ExtensionContent.sys.mjs").ExtensionContent.BrowserExtensionContent>; + type EventEmitter = InstanceType<typeof import("ExtensionCommon.sys.mjs").ExtensionCommon.EventEmitter>; + type ExtensionAPI = InstanceType<typeof import("ExtensionCommon.sys.mjs").ExtensionCommon.ExtensionAPI>; + type ExtensionError = InstanceType<typeof import("ExtensionUtils.sys.mjs").ExtensionUtils.ExtensionError>; + type LocaleData = InstanceType<typeof import("ExtensionCommon.sys.mjs").ExtensionCommon.LocaleData>; + type ProxyAPIImplementation = InstanceType<typeof import("ExtensionChild.sys.mjs").ExtensionChild.ProxyAPIImplementation>; + type SchemaAPIInterface = InstanceType<typeof import("ExtensionCommon.sys.mjs").ExtensionCommon.SchemaAPIInterface>; + type WorkerExtensionError = InstanceType<typeof import("ExtensionUtils.sys.mjs").ExtensionUtils.WorkerExtensionError>; + + // Other misc types. + type AddonWrapper = any; + type Context = BaseContext; + type NativeTab = Element; + type SavedFrame = object; + + // Can't define a const generic parameter in jsdocs yet. + // https://github.com/microsoft/TypeScript/issues/56634 + type ConduitInit<Send> = ConduitAddress & { send: Send; }; + type Conduit<Send> = import("../ConduitsChild.sys.mjs").PointConduit & { [s in `send${Items<Send>}`]: callback }; + type ConduitOpen = <const Send>(subject: object, address: ConduitInit<Send>) => Conduit<Send>; +} + +export {} diff --git a/toolkit/components/extensions/types/gecko.ts b/toolkit/components/extensions/types/gecko.ts new file mode 100644 index 0000000000..f6b5190f8d --- /dev/null +++ b/toolkit/components/extensions/types/gecko.ts @@ -0,0 +1,163 @@ +/** + * Global Gecko type declarations. + */ + +// @ts-ignore +import type { CiClass } from "lib.gecko.xpidl" + +declare global { + // Other misc types. + type Browser = InstanceType<typeof XULBrowserElement>; + type bytestring = string; + type callback = (...args: any[]) => any; + type ColorArray = number[]; + type integer = number; + type JSONValue = null | boolean | number | string | JSONValue[] | { [key: string]: JSONValue }; + + interface Document { + createXULElement(name: string): Element; + documentReadyForIdle: Promise<void>; + } + interface EventTarget { + ownerGlobal: Window; + } + interface Error { + code; + } + interface ErrorConstructor { + new (message?: string, options?: ErrorOptions, lineNo?: number): Error; + } + interface Window { + gBrowser; + } + // HACK to get the static isInstance for DOMException and Window? + interface Object { + isInstance(object: any): boolean; + } + + // XPIDL additions/overrides. + + interface nsISupports { + // OMG it works! + QueryInterface?<T extends CiClass<nsISupports>>(aCiClass: T): T['prototype']; + wrappedJSObject?: object; + } + interface nsIProperties { + get<T extends CiClass<nsISupports>>(prop: string, aCiClass: T): T['prototype']; + } + interface nsIPrefBranch { + getComplexValue<T extends CiClass<nsISupports>>(aPrefName: string, aCiClass: T): T['prototype']; + } + // TODO: incorporate above into lib.xpidl.d.ts generation, somehow? + + type Sandbox = typeof globalThis; + interface nsIXPCComponents_utils_Sandbox { + (principal: nsIPrincipal | nsIPrincipal[], options: object): Sandbox; + } + interface nsIXPCComponents_Utils { + cloneInto<T>(obj: T, ...args: any[]): T; + createObjectIn<T>(Sandbox, options?: T): T; + exportFunction<T extends callback>(f: T, ...args: any[]): T; + getWeakReference<T extends object>(T): { get(): T }; + readonly Sandbox: nsIXPCComponents_utils_Sandbox; + waiveXrays<T>(obj: T): T; + } + interface nsIDOMWindow extends Window { + docShell: nsIDocShell; + } + interface Document { + documentURIObject: nsIURI; + createXULElement(name: string): Element; + } + + // nsDocShell is the only thing implementing nsIDocShell, but it also + // implements nsIWebNavigation, and a few others, so this is "ok". + interface nsIDocShell extends nsIWebNavigation {} + interface nsISimpleEnumerator extends Iterable<any> {} + + namespace Components { + type Exception = Error; + } + namespace UrlbarUtils { + type RESULT_TYPE = any; + type RESULT_SOURCE = any; + } + + // Various mozilla globals. + var Cc, Cr, ChromeUtils, Components, dump, uneval; + + // [ChromeOnly] WebIDL, to be generated. + var BrowsingContext, ChannelWrapper, ChromeWindow, ChromeWorker, + ClonedErrorHolder, Glean, InspectorUtils, IOUtils, JSProcessActorChild, + JSProcessActorParent, JSWindowActor, JSWindowActorChild, + JSWindowActorParent, L10nRegistry, L10nFileSource, Localization, + MatchGlob, MatchPattern, MatchPatternSet, PathUtils, PreloadedScript, + StructuredCloneHolder, TelemetryStopwatch, WindowGlobalChild, + WebExtensionContentScript, WebExtensionParentActor, WebExtensionPolicy, + XULBrowserElement, nsIMessageListenerManager; + + interface XULElement extends Element {} + + // nsIServices is not a thing. + interface nsIServices { + scriptloader: mozIJSSubScriptLoader; + locale: mozILocaleService; + intl: mozIMozIntl; + storage: mozIStorageService; + appShell: nsIAppShellService; + startup: nsIAppStartup; + blocklist: nsIBlocklistService; + cache2: nsICacheStorageService; + catMan: nsICategoryManager; + clearData: nsIClearDataService; + clipboard: nsIClipboard; + console: nsIConsoleService; + cookieBanners: nsICookieBannerService; + cookies: nsICookieManager & nsICookieService; + appinfo: nsICrashReporter & nsIXULAppInfo & nsIXULRuntime; + DAPTelemetry: nsIDAPTelemetry; + DOMRequest: nsIDOMRequestService; + dns: nsIDNSService; + dirsvc: nsIDirectoryService & nsIProperties; + droppedLinkHandler: nsIDroppedLinkHandler; + eTLD: nsIEffectiveTLDService; + policies: nsIEnterprisePolicies; + env: nsIEnvironment; + els: nsIEventListenerService; + fog: nsIFOG; + focus: nsIFocusManager; + io: nsIIOService & nsINetUtil & nsISpeculativeConnect; + loadContextInfo: nsILoadContextInfoFactory; + domStorageManager: nsIDOMStorageManager & nsILocalStorageManager; + logins: nsILoginManager; + obs: nsIObserverService; + perms: nsIPermissionManager; + prefs: nsIPrefBranch & nsIPrefService; + profiler: nsIProfiler; + prompt: nsIPromptService; + sysinfo: nsISystemInfo & nsIPropertyBag2; + qms: nsIQuotaManagerService; + rfp: nsIRFPService; + scriptSecurityManager: nsIScriptSecurityManager; + search: nsISearchService; + sessionStorage: nsISessionStorageService; + strings: nsIStringBundleService; + telemetry: nsITelemetry; + textToSubURI: nsITextToSubURI; + tm: nsIThreadManager; + uriFixup: nsIURIFixup; + urlFormatter: nsIURLFormatter; + uuid: nsIUUIDGenerator; + vc: nsIVersionComparator; + wm: nsIWindowMediator; + ww: nsIWindowWatcher; + xulStore: nsIXULStore; + ppmm: any; + cpmm: any; + mm: any; + } + + var Ci: nsIXPCComponents_Interfaces; + var Cu: nsIXPCComponents_Utils; + var Services: nsIServices; +} diff --git a/toolkit/components/extensions/types/globals.ts b/toolkit/components/extensions/types/globals.ts new file mode 100644 index 0000000000..45722828e2 --- /dev/null +++ b/toolkit/components/extensions/types/globals.ts @@ -0,0 +1,33 @@ +/** + * Support types for toolkit/components/extensions code. + */ + +/// <reference lib="dom" /> +/// <reference path="./gecko.ts" /> +/// <reference path="./extensions.ts" /> + +// This now relies on types generated in bug 1872918, or get the built +// artifact tslib directly and put it in your src/node_modules/@types: +// https://phabricator.services.mozilla.com/D197620 +/// <reference types="lib.gecko.xpidl" /> + +// Exports for all other external modules redirected to globals.ts. +export var AppConstants, + GeckoViewConnection, GeckoViewWebExtension, IndexedDB, JSONFile, Log; + +/** + * This is a mock for the "class" from EventEmitter.sys.mjs. When we import + * it in extensions code using resource://gre/modules/EventEmitter.sys.mjs, + * the catch-all rule from tsconfig.json redirects it to this file. The export + * of the class below fulfills the import. The mock is needed when we subclass + * that EventEmitter, typescript gets confused because it's an old style + * function-and-prototype-based "class", and some types don't match up. + * + * TODO: Convert EventEmitter.sys.mjs into a proper class. + */ +export declare class EventEmitter { + on(event: string, listener: callback): void; + once(event: string, listener: callback): Promise<any>; + off(event: string, listener: callback): void; + emit(event: string, ...args: any[]): void; +} diff --git a/toolkit/components/extensions/webidl-api/ExtensionAPI.cpp.in b/toolkit/components/extensions/webidl-api/ExtensionAPI.cpp.in new file mode 100644 index 0000000000..8c64472a78 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionAPI.cpp.in @@ -0,0 +1,56 @@ +/* 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 "{{ webidl_name }}.h" +#include "ExtensionEventManager.h" + +#include "mozilla/dom/{{ webidl_name }}Binding.h" +#include "nsIGlobalObject.h" + +namespace mozilla::extensions { + +NS_IMPL_CYCLE_COLLECTING_ADDREF({{ webidl_name }}); +NS_IMPL_CYCLE_COLLECTING_RELEASE({{ webidl_name }}) +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE({{ webidl_name }}, mGlobal, mExtensionBrowser + /* TODO: add events properties if any */); + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION({{ webidl_name }}) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +/* TODO add implementation for the event manager getter if any. + +// Defines the OnEVENTNAME method implementation and expects a data member computed +// based on the getter method name (e.g. `mOnEVENTNAMEEventManager`) where the +// ExtensionEventManager instance is going to be stored. +NS_IMPL_WEBEXT_EVENTMGR({{webidl_name}}, u"onEVENTNAME"_ns, OnEVENTNAME) + +// or to use a specific data member name: +// +// NS_IMPL_WEBEXT_EVENTMGR_WITH_DATAMEMBER({{webidl_name}}, u"onEVENTNAME"_ns, OnEventName, mDataMemberName) + +*/ + +{{ webidl_name }}::{{ webidl_name }}(nsIGlobalObject* aGlobal, + ExtensionBrowser* aExtensionBrowser) + : mGlobal(aGlobal), mExtensionBrowser(aExtensionBrowser) { + MOZ_DIAGNOSTIC_ASSERT(mGlobal); + MOZ_DIAGNOSTIC_ASSERT(mExtensionBrowser); +} + +/* static */ +bool {{ webidl_name }}::IsAllowed(JSContext* aCx, JSObject* aGlobal) { + return true; +} + +JSObject* {{ webidl_name }}::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return dom::{{ webidl_name }}_Binding::Wrap(aCx, this, aGivenProto); +} + +nsIGlobalObject* {{ webidl_name }}::GetParentObject() const { return mGlobal; } + +} // namespace mozilla::extensions diff --git a/toolkit/components/extensions/webidl-api/ExtensionAPI.h.in b/toolkit/components/extensions/webidl-api/ExtensionAPI.h.in new file mode 100644 index 0000000000..f902d98d2e --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionAPI.h.in @@ -0,0 +1,71 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_{{ webidl_name }}_h +#define mozilla_extensions_{{ webidl_name }}_h + +#include "js/TypeDecls.h" +#include "mozilla/Attributes.h" +#include "mozilla/dom/BindingDeclarations.h" +#include "nsCycleCollectionParticipant.h" +#include "nsCOMPtr.h" +#include "nsISupports.h" +#include "nsWrapperCache.h" + +#include "ExtensionAPIBase.h" +#include "ExtensionBrowser.h" + +class nsIGlobalObject; + +namespace mozilla::extensions { + +class ExtensionEventManager; + +class {{ webidl_name }} final : public nsISupports, + public nsWrapperCache, + public ExtensionAPINamespace { + public: + {{ webidl_name }}(nsIGlobalObject* aGlobal, ExtensionBrowser* aExtensionBrowser); + + // ExtensionAPIBase methods + nsIGlobalObject* GetGlobalObject() const override { return mGlobal; } + + ExtensionBrowser* GetExtensionBrowser() const override { + return mExtensionBrowser; + } + + nsString GetAPINamespace() const override { return u"{{ api_namespace }}"_ns; } + + // nsWrapperCache interface methods + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + // DOM bindings methods + static bool IsAllowed(JSContext* aCx, JSObject* aGlobal); + + nsIGlobalObject* GetParentObject() const; + + // TODO: add method for the event manager objects if any. + // ExtensionEventManager* OnEVENTNAME(); + + // TODO: add methods for the property getters if any. + // void GetPROP_NAME(JSContext* aCx, JS::MutableHandle<JS::Value> aRetval); + + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS({{ webidl_name }}) + + private: + ~{{ webidl_name }}() = default; + + nsCOMPtr<nsIGlobalObject> mGlobal; + RefPtr<ExtensionBrowser> mExtensionBrowser; + // TODO: add RefPtr for the ExtensionEventManager instances if any. + // RefPtr<ExtensionEventManager> mOnEVENTNAMEEventMgr; +}; + +} // namespace mozilla::extensions + +#endif // mozilla_extensions_{{ webidl_name }}_h diff --git a/toolkit/components/extensions/webidl-api/ExtensionAPI.webidl.in b/toolkit/components/extensions/webidl-api/ExtensionAPI.webidl.in new file mode 100644 index 0000000000..43a25d5498 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionAPI.webidl.in @@ -0,0 +1,33 @@ +/* + * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT + * + * The content of this file has been generated based on the WebExtensions API + * JSONSchema using the following command: + * + * export SCRIPT_DIR="toolkit/components/extensions/webidl-api" + * mach python $SCRIPT_DIR/GenerateWebIDLBindings.py -- {{ api_namespace }} + * + * More info about generating webidl API bindings for WebExtensions API at: + * + * https://firefox-source-docs.mozilla.org/toolkit/components/extensions/webextensions/webidl_bindings.html + * + * A short summary of the special setup used by these WebIDL files (meant to aid + * webidl peers reviews and sign-offs) is available in the following section: + * + * https://firefox-source-docs.mozilla.org/toolkit/components/extensions/webextensions/webidl_bindings.html#review-process-on-changes-to-webidl-definitions + */ + +/* 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/. + * + * You are granted a license to use, reproduce and create derivative works of + * this document. + */ +{%+ if webidl_description_comment %} +{{ webidl_description_comment }} +{%- endif %} +[Exposed=({{ webidl_exposed_attr }}), LegacyNoInterfaceObject] +interface {{ webidl_name }} { +{{- webidl_definition_body }} +}; diff --git a/toolkit/components/extensions/webidl-api/ExtensionAPIAddRemoveListener.h b/toolkit/components/extensions/webidl-api/ExtensionAPIAddRemoveListener.h new file mode 100644 index 0000000000..5020641e23 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionAPIAddRemoveListener.h @@ -0,0 +1,36 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_ExtensionAPIAddRemoveListener_h +#define mozilla_extensions_ExtensionAPIAddRemoveListener_h + +#include "ExtensionAPIRequestForwarder.h" + +namespace mozilla { +namespace extensions { + +class ExtensionAPIAddRemoveListener : public ExtensionAPIRequestForwarder { + public: + enum class EType { + eAddListener, + eRemoveListener, + }; + + ExtensionAPIAddRemoveListener(const EType type, + const nsAString& aApiNamespace, + const nsAString& aApiEvent, + const nsAString& aApiObjectType, + const nsAString& aApiObjectId) + : ExtensionAPIRequestForwarder( + type == EType::eAddListener ? APIRequestType::ADD_LISTENER + : APIRequestType::REMOVE_LISTENER, + aApiNamespace, aApiEvent, aApiObjectType, aApiObjectId) {} +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_ExtensionAPIAddRemoveListener_h diff --git a/toolkit/components/extensions/webidl-api/ExtensionAPIBase.cpp b/toolkit/components/extensions/webidl-api/ExtensionAPIBase.cpp new file mode 100644 index 0000000000..503124a39a --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionAPIBase.cpp @@ -0,0 +1,364 @@ +/* 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 "ExtensionAPIBase.h" + +#include "ExtensionAPIRequestForwarder.h" +#include "ExtensionAPIAddRemoveListener.h" +#include "ExtensionAPICallAsyncFunction.h" +#include "ExtensionAPICallFunctionNoReturn.h" +#include "ExtensionAPICallSyncFunction.h" +#include "ExtensionAPIGetProperty.h" +#include "ExtensionBrowser.h" +#include "ExtensionEventManager.h" +#include "ExtensionPort.h" +#include "ExtensionSetting.h" + +#include "mozilla/ConsoleReportCollector.h" +#include "mozilla/dom/Promise.h" +#include "mozilla/dom/SerializedStackHolder.h" +#include "mozilla/dom/FunctionBinding.h" + +#include "js/CallAndConstruct.h" // JS::IsCallable + +namespace mozilla { +namespace extensions { + +// ChromeCompatCallbackHandler + +NS_IMPL_ISUPPORTS0(ChromeCompatCallbackHandler) + +// static +void ChromeCompatCallbackHandler::Create( + ExtensionBrowser* aExtensionBrowser, dom::Promise* aPromise, + const RefPtr<dom::Function>& aCallback) { + MOZ_ASSERT(aPromise); + MOZ_ASSERT(aExtensionBrowser); + MOZ_ASSERT(aCallback); + + RefPtr<ChromeCompatCallbackHandler> handler = + new ChromeCompatCallbackHandler(aExtensionBrowser, aCallback); + + aPromise->AppendNativeHandler(handler); +} + +void ChromeCompatCallbackHandler::ResolvedCallback(JSContext* aCx, + JS::Handle<JS::Value> aValue, + ErrorResult& aRv) { + JS::Rooted<JS::Value> retval(aCx); + IgnoredErrorResult rv; + MOZ_KnownLive(mCallback)->Call({aValue}, &retval, rv); +} + +void ChromeCompatCallbackHandler::RejectedCallback(JSContext* aCx, + JS::Handle<JS::Value> aValue, + ErrorResult& aRv) { + JS::Rooted<JS::Value> retval(aCx); + IgnoredErrorResult rv; + // Call the chrome-compatible callback without any parameter, the errors + // isn't passed to the callback as a parameter but the extension will be + // able to retrieve it from chrome.runtime.lastError. + mExtensionBrowser->SetLastError(aValue); + MOZ_KnownLive(mCallback)->Call({}, &retval, rv); + if (mExtensionBrowser->ClearLastError()) { + ReportUncheckedLastError(aCx, aValue); + } +} + +void ChromeCompatCallbackHandler::ReportUncheckedLastError( + JSContext* aCx, JS::Handle<JS::Value> aValue) { + nsCString sourceSpec; + uint32_t line = 0; + uint32_t column = 0; + nsString valueString; + + nsContentUtils::ExtractErrorValues(aCx, aValue, sourceSpec, &line, &column, + valueString); + + nsTArray<nsString> params; + params.AppendElement(valueString); + + RefPtr<ConsoleReportCollector> reporter = new ConsoleReportCollector(); + reporter->AddConsoleReport(nsIScriptError::errorFlag, "content javascript"_ns, + nsContentUtils::eDOM_PROPERTIES, sourceSpec, line, + column, "WebExtensionUncheckedLastError"_ns, + params); + + dom::WorkerPrivate* workerPrivate = dom::GetWorkerPrivateFromContext(aCx); + RefPtr<Runnable> r = NS_NewRunnableFunction( + "ChromeCompatCallbackHandler::ReportUncheckedLastError", + [reporter]() { reporter->FlushReportsToConsole(0); }); + workerPrivate->DispatchToMainThread(r.forget()); +} + +// WebExtensionStub methods shared between multiple API namespaces. + +void ExtensionAPIBase::CallWebExtMethodNotImplementedNoReturn( + JSContext* aCx, const nsAString& aApiMethod, + const dom::Sequence<JS::Value>& aArgs, ErrorResult& aRv) { + aRv.ThrowNotSupportedError("Not implemented"); +} + +void ExtensionAPIBase::CallWebExtMethodNotImplementedAsync( + JSContext* aCx, const nsAString& aApiMethod, + const dom::Sequence<JS::Value>& aArgs, + const dom::Optional<OwningNonNull<dom::Function>>& aCallback, + JS::MutableHandle<JS::Value> aRetval, ErrorResult& aRv) { + CallWebExtMethodNotImplementedNoReturn(aCx, aApiMethod, aArgs, aRv); +} + +void ExtensionAPIBase::CallWebExtMethodNotImplemented( + JSContext* aCx, const nsAString& aApiMethod, + const dom::Sequence<JS::Value>& aArgs, JS::MutableHandle<JS::Value> aRetval, + ErrorResult& aRv) { + CallWebExtMethodNotImplementedNoReturn(aCx, aApiMethod, aArgs, aRv); +} + +void ExtensionAPIBase::CallWebExtMethodNoReturn( + JSContext* aCx, const nsAString& aApiMethod, + const dom::Sequence<JS::Value>& aArgs, ErrorResult& aRv) { + auto request = CallFunctionNoReturn(aApiMethod); + request->Run(GetGlobalObject(), aCx, aArgs, aRv); + if (aRv.Failed()) { + return; + } +} + +void ExtensionAPIBase::CallWebExtMethod(JSContext* aCx, + const nsAString& aApiMethod, + const dom::Sequence<JS::Value>& aArgs, + JS::MutableHandle<JS::Value> aRetVal, + ErrorResult& aRv) { + auto request = CallSyncFunction(aApiMethod); + request->Run(GetGlobalObject(), aCx, aArgs, aRetVal, aRv); + if (aRv.Failed()) { + return; + } +} + +void ExtensionAPIBase::CallWebExtMethodReturnsString( + JSContext* aCx, const nsAString& aApiMethod, + const dom::Sequence<JS::Value>& aArgs, nsAString& aRetVal, + ErrorResult& aRv) { + JS::Rooted<JS::Value> retval(aCx); + auto request = CallSyncFunction(aApiMethod); + request->Run(GetGlobalObject(), aCx, aArgs, &retval, aRv); + if (aRv.Failed()) { + return; + } + + if (NS_WARN_IF(!retval.isString())) { + ThrowUnexpectedError(aCx, aRv); + return; + } + + nsAutoJSString str; + if (!str.init(aCx, retval.toString())) { + JS_ClearPendingException(aCx); + ThrowUnexpectedError(aCx, aRv); + return; + } + + aRetVal = str; +} + +already_AddRefed<ExtensionPort> ExtensionAPIBase::CallWebExtMethodReturnsPort( + JSContext* aCx, const nsAString& aApiMethod, + const dom::Sequence<JS::Value>& aArgs, ErrorResult& aRv) { + JS::Rooted<JS::Value> apiResult(aCx); + auto request = CallSyncFunction(aApiMethod); + request->Run(GetGlobalObject(), aCx, aArgs, &apiResult, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + + IgnoredErrorResult rv; + auto* extensionBrowser = GetExtensionBrowser(); + RefPtr<ExtensionPort> port = extensionBrowser->GetPort(apiResult, rv); + if (NS_WARN_IF(rv.Failed())) { + // ExtensionPort::Create doesn't throw the js exception with the generic + // error message as the "api request forwarding" helper classes. + ThrowUnexpectedError(aCx, aRv); + return nullptr; + } + + return port.forget(); +} + +void ExtensionAPIBase::CallWebExtMethodAsyncInternal( + JSContext* aCx, const nsAString& aApiMethod, + const dom::Sequence<JS::Value>& aArgs, + const RefPtr<dom::Function>& aCallback, + JS::MutableHandle<JS::Value> aRetval, ErrorResult& aRv) { + auto* global = GetGlobalObject(); + + IgnoredErrorResult erv; + RefPtr<dom::Promise> domPromise = dom::Promise::Create(global, erv); + if (NS_WARN_IF(erv.Failed())) { + ThrowUnexpectedError(aCx, aRv); + return; + } + MOZ_ASSERT(domPromise); + auto request = CallAsyncFunction(aApiMethod); + request->Run(global, aCx, aArgs, domPromise, aRv); + if (aRv.Failed()) { + return; + } + + // The async method has been called with the chrome-compatible callback + // convention. + if (aCallback) { + ChromeCompatCallbackHandler::Create(GetExtensionBrowser(), domPromise, + aCallback); + return; + } + + if (NS_WARN_IF(!ToJSValue(aCx, domPromise, aRetval))) { + ThrowUnexpectedError(aCx, aRv); + return; + } +} + +void ExtensionAPIBase::CallWebExtMethodAsync( + JSContext* aCx, const nsAString& aApiMethod, + const dom::Sequence<JS::Value>& aArgs, + const dom::Optional<OwningNonNull<dom::Function>>& aCallback, + JS::MutableHandle<JS::Value> aRetval, ErrorResult& aRv) { + RefPtr<dom::Function> callback = nullptr; + if (aCallback.WasPassed()) { + callback = &aCallback.Value(); + } + CallWebExtMethodAsyncInternal(aCx, aApiMethod, aArgs, callback, aRetval, aRv); +} + +void ExtensionAPIBase::CallWebExtMethodAsyncAmbiguous( + JSContext* aCx, const nsAString& aApiMethod, + const dom::Sequence<JS::Value>& aArgs, JS::MutableHandle<JS::Value> aRetval, + ErrorResult& aRv) { + RefPtr<dom::Function> chromeCompatCb; + auto lastElement = + aArgs.IsEmpty() ? JS::UndefinedValue() : aArgs.LastElement(); + dom::Sequence<JS::Value> callArgs(aArgs); + if (lastElement.isObject() && JS::IsCallable(&lastElement.toObject())) { + JS::Rooted<JSObject*> tempRoot(aCx, &lastElement.toObject()); + JS::Rooted<JSObject*> tempGlobalRoot(aCx, JS::CurrentGlobalOrNull(aCx)); + chromeCompatCb = new dom::Function(aCx, tempRoot, tempGlobalRoot, + dom::GetIncumbentGlobal()); + + Unused << callArgs.PopLastElement(); + } + CallWebExtMethodAsyncInternal(aCx, aApiMethod, callArgs, chromeCompatCb, + aRetval, aRv); +} + +// ExtensionAPIBase - API Request helpers + +void ExtensionAPIBase::GetWebExtPropertyAsString(const nsString& aPropertyName, + dom::DOMString& aRetval) { + IgnoredErrorResult rv; + + dom::AutoJSAPI jsapi; + auto* global = GetGlobalObject(); + + if (!jsapi.Init(global)) { + NS_WARNING("GetWebExtPropertyAsString fail to init jsapi"); + return; + } + + JSContext* cx = jsapi.cx(); + JS::Rooted<JS::Value> retval(cx); + + RefPtr<ExtensionAPIGetProperty> request = GetProperty(aPropertyName); + request->Run(global, cx, &retval, rv); + if (rv.Failed()) { + NS_WARNING("GetWebExtPropertyAsString failure"); + return; + } + nsAutoJSString strRetval; + if (!retval.isString() || !strRetval.init(cx, retval)) { + NS_WARNING("GetWebExtPropertyAsString got a non string result"); + return; + } + aRetval.SetKnownLiveString(strRetval); +} + +void ExtensionAPIBase::GetWebExtPropertyAsJSValue( + JSContext* aCx, const nsAString& aPropertyName, + JS::MutableHandle<JS::Value> aRetval) { + IgnoredErrorResult rv; + RefPtr<ExtensionAPIGetProperty> request = GetProperty(aPropertyName); + request->Run(GetGlobalObject(), aCx, aRetval, rv); + if (rv.Failed()) { + NS_WARNING("GetWebExtPropertyAsJSValue failure"); + return; + } +} + +already_AddRefed<ExtensionEventManager> ExtensionAPIBase::CreateEventManager( + const nsAString& aEventName) { + RefPtr<ExtensionEventManager> eventMgr = new ExtensionEventManager( + GetGlobalObject(), GetExtensionBrowser(), GetAPINamespace(), aEventName, + GetAPIObjectType(), GetAPIObjectId()); + return eventMgr.forget(); +} + +already_AddRefed<ExtensionSetting> ExtensionAPIBase::CreateSetting( + const nsAString& aSettingName) { + nsAutoString settingAPIPath; + settingAPIPath.Append(GetAPINamespace()); + settingAPIPath.AppendLiteral("."); + settingAPIPath.Append(aSettingName); + RefPtr<ExtensionSetting> settingAPI = new ExtensionSetting( + GetGlobalObject(), GetExtensionBrowser(), settingAPIPath); + return settingAPI.forget(); +} + +RefPtr<ExtensionAPICallFunctionNoReturn> ExtensionAPIBase::CallFunctionNoReturn( + const nsAString& aApiMethod) { + return new ExtensionAPICallFunctionNoReturn( + GetAPINamespace(), aApiMethod, GetAPIObjectType(), GetAPIObjectId()); +} + +RefPtr<ExtensionAPICallSyncFunction> ExtensionAPIBase::CallSyncFunction( + const nsAString& aApiMethod) { + return new ExtensionAPICallSyncFunction(GetAPINamespace(), aApiMethod, + GetAPIObjectType(), GetAPIObjectId()); +} + +RefPtr<ExtensionAPICallAsyncFunction> ExtensionAPIBase::CallAsyncFunction( + const nsAString& aApiMethod) { + return new ExtensionAPICallAsyncFunction( + GetAPINamespace(), aApiMethod, GetAPIObjectType(), GetAPIObjectId()); +} + +RefPtr<ExtensionAPIGetProperty> ExtensionAPIBase::GetProperty( + const nsAString& aApiProperty) { + return new ExtensionAPIGetProperty(GetAPINamespace(), aApiProperty, + GetAPIObjectType(), GetAPIObjectId()); +} + +RefPtr<ExtensionAPIAddRemoveListener> ExtensionAPIBase::SendAddListener( + const nsAString& aEventName) { + using EType = ExtensionAPIAddRemoveListener::EType; + return new ExtensionAPIAddRemoveListener( + EType::eAddListener, GetAPINamespace(), aEventName, GetAPIObjectType(), + GetAPIObjectId()); +} + +RefPtr<ExtensionAPIAddRemoveListener> ExtensionAPIBase::SendRemoveListener( + const nsAString& aEventName) { + using EType = ExtensionAPIAddRemoveListener::EType; + return new ExtensionAPIAddRemoveListener( + EType::eRemoveListener, GetAPINamespace(), aEventName, GetAPIObjectType(), + GetAPIObjectId()); +} + +// static +void ExtensionAPIBase::ThrowUnexpectedError(JSContext* aCx, ErrorResult& aRv) { + ExtensionAPIRequestForwarder::ThrowUnexpectedError(aCx, aRv); +} + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/webidl-api/ExtensionAPIBase.h b/toolkit/components/extensions/webidl-api/ExtensionAPIBase.h new file mode 100644 index 0000000000..cef4c77887 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionAPIBase.h @@ -0,0 +1,198 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_ExtensionAPIBase_h +#define mozilla_extensions_ExtensionAPIBase_h + +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/dom/FunctionBinding.h" +#include "mozilla/dom/PromiseNativeHandler.h" +#include "mozilla/ErrorResult.h" + +class nsIGlobalObject; + +namespace mozilla { + +namespace dom { +class Function; +} + +namespace extensions { + +#define NS_IMPL_WEBEXT_EVENTMGR_WITH_DATAMEMBER( \ + _class, _eventName, _eventGetterName, _eventDataMember) \ + ExtensionEventManager* _class::_eventGetterName() { \ + if (!(_eventDataMember)) { \ + (_eventDataMember) = CreateEventManager(_eventName); \ + } \ + return (_eventDataMember); \ + } +#define NS_IMPL_WEBEXT_EVENTMGR(_class, _eventName, _eventGetterName) \ + NS_IMPL_WEBEXT_EVENTMGR_WITH_DATAMEMBER( \ + _class, _eventName, _eventGetterName, m##_eventGetterName##EventMgr) + +#define NS_IMPL_WEBEXT_SETTING_WITH_DATAMEMBER( \ + _class, _settingName, _settingGetterName, _settingDataMember) \ + ExtensionSetting* _class::_settingGetterName() { \ + if (!(_settingDataMember)) { \ + (_settingDataMember) = CreateSetting(_settingName); \ + } \ + return (_settingDataMember); \ + } +#define NS_IMPL_WEBEXT_SETTING(_class, _settingName, _settingGetterName) \ + NS_IMPL_WEBEXT_SETTING_WITH_DATAMEMBER(_class, _settingName, \ + _settingGetterName, \ + m##_settingGetterName##Setting) + +class ExtensionAPIAddRemoveListener; +class ExtensionAPICallFunctionNoReturn; +class ExtensionAPICallSyncFunction; +class ExtensionAPICallAsyncFunction; +class ExtensionAPIGetProperty; +class ExtensionBrowser; +class ExtensionEventManager; +class ExtensionPort; +class ExtensionSetting; + +class ExtensionAPIBase { + public: + // WebExtensionStub methods shared between multiple API namespaces. + + virtual void CallWebExtMethodNotImplementedNoReturn( + JSContext* aCx, const nsAString& aApiMethod, + const dom::Sequence<JS::Value>& aArgs, ErrorResult& aRv); + + virtual void CallWebExtMethodNotImplementedAsync( + JSContext* aCx, const nsAString& aApiMethod, + const dom::Sequence<JS::Value>& aArgs, + const dom::Optional<OwningNonNull<dom::Function>>& aCallback, + JS::MutableHandle<JS::Value> aRetval, ErrorResult& aRv); + + virtual void CallWebExtMethodNotImplemented( + JSContext* aCx, const nsAString& aApiMethod, + const dom::Sequence<JS::Value>& aArgs, + JS::MutableHandle<JS::Value> aRetval, ErrorResult& aRv); + + virtual void CallWebExtMethodNoReturn(JSContext* aCx, + const nsAString& aApiMethod, + const dom::Sequence<JS::Value>& aArgs, + ErrorResult& aRv); + virtual void CallWebExtMethod(JSContext* aCx, const nsAString& aApiMethod, + const dom::Sequence<JS::Value>& aArgs, + JS::MutableHandle<JS::Value> aRetVal, + ErrorResult& aRv); + + virtual void CallWebExtMethodReturnsString( + JSContext* aCx, const nsAString& aApiMethod, + const dom::Sequence<JS::Value>& aArgs, nsAString& aRetVal, + ErrorResult& aRv); + + virtual already_AddRefed<ExtensionPort> CallWebExtMethodReturnsPort( + JSContext* aCx, const nsAString& aApiMethod, + const dom::Sequence<JS::Value>& aArgs, ErrorResult& aRv); + + virtual void CallWebExtMethodAsync( + JSContext* aCx, const nsAString& aApiMethod, + const dom::Sequence<JS::Value>& aArgs, + const dom::Optional<OwningNonNull<dom::Function>>& aCallback, + JS::MutableHandle<JS::Value> aRetVal, ErrorResult& aRv); + + virtual void CallWebExtMethodAsyncAmbiguous( + JSContext* aCx, const nsAString& aApiMethod, + const dom::Sequence<JS::Value>& aArgs, + JS::MutableHandle<JS::Value> aRetVal, ErrorResult& aRv); + + virtual void GetWebExtPropertyAsString(const nsString& aPropertyName, + dom::DOMString& aRetval); + + virtual void GetWebExtPropertyAsJSValue(JSContext* aCx, + const nsAString& aPropertyName, + JS::MutableHandle<JS::Value> aRetval); + + // API Requests helpers. + already_AddRefed<ExtensionEventManager> CreateEventManager( + const nsAString& aEventName); + + already_AddRefed<ExtensionSetting> CreateSetting( + const nsAString& aSettingName); + + RefPtr<ExtensionAPICallFunctionNoReturn> CallFunctionNoReturn( + const nsAString& aApiMethod); + + RefPtr<ExtensionAPICallSyncFunction> CallSyncFunction( + const nsAString& aApiMethod); + + RefPtr<ExtensionAPICallAsyncFunction> CallAsyncFunction( + const nsAString& aApiMethod); + + RefPtr<ExtensionAPIGetProperty> GetProperty(const nsAString& aApiProperty); + + RefPtr<ExtensionAPIAddRemoveListener> SendAddListener( + const nsAString& aEventName); + + RefPtr<ExtensionAPIAddRemoveListener> SendRemoveListener( + const nsAString& aEventName); + + static void ThrowUnexpectedError(JSContext* aCx, ErrorResult& aRv); + + protected: + virtual nsIGlobalObject* GetGlobalObject() const = 0; + virtual ExtensionBrowser* GetExtensionBrowser() const = 0; + virtual nsString GetAPINamespace() const = 0; + virtual nsString GetAPIObjectType() const = 0; + virtual nsString GetAPIObjectId() const = 0; + + private: + void CallWebExtMethodAsyncInternal(JSContext* aCx, + const nsAString& aApiMethod, + const dom::Sequence<JS::Value>& aArgs, + const RefPtr<dom::Function>& aCallback, + JS::MutableHandle<JS::Value> aRetval, + ErrorResult& aRv); +}; + +class ExtensionAPINamespace : public ExtensionAPIBase { + protected: + nsString GetAPIObjectType() const override { return VoidString(); } + + nsString GetAPIObjectId() const override { return VoidString(); }; +}; + +class ChromeCompatCallbackHandler final : public dom::PromiseNativeHandler { + public: + NS_DECL_THREADSAFE_ISUPPORTS + + static void Create(ExtensionBrowser* aExtensionBrowser, + dom::Promise* aPromise, + const RefPtr<dom::Function>& aCallback); + + MOZ_CAN_RUN_SCRIPT void ResolvedCallback(JSContext* aCx, + JS::Handle<JS::Value> aValue, + ErrorResult& aRv) override; + MOZ_CAN_RUN_SCRIPT void RejectedCallback(JSContext* aCx, + JS::Handle<JS::Value> aValue, + ErrorResult& aRv) override; + + private: + ChromeCompatCallbackHandler(ExtensionBrowser* aExtensionBrowser, + const RefPtr<dom::Function>& aCallback) + : mCallback(aCallback), mExtensionBrowser(aExtensionBrowser) { + MOZ_ASSERT(aCallback); + MOZ_ASSERT(aExtensionBrowser); + } + + ~ChromeCompatCallbackHandler() = default; + + void ReportUncheckedLastError(JSContext* aCx, JS::Handle<JS::Value> aValue); + + RefPtr<dom::Function> mCallback; + RefPtr<ExtensionBrowser> mExtensionBrowser; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_ExtensionAPIBase_h diff --git a/toolkit/components/extensions/webidl-api/ExtensionAPICallAsyncFunction.h b/toolkit/components/extensions/webidl-api/ExtensionAPICallAsyncFunction.h new file mode 100644 index 0000000000..bf2f57ceb3 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionAPICallAsyncFunction.h @@ -0,0 +1,29 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_ExtensionAPICallAsyncFunction_h +#define mozilla_extensions_ExtensionAPICallAsyncFunction_h + +#include "ExtensionAPIRequestForwarder.h" + +namespace mozilla { +namespace extensions { + +class ExtensionAPICallAsyncFunction : public ExtensionAPIRequestForwarder { + public: + ExtensionAPICallAsyncFunction(const nsAString& aApiNamespace, + const nsAString& aApiMethod, + const nsAString& aApiObjectType = u""_ns, + const nsAString& aApiObjectId = u""_ns) + : ExtensionAPIRequestForwarder( + mozIExtensionAPIRequest::RequestType::CALL_FUNCTION_ASYNC, + aApiNamespace, aApiMethod, aApiObjectType, aApiObjectId) {} +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_ExtensionAPICallAsyncFunction_h diff --git a/toolkit/components/extensions/webidl-api/ExtensionAPICallFunctionNoReturn.h b/toolkit/components/extensions/webidl-api/ExtensionAPICallFunctionNoReturn.h new file mode 100644 index 0000000000..4ff179f652 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionAPICallFunctionNoReturn.h @@ -0,0 +1,29 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_ExtensionAPICallFunctionNoReturn_h +#define mozilla_extensions_ExtensionAPICallFunctionNoReturn_h + +#include "ExtensionAPIRequestForwarder.h" + +namespace mozilla { +namespace extensions { + +class ExtensionAPICallFunctionNoReturn : public ExtensionAPIRequestForwarder { + public: + ExtensionAPICallFunctionNoReturn(const nsAString& aApiNamespace, + const nsAString& aApiMethod, + const nsAString& aApiObjectType = u""_ns, + const nsAString& aApiObjectId = u""_ns) + : ExtensionAPIRequestForwarder( + mozIExtensionAPIRequest::RequestType::CALL_FUNCTION_NO_RETURN, + aApiNamespace, aApiMethod, aApiObjectType, aApiObjectId) {} +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_ExtensionAPICallFunctionNoReturn_h diff --git a/toolkit/components/extensions/webidl-api/ExtensionAPICallSyncFunction.h b/toolkit/components/extensions/webidl-api/ExtensionAPICallSyncFunction.h new file mode 100644 index 0000000000..2a58082514 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionAPICallSyncFunction.h @@ -0,0 +1,29 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_ExtensionAPICallSyncFunction_h +#define mozilla_extensions_ExtensionAPICallSyncFunction_h + +#include "ExtensionAPIRequestForwarder.h" + +namespace mozilla { +namespace extensions { + +class ExtensionAPICallSyncFunction : public ExtensionAPIRequestForwarder { + public: + ExtensionAPICallSyncFunction(const nsAString& aApiNamespace, + const nsAString& aApiMethod, + const nsAString& aApiObjectType = u""_ns, + const nsAString& aApiObjectId = u""_ns) + : ExtensionAPIRequestForwarder( + mozIExtensionAPIRequest::RequestType::CALL_FUNCTION, aApiNamespace, + aApiMethod, aApiObjectType, aApiObjectId) {} +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_ExtensionAPICallSyncFunction_h diff --git a/toolkit/components/extensions/webidl-api/ExtensionAPIGetProperty.h b/toolkit/components/extensions/webidl-api/ExtensionAPIGetProperty.h new file mode 100644 index 0000000000..30b9423dad --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionAPIGetProperty.h @@ -0,0 +1,29 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_ExtensionAPIGetProperty_h +#define mozilla_extensions_ExtensionAPIGetProperty_h + +#include "ExtensionAPIRequestForwarder.h" + +namespace mozilla { +namespace extensions { + +class ExtensionAPIGetProperty : public ExtensionAPIRequestForwarder { + public: + ExtensionAPIGetProperty(const nsAString& aApiNamespace, + const nsAString& aApiProperty, + const nsAString& aApiObjectType = u""_ns, + const nsAString& aApiObjectId = u""_ns) + : ExtensionAPIRequestForwarder( + mozIExtensionAPIRequest::RequestType::GET_PROPERTY, aApiNamespace, + aApiProperty, aApiObjectType, aApiObjectId) {} +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_ExtensionAPICallSyncFunction_h diff --git a/toolkit/components/extensions/webidl-api/ExtensionAPIRequest.cpp b/toolkit/components/extensions/webidl-api/ExtensionAPIRequest.cpp new file mode 100644 index 0000000000..e5dd443695 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionAPIRequest.cpp @@ -0,0 +1,242 @@ +/* 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 "ExtensionAPIRequest.h" + +#include "mozilla/dom/ClientInfo.h" +#include "mozilla/extensions/WebExtensionPolicy.h" +#include "mozilla/ipc/BackgroundUtils.h" // PrincipalInfoToPrincipal + +namespace mozilla { +namespace extensions { + +// mozIExtensionServiceWorkerInfo + +NS_IMPL_ISUPPORTS(ExtensionServiceWorkerInfo, mozIExtensionServiceWorkerInfo) + +NS_IMETHODIMP +ExtensionServiceWorkerInfo::GetPrincipal(nsIPrincipal** aPrincipal) { + MOZ_ASSERT(NS_IsMainThread()); + NS_ENSURE_ARG_POINTER(aPrincipal); + auto principalOrErr = PrincipalInfoToPrincipal(mClientInfo.PrincipalInfo()); + if (NS_WARN_IF(principalOrErr.isErr())) { + return NS_ERROR_UNEXPECTED; + } + nsCOMPtr<nsIPrincipal> principal = principalOrErr.unwrap(); + principal.forget(aPrincipal); + return NS_OK; +} + +NS_IMETHODIMP +ExtensionServiceWorkerInfo::GetScriptURL(nsAString& aScriptURL) { + MOZ_ASSERT(NS_IsMainThread()); + aScriptURL = NS_ConvertUTF8toUTF16(mClientInfo.URL()); + return NS_OK; +} + +NS_IMETHODIMP +ExtensionServiceWorkerInfo::GetClientInfoId(nsAString& aClientInfoId) { + MOZ_ASSERT(NS_IsMainThread()); + aClientInfoId = NS_ConvertUTF8toUTF16(mClientInfo.Id().ToString().get()); + return NS_OK; +} + +NS_IMETHODIMP +ExtensionServiceWorkerInfo::GetDescriptorId(uint64_t* aDescriptorId) { + MOZ_ASSERT(NS_IsMainThread()); + *aDescriptorId = mDescriptorId; + return NS_OK; +} + +// mozIExtensionAPIRequest + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(ExtensionAPIRequest) + NS_INTERFACE_MAP_ENTRY(mozIExtensionAPIRequest) + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTION_CLASS(ExtensionAPIRequest) + +NS_IMPL_CYCLE_COLLECTING_ADDREF(ExtensionAPIRequest) +NS_IMPL_CYCLE_COLLECTING_RELEASE(ExtensionAPIRequest) + +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(ExtensionAPIRequest) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mEventListener) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mSWInfo) +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_IMPL_CYCLE_COLLECTION_TRACE_BEGIN(ExtensionAPIRequest) + NS_IMPL_CYCLE_COLLECTION_TRACE_JS_MEMBER_CALLBACK(mArgs) + NS_IMPL_CYCLE_COLLECTION_TRACE_JS_MEMBER_CALLBACK(mNormalizedArgs) + NS_IMPL_CYCLE_COLLECTION_TRACE_JS_MEMBER_CALLBACK(mStack) +NS_IMPL_CYCLE_COLLECTION_TRACE_END + +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(ExtensionAPIRequest) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mEventListener) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mSWInfo) + tmp->mStack.setUndefined(); + tmp->mArgs.setUndefined(); + tmp->mNormalizedArgs.setUndefined(); +NS_IMPL_CYCLE_COLLECTION_UNLINK_END + +ExtensionAPIRequest::ExtensionAPIRequest( + const mozIExtensionAPIRequest::RequestType aRequestType, + const ExtensionAPIRequestTarget& aRequestTarget) { + MOZ_ASSERT(NS_IsMainThread()); + mRequestType = aRequestType; + mRequestTarget = aRequestTarget; + mozilla::HoldJSObjects(this); +} + +void ExtensionAPIRequest::Init(Maybe<dom::ClientInfo>& aSWClientInfo, + const uint64_t aSWDescriptorId, + JS::Handle<JS::Value> aRequestArgs, + JS::Handle<JS::Value> aCallerStack) { + MOZ_ASSERT(NS_IsMainThread()); + mSWClientInfo = aSWClientInfo; + mSWDescriptorId = aSWDescriptorId; + mArgs.set(aRequestArgs); + mStack.set(aCallerStack); + mNormalizedArgs.setUndefined(); +} + +NS_IMETHODIMP +ExtensionAPIRequest::ToString(nsACString& aResult) { + aResult.Truncate(); + + nsAutoCString requestType; + nsAutoCString apiNamespace; + nsAutoCString apiName; + GetRequestType(requestType); + GetApiNamespace(apiNamespace); + GetApiName(apiName); + + if (mRequestTarget.mObjectType.IsEmpty()) { + aResult.AppendPrintf("[ExtensionAPIRequest %s %s.%s]", requestType.get(), + apiNamespace.get(), apiName.get()); + } else { + nsAutoCString objectType; + nsAutoCString objectId; + GetApiObjectType(objectType); + GetApiObjectId(objectId); + + aResult.AppendPrintf("[ExtensionAPIRequest %s %s.%s.%s (%s)]", + requestType.get(), apiNamespace.get(), + objectType.get(), apiName.get(), objectId.get()); + } + + return NS_OK; +} + +NS_IMETHODIMP +ExtensionAPIRequest::GetRequestType(nsACString& aRequestTypeName) { + MOZ_ASSERT(NS_IsMainThread()); + switch (mRequestType) { + case mozIExtensionAPIRequest::RequestType::CALL_FUNCTION: + aRequestTypeName = "callFunction"_ns; + break; + case mozIExtensionAPIRequest::RequestType::CALL_FUNCTION_NO_RETURN: + aRequestTypeName = "callFunctionNoReturn"_ns; + break; + case mozIExtensionAPIRequest::RequestType::CALL_FUNCTION_ASYNC: + aRequestTypeName = "callAsyncFunction"_ns; + break; + case mozIExtensionAPIRequest::RequestType::ADD_LISTENER: + aRequestTypeName = "addListener"_ns; + break; + case mozIExtensionAPIRequest::RequestType::REMOVE_LISTENER: + aRequestTypeName = "removeListener"_ns; + break; + case mozIExtensionAPIRequest::RequestType::GET_PROPERTY: + aRequestTypeName = "getProperty"_ns; + break; + default: + return NS_ERROR_UNEXPECTED; + } + return NS_OK; +} + +NS_IMETHODIMP +ExtensionAPIRequest::GetApiNamespace(nsACString& aApiNamespace) { + MOZ_ASSERT(NS_IsMainThread()); + aApiNamespace.Assign(NS_ConvertUTF16toUTF8(mRequestTarget.mNamespace)); + return NS_OK; +} + +NS_IMETHODIMP +ExtensionAPIRequest::GetApiName(nsACString& aApiName) { + MOZ_ASSERT(NS_IsMainThread()); + aApiName.Assign(NS_ConvertUTF16toUTF8(mRequestTarget.mMethod)); + return NS_OK; +} + +NS_IMETHODIMP +ExtensionAPIRequest::GetApiObjectType(nsACString& aApiObjectType) { + MOZ_ASSERT(NS_IsMainThread()); + aApiObjectType.Assign(NS_ConvertUTF16toUTF8(mRequestTarget.mObjectType)); + return NS_OK; +} + +NS_IMETHODIMP +ExtensionAPIRequest::GetApiObjectId(nsACString& aApiObjectId) { + MOZ_ASSERT(NS_IsMainThread()); + aApiObjectId.Assign(NS_ConvertUTF16toUTF8(mRequestTarget.mObjectId)); + return NS_OK; +} + +NS_IMETHODIMP +ExtensionAPIRequest::GetArgs(JSContext* aCx, + JS::MutableHandle<JS::Value> aRetval) { + MOZ_ASSERT(NS_IsMainThread()); + aRetval.set(mArgs); + return NS_OK; +} + +NS_IMETHODIMP +ExtensionAPIRequest::GetNormalizedArgs(JSContext* aCx, + JS::MutableHandle<JS::Value> aRetval) { + MOZ_ASSERT(NS_IsMainThread()); + aRetval.set(mNormalizedArgs); + return NS_OK; +} + +NS_IMETHODIMP +ExtensionAPIRequest::SetNormalizedArgs(JSContext* aCx, + JS::Handle<JS::Value> aNormalizedArgs) { + MOZ_ASSERT(NS_IsMainThread()); + mNormalizedArgs.set(aNormalizedArgs); + return NS_OK; +} + +NS_IMETHODIMP +ExtensionAPIRequest::GetCallerSavedFrame( + JSContext* aCx, JS::MutableHandle<JS::Value> aSavedFrame) { + MOZ_ASSERT(NS_IsMainThread()); + aSavedFrame.set(mStack); + return NS_OK; +} + +NS_IMETHODIMP +ExtensionAPIRequest::GetServiceWorkerInfo( + mozIExtensionServiceWorkerInfo** aSWInfo) { + MOZ_ASSERT(NS_IsMainThread()); + NS_ENSURE_ARG_POINTER(aSWInfo); + if (mSWClientInfo.isSome() && !mSWInfo) { + mSWInfo = new ExtensionServiceWorkerInfo(*mSWClientInfo, mSWDescriptorId); + } + NS_IF_ADDREF(*aSWInfo = mSWInfo); + return NS_OK; +} + +NS_IMETHODIMP +ExtensionAPIRequest::GetEventListener(mozIExtensionEventListener** aListener) { + MOZ_ASSERT(NS_IsMainThread()); + NS_ENSURE_ARG_POINTER(aListener); + NS_IF_ADDREF(*aListener = mEventListener); + return NS_OK; +} + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/webidl-api/ExtensionAPIRequest.h b/toolkit/components/extensions/webidl-api/ExtensionAPIRequest.h new file mode 100644 index 0000000000..b34d137958 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionAPIRequest.h @@ -0,0 +1,121 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_ExtensionAPIRequest_h +#define mozilla_extensions_ExtensionAPIRequest_h + +#include "ExtensionEventListener.h" + +#include "mozIExtensionAPIRequestHandling.h" +#include "mozilla/HoldDropJSObjects.h" +#include "mozilla/dom/ClientInfo.h" +#include "mozilla/extensions/WebExtensionPolicy.h" +#include "nsCycleCollectionParticipant.h" + +namespace mozilla { +namespace extensions { + +class ExtensionAPIRequestForwarder; +class RequestWorkerRunnable; + +// Represent the target of the API request forwarded, mObjectType and mObjectId +// are only expected to be polulated when the API request is originated from API +// object (like an ExtensionPort returned by a call to browser.runtime.connect). +struct ExtensionAPIRequestTarget { + nsString mNamespace; + nsString mMethod; + nsString mObjectType; + nsString mObjectId; +}; + +// A class that represents the service worker that has originated the API +// request. +class ExtensionServiceWorkerInfo : public mozIExtensionServiceWorkerInfo { + public: + NS_DECL_MOZIEXTENSIONSERVICEWORKERINFO + NS_DECL_ISUPPORTS + + explicit ExtensionServiceWorkerInfo(const dom::ClientInfo& aClientInfo, + const uint64_t aDescriptorId) + : mClientInfo(aClientInfo), mDescriptorId(aDescriptorId) {} + + private: + virtual ~ExtensionServiceWorkerInfo() = default; + + dom::ClientInfo mClientInfo; + uint64_t mDescriptorId; +}; + +// A class that represents a WebExtensions API request (a method call, +// add/remote listener or accessing a property getter) forwarded by the +// WebIDL bindings to the mozIExtensionAPIRequestHandler. +class ExtensionAPIRequest : public mozIExtensionAPIRequest { + public: + using APIRequestType = mozIExtensionAPIRequest::RequestType; + + NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(ExtensionAPIRequest) + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_MOZIEXTENSIONAPIREQUEST + + explicit ExtensionAPIRequest( + const mozIExtensionAPIRequest::RequestType aRequestType, + const ExtensionAPIRequestTarget& aRequestTarget); + + void Init(Maybe<dom::ClientInfo>& aSWClientInfo, + const uint64_t aSWDescriptorId, JS::Handle<JS::Value> aRequestArgs, + JS::Handle<JS::Value> aCallerStack); + + static bool ShouldHaveResult(const APIRequestType& aRequestType) { + switch (aRequestType) { + case APIRequestType::GET_PROPERTY: + case APIRequestType::CALL_FUNCTION: + case APIRequestType::CALL_FUNCTION_ASYNC: + return true; + case APIRequestType::CALL_FUNCTION_NO_RETURN: + case APIRequestType::ADD_LISTENER: + case APIRequestType::REMOVE_LISTENER: + break; + default: + MOZ_DIAGNOSTIC_ASSERT(false, "Unexpected APIRequestType"); + } + + return false; + } + + bool ShouldHaveResult() const { return ShouldHaveResult(mRequestType); } + + void SetEventListener(const RefPtr<ExtensionEventListener>& aListener) { + MOZ_ASSERT(!mEventListener); + mEventListener = aListener; + } + + private: + virtual ~ExtensionAPIRequest() { + mSWClientInfo = Nothing(); + mArgs.setUndefined(); + mNormalizedArgs.setUndefined(); + mStack.setUndefined(); + mEventListener = nullptr; + mozilla::DropJSObjects(this); + }; + + APIRequestType mRequestType; + ExtensionAPIRequestTarget mRequestTarget; + JS::Heap<JS::Value> mStack; + JS::Heap<JS::Value> mArgs; + JS::Heap<JS::Value> mNormalizedArgs; + Maybe<dom::ClientInfo> mSWClientInfo; + uint64_t mSWDescriptorId; + RefPtr<ExtensionServiceWorkerInfo> mSWInfo; + + // Only set for addListener/removeListener API requests. + RefPtr<ExtensionEventListener> mEventListener; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_ExtensionAPIRequest_h diff --git a/toolkit/components/extensions/webidl-api/ExtensionAPIRequestForwarder.cpp b/toolkit/components/extensions/webidl-api/ExtensionAPIRequestForwarder.cpp new file mode 100644 index 0000000000..5f6a966fc8 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionAPIRequestForwarder.cpp @@ -0,0 +1,705 @@ +/* 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 "ExtensionAPIRequestForwarder.h" +#include "ExtensionEventListener.h" +#include "ExtensionAPIBase.h" + +#include "js/Promise.h" +#include "js/PropertyAndElement.h" // JS_GetElement +#include "mozilla/dom/Client.h" +#include "mozilla/ClearOnShutdown.h" +#include "mozilla/dom/ClonedErrorHolder.h" +#include "mozilla/dom/ExtensionBrowserBinding.h" +#include "mozilla/dom/FunctionBinding.h" +#include "mozilla/dom/WorkerScope.h" +#include "mozilla/dom/SerializedStackHolder.h" +#include "mozilla/dom/ServiceWorkerInfo.h" +#include "mozilla/dom/ServiceWorkerManager.h" +#include "mozilla/dom/ServiceWorkerRegistrationInfo.h" +#include "mozilla/dom/StructuredCloneTags.h" +#include "mozilla/ExtensionPolicyService.h" +#include "nsIGlobalObject.h" +#include "nsImportModule.h" +#include "nsIXPConnect.h" + +namespace mozilla { +namespace extensions { + +// ExtensionAPIRequestForwarder + +// static +void ExtensionAPIRequestForwarder::ThrowUnexpectedError(JSContext* aCx, + ErrorResult& aRv) { + aRv.MightThrowJSException(); + JS_ReportErrorASCII(aCx, "An unexpected error occurred"); + aRv.StealExceptionFromJSContext(aCx); +} + +ExtensionAPIRequestForwarder::ExtensionAPIRequestForwarder( + const mozIExtensionAPIRequest::RequestType aRequestType, + const nsAString& aApiNamespace, const nsAString& aApiMethod, + const nsAString& aApiObjectType, const nsAString& aApiObjectId) { + mRequestType = aRequestType; + mRequestTarget.mNamespace = aApiNamespace; + mRequestTarget.mMethod = aApiMethod; + mRequestTarget.mObjectType = aApiObjectType; + mRequestTarget.mObjectId = aApiObjectId; +} + +// static +nsresult ExtensionAPIRequestForwarder::JSArrayToSequence( + JSContext* aCx, JS::Handle<JS::Value> aJSValue, + dom::Sequence<JS::Value>& aResult) { + bool isArray; + JS::Rooted<JSObject*> obj(aCx, aJSValue.toObjectOrNull()); + + if (NS_WARN_IF(!obj || !JS::IsArrayObject(aCx, obj, &isArray))) { + return NS_ERROR_UNEXPECTED; + } + + if (isArray) { + uint32_t len; + if (NS_WARN_IF(!JS::GetArrayLength(aCx, obj, &len))) { + return NS_ERROR_UNEXPECTED; + } + + for (uint32_t i = 0; i < len; i++) { + JS::Rooted<JS::Value> v(aCx); + JS_GetElement(aCx, obj, i, &v); + if (NS_WARN_IF(!aResult.AppendElement(v, fallible))) { + return NS_ERROR_OUT_OF_MEMORY; + } + } + } else if (NS_WARN_IF(!aResult.AppendElement(aJSValue, fallible))) { + return NS_ERROR_OUT_OF_MEMORY; + } + + return NS_OK; +} + +/* static */ +mozIExtensionAPIRequestHandler& +ExtensionAPIRequestForwarder::APIRequestHandler() { + static nsCOMPtr<mozIExtensionAPIRequestHandler> sAPIRequestHandler; + + MOZ_ASSERT(NS_IsMainThread()); + + if (MOZ_UNLIKELY(!sAPIRequestHandler)) { + sAPIRequestHandler = + do_ImportModule("resource://gre/modules/ExtensionProcessScript.jsm", + "ExtensionAPIRequestHandler"); + MOZ_RELEASE_ASSERT(sAPIRequestHandler); + ClearOnShutdown(&sAPIRequestHandler); + } + return *sAPIRequestHandler; +} + +void ExtensionAPIRequestForwarder::SetSerializedCallerStack( + UniquePtr<dom::SerializedStackHolder> aCallerStack) { + MOZ_ASSERT(dom::IsCurrentThreadRunningWorker()); + MOZ_ASSERT(mStackHolder.isNothing()); + mStackHolder = Some(std::move(aCallerStack)); +} + +void ExtensionAPIRequestForwarder::Run(nsIGlobalObject* aGlobal, JSContext* aCx, + const dom::Sequence<JS::Value>& aArgs, + ExtensionEventListener* aListener, + JS::MutableHandle<JS::Value> aRetVal, + ErrorResult& aRv) { + MOZ_ASSERT(dom::IsCurrentThreadRunningWorker()); + + dom::WorkerPrivate* workerPrivate = dom::GetCurrentThreadWorkerPrivate(); + MOZ_ASSERT(workerPrivate); + + RefPtr<RequestWorkerRunnable> runnable = + new RequestWorkerRunnable(workerPrivate, this); + + if (mStackHolder.isSome()) { + runnable->SetSerializedCallerStack(mStackHolder.extract()); + } + + RefPtr<dom::Promise> domPromise; + + IgnoredErrorResult rv; + + switch (mRequestType) { + case APIRequestType::CALL_FUNCTION_ASYNC: + domPromise = dom::Promise::Create(aGlobal, rv); + if (NS_WARN_IF(rv.Failed())) { + ThrowUnexpectedError(aCx, aRv); + return; + } + + runnable->Init(aGlobal, aCx, aArgs, domPromise, rv); + break; + + case APIRequestType::ADD_LISTENER: + [[fallthrough]]; + case APIRequestType::REMOVE_LISTENER: + runnable->Init(aGlobal, aCx, aArgs, aListener, aRv); + break; + + default: + runnable->Init(aGlobal, aCx, aArgs, rv); + } + + if (NS_WARN_IF(rv.Failed())) { + ThrowUnexpectedError(aCx, aRv); + return; + } + + runnable->Dispatch(dom::WorkerStatus::Canceling, rv); + if (NS_WARN_IF(rv.Failed())) { + ThrowUnexpectedError(aCx, aRv); + return; + } + + auto resultType = runnable->GetResultType(); + if (resultType.isNothing()) { + if (NS_WARN_IF(ExtensionAPIRequest::ShouldHaveResult(mRequestType))) { + ThrowUnexpectedError(aCx, aRv); + } + return; + } + + // Read and throw the extension error if needed. + if (resultType.isSome() && *resultType == APIResultType::EXTENSION_ERROR) { + JS::Rooted<JS::Value> ignoredResultValue(aCx); + runnable->ReadResult(aCx, &ignoredResultValue, aRv); + // When the result type is an error aRv is expected to be + // failed, if it is not throw the generic + // "An unexpected error occurred". + if (NS_WARN_IF(!aRv.Failed())) { + ThrowUnexpectedError(aCx, aRv); + } + return; + } + + if (mRequestType == APIRequestType::CALL_FUNCTION_ASYNC) { + MOZ_ASSERT(domPromise); + if (NS_WARN_IF(!ToJSValue(aCx, domPromise, aRetVal))) { + ThrowUnexpectedError(aCx, aRv); + } + return; + } + + JS::Rooted<JS::Value> resultValue(aCx); + runnable->ReadResult(aCx, &resultValue, rv); + if (NS_WARN_IF(rv.Failed())) { + ThrowUnexpectedError(aCx, aRv); + return; + } + + aRetVal.set(resultValue); +} + +void ExtensionAPIRequestForwarder::Run(nsIGlobalObject* aGlobal, JSContext* aCx, + const dom::Sequence<JS::Value>& aArgs, + JS::MutableHandle<JS::Value> aRetVal, + ErrorResult& aRv) { + Run(aGlobal, aCx, aArgs, nullptr, aRetVal, aRv); +} + +void ExtensionAPIRequestForwarder::Run(nsIGlobalObject* aGlobal, JSContext* aCx, + const dom::Sequence<JS::Value>& aArgs, + ErrorResult& aRv) { + JS::Rooted<JS::Value> ignoredRetval(aCx); + Run(aGlobal, aCx, aArgs, nullptr, &ignoredRetval, aRv); +} + +void ExtensionAPIRequestForwarder::Run(nsIGlobalObject* aGlobal, JSContext* aCx, + const dom::Sequence<JS::Value>& aArgs, + ExtensionEventListener* aListener, + ErrorResult& aRv) { + MOZ_ASSERT(aListener); + JS::Rooted<JS::Value> ignoredRetval(aCx); + Run(aGlobal, aCx, aArgs, aListener, &ignoredRetval, aRv); +} + +void ExtensionAPIRequestForwarder::Run( + nsIGlobalObject* aGlobal, JSContext* aCx, + const dom::Sequence<JS::Value>& aArgs, + const RefPtr<dom::Promise>& aPromiseRetval, ErrorResult& aRv) { + MOZ_ASSERT(aPromiseRetval); + JS::Rooted<JS::Value> promisedRetval(aCx); + Run(aGlobal, aCx, aArgs, &promisedRetval, aRv); + if (aRv.Failed()) { + return; + } + aPromiseRetval->MaybeResolve(promisedRetval); +} + +void ExtensionAPIRequestForwarder::Run(nsIGlobalObject* aGlobal, JSContext* aCx, + JS::MutableHandle<JS::Value> aRetVal, + ErrorResult& aRv) { + Run(aGlobal, aCx, {}, aRetVal, aRv); +} + +namespace { + +// Custom PromiseWorkerProxy callback to deserialize error objects +// from ClonedErrorHolder structured clone data. +JSObject* ExtensionAPIRequestStructuredCloneRead( + JSContext* aCx, JSStructuredCloneReader* aReader, + const dom::PromiseWorkerProxy* aProxy, uint32_t aTag, uint32_t aData) { + // Deserialize ClonedErrorHolder that may have been structured cloned + // as a result of a resolved/rejected promise. + if (aTag == dom::SCTAG_DOM_CLONED_ERROR_OBJECT) { + return dom::ClonedErrorHolder::ReadStructuredClone(aCx, aReader, nullptr); + } + + return nullptr; +} + +// Custom PromiseWorkerProxy callback to serialize error objects into +// ClonedErrorHolder structured clone data. +bool ExtensionAPIRequestStructuredCloneWrite(JSContext* aCx, + JSStructuredCloneWriter* aWriter, + dom::PromiseWorkerProxy* aProxy, + JS::Handle<JSObject*> aObj) { + // Try to serialize the object as a CloneErrorHolder, if it fails then + // the object wasn't an error. + IgnoredErrorResult rv; + UniquePtr<dom::ClonedErrorHolder> ceh = + dom::ClonedErrorHolder::Create(aCx, aObj, rv); + if (NS_WARN_IF(rv.Failed()) || !ceh) { + return false; + } + + return ceh->WriteStructuredClone(aCx, aWriter, nullptr); +} + +} // namespace + +RequestWorkerRunnable::RequestWorkerRunnable( + dom::WorkerPrivate* aWorkerPrivate, + ExtensionAPIRequestForwarder* aOuterAPIRequest) + : WorkerMainThreadRunnable(aWorkerPrivate, + "ExtensionAPIRequest :: WorkerRunnable"_ns) { + MOZ_ASSERT(dom::IsCurrentThreadRunningWorker()); + + MOZ_ASSERT(aOuterAPIRequest); + mOuterRequest = aOuterAPIRequest; +} + +void RequestWorkerRunnable::Init(nsIGlobalObject* aGlobal, JSContext* aCx, + const dom::Sequence<JS::Value>& aArgs, + ExtensionEventListener* aListener, + ErrorResult& aRv) { + MOZ_ASSERT(dom::IsCurrentThreadRunningWorker()); + + mSWDescriptorId = mWorkerPrivate->ServiceWorkerID(); + + auto* workerScope = mWorkerPrivate->GlobalScope(); + if (NS_WARN_IF(!workerScope)) { + aRv.Throw(NS_ERROR_UNEXPECTED); + return; + } + + mClientInfo = workerScope->GetClientInfo(); + if (mClientInfo.isNothing()) { + aRv.Throw(NS_ERROR_UNEXPECTED); + return; + } + + IgnoredErrorResult rv; + SerializeArgs(aCx, aArgs, rv); + if (NS_WARN_IF(rv.Failed())) { + aRv.Throw(NS_ERROR_UNEXPECTED); + return; + } + + if (!mStackHolder.isSome()) { + SerializeCallerStack(aCx); + } + + mEventListener = aListener; +} + +void RequestWorkerRunnable::Init(nsIGlobalObject* aGlobal, JSContext* aCx, + const dom::Sequence<JS::Value>& aArgs, + const RefPtr<dom::Promise>& aPromiseRetval, + ErrorResult& aRv) { + // Custom callbacks needed to make the PromiseWorkerProxy instance to + // be able to write and read errors using CloneErrorHolder. + static const dom::PromiseWorkerProxy:: + PromiseWorkerProxyStructuredCloneCallbacks + kExtensionAPIRequestStructuredCloneCallbacks = { + ExtensionAPIRequestStructuredCloneRead, + ExtensionAPIRequestStructuredCloneWrite, + }; + + Init(aGlobal, aCx, aArgs, /* aListener */ nullptr, aRv); + if (aRv.Failed()) { + return; + } + + RefPtr<dom::PromiseWorkerProxy> promiseProxy = + dom::PromiseWorkerProxy::Create( + mWorkerPrivate, aPromiseRetval, + &kExtensionAPIRequestStructuredCloneCallbacks); + if (!promiseProxy) { + aRv.Throw(NS_ERROR_DOM_ABORT_ERR); + return; + } + mPromiseProxy = promiseProxy.forget(); +} + +void RequestWorkerRunnable::SetSerializedCallerStack( + UniquePtr<dom::SerializedStackHolder> aCallerStack) { + MOZ_ASSERT(dom::IsCurrentThreadRunningWorker()); + MOZ_ASSERT(mStackHolder.isNothing()); + mStackHolder = Some(std::move(aCallerStack)); +} + +void RequestWorkerRunnable::SerializeCallerStack(JSContext* aCx) { + MOZ_ASSERT(dom::IsCurrentThreadRunningWorker()); + MOZ_ASSERT(mStackHolder.isNothing()); + mStackHolder = Some(dom::GetCurrentStack(aCx)); +} + +void RequestWorkerRunnable::DeserializeCallerStack( + JSContext* aCx, JS::MutableHandle<JS::Value> aRetval) { + MOZ_ASSERT(NS_IsMainThread()); + if (mStackHolder.isSome()) { + JS::Rooted<JSObject*> savedFrame(aCx, mStackHolder->get()->ReadStack(aCx)); + MOZ_ASSERT(savedFrame); + aRetval.set(JS::ObjectValue(*savedFrame)); + mStackHolder = Nothing(); + } +} + +void RequestWorkerRunnable::SerializeArgs(JSContext* aCx, + const dom::Sequence<JS::Value>& aArgs, + ErrorResult& aRv) { + MOZ_ASSERT(dom::IsCurrentThreadRunningWorker()); + MOZ_ASSERT(!mArgsHolder); + + JS::Rooted<JS::Value> jsval(aCx); + if (NS_WARN_IF(!ToJSValue(aCx, aArgs, &jsval))) { + aRv.Throw(NS_ERROR_UNEXPECTED); + return; + } + + mArgsHolder = Some(MakeUnique<dom::StructuredCloneHolder>( + dom::StructuredCloneHolder::CloningSupported, + dom::StructuredCloneHolder::TransferringNotSupported, + JS::StructuredCloneScope::SameProcess)); + mArgsHolder->get()->Write(aCx, jsval, aRv); +} + +nsresult RequestWorkerRunnable::DeserializeArgs( + JSContext* aCx, JS::MutableHandle<JS::Value> aArgs) { + MOZ_ASSERT(NS_IsMainThread()); + if (mArgsHolder.isSome() && mArgsHolder->get()->HasData()) { + IgnoredErrorResult rv; + + JS::Rooted<JS::Value> jsvalue(aCx); + mArgsHolder->get()->Read(xpc::CurrentNativeGlobal(aCx), aCx, &jsvalue, rv); + if (NS_WARN_IF(rv.Failed())) { + return NS_ERROR_UNEXPECTED; + } + + aArgs.set(jsvalue); + } + + return NS_OK; +} + +bool RequestWorkerRunnable::MainThreadRun() { + MOZ_ASSERT(NS_IsMainThread()); + nsCOMPtr<mozIExtensionAPIRequestHandler> handler = + &ExtensionAPIRequestForwarder::APIRequestHandler(); + nsCOMPtr<nsIXPConnectWrappedJS> wrapped = do_QueryInterface(handler); + dom::AutoJSAPI jsapi; + if (!jsapi.Init(wrapped->GetJSObjectGlobal())) { + return false; + } + + auto* cx = jsapi.cx(); + JS::Rooted<JS::Value> retval(cx); + return HandleAPIRequest(cx, &retval); +} + +already_AddRefed<ExtensionAPIRequest> RequestWorkerRunnable::CreateAPIRequest( + JSContext* aCx) { + JS::Rooted<JS::Value> callArgs(aCx); + JS::Rooted<JS::Value> callerStackValue(aCx); + + DeserializeArgs(aCx, &callArgs); + DeserializeCallerStack(aCx, &callerStackValue); + + RefPtr<ExtensionAPIRequest> request = new ExtensionAPIRequest( + mOuterRequest->GetRequestType(), *mOuterRequest->GetRequestTarget()); + request->Init(mClientInfo, mSWDescriptorId, callArgs, callerStackValue); + + if (mEventListener) { + request->SetEventListener(mEventListener.forget()); + } + + return request.forget(); +} + +already_AddRefed<WebExtensionPolicy> +RequestWorkerRunnable::GetWebExtensionPolicy() { + MOZ_ASSERT(NS_IsMainThread()); + MOZ_ASSERT(mWorkerPrivate); + auto* baseURI = mWorkerPrivate->GetBaseURI(); + RefPtr<WebExtensionPolicy> policy = + ExtensionPolicyService::GetSingleton().GetByURL(baseURI); + return policy.forget(); +} + +bool RequestWorkerRunnable::HandleAPIRequest( + JSContext* aCx, JS::MutableHandle<JS::Value> aRetval) { + MOZ_ASSERT(NS_IsMainThread()); + + RefPtr<WebExtensionPolicy> policy = GetWebExtensionPolicy(); + if (NS_WARN_IF(!policy || !policy->Active())) { + // Fails if no extension policy object has been found, or if the + // extension is not active. + return false; + } + + nsresult rv; + + RefPtr<ExtensionAPIRequest> request = CreateAPIRequest(aCx); + + nsCOMPtr<mozIExtensionAPIRequestHandler> handler = + &ExtensionAPIRequestForwarder::APIRequestHandler(); + RefPtr<mozIExtensionAPIRequestResult> apiResult; + rv = handler->HandleAPIRequest(policy, request, getter_AddRefs(apiResult)); + + if (NS_FAILED(rv)) { + return false; + } + + // A missing apiResult is expected for some request types + // (e.g. CALL_FUNCTION_NO_RETURN/ADD_LISTENER/REMOVE_LISTENER). + // If the apiResult is missing for a request type that expects + // to have one, consider the request as failed with an unknown error. + if (!apiResult) { + return !request->ShouldHaveResult(); + } + + mozIExtensionAPIRequestResult::ResultType resultType; + apiResult->GetType(&resultType); + apiResult->GetValue(aRetval); + + mResultType = Some(resultType); + + bool isExtensionError = + resultType == mozIExtensionAPIRequestResult::ResultType::EXTENSION_ERROR; + bool okSerializedError = false; + + if (aRetval.isObject()) { + // Try to serialize the result as an ClonedErrorHolder + // (because all API requests could receive one for EXTENSION_ERROR + // result types, and some also as a RETURN_VALUE result, e.g. + // runtime.lastError). + JS::Rooted<JSObject*> errObj(aCx, &aRetval.toObject()); + IgnoredErrorResult rv; + UniquePtr<dom::ClonedErrorHolder> ceh = + dom::ClonedErrorHolder::Create(aCx, errObj, rv); + if (!rv.Failed() && ceh) { + okSerializedError = ToJSValue(aCx, std::move(ceh), aRetval); + } else { + okSerializedError = false; + } + } + + if (isExtensionError && !okSerializedError) { + NS_WARNING("Failed to wrap ClonedErrorHolder"); + MOZ_DIAGNOSTIC_ASSERT(false, "Failed to wrap ClonedErrorHolder"); + return false; + } + + if (isExtensionError && !aRetval.isObject()) { + NS_WARNING("Unexpected non-object error"); + return false; + } + + switch (resultType) { + case mozIExtensionAPIRequestResult::ResultType::RETURN_VALUE: + return ProcessHandlerResult(aCx, aRetval); + case mozIExtensionAPIRequestResult::ResultType::EXTENSION_ERROR: + if (!aRetval.isObject()) { + return false; + } + return ProcessHandlerResult(aCx, aRetval); + } + + MOZ_DIAGNOSTIC_ASSERT(false, "Unexpected API request ResultType"); + return false; +} + +bool RequestWorkerRunnable::ProcessHandlerResult( + JSContext* aCx, JS::MutableHandle<JS::Value> aRetval) { + MOZ_ASSERT(NS_IsMainThread()); + + if (mOuterRequest->GetRequestType() == APIRequestType::CALL_FUNCTION_ASYNC) { + if (NS_WARN_IF(mResultType.isNothing())) { + return false; + } + + if (*mResultType == APIResultType::RETURN_VALUE) { + // For an Async API method we expect a promise object to be set + // as the value to return, if it is not we return earlier here + // (and then throw a generic unexpected error to the caller). + if (NS_WARN_IF(!aRetval.isObject())) { + return false; + } + JS::Rooted<JSObject*> obj(aCx, &aRetval.toObject()); + if (NS_WARN_IF(!JS::IsPromiseObject(obj))) { + return false; + } + + ErrorResult rv; + nsIGlobalObject* glob = xpc::CurrentNativeGlobal(aCx); + RefPtr<dom::Promise> retPromise = + dom::Promise::Resolve(glob, aCx, aRetval, rv); + if (rv.Failed()) { + return false; + } + retPromise->AppendNativeHandler(mPromiseProxy); + return true; + } + } + + switch (*mResultType) { + case APIResultType::RETURN_VALUE: + [[fallthrough]]; + case APIResultType::EXTENSION_ERROR: { + // In all other case we expect the result to be: + // - a structured clonable result + // - an extension error (e.g. due to the API call params validation + // errors), + // previously converted into a CloneErrorHolder + IgnoredErrorResult rv; + mResultHolder = Some(MakeUnique<dom::StructuredCloneHolder>( + dom::StructuredCloneHolder::CloningSupported, + dom::StructuredCloneHolder::TransferringNotSupported, + JS::StructuredCloneScope::SameProcess)); + mResultHolder->get()->Write(aCx, aRetval, rv); + return !rv.Failed(); + } + } + + MOZ_DIAGNOSTIC_ASSERT(false, "Unexpected API request ResultType"); + return false; +} + +void RequestWorkerRunnable::ReadResult(JSContext* aCx, + JS::MutableHandle<JS::Value> aResult, + ErrorResult& aRv) { + MOZ_ASSERT(mWorkerPrivate->IsOnCurrentThread()); + if (mResultHolder.isNothing() || !mResultHolder->get()->HasData()) { + return; + } + + if (NS_WARN_IF(mResultType.isNothing())) { + aRv.Throw(NS_ERROR_UNEXPECTED); + return; + } + + switch (*mResultType) { + case mozIExtensionAPIRequestResult::ResultType::RETURN_VALUE: + mResultHolder->get()->Read(xpc::CurrentNativeGlobal(aCx), aCx, aResult, + aRv); + return; + case mozIExtensionAPIRequestResult::ResultType::EXTENSION_ERROR: + JS::Rooted<JS::Value> exn(aCx); + IgnoredErrorResult rv; + mResultHolder->get()->Read(xpc::CurrentNativeGlobal(aCx), aCx, &exn, rv); + if (rv.Failed()) { + NS_WARNING("Failed to deserialize extension error"); + ExtensionAPIBase::ThrowUnexpectedError(aCx, aRv); + return; + } + + aRv.MightThrowJSException(); + aRv.ThrowJSException(aCx, exn); + return; + } + + MOZ_DIAGNOSTIC_ASSERT(false, "Unexpected API request ResultType"); + aRv.Throw(NS_ERROR_UNEXPECTED); +} + +// RequestInitWorkerContextRunnable + +RequestInitWorkerRunnable::RequestInitWorkerRunnable( + dom::WorkerPrivate* aWorkerPrivate, Maybe<dom::ClientInfo>& aSWClientInfo) + : WorkerMainThreadRunnable(aWorkerPrivate, + "extensions::RequestInitWorkerRunnable"_ns) { + MOZ_ASSERT(dom::IsCurrentThreadRunningWorker()); + MOZ_ASSERT(aSWClientInfo.isSome()); + mClientInfo = aSWClientInfo; +} + +bool RequestInitWorkerRunnable::MainThreadRun() { + MOZ_ASSERT(NS_IsMainThread()); + + auto* baseURI = mWorkerPrivate->GetBaseURI(); + RefPtr<WebExtensionPolicy> policy = + ExtensionPolicyService::GetSingleton().GetByURL(baseURI); + + RefPtr<ExtensionServiceWorkerInfo> swInfo = new ExtensionServiceWorkerInfo( + *mClientInfo, mWorkerPrivate->ServiceWorkerID()); + + nsCOMPtr<mozIExtensionAPIRequestHandler> handler = + &ExtensionAPIRequestForwarder::APIRequestHandler(); + MOZ_ASSERT(handler); + + if (NS_FAILED(handler->InitExtensionWorker(policy, swInfo))) { + NS_WARNING("nsIExtensionAPIRequestHandler.initExtensionWorker call failed"); + } + + return true; +} + +// NotifyWorkerLoadedRunnable + +nsresult NotifyWorkerLoadedRunnable::Run() { + MOZ_ASSERT(NS_IsMainThread()); + + RefPtr<WebExtensionPolicy> policy = + ExtensionPolicyService::GetSingleton().GetByURL(mSWBaseURI.get()); + + nsCOMPtr<mozIExtensionAPIRequestHandler> handler = + &ExtensionAPIRequestForwarder::APIRequestHandler(); + MOZ_ASSERT(handler); + + if (NS_FAILED(handler->OnExtensionWorkerLoaded(policy, mSWDescriptorId))) { + NS_WARNING( + "nsIExtensionAPIRequestHandler.onExtensionWorkerLoaded call failed"); + } + + return NS_OK; +} + +// NotifyWorkerDestroyedRunnable + +nsresult NotifyWorkerDestroyedRunnable::Run() { + MOZ_ASSERT(NS_IsMainThread()); + + RefPtr<WebExtensionPolicy> policy = + ExtensionPolicyService::GetSingleton().GetByURL(mSWBaseURI.get()); + + nsCOMPtr<mozIExtensionAPIRequestHandler> handler = + &ExtensionAPIRequestForwarder::APIRequestHandler(); + MOZ_ASSERT(handler); + + if (NS_FAILED(handler->OnExtensionWorkerDestroyed(policy, mSWDescriptorId))) { + NS_WARNING( + "nsIExtensionAPIRequestHandler.onExtensionWorkerDestroyed call failed"); + } + + return NS_OK; +} + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/webidl-api/ExtensionAPIRequestForwarder.h b/toolkit/components/extensions/webidl-api/ExtensionAPIRequestForwarder.h new file mode 100644 index 0000000000..a74c3e7c45 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionAPIRequestForwarder.h @@ -0,0 +1,258 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_ExtensionAPIRequestForwarder_h +#define mozilla_extensions_ExtensionAPIRequestForwarder_h + +#include "ExtensionAPIRequest.h" + +#include "mozilla/dom/PromiseWorkerProxy.h" +#include "mozilla/dom/RootedDictionary.h" +#include "mozilla/dom/SerializedStackHolder.h" +#include "mozilla/dom/StructuredCloneHolder.h" +#include "mozilla/dom/WorkerRunnable.h" +#include "mozilla/dom/WorkerPrivate.h" +#include "mozilla/dom/ToJSValue.h" + +namespace mozilla { +namespace dom { +class ClientInfoAndState; +class Function; +} // namespace dom +namespace extensions { + +class ExtensionAPIRequestForwarder; + +// A class used to forward an API request (a method call, add/remote listener or +// a property getter) originated from a WebExtensions global (a window, a +// content script sandbox or a service worker) to the JS privileged API request +// handler available on the main thread (mozIExtensionAPIRequestHandler). +// +// Instances of this class are meant to be short-living, and destroyed when the +// caller function is exiting. +class ExtensionAPIRequestForwarder { + friend class ExtensionAPIRequest; + + NS_INLINE_DECL_THREADSAFE_REFCOUNTING(ExtensionAPIRequestForwarder) + + public: + using APIRequestType = mozIExtensionAPIRequest::RequestType; + using APIResultType = mozIExtensionAPIRequestResult::ResultType; + + static nsresult JSArrayToSequence(JSContext* aCx, + JS::Handle<JS::Value> aJSValue, + dom::Sequence<JS::Value>& aResult); + + static void ThrowUnexpectedError(JSContext* aCx, ErrorResult& aRv); + + static mozIExtensionAPIRequestHandler& APIRequestHandler(); + + ExtensionAPIRequestForwarder(const APIRequestType aRequestType, + const nsAString& aApiNamespace, + const nsAString& aApiMethod, + const nsAString& aApiObjectType = u""_ns, + const nsAString& aApiObjectId = u""_ns); + + mozIExtensionAPIRequest::RequestType GetRequestType() const { + return mRequestType; + } + + const ExtensionAPIRequestTarget* GetRequestTarget() { + return &mRequestTarget; + } + + void Run(nsIGlobalObject* aGlobal, JSContext* aCx, + const dom::Sequence<JS::Value>& aArgs, ErrorResult& aRv); + + void Run(nsIGlobalObject* aGlobal, JSContext* aCx, + const dom::Sequence<JS::Value>& aArgs, + ExtensionEventListener* aListener, ErrorResult& aRv); + + void Run(nsIGlobalObject* aGlobal, JSContext* aCx, + const dom::Sequence<JS::Value>& aArgs, + JS::MutableHandle<JS::Value> aRetVal, ErrorResult& aRv); + + void Run(nsIGlobalObject* aGlobal, JSContext* aCx, + const dom::Sequence<JS::Value>& aArgs, + ExtensionEventListener* aListener, + JS::MutableHandle<JS::Value> aRetVal, ErrorResult& aRv); + + void Run(nsIGlobalObject* aGlobal, JSContext* aCx, + const dom::Sequence<JS::Value>& aArgs, + const RefPtr<dom::Promise>& aPromiseRetval, ErrorResult& aRv); + + void Run(nsIGlobalObject* aGlobal, JSContext* aCx, + JS::MutableHandle<JS::Value> aRetVal, ErrorResult& aRv); + + void SetSerializedCallerStack( + UniquePtr<dom::SerializedStackHolder> aCallerStack); + + protected: + virtual ~ExtensionAPIRequestForwarder() = default; + + private: + already_AddRefed<ExtensionAPIRequest> CreateAPIRequest( + nsIGlobalObject* aGlobal, JSContext* aCx, + const dom::Sequence<JS::Value>& aArgs, ExtensionEventListener* aListener, + ErrorResult& aRv); + + APIRequestType mRequestType; + ExtensionAPIRequestTarget mRequestTarget; + Maybe<UniquePtr<dom::SerializedStackHolder>> mStackHolder; +}; + +/* + * This runnable is used internally by ExtensionAPIRequestForwader class + * to call the JS privileged code that handle the API requests originated + * from the WebIDL bindings instantiated in a worker thread. + * + * The runnable is meant to block the worker thread until we get a result + * from the JS privileged code that handles the API request. + * + * For async API calls we still need to block the worker thread until + * we get a promise (which we link to the worker thread promise and + * at that point we unblock the worker thread), because the JS privileged + * code handling the API request may need to throw some errors synchonously + * (e.g. in case of additional validations based on the API schema definition + * for the parameter, like strings that has to pass additional validation + * or normalizations). + */ +class RequestWorkerRunnable : public dom::WorkerMainThreadRunnable { + public: + using APIRequestType = mozIExtensionAPIRequest::RequestType; + using APIResultType = mozIExtensionAPIRequestResult::ResultType; + + RequestWorkerRunnable(dom::WorkerPrivate* aWorkerPrivate, + ExtensionAPIRequestForwarder* aOuterAPIRequest); + + void SetSerializedCallerStack( + UniquePtr<dom::SerializedStackHolder> aCallerStack); + + /** + * Init a request runnable for AddListener and RemoveListener API requests + * (which do have an event callback callback and do not expect any return + * value). + */ + void Init(nsIGlobalObject* aGlobal, JSContext* aCx, + const dom::Sequence<JS::Value>& aArgs, + ExtensionEventListener* aListener, ErrorResult& aRv); + + /** + * Init a request runnable for CallFunctionNoReturn API requests (which do + * do not expect any return value). + */ + void Init(nsIGlobalObject* aGlobal, JSContext* aCx, + const dom::Sequence<JS::Value>& aArgs, ErrorResult& aRv) { + Init(aGlobal, aCx, aArgs, nullptr, aRv); + } + + /** + * Init a request runnable for CallAsyncFunction API requests (which do + * expect a promise as return value). + */ + void Init(nsIGlobalObject* aGlobal, JSContext* aCx, + const dom::Sequence<JS::Value>& aArgs, + const RefPtr<dom::Promise>& aPromiseRetval, ErrorResult& aRv); + + bool MainThreadRun() override; + + void ReadResult(JSContext* aCx, JS::MutableHandle<JS::Value> aResult, + ErrorResult& aRv); + + Maybe<mozIExtensionAPIRequestResult::ResultType> GetResultType() { + return mResultType; + } + + protected: + virtual bool ProcessHandlerResult(JSContext* aCx, + JS::MutableHandle<JS::Value> aRetval); + + already_AddRefed<WebExtensionPolicy> GetWebExtensionPolicy(); + already_AddRefed<ExtensionAPIRequest> CreateAPIRequest(JSContext* aCx); + + void SerializeCallerStack(JSContext* aCx); + void DeserializeCallerStack(JSContext* aCx, + JS::MutableHandle<JS::Value> aRetval); + void SerializeArgs(JSContext* aCx, const dom::Sequence<JS::Value>& aArgs, + ErrorResult& aRv); + nsresult DeserializeArgs(JSContext* aCx, JS::MutableHandle<JS::Value> aArgs); + + bool HandleAPIRequest(JSContext* aCx, JS::MutableHandle<JS::Value> aRetval); + + Maybe<mozIExtensionAPIRequestResult::ResultType> mResultType; + Maybe<UniquePtr<dom::StructuredCloneHolder>> mResultHolder; + RefPtr<dom::PromiseWorkerProxy> mPromiseProxy; + Maybe<UniquePtr<dom::StructuredCloneHolder>> mArgsHolder; + Maybe<UniquePtr<dom::SerializedStackHolder>> mStackHolder; + Maybe<dom::ClientInfo> mClientInfo; + uint64_t mSWDescriptorId; + + // Only set for addListener/removeListener API requests. + RefPtr<ExtensionEventListener> mEventListener; + + // The outer request object is kept alive by the caller for the + // entire life of the inner worker runnable. + ExtensionAPIRequestForwarder* mOuterRequest; +}; + +class RequestInitWorkerRunnable : public dom::WorkerMainThreadRunnable { + Maybe<dom::ClientInfo> mClientInfo; + + public: + RequestInitWorkerRunnable(dom::WorkerPrivate* aWorkerPrivate, + Maybe<dom::ClientInfo>& aSWClientInfo); + bool MainThreadRun() override; +}; + +class NotifyWorkerLoadedRunnable : public Runnable { + uint64_t mSWDescriptorId; + nsCOMPtr<nsIURI> mSWBaseURI; + + public: + explicit NotifyWorkerLoadedRunnable(const uint64_t aServiceWorkerDescriptorId, + const nsCOMPtr<nsIURI>& aWorkerBaseURI) + : Runnable("extensions::NotifyWorkerLoadedRunnable"), + mSWDescriptorId(aServiceWorkerDescriptorId), + mSWBaseURI(aWorkerBaseURI) { + MOZ_ASSERT(mSWDescriptorId > 0); + MOZ_ASSERT(mSWBaseURI); + } + + NS_IMETHOD Run() override; + + NS_INLINE_DECL_REFCOUNTING_INHERITED(NotifyWorkerLoadedRunnable, Runnable) + + private: + ~NotifyWorkerLoadedRunnable() = default; +}; + +class NotifyWorkerDestroyedRunnable : public Runnable { + uint64_t mSWDescriptorId; + nsCOMPtr<nsIURI> mSWBaseURI; + + public: + explicit NotifyWorkerDestroyedRunnable( + const uint64_t aServiceWorkerDescriptorId, + const nsCOMPtr<nsIURI>& aWorkerBaseURI) + : Runnable("extensions::NotifyWorkerDestroyedRunnable"), + mSWDescriptorId(aServiceWorkerDescriptorId), + mSWBaseURI(aWorkerBaseURI) { + MOZ_ASSERT(mSWDescriptorId > 0); + MOZ_ASSERT(mSWBaseURI); + } + + NS_IMETHOD Run() override; + + NS_INLINE_DECL_REFCOUNTING_INHERITED(NotifyWorkerDestroyedRunnable, Runnable) + + private: + ~NotifyWorkerDestroyedRunnable() = default; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_ExtensionAPIRequestForwarder_h diff --git a/toolkit/components/extensions/webidl-api/ExtensionAlarms.cpp b/toolkit/components/extensions/webidl-api/ExtensionAlarms.cpp new file mode 100644 index 0000000000..71f04e3ded --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionAlarms.cpp @@ -0,0 +1,49 @@ +/* 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 "ExtensionAlarms.h" +#include "ExtensionEventManager.h" + +#include "mozilla/dom/ExtensionAlarmsBinding.h" +#include "nsIGlobalObject.h" + +namespace mozilla { +namespace extensions { + +NS_IMPL_CYCLE_COLLECTING_ADDREF(ExtensionAlarms); +NS_IMPL_CYCLE_COLLECTING_RELEASE(ExtensionAlarms) +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(ExtensionAlarms, mGlobal, + mExtensionBrowser, mOnAlarmEventMgr); + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(ExtensionAlarms) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_WEBEXT_EVENTMGR(ExtensionAlarms, u"onAlarm"_ns, OnAlarm) + +ExtensionAlarms::ExtensionAlarms(nsIGlobalObject* aGlobal, + ExtensionBrowser* aExtensionBrowser) + : mGlobal(aGlobal), mExtensionBrowser(aExtensionBrowser) { + MOZ_DIAGNOSTIC_ASSERT(mGlobal); + MOZ_DIAGNOSTIC_ASSERT(mExtensionBrowser); +} + +/* static */ +bool ExtensionAlarms::IsAllowed(JSContext* aCx, JSObject* aGlobal) { + // TODO(Bug 1725478): this API visibility should be gated by the "alarms" + // permission. + return true; +} + +JSObject* ExtensionAlarms::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return dom::ExtensionAlarms_Binding::Wrap(aCx, this, aGivenProto); +} + +nsIGlobalObject* ExtensionAlarms::GetParentObject() const { return mGlobal; } + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/webidl-api/ExtensionAlarms.h b/toolkit/components/extensions/webidl-api/ExtensionAlarms.h new file mode 100644 index 0000000000..b969a2eb69 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionAlarms.h @@ -0,0 +1,70 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_ExtensionAlarms_h +#define mozilla_extensions_ExtensionAlarms_h + +#include "js/TypeDecls.h" +#include "mozilla/Attributes.h" +#include "mozilla/dom/BindingDeclarations.h" +#include "nsCycleCollectionParticipant.h" +#include "nsCOMPtr.h" +#include "nsISupports.h" +#include "nsWrapperCache.h" + +#include "ExtensionAPIBase.h" +#include "ExtensionBrowser.h" + +class nsIGlobalObject; + +namespace mozilla { + +namespace extensions { + +class ExtensionEventManager; + +class ExtensionAlarms final : public nsISupports, + public nsWrapperCache, + public ExtensionAPINamespace { + public: + ExtensionAlarms(nsIGlobalObject* aGlobal, + ExtensionBrowser* aExtensionBrowser); + + // ExtensionAPIBase methods + nsIGlobalObject* GetGlobalObject() const override { return mGlobal; } + + ExtensionBrowser* GetExtensionBrowser() const override { + return mExtensionBrowser; + } + + nsString GetAPINamespace() const override { return u"alarms"_ns; } + + // nsWrapperCache interface methods + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + // DOM bindings methods + static bool IsAllowed(JSContext* aCx, JSObject* aGlobal); + + nsIGlobalObject* GetParentObject() const; + + ExtensionEventManager* OnAlarm(); + + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(ExtensionAlarms) + + private: + ~ExtensionAlarms() = default; + + nsCOMPtr<nsIGlobalObject> mGlobal; + RefPtr<ExtensionBrowser> mExtensionBrowser; + RefPtr<ExtensionEventManager> mOnAlarmEventMgr; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_ExtensionAlarms_h diff --git a/toolkit/components/extensions/webidl-api/ExtensionBrowser.cpp b/toolkit/components/extensions/webidl-api/ExtensionBrowser.cpp new file mode 100644 index 0000000000..069c914dc4 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionBrowser.cpp @@ -0,0 +1,345 @@ +/* -*- 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 "ExtensionBrowser.h" +#include "ExtensionAPIRequestForwarder.h" + +#include "mozilla/dom/ExtensionBrowserBinding.h" +#include "mozilla/dom/ExtensionPortBinding.h" // ExtensionPortDescriptor +#include "mozilla/dom/WorkerScope.h" // GetWorkerPrivateFromContext +#include "mozilla/extensions/ExtensionAlarms.h" +#include "mozilla/extensions/ExtensionBrowserSettings.h" +#include "mozilla/extensions/ExtensionDns.h" +#include "mozilla/extensions/ExtensionMockAPI.h" +#include "mozilla/extensions/ExtensionPort.h" +#include "mozilla/extensions/ExtensionProxy.h" +#include "mozilla/extensions/ExtensionRuntime.h" +#include "mozilla/extensions/ExtensionScripting.h" +#include "mozilla/extensions/ExtensionTest.h" +#include "mozilla/extensions/WebExtensionPolicy.h" + +namespace mozilla { +namespace extensions { + +NS_IMPL_CYCLE_COLLECTION_CLASS(ExtensionBrowser) +NS_IMPL_CYCLE_COLLECTING_ADDREF(ExtensionBrowser) +NS_IMPL_CYCLE_COLLECTING_RELEASE(ExtensionBrowser) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(ExtensionBrowser) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(ExtensionBrowser) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mGlobal) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mExtensionAlarms) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mExtensionBrowserSettings) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mExtensionDns) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mExtensionMockAPI) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mExtensionProxy) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mExtensionRuntime) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mExtensionScripting) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mExtensionTest) + tmp->mLastError.setUndefined(); + tmp->mPortsLookup.Clear(); + NS_IMPL_CYCLE_COLLECTION_UNLINK_PRESERVED_WRAPPER +NS_IMPL_CYCLE_COLLECTION_UNLINK_END + +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(ExtensionBrowser) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mGlobal) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mExtensionAlarms) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mExtensionBrowserSettings) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mExtensionDns) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mExtensionMockAPI) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mExtensionRuntime) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mExtensionProxy) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mExtensionScripting) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mExtensionTest) +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_IMPL_CYCLE_COLLECTION_TRACE_BEGIN(ExtensionBrowser) + NS_IMPL_CYCLE_COLLECTION_TRACE_JS_MEMBER_CALLBACK(mLastError) + NS_IMPL_CYCLE_COLLECTION_TRACE_PRESERVED_WRAPPER +NS_IMPL_CYCLE_COLLECTION_TRACE_END + +ExtensionBrowser::ExtensionBrowser(nsIGlobalObject* aGlobal) + : mGlobal(aGlobal) { + MOZ_DIAGNOSTIC_ASSERT(mGlobal); +} + +JSObject* ExtensionBrowser::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return dom::ExtensionBrowser_Binding::Wrap(aCx, this, aGivenProto); +} + +nsIGlobalObject* ExtensionBrowser::GetParentObject() const { return mGlobal; } + +bool ExtensionAPIAllowed(JSContext* aCx, JSObject* aGlobal) { +#ifdef MOZ_WEBEXT_WEBIDL_ENABLED + // Only expose the Extension API bindings if: + // - the context is related to a worker where the Extension API are allowed + // (currently only the extension service worker declared in the extension + // manifest met this condition) + // - the global is an extension window or an extension content script sandbox + // TODO: + // - the support for the extension window is deferred to a followup. + // - support for the content script sandboxes is also deferred to follow-ups + // - lock native Extension API in an extension window or sandbox behind a + // separate pref. + MOZ_DIAGNOSTIC_ASSERT( + !NS_IsMainThread(), + "ExtensionAPI webidl bindings does not yet support main thread globals"); + + // Verify if the Extensions API should be allowed on a worker thread. + if (!StaticPrefs::extensions_backgroundServiceWorker_enabled_AtStartup()) { + return false; + } + + auto* workerPrivate = mozilla::dom::GetWorkerPrivateFromContext(aCx); + MOZ_ASSERT(workerPrivate); + MOZ_ASSERT(workerPrivate->IsServiceWorker()); + + return workerPrivate->ExtensionAPIAllowed(); +#else + // Always return false on build where MOZ_WEBEXT_WEBIDL_ENABLED is set to + // false (currently on all channels but nightly). + return false; +#endif +} + +void CreateAndDispatchInitWorkerContextRunnable() { + MOZ_ASSERT(dom::IsCurrentThreadRunningWorker()); + // DO NOT pass this WorkerPrivate raw pointer to anything else but the + // RequestInitWorkerRunnable (which extends dom::WorkerMainThreadRunnable). + dom::WorkerPrivate* workerPrivate = dom::GetCurrentThreadWorkerPrivate(); + MOZ_ASSERT(workerPrivate); + MOZ_ASSERT(workerPrivate->ExtensionAPIAllowed()); + MOZ_ASSERT(workerPrivate->IsServiceWorker()); + workerPrivate->AssertIsOnWorkerThread(); + + auto* workerScope = workerPrivate->GlobalScope(); + if (NS_WARN_IF(!workerScope)) { + return; + } + + Maybe<dom::ClientInfo> clientInfo = workerScope->GetClientInfo(); + if (NS_WARN_IF(clientInfo.isNothing())) { + return; + } + + RefPtr<RequestInitWorkerRunnable> runnable = + new RequestInitWorkerRunnable(std::move(workerPrivate), clientInfo); + IgnoredErrorResult rv; + runnable->Dispatch(dom::WorkerStatus::Canceling, rv); + if (rv.Failed()) { + NS_WARNING("Failed to dispatch extensions::RequestInitWorkerRunnable"); + } +} + +already_AddRefed<Runnable> CreateWorkerLoadedRunnable( + const uint64_t aServiceWorkerDescriptorId, + const nsCOMPtr<nsIURI>& aWorkerBaseURI) { + RefPtr<NotifyWorkerLoadedRunnable> runnable = new NotifyWorkerLoadedRunnable( + aServiceWorkerDescriptorId, aWorkerBaseURI); + return runnable.forget(); +} + +already_AddRefed<Runnable> CreateWorkerDestroyedRunnable( + const uint64_t aServiceWorkerDescriptorId, + const nsCOMPtr<nsIURI>& aWorkerBaseURI) { + RefPtr<NotifyWorkerDestroyedRunnable> runnable = + new NotifyWorkerDestroyedRunnable(aServiceWorkerDescriptorId, + aWorkerBaseURI); + return runnable.forget(); +} + +void ExtensionBrowser::SetLastError(JS::Handle<JS::Value> aLastError) { + mLastError.set(aLastError); + mCheckedLastError = false; +} + +void ExtensionBrowser::GetLastError(JS::MutableHandle<JS::Value> aRetVal) { + aRetVal.set(mLastError); + mCheckedLastError = true; +} + +bool ExtensionBrowser::ClearLastError() { + bool shouldReport = !mCheckedLastError; + mLastError.setUndefined(); + return shouldReport; +} + +already_AddRefed<ExtensionPort> ExtensionBrowser::GetPort( + JS::Handle<JS::Value> aDescriptorValue, ErrorResult& aRv) { + // Get a port descriptor from the js value got from the API request + // handler. + UniquePtr<dom::ExtensionPortDescriptor> portDescriptor = + ExtensionPort::ToPortDescriptor(aDescriptorValue, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + + auto portId = portDescriptor->mPortId; + auto maybePort = mPortsLookup.MaybeGet(portId); + if (maybePort.isSome() && maybePort.value().get()) { + RefPtr<ExtensionPort> existingPort = maybePort.value().get(); + return existingPort.forget(); + } + + RefPtr<ExtensionPort> newPort = + ExtensionPort::Create(mGlobal, this, std::move(portDescriptor)); + mPortsLookup.InsertOrUpdate(portId, newPort); + return newPort.forget(); +} + +void ExtensionBrowser::ForgetReleasedPort(const nsAString& aPortId) { + mPortsLookup.Remove(aPortId); +} + +ExtensionAlarms* ExtensionBrowser::GetExtensionAlarms() { + if (!mExtensionAlarms) { + mExtensionAlarms = new ExtensionAlarms(mGlobal, this); + } + + return mExtensionAlarms; +} + +ExtensionBrowserSettings* ExtensionBrowser::GetExtensionBrowserSettings() { + if (!mExtensionBrowserSettings) { + mExtensionBrowserSettings = new ExtensionBrowserSettings(mGlobal, this); + } + + return mExtensionBrowserSettings; +} + +ExtensionDns* ExtensionBrowser::GetExtensionDns() { + if (!mExtensionDns) { + mExtensionDns = new ExtensionDns(mGlobal, this); + } + + return mExtensionDns; +} + +ExtensionMockAPI* ExtensionBrowser::GetExtensionMockAPI() { + if (!mExtensionMockAPI) { + mExtensionMockAPI = new ExtensionMockAPI(mGlobal, this); + } + + return mExtensionMockAPI; +} + +ExtensionProxy* ExtensionBrowser::GetExtensionProxy() { + if (!mExtensionProxy) { + mExtensionProxy = new ExtensionProxy(mGlobal, this); + } + + return mExtensionProxy; +} + +ExtensionRuntime* ExtensionBrowser::GetExtensionRuntime() { + if (!mExtensionRuntime) { + mExtensionRuntime = new ExtensionRuntime(mGlobal, this); + } + + return mExtensionRuntime; +} + +ExtensionScripting* ExtensionBrowser::GetExtensionScripting() { + if (!mExtensionScripting) { + mExtensionScripting = new ExtensionScripting(mGlobal, this); + } + + return mExtensionScripting; +} + +ExtensionTest* ExtensionBrowser::GetExtensionTest() { + if (!mExtensionTest) { + mExtensionTest = new ExtensionTest(mGlobal, this); + } + + return mExtensionTest; +} + +// static +void ExtensionEventWakeupMap::ToMapKey(const nsAString& aAPINamespace, + const nsAString& aAPIName, + nsAString& aResultMapKey) { + aResultMapKey.Truncate(); + aResultMapKey.AppendPrintf("%s.%s", + NS_ConvertUTF16toUTF8(aAPINamespace).get(), + NS_ConvertUTF16toUTF8(aAPIName).get()); +} + +nsresult ExtensionEventWakeupMap::IncrementListeners( + const nsAString& aAPINamespace, const nsAString& aAPIName) { + nsString key; + ToMapKey(aAPINamespace, aAPIName, key); + auto maybeCount = MaybeGet(key); + if (maybeCount.isSome()) { + InsertOrUpdate(key, maybeCount.value() + 1); + } else { + InsertOrUpdate(key, 1); + } + + return NS_OK; +} + +nsresult ExtensionEventWakeupMap::DecrementListeners( + const nsAString& aAPINamespace, const nsAString& aAPIName) { + nsString key; + ToMapKey(aAPINamespace, aAPIName, key); + auto maybeCount = MaybeGet(key); + if (maybeCount.isSome()) { + MOZ_ASSERT(maybeCount.value() >= 1, "Unexpected counter value set to zero"); + uint64_t val = maybeCount.value() - 1; + if (val == 0) { + Remove(key); + } else { + InsertOrUpdate(key, val); + } + } + + return NS_OK; +} + +bool ExtensionEventWakeupMap::HasListener(const nsAString& aAPINamespace, + const nsAString& aAPIName) { + nsString key; + ToMapKey(aAPINamespace, aAPIName, key); + auto maybeCount = MaybeGet(key); + return (maybeCount.isSome() && maybeCount.value() > 0); +} + +nsresult ExtensionBrowser::TrackWakeupEventListener( + JSContext* aCx, const nsString& aAPINamespace, const nsString& aAPIName) { + auto* workerPrivate = mozilla::dom::GetWorkerPrivateFromContext(aCx); + if (workerPrivate->WorkerScriptExecutedSuccessfully()) { + // Ignore if the worker script has already executed all its synchronous + // statements. + return NS_OK; + } + mExpectedEventWakeupMap.IncrementListeners(aAPINamespace, aAPIName); + return NS_OK; +} + +nsresult ExtensionBrowser::UntrackWakeupEventListener( + JSContext* aCx, const nsString& aAPINamespace, const nsString& aAPIName) { + auto* workerPrivate = mozilla::dom::GetWorkerPrivateFromContext(aCx); + if (workerPrivate->WorkerScriptExecutedSuccessfully()) { + // Ignore if the worker script has already executed all its synchronous + return NS_OK; + } + mExpectedEventWakeupMap.DecrementListeners(aAPINamespace, aAPIName); + return NS_OK; +} + +bool ExtensionBrowser::HasWakeupEventListener(const nsString& aAPINamespace, + const nsString& aAPIName) { + return mExpectedEventWakeupMap.HasListener(aAPINamespace, aAPIName); +} + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/webidl-api/ExtensionBrowser.h b/toolkit/components/extensions/webidl-api/ExtensionBrowser.h new file mode 100644 index 0000000000..3c4b831b4e --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionBrowser.h @@ -0,0 +1,154 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_ExtensionBrowser_h +#define mozilla_extensions_ExtensionBrowser_h + +#include "nsCOMPtr.h" +#include "nsISupports.h" +#include "nsTHashMap.h" +#include "nsWrapperCache.h" +#include "mozilla/WeakPtr.h" +#include "xpcpublic.h" + +class nsIGlobalObject; +class nsIURI; + +namespace mozilla { + +class ErrorResult; + +namespace extensions { + +class ExtensionAlarms; +class ExtensionBrowserSettings; +class ExtensionDns; +class ExtensionMockAPI; +class ExtensionPort; +class ExtensionProxy; +class ExtensionRuntime; +class ExtensionScripting; +class ExtensionTest; + +bool ExtensionAPIAllowed(JSContext* aCx, JSObject* aGlobal); + +void CreateAndDispatchInitWorkerContextRunnable(); + +already_AddRefed<Runnable> CreateWorkerLoadedRunnable( + const uint64_t aServiceWorkerDescriptorId, + const nsCOMPtr<nsIURI>& aWorkerBaseURI); + +already_AddRefed<Runnable> CreateWorkerDestroyedRunnable( + const uint64_t aServiceWorkerDescriptorId, + const nsCOMPtr<nsIURI>& aWorkerBaseURI); + +// An HashMap used to keep track of listeners registered synchronously while +// the worker script is executing, used internally by nsIServiceWorkerManager +// wakeforExtensionAPIEvent method to resolve to true if the worker script +// spawned did have a listener subscribed for the related API event name. +class ExtensionEventWakeupMap final + : public nsTHashMap<nsStringHashKey, uint64_t> { + static void ToMapKey(const nsAString& aAPINamespace, + const nsAString& aAPIName, nsAString& aResultMapKey); + + public: + nsresult IncrementListeners(const nsAString& aAPINamespace, + const nsAString& aAPIName); + nsresult DecrementListeners(const nsAString& aAPINamespace, + const nsAString& aAPIName); + bool HasListener(const nsAString& aAPINamespace, const nsAString& aAPIName); +}; + +class ExtensionBrowser final : public nsISupports, public nsWrapperCache { + public: + explicit ExtensionBrowser(nsIGlobalObject* aGlobal); + + // Helpers used for the expected behavior of the browser.runtime.lastError + // and browser.extension.lastError. + void SetLastError(JS::Handle<JS::Value> aLastError); + void GetLastError(JS::MutableHandle<JS::Value> aRetVal); + // ClearLastError is used by ChromeCompatCallbackHandler::RejectedCallback + // to clear the lastError property. When this method returns true the + // caller will know that the error value wasn't checked by the callback and + // should be reported to the console + bool ClearLastError(); + + // Helpers used to keep track of the event listeners added during the + // initial sync worker script execution. + nsresult TrackWakeupEventListener(JSContext* aCx, + const nsString& aAPINamespace, + const nsString& aAPIName); + nsresult UntrackWakeupEventListener(JSContext* aCx, + const nsString& aAPINamespace, + const nsString& aAPIName); + bool HasWakeupEventListener(const nsString& aAPINamespace, + const nsString& aAPIName); + + // Helpers used for the ExtensionPort. + + // Get an ExtensionPort instance given its port descriptor (returns an + // existing port if an instance is still tracked in the ports lookup table, + // otherwise it creates a new one). + already_AddRefed<ExtensionPort> GetPort( + JS::Handle<JS::Value> aDescriptorValue, ErrorResult& aRv); + + // Remove the entry for an ExtensionPort tracked in the ports lookup map + // given its portId (called from the ExtensionPort destructor when the + // instance is being released). + void ForgetReleasedPort(const nsAString& aPortId); + + // nsWrapperCache interface methods + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + // DOM bindings methods + + nsIGlobalObject* GetParentObject() const; + + ExtensionAlarms* GetExtensionAlarms(); + ExtensionBrowserSettings* GetExtensionBrowserSettings(); + ExtensionDns* GetExtensionDns(); + ExtensionMockAPI* GetExtensionMockAPI(); + ExtensionProxy* GetExtensionProxy(); + ExtensionRuntime* GetExtensionRuntime(); + ExtensionScripting* GetExtensionScripting(); + ExtensionTest* GetExtensionTest(); + + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(ExtensionBrowser) + + private: + ~ExtensionBrowser() = default; + + nsCOMPtr<nsIGlobalObject> mGlobal; + JS::Heap<JS::Value> mLastError; + bool mCheckedLastError; + nsTHashMap<nsStringHashKey, WeakPtr<ExtensionPort>> mPortsLookup; + // `[APINamespace].[APIName]` => int64 (listeners count) + ExtensionEventWakeupMap mExpectedEventWakeupMap; + // NOTE: Make sure to don't forget to add for new API namespace instances + // added to the ones listed below the `NS_IMPL_CYCLE_COLLECTION_UNLINK` and + // `NS_IMPL_CYCLE_COLLECTION_TRAVERSE` macro calls in ExtensionBrowser.cpp, + // forgetting it would not result in a build error but it would leak the API + // namespace instance (and in debug builds the leak is going to hit an + // assertion failure when `WorkerThreadPrimaryRunnable::Run` calls the + // assertion `MOZ_ASSERT(!globalScopeAlive)`, after that + // `nsCycleCollector_shutdown()` has been called and we don't expect anything + // to be keeping the service worker global scope alive). + RefPtr<ExtensionAlarms> mExtensionAlarms; + RefPtr<ExtensionBrowserSettings> mExtensionBrowserSettings; + RefPtr<ExtensionDns> mExtensionDns; + RefPtr<ExtensionMockAPI> mExtensionMockAPI; + RefPtr<ExtensionProxy> mExtensionProxy; + RefPtr<ExtensionRuntime> mExtensionRuntime; + RefPtr<ExtensionScripting> mExtensionScripting; + RefPtr<ExtensionTest> mExtensionTest; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_ExtensionBrowser_h diff --git a/toolkit/components/extensions/webidl-api/ExtensionBrowserSettings.cpp b/toolkit/components/extensions/webidl-api/ExtensionBrowserSettings.cpp new file mode 100644 index 0000000000..57a5db3fe8 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionBrowserSettings.cpp @@ -0,0 +1,106 @@ +/* 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 "ExtensionBrowserSettings.h" +#include "ExtensionEventManager.h" +#include "ExtensionSetting.h" +#include "ExtensionBrowserSettingsColorManagement.h" + +#include "mozilla/dom/ExtensionBrowserSettingsBinding.h" +#include "nsIGlobalObject.h" + +namespace mozilla::extensions { + +NS_IMPL_CYCLE_COLLECTING_ADDREF(ExtensionBrowserSettings); +NS_IMPL_CYCLE_COLLECTING_RELEASE(ExtensionBrowserSettings) +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE( + ExtensionBrowserSettings, mGlobal, mExtensionBrowser, + mAllowPopupsForUserEventsSetting, mCacheEnabledSetting, + mCloseTabsByDoubleClickSetting, mContextMenuShowEventSetting, + mFtpProtocolEnabledSetting, mHomepageOverrideSetting, + mImageAnimationBehaviorSetting, mNewTabPageOverrideSetting, + mNewTabPositionSetting, mOpenBookmarksInNewTabsSetting, + mOpenSearchResultsInNewTabsSetting, mOpenUrlbarResultsInNewTabsSetting, + mWebNotificationsDisabledSetting, mOverrideDocumentColorsSetting, + mOverrideContentColorSchemeSetting, mUseDocumentFontsSetting, + mZoomFullPageSetting, mZoomSiteSpecificSetting, mColorManagementNamespace); + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(ExtensionBrowserSettings) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_WEBEXT_SETTING(ExtensionBrowserSettings, u"allowPopupsForUserEvents"_ns, + AllowPopupsForUserEvents) +NS_IMPL_WEBEXT_SETTING(ExtensionBrowserSettings, u"cacheEnabled"_ns, + CacheEnabled) +NS_IMPL_WEBEXT_SETTING(ExtensionBrowserSettings, u"closeTabsByDoubleClick"_ns, + CloseTabsByDoubleClick) +NS_IMPL_WEBEXT_SETTING(ExtensionBrowserSettings, u"contextMenuShowEvent"_ns, + ContextMenuShowEvent) +NS_IMPL_WEBEXT_SETTING(ExtensionBrowserSettings, u"ftpProtocolEnabled"_ns, + FtpProtocolEnabled) +NS_IMPL_WEBEXT_SETTING(ExtensionBrowserSettings, u"homepageOverride"_ns, + HomepageOverride) +NS_IMPL_WEBEXT_SETTING(ExtensionBrowserSettings, u"imageAnimationBehavior"_ns, + ImageAnimationBehavior) +NS_IMPL_WEBEXT_SETTING(ExtensionBrowserSettings, u"newTabPageOverride"_ns, + NewTabPageOverride) +NS_IMPL_WEBEXT_SETTING(ExtensionBrowserSettings, u"newTabPosition"_ns, + NewTabPosition) +NS_IMPL_WEBEXT_SETTING(ExtensionBrowserSettings, u"openBookmarksInNewTabs"_ns, + OpenBookmarksInNewTabs) +NS_IMPL_WEBEXT_SETTING(ExtensionBrowserSettings, + u"openSearchResultsInNewTabs"_ns, + OpenSearchResultsInNewTabs) +NS_IMPL_WEBEXT_SETTING(ExtensionBrowserSettings, + u"openUrlbarResultsInNewTabs"_ns, + OpenUrlbarResultsInNewTabs) +NS_IMPL_WEBEXT_SETTING(ExtensionBrowserSettings, u"webNotificationsDisabled"_ns, + WebNotificationsDisabled) +NS_IMPL_WEBEXT_SETTING(ExtensionBrowserSettings, u"overrideDocumentColors"_ns, + OverrideDocumentColors) +NS_IMPL_WEBEXT_SETTING(ExtensionBrowserSettings, + u"overrideContentColorScheme"_ns, + OverrideContentColorScheme) +NS_IMPL_WEBEXT_SETTING(ExtensionBrowserSettings, u"useDocumentFonts"_ns, + UseDocumentFonts) +NS_IMPL_WEBEXT_SETTING(ExtensionBrowserSettings, u"zoomFullPage"_ns, + ZoomFullPage) +NS_IMPL_WEBEXT_SETTING(ExtensionBrowserSettings, u"zoomSiteSpecific"_ns, + ZoomSiteSpecific) + +ExtensionBrowserSettings::ExtensionBrowserSettings( + nsIGlobalObject* aGlobal, ExtensionBrowser* aExtensionBrowser) + : mGlobal(aGlobal), mExtensionBrowser(aExtensionBrowser) { + MOZ_DIAGNOSTIC_ASSERT(mGlobal); + MOZ_DIAGNOSTIC_ASSERT(mExtensionBrowser); +} + +/* static */ +bool ExtensionBrowserSettings::IsAllowed(JSContext* aCx, JSObject* aGlobal) { + return true; +} + +JSObject* ExtensionBrowserSettings::WrapObject( + JSContext* aCx, JS::Handle<JSObject*> aGivenProto) { + return dom::ExtensionBrowserSettings_Binding::Wrap(aCx, this, aGivenProto); +} + +nsIGlobalObject* ExtensionBrowserSettings::GetParentObject() const { + return mGlobal; +} + +ExtensionBrowserSettingsColorManagement* +ExtensionBrowserSettings::GetExtensionBrowserSettingsColorManagement() { + if (!mColorManagementNamespace) { + mColorManagementNamespace = + new ExtensionBrowserSettingsColorManagement(mGlobal, mExtensionBrowser); + } + + return mColorManagementNamespace; +} + +} // namespace mozilla::extensions diff --git a/toolkit/components/extensions/webidl-api/ExtensionBrowserSettings.h b/toolkit/components/extensions/webidl-api/ExtensionBrowserSettings.h new file mode 100644 index 0000000000..aed892d493 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionBrowserSettings.h @@ -0,0 +1,107 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_ExtensionBrowserSettings_h +#define mozilla_extensions_ExtensionBrowserSettings_h + +#include "js/TypeDecls.h" +#include "mozilla/Attributes.h" +#include "mozilla/dom/BindingDeclarations.h" +#include "nsCycleCollectionParticipant.h" +#include "nsCOMPtr.h" +#include "nsISupports.h" +#include "nsWrapperCache.h" + +#include "ExtensionAPIBase.h" +#include "ExtensionBrowser.h" + +class nsIGlobalObject; + +namespace mozilla::extensions { + +class ExtensionBrowserSettingsColorManagement; +class ExtensionEventManager; +class ExtensionSetting; + +class ExtensionBrowserSettings final : public nsISupports, + public nsWrapperCache, + public ExtensionAPINamespace { + public: + ExtensionBrowserSettings(nsIGlobalObject* aGlobal, + ExtensionBrowser* aExtensionBrowser); + + // ExtensionAPIBase methods + nsIGlobalObject* GetGlobalObject() const override { return mGlobal; } + + ExtensionBrowser* GetExtensionBrowser() const override { + return mExtensionBrowser; + } + + nsString GetAPINamespace() const override { return u"browserSettings"_ns; } + + // nsWrapperCache interface methods + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + // DOM bindings methods + static bool IsAllowed(JSContext* aCx, JSObject* aGlobal); + + nsIGlobalObject* GetParentObject() const; + + ExtensionSetting* AllowPopupsForUserEvents(); + ExtensionSetting* CacheEnabled(); + ExtensionSetting* CloseTabsByDoubleClick(); + ExtensionSetting* ContextMenuShowEvent(); + ExtensionSetting* FtpProtocolEnabled(); + ExtensionSetting* HomepageOverride(); + ExtensionSetting* ImageAnimationBehavior(); + ExtensionSetting* NewTabPageOverride(); + ExtensionSetting* NewTabPosition(); + ExtensionSetting* OpenBookmarksInNewTabs(); + ExtensionSetting* OpenSearchResultsInNewTabs(); + ExtensionSetting* OpenUrlbarResultsInNewTabs(); + ExtensionSetting* WebNotificationsDisabled(); + ExtensionSetting* OverrideDocumentColors(); + ExtensionSetting* OverrideContentColorScheme(); + ExtensionSetting* UseDocumentFonts(); + ExtensionSetting* ZoomFullPage(); + ExtensionSetting* ZoomSiteSpecific(); + + ExtensionBrowserSettingsColorManagement* + GetExtensionBrowserSettingsColorManagement(); + + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(ExtensionBrowserSettings) + + private: + ~ExtensionBrowserSettings() = default; + + nsCOMPtr<nsIGlobalObject> mGlobal; + RefPtr<ExtensionBrowser> mExtensionBrowser; + RefPtr<ExtensionSetting> mAllowPopupsForUserEventsSetting; + RefPtr<ExtensionSetting> mCacheEnabledSetting; + RefPtr<ExtensionSetting> mCloseTabsByDoubleClickSetting; + RefPtr<ExtensionSetting> mContextMenuShowEventSetting; + RefPtr<ExtensionSetting> mFtpProtocolEnabledSetting; + RefPtr<ExtensionSetting> mHomepageOverrideSetting; + RefPtr<ExtensionSetting> mImageAnimationBehaviorSetting; + RefPtr<ExtensionSetting> mNewTabPageOverrideSetting; + RefPtr<ExtensionSetting> mNewTabPositionSetting; + RefPtr<ExtensionSetting> mOpenBookmarksInNewTabsSetting; + RefPtr<ExtensionSetting> mOpenSearchResultsInNewTabsSetting; + RefPtr<ExtensionSetting> mOpenUrlbarResultsInNewTabsSetting; + RefPtr<ExtensionSetting> mWebNotificationsDisabledSetting; + RefPtr<ExtensionSetting> mOverrideDocumentColorsSetting; + RefPtr<ExtensionSetting> mOverrideContentColorSchemeSetting; + RefPtr<ExtensionSetting> mUseDocumentFontsSetting; + RefPtr<ExtensionSetting> mZoomFullPageSetting; + RefPtr<ExtensionSetting> mZoomSiteSpecificSetting; + RefPtr<ExtensionBrowserSettingsColorManagement> mColorManagementNamespace; +}; + +} // namespace mozilla::extensions + +#endif // mozilla_extensions_ExtensionBrowserSettings_h diff --git a/toolkit/components/extensions/webidl-api/ExtensionBrowserSettingsColorManagement.cpp b/toolkit/components/extensions/webidl-api/ExtensionBrowserSettingsColorManagement.cpp new file mode 100644 index 0000000000..40009b350e --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionBrowserSettingsColorManagement.cpp @@ -0,0 +1,58 @@ +/* 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 "ExtensionBrowserSettingsColorManagement.h" +#include "ExtensionEventManager.h" + +#include "mozilla/dom/ExtensionBrowserSettingsColorManagementBinding.h" +#include "nsIGlobalObject.h" + +namespace mozilla::extensions { + +NS_IMPL_CYCLE_COLLECTING_ADDREF(ExtensionBrowserSettingsColorManagement); +NS_IMPL_CYCLE_COLLECTING_RELEASE(ExtensionBrowserSettingsColorManagement) +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(ExtensionBrowserSettingsColorManagement, + mGlobal, mExtensionBrowser, mModeSetting, + mUseNativeSRGBSetting, + mUseWebRenderCompositorSetting); + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(ExtensionBrowserSettingsColorManagement) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_WEBEXT_SETTING(ExtensionBrowserSettingsColorManagement, u"mode"_ns, + Mode); +NS_IMPL_WEBEXT_SETTING(ExtensionBrowserSettingsColorManagement, + u"useNativeSRGB"_ns, UseNativeSRGB); +NS_IMPL_WEBEXT_SETTING(ExtensionBrowserSettingsColorManagement, + u"useWebRenderCompositor"_ns, UseWebRenderCompositor); + +ExtensionBrowserSettingsColorManagement:: + ExtensionBrowserSettingsColorManagement(nsIGlobalObject* aGlobal, + ExtensionBrowser* aExtensionBrowser) + : mGlobal(aGlobal), mExtensionBrowser(aExtensionBrowser) { + MOZ_DIAGNOSTIC_ASSERT(mGlobal); + MOZ_DIAGNOSTIC_ASSERT(mExtensionBrowser); +} + +/* static */ +bool ExtensionBrowserSettingsColorManagement::IsAllowed(JSContext* aCx, + JSObject* aGlobal) { + return true; +} + +JSObject* ExtensionBrowserSettingsColorManagement::WrapObject( + JSContext* aCx, JS::Handle<JSObject*> aGivenProto) { + return dom::ExtensionBrowserSettingsColorManagement_Binding::Wrap( + aCx, this, aGivenProto); +} + +nsIGlobalObject* ExtensionBrowserSettingsColorManagement::GetParentObject() + const { + return mGlobal; +} + +} // namespace mozilla::extensions diff --git a/toolkit/components/extensions/webidl-api/ExtensionBrowserSettingsColorManagement.h b/toolkit/components/extensions/webidl-api/ExtensionBrowserSettingsColorManagement.h new file mode 100644 index 0000000000..021038cbe7 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionBrowserSettingsColorManagement.h @@ -0,0 +1,77 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_ExtensionBrowserSettingsColorManagement_h +#define mozilla_extensions_ExtensionBrowserSettingsColorManagement_h + +#include "js/TypeDecls.h" +#include "mozilla/Attributes.h" +#include "mozilla/dom/BindingDeclarations.h" +#include "nsCycleCollectionParticipant.h" +#include "nsCOMPtr.h" +#include "nsISupports.h" +#include "nsWrapperCache.h" + +#include "ExtensionAPIBase.h" +#include "ExtensionBrowser.h" +#include "ExtensionSetting.h" + +class nsIGlobalObject; + +namespace mozilla::extensions { + +class ExtensionEventManager; +class ExtensionSetting; + +class ExtensionBrowserSettingsColorManagement final + : public nsISupports, + public nsWrapperCache, + public ExtensionAPINamespace { + public: + ExtensionBrowserSettingsColorManagement(nsIGlobalObject* aGlobal, + ExtensionBrowser* aExtensionBrowser); + + // ExtensionAPIBase methods + nsIGlobalObject* GetGlobalObject() const override { return mGlobal; } + + ExtensionBrowser* GetExtensionBrowser() const override { + return mExtensionBrowser; + } + + nsString GetAPINamespace() const override { + return u"browserSettings.colorManagement"_ns; + } + + // nsWrapperCache interface methods + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + // DOM bindings methods + static bool IsAllowed(JSContext* aCx, JSObject* aGlobal); + + nsIGlobalObject* GetParentObject() const; + + ExtensionSetting* Mode(); + ExtensionSetting* UseNativeSRGB(); + ExtensionSetting* UseWebRenderCompositor(); + + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS( + ExtensionBrowserSettingsColorManagement) + + private: + ~ExtensionBrowserSettingsColorManagement() = default; + + nsCOMPtr<nsIGlobalObject> mGlobal; + RefPtr<ExtensionBrowser> mExtensionBrowser; + RefPtr<ExtensionSetting> mModeSetting; + RefPtr<ExtensionSetting> mUseNativeSRGBSetting; + RefPtr<ExtensionSetting> mUseWebRenderCompositorSetting; +}; + +} // namespace mozilla::extensions + +#endif // mozilla_extensions_ExtensionBrowserSettingsColorManagement_h diff --git a/toolkit/components/extensions/webidl-api/ExtensionDns.cpp b/toolkit/components/extensions/webidl-api/ExtensionDns.cpp new file mode 100644 index 0000000000..b38df15ce2 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionDns.cpp @@ -0,0 +1,40 @@ +/* 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 "ExtensionDns.h" +#include "ExtensionEventManager.h" + +#include "mozilla/dom/ExtensionDnsBinding.h" +#include "nsIGlobalObject.h" + +namespace mozilla::extensions { + +NS_IMPL_CYCLE_COLLECTING_ADDREF(ExtensionDns); +NS_IMPL_CYCLE_COLLECTING_RELEASE(ExtensionDns) +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(ExtensionDns, mGlobal, mExtensionBrowser); + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(ExtensionDns) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +ExtensionDns::ExtensionDns(nsIGlobalObject* aGlobal, + ExtensionBrowser* aExtensionBrowser) + : mGlobal(aGlobal), mExtensionBrowser(aExtensionBrowser) { + MOZ_DIAGNOSTIC_ASSERT(mGlobal); + MOZ_DIAGNOSTIC_ASSERT(mExtensionBrowser); +} + +/* static */ +bool ExtensionDns::IsAllowed(JSContext* aCx, JSObject* aGlobal) { return true; } + +JSObject* ExtensionDns::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return dom::ExtensionDns_Binding::Wrap(aCx, this, aGivenProto); +} + +nsIGlobalObject* ExtensionDns::GetParentObject() const { return mGlobal; } + +} // namespace mozilla::extensions diff --git a/toolkit/components/extensions/webidl-api/ExtensionDns.h b/toolkit/components/extensions/webidl-api/ExtensionDns.h new file mode 100644 index 0000000000..7359bbf10f --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionDns.h @@ -0,0 +1,63 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_ExtensionDns_h +#define mozilla_extensions_ExtensionDns_h + +#include "js/TypeDecls.h" +#include "mozilla/Attributes.h" +#include "mozilla/dom/BindingDeclarations.h" +#include "nsCycleCollectionParticipant.h" +#include "nsCOMPtr.h" +#include "nsISupports.h" +#include "nsWrapperCache.h" + +#include "ExtensionAPIBase.h" +#include "ExtensionBrowser.h" + +class nsIGlobalObject; + +namespace mozilla::extensions { + +class ExtensionEventManager; + +class ExtensionDns final : public nsISupports, + public nsWrapperCache, + public ExtensionAPINamespace { + public: + ExtensionDns(nsIGlobalObject* aGlobal, ExtensionBrowser* aExtensionBrowser); + + // ExtensionAPIBase methods + nsIGlobalObject* GetGlobalObject() const override { return mGlobal; } + + ExtensionBrowser* GetExtensionBrowser() const override { + return mExtensionBrowser; + } + + nsString GetAPINamespace() const override { return u"dns"_ns; } + + // nsWrapperCache interface methods + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + // DOM bindings methods + static bool IsAllowed(JSContext* aCx, JSObject* aGlobal); + + nsIGlobalObject* GetParentObject() const; + + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(ExtensionDns) + + private: + ~ExtensionDns() = default; + + nsCOMPtr<nsIGlobalObject> mGlobal; + RefPtr<ExtensionBrowser> mExtensionBrowser; +}; + +} // namespace mozilla::extensions + +#endif // mozilla_extensions_ExtensionDns_h diff --git a/toolkit/components/extensions/webidl-api/ExtensionEventListener.cpp b/toolkit/components/extensions/webidl-api/ExtensionEventListener.cpp new file mode 100644 index 0000000000..5ad9f2dfd8 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionEventListener.cpp @@ -0,0 +1,677 @@ +/* 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 "ExtensionEventListener.h" +#include "ExtensionAPIRequestForwarder.h" +#include "ExtensionPort.h" + +#include "mozilla/dom/ClonedErrorHolder.h" +#include "mozilla/dom/FunctionBinding.h" +#include "mozilla/dom/Promise.h" +#include "mozilla/dom/Promise-inl.h" +#include "nsThreadManager.h" // NS_IsMainThread + +namespace mozilla::extensions { + +namespace { + +class SendResponseCallback final : public nsISupports { + public: + NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(SendResponseCallback) + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + + static RefPtr<SendResponseCallback> Create( + nsIGlobalObject* aGlobalObject, const RefPtr<dom::Promise>& aPromise, + JS::Handle<JS::Value> aValue, ErrorResult& aRv) { + MOZ_ASSERT(dom::IsCurrentThreadRunningWorker()); + + RefPtr<SendResponseCallback> responseCallback = + new SendResponseCallback(aPromise, aValue); + + // Create a promise monitor that invalidates the sendResponse + // callback if the promise has been already resolved or rejected. + aPromise->AddCallbacksWithCycleCollectedArgs( + [](JSContext* aCx, JS::Handle<JS::Value> aValue, ErrorResult& aRv, + SendResponseCallback* aCallback) { aCallback->Cleanup(); }, + [](JSContext* aCx, JS::Handle<JS::Value> aValue, ErrorResult& aRv, + SendResponseCallback* aCallback) { aCallback->Cleanup(); }, + responseCallback); + + auto cleanupCb = [responseCallback]() { responseCallback->Cleanup(); }; + + // Create a StrongWorkerRef to the worker thread, the cleanup callback + // associated to the StrongWorkerRef will release the reference and resolve + // the promise returned to the ExtensionEventListener caller with undefined + // if the worker global is being destroyed. + auto* workerPrivate = dom::GetCurrentThreadWorkerPrivate(); + MOZ_ASSERT(workerPrivate); + workerPrivate->AssertIsOnWorkerThread(); + + RefPtr<dom::StrongWorkerRef> workerRef = dom::StrongWorkerRef::Create( + workerPrivate, "SendResponseCallback", cleanupCb); + if (NS_WARN_IF(!workerRef)) { + aRv.Throw(NS_ERROR_UNEXPECTED); + return nullptr; + } + + responseCallback->mWorkerRef = workerRef; + + return responseCallback; + } + + SendResponseCallback(const RefPtr<dom::Promise>& aPromise, + JS::Handle<JS::Value> aValue) + : mPromise(aPromise), mValue(aValue) { + MOZ_ASSERT(mPromise); + mozilla::HoldJSObjects(this); + } + + void Cleanup(bool aIsDestroying = false) { + // Return earlier if the instance has already been cleaned up. + if (!mPromise) { + return; + } + + NS_WARNING("SendResponseCallback::Cleanup"); + + mPromise->MaybeResolveWithUndefined(); + mPromise = nullptr; + + // Skipped if called from the destructor. + if (!aIsDestroying && mValue.isObject()) { + // Release the reference to the SendResponseCallback. + js::SetFunctionNativeReserved(&mValue.toObject(), + SLOT_SEND_RESPONSE_CALLBACK_INSTANCE, + JS::PrivateValue(nullptr)); + } + + if (mWorkerRef) { + mWorkerRef = nullptr; + } + } + + static bool Call(JSContext* aCx, unsigned aArgc, JS::Value* aVp) { + JS::CallArgs args = CallArgsFromVp(aArgc, aVp); + JS::Rooted<JSObject*> callee(aCx, &args.callee()); + + JS::Value v = js::GetFunctionNativeReserved( + callee, SLOT_SEND_RESPONSE_CALLBACK_INSTANCE); + + SendResponseCallback* sendResponse = + reinterpret_cast<SendResponseCallback*>(v.toPrivate()); + if (!sendResponse || !sendResponse->mPromise || + !sendResponse->mPromise->PromiseObj()) { + NS_WARNING("SendResponseCallback called after being invalidated"); + return true; + } + + sendResponse->mPromise->MaybeResolve(args.get(0)); + sendResponse->Cleanup(); + + return true; + } + + private: + ~SendResponseCallback() { + mozilla::DropJSObjects(this); + this->Cleanup(true); + }; + + RefPtr<dom::Promise> mPromise; + JS::Heap<JS::Value> mValue; + RefPtr<dom::StrongWorkerRef> mWorkerRef; +}; + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(SendResponseCallback) + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTION_CLASS(SendResponseCallback) + +NS_IMPL_CYCLE_COLLECTING_ADDREF(SendResponseCallback) +NS_IMPL_CYCLE_COLLECTING_RELEASE(SendResponseCallback) + +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(SendResponseCallback) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mPromise) +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_IMPL_CYCLE_COLLECTION_TRACE_BEGIN(SendResponseCallback) + NS_IMPL_CYCLE_COLLECTION_TRACE_JS_MEMBER_CALLBACK(mValue) +NS_IMPL_CYCLE_COLLECTION_TRACE_END + +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(SendResponseCallback) + tmp->mValue.setUndefined(); + // Resolve the promise with undefined (as "unhandled") before unlinking it. + if (tmp->mPromise) { + tmp->mPromise->MaybeResolveWithUndefined(); + } + NS_IMPL_CYCLE_COLLECTION_UNLINK(mPromise); +NS_IMPL_CYCLE_COLLECTION_UNLINK_END + +} // anonymous namespace + +// ExtensionEventListener + +NS_IMPL_ISUPPORTS(ExtensionEventListener, mozIExtensionEventListener) + +// static +already_AddRefed<ExtensionEventListener> ExtensionEventListener::Create( + nsIGlobalObject* aGlobal, ExtensionBrowser* aExtensionBrowser, + dom::Function* aCallback, CleanupCallback&& aCleanupCallback, + ErrorResult& aRv) { + MOZ_ASSERT(dom::IsCurrentThreadRunningWorker()); + RefPtr<ExtensionEventListener> extCb = + new ExtensionEventListener(aGlobal, aExtensionBrowser, aCallback); + + auto* workerPrivate = dom::GetCurrentThreadWorkerPrivate(); + MOZ_ASSERT(workerPrivate); + workerPrivate->AssertIsOnWorkerThread(); + RefPtr<dom::StrongWorkerRef> workerRef = dom::StrongWorkerRef::Create( + workerPrivate, "ExtensionEventListener", std::move(aCleanupCallback)); + if (!workerRef) { + aRv.Throw(NS_ERROR_UNEXPECTED); + return nullptr; + } + + extCb->mWorkerRef = new dom::ThreadSafeWorkerRef(workerRef); + + return extCb.forget(); +} + +// static +UniquePtr<dom::StructuredCloneHolder> +ExtensionEventListener::SerializeCallArguments(const nsTArray<JS::Value>& aArgs, + JSContext* aCx, + ErrorResult& aRv) { + JS::Rooted<JS::Value> jsval(aCx); + if (NS_WARN_IF(!dom::ToJSValue(aCx, aArgs, &jsval))) { + aRv.Throw(NS_ERROR_UNEXPECTED); + return nullptr; + } + + UniquePtr<dom::StructuredCloneHolder> argsHolder = + MakeUnique<dom::StructuredCloneHolder>( + dom::StructuredCloneHolder::CloningSupported, + dom::StructuredCloneHolder::TransferringNotSupported, + JS::StructuredCloneScope::SameProcess); + + argsHolder->Write(aCx, jsval, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + + return argsHolder; +} + +NS_IMETHODIMP ExtensionEventListener::CallListener( + const nsTArray<JS::Value>& aArgs, ListenerCallOptions* aCallOptions, + JSContext* aCx, dom::Promise** aPromiseResult) { + MOZ_ASSERT(NS_IsMainThread()); + NS_ENSURE_ARG_POINTER(aPromiseResult); + + // Process and validate call options. + APIObjectType apiObjectType = APIObjectType::NONE; + JS::Rooted<JS::Value> apiObjectDescriptor(aCx); + if (aCallOptions) { + aCallOptions->GetApiObjectType(&apiObjectType); + aCallOptions->GetApiObjectDescriptor(&apiObjectDescriptor); + + // Explicitly check that the APIObjectType is one of expected ones, + // raise to the caller an explicit error if it is not. + // + // This is using a switch to also get a warning if a new value is added to + // the APIObjectType enum and it is not yet handled. + switch (apiObjectType) { + case APIObjectType::NONE: + if (NS_WARN_IF(!apiObjectDescriptor.isNullOrUndefined())) { + JS_ReportErrorASCII( + aCx, + "Unexpected non-null apiObjectDescriptor on apiObjectType=NONE"); + return NS_ERROR_UNEXPECTED; + } + break; + case APIObjectType::RUNTIME_PORT: + if (NS_WARN_IF(apiObjectDescriptor.isNullOrUndefined())) { + JS_ReportErrorASCII(aCx, + "Unexpected null apiObjectDescriptor on " + "apiObjectType=RUNTIME_PORT"); + return NS_ERROR_UNEXPECTED; + } + break; + default: + MOZ_CRASH("Unexpected APIObjectType"); + return NS_ERROR_UNEXPECTED; + } + } + + // Create promise to be returned. + IgnoredErrorResult rv; + RefPtr<dom::Promise> retPromise; + + nsIGlobalObject* global = xpc::CurrentNativeGlobal(aCx); + if (NS_WARN_IF(!global)) { + return NS_ERROR_FAILURE; + } + retPromise = dom::Promise::Create(global, rv); + if (NS_WARN_IF(rv.Failed())) { + return rv.StealNSResult(); + } + + // Convert args into a non-const sequence. + dom::Sequence<JS::Value> args; + if (!args.AppendElements(aArgs, fallible)) { + return NS_ERROR_OUT_OF_MEMORY; + } + + // Execute the listener call. + + MutexAutoLock lock(mMutex); + + if (NS_WARN_IF(!mWorkerRef)) { + return NS_ERROR_ABORT; + } + + if (apiObjectType != APIObjectType::NONE) { + bool prependArgument = false; + aCallOptions->GetApiObjectPrepended(&prependArgument); + // Prepend or append the apiObjectDescriptor data to the call arguments, + // the worker runnable will convert that into an API object + // instance on the worker thread. + if (prependArgument) { + if (!args.InsertElementAt(0, std::move(apiObjectDescriptor), fallible)) { + return NS_ERROR_OUT_OF_MEMORY; + } + } else { + if (!args.AppendElement(std::move(apiObjectDescriptor), fallible)) { + return NS_ERROR_OUT_OF_MEMORY; + } + } + } + + UniquePtr<dom::StructuredCloneHolder> argsHolder = + SerializeCallArguments(args, aCx, rv); + if (NS_WARN_IF(rv.Failed())) { + return rv.StealNSResult(); + } + + RefPtr<ExtensionListenerCallWorkerRunnable> runnable = + new ExtensionListenerCallWorkerRunnable(this, std::move(argsHolder), + aCallOptions, retPromise); + runnable->Dispatch(); + retPromise.forget(aPromiseResult); + + return NS_OK; +} + +dom::WorkerPrivate* ExtensionEventListener::GetWorkerPrivate() const { + MOZ_ASSERT(mWorkerRef); + return mWorkerRef->Private(); +} + +// ExtensionListenerCallWorkerRunnable + +void ExtensionListenerCallWorkerRunnable::DeserializeCallArguments( + JSContext* aCx, dom::Sequence<JS::Value>& aArgs, ErrorResult& aRv) { + JS::Rooted<JS::Value> jsvalue(aCx); + + mArgsHolder->Read(xpc::CurrentNativeGlobal(aCx), aCx, &jsvalue, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return; + } + + nsresult rv2 = + ExtensionAPIRequestForwarder::JSArrayToSequence(aCx, jsvalue, aArgs); + if (NS_FAILED(rv2)) { + aRv.Throw(rv2); + } +} + +bool ExtensionListenerCallWorkerRunnable::WorkerRun( + JSContext* aCx, dom::WorkerPrivate* aWorkerPrivate) { + MOZ_ASSERT(aWorkerPrivate); + aWorkerPrivate->AssertIsOnWorkerThread(); + MOZ_ASSERT(aWorkerPrivate == mWorkerPrivate); + auto global = mListener->GetGlobalObject(); + if (NS_WARN_IF(!global)) { + return true; + } + + RefPtr<ExtensionBrowser> extensionBrowser = mListener->GetExtensionBrowser(); + if (NS_WARN_IF(!extensionBrowser)) { + return true; + } + + auto fn = mListener->GetCallback(); + if (NS_WARN_IF(!fn)) { + return true; + } + + IgnoredErrorResult rv; + dom::Sequence<JS::Value> argsSequence; + dom::SequenceRooter<JS::Value> arguments(aCx, &argsSequence); + + DeserializeCallArguments(aCx, argsSequence, rv); + if (NS_WARN_IF(rv.Failed())) { + return true; + } + + RefPtr<dom::Promise> retPromise; + RefPtr<dom::StrongWorkerRef> workerRef; + + retPromise = dom::Promise::Create(global, rv); + if (retPromise) { + workerRef = dom::StrongWorkerRef::Create( + aWorkerPrivate, "ExtensionListenerCallWorkerRunnable", []() {}); + } + + if (NS_WARN_IF(rv.Failed() || !workerRef)) { + auto rejectMainThreadPromise = + [error = rv.Failed() ? rv.StealNSResult() : NS_ERROR_UNEXPECTED, + promiseResult = std::move(mPromiseResult)]() { + // TODO(rpl): this seems to be currently rejecting an error object + // without a stack trace, its a corner case but we may look into + // improve this error. + promiseResult->MaybeReject(error); + }; + + nsCOMPtr<nsIRunnable> runnable = + NS_NewRunnableFunction(__func__, std::move(rejectMainThreadPromise)); + NS_DispatchToMainThread(runnable); + JS_ClearPendingException(aCx); + return true; + } + + ExtensionListenerCallPromiseResultHandler::Create( + retPromise, this, new dom::ThreadSafeWorkerRef(workerRef)); + + // Translate the first parameter into the API object type (e.g. an + // ExtensionPort), the content of the original argument value is expected to + // be a dictionary that is valid as an internal descriptor for that API object + // type. + if (mAPIObjectType != APIObjectType::NONE) { + IgnoredErrorResult rv; + + // The api object descriptor is expected to have been prepended to the + // other arguments, assert here that the argsSequence does contain at least + // one element. + MOZ_ASSERT(!argsSequence.IsEmpty()); + + uint32_t apiObjectIdx = mAPIObjectPrepended ? 0 : argsSequence.Length() - 1; + JS::Rooted<JS::Value> apiObjectDescriptor( + aCx, argsSequence.ElementAt(apiObjectIdx)); + JS::Rooted<JS::Value> apiObjectValue(aCx); + + // We only expect the object type to be RUNTIME_PORT at the moment, + // until we will need to expect it to support other object types that + // some specific API may need. + MOZ_ASSERT(mAPIObjectType == APIObjectType::RUNTIME_PORT); + RefPtr<ExtensionPort> port = + extensionBrowser->GetPort(apiObjectDescriptor, rv); + if (NS_WARN_IF(rv.Failed())) { + retPromise->MaybeReject(rv.StealNSResult()); + return true; + } + + if (NS_WARN_IF(!dom::ToJSValue(aCx, port, &apiObjectValue))) { + retPromise->MaybeReject(NS_ERROR_UNEXPECTED); + return true; + } + + argsSequence.ReplaceElementAt(apiObjectIdx, apiObjectValue); + } + + // Create callback argument and append it to the call arguments. + JS::Rooted<JSObject*> sendResponseObj(aCx); + + switch (mCallbackArgType) { + case CallbackType::CALLBACK_NONE: + break; + case CallbackType::CALLBACK_SEND_RESPONSE: { + JS::Rooted<JSFunction*> sendResponseFn( + aCx, js::NewFunctionWithReserved(aCx, SendResponseCallback::Call, + /* nargs */ 1, 0, "sendResponse")); + sendResponseObj = JS_GetFunctionObject(sendResponseFn); + JS::Rooted<JS::Value> sendResponseValue( + aCx, JS::ObjectValue(*sendResponseObj)); + + // Create a SendResponseCallback instance that keeps a reference + // to the promise to resolve when the static SendReponseCallback::Call + // is being called. + // the SendReponseCallback instance from the resolved slot to resolve + // the promise and invalidated the sendResponse callback (any new call + // becomes a noop). + RefPtr<SendResponseCallback> sendResponsePtr = + SendResponseCallback::Create(global, retPromise, sendResponseValue, + rv); + if (NS_WARN_IF(rv.Failed())) { + retPromise->MaybeReject(NS_ERROR_UNEXPECTED); + return true; + } + + // Store the SendResponseCallback instance in a private value set on the + // function object reserved slot, where ehe SendResponseCallback::Call + // static function will get it back to resolve the related promise + // and then invalidate the sendResponse callback (any new call + // becomes a noop). + js::SetFunctionNativeReserved(sendResponseObj, + SLOT_SEND_RESPONSE_CALLBACK_INSTANCE, + JS::PrivateValue(sendResponsePtr)); + + if (NS_WARN_IF( + !argsSequence.AppendElement(sendResponseValue, fallible))) { + retPromise->MaybeReject(NS_ERROR_OUT_OF_MEMORY); + return true; + } + + break; + } + default: + MOZ_ASSERT_UNREACHABLE("Unexpected callbackType"); + break; + } + + // TODO: should `nsAutoMicroTask mt;` be used here? + dom::AutoEntryScript aes(global, "WebExtensionAPIEvent"); + JS::Rooted<JS::Value> retval(aCx); + ErrorResult erv; + erv.MightThrowJSException(); + MOZ_KnownLive(fn)->Call(argsSequence, &retval, erv, "WebExtensionAPIEvent", + dom::Function::eRethrowExceptions); + + // Calling the callback may have thrown an exception. + // TODO: add a ListenerCallOptions to optionally report the exception + // instead of forwarding it to the caller. + erv.WouldReportJSException(); + + if (erv.Failed()) { + if (erv.IsUncatchableException()) { + // TODO: include some more info? (e.g. api path). + retPromise->MaybeRejectWithTimeoutError( + "WebExtensions API Event listener threw uncatchable exception"); + return true; + } + + retPromise->MaybeReject(std::move(erv)); + return true; + } + + // Custom return value handling logic for events that do pass a + // sendResponse callback parameter (see expected behavior + // for the runtime.onMessage sendResponse parameter on MDN: + // https://mzl.la/3dokpMi): + // + // - listener returns Boolean true => the extension listener is + // expected to call sendResponse callback parameter asynchronosuly + // - listener return a Promise object => the promise is the listener + // response + // - listener return any other value => the listener didn't handle the + // event and the return value is ignored + // + if (mCallbackArgType == CallbackType::CALLBACK_SEND_RESPONSE) { + if (retval.isBoolean() && retval.isTrue()) { + // The listener returned `true` and so the promise relate to the + // listener call will be resolved once the extension will call + // the sendResponce function passed as a callback argument. + return true; + } + + // If the retval isn't true and it is not a Promise object, + // the listener isn't handling the event, and we resolve the + // promise with undefined (if the listener didn't reply already + // by calling sendResponse synchronsouly). + // undefined ( + if (!ExtensionEventListener::IsPromise(aCx, retval)) { + // Mark this listener call as cancelled, + // ExtensionListenerCallPromiseResult will check to know that it should + // release the main thread promise without resolving it. + // + // TODO: double-check if we should also cancel rejecting the promise + // returned by mozIExtensionEventListener.callListener when the listener + // call throws (by comparing it with the behavior on the current + // privileged-based API implementation). + mIsCallResultCancelled = true; + retPromise->MaybeResolveWithUndefined(); + + // Invalidate the sendResponse function by setting the private + // value where the SendResponseCallback instance was stored + // to a nullptr. + js::SetFunctionNativeReserved(sendResponseObj, + SLOT_SEND_RESPONSE_CALLBACK_INSTANCE, + JS::PrivateValue(nullptr)); + + return true; + } + } + + retPromise->MaybeResolve(retval); + + return true; +} + +// ExtensionListenerCallPromiseResultHandler + +NS_IMPL_ISUPPORTS0(ExtensionListenerCallPromiseResultHandler) + +// static +void ExtensionListenerCallPromiseResultHandler::Create( + const RefPtr<dom::Promise>& aPromise, + const RefPtr<ExtensionListenerCallWorkerRunnable>& aWorkerRunnable, + dom::ThreadSafeWorkerRef* aWorkerRef) { + MOZ_ASSERT(aPromise); + MOZ_ASSERT(aWorkerRef); + MOZ_ASSERT(aWorkerRef->Private()->IsOnCurrentThread()); + + RefPtr<ExtensionListenerCallPromiseResultHandler> handler = + new ExtensionListenerCallPromiseResultHandler(aWorkerRef, + aWorkerRunnable); + aPromise->AppendNativeHandler(handler); +} + +void ExtensionListenerCallPromiseResultHandler::WorkerRunCallback( + JSContext* aCx, JS::Handle<JS::Value> aValue, + PromiseCallbackType aCallbackType) { + MOZ_ASSERT(mWorkerRef); + mWorkerRef->Private()->AssertIsOnWorkerThread(); + + // The listener call was cancelled (e.g. when a runtime.onMessage listener + // returned false), release resources associated with this promise handler + // on the main thread without resolving the promise associated to the + // extension event listener call. + if (mWorkerRunnable->IsCallResultCancelled()) { + auto releaseMainThreadPromise = [runnable = std::move(mWorkerRunnable), + workerRef = std::move(mWorkerRef)]() {}; + nsCOMPtr<nsIRunnable> runnable = + NS_NewRunnableFunction(__func__, std::move(releaseMainThreadPromise)); + NS_DispatchToMainThread(runnable); + return; + } + + JS::Rooted<JS::Value> retval(aCx, aValue); + + if (retval.isObject()) { + // Try to serialize the result as an ClonedErrorHolder, + // in case the value is an Error object. + IgnoredErrorResult rv; + JS::Rooted<JSObject*> errObj(aCx, &retval.toObject()); + UniquePtr<dom::ClonedErrorHolder> ceh = + dom::ClonedErrorHolder::Create(aCx, errObj, rv); + if (!rv.Failed() && ceh) { + Unused << NS_WARN_IF(!ToJSValue(aCx, std::move(ceh), &retval)); + } + } + + UniquePtr<dom::StructuredCloneHolder> resHolder = + MakeUnique<dom::StructuredCloneHolder>( + dom::StructuredCloneHolder::CloningSupported, + dom::StructuredCloneHolder::TransferringNotSupported, + JS::StructuredCloneScope::SameProcess); + + IgnoredErrorResult erv; + resHolder->Write(aCx, retval, erv); + + // Failed to serialize the result, dispatch a runnable to reject + // the promise returned to the caller of the mozIExtensionCallback + // callWithPromiseResult method. + if (NS_WARN_IF(erv.Failed())) { + auto rejectMainThreadPromise = [error = erv.StealNSResult(), + runnable = std::move(mWorkerRunnable), + resHolder = std::move(resHolder)]() { + RefPtr<dom::Promise> promiseResult = std::move(runnable->mPromiseResult); + promiseResult->MaybeReject(error); + }; + + nsCOMPtr<nsIRunnable> runnable = + NS_NewRunnableFunction(__func__, std::move(rejectMainThreadPromise)); + NS_DispatchToMainThread(runnable); + JS_ClearPendingException(aCx); + return; + } + + auto resolveMainThreadPromise = [callbackType = aCallbackType, + resHolder = std::move(resHolder), + runnable = std::move(mWorkerRunnable), + workerRef = std::move(mWorkerRef)]() { + RefPtr<dom::Promise> promiseResult = std::move(runnable->mPromiseResult); + + auto* global = promiseResult->GetGlobalObject(); + dom::AutoEntryScript aes(global, + "ExtensionListenerCallWorkerRunnable::WorkerRun"); + JSContext* cx = aes.cx(); + JS::Rooted<JS::Value> jsvalue(cx); + IgnoredErrorResult rv; + + resHolder->Read(global, cx, &jsvalue, rv); + + if (NS_WARN_IF(rv.Failed())) { + promiseResult->MaybeReject(rv.StealNSResult()); + JS_ClearPendingException(cx); + } else { + switch (callbackType) { + case PromiseCallbackType::Resolve: + promiseResult->MaybeResolve(jsvalue); + break; + case PromiseCallbackType::Reject: + promiseResult->MaybeReject(jsvalue); + break; + } + } + }; + + nsCOMPtr<nsIRunnable> runnable = + NS_NewRunnableFunction(__func__, std::move(resolveMainThreadPromise)); + NS_DispatchToMainThread(runnable); +} + +void ExtensionListenerCallPromiseResultHandler::ResolvedCallback( + JSContext* aCx, JS::Handle<JS::Value> aValue, ErrorResult& aRv) { + WorkerRunCallback(aCx, aValue, PromiseCallbackType::Resolve); +} + +void ExtensionListenerCallPromiseResultHandler::RejectedCallback( + JSContext* aCx, JS::Handle<JS::Value> aValue, ErrorResult& aRv) { + WorkerRunCallback(aCx, aValue, PromiseCallbackType::Reject); +} + +} // namespace mozilla::extensions diff --git a/toolkit/components/extensions/webidl-api/ExtensionEventListener.h b/toolkit/components/extensions/webidl-api/ExtensionEventListener.h new file mode 100644 index 0000000000..e986e4f58b --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionEventListener.h @@ -0,0 +1,234 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_ExtensionEventListener_h +#define mozilla_extensions_ExtensionEventListener_h + +#include "js/Promise.h" // JS::IsPromiseObject +#include "mozIExtensionAPIRequestHandling.h" +#include "mozilla/dom/PromiseNativeHandler.h" +#include "mozilla/dom/StructuredCloneHolder.h" +#include "mozilla/dom/WorkerRunnable.h" +#include "mozilla/dom/WorkerPrivate.h" + +#include "ExtensionBrowser.h" + +class nsIGlobalObject; + +namespace mozilla { + +namespace dom { +class Function; +} // namespace dom + +namespace extensions { + +#define SLOT_SEND_RESPONSE_CALLBACK_INSTANCE 0 + +// A class that represents a callback parameter passed to WebExtensions API +// addListener / removeListener methods. +// +// Instances of this class are sent to the mozIExtensionAPIRequestHandler as +// a property of the mozIExtensionAPIRequest. +// +// The mozIExtensionEventListener xpcom interface provides methods that allow +// the mozIExtensionAPIRequestHandler running in the Main Thread to call the +// underlying callback Function on its owning thread. +class ExtensionEventListener final : public mozIExtensionEventListener { + public: + NS_DECL_MOZIEXTENSIONEVENTLISTENER + NS_DECL_THREADSAFE_ISUPPORTS + + using CleanupCallback = std::function<void()>; + using ListenerCallOptions = mozIExtensionListenerCallOptions; + using APIObjectType = ListenerCallOptions::APIObjectType; + using CallbackType = ListenerCallOptions::CallbackType; + + static already_AddRefed<ExtensionEventListener> Create( + nsIGlobalObject* aGlobal, ExtensionBrowser* aExtensionBrowser, + dom::Function* aCallback, CleanupCallback&& aCleanupCallback, + ErrorResult& aRv); + + static bool IsPromise(JSContext* aCx, JS::Handle<JS::Value> aValue) { + if (!aValue.isObject()) { + return false; + } + + JS::Rooted<JSObject*> obj(aCx, &aValue.toObject()); + return JS::IsPromiseObject(obj); + } + + dom::WorkerPrivate* GetWorkerPrivate() const; + + RefPtr<dom::Function> GetCallback() const { return mCallback; } + + nsCOMPtr<nsIGlobalObject> GetGlobalObject() const { + nsCOMPtr<nsIGlobalObject> global = do_QueryReferent(mGlobal); + return global; + } + + ExtensionBrowser* GetExtensionBrowser() const { return mExtensionBrowser; } + + void Cleanup() { + if (mWorkerRef) { + MutexAutoLock lock(mMutex); + + mWorkerRef->Private()->AssertIsOnWorkerThread(); + mWorkerRef = nullptr; + } + + mGlobal = nullptr; + mCallback = nullptr; + mExtensionBrowser = nullptr; + } + + private: + ExtensionEventListener(nsIGlobalObject* aGlobal, + ExtensionBrowser* aExtensionBrowser, + dom::Function* aCallback) + : mGlobal(do_GetWeakReference(aGlobal)), + mExtensionBrowser(aExtensionBrowser), + mCallback(aCallback), + mMutex("ExtensionEventListener::mMutex") { + MOZ_ASSERT(aGlobal); + MOZ_ASSERT(aExtensionBrowser); + MOZ_ASSERT(aCallback); + }; + + static UniquePtr<dom::StructuredCloneHolder> SerializeCallArguments( + const nsTArray<JS::Value>& aArgs, JSContext* aCx, ErrorResult& aRv); + + ~ExtensionEventListener() { Cleanup(); }; + + // Accessed on the main and on the owning threads. + RefPtr<dom::ThreadSafeWorkerRef> mWorkerRef; + + // Accessed only on the owning thread. + nsWeakPtr mGlobal; + RefPtr<ExtensionBrowser> mExtensionBrowser; + RefPtr<dom::Function> mCallback; + + // Used to make sure we are not going to release the + // instance on the worker thread, while we are in the + // process of forwarding a call from the main thread. + Mutex mMutex MOZ_UNANNOTATED; +}; + +// A WorkerRunnable subclass used to call an ExtensionEventListener +// in the thread that owns the dom::Function wrapped by the +// ExtensionEventListener class. +class ExtensionListenerCallWorkerRunnable final : public dom::WorkerRunnable { + friend class ExtensionListenerCallPromiseResultHandler; + + public: + using ListenerCallOptions = mozIExtensionListenerCallOptions; + using APIObjectType = ListenerCallOptions::APIObjectType; + using CallbackType = ListenerCallOptions::CallbackType; + + ExtensionListenerCallWorkerRunnable( + const RefPtr<ExtensionEventListener>& aExtensionEventListener, + UniquePtr<dom::StructuredCloneHolder> aArgsHolder, + ListenerCallOptions* aCallOptions, + RefPtr<dom::Promise> aPromiseRetval = nullptr) + : WorkerRunnable(aExtensionEventListener->GetWorkerPrivate(), + "ExtensionListenerCallWorkerRunnable", WorkerThread), + mListener(aExtensionEventListener), + mArgsHolder(std::move(aArgsHolder)), + mPromiseResult(std::move(aPromiseRetval)), + mAPIObjectType(APIObjectType::NONE), + mCallbackArgType(CallbackType::CALLBACK_NONE) { + MOZ_ASSERT(NS_IsMainThread()); + MOZ_ASSERT(aExtensionEventListener); + + if (aCallOptions) { + aCallOptions->GetApiObjectType(&mAPIObjectType); + aCallOptions->GetApiObjectPrepended(&mAPIObjectPrepended); + aCallOptions->GetCallbackType(&mCallbackArgType); + } + } + + MOZ_CAN_RUN_SCRIPT_BOUNDARY + bool WorkerRun(JSContext* aCx, dom::WorkerPrivate* aWorkerPrivate) override; + + bool IsCallResultCancelled() { return mIsCallResultCancelled; } + + private: + ~ExtensionListenerCallWorkerRunnable() { + NS_ReleaseOnMainThread("~ExtensionListenerCallWorkerRunnable", + mPromiseResult.forget()); + ReleaseArgsHolder(); + mListener = nullptr; + } + + void ReleaseArgsHolder() { + if (NS_IsMainThread()) { + mArgsHolder = nullptr; + } else { + auto releaseArgsHolder = [argsHolder = std::move(mArgsHolder)]() {}; + nsCOMPtr<nsIRunnable> runnable = + NS_NewRunnableFunction(__func__, std::move(releaseArgsHolder)); + NS_DispatchToMainThread(runnable); + } + } + + void DeserializeCallArguments(JSContext* aCx, dom::Sequence<JS::Value>& aArg, + ErrorResult& aRv); + + RefPtr<ExtensionEventListener> mListener; + UniquePtr<dom::StructuredCloneHolder> mArgsHolder; + RefPtr<dom::Promise> mPromiseResult; + bool mIsCallResultCancelled = false; + // Call Options. + bool mAPIObjectPrepended; + APIObjectType mAPIObjectType; + CallbackType mCallbackArgType; +}; + +// A class attached to the promise that should be resolved once the extension +// event listener call has been handled, responsible for serializing resolved +// values or rejected errors on the listener's owning thread and sending them to +// the extension event listener caller running on the main thread. +class ExtensionListenerCallPromiseResultHandler + : public dom::PromiseNativeHandler { + public: + NS_DECL_THREADSAFE_ISUPPORTS + + static void Create( + const RefPtr<dom::Promise>& aPromise, + const RefPtr<ExtensionListenerCallWorkerRunnable>& aWorkerRunnable, + dom::ThreadSafeWorkerRef* aWorkerRef); + + // PromiseNativeHandler + void ResolvedCallback(JSContext* aCx, JS::Handle<JS::Value> aValue, + ErrorResult& aRv) override; + void RejectedCallback(JSContext* aCx, JS::Handle<JS::Value> aValue, + ErrorResult& aRv) override; + + enum class PromiseCallbackType { Resolve, Reject }; + + private: + ExtensionListenerCallPromiseResultHandler( + dom::ThreadSafeWorkerRef* aWorkerRef, + RefPtr<ExtensionListenerCallWorkerRunnable> aWorkerRunnable) + : mWorkerRef(aWorkerRef), mWorkerRunnable(std::move(aWorkerRunnable)) {} + + ~ExtensionListenerCallPromiseResultHandler() = default; + + void WorkerRunCallback(JSContext* aCx, JS::Handle<JS::Value> aValue, + PromiseCallbackType aCallbackType); + + // Set and accessed only on the owning worker thread. + RefPtr<dom::ThreadSafeWorkerRef> mWorkerRef; + + // Reference to the runnable created on and owned by the main thread, + // accessed on the worker thread and released on the owning thread. + RefPtr<ExtensionListenerCallWorkerRunnable> mWorkerRunnable; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_ExtensionEventListener_h diff --git a/toolkit/components/extensions/webidl-api/ExtensionEventManager.cpp b/toolkit/components/extensions/webidl-api/ExtensionEventManager.cpp new file mode 100644 index 0000000000..f2bb437add --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionEventManager.cpp @@ -0,0 +1,167 @@ +/* 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 "ExtensionEventManager.h" + +#include "ExtensionAPIAddRemoveListener.h" + +#include "mozilla/dom/ExtensionEventManagerBinding.h" +#include "nsIGlobalObject.h" +#include "ExtensionEventListener.h" + +namespace mozilla { +namespace extensions { + +NS_IMPL_CYCLE_COLLECTION_CLASS(ExtensionEventManager); +NS_IMPL_CYCLE_COLLECTING_ADDREF(ExtensionEventManager); +NS_IMPL_CYCLE_COLLECTING_RELEASE(ExtensionEventManager) + +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(ExtensionEventManager) + tmp->mListeners.clear(); + NS_IMPL_CYCLE_COLLECTION_UNLINK(mGlobal) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mExtensionBrowser) + NS_IMPL_CYCLE_COLLECTION_UNLINK_PRESERVED_WRAPPER +NS_IMPL_CYCLE_COLLECTION_UNLINK_END + +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(ExtensionEventManager) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mGlobal) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mExtensionBrowser) +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_IMPL_CYCLE_COLLECTION_TRACE_BEGIN(ExtensionEventManager) + for (auto iter = tmp->mListeners.iter(); !iter.done(); iter.next()) { + aCallbacks.Trace(&iter.get().mutableKey(), "mListeners key", aClosure); + } + NS_IMPL_CYCLE_COLLECTION_TRACE_PRESERVED_WRAPPER +NS_IMPL_CYCLE_COLLECTION_TRACE_END + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(ExtensionEventManager) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +ExtensionEventManager::ExtensionEventManager( + nsIGlobalObject* aGlobal, ExtensionBrowser* aExtensionBrowser, + const nsAString& aNamespace, const nsAString& aEventName, + const nsAString& aObjectType, const nsAString& aObjectId) + : mGlobal(aGlobal), + mExtensionBrowser(aExtensionBrowser), + mAPINamespace(aNamespace), + mEventName(aEventName), + mAPIObjectType(aObjectType), + mAPIObjectId(aObjectId) { + MOZ_DIAGNOSTIC_ASSERT(mGlobal); + MOZ_DIAGNOSTIC_ASSERT(mExtensionBrowser); + + mozilla::HoldJSObjects(this); +} + +ExtensionEventManager::~ExtensionEventManager() { + ReleaseListeners(); + mozilla::DropJSObjects(this); +}; + +void ExtensionEventManager::ReleaseListeners() { + if (mListeners.empty()) { + return; + } + + for (auto iter = mListeners.iter(); !iter.done(); iter.next()) { + iter.get().value()->Cleanup(); + } + + mListeners.clear(); +} + +JSObject* ExtensionEventManager::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return dom::ExtensionEventManager_Binding::Wrap(aCx, this, aGivenProto); +} + +nsIGlobalObject* ExtensionEventManager::GetParentObject() const { + return mGlobal; +} + +void ExtensionEventManager::AddListener( + JSContext* aCx, dom::Function& aCallback, + const dom::Optional<JS::Handle<JSObject*>>& aOptions, ErrorResult& aRv) { + JS::Rooted<JSObject*> cb(aCx, aCallback.CallbackOrNull()); + if (cb == nullptr) { + ThrowUnexpectedError(aCx, aRv); + return; + } + + RefPtr<ExtensionEventManager> self = this; + + IgnoredErrorResult rv; + RefPtr<ExtensionEventListener> wrappedCb = ExtensionEventListener::Create( + mGlobal, mExtensionBrowser, &aCallback, + [self = std::move(self)]() { self->ReleaseListeners(); }, rv); + + if (NS_WARN_IF(rv.Failed())) { + ThrowUnexpectedError(aCx, aRv); + return; + } + + RefPtr<ExtensionEventListener> storedWrapper = wrappedCb; + if (!mListeners.put(cb, std::move(storedWrapper))) { + ThrowUnexpectedError(aCx, aRv); + return; + } + + auto request = SendAddListener(mEventName); + request->Run(mGlobal, aCx, {}, wrappedCb, aRv); + + if (!aRv.Failed() && mAPIObjectType.IsEmpty()) { + mExtensionBrowser->TrackWakeupEventListener(aCx, mAPINamespace, mEventName); + } +} + +void ExtensionEventManager::RemoveListener(dom::Function& aCallback, + ErrorResult& aRv) { + dom::AutoJSAPI jsapi; + if (NS_WARN_IF(!jsapi.Init(mGlobal))) { + aRv.Throw(NS_ERROR_UNEXPECTED); + return; + } + + JSContext* cx = jsapi.cx(); + JS::Rooted<JSObject*> cb(cx, aCallback.CallbackOrNull()); + const auto& ptr = mListeners.lookup(cb); + + // Return earlier if the listener wasn't found + if (!ptr) { + return; + } + + RefPtr<ExtensionEventListener> wrappedCb = ptr->value(); + auto request = SendRemoveListener(mEventName); + request->Run(mGlobal, cx, {}, wrappedCb, aRv); + + if (NS_WARN_IF(aRv.Failed())) { + return; + } + + if (mAPIObjectType.IsEmpty()) { + mExtensionBrowser->UntrackWakeupEventListener(cx, mAPINamespace, + mEventName); + } + + mListeners.remove(cb); + + wrappedCb->Cleanup(); +} + +bool ExtensionEventManager::HasListener(dom::Function& aCallback, + ErrorResult& aRv) const { + return mListeners.has(aCallback.CallbackOrNull()); +} + +bool ExtensionEventManager::HasListeners(ErrorResult& aRv) const { + return !mListeners.empty(); +} + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/webidl-api/ExtensionEventManager.h b/toolkit/components/extensions/webidl-api/ExtensionEventManager.h new file mode 100644 index 0000000000..eff3b3e045 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionEventManager.h @@ -0,0 +1,99 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_ExtensionEventManager_h +#define mozilla_extensions_ExtensionEventManager_h + +#include "js/GCHashTable.h" // for JS::GCHashMap +#include "js/TypeDecls.h" // for JS::Handle, JSContext, JSObject, ... +#include "mozilla/Attributes.h" +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/ErrorResult.h" +#include "nsCycleCollectionParticipant.h" +#include "nsCOMPtr.h" +#include "nsISupports.h" +#include "nsPointerHashKeys.h" +#include "nsRefPtrHashtable.h" +#include "nsWrapperCache.h" + +#include "ExtensionAPIBase.h" + +class nsIGlobalObject; + +namespace mozilla { + +namespace dom { +class Function; +} // namespace dom + +namespace extensions { + +class ExtensionBrowser; +class ExtensionEventListener; + +class ExtensionEventManager final : public nsISupports, + public nsWrapperCache, + public ExtensionAPIBase { + public: + ExtensionEventManager(nsIGlobalObject* aGlobal, + ExtensionBrowser* aExtensionBrowser, + const nsAString& aNamespace, + const nsAString& aEventName, + const nsAString& aObjectType = VoidString(), + const nsAString& aObjectId = VoidString()); + + // nsWrapperCache interface methods + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + nsIGlobalObject* GetParentObject() const; + + bool HasListener(dom::Function& aCallback, ErrorResult& aRv) const; + bool HasListeners(ErrorResult& aRv) const; + + void AddListener(JSContext* aCx, dom::Function& aCallback, + const dom::Optional<JS::Handle<JSObject*>>& aOptions, + ErrorResult& aRv); + void RemoveListener(dom::Function& aCallback, ErrorResult& aRv); + + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(ExtensionEventManager) + + protected: + // ExtensionAPIBase methods + nsIGlobalObject* GetGlobalObject() const override { return mGlobal; } + ExtensionBrowser* GetExtensionBrowser() const override { + return mExtensionBrowser; + } + + nsString GetAPINamespace() const override { return mAPINamespace; } + + nsString GetAPIObjectType() const override { return mAPIObjectType; } + + nsString GetAPIObjectId() const override { return mAPIObjectId; } + + private: + using ListenerWrappersMap = + JS::GCHashMap<JS::Heap<JSObject*>, RefPtr<ExtensionEventListener>, + js::StableCellHasher<JS::Heap<JSObject*>>, + js::SystemAllocPolicy>; + + ~ExtensionEventManager(); + + void ReleaseListeners(); + + nsCOMPtr<nsIGlobalObject> mGlobal; + RefPtr<ExtensionBrowser> mExtensionBrowser; + nsString mAPINamespace; + nsString mEventName; + nsString mAPIObjectType; + nsString mAPIObjectId; + ListenerWrappersMap mListeners; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_ExtensionEventManager_h diff --git a/toolkit/components/extensions/webidl-api/ExtensionMockAPI.cpp b/toolkit/components/extensions/webidl-api/ExtensionMockAPI.cpp new file mode 100644 index 0000000000..c89ca3a11f --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionMockAPI.cpp @@ -0,0 +1,59 @@ +/* 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 "ExtensionMockAPI.h" +#include "ExtensionEventManager.h" + +#include "mozilla/dom/ExtensionMockAPIBinding.h" +#include "mozilla/extensions/ExtensionPort.h" +#include "nsIGlobalObject.h" + +namespace mozilla { +namespace extensions { + +NS_IMPL_CYCLE_COLLECTING_ADDREF(ExtensionMockAPI); +NS_IMPL_CYCLE_COLLECTING_RELEASE(ExtensionMockAPI) +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(ExtensionMockAPI, mGlobal, + mExtensionBrowser, mOnTestEventMgr); + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(ExtensionMockAPI) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_WEBEXT_EVENTMGR_WITH_DATAMEMBER(ExtensionMockAPI, u"onTestEvent"_ns, + OnTestEvent, mOnTestEventMgr) + +ExtensionMockAPI::ExtensionMockAPI(nsIGlobalObject* aGlobal, + ExtensionBrowser* aExtensionBrowser) + : mGlobal(aGlobal), mExtensionBrowser(aExtensionBrowser) { + MOZ_DIAGNOSTIC_ASSERT(mGlobal); + MOZ_DIAGNOSTIC_ASSERT(mExtensionBrowser); +} + +/* static */ +bool ExtensionMockAPI::IsAllowed(JSContext* aCx, JSObject* aGlobal) { + return true; +} + +JSObject* ExtensionMockAPI::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return dom::ExtensionMockAPI_Binding::Wrap(aCx, this, aGivenProto); +} + +nsIGlobalObject* ExtensionMockAPI::GetParentObject() const { return mGlobal; } + +void ExtensionMockAPI::GetPropertyAsErrorObject( + JSContext* aCx, JS::MutableHandle<JS::Value> aRetval) { + ExtensionAPIBase::GetWebExtPropertyAsJSValue(aCx, u"propertyAsErrorObject"_ns, + aRetval); +} + +void ExtensionMockAPI::GetPropertyAsString(DOMString& aRetval) { + GetWebExtPropertyAsString(u"getPropertyAsString"_ns, aRetval); +} + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/webidl-api/ExtensionMockAPI.h b/toolkit/components/extensions/webidl-api/ExtensionMockAPI.h new file mode 100644 index 0000000000..cc56bfa29e --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionMockAPI.h @@ -0,0 +1,78 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_ExtensionMockAPI_h +#define mozilla_extensions_ExtensionMockAPI_h + +#include "js/TypeDecls.h" +#include "mozilla/Attributes.h" +#include "mozilla/dom/BindingDeclarations.h" +#include "nsCycleCollectionParticipant.h" +#include "nsCOMPtr.h" +#include "nsISupports.h" +#include "nsWrapperCache.h" + +#include "ExtensionAPIBase.h" +#include "ExtensionBrowser.h" + +class nsIGlobalObject; + +namespace mozilla { + +namespace extensions { + +using dom::DOMString; + +class ExtensionEventManager; +class ExtensionPort; + +class ExtensionMockAPI final : public nsISupports, + public nsWrapperCache, + public ExtensionAPINamespace { + public: + ExtensionMockAPI(nsIGlobalObject* aGlobal, + ExtensionBrowser* aExtensionBrowser); + + // nsWrapperCache interface methods + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + // DOM bindings methods + static bool IsAllowed(JSContext* aCx, JSObject* aGlobal); + + nsIGlobalObject* GetParentObject() const; + + void GetPropertyAsErrorObject(JSContext* aCx, + JS::MutableHandle<JS::Value> aRetval); + void GetPropertyAsString(DOMString& aRetval); + + ExtensionEventManager* OnTestEvent(); + + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(ExtensionMockAPI) + + protected: + // ExtensionAPIBase methods + nsIGlobalObject* GetGlobalObject() const override { return mGlobal; } + + ExtensionBrowser* GetExtensionBrowser() const override { + return mExtensionBrowser; + } + + nsString GetAPINamespace() const override { return u"mockExtensionAPI"_ns; } + + private: + ~ExtensionMockAPI() = default; + + nsCOMPtr<nsIGlobalObject> mGlobal; + RefPtr<ExtensionBrowser> mExtensionBrowser; + RefPtr<ExtensionEventManager> mOnTestEventMgr; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_ExtensionMockAPI_h diff --git a/toolkit/components/extensions/webidl-api/ExtensionPort.cpp b/toolkit/components/extensions/webidl-api/ExtensionPort.cpp new file mode 100644 index 0000000000..2329dfdfe2 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionPort.cpp @@ -0,0 +1,109 @@ +/* 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 "ExtensionPort.h" +#include "ExtensionBrowser.h" +#include "ExtensionEventManager.h" + +#include "mozilla/dom/BindingUtils.h" // SequenceRooter +#include "mozilla/dom/ExtensionPortBinding.h" +#include "mozilla/dom/ScriptSettings.h" // AutoEntryScript +#include "nsIGlobalObject.h" + +namespace mozilla { +namespace extensions { + +NS_IMPL_CYCLE_COLLECTING_ADDREF(ExtensionPort); +NS_IMPL_CYCLE_COLLECTING_RELEASE(ExtensionPort) + +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(ExtensionPort) + +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(ExtensionPort) + // Clean the entry for this instance from the ports lookup map + // stored in the related ExtensionBrowser instance. + tmp->ForgetReleasedPort(); + NS_IMPL_CYCLE_COLLECTION_UNLINK(mExtensionBrowser, mOnDisconnectEventMgr, + mOnMessageEventMgr) + NS_IMPL_CYCLE_COLLECTION_UNLINK_PRESERVED_WRAPPER + NS_IMPL_CYCLE_COLLECTION_UNLINK_WEAK_PTR +NS_IMPL_CYCLE_COLLECTION_UNLINK_END + +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(ExtensionPort) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mExtensionBrowser, mOnDisconnectEventMgr, + mOnMessageEventMgr) +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(ExtensionPort) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_WEBEXT_EVENTMGR(ExtensionPort, u"onMessage"_ns, OnMessage) +NS_IMPL_WEBEXT_EVENTMGR(ExtensionPort, u"onDisconnect"_ns, OnDisconnect) + +// static +already_AddRefed<ExtensionPort> ExtensionPort::Create( + nsIGlobalObject* aGlobal, ExtensionBrowser* aExtensionBrowser, + UniquePtr<dom::ExtensionPortDescriptor>&& aPortDescriptor) { + RefPtr<ExtensionPort> port = + new ExtensionPort(aGlobal, aExtensionBrowser, std::move(aPortDescriptor)); + return port.forget(); +} + +// static +UniquePtr<dom::ExtensionPortDescriptor> ExtensionPort::ToPortDescriptor( + JS::Handle<JS::Value> aDescriptorValue, ErrorResult& aRv) { + if (!aDescriptorValue.isObject()) { + aRv.Throw(NS_ERROR_UNEXPECTED); + return nullptr; + } + + dom::AutoEntryScript aes(&aDescriptorValue.toObject(), __func__); + JSContext* acx = aes.cx(); + auto portDescriptor = MakeUnique<dom::ExtensionPortDescriptor>(); + if (!portDescriptor->Init(acx, aDescriptorValue, __func__)) { + aRv.Throw(NS_ERROR_UNEXPECTED); + return nullptr; + } + + return portDescriptor; +} + +ExtensionPort::ExtensionPort( + nsIGlobalObject* aGlobal, ExtensionBrowser* aExtensionBrowser, + UniquePtr<dom::ExtensionPortDescriptor>&& aPortDescriptor) + : mGlobal(aGlobal), + mExtensionBrowser(aExtensionBrowser), + mPortDescriptor(std::move(aPortDescriptor)) { + MOZ_DIAGNOSTIC_ASSERT(mGlobal); + MOZ_DIAGNOSTIC_ASSERT(mExtensionBrowser); +} + +void ExtensionPort::ForgetReleasedPort() { + if (mExtensionBrowser) { + mExtensionBrowser->ForgetReleasedPort(mPortDescriptor->mPortId); + mExtensionBrowser = nullptr; + } + mPortDescriptor = nullptr; + mGlobal = nullptr; +} + +nsString ExtensionPort::GetAPIObjectId() const { + return mPortDescriptor->mPortId; +} + +JSObject* ExtensionPort::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return dom::ExtensionPort_Binding::Wrap(aCx, this, aGivenProto); +} + +nsIGlobalObject* ExtensionPort::GetParentObject() const { return mGlobal; } + +void ExtensionPort::GetName(nsAString& aString) { + aString.Assign(mPortDescriptor->mName); +} + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/webidl-api/ExtensionPort.h b/toolkit/components/extensions/webidl-api/ExtensionPort.h new file mode 100644 index 0000000000..35c072082c --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionPort.h @@ -0,0 +1,96 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_ExtensionPort_h +#define mozilla_extensions_ExtensionPort_h + +#include "js/TypeDecls.h" +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/WeakPtr.h" +#include "nsWrapperCache.h" + +#include "ExtensionAPIBase.h" + +class nsIGlobalObject; + +namespace mozilla { + +class ErrorResult; + +namespace dom { +struct ExtensionPortDescriptor; +} + +namespace extensions { + +class ExtensionEventManager; + +class ExtensionPort final : public nsISupports, + public nsWrapperCache, + public SupportsWeakPtr, + public ExtensionAPIBase { + public: + static already_AddRefed<ExtensionPort> Create( + nsIGlobalObject* aGlobal, ExtensionBrowser* aExtensionBrowser, + UniquePtr<dom::ExtensionPortDescriptor>&& aPortDescriptor); + + static UniquePtr<dom::ExtensionPortDescriptor> ToPortDescriptor( + JS::Handle<JS::Value> aDescriptorValue, ErrorResult& aRv); + + // nsWrapperCache interface methods + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + nsIGlobalObject* GetParentObject() const; + + ExtensionEventManager* OnDisconnect(); + ExtensionEventManager* OnMessage(); + + void GetName(nsAString& aString); + void GetError(JSContext* aCx, JS::MutableHandle<JS::Value> aRetval) { + GetWebExtPropertyAsJSValue(aCx, u"error"_ns, aRetval); + } + void GetSender(JSContext* aCx, JS::MutableHandle<JS::Value> aRetval) { + GetWebExtPropertyAsJSValue(aCx, u"sender"_ns, aRetval); + }; + + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(ExtensionPort) + + protected: + // ExtensionAPIBase methods + nsIGlobalObject* GetGlobalObject() const override { return mGlobal; } + + ExtensionBrowser* GetExtensionBrowser() const override { + return mExtensionBrowser; + } + + nsString GetAPINamespace() const override { return u"runtime"_ns; } + + nsString GetAPIObjectType() const override { return u"Port"_ns; } + + nsString GetAPIObjectId() const override; + + private: + ExtensionPort(nsIGlobalObject* aGlobal, ExtensionBrowser* aExtensionBrowser, + UniquePtr<dom::ExtensionPortDescriptor>&& aPortDescriptor); + + ~ExtensionPort() = default; + + void ForgetReleasedPort(); + + nsCOMPtr<nsIGlobalObject> mGlobal; + RefPtr<ExtensionBrowser> mExtensionBrowser; + RefPtr<ExtensionEventManager> mOnDisconnectEventMgr; + RefPtr<ExtensionEventManager> mOnMessageEventMgr; + UniquePtr<dom::ExtensionPortDescriptor> mPortDescriptor; + RefPtr<dom::Function> mCallback; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_ExtensionPort_h diff --git a/toolkit/components/extensions/webidl-api/ExtensionProxy.cpp b/toolkit/components/extensions/webidl-api/ExtensionProxy.cpp new file mode 100644 index 0000000000..6abbdfd3c9 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionProxy.cpp @@ -0,0 +1,51 @@ +/* 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 "ExtensionProxy.h" +#include "ExtensionEventManager.h" +#include "ExtensionSetting.h" + +#include "mozilla/dom/ExtensionProxyBinding.h" +#include "nsIGlobalObject.h" + +namespace mozilla::extensions { + +NS_IMPL_CYCLE_COLLECTING_ADDREF(ExtensionProxy); +NS_IMPL_CYCLE_COLLECTING_RELEASE(ExtensionProxy) +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(ExtensionProxy, mGlobal, + mExtensionBrowser, mSettingsMgr, + mOnRequestEventMgr, mOnErrorEventMgr); + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(ExtensionProxy) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_WEBEXT_EVENTMGR(ExtensionProxy, u"onRequest"_ns, OnRequest) +NS_IMPL_WEBEXT_EVENTMGR(ExtensionProxy, u"onError"_ns, OnError) + +NS_IMPL_WEBEXT_SETTING_WITH_DATAMEMBER(ExtensionProxy, u"settings"_ns, Settings, + mSettingsMgr) + +ExtensionProxy::ExtensionProxy(nsIGlobalObject* aGlobal, + ExtensionBrowser* aExtensionBrowser) + : mGlobal(aGlobal), mExtensionBrowser(aExtensionBrowser) { + MOZ_DIAGNOSTIC_ASSERT(mGlobal); + MOZ_DIAGNOSTIC_ASSERT(mExtensionBrowser); +} + +/* static */ +bool ExtensionProxy::IsAllowed(JSContext* aCx, JSObject* aGlobal) { + return true; +} + +JSObject* ExtensionProxy::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return dom::ExtensionProxy_Binding::Wrap(aCx, this, aGivenProto); +} + +nsIGlobalObject* ExtensionProxy::GetParentObject() const { return mGlobal; } + +} // namespace mozilla::extensions diff --git a/toolkit/components/extensions/webidl-api/ExtensionProxy.h b/toolkit/components/extensions/webidl-api/ExtensionProxy.h new file mode 100644 index 0000000000..3159c340f1 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionProxy.h @@ -0,0 +1,72 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_ExtensionProxy_h +#define mozilla_extensions_ExtensionProxy_h + +#include "js/TypeDecls.h" +#include "mozilla/Attributes.h" +#include "mozilla/dom/BindingDeclarations.h" +#include "nsCycleCollectionParticipant.h" +#include "nsCOMPtr.h" +#include "nsISupports.h" +#include "nsWrapperCache.h" + +#include "ExtensionAPIBase.h" +#include "ExtensionBrowser.h" + +class nsIGlobalObject; + +namespace mozilla::extensions { + +class ExtensionEventManager; +class ExtensionSetting; + +class ExtensionProxy final : public nsISupports, + public nsWrapperCache, + public ExtensionAPINamespace { + public: + ExtensionProxy(nsIGlobalObject* aGlobal, ExtensionBrowser* aExtensionBrowser); + + // ExtensionAPIBase methods + nsIGlobalObject* GetGlobalObject() const override { return mGlobal; } + + ExtensionBrowser* GetExtensionBrowser() const override { + return mExtensionBrowser; + } + + nsString GetAPINamespace() const override { return u"proxy"_ns; } + + // nsWrapperCache interface methods + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + // DOM bindings methods + static bool IsAllowed(JSContext* aCx, JSObject* aGlobal); + + nsIGlobalObject* GetParentObject() const; + + ExtensionEventManager* OnRequest(); + ExtensionEventManager* OnError(); + + ExtensionSetting* Settings(); + + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(ExtensionProxy) + + private: + ~ExtensionProxy() = default; + + nsCOMPtr<nsIGlobalObject> mGlobal; + RefPtr<ExtensionBrowser> mExtensionBrowser; + RefPtr<ExtensionEventManager> mOnRequestEventMgr; + RefPtr<ExtensionEventManager> mOnErrorEventMgr; + RefPtr<ExtensionSetting> mSettingsMgr; +}; + +} // namespace mozilla::extensions + +#endif // mozilla_extensions_ExtensionProxy_h diff --git a/toolkit/components/extensions/webidl-api/ExtensionRuntime.cpp b/toolkit/components/extensions/webidl-api/ExtensionRuntime.cpp new file mode 100644 index 0000000000..e93fec91da --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionRuntime.cpp @@ -0,0 +1,67 @@ +/* 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 "ExtensionRuntime.h" +#include "ExtensionEventManager.h" + +#include "mozilla/dom/ExtensionRuntimeBinding.h" +#include "nsIGlobalObject.h" + +namespace mozilla { +namespace extensions { + +NS_IMPL_CYCLE_COLLECTING_ADDREF(ExtensionRuntime); +NS_IMPL_CYCLE_COLLECTING_RELEASE(ExtensionRuntime) +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE( + ExtensionRuntime, mGlobal, mExtensionBrowser, mOnStartupEventMgr, + mOnInstalledEventMgr, mOnUpdateAvailableEventMgr, mOnConnectEventMgr, + mOnConnectExternalEventMgr, mOnMessageEventMgr, mOnMessageExternalEventMgr); + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(ExtensionRuntime) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_WEBEXT_EVENTMGR(ExtensionRuntime, u"onStartup"_ns, OnStartup) +NS_IMPL_WEBEXT_EVENTMGR(ExtensionRuntime, u"onInstalled"_ns, OnInstalled) +NS_IMPL_WEBEXT_EVENTMGR(ExtensionRuntime, u"onUpdateAvailable"_ns, + OnUpdateAvailable) +NS_IMPL_WEBEXT_EVENTMGR(ExtensionRuntime, u"onConnect"_ns, OnConnect) +NS_IMPL_WEBEXT_EVENTMGR(ExtensionRuntime, u"onConnectExternal"_ns, + OnConnectExternal) +NS_IMPL_WEBEXT_EVENTMGR(ExtensionRuntime, u"onMessage"_ns, OnMessage) +NS_IMPL_WEBEXT_EVENTMGR(ExtensionRuntime, u"onMessageExternal"_ns, + OnMessageExternal) + +ExtensionRuntime::ExtensionRuntime(nsIGlobalObject* aGlobal, + ExtensionBrowser* aExtensionBrowser) + : mGlobal(aGlobal), mExtensionBrowser(aExtensionBrowser) { + MOZ_DIAGNOSTIC_ASSERT(mGlobal); + MOZ_DIAGNOSTIC_ASSERT(mExtensionBrowser); +} + +/* static */ +bool ExtensionRuntime::IsAllowed(JSContext* aCx, JSObject* aGlobal) { + return true; +} + +JSObject* ExtensionRuntime::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return dom::ExtensionRuntime_Binding::Wrap(aCx, this, aGivenProto); +} + +nsIGlobalObject* ExtensionRuntime::GetParentObject() const { return mGlobal; } + +void ExtensionRuntime::GetLastError(JSContext* aCx, + JS::MutableHandle<JS::Value> aRetval) { + mExtensionBrowser->GetLastError(aRetval); +} + +void ExtensionRuntime::GetId(dom::DOMString& aRetval) { + GetWebExtPropertyAsString(u"id"_ns, aRetval); +} + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/webidl-api/ExtensionRuntime.h b/toolkit/components/extensions/webidl-api/ExtensionRuntime.h new file mode 100644 index 0000000000..238f79cac0 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionRuntime.h @@ -0,0 +1,85 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_ExtensionRuntime_h +#define mozilla_extensions_ExtensionRuntime_h + +#include "js/TypeDecls.h" +#include "mozilla/Attributes.h" +#include "mozilla/dom/BindingDeclarations.h" +#include "nsCycleCollectionParticipant.h" +#include "nsCOMPtr.h" +#include "nsISupports.h" +#include "nsWrapperCache.h" + +#include "ExtensionAPIBase.h" +#include "ExtensionBrowser.h" + +class nsIGlobalObject; + +namespace mozilla { + +namespace extensions { + +class ExtensionEventManager; + +class ExtensionRuntime final : public nsISupports, + public nsWrapperCache, + public ExtensionAPINamespace { + public: + ExtensionRuntime(nsIGlobalObject* aGlobal, + ExtensionBrowser* aExtensionBrowser); + + // ExtensionAPIBase methods + nsIGlobalObject* GetGlobalObject() const override { return mGlobal; } + + ExtensionBrowser* GetExtensionBrowser() const override { + return mExtensionBrowser; + } + + nsString GetAPINamespace() const override { return u"runtime"_ns; } + + // nsWrapperCache interface methods + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + // DOM bindings methods + static bool IsAllowed(JSContext* aCx, JSObject* aGlobal); + + nsIGlobalObject* GetParentObject() const; + + ExtensionEventManager* OnStartup(); + ExtensionEventManager* OnInstalled(); + ExtensionEventManager* OnUpdateAvailable(); + ExtensionEventManager* OnConnect(); + ExtensionEventManager* OnConnectExternal(); + ExtensionEventManager* OnMessage(); + ExtensionEventManager* OnMessageExternal(); + + void GetLastError(JSContext* aCx, JS::MutableHandle<JS::Value> aRetval); + void GetId(dom::DOMString& aRetval); + + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(ExtensionRuntime) + + private: + ~ExtensionRuntime() = default; + + nsCOMPtr<nsIGlobalObject> mGlobal; + RefPtr<ExtensionBrowser> mExtensionBrowser; + RefPtr<ExtensionEventManager> mOnStartupEventMgr; + RefPtr<ExtensionEventManager> mOnInstalledEventMgr; + RefPtr<ExtensionEventManager> mOnUpdateAvailableEventMgr; + RefPtr<ExtensionEventManager> mOnConnectEventMgr; + RefPtr<ExtensionEventManager> mOnConnectExternalEventMgr; + RefPtr<ExtensionEventManager> mOnMessageEventMgr; + RefPtr<ExtensionEventManager> mOnMessageExternalEventMgr; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_ExtensionRuntime_h diff --git a/toolkit/components/extensions/webidl-api/ExtensionScripting.cpp b/toolkit/components/extensions/webidl-api/ExtensionScripting.cpp new file mode 100644 index 0000000000..2f07fbb8f1 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionScripting.cpp @@ -0,0 +1,43 @@ +/* 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 "ExtensionScripting.h" +#include "ExtensionEventManager.h" + +#include "mozilla/dom/ExtensionScriptingBinding.h" +#include "nsIGlobalObject.h" + +namespace mozilla::extensions { + +NS_IMPL_CYCLE_COLLECTING_ADDREF(ExtensionScripting); +NS_IMPL_CYCLE_COLLECTING_RELEASE(ExtensionScripting) +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(ExtensionScripting, mGlobal, + mExtensionBrowser); + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(ExtensionScripting) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +ExtensionScripting::ExtensionScripting(nsIGlobalObject* aGlobal, + ExtensionBrowser* aExtensionBrowser) + : mGlobal(aGlobal), mExtensionBrowser(aExtensionBrowser) { + MOZ_DIAGNOSTIC_ASSERT(mGlobal); + MOZ_DIAGNOSTIC_ASSERT(mExtensionBrowser); +} + +/* static */ +bool ExtensionScripting::IsAllowed(JSContext* aCx, JSObject* aGlobal) { + return true; +} + +JSObject* ExtensionScripting::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return dom::ExtensionScripting_Binding::Wrap(aCx, this, aGivenProto); +} + +nsIGlobalObject* ExtensionScripting::GetParentObject() const { return mGlobal; } + +} // namespace mozilla::extensions diff --git a/toolkit/components/extensions/webidl-api/ExtensionScripting.h b/toolkit/components/extensions/webidl-api/ExtensionScripting.h new file mode 100644 index 0000000000..42d61015aa --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionScripting.h @@ -0,0 +1,67 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_ExtensionScripting_h +#define mozilla_extensions_ExtensionScripting_h + +#include "js/TypeDecls.h" +#include "mozilla/Attributes.h" +#include "mozilla/dom/BindingDeclarations.h" +#include "nsCycleCollectionParticipant.h" +#include "nsCOMPtr.h" +#include "nsISupports.h" +#include "nsWrapperCache.h" + +#include "ExtensionAPIBase.h" +#include "ExtensionBrowser.h" + +class nsIGlobalObject; + +namespace mozilla { + +namespace extensions { + +class ExtensionEventManager; + +class ExtensionScripting final : public nsISupports, + public nsWrapperCache, + public ExtensionAPINamespace { + public: + ExtensionScripting(nsIGlobalObject* aGlobal, + ExtensionBrowser* aExtensionBrowser); + + // ExtensionAPIBase methods + nsIGlobalObject* GetGlobalObject() const override { return mGlobal; } + + ExtensionBrowser* GetExtensionBrowser() const override { + return mExtensionBrowser; + } + + nsString GetAPINamespace() const override { return u"scripting"_ns; } + + // nsWrapperCache interface methods + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + // DOM bindings methods + static bool IsAllowed(JSContext* aCx, JSObject* aGlobal); + + nsIGlobalObject* GetParentObject() const; + + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(ExtensionScripting) + + private: + ~ExtensionScripting() = default; + + nsCOMPtr<nsIGlobalObject> mGlobal; + RefPtr<ExtensionBrowser> mExtensionBrowser; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_ExtensionScripting_h diff --git a/toolkit/components/extensions/webidl-api/ExtensionSetting.cpp b/toolkit/components/extensions/webidl-api/ExtensionSetting.cpp new file mode 100644 index 0000000000..36a4fe8a01 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionSetting.cpp @@ -0,0 +1,48 @@ +/* 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 "ExtensionSetting.h" +#include "ExtensionEventManager.h" + +#include "mozilla/dom/ExtensionSettingBinding.h" +#include "nsIGlobalObject.h" + +namespace mozilla::extensions { + +NS_IMPL_CYCLE_COLLECTING_ADDREF(ExtensionSetting); +NS_IMPL_CYCLE_COLLECTING_RELEASE(ExtensionSetting) +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(ExtensionSetting, mGlobal, + mExtensionBrowser, mOnChangeEventMgr); + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(ExtensionSetting) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_WEBEXT_EVENTMGR(ExtensionSetting, u"onChange"_ns, OnChange) + +ExtensionSetting::ExtensionSetting(nsIGlobalObject* aGlobal, + ExtensionBrowser* aExtensionBrowser, + const nsAString& aNamespace) + : mGlobal(aGlobal), + mExtensionBrowser(aExtensionBrowser), + mAPINamespace(aNamespace) { + MOZ_DIAGNOSTIC_ASSERT(mGlobal); + MOZ_DIAGNOSTIC_ASSERT(mExtensionBrowser); +} + +/* static */ +bool ExtensionSetting::IsAllowed(JSContext* aCx, JSObject* aGlobal) { + return true; +} + +JSObject* ExtensionSetting::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return dom::ExtensionSetting_Binding::Wrap(aCx, this, aGivenProto); +} + +nsIGlobalObject* ExtensionSetting::GetParentObject() const { return mGlobal; } + +} // namespace mozilla::extensions diff --git a/toolkit/components/extensions/webidl-api/ExtensionSetting.h b/toolkit/components/extensions/webidl-api/ExtensionSetting.h new file mode 100644 index 0000000000..c1692e8a14 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionSetting.h @@ -0,0 +1,69 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_ExtensionSetting_h +#define mozilla_extensions_ExtensionSetting_h + +#include "js/TypeDecls.h" +#include "mozilla/Attributes.h" +#include "mozilla/dom/BindingDeclarations.h" +#include "nsCycleCollectionParticipant.h" +#include "nsCOMPtr.h" +#include "nsISupports.h" +#include "nsWrapperCache.h" + +#include "ExtensionAPIBase.h" +#include "ExtensionBrowser.h" + +class nsIGlobalObject; + +namespace mozilla::extensions { + +class ExtensionEventManager; + +class ExtensionSetting final : public nsISupports, + public nsWrapperCache, + public ExtensionAPINamespace { + public: + ExtensionSetting(nsIGlobalObject* aGlobal, + ExtensionBrowser* aExtensionBrowser, + const nsAString& aNamespace); + + // ExtensionAPIBase methods + nsIGlobalObject* GetGlobalObject() const override { return mGlobal; } + + ExtensionBrowser* GetExtensionBrowser() const override { + return mExtensionBrowser; + } + + nsString GetAPINamespace() const override { return mAPINamespace; } + + // nsWrapperCache interface methods + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + // DOM bindings methods + static bool IsAllowed(JSContext* aCx, JSObject* aGlobal); + + nsIGlobalObject* GetParentObject() const; + + ExtensionEventManager* OnChange(); + + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(ExtensionSetting) + + private: + ~ExtensionSetting() = default; + + nsCOMPtr<nsIGlobalObject> mGlobal; + RefPtr<ExtensionBrowser> mExtensionBrowser; + nsString mAPINamespace; + RefPtr<ExtensionEventManager> mOnChangeEventMgr; +}; + +} // namespace mozilla::extensions + +#endif // mozilla_extensions_ExtensionDns_h diff --git a/toolkit/components/extensions/webidl-api/ExtensionTest.cpp b/toolkit/components/extensions/webidl-api/ExtensionTest.cpp new file mode 100644 index 0000000000..e9cde7ccad --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionTest.cpp @@ -0,0 +1,527 @@ +/* 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 "ExtensionTest.h" +#include "ExtensionEventManager.h" +#include "ExtensionAPICallFunctionNoReturn.h" + +#include "js/Equality.h" // JS::StrictlyEqual +#include "js/PropertyAndElement.h" // JS_GetProperty +#include "mozilla/dom/ExtensionTestBinding.h" +#include "nsIGlobalObject.h" +#include "js/RegExp.h" +#include "mozilla/dom/WorkerScope.h" +#include "prenv.h" + +namespace mozilla { +namespace extensions { + +bool IsInAutomation(JSContext* aCx, JSObject* aGlobal) { + return NS_IsMainThread() + ? xpc::IsInAutomation() + : dom::WorkerGlobalScope::IsInAutomation(aCx, aGlobal); +} + +NS_IMPL_CYCLE_COLLECTING_ADDREF(ExtensionTest); +NS_IMPL_CYCLE_COLLECTING_RELEASE(ExtensionTest) +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(ExtensionTest, mGlobal, mExtensionBrowser, + mOnMessageEventMgr); + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(ExtensionTest) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_WEBEXT_EVENTMGR(ExtensionTest, u"onMessage"_ns, OnMessage) + +ExtensionTest::ExtensionTest(nsIGlobalObject* aGlobal, + ExtensionBrowser* aExtensionBrowser) + : mGlobal(aGlobal), mExtensionBrowser(aExtensionBrowser) { + MOZ_DIAGNOSTIC_ASSERT(mGlobal); + MOZ_DIAGNOSTIC_ASSERT(mExtensionBrowser); +} + +/* static */ +bool ExtensionTest::IsAllowed(JSContext* aCx, JSObject* aGlobal) { + // Allow browser.test API namespace while running in xpcshell tests. + if (PR_GetEnv("XPCSHELL_TEST_PROFILE_DIR")) { + return true; + } + + return IsInAutomation(aCx, aGlobal); +} + +JSObject* ExtensionTest::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return dom::ExtensionTest_Binding::Wrap(aCx, this, aGivenProto); +} + +nsIGlobalObject* ExtensionTest::GetParentObject() const { return mGlobal; } + +void ExtensionTest::CallWebExtMethodAssertEq( + JSContext* aCx, const nsAString& aApiMethod, + const dom::Sequence<JS::Value>& aArgs, ErrorResult& aRv) { + uint32_t argsCount = aArgs.Length(); + + JS::Rooted<JS::Value> expectedVal( + aCx, argsCount > 0 ? aArgs[0] : JS::UndefinedValue()); + JS::Rooted<JS::Value> actualVal( + aCx, argsCount > 1 ? aArgs[1] : JS::UndefinedValue()); + JS::Rooted<JS::Value> messageVal( + aCx, argsCount > 2 ? aArgs[2] : JS::UndefinedValue()); + + bool isEqual; + if (NS_WARN_IF(!JS::StrictlyEqual(aCx, actualVal, expectedVal, &isEqual))) { + ThrowUnexpectedError(aCx, aRv); + return; + } + + JS::Rooted<JSString*> expectedJSString(aCx, JS::ToString(aCx, expectedVal)); + JS::Rooted<JSString*> actualJSString(aCx, JS::ToString(aCx, actualVal)); + JS::Rooted<JSString*> messageJSString(aCx, JS::ToString(aCx, messageVal)); + + nsString expected; + nsString actual; + nsString message; + + if (NS_WARN_IF(!AssignJSString(aCx, expected, expectedJSString) || + !AssignJSString(aCx, actual, actualJSString) || + !AssignJSString(aCx, message, messageJSString))) { + ThrowUnexpectedError(aCx, aRv); + return; + } + + if (!isEqual && actual.Equals(expected)) { + actual.AppendLiteral(" (different)"); + } + + if (NS_WARN_IF(!dom::ToJSValue(aCx, expected, &expectedVal) || + !dom::ToJSValue(aCx, actual, &actualVal) || + !dom::ToJSValue(aCx, message, &messageVal))) { + ThrowUnexpectedError(aCx, aRv); + return; + } + + dom::Sequence<JS::Value> args; + if (NS_WARN_IF(!args.AppendElement(expectedVal, fallible) || + !args.AppendElement(actualVal, fallible) || + !args.AppendElement(messageVal, fallible))) { + ThrowUnexpectedError(aCx, aRv); + return; + } + + CallWebExtMethodNoReturn(aCx, aApiMethod, args, aRv); +} + +MOZ_CAN_RUN_SCRIPT bool ExtensionTest::AssertMatchInternal( + JSContext* aCx, const JS::HandleValue aActualValue, + const JS::HandleValue aExpectedMatchValue, const nsAString& aMessagePre, + const nsAString& aMessage, + UniquePtr<dom::SerializedStackHolder> aSerializedCallerStack, + ErrorResult& aRv) { + // Stringify the actual value, if the expected value is a regexp or a string + // then it will be used as part of the matching assertion, otherwise it is + // still interpolated in the assertion message. + JS::Rooted<JSString*> actualToString(aCx, JS::ToString(aCx, aActualValue)); + NS_ENSURE_TRUE(actualToString, false); + nsAutoJSString actualString; + NS_ENSURE_TRUE(actualString.init(aCx, actualToString), false); + + bool matched = false; + + if (aExpectedMatchValue.isObject()) { + JS::Rooted<JSObject*> expectedMatchObj(aCx, + &aExpectedMatchValue.toObject()); + + bool isRegexp; + NS_ENSURE_TRUE(JS::ObjectIsRegExp(aCx, expectedMatchObj, &isRegexp), false); + + if (isRegexp) { + // Expected value is a regexp, test if the stringified actual value does + // match. + nsString input(actualString); + size_t index = 0; + JS::Rooted<JS::Value> rxResult(aCx); + NS_ENSURE_TRUE(JS::ExecuteRegExpNoStatics( + aCx, expectedMatchObj, input.BeginWriting(), + actualString.Length(), &index, true, &rxResult), + false); + matched = !rxResult.isNull(); + } else if (JS::IsCallable(expectedMatchObj) && + !JS::IsConstructor(expectedMatchObj)) { + // Expected value is a matcher function, execute it with the value as a + // parameter: + // + // - if the matcher function throws, steal the exception to re-raise it + // to the extension code that called the assertion method, but + // continue to still report the assertion as failed to the WebExtensions + // internals. + // + // - if the function return a falsey value, the assertion should fail and + // no exception is raised to the extension code that called the + // assertion + JS::Rooted<JS::Value> retval(aCx); + aRv.MightThrowJSException(); + if (!JS::Call(aCx, JS::UndefinedHandleValue, expectedMatchObj, + JS::HandleValueArray(aActualValue), &retval)) { + aRv.StealExceptionFromJSContext(aCx); + matched = false; + } else { + matched = JS::ToBoolean(retval); + } + } else if (JS::IsConstructor(expectedMatchObj)) { + // Expected value is a constructor, test if the actual value is an + // instanceof the expected constructor. + NS_ENSURE_TRUE( + JS_HasInstance(aCx, expectedMatchObj, aActualValue, &matched), false); + } else { + // Fallback to strict equal for any other js object type we don't expect. + NS_ENSURE_TRUE( + JS::StrictlyEqual(aCx, aActualValue, aExpectedMatchValue, &matched), + false); + } + } else if (aExpectedMatchValue.isString()) { + // Expected value is a string, assertion should fail if the expected string + // isn't equal to the stringified actual value. + JS::Rooted<JSString*> expectedToString( + aCx, JS::ToString(aCx, aExpectedMatchValue)); + NS_ENSURE_TRUE(expectedToString, false); + + nsAutoJSString expectedString; + NS_ENSURE_TRUE(expectedString.init(aCx, expectedToString), false); + + // If actual is an object and it has a message property that is a string, + // then we want to use that message string as the string to compare the + // expected one with. + // + // This is needed mainly to match the current JS implementation. + // + // TODO(Bug 1731094): as a low priority follow up, we may want to reconsider + // and compare the entire stringified error (which is also often a common + // behavior in many third party JS test frameworks). + JS::Rooted<JS::Value> messageVal(aCx); + if (aActualValue.isObject()) { + JS::Rooted<JSObject*> actualValueObj(aCx, &aActualValue.toObject()); + + if (!JS_GetProperty(aCx, actualValueObj, "message", &messageVal)) { + // GetProperty may raise an exception, in that case we steal the + // exception to re-raise it to the caller, but continue to still report + // the assertion as failed to the WebExtensions internals. + aRv.StealExceptionFromJSContext(aCx); + matched = false; + } + + if (messageVal.isString()) { + actualToString.set(messageVal.toString()); + NS_ENSURE_TRUE(actualString.init(aCx, actualToString), false); + } + } + matched = expectedString.Equals(actualString); + } else { + // Fallback to strict equal for any other js value type we don't expect. + NS_ENSURE_TRUE( + JS::StrictlyEqual(aCx, aActualValue, aExpectedMatchValue, &matched), + false); + } + + // Convert the expected value to a source string, to be interpolated + // in the assertion message. + JS::Rooted<JSString*> expectedToSource( + aCx, JS_ValueToSource(aCx, aExpectedMatchValue)); + NS_ENSURE_TRUE(expectedToSource, false); + nsAutoJSString expectedSource; + NS_ENSURE_TRUE(expectedSource.init(aCx, expectedToSource), false); + + nsString message; + message.AppendPrintf("%s to match '%s', got '%s'", + NS_ConvertUTF16toUTF8(aMessagePre).get(), + NS_ConvertUTF16toUTF8(expectedSource).get(), + NS_ConvertUTF16toUTF8(actualString).get()); + if (!aMessage.IsEmpty()) { + message.AppendPrintf(": %s", NS_ConvertUTF16toUTF8(aMessage).get()); + } + + // Complete the assertion by forwarding the boolean result and the + // interpolated assertion message to the test.assertTrue API method on the + // main thread. + dom::Sequence<JS::Value> assertTrueArgs; + JS::Rooted<JS::Value> arg0(aCx); + JS::Rooted<JS::Value> arg1(aCx); + NS_ENSURE_FALSE(!dom::ToJSValue(aCx, matched, &arg0) || + !dom::ToJSValue(aCx, message, &arg1) || + !assertTrueArgs.AppendElement(arg0, fallible) || + !assertTrueArgs.AppendElement(arg1, fallible), + false); + + auto request = CallFunctionNoReturn(u"assertTrue"_ns); + IgnoredErrorResult erv; + if (aSerializedCallerStack) { + request->SetSerializedCallerStack(std::move(aSerializedCallerStack)); + } + request->Run(GetGlobalObject(), aCx, assertTrueArgs, erv); + NS_ENSURE_FALSE(erv.Failed(), false); + return true; +} + +MOZ_CAN_RUN_SCRIPT void ExtensionTest::AssertThrows( + JSContext* aCx, dom::Function& aFunction, + const JS::HandleValue aExpectedError, const nsAString& aMessage, + ErrorResult& aRv) { + // Call the function that is expected to throw, then get the pending exception + // to pass it to the AssertMatchInternal. + ErrorResult erv; + erv.MightThrowJSException(); + JS::Rooted<JS::Value> ignoredRetval(aCx); + aFunction.Call({}, &ignoredRetval, erv, "ExtensionTest::AssertThrows", + dom::Function::eRethrowExceptions); + + bool didThrow = false; + JS::Rooted<JS::Value> exn(aCx); + + if (erv.MaybeSetPendingException(aCx) && JS_GetPendingException(aCx, &exn)) { + JS_ClearPendingException(aCx); + didThrow = true; + } + + // If the function did not throw, then the assertion is failed + // and the result should be forwarded to assertTrue on the main thread. + if (!didThrow) { + JS::Rooted<JSString*> expectedErrorToSource( + aCx, JS_ValueToSource(aCx, aExpectedError)); + if (NS_WARN_IF(!expectedErrorToSource)) { + ThrowUnexpectedError(aCx, aRv); + return; + } + nsAutoJSString expectedErrorSource; + if (NS_WARN_IF(!expectedErrorSource.init(aCx, expectedErrorToSource))) { + ThrowUnexpectedError(aCx, aRv); + return; + } + + nsString message; + message.AppendPrintf("Function did not throw, expected error '%s'", + NS_ConvertUTF16toUTF8(expectedErrorSource).get()); + if (!aMessage.IsEmpty()) { + message.AppendPrintf(": %s", NS_ConvertUTF16toUTF8(aMessage).get()); + } + + dom::Sequence<JS::Value> assertTrueArgs; + JS::Rooted<JS::Value> arg0(aCx); + JS::Rooted<JS::Value> arg1(aCx); + if (NS_WARN_IF(!dom::ToJSValue(aCx, false, &arg0) || + !dom::ToJSValue(aCx, message, &arg1) || + !assertTrueArgs.AppendElement(arg0, fallible) || + !assertTrueArgs.AppendElement(arg1, fallible))) { + ThrowUnexpectedError(aCx, aRv); + return; + } + + CallWebExtMethodNoReturn(aCx, u"assertTrue"_ns, assertTrueArgs, aRv); + if (NS_WARN_IF(aRv.Failed())) { + ThrowUnexpectedError(aCx, aRv); + } + return; + } + + if (NS_WARN_IF(!AssertMatchInternal(aCx, exn, aExpectedError, + u"Function threw, expecting error"_ns, + aMessage, nullptr, aRv))) { + ThrowUnexpectedError(aCx, aRv); + } +} + +MOZ_CAN_RUN_SCRIPT void ExtensionTest::AssertThrows( + JSContext* aCx, dom::Function& aFunction, + const JS::HandleValue aExpectedError, ErrorResult& aRv) { + AssertThrows(aCx, aFunction, aExpectedError, EmptyString(), aRv); +} + +#define ASSERT_REJECT_UNKNOWN_FAIL_STR "Failed to complete assertRejects call" + +class AssertRejectsHandler final : public dom::PromiseNativeHandler { + public: + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(AssertRejectsHandler) + + static void Create(ExtensionTest* aExtensionTest, dom::Promise* aPromise, + dom::Promise* outPromise, + JS::Handle<JS::Value> aExpectedMatchValue, + const nsAString& aMessage, + UniquePtr<dom::SerializedStackHolder>&& aCallerStack) { + MOZ_ASSERT(aPromise); + MOZ_ASSERT(outPromise); + MOZ_ASSERT(aExtensionTest); + + RefPtr<AssertRejectsHandler> handler = new AssertRejectsHandler( + aExtensionTest, outPromise, aExpectedMatchValue, aMessage, + std::move(aCallerStack)); + + aPromise->AppendNativeHandler(handler); + } + + MOZ_CAN_RUN_SCRIPT void ResolvedCallback(JSContext* aCx, + JS::Handle<JS::Value> aValue, + ErrorResult& aRv) override { + nsAutoJSString expectedErrorSource; + JS::Rooted<JS::Value> rootedExpectedMatchValue(aCx, mExpectedMatchValue); + JS::Rooted<JSString*> expectedErrorToSource( + aCx, JS_ValueToSource(aCx, rootedExpectedMatchValue)); + if (NS_WARN_IF(!expectedErrorToSource || + !expectedErrorSource.init(aCx, expectedErrorToSource))) { + mOutPromise->MaybeRejectWithUnknownError(ASSERT_REJECT_UNKNOWN_FAIL_STR); + return; + } + + nsString message; + message.AppendPrintf("Promise resolved, expect rejection '%s'", + NS_ConvertUTF16toUTF8(expectedErrorSource).get()); + + if (!mMessageStr.IsEmpty()) { + message.AppendPrintf(": %s", NS_ConvertUTF16toUTF8(mMessageStr).get()); + } + + dom::Sequence<JS::Value> assertTrueArgs; + JS::Rooted<JS::Value> arg0(aCx); + JS::Rooted<JS::Value> arg1(aCx); + if (NS_WARN_IF(!dom::ToJSValue(aCx, false, &arg0) || + !dom::ToJSValue(aCx, message, &arg1) || + !assertTrueArgs.AppendElement(arg0, fallible) || + !assertTrueArgs.AppendElement(arg1, fallible))) { + mOutPromise->MaybeRejectWithUnknownError(ASSERT_REJECT_UNKNOWN_FAIL_STR); + return; + } + + IgnoredErrorResult erv; + auto request = mExtensionTest->CallFunctionNoReturn(u"assertTrue"_ns); + request->SetSerializedCallerStack(std::move(mCallerStack)); + request->Run(mExtensionTest->GetGlobalObject(), aCx, assertTrueArgs, erv); + if (NS_WARN_IF(erv.Failed())) { + mOutPromise->MaybeRejectWithUnknownError(ASSERT_REJECT_UNKNOWN_FAIL_STR); + return; + } + mOutPromise->MaybeResolve(JS::UndefinedValue()); + } + + MOZ_CAN_RUN_SCRIPT void RejectedCallback(JSContext* aCx, + JS::Handle<JS::Value> aValue, + ErrorResult& aRv) override { + JS::Rooted<JS::Value> expectedMatchRooted(aCx, mExpectedMatchValue); + ErrorResult erv; + + if (NS_WARN_IF(!MOZ_KnownLive(mExtensionTest) + ->AssertMatchInternal( + aCx, aValue, expectedMatchRooted, + u"Promise rejected, expected rejection"_ns, + mMessageStr, std::move(mCallerStack), erv))) { + // Reject for other unknown errors. + mOutPromise->MaybeRejectWithUnknownError(ASSERT_REJECT_UNKNOWN_FAIL_STR); + return; + } + + // Reject with the matcher function exception. + erv.WouldReportJSException(); + if (erv.Failed()) { + mOutPromise->MaybeReject(std::move(erv)); + return; + } + mExpectedMatchValue.setUndefined(); + mOutPromise->MaybeResolveWithUndefined(); + } + + private: + AssertRejectsHandler(ExtensionTest* aExtensionTest, dom::Promise* mOutPromise, + JS::Handle<JS::Value> aExpectedMatchValue, + const nsAString& aMessage, + UniquePtr<dom::SerializedStackHolder>&& aCallerStack) + : mOutPromise(mOutPromise), mExtensionTest(aExtensionTest) { + MOZ_ASSERT(mOutPromise); + MOZ_ASSERT(mExtensionTest); + mozilla::HoldJSObjects(this); + mExpectedMatchValue.set(aExpectedMatchValue); + mCallerStack = std::move(aCallerStack); + mMessageStr = aMessage; + } + + ~AssertRejectsHandler() { + mOutPromise = nullptr; + mExtensionTest = nullptr; + mExpectedMatchValue.setUndefined(); + mozilla::DropJSObjects(this); + }; + + RefPtr<dom::Promise> mOutPromise; + RefPtr<ExtensionTest> mExtensionTest; + JS::Heap<JS::Value> mExpectedMatchValue; + UniquePtr<dom::SerializedStackHolder> mCallerStack; + nsString mMessageStr; +}; + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(AssertRejectsHandler) + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTION_CLASS(AssertRejectsHandler) + +NS_IMPL_CYCLE_COLLECTING_ADDREF(AssertRejectsHandler) +NS_IMPL_CYCLE_COLLECTING_RELEASE(AssertRejectsHandler) + +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(AssertRejectsHandler) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mExtensionTest) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mOutPromise) +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_IMPL_CYCLE_COLLECTION_TRACE_BEGIN(AssertRejectsHandler) + NS_IMPL_CYCLE_COLLECTION_TRACE_JS_MEMBER_CALLBACK(mExpectedMatchValue) +NS_IMPL_CYCLE_COLLECTION_TRACE_END + +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(AssertRejectsHandler) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mExtensionTest) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mOutPromise) + tmp->mExpectedMatchValue.setUndefined(); +NS_IMPL_CYCLE_COLLECTION_UNLINK_END + +void ExtensionTest::AssertRejects( + JSContext* aCx, dom::Promise& aPromise, + const JS::HandleValue aExpectedError, const nsAString& aMessage, + const dom::Optional<OwningNonNull<dom::Function>>& aCallback, + JS::MutableHandle<JS::Value> aRetval, ErrorResult& aRv) { + auto* global = GetGlobalObject(); + + IgnoredErrorResult erv; + RefPtr<dom::Promise> outPromise = dom::Promise::Create(global, erv); + if (NS_WARN_IF(erv.Failed())) { + ThrowUnexpectedError(aCx, aRv); + return; + } + MOZ_ASSERT(outPromise); + + AssertRejectsHandler::Create(this, &aPromise, outPromise, aExpectedError, + aMessage, dom::GetCurrentStack(aCx)); + + if (aCallback.WasPassed()) { + // In theory we could also support the callback-based behavior, but we + // only use this in tests and so we don't really need to support it + // for Chrome-compatibility reasons. + aRv.ThrowNotSupportedError("assertRejects does not support a callback"); + return; + } + + if (NS_WARN_IF(!ToJSValue(aCx, outPromise, aRetval))) { + ThrowUnexpectedError(aCx, aRv); + return; + } +} + +void ExtensionTest::AssertRejects( + JSContext* aCx, dom::Promise& aPromise, + const JS::HandleValue aExpectedError, + const dom::Optional<OwningNonNull<dom::Function>>& aCallback, + JS::MutableHandle<JS::Value> aRetval, ErrorResult& aRv) { + AssertRejects(aCx, aPromise, aExpectedError, EmptyString(), aCallback, + aRetval, aRv); +} + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/webidl-api/ExtensionTest.h b/toolkit/components/extensions/webidl-api/ExtensionTest.h new file mode 100644 index 0000000000..09cfa8bde0 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionTest.h @@ -0,0 +1,102 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_ExtensionTest_h +#define mozilla_extensions_ExtensionTest_h + +#include "js/TypeDecls.h" +#include "mozilla/Attributes.h" +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/dom/SerializedStackHolder.h" +#include "nsCycleCollectionParticipant.h" +#include "nsCOMPtr.h" +#include "nsISupports.h" +#include "nsWrapperCache.h" + +#include "ExtensionAPIBase.h" +#include "ExtensionBrowser.h" + +class nsIGlobalObject; + +namespace mozilla { + +namespace extensions { + +class ExtensionEventManager; + +bool IsInAutomation(JSContext* aCx, JSObject* aGlobal); + +class ExtensionTest final : public nsISupports, + public nsWrapperCache, + public ExtensionAPINamespace { + public: + ExtensionTest(nsIGlobalObject* aGlobal, ExtensionBrowser* aExtensionBrowser); + + // ExtensionAPIBase methods + nsIGlobalObject* GetGlobalObject() const override { return mGlobal; } + + ExtensionBrowser* GetExtensionBrowser() const override { + return mExtensionBrowser; + } + + nsString GetAPINamespace() const override { return u"test"_ns; } + + // nsWrapperCache interface methods + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + // DOM bindings methods + static bool IsAllowed(JSContext* aCx, JSObject* aGlobal); + + nsIGlobalObject* GetParentObject() const; + + void CallWebExtMethodAssertEq(JSContext* aCx, const nsAString& aApiMethod, + const dom::Sequence<JS::Value>& aArgs, + ErrorResult& aRv); + + MOZ_CAN_RUN_SCRIPT bool AssertMatchInternal( + JSContext* aCx, const JS::HandleValue aActualValue, + const JS::HandleValue aExpectedMatchValue, const nsAString& aMessagePre, + const nsAString& aMessage, + UniquePtr<dom::SerializedStackHolder> aSerializedCallerStack, + ErrorResult& aRv); + + MOZ_CAN_RUN_SCRIPT void AssertThrows(JSContext* aCx, dom::Function& aFunction, + const JS::HandleValue aExpectedError, + const nsAString& aMessage, + ErrorResult& aRv); + MOZ_CAN_RUN_SCRIPT void AssertThrows(JSContext* aCx, dom::Function& aFunction, + const JS::HandleValue aExpectedError, + ErrorResult& aRv); + + void AssertRejects( + JSContext* aCx, dom::Promise& aPromise, + const JS::HandleValue aExpectedError, const nsAString& aMessage, + const dom::Optional<OwningNonNull<dom::Function>>& aCallback, + JS::MutableHandle<JS::Value> aRetval, ErrorResult& aRv); + void AssertRejects( + JSContext* aCx, dom::Promise& aPromise, + const JS::HandleValue aExpectedError, + const dom::Optional<OwningNonNull<dom::Function>>& aCallback, + JS::MutableHandle<JS::Value> aRetval, ErrorResult& aRv); + + ExtensionEventManager* OnMessage(); + + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(ExtensionTest) + + private: + ~ExtensionTest() = default; + + nsCOMPtr<nsIGlobalObject> mGlobal; + RefPtr<ExtensionBrowser> mExtensionBrowser; + RefPtr<ExtensionEventManager> mOnMessageEventMgr; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_ExtensionTest_h diff --git a/toolkit/components/extensions/webidl-api/ExtensionWebIDL.conf b/toolkit/components/extensions/webidl-api/ExtensionWebIDL.conf new file mode 100644 index 0000000000..be312c80d4 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/ExtensionWebIDL.conf @@ -0,0 +1,101 @@ +# -*- Mode:Python; tab-width:8; indent-tabs-mode:nil -*- */ +# vim: set ts=8 sts=4 et sw=4 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/. + +# WebExtension WebIDL API Bindings Configuration, used by the script +# `dom/extensions-webidl/GenerateWebIDLBindingsFromJSONSchema.py` +# to customize the WebIDL generated based on the WebExtensions API JSON Schemas. +# +# Generating the WebIDL definitions for some of the WebExtensions API does require +# some special handling, there are corresponding entries in the configuration tables +# below. +# + +# Mapping table between the JSON Schema types (represented as keys of the map) +# and the related WebIDL type (represented by the value in the map). +# Any mapping missing from this table will fallback to use the "any" webidl type +# (See GenerateWebIDLBindings.py WebIDLHelpers.webidl_type_from_mapping method). +# +# NOTE: Please keep this table in alphabetic order (upper and lower case in two +# separate alphabetic orders, group of the upcase ones first). +WEBEXT_TYPES_MAPPING = { + "ExpectedError": "any", # Only used in test.assertThrows/assertRejects + "Port": "ExtensionPort", + "Promise": "Promise<any>", + "StreamFilter": "ExtensionStreamFilter", + "any": "any", + "boolean": "boolean", + "number": "float", + "function": "Function", + "integer": "long", + "object": "any", # TODO: as a follow up we may look into generating webidl dictionaries to achieve a more precise mapping + "runtime.Port": "ExtensionPort", + "string": "DOMString", + "types.Setting": "ExtensionSetting", +} + +# Set of the types from the WEBEXT_TYPES_MAPPING that will be threated as primitive +# types (e.g. used to omit optional attribute in the WebIDL methods type signatures). +# +# NOTE: Please keep this table in alphabetic order (upper and lower case in two +# separate alphabetic orders, group of the update ones first). +WEBIDL_PRIMITIVE_TYPES = set([ + "DOMString", + "boolean", + "float" + "long", +]) + +# Mapping table for some APIs that do require special handling and a +# specific stub method should be set in the generated webidl extended +# attribute `WebExtensionStub`. +# +# The key in this map represent the API method name (including the +# API namespace that is part of), the value is the value to set on the +# `WebExtensionStub` webidl extended attribute: +# +# "namespace.methodName": "WebExtensionStubName", +# +# NOTE: Please keep this table in alphabetic order. +WEBEXT_STUBS_MAPPING = { + "dns.resolve": "AsyncAmbiguous", + "runtime.connect": "ReturnsPort", + "runtime.connectNative": "ReturnsPort", + "runtime.getURL": "ReturnsString", + # TODO: Bug 1782690 - This method accepts functions/args so we'll need to + # serialize them. + "scripting.executeScript": "NotImplementedAsync", + "scripting.getRegisteredContentScripts": "AsyncAmbiguous", + "scripting.unregisterContentScripts": "AsyncAmbiguous", + "test.assertEq": "AssertEq", + "test.assertRejects": False, # No WebExtensionStub attribute. + "test.assertThrows": False, # No WebExtensionStub attribute. + "test.withHandlingUserInput": "NotImplementedNoReturn", +} + +WEBEXT_WORKER_HIDDEN_SET = set([ + "runtime.getFrameId", + "runtime.getBackgroundPage", +]) + +# Mapping table for the directories where the JSON API schema will be loaded +# from. +WEBEXT_SCHEMADIRS_MAPPING = { + "toolkit": ["toolkit", "components", "extensions", "schemas"], + "browser": ["browser", "components", "extensions", "schemas"], + "mobile": ["mobile", "android", "components", "extensions", "schemas"], +} + +# List of toolkit-level WebExtensions API namespaces that are not included +# in android builds. +# +# NOTE: keep this list in sync with the API namespaces excluded in +# - toolkit/components/extensions/jar.mn +# - toolkit/components/extensions/schemas/jar.mn +WEBEXT_ANDROID_EXCLUDED = [ + "captivePortal", + "geckoProfiler", + "identity" +] diff --git a/toolkit/components/extensions/webidl-api/GenerateWebIDLBindings.py b/toolkit/components/extensions/webidl-api/GenerateWebIDLBindings.py new file mode 100644 index 0000000000..3b31bd924d --- /dev/null +++ b/toolkit/components/extensions/webidl-api/GenerateWebIDLBindings.py @@ -0,0 +1,1612 @@ +# 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 argparse +import difflib +import json +import logging +import os +import subprocess +import sys +import tempfile + +try: + import buildconfig + import jinja2 + import jsonschema + import mozpack.path as mozpath +except ModuleNotFoundError as e: + print( + "This script should be executed using `mach python %s`\n" % __file__, + file=sys.stderr, + ) + raise e + +WEBIDL_DIR = mozpath.join("dom", "webidl") +WEBIDL_DIR_FULLPATH = mozpath.join(buildconfig.topsrcdir, WEBIDL_DIR) + +CPP_DIR = mozpath.join("toolkit", "components", "extensions", "webidl-api") +CPP_DIR_FULLPATH = mozpath.join(buildconfig.topsrcdir, CPP_DIR) + +# Absolute path to the base dir for this script. +BASE_DIR = CPP_DIR_FULLPATH + +# TODO(Bug 1724785): a patch to introduce the doc page linked below is attached to +# this bug and meant to ideally land along with this patch. +DOCS_NEXT_STEPS = """ +The following documentation page provides more in depth details of the next steps: + +https://firefox-source-docs.mozilla.org/toolkit/components/extensions/webextensions/wiring_up_new_webidl_bindings.html +""" + +# Load the configuration file. +glbl = {} +with open(mozpath.join(BASE_DIR, "ExtensionWebIDL.conf")) as f: + exec(f.read(), glbl) + +# Special mapping between the JSON schema type and the related WebIDL type. +WEBEXT_TYPES_MAPPING = glbl["WEBEXT_TYPES_MAPPING"] + +# Special mapping for the `WebExtensionStub` to be used for API methods that +# require special handling. +WEBEXT_STUBS_MAPPING = glbl["WEBEXT_STUBS_MAPPING"] + +# Schema entries that should be hidden in workers. +WEBEXT_WORKER_HIDDEN_SET = glbl["WEBEXT_WORKER_HIDDEN_SET"] + +# Set of the webidl type names to be threated as primitive types. +WEBIDL_PRIMITIVE_TYPES = glbl["WEBIDL_PRIMITIVE_TYPES"] + +# Mapping table for the directory where the JSON schema are going to be loaded from, +# the 'toolkit' ones are potentially available on both desktop and mobile builds +# (if not specified otherwise through the WEBEXT_ANDROID_EXCLUDED list), whereas the +# 'browser' and 'mobile' ones are only available on desktop and mobile builds +# respectively. +# +# `load_and_parse_JSONSchema` will iterate over this map and will call `Schemas` +# load_schemas method passing the path to the directory with the schema data and the +# related key from this map as the `schema_group` associated with all the schema data +# being loaded. +# +# Schema data loaded from different groups may potentially overlap, and the resulting +# generated webidl may contain preprocessing macro to conditionally include different +# webidl signatures on different builds (in particular for the Desktop vs. Android +# differences). +WEBEXT_SCHEMADIRS_MAPPING = glbl["WEBEXT_SCHEMADIRS_MAPPING"] + +# List of toolkit-level WebExtensions API namespaces that are not included in +# android builds. +WEBEXT_ANDROID_EXCLUDED = glbl["WEBEXT_ANDROID_EXCLUDED"] + +# Define a custom jsonschema validation class +WebExtAPIValidator = jsonschema.validators.extend( + jsonschema.validators.Draft4Validator, +) +# Hack: inject any as a valid simple types. +WebExtAPIValidator.META_SCHEMA["definitions"]["simpleTypes"]["enum"].append("any") + + +def run_diff( + diff_cmd, + left_name, + left_text, + right_name, + right_text, + always_return_diff_output=True, +): + """ + Creates two temporary files and run the given `diff_cmd` to generate a diff + between the two temporary files (used to generate diffs related to the JSON + Schema files for desktop and mobile builds) + """ + + diff_output = "" + + # Generate the diff using difflib if diff_cmd isn't set. + if diff_cmd is None: + diff_generator = difflib.unified_diff( + left_text.splitlines(keepends=True), + right_text.splitlines(keepends=True), + fromfile=left_name, + tofile=right_name, + ) + diff_output = "".join(diff_generator) + else: + # Optionally allow to generate the diff using an external diff tool + # (e.g. choosing `icdiff` through `--diff-command icdiff` would generate + # colored side-by-side diffs). + with tempfile.NamedTemporaryFile("w+t", prefix="%s-" % left_name) as left_file: + with tempfile.NamedTemporaryFile( + "w+t", prefix="%s-" % right_name + ) as right_file: + left_file.write(left_text) + left_file.flush() + right_file.write(right_text) + right_file.flush() + diff_output = subprocess.run( + [diff_cmd, "-u", left_file.name, right_file.name], + capture_output=True, + ).stdout.decode("utf-8") + + if always_return_diff_output and len(diff_output) == 0: + return "Diff empty: both files have the exact same content." + + return diff_output + + +def read_json(json_file_path): + """ + Helper function used to read the WebExtensions API schema JSON files + by ignoring the license comment on the top of some of those files. + Same helper as the one available in Schemas.jsm: + https://searchfox.org/mozilla-central/rev/3434a9df60373a997263107e6f124fb164ddebf2/toolkit/components/extensions/Schemas.jsm#70 + """ + with open(json_file_path) as json_file: + txt = json_file.read() + # Chrome JSON files include a license comment that we need to + # strip off for this to be valid JSON. As a hack, we just + # look for the first '[' character, which signals the start + # of the JSON content. + return json.loads(txt[txt.index("[") :]) + + +def write_with_overwrite_confirm( + relpath, + abspath, + newcontent, + diff_prefix, + diff_command=None, + overwrite_existing=False, +): + is_overwriting = False + no_changes = False + + # Make sure generated files do have a newline at the end of the file. + if newcontent[-1] != "\n": + newcontent = newcontent + "\n" + + if os.path.exists(abspath): + with open(abspath, "r") as existingfile: + existingcontent = existingfile.read() + if existingcontent == newcontent: + no_changes = True + elif not overwrite_existing: + print("Found existing %s.\n" % relpath, file=sys.stderr) + print( + "(Run again with --overwrite-existing to allow overwriting it automatically)", + file=sys.stderr, + ) + data = "" + while data not in ["Y", "N", "D"]: + data = input( + "\nOverwrite %s? (Y = Yes / N = No / D = Diff)\n" % relpath + ).upper() + if data == "N": + print( + "Aborted saving updated content to file %s" % relpath, + file=sys.stderr, + ) + return False + elif data == "D": + print( + run_diff( + diff_command, + "%s--existing" % diff_prefix, + "".join(open(abspath, "r").readlines()), + "%s--updated" % diff_prefix, + newcontent, + ) + ) + data = "" # Ask confirmation again after printing diff. + elif data == "Y": + is_overwriting = True + break + else: + is_overwriting = True + + if is_overwriting: + print("Overwriting %s.\n" % relpath, file=sys.stderr) + + if no_changes: + print("No changes for the existing %s.\n" % relpath, file=sys.stderr) + else: + with open(abspath, "w") as dest_file: + dest_file.write(newcontent) + print("Wrote new content in file %s" % relpath) + + # Return true if there were changes written on disk + return not no_changes + + +class DefaultDict(dict): + def __init__(self, createDefault): + self._createDefault = createDefault + + def getOrCreate(self, key): + if key not in self: + self[key] = self._createDefault(key) + return self[key] + + +class WebIDLHelpers: + """ + A collection of helpers used to generate the WebIDL definitions for the + API entries loaded from the collected JSON schema files. + """ + + @classmethod + def expect_instance(cls, obj, expected_class): + """ + Raise a TypeError if `obj` is not an instance of `Class`. + """ + + if not isinstance(obj, expected_class): + raise TypeError( + "Unexpected object type, expected %s: %s" % (expected_class, obj) + ) + + @classmethod + def namespace_to_webidl_definition(cls, api_ns, schema_group): + """ + Generate the WebIDL definition for the given APINamespace instance. + """ + + # TODO: schema_group is currently unused in this method. + template = api_ns.root.jinja_env.get_template("ExtensionAPI.webidl.in") + return template.render(cls.to_template_props(api_ns)) + + @classmethod + def to_webidl_definition_name(cls, text): + """ + Convert a namespace name into its related webidl definition name. + """ + + # Join namespace parts, with capitalized first letters. + name = "Extension" + for part in text.split("."): + name += part[0].upper() + part[1:] + return name + + @classmethod + def to_template_props(cls, api_ns): + """ + Convert an APINamespace object its the set of properties that are + expected by the webidl template. + """ + + cls.expect_instance(api_ns, APINamespace) + + webidl_description_comment = ( + '// WebIDL definition for the "%s" WebExtensions API' % api_ns.name + ) + webidl_name = cls.to_webidl_definition_name(api_ns.name) + + # TODO: some API should not be exposed to service workers (e.g. runtime.getViews), + # refer to a config file to detect this kind of exceptions/special cases. + # + # TODO: once we want to expose the WebIDL bindings to extension windows + # and not just service workers we will need to add "Window" to the + # webidl_exposed_attr and only expose APIs with allowed_context "devtools_only" + # on Windows. + # + # e.g. + # if "devtools_only" in api_ns.allowed_contexts: + # webidl_exposed_attr = ", ".join(["Window"]) + # else: + # webidl_exposed_attr = ", ".join(["ServiceWorker", "Window"]) + if "devtools_only" in api_ns.allowed_contexts: + raise Exception("Not yet supported: devtools_only allowed_contexts") + + if "content_only" in api_ns.allowed_contexts: + raise Exception("Not yet supported: content_only allowed_contexts") + + webidl_exposed_attr = ", ".join(["ServiceWorker"]) + + webidl_definition_body = cls.to_webidl_definition_body(api_ns) + return { + "api_namespace": api_ns.name, + "webidl_description_comment": webidl_description_comment, + "webidl_name": webidl_name, + "webidl_exposed_attr": webidl_exposed_attr, + "webidl_definition_body": webidl_definition_body, + } + + @classmethod + def to_webidl_definition_body(cls, api_ns): + """ + Generate the body of an API namespace webidl definition. + """ + + cls.expect_instance(api_ns, APINamespace) + + body = [] + + # TODO: once we are going to expose the webidl bindings to + # content scripts we should generate a separate definition + # for the content_only parts of the API namespaces and make + # them part of a separate `ExtensionContent<APINamespace>` + # webidl interface (e.g. `ExtensionContentUserScripts` would + # contain only the part of the userScripts API namespace that + # should be available to the content scripts globals. + def should_include(api_entry): + if isinstance( + api_entry, APIFunction + ) and WebIDLHelpers.webext_method_hidden_in_worker(api_entry): + return False + if api_entry.is_mv2_only: + return False + return "content_only" not in api_entry.get_allowed_contexts() + + webidl_functions = [ + cls.to_webidl_method(v) + for v in api_ns.functions.values() + if should_include(v) + ] + if len(webidl_functions) > 0: + body = body + ["\n // API methods.\n", "\n\n".join(webidl_functions)] + + webidl_events = [ + cls.to_webidl_event_property(v) + for v in api_ns.events.values() + if should_include(v) + ] + if len(webidl_events) > 0: + body = body + ["\n // API events.\n", "\n\n".join(webidl_events)] + + webidl_props = [ + cls.to_webidl_property(v) + for v in api_ns.properties.values() + if should_include(v) + ] + if len(webidl_props) > 0: + body = body + ["\n // API properties.\n", "\n\n".join(webidl_props)] + + webidl_child_ns = [ + cls.to_webidl_namespace_property(v) + for v in api_ns.get_child_namespaces() + if should_include(v) + ] + if len(webidl_child_ns) > 0: + body = body + [ + "\n // API child namespaces.\n", + "\n\n".join(webidl_child_ns), + ] + + return "\n".join(body) + + @classmethod + def to_webidl_namespace_property(cls, api_ns): + """ + Generate the webidl fragment for a child APINamespace property (an + API namespace included in a parent API namespace, e.g. `devtools.panels` + is a child namespace for `devtools` and `privacy.network` is a child + namespace for `privacy`). + """ + + cls.expect_instance(api_ns, APINamespace) + + # TODO: at the moment this method is not yet checking if an entry should + # be wrapped into build time macros in the generated webidl definitions + # (as done for methods and event properties). + # + # We may look into it if there is any property that needs this + # (at the moment it seems that we may defer it) + + prop_name = api_ns.name[api_ns.name.find(".") + 1 :] + prop_type = WebIDLHelpers.to_webidl_definition_name(api_ns.name) + attrs = [ + "Replaceable", + "SameObject", + 'BinaryName="Get%s"' % prop_type, + 'Func="mozilla::extensions::%s::IsAllowed' % prop_type, + ] + + lines = [ + " [%s]" % ", ".join(attrs), + " readonly attribute %s %s;" % (prop_type, prop_name), + ] + + return "\n".join(lines) + + @classmethod + def to_webidl_definition(cls, api_entry, schema_group): + """ + Convert a API namespace or entry class instance into its webidl + definition. + """ + + if isinstance(api_entry, APINamespace): + return cls.namespace_to_webidl_definition(api_entry, schema_group) + if isinstance(api_entry, APIFunction): + return cls.to_webidl_method(api_entry, schema_group) + if isinstance(api_entry, APIProperty): + return cls.to_webidl_property(api_entry, schema_group) + if isinstance(api_entry, APIEvent): + return cls.to_webidl_event_property(api_entry, schema_group) + if isinstance(api_entry, APIType): + # return None for APIType instances, which are currently not being + # turned into webidl definitions. + return None + + raise Exception("Unknown api_entry type: %s" % api_entry) + + @classmethod + def to_webidl_property(cls, api_property, schema_group=None): + """ + Returns the WebIDL fragment for the given APIProperty entry to be included + in the body of a WebExtension API namespace webidl definition. + """ + + cls.expect_instance(api_property, APIProperty) + + # TODO: at the moment this method is not yet checking if an entry should + # be wrapped into build time macros in the generated webidl definitions + # (as done for methods and event properties). + # + # We may look into it if there is any property that needs this + # (at the moment it seems that we may defer it) + + attrs = ["Replaceable"] + + schema_data = api_property.get_schema_data(schema_group) + proptype = cls.webidl_type_from_mapping( + schema_data, "%s property type" % api_property.api_path_string + ) + + lines = [ + " [%s]" % ", ".join(attrs), + " readonly attribute %s %s;" % (proptype, api_property.name), + ] + + return "\n".join(lines) + + @classmethod + def to_webidl_event_property(cls, api_event, schema_group=None): + """ + Returns the WebIDL fragment for the given APIEvent entry to be included + in the body of a WebExtension API namespace webidl definition. + """ + + cls.expect_instance(api_event, APIEvent) + + def generate_webidl(group): + # Empty if the event doesn't exist in the given schema_group. + if group and group not in api_event.schema_groups: + return "" + attrs = ["Replaceable", "SameObject"] + return "\n".join( + [ + " [%s]" % ", ".join(attrs), + " readonly attribute ExtensionEventManager %s;" % api_event.name, + ] + ) + + if schema_group is not None: + return generate_webidl(schema_group) + + return cls.maybe_wrap_in_buildtime_macros(api_event, generate_webidl) + + @classmethod + def to_webidl_method(cls, api_fun, schema_group=None): + """ + Returns the WebIDL definition for the given APIFunction entry to be included + in the body of a WebExtension API namespace webidl definition. + """ + + cls.expect_instance(api_fun, APIFunction) + + def generate_webidl(group): + attrs = ["Throws"] + stub_attr = cls.webext_method_stub(api_fun, group) + if stub_attr: + attrs = attrs + [stub_attr] + retval_type = cls.webidl_method_retval_type(api_fun, group) + lines = [] + for fn_params in api_fun.iter_multiple_webidl_signatures_params(group): + params = ", ".join(cls.webidl_method_params(api_fun, group, fn_params)) + lines.extend( + [ + " [%s]" % ", ".join(attrs), + " %s %s(%s);" % (retval_type, api_fun.name, params), + ] + ) + return "\n".join(lines) + + if schema_group is not None: + return generate_webidl(schema_group) + + return cls.maybe_wrap_in_buildtime_macros(api_fun, generate_webidl) + + @classmethod + def maybe_wrap_in_buildtime_macros(cls, api_entry, generate_webidl_fn): + """ + Wrap the generated webidl content into buildtime macros if there are + differences between Android and Desktop JSON schema that turns into + different webidl definitions. + """ + + browser_webidl = None + mobile_webidl = None + + if api_entry.in_browser: + browser_webidl = generate_webidl_fn("browser") + elif api_entry.in_toolkit: + browser_webidl = generate_webidl_fn("toolkit") + + if api_entry.in_mobile: + mobile_webidl = generate_webidl_fn("mobile") + + # Generate a method signature surrounded by `#if defined(ANDROID)` macros + # to conditionally exclude APIs that are not meant to be available in + # Android builds. + if api_entry.in_browser and not api_entry.in_mobile: + return "#if !defined(ANDROID)\n%s\n#endif" % browser_webidl + + # NOTE: at the moment none of the API seems to be exposed on mobile but + # not on desktop. + if api_entry.in_mobile and not api_entry.in_browser: + return "#if defined(ANDROID)\n%s\n#endif" % mobile_webidl + + # NOTE: at the moment none of the API seems to be available in both + # mobile and desktop builds and have different webidl signature + # (at least until not all method param types are converted into non-any + # webidl type signatures) + if browser_webidl != mobile_webidl and mobile_webidl is not None: + return "#if defined(ANDROID)\n%s\n#else\n%s\n#endif" % ( + mobile_webidl, + browser_webidl, + ) + + return browser_webidl + + @classmethod + def webext_method_hidden_in_worker(cls, api_fun, schema_group=None): + """ + Determine if a method should be hidden in the generated webidl + for a worker global. + """ + cls.expect_instance(api_fun, APIFunction) + api_path = ".".join([*api_fun.path]) + return api_path in WEBEXT_WORKER_HIDDEN_SET + + @classmethod + def webext_method_stub(cls, api_fun, schema_group=None): + """ + Returns the WebExtensionStub WebIDL extended attribute for the given APIFunction. + """ + + cls.expect_instance(api_fun, APIFunction) + + stub = "WebExtensionStub" + + api_path = ".".join([*api_fun.path]) + + if api_path in WEBEXT_STUBS_MAPPING: + logging.debug("Looking for %s in WEBEXT_STUBS_MAPPING", api_path) + # if the stub config for a given api_path is a boolean, then do not stub the + # method if it is set to False and use the default one if set to true. + if isinstance(WEBEXT_STUBS_MAPPING[api_path], bool): + if not WEBEXT_STUBS_MAPPING[api_path]: + return "" + else: + return "%s" % stub + return '%s="%s"' % (stub, WEBEXT_STUBS_MAPPING[api_path]) + + schema_data = api_fun.get_schema_data(schema_group) + + is_ambiguous = False + if "allowAmbiguousOptionalArguments" in schema_data: + is_ambiguous = True + + if api_fun.is_async(): + if is_ambiguous: + # Specialized stub for async methods with ambiguous args. + return '%s="AsyncAmbiguous"' % stub + return '%s="Async"' % stub + + if "returns" in schema_data: + # If the method requires special handling just add it to + # the WEBEXT_STUBS_MAPPING table. + return stub + + return '%s="NoReturn"' % stub + + @classmethod + def webidl_method_retval_type(cls, api_fun, schema_group=None): + """ + Return the webidl return value type for the given `APIFunction` entry. + + If the JSON schema for the method is not marked as asynchronous and + there is a `returns` schema property, the return type will be defined + from it (See WebIDLHelpers.webidl_type_from_mapping for more info about + the type mapping). + """ + + cls.expect_instance(api_fun, APIFunction) + + if api_fun.is_async(schema_group): + # webidl signature for the Async methods will return any, then + # the implementation will return a Promise if no callback was passed + # to the method and undefined if the optional chrome compatible callback + # was passed as a parameter. + return "any" + + schema_data = api_fun.get_schema_data(schema_group) + if "returns" in schema_data: + return cls.webidl_type_from_mapping( + schema_data["returns"], "%s return value" % api_fun.api_path_string + ) + + return "undefined" + + @classmethod + def webidl_method_params(cls, api_fun, schema_group=None, params_schema_data=None): + """ + Return the webidl method parameters for the given `APIFunction` entry. + + If the schema for the function includes `allowAmbiguousOptionalArguments` + then the methods paramers are going to be the variadic arguments of type + `any` (e.g. `undefined myMethod(any... args);`). + + If params_schema_data is None, then the parameters will be resolved internally + from the schema data. + """ + + cls.expect_instance(api_fun, APIFunction) + + params = [] + + schema_data = api_fun.get_schema_data(schema_group) + + # Use a variadic positional argument if the methods allows + # ambiguous optional arguments. + # + # The ambiguous mapping is currently used for: + # + # - API methods that have an allowAmbiguousOptionalArguments + # property in their JSONSchema definition + # (e.g. browser.runtime.sendMessage) + # + # - API methods for which the currently autogenerated + # methods are not all distinguishable from a WebIDL + # parser perspective + # (e.g. scripting.getRegisteredContentScripts and + # scripting.unregisterContentScripts, where + # `any filter, optional Function` and `optional Function` + # are not distinguishable when called with a single + # parameter set to an undefined value). + if api_fun.has_ambiguous_stub_mapping(schema_group): + return ["any... args"] + + if params_schema_data is None: + if "parameters" in schema_data: + params_schema_data = schema_data["parameters"] + else: + params_schema_data = [] + + for param in params_schema_data: + is_optional = "optional" in param and param["optional"] + + if ( + api_fun.is_async(schema_group) + and schema_data["async"] == param["name"] + and schema_data["parameters"][-1] == param + ): + # the last async callback parameter is validated and added later + # in this method. + continue + + api_path = api_fun.api_path_string + pname = param["name"] + ptype = cls.webidl_type_from_mapping( + param, f"{api_path} method parameter {pname}" + ) + + if ( + ptype != "any" + and not cls.webidl_type_is_primitive(ptype) + and is_optional + ): + if ptype != "Function": + raise TypeError( + f"unexpected optional type: '{ptype}'. " + f"Only Function is expected to be marked as optional: '{api_path}' parameter '{pname}'" + ) + ptype = f"optional {ptype}" + + params.append(f"{ptype} {pname}") + + if api_fun.is_async(schema_group): + # Add the chrome-compatible callback as an additional optional parameter + # when the method is async. + # + # The parameter name will be "callback" (default) or the custom one set in + # the schema data (`get_sync_callback_name` also validates the consistency + # of the schema data for the callback parameter and throws if the expected + # parameter is missing). + params.append( + f"optional Function {api_fun.get_async_callback_name(schema_group)}" + ) + + return params + + @classmethod + def webidl_type_is_primitive(cls, webidl_type): + return webidl_type in WEBIDL_PRIMITIVE_TYPES + + @classmethod + def webidl_type_from_mapping(cls, schema_data, where_info): + """ + Return the WebIDL type related to the given `schema_data`. + + The JSON schema type is going to be derived from: + - `type` and `isInstanceOf` properties + - or `$ref` property + + and then converted into the related WebIDL type using the + `WEBEXT_TYPES_MAPPING` table. + + The caller needs also specify where the type mapping + where meant to be used in form of an arbitrary string + passed through the `where_info` parameter, which is + only used to log a more detailed debug message for types + there couldn't be resolved from the schema data. + + Returns `any` if no special mapping has been found. + """ + + if "type" in schema_data: + if ( + "isInstanceOf" in schema_data + and schema_data["isInstanceOf"] in WEBEXT_TYPES_MAPPING + ): + schema_type = schema_data["isInstanceOf"] + else: + schema_type = schema_data["type"] + elif "$ref" in schema_data: + schema_type = schema_data["$ref"] + else: + logging.info( + "%s %s. %s: %s", + "Falling back to webidl type 'any' for", + where_info, + "Unable to get a schema_type from schema data", + json.dumps(schema_data, indent=True), + ) + return "any" + + if schema_type in WEBEXT_TYPES_MAPPING: + return WEBEXT_TYPES_MAPPING[schema_type] + + logging.warning( + "%s %s. %s: %s", + "Falling back to webidl type 'any' for", + where_info, + "No type mapping found in WEBEXT_TYPES_MAPPING for schema_type", + schema_type, + ) + + return "any" + + +class APIEntry: + """ + Base class for the classes that represents the JSON schema data. + """ + + def __init__(self, parent, name, ns_path): + self.parent = parent + self.root = parent.root + self.name = name + self.path = [*ns_path, name] + + self.schema_data_list = [] + self.schema_data_by_group = DefaultDict(lambda _: []) + + def add_schema(self, schema_data, schema_group): + """ + Add schema data loaded from a specific group of schema files. + + Each entry may have more than one schema_data coming from a different group + of schema files, but only one entry per schema group is currently expected + and a TypeError is going to raised if this assumption is violated. + + NOTE: entries part of the 'manifest' are expected to have more than one schema_data + coming from the same group of schema files, but it doesn't represent any actual + API namespace and so we can ignore them for the purpose of generating the WebIDL + definitions. + """ + + self.schema_data_by_group.getOrCreate(schema_group).append(schema_data) + + # If the new schema_data is deep equal to an existing one + # don't bother adding it even if it was in a different schema_group. + if schema_data not in self.schema_data_list: + self.schema_data_list.append(schema_data) + + in_manifest_namespace = self.api_path_string.startswith("manifest.") + + # Raise an error if we do have multiple schema entries for the same + # schema group, but skip it for the "manifest" namespace because it. + # is expected for it to have multiple schema data entries for the + # same type and at the moment we don't even use that namespace to + # generate and webidl definitions. + if ( + not in_manifest_namespace + and len(self.schema_data_by_group[schema_group]) > 1 + ): + raise TypeError( + 'Unxpected multiple schema data for API property "%s" in schema group %s' + % (self.api_path_string, schema_group) + ) + + def get_allowed_contexts(self, schema_group=None): + """ + Return the allowed contexts for this API entry, or the default contexts from its + parent entry otherwise. + """ + + if schema_group is not None: + if schema_group not in self.schema_data_by_group: + return [] + if "allowedContexts" in self.schema_data_by_group[schema_group]: + return self.schema_data_by_group[schema_group]["allowedContexts"] + else: + if "allowedContexts" in self.schema_data_list[0]: + return self.schema_data_list[0]["allowedContexts"] + + if self.parent: + return self.parent.default_contexts + + return [] + + @property + def schema_groups(self): + """List of the schema groups that have schema data for this entry.""" + return [*self.schema_data_by_group.keys()] + + @property + def in_toolkit(self): + """Whether the API entry is defined by toolkit schemas.""" + return "toolkit" in self.schema_groups + + @property + def in_browser(self): + """Whether the API entry is defined by browser schemas.""" + return "browser" in self.schema_groups + + @property + def in_mobile(self): + """Whether the API entry is defined by mobile schemas.""" + return "mobile" in self.schema_groups + + @property + def is_mv2_only(self): + # Each API entry should not have multiple max_manifest_version property + # conflicting with each other (even if there is schema data coming from multiple + # JSONSchema files, eg. when a base toolkit schema definition is extended by additional + # schema data on Desktop or Mobile), and so here we just iterate over all the schema + # data related to this entry and look for the first max_manifest_version property value + # we can find if any. + for entry in self.schema_data_list: + if "max_manifest_version" in entry and entry["max_manifest_version"] < 3: + return True + return False + + def dump_platform_diff(self, diff_cmd, only_if_webidl_differ): + """ + Dump a diff of the JSON schema data coming from browser and mobile, + if the API did have schema data loaded from both these group of schema files. + """ + if len(self.schema_groups) <= 1: + return + + # We don't expect any schema data from "toolkit" that we expect to also have + # duplicated (and potentially different) schema data in the other groups + # of schema data ("browser" and "mobile). + # + # For the API that are shared but slightly different in the Desktop and Android + # builds we expect the schema data to only be placed in the related group of schema + # ("browser" and "mobile"). + # + # We throw a TypeError here to detect if that assumption is violated while we are + # collecting the platform diffs, while keeping the logic for the generated diff + # below simple with the guarantee that we wouldn't get to it if that assumption + # is violated. + if "toolkit" in self.schema_groups: + raise TypeError( + "Unexpected diff between toolkit and browser/mobile schema: %s" + % self.api_path_string + ) + + # Compare the webidl signature generated for mobile vs desktop, + # generate different signature surrounded by macro if they differ + # or only include one if the generated webidl signature would still + # be the same. + browser_schema_data = self.schema_data_by_group["browser"][0] + mobile_schema_data = self.schema_data_by_group["mobile"][0] + + if only_if_webidl_differ: + browser_webidl = WebIDLHelpers.to_webidl_definition(self, "browser") + mobile_webidl = WebIDLHelpers.to_webidl_definition(self, "mobile") + + if browser_webidl == mobile_webidl: + return + + json_diff = run_diff( + diff_cmd, + "%s-browser" % self.api_path_string, + json.dumps(browser_schema_data, indent=True), + "%s-mobile" % self.api_path_string, + json.dumps(mobile_schema_data, indent=True), + always_return_diff_output=False, + ) + + if len(json_diff.strip()) == 0: + return + + # Print a diff of the browser vs. mobile JSON schema. + print("\n\n## API schema desktop vs. mobile for %s\n\n" % self.api_path_string) + print("```diff\n%s\n```" % json_diff) + + def get_schema_data(self, schema_group=None): + """ + Get schema data loaded for this entry (optionally from a specific group + of schema files). + """ + if schema_group is None: + return self.schema_data_list[0] + return self.schema_data_by_group[schema_group][0] + + @property + def api_path_string(self): + """Convert the path list into the full namespace string.""" + return ".".join(self.path) + + +class APIType(APIEntry): + """Class to represent an API type""" + + +class APIProperty(APIEntry): + """Class to represent an API property""" + + +class APIEvent(APIEntry): + """Class to represent an API Event""" + + +class APIFunction(APIEntry): + """Class to represent an API function""" + + def is_async(self, schema_group=None): + """ + Returns True is the APIFunction is marked as asynchronous in its schema data. + """ + schema_data = self.get_schema_data(schema_group) + return "async" in schema_data + + def is_optional_param(self, param): + return "optional" in param and param["optional"] + + def is_callback_param(self, param, schema_group=None): + return self.is_async(schema_group) and ( + param["name"] == self.get_async_callback_name(schema_group) + ) + + def iter_multiple_webidl_signatures_params(self, schema_group=None): + """ + Lazily generate the parameters set to use in the multiple webidl definitions + that should be generated by this method, due to a set of optional parameters + followed by a mandatory one. + + NOTE: the caller SHOULD NOT mutate (or save for later use) the list of parameters + yielded by this generator function (because the parameters list and parameters + are not deep cloned and reused internally between yielded values). + """ + schema_data = self.get_schema_data(schema_group) + parameters = schema_data["parameters"].copy() + yield parameters + + if not self.has_multiple_webidl_signatures(schema_group): + return + + def get_next_idx(p): + return parameters.index(p) + 1 + + def get_next_rest(p): + return parameters[get_next_idx(p) : :] + + def is_optional(p): + return self.is_optional_param(p) + + def is_mandatory(p): + return not is_optional(p) + + rest = parameters + while not all(is_mandatory(param) for param in rest): + param = next(filter(is_optional, rest)) + rest = get_next_rest(param) + if self.is_callback_param(param, schema_group): + return + + parameters.remove(param) + yield parameters + + def has_ambiguous_stub_mapping(self, schema_group): + # Determine if the API should be using the AsyncAmbiguous + # stub method per its JSONSchema data. + schema_data = self.get_schema_data(schema_group) + is_ambiguous = False + if "allowAmbiguousOptionalArguments" in schema_data: + is_ambiguous = True + + if not is_ambiguous: + # Determine if the API should be using the AsyncAmbiguous + # stub method per configuration set from ExtensionWebIDL.conf. + api_path = ".".join([*self.path]) + if api_path in WEBEXT_STUBS_MAPPING: + return WEBEXT_STUBS_MAPPING[api_path] == "AsyncAmbiguous" + + return is_ambiguous + + def has_multiple_webidl_signatures(self, schema_group=None): + """ + Determine if the API method in the JSONSchema needs to be turned in + multiple function signatures in the WebIDL definitions (e.g. `alarms.create`, + needs two separate WebIDL definitions accepting 1 and 2 parameters to match the + expected behaviors). + """ + + if self.has_ambiguous_stub_mapping(schema_group): + # The few methods that are marked as ambiguous (only runtime.sendMessage, + # besides the ones in the `test` API) are currently generated as + # a single webidl method with a variadic parameter. + return False + + schema_data = self.get_schema_data(schema_group) + params = schema_data["parameters"] or [] + + return not all(not self.is_optional_param(param) for param in params) + + def get_async_callback_name(self, schema_group): + """ + Get the async callback name, or raise a TypeError if inconsistencies are detected + in the schema data related to the expected callback parameter. + """ + # For an async method we expect the "async" keyword to be either + # set to `true` or to a callback name, in which case we expect + # to have a callback parameter with the same name as the last + # of the function schema parameters: + schema_data = self.get_schema_data(schema_group) + if "async" not in schema_data or schema_data["async"] is False: + raise TypeError("%s schema is not an async function" % self.api_path_string) + + if isinstance(schema_data["async"], str): + cb_name = schema_data["async"] + if "parameters" not in schema_data or not schema_data["parameters"]: + raise TypeError( + "%s is missing a parameter definition for async callback %s" + % (self.api_path_string, cb_name) + ) + + last_param = schema_data["parameters"][-1] + if last_param["name"] != cb_name or last_param["type"] != "function": + raise TypeError( + "%s is missing a parameter definition for async callback %s" + % (self.api_path_string, cb_name) + ) + return cb_name + + # default callback name on `"async": true` in the schema data. + return "callback" + + +class APINamespace: + """Class to represent an API namespace""" + + def __init__(self, root, name, ns_path): + self.root = root + self.name = name + if name: + self.path = [*ns_path, name] + else: + self.path = [*ns_path] + + # All the schema data collected for this namespace across all the + # json schema files loaded, grouped by the schem_group they are being + # loaded from ('toolkit', 'desktop', mobile'). + self.schema_data_by_group = DefaultDict(lambda _: []) + + # class properties populated by parse_schemas. + + self.max_manifest_version = None + self.permissions = set() + self.allowed_contexts = set() + self.default_contexts = set() + + self.types = DefaultDict(lambda type_id: APIType(self, type_id, self.path)) + self.properties = DefaultDict( + lambda prop_id: APIProperty(self, prop_id, self.path) + ) + self.functions = DefaultDict( + lambda fn_name: APIFunction(self, fn_name, self.path) + ) + self.events = DefaultDict( + lambda event_name: APIEvent(self, event_name, self.path) + ) + + def get_allowed_contexts(self): + """ + Return the allowed contexts for this API namespace + """ + return self.allowed_contexts + + @property + def schema_groups(self): + """List of the schema groups that have schema data for this entry.""" + return [*self.schema_data_by_group.keys()] + + @property + def in_toolkit(self): + """Whether the API entry is defined by toolkit schemas.""" + return "toolkit" in self.schema_groups + + @property + def in_browser(self): + """Whether the API entry is defined by browser schemas.""" + return "browser" in self.schema_groups + + @property + def in_mobile(self): + """Whether the API entry is defined by mobile schemas.""" + if self.name in WEBEXT_ANDROID_EXCLUDED: + return False + return "mobile" in self.schema_groups + + @property + def is_mv2_only(self): + return self.max_manifest_version == 2 + + @property + def api_path_string(self): + """Convert the path list into the full namespace string.""" + return ".".join(self.path) + + def add_schema(self, schema_data, schema_group): + """Add schema data loaded from a specific group of schema files.""" + self.schema_data_by_group.getOrCreate(schema_group).append(schema_data) + + def parse_schemas(self): + """Parse all the schema data collected (from all schema groups).""" + for schema_group, schema_data in self.schema_data_by_group.items(): + self._parse_schema_data(schema_data, schema_group) + + def _parse_schema_data(self, schema_data, schema_group): + for data in schema_data: + # TODO: we should actually don't merge together permissions and + # allowedContext/defaultContext, because in some cases the schema files + # are split in two when only part of the API is available to the + # content scripts. + + # load permissions, allowed_contexts and default_contexts + if "permissions" in data: + self.permissions.update(data["permissions"]) + if "allowedContexts" in data: + self.allowed_contexts.update(data["allowedContexts"]) + if "defaultContexts" in data: + self.default_contexts.update(data["defaultContexts"]) + if "max_manifest_version" in data: + if ( + self.max_manifest_version is not None + and self.max_manifest_version != data["max_manifest_version"] + ): + raise TypeError( + "Error loading schema data - overwriting existing max_manifest_version" + " value\n\tPrevious max_manifest_version set: %s\n\tschema_group: %s" + "\n\tschema_data: %s" + % (self.max_manifest_version, schema_group, schema_data) + ) + self.max_manifest_version = data["max_manifest_version"] + + api_path = self.api_path_string + + # load types + if "types" in data: + for type_data in data["types"]: + type_id = None + if "id" in type_data: + type_id = type_data["id"] + elif "$extend" in type_data: + type_id = type_data["$extend"] + elif "unsupported" in type_data: + # No need to raise an error for an unsupported type + # it will ignored below before adding it to the map + # of the namespace types. + pass + else: + # Supported entries without an "id" or "$extend" + # property are unexpected, log a warning and + # fail explicitly if that happens to be the case. + logging.critical( + "Error loading schema data type from '%s %s': %s", + schema_group, + api_path, + json.dumps(type_data, indent=True), + ) + raise TypeError( + "Error loading schema type data defined in '%s %s'" + % (schema_group, api_path), + ) + + if "unsupported" in type_data: + # Skip unsupported type. + logging.debug( + "Skipping unsupported type '%s'", + "%s %s.%s" % (schema_group, api_path, type_id), + ) + continue + + assert type_id + type_entry = self.types.getOrCreate(type_id) + type_entry.add_schema(type_data, schema_group) + + # load properties + if "properties" in data: + for prop_id, prop_data in data["properties"].items(): + # Skip unsupported type. + if "unsupported" in prop_data: + logging.debug( + "Skipping unsupported property '%s'", + "%s %s.%s" % (schema_group, api_path, prop_id), + ) + continue + prop_entry = self.properties.getOrCreate(prop_id) + prop_entry.add_schema(prop_data, schema_group) + + # load functions + if "functions" in data: + for func_data in data["functions"]: + func_name = func_data["name"] + # Skip unsupported function. + if "unsupported" in func_data: + logging.debug( + "Skipping unsupported function '%s'", + "%s %s.%s" % (schema_group, api_path, func_name), + ) + continue + func_entry = self.functions.getOrCreate(func_name) + func_entry.add_schema(func_data, schema_group) + + # load events + if "events" in data: + for event_data in data["events"]: + event_name = event_data["name"] + # Skip unsupported function. + if "unsupported" in event_data: + logging.debug( + "Skipping unsupported event: '%s'", + "%s %s.%s" % (schema_group, api_path, event_name), + ) + continue + event_entry = self.events.getOrCreate(event_name) + event_entry.add_schema(event_data, schema_group) + + def get_child_namespace_names(self): + """Returns the list of child namespaces for the current namespace""" + + # some API namespaces may contains other namespaces + # e.g. 'devtools' does contain 'devtools.inspectedWindow', + # 'devtools.panels' etc. + return [ + ns + for ns in self.root.get_all_namespace_names() + if ns.startswith(self.name + ".") + ] + + def get_child_namespaces(self): + """Returns all the APINamespace instances for the child namespaces""" + return [ + self.root.get_namespace(name) for name in self.get_child_namespace_names() + ] + + def get_boilerplate_cpp_header(self): + template = self.root.jinja_env.get_template("ExtensionAPI.h.in") + webidl_props = WebIDLHelpers.to_template_props(self) + return template.render( + {"webidl_name": webidl_props["webidl_name"], "api_namespace": self.name} + ) + + def get_boilerplate_cpp(self): + template = self.root.jinja_env.get_template("ExtensionAPI.cpp.in") + webidl_props = WebIDLHelpers.to_template_props(self) + return template.render( + {"webidl_name": webidl_props["webidl_name"], "api_namespace": self.name} + ) + + def dump(self, schema_group=None): + """ + Used by the --dump-namespaces-info flag to dump some info + for a given namespace based on all the schema files loaded. + """ + + def get_entry_names_by_group(values): + res = {"both": [], "mobile": [], "browser": []} + for item in values: + if item.in_toolkit or (item.in_browser and item.in_mobile): + res["both"].append(item.name) + elif item.in_browser and not item.in_mobile: + res["browser"].append(item.name) + elif item.in_mobile and not item.in_desktop: + res["mobile"].append(item.name) + return res + + def dump_names_by_group(values): + entries_map = get_entry_names_by_group(values) + print(" both: %s" % entries_map["both"]) + print(" only on desktop: %s" % entries_map["browser"]) + print(" only on mobile: %s" % entries_map["mobile"]) + + if schema_group is not None and [schema_group] != self.schema_groups: + return + + print("\n## %s\n" % self.name) + + print("schema groups: ", self.schema_groups) + print("max manifest version: ", self.max_manifest_version) + print("permissions: ", self.permissions) + print("allowed contexts: ", self.allowed_contexts) + print("default contexts: ", self.default_contexts) + + print("functions:") + dump_names_by_group(self.functions.values()) + fn_multi_signatures = list( + filter( + lambda fn: fn.has_multiple_webidl_signatures(), self.functions.values() + ) + ) + if len(fn_multi_signatures) > 0: + print("functions with multiple WebIDL type signatures:") + for fn in fn_multi_signatures: + print(" -", fn.name) + for params in fn.iter_multiple_webidl_signatures_params(): + print(" -", params) + + print("events:") + dump_names_by_group(self.events.values()) + print("properties:") + dump_names_by_group(self.properties.values()) + print("types:") + dump_names_by_group(self.types.values()) + + print("child namespaces:") + dump_names_by_group(self.get_child_namespaces()) + + +class Schemas: + """Helper class used to load and parse all the schema files""" + + def __init__(self): + self.json_schemas = dict() + self.api_namespaces = DefaultDict(lambda name: APINamespace(self, name, [])) + self.jinja_env = jinja2.Environment( + loader=jinja2.FileSystemLoader(BASE_DIR), + ) + + def load_schemas(self, schema_dir_path, schema_group): + """ + Helper function used to read all WebExtensions API schema JSON files + from a given directory. + """ + for file_name in os.listdir(schema_dir_path): + if file_name.endswith(".json"): + full_path = os.path.join(schema_dir_path, file_name) + rel_path = os.path.relpath(full_path, buildconfig.topsrcdir) + + logging.debug("Loading schema file %s", rel_path) + + schema_data = read_json(full_path) + self.json_schemas[full_path] = schema_data + + for schema_data_entry in schema_data: + name = schema_data_entry["namespace"] + # Validate the schema while loading them. + WebExtAPIValidator.check_schema(schema_data_entry) + + api_ns = self.api_namespaces.getOrCreate(name) + api_ns.add_schema(schema_data_entry, schema_group) + self.api_namespaces[name] = api_ns + + def get_all_namespace_names(self): + """ + Return an array of all namespace names + """ + return [*self.api_namespaces.keys()] + + def parse_schemas(self): + """ + Helper function used to parse all the collected API schemas. + """ + for api_ns in self.api_namespaces.values(): + api_ns.parse_schemas() + + def get_namespace(self, name): + """ + Return a APINamespace instance for the given api name. + """ + return self.api_namespaces[name] + + def dump_namespaces(self): + """ + Dump all namespaces collected to stdout. + """ + print(self.get_all_namespace_names()) + + def dump(self): + """ + Dump all collected schema to stdout. + """ + print(json.dumps(self.json_schemas, indent=True)) + + +def parse_command_and_args(): + parser = argparse.ArgumentParser() + + # global cli flags shared by all sub-commands. + parser.add_argument("--verbose", "-v", action="count", default=0) + parser.add_argument( + "--diff-command", + type=str, + metavar="DIFFCMD", + help="select the diff command used to generate diffs (defaults to 'diff')", + ) + + parser.add_argument( + "--generate-cpp-boilerplate", + action="store_true", + help="'generate' command flag to be used to generate cpp boilerplate" + + " for the given NAMESPACE", + ) + parser.add_argument( + "--overwrite-existing", + action="store_true", + help="'generate' command flag to be used to allow the script to" + + " overwrite existing files (API webidl and cpp boilerplate files)", + ) + + parser.add_argument( + "api_namespaces", + type=str, + metavar="NAMESPACE", + nargs="+", + help="WebExtensions API namespaces to generate webidl and cpp boilerplates for", + ) + + return parser.parse_args() + + +def load_and_parse_JSONSchema(): + """Load and parse all JSONSchema data""" + + # Initialize Schemas and load all the JSON schema from the directories + # listed in WEBEXT_SCHEMADIRS_MAPPING. + schemas = Schemas() + for schema_group, schema_dir_components in WEBEXT_SCHEMADIRS_MAPPING.items(): + schema_dir = mozpath.join(buildconfig.topsrcdir, *schema_dir_components) + schemas.load_schemas(schema_dir, schema_group) + + # Parse all the schema loaded (which also run some validation based on the + # expectations of the code that generates the webidl definitions). + schemas.parse_schemas() + + return schemas + + +# Run the 'generate' subcommand which does: +# +# - generates the webidl file for the new API +# - generate boilerplate for the C++ files that implements the new webidl definition +# - provides details about the rest of steps needed to fully wire up the WebExtensions API +# in the `browser` and `chrome` globals defined through WebIDL. +# +# This command is the entry point for the main feature provided by this scripts. +def run_generate_command(args, schemas): + show_next_steps = False + + for api_ns_str in args.api_namespaces: + webidl_name = WebIDLHelpers.to_webidl_definition_name(api_ns_str) + + # Generate webidl definition. + webidl_relpath = mozpath.join(WEBIDL_DIR, "%s.webidl" % webidl_name) + webidl_abspath = mozpath.join(WEBIDL_DIR_FULLPATH, "%s.webidl" % webidl_name) + print( + "\nGenerating webidl definition for '%s' => %s" + % (api_ns_str, webidl_relpath) + ) + api_ns = schemas.get_namespace(api_ns_str) + + did_wrote_webidl_changes = write_with_overwrite_confirm( + relpath=webidl_relpath, + abspath=webidl_abspath, + newcontent=WebIDLHelpers.to_webidl_definition(api_ns, None), + diff_prefix="%s.webidl" % webidl_name, + diff_command=args.diff_command, + overwrite_existing=args.overwrite_existing, + ) + + if did_wrote_webidl_changes: + show_next_steps = True + + cpp_abspath = mozpath.join(CPP_DIR_FULLPATH, "%s.cpp" % webidl_name) + cpp_header_abspath = mozpath.join(CPP_DIR_FULLPATH, "%s.h" % webidl_name) + + cpp_files_exist = os.path.exists(cpp_abspath) and os.path.exists( + cpp_header_abspath + ) + + # Generate c++ boilerplate files if forced by the cli flag or + # if the cpp files do not exist yet. + if args.generate_cpp_boilerplate or not cpp_files_exist: + print( + "\nGenerating C++ boilerplate for '%s' => %s.h/.cpp" + % (api_ns_str, webidl_name) + ) + + cpp_relpath = mozpath.join(CPP_DIR, "%s.cpp" % webidl_name) + cpp_header_relpath = mozpath.join(CPP_DIR, "%s.h" % webidl_name) + + write_with_overwrite_confirm( + relpath=cpp_header_relpath, + abspath=cpp_header_abspath, + newcontent=api_ns.get_boilerplate_cpp_header(), + diff_prefix="%s.h" % webidl_name, + diff_command=args.diff_command, + overwrite_existing=False, + ) + write_with_overwrite_confirm( + relpath=cpp_relpath, + abspath=cpp_abspath, + newcontent=api_ns.get_boilerplate_cpp(), + diff_prefix="%s.cpp" % webidl_name, + diff_command=args.diff_command, + overwrite_existing=False, + ) + + if show_next_steps: + separator = "-" * 20 + print( + "\n%s\n\n" + "NEXT STEPS\n" + "==========\n\n" + "It is not done yet!!!\n" + "%s" % (separator, DOCS_NEXT_STEPS) + ) + + +def set_logging_level(verbose): + """Set the logging level (defaults to WARNING), and increased to + INFO or DEBUG based on the verbose counter flag value""" + # Increase logging level based on the args.verbose counter flag value. + # (Default logging level should include warnings). + if verbose == 0: + logging_level = "WARNING" + elif verbose >= 2: + logging_level = "DEBUG" + else: + logging_level = "INFO" + logging.getLogger().setLevel(logging_level) + logging.info("Logging level set to %s", logging_level) + + +def main(): + """Entry point function for this script""" + + args = parse_command_and_args() + set_logging_level(args.verbose) + schemas = load_and_parse_JSONSchema() + run_generate_command(args, schemas) + + +if __name__ == "__main__": + main() diff --git a/toolkit/components/extensions/webidl-api/InspectJSONSchema.py b/toolkit/components/extensions/webidl-api/InspectJSONSchema.py new file mode 100644 index 0000000000..68c696ef63 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/InspectJSONSchema.py @@ -0,0 +1,152 @@ +# 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 argparse +import sys + +# Sanity check (ensure the script has been executed through `mach python`). +try: + import buildconfig + + # try to access an existing property to please flake8 linting and as an + # additional sanity check. + buildconfig.topsrcdir +except ModuleNotFoundError or AttributeError: + print( + "This script should be executed using `mach python %s`" % __file__, + file=sys.stderr, + ) + sys.exit(1) + +from GenerateWebIDLBindings import load_and_parse_JSONSchema, set_logging_level + + +def get_args_and_argparser(): + parser = argparse.ArgumentParser() + + # global cli flags shared by all sub-commands. + parser.add_argument("--verbose", "-v", action="count", default=0) + parser.add_argument( + "--diff-command", + type=str, + metavar="DIFFCMD", + help="select the diff command used to generate diffs (defaults to 'diff')", + ) + + # --dump-namespaces-list flag (this is also the default for the 'inspect' command + # when no other flag is specified). + parser.add_argument( + "--dump-namespaces-list", + action="store_true", + help="'inspect' command flag - dump list of all API namespaces defined in all" + + " JSONSchema files loaded", + ) + + # --dump-platform-diffs flag and other sub-flags that can be used with it. + parser.add_argument( + "--dump-platform-diffs", + action="store_true", + help="'inspect' command flag - list all APIs with platform specific differences", + ) + parser.add_argument( + "--only-if-webidl-diffs", + action="store_true", + help="'inspect' command flag - limits --dump-platform-diff to APIs with differences" + + " in the generated webidl", + ) + + # --dump-namespaces-info flag and other flags that can be used with it. + parser.add_argument( + "--dump-namespaces-info", + nargs="+", + type=str, + metavar="NAMESPACE", + help="'inspect' command flag - dump data loaded for the given NAMESPACE(s)", + ) + parser.add_argument( + "--only-in-schema-group", + type=str, + metavar="SCHEMAGROUP", + help="'inspect' command flag - list api namespace in the given schema group" + + " (toolkit, browser or mobile)", + ) + + args = parser.parse_args() + + return [args, parser] + + +# Run the 'inspect' subcommand: these command (and its cli flags) is useful to +# inspect the JSONSchema data loaded, which is explicitly useful when debugging +# or evaluating changes to this scripts (e.g. changes that may be needed if the +# API namespace definition isn't complete or its generated content has issues). +def run_inspect_command(args, schemas, parser): + # --dump-namespaces-info: print a summary view of all the namespaces available + # after loading and parsing all the collected JSON schema files. + if args.dump_namespaces_info: + if "ALL" in args.dump_namespaces_info: + for namespace in schemas.get_all_namespace_names(): + schemas.get_namespace(namespace).dump(args.only_in_schema_group) + + return + + for namespace in args.dump_namespaces_info: + schemas.get_namespace(namespace).dump(args.only_in_schema_group) + return + + # --dump-platform-diffs: print diffs for the JSON schema where we detected + # differences between the desktop and mobile JSON schema files. + if args.dump_platform_diffs: + for namespace in schemas.get_all_namespace_names(): + apiNamespace = schemas.get_namespace(namespace) + if len(apiNamespace.schema_groups) <= 1: + continue + for apiMethod in apiNamespace.functions.values(): + if len(apiNamespace.schema_groups) <= 1: + continue + apiMethod.dump_platform_diff( + args.diff_command, args.only_if_webidl_diffs + ) + for apiEvent in apiNamespace.events.values(): + if len(apiEvent.schema_groups) <= 1: + continue + apiEvent.dump_platform_diff( + args.diff_command, args.only_if_webidl_diffs + ) + for apiProperty in apiNamespace.properties.values(): + if len(apiProperty.schema_groups) <= 1: + continue + apiProperty.dump_platform_diff( + args.diff_command, args.only_if_webidl_diffs + ) + # TODO: ideally we may also want to report differences in the + # type definitions, but this requires also some tweaks to adjust + # dump_platform_diff expectations and logic. + return + + # Dump the list of all known API namespaces based on all the loaded JSONSchema data. + if args.dump_namespaces_list: + schemas.dump_namespaces() + return + + # Print the help message and exit 1 as a fallback. + print( + "ERROR: No option selected, choose one from the following usage message.\n", + file=sys.stderr, + ) + parser.print_help() + sys.exit(1) + + +def main(): + """Entry point function for this script""" + + [args, parser] = get_args_and_argparser() + set_logging_level(args.verbose) + schemas = load_and_parse_JSONSchema() + run_inspect_command(args, schemas, parser) + + +if __name__ == "__main__": + main() diff --git a/toolkit/components/extensions/webidl-api/moz.build b/toolkit/components/extensions/webidl-api/moz.build new file mode 100644 index 0000000000..3acfb87516 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/moz.build @@ -0,0 +1,78 @@ +# -*- 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 = ("WebExtensions", "General") + +# WebExtensions API objects and request handling internals. +UNIFIED_SOURCES += [ + "ExtensionAPIBase.cpp", + "ExtensionAPIRequest.cpp", + "ExtensionAPIRequestForwarder.cpp", + "ExtensionBrowser.cpp", + "ExtensionEventListener.cpp", + "ExtensionEventManager.cpp", + "ExtensionPort.cpp", +] + +EXPORTS.mozilla.extensions += [ + "ExtensionAPIBase.h", + "ExtensionBrowser.h", + "ExtensionEventManager.h", + "ExtensionPort.h", +] + +# WebExtensions API namespaces. +UNIFIED_SOURCES += [ + "ExtensionAlarms.cpp", + "ExtensionBrowserSettings.cpp", + "ExtensionBrowserSettingsColorManagement.cpp", + "ExtensionDns.cpp", + "ExtensionProxy.cpp", + "ExtensionRuntime.cpp", + "ExtensionScripting.cpp", + "ExtensionSetting.cpp", + "ExtensionTest.cpp", +] + +EXPORTS.mozilla.extensions += [ + "ExtensionAlarms.h", + "ExtensionBrowserSettings.h", + "ExtensionBrowserSettingsColorManagement.h", + "ExtensionDns.h", + "ExtensionProxy.h", + "ExtensionRuntime.h", + "ExtensionScripting.h", + "ExtensionSetting.h", + "ExtensionTest.h", +] + +# The following is not a real WebExtensions API, it is a test WebIDL +# interface that includes a collection of the cases useful to unit +# test the API request forwarding mechanism without tying it to +# a specific WebExtensions API. +UNIFIED_SOURCES += ["ExtensionMockAPI.cpp"] +EXPORTS.mozilla.extensions += ["ExtensionMockAPI.h"] + +# Propagate the build config to be able to use it in souce code preprocessing +# (used in mozilla::extensions::ExtensionAPIAllowed to disable the webidl +# bindings in non-nightly builds). +if CONFIG["MOZ_WEBEXT_WEBIDL_ENABLED"]: + DEFINES["MOZ_WEBEXT_WEBIDL_ENABLED"] = True + +include("/ipc/chromium/chromium-config.mozbuild") + +LOCAL_INCLUDES += [ + "/js/xpconnect/src", +] + +FINAL_LIBRARY = "xul" + +# Must be defined unconditionally (TC tasks doesn't account for build +# configs and these tests do not depend on the bindings to be enabled). +PYTHON_UNITTEST_MANIFESTS += ["test/python.toml"] + +include("/tools/fuzzing/libfuzzer-config.mozbuild") diff --git a/toolkit/components/extensions/webidl-api/test/README.md b/toolkit/components/extensions/webidl-api/test/README.md new file mode 100644 index 0000000000..14baae0d82 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/test/README.md @@ -0,0 +1,60 @@ +pytest test coverage for the GenerateWebIDLBindings.py script +============================================================= + +This directory contains tests for the GenerateWebIDLBindings.py script, +which is used to parse the WebExtensions APIs schema files and generate +the corresponding WebIDL definitions. + +See ["WebIDL WebExtensions API Bindings" section from the Firefox Developer documentation](https://firefox-source-docs.mozilla.org/toolkit/components/extensions/webextensions/webidl_bindings.html) +for more details about how the script is used, this README covers only how +this test suite works. + +Run tests +--------- + +The tests part of this test suite can be executed locally using the following `mach` command: + +``` +mach python-test toolkit/components/extensions/webidl-api/test +``` + +Write a new test file +--------------------- + +To add a new test file to this test suite: +- create a new python script file named as `test_....py` +- add the test file to the `python.ini` manifest +- In the new test file make sure to include: + - copyright notes as the other test file in this directory + - import the helper module and call its `setup()` method (`setup` makes sure to add + the directory where the target script is in the python library paths and the + `helpers` module does also enable the code coverage if the environment variable + is detected): + ``` + import helpers # Import test helpers module. + ... + + helpers.setup() + ``` + - don't forget to call `mozunit.main` at the end of the test file: + ``` + if __name__ == "__main__": + mozunit.main() + ``` + - add new test cases by defining new functions named as `test_...`, + its parameter are the names of the pytest fixture functions to + be passed to the test case: + ``` + def test_something(base_schema, write_jsonschema_fixtures): + ... + ``` +Create new test fixtures +------------------------ + +All the test fixture used by this set of tests are defined in `conftest.py` +and decorated with `@pytest.fixture`. + +See the pytest documentation for more details about how the pytest fixture works: +- https://docs.pytest.org/en/latest/explanation/fixtures.html +- https://docs.pytest.org/en/latest/how-to/fixtures.html +- https://docs.pytest.org/en/latest/reference/fixtures.html diff --git a/toolkit/components/extensions/webidl-api/test/conftest.py b/toolkit/components/extensions/webidl-api/test/conftest.py new file mode 100644 index 0000000000..1e41ed0690 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/test/conftest.py @@ -0,0 +1,39 @@ +# 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 os + +import pytest + + +@pytest.fixture +def base_schema(): + def inner(): + return { + "namespace": "testAPIName", + "permissions": [], + "types": [], + "functions": [], + "events": [], + } + + return inner + + +@pytest.fixture +def write_jsonschema_fixtures(tmpdir): + """Write test schema data into per-testcase (in tmpdir or the given directory)""" + + def inner(jsonschema_fixtures, targetdir=None): + assert jsonschema_fixtures + if targetdir is None: + targetdir = tmpdir + for filename, filecontent in jsonschema_fixtures.items(): + assert isinstance(filename, str) and filename + assert isinstance(filecontent, str) and filecontent + with open(os.path.join(targetdir, filename), "w") as jsonfile: + jsonfile.write(filecontent) + return targetdir + + return inner diff --git a/toolkit/components/extensions/webidl-api/test/helpers.py b/toolkit/components/extensions/webidl-api/test/helpers.py new file mode 100644 index 0000000000..e2ebec7103 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/test/helpers.py @@ -0,0 +1,22 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import sys + +import mozpack.path as mozpath + +setup_called = False + + +def setup(): + """Add the directory of the targeted python modules to the python sys.path""" + + global setup_called + if setup_called: + return + setup_called = True + + OUR_DIR = mozpath.abspath(mozpath.dirname(__file__)) + TARGET_MOD_DIR = mozpath.normpath(mozpath.join(OUR_DIR, "..")) + sys.path.append(TARGET_MOD_DIR) diff --git a/toolkit/components/extensions/webidl-api/test/python.toml b/toolkit/components/extensions/webidl-api/test/python.toml new file mode 100644 index 0000000000..b46d5aa895 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/test/python.toml @@ -0,0 +1,10 @@ +[DEFAULT] +subsuite = "webext-python" + +["test_all_schemas_smoketest.py"] + +["test_json_schema_parsing.py"] + +["test_json_schema_platform_diffs.py"] + +["test_webidl_from_json_schema.py"] diff --git a/toolkit/components/extensions/webidl-api/test/test_all_schemas_smoketest.py b/toolkit/components/extensions/webidl-api/test/test_all_schemas_smoketest.py new file mode 100644 index 0000000000..f0ab6d496e --- /dev/null +++ b/toolkit/components/extensions/webidl-api/test/test_all_schemas_smoketest.py @@ -0,0 +1,22 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import helpers # Import test helpers module. +import mozunit + +helpers.setup() + +from GenerateWebIDLBindings import load_and_parse_JSONSchema + + +def test_all_jsonschema_load_and_parse_smoketest(): + """Make sure it can load and parse all JSONSchema files successfully""" + schemas = load_and_parse_JSONSchema() + assert schemas + assert len(schemas.json_schemas) > 0 + assert len(schemas.api_namespaces) > 0 + + +if __name__ == "__main__": + mozunit.main() diff --git a/toolkit/components/extensions/webidl-api/test/test_json_schema_parsing.py b/toolkit/components/extensions/webidl-api/test/test_json_schema_parsing.py new file mode 100644 index 0000000000..79b59bf928 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/test/test_json_schema_parsing.py @@ -0,0 +1,215 @@ +# 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 json +import os +from textwrap import dedent + +import helpers # Import test helpers module. +import mozunit +import pytest + +helpers.setup() + +from GenerateWebIDLBindings import APIEvent, APIFunction, APINamespace, APIType, Schemas + + +def test_parse_simple_single_api_namespace(write_jsonschema_fixtures): + """ + Test Basic loading and parsing a single API JSONSchema: + - single line comments outside of the json structure are ignored + - parse a simple namespace that includes one permission, type, + function and event + """ + schema_dir = write_jsonschema_fixtures( + { + "test_api.json": dedent( + """ + // Single line comments added before the JSON data are tolerated + // and ignored. + [ + { + "namespace": "fantasyApi", + "permissions": ["fantasyPermission"], + "types": [ + { + "id": "MyType", + "type": "string", + "choices": ["value1", "value2"] + } + ], + "functions": [ + { + "name": "myMethod", + "type": "function", + "parameters": [ + { "name": "fnParam", "type": "string" }, + { "name": "fnRefParam", "$ref": "MyType" } + ] + } + ], + "events": [ + { + "name": "onSomeEvent", + "type": "function", + "parameters": [ + { "name": "evParam", "type": "string" }, + { "name": "evRefParam", "$ref": "MyType" } + ] + } + ] + } + ] + """ + ), + } + ) + + schemas = Schemas() + schemas.load_schemas(schema_dir, "toolkit") + + assert schemas.get_all_namespace_names() == ["fantasyApi"] + + apiNs = schemas.api_namespaces["fantasyApi"] + assert isinstance(apiNs, APINamespace) + + # Properties related to where the JSON schema is coming from + # (toolkit, browser or mobile schema directories). + assert apiNs.in_toolkit + assert not apiNs.in_browser + assert not apiNs.in_mobile + + # api_path_string is expected to be exactly the namespace name for + # non-nested API namespaces. + assert apiNs.api_path_string == "fantasyApi" + + # parse the schema and verify it includes the expected types events and function. + schemas.parse_schemas() + + assert set(["fantasyPermission"]) == apiNs.permissions + assert ["MyType"] == list(apiNs.types.keys()) + assert ["myMethod"] == list(apiNs.functions.keys()) + assert ["onSomeEvent"] == list(apiNs.events.keys()) + + type_entry = apiNs.types.get("MyType") + fn_entry = apiNs.functions.get("myMethod") + ev_entry = apiNs.events.get("onSomeEvent") + + assert isinstance(type_entry, APIType) + assert isinstance(fn_entry, APIFunction) + assert isinstance(ev_entry, APIEvent) + + +def test_parse_error_on_types_without_id_or_extend( + base_schema, write_jsonschema_fixtures +): + """ + Test parsing types without id or $extend raise an error while parsing. + """ + schema_dir = write_jsonschema_fixtures( + { + "test_broken_types.json": json.dumps( + [ + { + **base_schema(), + "namespace": "testBrokenTypeAPI", + "types": [ + { + # type with no "id2 or "$ref" properties + } + ], + } + ] + ) + } + ) + + schemas = Schemas() + schemas.load_schemas(schema_dir, "toolkit") + + with pytest.raises( + Exception, + match=r"Error loading schema type data defined in 'toolkit testBrokenTypeAPI'", + ): + schemas.parse_schemas() + + +def test_parse_ignores_unsupported_types(base_schema, write_jsonschema_fixtures): + """ + Test parsing types without id or $extend raise an error while parsing. + """ + schema_dir = write_jsonschema_fixtures( + { + "test_broken_types.json": json.dumps( + [ + { + **base_schema(), + "namespace": "testUnsupportedTypesAPI", + "types": [ + { + "id": "AnUnsupportedType", + "type": "string", + "unsupported": True, + }, + { + # missing id or $ref shouldn't matter + # no parsing error expected. + "unsupported": True, + }, + {"id": "ASupportedType", "type": "string"}, + ], + } + ] + ) + } + ) + + schemas = Schemas() + schemas.load_schemas(schema_dir, "toolkit") + schemas.parse_schemas() + apiNs = schemas.api_namespaces["testUnsupportedTypesAPI"] + assert set(apiNs.types.keys()) == set(["ASupportedType"]) + + +def test_parse_error_on_namespace_with_inconsistent_max_manifest_version( + base_schema, write_jsonschema_fixtures, tmpdir +): + """ + Test parsing types without id or $extend raise an error while parsing. + """ + browser_schema_dir = os.path.join(tmpdir, "browser") + mobile_schema_dir = os.path.join(tmpdir, "mobile") + os.mkdir(browser_schema_dir) + os.mkdir(mobile_schema_dir) + + base_namespace_schema = { + **base_schema(), + "namespace": "testInconsistentMaxManifestVersion", + } + + browser_schema = {**base_namespace_schema, "max_manifest_version": 2} + mobile_schema = {**base_namespace_schema, "max_manifest_version": 3} + + write_jsonschema_fixtures( + {"test_inconsistent_maxmv.json": json.dumps([browser_schema])}, + browser_schema_dir, + ) + + write_jsonschema_fixtures( + {"test_inconsistent_maxmv.json": json.dumps([mobile_schema])}, mobile_schema_dir + ) + + schemas = Schemas() + schemas.load_schemas(browser_schema_dir, "browser") + schemas.load_schemas(mobile_schema_dir, "mobile") + + with pytest.raises( + TypeError, + match=r"Error loading schema data - overwriting existing max_manifest_version value", + ): + schemas.parse_schemas() + + +if __name__ == "__main__": + mozunit.main() diff --git a/toolkit/components/extensions/webidl-api/test/test_json_schema_platform_diffs.py b/toolkit/components/extensions/webidl-api/test/test_json_schema_platform_diffs.py new file mode 100644 index 0000000000..51ae918eb0 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/test/test_json_schema_platform_diffs.py @@ -0,0 +1,153 @@ +# 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 os +import types +from textwrap import dedent + +import helpers # Import test helpers module. +import mozunit + +helpers.setup() + +from GenerateWebIDLBindings import Schemas +from InspectJSONSchema import run_inspect_command + + +def test_inspect_schema_platform_diffs(capsys, write_jsonschema_fixtures, tmpdir): + """ + Test InspectJSONSchema --dump-platform-diff command. + """ + browser_schema_dir = os.path.join(tmpdir, "browser") + mobile_schema_dir = os.path.join(tmpdir, "mobile") + os.mkdir(browser_schema_dir) + os.mkdir(mobile_schema_dir) + + write_jsonschema_fixtures( + { + "test_api_browser.json": dedent( + """ + [ + { + "namespace": "apiWithDiff", + "functions": [ + { + "name": "sharedMethod", + "type": "function", + "parameters": [ + { "name": "sharedParam", "type": "string" }, + { "name": "desktopOnlyMethodParam", "type": "string" } + ] + }, + { + "name": "desktopMethod", + "type": "function", + "parameters": [] + } + ], + "events": [ + { + "name": "onSharedEvent", + "type": "function", + "parameters": [ + { "name": "sharedParam", "type": "string" }, + { "name": "desktopOnlyEventParam", "type": "string" } + ] + } + ], + "properties": { + "sharedProperty": { "type": "string", "value": "desktop-value" }, + "desktopOnlyProperty": { "type": "string", "value": "desktop-only-value" } + } + } + ] + """ + ) + }, + browser_schema_dir, + ) + + write_jsonschema_fixtures( + { + "test_api_mobile.json": dedent( + """ + [ + { + "namespace": "apiWithDiff", + "functions": [ + { + "name": "sharedMethod", + "type": "function", + "parameters": [ + { "name": "sharedParam", "type": "string" }, + { "name": "mobileOnlyMethodParam", "type": "string" } + ] + }, + { + "name": "mobileMethod", + "type": "function", + "parameters": [] + } + ], + "events": [ + { + "name": "onSharedEvent", + "type": "function", + "parameters": [ + { "name": "sharedParam", "type": "string" }, + { "name": "mobileOnlyEventParam", "type": "string" } + ] + } + ], + "properties": { + "sharedProperty": { "type": "string", "value": "mobile-value" }, + "mobileOnlyProperty": { "type": "string", "value": "mobile-only-value" } + } + } + ] + """ + ) + }, + mobile_schema_dir, + ) + + schemas = Schemas() + schemas.load_schemas(browser_schema_dir, "browser") + schemas.load_schemas(mobile_schema_dir, "mobile") + + assert schemas.get_all_namespace_names() == ["apiWithDiff"] + apiNs = schemas.api_namespaces["apiWithDiff"] + assert apiNs.in_browser + assert apiNs.in_mobile + + apiNs.parse_schemas() + + fakeArgs = types.SimpleNamespace() + fakeArgs.dump_namespaces_info = False + fakeArgs.dump_platform_diffs = True + fakeArgs.only_if_webidl_diffs = False + fakeArgs.diff_command = None + + fakeParser = types.SimpleNamespace() + fakeParser.print_help = lambda: None + + run_inspect_command(fakeArgs, schemas, fakeParser) + + captured = capsys.readouterr() + assert "API schema desktop vs. mobile for apiWithDiff.sharedMethod" in captured.out + assert '- "name": "desktopOnlyMethodParam",' in captured.out + assert '+ "name": "mobileOnlyMethodParam",' in captured.out + assert "API schema desktop vs. mobile for apiWithDiff.onSharedEvent" in captured.out + assert '- "name": "desktopOnlyEventParam",' in captured.out + assert '+ "name": "mobileOnlyEventParam",' in captured.out + assert ( + "API schema desktop vs. mobile for apiWithDiff.sharedProperty" in captured.out + ) + assert '- "value": "desktop-value"' in captured.out + assert '+ "value": "mobile-value"' in captured.out + assert captured.err == "" + + +if __name__ == "__main__": + mozunit.main() diff --git a/toolkit/components/extensions/webidl-api/test/test_webidl_from_json_schema.py b/toolkit/components/extensions/webidl-api/test/test_webidl_from_json_schema.py new file mode 100644 index 0000000000..64cd7a7361 --- /dev/null +++ b/toolkit/components/extensions/webidl-api/test/test_webidl_from_json_schema.py @@ -0,0 +1,110 @@ +# 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/. + +from textwrap import dedent + +import helpers # Import test helpers module. +import mozunit + +helpers.setup() + +from GenerateWebIDLBindings import ( + WEBEXT_STUBS_MAPPING, + APIFunction, + Schemas, + WebIDLHelpers, +) + +original_stub_mapping_config = WEBEXT_STUBS_MAPPING.copy() + + +def teardown_function(): + WEBEXT_STUBS_MAPPING.clear() + for key in original_stub_mapping_config: + WEBEXT_STUBS_MAPPING[key] = original_stub_mapping_config[key] + + +def test_ambiguous_stub_mappings(write_jsonschema_fixtures): + """ + Test generated webidl for methods that are either + - being marked as ambiguous because of the "allowAmbiguousOptionalArguments" property + in their JSONSchema definition + - mapped to "AsyncAmbiguous" stub per WEBEXT_STUBS_MAPPING python script config + """ + + schema_dir = write_jsonschema_fixtures( + { + "test_api.json": dedent( + """ + [ + { + "namespace": "testAPINamespace", + "functions": [ + { + "name": "jsonSchemaAmbiguousMethod", + "type": "function", + "allowAmbiguousOptionalArguments": true, + "async": true, + "parameters": [ + {"type": "any", "name": "param1", "optional": true}, + {"type": "any", "name": "param2", "optional": true}, + {"type": "string", "name": "param3", "optional": true} + ] + }, + { + "name": "configuredAsAmbiguousMethod", + "type": "function", + "async": "callback", + "parameters": [ + {"name": "param1", "optional": true, "type": "object"}, + {"name": "callback", "type": "function", "parameters": []} + ] + } + ] + } + ] + """ + ) + } + ) + + assert "testAPINamespace.configuredAsAmbiguousMethod" not in WEBEXT_STUBS_MAPPING + # NOTE: mocked config reverted in the teardown_method pytest hook. + WEBEXT_STUBS_MAPPING[ + "testAPINamespace.configuredAsAmbiguousMethod" + ] = "AsyncAmbiguous" + + schemas = Schemas() + schemas.load_schemas(schema_dir, "toolkit") + + assert schemas.get_all_namespace_names() == ["testAPINamespace"] + schemas.parse_schemas() + + apiNs = schemas.get_namespace("testAPINamespace") + fnAmbiguousBySchema = apiNs.functions.get("jsonSchemaAmbiguousMethod") + + assert isinstance(fnAmbiguousBySchema, APIFunction) + generated_webidl = WebIDLHelpers.to_webidl_definition(fnAmbiguousBySchema, None) + expected_webidl = "\n".join( + [ + ' [Throws, WebExtensionStub="AsyncAmbiguous"]', + " any jsonSchemaAmbiguousMethod(any... args);", + ] + ) + assert generated_webidl == expected_webidl + + fnAmbiguousByConfig = apiNs.functions.get("configuredAsAmbiguousMethod") + assert isinstance(fnAmbiguousByConfig, APIFunction) + generated_webidl = WebIDLHelpers.to_webidl_definition(fnAmbiguousByConfig, None) + expected_webidl = "\n".join( + [ + ' [Throws, WebExtensionStub="AsyncAmbiguous"]', + " any configuredAsAmbiguousMethod(any... args);", + ] + ) + assert generated_webidl == expected_webidl + + +if __name__ == "__main__": + mozunit.main() diff --git a/toolkit/components/extensions/webrequest/ChannelWrapper.cpp b/toolkit/components/extensions/webrequest/ChannelWrapper.cpp new file mode 100644 index 0000000000..b3e1bbcda6 --- /dev/null +++ b/toolkit/components/extensions/webrequest/ChannelWrapper.cpp @@ -0,0 +1,1281 @@ +/* -*- 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 "ChannelWrapper.h" + +#include "jsapi.h" +#include "xpcpublic.h" + +#include "mozilla/BasePrincipal.h" +#include "mozilla/SystemPrincipal.h" + +#include "NSSErrorsService.h" +#include "nsITransportSecurityInfo.h" + +#include "mozilla/AddonManagerWebAPI.h" +#include "mozilla/ClearOnShutdown.h" +#include "mozilla/Components.h" +#include "mozilla/ErrorNames.h" +#include "mozilla/ResultExtensions.h" +#include "mozilla/Try.h" +#include "mozilla/Unused.h" +#include "mozilla/dom/Element.h" +#include "mozilla/dom/Event.h" +#include "mozilla/dom/EventBinding.h" +#include "mozilla/dom/BrowserHost.h" +#include "mozIThirdPartyUtil.h" +#include "nsContentUtils.h" +#include "nsIContentPolicy.h" +#include "nsIClassifiedChannel.h" +#include "nsIHttpChannelInternal.h" +#include "nsIHttpHeaderVisitor.h" +#include "nsIInterfaceRequestor.h" +#include "nsIInterfaceRequestorUtils.h" +#include "nsILoadContext.h" +#include "nsIProxiedChannel.h" +#include "nsIProxyInfo.h" +#include "nsITraceableChannel.h" +#include "nsIWritablePropertyBag.h" +#include "nsIWritablePropertyBag2.h" +#include "nsNetUtil.h" +#include "nsProxyRelease.h" +#include "nsPrintfCString.h" + +using namespace mozilla::dom; +using namespace JS; + +namespace mozilla { +namespace extensions { + +#define CHANNELWRAPPER_PROP_KEY u"ChannelWrapper::CachedInstance"_ns + +using CF = nsIClassifiedChannel::ClassificationFlags; +using MUC = MozUrlClassificationFlags; + +struct ClassificationStruct { + uint32_t mFlag; + MozUrlClassificationFlags mValue; +}; +static const ClassificationStruct classificationArray[] = { + {CF::CLASSIFIED_FINGERPRINTING, MUC::Fingerprinting}, + {CF::CLASSIFIED_FINGERPRINTING_CONTENT, MUC::Fingerprinting_content}, + {CF::CLASSIFIED_CRYPTOMINING, MUC::Cryptomining}, + {CF::CLASSIFIED_CRYPTOMINING_CONTENT, MUC::Cryptomining_content}, + {CF::CLASSIFIED_EMAILTRACKING, MUC::Emailtracking}, + {CF::CLASSIFIED_EMAILTRACKING_CONTENT, MUC::Emailtracking_content}, + {CF::CLASSIFIED_TRACKING, MUC::Tracking}, + {CF::CLASSIFIED_TRACKING_AD, MUC::Tracking_ad}, + {CF::CLASSIFIED_TRACKING_ANALYTICS, MUC::Tracking_analytics}, + {CF::CLASSIFIED_TRACKING_SOCIAL, MUC::Tracking_social}, + {CF::CLASSIFIED_TRACKING_CONTENT, MUC::Tracking_content}, + {CF::CLASSIFIED_SOCIALTRACKING, MUC::Socialtracking}, + {CF::CLASSIFIED_SOCIALTRACKING_FACEBOOK, MUC::Socialtracking_facebook}, + {CF::CLASSIFIED_SOCIALTRACKING_LINKEDIN, MUC::Socialtracking_linkedin}, + {CF::CLASSIFIED_SOCIALTRACKING_TWITTER, MUC::Socialtracking_twitter}, + {CF::CLASSIFIED_ANY_BASIC_TRACKING, MUC::Any_basic_tracking}, + {CF::CLASSIFIED_ANY_STRICT_TRACKING, MUC::Any_strict_tracking}, + {CF::CLASSIFIED_ANY_SOCIAL_TRACKING, MUC::Any_social_tracking}}; + +/***************************************************************************** + * Lifetimes + *****************************************************************************/ + +namespace { +class ChannelListHolder : public LinkedList<ChannelWrapper> { + public: + ChannelListHolder() = default; + + ~ChannelListHolder(); +}; + +} // anonymous namespace + +ChannelListHolder::~ChannelListHolder() { + while (ChannelWrapper* wrapper = popFirst()) { + wrapper->Die(); + } +} + +static LinkedList<ChannelWrapper>* GetChannelList() { + static UniquePtr<ChannelListHolder> sChannelList; + if (!sChannelList && !PastShutdownPhase(ShutdownPhase::XPCOMShutdown)) { + sChannelList.reset(new ChannelListHolder()); + ClearOnShutdown(&sChannelList, ShutdownPhase::XPCOMShutdown); + } + return sChannelList.get(); +} + +NS_IMPL_CYCLE_COLLECTING_ADDREF(ChannelWrapper::ChannelWrapperStub) +NS_IMPL_CYCLE_COLLECTING_RELEASE(ChannelWrapper::ChannelWrapperStub) + +NS_IMPL_CYCLE_COLLECTION(ChannelWrapper::ChannelWrapperStub, mChannelWrapper) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(ChannelWrapper::ChannelWrapperStub) + NS_INTERFACE_MAP_ENTRY_TEAROFF_AMBIGUOUS(ChannelWrapper, EventTarget, + mChannelWrapper) + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +/***************************************************************************** + * Initialization + *****************************************************************************/ + +ChannelWrapper::ChannelWrapper(nsISupports* aParent, nsIChannel* aChannel) + : ChannelHolder(aChannel), mParent(aParent) { + mStub = new ChannelWrapperStub(this); + + if (auto* list = GetChannelList()) { + list->insertBack(this); + } +} + +ChannelWrapper::~ChannelWrapper() { + if (LinkedListElement<ChannelWrapper>::isInList()) { + LinkedListElement<ChannelWrapper>::remove(); + } +} + +void ChannelWrapper::Die() { + if (mStub) { + mStub->mChannelWrapper = nullptr; + } +} + +/* static */ +already_AddRefed<ChannelWrapper> ChannelWrapper::Get(const GlobalObject& global, + nsIChannel* channel) { + RefPtr<ChannelWrapper> wrapper; + + nsCOMPtr<nsIWritablePropertyBag2> props = do_QueryInterface(channel); + if (props) { + wrapper = do_GetProperty(props, CHANNELWRAPPER_PROP_KEY); + if (wrapper) { + // Assume cached attributes may have changed at this point. + wrapper->ClearCachedAttributes(); + } + } + + if (!wrapper) { + wrapper = new ChannelWrapper(global.GetAsSupports(), channel); + if (props) { + Unused << props->SetPropertyAsInterface(CHANNELWRAPPER_PROP_KEY, + wrapper->mStub); + } + } + + return wrapper.forget(); +} + +already_AddRefed<ChannelWrapper> ChannelWrapper::GetRegisteredChannel( + const GlobalObject& global, uint64_t aChannelId, + const WebExtensionPolicy& aAddon, nsIRemoteTab* aRemoteTab) { + ContentParent* contentParent = nullptr; + if (BrowserHost* host = BrowserHost::GetFrom(aRemoteTab)) { + contentParent = host->GetActor()->Manager(); + } + + auto& webreq = WebRequestService::GetSingleton(); + + nsCOMPtr<nsITraceableChannel> channel = + webreq.GetTraceableChannel(aChannelId, aAddon.Id(), contentParent); + if (!channel) { + return nullptr; + } + nsCOMPtr<nsIChannel> chan(do_QueryInterface(channel)); + return ChannelWrapper::Get(global, chan); +} + +void ChannelWrapper::SetChannel(nsIChannel* aChannel) { + detail::ChannelHolder::SetChannel(aChannel); + ClearCachedAttributes(); + ChannelWrapper_Binding::ClearCachedFinalURIValue(this); + ChannelWrapper_Binding::ClearCachedFinalURLValue(this); + mFinalURLInfo.reset(); + ChannelWrapper_Binding::ClearCachedProxyInfoValue(this); +} + +void ChannelWrapper::ClearCachedAttributes() { + ChannelWrapper_Binding::ClearCachedRemoteAddressValue(this); + ChannelWrapper_Binding::ClearCachedStatusCodeValue(this); + ChannelWrapper_Binding::ClearCachedStatusLineValue(this); + ChannelWrapper_Binding::ClearCachedUrlClassificationValue(this); + if (!mFiredErrorEvent) { + ChannelWrapper_Binding::ClearCachedErrorStringValue(this); + } + + ChannelWrapper_Binding::ClearCachedRequestSizeValue(this); + ChannelWrapper_Binding::ClearCachedResponseSizeValue(this); +} + +/***************************************************************************** + * ... + *****************************************************************************/ + +void ChannelWrapper::Cancel(uint32_t aResult, uint32_t aReason, + ErrorResult& aRv) { + nsresult rv = NS_ERROR_UNEXPECTED; + if (nsCOMPtr<nsIChannel> chan = MaybeChannel()) { + nsCOMPtr<nsILoadInfo> loadInfo = GetLoadInfo(); + if (aReason > 0 && loadInfo) { + loadInfo->SetRequestBlockingReason(aReason); + } + rv = chan->Cancel(nsresult(aResult)); + ErrorCheck(); + } + if (NS_FAILED(rv)) { + aRv.Throw(rv); + } +} + +void ChannelWrapper::RedirectTo(nsIURI* aURI, ErrorResult& aRv) { + nsresult rv = NS_ERROR_UNEXPECTED; + if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) { + rv = chan->RedirectTo(aURI); + } + if (NS_FAILED(rv)) { + aRv.Throw(rv); + } +} + +void ChannelWrapper::UpgradeToSecure(ErrorResult& aRv) { + nsresult rv = NS_ERROR_UNEXPECTED; + if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) { + rv = chan->UpgradeToSecure(); + } + if (NS_FAILED(rv)) { + aRv.Throw(rv); + } +} + +void ChannelWrapper::Suspend(const nsCString& aProfileMarkerText, + ErrorResult& aRv) { + if (!mSuspended) { + nsresult rv = NS_ERROR_UNEXPECTED; + if (nsCOMPtr<nsIChannel> chan = MaybeChannel()) { + rv = chan->Suspend(); + } + if (NS_FAILED(rv)) { + aRv.Throw(rv); + } else { + mSuspended = true; + MOZ_ASSERT(mSuspendedMarkerText.IsVoid()); + mSuspendedMarkerText = aProfileMarkerText; + PROFILER_MARKER_TEXT("Extension Suspend", NETWORK, + MarkerOptions(MarkerTiming::IntervalStart()), + mSuspendedMarkerText); + } + } +} + +void ChannelWrapper::Resume(ErrorResult& aRv) { + if (mSuspended) { + nsresult rv = NS_ERROR_UNEXPECTED; + if (nsCOMPtr<nsIChannel> chan = MaybeChannel()) { + rv = chan->Resume(); + } + if (NS_FAILED(rv)) { + aRv.Throw(rv); + } else { + mSuspended = false; + PROFILER_MARKER_TEXT("Extension Suspend", NETWORK, + MarkerOptions(MarkerTiming::IntervalEnd()), + mSuspendedMarkerText); + mSuspendedMarkerText = VoidCString(); + } + } +} + +void ChannelWrapper::GetContentType(nsCString& aContentType) const { + if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) { + Unused << chan->GetContentType(aContentType); + } +} + +void ChannelWrapper::SetContentType(const nsACString& aContentType) { + if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) { + Unused << chan->SetContentType(aContentType); + } +} + +/***************************************************************************** + * Headers + *****************************************************************************/ + +namespace { + +class MOZ_STACK_CLASS HeaderVisitor final : public nsIHttpHeaderVisitor { + public: + NS_DECL_NSIHTTPHEADERVISITOR + + explicit HeaderVisitor(nsTArray<dom::MozHTTPHeader>& aHeaders) + : mHeaders(aHeaders) {} + + HeaderVisitor(nsTArray<dom::MozHTTPHeader>& aHeaders, + const nsCString& aContentTypeHdr) + : mHeaders(aHeaders), mContentTypeHdr(aContentTypeHdr) {} + + void VisitRequestHeaders(nsIHttpChannel* aChannel, ErrorResult& aRv) { + CheckResult(aChannel->VisitRequestHeaders(this), aRv); + } + + void VisitResponseHeaders(nsIHttpChannel* aChannel, ErrorResult& aRv) { + CheckResult(aChannel->VisitResponseHeaders(this), aRv); + } + + NS_IMETHOD QueryInterface(REFNSIID aIID, void** aInstancePtr) override; + + // Stub AddRef/Release since this is a stack class. + NS_IMETHOD_(MozExternalRefCountType) AddRef(void) override { + return ++mRefCnt; + } + + NS_IMETHOD_(MozExternalRefCountType) Release(void) override { + return --mRefCnt; + } + + virtual ~HeaderVisitor() { MOZ_DIAGNOSTIC_ASSERT(mRefCnt == 0); } + + private: + bool CheckResult(nsresult aNSRv, ErrorResult& aRv) { + if (NS_FAILED(aNSRv)) { + aRv.Throw(aNSRv); + return false; + } + return true; + } + + nsTArray<dom::MozHTTPHeader>& mHeaders; + nsCString mContentTypeHdr = VoidCString(); + + nsrefcnt mRefCnt = 0; +}; + +NS_IMETHODIMP +HeaderVisitor::VisitHeader(const nsACString& aHeader, + const nsACString& aValue) { + auto dict = mHeaders.AppendElement(fallible); + if (!dict) { + return NS_ERROR_OUT_OF_MEMORY; + } + dict->mName = aHeader; + + if (!mContentTypeHdr.IsVoid() && + aHeader.LowerCaseEqualsLiteral("content-type")) { + dict->mValue = mContentTypeHdr; + } else { + dict->mValue = aValue; + } + + return NS_OK; +} + +NS_IMPL_QUERY_INTERFACE(HeaderVisitor, nsIHttpHeaderVisitor) + +} // anonymous namespace + +void ChannelWrapper::GetRequestHeaders(nsTArray<dom::MozHTTPHeader>& aRetVal, + ErrorResult& aRv) const { + if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) { + HeaderVisitor visitor(aRetVal); + visitor.VisitRequestHeaders(chan, aRv); + } else { + aRv.Throw(NS_ERROR_UNEXPECTED); + } +} + +void ChannelWrapper::GetRequestHeader(const nsCString& aHeader, + nsCString& aResult, + ErrorResult& aRv) const { + aResult.SetIsVoid(true); + if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) { + Unused << chan->GetRequestHeader(aHeader, aResult); + } else { + aRv.Throw(NS_ERROR_UNEXPECTED); + } +} + +void ChannelWrapper::GetResponseHeaders(nsTArray<dom::MozHTTPHeader>& aRetVal, + ErrorResult& aRv) const { + if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) { + HeaderVisitor visitor(aRetVal, mContentTypeHdr); + visitor.VisitResponseHeaders(chan, aRv); + } else { + aRv.Throw(NS_ERROR_UNEXPECTED); + } +} + +void ChannelWrapper::SetRequestHeader(const nsCString& aHeader, + const nsCString& aValue, bool aMerge, + ErrorResult& aRv) { + nsresult rv = NS_ERROR_UNEXPECTED; + if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) { + rv = chan->SetRequestHeader(aHeader, aValue, aMerge); + } + if (NS_FAILED(rv)) { + aRv.Throw(rv); + } +} + +void ChannelWrapper::SetResponseHeader(const nsCString& aHeader, + const nsCString& aValue, bool aMerge, + ErrorResult& aRv) { + nsresult rv = NS_ERROR_UNEXPECTED; + if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) { + if (aHeader.LowerCaseEqualsLiteral("content-type")) { + rv = chan->SetContentType(aValue); + if (NS_SUCCEEDED(rv)) { + mContentTypeHdr = aValue; + } + } else { + rv = chan->SetResponseHeader(aHeader, aValue, aMerge); + } + } + if (NS_FAILED(rv)) { + aRv.Throw(rv); + } +} + +/***************************************************************************** + * LoadInfo + *****************************************************************************/ + +already_AddRefed<nsILoadContext> ChannelWrapper::GetLoadContext() const { + if (nsCOMPtr<nsIChannel> chan = MaybeChannel()) { + nsCOMPtr<nsILoadContext> ctxt; + // Fetch() from Workers saves BrowsingContext/LoadContext information in + // nsILoadInfo.workerAssociatedBrowsingContext. So we can not use + // NS_QueryNotificationCallbacks to get LoadContext of the channel. + RefPtr<BrowsingContext> bc; + nsCOMPtr<nsILoadInfo> loadInfo = chan->LoadInfo(); + loadInfo->GetWorkerAssociatedBrowsingContext(getter_AddRefs(bc)); + if (bc) { + ctxt = bc.forget(); + return ctxt.forget(); + } + NS_QueryNotificationCallbacks(chan, ctxt); + return ctxt.forget(); + } + return nullptr; +} + +already_AddRefed<Element> ChannelWrapper::GetBrowserElement() const { + if (nsCOMPtr<nsILoadContext> ctxt = GetLoadContext()) { + RefPtr<Element> elem; + if (NS_SUCCEEDED(ctxt->GetTopFrameElement(getter_AddRefs(elem)))) { + return elem.forget(); + } + } + return nullptr; +} + +bool ChannelWrapper::IsServiceWorkerScript() const { + nsCOMPtr<nsIChannel> chan = MaybeChannel(); + return IsServiceWorkerScript(chan); +} + +// static +bool ChannelWrapper::IsServiceWorkerScript(const nsCOMPtr<nsIChannel>& chan) { + nsCOMPtr<nsILoadInfo> loadInfo; + + if (chan) { + chan->GetLoadInfo(getter_AddRefs(loadInfo)); + } + + if (loadInfo) { + // Not a script. + if (loadInfo->GetExternalContentPolicyType() != + ExtContentPolicy::TYPE_SCRIPT) { + return false; + } + + // Service worker main script load. + if (loadInfo->InternalContentPolicyType() == + nsIContentPolicy::TYPE_INTERNAL_SERVICE_WORKER) { + return true; + } + + // Service worker import scripts load. + if (loadInfo->InternalContentPolicyType() == + nsIContentPolicy::TYPE_INTERNAL_WORKER_IMPORT_SCRIPTS || + loadInfo->InternalContentPolicyType() == + nsIContentPolicy::TYPE_INTERNAL_WORKER_STATIC_MODULE) { + nsLoadFlags loadFlags = 0; + chan->GetLoadFlags(&loadFlags); + return loadFlags & nsIChannel::LOAD_BYPASS_SERVICE_WORKER; + } + } + + return false; +} + +static inline bool IsSystemPrincipal(nsIPrincipal* aPrincipal) { + return BasePrincipal::Cast(aPrincipal)->Is<SystemPrincipal>(); +} + +bool ChannelWrapper::IsSystemLoad() const { + if (nsCOMPtr<nsILoadInfo> loadInfo = GetLoadInfo()) { + if (nsIPrincipal* prin = loadInfo->GetLoadingPrincipal()) { + return IsSystemPrincipal(prin); + } + + // loadingPrincipal is only non-null for top-level loads. + // In practice we would never encounter a system principal for a top-level + // load that passes through ChannelWrapper, at least not for HTTP channels. + MOZ_ASSERT(Type() == MozContentPolicyType::Main_frame); + } + return false; +} + +bool ChannelWrapper::CanModify() const { + if (WebExtensionPolicy::IsRestrictedURI(FinalURLInfo())) { + return false; + } + + if (nsCOMPtr<nsILoadInfo> loadInfo = GetLoadInfo()) { + if (nsIPrincipal* prin = loadInfo->GetLoadingPrincipal()) { + if (IsSystemPrincipal(prin)) { + return false; + } + + auto* docURI = DocumentURLInfo(); + if (docURI && WebExtensionPolicy::IsRestrictedURI(*docURI)) { + return false; + } + } + } + return true; +} + +already_AddRefed<nsIURI> ChannelWrapper::GetOriginURI() const { + nsCOMPtr<nsIURI> uri; + if (nsCOMPtr<nsILoadInfo> loadInfo = GetLoadInfo()) { + if (nsIPrincipal* prin = loadInfo->TriggeringPrincipal()) { + if (prin->GetIsContentPrincipal()) { + auto* basePrin = BasePrincipal::Cast(prin); + Unused << basePrin->GetURI(getter_AddRefs(uri)); + } + } + } + return uri.forget(); +} + +already_AddRefed<nsIURI> ChannelWrapper::GetDocumentURI() const { + nsCOMPtr<nsIURI> uri; + if (nsCOMPtr<nsILoadInfo> loadInfo = GetLoadInfo()) { + if (nsIPrincipal* prin = loadInfo->GetLoadingPrincipal()) { + if (prin->GetIsContentPrincipal()) { + auto* basePrin = BasePrincipal::Cast(prin); + Unused << basePrin->GetURI(getter_AddRefs(uri)); + } + } + } + return uri.forget(); +} + +void ChannelWrapper::GetOriginURL(nsCString& aRetVal) const { + if (nsCOMPtr<nsIURI> uri = GetOriginURI()) { + Unused << uri->GetSpec(aRetVal); + } +} + +void ChannelWrapper::GetDocumentURL(nsCString& aRetVal) const { + if (nsCOMPtr<nsIURI> uri = GetDocumentURI()) { + Unused << uri->GetSpec(aRetVal); + } +} + +const URLInfo& ChannelWrapper::FinalURLInfo() const { + if (mFinalURLInfo.isNothing()) { + ErrorResult rv; + nsCOMPtr<nsIURI> uri = FinalURI(); + MOZ_ASSERT(uri); + + // If this is a view-source scheme, get the nested uri. + while (uri && uri->SchemeIs("view-source")) { + nsCOMPtr<nsINestedURI> nested = do_QueryInterface(uri); + if (!nested) { + break; + } + nested->GetInnerURI(getter_AddRefs(uri)); + } + mFinalURLInfo.emplace(uri.get(), true); + + // If this is a WebSocket request, mangle the URL so that the scheme is + // ws: or wss:, as appropriate. + auto& url = mFinalURLInfo.ref(); + if (Type() == MozContentPolicyType::Websocket && + (url.Scheme() == nsGkAtoms::http || url.Scheme() == nsGkAtoms::https)) { + nsAutoCString spec(url.CSpec()); + spec.Replace(0, 4, "ws"_ns); + + Unused << NS_NewURI(getter_AddRefs(uri), spec); + MOZ_RELEASE_ASSERT(uri); + mFinalURLInfo.reset(); + mFinalURLInfo.emplace(uri.get(), true); + } + } + return mFinalURLInfo.ref(); +} + +const URLInfo* ChannelWrapper::DocumentURLInfo() const { + if (mDocumentURLInfo.isNothing()) { + nsCOMPtr<nsIURI> uri = GetDocumentURI(); + if (!uri) { + return nullptr; + } + mDocumentURLInfo.emplace(uri.get(), true); + } + return &mDocumentURLInfo.ref(); +} + +bool ChannelWrapper::Matches( + const dom::MozRequestFilter& aFilter, const WebExtensionPolicy* aExtension, + const dom::MozRequestMatchOptions& aOptions) const { + if (!HaveChannel()) { + return false; + } + + if (!aFilter.mTypes.IsNull() && !aFilter.mTypes.Value().Contains(Type())) { + return false; + } + + auto& urlInfo = FinalURLInfo(); + if (aFilter.mUrls && !aFilter.mUrls->Matches(urlInfo)) { + return false; + } + + nsCOMPtr<nsILoadInfo> loadInfo = GetLoadInfo(); + bool isPrivate = + loadInfo && loadInfo->GetOriginAttributes().mPrivateBrowsingId > 0; + if (!aFilter.mIncognito.IsNull() && aFilter.mIncognito.Value() != isPrivate) { + return false; + } + + if (aExtension) { + // Verify extension access to private requests + if (isPrivate && !aExtension->PrivateBrowsingAllowed()) { + return false; + } + + bool isProxy = + aOptions.mIsProxy && aExtension->HasPermission(nsGkAtoms::proxy); + // Proxies are allowed access to all urls, including restricted urls. + if (!aExtension->CanAccessURI(urlInfo, false, !isProxy, true)) { + return false; + } + + // If this isn't the proxy phase of the request, check that the extension + // has origin permissions for origin that originated the request. + if (!isProxy) { + if (IsSystemLoad()) { + return false; + } + + auto origin = DocumentURLInfo(); + // Extensions with the file:-permission may observe requests from file: + // origins, because such documents can already be modified by content + // scripts anyway. + if (origin && !aExtension->CanAccessURI(*origin, false, true, true)) { + return false; + } + } + } + + return true; +} + +int64_t NormalizeFrameID(nsILoadInfo* aLoadInfo, uint64_t bcID) { + RefPtr<BrowsingContext> bc = aLoadInfo->GetWorkerAssociatedBrowsingContext(); + if (!bc) { + bc = aLoadInfo->GetBrowsingContext(); + } + + if (!bc || bcID == bc->Top()->Id()) { + return 0; + } + return bcID; +} + +uint64_t ChannelWrapper::BrowsingContextId(nsILoadInfo* aLoadInfo) const { + auto frameID = aLoadInfo->GetFrameBrowsingContextID(); + if (!frameID) { + frameID = aLoadInfo->GetWorkerAssociatedBrowsingContextID(); + } + if (!frameID) { + frameID = aLoadInfo->GetBrowsingContextID(); + } + return frameID; +} + +int64_t ChannelWrapper::FrameId() const { + if (nsCOMPtr<nsILoadInfo> loadInfo = GetLoadInfo()) { + return NormalizeFrameID(loadInfo, BrowsingContextId(loadInfo)); + } + return 0; +} + +int64_t ChannelWrapper::ParentFrameId() const { + if (nsCOMPtr<nsILoadInfo> loadInfo = GetLoadInfo()) { + RefPtr<BrowsingContext> bc = loadInfo->GetWorkerAssociatedBrowsingContext(); + if (!bc) { + bc = loadInfo->GetBrowsingContext(); + } + if (bc) { + if (BrowsingContextId(loadInfo) == bc->Top()->Id()) { + return -1; + } + + uint64_t parentID = -1; + if (loadInfo->GetFrameBrowsingContextID()) { + parentID = loadInfo->GetBrowsingContextID(); + } else if (bc->GetParent()) { + parentID = bc->GetParent()->Id(); + } + return NormalizeFrameID(loadInfo, parentID); + } + } + return -1; +} + +void ChannelWrapper::GetFrameAncestors( + dom::Nullable<nsTArray<dom::MozFrameAncestorInfo>>& aFrameAncestors, + ErrorResult& aRv) const { + nsCOMPtr<nsILoadInfo> loadInfo = GetLoadInfo(); + if (!loadInfo || BrowsingContextId(loadInfo) == 0) { + aFrameAncestors.SetNull(); + return; + } + + nsresult rv = GetFrameAncestors(loadInfo, aFrameAncestors.SetValue()); + if (NS_FAILED(rv)) { + aRv.Throw(rv); + } +} + +nsresult ChannelWrapper::GetFrameAncestors( + nsILoadInfo* aLoadInfo, + nsTArray<dom::MozFrameAncestorInfo>& aFrameAncestors) const { + const nsTArray<nsCOMPtr<nsIPrincipal>>& ancestorPrincipals = + aLoadInfo->AncestorPrincipals(); + const nsTArray<uint64_t>& ancestorBrowsingContextIDs = + aLoadInfo->AncestorBrowsingContextIDs(); + uint32_t size = ancestorPrincipals.Length(); + MOZ_DIAGNOSTIC_ASSERT(size == ancestorBrowsingContextIDs.Length()); + if (size != ancestorBrowsingContextIDs.Length()) { + return NS_ERROR_UNEXPECTED; + } + + bool subFrame = aLoadInfo->GetExternalContentPolicyType() == + ExtContentPolicy::TYPE_SUBDOCUMENT; + if (!aFrameAncestors.SetCapacity(subFrame ? size : size + 1, fallible)) { + return NS_ERROR_OUT_OF_MEMORY; + } + + // The immediate parent is always the first element in the ancestor arrays, + // however SUBDOCUMENTs do not have their immediate parent included, so we + // inject it here. This will force wrapper.parentBrowsingContextId == + // wrapper.frameAncestors[0].frameId to always be true. All ather requests + // already match this way. + if (subFrame) { + auto ancestor = aFrameAncestors.AppendElement(); + GetDocumentURL(ancestor->mUrl); + ancestor->mFrameId = ParentFrameId(); + } + + for (uint32_t i = 0; i < size; ++i) { + auto ancestor = aFrameAncestors.AppendElement(); + MOZ_TRY(ancestorPrincipals[i]->GetAsciiSpec(ancestor->mUrl)); + ancestor->mFrameId = + NormalizeFrameID(aLoadInfo, ancestorBrowsingContextIDs[i]); + } + return NS_OK; +} + +/***************************************************************************** + * Response filtering + *****************************************************************************/ + +void ChannelWrapper::RegisterTraceableChannel(const WebExtensionPolicy& aAddon, + nsIRemoteTab* aBrowserParent) { + // We can't attach new listeners after the response has started, so don't + // bother registering anything. + if (mResponseStarted || !CanModify()) { + return; + } + + mAddonEntries.InsertOrUpdate(aAddon.Id(), aBrowserParent); + if (!mChannelEntry) { + mChannelEntry = WebRequestService::GetSingleton().RegisterChannel(this); + CheckEventListeners(); + } +} + +already_AddRefed<nsITraceableChannel> ChannelWrapper::GetTraceableChannel( + nsAtom* aAddonId, dom::ContentParent* aContentParent) const { + nsCOMPtr<nsIRemoteTab> remoteTab; + if (mAddonEntries.Get(aAddonId, getter_AddRefs(remoteTab))) { + ContentParent* contentParent = nullptr; + if (remoteTab) { + contentParent = + BrowserHost::GetFrom(remoteTab.get())->GetActor()->Manager(); + } + + if (contentParent == aContentParent) { + nsCOMPtr<nsITraceableChannel> chan = QueryChannel(); + return chan.forget(); + } + } + return nullptr; +} + +/***************************************************************************** + * ... + *****************************************************************************/ + +MozContentPolicyType GetContentPolicyType(ExtContentPolicyType aType) { + // Note: Please keep this function in sync with the external types in + // nsIContentPolicy.idl + switch (aType) { + case ExtContentPolicy::TYPE_DOCUMENT: + return MozContentPolicyType::Main_frame; + case ExtContentPolicy::TYPE_SUBDOCUMENT: + return MozContentPolicyType::Sub_frame; + case ExtContentPolicy::TYPE_STYLESHEET: + return MozContentPolicyType::Stylesheet; + case ExtContentPolicy::TYPE_SCRIPT: + return MozContentPolicyType::Script; + case ExtContentPolicy::TYPE_IMAGE: + return MozContentPolicyType::Image; + case ExtContentPolicy::TYPE_OBJECT: + return MozContentPolicyType::Object; + case ExtContentPolicy::TYPE_OBJECT_SUBREQUEST: + return MozContentPolicyType::Object_subrequest; + case ExtContentPolicy::TYPE_XMLHTTPREQUEST: + return MozContentPolicyType::Xmlhttprequest; + // TYPE_FETCH returns xmlhttprequest for cross-browser compatibility. + case ExtContentPolicy::TYPE_FETCH: + return MozContentPolicyType::Xmlhttprequest; + case ExtContentPolicy::TYPE_XSLT: + return MozContentPolicyType::Xslt; + case ExtContentPolicy::TYPE_PING: + return MozContentPolicyType::Ping; + case ExtContentPolicy::TYPE_BEACON: + return MozContentPolicyType::Beacon; + case ExtContentPolicy::TYPE_DTD: + return MozContentPolicyType::Xml_dtd; + case ExtContentPolicy::TYPE_FONT: + case ExtContentPolicy::TYPE_UA_FONT: + return MozContentPolicyType::Font; + case ExtContentPolicy::TYPE_MEDIA: + return MozContentPolicyType::Media; + case ExtContentPolicy::TYPE_WEBSOCKET: + return MozContentPolicyType::Websocket; + case ExtContentPolicy::TYPE_CSP_REPORT: + return MozContentPolicyType::Csp_report; + case ExtContentPolicy::TYPE_IMAGESET: + return MozContentPolicyType::Imageset; + case ExtContentPolicy::TYPE_WEB_MANIFEST: + return MozContentPolicyType::Web_manifest; + case ExtContentPolicy::TYPE_SPECULATIVE: + return MozContentPolicyType::Speculative; + case ExtContentPolicy::TYPE_PROXIED_WEBRTC_MEDIA: + case ExtContentPolicy::TYPE_INVALID: + case ExtContentPolicy::TYPE_OTHER: + case ExtContentPolicy::TYPE_SAVEAS_DOWNLOAD: + case ExtContentPolicy::TYPE_WEB_TRANSPORT: + case ExtContentPolicy::TYPE_WEB_IDENTITY: + break; + // Do not add default: so that compilers can catch the missing case. + } + return MozContentPolicyType::Other; +} + +MozContentPolicyType ChannelWrapper::Type() const { + if (nsCOMPtr<nsILoadInfo> loadInfo = GetLoadInfo()) { + return GetContentPolicyType(loadInfo->GetExternalContentPolicyType()); + } + return MozContentPolicyType::Other; +} + +void ChannelWrapper::GetMethod(nsCString& aMethod) const { + if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) { + Unused << chan->GetRequestMethod(aMethod); + } +} + +/***************************************************************************** + * ... + *****************************************************************************/ + +uint32_t ChannelWrapper::StatusCode() const { + uint32_t result = 0; + if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) { + Unused << chan->GetResponseStatus(&result); + } + return result; +} + +void ChannelWrapper::GetStatusLine(nsCString& aRetVal) const { + nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel(); + nsCOMPtr<nsIHttpChannelInternal> internal = do_QueryInterface(chan); + + if (internal) { + nsAutoCString statusText; + uint32_t major, minor, status; + if (NS_FAILED(chan->GetResponseStatus(&status)) || + NS_FAILED(chan->GetResponseStatusText(statusText)) || + NS_FAILED(internal->GetResponseVersion(&major, &minor))) { + return; + } + + aRetVal = nsPrintfCString("HTTP/%u.%u %u %s", major, minor, status, + statusText.get()); + } +} + +uint64_t ChannelWrapper::ResponseSize() const { + uint64_t result = 0; + if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) { + Unused << chan->GetTransferSize(&result); + } + return result; +} + +uint64_t ChannelWrapper::RequestSize() const { + uint64_t result = 0; + if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) { + Unused << chan->GetRequestSize(&result); + } + return result; +} + +/***************************************************************************** + * ... + *****************************************************************************/ + +already_AddRefed<nsIURI> ChannelWrapper::FinalURI() const { + nsCOMPtr<nsIURI> uri; + if (nsCOMPtr<nsIChannel> chan = MaybeChannel()) { + NS_GetFinalChannelURI(chan, getter_AddRefs(uri)); + } + return uri.forget(); +} + +void ChannelWrapper::GetFinalURL(nsString& aRetVal) const { + if (HaveChannel()) { + aRetVal = FinalURLInfo().Spec(); + } +} + +/***************************************************************************** + * ... + *****************************************************************************/ + +nsresult FillProxyInfo(MozProxyInfo& aDict, nsIProxyInfo* aProxyInfo) { + MOZ_TRY(aProxyInfo->GetHost(aDict.mHost)); + MOZ_TRY(aProxyInfo->GetPort(&aDict.mPort)); + MOZ_TRY(aProxyInfo->GetType(aDict.mType)); + MOZ_TRY(aProxyInfo->GetUsername(aDict.mUsername)); + MOZ_TRY( + aProxyInfo->GetProxyAuthorizationHeader(aDict.mProxyAuthorizationHeader)); + MOZ_TRY(aProxyInfo->GetConnectionIsolationKey(aDict.mConnectionIsolationKey)); + MOZ_TRY(aProxyInfo->GetFailoverTimeout(&aDict.mFailoverTimeout.Construct())); + + uint32_t flags; + MOZ_TRY(aProxyInfo->GetFlags(&flags)); + aDict.mProxyDNS = flags & nsIProxyInfo::TRANSPARENT_PROXY_RESOLVES_HOST; + + return NS_OK; +} + +void ChannelWrapper::GetProxyInfo(dom::Nullable<MozProxyInfo>& aRetVal, + ErrorResult& aRv) const { + nsCOMPtr<nsIProxyInfo> proxyInfo; + if (nsCOMPtr<nsIProxiedChannel> proxied = QueryChannel()) { + Unused << proxied->GetProxyInfo(getter_AddRefs(proxyInfo)); + } + if (proxyInfo) { + MozProxyInfo result; + + nsresult rv = FillProxyInfo(result, proxyInfo); + if (NS_FAILED(rv)) { + aRv.Throw(rv); + } else { + aRetVal.SetValue(std::move(result)); + } + } +} + +void ChannelWrapper::GetRemoteAddress(nsCString& aRetVal) const { + aRetVal.SetIsVoid(true); + if (nsCOMPtr<nsIHttpChannelInternal> internal = QueryChannel()) { + Unused << internal->GetRemoteAddress(aRetVal); + } +} + +void FillClassification( + Sequence<mozilla::dom::MozUrlClassificationFlags>& classifications, + uint32_t classificationFlags, ErrorResult& aRv) { + if (classificationFlags == 0) { + return; + } + for (const auto& entry : classificationArray) { + if (classificationFlags & entry.mFlag) { + if (!classifications.AppendElement(entry.mValue, mozilla::fallible)) { + aRv.Throw(NS_ERROR_OUT_OF_MEMORY); + return; + } + } + } +} + +void ChannelWrapper::GetUrlClassification( + dom::Nullable<dom::MozUrlClassification>& aRetVal, ErrorResult& aRv) const { + MozUrlClassification classification; + if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) { + nsCOMPtr<nsIClassifiedChannel> classified = do_QueryInterface(chan); + MOZ_DIAGNOSTIC_ASSERT( + classified, + "Must be an object inheriting from both nsIHttpChannel and " + "nsIClassifiedChannel"); + uint32_t classificationFlags; + classified->GetFirstPartyClassificationFlags(&classificationFlags); + FillClassification(classification.mFirstParty, classificationFlags, aRv); + if (aRv.Failed()) { + return; + } + classified->GetThirdPartyClassificationFlags(&classificationFlags); + FillClassification(classification.mThirdParty, classificationFlags, aRv); + } + aRetVal.SetValue(std::move(classification)); +} + +bool ChannelWrapper::ThirdParty() const { + nsCOMPtr<mozIThirdPartyUtil> thirdPartyUtil = + components::ThirdPartyUtil::Service(); + if (NS_WARN_IF(!thirdPartyUtil)) { + return true; + } + + nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel(); + if (!chan) { + return false; + } + + bool thirdParty = false; + nsresult rv = thirdPartyUtil->IsThirdPartyChannel(chan, nullptr, &thirdParty); + if (NS_WARN_IF(NS_FAILED(rv))) { + return true; + } + + return thirdParty; +} + +/***************************************************************************** + * Error handling + *****************************************************************************/ + +void ChannelWrapper::GetErrorString(nsString& aRetVal) const { + if (nsCOMPtr<nsIChannel> chan = MaybeChannel()) { + nsCOMPtr<nsITransportSecurityInfo> securityInfo; + Unused << chan->GetSecurityInfo(getter_AddRefs(securityInfo)); + if (securityInfo) { + int32_t errorCode = 0; + securityInfo->GetErrorCode(&errorCode); + if (psm::IsNSSErrorCode(errorCode)) { + nsCOMPtr<nsINSSErrorsService> nsserr = + do_GetService(NS_NSS_ERRORS_SERVICE_CONTRACTID); + + nsresult rv = psm::GetXPCOMFromNSSError(errorCode); + if (nsserr && NS_SUCCEEDED(nsserr->GetErrorMessage(rv, aRetVal))) { + return; + } + } + } + + nsresult status; + if (NS_SUCCEEDED(chan->GetStatus(&status)) && NS_FAILED(status)) { + nsAutoCString name; + GetErrorName(status, name); + AppendUTF8toUTF16(name, aRetVal); + } else { + aRetVal.SetIsVoid(true); + } + } else { + aRetVal.AssignLiteral("NS_ERROR_UNEXPECTED"); + } +} + +void ChannelWrapper::ErrorCheck() { + if (!mFiredErrorEvent) { + nsAutoString error; + GetErrorString(error); + if (error.Length()) { + mChannelEntry = nullptr; + mFiredErrorEvent = true; + ChannelWrapper_Binding::ClearCachedErrorStringValue(this); + FireEvent(u"error"_ns); + } + } +} + +/***************************************************************************** + * nsIWebRequestListener + *****************************************************************************/ + +NS_IMPL_ISUPPORTS(ChannelWrapper::RequestListener, nsIStreamListener, + nsIMultiPartChannelListener, nsIRequestObserver, + nsIThreadRetargetableStreamListener) + +ChannelWrapper::RequestListener::~RequestListener() { + NS_ReleaseOnMainThread("RequestListener::mChannelWrapper", + mChannelWrapper.forget()); +} + +nsresult ChannelWrapper::RequestListener::Init() { + if (nsCOMPtr<nsITraceableChannel> chan = mChannelWrapper->QueryChannel()) { + return chan->SetNewListener(this, false, + getter_AddRefs(mOrigStreamListener)); + } + return NS_ERROR_UNEXPECTED; +} + +NS_IMETHODIMP +ChannelWrapper::RequestListener::OnStartRequest(nsIRequest* request) { + MOZ_ASSERT(mOrigStreamListener, "Should have mOrigStreamListener"); + + mChannelWrapper->mChannelEntry = nullptr; + mChannelWrapper->mResponseStarted = true; + mChannelWrapper->ErrorCheck(); + mChannelWrapper->FireEvent(u"start"_ns); + + return mOrigStreamListener->OnStartRequest(request); +} + +NS_IMETHODIMP +ChannelWrapper::RequestListener::OnStopRequest(nsIRequest* request, + nsresult aStatus) { + MOZ_ASSERT(mOrigStreamListener, "Should have mOrigStreamListener"); + + mChannelWrapper->mChannelEntry = nullptr; + mChannelWrapper->ErrorCheck(); + mChannelWrapper->FireEvent(u"stop"_ns); + + return mOrigStreamListener->OnStopRequest(request, aStatus); +} + +NS_IMETHODIMP +ChannelWrapper::RequestListener::OnDataAvailable(nsIRequest* request, + nsIInputStream* inStr, + uint64_t sourceOffset, + uint32_t count) { + MOZ_ASSERT(mOrigStreamListener, "Should have mOrigStreamListener"); + return mOrigStreamListener->OnDataAvailable(request, inStr, sourceOffset, + count); +} + +NS_IMETHODIMP +ChannelWrapper::RequestListener::OnAfterLastPart(nsresult aStatus) { + MOZ_ASSERT(mOrigStreamListener, "Should have mOrigStreamListener"); + if (nsCOMPtr<nsIMultiPartChannelListener> listener = + do_QueryInterface(mOrigStreamListener)) { + return listener->OnAfterLastPart(aStatus); + } + return NS_OK; +} + +NS_IMETHODIMP +ChannelWrapper::RequestListener::CheckListenerChain() { + MOZ_ASSERT(NS_IsMainThread(), "Should be on main thread!"); + nsresult rv; + nsCOMPtr<nsIThreadRetargetableStreamListener> retargetableListener = + do_QueryInterface(mOrigStreamListener, &rv); + if (retargetableListener) { + return retargetableListener->CheckListenerChain(); + } + return rv; +} + +NS_IMETHODIMP +ChannelWrapper::RequestListener::OnDataFinished(nsresult aStatus) { + MOZ_ASSERT(mOrigStreamListener, "Should have mOrigStreamListener"); + nsCOMPtr<nsIThreadRetargetableStreamListener> retargetableListener = + do_QueryInterface(mOrigStreamListener); + if (retargetableListener) { + return retargetableListener->OnDataFinished(aStatus); + } + + return NS_OK; +} + +/***************************************************************************** + * Event dispatching + *****************************************************************************/ + +void ChannelWrapper::FireEvent(const nsAString& aType) { + EventInit init; + init.mBubbles = false; + init.mCancelable = false; + + RefPtr<Event> event = Event::Constructor(this, aType, init); + event->SetTrusted(true); + + DispatchEvent(*event); +} + +void ChannelWrapper::CheckEventListeners() { + if (!mAddedStreamListener && + (HasListenersFor(nsGkAtoms::onerror) || + HasListenersFor(nsGkAtoms::onstart) || + HasListenersFor(nsGkAtoms::onstop) || mChannelEntry)) { + auto listener = MakeRefPtr<RequestListener>(this); + if (!NS_WARN_IF(NS_FAILED(listener->Init()))) { + mAddedStreamListener = true; + } + } +} + +void ChannelWrapper::EventListenerAdded(nsAtom* aType) { + CheckEventListeners(); +} + +void ChannelWrapper::EventListenerRemoved(nsAtom* aType) { + CheckEventListeners(); +} + +/***************************************************************************** + * Glue + *****************************************************************************/ + +JSObject* ChannelWrapper::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return ChannelWrapper_Binding::Wrap(aCx, this, aGivenProto); +} + +NS_IMPL_CYCLE_COLLECTION_CLASS(ChannelWrapper) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(ChannelWrapper) + NS_INTERFACE_MAP_ENTRY_CONCRETE(ChannelWrapper) +NS_INTERFACE_MAP_END_INHERITING(DOMEventTargetHelper) + +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN_INHERITED(ChannelWrapper, + DOMEventTargetHelper) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mParent) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mStub) + NS_IMPL_CYCLE_COLLECTION_UNLINK_WEAK_PTR +NS_IMPL_CYCLE_COLLECTION_UNLINK_END + +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN_INHERITED(ChannelWrapper, + DOMEventTargetHelper) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mParent) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mStub) +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_IMPL_ADDREF_INHERITED(ChannelWrapper, DOMEventTargetHelper) +NS_IMPL_RELEASE_INHERITED(ChannelWrapper, DOMEventTargetHelper) + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/webrequest/ChannelWrapper.h b/toolkit/components/extensions/webrequest/ChannelWrapper.h new file mode 100644 index 0000000000..8135a4eba8 --- /dev/null +++ b/toolkit/components/extensions/webrequest/ChannelWrapper.h @@ -0,0 +1,357 @@ +/* -*- Mode: C++; tab-width: 2; 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/. */ + +#ifndef mozilla_extensions_ChannelWrapper_h +#define mozilla_extensions_ChannelWrapper_h + +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/dom/ChannelWrapperBinding.h" + +#include "mozilla/WebRequestService.h" +#include "mozilla/extensions/MatchPattern.h" +#include "mozilla/extensions/WebExtensionPolicy.h" + +#include "mozilla/Attributes.h" +#include "mozilla/LinkedList.h" +#include "mozilla/Maybe.h" +#include "mozilla/UniquePtr.h" +#include "mozilla/WeakPtr.h" + +#include "mozilla/DOMEventTargetHelper.h" +#include "nsAtomHashKeys.h" +#include "nsCOMPtr.h" +#include "nsCycleCollectionParticipant.h" +#include "nsIChannel.h" +#include "nsIHttpChannel.h" +#include "nsIMultiPartChannel.h" +#include "nsIStreamListener.h" +#include "nsIRemoteTab.h" +#include "nsIThreadRetargetableStreamListener.h" +#include "nsInterfaceHashtable.h" +#include "nsIWeakReferenceUtils.h" +#include "nsWrapperCache.h" + +#define NS_CHANNELWRAPPER_IID \ + { \ + 0xc06162d2, 0xb803, 0x43b4, { \ + 0xaa, 0x31, 0xcf, 0x69, 0x7f, 0x93, 0x68, 0x1c \ + } \ + } + +class nsILoadContext; +class nsITraceableChannel; + +namespace mozilla { +namespace dom { +class ContentParent; +class Element; +} // namespace dom +namespace extensions { + +namespace detail { + +// We need to store our wrapped channel as a weak reference, since channels +// are not cycle collected, and we're going to be hanging this wrapper +// instance off the channel in order to ensure the same channel always has +// the same wrapper. +// +// But since performance matters here, and we don't want to have to +// QueryInterface the channel every time we touch it, we store separate +// nsIChannel and nsIHttpChannel weak references, and check that the WeakPtr +// is alive before returning it. +// +// This holder class prevents us from accidentally touching the weak pointer +// members directly from our ChannelWrapper class. +struct ChannelHolder { + explicit ChannelHolder(nsIChannel* aChannel) + : mChannel(do_GetWeakReference(aChannel)), mWeakChannel(aChannel) {} + + bool HaveChannel() const { return mChannel && mChannel->IsAlive(); } + + void SetChannel(nsIChannel* aChannel) { + mChannel = do_GetWeakReference(aChannel); + mWeakChannel = aChannel; + mWeakHttpChannel.reset(); + } + + already_AddRefed<nsIChannel> MaybeChannel() const { + if (!HaveChannel()) { + mWeakChannel = nullptr; + } + return do_AddRef(mWeakChannel); + } + + already_AddRefed<nsIHttpChannel> MaybeHttpChannel() const { + if (mWeakHttpChannel.isNothing()) { + nsCOMPtr<nsIHttpChannel> chan = QueryChannel(); + mWeakHttpChannel.emplace(chan.get()); + } + + if (!HaveChannel()) { + mWeakHttpChannel.ref() = nullptr; + } + return do_AddRef(mWeakHttpChannel.value()); + } + + const nsQueryReferent QueryChannel() const { + return do_QueryReferent(mChannel); + } + + private: + nsWeakPtr mChannel; + + mutable nsIChannel* MOZ_NON_OWNING_REF mWeakChannel; + mutable Maybe<nsIHttpChannel*> MOZ_NON_OWNING_REF mWeakHttpChannel; +}; +} // namespace detail + +class WebRequestChannelEntry; + +class ChannelWrapper final : public DOMEventTargetHelper, + public SupportsWeakPtr, + public LinkedListElement<ChannelWrapper>, + private detail::ChannelHolder { + public: + NS_DECL_ISUPPORTS_INHERITED + NS_DECL_CYCLE_COLLECTION_CLASS_INHERITED(ChannelWrapper, DOMEventTargetHelper) + + NS_DECLARE_STATIC_IID_ACCESSOR(NS_CHANNELWRAPPER_IID) + + void Die(); + + static already_AddRefed<extensions::ChannelWrapper> Get( + const dom::GlobalObject& global, nsIChannel* channel); + static already_AddRefed<extensions::ChannelWrapper> GetRegisteredChannel( + const dom::GlobalObject& global, uint64_t aChannelId, + const WebExtensionPolicy& aAddon, nsIRemoteTab* aBrowserParent); + + uint64_t Id() const { return mId; } + + already_AddRefed<nsIChannel> GetChannel() const { return MaybeChannel(); } + + void SetChannel(nsIChannel* aChannel); + + void Cancel(uint32_t result, uint32_t reason, ErrorResult& aRv); + + void RedirectTo(nsIURI* uri, ErrorResult& aRv); + void UpgradeToSecure(ErrorResult& aRv); + + bool Suspended() const { return mSuspended; } + void Suspend(const nsCString& aProfileMarkerText, ErrorResult& aRv); + void Resume(ErrorResult& aRv); + + void GetContentType(nsCString& aContentType) const; + void SetContentType(const nsACString& aContentType); + + void RegisterTraceableChannel(const WebExtensionPolicy& aAddon, + nsIRemoteTab* aBrowserParent); + + already_AddRefed<nsITraceableChannel> GetTraceableChannel( + nsAtom* aAddonId, dom::ContentParent* aContentParent) const; + + void GetMethod(nsCString& aRetVal) const; + + dom::MozContentPolicyType Type() const; + + uint32_t StatusCode() const; + + uint64_t ResponseSize() const; + + uint64_t RequestSize() const; + + void GetStatusLine(nsCString& aRetVal) const; + + void GetErrorString(nsString& aRetVal) const; + + void ErrorCheck(); + + IMPL_EVENT_HANDLER(error); + IMPL_EVENT_HANDLER(start); + IMPL_EVENT_HANDLER(stop); + + already_AddRefed<nsIURI> FinalURI() const; + + void GetFinalURL(nsString& aRetVal) const; + + bool Matches(const dom::MozRequestFilter& aFilter, + const WebExtensionPolicy* aExtension, + const dom::MozRequestMatchOptions& aOptions) const; + + already_AddRefed<nsILoadInfo> GetLoadInfo() const { + nsCOMPtr<nsIChannel> chan = MaybeChannel(); + if (chan) { + return chan->LoadInfo(); + } + return nullptr; + } + + int64_t FrameId() const; + + int64_t ParentFrameId() const; + + void GetFrameAncestors( + dom::Nullable<nsTArray<dom::MozFrameAncestorInfo>>& aFrameAncestors, + ErrorResult& aRv) const; + + bool IsServiceWorkerScript() const; + + static bool IsServiceWorkerScript(const nsCOMPtr<nsIChannel>& aChannel); + + bool IsSystemLoad() const; + + void GetOriginURL(nsCString& aRetVal) const; + + void GetDocumentURL(nsCString& aRetVal) const; + + already_AddRefed<nsIURI> GetOriginURI() const; + + already_AddRefed<nsIURI> GetDocumentURI() const; + + already_AddRefed<nsILoadContext> GetLoadContext() const; + + already_AddRefed<dom::Element> GetBrowserElement() const; + + bool CanModify() const; + bool GetCanModify(ErrorResult& aRv) const { return CanModify(); } + + void GetProxyInfo(dom::Nullable<dom::MozProxyInfo>& aRetVal, + ErrorResult& aRv) const; + + void GetRemoteAddress(nsCString& aRetVal) const; + + void GetRequestHeaders(nsTArray<dom::MozHTTPHeader>& aRetVal, + ErrorResult& aRv) const; + void GetRequestHeader(const nsCString& aHeader, nsCString& aResult, + ErrorResult& aRv) const; + + void GetResponseHeaders(nsTArray<dom::MozHTTPHeader>& aRetVal, + ErrorResult& aRv) const; + + void SetRequestHeader(const nsCString& header, const nsCString& value, + bool merge, ErrorResult& aRv); + + void SetResponseHeader(const nsCString& header, const nsCString& value, + bool merge, ErrorResult& aRv); + + void GetUrlClassification(dom::Nullable<dom::MozUrlClassification>& aRetVal, + ErrorResult& aRv) const; + + bool ThirdParty() const; + + using EventTarget::EventListenerAdded; + using EventTarget::EventListenerRemoved; + virtual void EventListenerAdded(nsAtom* aType) override; + virtual void EventListenerRemoved(nsAtom* aType) override; + + nsISupports* GetParentObject() const { return mParent; } + + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + protected: + ~ChannelWrapper(); + + private: + ChannelWrapper(nsISupports* aParent, nsIChannel* aChannel); + + void ClearCachedAttributes(); + + bool CheckAlive(ErrorResult& aRv) const { + if (!HaveChannel()) { + aRv.Throw(NS_ERROR_UNEXPECTED); + return false; + } + return true; + } + + void FireEvent(const nsAString& aType); + + const URLInfo& FinalURLInfo() const; + const URLInfo* DocumentURLInfo() const; + + uint64_t BrowsingContextId(nsILoadInfo* aLoadInfo) const; + + nsresult GetFrameAncestors( + nsILoadInfo* aLoadInfo, + nsTArray<dom::MozFrameAncestorInfo>& aFrameAncestors) const; + + static uint64_t GetNextId() { + static uint64_t sNextId = 1; + return ++sNextId; + } + + void CheckEventListeners(); + + class ChannelWrapperStub final : public nsISupports { + public: + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_CLASS(ChannelWrapperStub) + + explicit ChannelWrapperStub(ChannelWrapper* aChannelWrapper) + : mChannelWrapper(aChannelWrapper) {} + + private: + friend class ChannelWrapper; + + RefPtr<ChannelWrapper> mChannelWrapper; + + protected: + ~ChannelWrapperStub() = default; + }; + + RefPtr<ChannelWrapperStub> mStub; + + mutable Maybe<URLInfo> mFinalURLInfo; + mutable Maybe<URLInfo> mDocumentURLInfo; + + UniquePtr<WebRequestChannelEntry> mChannelEntry; + + // The overridden Content-Type header value. + nsCString mContentTypeHdr = VoidCString(); + + const uint64_t mId = GetNextId(); + nsCOMPtr<nsISupports> mParent; + + bool mAddedStreamListener = false; + bool mFiredErrorEvent = false; + bool mSuspended = false; + bool mResponseStarted = false; + + nsInterfaceHashtable<nsAtomHashKey, nsIRemoteTab> mAddonEntries; + + // The text for the "Extension Suspend" marker, set from the Suspend method + // when called for the first time and then cleared on the Resume method. + nsCString mSuspendedMarkerText = VoidCString(); + + class RequestListener final : public nsIMultiPartChannelListener, + public nsIThreadRetargetableStreamListener { + public: + NS_DECL_THREADSAFE_ISUPPORTS + NS_DECL_NSIREQUESTOBSERVER + NS_DECL_NSISTREAMLISTENER + NS_DECL_NSIMULTIPARTCHANNELLISTENER + NS_DECL_NSITHREADRETARGETABLESTREAMLISTENER + + explicit RequestListener(ChannelWrapper* aWrapper) + : mChannelWrapper(aWrapper) {} + + nsresult Init(); + + protected: + virtual ~RequestListener(); + + private: + RefPtr<ChannelWrapper> mChannelWrapper; + nsCOMPtr<nsIStreamListener> mOrigStreamListener; + }; +}; + +NS_DEFINE_STATIC_IID_ACCESSOR(ChannelWrapper, NS_CHANNELWRAPPER_IID) + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_ChannelWrapper_h diff --git a/toolkit/components/extensions/webrequest/PStreamFilter.ipdl b/toolkit/components/extensions/webrequest/PStreamFilter.ipdl new file mode 100644 index 0000000000..d651ba1760 --- /dev/null +++ b/toolkit/components/extensions/webrequest/PStreamFilter.ipdl @@ -0,0 +1,39 @@ +/* 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 protocol PBackground; + +namespace mozilla { +namespace extensions { + +[ParentProc=any, ChildProc=any] +async protocol PStreamFilter +{ +parent: + async Write(uint8_t[] data); + + async FlushedData(); + + async Suspend(); + async Resume(); + async Close(); + async Disconnect(); + async Destroy(); + +child: + async Resumed(); + async Suspended(); + async Closed(); + async Error(nsCString error); + + async FlushData(); + + async StartRequest(); + async Data(uint8_t[] data); + async StopRequest(nsresult aStatus); +}; + +} // namespace extensions +} // namespace mozilla + diff --git a/toolkit/components/extensions/webrequest/SecurityInfo.sys.mjs b/toolkit/components/extensions/webrequest/SecurityInfo.sys.mjs new file mode 100644 index 0000000000..7efd1cbcdf --- /dev/null +++ b/toolkit/components/extensions/webrequest/SecurityInfo.sys.mjs @@ -0,0 +1,359 @@ +/* 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 wpl = Ci.nsIWebProgressListener; +const lazy = {}; +XPCOMUtils.defineLazyServiceGetter( + lazy, + "NSSErrorsService", + "@mozilla.org/nss_errors_service;1", + "nsINSSErrorsService" +); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "pkps", + "@mozilla.org/security/publickeypinningservice;1", + "nsIPublicKeyPinningService" +); + +// NOTE: SecurityInfo is largely reworked from the devtools NetworkHelper with changes +// to better support the WebRequest api. The objects returned are formatted specifically +// to pass through as part of a response to webRequest listeners. + +export const SecurityInfo = { + /** + * Extracts security information from nsIChannel.securityInfo. + * + * @param {nsIChannel} channel + * If null channel is assumed to be insecure. + * @param {object} options + * + * @returns {object} + * Returns an object containing following members: + * - state: The security of the connection used to fetch this + * request. Has one of following string values: + * - "insecure": the connection was not secure (only http) + * - "weak": the connection has minor security issues + * - "broken": secure connection failed (e.g. expired cert) + * - "secure": the connection was properly secured. + * If state == broken: + * - errorMessage: full error message from + * nsITransportSecurityInfo. + * If state == secure: + * - protocolVersion: one of TLSv1, TLSv1.1, TLSv1.2, TLSv1.3. + * - cipherSuite: the cipher suite used in this connection. + * - cert: information about certificate used in this connection. + * See parseCertificateInfo for the contents. + * - hsts: true if host uses Strict Transport Security, + * false otherwise + * - hpkp: true if host uses Public Key Pinning, false otherwise + * If state == weak: Same as state == secure and + * - weaknessReasons: list of reasons that cause the request to be + * considered weak. See getReasonsForWeakness. + */ + getSecurityInfo(channel, options = {}) { + const info = { + state: "insecure", + }; + + /** + * Different scenarios to consider here and how they are handled: + * - request is HTTP, the connection is not secure + * => securityInfo is null + * => state === "insecure" + * + * - request is HTTPS, the connection is secure + * => .securityState has STATE_IS_SECURE flag + * => state === "secure" + * + * - request is HTTPS, the connection has security issues + * => .securityState has STATE_IS_INSECURE flag + * => .errorCode is an NSS error code. + * => state === "broken" + * + * - request is HTTPS, the connection was terminated before the security + * could be validated + * => .securityState has STATE_IS_INSECURE flag + * => .errorCode is NOT an NSS error code. + * => .errorMessage is not available. + * => state === "insecure" + * + * - request is HTTPS but it uses a weak cipher or old protocol, see + * https://hg.mozilla.org/mozilla-central/annotate/def6ed9d1c1a/ + * security/manager/ssl/nsNSSCallbacks.cpp#l1233 + * - request is mixed content (which makes no sense whatsoever) + * => .securityState has STATE_IS_BROKEN flag + * => .errorCode is NOT an NSS error code + * => .errorMessage is not available + * => state === "weak" + */ + + let securityInfo = channel.securityInfo; + + if (!securityInfo) { + return info; + } + + if (lazy.NSSErrorsService.isNSSErrorCode(securityInfo.errorCode)) { + // The connection failed. + info.state = "broken"; + info.errorMessage = securityInfo.errorMessage; + if (options.certificateChain && securityInfo.failedCertChain) { + info.certificates = this.getCertificateChain( + securityInfo.failedCertChain, + false, + options + ); + } + return info; + } + + const state = securityInfo.securityState; + + let uri = channel.URI; + if (uri && !uri.schemeIs("https") && !uri.schemeIs("wss")) { + // it is not enough to look at the transport security info - + // schemes other than https and wss are subject to + // downgrade/etc at the scheme level and should always be + // considered insecure. + // Leave info.state = "insecure"; + } else if (state & wpl.STATE_IS_SECURE) { + // The connection is secure if the scheme is sufficient + info.state = "secure"; + } else if (state & wpl.STATE_IS_BROKEN) { + // The connection is not secure, there was no error but there's some + // minor security issues. + info.state = "weak"; + info.weaknessReasons = this.getReasonsForWeakness(state); + } else if (state & wpl.STATE_IS_INSECURE) { + // This was most likely an https request that was aborted before + // validation. Return info as info.state = insecure. + return info; + } else { + // No known STATE_IS_* flags. + return info; + } + + // Cipher suite. + info.cipherSuite = securityInfo.cipherName; + + // Length (in bits) of the secret key + info.secretKeyLength = securityInfo.secretKeyLength; + + // Key exchange group name. + if (securityInfo.keaGroupName !== "none") { + info.keaGroupName = securityInfo.keaGroupName; + } + + // Certificate signature scheme. + if (securityInfo.signatureSchemeName !== "none") { + info.signatureSchemeName = securityInfo.signatureSchemeName; + } + + if ( + securityInfo.overridableErrorCategory == + Ci.nsITransportSecurityInfo.ERROR_TRUST + ) { + info.overridableErrorCategory = "trust_error"; + info.isUntrusted = true; + } else if ( + securityInfo.overridableErrorCategory == + Ci.nsITransportSecurityInfo.ERROR_DOMAIN + ) { + info.overridableErrorCategory = "domain_mismatch"; + info.isDomainMismatch = true; + } else if ( + securityInfo.overridableErrorCategory == + Ci.nsITransportSecurityInfo.ERROR_TIME + ) { + info.overridableErrorCategory = "expired_or_not_yet_valid"; + info.isNotValidAtThisTime = true; + } + info.isExtendedValidation = securityInfo.isExtendedValidation; + + info.certificateTransparencyStatus = this.getTransparencyStatus( + securityInfo.certificateTransparencyStatus + ); + + // Protocol version. + info.protocolVersion = this.formatSecurityProtocol( + securityInfo.protocolVersion + ); + + if (options.certificateChain && securityInfo.succeededCertChain) { + info.certificates = this.getCertificateChain( + securityInfo.succeededCertChain, + securityInfo.isBuiltCertChainRootBuiltInRoot, + options + ); + } else { + info.certificates = [ + this.parseCertificateInfo(securityInfo.serverCert, false, options), + ]; + } + + // HSTS and static pinning if available. + if (uri && uri.host) { + info.hsts = channel.loadInfo.hstsStatus; + info.hpkp = lazy.pkps.hostHasPins(uri); + } else { + info.hsts = false; + info.hpkp = false; + } + + // These values can be unset in rare cases, e.g. when stashed connection + // data is deseralized from an older version of Firefox. + try { + info.usedEch = securityInfo.isAcceptedEch; + } catch { + info.usedEch = false; + } + try { + info.usedDelegatedCredentials = securityInfo.isDelegatedCredential; + } catch { + info.usedDelegatedCredentials = false; + } + info.usedOcsp = securityInfo.madeOCSPRequests; + info.usedPrivateDns = securityInfo.usedPrivateDNS; + + return info; + }, + + getCertificateChain(certChain, isRootBuiltInRoot, options = {}) { + let certificates = []; + // The end-entity and intermediates aren't built-in roots. + for (let cert of certChain.slice(0, -1)) { + certificates.push(this.parseCertificateInfo(cert, false, options)); + } + // The last in the chain may be the root (for successful chains), which may + // be a built-in root. + let rootCert = certChain.at(-1); + if (rootCert) { + certificates.push( + this.parseCertificateInfo(rootCert, isRootBuiltInRoot, options) + ); + } + return certificates; + }, + + /** + * Takes an nsIX509Cert and returns an object with certificate information. + * + * @param {nsIX509Cert} cert + * The certificate to extract the information from. + * @param {boolean} isBuiltInRoot + * Whether or not this certificate is a built-in root. + * @param {object} options + * @returns {object} + * An object with following format: + * { + * subject: subjectName, + * issuer: issuerName, + * validity: { start, end }, + * fingerprint: { sha1, sha256 } + * } + */ + parseCertificateInfo(cert, isBuiltInRoot, options = {}) { + if (!cert) { + return {}; + } + + let certData = { + subject: cert.subjectName, + issuer: cert.issuerName, + validity: { + start: cert.validity.notBefore + ? Math.trunc(cert.validity.notBefore / 1000) + : 0, + end: cert.validity.notAfter + ? Math.trunc(cert.validity.notAfter / 1000) + : 0, + }, + fingerprint: { + sha1: cert.sha1Fingerprint, + sha256: cert.sha256Fingerprint, + }, + serialNumber: cert.serialNumber, + isBuiltInRoot, + subjectPublicKeyInfoDigest: { + sha256: cert.sha256SubjectPublicKeyInfoDigest, + }, + }; + if (options.rawDER) { + certData.rawDER = cert.getRawDER(); + } + return certData; + }, + + // Bug 1355903 Transparency is currently disabled using security.pki.certificate_transparency.mode + getTransparencyStatus(status) { + switch (status) { + case Ci.nsITransportSecurityInfo.CERTIFICATE_TRANSPARENCY_NOT_APPLICABLE: + return "not_applicable"; + case Ci.nsITransportSecurityInfo + .CERTIFICATE_TRANSPARENCY_POLICY_COMPLIANT: + return "policy_compliant"; + case Ci.nsITransportSecurityInfo + .CERTIFICATE_TRANSPARENCY_POLICY_NOT_ENOUGH_SCTS: + return "policy_not_enough_scts"; + case Ci.nsITransportSecurityInfo + .CERTIFICATE_TRANSPARENCY_POLICY_NOT_DIVERSE_SCTS: + return "policy_not_diverse_scts"; + } + return "unknown"; + }, + + /** + * Takes protocolVersion of TransportSecurityInfo object and returns human readable + * description. + * + * @param {number} version + * One of nsITransportSecurityInfo version constants. + * @returns {string} + * One of TLSv1, TLSv1.1, TLSv1.2, TLSv1.3 if version + * is valid, Unknown otherwise. + */ + formatSecurityProtocol(version) { + switch (version) { + case Ci.nsITransportSecurityInfo.TLS_VERSION_1: + return "TLSv1"; + case Ci.nsITransportSecurityInfo.TLS_VERSION_1_1: + return "TLSv1.1"; + case Ci.nsITransportSecurityInfo.TLS_VERSION_1_2: + return "TLSv1.2"; + case Ci.nsITransportSecurityInfo.TLS_VERSION_1_3: + return "TLSv1.3"; + } + return "unknown"; + }, + + /** + * Takes the securityState bitfield and returns reasons for weak connection + * as an array of strings. + * + * @param {number} state + * nsITransportSecurityInfo.securityState. + * + * @returns {Array<string>} + * List of weakness reasons. A subset of { cipher } where + * cipher: The cipher suite is consireded to be weak (RC4). + */ + getReasonsForWeakness(state) { + // If there's non-fatal security issues the request has STATE_IS_BROKEN + // flag set. See https://hg.mozilla.org/mozilla-central/file/44344099d119 + // /security/manager/ssl/nsNSSCallbacks.cpp#l1233 + let reasons = []; + + if (state & wpl.STATE_IS_BROKEN) { + if (state & wpl.STATE_USES_WEAK_CRYPTO) { + reasons.push("cipher"); + } + } + + return reasons; + }, +}; diff --git a/toolkit/components/extensions/webrequest/StreamFilter.cpp b/toolkit/components/extensions/webrequest/StreamFilter.cpp new file mode 100644 index 0000000000..abfdabb3d5 --- /dev/null +++ b/toolkit/components/extensions/webrequest/StreamFilter.cpp @@ -0,0 +1,267 @@ +/* -*- 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 "StreamFilter.h" + +#include "jsapi.h" +#include "jsfriendapi.h" +#include "xpcpublic.h" + +#include "mozilla/AbstractThread.h" +#include "mozilla/extensions/StreamFilterChild.h" +#include "mozilla/extensions/StreamFilterEvents.h" +#include "mozilla/extensions/StreamFilterParent.h" +#include "mozilla/dom/AutoEntryScript.h" +#include "mozilla/dom/ContentChild.h" +#include "mozilla/dom/RootedDictionary.h" +#include "mozilla/ipc/Endpoint.h" +#include "nsContentUtils.h" +#include "nsCycleCollectionParticipant.h" +#include "nsLiteralString.h" +#include "nsThreadUtils.h" +#include "nsTArray.h" + +using namespace JS; +using namespace mozilla::dom; + +namespace mozilla { +namespace extensions { + +/***************************************************************************** + * Initialization + *****************************************************************************/ + +StreamFilter::StreamFilter(nsIGlobalObject* aParent, uint64_t aRequestId, + const nsAString& aAddonId) + : mParent(aParent), mChannelId(aRequestId), mAddonId(NS_Atomize(aAddonId)) { + MOZ_ASSERT(aParent); + + Connect(); +}; + +StreamFilter::~StreamFilter() { ForgetActor(); } + +void StreamFilter::ForgetActor() { + if (mActor) { + mActor->Cleanup(); + mActor->SetStreamFilter(nullptr); + } +} + +/* static */ +already_AddRefed<StreamFilter> StreamFilter::Create(GlobalObject& aGlobal, + uint64_t aRequestId, + const nsAString& aAddonId) { + nsCOMPtr<nsIGlobalObject> global = do_QueryInterface(aGlobal.GetAsSupports()); + MOZ_ASSERT(global); + + RefPtr<StreamFilter> filter = new StreamFilter(global, aRequestId, aAddonId); + return filter.forget(); +} + +/***************************************************************************** + * Actor allocation + *****************************************************************************/ + +void StreamFilter::Connect() { + MOZ_ASSERT(!mActor); + + mActor = new StreamFilterChild(); + mActor->SetStreamFilter(this); + + nsAutoString addonId; + mAddonId->ToString(addonId); + + ContentChild* cc = ContentChild::GetSingleton(); + RefPtr<StreamFilter> self(this); + if (cc) { + cc->SendInitStreamFilter(mChannelId, addonId) + ->Then( + GetCurrentSerialEventTarget(), __func__, + [self](mozilla::ipc::Endpoint<PStreamFilterChild>&& aEndpoint) { + self->FinishConnect(std::move(aEndpoint)); + }, + [self](mozilla::ipc::ResponseRejectReason&& aReason) { + self->mActor->RecvInitialized(false); + }); + } else { + StreamFilterParent::Create(nullptr, mChannelId, addonId) + ->Then( + GetCurrentSerialEventTarget(), __func__, + [self](mozilla::ipc::Endpoint<PStreamFilterChild>&& aEndpoint) { + self->FinishConnect(std::move(aEndpoint)); + }, + [self](bool aDummy) { self->mActor->RecvInitialized(false); }); + } +} + +void StreamFilter::FinishConnect( + mozilla::ipc::Endpoint<PStreamFilterChild>&& aEndpoint) { + if (aEndpoint.IsValid()) { + MOZ_RELEASE_ASSERT(aEndpoint.Bind(mActor)); + mActor->RecvInitialized(true); + } else { + mActor->RecvInitialized(false); + } +} + +bool StreamFilter::CheckAlive() { + // Check whether the global that owns this StreamFitler is still scriptable + // and, if not, disconnect the actor so that it can be cleaned up. + JSObject* wrapper = GetWrapperPreserveColor(); + if (!wrapper || !xpc::Scriptability::Get(wrapper).Allowed()) { + ForgetActor(); + return false; + } + return true; +} + +/***************************************************************************** + * Binding methods + *****************************************************************************/ + +void StreamFilter::Write(const ArrayBufferOrUint8Array& aData, + ErrorResult& aRv) { + if (!mActor) { + aRv.Throw(NS_ERROR_NOT_INITIALIZED); + return; + } + + nsTArray<uint8_t> data; + if (!AppendTypedArrayDataTo(aData, data)) { + aRv.Throw(NS_ERROR_OUT_OF_MEMORY); + return; + } + + mActor->Write(std::move(data), aRv); +} + +StreamFilterStatus StreamFilter::Status() const { + if (!mActor) { + return StreamFilterStatus::Uninitialized; + } + return mActor->Status(); +} + +void StreamFilter::Suspend(ErrorResult& aRv) { + if (mActor) { + mActor->Suspend(aRv); + } else { + aRv.Throw(NS_ERROR_NOT_INITIALIZED); + } +} + +void StreamFilter::Resume(ErrorResult& aRv) { + if (mActor) { + mActor->Resume(aRv); + } else { + aRv.Throw(NS_ERROR_NOT_INITIALIZED); + } +} + +void StreamFilter::Disconnect(ErrorResult& aRv) { + if (mActor) { + mActor->Disconnect(aRv); + } else { + aRv.Throw(NS_ERROR_NOT_INITIALIZED); + } +} + +void StreamFilter::Close(ErrorResult& aRv) { + if (mActor) { + mActor->Close(aRv); + } else { + aRv.Throw(NS_ERROR_NOT_INITIALIZED); + } +} + +/***************************************************************************** + * Event emitters + *****************************************************************************/ + +void StreamFilter::FireEvent(const nsAString& aType) { + EventInit init; + init.mBubbles = false; + init.mCancelable = false; + + RefPtr<Event> event = Event::Constructor(this, aType, init); + event->SetTrusted(true); + + DispatchEvent(*event); +} + +void StreamFilter::FireDataEvent(const nsTArray<uint8_t>& aData) { + AutoEntryScript aes(mParent, "StreamFilter data event"); + JSContext* cx = aes.cx(); + + RootedDictionary<StreamFilterDataEventInit> init(cx); + init.mBubbles = false; + init.mCancelable = false; + + ErrorResult error; + auto buffer = ArrayBuffer::Create(cx, aData, error); + if (error.Failed()) { + // TODO: There is no way to recover from this. This chunk of data is lost. + error.SuppressException(); + FireErrorEvent(u"Out of memory"_ns); + return; + } + + init.mData.Init(buffer); + + RefPtr<StreamFilterDataEvent> event = + StreamFilterDataEvent::Constructor(this, u"data"_ns, init); + event->SetTrusted(true); + + DispatchEvent(*event); +} + +void StreamFilter::FireErrorEvent(const nsAString& aError) { + MOZ_ASSERT(mError.IsEmpty()); + + mError = aError; + FireEvent(u"error"_ns); +} + +/***************************************************************************** + * Glue + *****************************************************************************/ + +/* static */ +bool StreamFilter::IsAllowedInContext(JSContext* aCx, JSObject* /* unused */) { + return nsContentUtils::CallerHasPermission(aCx, + nsGkAtoms::webRequestBlocking); +} + +JSObject* StreamFilter::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return StreamFilter_Binding::Wrap(aCx, this, aGivenProto); +} + +NS_IMPL_CYCLE_COLLECTION_CLASS(StreamFilter) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(StreamFilter) +NS_INTERFACE_MAP_END_INHERITING(DOMEventTargetHelper) + +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN_INHERITED(StreamFilter, + DOMEventTargetHelper) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mParent) +NS_IMPL_CYCLE_COLLECTION_UNLINK_END + +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN_INHERITED(StreamFilter, + DOMEventTargetHelper) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mParent) +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_IMPL_CYCLE_COLLECTION_TRACE_BEGIN_INHERITED(StreamFilter, + DOMEventTargetHelper) +NS_IMPL_CYCLE_COLLECTION_TRACE_END + +NS_IMPL_ADDREF_INHERITED(StreamFilter, DOMEventTargetHelper) +NS_IMPL_RELEASE_INHERITED(StreamFilter, DOMEventTargetHelper) + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/webrequest/StreamFilter.h b/toolkit/components/extensions/webrequest/StreamFilter.h new file mode 100644 index 0000000000..e5df536167 --- /dev/null +++ b/toolkit/components/extensions/webrequest/StreamFilter.h @@ -0,0 +1,96 @@ +/* -*- Mode: C++; tab-width: 2; 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/. */ + +#ifndef mozilla_extensions_StreamFilter_h +#define mozilla_extensions_StreamFilter_h + +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/dom/StreamFilterBinding.h" + +#include "mozilla/DOMEventTargetHelper.h" +#include "nsCOMPtr.h" +#include "nsCycleCollectionParticipant.h" +#include "nsAtom.h" + +namespace mozilla { + +namespace ipc { +template <class T> +class Endpoint; +} + +namespace extensions { + +class PStreamFilterChild; +class StreamFilterChild; + +class StreamFilter : public DOMEventTargetHelper { + friend class StreamFilterChild; + + NS_DECL_ISUPPORTS_INHERITED + NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS_INHERITED(StreamFilter, + DOMEventTargetHelper) + + static already_AddRefed<StreamFilter> Create(dom::GlobalObject& global, + uint64_t aRequestId, + const nsAString& aAddonId); + + explicit StreamFilter(nsIGlobalObject* aParent, uint64_t aRequestId, + const nsAString& aAddonId); + + IMPL_EVENT_HANDLER(start); + IMPL_EVENT_HANDLER(stop); + IMPL_EVENT_HANDLER(data); + IMPL_EVENT_HANDLER(error); + + void Write(const dom::ArrayBufferOrUint8Array& aData, ErrorResult& aRv); + + void GetError(nsAString& aError) { aError = mError; } + + dom::StreamFilterStatus Status() const; + void Suspend(ErrorResult& aRv); + void Resume(ErrorResult& aRv); + void Disconnect(ErrorResult& aRv); + void Close(ErrorResult& aRv); + + nsISupports* GetParentObject() const { return mParent; } + + virtual JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + static bool IsAllowedInContext(JSContext* aCx, JSObject* aObj); + + protected: + virtual ~StreamFilter(); + + void FireEvent(const nsAString& aType); + + void FireDataEvent(const nsTArray<uint8_t>& aData); + + void FireErrorEvent(const nsAString& aError); + + bool CheckAlive(); + + private: + void Connect(); + + void FinishConnect(mozilla::ipc::Endpoint<PStreamFilterChild>&& aEndpoint); + + void ForgetActor(); + + nsCOMPtr<nsIGlobalObject> mParent; + RefPtr<StreamFilterChild> mActor; + + nsString mError; + + const uint64_t mChannelId; + const RefPtr<nsAtom> mAddonId; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_StreamFilter_h diff --git a/toolkit/components/extensions/webrequest/StreamFilterBase.h b/toolkit/components/extensions/webrequest/StreamFilterBase.h new file mode 100644 index 0000000000..4f413835ef --- /dev/null +++ b/toolkit/components/extensions/webrequest/StreamFilterBase.h @@ -0,0 +1,38 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_StreamFilterBase_h +#define mozilla_extensions_StreamFilterBase_h + +#include "mozilla/LinkedList.h" +#include "nsTArray.h" + +namespace mozilla { +namespace extensions { + +class StreamFilterBase { + public: + typedef nsTArray<uint8_t> Data; + + protected: + class BufferedData : public LinkedListElement<BufferedData> { + public: + explicit BufferedData(Data&& aData) : mData(std::move(aData)) {} + + Data mData; + }; + + LinkedList<BufferedData> mBufferedData; + + inline void BufferData(Data&& aData) { + mBufferedData.insertBack(new BufferedData(std::move(aData))); + }; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_StreamFilterBase_h diff --git a/toolkit/components/extensions/webrequest/StreamFilterChild.cpp b/toolkit/components/extensions/webrequest/StreamFilterChild.cpp new file mode 100644 index 0000000000..955460f834 --- /dev/null +++ b/toolkit/components/extensions/webrequest/StreamFilterChild.cpp @@ -0,0 +1,516 @@ +/* -*- 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 "StreamFilterChild.h" +#include "StreamFilter.h" + +#include "mozilla/Assertions.h" +#include "mozilla/UniquePtr.h" + +namespace mozilla { +namespace extensions { + +using mozilla::dom::StreamFilterStatus; +using mozilla::ipc::IPCResult; + +/***************************************************************************** + * Initialization and cleanup + *****************************************************************************/ + +void StreamFilterChild::Cleanup() { + switch (mState) { + case State::Closing: + case State::Closed: + case State::Error: + case State::Disconnecting: + case State::Disconnected: + break; + + default: + ErrorResult rv; + Disconnect(rv); + break; + } +} + +/***************************************************************************** + * State change methods + *****************************************************************************/ + +void StreamFilterChild::Suspend(ErrorResult& aRv) { + switch (mState) { + case State::TransferringData: + mState = State::Suspending; + mNextState = State::Suspended; + + SendSuspend(); + break; + + case State::Suspending: + switch (mNextState) { + case State::Suspended: + case State::Resuming: + mNextState = State::Suspended; + break; + + default: + aRv.Throw(NS_ERROR_FAILURE); + return; + } + break; + + case State::Resuming: + switch (mNextState) { + case State::TransferringData: + case State::Suspending: + mNextState = State::Suspending; + break; + + default: + aRv.Throw(NS_ERROR_FAILURE); + return; + } + break; + + case State::Suspended: + break; + + default: + aRv.Throw(NS_ERROR_FAILURE); + break; + } +} + +void StreamFilterChild::Resume(ErrorResult& aRv) { + switch (mState) { + case State::Suspended: + mState = State::Resuming; + mNextState = State::TransferringData; + + SendResume(); + break; + + case State::Suspending: + switch (mNextState) { + case State::Suspended: + case State::Resuming: + mNextState = State::Resuming; + break; + + default: + aRv.Throw(NS_ERROR_FAILURE); + return; + } + break; + + case State::Resuming: + case State::TransferringData: + break; + + default: + aRv.Throw(NS_ERROR_FAILURE); + return; + } + + FlushBufferedData(); +} + +void StreamFilterChild::Disconnect(ErrorResult& aRv) { + switch (mState) { + case State::Suspended: + case State::TransferringData: + case State::FinishedTransferringData: + mState = State::Disconnecting; + mNextState = State::Disconnected; + + WriteBufferedData(); + SendDisconnect(); + break; + + case State::Suspending: + case State::Resuming: + switch (mNextState) { + case State::Suspended: + case State::Resuming: + case State::Disconnecting: + mNextState = State::Disconnecting; + break; + + default: + aRv.Throw(NS_ERROR_FAILURE); + return; + } + break; + + case State::Disconnecting: + case State::Disconnected: + break; + + default: + aRv.Throw(NS_ERROR_FAILURE); + return; + } +} + +void StreamFilterChild::Close(ErrorResult& aRv) { + switch (mState) { + case State::Suspended: + case State::TransferringData: + case State::FinishedTransferringData: + mState = State::Closing; + mNextState = State::Closed; + + SendClose(); + break; + + case State::Suspending: + case State::Resuming: + mNextState = State::Closing; + break; + + case State::Closing: + MOZ_DIAGNOSTIC_ASSERT(mNextState == State::Closed); + break; + + case State::Closed: + break; + + default: + aRv.Throw(NS_ERROR_FAILURE); + return; + } + + mBufferedData.clear(); +} + +/***************************************************************************** + * Internal state management + *****************************************************************************/ + +void StreamFilterChild::SetNextState() { + mState = mNextState; + + switch (mNextState) { + case State::Suspending: + mNextState = State::Suspended; + SendSuspend(); + break; + + case State::Resuming: + mNextState = State::TransferringData; + SendResume(); + break; + + case State::Closing: + mNextState = State::Closed; + SendClose(); + break; + + case State::Disconnecting: + mNextState = State::Disconnected; + SendDisconnect(); + break; + + case State::FinishedTransferringData: + if (mStreamFilter) { + mStreamFilter->FireEvent(u"stop"_ns); + // We don't need access to the stream filter after this point, so break + // our reference cycle, so that it can be collected if we're the last + // reference. + mStreamFilter = nullptr; + } + break; + + case State::TransferringData: + FlushBufferedData(); + break; + + case State::Closed: + case State::Disconnected: + case State::Error: + mStreamFilter = nullptr; + break; + + default: + break; + } +} + +void StreamFilterChild::MaybeStopRequest() { + if (!mReceivedOnStop || !mBufferedData.isEmpty()) { + return; + } + + if (mStreamFilter) { + Unused << mStreamFilter->CheckAlive(); + } + + switch (mState) { + case State::Suspending: + case State::Resuming: + mNextState = State::FinishedTransferringData; + return; + + case State::Disconnecting: + case State::Closing: + case State::Closed: + break; + + default: + mState = State::FinishedTransferringData; + if (mStreamFilter) { + mStreamFilter->FireEvent(u"stop"_ns); + // We don't need access to the stream filter after this point, so break + // our reference cycle, so that it can be collected if we're the last + // reference. + mStreamFilter = nullptr; + } + break; + } +} + +/***************************************************************************** + * State change acknowledgment callbacks + *****************************************************************************/ + +void StreamFilterChild::RecvInitialized(bool aSuccess) { + MOZ_ASSERT(mState == State::Uninitialized); + + if (aSuccess) { + mState = State::Initialized; + } else { + mState = State::Error; + if (mStreamFilter) { + mStreamFilter->FireErrorEvent(u"Invalid request ID"_ns); + mStreamFilter = nullptr; + } + } +} + +IPCResult StreamFilterChild::RecvError(const nsCString& aError) { + mState = State::Error; + if (mStreamFilter) { + mStreamFilter->FireErrorEvent(NS_ConvertUTF8toUTF16(aError)); + mStreamFilter = nullptr; + } + SendDestroy(); + return IPC_OK(); +} + +IPCResult StreamFilterChild::RecvClosed() { + MOZ_DIAGNOSTIC_ASSERT(mState == State::Closing); + + SetNextState(); + return IPC_OK(); +} + +IPCResult StreamFilterChild::RecvSuspended() { + MOZ_DIAGNOSTIC_ASSERT(mState == State::Suspending); + + SetNextState(); + return IPC_OK(); +} + +IPCResult StreamFilterChild::RecvResumed() { + MOZ_DIAGNOSTIC_ASSERT(mState == State::Resuming); + + SetNextState(); + return IPC_OK(); +} + +IPCResult StreamFilterChild::RecvFlushData() { + MOZ_DIAGNOSTIC_ASSERT(mState == State::Disconnecting); + + SendFlushedData(); + SetNextState(); + return IPC_OK(); +} + +/***************************************************************************** + * Other binding methods + *****************************************************************************/ + +void StreamFilterChild::Write(Data&& aData, ErrorResult& aRv) { + switch (mState) { + case State::Suspending: + case State::Resuming: + switch (mNextState) { + case State::Suspended: + case State::TransferringData: + break; + + default: + aRv.Throw(NS_ERROR_FAILURE); + return; + } + break; + + case State::Suspended: + case State::TransferringData: + case State::FinishedTransferringData: + break; + + default: + aRv.Throw(NS_ERROR_FAILURE); + return; + } + + SendWrite(std::move(aData)); +} + +StreamFilterStatus StreamFilterChild::Status() const { + switch (mState) { + case State::Uninitialized: + case State::Initialized: + return StreamFilterStatus::Uninitialized; + + case State::TransferringData: + return StreamFilterStatus::Transferringdata; + + case State::Suspended: + return StreamFilterStatus::Suspended; + + case State::FinishedTransferringData: + return StreamFilterStatus::Finishedtransferringdata; + + case State::Resuming: + case State::Suspending: + switch (mNextState) { + case State::TransferringData: + case State::Resuming: + return StreamFilterStatus::Transferringdata; + + case State::Suspended: + case State::Suspending: + return StreamFilterStatus::Suspended; + + case State::Closing: + return StreamFilterStatus::Closed; + + case State::Disconnecting: + return StreamFilterStatus::Disconnected; + + default: + MOZ_ASSERT_UNREACHABLE("Unexpected next state"); + return StreamFilterStatus::Suspended; + } + break; + + case State::Closing: + case State::Closed: + return StreamFilterStatus::Closed; + + case State::Disconnecting: + case State::Disconnected: + return StreamFilterStatus::Disconnected; + + case State::Error: + return StreamFilterStatus::Failed; + }; + + MOZ_ASSERT_UNREACHABLE("Not reached"); + return StreamFilterStatus::Failed; +} + +/***************************************************************************** + * Request state notifications + *****************************************************************************/ + +IPCResult StreamFilterChild::RecvStartRequest() { + MOZ_ASSERT(mState == State::Initialized); + + mState = State::TransferringData; + + if (mStreamFilter) { + mStreamFilter->FireEvent(u"start"_ns); + Unused << mStreamFilter->CheckAlive(); + } + return IPC_OK(); +} + +IPCResult StreamFilterChild::RecvStopRequest(const nsresult& aStatus) { + mReceivedOnStop = true; + MaybeStopRequest(); + return IPC_OK(); +} + +/***************************************************************************** + * Incoming request data handling + *****************************************************************************/ + +void StreamFilterChild::EmitData(const Data& aData) { + MOZ_ASSERT(CanFlushData()); + if (mStreamFilter) { + mStreamFilter->FireDataEvent(aData); + } + + MaybeStopRequest(); +} + +void StreamFilterChild::FlushBufferedData() { + while (!mBufferedData.isEmpty() && CanFlushData()) { + UniquePtr<BufferedData> data(mBufferedData.popFirst()); + + EmitData(data->mData); + } +} + +void StreamFilterChild::WriteBufferedData() { + while (!mBufferedData.isEmpty()) { + UniquePtr<BufferedData> data(mBufferedData.popFirst()); + + SendWrite(data->mData); + } +} + +IPCResult StreamFilterChild::RecvData(Data&& aData) { + MOZ_ASSERT(!mReceivedOnStop); + + if (mStreamFilter) { + Unused << mStreamFilter->CheckAlive(); + } + + switch (mState) { + case State::TransferringData: + case State::Resuming: + EmitData(aData); + break; + + case State::FinishedTransferringData: + MOZ_ASSERT_UNREACHABLE("Received data in unexpected state"); + EmitData(aData); + break; + + case State::Suspending: + case State::Suspended: + BufferData(std::move(aData)); + break; + + case State::Disconnecting: + SendWrite(std::move(aData)); + break; + + case State::Closing: + break; + + default: + MOZ_ASSERT_UNREACHABLE("Received data in unexpected state"); + return IPC_FAIL_NO_REASON(this); + } + + return IPC_OK(); +} + +/***************************************************************************** + * Glue + *****************************************************************************/ + +void StreamFilterChild::ActorDestroy(ActorDestroyReason aWhy) { + mStreamFilter = nullptr; +} + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/webrequest/StreamFilterChild.h b/toolkit/components/extensions/webrequest/StreamFilterChild.h new file mode 100644 index 0000000000..a3873c1283 --- /dev/null +++ b/toolkit/components/extensions/webrequest/StreamFilterChild.h @@ -0,0 +1,135 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_StreamFilterChild_h +#define mozilla_extensions_StreamFilterChild_h + +#include "StreamFilterBase.h" +#include "mozilla/extensions/PStreamFilterChild.h" +#include "mozilla/extensions/StreamFilter.h" + +#include "mozilla/LinkedList.h" +#include "mozilla/dom/StreamFilterBinding.h" +#include "nsISupportsImpl.h" + +namespace mozilla { +class ErrorResult; + +namespace extensions { + +using mozilla::dom::StreamFilterStatus; +using mozilla::ipc::IPCResult; + +class StreamFilter; + +class StreamFilterChild final : public PStreamFilterChild, + public StreamFilterBase { + friend class StreamFilter; + friend class PStreamFilterChild; + + public: + NS_INLINE_DECL_REFCOUNTING(StreamFilterChild, final) + + StreamFilterChild() : mState(State::Uninitialized), mReceivedOnStop(false) {} + + enum class State { + // Uninitialized, waiting for constructor response from parent. + Uninitialized, + // Initialized, but channel has not begun transferring data. + Initialized, + // The stream's OnStartRequest event has been dispatched, and the channel is + // transferring data. + TransferringData, + // The channel's OnStopRequest event has been dispatched, and the channel is + // no longer transferring data. Data may still be written to the output + // stream listener. + FinishedTransferringData, + // The channel is being suspended, and we're waiting for confirmation of + // suspension from the parent. + Suspending, + // The channel has been suspended in the parent. Data may still be written + // to the output stream listener in this state. + Suspended, + // The channel is suspended. Resume has been called, and we are waiting for + // confirmation of resumption from the parent. + Resuming, + // The close() method has been called, and no further output may be written. + // We are waiting for confirmation from the parent. + Closing, + // The close() method has been called, and we have been disconnected from + // our parent. + Closed, + // The channel is being disconnected from the parent, and all further events + // and data will pass unfiltered. Data received by the child in this state + // will be automatically written to the output stream listener. No data may + // be explicitly written. + Disconnecting, + // The channel has been disconnected from the parent, and all further data + // and events will be transparently passed to the output stream listener + // without passing through the child. + Disconnected, + // An error has occurred and the child is disconnected from the parent. + Error, + }; + + void Suspend(ErrorResult& aRv); + void Resume(ErrorResult& aRv); + void Disconnect(ErrorResult& aRv); + void Close(ErrorResult& aRv); + void Cleanup(); + + void Write(Data&& aData, ErrorResult& aRv); + + State GetState() const { return mState; } + + StreamFilterStatus Status() const; + + void RecvInitialized(bool aSuccess); + + protected: + IPCResult RecvStartRequest(); + IPCResult RecvData(Data&& data); + IPCResult RecvStopRequest(const nsresult& aStatus); + IPCResult RecvError(const nsCString& aError); + + IPCResult RecvClosed(); + IPCResult RecvSuspended(); + IPCResult RecvResumed(); + IPCResult RecvFlushData(); + + void SetStreamFilter(StreamFilter* aStreamFilter) { + mStreamFilter = aStreamFilter; + } + + private: + ~StreamFilterChild() = default; + + void SetNextState(); + + void MaybeStopRequest(); + + void EmitData(const Data& aData); + + bool CanFlushData() { + return (mState == State::TransferringData || mState == State::Resuming); + } + + void FlushBufferedData(); + void WriteBufferedData(); + + virtual void ActorDestroy(ActorDestroyReason aWhy) override; + + State mState; + State mNextState; + bool mReceivedOnStop; + + RefPtr<StreamFilter> mStreamFilter; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_StreamFilterChild_h diff --git a/toolkit/components/extensions/webrequest/StreamFilterEvents.cpp b/toolkit/components/extensions/webrequest/StreamFilterEvents.cpp new file mode 100644 index 0000000000..02eaf80f49 --- /dev/null +++ b/toolkit/components/extensions/webrequest/StreamFilterEvents.cpp @@ -0,0 +1,53 @@ +/* -*- Mode: C++; tab-width: 2; 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 "mozilla/extensions/StreamFilterEvents.h" + +namespace mozilla { +namespace extensions { + +NS_IMPL_CYCLE_COLLECTION_CLASS(StreamFilterDataEvent) + +NS_IMPL_ADDREF_INHERITED(StreamFilterDataEvent, Event) +NS_IMPL_RELEASE_INHERITED(StreamFilterDataEvent, Event) + +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN_INHERITED(StreamFilterDataEvent, Event) +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_IMPL_CYCLE_COLLECTION_TRACE_BEGIN_INHERITED(StreamFilterDataEvent, Event) + NS_IMPL_CYCLE_COLLECTION_TRACE_JS_MEMBER_CALLBACK(mData) +NS_IMPL_CYCLE_COLLECTION_TRACE_END + +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN_INHERITED(StreamFilterDataEvent, Event) + tmp->mData = nullptr; +NS_IMPL_CYCLE_COLLECTION_UNLINK_END + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(StreamFilterDataEvent) +NS_INTERFACE_MAP_END_INHERITING(Event) + +/* static */ +already_AddRefed<StreamFilterDataEvent> StreamFilterDataEvent::Constructor( + dom::EventTarget* aEventTarget, const nsAString& aType, + const dom::StreamFilterDataEventInit& aParam) { + RefPtr<StreamFilterDataEvent> event = new StreamFilterDataEvent(aEventTarget); + + bool trusted = event->Init(aEventTarget); + event->InitEvent(aType, aParam.mBubbles, aParam.mCancelable); + event->SetTrusted(trusted); + event->SetComposed(aParam.mComposed); + + event->SetData(aParam.mData); + + return event.forget(); +} + +JSObject* StreamFilterDataEvent::WrapObjectInternal( + JSContext* aCx, JS::Handle<JSObject*> aGivenProto) { + return dom::StreamFilterDataEvent_Binding::Wrap(aCx, this, aGivenProto); +} + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/webrequest/StreamFilterEvents.h b/toolkit/components/extensions/webrequest/StreamFilterEvents.h new file mode 100644 index 0000000000..3c7c2e91ee --- /dev/null +++ b/toolkit/components/extensions/webrequest/StreamFilterEvents.h @@ -0,0 +1,64 @@ +/* -*- Mode: C++; tab-width: 2; 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/. */ + +#ifndef mozilla_extensions_StreamFilterEvents_h +#define mozilla_extensions_StreamFilterEvents_h + +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/dom/StreamFilterDataEventBinding.h" +#include "mozilla/extensions/StreamFilter.h" + +#include "js/RootingAPI.h" +#include "js/TypeDecls.h" + +#include "mozilla/HoldDropJSObjects.h" +#include "mozilla/dom/Event.h" +#include "nsCOMPtr.h" +#include "nsCycleCollectionParticipant.h" + +namespace mozilla::extensions { + +class StreamFilterDataEvent : public dom::Event { + NS_DECL_ISUPPORTS_INHERITED + NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS_INHERITED(StreamFilterDataEvent, + Event) + + explicit StreamFilterDataEvent(dom::EventTarget* aEventTarget) + : Event(aEventTarget, nullptr, nullptr) { + mozilla::HoldJSObjects(this); + } + + static already_AddRefed<StreamFilterDataEvent> Constructor( + dom::EventTarget* aEventTarget, const nsAString& aType, + const dom::StreamFilterDataEventInit& aParam); + + static already_AddRefed<StreamFilterDataEvent> Constructor( + dom::GlobalObject& aGlobal, const nsAString& aType, + const dom::StreamFilterDataEventInit& aParam) { + nsCOMPtr<dom::EventTarget> target = + do_QueryInterface(aGlobal.GetAsSupports()); + return Constructor(target, aType, aParam); + } + + void GetData(JSContext* aCx, JS::MutableHandle<JSObject*> aResult) { + aResult.set(mData); + } + + virtual JSObject* WrapObjectInternal( + JSContext* aCx, JS::Handle<JSObject*> aGivenProto) override; + + protected: + virtual ~StreamFilterDataEvent() { mozilla::DropJSObjects(this); } + + private: + JS::Heap<JSObject*> mData; + + void SetData(const dom::ArrayBuffer& aData) { mData = aData.Obj(); } +}; + +} // namespace mozilla::extensions + +#endif // mozilla_extensions_StreamFilterEvents_h diff --git a/toolkit/components/extensions/webrequest/StreamFilterParent.cpp b/toolkit/components/extensions/webrequest/StreamFilterParent.cpp new file mode 100644 index 0000000000..467616ed0f --- /dev/null +++ b/toolkit/components/extensions/webrequest/StreamFilterParent.cpp @@ -0,0 +1,850 @@ +/* -*- 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 "StreamFilterParent.h" + +#include "HttpChannelChild.h" +#include "mozilla/ExtensionPolicyService.h" +#include "mozilla/Unused.h" +#include "mozilla/dom/ContentParent.h" +#include "mozilla/net/ChannelEventQueue.h" +#include "mozilla/StaticPrefs_extensions.h" +#include "mozilla/Try.h" +#include "nsHttpChannel.h" +#include "nsIChannel.h" +#include "nsIInputStream.h" +#include "nsITraceableChannel.h" +#include "nsProxyRelease.h" +#include "nsQueryObject.h" +#include "nsSocketTransportService2.h" +#include "nsStringStream.h" +#include "mozilla/net/DocumentChannelChild.h" +#include "nsIViewSourceChannel.h" + +namespace mozilla { +namespace extensions { + +/***************************************************************************** + * Event queueing helpers + *****************************************************************************/ + +using net::ChannelEvent; +using net::ChannelEventQueue; + +namespace { + +// Define some simple ChannelEvent sub-classes that store the appropriate +// EventTarget and delegate their Run methods to a wrapped Runnable or lambda +// function. + +class ChannelEventWrapper : public ChannelEvent { + public: + ChannelEventWrapper(nsIEventTarget* aTarget) : mTarget(aTarget) {} + + already_AddRefed<nsIEventTarget> GetEventTarget() override { + return do_AddRef(mTarget); + } + + protected: + ~ChannelEventWrapper() override = default; + + private: + nsCOMPtr<nsIEventTarget> mTarget; +}; + +class ChannelEventFunction final : public ChannelEventWrapper { + public: + ChannelEventFunction(nsIEventTarget* aTarget, std::function<void()>&& aFunc) + : ChannelEventWrapper(aTarget), mFunc(std::move(aFunc)) {} + + void Run() override { mFunc(); } + + protected: + ~ChannelEventFunction() override = default; + + private: + std::function<void()> mFunc; +}; + +class ChannelEventRunnable final : public ChannelEventWrapper { + public: + ChannelEventRunnable(nsIEventTarget* aTarget, + already_AddRefed<Runnable> aRunnable) + : ChannelEventWrapper(aTarget), mRunnable(aRunnable) {} + + void Run() override { + nsresult rv = mRunnable->Run(); + Unused << NS_WARN_IF(NS_FAILED(rv)); + } + + protected: + ~ChannelEventRunnable() override = default; + + private: + RefPtr<Runnable> mRunnable; +}; + +} // anonymous namespace + +/***************************************************************************** + * Initialization + *****************************************************************************/ + +StreamFilterParent::StreamFilterParent() + : mMainThread(GetCurrentSerialEventTarget()), + mIOThread(mMainThread), + mQueue(new ChannelEventQueue(static_cast<nsIStreamListener*>(this))), + mBufferMutex("StreamFilter buffer mutex"), + mReceivedStop(false), + mSentStop(false), + mContext(nullptr), + mOffset(0), + mState(State::Uninitialized) {} + +StreamFilterParent::~StreamFilterParent() { + NS_ReleaseOnMainThread("StreamFilterParent::mChannel", mChannel.forget()); + NS_ReleaseOnMainThread("StreamFilterParent::mLoadGroup", mLoadGroup.forget()); + NS_ReleaseOnMainThread("StreamFilterParent::mOrigListener", + mOrigListener.forget()); + NS_ReleaseOnMainThread("StreamFilterParent::mContext", mContext.forget()); + mQueue->NotifyReleasingOwner(); +} + +auto StreamFilterParent::Create(dom::ContentParent* aContentParent, + uint64_t aChannelId, const nsAString& aAddonId) + -> RefPtr<ChildEndpointPromise> { + AssertIsMainThread(); + + auto& webreq = WebRequestService::GetSingleton(); + + RefPtr<nsAtom> addonId = NS_Atomize(aAddonId); + nsCOMPtr<nsITraceableChannel> channel = + webreq.GetTraceableChannel(aChannelId, addonId, aContentParent); + + RefPtr<mozilla::net::nsHttpChannel> chan = do_QueryObject(channel); + if (!chan) { + return ChildEndpointPromise::CreateAndReject(false, __func__); + } + + nsCOMPtr<nsIChannel> genChan(do_QueryInterface(channel)); + if (!StaticPrefs::extensions_filterResponseServiceWorkerScript_disabled() && + ChannelWrapper::IsServiceWorkerScript(genChan)) { + RefPtr<extensions::WebExtensionPolicy> addonPolicy = + ExtensionPolicyService::GetSingleton().GetByID(aAddonId); + + if (!addonPolicy || + !addonPolicy->HasPermission( + nsGkAtoms::webRequestFilterResponse_serviceWorkerScript)) { + return ChildEndpointPromise::CreateAndReject(false, __func__); + } + } + + // Disable alt-data for extension stream listeners. + nsCOMPtr<nsIHttpChannelInternal> internal(do_QueryObject(channel)); + internal->DisableAltDataCache(); + + return chan->AttachStreamFilter(); +} + +/* static */ +void StreamFilterParent::Attach(nsIChannel* aChannel, + ParentEndpoint&& aEndpoint) { + auto self = MakeRefPtr<StreamFilterParent>(); + + self->ActorThread()->Dispatch( + NewRunnableMethod<ParentEndpoint&&>("StreamFilterParent::Bind", self, + &StreamFilterParent::Bind, + std::move(aEndpoint)), + NS_DISPATCH_NORMAL); + + // If aChannel is a HttpChannelChild, ask HttpChannelChild to hold a weak + // reference on this StreamFilterParent. Such that HttpChannelChild has a + // chance to disconnect this StreamFilterParent if internal redirection + // happens, i.e. ServiceWorker fallback redirection. + RefPtr<net::HttpChannelChild> channelChild = do_QueryObject(aChannel); + if (channelChild) { + channelChild->RegisterStreamFilter(self); + } + + self->Init(aChannel); +} + +void StreamFilterParent::Disconnect(const nsACString& aReason) { + AssertIsMainThread(); + MOZ_DIAGNOSTIC_ASSERT(mBeforeOnStartRequest); + + mDisconnected = true; + + nsAutoCString reason(aReason); + + RefPtr<StreamFilterParent> self(this); + RunOnActorThread(FUNC, [self, reason] { + if (self->IPCActive()) { + self->mState = State::Disconnected; + self->CheckResult(self->SendError(reason)); + } + }); +} + +void StreamFilterParent::Bind(ParentEndpoint&& aEndpoint) { + aEndpoint.Bind(this); +} + +void StreamFilterParent::Init(nsIChannel* aChannel) { + mChannel = aChannel; + + nsCOMPtr<nsITraceableChannel> traceable = do_QueryInterface(aChannel); + if (MOZ_UNLIKELY(!traceable)) { + // nsViewSourceChannel is not nsITraceableChannel, but wraps one. Unwrap it. + nsCOMPtr<nsIViewSourceChannel> vsc = do_QueryInterface(aChannel); + if (vsc) { + traceable = do_QueryObject(vsc->GetInnerChannel()); + // OnStartRequest etc. is passed the unwrapped channel, so update mChannel + // to prevent OnStartRequest from mistaking it for a redirect, which would + // close the filter. + mChannel = do_QueryObject(traceable); + } + // TODO bug 1683403: Replace assertion; Close StreamFilter instead. + MOZ_RELEASE_ASSERT(traceable); + } + + nsresult rv = + traceable->SetNewListener(this, /* aMustApplyContentConversion = */ true, + getter_AddRefs(mOrigListener)); + MOZ_RELEASE_ASSERT(NS_SUCCEEDED(rv)); +} + +/***************************************************************************** + * nsIThreadRetargetableStreamListener + *****************************************************************************/ + +NS_IMETHODIMP +StreamFilterParent::CheckListenerChain() { + AssertIsMainThread(); + + nsCOMPtr<nsIThreadRetargetableStreamListener> trsl = + do_QueryInterface(mOrigListener); + if (trsl) { + return trsl->CheckListenerChain(); + } + return NS_ERROR_FAILURE; +} + +NS_IMETHODIMP +StreamFilterParent::OnDataFinished(nsresult aStatus) { + AssertIsIOThread(); + + // Forwarding onDataFinished to the mOriginListener when: + // - the StreamFilter is already disconnected + // - it does not have any buffered data which would still need + // to be sent to the mOrigListener and we have + // - we have not yet called mOrigListener OnStopRequest method. + if (!mDisconnected || !mBufferedData.isEmpty() || mSentStop) { + return NS_OK; + } + + nsCOMPtr<nsIThreadRetargetableStreamListener> listener = + do_QueryInterface(mOrigListener); + + if (listener) { + return listener->OnDataFinished(aStatus); + } + + return NS_OK; +} + +/***************************************************************************** + * Error handling + *****************************************************************************/ + +void StreamFilterParent::Broken() { + AssertIsActorThread(); + + switch (mState) { + case State::Initialized: + case State::TransferringData: + case State::Suspended: { + mState = State::Disconnecting; + RefPtr<StreamFilterParent> self(this); + RunOnMainThread(FUNC, [=] { + if (self->mChannel) { + self->mChannel->Cancel(NS_ERROR_FAILURE); + } + }); + + FinishDisconnect(); + } break; + + default: + break; + } +} + +/***************************************************************************** + * State change requests + *****************************************************************************/ + +IPCResult StreamFilterParent::RecvClose() { + AssertIsActorThread(); + + mState = State::Closed; + + if (!mSentStop) { + RefPtr<StreamFilterParent> self(this); + RunOnMainThread(FUNC, [=] { + nsresult rv = self->EmitStopRequest(NS_OK); + Unused << NS_WARN_IF(NS_FAILED(rv)); + }); + } + + Unused << SendClosed(); + Destroy(); + return IPC_OK(); +} + +void StreamFilterParent::Destroy() { + // Close the channel asynchronously so the actor is never destroyed before + // this message is fully processed. + ActorThread()->Dispatch(NewRunnableMethod("StreamFilterParent::Close", this, + &StreamFilterParent::Close), + NS_DISPATCH_NORMAL); +} + +IPCResult StreamFilterParent::RecvDestroy() { + AssertIsActorThread(); + Destroy(); + return IPC_OK(); +} + +IPCResult StreamFilterParent::RecvSuspend() { + AssertIsActorThread(); + + if (mState == State::TransferringData) { + RefPtr<StreamFilterParent> self(this); + RunOnMainThread(FUNC, [=] { + self->mChannel->Suspend(); + + RunOnActorThread(FUNC, [=] { + if (self->IPCActive()) { + self->mState = State::Suspended; + self->CheckResult(self->SendSuspended()); + } + }); + }); + } + return IPC_OK(); +} + +IPCResult StreamFilterParent::RecvResume() { + AssertIsActorThread(); + + if (mState == State::Suspended) { + // Change state before resuming so incoming data is handled correctly + // immediately after resuming. + mState = State::TransferringData; + + RefPtr<StreamFilterParent> self(this); + RunOnMainThread(FUNC, [=] { + self->mChannel->Resume(); + + RunOnActorThread(FUNC, [=] { + if (self->IPCActive()) { + self->CheckResult(self->SendResumed()); + } + }); + }); + } + return IPC_OK(); +} +IPCResult StreamFilterParent::RecvDisconnect() { + AssertIsActorThread(); + + if (mState == State::Suspended) { + RefPtr<StreamFilterParent> self(this); + RunOnMainThread(FUNC, [=] { self->mChannel->Resume(); }); + } else if (mState != State::TransferringData) { + return IPC_OK(); + } + + mState = State::Disconnecting; + CheckResult(SendFlushData()); + return IPC_OK(); +} + +IPCResult StreamFilterParent::RecvFlushedData() { + AssertIsActorThread(); + + MOZ_ASSERT(mState == State::Disconnecting); + + Destroy(); + + FinishDisconnect(); + return IPC_OK(); +} + +void StreamFilterParent::FinishDisconnect() { + RefPtr<StreamFilterParent> self(this); + RunOnIOThread(FUNC, [=] { + self->FlushBufferedData(); + + RunOnMainThread(FUNC, [=] { + if (self->mReceivedStop && !self->mSentStop) { + nsresult rv = self->EmitStopRequest(NS_OK); + Unused << NS_WARN_IF(NS_FAILED(rv)); + } else if (self->mLoadGroup && !self->mDisconnected) { + Unused << self->mLoadGroup->RemoveRequest(self, nullptr, NS_OK); + } + self->mDisconnected = true; + }); + + RunOnActorThread(FUNC, [=] { + if (self->mState != State::Closed) { + self->mState = State::Disconnected; + } + }); + }); +} + +/***************************************************************************** + * Data output + *****************************************************************************/ + +IPCResult StreamFilterParent::RecvWrite(Data&& aData) { + AssertIsActorThread(); + + RunOnIOThread(NewRunnableMethod<Data&&>("StreamFilterParent::WriteMove", this, + &StreamFilterParent::WriteMove, + std::move(aData))); + return IPC_OK(); +} + +void StreamFilterParent::WriteMove(Data&& aData) { + nsresult rv = Write(aData); + Unused << NS_WARN_IF(NS_FAILED(rv)); +} + +nsresult StreamFilterParent::Write(Data& aData) { + AssertIsIOThread(); + + nsCOMPtr<nsIInputStream> stream; + nsresult rv = NS_NewByteInputStream( + getter_AddRefs(stream), + Span(reinterpret_cast<char*>(aData.Elements()), aData.Length()), + NS_ASSIGNMENT_DEPEND); + NS_ENSURE_SUCCESS(rv, rv); + + rv = + mOrigListener->OnDataAvailable(mChannel, stream, mOffset, aData.Length()); + NS_ENSURE_SUCCESS(rv, rv); + + mOffset += aData.Length(); + return NS_OK; +} + +/***************************************************************************** + * nsIRequest + *****************************************************************************/ + +NS_IMETHODIMP +StreamFilterParent::GetName(nsACString& aName) { + AssertIsMainThread(); + MOZ_ASSERT(mChannel); + return mChannel->GetName(aName); +} + +NS_IMETHODIMP +StreamFilterParent::GetStatus(nsresult* aStatus) { + AssertIsMainThread(); + MOZ_ASSERT(mChannel); + return mChannel->GetStatus(aStatus); +} + +NS_IMETHODIMP +StreamFilterParent::IsPending(bool* aIsPending) { + switch (mState) { + case State::Initialized: + case State::TransferringData: + case State::Suspended: + *aIsPending = true; + break; + default: + *aIsPending = false; + } + return NS_OK; +} + +NS_IMETHODIMP StreamFilterParent::SetCanceledReason(const nsACString& aReason) { + return SetCanceledReasonImpl(aReason); +} + +NS_IMETHODIMP StreamFilterParent::GetCanceledReason(nsACString& aReason) { + return GetCanceledReasonImpl(aReason); +} + +NS_IMETHODIMP StreamFilterParent::CancelWithReason(nsresult aStatus, + const nsACString& aReason) { + return CancelWithReasonImpl(aStatus, aReason); +} + +NS_IMETHODIMP +StreamFilterParent::Cancel(nsresult aResult) { + AssertIsMainThread(); + MOZ_ASSERT(mChannel); + return mChannel->Cancel(aResult); +} + +NS_IMETHODIMP +StreamFilterParent::Suspend() { + AssertIsMainThread(); + MOZ_ASSERT(mChannel); + return mChannel->Suspend(); +} + +NS_IMETHODIMP +StreamFilterParent::Resume() { + AssertIsMainThread(); + MOZ_ASSERT(mChannel); + return mChannel->Resume(); +} + +NS_IMETHODIMP +StreamFilterParent::GetLoadGroup(nsILoadGroup** aLoadGroup) { + *aLoadGroup = mLoadGroup; + return NS_OK; +} + +NS_IMETHODIMP +StreamFilterParent::SetLoadGroup(nsILoadGroup* aLoadGroup) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +StreamFilterParent::GetLoadFlags(nsLoadFlags* aLoadFlags) { + AssertIsMainThread(); + MOZ_ASSERT(mChannel); + MOZ_TRY(mChannel->GetLoadFlags(aLoadFlags)); + *aLoadFlags &= ~nsIChannel::LOAD_DOCUMENT_URI; + return NS_OK; +} + +NS_IMETHODIMP +StreamFilterParent::SetLoadFlags(nsLoadFlags aLoadFlags) { + AssertIsMainThread(); + MOZ_ASSERT(mChannel); + return mChannel->SetLoadFlags(aLoadFlags); +} + +NS_IMETHODIMP +StreamFilterParent::GetTRRMode(nsIRequest::TRRMode* aTRRMode) { + return GetTRRModeImpl(aTRRMode); +} + +NS_IMETHODIMP +StreamFilterParent::SetTRRMode(nsIRequest::TRRMode aTRRMode) { + return SetTRRModeImpl(aTRRMode); +} + +/***************************************************************************** + * nsIStreamListener + *****************************************************************************/ + +NS_IMETHODIMP +StreamFilterParent::OnStartRequest(nsIRequest* aRequest) { + AssertIsMainThread(); + + // Always reset mChannel if aRequest is different. Various calls in + // StreamFilterParent will use mChannel, but aRequest is *always* the + // right channel to use at this point. + // + // For ALL redirections, we will disconnect this listener. Extensions + // will create a new filter if they need it. + mBeforeOnStartRequest = false; + if (aRequest != mChannel) { + nsCOMPtr<nsIChannel> channel = do_QueryInterface(aRequest); + nsCOMPtr<nsILoadInfo> loadInfo = channel ? channel->LoadInfo() : nullptr; + mChannel = channel; + + if (!(loadInfo && + loadInfo->RedirectChainIncludingInternalRedirects().IsEmpty())) { + mDisconnected = true; + mDisconnectedByOnStartRequest = true; + + RefPtr<StreamFilterParent> self(this); + RunOnActorThread(FUNC, [=] { + if (self->IPCActive()) { + self->mState = State::Disconnected; + CheckResult(self->SendError("Channel redirected"_ns)); + } + }); + } + } + + // Check if alterate cached data is being sent, if so we receive un-decoded + // data and we must disconnect the filter and send an error to the extension. + if (!mDisconnected) { + RefPtr<net::HttpBaseChannel> chan = do_QueryObject(aRequest); + if (chan && chan->IsDeliveringAltData()) { + mDisconnected = true; + mDisconnectedByOnStartRequest = true; + + RefPtr<StreamFilterParent> self(this); + RunOnActorThread(FUNC, [=] { + if (self->IPCActive()) { + self->mState = State::Disconnected; + CheckResult( + self->SendError("Channel is delivering cached alt-data"_ns)); + } + }); + } + } + + if (!mDisconnected) { + Unused << mChannel->GetLoadGroup(getter_AddRefs(mLoadGroup)); + if (mLoadGroup) { + Unused << mLoadGroup->AddRequest(this, nullptr); + } + } + + nsresult rv = mOrigListener->OnStartRequest(aRequest); + + // Important: Do this only *after* running the next listener in the chain, so + // that we get the final delivery target after any retargeting that it may do. + if (nsCOMPtr<nsIThreadRetargetableRequest> req = + do_QueryInterface(aRequest)) { + nsCOMPtr<nsISerialEventTarget> thread; + Unused << req->GetDeliveryTarget(getter_AddRefs(thread)); + if (thread) { + mIOThread = std::move(thread); + } + } + + // Important: Do this *after* we have set the thread delivery target, or it is + // possible in rare circumstances for an extension to attempt to write data + // before the thread has been set up, even though there are several layers of + // asynchrony involved. + if (!mDisconnected) { + RefPtr<StreamFilterParent> self(this); + RunOnActorThread(FUNC, [=] { + if (self->IPCActive()) { + self->mState = State::TransferringData; + self->CheckResult(self->SendStartRequest()); + } + }); + } + + return rv; +} + +NS_IMETHODIMP +StreamFilterParent::OnStopRequest(nsIRequest* aRequest, nsresult aStatusCode) { + AssertIsMainThread(); + MOZ_ASSERT(aRequest == mChannel); + + mReceivedStop = true; + if (mDisconnected) { + return EmitStopRequest(aStatusCode); + } + + RefPtr<StreamFilterParent> self(this); + RunOnActorThread(FUNC, [=] { + if (self->IPCActive()) { + self->CheckResult(self->SendStopRequest(aStatusCode)); + } else if (self->mState != State::Disconnecting) { + // If we're currently disconnecting, then we'll emit a stop + // request at the end of that process. Otherwise we need to + // manually emit one here, since we won't be getting a response + // from the child. + RunOnMainThread(FUNC, [=] { + if (!self->mSentStop) { + self->EmitStopRequest(aStatusCode); + } + }); + } + }); + return NS_OK; +} + +nsresult StreamFilterParent::EmitStopRequest(nsresult aStatusCode) { + AssertIsMainThread(); + MOZ_ASSERT(!mSentStop); + + mSentStop = true; + nsresult rv = mOrigListener->OnStopRequest(mChannel, aStatusCode); + + if (mLoadGroup && !mDisconnected) { + Unused << mLoadGroup->RemoveRequest(this, nullptr, aStatusCode); + } + + return rv; +} + +/***************************************************************************** + * Incoming data handling + *****************************************************************************/ + +void StreamFilterParent::DoSendData(Data&& aData) { + AssertIsActorThread(); + + if (mState == State::TransferringData) { + CheckResult(SendData(aData)); + } +} + +NS_IMETHODIMP +StreamFilterParent::OnDataAvailable(nsIRequest* aRequest, + nsIInputStream* aInputStream, + uint64_t aOffset, uint32_t aCount) { + AssertIsIOThread(); + + if (mDisconnectedByOnStartRequest || mState == State::Disconnected) { + // If we're offloading data in a thread pool, it's possible that we'll + // have buffered some additional data while waiting for the buffer to + // flush. So, if there's any buffered data left, flush that before we + // flush this incoming data. + // + // Note: When in the eDisconnected state, the buffer list is guaranteed + // never to be accessed by another thread during an OnDataAvailable call. + if (!mBufferedData.isEmpty()) { + FlushBufferedData(); + } + + mOffset += aCount; + return mOrigListener->OnDataAvailable(aRequest, aInputStream, + mOffset - aCount, aCount); + } + + Data data; + data.SetLength(aCount); + + uint32_t count; + nsresult rv = aInputStream->Read(reinterpret_cast<char*>(data.Elements()), + aCount, &count); + NS_ENSURE_SUCCESS(rv, rv); + NS_ENSURE_TRUE(count == aCount, NS_ERROR_UNEXPECTED); + + if (mState == State::Disconnecting) { + MutexAutoLock al(mBufferMutex); + BufferData(std::move(data)); + } else if (mState == State::Closed) { + return NS_ERROR_FAILURE; + } else { + ActorThread()->Dispatch( + NewRunnableMethod<Data&&>("StreamFilterParent::DoSendData", this, + &StreamFilterParent::DoSendData, + std::move(data)), + NS_DISPATCH_NORMAL); + } + return NS_OK; +} + +nsresult StreamFilterParent::FlushBufferedData() { + AssertIsIOThread(); + + // When offloading data to a thread pool, OnDataAvailable isn't guaranteed + // to always run in the same thread, so it's possible for this function to + // run in parallel with OnDataAvailable. + MutexAutoLock al(mBufferMutex); + + while (!mBufferedData.isEmpty()) { + UniquePtr<BufferedData> data(mBufferedData.popFirst()); + + nsresult rv = Write(data->mData); + NS_ENSURE_SUCCESS(rv, rv); + } + + return NS_OK; +} + +/***************************************************************************** + * Thread helpers + *****************************************************************************/ + +nsIEventTarget* StreamFilterParent::ActorThread() { + return net::gSocketTransportService; +} + +bool StreamFilterParent::IsActorThread() { + return ActorThread()->IsOnCurrentThread(); +} + +void StreamFilterParent::AssertIsActorThread() { MOZ_ASSERT(IsActorThread()); } + +nsISerialEventTarget* StreamFilterParent::IOThread() { return mIOThread; } + +bool StreamFilterParent::IsIOThread() { return mIOThread->IsOnCurrentThread(); } + +void StreamFilterParent::AssertIsIOThread() { MOZ_ASSERT(IsIOThread()); } + +template <typename Function> +void StreamFilterParent::RunOnMainThread(const char* aName, Function&& aFunc) { + mQueue->RunOrEnqueue(new ChannelEventFunction(mMainThread, std::move(aFunc))); +} + +void StreamFilterParent::RunOnMainThread(already_AddRefed<Runnable> aRunnable) { + mQueue->RunOrEnqueue( + new ChannelEventRunnable(mMainThread, std::move(aRunnable))); +} + +template <typename Function> +void StreamFilterParent::RunOnIOThread(const char* aName, Function&& aFunc) { + mQueue->RunOrEnqueue(new ChannelEventFunction(mIOThread, std::move(aFunc))); +} + +void StreamFilterParent::RunOnIOThread(already_AddRefed<Runnable> aRunnable) { + mQueue->RunOrEnqueue( + new ChannelEventRunnable(mIOThread, std::move(aRunnable))); +} + +template <typename Function> +void StreamFilterParent::RunOnActorThread(const char* aName, Function&& aFunc) { + // We don't use mQueue for dispatch to the actor thread. + // + // The main thread and IO thread are used for dispatching events to the + // wrapped stream listener, and those events need to be processed + // consistently, in the order they were dispatched. An event dispatched to the + // main thread can't be run before events that were dispatched to the IO + // thread before it. + // + // Additionally, the IO thread is likely to be a thread pool, which means that + // without thread-safe queuing, it's possible for multiple events dispatched + // to it to be processed in parallel, or out of order. + // + // The actor thread, however, is always a serial event target. Its events are + // always processed in order, and events dispatched to the actor thread are + // independent of the events in the output event queue. + if (IsActorThread()) { + aFunc(); + } else { + ActorThread()->Dispatch(std::move(NS_NewRunnableFunction(aName, aFunc)), + NS_DISPATCH_NORMAL); + } +} + +/***************************************************************************** + * Glue + *****************************************************************************/ + +void StreamFilterParent::ActorDestroy(ActorDestroyReason aWhy) { + AssertIsActorThread(); + + if (mState != State::Disconnected && mState != State::Closed) { + Broken(); + } +} + +NS_INTERFACE_MAP_BEGIN(StreamFilterParent) + NS_INTERFACE_MAP_ENTRY(nsIStreamListener) + NS_INTERFACE_MAP_ENTRY(nsIRequestObserver) + NS_INTERFACE_MAP_ENTRY(nsIRequest) + NS_INTERFACE_MAP_ENTRY(nsIThreadRetargetableStreamListener) + NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIStreamListener) +NS_INTERFACE_MAP_END + +NS_IMPL_ADDREF(StreamFilterParent) +NS_IMPL_RELEASE(StreamFilterParent) + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/webrequest/StreamFilterParent.h b/toolkit/components/extensions/webrequest/StreamFilterParent.h new file mode 100644 index 0000000000..6707bd6d81 --- /dev/null +++ b/toolkit/components/extensions/webrequest/StreamFilterParent.h @@ -0,0 +1,198 @@ +/* -*- 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/. */ + +#ifndef mozilla_extensions_StreamFilterParent_h +#define mozilla_extensions_StreamFilterParent_h + +#include "StreamFilterBase.h" +#include "mozilla/extensions/PStreamFilterParent.h" + +#include "mozilla/LinkedList.h" +#include "mozilla/Mutex.h" +#include "mozilla/WebRequestService.h" +#include "nsIStreamListener.h" +#include "nsIThread.h" +#include "nsIThreadRetargetableStreamListener.h" +#include "nsThreadUtils.h" + +#if defined(_MSC_VER) +# define FUNC __FUNCSIG__ +#else +# define FUNC __PRETTY_FUNCTION__ +#endif + +namespace mozilla { +namespace dom { +class ContentParent; +} +namespace net { +class ChannelEventQueue; +class nsHttpChannel; +} // namespace net + +namespace extensions { + +using namespace mozilla::dom; +using mozilla::ipc::IPCResult; + +class StreamFilterParent final : public PStreamFilterParent, + public nsIThreadRetargetableStreamListener, + public nsIRequest, + public StreamFilterBase { + friend class PStreamFilterParent; + + public: + NS_DECL_THREADSAFE_ISUPPORTS + NS_DECL_NSISTREAMLISTENER + NS_DECL_NSIREQUEST + NS_DECL_NSIREQUESTOBSERVER + NS_DECL_NSITHREADRETARGETABLESTREAMLISTENER + + StreamFilterParent(); + + using ParentEndpoint = mozilla::ipc::Endpoint<PStreamFilterParent>; + using ChildEndpoint = mozilla::ipc::Endpoint<PStreamFilterChild>; + + using ChildEndpointPromise = MozPromise<ChildEndpoint, bool, true>; + + [[nodiscard]] static RefPtr<ChildEndpointPromise> Create( + ContentParent* aContentParent, uint64_t aChannelId, + const nsAString& aAddonId); + + static void Attach(nsIChannel* aChannel, ParentEndpoint&& aEndpoint); + + enum class State { + // The parent has been created, but not yet constructed by the child. + Uninitialized, + // The parent has been successfully constructed. + Initialized, + // The OnRequestStarted event has been received, and data is being + // transferred to the child. + TransferringData, + // The channel is suspended. + Suspended, + // The channel has been closed by the child, and will send or receive data. + Closed, + // The channel is being disconnected from the child, so that all further + // data and events pass unfiltered to the output listener. Any data + // currnetly in transit to, or buffered by, the child will be written to the + // output listener before we enter the Disconnected atate. + Disconnecting, + // The channel has been disconnected from the child, and all further data + // and events will be passed directly to the output listener. + Disconnected, + }; + + // This method makes StreamFilterParent to disconnect from channel. + // Notice that this method can only be called before OnStartRequest(). + void Disconnect(const nsACString& aReason); + + protected: + virtual ~StreamFilterParent(); + + IPCResult RecvWrite(Data&& aData); + IPCResult RecvFlushedData(); + IPCResult RecvSuspend(); + IPCResult RecvResume(); + IPCResult RecvClose(); + IPCResult RecvDisconnect(); + IPCResult RecvDestroy(); + + private: + bool IPCActive() { + return (mState != State::Closed && mState != State::Disconnecting && + mState != State::Disconnected); + } + + void Init(nsIChannel* aChannel); + + void Bind(ParentEndpoint&& aEndpoint); + + void Destroy(); + + nsresult FlushBufferedData(); + + nsresult Write(Data& aData); + + void WriteMove(Data&& aData); + + void DoSendData(Data&& aData); + + nsresult EmitStopRequest(nsresult aStatusCode); + + virtual void ActorDestroy(ActorDestroyReason aWhy) override; + + void Broken(); + void FinishDisconnect(); + + void CheckResult(bool aResult) { + if (NS_WARN_IF(!aResult)) { + Broken(); + } + } + + inline nsIEventTarget* ActorThread(); + + inline nsISerialEventTarget* IOThread(); + + inline bool IsIOThread(); + + inline bool IsActorThread(); + + inline void AssertIsActorThread(); + + inline void AssertIsIOThread(); + + static void AssertIsMainThread() { MOZ_ASSERT(NS_IsMainThread()); } + + template <typename Function> + void RunOnMainThread(const char* aName, Function&& aFunc); + + void RunOnMainThread(already_AddRefed<Runnable> aRunnable); + + template <typename Function> + void RunOnActorThread(const char* aName, Function&& aFunc); + + template <typename Function> + void RunOnIOThread(const char* aName, Function&& aFunc); + + void RunOnIOThread(already_AddRefed<Runnable>); + + nsCOMPtr<nsIChannel> mChannel; + nsCOMPtr<nsILoadGroup> mLoadGroup; + nsCOMPtr<nsIStreamListener> mOrigListener; + + nsCOMPtr<nsISerialEventTarget> mMainThread; + nsCOMPtr<nsISerialEventTarget> mIOThread; + + RefPtr<net::ChannelEventQueue> mQueue; + + Mutex mBufferMutex MOZ_UNANNOTATED; + + bool mReceivedStop; + bool mSentStop; + bool mDisconnected = false; + + // If redirection happens or alterate cached data is being sent, the stream + // filter is disconnected in OnStartRequest and the following ODA would not + // be filtered. Using mDisconnected causes race condition. mState is possible + // to late to be set, which leads out of sync. + bool mDisconnectedByOnStartRequest = false; + + bool mBeforeOnStartRequest = true; + + nsCOMPtr<nsISupports> mContext; + uint64_t mOffset; + + // Use Release-Acquire ordering to ensure the OMT ODA is not sent while + // the channel is disconnecting or closed. + Atomic<State, ReleaseAcquire> mState; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_StreamFilterParent_h diff --git a/toolkit/components/extensions/webrequest/WebNavigationContent.cpp b/toolkit/components/extensions/webrequest/WebNavigationContent.cpp new file mode 100644 index 0000000000..9b7a53a3bc --- /dev/null +++ b/toolkit/components/extensions/webrequest/WebNavigationContent.cpp @@ -0,0 +1,325 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "mozilla/extensions/WebNavigationContent.h" + +#include "mozilla/Assertions.h" +#include "mozilla/ClearOnShutdown.h" +#include "mozilla/dom/BrowserChild.h" +#include "mozilla/dom/BrowsingContext.h" +#include "mozilla/dom/ContentFrameMessageManager.h" +#include "mozilla/dom/Document.h" +#include "mozilla/dom/Event.h" +#include "mozilla/dom/EventTarget.h" +#include "mozilla/extensions/ExtensionsChild.h" +#include "mozilla/EventListenerManager.h" +#include "mozilla/RefPtr.h" +#include "mozilla/ResultExtensions.h" +#include "mozilla/Services.h" +#include "mozilla/Try.h" +#include "nsCRT.h" +#include "nsDocShellLoadTypes.h" +#include "nsPIWindowRoot.h" +#include "nsIChannel.h" +#include "nsIDocShell.h" +#include "nsIInterfaceRequestorUtils.h" +#include "nsIObserverService.h" +#include "nsIPropertyBag2.h" +#include "nsIWebNavigation.h" +#include "nsIWebProgress.h" +#include "nsQueryObject.h" + +namespace mozilla { +namespace extensions { + +/* static */ +already_AddRefed<WebNavigationContent> WebNavigationContent::GetSingleton() { + static RefPtr<WebNavigationContent> sSingleton; + if (!sSingleton) { + sSingleton = new WebNavigationContent(); + sSingleton->Init(); + ClearOnShutdown(&sSingleton); + } + return do_AddRef(sSingleton); +} + +NS_IMPL_ISUPPORTS(WebNavigationContent, nsIObserver, nsIDOMEventListener, + nsIWebProgressListener, nsISupportsWeakReference) + +void WebNavigationContent::Init() { + nsCOMPtr<nsIObserverService> obs = services::GetObserverService(); + + obs->AddObserver(this, "chrome-event-target-created", true); + obs->AddObserver(this, "webNavigation-createdNavigationTarget-from-js", true); +} + +NS_IMETHODIMP WebNavigationContent::Observe(nsISupports* aSubject, + char const* aTopic, + char16_t const* aData) { + if (!nsCRT::strcmp(aTopic, "chrome-event-target-created")) { + // This notification is sent whenever a new window root is created, with the + // subject being an EventTarget corresponding to either an nsWindowRoot, or + // additionally also an InProcessBrowserChildMessageManager in the parent. + // This is the same entry point used to register listeners for the JS window + // actor API. + if (RefPtr<dom::EventTarget> eventTarget = do_QueryObject(aSubject)) { + AttachListeners(eventTarget); + } + + nsCOMPtr<nsIDocShell> docShell; + if (nsCOMPtr<nsPIWindowRoot> root = do_QueryInterface(aSubject)) { + docShell = root->GetWindow()->GetDocShell(); + } else if (RefPtr<dom::ContentFrameMessageManager> mm = + do_QueryObject(aSubject)) { + docShell = mm->GetDocShell(IgnoreErrors()); + } + if (docShell && docShell->GetBrowsingContext()->IsContent()) { + nsCOMPtr<nsIWebProgress> webProgress(do_GetInterface(docShell)); + + webProgress->AddProgressListener(this, + nsIWebProgress::NOTIFY_STATE_WINDOW | + nsIWebProgress::NOTIFY_LOCATION); + } + } else if (!nsCRT::strcmp(aTopic, + "webNavigation-createdNavigationTarget-from-js")) { + if (nsCOMPtr<nsIPropertyBag2> props = do_QueryInterface(aSubject)) { + return OnCreatedNavigationTargetFromJS(props); + } + } + return NS_OK; +} + +void WebNavigationContent::AttachListeners(dom::EventTarget* aEventTarget) { + EventListenerManager* elm = aEventTarget->GetOrCreateListenerManager(); + NS_ENSURE_TRUE_VOID(elm); + + elm->AddEventListenerByType(this, u"DOMContentLoaded"_ns, + TrustedEventsAtCapture()); +} + +NS_IMETHODIMP +WebNavigationContent::HandleEvent(dom::Event* aEvent) { + if (aEvent->ShouldIgnoreChromeEventTargetListener()) { + return NS_OK; + } + +#ifdef DEBUG + { + nsAutoString type; + aEvent->GetType(type); + MOZ_ASSERT(type.EqualsLiteral("DOMContentLoaded")); + } +#endif + + if (RefPtr<dom::Document> doc = do_QueryObject(aEvent->GetTarget())) { + dom::BrowsingContext* bc = doc->GetBrowsingContext(); + if (bc && bc->IsContent()) { + ExtensionsChild::Get().SendDOMContentLoaded(bc, doc->GetDocumentURI()); + } + } + + return NS_OK; +} + +static dom::BrowsingContext* GetBrowsingContext(nsIWebProgress* aWebProgress) { + // FIXME: Get this via nsIWebNavigation instead. + nsCOMPtr<nsIDocShell> docShell(do_GetInterface(aWebProgress)); + return docShell->GetBrowsingContext(); +} + +FrameTransitionData WebNavigationContent::GetFrameTransitionData( + nsIWebProgress* aWebProgress, nsIRequest* aRequest) { + FrameTransitionData result; + + uint32_t loadType = 0; + Unused << aWebProgress->GetLoadType(&loadType); + + if (loadType & nsIDocShell::LOAD_CMD_HISTORY) { + result.forwardBack() = true; + } + + if (loadType & nsIDocShell::LOAD_CMD_RELOAD) { + result.reload() = true; + } + + if (LOAD_TYPE_HAS_FLAGS(loadType, nsIWebNavigation::LOAD_FLAGS_IS_REFRESH)) { + result.clientRedirect() = true; + } + + if (nsCOMPtr<nsIChannel> channel = do_QueryInterface(aRequest)) { + nsCOMPtr<nsILoadInfo> loadInfo(channel->LoadInfo()); + if (loadInfo->RedirectChain().Length()) { + result.serverRedirect() = true; + } + if (loadInfo->GetIsFormSubmission() && + !(loadType & (nsIDocShell::LOAD_CMD_HISTORY | + + nsIDocShell::LOAD_CMD_RELOAD))) { + result.formSubmit() = true; + } + } + + return result; +} + +nsresult WebNavigationContent::OnCreatedNavigationTargetFromJS( + nsIPropertyBag2* aProps) { + nsCOMPtr<nsIDocShell> createdDocShell( + do_GetProperty(aProps, u"createdTabDocShell"_ns)); + nsCOMPtr<nsIDocShell> sourceDocShell( + do_GetProperty(aProps, u"sourceTabDocShell"_ns)); + + NS_ENSURE_ARG_POINTER(createdDocShell); + NS_ENSURE_ARG_POINTER(sourceDocShell); + + dom::BrowsingContext* createdBC = createdDocShell->GetBrowsingContext(); + dom::BrowsingContext* sourceBC = sourceDocShell->GetBrowsingContext(); + if (createdBC->IsContent() && sourceBC->IsContent()) { + nsCString url; + Unused << aProps->GetPropertyAsACString(u"url"_ns, url); + + ExtensionsChild::Get().SendCreatedNavigationTarget(createdBC, sourceBC, + url); + } + return NS_OK; +} + +// nsIWebProgressListener +NS_IMETHODIMP +WebNavigationContent::OnStateChange(nsIWebProgress* aWebProgress, + nsIRequest* aRequest, uint32_t aStateFlags, + nsresult aStatus) { + nsCOMPtr<nsIChannel> channel(do_QueryInterface(aRequest)); + NS_ENSURE_TRUE(channel, NS_ERROR_UNEXPECTED); + + nsCOMPtr<nsIURI> uri; + MOZ_TRY(channel->GetURI(getter_AddRefs(uri))); + + // Prevents "about", "chrome", "resource" and "moz-extension" URI schemes to + // be reported with the resolved "file" or "jar" URIs (see bug 1246125) + if (uri->SchemeIs("file") || uri->SchemeIs("jar")) { + nsCOMPtr<nsIURI> originalURI; + MOZ_TRY(channel->GetOriginalURI(getter_AddRefs(originalURI))); + // FIXME: We probably actually want NS_GetFinalChannelURI here. + if (originalURI->SchemeIs("about") || originalURI->SchemeIs("chrome") || + originalURI->SchemeIs("resource") || + originalURI->SchemeIs("moz-extension")) { + uri = originalURI.forget(); + } + } + + RefPtr<dom::BrowsingContext> bc(GetBrowsingContext(aWebProgress)); + NS_ENSURE_ARG_POINTER(bc); + + ExtensionsChild::Get().SendStateChange(bc, uri, aStatus, aStateFlags); + + // Based on the docs of the webNavigation.onCommitted event, it should be + // raised when: "The document might still be downloading, but at least part + // of the document has been received" and for some reason we don't fire + // onLocationChange for the initial navigation of a sub-frame. For the above + // two reasons, when the navigation event is related to a sub-frame we process + // the document change here and then send an OnDocumentChange message to the + // main process, where it will be turned into a webNavigation.onCommitted + // event. (bug 1264936 and bug 125662) + if (!bc->IsTop() && aStateFlags & nsIWebProgressListener::STATE_IS_DOCUMENT) { + ExtensionsChild::Get().SendDocumentChange( + bc, GetFrameTransitionData(aWebProgress, aRequest), uri); + } + return NS_OK; +} + +NS_IMETHODIMP +WebNavigationContent::OnProgressChange(nsIWebProgress* aWebProgress, + nsIRequest* aRequest, + int32_t aCurSelfProgress, + int32_t aMaxSelfProgress, + int32_t aCurTotalProgress, + int32_t aMaxTotalProgress) { + MOZ_ASSERT_UNREACHABLE("Listener did not request ProgressChange events"); + return NS_OK; +} + +NS_IMETHODIMP +WebNavigationContent::OnLocationChange(nsIWebProgress* aWebProgress, + nsIRequest* aRequest, nsIURI* aLocation, + uint32_t aFlags) { + RefPtr<dom::BrowsingContext> bc(GetBrowsingContext(aWebProgress)); + NS_ENSURE_ARG_POINTER(bc); + + // When a frame navigation doesn't change the current loaded document + // (which can be due to history.pushState/replaceState or to a changed hash in + // the url), it is reported only to the onLocationChange, for this reason we + // process the history change here and then we are going to send an + // OnHistoryChange message to the main process, where it will be turned into + // a webNavigation.onHistoryStateUpdated/onReferenceFragmentUpdated event. + if (aFlags & nsIWebProgressListener::LOCATION_CHANGE_SAME_DOCUMENT) { + uint32_t loadType = 0; + MOZ_TRY(aWebProgress->GetLoadType(&loadType)); + + // When the location changes but the document is the same: + // - path not changed and hash changed -> |onReferenceFragmentUpdated| + // (even if it changed using |history.pushState|) + // - path not changed and hash not changed -> |onHistoryStateUpdated| + // (only if it changes using |history.pushState|) + // - path changed -> |onHistoryStateUpdated| + bool isHistoryStateUpdated = false; + bool isReferenceFragmentUpdated = false; + if (aFlags & nsIWebProgressListener::LOCATION_CHANGE_HASHCHANGE) { + isReferenceFragmentUpdated = true; + } else if (loadType & nsIDocShell::LOAD_CMD_PUSHSTATE) { + isHistoryStateUpdated = true; + } else if (loadType & nsIDocShell::LOAD_CMD_HISTORY) { + isHistoryStateUpdated = true; + } + + if (isHistoryStateUpdated || isReferenceFragmentUpdated) { + ExtensionsChild::Get().SendHistoryChange( + bc, GetFrameTransitionData(aWebProgress, aRequest), aLocation, + isHistoryStateUpdated, isReferenceFragmentUpdated); + } + } else if (bc->IsTop()) { + MOZ_ASSERT(bc->IsInProcess()); + if (RefPtr browserChild = dom::BrowserChild::GetFrom(bc->GetDocShell())) { + // Only send progress events which happen after we've started loading + // things into the BrowserChild. This matches the behavior of the remote + // WebProgress implementation. + if (browserChild->ShouldSendWebProgressEventsToParent()) { + // We have to catch the document changes from top level frames here, + // where we can detect the "server redirect" transition. + // (bug 1264936 and bug 125662) + ExtensionsChild::Get().SendDocumentChange( + bc, GetFrameTransitionData(aWebProgress, aRequest), aLocation); + } + } + } + + return NS_OK; +} + +NS_IMETHODIMP +WebNavigationContent::OnStatusChange(nsIWebProgress* aWebProgress, + nsIRequest* aRequest, nsresult aStatus, + const char16_t* aMessage) { + MOZ_ASSERT_UNREACHABLE("Listener did not request StatusChange events"); + return NS_OK; +} + +NS_IMETHODIMP +WebNavigationContent::OnSecurityChange(nsIWebProgress* aWebProgress, + nsIRequest* aRequest, uint32_t aState) { + MOZ_ASSERT_UNREACHABLE("Listener did not request SecurityChange events"); + return NS_OK; +} + +NS_IMETHODIMP +WebNavigationContent::OnContentBlockingEvent(nsIWebProgress* aWebProgress, + nsIRequest* aRequest, + uint32_t aEvent) { + MOZ_ASSERT_UNREACHABLE("Listener did not request ContentBlocking events"); + return NS_OK; +} + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/webrequest/WebNavigationContent.h b/toolkit/components/extensions/webrequest/WebNavigationContent.h new file mode 100644 index 0000000000..8916b4436e --- /dev/null +++ b/toolkit/components/extensions/webrequest/WebNavigationContent.h @@ -0,0 +1,57 @@ +/* -*- Mode: C++; tab-width: 2; 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/. */ + +#ifndef mozilla_extensions_WebNavigationContent_h +#define mozilla_extensions_WebNavigationContent_h + +#include "nsIDOMEventListener.h" +#include "nsIObserver.h" +#include "nsIWebProgressListener.h" +#include "nsWeakReference.h" + +class nsIPropertyBag2; +class nsIRequest; +class nsIWebProgress; + +namespace mozilla { +namespace dom { +class EventTarget; +} // namespace dom + +namespace extensions { + +class FrameTransitionData; + +class WebNavigationContent final : public nsIObserver, + public nsIDOMEventListener, + public nsIWebProgressListener, + public nsSupportsWeakReference { + public: + NS_DECL_ISUPPORTS + NS_DECL_NSIOBSERVER + NS_DECL_NSIDOMEVENTLISTENER + NS_DECL_NSIWEBPROGRESSLISTENER + + static already_AddRefed<WebNavigationContent> GetSingleton(); + + private: + WebNavigationContent() = default; + ~WebNavigationContent() = default; + + void AttachListeners(mozilla::dom::EventTarget* aEventTarget); + + void Init(); + + FrameTransitionData GetFrameTransitionData(nsIWebProgress* aWebProgress, + nsIRequest* aRequest); + + nsresult OnCreatedNavigationTargetFromJS(nsIPropertyBag2* aProps); +}; + +} // namespace extensions +} // namespace mozilla + +#endif // defined mozilla_extensions_WebNavigationContent_h diff --git a/toolkit/components/extensions/webrequest/WebRequest.sys.mjs b/toolkit/components/extensions/webrequest/WebRequest.sys.mjs new file mode 100644 index 0000000000..1d9bbb2260 --- /dev/null +++ b/toolkit/components/extensions/webrequest/WebRequest.sys.mjs @@ -0,0 +1,1337 @@ +/* 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/. */ +// @ts-nocheck Defer for now. + +const { nsIHttpActivityObserver, nsISocketTransport } = Ci; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionDNR: "resource://gre/modules/ExtensionDNR.sys.mjs", + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", + ExtensionUtils: "resource://gre/modules/ExtensionUtils.sys.mjs", + SecurityInfo: "resource://gre/modules/SecurityInfo.sys.mjs", + WebRequestUpload: "resource://gre/modules/WebRequestUpload.sys.mjs", +}); + +// WebRequest.jsm's only consumer is ext-webRequest.js, so we can depend on +// the apiManager.global being initialized. +ChromeUtils.defineLazyGetter(lazy, "tabTracker", () => { + return lazy.ExtensionParent.apiManager.global.tabTracker; +}); +ChromeUtils.defineLazyGetter( + lazy, + "getCookieStoreIdForOriginAttributes", + () => { + return lazy.ExtensionParent.apiManager.global + .getCookieStoreIdForOriginAttributes; + } +); + +// URI schemes that service workers are allowed to load scripts from (any other +// scheme is not allowed by the specs and it is not expected by the service workers +// internals neither, which would likely trigger unexpected behaviors). +const ALLOWED_SERVICEWORKER_SCHEMES = ["https", "http", "moz-extension"]; + +// Response HTTP Headers matching the following patterns are restricted for changes +// applied by MV3 extensions. +const MV3_RESTRICTED_HEADERS_PATTERNS = [ + /^cross-origin-embedder-policy$/, + /^cross-origin-opener-policy$/, + /^cross-origin-resource-policy$/, + /^x-frame-options$/, + /^access-control-/, +]; + +// Classes of requests that should be sent immediately instead of batched. +// Covers basically anything that can delay first paint or DOMContentLoaded: +// top frame HTML, <head> blocking CSS, fonts preflight, sync JS and XHR. +const URGENT_CLASSES = + Ci.nsIClassOfService.Leader | + Ci.nsIClassOfService.Unblocked | + Ci.nsIClassOfService.UrgentStart | + Ci.nsIClassOfService.TailForbidden; + +function runLater(job) { + Services.tm.dispatchToMainThread(job); +} + +function parseFilter(filter) { + if (!filter) { + filter = {}; + } + + return { + urls: filter.urls || null, + types: filter.types || null, + tabId: filter.tabId ?? null, + windowId: filter.windowId ?? null, + incognito: filter.incognito ?? null, + }; +} + +function parseExtra(extra, allowed = [], optionsObj = {}) { + if (extra) { + for (let ex of extra) { + if (!allowed.includes(ex)) { + throw new lazy.ExtensionUtils.ExtensionError(`Invalid option ${ex}`); + } + } + } + + let result = Object.assign({}, optionsObj); + for (let al of allowed) { + if (extra && extra.includes(al)) { + result[al] = true; + } + } + return result; +} + +function isThenable(value) { + return value && typeof value === "object" && typeof value.then === "function"; +} + +// Verify a requested redirect and throw a more explicit error. +function verifyRedirect(channel, redirectUri, finalUrl, addonId) { + const { isServiceWorkerScript } = channel; + + if ( + isServiceWorkerScript && + channel.loadInfo?.internalContentPolicyType === + Ci.nsIContentPolicy.TYPE_INTERNAL_SERVICE_WORKER + ) { + throw new Error( + `Invalid redirectUrl ${redirectUri?.spec} on service worker main script ${finalUrl} requested by ${addonId}` + ); + } + + if ( + isServiceWorkerScript && + (channel.loadInfo?.internalContentPolicyType === + Ci.nsIContentPolicy.TYPE_INTERNAL_WORKER_IMPORT_SCRIPTS || + channel.loadInfo?.internalContentPolicyType === + Ci.nsIContentPolicy.TYPE_INTERNAL_WORKER_STATIC_MODULE) && + !ALLOWED_SERVICEWORKER_SCHEMES.includes(redirectUri?.scheme) + ) { + throw new Error( + `Invalid redirectUrl ${redirectUri?.spec} on service worker imported script ${finalUrl} requested by ${addonId}` + ); + } +} + +class HeaderChanger { + constructor(channel) { + this.channel = channel; + + this.array = this.readHeaders(); + } + + getMap() { + if (!this.map) { + this.map = new Map(); + for (let header of this.array) { + this.map.set(header.name.toLowerCase(), header); + } + } + return this.map; + } + + toArray() { + return this.array; + } + + validateHeaders(headers) { + // We should probably use schema validation for this. + + if (!Array.isArray(headers)) { + return false; + } + + return headers.every(header => { + if (typeof header !== "object" || header === null) { + return false; + } + + if (typeof header.name !== "string") { + return false; + } + + return ( + typeof header.value === "string" || Array.isArray(header.binaryValue) + ); + }); + } + + applyChanges(headers, opts = {}) { + if (!this.validateHeaders(headers)) { + Cu.reportError(`Invalid header array: ${uneval(headers)}`); + return; + } + + let newHeaders = new Set(headers.map(({ name }) => name.toLowerCase())); + + // Remove missing headers. + let origHeaders = this.getMap(); + for (let name of origHeaders.keys()) { + if (!newHeaders.has(name)) { + this.setHeader(name, "", false, opts, name); + } + } + + // Set new or changed headers. If there are multiple headers with the same + // name (e.g. Set-Cookie), merge them, instead of having new values + // overwrite previous ones. + // + // When the new value of a header is equal the existing value of the header + // (e.g. the initial response set "Set-Cookie: examplename=examplevalue", + // and an extension also added the header + // "Set-Cookie: examplename=examplevalue") then the header value is not + // re-set, but subsequent headers of the same type will be merged in. + // + // Multiple addons will be able to provide modifications to any headers + // listed in the default set. + let headersAlreadySet = new Set(); + for (let { name, value, binaryValue } of headers) { + if (binaryValue) { + value = String.fromCharCode(...binaryValue); + } + + let lowerCaseName = name.toLowerCase(); + let original = origHeaders.get(lowerCaseName); + + if (!original || value !== original.value) { + let shouldMerge = headersAlreadySet.has(lowerCaseName); + this.setHeader(name, value, shouldMerge, opts, lowerCaseName); + } + + headersAlreadySet.add(lowerCaseName); + } + } +} + +const checkRestrictedHeaderValue = (value, opts = {}) => { + let uri = Services.io.newURI(`https://${value}/`); + let { policy } = opts; + + if (policy && !policy.allowedOrigins.matches(uri)) { + throw new Error(`Unable to set host header, url missing from permissions.`); + } + + if (WebExtensionPolicy.isRestrictedURI(uri)) { + throw new Error(`Unable to set host header to restricted url.`); + } +}; + +class RequestHeaderChanger extends HeaderChanger { + setHeader(name, value, merge, opts, lowerCaseName) { + try { + if (value && lowerCaseName === "host") { + checkRestrictedHeaderValue(value, opts); + } + this.channel.setRequestHeader(name, value, merge); + } catch (e) { + Cu.reportError(new Error(`Error setting request header ${name}: ${e}`)); + } + } + + readHeaders() { + return this.channel.getRequestHeaders(); + } +} + +class ResponseHeaderChanger extends HeaderChanger { + didModifyCSP = false; + + setHeader(name, value, merge, opts, lowerCaseName) { + if (lowerCaseName === "content-security-policy") { + // When multiple add-ons change the CSP, enforce the combined (strictest) + // policy - see bug 1462989 for motivation. + // When value is unset, don't force the header to be merged, to allow + // add-ons to clear the header if wanted. + if (value) { + merge = merge || this.didModifyCSP; + } + + // For manifest_version 3 extension, we are currently only allowing to + // merge additional CSP strings to the existing ones, which will be initially + // stricter than currently allowed to manifest_version 2 extensions, then + // following up with either a new permission and/or some more changes to the + // APIs (and possibly making the behavior more deterministic than it is for + // manifest_version 2 at the moment). + if (opts.policy.manifestVersion > 2) { + if (value) { + // If the given CSP header value is non empty, then it should be + // merged to the existing one. + merge = true; + } else { + // If the given CSP header value is empty (which would be clearing the + // CSP header), it should be considered a no-op and this.didModifyCSP + // shouldn't be changed to true. + return; + } + } + + this.didModifyCSP = true; + } else if ( + opts.policy.manifestVersion > 2 && + this.isResponseHeaderRestricted(lowerCaseName) + ) { + // TODO (Bug 1787155 and Bug 1273281) open this up to MV3 extensions, + // locked behind manifest.json declarative permission and a separate + // explicit user-controlled permission (and ideally also check for + // changes that would lead to security downgrades). + Cu.reportError( + `Disallowed change restricted response header ${name} on ${this.channel.finalURL} from ${opts.policy.debugName}` + ); + return; + } + + try { + this.channel.setResponseHeader(name, value, merge); + } catch (e) { + Cu.reportError(new Error(`Error setting response header ${name}: ${e}`)); + } + } + + isResponseHeaderRestricted(lowerCaseHeaderName) { + return MV3_RESTRICTED_HEADERS_PATTERNS.some(regex => + regex.test(lowerCaseHeaderName) + ); + } + + readHeaders() { + return this.channel.getResponseHeaders(); + } +} + +const MAYBE_CACHED_EVENTS = new Set([ + "onResponseStarted", + "onHeadersReceived", + "onBeforeRedirect", + "onCompleted", + "onErrorOccurred", +]); + +const OPTIONAL_PROPERTIES = [ + "requestHeaders", + "responseHeaders", + "statusCode", + "statusLine", + "error", + "redirectUrl", + "requestBody", + "scheme", + "realm", + "isProxy", + "challenger", + "proxyInfo", + "ip", + "frameAncestors", + "urlClassification", + "requestSize", + "responseSize", +]; + +function serializeRequestData(eventName) { + let data = { + requestId: this.requestId, + url: this.url, + originUrl: this.originUrl, + documentUrl: this.documentUrl, + method: this.method, + type: this.type, + timeStamp: Date.now(), + tabId: this.tabId, + frameId: this.frameId, + parentFrameId: this.parentFrameId, + incognito: this.incognito, + thirdParty: this.thirdParty, + cookieStoreId: this.cookieStoreId, + urgentSend: this.urgentSend, + }; + + if (MAYBE_CACHED_EVENTS.has(eventName)) { + data.fromCache = !!this.fromCache; + } + + for (let opt of OPTIONAL_PROPERTIES) { + if (typeof this[opt] !== "undefined") { + data[opt] = this[opt]; + } + } + + if (this.urlClassification) { + data.urlClassification = { + firstParty: this.urlClassification.firstParty.filter( + c => !c.startsWith("socialtracking_") + ), + thirdParty: this.urlClassification.thirdParty.filter( + c => !c.startsWith("socialtracking_") + ), + }; + } + + return data; +} + +var HttpObserverManager; + +var ChannelEventSink = { + _classDescription: "WebRequest channel event sink", + _classID: Components.ID("115062f8-92f1-11e5-8b7f-080027b0f7ec"), + _contractID: "@mozilla.org/webrequest/channel-event-sink;1", + + QueryInterface: ChromeUtils.generateQI(["nsIChannelEventSink", "nsIFactory"]), + + init() { + Components.manager + .QueryInterface(Ci.nsIComponentRegistrar) + .registerFactory( + this._classID, + this._classDescription, + this._contractID, + this + ); + }, + + register() { + Services.catMan.addCategoryEntry( + "net-channel-event-sinks", + this._contractID, + this._contractID, + false, + true + ); + }, + + unregister() { + Services.catMan.deleteCategoryEntry( + "net-channel-event-sinks", + this._contractID, + false + ); + }, + + // nsIChannelEventSink implementation + asyncOnChannelRedirect(oldChannel, newChannel, flags, redirectCallback) { + runLater(() => redirectCallback.onRedirectVerifyCallback(Cr.NS_OK)); + try { + HttpObserverManager.onChannelReplaced(oldChannel, newChannel); + } catch (e) { + // we don't wanna throw: it would abort the redirection + } + }, + + // nsIFactory implementation + createInstance(iid) { + return this.QueryInterface(iid); + }, +}; + +ChannelEventSink.init(); + +// nsIAuthPrompt2 implementation for onAuthRequired +class AuthRequestor { + constructor(channel, httpObserver) { + this.notificationCallbacks = channel.notificationCallbacks; + this.loadGroupCallbacks = + channel.loadGroup && channel.loadGroup.notificationCallbacks; + this.httpObserver = httpObserver; + } + + getInterface(iid) { + if (iid.equals(Ci.nsIAuthPromptProvider) || iid.equals(Ci.nsIAuthPrompt2)) { + return this; + } + try { + return this.notificationCallbacks.getInterface(iid); + } catch (e) {} + throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE); + } + + _getForwardedInterface(iid) { + try { + return this.notificationCallbacks.getInterface(iid); + } catch (e) { + return this.loadGroupCallbacks.getInterface(iid); + } + } + + // nsIAuthPromptProvider getAuthPrompt + getAuthPrompt(reason, iid) { + // This should never get called without getInterface having been called first. + if (iid.equals(Ci.nsIAuthPrompt2)) { + return this; + } + return this._getForwardedInterface(Ci.nsIAuthPromptProvider).getAuthPrompt( + reason, + iid + ); + } + + // nsIAuthPrompt2 promptAuth + promptAuth(channel, level, authInfo) { + this._getForwardedInterface(Ci.nsIAuthPrompt2).promptAuth( + channel, + level, + authInfo + ); + } + + _getForwardPrompt(data) { + let reason = data.isProxy + ? Ci.nsIAuthPromptProvider.PROMPT_PROXY + : Ci.nsIAuthPromptProvider.PROMPT_NORMAL; + for (let callbacks of [ + this.notificationCallbacks, + this.loadGroupCallbacks, + ]) { + try { + return callbacks + .getInterface(Ci.nsIAuthPromptProvider) + .getAuthPrompt(reason, Ci.nsIAuthPrompt2); + } catch (e) {} + try { + return callbacks.getInterface(Ci.nsIAuthPrompt2); + } catch (e) {} + } + throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE); + } + + // nsIAuthPrompt2 asyncPromptAuth + asyncPromptAuth(channel, callback, context, level, authInfo) { + let wrapper = ChannelWrapper.get(channel); + + let uri = channel.URI; + let proxyInfo; + let isProxy = !!(authInfo.flags & authInfo.AUTH_PROXY); + if (isProxy && channel instanceof Ci.nsIProxiedChannel) { + proxyInfo = channel.proxyInfo; + } + let data = { + scheme: authInfo.authenticationScheme, + realm: authInfo.realm, + isProxy, + challenger: { + host: proxyInfo ? proxyInfo.host : uri.host, + port: proxyInfo ? proxyInfo.port : uri.port, + }, + }; + + // In the case that no listener provides credentials, we fallback to the + // previously set callback class for authentication. + wrapper.authPromptForward = () => { + try { + let prompt = this._getForwardPrompt(data); + prompt.asyncPromptAuth(channel, callback, context, level, authInfo); + } catch (e) { + Cu.reportError(`webRequest asyncPromptAuth failure ${e}`); + callback.onAuthCancelled(context, false); + } + wrapper.authPromptForward = null; + wrapper.authPromptCallback = null; + }; + wrapper.authPromptCallback = authCredentials => { + // The API allows for canceling the request, providing credentials or + // doing nothing, so we do not provide a way to call onAuthCanceled. + // Canceling the request will result in canceling the authentication. + if ( + authCredentials && + typeof authCredentials.username === "string" && + typeof authCredentials.password === "string" + ) { + authInfo.username = authCredentials.username; + authInfo.password = authCredentials.password; + try { + callback.onAuthAvailable(context, authInfo); + } catch (e) { + Cu.reportError(`webRequest onAuthAvailable failure ${e}`); + } + // At least one addon has responded, so we won't forward to the regular + // prompt handlers. + wrapper.authPromptForward = null; + wrapper.authPromptCallback = null; + } + }; + + this.httpObserver.runChannelListener(wrapper, "onAuthRequired", data); + + return { + QueryInterface: ChromeUtils.generateQI(["nsICancelable"]), + cancel() { + try { + callback.onAuthCancelled(context, false); + } catch (e) { + Cu.reportError(`webRequest onAuthCancelled failure ${e}`); + } + wrapper.authPromptForward = null; + wrapper.authPromptCallback = null; + }, + }; + } +} + +AuthRequestor.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIInterfaceRequestor", + "nsIAuthPromptProvider", + "nsIAuthPrompt2", +]); + +// Most WebRequest events are implemented via the observer services, but +// a few use custom xpcom interfaces. This class (HttpObserverManager) +// serves two main purposes: +// 1. It abstracts away the names and details of the underlying +// implementation (e.g., onBeforeBeforeRequest is dispatched from +// the http-on-modify-request observable). +// 2. It aggregates multiple listeners so that a single observer or +// handler can serve multiple webRequest listeners. +HttpObserverManager = { + listeners: { + // onBeforeRequest uses http-on-modify observer for HTTP(S). + onBeforeRequest: new Map(), + + // onBeforeSendHeaders and onSendHeaders correspond to the + // http-on-before-connect observer. + onBeforeSendHeaders: new Map(), + onSendHeaders: new Map(), + + // onHeadersReceived corresponds to the http-on-examine-* obserservers. + onHeadersReceived: new Map(), + + // onAuthRequired is handled via the nsIAuthPrompt2 xpcom interface + // which is managed here by AuthRequestor. + onAuthRequired: new Map(), + + // onBeforeRedirect is handled by the nsIChannelEVentSink xpcom interface + // which is managed here by ChannelEventSink. + onBeforeRedirect: new Map(), + + // onResponseStarted, onErrorOccurred, and OnCompleted correspond + // to events dispatched by the ChannelWrapper EventTarget. + onResponseStarted: new Map(), + onErrorOccurred: new Map(), + onCompleted: new Map(), + }, + // Whether there are any registered declarativeNetRequest rules. These DNR + // rules may match new requests and result in request modifications. + dnrActive: false, + + openingInitialized: false, + beforeConnectInitialized: false, + examineInitialized: false, + redirectInitialized: false, + activityInitialized: false, + needTracing: false, + hasRedirects: false, + + getWrapper(nativeChannel) { + let wrapper = ChannelWrapper.get(nativeChannel); + if (!wrapper._addedListeners) { + /* eslint-disable mozilla/balanced-listeners */ + if (this.listeners.onErrorOccurred.size) { + wrapper.addEventListener("error", this); + } + if (this.listeners.onResponseStarted.size) { + wrapper.addEventListener("start", this); + } + if (this.listeners.onCompleted.size) { + wrapper.addEventListener("stop", this); + } + /* eslint-enable mozilla/balanced-listeners */ + + wrapper._addedListeners = true; + } + return wrapper; + }, + + get activityDistributor() { + return Cc["@mozilla.org/network/http-activity-distributor;1"].getService( + Ci.nsIHttpActivityDistributor + ); + }, + + // This method is called whenever webRequest listeners are added or removed. + // It reconciles the set of listeners with underlying observers, event + // handlers, etc. by adding new low-level handlers for any newly added + // webRequest listeners and removing those that are no longer needed if + // there are no more listeners for corresponding webRequest events. + addOrRemove() { + let needOpening = this.listeners.onBeforeRequest.size || this.dnrActive; + let needBeforeConnect = + this.listeners.onBeforeSendHeaders.size || + this.listeners.onSendHeaders.size || + this.dnrActive; + if (needOpening && !this.openingInitialized) { + this.openingInitialized = true; + Services.obs.addObserver(this, "http-on-modify-request"); + } else if (!needOpening && this.openingInitialized) { + this.openingInitialized = false; + Services.obs.removeObserver(this, "http-on-modify-request"); + } + if (needBeforeConnect && !this.beforeConnectInitialized) { + this.beforeConnectInitialized = true; + Services.obs.addObserver(this, "http-on-before-connect"); + } else if (!needBeforeConnect && this.beforeConnectInitialized) { + this.beforeConnectInitialized = false; + Services.obs.removeObserver(this, "http-on-before-connect"); + } + + let haveBlocking = Object.values(this.listeners).some(listeners => + Array.from(listeners.values()).some(listener => listener.blockingAllowed) + ); + + this.needTracing = + this.listeners.onResponseStarted.size || + this.listeners.onErrorOccurred.size || + this.listeners.onCompleted.size || + haveBlocking; + + let needExamine = + this.needTracing || + this.listeners.onHeadersReceived.size || + this.listeners.onAuthRequired.size || + this.dnrActive; + + if (needExamine && !this.examineInitialized) { + this.examineInitialized = true; + Services.obs.addObserver(this, "http-on-examine-response"); + Services.obs.addObserver(this, "http-on-examine-cached-response"); + Services.obs.addObserver(this, "http-on-examine-merged-response"); + } else if (!needExamine && this.examineInitialized) { + this.examineInitialized = false; + Services.obs.removeObserver(this, "http-on-examine-response"); + Services.obs.removeObserver(this, "http-on-examine-cached-response"); + Services.obs.removeObserver(this, "http-on-examine-merged-response"); + } + + // If we have any listeners, we need the channelsink so the channelwrapper is + // updated properly. Otherwise events for channels that are redirected will not + // happen correctly. If we have no listeners, shut it down. + this.hasRedirects = this.listeners.onBeforeRedirect.size > 0; + let needRedirect = + this.hasRedirects || needExamine || needOpening || needBeforeConnect; + if (needRedirect && !this.redirectInitialized) { + this.redirectInitialized = true; + ChannelEventSink.register(); + } else if (!needRedirect && this.redirectInitialized) { + this.redirectInitialized = false; + ChannelEventSink.unregister(); + } + + let needActivity = this.listeners.onErrorOccurred.size; + if (needActivity && !this.activityInitialized) { + this.activityInitialized = true; + this.activityDistributor.addObserver(this); + } else if (!needActivity && this.activityInitialized) { + this.activityInitialized = false; + this.activityDistributor.removeObserver(this); + } + }, + + addListener(kind, callback, opts) { + this.listeners[kind].set(callback, opts); + this.addOrRemove(); + }, + + removeListener(kind, callback) { + this.listeners[kind].delete(callback); + this.addOrRemove(); + }, + + setDNRHandlingEnabled(dnrActive) { + this.dnrActive = dnrActive; + this.addOrRemove(); + }, + + observe(subject, topic, data) { + let channel = this.getWrapper(subject); + switch (topic) { + case "http-on-modify-request": + this.runChannelListener(channel, "onBeforeRequest"); + break; + case "http-on-before-connect": + this.runChannelListener(channel, "onBeforeSendHeaders"); + break; + case "http-on-examine-cached-response": + case "http-on-examine-merged-response": + channel.fromCache = true; + // falls through + case "http-on-examine-response": + this.examine(channel, topic, data); + break; + } + }, + + // We map activity values with tentative error names, e.g. "STATUS_RESOLVING" => "NS_ERROR_NET_ON_RESOLVING". + get activityErrorsMap() { + let prefix = /^(?:ACTIVITY_SUBTYPE_|STATUS_)/; + let map = new Map(); + for (let iface of [nsIHttpActivityObserver, nsISocketTransport]) { + for (let c of Object.keys(iface).filter(name => prefix.test(name))) { + map.set(iface[c], c.replace(prefix, "NS_ERROR_NET_ON_")); + } + } + delete this.activityErrorsMap; + this.activityErrorsMap = map; + return this.activityErrorsMap; + }, + GOOD_LAST_ACTIVITY: nsIHttpActivityObserver.ACTIVITY_SUBTYPE_RESPONSE_HEADER, + observeActivity( + nativeChannel, + activityType, + activitySubtype /* , aTimestamp, aExtraSizeData, aExtraStringData */ + ) { + // Sometimes we get a NullHttpChannel, which implements + // nsIHttpChannel but not nsIChannel. + if (!(nativeChannel instanceof Ci.nsIChannel)) { + return; + } + let channel = this.getWrapper(nativeChannel); + + let lastActivity = channel.lastActivity || 0; + if ( + activitySubtype === + nsIHttpActivityObserver.ACTIVITY_SUBTYPE_RESPONSE_COMPLETE && + lastActivity && + lastActivity !== this.GOOD_LAST_ACTIVITY + ) { + // Since the channel's security info is assigned in onStartRequest and + // errorCheck is called in ChannelWrapper::onStartRequest, we should check + // the errorString after onStartRequest to make sure errors have a chance + // to be processed before we fall back to a generic error string. + channel.addEventListener( + "start", + () => { + if (!channel.errorString) { + this.runChannelListener(channel, "onErrorOccurred", { + error: + this.activityErrorsMap.get(lastActivity) || + `NS_ERROR_NET_UNKNOWN_${lastActivity}`, + }); + } + }, + { once: true } + ); + } else if ( + lastActivity !== this.GOOD_LAST_ACTIVITY && + lastActivity !== + nsIHttpActivityObserver.ACTIVITY_SUBTYPE_TRANSACTION_CLOSE + ) { + channel.lastActivity = activitySubtype; + } + }, + + getRequestData(channel, extraData) { + let originAttributes = channel.loadInfo?.originAttributes; + let cos = channel.channel.QueryInterface(Ci.nsIClassOfService); + + let data = { + requestId: String(channel.id), + url: channel.finalURL, + method: channel.method, + type: channel.type, + fromCache: channel.fromCache, + incognito: originAttributes?.privateBrowsingId > 0, + thirdParty: channel.thirdParty, + + originUrl: channel.originURL || undefined, + documentUrl: channel.documentURL || undefined, + + tabId: this.getBrowserData(channel).tabId, + frameId: channel.frameId, + parentFrameId: channel.parentFrameId, + + frameAncestors: channel.frameAncestors || undefined, + + ip: channel.remoteAddress, + + proxyInfo: channel.proxyInfo, + + serialize: serializeRequestData, + requestSize: channel.requestSize, + responseSize: channel.responseSize, + urlClassification: channel.urlClassification, + + // Figure out if this is an urgent request that shouldn't be batched. + urgentSend: (cos.classFlags & URGENT_CLASSES) > 0, + }; + + if (originAttributes) { + data.cookieStoreId = + lazy.getCookieStoreIdForOriginAttributes(originAttributes); + } + + return Object.assign(data, extraData); + }, + + handleEvent(event) { + let channel = event.currentTarget; + switch (event.type) { + case "error": + this.runChannelListener(channel, "onErrorOccurred", { + error: channel.errorString, + }); + break; + case "start": + this.runChannelListener(channel, "onResponseStarted"); + break; + case "stop": + this.runChannelListener(channel, "onCompleted"); + break; + } + }, + + STATUS_TYPES: new Set([ + "onHeadersReceived", + "onAuthRequired", + "onBeforeRedirect", + "onResponseStarted", + "onCompleted", + ]), + FILTER_TYPES: new Set([ + "onBeforeRequest", + "onBeforeSendHeaders", + "onSendHeaders", + "onHeadersReceived", + "onAuthRequired", + "onBeforeRedirect", + ]), + + getBrowserData(wrapper) { + let browserData = wrapper._browserData; + if (!browserData) { + if (wrapper.browserElement) { + browserData = lazy.tabTracker.getBrowserData(wrapper.browserElement); + } else { + browserData = { tabId: -1, windowId: -1 }; + } + wrapper._browserData = browserData; + } + return browserData; + }, + + runChannelListener(channel, kind, extraData = null) { + let handlerResults = []; + let requestHeaders; + let responseHeaders; + + try { + if (kind !== "onErrorOccurred" && channel.errorString) { + return; + } + if (this.dnrActive) { + // DNR may modify (but not cancel) the request at this stage. + lazy.ExtensionDNR.beforeWebRequestEvent(channel, kind); + } + + let registerFilter = this.FILTER_TYPES.has(kind); + let commonData = null; + let requestBody; + this.listeners[kind].forEach((opts, callback) => { + if (opts.filter.tabId !== null || opts.filter.windowId !== null) { + const { tabId, windowId } = this.getBrowserData(channel); + if ( + (opts.filter.tabId !== null && tabId != opts.filter.tabId) || + (opts.filter.windowId !== null && windowId != opts.filter.windowId) + ) { + return; + } + } + if (!channel.matches(opts.filter, opts.policy, extraData)) { + return; + } + + let extension = opts.policy?.extension; + // TODO: Move this logic to ChannelWrapper::matches, see bug 1699481 + if ( + extension?.userContextIsolation && + !extension.canAccessContainer( + channel.loadInfo?.originAttributes.userContextId + ) + ) { + return; + } + + if (!commonData) { + commonData = this.getRequestData(channel, extraData); + if (this.STATUS_TYPES.has(kind)) { + commonData.statusCode = channel.statusCode; + commonData.statusLine = channel.statusLine; + } + } + let data = Object.create(commonData); + data.urgentSend = data.urgentSend && opts.blocking; + + if (registerFilter && opts.blocking && opts.policy) { + data.registerTraceableChannel = (policy, remoteTab) => { + // `channel` is a ChannelWrapper, which contains the actual + // underlying nsIChannel in `channel.channel`. For startup events + // that are held until the extension background page is started, + // it is possible that the underlying channel can be closed and + // cleaned up between the time the event occurred and the time + // we reach this code. + if (channel.channel) { + channel.registerTraceableChannel(policy, remoteTab); + } + }; + } + + if (opts.requestHeaders) { + requestHeaders = requestHeaders || new RequestHeaderChanger(channel); + data.requestHeaders = requestHeaders.toArray(); + } + + if (opts.responseHeaders) { + try { + responseHeaders = + responseHeaders || new ResponseHeaderChanger(channel); + data.responseHeaders = responseHeaders.toArray(); + } catch (e) { + /* headers may not be available on some redirects */ + } + } + + if (opts.requestBody && channel.canModify) { + requestBody = + requestBody || + lazy.WebRequestUpload.createRequestBody(channel.channel); + data.requestBody = requestBody; + } + + try { + let result = callback(data); + + // isProxy is set during onAuth if the auth request is for a proxy. + // We allow handling proxy auth regardless of canModify. + if ( + (channel.canModify || data.isProxy) && + typeof result === "object" && + opts.blocking + ) { + handlerResults.push({ opts, result }); + } + } catch (e) { + Cu.reportError(e); + } + }); + } catch (e) { + Cu.reportError(e); + } + + if (this.dnrActive && lazy.ExtensionDNR.handleRequest(channel, kind)) { + return; + } + + return this.applyChanges( + kind, + channel, + handlerResults, + requestHeaders, + responseHeaders + ); + }, + + async applyChanges( + kind, + channel, + handlerResults, + requestHeaders, + responseHeaders + ) { + const { finalURL, id: chanId } = channel; + let shouldResume = !channel.suspended; + // NOTE: if a request has been suspended before the GeckoProfiler + // has been activated and then resumed while the GeckoProfiler is active + // and collecting data, the resulting "Extension Suspend" marker will be + // recorded with an empty marker text (and so without url, chan id and + // the supenders addon ids). + let markerText = ""; + if (Services.profiler?.IsActive()) { + const suspenders = handlerResults + .filter(({ result }) => isThenable(result)) + .map(({ opts }) => opts.addonId) + .join(", "); + markerText = `${kind} ${finalURL} by ${suspenders} (chanId: ${chanId})`; + } + try { + for (let { opts, result } of handlerResults) { + if (isThenable(result)) { + channel.suspend(markerText); + try { + result = await result; + } catch (e) { + let error; + + if (e instanceof Error) { + error = e; + } else if (typeof e === "object" && e.message) { + error = new Error(e.message, e.fileName, e.lineNumber); + } + + Cu.reportError(error); + continue; + } + if (!result || typeof result !== "object") { + continue; + } + } + + if ( + kind === "onAuthRequired" && + result.authCredentials && + channel.authPromptCallback + ) { + channel.authPromptCallback(result.authCredentials); + } + + // We allow proxy auth to cancel or handle authCredentials regardless of + // canModify, but ensure we do nothing else. + if (!channel.canModify) { + continue; + } + + if (result.cancel) { + channel.resume(); + channel.cancel( + Cr.NS_ERROR_ABORT, + Ci.nsILoadInfo.BLOCKING_REASON_EXTENSION_WEBREQUEST + ); + ChromeUtils.addProfilerMarker( + "Extension Canceled", + { category: "Network" }, + `${kind} ${finalURL} canceled by ${opts.addonId} (chanId: ${chanId})` + ); + if (opts.policy) { + let properties = channel.channel.QueryInterface( + Ci.nsIWritablePropertyBag + ); + properties.setProperty("cancelledByExtension", opts.policy.id); + } + return; + } + + if (result.redirectUrl) { + try { + const { redirectUrl } = result; + channel.resume(); + const redirectUri = Services.io.newURI(redirectUrl); + verifyRedirect(channel, redirectUri, finalURL, opts.addonId); + channel.redirectTo(redirectUri); + ChromeUtils.addProfilerMarker( + "Extension Redirected", + { category: "Network" }, + `${kind} ${finalURL} redirected to ${redirectUrl} by ${opts.addonId} (chanId: ${chanId})` + ); + if (opts.policy) { + let properties = channel.channel.QueryInterface( + Ci.nsIWritablePropertyBag + ); + properties.setProperty("redirectedByExtension", opts.policy.id); + } + + // Web Extensions using the WebRequest API are allowed + // to redirect a channel to a data: URI, hence we mark + // the channel to let the redirect blocker know. Please + // note that this marking needs to happen after the + // channel.redirectTo is called because the channel's + // RedirectTo() implementation explicitly drops the flag + // to avoid additional redirects not caused by the + // Web Extension. + channel.loadInfo.allowInsecureRedirectToDataURI = true; + + // To pass CORS checks, we pretend the current request's + // response allows the triggering origin to access. + let origin = channel.getRequestHeader("Origin"); + if (origin) { + channel.setResponseHeader("Access-Control-Allow-Origin", origin); + channel.setResponseHeader( + "Access-Control-Allow-Credentials", + "true" + ); + + // Compute an arbitrary 'Access-Control-Allow-Headers' + // for the internal Redirect + + let allowHeaders = channel + .getRequestHeaders() + .map(header => header.name) + .join(); + channel.setResponseHeader( + "Access-Control-Allow-Headers", + allowHeaders + ); + + channel.setResponseHeader( + "Access-Control-Allow-Methods", + channel.method + ); + } + + return; + } catch (e) { + Cu.reportError(e); + } + } + + if (result.upgradeToSecure && kind === "onBeforeRequest") { + try { + channel.upgradeToSecure(); + } catch (e) { + Cu.reportError(e); + } + } + + if (opts.requestHeaders && result.requestHeaders && requestHeaders) { + requestHeaders.applyChanges(result.requestHeaders, opts); + } + + if (opts.responseHeaders && result.responseHeaders && responseHeaders) { + responseHeaders.applyChanges(result.responseHeaders, opts); + } + } + + // If a listener did not cancel the request or provide credentials, we + // forward the auth request to the base handler. + if (kind === "onAuthRequired" && channel.authPromptForward) { + channel.authPromptForward(); + } + + if (kind === "onBeforeSendHeaders" && this.listeners.onSendHeaders.size) { + this.runChannelListener(channel, "onSendHeaders"); + } else if (kind !== "onErrorOccurred") { + channel.errorCheck(); + } + } catch (e) { + Cu.reportError(e); + } + + // Only resume the channel if it was suspended by this call. + if (shouldResume) { + channel.resume(); + } + }, + + shouldHookListener(listener, channel, extraData) { + if (listener.size == 0) { + return false; + } + + for (let opts of listener.values()) { + if (channel.matches(opts.filter, opts.policy, extraData)) { + return true; + } + } + return false; + }, + + examine(channel, topic, data) { + if (this.listeners.onHeadersReceived.size || this.dnrActive) { + this.runChannelListener(channel, "onHeadersReceived"); + } + + if ( + !channel.hasAuthRequestor && + this.shouldHookListener(this.listeners.onAuthRequired, channel, { + isProxy: true, + }) + ) { + channel.channel.notificationCallbacks = new AuthRequestor( + channel.channel, + this + ); + channel.hasAuthRequestor = true; + } + }, + + onChannelReplaced(oldChannel, newChannel) { + let channel = this.getWrapper(oldChannel); + + // We want originalURI, this will provide a moz-ext rather than jar or file + // uri on redirects. + if (this.hasRedirects) { + this.runChannelListener(channel, "onBeforeRedirect", { + redirectUrl: newChannel.originalURI.spec, + }); + } + channel.channel = newChannel; + }, +}; + +function HttpEvent(internalEvent, options) { + this.internalEvent = internalEvent; + this.options = options; +} + +HttpEvent.prototype = { + addListener(callback, filter = null, options = null, optionsObject = null) { + let opts = parseExtra(options, this.options, optionsObject); + opts.filter = parseFilter(filter); + HttpObserverManager.addListener(this.internalEvent, callback, opts); + }, + + removeListener(callback) { + HttpObserverManager.removeListener(this.internalEvent, callback); + }, +}; + +var onBeforeRequest = new HttpEvent("onBeforeRequest", [ + "blocking", + "requestBody", +]); +var onBeforeSendHeaders = new HttpEvent("onBeforeSendHeaders", [ + "requestHeaders", + "blocking", +]); +var onSendHeaders = new HttpEvent("onSendHeaders", ["requestHeaders"]); +var onHeadersReceived = new HttpEvent("onHeadersReceived", [ + "blocking", + "responseHeaders", +]); +var onAuthRequired = new HttpEvent("onAuthRequired", [ + "blocking", + "responseHeaders", +]); +var onBeforeRedirect = new HttpEvent("onBeforeRedirect", ["responseHeaders"]); +var onResponseStarted = new HttpEvent("onResponseStarted", ["responseHeaders"]); +var onCompleted = new HttpEvent("onCompleted", ["responseHeaders"]); +var onErrorOccurred = new HttpEvent("onErrorOccurred"); + +export var WebRequest = { + setDNRHandlingEnabled: dnrActive => { + HttpObserverManager.setDNRHandlingEnabled(dnrActive); + }, + getTabIdForChannelWrapper: channel => { + // Warning: This method should only be called after the initialization of + // ExtensionParent.apiManager.global. Generally, this means that this method + // should only be used by implementations of extension API methods (which + // themselves are loaded in ExtensionParent.apiManager.global and therefore + // imply the initialization of ExtensionParent.apiManager.global). + return HttpObserverManager.getBrowserData(channel).tabId; + }, + + onBeforeRequest, + onBeforeSendHeaders, + onSendHeaders, + onHeadersReceived, + onAuthRequired, + onBeforeRedirect, + onResponseStarted, + onCompleted, + onErrorOccurred, + + getSecurityInfo: details => { + let channel = ChannelWrapper.getRegisteredChannel( + details.id, + details.policy, + details.remoteTab + ); + if (channel) { + return lazy.SecurityInfo.getSecurityInfo( + channel.channel, + details.options + ); + } + }, +}; diff --git a/toolkit/components/extensions/webrequest/WebRequestService.cpp b/toolkit/components/extensions/webrequest/WebRequestService.cpp new file mode 100644 index 0000000000..7ec2433ac5 --- /dev/null +++ b/toolkit/components/extensions/webrequest/WebRequestService.cpp @@ -0,0 +1,55 @@ +/* -*- 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 "WebRequestService.h" + +#include "mozilla/Assertions.h" +#include "mozilla/ClearOnShutdown.h" + +using namespace mozilla; +using namespace mozilla::dom; +using namespace mozilla::extensions; + +static StaticRefPtr<WebRequestService> sWebRequestService; + +/* static */ WebRequestService& WebRequestService::GetSingleton() { + if (!sWebRequestService) { + sWebRequestService = new WebRequestService(); + ClearOnShutdown(&sWebRequestService); + } + return *sWebRequestService; +} + +UniquePtr<WebRequestChannelEntry> WebRequestService::RegisterChannel( + ChannelWrapper* aChannel) { + UniquePtr<ChannelEntry> entry(new ChannelEntry(aChannel)); + + mChannelEntries.WithEntryHandle(entry->mChannelId, [&](auto&& key) { + MOZ_DIAGNOSTIC_ASSERT(!key); + key.Insert(entry.get()); + }); + + return entry; +} + +already_AddRefed<nsITraceableChannel> WebRequestService::GetTraceableChannel( + uint64_t aChannelId, nsAtom* aAddonId, ContentParent* aContentParent) { + if (auto entry = mChannelEntries.Get(aChannelId)) { + if (entry->mChannel) { + return entry->mChannel->GetTraceableChannel(aAddonId, aContentParent); + } + } + return nullptr; +} + +WebRequestChannelEntry::WebRequestChannelEntry(ChannelWrapper* aChannel) + : mChannelId(aChannel->Id()), mChannel(aChannel) {} + +WebRequestChannelEntry::~WebRequestChannelEntry() { + if (sWebRequestService) { + sWebRequestService->mChannelEntries.Remove(mChannelId); + } +} diff --git a/toolkit/components/extensions/webrequest/WebRequestService.h b/toolkit/components/extensions/webrequest/WebRequestService.h new file mode 100644 index 0000000000..b963586169 --- /dev/null +++ b/toolkit/components/extensions/webrequest/WebRequestService.h @@ -0,0 +1,79 @@ +/* -*- 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/. */ + +#ifndef mozilla_WebRequestService_h +#define mozilla_WebRequestService_h + +#include "mozilla/LinkedList.h" +#include "mozilla/UniquePtr.h" + +#include "mozilla/extensions/ChannelWrapper.h" +#include "mozilla/extensions/WebExtensionPolicy.h" + +#include "nsHashKeys.h" +#include "nsTHashMap.h" + +class nsAtom; +class nsIRemoteTab; +class nsITraceableChannel; + +namespace mozilla { +namespace dom { +class BrowserParent; +class ContentParent; +} // namespace dom + +namespace extensions { + +class ChannelWrapper; + +class WebRequestChannelEntry final { + public: + ~WebRequestChannelEntry(); + + private: + friend class WebRequestService; + + explicit WebRequestChannelEntry(ChannelWrapper* aChannel); + + uint64_t mChannelId; + WeakPtr<ChannelWrapper> mChannel; +}; + +class WebRequestService final { + public: + NS_INLINE_DECL_REFCOUNTING(WebRequestService) + + WebRequestService() = default; + + static already_AddRefed<WebRequestService> GetInstance() { + return do_AddRef(&GetSingleton()); + } + + static WebRequestService& GetSingleton(); + + using ChannelEntry = WebRequestChannelEntry; + + UniquePtr<ChannelEntry> RegisterChannel(ChannelWrapper* aChannel); + + void UnregisterTraceableChannel(uint64_t aChannelId); + + already_AddRefed<nsITraceableChannel> GetTraceableChannel( + uint64_t aChannelId, nsAtom* aAddonId, + dom::ContentParent* aContentParent); + + private: + ~WebRequestService() = default; + + friend ChannelEntry; + + nsTHashMap<nsUint64HashKey, ChannelEntry*> mChannelEntries; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_WebRequestService_h diff --git a/toolkit/components/extensions/webrequest/WebRequestUpload.sys.mjs b/toolkit/components/extensions/webrequest/WebRequestUpload.sys.mjs new file mode 100644 index 0000000000..09f2e25a7e --- /dev/null +++ b/toolkit/components/extensions/webrequest/WebRequestUpload.sys.mjs @@ -0,0 +1,560 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { DefaultMap } = ExtensionUtils; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "mimeHeader", + "@mozilla.org/network/mime-hdrparam;1", + "nsIMIMEHeaderParam" +); + +const BinaryInputStream = Components.Constructor( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" +); +const ConverterInputStream = Components.Constructor( + "@mozilla.org/intl/converter-input-stream;1", + "nsIConverterInputStream", + "init" +); + +export var WebRequestUpload; + +/** + * Parses the given raw header block, and stores the value of each + * lower-cased header name in the resulting map. + */ +class Headers extends Map { + constructor(headerText) { + super(); + + if (headerText) { + this.parseHeaders(headerText); + } + } + + parseHeaders(headerText) { + let lines = headerText.split("\r\n"); + + let lastHeader; + for (let line of lines) { + // The first empty line indicates the end of the header block. + if (line === "") { + return; + } + + // Lines starting with whitespace are appended to the previous + // header. + if (/^\s/.test(line)) { + if (lastHeader) { + let val = this.get(lastHeader); + this.set(lastHeader, `${val}\r\n${line}`); + } + continue; + } + + let match = /^(.*?)\s*:\s+(.*)/.exec(line); + if (match) { + lastHeader = match[1].toLowerCase(); + this.set(lastHeader, match[2]); + } + } + } + + /** + * If the given header exists, and contains the given parameter, + * returns the value of that parameter. + * + * @param {string} name + * The lower-cased header name. + * @param {string} paramName + * The name of the parameter to retrieve, or empty to retrieve + * the first (possibly unnamed) parameter. + * @returns {string | null} + */ + getParam(name, paramName) { + return Headers.getParam(this.get(name), paramName); + } + + /** + * If the given header value is non-null, and contains the given + * parameter, returns the value of that parameter. + * + * @param {string | null} header + * The text of the header from which to retrieve the param. + * @param {string} paramName + * The name of the parameter to retrieve, or empty to retrieve + * the first (possibly unnamed) parameter. + * @returns {string | null} + */ + static getParam(header, paramName) { + if (header) { + // The service expects this to be a raw byte string, so convert to + // UTF-8. + let bytes = new TextEncoder().encode(header); + let binHeader = String.fromCharCode(...bytes); + + return lazy.mimeHeader.getParameterHTTP( + binHeader, + paramName, + null, + false, + {} + ); + } + + return null; + } +} + +/** + * Creates a new Object with a corresponding property for every + * key-value pair in the given Map. + * + * @param {Map} map + * The map to convert. + * @returns {object} + */ +function mapToObject(map) { + let result = {}; + for (let [key, value] of map) { + result[key] = value; + } + return result; +} + +/** + * Rewinds the given seekable input stream to its beginning, and catches + * any resulting errors. + * + * @param {nsISeekableStream} stream + * The stream to rewind. + */ +function rewind(stream) { + // Do this outside the try-catch so that we throw if the stream is not + // actually seekable. + stream.QueryInterface(Ci.nsISeekableStream); + + try { + stream.seek(0, 0); + } catch (e) { + // It might be already closed, e.g. because of a previous error. + Cu.reportError(e); + } +} + +/** + * Iterates over all of the sub-streams that make up the given stream, + * or yields the stream itself if it is not a multi-part stream. + * + * @param {nsIIMultiplexInputStream|nsIStreamBufferAccess<nsIMultiplexInputStream>|nsIInputStream} outerStream + * The outer stream over which to iterate. + */ +function* getStreams(outerStream) { + // If this is a multi-part stream, we need to iterate over its sub-streams, + // rather than treating it as a simple input stream. Since it may be wrapped + // in a buffered input stream, unwrap it before we do any checks. + let unbuffered = outerStream; + if (outerStream instanceof Ci.nsIStreamBufferAccess) { + unbuffered = outerStream.unbufferedStream; + } + + if (unbuffered instanceof Ci.nsIMultiplexInputStream) { + let count = unbuffered.count; + for (let i = 0; i < count; i++) { + yield unbuffered.getStream(i); + } + } else { + yield outerStream; + } +} + +/** + * Parses the form data of the given stream as either multipart/form-data or + * x-www-form-urlencoded, and returns a map of its fields. + * + * @param {nsIInputStream} stream + * The input stream from which to parse the form data. + * @param {nsIHttpChannel} channel + * The channel to which the stream belongs. + * @param {boolean} [lenient = false] + * If true, the operation will succeed even if there are UTF-8 + * decoding errors. + * + * @returns {Map<string, Array<string>> | null} + */ +function parseFormData(stream, channel, lenient = false) { + const BUFFER_SIZE = 8192; + + let touchedStreams = new Set(); + let converterStreams = []; + + /** + * Creates a converter input stream from the given raw input stream, + * and adds it to the list of streams to be rewound at the end of + * parsing. + * + * Returns null if the given raw stream cannot be rewound. + * + * @param {nsIInputStream} stream + * The base stream from which to create a converter. + * @returns {ConverterInputStream | null} + */ + function createTextStream(stream) { + if (!(stream instanceof Ci.nsISeekableStream)) { + return null; + } + + touchedStreams.add(stream); + let converterStream = ConverterInputStream( + stream, + "UTF-8", + 0, + lenient ? Ci.nsIConverterInputStream.DEFAULT_REPLACEMENT_CHARACTER : 0 + ); + converterStreams.push(converterStream); + return converterStream; + } + + /** + * Reads a string of no more than the given length from the given text + * stream. + * + * @param {ConverterInputStream} stream + * The stream to read. + * @param {integer} [length = BUFFER_SIZE] + * The maximum length of data to read. + * @returns {string} + */ + function readString(stream, length = BUFFER_SIZE) { + let data = {}; + stream.readString(length, data); + return data.value; + } + + /** + * Iterates over all of the sub-streams of the given (possibly multi-part) + * input stream, and yields a ConverterInputStream for each + * nsIStringInputStream among them. + * + * @param {nsIInputStream|nsIMultiplexInputStream} outerStream + * The multi-part stream over which to iterate. + */ + function* getTextStreams(outerStream) { + for (let stream of getStreams(outerStream)) { + if (stream instanceof Ci.nsIStringInputStream) { + touchedStreams.add(outerStream); + yield createTextStream(stream); + } + } + } + + /** + * Iterates over all of the string streams of the given (possibly + * multi-part) input stream, and yields all of the available data in each as + * chunked strings, each no more than BUFFER_SIZE in length. + * + * @param {nsIInputStream|nsIMultiplexInputStream} outerStream + * The multi-part stream over which to iterate. + */ + function* readAllStrings(outerStream) { + for (let textStream of getTextStreams(outerStream)) { + let str; + while ((str = readString(textStream))) { + yield str; + } + } + } + + /** + * Iterates over the text contents of all of the string streams in the given + * (possibly multi-part) input stream, splits them at occurrences of the + * given boundary string, and yields each part. + * + * @param {nsIInputStream|nsIMultiplexInputStream} stream + * The multi-part stream over which to iterate. + * @param {string} boundary + * The boundary at which to split the parts. + * @param {string} [tail = ""] + * Any initial data to prepend to the start of the stream data. + */ + function* getParts(stream, boundary, tail = "") { + for (let chunk of readAllStrings(stream)) { + chunk = tail + chunk; + + let parts = chunk.split(boundary); + tail = parts.pop(); + + yield* parts; + } + + if (tail) { + yield tail; + } + } + + /** + * Parses the given stream as multipart/form-data and returns a map of its fields. + * + * @param {nsIMultiplexInputStream|nsIInputStream} stream + * The (possibly multi-part) stream to parse. + * @param {string} boundary + * The boundary at which to split the parts. + * @returns {Map<string, Array<string>>} + */ + function parseMultiPart(stream, boundary) { + let formData = new DefaultMap(() => []); + + for (let part of getParts(stream, boundary, "\r\n")) { + if (part === "") { + // The first part will always be empty. + continue; + } + if (part === "--\r\n") { + // This indicates the end of the stream. + break; + } + + let end = part.indexOf("\r\n\r\n"); + + // All valid parts must begin with \r\n, and we can't process form + // fields without any header block. + if (!part.startsWith("\r\n") || end <= 0) { + throw new Error("Invalid MIME stream"); + } + + let content = part.slice(end + 4); + let headerText = part.slice(2, end); + let headers = new Headers(headerText); + + let name = headers.getParam("content-disposition", "name"); + if ( + !name || + headers.getParam("content-disposition", "") !== "form-data" + ) { + throw new Error( + "Invalid MIME stream: No valid Content-Disposition header" + ); + } + + // Decode the percent-escapes in the name. Unlike with decodeURIComponent, + // partial percent-escapes are passed through as is rather than throwing + // exceptions. + name = name.replace(/(%[0-9A-Fa-f]{2})+/g, match => { + const bytes = new Uint8Array(match.length / 3); + for (let i = 0; i < match.length / 3; i++) { + bytes[i] = parseInt(match.substring(i * 3 + 1, (i + 1) * 3), 16); + } + return new TextDecoder("utf-8").decode(bytes); + }); + + if (headers.has("content-type")) { + // For file upload fields, we return the filename, rather than the + // file data. We're following Chrome in not percent-decoding the + // filename. + let filename = headers.getParam("content-disposition", "filename"); + content = filename || ""; + } + formData.get(name).push(content); + } + + return formData; + } + + /** + * Parses the given stream as x-www-form-urlencoded, and returns a map of its fields. + * + * @param {nsIInputStream} stream + * The stream to parse. + * @returns {Map<string, Array<string>>} + */ + function parseUrlEncoded(stream) { + let formData = new DefaultMap(() => []); + + for (let part of getParts(stream, "&")) { + let [name, value] = part + .replace(/\+/g, " ") + .split("=") + .map(decodeURIComponent); + formData.get(name).push(value); + } + + return formData; + } + + try { + if (stream instanceof Ci.nsIMIMEInputStream && stream.data) { + stream = stream.data; + } + + channel.QueryInterface(Ci.nsIHttpChannel); + let contentType = channel.getRequestHeader("Content-Type"); + + switch (Headers.getParam(contentType, "")) { + case "multipart/form-data": + let boundary = Headers.getParam(contentType, "boundary"); + return parseMultiPart(stream, `\r\n--${boundary}`); + + case "application/x-www-form-urlencoded": + return parseUrlEncoded(stream); + } + } finally { + for (let stream of touchedStreams) { + rewind(stream); + } + for (let converterStream of converterStreams) { + // Release the reference to the underlying input stream, to prevent the + // destructor of nsConverterInputStream from closing the stream, which + // would cause uploads to break. + converterStream.init(null, null, 0, 0); + } + } + + return null; +} + +/** + * Parses the form data of the given stream as either multipart/form-data or + * x-www-form-urlencoded, and returns a map of its fields. + * + * Returns null if the stream is not seekable. + * + * @param {nsIMultiplexInputStream|nsIInputStream} stream + * The (possibly multi-part) stream from which to create the form data. + * @param {nsIChannel} channel + * The channel to which the stream belongs. + * @param {boolean} [lenient = false] + * If true, the operation will succeed even if there are UTF-8 + * decoding errors. + * @returns {Map<string, Array<string>> | null} + */ +function createFormData(stream, channel, lenient) { + if (!(stream instanceof Ci.nsISeekableStream)) { + return null; + } + + try { + let formData = parseFormData(stream, channel, lenient); + if (formData) { + return mapToObject(formData); + } + } catch (e) { + Cu.reportError(e); + } finally { + rewind(stream); + } + return null; +} + +/** + * Iterates over all of the sub-streams of the given (possibly multi-part) + * input stream, and yields an object containing the data for each chunk, up + * to a total of `maxRead` bytes. + * + * @param {nsIMultiplexInputStream|nsIInputStream} outerStream + * The stream for which to return data. + * @param {integer} [maxRead = WebRequestUpload.MAX_RAW_BYTES] + * The maximum total bytes to read. + */ +function* getRawDataChunked( + outerStream, + maxRead = WebRequestUpload.MAX_RAW_BYTES +) { + for (let stream of getStreams(outerStream)) { + // We need to inspect the stream to make sure it's not a file input + // stream. If it's wrapped in a buffered input stream, unwrap it first, + // so we can inspect the inner stream directly. + let unbuffered = stream; + if (stream instanceof Ci.nsIStreamBufferAccess) { + unbuffered = stream.unbufferedStream; + } + + // For file fields, we return an object containing the full path of + // the file, rather than its data. + if ( + unbuffered instanceof Ci.nsIFileInputStream || + unbuffered instanceof Ci.mozIRemoteLazyInputStream + ) { + // But this is not actually supported yet. + yield { file: "<file>" }; + continue; + } + + try { + let binaryStream = BinaryInputStream(stream); + let available; + while ((available = binaryStream.available())) { + let buffer = new ArrayBuffer(Math.min(maxRead, available)); + binaryStream.readArrayBuffer(buffer.byteLength, buffer); + + maxRead -= buffer.byteLength; + + let chunk = { bytes: buffer }; + + if (buffer.byteLength < available) { + chunk.truncated = true; + chunk.originalSize = available; + } + + yield chunk; + + if (maxRead <= 0) { + return; + } + } + } finally { + rewind(stream); + } + } +} + +WebRequestUpload = { + createRequestBody(channel) { + if (!(channel instanceof Ci.nsIUploadChannel) || !channel.uploadStream) { + return null; + } + + if ( + channel instanceof Ci.nsIUploadChannel2 && + channel.uploadStreamHasHeaders + ) { + return { error: "Upload streams with headers are unsupported" }; + } + + try { + let stream = channel.uploadStream; + + let formData = createFormData(stream, channel); + if (formData) { + return { formData }; + } + + // If we failed to parse the stream as form data, return it as a + // sequence of raw data chunks, along with a leniently-parsed form + // data object, which ignores encoding errors. + return { + raw: Array.from(getRawDataChunked(stream)), + lenientFormData: createFormData(stream, channel, true), + }; + } catch (e) { + Cu.reportError(e); + return { error: e.message || String(e) }; + } + }, +}; + +XPCOMUtils.defineLazyPreferenceGetter( + WebRequestUpload, + "MAX_RAW_BYTES", + "webextensions.webRequest.requestBodyMaxRawBytes" +); diff --git a/toolkit/components/extensions/webrequest/components.conf b/toolkit/components/extensions/webrequest/components.conf new file mode 100644 index 0000000000..9b1e6f86da --- /dev/null +++ b/toolkit/components/extensions/webrequest/components.conf @@ -0,0 +1,16 @@ +# -*- 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': '{acb22042-2b6c-427b-b550-b9f407c6fff6}', + 'contract_ids': ['@mozilla.org/extensions/web-navigation-content;1'], + 'type': 'mozilla::extensions::WebNavigationContent', + 'constructor': 'mozilla::extensions::WebNavigationContent::GetSingleton', + 'headers': ['mozilla/extensions/WebNavigationContent.h'], + 'categories': {'app-startup': 'WebNavigationContent'}, + }, +] diff --git a/toolkit/components/extensions/webrequest/moz.build b/toolkit/components/extensions/webrequest/moz.build new file mode 100644 index 0000000000..935531d916 --- /dev/null +++ b/toolkit/components/extensions/webrequest/moz.build @@ -0,0 +1,60 @@ +# -*- 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/. + +EXTRA_JS_MODULES += [ + "SecurityInfo.sys.mjs", + "WebRequest.sys.mjs", + "WebRequestUpload.sys.mjs", +] + +UNIFIED_SOURCES += [ + "ChannelWrapper.cpp", + "StreamFilter.cpp", + "StreamFilterChild.cpp", + "StreamFilterEvents.cpp", + "StreamFilterParent.cpp", + "WebNavigationContent.cpp", + "WebRequestService.cpp", +] + +IPDL_SOURCES += [ + "PStreamFilter.ipdl", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +EXPORTS.mozilla += [ + "WebRequestService.h", +] + +EXPORTS.mozilla.extensions += [ + "ChannelWrapper.h", + "StreamFilter.h", + "StreamFilterBase.h", + "StreamFilterChild.h", + "StreamFilterEvents.h", + "StreamFilterParent.h", + "WebNavigationContent.h", +] + +LOCAL_INCLUDES += [ + "/caps", +] + +include("/ipc/chromium/chromium-config.mozbuild") + +LOCAL_INCLUDES += [ + # For nsHttpChannel.h + "/netwerk/base", + "/netwerk/protocol/http", +] + +FINAL_LIBRARY = "xul" + +with Files("**"): + BUG_COMPONENT = ("WebExtensions", "Request Handling") |