/* -*- 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", ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs", }); ChromeUtils.defineModuleGetter( lazy, "NetUtil", "resource://gre/modules/NetUtil.jsm" ); XPCOMUtils.defineLazyServiceGetter( lazy, "contentPolicyService", "@mozilla.org/addons/content-policy;1", "nsIAddonContentPolicy" ); XPCOMUtils.defineLazyGetter( lazy, "StartupCache", () => lazy.ExtensionParent.StartupCache ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "treatWarningsAsErrors", "extensions.webextensions.warnings-as-errors", false ); export let Schemas; 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} 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; }, }; this.postprocessors = POSTPROCESSORS; this.isChromeCompat = false; this.currentChoices = new Set(); this.choicePathIndex = 0; for (let method of overridableMethods) { if (method in params) { this[method] = params[method].bind(params); } } let props = ["isChromeCompat", "manifestVersion", "preprocessors"]; for (let prop of props) { if (prop in params) { if (prop in this && typeof this[prop] == "object") { Object.assign(this[prop], params[prop]); } else { this[prop] = params[prop]; } } } } 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} 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} 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} 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} path * The full path from the root injection object to this entry. * @param {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} 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(new Date(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} 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 {value} [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 {value} [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} 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} 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} 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} [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} 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} [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: }. 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]; } 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]; } 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 { 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 { 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. 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); } for (let type of Object.keys(LOADERS)) { this[type] = 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(); } *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|null} base * A base schema root (or roots) from which to derive, or null. * @param {Map} 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; } } 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} 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) ); }, };