summaryrefslogtreecommitdiffstats
path: root/toolkit/components/webextensions/Schemas.jsm
diff options
context:
space:
mode:
authorMatt A. Tobin <email@mattatobin.com>2018-02-10 02:51:36 -0500
committerMatt A. Tobin <email@mattatobin.com>2018-02-10 02:51:36 -0500
commit37d5300335d81cecbecc99812747a657588c63eb (patch)
tree765efa3b6a56bb715d9813a8697473e120436278 /toolkit/components/webextensions/Schemas.jsm
parentb2bdac20c02b12f2057b9ef70b0a946113a00e00 (diff)
parent4fb11cd5966461bccc3ed1599b808237be6b0de9 (diff)
downloadUXP-37d5300335d81cecbecc99812747a657588c63eb.tar
UXP-37d5300335d81cecbecc99812747a657588c63eb.tar.gz
UXP-37d5300335d81cecbecc99812747a657588c63eb.tar.lz
UXP-37d5300335d81cecbecc99812747a657588c63eb.tar.xz
UXP-37d5300335d81cecbecc99812747a657588c63eb.zip
Merge branch 'ext-work'
Diffstat (limited to 'toolkit/components/webextensions/Schemas.jsm')
-rw-r--r--toolkit/components/webextensions/Schemas.jsm2143
1 files changed, 2143 insertions, 0 deletions
diff --git a/toolkit/components/webextensions/Schemas.jsm b/toolkit/components/webextensions/Schemas.jsm
new file mode 100644
index 000000000..159211c79
--- /dev/null
+++ b/toolkit/components/webextensions/Schemas.jsm
@@ -0,0 +1,2143 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const Ci = Components.interfaces;
+const Cc = Components.classes;
+const Cu = Components.utils;
+const Cr = Components.results;
+
+const global = this;
+
+Cu.importGlobalProperties(["URL"]);
+
+Cu.import("resource://gre/modules/NetUtil.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+Cu.import("resource://gre/modules/ExtensionUtils.jsm");
+var {
+ DefaultMap,
+ instanceOf,
+} = ExtensionUtils;
+
+class DeepMap extends DefaultMap {
+ constructor() {
+ super(() => new DeepMap());
+ }
+
+ getPath(...keys) {
+ return keys.reduce((map, prop) => map.get(prop), this);
+ }
+}
+
+XPCOMUtils.defineLazyServiceGetter(this, "contentPolicyService",
+ "@mozilla.org/addons/content-policy;1",
+ "nsIAddonContentPolicy");
+
+this.EXPORTED_SYMBOLS = ["Schemas"];
+
+/* globals Schemas, URL */
+
+function readJSON(url) {
+ return new Promise((resolve, reject) => {
+ 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 = 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);
+ }
+ });
+ });
+}
+
+/**
+ * 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 = Cu.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),
+ });
+}
+
+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");
+ },
+};
+
+// 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 t = typeof(value);
+ if (t == "object") {
+ if (value === null) {
+ return "null";
+ } else if (Array.isArray(value)) {
+ return "array";
+ } else if (Object.prototype.toString.call(value) == "[object ArrayBuffer]") {
+ return "binary";
+ }
+ } else if (t == "number") {
+ if (value % 1 == 0) {
+ return "integer";
+ }
+ }
+ return t;
+}
+
+// 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,
+ "shouldInject",
+ "getImplementation",
+];
+
+/**
+ * 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;
+
+ 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 = ["preprocessors", "isChromeCompat"];
+ 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;
+ }
+
+ 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.checkLoadURIStrWithPrincipal(this.principal, 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;
+ }
+
+ /**
+ * 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} errorMessage
+ * The error message which will be displayed when this is the
+ * only possible matching schema.
+ * @param {string} 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.
+ * @returns {object}
+ */
+ error(errorMessage, choicesMessage = undefined) {
+ if (choicesMessage !== null) {
+ let {choicePath} = this;
+ if (choicePath) {
+ choicesMessage = `.${choicePath} must ${choicesMessage}`;
+ }
+
+ this.currentChoices.add(choicesMessage);
+ }
+
+ if (this.currentTarget) {
+ return {error: `Error processing ${this.currentTarget}: ${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
+ * @returns {Error}
+ */
+ makeError(message) {
+ let {error} = this.error(message);
+ 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) {
+ Cu.reportError(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: Array.from(choices)};
+ } finally {
+ this.currentChoices = currentChoices;
+ this.choicePathIndex = choicePathIndex;
+
+ choices = Array.from(choices);
+ if (choices.length == 1) {
+ currentChoices.add(choices[0]);
+ } else if (choices.length) {
+ let n = choices.length - 1;
+ choices[n] = `or ${choices[n]}`;
+
+ this.error(null, `must either [${choices.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();
+ }
+ }
+}
+
+/**
+ * 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) {
+ super(params, CONTEXT_FOR_INJECTION);
+ }
+
+ /**
+ * 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");
+ }
+}
+
+/**
+ * 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 = {
+ url(string, context) {
+ let url = new URL(string).href;
+
+ if (!context.checkLoadURL(url)) {
+ throw new Error(`Access denied for URL ${url}`);
+ }
+ return url;
+ },
+
+ 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) {
+ // Do not accept a string which resolves as an absolute URL, or any
+ // protocol-relative URL.
+ if (!string.startsWith("//")) {
+ try {
+ new URL(string);
+ } catch (e) {
+ return FORMATS.relativeUrl(string, context);
+ }
+ }
+
+ throw new SyntaxError(`String ${JSON.stringify(string)} must be a relative URL`);
+ },
+
+ contentSecurityPolicy(string, context) {
+ let error = contentPolicyService.validateAddonCSP(string);
+ if (error != null) {
+ throw new SyntaxError(error);
+ }
+ 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;
+ },
+};
+
+// 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 || [];
+ }
+
+ /**
+ * 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.logError(context.makeError(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);
+ }
+ }
+
+ /**
+ * Injects JS values for the entry into the extension API
+ * namespace. The default implementation is to do nothing.
+ * `context` is used to call the actual implementation
+ * of a given function or event.
+ *
+ * @param {Array<string>} path The API path, e.g. `["storage", "local"]`.
+ * @param {string} name The method name, e.g. "get".
+ * @param {object} dest The object where `path`.`name` should be stored.
+ * @param {InjectionContext} context
+ */
+ inject(path, name, dest, context) {
+ }
+}
+
+// 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", "allowedContexts"];
+ }
+
+ /**
+ * Parses the given schema object and returns an instance of this
+ * class which corresponds to its properties.
+ *
+ * @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(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 = []) {
+ 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]/.test(type)) {
+ 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(schema, path, extraProperties = []) {
+ this.checkSchemaProperties(schema, path, extraProperties);
+
+ let choices = schema.choices.map(t => Schemas.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) {
+ let r = choice.normalize(value, context);
+ if (!r.error) {
+ return r;
+ }
+
+ error = r;
+ }
+ });
+
+ if (result) {
+ return result;
+ }
+ if (choices.length <= 1) {
+ return error;
+ }
+
+ let n = choices.length - 1;
+ choices[n] = `or ${choices[n]}`;
+
+ let message = `Value must either: ${choices.join(", ")}`;
+
+ return context.error(message, null);
+ }
+
+ checkBaseType(baseType) {
+ return this.choices.some(t => t.checkBaseType(baseType));
+ }
+}
+
+// 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(schema, path, extraProperties = []) {
+ this.checkSchemaProperties(schema, path, extraProperties);
+
+ let ref = schema.$ref;
+ let ns = path[0];
+ if (ref.includes(".")) {
+ [ns, ref] = ref.split(".");
+ }
+ return new this(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(schema, namespaceName, reference) {
+ super(schema);
+ this.namespaceName = namespaceName;
+ this.reference = reference;
+ }
+
+ get targetType() {
+ let ns = Schemas.namespaces.get(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(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, enumeration,
+ schema.minLength || 0,
+ schema.maxLength || Infinity,
+ pattern,
+ format);
+ }
+
+ constructor(schema, enumeration, minLength, maxLength, pattern, format) {
+ super(schema);
+ 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";
+ }
+
+ inject(path, name, dest, context) {
+ if (this.enumeration) {
+ exportLazyGetter(dest, name, () => {
+ let obj = Cu.createObjectIn(dest);
+ for (let e of this.enumeration) {
+ obj[e.toUpperCase()] = e;
+ }
+ return obj;
+ });
+ }
+ }
+}
+
+let SubModuleType;
+class ObjectType extends Type {
+ static get EXTRA_PROPERTIES() {
+ return ["properties", "patternProperties", ...super.EXTRA_PROPERTIES];
+ }
+
+ static parseSchema(schema, path, extraProperties = []) {
+ if ("functions" in schema) {
+ return SubModuleType.parseSchema(schema, path, extraProperties);
+ }
+
+ if (!("$extend" in schema)) {
+ // Only allow extending "properties" and "patternProperties".
+ extraProperties = ["additionalProperties", "isInstanceOf", ...extraProperties];
+ }
+ this.checkSchemaProperties(schema, path, extraProperties);
+
+ let parseProperty = (schema, extraProps = []) => {
+ return {
+ type: Schemas.parseSchema(schema, path,
+ ["unsupported", "onError", "permissions", ...extraProps]),
+ optional: schema.optional || false,
+ unsupported: schema.unsupported || false,
+ onError: schema.onError || null,
+ };
+ };
+
+ // 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 = Schemas.parseSchema(type, path);
+ }
+
+ return new this(schema, properties, additionalProperties, patternProperties, schema.isInstanceOf || null);
+ }
+
+ constructor(schema, properties, additionalProperties, patternProperties, isInstanceOf) {
+ super(schema);
+ this.properties = properties;
+ this.additionalProperties = additionalProperties;
+ this.patternProperties = patternProperties;
+ this.isInstanceOf = isInstanceOf;
+ }
+
+ 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 = Cu.getClassName(value, true);
+ if (klass != "Object") {
+ throw context.error(`Expected a plain JavaScript object, got a ${klass}`,
+ `be a plain JavaScript object`);
+ }
+
+ let properties = Object.create(null);
+
+ let waived = Cu.waiveXrays(value);
+ for (let prop of Object.getOwnPropertyNames(waived)) {
+ let desc = Object.getOwnPropertyDescriptor(waived, prop);
+ if (desc.get || desc.set) {
+ throw context.error("Objects cannot have getters or setters on properties",
+ "contain no getter or setter properties");
+ }
+ // Chrome ignores non-enumerable properties.
+ if (desc.enumerable) {
+ properties[prop] = Cu.unwaiveXrays(desc.value);
+ }
+ }
+
+ return properties;
+ }
+
+ checkProperty(context, prop, propType, result, properties, remainingProps) {
+ let {type, optional, unsupported, onError} = propType;
+ let error = null;
+
+ 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] = null;
+ } 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] = null;
+ }
+
+ if (error) {
+ if (onError == "warn") {
+ context.logError(error.error);
+ } else if (onError != "ignore") {
+ throw error;
+ }
+
+ result[prop] = null;
+ }
+ }
+
+ normalize(value, context) {
+ try {
+ let v = this.normalizeBase("object", value, context);
+ if (v.error) {
+ return v;
+ }
+ value = v.value;
+
+ if (this.isInstanceOf) {
+ 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 (!instanceOf(value, this.isInstanceOf)) {
+ 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 type = this.additionalProperties;
+ let r = context.withPath(prop, () => type.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(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.map(fun => Schemas.parseFunction(path, fun));
+
+ return new this(functions);
+ }
+
+ constructor(functions) {
+ super();
+ this.functions = functions;
+ }
+};
+
+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(schema, path, extraProperties = []) {
+ this.checkSchemaProperties(schema, path, extraProperties);
+
+ return new this(schema, schema.minimum || -Infinity, schema.maximum || Infinity);
+ }
+
+ 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 {
+ normalize(value, context) {
+ return this.normalizeBase("boolean", value, context);
+ }
+
+ checkBaseType(baseType) {
+ return baseType == "boolean";
+ }
+}
+
+class ArrayType extends Type {
+ static get EXTRA_PROPERTIES() {
+ return ["items", "minItems", "maxItems", ...super.EXTRA_PROPERTIES];
+ }
+
+ static parseSchema(schema, path, extraProperties = []) {
+ this.checkSchemaProperties(schema, path, extraProperties);
+
+ let items = Schemas.parseSchema(schema.items, path);
+
+ 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;
+ }
+
+ 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) {
+ return element;
+ }
+ 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", ...super.EXTRA_PROPERTIES];
+ }
+
+ static parseSchema(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: Schemas.parseSchema(param, path, ["name", "optional", "default"]),
+ name: param.name,
+ optional: param.optional == null ? isCallback : param.optional,
+ default: param.default == undefined ? null : param.default,
+ });
+ }
+ }
+ if (isExpectingCallback) {
+ throw new Error(`Internal error: Expected a callback parameter with name ${schema.async}`);
+ }
+
+ let hasAsyncCallback = false;
+ if (isAsync) {
+ hasAsyncCallback = (parameters &&
+ parameters.length &&
+ parameters[parameters.length - 1].name == schema.async);
+
+ if (schema.returns) {
+ throw new Error("Internal error: Async functions must not have return values.");
+ }
+ if (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);
+ }
+
+ constructor(schema, parameters, isAsync, hasAsyncCallback) {
+ super(schema);
+ this.parameters = parameters;
+ this.isAsync = isAsync;
+ this.hasAsyncCallback = hasAsyncCallback;
+ }
+
+ 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;
+ }
+
+ inject(path, name, dest, context) {
+ dest[name] = this.value;
+ }
+}
+
+// Represents a "property" defined in a schema namespace that is not a
+// constant.
+class TypeProperty extends Entry {
+ constructor(schema, namespaceName, name, type, writable) {
+ super(schema);
+ this.namespaceName = namespaceName;
+ this.name = name;
+ this.type = type;
+ this.writable = writable;
+ }
+
+ throwError(context, msg) {
+ throw context.makeError(`${msg} for ${this.namespaceName}.${this.name}.`);
+ }
+
+ inject(path, name, dest, context) {
+ if (this.unsupported) {
+ return;
+ }
+
+ let apiImpl = context.getImplementation(path.join("."), name);
+
+ let getStub = () => {
+ this.checkDeprecated(context);
+ return apiImpl.getProperty();
+ };
+
+ let desc = {
+ configurable: false,
+ enumerable: true,
+
+ get: Cu.exportFunction(getStub, dest),
+ };
+
+ if (this.writable) {
+ let setStub = (value) => {
+ let normalized = this.type.normalize(value, context);
+ if (normalized.error) {
+ this.throwError(context, normalized.error);
+ }
+
+ apiImpl.setProperty(normalized.value);
+ };
+
+ desc.set = Cu.exportFunction(setStub, dest);
+ }
+
+ Object.defineProperty(dest, name, desc);
+ }
+}
+
+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(schema, name, namespaceName, reference, properties) {
+ super(schema);
+ this.name = name;
+ this.namespaceName = namespaceName;
+ this.reference = reference;
+ this.properties = properties;
+ }
+
+ inject(path, name, dest, context) {
+ exportLazyGetter(dest, name, () => {
+ let obj = Cu.createObjectIn(dest);
+
+ let ns = Schemas.namespaces.get(this.namespaceName);
+ let type = ns.get(this.reference);
+ if (!type && this.reference.includes(".")) {
+ let [namespaceName, ref] = this.reference.split(".");
+ ns = Schemas.namespaces.get(namespaceName);
+ type = ns.get(ref);
+ }
+ if (!type || !(type instanceof SubModuleType)) {
+ throw new Error(`Internal error: ${this.namespaceName}.${this.reference} is not a sub-module`);
+ }
+
+ let functions = type.functions;
+ for (let fun of functions) {
+ let subpath = path.concat(name);
+ let namespace = subpath.join(".");
+ let allowedContexts = fun.allowedContexts.length ? fun.allowedContexts : ns.defaultContexts;
+ if (context.shouldInject(namespace, fun.name, allowedContexts)) {
+ fun.inject(subpath, fun.name, obj, context);
+ }
+ }
+
+ // TODO: Inject this.properties.
+
+ return obj;
+ });
+ }
+}
+
+// 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, global);
+ } 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} (${r.error})`);
+ }
+ return r.value;
+ });
+
+ return fixedArgs;
+ }
+}
+
+// Represents a "function" defined in a schema namespace.
+class FunctionEntry extends CallEntry {
+ constructor(schema, path, name, type, unsupported, allowAmbiguousOptionalArguments, returns, permissions) {
+ super(schema, path, name, type.parameters, allowAmbiguousOptionalArguments);
+ this.unsupported = unsupported;
+ this.returns = returns;
+ this.permissions = permissions;
+
+ this.isAsync = type.isAsync;
+ this.hasAsyncCallback = type.hasAsyncCallback;
+ }
+
+ inject(path, name, dest, context) {
+ if (this.unsupported) {
+ return;
+ }
+
+ if (this.permissions && !this.permissions.some(perm => context.hasPermission(perm))) {
+ return;
+ }
+
+ exportLazyGetter(dest, name, () => {
+ let apiImpl = context.getImplementation(path.join("."), 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 = () => {};
+ }
+ return apiImpl.callAsyncFunction(actuals, callback);
+ };
+ } 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);
+ return apiImpl.callFunction(actuals);
+ };
+ }
+ return Cu.exportFunction(stub, dest);
+ });
+ }
+}
+
+// Represents an "event" defined in a schema namespace.
+class Event extends CallEntry {
+ 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;
+ }
+
+ inject(path, name, dest, context) {
+ if (this.unsupported) {
+ return;
+ }
+
+ if (this.permissions && !this.permissions.some(perm => context.hasPermission(perm))) {
+ return;
+ }
+
+ exportLazyGetter(dest, name, () => {
+ let apiImpl = context.getImplementation(path.join("."), 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(dest);
+
+ Cu.exportFunction(addStub, obj, {defineAs: "addListener"});
+ Cu.exportFunction(removeStub, obj, {defineAs: "removeListener"});
+ Cu.exportFunction(hasStub, obj, {defineAs: "hasListener"});
+
+ return obj;
+ });
+ }
+}
+
+const TYPES = Object.freeze(Object.assign(Object.create(null), {
+ any: AnyType,
+ array: ArrayType,
+ boolean: BooleanType,
+ function: FunctionType,
+ integer: IntegerType,
+ number: NumberType,
+ object: ObjectType,
+ string: StringType,
+}));
+
+this.Schemas = {
+ initialized: false,
+
+ // Maps a schema URL to the JSON contained in that schema file. This
+ // is useful for sending the JSON across processes.
+ schemaJSON: new Map(),
+
+ // Map[<schema-name> -> Map[<symbol-name> -> Entry]]
+ // This keeps track of all the schemas that have been loaded so far.
+ namespaces: new Map(),
+
+ register(namespaceName, symbol, value) {
+ let ns = this.namespaces.get(namespaceName);
+ if (!ns) {
+ ns = new Map();
+ ns.name = namespaceName;
+ ns.permissions = null;
+ ns.allowedContexts = [];
+ ns.defaultContexts = [];
+ this.namespaces.set(namespaceName, ns);
+ }
+ ns.set(symbol, value);
+ },
+
+ parseSchema(schema, path, extraProperties = []) {
+ let allowedProperties = new Set(extraProperties);
+
+ if ("choices" in schema) {
+ return ChoiceType.parseSchema(schema, path, allowedProperties);
+ } else if ("$ref" in schema) {
+ return RefType.parseSchema(schema, path, allowedProperties);
+ }
+
+ if (!("type" in schema)) {
+ throw new Error(`Unexpected value for type: ${JSON.stringify(schema)}`);
+ }
+
+ allowedProperties.add("type");
+
+ let type = TYPES[schema.type];
+ if (!type) {
+ throw new Error(`Unexpected type ${schema.type}`);
+ }
+ return type.parseSchema(schema, path, allowedProperties);
+ },
+
+ parseFunction(path, fun) {
+ let f = new FunctionEntry(fun, path, fun.name,
+ this.parseSchema(fun, path,
+ ["name", "unsupported", "returns",
+ "permissions",
+ "allowAmbiguousOptionalArguments"]),
+ fun.unsupported || false,
+ fun.allowAmbiguousOptionalArguments || false,
+ fun.returns || null,
+ fun.permissions || null);
+ return f;
+ },
+
+ loadType(namespaceName, type) {
+ if ("$extend" in type) {
+ this.extendType(namespaceName, type);
+ } else {
+ this.register(namespaceName, type.id, this.parseSchema(type, [namespaceName], ["id"]));
+ }
+ },
+
+ extendType(namespaceName, type) {
+ let ns = Schemas.namespaces.get(namespaceName);
+ let targetType = ns && ns.get(type.$extend);
+
+ // Only allow extending object and choices types for now.
+ if (targetType instanceof ObjectType) {
+ type.type = "object";
+ } else if (!targetType) {
+ throw new Error(`Internal error: Attempt to extend a nonexistant 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.parseSchema(type, [namespaceName], ["$extend"]);
+ if (parsed.constructor !== targetType.constructor) {
+ throw new Error(`Internal error: Bad attempt to extend ${type.$extend}`);
+ }
+
+ targetType.extend(parsed);
+ },
+
+ loadProperty(namespaceName, name, prop) {
+ if ("$ref" in prop) {
+ if (!prop.unsupported) {
+ this.register(namespaceName, name, new SubModuleProperty(prop, name, namespaceName, prop.$ref,
+ prop.properties || {}));
+ }
+ } else if ("value" in prop) {
+ this.register(namespaceName, name, 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.parseSchema(prop, [namespaceName], ["optional", "writable"]);
+ this.register(namespaceName, name, new TypeProperty(prop, namespaceName, name, type, prop.writable || false));
+ }
+ },
+
+ loadFunction(namespaceName, fun) {
+ let f = this.parseFunction([namespaceName], fun);
+ this.register(namespaceName, fun.name, f);
+ },
+
+ loadEvent(namespaceName, event) {
+ let extras = event.extraParameters || [];
+ extras = extras.map(param => {
+ return {
+ type: this.parseSchema(param, [namespaceName], ["name", "optional", "default"]),
+ name: param.name,
+ optional: param.optional || false,
+ default: param.default == undefined ? null : param.default,
+ };
+ });
+
+ // We ignore these properties for now.
+ /* eslint-disable no-unused-vars */
+ let returns = event.returns;
+ let filters = event.filters;
+ /* eslint-enable no-unused-vars */
+
+ let type = this.parseSchema(event, [namespaceName],
+ ["name", "unsupported", "permissions",
+ "extraParameters", "returns", "filters"]);
+
+ let e = new Event(event, [namespaceName], event.name, type, extras,
+ event.unsupported || false,
+ event.permissions || null);
+ this.register(namespaceName, event.name, e);
+ },
+
+ init() {
+ if (this.initialized) {
+ return;
+ }
+ this.initialized = true;
+
+ if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT) {
+ let data = Services.cpmm.initialProcessData;
+ let schemas = data["Extension:Schemas"];
+ if (schemas) {
+ this.schemaJSON = schemas;
+ }
+ Services.cpmm.addMessageListener("Schema:Add", this);
+ }
+
+ this.flushSchemas();
+ },
+
+ receiveMessage(msg) {
+ switch (msg.name) {
+ case "Schema:Add":
+ this.schemaJSON.set(msg.data.url, msg.data.schema);
+ this.flushSchemas();
+ break;
+
+ case "Schema:Delete":
+ this.schemaJSON.delete(msg.data.url);
+ this.flushSchemas();
+ break;
+ }
+ },
+
+ flushSchemas() {
+ XPCOMUtils.defineLazyGetter(this, "namespaces",
+ () => this.parseSchemas());
+ },
+
+ parseSchemas() {
+ Object.defineProperty(this, "namespaces", {
+ enumerable: true,
+ configurable: true,
+ value: new Map(),
+ });
+
+ for (let json of this.schemaJSON.values()) {
+ try {
+ this.loadSchema(json);
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ }
+
+ return this.namespaces;
+ },
+
+ loadSchema(json) {
+ for (let namespace of json) {
+ let name = namespace.namespace;
+
+ let types = namespace.types || [];
+ for (let type of types) {
+ this.loadType(name, type);
+ }
+
+ let properties = namespace.properties || {};
+ for (let propertyName of Object.keys(properties)) {
+ this.loadProperty(name, propertyName, properties[propertyName]);
+ }
+
+ let functions = namespace.functions || [];
+ for (let fun of functions) {
+ this.loadFunction(name, fun);
+ }
+
+ let events = namespace.events || [];
+ for (let event of events) {
+ this.loadEvent(name, event);
+ }
+
+ let ns = this.namespaces.get(name);
+ ns.permissions = namespace.permissions || null;
+ ns.allowedContexts = namespace.allowedContexts || [];
+ ns.defaultContexts = namespace.defaultContexts || [];
+ }
+ },
+
+ load(url) {
+ if (Services.appinfo.processType != Services.appinfo.PROCESS_TYPE_CONTENT) {
+ return readJSON(url).then(json => {
+ this.schemaJSON.set(url, json);
+
+ let data = Services.ppmm.initialProcessData;
+ data["Extension:Schemas"] = this.schemaJSON;
+
+ Services.ppmm.broadcastAsyncMessage("Schema:Add", {url, schema: json});
+
+ this.flushSchemas();
+ });
+ }
+ },
+
+ unload(url) {
+ this.schemaJSON.delete(url);
+
+ let data = Services.ppmm.initialProcessData;
+ data["Extension:Schemas"] = this.schemaJSON;
+
+ Services.ppmm.broadcastAsyncMessage("Schema:Delete", {url});
+
+ this.flushSchemas();
+ },
+
+ /**
+ * 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.namespaces.get(namespace);
+ if (ns && ns.permissions) {
+ return ns.permissions.some(perm => wrapperFuncs.hasPermission(perm));
+ }
+ return true;
+ },
+
+ 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) {
+ let context = new InjectionContext(wrapperFuncs);
+
+ let createNamespace = ns => {
+ let obj = Cu.createObjectIn(dest);
+
+ for (let [name, entry] of ns) {
+ let allowedContexts = entry.allowedContexts;
+ if (!allowedContexts.length) {
+ allowedContexts = ns.defaultContexts;
+ }
+
+ if (context.shouldInject(ns.name, name, allowedContexts)) {
+ entry.inject([ns.name], name, obj, context);
+ }
+ }
+
+ // Remove the namespace object if it is empty
+ if (Object.keys(obj).length) {
+ return obj;
+ }
+ };
+
+ let createNestedNamespaces = (parent, namespaces) => {
+ for (let [prop, namespace] of namespaces) {
+ if (namespace instanceof DeepMap) {
+ exportLazyGetter(parent, prop, () => {
+ let obj = Cu.createObjectIn(parent);
+ createNestedNamespaces(obj, namespace);
+ return obj;
+ });
+ } else {
+ exportLazyGetter(parent, prop,
+ () => createNamespace(namespace));
+ }
+ }
+ };
+
+ let nestedNamespaces = new DeepMap();
+ for (let ns of this.namespaces.values()) {
+ if (ns.permissions && !ns.permissions.some(perm => context.hasPermission(perm))) {
+ continue;
+ }
+
+ if (!wrapperFuncs.shouldInject(ns.name, null, ns.allowedContexts)) {
+ continue;
+ }
+
+ if (ns.name.includes(".")) {
+ let path = ns.name.split(".");
+ let leafName = path.pop();
+
+ let parent = nestedNamespaces.getPath(...path);
+
+ parent.set(leafName, ns);
+ } else {
+ exportLazyGetter(dest, ns.name,
+ () => createNamespace(ns));
+ }
+ }
+
+ createNestedNamespaces(dest, nestedNamespaces);
+ },
+
+ /**
+ * 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.namespaces.get(namespaceName);
+ let type = ns.get(prop);
+
+ return type.normalize(obj, new Context(context));
+ },
+};