Bug 1225715: Part 2 - Add string format support to schemas. r?billm draft
authorKris Maglione <maglione.k@gmail.com>
Thu, 21 Jan 2016 16:29:15 -0800
changeset 324462 7b6fcdd90fa71cd6aee8137bf7a97847685dc2ca
parent 324461 c5d132a20ecc6bd9f70cfaf7ea933926ee2bdc30
child 324463 4905fa3a603e1cd4c4e9ca98d2dfcffc7fa103f3
push id9920
push usermaglione.k@gmail.com
push dateSat, 23 Jan 2016 01:12:39 +0000
reviewersbillm
bugs1225715
milestone46.0a1
Bug 1225715: Part 2 - Add string format support to schemas. r?billm
toolkit/components/extensions/Schemas.jsm
toolkit/components/extensions/test/xpcshell/test_ext_schemas.js
--- 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"]));