--- a/toolkit/components/extensions/Schemas.jsm
+++ b/toolkit/components/extensions/Schemas.jsm
@@ -11,20 +11,22 @@ const Cr = Components.results;
Cu.import("resource://gre/modules/ExtensionUtils.jsm");
var {
instanceOf,
} = ExtensionUtils;
this.EXPORTED_SYMBOLS = ["Schemas"];
-/* globals Schemas */
+/* globals Schemas, URL */
Cu.import("resource://gre/modules/NetUtil.jsm");
+Cu.importGlobalProperties(["URL"]);
+
function readJSON(uri) {
return new Promise((resolve, reject) => {
NetUtil.asyncFetch({uri, loadUsingSystemPrincipal: true}, (inputStream, status) => {
if (!Components.isSuccessCode(status)) {
reject(new Error(status));
return;
}
try {
@@ -69,16 +71,46 @@ function getValueBaseType(value) {
} else if (t == "number") {
if (value % 1 == 0) {
return "integer";
}
}
return t;
}
+const FORMATS = {
+ url(string, context) {
+ let url = new URL(string).href;
+
+ context.checkLoadURL(url);
+ return url;
+ },
+
+ relativeUrl(string, context) {
+ let url = new URL(string, context.url).href;
+
+ context.checkLoadURL(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`);
+ },
+};
+
// 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 {
// Injects JS values for the entry into the extension API
// namespace. The default implementation is to do
// nothing. |wrapperFuncs| is used to call the actual implementation
// of a given function or event. It's an object with properties
@@ -131,19 +163,19 @@ class AnyType extends Type {
// An untagged union type.
class ChoiceType extends Type {
constructor(choices) {
super();
this.choices = choices;
}
- normalize(value) {
+ normalize(value, context) {
for (let choice of this.choices) {
- let r = choice.normalize(value);
+ let r = choice.normalize(value, context);
if (!r.error) {
return r;
}
}
return {error: "No valid choice"};
}
@@ -157,45 +189,46 @@ class RefType extends Type {
// For a reference to a type named T declared in namespace NS,
// namespaceName will be NS and reference will be T.
constructor(namespaceName, reference) {
super();
this.namespaceName = namespaceName;
this.reference = reference;
}
- normalize(value) {
+ normalize(value, context) {
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);
+ return type.normalize(value, context);
}
checkBaseType(baseType) {
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.checkBaseType(baseType);
}
}
class StringType extends Type {
- constructor(enumeration, minLength, maxLength, pattern) {
+ constructor(enumeration, minLength, maxLength, pattern, format) {
super();
this.enumeration = enumeration;
this.minLength = minLength;
this.maxLength = maxLength;
this.pattern = pattern;
+ this.format = format;
}
- normalize(value) {
+ normalize(value, context) {
let r = this.normalizeBase("string", value);
if (r.error) {
return r;
}
if (this.enumeration) {
if (this.enumeration.includes(value)) {
return {value};
@@ -209,16 +242,24 @@ class StringType extends Type {
if (value.length > this.maxLength) {
return {error: `String ${JSON.stringify(value)} is too long (must be ${this.maxLength})`};
}
if (this.pattern && !this.pattern.test(value)) {
return {error: `String ${JSON.stringify(value)} must match ${this.pattern}`};
}
+ if (this.format) {
+ try {
+ r.value = this.format(r.value, context);
+ } catch (e) {
+ return {error: String(e)};
+ }
+ }
+
return r;
}
checkBaseType(baseType) {
return baseType == "string";
}
inject(name, dest, wrapperFuncs) {
@@ -240,17 +281,17 @@ class ObjectType extends Type {
this.patternProperties = patternProperties;
this.isInstanceOf = isInstanceOf;
}
checkBaseType(baseType) {
return baseType == "object";
}
- normalize(value) {
+ normalize(value, context) {
let v = this.normalizeBase("object", value);
if (v.error) {
return v;
}
if (this.isInstanceOf) {
if (Object.keys(this.properties).length ||
this.patternProperties.length ||
@@ -304,17 +345,17 @@ class ObjectType extends Type {
if (unsupported) {
if (prop in properties) {
return {error: `Property "${prop}" is unsupported by Firefox`};
}
} else if (prop in properties) {
if (optional && (properties[prop] === null || properties[prop] === undefined)) {
result[prop] = null;
} else {
- let r = type.normalize(properties[prop]);
+ let r = type.normalize(properties[prop], context);
if (r.error) {
return r;
}
result[prop] = r.value;
}
} else if (!optional) {
return {error: `Property "${prop}" is required`};
} else {
@@ -342,17 +383,17 @@ class ObjectType extends Type {
return error;
}
continue outer;
}
}
if (this.additionalProperties) {
- let r = this.additionalProperties.normalize(properties[prop]);
+ let r = this.additionalProperties.normalize(properties[prop], context);
if (r.error) {
return r;
}
result[prop] = r.value;
} else {
return {error: `Unexpected property "${prop}"`};
}
}
@@ -426,25 +467,25 @@ class BooleanType extends Type {
class ArrayType extends Type {
constructor(itemType, minItems, maxItems) {
super();
this.itemType = itemType;
this.minItems = minItems;
this.maxItems = maxItems;
}
- normalize(value) {
+ normalize(value, context) {
let v = this.normalizeBase("array", value);
if (v.error) {
return v;
}
let result = [];
for (let element of value) {
- element = this.itemType.normalize(element);
+ element = this.itemType.normalize(element, context);
if (element.error) {
return element;
}
result.push(element.value);
}
if (result.length < this.minItems) {
return {error: `Array requires at least ${this.minItems} items; you have ${result.length}`};
@@ -513,17 +554,17 @@ class CallEntry extends Entry {
this.allowAmbiguousOptionalArguments = allowAmbiguousOptionalArguments;
}
throwError(global, msg) {
global = Cu.getGlobalForObject(global);
throw new global.Error(`${msg} for ${this.namespaceName}.${this.name}.`);
}
- checkParameters(args, global) {
+ checkParameters(args, global, context) {
let fixedArgs = [];
// First we create a new array, fixedArgs, that is the same as
// |args| but with null values in place of omitted optional
// parameters.
let check = (parameterIndex, argIndex) => {
if (parameterIndex == this.parameters.length) {
if (argIndex == args.length) {
@@ -571,17 +612,17 @@ class CallEntry extends Entry {
}
// Now we normalize (and fully type check) all non-omitted arguments.
fixedArgs = fixedArgs.map((arg, parameterIndex) => {
if (arg === null) {
return null;
} else {
let parameter = this.parameters[parameterIndex];
- let r = parameter.type.normalize(arg);
+ let r = parameter.type.normalize(arg, context);
if (r.error) {
this.throwError(global, `Type error for parameter ${parameter.name} (${r.error})`);
}
return r.value;
}
});
return fixedArgs;
@@ -596,17 +637,17 @@ class FunctionEntry extends CallEntry {
}
inject(name, dest, wrapperFuncs) {
if (this.unsupported) {
return;
}
let stub = (...args) => {
- let actuals = this.checkParameters(args, dest);
+ let actuals = this.checkParameters(args, dest, wrapperFuncs);
return wrapperFuncs.callFunction(this.namespaceName, name, actuals);
};
Cu.exportFunction(stub, dest, {defineAs: name});
}
}
// Represents an "event" defined in a schema namespace.
class Event extends CallEntry {
@@ -697,43 +738,53 @@ this.Schemas = {
if (!("type" in type)) {
throw new Error(`Unexpected value for type: ${JSON.stringify(type)}`);
}
allowedProperties.add("type");
// Otherwise it's a normal type...
if (type.type == "string") {
- checkTypeProperties("enum", "minLength", "maxLength", "pattern");
+ checkTypeProperties("enum", "minLength", "maxLength", "pattern", "format");
let enumeration = type.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;
} else {
return e;
}
});
}
+
let pattern = null;
if (type.pattern) {
try {
pattern = parsePattern(type.pattern);
} catch (e) {
throw new Error(`Internal error: Invalid pattern ${JSON.stringify(type.pattern)}`);
}
}
+
+ let format = null;
+ if (type.format) {
+ if (!(type.format in FORMATS)) {
+ throw new Error(`Internal error: Invalid string format ${type.format}`);
+ }
+ format = FORMATS[type.format];
+ }
return new StringType(enumeration,
type.minLength || 0,
type.maxLength || Infinity,
- pattern);
+ pattern,
+ format);
} else if (type.type == "object") {
let parseProperty = type => {
return {
type: this.parseType(namespaceName, type,
["optional", "unsupported", "deprecated"]),
optional: type.optional || false,
unsupported: type.unsupported || false,
};
--- a/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js
@@ -145,16 +145,32 @@ let json = [
{
name: "pattern",
type: "function",
parameters: [
{name: "arg", type: "string", pattern: "(?i)^[0-9a-f]+$"},
],
},
+
+ {
+ name: "format",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ type: "object",
+ properties: {
+ url: {type: "string", "format": "url", "optional": true},
+ relativeUrl: {type: "string", "format": "relativeUrl", "optional": true},
+ strictRelativeUrl: {type: "string", "format": "strictRelativeUrl", "optional": true},
+ },
+ },
+ ],
+ },
],
events: [
{
name: "onFoo",
type: "function",
},
@@ -177,16 +193,25 @@ function tally(kind, ns, name, args) {
}
function verify(...args) {
do_check_eq(JSON.stringify(tallied), JSON.stringify(args));
tallied = null;
}
let wrapper = {
+ url: "moz-extension://b66e3509-cdb3-44f6-8eb8-c8b39b3a1d27/",
+
+ checkLoadURL(url) {
+ if (url.startsWith("chrome:")) {
+ throw new Error("Access denied");
+ }
+ return url;
+ },
+
callFunction(ns, name, args) {
tally("call", ns, name, args);
},
addListener(ns, name, listener, args) {
tally("addListener", ns, name, [listener, args]);
},
removeListener(ns, name, listener) {
@@ -349,16 +374,41 @@ add_task(function* () {
root.testing.pattern("DEADbeef");
verify("call", "testing", "pattern", ["DEADbeef"]);
tallied = null;
Assert.throws(() => root.testing.pattern("DEADcow"),
/String "DEADcow" must match \/\^\[0-9a-f\]\+\$\/i/,
"should throw for non-match");
+ root.testing.format({url: "http://foo/bar",
+ relativeUrl: "http://foo/bar"});
+ verify("call", "testing", "format", [{url: "http://foo/bar",
+ relativeUrl: "http://foo/bar",
+ strictRelativeUrl: null}]);
+ tallied = null;
+
+ root.testing.format({relativeUrl: "foo.html", strictRelativeUrl: "foo.html"});
+ verify("call", "testing", "format", [{url: null,
+ relativeUrl: `${wrapper.url}foo.html`,
+ strictRelativeUrl: `${wrapper.url}foo.html`}]);
+ tallied = null;
+
+ for (let format of ["url", "relativeUrl"]) {
+ Assert.throws(() => root.testing.format({[format]: "chrome://foo/content/"}),
+ /Access denied/,
+ "should throw for access denied");
+ }
+
+ for (let url of ["//foo.html", "http://foo/bar.html"]) {
+ Assert.throws(() => root.testing.format({strictRelativeUrl: url}),
+ /must be a relative URL/,
+ "should throw for non-relative URL");
+ }
+
root.testing.onFoo.addListener(f);
do_check_eq(JSON.stringify(tallied.slice(0, -1)), JSON.stringify(["addListener", "testing", "onFoo"]));
do_check_eq(tallied[3][0], f);
do_check_eq(JSON.stringify(tallied[3][1]), JSON.stringify([]));
tallied = null;
root.testing.onFoo.removeListener(f);
do_check_eq(JSON.stringify(tallied.slice(0, -1)), JSON.stringify(["removeListener", "testing", "onFoo"]));