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/. */
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs";
var { DefaultMap, DefaultWeakMap } = ExtensionUtils;
/** @type {Lazy} */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs",
});
XPCOMUtils.defineLazyServiceGetter(
lazy,
"contentPolicyService",
"@mozilla.org/addons/content-policy;1",
"nsIAddonContentPolicy"
);
ChromeUtils.defineLazyGetter(
lazy,
"StartupCache",
() => lazy.ExtensionParent.StartupCache
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"treatWarningsAsErrors",
"extensions.webextensions.warnings-as-errors",
false
);
const KEY_CONTENT_SCHEMAS = "extensions-framework/schemas/content";
const KEY_PRIVILEGED_SCHEMAS = "extensions-framework/schemas/privileged";
const MIN_MANIFEST_VERSION = 2;
const MAX_MANIFEST_VERSION = 3;
const { DEBUG } = AppConstants;
const isParentProcess =
Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT;
function readJSON(url) {
return new Promise((resolve, reject) => {
lazy.NetUtil.asyncFetch(
{ uri: url, loadUsingSystemPrincipal: true },
(inputStream, status) => {
if (!Components.isSuccessCode(status)) {
// Convert status code to a string
let e = Components.Exception("", status);
reject(new Error(`Error while loading '${url}' (${e.name})`));
return;
}
try {
let text = lazy.NetUtil.readInputStreamToString(
inputStream,
inputStream.available()
);
// Chrome JSON files include a license comment that we need to
// strip off for this to be valid JSON. As a hack, we just
// look for the first '[' character, which signals the start
// of the JSON content.
let index = text.indexOf("[");
text = text.slice(index);
resolve(JSON.parse(text));
} catch (e) {
reject(e);
}
}
);
});
}
function stripDescriptions(json, stripThis = true) {
if (Array.isArray(json)) {
for (let i = 0; i < json.length; i++) {
if (typeof json[i] === "object" && json[i] !== null) {
json[i] = stripDescriptions(json[i]);
}
}
return json;
}
let result = {};
// Objects are handled much more efficiently, both in terms of memory and
// CPU, if they have the same shape as other objects that serve the same
// purpose. So, normalize the order of properties to increase the chances
// that the majority of schema objects wind up in large shape groups.
for (let key of Object.keys(json).sort()) {
if (stripThis && key === "description" && typeof json[key] === "string") {
continue;
}
if (typeof json[key] === "object" && json[key] !== null) {
result[key] = stripDescriptions(json[key], key !== "properties");
} else {
result[key] = json[key];
}
}
return result;
}
function blobbify(json) {
// We don't actually use descriptions at runtime, and they make up about a
// third of the size of our structured clone data, so strip them before
// blobbifying.
json = stripDescriptions(json);
return new StructuredCloneHolder("Schemas/blobbify", null, json);
}
async function readJSONAndBlobbify(url) {
let json = await readJSON(url);
return blobbify(json);
}
/**
* Defines a lazy getter for the given property on the given object. Any
* security wrappers are waived on the object before the property is
* defined, and the getter and setter methods are wrapped for the target
* scope.
*
* The given getter function is guaranteed to be called only once, even
* if the target scope retrieves the wrapped getter from the property
* descriptor and calls it directly.
*
* @param {object} object
* The object on which to define the getter.
* @param {string | symbol} prop
* The property name for which to define the getter.
* @param {Function} getter
* The function to call in order to generate the final property
* value.
*/
function exportLazyGetter(object, prop, getter) {
object = ChromeUtils.waiveXrays(object);
let redefine = value => {
if (value === undefined) {
delete object[prop];
} else {
Object.defineProperty(object, prop, {
enumerable: true,
configurable: true,
writable: true,
value,
});
}
getter = null;
return value;
};
Object.defineProperty(object, prop, {
enumerable: true,
configurable: true,
get: Cu.exportFunction(function () {
return redefine(getter.call(this));
}, object),
set: Cu.exportFunction(value => {
redefine(value);
}, object),
});
}
/**
* Defines a lazily-instantiated property descriptor on the given
* object. Any security wrappers are waived on the object before the
* property is defined.
*
* The given getter function is guaranteed to be called only once, even
* if the target scope retrieves the wrapped getter from the property
* descriptor and calls it directly.
*
* @param {object} object
* The object on which to define the getter.
* @param {string | symbol} prop
* The property name for which to define the getter.
* @param {Function} getter
* The function to call in order to generate the final property
* descriptor object. This will be called, and the property
* descriptor installed on the object, the first time the
* property is written or read. The function may return
* undefined, which will cause the property to be deleted.
*/
function exportLazyProperty(object, prop, getter) {
object = ChromeUtils.waiveXrays(object);
let redefine = obj => {
let desc = getter.call(obj);
getter = null;
delete object[prop];
if (desc) {
let defaults = {
configurable: true,
enumerable: true,
};
if (!desc.set && !desc.get) {
defaults.writable = true;
}
Object.defineProperty(object, prop, Object.assign(defaults, desc));
}
};
Object.defineProperty(object, prop, {
enumerable: true,
configurable: true,
get: Cu.exportFunction(function () {
redefine(this);
return object[prop];
}, object),
set: Cu.exportFunction(function (value) {
redefine(this);
object[prop] = value;
}, object),
});
}
const POSTPROCESSORS = {
convertImageDataToURL(imageData, context) {
let document = context.cloneScope.document;
let canvas = document.createElementNS(
"canvas"
);
canvas.width = imageData.width;
canvas.height = imageData.height;
canvas.getContext("2d").putImageData(imageData, 0, 0);
return canvas.toDataURL("image/png");
},
mutuallyExclusiveBlockingOrAsyncBlocking(value, context) {
if (!Array.isArray(value)) {
return value;
}
if (value.includes("blocking") && value.includes("asyncBlocking")) {
throw new context.cloneScope.Error(
"'blocking' and 'asyncBlocking' are mutually exclusive"
);
}
return value;
},
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;
},
webRequestBlockingOrAuthProviderPermissionRequired(string, context) {
if (
string === "blocking" &&
!(
context.hasPermission("webRequestBlocking") ||
context.hasPermission("webRequestAuthProvider")
)
) {
throw new context.cloneScope.Error(
"Using webRequest.onAuthRequired.addListener with the " +
"blocking option requires either the 'webRequestBlocking' " +
"or 'webRequestAuthProvider' 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;
},
incognitoSplitUnsupportedAndFallback(value, context) {
if (value === "split") {
// incognito:split has not been implemented (bug 1380812). There are two
// alternatives: "spanning" and "not_allowed".
//
// "incognito":"split" is required by Chrome when extensions want to load
// any extension page in a tab in Chrome. In Firefox that is not required,
// so extensions could replace "split" with "spanning".
// Another (poorly documented) effect of "incognito":"split" is separation
// of some state between some extension APIs. Because this can in theory
// result in unwanted mixing of state between private and non-private
// browsing, we fall back to "not_allowed", which prevents the user from
// enabling the extension in private browsing windows.
value = "not_allowed";
context.logWarning(
`incognito "split" is unsupported. Falling back to incognito "${value}".`
);
}
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) {
return value;
},
...params.preprocessors,
};
this.postprocessors = POSTPROCESSORS;
this.isChromeCompat = params.isChromeCompat ?? false;
this.manifestVersion = params.manifestVersion;
this.currentChoices = new Set();
this.choicePathIndex = 0;
for (let method of overridableMethods) {
if (method in params) {
this[method] = params[method].bind(params);
}
}
}
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 ignoreUnrecognizedProperties() {
return !!this.params.ignoreUnrecognizedProperties;
}
get principal() {
return (
this.params.principal ||
Services.scriptSecurityManager.createNullPrincipal({})
);
}
/**
* Checks whether `url` may be loaded by the extension in this context.
*
* @param {string} url The URL that the extension wished to load.
* @returns {boolean} Whether the context may load `url`.
*/
checkLoadURL(url) {
let ssm = Services.scriptSecurityManager;
try {
ssm.checkLoadURIWithPrincipal(
this.principal,
Services.io.newURI(url),
ssm.DISALLOW_INHERIT_PRINCIPAL
);
} catch (e) {
return false;
}
return true;
}
/**
* Checks whether this context has the given permission.
*
* @param {string} _permission
* The name of the permission to check.
*
* @returns {boolean} True if the context has the given permission.
*/
hasPermission(_permission) {
return false;
}
/**
* Checks whether the given permission can be dynamically revoked or
* granted.
*
* @param {string} _permission
* The name of the permission to check.
*
* @returns {boolean} True if the given permission is revokable.
*/
isPermissionRevokable(_permission) {
return false;
}
/**
* Returns an error result object with the given message, for return
* by Type normalization functions.
*
* If the context has a `currentTarget` value, this is prepended to
* the message to indicate the location of the error.
*
* @param {string | Function} errorMessage
* The error message which will be displayed when this is the
* only possible matching schema. If a function is passed, it
* will be evaluated when the error string is first needed, and
* must return a string.
* @param {string | Function} choicesMessage
* The message describing the valid what constitutes a valid
* value for this schema, which will be displayed when multiple
* schema choices are available and none match.
*
* A caller may pass `null` to prevent a choice from being
* added, but this should *only* be done from code processing a
* choices type.
* @param {boolean} [warning = false]
* If true, make message prefixed `Warning`. If false, make message
* prefixed `Error`
* @returns {object}
*/
error(errorMessage, choicesMessage = undefined, warning = false) {
if (choicesMessage !== null) {
let { choicePath } = this;
if (choicePath) {
choicesMessage = `.${choicePath} must ${choicesMessage}`;
}
this.currentChoices.add(choicesMessage);
}
if (this.currentTarget) {
let { currentTarget } = this;
return {
error: () =>
`${
warning ? "Warning" : "Error"
} processing ${currentTarget}: ${forceString(errorMessage)}`,
};
}
return { error: errorMessage };
}
/**
* Creates an `Error` object belonging to the current unprivileged
* scope. If there is no unprivileged scope associated with this
* context, the message is returned as a string.
*
* If the context has a `currentTarget` value, this is prepended to
* the message, in the same way as for the `error` method.
*
* @param {string} message
* @param {object} [options]
* @param {boolean} [options.warning = false]
* @returns {Error}
*/
makeError(message, { warning = false } = {}) {
let error = forceString(this.error(message, null, warning).error);
if (this.cloneScope) {
return new this.cloneScope.Error(error);
}
return error;
}
/**
* Logs the given error to the console. May be overridden to enable
* custom logging.
*
* @param {Error|string} error
*/
logError(error) {
if (this.cloneScope) {
Cu.reportError(
// Error objects logged using Cu.reportError are not associated
// to the related innerWindowID. This results in a leaked docshell
// since consoleService cannot release the error object when the
// extension global is destroyed.
typeof error == "string" ? error : String(error),
// Report the error with the appropriate stack trace when the
// is related to an actual extension global (instead of being
// related to a manifest validation).
this.principal && ChromeUtils.getCallerLocation(this.principal)
);
} else {
Cu.reportError(error);
}
}
/**
* Logs a warning. An error might be thrown when we treat warnings as errors.
*
* @param {string} warningMessage
*/
logWarning(warningMessage) {
let error = this.makeError(warningMessage, { warning: true });
this.logError(error);
if (lazy.treatWarningsAsErrors) {
// This pref is false by default, and true by default in tests to
// discourage the use of deprecated APIs in our unit tests.
// If a warning is an expected part of a test, temporarily set the pref
// to false, e.g. with the ExtensionTestUtils.failOnSchemaWarnings helper.
Services.console.logStringMessage(
"Treating warning as error because the preference " +
"extensions.webextensions.warnings-as-errors is set to true"
);
if (typeof error === "string") {
error = new Error(error);
}
throw error;
}
}
/**
* Returns the name of the value currently being normalized. For a
* nested object, this is usually approximately equivalent to the
* JavaScript property accessor for that property. Given:
*
* { foo: { bar: [{ baz: x }] } }
*
* When processing the value for `x`, the currentTarget is
* 'foo.bar.0.baz'
*/
get currentTarget() {
return this.path.join(".");
}
/**
* Executes the given callback, and returns an array of choice strings
* passed to {@see #error} during its execution.
*
* @param {Function} callback
* @returns {object}
* An object with a `result` property containing the return
* value of the callback, and a `choice` property containing
* an array of choices.
*/
withChoices(callback) {
let { currentChoices, choicePathIndex } = this;
let choices = new Set();
this.currentChoices = choices;
this.choicePathIndex = this.path.length;
try {
let result = callback();
return { result, choices };
} finally {
this.currentChoices = currentChoices;
this.choicePathIndex = choicePathIndex;
if (choices.size == 1) {
for (let choice of choices) {
currentChoices.add(choice);
}
} else if (choices.size) {
this.error(null, () => {
let array = Array.from(choices, forceString);
let n = array.length - 1;
array[n] = `or ${array[n]}`;
return `must either [${array.join(", ")}]`;
});
}
}
}
/**
* Appends the given component to the `currentTarget` path to indicate
* that it is being processed, calls the given callback function, and
* then restores the original path.
*
* This is used to identify the path of the property being processed
* when reporting type errors.
*
* @param {string} component
* @param {Function} callback
* @returns {*}
*/
withPath(component, callback) {
this.path.push(component);
try {
return callback();
} finally {
this.path.pop();
}
}
matchManifestVersion(entry) {
let { manifestVersion } = this;
return (
manifestVersion >= entry.min_manifest_version &&
manifestVersion <= entry.max_manifest_version
);
}
}
/**
* Represents a schema entry to be injected into an object. Handles the
* injection, revocation, and permissions of said entry.
*
* @param {InjectionContext} context
* The injection context for the entry.
* @param {Entry} entry
* The entry to inject.
* @param {object} parentObject
* The object into which to inject this entry.
* @param {string} name
* The property name at which to inject this entry.
* @param {Array<string>} path
* The full path from the root entry to this entry.
* @param {Entry} parentEntry
* The parent entry for the injected entry.
*/
class InjectionEntry {
constructor(context, entry, parentObj, name, path, parentEntry) {
this.context = context;
this.entry = entry;
this.parentObj = parentObj;
this.name = name;
this.path = path;
this.parentEntry = parentEntry;
this.injected = null;
this.lazyInjected = null;
}
/**
* @property {Array<string>} allowedContexts
* The list of allowed contexts into which the entry may be
* injected.
*/
get allowedContexts() {
let { allowedContexts } = this.entry;
if (allowedContexts.length) {
return allowedContexts;
}
return this.parentEntry.defaultContexts;
}
/**
* @property {boolean} isRevokable
* Returns true if this entry may be dynamically injected or
* revoked based on its permissions.
*/
get isRevokable() {
return (
this.entry.permissions &&
this.entry.permissions.some(perm =>
this.context.isPermissionRevokable(perm)
)
);
}
/**
* @property {boolean} hasPermission
* Returns true if the injection context currently has the
* appropriate permissions to access this entry.
*/
get hasPermission() {
return (
!this.entry.permissions ||
this.entry.permissions.some(perm => this.context.hasPermission(perm))
);
}
/**
* @property {boolean} shouldInject
* Returns true if this entry should be injected in the given
* context, without respect to permissions.
*/
get shouldInject() {
return (
this.context.matchManifestVersion(this.entry) &&
this.context.shouldInject(
this.path.join("."),
this.name,
this.allowedContexts
)
);
}
/**
* Revokes this entry, removing its property from its parent object,
* and invalidating its wrappers.
*/
revoke() {
if (this.lazyInjected) {
this.lazyInjected = false;
} else if (this.injected) {
if (this.injected.revoke) {
this.injected.revoke();
}
try {
let unwrapped = ChromeUtils.waiveXrays(this.parentObj);
delete unwrapped[this.name];
} catch (e) {
Cu.reportError(e);
}
let { value } = this.injected.descriptor;
if (value) {
this.context.revokeChildren(value);
}
this.injected = null;
}
}
/**
* Returns a property descriptor object for this entry, if it should
* be injected, or undefined if it should not.
*
* @returns {object?}
* A property descriptor object, or undefined if the property
* should be removed.
*/
getDescriptor() {
this.lazyInjected = false;
if (this.injected) {
let path = [...this.path, this.name];
throw new Error(
`Attempting to re-inject already injected entry: ${path.join(".")}`
);
}
if (!this.shouldInject) {
return;
}
if (this.isRevokable) {
this.context.pendingEntries.add(this);
}
if (!this.hasPermission) {
return;
}
this.injected = this.entry.getDescriptor(this.path, this.context);
if (!this.injected) {
return undefined;
}
return this.injected.descriptor;
}
/**
* Injects a lazy property descriptor into the parent object which
* checks permissions and eligibility for injection the first time it
* is accessed.
*/
lazyInject() {
if (this.lazyInjected || this.injected) {
let path = [...this.path, this.name];
throw new Error(
`Attempting to re-lazy-inject already injected entry: ${path.join(".")}`
);
}
this.lazyInjected = true;
exportLazyProperty(this.parentObj, this.name, () => {
if (this.lazyInjected) {
return this.getDescriptor();
}
});
}
/**
* Injects or revokes this entry if its current state does not match
* the context's current permissions.
*/
permissionsChanged() {
if (this.injected) {
this.maybeRevoke();
} else {
this.maybeInject();
}
}
maybeInject() {
if (!this.injected && !this.lazyInjected) {
this.lazyInject();
}
}
maybeRevoke() {
if (this.injected && !this.hasPermission) {
this.revoke();
}
}
}
/**
* Holds methods that run the actual implementation of the extension APIs. These
* methods are only called if the extension API invocation matches the signature
* as defined in the schema. Otherwise an error is reported to the context.
*/
class InjectionContext extends Context {
constructor(params, schemaRoot) {
super(params, CONTEXT_FOR_INJECTION);
this.schemaRoot = schemaRoot;
this.pendingEntries = new Set();
this.children = new DefaultWeakMap(() => new Map());
this.injectedRoots = new Set();
if (params.setPermissionsChangedCallback) {
params.setPermissionsChangedCallback(this.permissionsChanged.bind(this));
}
}
/**
* Check whether the API should be injected.
*
* @abstract
* @param {string} _namespace The namespace of the API. This may contain dots,
* e.g. in the case of "devtools.inspectedWindow".
* @param {string?} _name The name of the property in the namespace.
* `null` if we are checking whether the namespace should be injected.
* @param {Array<string>} _allowedContexts A list of additional contexts in
* which this API should be available. May include any of:
* "main" - The main chrome browser process.
* "addon" - An addon process.
* "content" - A content process.
* @returns {boolean} Whether the API should be injected.
*/
shouldInject(_namespace, _name, _allowedContexts) {
throw new Error("Not implemented");
}
/**
* Generate the implementation for `namespace`.`name`.
*
* @abstract
* @param {string} _namespace The full path to the namespace of the API, minus
* the name of the method or property. E.g. "storage.local".
* @param {string} _name The name of the method, property or event.
* @returns {import("ExtensionCommon.sys.mjs").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|Namespace} 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 {Partial<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) {
// TODO bug 1797376: Despite the name, this format is NOT a "hostname",
// but hostname + port and may fail with IPv6. Use canonicalDomain instead.
let valid = true;
try {
valid = new URL(`http://${string}`).host === string;
} catch (e) {
valid = false;
}
if (!valid) {
throw new Error(`Invalid hostname ${string}`);
}
return string;
},
canonicalDomain(string) {
let valid;
try {
valid = new URL(`http://${string}`).hostname === string;
} catch (e) {
valid = false;
}
if (!valid) {
// Require the input to be a canonical domain.
// Rejects obvious non-domains such as URLs,
// but also catches non-IDN (punycode) domains.
throw new Error(`Invalid domain ${string}`);
}
return string;
},
url(string, context) {
let url = new URL(string).href;
if (!context.checkLoadURL(url)) {
throw new Error(`Access denied for URL ${url}`);
}
return url;
},
origin(string, context) {
let url;
try {
url = new URL(string);
} catch (e) {
throw new Error(`Invalid origin: ${string}`);
}
if (!/^https?:/.test(url.protocol)) {
throw new Error(`Invalid origin must be http or https for URL ${string}`);
}
// url.origin is punycode so a direct check against string wont work.
// url.href appends a slash even if not in the original string, we we
// additionally check that string does not end in slash.
if (string.endsWith("/") || url.href != new URL(url.origin).href) {
throw new Error(
`Invalid origin for URL ${string}, replace with origin ${url.origin}`
);
}
if (!context.checkLoadURL(url.origin)) {
throw new Error(`Access denied for URL ${url}`);
}
return url.origin;
},
relativeUrl(string, context) {
if (!context.url) {
// If there's no context URL, return relative URLs unresolved, and
// skip security checks for them.
try {
new URL(string);
} catch (e) {
return string;
}
}
let url = new URL(string, context.url).href;
if (!context.checkLoadURL(url)) {
throw new Error(`Access denied for URL ${url}`);
}
return url;
},
strictRelativeUrl(string, context) {
void FORMATS.unresolvedRelativeUrl(string);
return FORMATS.relativeUrl(string, context);
},
unresolvedRelativeUrl(string) {
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) {
// 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(Date.parse(string))) {
throw new Error(`Invalid date string ${string}`);
}
return string;
},
manifestShortcutKey(string, { extensionManifest = true } = {}) {
const result = lazy.ShortcutUtils.validate(string, { extensionManifest });
if (result == lazy.ShortcutUtils.IS_VALID) {
return string;
}
const SEE_DETAILS =
`For details see: ` +
let errorMessage;
switch (result) {
case lazy.ShortcutUtils.INVALID_KEY_IN_EXTENSION_MANIFEST:
errorMessage =
`Value "${string}" must not include extended F13-F19 keys. ` +
`F13-F19 keys can only be used for user-defined keyboard shortcuts in about:addons ` +
`"Manage Extension Shortcuts". ${SEE_DETAILS}`;
break;
default:
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. ${SEE_DETAILS}`;
}
throw new Error(errorMessage);
},
manifestShortcutKeyOrEmpty(string) {
// manifestShortcutKey is the formatter applied to the manifest keys assigned
// through the manifest, while manifestShortcutKeyOrEmpty is the formatter
// used by the commands.update API method JSONSchema.
return string === ""
? ""
: FORMATS.manifestShortcutKey(string, { extensionManifest: false });
},
versionString(string, context) {
const parts = string.split(".");
if (
// We accept up to 4 numbers.
parts.length > 4 ||
// Non-zero values cannot start with 0 and we allow numbers up to 9 digits.
parts.some(part => !/^(0|[1-9][0-9]{0,8})$/.test(part))
) {
context.logWarning(
`version must be a version string consisting of at most 4 integers ` +
`of at most 9 digits without leading zeros, and separated with dots`
);
}
// The idea is to only emit a warning when the version string does not
// match the simple format we want to encourage developers to use. Given
// the version is required, we always accept the value as is.
return string;
},
};
// Schema files contain namespaces, and each namespace contains types,
// properties, functions, and events. An Entry is a base class for
// types, properties, functions, and events.
class Entry {
/** @type {Entry} */
fallbackEntry;
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 {any} [value]
*/
logDeprecation(context, value = null) {
let message = "This property is deprecated";
if (typeof this.deprecated == "string") {
message = this.deprecated;
if (message.includes("${value}")) {
try {
value = JSON.stringify(value);
} catch (e) {
value = String(value);
}
message = message.replace(/\$\{value\}/g, () => value);
}
}
context.logWarning(message);
}
/**
* Checks whether the entry is deprecated and, if so, logs a
* deprecation message.
*
* @param {Context} context
* @param {any} [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 {Iterable<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
*
* @param {string} _baseType
*/
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() {
return true;
}
}
// An untagged union type.
class ChoiceType extends Type {
static get EXTRA_PROPERTIES() {
return ["choices", ...super.EXTRA_PROPERTIES];
}
/** @type {(root, schema, path, extraProperties?: Iterable) => ChoiceType} */
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;