2144 lines
63 KiB
JavaScript
2144 lines
63 KiB
JavaScript
/* 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));
|
|
},
|
|
};
|