summaryrefslogtreecommitdiffstats
path: root/toolkit/components/extensions/Schemas.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/extensions/Schemas.sys.mjs')
-rw-r--r--toolkit/components/extensions/Schemas.sys.mjs3946
1 files changed, 3946 insertions, 0 deletions
diff --git a/toolkit/components/extensions/Schemas.sys.mjs b/toolkit/components/extensions/Schemas.sys.mjs
new file mode 100644
index 0000000000..b8a7320c7c
--- /dev/null
+++ b/toolkit/components/extensions/Schemas.sys.mjs
@@ -0,0 +1,3946 @@
+/* -*- 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<string>} overridableMethods
+ */
+ constructor(params, overridableMethods = CONTEXT_FOR_VALIDATION) {
+ this.params = params;
+
+ if (typeof params.manifestVersion !== "number") {
+ throw new Error(
+ `Unexpected params.manifestVersion value: ${params.manifestVersion}`
+ );
+ }
+
+ this.path = [];
+ this.preprocessors = {
+ localize(value, context) {
+ return value;
+ },
+ };
+ 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<string>} path
+ * The full path from the root entry to this entry.
+ * @param {Entry} parentEntry
+ * The parent entry for the injected entry.
+ */
+class InjectionEntry {
+ constructor(context, entry, parentObj, name, path, parentEntry) {
+ this.context = context;
+ this.entry = entry;
+ this.parentObj = parentObj;
+ this.name = name;
+ this.path = path;
+ this.parentEntry = parentEntry;
+
+ this.injected = null;
+ this.lazyInjected = null;
+ }
+
+ /**
+ * @property {Array<string>} allowedContexts
+ * The list of allowed contexts into which the entry may be
+ * injected.
+ */
+ get allowedContexts() {
+ let { allowedContexts } = this.entry;
+ if (allowedContexts.length) {
+ return allowedContexts;
+ }
+ return this.parentEntry.defaultContexts;
+ }
+
+ /**
+ * @property {boolean} isRevokable
+ * Returns true if this entry may be dynamically injected or
+ * revoked based on its permissions.
+ */
+ get isRevokable() {
+ return (
+ this.entry.permissions &&
+ this.entry.permissions.some(perm =>
+ this.context.isPermissionRevokable(perm)
+ )
+ );
+ }
+
+ /**
+ * @property {boolean} hasPermission
+ * Returns true if the injection context currently has the
+ * appropriate permissions to access this entry.
+ */
+ get hasPermission() {
+ return (
+ !this.entry.permissions ||
+ this.entry.permissions.some(perm => this.context.hasPermission(perm))
+ );
+ }
+
+ /**
+ * @property {boolean} shouldInject
+ * Returns true if this entry should be injected in the given
+ * context, without respect to permissions.
+ */
+ get shouldInject() {
+ return (
+ this.context.matchManifestVersion(this.entry) &&
+ this.context.shouldInject(
+ this.path.join("."),
+ this.name,
+ this.allowedContexts
+ )
+ );
+ }
+
+ /**
+ * Revokes this entry, removing its property from its parent object,
+ * and invalidating its wrappers.
+ */
+ revoke() {
+ if (this.lazyInjected) {
+ this.lazyInjected = false;
+ } else if (this.injected) {
+ if (this.injected.revoke) {
+ this.injected.revoke();
+ }
+
+ try {
+ let unwrapped = ChromeUtils.waiveXrays(this.parentObj);
+ delete unwrapped[this.name];
+ } catch (e) {
+ Cu.reportError(e);
+ }
+
+ let { value } = this.injected.descriptor;
+ if (value) {
+ this.context.revokeChildren(value);
+ }
+
+ this.injected = null;
+ }
+ }
+
+ /**
+ * Returns a property descriptor object for this entry, if it should
+ * be injected, or undefined if it should not.
+ *
+ * @returns {object?}
+ * A property descriptor object, or undefined if the property
+ * should be removed.
+ */
+ getDescriptor() {
+ this.lazyInjected = false;
+
+ if (this.injected) {
+ let path = [...this.path, this.name];
+ throw new Error(
+ `Attempting to re-inject already injected entry: ${path.join(".")}`
+ );
+ }
+
+ if (!this.shouldInject) {
+ return;
+ }
+
+ if (this.isRevokable) {
+ this.context.pendingEntries.add(this);
+ }
+
+ if (!this.hasPermission) {
+ return;
+ }
+
+ this.injected = this.entry.getDescriptor(this.path, this.context);
+ if (!this.injected) {
+ return undefined;
+ }
+
+ return this.injected.descriptor;
+ }
+
+ /**
+ * Injects a lazy property descriptor into the parent object which
+ * checks permissions and eligibility for injection the first time it
+ * is accessed.
+ */
+ lazyInject() {
+ if (this.lazyInjected || this.injected) {
+ let path = [...this.path, this.name];
+ throw new Error(
+ `Attempting to re-lazy-inject already injected entry: ${path.join(".")}`
+ );
+ }
+
+ this.lazyInjected = true;
+ exportLazyProperty(this.parentObj, this.name, () => {
+ if (this.lazyInjected) {
+ return this.getDescriptor();
+ }
+ });
+ }
+
+ /**
+ * Injects or revokes this entry if its current state does not match
+ * the context's current permissions.
+ */
+ permissionsChanged() {
+ if (this.injected) {
+ this.maybeRevoke();
+ } else {
+ this.maybeInject();
+ }
+ }
+
+ maybeInject() {
+ if (!this.injected && !this.lazyInjected) {
+ this.lazyInject();
+ }
+ }
+
+ maybeRevoke() {
+ if (this.injected && !this.hasPermission) {
+ this.revoke();
+ }
+ }
+}
+
+/**
+ * Holds methods that run the actual implementation of the extension APIs. These
+ * methods are only called if the extension API invocation matches the signature
+ * as defined in the schema. Otherwise an error is reported to the context.
+ */
+class InjectionContext extends Context {
+ constructor(params, schemaRoot) {
+ super(params, CONTEXT_FOR_INJECTION);
+
+ this.schemaRoot = schemaRoot;
+
+ this.pendingEntries = new Set();
+ this.children = new DefaultWeakMap(() => new Map());
+
+ this.injectedRoots = new Set();
+
+ if (params.setPermissionsChangedCallback) {
+ params.setPermissionsChangedCallback(this.permissionsChanged.bind(this));
+ }
+ }
+
+ /**
+ * Check whether the API should be injected.
+ *
+ * @abstract
+ * @param {string} namespace The namespace of the API. This may contain dots,
+ * e.g. in the case of "devtools.inspectedWindow".
+ * @param {string} [name] The name of the property in the namespace.
+ * `null` if we are checking whether the namespace should be injected.
+ * @param {Array<string>} allowedContexts A list of additional contexts in which
+ * this API should be available. May include any of:
+ * "main" - The main chrome browser process.
+ * "addon" - An addon process.
+ * "content" - A content process.
+ * @returns {boolean} Whether the API should be injected.
+ */
+ shouldInject(namespace, name, allowedContexts) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Generate the implementation for `namespace`.`name`.
+ *
+ * @abstract
+ * @param {string} namespace The full path to the namespace of the API, minus
+ * the name of the method or property. E.g. "storage.local".
+ * @param {string} name The name of the method, property or event.
+ * @returns {SchemaAPIInterface} The implementation of the API.
+ */
+ getImplementation(namespace, name) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Updates all injection entries which may need to be updated after a
+ * permission change, revoking or re-injecting them as necessary.
+ */
+ permissionsChanged() {
+ for (let entry of this.pendingEntries) {
+ try {
+ entry.permissionsChanged();
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ }
+ }
+
+ /**
+ * Recursively revokes all child injection entries of the given
+ * object.
+ *
+ * @param {object} object
+ * The object for which to invoke children.
+ */
+ revokeChildren(object) {
+ if (!this.children.has(object)) {
+ return;
+ }
+
+ let children = this.children.get(object);
+ for (let [name, entry] of children.entries()) {
+ try {
+ entry.revoke();
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ children.delete(name);
+
+ // When we revoke children for an object, we consider that object
+ // dead. If the entry is ever reified again, a new object is
+ // created, with new child entries.
+ this.pendingEntries.delete(entry);
+ }
+ this.children.delete(object);
+ }
+
+ _getInjectionEntry(entry, dest, name, path, parentEntry) {
+ let injection = new InjectionEntry(
+ this,
+ entry,
+ dest,
+ name,
+ path,
+ parentEntry
+ );
+
+ this.children.get(dest).set(name, injection);
+
+ return injection;
+ }
+
+ /**
+ * Returns the property descriptor for the given entry.
+ *
+ * @param {Entry} entry
+ * The entry instance to return a descriptor for.
+ * @param {object} dest
+ * The object into which this entry is being injected.
+ * @param {string} name
+ * The property name on the destination object where the entry
+ * will be injected.
+ * @param {Array<string>} path
+ * The full path from the root injection object to this entry.
+ * @param {Entry} parentEntry
+ * The parent entry for this entry.
+ *
+ * @returns {object?}
+ * A property descriptor object, or null if the entry should
+ * not be injected.
+ */
+ getDescriptor(entry, dest, name, path, parentEntry) {
+ let injection = this._getInjectionEntry(
+ entry,
+ dest,
+ name,
+ path,
+ parentEntry
+ );
+
+ return injection.getDescriptor();
+ }
+
+ /**
+ * Lazily injects the given entry into the given object.
+ *
+ * @param {Entry} entry
+ * The entry instance to lazily inject.
+ * @param {object} dest
+ * The object into which to inject this entry.
+ * @param {string} name
+ * The property name at which to inject the entry.
+ * @param {Array<string>} path
+ * The full path from the root injection object to this entry.
+ * @param {Entry} parentEntry
+ * The parent entry for this entry.
+ */
+ injectInto(entry, dest, name, path, parentEntry) {
+ let injection = this._getInjectionEntry(
+ entry,
+ dest,
+ name,
+ path,
+ parentEntry
+ );
+
+ injection.lazyInject();
+ }
+}
+
+/**
+ * The methods in this singleton represent the "format" specifier for
+ * JSON Schema string types.
+ *
+ * Each method either returns a normalized version of the original
+ * value, or throws an error if the value is not valid for the given
+ * format.
+ */
+const FORMATS = {
+ hostname(string, context) {
+ // TODO bug 1797376: Despite the name, this format is NOT a "hostname",
+ // but hostname + port and may fail with IPv6. Use canonicalDomain instead.
+ let valid = true;
+
+ try {
+ valid = new URL(`http://${string}`).host === string;
+ } catch (e) {
+ valid = false;
+ }
+
+ if (!valid) {
+ throw new Error(`Invalid hostname ${string}`);
+ }
+
+ return string;
+ },
+
+ canonicalDomain(string, context) {
+ let valid;
+
+ try {
+ valid = new URL(`http://${string}`).hostname === string;
+ } catch (e) {
+ valid = false;
+ }
+
+ if (!valid) {
+ // Require the input to be a canonical domain.
+ // Rejects obvious non-domains such as URLs,
+ // but also catches non-IDN (punycode) domains.
+ throw new Error(`Invalid domain ${string}`);
+ }
+
+ return string;
+ },
+
+ url(string, context) {
+ let url = new URL(string).href;
+
+ if (!context.checkLoadURL(url)) {
+ throw new Error(`Access denied for URL ${url}`);
+ }
+ return url;
+ },
+
+ origin(string, context) {
+ let url;
+ try {
+ url = new URL(string);
+ } catch (e) {
+ throw new Error(`Invalid origin: ${string}`);
+ }
+ if (!/^https?:/.test(url.protocol)) {
+ throw new Error(`Invalid origin must be http or https for URL ${string}`);
+ }
+ // url.origin is punycode so a direct check against string wont work.
+ // url.href appends a slash even if not in the original string, we we
+ // additionally check that string does not end in slash.
+ if (string.endsWith("/") || url.href != new URL(url.origin).href) {
+ throw new Error(
+ `Invalid origin for URL ${string}, replace with origin ${url.origin}`
+ );
+ }
+ if (!context.checkLoadURL(url.origin)) {
+ throw new Error(`Access denied for URL ${url}`);
+ }
+ return url.origin;
+ },
+
+ relativeUrl(string, context) {
+ if (!context.url) {
+ // If there's no context URL, return relative URLs unresolved, and
+ // skip security checks for them.
+ try {
+ new URL(string);
+ } catch (e) {
+ return string;
+ }
+ }
+
+ let url = new URL(string, context.url).href;
+
+ if (!context.checkLoadURL(url)) {
+ throw new Error(`Access denied for URL ${url}`);
+ }
+ return url;
+ },
+
+ strictRelativeUrl(string, context) {
+ void FORMATS.unresolvedRelativeUrl(string, context);
+ return FORMATS.relativeUrl(string, context);
+ },
+
+ unresolvedRelativeUrl(string, context) {
+ if (!string.startsWith("//")) {
+ try {
+ new URL(string);
+ } catch (e) {
+ return string;
+ }
+ }
+
+ throw new SyntaxError(
+ `String ${JSON.stringify(string)} must be a relative URL`
+ );
+ },
+
+ homepageUrl(string, context) {
+ // Pipes are used for separating homepages, but we only allow extensions to
+ // set a single homepage. Encoding any pipes makes it one URL.
+ return FORMATS.relativeUrl(
+ string.replace(new RegExp("\\|", "g"), "%7C"),
+ context
+ );
+ },
+
+ imageDataOrStrictRelativeUrl(string, context) {
+ // Do not accept a string which resolves as an absolute URL, or any
+ // protocol-relative URL, except PNG or JPG data URLs
+ if (
+ !string.startsWith("data:image/png;base64,") &&
+ !string.startsWith("data:image/jpeg;base64,")
+ ) {
+ try {
+ return FORMATS.strictRelativeUrl(string, context);
+ } catch (e) {
+ throw new SyntaxError(
+ `String ${JSON.stringify(
+ string
+ )} must be a relative or PNG or JPG data:image URL`
+ );
+ }
+ }
+ return string;
+ },
+
+ contentSecurityPolicy(string, context) {
+ // Manifest V3 extension_pages allows WASM. When sandbox is
+ // implemented, or any other V3 or later directive, the flags
+ // logic will need to be updated.
+ let flags =
+ context.manifestVersion < 3
+ ? Ci.nsIAddonContentPolicy.CSP_ALLOW_ANY
+ : Ci.nsIAddonContentPolicy.CSP_ALLOW_WASM;
+ let error = lazy.contentPolicyService.validateAddonCSP(string, flags);
+ if (error != null) {
+ // The CSP validation error is not reported as part of the "choices" error message,
+ // we log the CSP validation error explicitly here to make it easier for the addon developers
+ // to see and fix the extension CSP.
+ context.logError(`Error processing ${context.currentTarget}: ${error}`);
+ return null;
+ }
+ return string;
+ },
+
+ date(string, context) {
+ // A valid ISO 8601 timestamp.
+ const PATTERN =
+ /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{3})?(Z|([-+]\d{2}:?\d{2})))?$/;
+ if (!PATTERN.test(string)) {
+ throw new Error(`Invalid date string ${string}`);
+ }
+ // Our pattern just checks the format, we could still have invalid
+ // values (e.g., month=99 or month=02 and day=31). Let the Date
+ // constructor do the dirty work of validating.
+ if (isNaN(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<string>} allowedContexts A list of allowed contexts
+ * to consider before generating the API.
+ * These are not parsed by the schema, but passed to `shouldInject`.
+ */
+ this.allowedContexts = schema.allowedContexts || [];
+
+ this.min_manifest_version =
+ schema.min_manifest_version ?? MIN_MANIFEST_VERSION;
+ this.max_manifest_version =
+ schema.max_manifest_version ?? MAX_MANIFEST_VERSION;
+ }
+
+ /**
+ * Preprocess the given value with the preprocessor declared in
+ * `preprocessor`.
+ *
+ * @param {*} value
+ * @param {Context} context
+ * @returns {*}
+ */
+ preprocess(value, context) {
+ if (this.preprocessor) {
+ return context.preprocessors[this.preprocessor](value, context);
+ }
+ return value;
+ }
+
+ /**
+ * Postprocess the given result with the postprocessor declared in
+ * `postprocessor`.
+ *
+ * @param {object} result
+ * @param {Context} context
+ * @returns {object}
+ */
+ postprocess(result, context) {
+ if (result.error || !this.postprocessor) {
+ return result;
+ }
+
+ let value = context.postprocessors[this.postprocessor](
+ result.value,
+ context
+ );
+ return { value };
+ }
+
+ /**
+ * Logs a deprecation warning for this entry, based on the value of
+ * its `deprecated` property.
+ *
+ * @param {Context} context
+ * @param {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<string>} path The API path, e.g. `["storage", "local"]`.
+ * @param {InjectionContext} context
+ *
+ * @returns {object?}
+ * An object containing a `descriptor` property, specifying the
+ * entry's property descriptor, and an optional `revoke`
+ * method, to be called when the entry is being revoked.
+ */
+ getDescriptor(path, context) {
+ return undefined;
+ }
+}
+
+// Corresponds either to a type declared in the "types" section of the
+// schema or else to any type object used throughout the schema.
+class Type extends Entry {
+ /**
+ * @property {Array<string>} EXTRA_PROPERTIES
+ * An array of extra properties which may be present for
+ * schemas of this type.
+ */
+ static get EXTRA_PROPERTIES() {
+ return [
+ "description",
+ "deprecated",
+ "preprocess",
+ "postprocess",
+ "privileged",
+ "allowedContexts",
+ "min_manifest_version",
+ "max_manifest_version",
+ ];
+ }
+
+ /**
+ * Parses the given schema object and returns an instance of this
+ * class which corresponds to its properties.
+ *
+ * @param {SchemaRoot} root
+ * The root schema for this type.
+ * @param {object} schema
+ * A JSON schema object which corresponds to a definition of
+ * this type.
+ * @param {Array<string>} path
+ * The path to this schema object from the root schema,
+ * corresponding to the property names and array indices
+ * traversed during parsing in order to arrive at this schema
+ * object.
+ * @param {Array<string>} [extraProperties]
+ * An array of extra property names which are valid for this
+ * schema in the current context.
+ * @returns {Type}
+ * An instance of this type which corresponds to the given
+ * schema object.
+ * @static
+ */
+ static parseSchema(root, schema, path, extraProperties = []) {
+ this.checkSchemaProperties(schema, path, extraProperties);
+
+ return new this(schema);
+ }
+
+ /**
+ * Checks that all of the properties present in the given schema
+ * object are valid properties for this type, and throws if invalid.
+ *
+ * @param {object} schema
+ * A JSON schema object.
+ * @param {Array<string>} path
+ * The path to this schema object from the root schema,
+ * corresponding to the property names and array indices
+ * traversed during parsing in order to arrive at this schema
+ * object.
+ * @param {Array<string>} [extra]
+ * An array of extra property names which are valid for this
+ * schema in the current context.
+ * @throws {Error}
+ * An error describing the first invalid property found in the
+ * schema object.
+ */
+ static checkSchemaProperties(schema, path, extra = []) {
+ if (DEBUG) {
+ let allowedSet = new Set([...this.EXTRA_PROPERTIES, ...extra]);
+
+ for (let prop of Object.keys(schema)) {
+ if (!allowedSet.has(prop)) {
+ throw new Error(
+ `Internal error: Namespace ${path.join(".")} has ` +
+ `invalid type property "${prop}" ` +
+ `in type "${schema.id || JSON.stringify(schema)}"`
+ );
+ }
+ }
+ }
+ }
+
+ // Takes a value, checks that it has the correct type, and returns a
+ // "normalized" version of the value. The normalized version will
+ // include "nulls" in place of omitted optional properties. The
+ // result of this function is either {error: "Some type error"} or
+ // {value: <normalized-value>}.
+ normalize(value, context) {
+ return context.error("invalid type");
+ }
+
+ // Unlike normalize, this function does a shallow check to see if
+ // |baseType| (one of the possible getValueBaseType results) is
+ // valid for this type. It returns true or false. It's used to fill
+ // in optional arguments to functions before actually type checking
+
+ checkBaseType(baseType) {
+ return false;
+ }
+
+ // Helper method that simply relies on checkBaseType to implement
+ // normalize. Subclasses can choose to use it or not.
+ normalizeBase(type, value, context) {
+ if (this.checkBaseType(getValueBaseType(value))) {
+ this.checkDeprecated(context, value);
+ return { value: this.preprocess(value, context) };
+ }
+
+ let choice;
+ if ("aeiou".includes(type[0])) {
+ choice = `be an ${type} value`;
+ } else {
+ choice = `be a ${type} value`;
+ }
+
+ return context.error(
+ () => `Expected ${type} instead of ${JSON.stringify(value)}`,
+ choice
+ );
+ }
+}
+
+// Type that allows any value.
+class AnyType extends Type {
+ normalize(value, context) {
+ this.checkDeprecated(context, value);
+ return this.postprocess({ value }, context);
+ }
+
+ checkBaseType(baseType) {
+ return true;
+ }
+}
+
+// An untagged union type.
+class ChoiceType extends Type {
+ static get EXTRA_PROPERTIES() {
+ return ["choices", ...super.EXTRA_PROPERTIES];
+ }
+
+ 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<SchemaRoot>|null} base
+ * A base schema root (or roots) from which to derive, or null.
+ * @param {Map<string, Array|StructuredCloneHolder>} schemaJSON
+ * A map of schema URLs and corresponding JSON blobs from which to
+ * populate this root namespace.
+ */
+export class SchemaRoot extends Namespace {
+ constructor(base, schemaJSON) {
+ super(null, "", []);
+
+ if (Array.isArray(base)) {
+ base = new SchemaRoots(this, base);
+ }
+
+ this.root = this;
+ this.base = base;
+ this.schemaJSON = schemaJSON;
+ }
+
+ *getNamespaces(path) {
+ let name = path.join(".");
+
+ let ns = this.getNamespace(name, false);
+ if (ns) {
+ yield ns;
+ }
+
+ if (this.base) {
+ yield* this.base.getNamespaces(name);
+ }
+ }
+
+ /**
+ * Returns the sub-namespace with the given name. If the given namespace
+ * doesn't already exist, attempts to find it in the base SchemaRoot before
+ * creating a new empty namespace.
+ *
+ * @param {string} name
+ * The namespace to retrieve.
+ * @param {boolean} [create = true]
+ * If true, an empty namespace should be created if one does not
+ * already exist.
+ * @returns {Namespace|null}
+ */
+ getNamespace(name, create = true) {
+ let ns = super.getNamespace(name, false);
+ if (ns) {
+ return ns;
+ }
+
+ ns = this.base && this.base.getNamespace(name, false);
+ if (ns) {
+ return ns;
+ }
+ return create && super.getNamespace(name, create);
+ }
+
+ /**
+ * Like getNamespace, but does not take the base SchemaRoot into account.
+ *
+ * @param {string} name
+ * The namespace to retrieve.
+ * @returns {Namespace}
+ */
+ getOwnNamespace(name) {
+ return super.getNamespace(name);
+ }
+
+ parseSchema(schema, path, extraProperties = []) {
+ let allowedProperties = DEBUG && new Set(extraProperties);
+
+ if ("choices" in schema) {
+ return ChoiceType.parseSchema(this, schema, path, allowedProperties);
+ } else if ("$ref" in schema) {
+ return RefType.parseSchema(this, schema, path, allowedProperties);
+ }
+
+ let type = TYPES[schema.type];
+
+ if (DEBUG) {
+ allowedProperties.add("type");
+
+ if (!("type" in schema)) {
+ throw new Error(`Unexpected value for type: ${JSON.stringify(schema)}`);
+ }
+
+ if (!type) {
+ throw new Error(`Unexpected type ${schema.type}`);
+ }
+ }
+
+ return type.parseSchema(this, schema, path, allowedProperties);
+ }
+
+ parseSchemas() {
+ for (let [key, schema] of this.schemaJSON.entries()) {
+ try {
+ if (typeof schema.deserialize === "function") {
+ schema = schema.deserialize(globalThis, isParentProcess);
+
+ // If we're in the parent process, we need to keep the
+ // StructuredCloneHolder blob around in order to send to future child
+ // processes. If we're in a child, we have no further use for it, so
+ // just store the deserialized schema data in its place.
+ if (!isParentProcess) {
+ this.schemaJSON.set(key, schema);
+ }
+ }
+
+ this.loadSchema(schema);
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ }
+ }
+
+ loadSchema(json) {
+ for (let namespace of json) {
+ this.getOwnNamespace(namespace.namespace).addSchema(namespace);
+ }
+ }
+
+ /**
+ * Checks whether a given object has the necessary permissions to
+ * expose the given namespace.
+ *
+ * @param {string} namespace
+ * The top-level namespace to check permissions for.
+ * @param {object} wrapperFuncs
+ * Wrapper functions for the given context.
+ * @param {Function} wrapperFuncs.hasPermission
+ * A function which, when given a string argument, returns true
+ * if the context has the given permission.
+ * @returns {boolean}
+ * True if the context has permission for the given namespace.
+ */
+ checkPermissions(namespace, wrapperFuncs) {
+ let ns = this.getNamespace(namespace);
+ if (ns && ns.permissions) {
+ return ns.permissions.some(perm => wrapperFuncs.hasPermission(perm));
+ }
+ return true;
+ }
+
+ /**
+ * Inject registered extension APIs into `dest`.
+ *
+ * @param {object} dest The root namespace for the APIs.
+ * This object is usually exposed to extensions as "chrome" or "browser".
+ * @param {object} wrapperFuncs An implementation of the InjectionContext
+ * interface, which runs the actual functionality of the generated API.
+ */
+ inject(dest, wrapperFuncs) {
+ let context = new InjectionContext(wrapperFuncs, this);
+
+ this.injectInto(dest, context);
+ }
+
+ injectInto(dest, context) {
+ // For schema graphs where multiple schema roots have the same base, don't
+ // inject it more than once.
+
+ if (!context.injectedRoots.has(this)) {
+ context.injectedRoots.add(this);
+ if (this.base) {
+ this.base.injectInto(dest, context);
+ }
+ super.injectInto(dest, context);
+ }
+ }
+
+ /**
+ * Normalize `obj` according to the loaded schema for `typeName`.
+ *
+ * @param {object} obj The object to normalize against the schema.
+ * @param {string} typeName The name in the format namespace.propertyname
+ * @param {object} context An implementation of Context. Any validation errors
+ * are reported to the given context.
+ * @returns {object} The normalized object.
+ */
+ normalize(obj, typeName, context) {
+ let [namespaceName, prop] = typeName.split(".");
+ let ns = this.getNamespace(namespaceName);
+ let type = ns.get(prop);
+
+ let result = type.normalize(obj, new Context(context));
+ if (result.error) {
+ return { error: forceString(result.error) };
+ }
+ return result;
+ }
+}
+
+Schemas = {
+ initialized: false,
+
+ REVOKE: Symbol("@@revoke"),
+
+ // Maps a schema URL to the JSON contained in that schema file. This
+ // is useful for sending the JSON across processes.
+ schemaJSON: new Map(),
+
+ // A map of schema JSON which should be available in all content processes.
+ contentSchemaJSON: new Map(),
+
+ // A map of schema JSON which should only be available to extension processes.
+ privilegedSchemaJSON: new Map(),
+
+ _rootSchema: null,
+
+ // A weakmap for the validation Context class instances given an extension
+ // context (keyed by the extensin context instance).
+ // This is used instead of the InjectionContext for webIDL API validation
+ // and normalization (see Schemas.checkParameters).
+ paramsValidationContexts: new DefaultWeakMap(
+ extContext => new Context(extContext)
+ ),
+
+ get rootSchema() {
+ if (!this.initialized) {
+ this.init();
+ }
+ if (!this._rootSchema) {
+ this._rootSchema = new SchemaRoot(null, this.schemaJSON);
+ this._rootSchema.parseSchemas();
+ }
+ return this._rootSchema;
+ },
+
+ getNamespace(name) {
+ return this.rootSchema.getNamespace(name);
+ },
+
+ init() {
+ if (this.initialized) {
+ return;
+ }
+ this.initialized = true;
+
+ if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT) {
+ let addSchemas = schemas => {
+ for (let [key, value] of schemas.entries()) {
+ this.schemaJSON.set(key, value);
+ }
+ };
+
+ if (WebExtensionPolicy.isExtensionProcess || DEBUG) {
+ addSchemas(Services.cpmm.sharedData.get(KEY_PRIVILEGED_SCHEMAS));
+ }
+
+ let schemas = Services.cpmm.sharedData.get(KEY_CONTENT_SCHEMAS);
+ if (schemas) {
+ addSchemas(schemas);
+ }
+ }
+ },
+
+ _loadCachedSchemasPromise: null,
+ loadCachedSchemas() {
+ if (!this._loadCachedSchemasPromise) {
+ this._loadCachedSchemasPromise = lazy.StartupCache.schemas
+ .getAll()
+ .then(results => {
+ return results;
+ });
+ }
+
+ return this._loadCachedSchemasPromise;
+ },
+
+ addSchema(url, schema, content = false) {
+ this.schemaJSON.set(url, schema);
+
+ if (content) {
+ this.contentSchemaJSON.set(url, schema);
+ } else {
+ this.privilegedSchemaJSON.set(url, schema);
+ }
+
+ if (this._rootSchema) {
+ throw new Error("Schema loaded after root schema populated");
+ }
+ },
+
+ updateSharedSchemas() {
+ let { sharedData } = Services.ppmm;
+
+ sharedData.set(KEY_CONTENT_SCHEMAS, this.contentSchemaJSON);
+ sharedData.set(KEY_PRIVILEGED_SCHEMAS, this.privilegedSchemaJSON);
+ },
+
+ fetch(url) {
+ return readJSONAndBlobbify(url);
+ },
+
+ processSchema(json) {
+ return blobbify(json);
+ },
+
+ async load(url, content = false) {
+ if (!isParentProcess) {
+ return;
+ }
+
+ const startTime = Cu.now();
+ let schemaCache = await this.loadCachedSchemas();
+ const fromCache = schemaCache.has(url);
+
+ let blob =
+ schemaCache.get(url) ||
+ (await lazy.StartupCache.schemas.get(url, readJSONAndBlobbify));
+
+ if (!this.schemaJSON.has(url)) {
+ this.addSchema(url, blob, content);
+ }
+
+ ChromeUtils.addProfilerMarker(
+ "ExtensionSchemas",
+ { startTime },
+ `load ${url}, from cache: ${fromCache}`
+ );
+ },
+
+ /**
+ * Checks whether a given object has the necessary permissions to
+ * expose the given namespace.
+ *
+ * @param {string} namespace
+ * The top-level namespace to check permissions for.
+ * @param {object} wrapperFuncs
+ * Wrapper functions for the given context.
+ * @param {Function} wrapperFuncs.hasPermission
+ * A function which, when given a string argument, returns true
+ * if the context has the given permission.
+ * @returns {boolean}
+ * True if the context has permission for the given namespace.
+ */
+ checkPermissions(namespace, wrapperFuncs) {
+ return this.rootSchema.checkPermissions(namespace, wrapperFuncs);
+ },
+
+ /**
+ * Returns a sorted array of permission names for the given permission types.
+ *
+ * @param {Array} types An array of permission types, defaults to all permissions.
+ * @returns {Array} sorted array of permission names
+ */
+ getPermissionNames(
+ types = [
+ "Permission",
+ "OptionalPermission",
+ "PermissionNoPrompt",
+ "OptionalPermissionNoPrompt",
+ "PermissionPrivileged",
+ ]
+ ) {
+ const ns = this.getNamespace("manifest");
+ let names = [];
+ for (let typeName of types) {
+ for (let choice of ns
+ .get(typeName)
+ .choices.filter(choice => choice.enumeration)) {
+ names = names.concat(choice.enumeration);
+ }
+ }
+ return names.sort();
+ },
+
+ exportLazyGetter,
+
+ /**
+ * Inject registered extension APIs into `dest`.
+ *
+ * @param {object} dest The root namespace for the APIs.
+ * This object is usually exposed to extensions as "chrome" or "browser".
+ * @param {object} wrapperFuncs An implementation of the InjectionContext
+ * interface, which runs the actual functionality of the generated API.
+ */
+ inject(dest, wrapperFuncs) {
+ this.rootSchema.inject(dest, wrapperFuncs);
+ },
+
+ /**
+ * Normalize `obj` according to the loaded schema for `typeName`.
+ *
+ * @param {object} obj The object to normalize against the schema.
+ * @param {string} typeName The name in the format namespace.propertyname
+ * @param {object} context An implementation of Context. Any validation errors
+ * are reported to the given context.
+ * @returns {object} The normalized object.
+ */
+ normalize(obj, typeName, context) {
+ return this.rootSchema.normalize(obj, typeName, context);
+ },
+
+ /**
+ * Validate and normalize the arguments for an API request originated
+ * from the webIDL API bindings.
+ *
+ * This provides for calls originating through WebIDL the parameters
+ * validation and normalization guarantees that the ext-APINAMESPACE.js
+ * scripts expects (what InjectionContext does for the regular bindings).
+ *
+ * @param {object} extContext
+ * @param {mozIExtensionAPIRequest } apiRequest
+ *
+ * @returns {Array<any>} Normalized arguments array.
+ */
+ checkWebIDLRequestParameters(extContext, apiRequest) {
+ const getSchemaForProperty = (schemaObj, propName, schemaPath) => {
+ if (schemaObj instanceof Namespace) {
+ return schemaObj?.get(propName);
+ } else if (schemaObj instanceof SubModuleProperty) {
+ for (const fun of schemaObj.targetType.functions) {
+ if (fun.name === propName) {
+ return fun;
+ }
+ }
+
+ for (const fun of schemaObj.targetType.events) {
+ if (fun.name === propName) {
+ return fun;
+ }
+ }
+ } else if (schemaObj instanceof Event) {
+ return schemaObj;
+ }
+
+ const schemaPathType = schemaObj?.constructor.name;
+ throw new Error(
+ `API Schema for "${propName}" not found in ${schemaPath} (${schemaPath} type is ${schemaPathType})`
+ );
+ };
+ const { requestType, apiNamespace, apiName } = apiRequest;
+
+ let [ns, ...rest] = (
+ ["addListener", "removeListener"].includes(requestType)
+ ? `${apiNamespace}.${apiName}.${requestType}`
+ : `${apiNamespace}.${apiName}`
+ ).split(".");
+ let apiSchema = this.getNamespace(ns);
+
+ // Keep track of the current schema path, populated while navigating the nested API schema
+ // data and then used to include the full path to the API schema that is hitting unexpected
+ // errors due to schema data not found or an unexpected schema type.
+ let schemaPath = [ns];
+
+ while (rest.length) {
+ // Nested property as namespace (e.g. used for proxy.settings requests).
+ if (!apiSchema) {
+ throw new Error(`API Schema not found for ${schemaPath.join(".")}`);
+ }
+
+ let [propName, ...newRest] = rest;
+ rest = newRest;
+
+ apiSchema = getSchemaForProperty(
+ apiSchema,
+ propName,
+ schemaPath.join(".")
+ );
+ schemaPath.push(propName);
+ }
+
+ if (!apiSchema) {
+ throw new Error(`API Schema not found for ${schemaPath.join(".")}`);
+ }
+
+ if (!apiSchema.checkParameters) {
+ throw new Error(
+ `Unexpected API Schema type for ${schemaPath.join(
+ "."
+ )} (${schemaPath.join(".")} type is ${apiSchema.constructor.name})`
+ );
+ }
+
+ return apiSchema.checkParameters(
+ apiRequest.args,
+ this.paramsValidationContexts.get(extContext)
+ );
+ },
+};