Source code

Revision control

Copy as Markdown

Other Tools

/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const { AppConstants } = ChromeUtils.import(
);
const { XPCOMUtils } = ChromeUtils.importESModule(
);
const { ExtensionUtils } = ChromeUtils.import(
);
var { DefaultMap, DefaultWeakMap } = ExtensionUtils;
const lazy = {};
ChromeUtils.defineModuleGetter(
lazy,
"ExtensionParent",
);
ChromeUtils.defineModuleGetter(
lazy,
"NetUtil",
);
ChromeUtils.defineESModuleGetters(lazy, {
});
XPCOMUtils.defineLazyServiceGetter(
lazy,
"contentPolicyService",
"@mozilla.org/addons/content-policy;1",
"nsIAddonContentPolicy"
);
XPCOMUtils.defineLazyGetter(
lazy,
"StartupCache",
() => lazy.ExtensionParent.StartupCache
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"treatWarningsAsErrors",
"extensions.webextensions.warnings-as-errors",
false
);
const EXPORTED_SYMBOLS = ["SchemaRoot", "Schemas"];
let Schemas;
const KEY_CONTENT_SCHEMAS = "extensions-framework/schemas/content";
const KEY_PRIVILEGED_SCHEMAS = "extensions-framework/schemas/privileged";
const MIN_MANIFEST_VERSION = 2;
const MAX_MANIFEST_VERSION = 3;
const { DEBUG } = AppConstants;
const isParentProcess =
Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT;
function readJSON(url) {
return new Promise((resolve, reject) => {
lazy.NetUtil.asyncFetch(
{ uri: url, loadUsingSystemPrincipal: true },
(inputStream, status) => {
if (!Components.isSuccessCode(status)) {
// Convert status code to a string
let e = Components.Exception("", status);
reject(new Error(`Error while loading '${url}' (${e.name})`));
return;
}
try {
let text = lazy.NetUtil.readInputStreamToString(
inputStream,
inputStream.available()
);
// Chrome JSON files include a license comment that we need to
// strip off for this to be valid JSON. As a hack, we just
// look for the first '[' character, which signals the start
// of the JSON content.
let index = text.indexOf("[");
text = text.slice(index);
resolve(JSON.parse(text));
} catch (e) {
reject(e);
}
}
);
});
}
function stripDescriptions(json, stripThis = true) {
if (Array.isArray(json)) {
for (let i = 0; i < json.length; i++) {
if (typeof json[i] === "object" && json[i] !== null) {
json[i] = stripDescriptions(json[i]);
}
}
return json;
}
let result = {};
// Objects are handled much more efficiently, both in terms of memory and
// CPU, if they have the same shape as other objects that serve the same
// purpose. So, normalize the order of properties to increase the chances
// that the majority of schema objects wind up in large shape groups.
for (let key of Object.keys(json).sort()) {
if (stripThis && key === "description" && typeof json[key] === "string") {
continue;
}
if (typeof json[key] === "object" && json[key] !== null) {
result[key] = stripDescriptions(json[key], key !== "properties");
} else {
result[key] = json[key];
}
}
return result;
}
function blobbify(json) {
// We don't actually use descriptions at runtime, and they make up about a
// third of the size of our structured clone data, so strip them before
// blobbifying.
json = stripDescriptions(json);
return new StructuredCloneHolder(json);
}
async function readJSONAndBlobbify(url) {
let json = await readJSON(url);
return blobbify(json);
}
/**
* Defines a lazy getter for the given property on the given object. Any
* security wrappers are waived on the object before the property is
* defined, and the getter and setter methods are wrapped for the target
* scope.
*
* The given getter function is guaranteed to be called only once, even
* if the target scope retrieves the wrapped getter from the property
* descriptor and calls it directly.
*
* @param {object} object
* The object on which to define the getter.
* @param {string|Symbol} prop
* The property name for which to define the getter.
* @param {function} getter
* The function to call in order to generate the final property
* value.
*/
function exportLazyGetter(object, prop, getter) {
object = ChromeUtils.waiveXrays(object);
let redefine = value => {
if (value === undefined) {
delete object[prop];
} else {
Object.defineProperty(object, prop, {
enumerable: true,
configurable: true,
writable: true,
value,
});
}
getter = null;
return value;
};
Object.defineProperty(object, prop, {
enumerable: true,
configurable: true,
get: Cu.exportFunction(function() {
return redefine(getter.call(this));
}, object),
set: Cu.exportFunction(value => {
redefine(value);
}, object),
});
}
/**
* Defines a lazily-instantiated property descriptor on the given
* object. Any security wrappers are waived on the object before the
* property is defined.
*
* The given getter function is guaranteed to be called only once, even
* if the target scope retrieves the wrapped getter from the property
* descriptor and calls it directly.
*
* @param {object} object
* The object on which to define the getter.
* @param {string|Symbol} prop
* The property name for which to define the getter.
* @param {function} getter
* The function to call in order to generate the final property
* descriptor object. This will be called, and the property
* descriptor installed on the object, the first time the
* property is written or read. The function may return
* undefined, which will cause the property to be deleted.
*/
function exportLazyProperty(object, prop, getter) {
object = ChromeUtils.waiveXrays(object);
let redefine = obj => {
let desc = getter.call(obj);
getter = null;
delete object[prop];
if (desc) {
let defaults = {
configurable: true,
enumerable: true,
};
if (!desc.set && !desc.get) {
defaults.writable = true;
}
Object.defineProperty(object, prop, Object.assign(defaults, desc));
}
};
Object.defineProperty(object, prop, {
enumerable: true,
configurable: true,
get: Cu.exportFunction(function() {
redefine(this);
return object[prop];
}, object),
set: Cu.exportFunction(function(value) {
redefine(this);
object[prop] = value;
}, object),
});
}
const POSTPROCESSORS = {
convertImageDataToURL(imageData, context) {
let document = context.cloneScope.document;
let canvas = document.createElementNS(
"canvas"
);
canvas.width = imageData.width;
canvas.height = imageData.height;
canvas.getContext("2d").putImageData(imageData, 0, 0);
return canvas.toDataURL("image/png");
},
webRequestBlockingPermissionRequired(string, context) {
if (string === "blocking" && !context.hasPermission("webRequestBlocking")) {
throw new context.cloneScope.Error(
"Using webRequest.addListener with the " +
"blocking option requires the 'webRequestBlocking' permission."
);
}
return string;
},
requireBackgroundServiceWorkerEnabled(value, context) {
if (WebExtensionPolicy.backgroundServiceWorkerEnabled) {
return value;
}
// Add an error to the manifest validations and throw the
// same error.
const msg = "background.service_worker is currently disabled";
context.logError(context.makeError(msg));
throw new Error(msg);
},
manifestVersionCheck(value, context) {
if (
value == 2 ||
(value == 3 &&
Services.prefs.getBoolPref("extensions.manifestV3.enabled", false))
) {
return value;
}
const msg = `Unsupported manifest version: ${value}`;
context.logError(context.makeError(msg));
throw new Error(msg);
},
webAccessibleMatching(value, context) {
// Ensure each object has at least one of matches or extension_ids array.
for (let obj of value) {
if (!obj.matches && !obj.extension_ids) {
const msg = `web_accessible_resources requires one of "matches" or "extension_ids"`;
context.logError(context.makeError(msg));
throw new Error(msg);
}
}
return value;
},
};
// Parses a regular expression, with support for the Python extended
// syntax that allows setting flags by including the string (?im)
function parsePattern(pattern) {
let flags = "";
let match = /^\(\?([im]*)\)(.*)/.exec(pattern);
if (match) {
[, flags, pattern] = match;
}
return new RegExp(pattern, flags);
}
function getValueBaseType(value) {
let type = typeof value;
switch (type) {
case "object":
if (value === null) {
return "null";
}
if (Array.isArray(value)) {
return "array";
}
break;
case "number":
if (value % 1 === 0) {
return "integer";
}
}
return type;
}
// Methods of Context that are used by Schemas.normalize. These methods can be
// overridden at the construction of Context.
const CONTEXT_FOR_VALIDATION = ["checkLoadURL", "hasPermission", "logError"];
// Methods of Context that are used by Schemas.inject.
// Callers of Schemas.inject should implement all of these methods.
const CONTEXT_FOR_INJECTION = [
...CONTEXT_FOR_VALIDATION,
"getImplementation",
"isPermissionRevokable",
"shouldInject",
];
// If the message is a function, call it and return the result.
// Otherwise, assume it's a string.
function forceString(msg) {
if (typeof msg === "function") {
return msg();
}
return msg;
}
/**
* A context for schema validation and error reporting. This class is only used
* internally within Schemas.
*/
class Context {
/**
* @param {object} params Provides the implementation of this class.
* @param {Array<string>} overridableMethods
*/
constructor(params, overridableMethods = CONTEXT_FOR_VALIDATION) {
this.params = params;
if (typeof params.manifestVersion !== "number") {
throw new Error(
`Unexpected params.manifestVersion value: ${params.manifestVersion}`
);
}
this.path = [];
this.preprocessors = {
localize(value, context) {
return value;
},
};
this.postprocessors = POSTPROCESSORS;
this.isChromeCompat = false;
this.currentChoices = new Set();
this.choicePathIndex = 0;
for (let method of overridableMethods) {
if (method in params) {
this[method] = params[method].bind(params);
}
}
let props = ["isChromeCompat", "manifestVersion", "preprocessors"];
for (let prop of props) {
if (prop in params) {
if (prop in this && typeof this[prop] == "object") {
Object.assign(this[prop], params[prop]);
} else {
this[prop] = params[prop];
}
}
}
}
get choicePath() {
let path = this.path.slice(this.choicePathIndex);
return path.join(".");
}
get cloneScope() {
return this.params.cloneScope || undefined;
}
get url() {
return this.params.url;
}
get principal() {
return (
this.params.principal ||
Services.scriptSecurityManager.createNullPrincipal({})
);
}
/**
* Checks whether `url` may be loaded by the extension in this context.
*
* @param {string} url The URL that the extension wished to load.
* @returns {boolean} Whether the context may load `url`.
*/
checkLoadURL(url) {
let ssm = Services.scriptSecurityManager;
try {
ssm.checkLoadURIWithPrincipal(
this.principal,
Services.io.newURI(url),
ssm.DISALLOW_INHERIT_PRINCIPAL
);
} catch (e) {
return false;
}
return true;
}
/**
* Checks whether this context has the given permission.
*
* @param {string} permission
* The name of the permission to check.
*
* @returns {boolean} True if the context has the given permission.
*/
hasPermission(permission) {
return false;
}
/**
* Checks whether the given permission can be dynamically revoked or
* granted.
*
* @param {string} permission
* The name of the permission to check.
*
* @returns {boolean} True if the given permission is revokable.
*/
isPermissionRevokable(permission) {
return false;
}
/**
* Returns an error result object with the given message, for return
* by Type normalization functions.
*
* If the context has a `currentTarget` value, this is prepended to
* the message to indicate the location of the error.
*
* @param {string|function} errorMessage
* The error message which will be displayed when this is the
* only possible matching schema. If a function is passed, it
* will be evaluated when the error string is first needed, and
* must return a string.
* @param {string|function} choicesMessage
* The message describing the valid what constitutes a valid
* value for this schema, which will be displayed when multiple
* schema choices are available and none match.
*
* A caller may pass `null` to prevent a choice from being
* added, but this should *only* be done from code processing a
* choices type.
* @param {boolean} [warning = false]
* If true, make message prefixed `Warning`. If false, make message
* prefixed `Error`
* @returns {object}
*/
error(errorMessage, choicesMessage = undefined, warning = false) {
if (choicesMessage !== null) {
let { choicePath } = this;
if (choicePath) {
choicesMessage = `.${choicePath} must ${choicesMessage}`;
}
this.currentChoices.add(choicesMessage);
}
if (this.currentTarget) {
let { currentTarget } = this;
return {
error: () =>
`${
warning ? "Warning" : "Error"
} processing ${currentTarget}: ${forceString(errorMessage)}`,
};
}
return { error: errorMessage };
}
/**
* Creates an `Error` object belonging to the current unprivileged
* scope. If there is no unprivileged scope associated with this
* context, the message is returned as a string.
*
* If the context has a `currentTarget` value, this is prepended to
* the message, in the same way as for the `error` method.
*
* @param {string} message
* @param {object} [options]
* @param {boolean} [options.warning = false]
* @returns {Error}
*/
makeError(message, { warning = false } = {}) {
let error = forceString(this.error(message, null, warning).error);
if (this.cloneScope) {
return new this.cloneScope.Error(error);
}
return error;
}
/**
* Logs the given error to the console. May be overridden to enable
* custom logging.
*
* @param {Error|string} error
*/
logError(error) {
if (this.cloneScope) {
Cu.reportError(
// Error objects logged using Cu.reportError are not associated
// to the related innerWindowID. This results in a leaked docshell
// since consoleService cannot release the error object when the
// extension global is destroyed.
typeof error == "string" ? error : String(error),
// Report the error with the appropriate stack trace when the
// is related to an actual extension global (instead of being
// related to a manifest validation).
this.principal && ChromeUtils.getCallerLocation(this.principal)
);
} else {
Cu.reportError(error);
}
}
/**
* Returns the name of the value currently being normalized. For a
* nested object, this is usually approximately equivalent to the
* JavaScript property accessor for that property. Given:
*
* { foo: { bar: [{ baz: x }] } }
*
* When processing the value for `x`, the currentTarget is
* 'foo.bar.0.baz'
*/
get currentTarget() {
return this.path.join(".");
}
/**
* Executes the given callback, and returns an array of choice strings
* passed to {@see #error} during its execution.
*
* @param {function} callback
* @returns {object}
* An object with a `result` property containing the return
* value of the callback, and a `choice` property containing
* an array of choices.
*/
withChoices(callback) {
let { currentChoices, choicePathIndex } = this;
let choices = new Set();
this.currentChoices = choices;
this.choicePathIndex = this.path.length;
try {
let result = callback();
return { result, choices };
} finally {
this.currentChoices = currentChoices;
this.choicePathIndex = choicePathIndex;
if (choices.size == 1) {
for (let choice of choices) {
currentChoices.add(choice);
}
} else if (choices.size) {
this.error(null, () => {
let array = Array.from(choices, forceString);
let n = array.length - 1;
array[n] = `or ${array[n]}`;
return `must either [${array.join(", ")}]`;
});
}
}
}
/**
* Appends the given component to the `currentTarget` path to indicate
* that it is being processed, calls the given callback function, and
* then restores the original path.
*
* This is used to identify the path of the property being processed
* when reporting type errors.
*
* @param {string} component
* @param {function} callback
* @returns {*}
*/
withPath(component, callback) {
this.path.push(component);
try {
return callback();
} finally {
this.path.pop();
}
}
matchManifestVersion(entry) {
let { manifestVersion } = this;
return (
manifestVersion >= entry.min_manifest_version &&
manifestVersion <= entry.max_manifest_version
);
}
}
/**
* Represents a schema entry to be injected into an object. Handles the
* injection, revocation, and permissions of said entry.
*
* @param {InjectionContext} context
* The injection context for the entry.
* @param {Entry} entry
* The entry to inject.
* @param {object} parentObject
* The object into which to inject this entry.
* @param {string} name
* The property name at which to inject this entry.
* @param {Array<string>} path
* The full path from the root entry to this entry.
* @param {Entry} parentEntry
* The parent entry for the injected entry.
*/
class InjectionEntry {
constructor(context, entry, parentObj, name, path, parentEntry) {
this.context = context;
this.entry = entry;
this.parentObj = parentObj;
this.name = name;
this.path = path;
this.parentEntry = parentEntry;
this.injected = null;
this.lazyInjected = null;
}
/**
* @property {Array<string>} allowedContexts
* The list of allowed contexts into which the entry may be
* injected.
*/
get allowedContexts() {
let { allowedContexts } = this.entry;
if (allowedContexts.length) {
return allowedContexts;
}
return this.parentEntry.defaultContexts;
}
/**
* @property {boolean} isRevokable
* Returns true if this entry may be dynamically injected or
* revoked based on its permissions.
*/
get isRevokable() {
return (
this.entry.permissions &&
this.entry.permissions.some(perm =>
this.context.isPermissionRevokable(perm)
)
);
}
/**
* @property {boolean} hasPermission
* Returns true if the injection context currently has the
* appropriate permissions to access this entry.
*/
get hasPermission() {
return (
!this.entry.permissions ||
this.entry.permissions.some(perm => this.context.hasPermission(perm))
);
}
/**
* @property {boolean} shouldInject
* Returns true if this entry should be injected in the given
* context, without respect to permissions.
*/
get shouldInject() {
return (
this.context.matchManifestVersion(this.entry) &&
this.context.shouldInject(
this.path.join("."),
this.name,
this.allowedContexts
)
);
}
/**
* Revokes this entry, removing its property from its parent object,
* and invalidating its wrappers.
*/
revoke() {
if (this.lazyInjected) {
this.lazyInjected = false;
} else if (this.injected) {
if (this.injected.revoke) {
this.injected.revoke();
}
try {
let unwrapped = ChromeUtils.waiveXrays(this.parentObj);
delete unwrapped[this.name];
} catch (e) {
Cu.reportError(e);
}
let { value } = this.injected.descriptor;
if (value) {
this.context.revokeChildren(value);
}
this.injected = null;
}
}
/**
* Returns a property descriptor object for this entry, if it should
* be injected, or undefined if it should not.
*
* @returns {object?}
* A property descriptor object, or undefined if the property
* should be removed.
*/
getDescriptor() {
this.lazyInjected = false;
if (this.injected) {
let path = [...this.path, this.name];
throw new Error(
`Attempting to re-inject already injected entry: ${path.join(".")}`
);
}
if (!this.shouldInject) {
return;
}
if (this.isRevokable) {
this.context.pendingEntries.add(this);
}
if (!this.hasPermission) {
return;
}
this.injected = this.entry.getDescriptor(this.path, this.context);
if (!this.injected) {
return undefined;
}
return this.injected.descriptor;
}
/**
* Injects a lazy property descriptor into the parent object which
* checks permissions and eligibility for injection the first time it
* is accessed.
*/
lazyInject() {
if (this.lazyInjected || this.injected) {
let path = [...this.path, this.name];
throw new Error(
`Attempting to re-lazy-inject already injected entry: ${path.join(".")}`
);
}
this.lazyInjected = true;
exportLazyProperty(this.parentObj, this.name, () => {
if (this.lazyInjected) {
return this.getDescriptor();
}
});
}
/**
* Injects or revokes this entry if its current state does not match
* the context's current permissions.
*/
permissionsChanged() {
if (this.injected) {
this.maybeRevoke();
} else {
this.maybeInject();
}
}
maybeInject() {
if (!this.injected && !this.lazyInjected) {
this.lazyInject();
}
}
maybeRevoke() {
if (this.injected && !this.hasPermission) {
this.revoke();
}
}
}
/**
* Holds methods that run the actual implementation of the extension APIs. These
* methods are only called if the extension API invocation matches the signature
* as defined in the schema. Otherwise an error is reported to the context.
*/
class InjectionContext extends Context {
constructor(params, schemaRoot) {
super(params, CONTEXT_FOR_INJECTION);
this.schemaRoot = schemaRoot;
this.pendingEntries = new Set();
this.children = new DefaultWeakMap(() => new Map());
this.injectedRoots = new Set();
if (params.setPermissionsChangedCallback) {
params.setPermissionsChangedCallback(this.permissionsChanged.bind(this));
}
}
/**
* Check whether the API should be injected.
*
* @abstract
* @param {string} namespace The namespace of the API. This may contain dots,
* e.g. in the case of "devtools.inspectedWindow".
* @param {string} [name] The name of the property in the namespace.
* `null` if we are checking whether the namespace should be injected.
* @param {Array<string>} allowedContexts A list of additional contexts in which
* this API should be available. May include any of:
* "main" - The main chrome browser process.
* "addon" - An addon process.
* "content" - A content process.
* @returns {boolean} Whether the API should be injected.
*/
shouldInject(namespace, name, allowedContexts) {
throw new Error("Not implemented");
}
/**
* Generate the implementation for `namespace`.`name`.
*
* @abstract
* @param {string} namespace The full path to the namespace of the API, minus
* the name of the method or property. E.g. "storage.local".
* @param {string} name The name of the method, property or event.
* @returns {SchemaAPIInterface} The implementation of the API.
*/
getImplementation(namespace, name) {
throw new Error("Not implemented");
}
/**
* Updates all injection entries which may need to be updated after a
* permission change, revoking or re-injecting them as necessary.
*/
permissionsChanged() {
for (let entry of this.pendingEntries) {
try {
entry.permissionsChanged();
} catch (e) {
Cu.reportError(e);
}
}
}
/**
* Recursively revokes all child injection entries of the given
* object.
*
* @param {object} object
* The object for which to invoke children.
*/
revokeChildren(object) {
if (!this.children.has(object)) {
return;
}
let children = this.children.get(object);
for (let [name, entry] of children.entries()) {
try {
entry.revoke();
} catch (e) {
Cu.reportError(e);
}
children.delete(name);
// When we revoke children for an object, we consider that object
// dead. If the entry is ever reified again, a new object is
// created, with new child entries.
this.pendingEntries.delete(entry);
}
this.children.delete(object);
}
_getInjectionEntry(entry, dest, name, path, parentEntry) {
let injection = new InjectionEntry(
this,
entry,
dest,
name,
path,
parentEntry
);
this.children.get(dest).set(name, injection);
return injection;
}
/**
* Returns the property descriptor for the given entry.
*
* @param {Entry} entry
* The entry instance to return a descriptor for.
* @param {object} dest
* The object into which this entry is being injected.
* @param {string} name
* The property name on the destination object where the entry
* will be injected.
* @param {Array<string>} path
* The full path from the root injection object to this entry.
* @param {Entry} parentEntry
* The parent entry for this entry.
*
* @returns {object?}
* A property descriptor object, or null if the entry should
* not be injected.
*/
getDescriptor(entry, dest, name, path, parentEntry) {
let injection = this._getInjectionEntry(
entry,
dest,
name,
path,
parentEntry
);
return injection.getDescriptor();
}
/**
* Lazily injects the given entry into the given object.
*
* @param {Entry} entry
* The entry instance to lazily inject.
* @param {object} dest
* The object into which to inject this entry.
* @param {string} name
* The property name at which to inject the entry.
* @param {Array<string>} path
* The full path from the root injection object to this entry.
* @param {Entry} parentEntry
* The parent entry for this entry.
*/
injectInto(entry, dest, name, path, parentEntry) {
let injection = this._getInjectionEntry(
entry,
dest,
name,
path,
parentEntry
);
injection.lazyInject();
}
}
/**
* The methods in this singleton represent the "format" specifier for
* JSON Schema string types.
*
* Each method either returns a normalized version of the original
* value, or throws an error if the value is not valid for the given
* format.
*/
const FORMATS = {
hostname(string, context) {
let valid = true;
try {
valid = new URL(`http://${string}`).host === string;
} catch (e) {
valid = false;
}
if (!valid) {
throw new Error(`Invalid hostname ${string}`);
}
return string;
},
url(string, context) {
let url = new URL(string).href;
if (!context.checkLoadURL(url)) {
throw new Error(`Access denied for URL ${url}`);
}
return url;
},
origin(string, context) {
let url;
try {
url = new URL(string);
} catch (e) {
throw new Error(`Invalid origin: ${string}`);
}
if (!/^https?:/.test(url.protocol)) {
throw new Error(`Invalid origin must be http or https for URL ${string}`);
}
// url.origin is punycode so a direct check against string wont work.
// url.href appends a slash even if not in the original string, we we
// additionally check that string does not end in slash.
if (string.endsWith("/") || url.href != new URL(url.origin).href) {
throw new Error(
`Invalid origin for URL ${string}, replace with origin ${url.origin}`
);
}
if (!context.checkLoadURL(url.origin)) {
throw new Error(`Access denied for URL ${url}`);
}
return url.origin;
},
relativeUrl(string, context) {
if (!context.url) {
// If there's no context URL, return relative URLs unresolved, and
// skip security checks for them.
try {
new URL(string);
} catch (e) {
return string;
}
}
let url = new URL(string, context.url).href;
if (!context.checkLoadURL(url)) {
throw new Error(`Access denied for URL ${url}`);
}
return url;
},
strictRelativeUrl(string, context) {
void FORMATS.unresolvedRelativeUrl(string, context);
return FORMATS.relativeUrl(string, context);
},
unresolvedRelativeUrl(string, context) {
if (!string.startsWith("//")) {
try {
new URL(string);
} catch (e) {
return string;
}
}
throw new SyntaxError(
`String ${JSON.stringify(string)} must be a relative URL`
);
},
homepageUrl(string, context) {
// Pipes are used for separating homepages, but we only allow extensions to
// set a single homepage. Encoding any pipes makes it one URL.
return FORMATS.relativeUrl(
string.replace(new RegExp("\\|", "g"), "%7C"),
context
);
},
imageDataOrStrictRelativeUrl(string, context) {
// Do not accept a string which resolves as an absolute URL, or any
// protocol-relative URL, except PNG or JPG data URLs
if (
!string.startsWith("data:image/png;base64,") &&
!string.startsWith("data:image/jpeg;base64,")
) {
try {
return FORMATS.strictRelativeUrl(string, context);
} catch (e) {
throw new SyntaxError(
`String ${JSON.stringify(
string
)} must be a relative or PNG or JPG data:image URL`
);
}
}
return string;
},
contentSecurityPolicy(string, context) {
// Manifest V3 extension_pages allows WASM. When sandbox is
// implemented, or any other V3 or later directive, the flags
// logic will need to be updated.
let flags =
context.manifestVersion < 3
? Ci.nsIAddonContentPolicy.CSP_ALLOW_ANY
: Ci.nsIAddonContentPolicy.CSP_ALLOW_WASM;
let error = lazy.contentPolicyService.validateAddonCSP(string, flags);
if (error != null) {
// The CSP validation error is not reported as part of the "choices" error message,
// we log the CSP validation error explicitly here to make it easier for the addon developers
// to see and fix the extension CSP.
context.logError(`Error processing ${context.currentTarget}: ${error}`);
return null;
}
return string;
},
date(string, context) {
// A valid ISO 8601 timestamp.
const PATTERN = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{3})?(Z|([-+]\d{2}:?\d{2})))?$/;
if (!PATTERN.test(string)) {
throw new Error(`Invalid date string ${string}`);
}
// Our pattern just checks the format, we could still have invalid
// values (e.g., month=99 or month=02 and day=31). Let the Date
// constructor do the dirty work of validating.
if (isNaN(new Date(string))) {
throw new Error(`Invalid date string ${string}`);
}
return string;
},
manifestShortcutKey(string, context) {
if (lazy.ShortcutUtils.validate(string) == lazy.ShortcutUtils.IS_VALID) {
return string;
}
let errorMessage =
`Value "${string}" must consist of ` +
`either a combination of one or two modifiers, including ` +
`a mandatory primary modifier and a key, separated by '+', ` +
`or a media key. For details see: ` +
throw new Error(errorMessage);
},
manifestShortcutKeyOrEmpty(string, context) {
return string === "" ? "" : FORMATS.manifestShortcutKey(string, context);
},
};
// Schema files contain namespaces, and each namespace contains types,
// properties, functions, and events. An Entry is a base class for
// types, properties, functions, and events.
class Entry {
constructor(schema = {}) {
/**
* If set to any value which evaluates as true, this entry is
* deprecated, and any access to it will result in a deprecation
* warning being logged to the browser console.
*
* If the value is a string, it will be appended to the deprecation
* message. If it contains the substring "${value}", it will be
* replaced with a string representation of the value being
* processed.
*
* If the value is any other truthy value, a generic deprecation
* message will be emitted.
*/
this.deprecated = false;
if ("deprecated" in schema) {
this.deprecated = schema.deprecated;
}
/**
* @property {string} [preprocessor]
* If set to a string value, and a preprocessor of the same is
* defined in the validation context, it will be applied to this
* value prior to any normalization.
*/
this.preprocessor = schema.preprocess || null;
/**
* @property {string} [postprocessor]
* If set to a string value, and a postprocessor of the same is
* defined in the validation context, it will be applied to this
* value after any normalization.
*/
this.postprocessor = schema.postprocess || null;
/**
* @property {Array<string>} allowedContexts A list of allowed contexts
* to consider before generating the API.
* These are not parsed by the schema, but passed to `shouldInject`.
*/
this.allowedContexts = schema.allowedContexts || [];
this.min_manifest_version =
schema.min_manifest_version ?? MIN_MANIFEST_VERSION;
this.max_manifest_version =
schema.max_manifest_version ?? MAX_MANIFEST_VERSION;
}
/**
* Preprocess the given value with the preprocessor declared in
* `preprocessor`.
*
* @param {*} value
* @param {Context} context
* @returns {*}
*/
preprocess(value, context) {
if (this.preprocessor) {
return context.preprocessors[this.preprocessor](value, context);
}
return value;
}
/**
* Postprocess the given result with the postprocessor declared in
* `postprocessor`.
*
* @param {object} result
* @param {Context} context
* @returns {object}
*/
postprocess(result, context) {
if (result.error || !this.postprocessor) {
return result;
}
let value = context.postprocessors[this.postprocessor](
result.value,
context
);
return { value };
}
/**
* Logs a deprecation warning for this entry, based on the value of
* its `deprecated` property.
*
* @param {Context} context
* @param {value} [value]
*/
logDeprecation(context, value = null) {
let message = "This property is deprecated";
if (typeof this.deprecated == "string") {
message = this.deprecated;
if (message.includes("${value}")) {
try {
value = JSON.stringify(value);
} catch (e) {
value = String(value);
}
message = message.replace(/\$\{value\}/g, () => value);
}
}
this.logWarning(context, message);
}
/**
* @param {Context} context
* @param {string} warningMessage
*/
logWarning(context, warningMessage) {
let error = context.makeError(warningMessage, { warning: true });
context.logError(error);
if (lazy.treatWarningsAsErrors) {
// This pref is false by default, and true by default in tests to
// discourage the use of deprecated APIs in our unit tests.
// If a warning is an expected part of a test, temporarily set the pref
// to false, e.g. with the ExtensionTestUtils.failOnSchemaWarnings helper.
Services.console.logStringMessage(
"Treating warning as error because the preference " +
"extensions.webextensions.warnings-as-errors is set to true"
);
if (typeof error === "string") {
error = new Error(error);
}
throw error;
}
}
/**
* Checks whether the entry is deprecated and, if so, logs a
* deprecation message.
*
* @param {Context} context
* @param {value} [value]
*/
checkDeprecated(context, value = null) {
if (this.deprecated) {
this.logDeprecation(context, value);
}
}
/**
* Returns an object containing property descriptor for use when
* injecting this entry into an API object.
*
* @param {Array<string>} path The API path, e.g. `["storage", "local"]`.
* @param {InjectionContext} context
*
* @returns {object?}
* An object containing a `descriptor` property, specifying the
* entry's property descriptor, and an optional `revoke`
* method, to be called when the entry is being revoked.
*/
getDescriptor(path, context) {
return undefined;
}
}
// Corresponds either to a type declared in the "types" section of the
// schema or else to any type object used throughout the schema.
class Type extends Entry {
/**
* @property {Array<string>} EXTRA_PROPERTIES
* An array of extra properties which may be present for
* schemas of this type.
*/
static get EXTRA_PROPERTIES() {
return [
"description",
"deprecated",
"preprocess",
"postprocess",
"privileged",
"allowedContexts",
"min_manifest_version",
"max_manifest_version",
];
}
/**
* Parses the given schema object and returns an instance of this
* class which corresponds to its properties.
*
* @param {SchemaRoot} root
* The root schema for this type.
* @param {object} schema
* A JSON schema object which corresponds to a definition of
* this type.
* @param {Array<string>} path
* The path to this schema object from the root schema,
* corresponding to the property names and array indices
* traversed during parsing in order to arrive at this schema
* object.
* @param {Array<string>} [extraProperties]
* An array of extra property names which are valid for this
* schema in the current context.
* @returns {Type}
* An instance of this type which corresponds to the given
* schema object.
* @static
*/
static parseSchema(root, schema, path, extraProperties = []) {
this.checkSchemaProperties(schema, path, extraProperties);
return new this(schema);
}
/**
* Checks that all of the properties present in the given schema
* object are valid properties for this type, and throws if invalid.
*
* @param {object} schema
* A JSON schema object.
* @param {Array<string>} path
* The path to this schema object from the root schema,
* corresponding to the property names and array indices
* traversed during parsing in order to arrive at this schema
* object.
* @param {Array<string>} [extra]
* An array of extra property names which are valid for this
* schema in the current context.
* @throws {Error}
* An error describing the first invalid property found in the
* schema object.
*/
static checkSchemaProperties(schema, path, extra = []) {
if (DEBUG) {
let allowedSet = new Set([...this.EXTRA_PROPERTIES, ...extra]);
for (let prop of Object.keys(schema)) {
if (!allowedSet.has(prop)) {
throw new Error(
`Internal error: Namespace ${path.join(".")} has ` +
`invalid type property "${prop}" ` +
`in type "${schema.id || JSON.stringify(schema)}"`
);
}
}
}
}
// Takes a value, checks that it has the correct type, and returns a
// "normalized" version of the value. The normalized version will
// include "nulls" in place of omitted optional properties. The
// result of this function is either {error: "Some type error"} or
// {value: <normalized-value>}.
normalize(value, context) {
return context.error("invalid type");
}
// Unlike normalize, this function does a shallow check to see if
// |baseType| (one of the possible getValueBaseType results) is
// valid for this type. It returns true or false. It's used to fill
// in optional arguments to functions before actually type checking
checkBaseType(baseType) {
return false;
}
// Helper method that simply relies on checkBaseType to implement
// normalize. Subclasses can choose to use it or not.
normalizeBase(type, value, context) {
if (this.checkBaseType(getValueBaseType(value))) {
this.checkDeprecated(context, value);
return { value: this.preprocess(value, context) };
}
let choice;
if ("aeiou".includes(type[0])) {
choice = `be an ${type} value`;
} else {
choice = `be a ${type} value`;
}
return context.error(
() => `Expected ${type} instead of ${JSON.stringify(value)}`,
choice
);
}
}
// Type that allows any value.
class AnyType extends Type {
normalize(value, context) {
this.checkDeprecated(context, value);
return this.postprocess({ value }, context);
}
checkBaseType(baseType) {
return true;
}
}
// An untagged union type.
class ChoiceType extends Type {
static get EXTRA_PROPERTIES() {
return ["choices", ...super.EXTRA_PROPERTIES];
}
static parseSchema(root, schema, path, extraProperties = []) {
this.checkSchemaProperties(schema, path, extraProperties);
let choices = schema.choices.map(t => root.parseSchema(t, path));
return new this(schema, choices);
}
constructor(schema, choices) {
super(schema);
this.choices = choices;
}
extend(type) {
this.choices.push(...type.choices);
return this;
}
normalize(value, context) {
this.checkDeprecated(context, value);
let error;
let { choices, result } = context.withChoices(() => {
for (let choice of this.choices) {
// Ignore a possible choice if it is not supported by
// the manifest version we are normalizing.
if (!context.matchManifestVersion(choice)) {
continue;
}
let r = choice.normalize(value, context);
if (!r.error) {
return r;
}
error = r;
}
});
if (result) {
return result;
}
if (choices.size <= 1) {
return error;
}
choices = Array.from(choices, forceString);
let n = choices.length - 1;
choices[n] = `or ${choices[n]}`;
let message;
if (typeof value === "object") {
message = () => `Value must either: ${choices.join(", ")}`;
} else {
message = () =>
`Value ${JSON.stringify(value)} must either: ${choices.join(", ")}`;
}
return context.error(message, null);
}
checkBaseType(baseType) {
return this.choices.some(t => t.checkBaseType(baseType));
}
getDescriptor(path, context) {
// In StringType.getDescriptor, unlike any other Type, a descriptor is returned if
// it is an enumeration. Since we need versioned choices in some cases, here we
// build a list of valid enumerations that will work for a given manifest version.
if (
!this.choices.length ||
!this.choices.every(t => t.checkBaseType("string") && t.enumeration)
) {
return;
}
let obj = Cu.createObjectIn(context.cloneScope);
let descriptor = { value: obj };
for (let choice of this.choices) {
// Ignore a possible choice if it is not supported by
// the manifest version we are normalizing.
if (!context.matchManifestVersion(choice)) {
continue;
}
let d = choice.getDescriptor(path, context);
if (d) {
Object.assign(obj, d.descriptor.value);
}
}
return { descriptor };
}
}
// This is a reference to another type--essentially a typedef.
class RefType extends Type {
static get EXTRA_PROPERTIES() {
return ["$ref", ...super.EXTRA_PROPERTIES];
}
static parseSchema(root, schema, path, extraProperties = []) {
this.checkSchemaProperties(schema, path, extraProperties);
let ref = schema.$ref;
let ns = path.join(".");
if (ref.includes(".")) {
[, ns, ref] = /^(.*)\.(.*?)$/.exec(ref);
}
return new this(root, schema, ns, ref);
}
// For a reference to a type named T declared in namespace NS,
// namespaceName will be NS and reference will be T.
constructor(root, schema, namespaceName, reference) {
super(schema);
this.root = root;
this.namespaceName = namespaceName;
this.reference = reference;
}
get targetType() {
let ns = this.root.getNamespace(this.namespaceName);
let type = ns.get(this.reference);
if (!type) {
throw new Error(`Internal error: Type ${this.reference} not found`);
}
return type;
}
normalize(value, context) {
this.checkDeprecated(context, value);
return this.targetType.normalize(value, context);
}
checkBaseType(baseType) {
return this.targetType.checkBaseType(baseType);
}
}
class StringType extends Type {
static get EXTRA_PROPERTIES() {
return [
"enum",
"minLength",
"maxLength",
"pattern",
"format",
...super.EXTRA_PROPERTIES,
];
}
static parseSchema(root, schema, path, extraProperties = []) {
this.checkSchemaProperties(schema, path, extraProperties);
let enumeration = schema.enum || null;
if (enumeration) {
// The "enum" property is either a list of strings that are
// valid values or else a list of {name, description} objects,
// where the .name values are the valid values.
enumeration = enumeration.map(e => {
if (typeof e == "object") {
return e.name;
}
return e;
});
}
let pattern = null;
if (schema.pattern) {
try {
pattern = parsePattern(schema.pattern);
} catch (e) {
throw new Error(
`Internal error: Invalid pattern ${JSON.stringify(schema.pattern)}`
);
}
}
let format = null;
if (schema.format) {
if (!(schema.format in FORMATS)) {
throw new Error(
`Internal error: Invalid string format ${schema.format}`
);
}
format = FORMATS[schema.format];
}
return new this(
schema,
schema.id || undefined,
enumeration,
schema.minLength || 0,
schema.maxLength || Infinity,
pattern,
format
);
}
constructor(
schema,
name,
enumeration,
minLength,
maxLength,
pattern,
format
) {
super(schema);
this.name = name;
this.enumeration = enumeration;
this.minLength = minLength;
this.maxLength = maxLength;
this.pattern = pattern;
this.format = format;
}
normalize(value, context) {
let r = this.normalizeBase("string", value, context);
if (r.error) {
return r;
}
value = r.value;
if (this.enumeration) {
if (this.enumeration.includes(value)) {
return this.postprocess({ value }, context);
}
let choices = this.enumeration.map(JSON.stringify).join(", ");
return context.error(
() => `Invalid enumeration value ${JSON.stringify(value)}`,
`be one of [${choices}]`
);
}
if (value.length < this.minLength) {
return context.error(
() =>
`String ${JSON.stringify(value)} is too short (must be ${
this.minLength
})`,
`be longer than ${this.minLength}`
);
}
if (value.length > this.maxLength) {
return context.error(
() =>
`String ${JSON.stringify(value)} is too long (must be ${
this.maxLength
})`,
`be shorter than ${this.maxLength}`
);
}
if (this.pattern && !this.pattern.test(value)) {
return context.error(
() => `String ${JSON.stringify(value)} must match ${this.pattern}`,
`match the pattern ${this.pattern.toSource()}`
);
}
if (this.format) {
try {
r.value = this.format(r.value, context);
} catch (e) {
return context.error(
String(e),
`match the format "${this.format.name}"`
);
}
}
return r;
}
checkBaseType(baseType) {
return baseType == "string";
}
getDescriptor(path, context) {
if (this.enumeration) {
let obj = Cu.createObjectIn(context.cloneScope);
for (let e of this.enumeration) {
obj[e.toUpperCase()] = e;
}
return {
descriptor: { value: obj },
};
}
}
}
class NullType extends Type {
normalize(value, context) {
return this.normalizeBase("null", value, context);
}
checkBaseType(baseType) {
return baseType == "null";
}
}
let FunctionEntry;
let Event;
let SubModuleType;
class ObjectType extends Type {
static get EXTRA_PROPERTIES() {
return [
"properties",
"patternProperties",
"$import",
...super.EXTRA_PROPERTIES,
];
}
static parseSchema(root, schema, path, extraProperties = []) {
if ("functions" in schema) {
return SubModuleType.parseSchema(root, schema, path, extraProperties);
}
if (DEBUG && !("$extend" in schema)) {
// Only allow extending "properties" and "patternProperties".
extraProperties = [
"additionalProperties",
"isInstanceOf",
...extraProperties,
];
}
this.checkSchemaProperties(schema, path, extraProperties);
let imported = null;
if ("$import" in schema) {
let importPath = schema.$import;
let idx = importPath.indexOf(".");
if (idx === -1) {
imported = [path[0], importPath];
} else {
imported = [importPath.slice(0, idx), importPath.slice(idx + 1)];
}
}
let parseProperty = (schema, extraProps = []) => {
return {
type: root.parseSchema(
schema,
path,
DEBUG && [
"unsupported",
"onError",
"permissions",
"default",
...extraProps,
]
),
optional: schema.optional || false,
unsupported: schema.unsupported || false,
onError: schema.onError || null,
default: schema.default === undefined ? null : schema.default,
};
};
// Parse explicit "properties" object.
let properties = Object.create(null);
for (let propName of Object.keys(schema.properties || {})) {
properties[propName] = parseProperty(schema.properties[propName], [
"optional",
]);
}
// Parse regexp properties from "patternProperties" object.
let patternProperties = [];
for (let propName of Object.keys(schema.patternProperties || {})) {
let pattern;
try {
pattern = parsePattern(propName);
} catch (e) {
throw new Error(
`Internal error: Invalid property pattern ${JSON.stringify(propName)}`
);