summaryrefslogtreecommitdiffstats
path: root/toolkit/components/utils
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/utils')
-rw-r--r--toolkit/components/utils/ClientEnvironment.sys.mjs264
-rw-r--r--toolkit/components/utils/FilterExpressions.sys.mjs137
-rw-r--r--toolkit/components/utils/JsonSchemaValidator.sys.mjs587
-rw-r--r--toolkit/components/utils/Sampling.sys.mjs170
-rw-r--r--toolkit/components/utils/SimpleServices.sys.mjs202
-rw-r--r--toolkit/components/utils/WindowsInstallsInfo.sys.mjs91
-rw-r--r--toolkit/components/utils/WindowsVersionInfo.sys.mjs112
-rw-r--r--toolkit/components/utils/components.conf21
-rw-r--r--toolkit/components/utils/moz.build28
-rw-r--r--toolkit/components/utils/mozjexl.js1
-rw-r--r--toolkit/components/utils/test/unit/test_ClientEnvironment.js154
-rw-r--r--toolkit/components/utils/test/unit/test_FilterExpressions.js410
-rw-r--r--toolkit/components/utils/test/unit/test_JsonSchemaValidator.js1990
-rw-r--r--toolkit/components/utils/test/unit/test_Sampling.js127
-rw-r--r--toolkit/components/utils/test/unit/xpcshell.toml10
15 files changed, 4304 insertions, 0 deletions
diff --git a/toolkit/components/utils/ClientEnvironment.sys.mjs b/toolkit/components/utils/ClientEnvironment.sys.mjs
new file mode 100644
index 0000000000..7f1b4cb970
--- /dev/null
+++ b/toolkit/components/utils/ClientEnvironment.sys.mjs
@@ -0,0 +1,264 @@
+/* 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";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AttributionCode: "resource:///modules/AttributionCode.sys.mjs",
+ NormandyUtils: "resource://normandy/lib/NormandyUtils.sys.mjs",
+ ShellService: "resource:///modules/ShellService.sys.mjs",
+ TelemetryArchive: "resource://gre/modules/TelemetryArchive.sys.mjs",
+ TelemetryController: "resource://gre/modules/TelemetryController.sys.mjs",
+ UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
+ WindowsVersionInfo:
+ "resource://gre/modules/components-utils/WindowsVersionInfo.sys.mjs",
+});
+
+/**
+ * Create an object that provides general information about the client application.
+ *
+ * Components like Normandy RecipeRunner use this as part of the context for filter expressions,
+ * so avoid adding non-getter functions as attributes, as filter expressions
+ * cannot execute functions.
+ *
+ * Also note that, because filter expressions implicitly resolve promises, you
+ * can add getter functions that return promises for async data.
+ */
+export class ClientEnvironmentBase {
+ static get distribution() {
+ return Services.prefs
+ .getDefaultBranch(null)
+ .getCharPref("distribution.id", "default");
+ }
+
+ static get telemetry() {
+ return (async () => {
+ const pings = await lazy.TelemetryArchive.promiseArchivedPingList();
+
+ // get most recent ping per type
+ const mostRecentPings = {};
+ for (const ping of pings) {
+ if (ping.type in mostRecentPings) {
+ if (
+ mostRecentPings[ping.type].timestampCreated < ping.timestampCreated
+ ) {
+ mostRecentPings[ping.type] = ping;
+ }
+ } else {
+ mostRecentPings[ping.type] = ping;
+ }
+ }
+
+ const telemetry = {};
+ for (const key in mostRecentPings) {
+ const ping = mostRecentPings[key];
+ telemetry[ping.type] =
+ await lazy.TelemetryArchive.promiseArchivedPingById(ping.id);
+ }
+ return telemetry;
+ })();
+ }
+
+ static get liveTelemetry() {
+ // Construct a proxy object that forwards access to the main ping, and
+ // throws errors for other ping types. The intent is to allow using
+ // `telemetry` and `liveTelemetry` in similar ways, but to fail fast if
+ // the wrong telemetry types are accessed.
+ let target = {};
+ try {
+ target.main = lazy.TelemetryController.getCurrentPingData();
+ } catch (err) {
+ console.error(err);
+ }
+
+ return new Proxy(target, {
+ get(target, prop, receiver) {
+ if (prop == "main") {
+ return target.main;
+ }
+ if (prop == "then") {
+ // this isn't a Promise, but it's not a problem to check
+ return undefined;
+ }
+ throw new Error(
+ `Live telemetry only includes the main ping, not the ${prop} ping`
+ );
+ },
+ has(target, prop) {
+ return prop == "main";
+ },
+ });
+ }
+
+ // Note that we intend to replace usages of this with client_id in https://bugzilla.mozilla.org/show_bug.cgi?id=1542955
+ static get randomizationId() {
+ let id = Services.prefs.getCharPref("app.normandy.user_id", "");
+ if (!id) {
+ id = lazy.NormandyUtils.generateUuid();
+ Services.prefs.setCharPref("app.normandy.user_id", id);
+ }
+ return id;
+ }
+
+ static get version() {
+ return AppConstants.MOZ_APP_VERSION_DISPLAY;
+ }
+
+ static get channel() {
+ return lazy.UpdateUtils.getUpdateChannel(false);
+ }
+
+ static get isDefaultBrowser() {
+ return lazy.ShellService.isDefaultBrowser();
+ }
+
+ static get searchEngine() {
+ return (async () => {
+ const defaultEngineInfo = await Services.search.getDefault();
+ return defaultEngineInfo.telemetryId;
+ })();
+ }
+
+ static get syncSetup() {
+ return Services.prefs.prefHasUserValue("services.sync.username");
+ }
+
+ static get syncDesktopDevices() {
+ return Services.prefs.getIntPref(
+ "services.sync.clients.devices.desktop",
+ 0
+ );
+ }
+
+ static get syncMobileDevices() {
+ return Services.prefs.getIntPref("services.sync.clients.devices.mobile", 0);
+ }
+
+ static get syncTotalDevices() {
+ return this.syncDesktopDevices + this.syncMobileDevices;
+ }
+
+ static get addons() {
+ return (async () => {
+ const addons = await lazy.AddonManager.getAllAddons();
+ return addons.reduce((acc, addon) => {
+ const {
+ id,
+ isActive,
+ name,
+ type,
+ version,
+ installDate: installDateN,
+ } = addon;
+ const installDate = new Date(installDateN);
+ acc[id] = { id, isActive, name, type, version, installDate };
+ return acc;
+ }, {});
+ })();
+ }
+
+ static get plugins() {
+ return (async () => {
+ const plugins = await lazy.AddonManager.getAddonsByTypes(["plugin"]);
+ return plugins.reduce((acc, plugin) => {
+ const { name, description, version } = plugin;
+ acc[name] = { name, description, version };
+ return acc;
+ }, {});
+ })();
+ }
+
+ static get locale() {
+ return Services.locale.appLocaleAsBCP47;
+ }
+
+ static get doNotTrack() {
+ return Services.prefs.getBoolPref(
+ "privacy.donottrackheader.enabled",
+ false
+ );
+ }
+
+ static get os() {
+ function coerceToNumber(version) {
+ const parts = version.split(".");
+ return parseFloat(parts.slice(0, 2).join("."));
+ }
+
+ function getOsVersion() {
+ let version = null;
+ try {
+ version = Services.sysinfo.getProperty("version", null);
+ } catch (_e) {
+ // getProperty can throw if the version does not exist
+ }
+ if (version) {
+ version = coerceToNumber(version);
+ }
+ return version;
+ }
+
+ let osInfo = {
+ isWindows: AppConstants.platform == "win",
+ isMac: AppConstants.platform === "macosx",
+ isLinux: AppConstants.platform === "linux",
+
+ get windowsVersion() {
+ if (!osInfo.isWindows) {
+ return null;
+ }
+ return getOsVersion();
+ },
+
+ /**
+ * Gets the windows build number by querying the OS directly. The initial
+ * version was copied from toolkit/components/telemetry/app/TelemetryEnvironment.jsm
+ * @returns {number | null} The build number, or null on non-Windows platform or if there is an error.
+ */
+ get windowsBuildNumber() {
+ if (!osInfo.isWindows) {
+ return null;
+ }
+
+ return lazy.WindowsVersionInfo.get({ throwOnError: false }).buildNumber;
+ },
+
+ get macVersion() {
+ const darwinVersion = osInfo.darwinVersion;
+ // Versions of OSX with Darwin < 5 don't follow this pattern
+ if (darwinVersion >= 5) {
+ // OSX 10.1 used Darwin 5, OSX 10.2 used Darwin 6, and so on.
+ const intPart = Math.floor(darwinVersion);
+ return 10 + 0.1 * (intPart - 4);
+ }
+ return null;
+ },
+
+ get darwinVersion() {
+ if (!osInfo.isMac) {
+ return null;
+ }
+ return getOsVersion();
+ },
+
+ // Version information on linux is a lot harder and a lot less useful, so
+ // don't do anything about it here.
+ };
+
+ return osInfo;
+ }
+
+ static get attribution() {
+ return lazy.AttributionCode.getAttrDataAsync();
+ }
+
+ static get appinfo() {
+ Services.appinfo.QueryInterface(Ci.nsIXULAppInfo);
+ Services.appinfo.QueryInterface(Ci.nsIPlatformInfo);
+ return Services.appinfo;
+ }
+}
diff --git a/toolkit/components/utils/FilterExpressions.sys.mjs b/toolkit/components/utils/FilterExpressions.sys.mjs
new file mode 100644
index 0000000000..0dba44ec70
--- /dev/null
+++ b/toolkit/components/utils/FilterExpressions.sys.mjs
@@ -0,0 +1,137 @@
+/* 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/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Sampling: "resource://gre/modules/components-utils/Sampling.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "mozjexl",
+ "resource://gre/modules/components-utils/mozjexl.js"
+);
+
+function getPrefValue(prefKey, defaultValue) {
+ switch (Services.prefs.getPrefType(prefKey)) {
+ case Ci.nsIPrefBranch.PREF_STRING:
+ return Services.prefs.getStringPref(prefKey);
+
+ case Ci.nsIPrefBranch.PREF_INT:
+ return Services.prefs.getIntPref(prefKey);
+
+ case Ci.nsIPrefBranch.PREF_BOOL:
+ return Services.prefs.getBoolPref(prefKey);
+
+ case Ci.nsIPrefBranch.PREF_INVALID:
+ return defaultValue;
+
+ default:
+ throw new Error(`Error getting pref ${prefKey}.`);
+ }
+}
+
+ChromeUtils.defineLazyGetter(lazy, "jexl", () => {
+ const jexl = new lazy.mozjexl.Jexl();
+ jexl.addTransforms({
+ date: dateString => new Date(dateString),
+ stableSample: lazy.Sampling.stableSample,
+ bucketSample: lazy.Sampling.bucketSample,
+ preferenceValue: getPrefValue,
+ preferenceIsUserSet: prefKey => Services.prefs.prefHasUserValue(prefKey),
+ preferenceExists: prefKey =>
+ Services.prefs.getPrefType(prefKey) != Ci.nsIPrefBranch.PREF_INVALID,
+ keys,
+ length,
+ mapToProperty,
+ regExpMatch,
+ versionCompare,
+ });
+ jexl.addBinaryOp("intersect", 40, operatorIntersect);
+ return jexl;
+});
+
+export var FilterExpressions = {
+ getAvailableTransforms() {
+ return Object.keys(lazy.jexl._transforms);
+ },
+
+ eval(expr, context = {}) {
+ const onelineExpr = expr.replace(/[\t\n\r]/g, " ");
+ return lazy.jexl.eval(onelineExpr, context);
+ },
+};
+
+/**
+ * Return an array of the given object's own keys (specifically, its enumerable
+ * properties), or undefined if the argument isn't an object.
+ * @param {Object} obj
+ * @return {Array[String]|undefined}
+ */
+function keys(obj) {
+ if (typeof obj !== "object" || obj === null) {
+ return undefined;
+ }
+
+ return Object.keys(obj);
+}
+
+/**
+ * Return the length of an array
+ * @param {Array} arr
+ * @return {number}
+ */
+function length(arr) {
+ return Array.isArray(arr) ? arr.length : undefined;
+}
+
+/**
+ * Given an input array and property name, return an array with each element of
+ * the original array replaced with the given property of that element.
+ * @param {Array} arr
+ * @param {string} prop
+ * @return {Array}
+ */
+function mapToProperty(arr, prop) {
+ return Array.isArray(arr) ? arr.map(elem => elem[prop]) : undefined;
+}
+
+/**
+ * Find all the values that are present in both lists. Returns undefined if
+ * the arguments are not both Arrays.
+ * @param {Array} listA
+ * @param {Array} listB
+ * @return {Array|undefined}
+ */
+function operatorIntersect(listA, listB) {
+ if (!Array.isArray(listA) || !Array.isArray(listB)) {
+ return undefined;
+ }
+
+ return listA.filter(item => listB.includes(item));
+}
+
+/**
+ * Matches a string against a regular expression. Returns null if there are
+ * no matches or an Array of matches.
+ * @param {string} str
+ * @param {string} pattern
+ * @param {string} flags
+ * @return {Array|null}
+ */
+function regExpMatch(str, pattern, flags) {
+ const re = new RegExp(pattern, flags);
+ return str.match(re);
+}
+
+/**
+ * Compares v1 to v2 and returns 0 if they are equal, a negative number if
+ * v1 < v2 or a positive number if v1 > v2.
+ * @param {string} v1
+ * @param {string} v2
+ * @return {number}
+ */
+function versionCompare(v1, v2) {
+ return Services.vc.compare(v1, v2);
+}
diff --git a/toolkit/components/utils/JsonSchemaValidator.sys.mjs b/toolkit/components/utils/JsonSchemaValidator.sys.mjs
new file mode 100644
index 0000000000..529e60e310
--- /dev/null
+++ b/toolkit/components/utils/JsonSchemaValidator.sys.mjs
@@ -0,0 +1,587 @@
+/* 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/. */
+
+/* This file implements a not-quite standard JSON schema validator. It differs
+ * from the spec in a few ways:
+ *
+ * - the spec doesn't allow custom types to be defined, but this validator
+ * defines "URL", "URLorEmpty", "origin" etc.
+ * - Strings are automatically converted to `URL` objects for the appropriate
+ * types.
+ * - It doesn't support "pattern" when matching strings.
+ * - The boolean type accepts (and casts) 0 and 1 as valid values.
+ */
+
+const lazy = {};
+
+ChromeUtils.defineLazyGetter(lazy, "log", () => {
+ let { ConsoleAPI } = ChromeUtils.importESModule(
+ "resource://gre/modules/Console.sys.mjs"
+ );
+ return new ConsoleAPI({
+ prefix: "JsonSchemaValidator.jsm",
+ // tip: set maxLogLevel to "debug" and use log.debug() to create detailed
+ // messages during development. See LOG_LEVELS in Console.sys.mjs for details.
+ maxLogLevel: "error",
+ });
+});
+
+/**
+ * To validate a single value, use the static `JsonSchemaValidator.validate`
+ * method. If you need to validate multiple values, you instead might want to
+ * make a JsonSchemaValidator instance with the options you need and then call
+ * the `validate` instance method.
+ */
+export class JsonSchemaValidator {
+ /**
+ * Validates a value against a schema.
+ *
+ * @param {*} value
+ * The value to validate.
+ * @param {object} schema
+ * The schema to validate against.
+ * @param {boolean} allowArrayNonMatchingItems
+ * When true:
+ * Invalid items in arrays will be ignored, and they won't be included in
+ * result.parsedValue.
+ * When false:
+ * Invalid items in arrays will cause validation to fail.
+ * @param {boolean} allowExplicitUndefinedProperties
+ * When true:
+ * `someProperty: undefined` will be allowed for non-required properties.
+ * When false:
+ * `someProperty: undefined` will cause validation to fail even for
+ * properties that are not required.
+ * @param {boolean} allowNullAsUndefinedProperties
+ * When true:
+ * `someProperty: null` will be allowed for non-required properties whose
+ * expected types are non-null.
+ * When false:
+ * `someProperty: null` will cause validation to fail for non-required
+ * properties, except for properties whose expected types are null.
+ * @param {boolean} allowAdditionalProperties
+ * When true:
+ * Properties that are not defined in the schema will be ignored, and they
+ * won't be included in result.parsedValue.
+ * When false:
+ * Properties that are not defined in the schema will cause validation to
+ * fail.
+ * Note: Schema objects of type "object" can also contain a boolean property
+ * called `additionalProperties` that functions as a local version of this
+ * param. When true, extra properties will be allowed in the corresponding
+ * input objects regardless of `allowAdditionalProperties`, and as with
+ * `allowAdditionalProperties`, extra properties won't be included in
+ * `result.parsedValue`. (The inverse is not true: If a schema object
+ * defines `additionalProperties: false` but `allowAdditionalProperties`
+ * is true, extra properties will be allowed.)
+ * @return {object}
+ * The result of the validation, an object that looks like this:
+ *
+ * {
+ * valid,
+ * parsedValue,
+ * error: {
+ * message,
+ * rootValue,
+ * rootSchema,
+ * invalidValue,
+ * invalidPropertyNameComponents,
+ * }
+ * }
+ *
+ * {boolean} valid
+ * True if validation is successful, false if not.
+ * {*} parsedValue
+ * If validation is successful, this is the validated value. It can
+ * differ from the passed-in value in the following ways:
+ * * If a type in the schema is "URL" or "URLorEmpty", the passed-in
+ * value can use a string instead and it will be converted into a
+ * `URL` object in parsedValue.
+ * * Some of the `allow*` parameters control the properties that appear.
+ * See above.
+ * {Error} error
+ * If validation fails, `error` will be present. It contains a number of
+ * properties useful for understanding the validation failure.
+ * {string} error.message
+ * The validation failure message.
+ * {*} error.rootValue
+ * The passed-in value.
+ * {object} error.rootSchema
+ * The passed-in schema.
+ * {*} invalidValue
+ * The value that caused validation to fail. If the passed-in value is a
+ * scalar type, this will be the value itself. If the value is an object
+ * or array, it will be the specific nested value in the object or array
+ * that caused validation to fail.
+ * {array} invalidPropertyNameComponents
+ * If the passed-in value is an object or array, this will contain the
+ * names of the object properties or array indexes where invalidValue can
+ * be found. For example, assume the passed-in value is:
+ * { foo: { bar: { baz: 123 }}}
+ * And assume `baz` should be a string instead of a number. Then
+ * invalidValue will be 123, and invalidPropertyNameComponents will be
+ * ["foo", "bar", "baz"], indicating that the erroneous property in the
+ * passed-in object is `foo.bar.baz`.
+ */
+ static validate(
+ value,
+ schema,
+ {
+ allowArrayNonMatchingItems = false,
+ allowExplicitUndefinedProperties = false,
+ allowNullAsUndefinedProperties = false,
+ allowAdditionalProperties = false,
+ } = {}
+ ) {
+ let validator = new JsonSchemaValidator({
+ allowArrayNonMatchingItems,
+ allowExplicitUndefinedProperties,
+ allowNullAsUndefinedProperties,
+ allowAdditionalProperties,
+ });
+ return validator.validate(value, schema);
+ }
+
+ /**
+ * Constructor.
+ *
+ * @param {boolean} allowArrayNonMatchingItems
+ * See the static `validate` method above.
+ * @param {boolean} allowExplicitUndefinedProperties
+ * See the static `validate` method above.
+ * @param {boolean} allowNullAsUndefinedProperties
+ * See the static `validate` method above.
+ * @param {boolean} allowAdditionalProperties
+ * See the static `validate` method above.
+ */
+ constructor({
+ allowArrayNonMatchingItems = false,
+ allowExplicitUndefinedProperties = false,
+ allowNullAsUndefinedProperties = false,
+ allowAdditionalProperties = false,
+ } = {}) {
+ this.allowArrayNonMatchingItems = allowArrayNonMatchingItems;
+ this.allowExplicitUndefinedProperties = allowExplicitUndefinedProperties;
+ this.allowNullAsUndefinedProperties = allowNullAsUndefinedProperties;
+ this.allowAdditionalProperties = allowAdditionalProperties;
+ }
+
+ /**
+ * Validates a value against a schema.
+ *
+ * @param {*} value
+ * The value to validate.
+ * @param {object} schema
+ * The schema to validate against.
+ * @return {object}
+ * The result object. See the static `validate` method above.
+ */
+ validate(value, schema) {
+ return this._validateRecursive(value, schema, [], {
+ rootValue: value,
+ rootSchema: schema,
+ });
+ }
+
+ // eslint-disable-next-line complexity
+ _validateRecursive(param, properties, keyPath, state) {
+ lazy.log.debug(`checking @${param}@ for type ${properties.type}`);
+
+ if (Array.isArray(properties.type)) {
+ lazy.log.debug("type is an array");
+ // For an array of types, the value is valid if it matches any of the
+ // listed types. To check this, make versions of the object definition
+ // that include only one type at a time, and check the value against each
+ // one.
+ for (const type of properties.type) {
+ let typeProperties = Object.assign({}, properties, { type });
+ lazy.log.debug(`checking subtype ${type}`);
+ let result = this._validateRecursive(
+ param,
+ typeProperties,
+ keyPath,
+ state
+ );
+ if (result.valid) {
+ return result;
+ }
+ }
+ // None of the types matched
+ return {
+ valid: false,
+ error: new JsonSchemaValidatorError({
+ message:
+ `The value '${valueToString(param)}' does not match any type in ` +
+ valueToString(properties.type),
+ value: param,
+ keyPath,
+ state,
+ }),
+ };
+ }
+
+ switch (properties.type) {
+ case "boolean":
+ case "number":
+ case "integer":
+ case "string":
+ case "URL":
+ case "URLorEmpty":
+ case "origin":
+ case "null": {
+ let result = this._validateSimpleParam(
+ param,
+ properties.type,
+ keyPath,
+ state
+ );
+ if (!result.valid) {
+ return result;
+ }
+ if (properties.enum && typeof result.parsedValue !== "boolean") {
+ if (!properties.enum.includes(param)) {
+ return {
+ valid: false,
+ error: new JsonSchemaValidatorError({
+ message:
+ `The value '${valueToString(param)}' is not one of the ` +
+ `enumerated values ${valueToString(properties.enum)}`,
+ value: param,
+ keyPath,
+ state,
+ }),
+ };
+ }
+ }
+ return result;
+ }
+
+ case "array":
+ if (!Array.isArray(param)) {
+ return {
+ valid: false,
+ error: new JsonSchemaValidatorError({
+ message:
+ `The value '${valueToString(param)}' does not match the ` +
+ `expected type 'array'`,
+ value: param,
+ keyPath,
+ state,
+ }),
+ };
+ }
+
+ let parsedArray = [];
+ for (let i = 0; i < param.length; i++) {
+ let item = param[i];
+ lazy.log.debug(
+ `in array, checking @${item}@ for type ${properties.items.type}`
+ );
+ let result = this._validateRecursive(
+ item,
+ properties.items,
+ keyPath.concat(i),
+ state
+ );
+ if (!result.valid) {
+ if (
+ ("strict" in properties && properties.strict) ||
+ (!("strict" in properties) && !this.allowArrayNonMatchingItems)
+ ) {
+ return result;
+ }
+ continue;
+ }
+
+ parsedArray.push(result.parsedValue);
+ }
+
+ return { valid: true, parsedValue: parsedArray };
+
+ case "object": {
+ if (typeof param != "object" || !param) {
+ return {
+ valid: false,
+ error: new JsonSchemaValidatorError({
+ message:
+ `The value '${valueToString(param)}' does not match the ` +
+ `expected type 'object'`,
+ value: param,
+ keyPath,
+ state,
+ }),
+ };
+ }
+
+ let parsedObj = {};
+ let patternProperties = [];
+ if ("patternProperties" in properties) {
+ for (let prop of Object.keys(properties.patternProperties || {})) {
+ let pattern;
+ try {
+ pattern = new RegExp(prop);
+ } catch (e) {
+ throw new Error(
+ `Internal error: Invalid property pattern ${prop}`
+ );
+ }
+ patternProperties.push({
+ pattern,
+ schema: properties.patternProperties[prop],
+ });
+ }
+ }
+
+ if (properties.required) {
+ for (let required of properties.required) {
+ if (!(required in param)) {
+ lazy.log.error(`Object is missing required property ${required}`);
+ return {
+ valid: false,
+ error: new JsonSchemaValidatorError({
+ message: `Object is missing required property '${required}'`,
+ value: param,
+ keyPath,
+ state,
+ }),
+ };
+ }
+ }
+ }
+
+ for (let item of Object.keys(param)) {
+ let schema;
+ if (
+ "properties" in properties &&
+ properties.properties.hasOwnProperty(item)
+ ) {
+ schema = properties.properties[item];
+ } else if (patternProperties.length) {
+ for (let patternProperty of patternProperties) {
+ if (patternProperty.pattern.test(item)) {
+ schema = patternProperty.schema;
+ break;
+ }
+ }
+ }
+ if (!schema) {
+ let allowAdditionalProperties =
+ properties.additionalProperties ||
+ (!properties.strict && this.allowAdditionalProperties);
+ if (allowAdditionalProperties) {
+ continue;
+ }
+ return {
+ valid: false,
+ error: new JsonSchemaValidatorError({
+ message: `Object has unexpected property '${item}'`,
+ value: param,
+ keyPath,
+ state,
+ }),
+ };
+ }
+ let allowExplicitUndefinedProperties =
+ !properties.strict && this.allowExplicitUndefinedProperties;
+ let allowNullAsUndefinedProperties =
+ !properties.strict && this.allowNullAsUndefinedProperties;
+ let isUndefined =
+ (!allowExplicitUndefinedProperties && !(item in param)) ||
+ (allowExplicitUndefinedProperties && param[item] === undefined) ||
+ (allowNullAsUndefinedProperties && param[item] === null);
+ if (isUndefined) {
+ continue;
+ }
+ let result = this._validateRecursive(
+ param[item],
+ schema,
+ keyPath.concat(item),
+ state
+ );
+ if (!result.valid) {
+ return result;
+ }
+ parsedObj[item] = result.parsedValue;
+ }
+ return { valid: true, parsedValue: parsedObj };
+ }
+
+ case "JSON":
+ if (typeof param == "object") {
+ return { valid: true, parsedValue: param };
+ }
+ try {
+ let json = JSON.parse(param);
+ if (typeof json != "object") {
+ return {
+ valid: false,
+ error: new JsonSchemaValidatorError({
+ message: `JSON was not an object: ${valueToString(param)}`,
+ value: param,
+ keyPath,
+ state,
+ }),
+ };
+ }
+ return { valid: true, parsedValue: json };
+ } catch (e) {
+ lazy.log.error("JSON string couldn't be parsed");
+ return {
+ valid: false,
+ error: new JsonSchemaValidatorError({
+ message: `JSON string could not be parsed: ${valueToString(
+ param
+ )}`,
+ value: param,
+ keyPath,
+ state,
+ }),
+ };
+ }
+ }
+
+ return {
+ valid: false,
+ error: new JsonSchemaValidatorError({
+ message: `Invalid schema property type: ${valueToString(
+ properties.type
+ )}`,
+ value: param,
+ keyPath,
+ state,
+ }),
+ };
+ }
+
+ _validateSimpleParam(param, type, keyPath, state) {
+ let valid = false;
+ let parsedParam = param;
+ let error = undefined;
+
+ switch (type) {
+ case "boolean":
+ if (typeof param == "boolean") {
+ valid = true;
+ } else if (typeof param == "number" && (param == 0 || param == 1)) {
+ valid = true;
+ parsedParam = !!param;
+ }
+ break;
+
+ case "number":
+ case "string":
+ valid = typeof param == type;
+ break;
+
+ // integer is an alias to "number" that some JSON schema tools use
+ case "integer":
+ valid = typeof param == "number";
+ break;
+
+ case "null":
+ valid = param === null;
+ break;
+
+ case "origin":
+ if (typeof param != "string") {
+ break;
+ }
+
+ try {
+ parsedParam = new URL(param);
+
+ if (parsedParam.protocol == "file:") {
+ // Treat the entire file URL as an origin.
+ // Note this is stricter than the current Firefox policy,
+ // but consistent with Chrome.
+ // See https://bugzilla.mozilla.org/show_bug.cgi?id=803143
+ valid = true;
+ } else {
+ let pathQueryRef = parsedParam.pathname + parsedParam.hash;
+ // Make sure that "origin" types won't accept full URLs.
+ if (pathQueryRef != "/" && pathQueryRef != "") {
+ lazy.log.error(
+ `Ignoring parameter "${param}" - origin was expected but received full URL.`
+ );
+ valid = false;
+ } else {
+ valid = true;
+ }
+ }
+ } catch (ex) {
+ lazy.log.error(`Ignoring parameter "${param}" - not a valid origin.`);
+ valid = false;
+ }
+ break;
+
+ case "URL":
+ case "URLorEmpty":
+ if (typeof param != "string") {
+ break;
+ }
+
+ if (type == "URLorEmpty" && param === "") {
+ valid = true;
+ break;
+ }
+
+ try {
+ parsedParam = new URL(param);
+ valid = true;
+ } catch (ex) {
+ if (!param.startsWith("http")) {
+ lazy.log.error(
+ `Ignoring parameter "${param}" - scheme (http or https) must be specified.`
+ );
+ }
+ valid = false;
+ }
+ break;
+ }
+
+ if (!valid && !error) {
+ error = new JsonSchemaValidatorError({
+ message:
+ `The value '${valueToString(param)}' does not match the expected ` +
+ `type '${type}'`,
+ value: param,
+ keyPath,
+ state,
+ });
+ }
+
+ let result = {
+ valid,
+ parsedValue: parsedParam,
+ };
+ if (error) {
+ result.error = error;
+ }
+ return result;
+ }
+}
+
+class JsonSchemaValidatorError extends Error {
+ constructor({ message, value, keyPath, state } = {}, ...args) {
+ if (keyPath.length) {
+ message +=
+ ". " +
+ `The invalid value is property '${keyPath.join(".")}' in ` +
+ JSON.stringify(state.rootValue);
+ }
+ super(message, ...args);
+ this.name = "JsonSchemaValidatorError";
+ this.rootValue = state.rootValue;
+ this.rootSchema = state.rootSchema;
+ this.invalidPropertyNameComponents = keyPath;
+ this.invalidValue = value;
+ }
+}
+
+function valueToString(value) {
+ try {
+ return JSON.stringify(value);
+ } catch (ex) {}
+ return String(value);
+}
diff --git a/toolkit/components/utils/Sampling.sys.mjs b/toolkit/components/utils/Sampling.sys.mjs
new file mode 100644
index 0000000000..770e5bf6a5
--- /dev/null
+++ b/toolkit/components/utils/Sampling.sys.mjs
@@ -0,0 +1,170 @@
+/* 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/. */
+
+const hashBits = 48;
+const hashLength = hashBits / 4; // each hexadecimal digit represents 4 bits
+const hashMultiplier = Math.pow(2, hashBits) - 1;
+
+export var Sampling = {
+ /**
+ * Map from the range [0, 1] to [0, 2^48].
+ * @param {number} frac A float from 0.0 to 1.0.
+ * @return {string} A 48 bit number represented in hex, padded to 12 characters.
+ */
+ fractionToKey(frac) {
+ if (frac < 0 || frac > 1) {
+ throw new Error(`frac must be between 0 and 1 inclusive (got ${frac})`);
+ }
+
+ return Math.floor(frac * hashMultiplier)
+ .toString(16)
+ .padStart(hashLength, "0");
+ },
+
+ /**
+ * @param {ArrayBuffer} buffer Data to convert
+ * @returns {String} `buffer`'s content, converted to a hexadecimal string.
+ */
+ bufferToHex(buffer) {
+ const hexCodes = [];
+ const view = new DataView(buffer);
+ for (let i = 0; i < view.byteLength; i += 4) {
+ // Using getUint32 reduces the number of iterations needed (we process 4 bytes each time)
+ const value = view.getUint32(i);
+ // toString(16) will give the hex representation of the number without padding
+ hexCodes.push(value.toString(16).padStart(8, "0"));
+ }
+
+ // Join all the hex strings into one
+ return hexCodes.join("");
+ },
+
+ /**
+ * Check if an input hash is contained in a bucket range.
+ *
+ * isHashInBucket(fractionToKey(0.5), 3, 6, 10) -> returns true
+ *
+ * minBucket
+ * | hash
+ * v v
+ * [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
+ * ^
+ * maxBucket
+ *
+ * @param inputHash {String}
+ * @param minBucket {int} The lower boundary, inclusive, of the range to check.
+ * @param maxBucket {int} The upper boundary, exclusive, of the range to check.
+ * @param bucketCount {int} The total number of buckets. Should be greater than
+ * or equal to maxBucket.
+ */
+ isHashInBucket(inputHash, minBucket, maxBucket, bucketCount) {
+ const minHash = Sampling.fractionToKey(minBucket / bucketCount);
+ const maxHash = Sampling.fractionToKey(maxBucket / bucketCount);
+ return minHash <= inputHash && inputHash < maxHash;
+ },
+
+ /**
+ * @promise A hash of `data`, truncated to the 12 most significant characters.
+ */
+ async truncatedHash(data) {
+ const hasher = crypto.subtle;
+ const input = new TextEncoder().encode(JSON.stringify(data));
+ const hash = await hasher.digest("SHA-256", input);
+ // truncate hash to 12 characters (2^48), because the full hash is larger
+ // than JS can meaningfully represent as a number.
+ return Sampling.bufferToHex(hash).slice(0, 12);
+ },
+
+ /**
+ * Sample by splitting the input into two buckets, one with a size (rate) and
+ * another with a size (1.0 - rate), and then check if the input's hash falls
+ * into the first bucket.
+ *
+ * @param {object} input Input to hash to determine the sample.
+ * @param {Number} rate Number between 0.0 and 1.0 to sample at. A value of
+ * 0.25 returns true 25% of the time.
+ * @promises {boolean} True if the input is in the sample.
+ */
+ async stableSample(input, rate) {
+ const inputHash = await Sampling.truncatedHash(input);
+ const samplePoint = Sampling.fractionToKey(rate);
+
+ return inputHash < samplePoint;
+ },
+
+ /**
+ * Sample by splitting the input space into a series of buckets, and checking
+ * if the given input is in a range of buckets.
+ *
+ * The range to check is defined by a start point and length, and can wrap
+ * around the input space. For example, if there are 100 buckets, and we ask to
+ * check 50 buckets starting from bucket 70, then buckets 70-99 and 0-19 will
+ * be checked.
+ *
+ * @param {object} input Input to hash to determine the matching bucket.
+ * @param {integer} start Index of the bucket to start checking.
+ * @param {integer} count Number of buckets to check.
+ * @param {integer} total Total number of buckets to group inputs into.
+ * @promises {boolean} True if the given input is within the range of buckets
+ * we're checking. */
+ async bucketSample(input, start, count, total) {
+ const inputHash = await Sampling.truncatedHash(input);
+ const wrappedStart = start % total;
+ const end = wrappedStart + count;
+
+ // If the range we're testing wraps, we have to check two ranges: from start
+ // to max, and from min to end.
+ if (end > total) {
+ return (
+ Sampling.isHashInBucket(inputHash, 0, end % total, total) ||
+ Sampling.isHashInBucket(inputHash, wrappedStart, total, total)
+ );
+ }
+
+ return Sampling.isHashInBucket(inputHash, wrappedStart, end, total);
+ },
+
+ /**
+ * Sample over a list of ratios such that, over the input space, each ratio
+ * has a number of matches in correct proportion to the other ratios.
+ *
+ * For example, given the ratios:
+ *
+ * [1, 2, 3, 4]
+ *
+ * 10% of all inputs will return 0, 20% of all inputs will return 1, 30% will
+ * return 2, and 40% will return 3. You can determine the percent of inputs
+ * that will return an index by dividing the ratio by the sum of all ratios
+ * passed in. In the case above, 4 / (1 + 2 + 3 + 4) == 0.4, or 40% of the
+ * inputs.
+ *
+ * @param {object} input
+ * @param {Array<integer>} ratios
+ * @promises {integer}
+ * Index of the ratio that matched the input
+ * @rejects {Error}
+ * If the list of ratios doesn't have at least one element
+ */
+ async ratioSample(input, ratios) {
+ if (ratios.length < 1) {
+ throw new Error(
+ `ratios must be at least 1 element long (got length: ${ratios.length})`
+ );
+ }
+
+ const inputHash = await Sampling.truncatedHash(input);
+ const ratioTotal = ratios.reduce((acc, ratio) => acc + ratio);
+
+ let samplePoint = 0;
+ for (let k = 0; k < ratios.length - 1; k++) {
+ samplePoint += ratios[k];
+ if (inputHash <= Sampling.fractionToKey(samplePoint / ratioTotal)) {
+ return k;
+ }
+ }
+
+ // No need to check the last bucket if the others didn't match.
+ return ratios.length - 1;
+ },
+};
diff --git a/toolkit/components/utils/SimpleServices.sys.mjs b/toolkit/components/utils/SimpleServices.sys.mjs
new file mode 100644
index 0000000000..a69388435c
--- /dev/null
+++ b/toolkit/components/utils/SimpleServices.sys.mjs
@@ -0,0 +1,202 @@
+/* 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/. */
+
+/*
+ * Dumping ground for simple services for which the isolation of a full global
+ * is overkill. Be careful about namespace pollution, and be mindful about
+ * importing lots of JSMs in global scope, since this file will almost certainly
+ * be loaded from enough callsites that any such imports will always end up getting
+ * eagerly loaded at startup.
+ */
+
+/* globals WebExtensionPolicy */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "streamConv",
+ "@mozilla.org/streamConverters;1",
+ "nsIStreamConverterService"
+);
+const ArrayBufferInputStream = Components.Constructor(
+ "@mozilla.org/io/arraybuffer-input-stream;1",
+ "nsIArrayBufferInputStream",
+ "setData"
+);
+
+/*
+ * This class provides a stream filter for locale messages in CSS files served
+ * by the moz-extension: protocol handler.
+ *
+ * See SubstituteChannel in netwerk/protocol/res/ExtensionProtocolHandler.cpp
+ * for usage.
+ */
+export function AddonLocalizationConverter() {}
+
+AddonLocalizationConverter.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIStreamConverter"]),
+
+ FROM_TYPE: "application/vnd.mozilla.webext.unlocalized",
+ TO_TYPE: "text/css",
+
+ checkTypes(aFromType, aToType) {
+ if (aFromType != this.FROM_TYPE) {
+ throw Components.Exception(
+ "Invalid aFromType value",
+ Cr.NS_ERROR_INVALID_ARG,
+ Components.stack.caller.caller
+ );
+ }
+ if (aToType != this.TO_TYPE) {
+ throw Components.Exception(
+ "Invalid aToType value",
+ Cr.NS_ERROR_INVALID_ARG,
+ Components.stack.caller.caller
+ );
+ }
+ },
+
+ // aContext must be a nsIURI object for a valid moz-extension: URL.
+ getAddon(aContext) {
+ // In this case, we want the add-on ID even if the URL is web accessible,
+ // so check the root rather than the exact path.
+ let uri = Services.io.newURI("/", null, aContext);
+
+ let addon = WebExtensionPolicy.getByURI(uri);
+ if (!addon) {
+ throw new Components.Exception(
+ "Invalid context",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+ return addon;
+ },
+
+ convertToStream(aAddon, aString) {
+ aString = aAddon.localize(aString);
+ let bytes = new TextEncoder().encode(aString).buffer;
+ return new ArrayBufferInputStream(bytes, 0, bytes.byteLength);
+ },
+
+ convert(aStream, aFromType, aToType, aContext) {
+ this.checkTypes(aFromType, aToType);
+ let addon = this.getAddon(aContext);
+
+ let count = aStream.available();
+ let string = count
+ ? new TextDecoder().decode(lazy.NetUtil.readInputStream(aStream, count))
+ : "";
+ return this.convertToStream(addon, string);
+ },
+
+ asyncConvertData(aFromType, aToType, aListener, aContext) {
+ this.checkTypes(aFromType, aToType);
+ this.addon = this.getAddon(aContext);
+ this.listener = aListener;
+ },
+
+ onStartRequest(aRequest) {
+ this.parts = [];
+ this.decoder = new TextDecoder();
+ },
+
+ onDataAvailable(aRequest, aInputStream, aOffset, aCount) {
+ let bytes = lazy.NetUtil.readInputStream(aInputStream, aCount);
+ this.parts.push(this.decoder.decode(bytes, { stream: true }));
+ },
+
+ onStopRequest(aRequest, aStatusCode) {
+ try {
+ this.listener.onStartRequest(aRequest, null);
+ if (Components.isSuccessCode(aStatusCode)) {
+ this.parts.push(this.decoder.decode());
+ let string = this.parts.join("");
+ let stream = this.convertToStream(this.addon, string);
+
+ this.listener.onDataAvailable(aRequest, stream, 0, stream.available());
+ }
+ } catch (e) {
+ aStatusCode = e.result || Cr.NS_ERROR_FAILURE;
+ }
+ this.listener.onStopRequest(aRequest, aStatusCode);
+ },
+};
+
+export function HttpIndexViewer() {}
+
+HttpIndexViewer.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIDocumentLoaderFactory"]),
+
+ createInstance(
+ aCommand,
+ aChannel,
+ aLoadGroup,
+ aContentType,
+ aContainer,
+ aExtraInfo,
+ aDocListenerResult
+ ) {
+ // Bug 1824325: application/http-index-format is deprecated for almost all
+ // sites, we only allow it for urls with a inner scheme of "file" or
+ // "moz-gio" (specified in network.http_index_format.allowed_schemes).
+ // This also includes jar: and resource:// uris, as jar: uris has a inner
+ // scheme of "file", and resource:// uris have been turned into either a
+ // jar: or file:// uri by the point where we are checking them here.
+
+ let uri = aChannel.URI;
+ if (uri instanceof Ci.nsINestedURI) {
+ uri = uri.QueryInterface(Ci.nsINestedURI).innermostURI;
+ }
+
+ const allowedSchemes = Services.prefs.getStringPref(
+ "network.http_index_format.allowed_schemes",
+ ""
+ );
+ let isFile =
+ allowedSchemes === "*" || allowedSchemes.split(",").some(uri.schemeIs);
+ let contentType = isFile ? "text/html" : "text/plain";
+
+ aChannel.contentType = contentType;
+
+ let contract = Services.catMan.getCategoryEntry(
+ "Gecko-Content-Viewers",
+ contentType
+ );
+ let factory = Cc[contract].getService(Ci.nsIDocumentLoaderFactory);
+
+ let listener = {};
+ let res = factory.createInstance(
+ "view",
+ aChannel,
+ aLoadGroup,
+ contentType,
+ aContainer,
+ aExtraInfo,
+ listener
+ );
+
+ if (isFile) {
+ aDocListenerResult.value = lazy.streamConv.asyncConvertData(
+ "application/http-index-format",
+ "text/html",
+ listener.value,
+ null
+ );
+ } else {
+ aDocListenerResult.value = listener.value;
+ aChannel.loadInfo.browsingContext.window.console.warn(
+ "application/http-index-format is deprecated, content will display as plain text"
+ );
+ }
+
+ return res;
+ },
+};
diff --git a/toolkit/components/utils/WindowsInstallsInfo.sys.mjs b/toolkit/components/utils/WindowsInstallsInfo.sys.mjs
new file mode 100644
index 0000000000..b599687e6c
--- /dev/null
+++ b/toolkit/components/utils/WindowsInstallsInfo.sys.mjs
@@ -0,0 +1,91 @@
+/* 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/. */
+
+export var WindowsInstallsInfo = {
+ /**
+ * Retrieve install paths of this app, based on the values in the TaskBarIDs registry key.
+ *
+ * Installs from unarchived packages do not have a TaskBarID registry key and
+ * therefore won't appear in the result.
+ *
+ * @param {Number} [limit] Optional, maximum number of installation paths to count.
+ Defaults to 1024.
+ * @param {Set} [exclude] Optional, an Set of paths to exclude from the count.
+ * @returns {Set} Set of install paths, lower cased.
+ */
+ getInstallPaths(limit = 1024, exclude = new Set()) {
+ // This is somewhat more complicated than just collecting all values because
+ // the same install can be listed in both HKCU and HKLM. The strategy is to
+ // add all paths to a Set to deduplicate.
+
+ const lcExclude = new Set();
+ exclude.forEach(p => lcExclude.add(p.toLowerCase()));
+
+ // Add the names of the values under `rootKey\subKey` to `set`.
+ // All strings are lower cased first, as Windows paths are not case-sensitive.
+ function collectValues(rootKey, wowFlag, subKey, set) {
+ const key = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ Ci.nsIWindowsRegKey
+ );
+
+ try {
+ key.open(rootKey, subKey, key.ACCESS_READ | wowFlag);
+ } catch (_e) {
+ // The key may not exist, ignore.
+ // (nsWindowsRegKey::Open doesn't provide detailed error codes)
+ return;
+ }
+ const valueCount = key.valueCount;
+
+ try {
+ for (let i = 0; i < valueCount; ++i) {
+ const path = key.getValueName(i).toLowerCase();
+ if (!lcExclude.has(path)) {
+ set.add(path);
+ }
+ if (set.size >= limit) {
+ break;
+ }
+ }
+ } finally {
+ key.close();
+ }
+ }
+
+ const subKeyName = `Software\\Mozilla\\${Services.appinfo.name}\\TaskBarIDs`;
+
+ const paths = new Set();
+
+ // First collect from HKLM for both 32-bit and 64-bit installs regardless of the architecture
+ // of the current application.
+ for (const wowFlag of [
+ Ci.nsIWindowsRegKey.WOW64_32,
+ Ci.nsIWindowsRegKey.WOW64_64,
+ ]) {
+ collectValues(
+ Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
+ wowFlag,
+ subKeyName,
+ paths
+ );
+ if (paths.size >= limit) {
+ break;
+ }
+ }
+
+ if (paths.size < limit) {
+ // Then collect from HKCU.
+ // HKCU\Software is shared between 32 and 64 so nothing special is needed for WOW64,
+ // ref https://docs.microsoft.com/en-us/windows/win32/winprog64/shared-registry-keys
+ collectValues(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ 0 /* wowFlag */,
+ subKeyName,
+ paths
+ );
+ }
+
+ return paths;
+ },
+};
diff --git a/toolkit/components/utils/WindowsVersionInfo.sys.mjs b/toolkit/components/utils/WindowsVersionInfo.sys.mjs
new file mode 100644
index 0000000000..70fa3ef9c6
--- /dev/null
+++ b/toolkit/components/utils/WindowsVersionInfo.sys.mjs
@@ -0,0 +1,112 @@
+/* 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 { ctypes } from "resource://gre/modules/ctypes.sys.mjs";
+
+const BYTE = ctypes.uint8_t;
+const WORD = ctypes.uint16_t;
+const DWORD = ctypes.uint32_t;
+const WCHAR = ctypes.char16_t;
+const BOOL = ctypes.int;
+
+export var WindowsVersionInfo = {
+ UNKNOWN_VERSION_INFO: {
+ servicePackMajor: null,
+ servicePackMinor: null,
+ buildNumber: null,
+ },
+
+ /**
+ * Gets the service pack and build number on Windows platforms.
+ *
+ * @param opts {Object} keyword arguments
+ * @param opts.throwOnError {boolean} Optional, defaults to true. If set to
+ * false will return an object with keys set to null instead of throwing an
+ * error. If set to true, errors will be thrown instead.
+ * @throws If `throwOnError` is true and version information cannot be
+ * determined.
+ * @return {object} An object containing keys `servicePackMajor`,
+ * `servicePackMinor`, and `buildNumber`. If `throwOnError` is false, these
+ * values may be null.
+ */
+ get({ throwOnError = true } = {}) {
+ function throwOrUnknown(err) {
+ if (throwOnError) {
+ throw err;
+ }
+ console.error(err);
+ return WindowsVersionInfo.UNKNOWN_VERSION_INFO;
+ }
+
+ if (AppConstants.platform !== "win") {
+ return throwOrUnknown(
+ WindowsVersionInfo.NotWindowsError(
+ `Cannot get Windows version info on platform ${AppConstants.platform}`
+ )
+ );
+ }
+
+ // This structure is described at:
+ // http://msdn.microsoft.com/en-us/library/ms724833%28v=vs.85%29.aspx
+ const SZCSDVERSIONLENGTH = 128;
+ const OSVERSIONINFOEXW = new ctypes.StructType("OSVERSIONINFOEXW", [
+ { dwOSVersionInfoSize: DWORD },
+ { dwMajorVersion: DWORD },
+ { dwMinorVersion: DWORD },
+ { dwBuildNumber: DWORD },
+ { dwPlatformId: DWORD },
+ { szCSDVersion: ctypes.ArrayType(WCHAR, SZCSDVERSIONLENGTH) },
+ { wServicePackMajor: WORD },
+ { wServicePackMinor: WORD },
+ { wSuiteMask: WORD },
+ { wProductType: BYTE },
+ { wReserved: BYTE },
+ ]);
+
+ let kernel32;
+ try {
+ kernel32 = ctypes.open("kernel32");
+ } catch (err) {
+ return throwOrUnknown(
+ new WindowsVersionInfo.CannotOpenKernelError(
+ `Unable to open kernel32! ${err}`
+ )
+ );
+ }
+
+ try {
+ let GetVersionEx = kernel32.declare(
+ "GetVersionExW",
+ ctypes.winapi_abi,
+ BOOL,
+ OSVERSIONINFOEXW.ptr
+ );
+ let winVer = OSVERSIONINFOEXW();
+ winVer.dwOSVersionInfoSize = OSVERSIONINFOEXW.size;
+
+ if (GetVersionEx(winVer.address()) === 0) {
+ throw new WindowsVersionInfo.GetVersionExError(
+ "Failure in GetVersionEx (returned 0)"
+ );
+ }
+
+ return {
+ servicePackMajor: winVer.wServicePackMajor,
+ servicePackMinor: winVer.wServicePackMinor,
+ buildNumber: winVer.dwBuildNumber,
+ };
+ } catch (err) {
+ return throwOrUnknown(err);
+ } finally {
+ if (kernel32) {
+ kernel32.close();
+ }
+ }
+ },
+
+ CannotOpenKernelError: class extends Error {},
+ GetVersionExError: class extends Error {},
+ NotWindowsError: class extends Error {},
+};
diff --git a/toolkit/components/utils/components.conf b/toolkit/components/utils/components.conf
new file mode 100644
index 0000000000..365138507e
--- /dev/null
+++ b/toolkit/components/utils/components.conf
@@ -0,0 +1,21 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+Classes = [
+ {
+ 'cid': '{ded150e3-c92e-4077-a396-0dba9953e39f}',
+ 'contract_ids': ['@mozilla.org/streamconv;1?from=application/vnd.mozilla.webext.unlocalized&to=text/css'],
+ 'esModule': 'resource://gre/modules/SimpleServices.sys.mjs',
+ 'constructor': 'AddonLocalizationConverter',
+ },
+ {
+ 'cid': '{742ad274-34c5-43d1-a8b7-293eaf8962d6}',
+ 'contract_ids': ['@mozilla.org/content-viewers/http-index-format'],
+ 'esModule': 'resource://gre/modules/SimpleServices.sys.mjs',
+ 'constructor': 'HttpIndexViewer',
+ 'categories': {'Gecko-Content-Viewers': 'application/http-index-format'},
+ },
+]
diff --git a/toolkit/components/utils/moz.build b/toolkit/components/utils/moz.build
new file mode 100644
index 0000000000..54b3a32bd3
--- /dev/null
+++ b/toolkit/components/utils/moz.build
@@ -0,0 +1,28 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Toolkit", "General")
+
+EXTRA_JS_MODULES += [
+ "SimpleServices.sys.mjs",
+]
+
+EXTRA_JS_MODULES["components-utils"] = [
+ "ClientEnvironment.sys.mjs",
+ "FilterExpressions.sys.mjs",
+ "JsonSchemaValidator.sys.mjs",
+ "mozjexl.js",
+ "Sampling.sys.mjs",
+ "WindowsInstallsInfo.sys.mjs",
+ "WindowsVersionInfo.sys.mjs",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.toml"]
diff --git a/toolkit/components/utils/mozjexl.js b/toolkit/components/utils/mozjexl.js
new file mode 100644
index 0000000000..66d88bcacd
--- /dev/null
+++ b/toolkit/components/utils/mozjexl.js
@@ -0,0 +1 @@
+/* eslint-disable */this.mozjexl=function(a){function b(d){if(c[d])return c[d].exports;var e=c[d]={i:d,l:!1,exports:{}};return a[d].call(e.exports,e,e.exports,b),e.l=!0,e.exports}var c={};return b.m=a,b.c=c,b.d=function(a,c,d){b.o(a,c)||Object.defineProperty(a,c,{configurable:!1,enumerable:!0,get:d})},b.n=function(a){var c=a&&a.__esModule?function(){return a['default']}:function(){return a};return b.d(c,'a',c),c},b.o=function(a,b){return Object.prototype.hasOwnProperty.call(a,b)},b.p='',b(b.s=93)}({65:function(a,b){b.argVal=function(a){this._cursor.args.push(a)},b.arrayStart=function(){this._placeAtCursor({type:'ArrayLiteral',value:[]})},b.arrayVal=function(a){a&&this._cursor.value.push(a)},b.binaryOp=function(a){for(var b=this._grammar[a.value].precedence||0,c=this._cursor._parent;c&&c.operator&&this._grammar[c.operator].precedence>=b;)this._cursor=c,c=c._parent;var d={type:'BinaryExpression',operator:a.value,left:this._cursor};this._setParent(this._cursor,d),this._cursor=c,this._placeAtCursor(d)},b.dot=function(){this._nextIdentEncapsulate=this._cursor&&('BinaryExpression'!=this._cursor.type||'BinaryExpression'==this._cursor.type&&this._cursor.right)&&'UnaryExpression'!=this._cursor.type,this._nextIdentRelative=!this._cursor||this._cursor&&!this._nextIdentEncapsulate,this._nextIdentRelative&&(this._relative=!0)},b.filter=function(a){this._placeBeforeCursor({type:'FilterExpression',expr:a,relative:this._subParser.isRelative(),subject:this._cursor})},b.identifier=function(a){var b={type:'Identifier',value:a.value};this._nextIdentEncapsulate?(b.from=this._cursor,this._placeBeforeCursor(b),this._nextIdentEncapsulate=!1):(this._nextIdentRelative&&(b.relative=!0),this._placeAtCursor(b))},b.literal=function(a){this._placeAtCursor({type:'Literal',value:a.value})},b.objKey=function(a){this._curObjKey=a.value},b.objStart=function(){this._placeAtCursor({type:'ObjectLiteral',value:{}})},b.objVal=function(a){this._cursor.value[this._curObjKey]=a},b.subExpression=function(a){this._placeAtCursor(a)},b.ternaryEnd=function(a){this._cursor.alternate=a},b.ternaryMid=function(a){this._cursor.consequent=a},b.ternaryStart=function(){this._tree={type:'ConditionalExpression',test:this._tree},this._cursor=this._tree},b.transform=function(a){this._placeBeforeCursor({type:'Transform',name:a.value,args:[],subject:this._cursor})},b.unaryOp=function(a){this._placeAtCursor({type:'UnaryExpression',operator:a.value})}},93:function(a,b,c){function d(){this._customGrammar=null,this._lexer=null,this._transforms={}}var e=c(94),f=c(96),g=c(97),h=c(99).elements;d.prototype.addBinaryOp=function(a,b,c){this._addGrammarElement(a,{type:'binaryOp',precedence:b,eval:c})},d.prototype.addUnaryOp=function(a,b){this._addGrammarElement(a,{type:'unaryOp',weight:Infinity,eval:b})},d.prototype.addTransform=function(a,b){this._transforms[a]=b},d.prototype.addTransforms=function(a){for(var b in a)a.hasOwnProperty(b)&&(this._transforms[b]=a[b])},d.prototype.getTransform=function(a){return this._transforms[a]},d.prototype.eval=function(a,b,c){'function'==typeof b?(c=b,b={}):!b&&(b={});var d=this._eval(a,b);if(c){var e=!1;return d.then(function(a){e=!0,setTimeout(c.bind(null,null,a),0)}).catch(function(a){e||setTimeout(c.bind(null,a),0)})}return d},d.prototype.removeOp=function(a){var b=this._getCustomGrammar();b[a]&&('binaryOp'==b[a].type||'unaryOp'==b[a].type)&&(delete b[a],this._lexer=null)},d.prototype._addGrammarElement=function(a,b){var c=this._getCustomGrammar();c[a]=b,this._lexer=null},d.prototype._eval=function(a,b){var c=this,d=this._getGrammar(),f=new g(d),h=new e(d,this._transforms,b);return Promise.resolve().then(function(){return f.addTokens(c._getLexer().tokenize(a)),h.eval(f.complete())})},d.prototype._getCustomGrammar=function(){if(!this._customGrammar)for(var a in this._customGrammar={},h)h.hasOwnProperty(a)&&(this._customGrammar[a]=h[a]);return this._customGrammar},d.prototype._getGrammar=function(){return this._customGrammar||h},d.prototype._getLexer=function(){return this._lexer||(this._lexer=new f(this._getGrammar())),this._lexer},a.exports=new d,a.exports.Jexl=d},94:function(a,b,c){var d=c(95),e=function(a,b,c,d){this._grammar=a,this._transforms=b||{},this._context=c||{},this._relContext=d||this._context};e.prototype.eval=function(a){var b=this;return Promise.resolve().then(function(){return d[a.type].call(b,a)})},e.prototype.evalArray=function(a){return Promise.all(a.map(function(a){return this.eval(a)},this))},e.prototype.evalMap=function(a){var b=Object.keys(a),c={},d=b.map(function(b){return this.eval(a[b])},this);return Promise.all(d).then(function(a){return a.forEach(function(a,d){c[b[d]]=a}),c})},e.prototype._filterRelative=function(a,b){if(void 0!==a){var c=[];return Array.isArray(a)||(a=[a]),a.forEach(function(a){var d=new e(this._grammar,this._transforms,this._context,a);c.push(d.eval(b))},this),Promise.all(c).then(function(b){var c=[];return b.forEach(function(b,d){b&&c.push(a[d])}),c})}},e.prototype._filterStatic=function(a,b){return this.eval(b).then(function(b){return'boolean'==typeof b?b?a:void 0:void 0===a?void 0:a[b]})},a.exports=e},95:function(a,b){b.ArrayLiteral=function(a){return this.evalArray(a.value)},b.BinaryExpression=function(a){var b=this;return Promise.all([this.eval(a.left),this.eval(a.right)]).then(function(c){return b._grammar[a.operator].eval(c[0],c[1])})},b.ConditionalExpression=function(a){var b=this;return this.eval(a.test).then(function(c){return c?a.consequent?b.eval(a.consequent):c:b.eval(a.alternate)})},b.FilterExpression=function(a){var b=this;return this.eval(a.subject).then(function(c){return a.relative?b._filterRelative(c,a.expr):b._filterStatic(c,a.expr)})},b.Identifier=function(a){return a.from?this.eval(a.from).then(function(b){if(void 0!==b)return Array.isArray(b)&&(b=b[0]),b[a.value]}):a.relative?this._relContext[a.value]:this._context[a.value]},b.Literal=function(a){return a.value},b.ObjectLiteral=function(a){return this.evalMap(a.value)},b.Transform=function(a){var b=this._transforms[a.name];if(!b)throw new Error('Transform \''+a.name+'\' is not defined.');return Promise.all([this.eval(a.subject),this.evalArray(a.args||[])]).then(function(a){return b.apply(null,[a[0]].concat(a[1]))})},b.UnaryExpression=function(a){var b=this;return this.eval(a.right).then(function(c){return b._grammar[a.operator].eval(c)})}},96:function(a){function b(a){this._grammar=a}var c=/^-?(?:(?:[0-9]*\.[0-9]+)|[0-9]+)$/,d=/^[a-zA-Z_\$][a-zA-Z0-9_\$]*$/,e=/\\\\/,f=['\'(?:(?:\\\\\')?[^\'])*\'','"(?:(?:\\\\")?[^"])*"','\\s+','\\btrue\\b','\\bfalse\\b'],g=['\\b[a-zA-Z_\\$][a-zA-Z0-9_\\$]*\\b','(?:(?:[0-9]*\\.[0-9]+)|[0-9]+)'],h=['binaryOp','unaryOp','openParen','openBracket','question','colon'];b.prototype.getElements=function(a){var b=this._getSplitRegex();return a.split(b).filter(function(a){return a})},b.prototype.getTokens=function(a){for(var b=[],c=!1,d=0;d<a.length;d++)this._isWhitespace(a[d])?b.length&&(b[b.length-1].raw+=a[d]):'-'===a[d]&&this._isNegative(b)?c=!0:(c&&(a[d]='-'+a[d],c=!1),b.push(this._createToken(a[d])));return c&&b.push(this._createToken('-')),b},b.prototype.tokenize=function(a){var b=this.getElements(a);return this.getTokens(b)},b.prototype._createToken=function(a){var b={type:'literal',value:a,raw:a};if('"'==a[0]||'\''==a[0])b.value=this._unquote(a);else if(a.match(c))b.value=parseFloat(a);else if('true'===a||'false'===a)b.value='true'==a;else if(this._grammar[a])b.type=this._grammar[a].type;else if(a.match(d))b.type='identifier';else throw new Error('Invalid expression token: '+a);return b},b.prototype._escapeRegExp=function(a){return a=a.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'),a.match(d)&&(a='\\b'+a+'\\b'),a},b.prototype._getSplitRegex=function(){if(!this._splitRegex){var a=Object.keys(this._grammar);a=a.sort(function(c,a){return a.length-c.length}).map(function(a){return this._escapeRegExp(a)},this),this._splitRegex=new RegExp('('+[f.join('|'),a.join('|'),g.join('|')].join('|')+')')}return this._splitRegex},b.prototype._isNegative=function(a){return!a.length||h.some(function(b){return b===a[a.length-1].type})};var i=/^\s*$/;b.prototype._isWhitespace=function(a){return i.test(a)},b.prototype._unquote=function(a){var b=a[0],c=new RegExp('\\\\'+b,'g');return a.substr(1,a.length-2).replace(c,b).replace(e,'\\')},a.exports=b},97:function(a,b,c){function d(a,b,c){this._grammar=a,this._state='expectOperand',this._tree=null,this._exprStr=b||'',this._relative=!1,this._stopMap=c||{}}var e=c(65),f=c(98).states;d.prototype.addToken=function(a){if('complete'==this._state)throw new Error('Cannot add a new token to a completed Parser');var b=f[this._state],c=this._exprStr;if(this._exprStr+=a.raw,b.subHandler){this._subParser||this._startSubExpression(c);var d=this._subParser.addToken(a);if(d){if(this._endSubExpression(),this._parentStop)return d;this._state=d}}else if(b.tokenTypes[a.type]){var g=b.tokenTypes[a.type],h=e[a.type];g.handler&&(h=g.handler),h&&h.call(this,a),g.toState&&(this._state=g.toState)}else{if(this._stopMap[a.type])return this._stopMap[a.type];throw new Error('Token '+a.raw+' ('+a.type+') unexpected in expression: '+this._exprStr)}return!1},d.prototype.addTokens=function(a){a.forEach(this.addToken,this)},d.prototype.complete=function(){if(this._cursor&&!f[this._state].completable)throw new Error('Unexpected end of expression: '+this._exprStr);return this._subParser&&this._endSubExpression(),this._state='complete',this._cursor?this._tree:null},d.prototype.isRelative=function(){return this._relative},d.prototype._endSubExpression=function(){f[this._state].subHandler.call(this,this._subParser.complete()),this._subParser=null},d.prototype._placeAtCursor=function(a){this._cursor?(this._cursor.right=a,this._setParent(a,this._cursor)):this._tree=a,this._cursor=a},d.prototype._placeBeforeCursor=function(a){this._cursor=this._cursor._parent,this._placeAtCursor(a)},d.prototype._setParent=function(a,b){Object.defineProperty(a,'_parent',{value:b,writable:!0})},d.prototype._startSubExpression=function(a){var b=f[this._state].endStates;b||(this._parentStop=!0,b=this._stopMap),this._subParser=new d(this._grammar,a,b)},a.exports=d},98:function(a,b,c){var d=c(65);b.states={expectOperand:{tokenTypes:{literal:{toState:'expectBinOp'},identifier:{toState:'identifier'},unaryOp:{},openParen:{toState:'subExpression'},openCurl:{toState:'expectObjKey',handler:d.objStart},dot:{toState:'traverse'},openBracket:{toState:'arrayVal',handler:d.arrayStart}}},expectBinOp:{tokenTypes:{binaryOp:{toState:'expectOperand'},pipe:{toState:'expectTransform'},dot:{toState:'traverse'},question:{toState:'ternaryMid',handler:d.ternaryStart}},completable:!0},expectTransform:{tokenTypes:{identifier:{toState:'postTransform',handler:d.transform}}},expectObjKey:{tokenTypes:{identifier:{toState:'expectKeyValSep',handler:d.objKey},closeCurl:{toState:'expectBinOp'}}},expectKeyValSep:{tokenTypes:{colon:{toState:'objVal'}}},postTransform:{tokenTypes:{openParen:{toState:'argVal'},binaryOp:{toState:'expectOperand'},dot:{toState:'traverse'},openBracket:{toState:'filter'},pipe:{toState:'expectTransform'}},completable:!0},postTransformArgs:{tokenTypes:{binaryOp:{toState:'expectOperand'},dot:{toState:'traverse'},openBracket:{toState:'filter'},pipe:{toState:'expectTransform'}},completable:!0},identifier:{tokenTypes:{binaryOp:{toState:'expectOperand'},dot:{toState:'traverse'},openBracket:{toState:'filter'},pipe:{toState:'expectTransform'},question:{toState:'ternaryMid',handler:d.ternaryStart}},completable:!0},traverse:{tokenTypes:{identifier:{toState:'identifier'}}},filter:{subHandler:d.filter,endStates:{closeBracket:'identifier'}},subExpression:{subHandler:d.subExpression,endStates:{closeParen:'expectBinOp'}},argVal:{subHandler:d.argVal,endStates:{comma:'argVal',closeParen:'postTransformArgs'}},objVal:{subHandler:d.objVal,endStates:{comma:'expectObjKey',closeCurl:'expectBinOp'}},arrayVal:{subHandler:d.arrayVal,endStates:{comma:'arrayVal',closeBracket:'expectBinOp'}},ternaryMid:{subHandler:d.ternaryMid,endStates:{colon:'ternaryEnd'}},ternaryEnd:{subHandler:d.ternaryEnd,completable:!0}}},99:function(a,b){b.elements={".":{type:'dot'},"[":{type:'openBracket'},"]":{type:'closeBracket'},"|":{type:'pipe'},"{":{type:'openCurl'},"}":{type:'closeCurl'},":":{type:'colon'},",":{type:'comma'},"(":{type:'openParen'},")":{type:'closeParen'},"?":{type:'question'},"+":{type:'binaryOp',precedence:30,eval:function(a,b){return a+b}},"-":{type:'binaryOp',precedence:30,eval:function(a,b){return a-b}},"*":{type:'binaryOp',precedence:40,eval:function(a,b){return a*b}},"/":{type:'binaryOp',precedence:40,eval:function(a,b){return a/b}},"//":{type:'binaryOp',precedence:40,eval:function(a,b){return Math.floor(a/b)}},"%":{type:'binaryOp',precedence:50,eval:function(a,b){return a%b}},"^":{type:'binaryOp',precedence:50,eval:function(a,b){return Math.pow(a,b)}},"==":{type:'binaryOp',precedence:20,eval:function(a,b){return a==b}},"!=":{type:'binaryOp',precedence:20,eval:function(a,b){return a!=b}},">":{type:'binaryOp',precedence:20,eval:function(a,b){return a>b}},">=":{type:'binaryOp',precedence:20,eval:function(a,b){return a>=b}},"<":{type:'binaryOp',precedence:20,eval:function(a,b){return a<b}},"<=":{type:'binaryOp',precedence:20,eval:function(a,b){return a<=b}},"&&":{type:'binaryOp',precedence:10,eval:function(a,b){return a&&b}},"||":{type:'binaryOp',precedence:10,eval:function(a,b){return a||b}},in:{type:'binaryOp',precedence:20,eval:function(a,b){return'string'==typeof b?-1!==b.indexOf(a):!!Array.isArray(b)&&b.some(function(b){return b==a})}},"!":{type:'unaryOp',precedence:Infinity,eval:function(a){return!a}}}}});this.EXPORTED_SYMBOLS = ["mozjexl"]; \ No newline at end of file
diff --git a/toolkit/components/utils/test/unit/test_ClientEnvironment.js b/toolkit/components/utils/test/unit/test_ClientEnvironment.js
new file mode 100644
index 0000000000..72ca6d970f
--- /dev/null
+++ b/toolkit/components/utils/test/unit/test_ClientEnvironment.js
@@ -0,0 +1,154 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ AppConstants: "resource://gre/modules/AppConstants.sys.mjs",
+ ClientEnvironmentBase:
+ "resource://gre/modules/components-utils/ClientEnvironment.sys.mjs",
+ NormandyTestUtils: "resource://testing-common/NormandyTestUtils.sys.mjs",
+ TelemetryController: "resource://gre/modules/TelemetryController.sys.mjs",
+ updateAppInfo: "resource://testing-common/AppInfo.sys.mjs",
+});
+
+add_setup(() => {
+ updateAppInfo();
+});
+
+add_task(async function test_OS_data() {
+ const os = ClientEnvironmentBase.os;
+ Assert.notStrictEqual(
+ os,
+ undefined,
+ "OS data should be available in the context"
+ );
+
+ let osCount = 0;
+ if (os.isWindows) {
+ osCount += 1;
+ }
+ if (os.isMac) {
+ osCount += 1;
+ }
+ if (os.isLinux) {
+ osCount += 1;
+ }
+ Assert.lessOrEqual(osCount, 1, "At most one OS should match");
+
+ // if on Windows, Windows versions should be set, and Mac versions should not be
+ if (os.isWindows) {
+ equal(
+ typeof os.windowsVersion,
+ "number",
+ "Windows version should be a number"
+ );
+ equal(
+ typeof os.windowsBuildNumber,
+ "number",
+ "Windows build number should be a number"
+ );
+ equal(os.macVersion, null, "Mac version should not be set");
+ equal(os.darwinVersion, null, "Darwin version should not be set");
+ }
+
+ // if on Mac, Mac versions should be set, and Windows versions should not be
+ if (os.isMac) {
+ equal(typeof os.macVersion, "number", "Mac version should be a number");
+ equal(
+ typeof os.darwinVersion,
+ "number",
+ "Darwin version should be a number"
+ );
+ equal(os.windowsVersion, null, "Windows version should not be set");
+ equal(
+ os.windowsBuildNumber,
+ null,
+ "Windows build number version should not be set"
+ );
+ }
+
+ // if on Linux, no versions should be set
+ if (os.isLinux) {
+ equal(os.macVersion, null, "Mac version should not be set");
+ equal(os.darwinVersion, null, "Darwin version should not be set");
+ equal(os.windowsVersion, null, "Windows version should not be set");
+ equal(
+ os.windowsBuildNumber,
+ null,
+ "Windows build number version should not be set"
+ );
+ }
+});
+
+add_task(async function test_attributionData() {
+ try {
+ await ClientEnvironmentBase.attribution;
+ } catch (ex) {
+ equal(
+ ex.result,
+ Cr.NS_ERROR_FILE_NOT_FOUND,
+ "Test environment does not have attribution data"
+ );
+ }
+});
+
+add_task(async function testLiveTelemetry() {
+ // Setup telemetry so we can read from it
+ do_get_profile(true);
+ await TelemetryController.testSetup();
+
+ equal(
+ ClientEnvironmentBase.liveTelemetry.main.environment.build.displayVersion,
+ AppConstants.MOZ_APP_VERSION_DISPLAY,
+ "Telemetry data is available"
+ );
+
+ Assert.throws(
+ () => ClientEnvironmentBase.liveTelemetry.anotherPingType,
+ /Live telemetry.*anotherPingType/,
+ "Non-main pings should raise an error if accessed"
+ );
+
+ // Put things back the way we found them
+ await TelemetryController.testShutdown();
+});
+
+add_task(function testBuildId() {
+ Assert.notStrictEqual(
+ ClientEnvironmentBase.appinfo,
+ undefined,
+ "appinfo should be available in the context"
+ );
+ Assert.strictEqual(
+ typeof ClientEnvironmentBase.appinfo,
+ "object",
+ "appinfo should be an object"
+ );
+ Assert.strictEqual(
+ typeof ClientEnvironmentBase.appinfo.appBuildID,
+ "string",
+ "buildId should be a string"
+ );
+});
+
+add_task(
+ {
+ skip_if: () => AppConstants.MOZ_BUILD_APP != "browser",
+ },
+ async function testRandomizationId() {
+ // Should generate an id if none is set
+ await Services.prefs.clearUserPref("app.normandy.user_id");
+ Assert.ok(
+ NormandyTestUtils.isUuid(ClientEnvironmentBase.randomizationId),
+ "randomizationId should be available"
+ );
+
+ // Should read the right preference
+ await Services.prefs.setStringPref("app.normandy.user_id", "fake id");
+ Assert.equal(
+ ClientEnvironmentBase.randomizationId,
+ "fake id",
+ "randomizationId should read from preferences"
+ );
+ }
+);
diff --git a/toolkit/components/utils/test/unit/test_FilterExpressions.js b/toolkit/components/utils/test/unit/test_FilterExpressions.js
new file mode 100644
index 0000000000..3fd09a95d3
--- /dev/null
+++ b/toolkit/components/utils/test/unit/test_FilterExpressions.js
@@ -0,0 +1,410 @@
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ FilterExpressions:
+ "resource://gre/modules/components-utils/FilterExpressions.sys.mjs",
+});
+
+// Basic JEXL tests
+add_task(async function () {
+ let val;
+ // Test that basic expressions work
+ val = await FilterExpressions.eval("2+2");
+ equal(val, 4, "basic expression works");
+
+ // Test that multiline expressions work
+ val = await FilterExpressions.eval(`
+ 2
+ +
+ 2
+ `);
+ equal(val, 4, "multiline expression works");
+
+ // Test that it reads from the context correctly.
+ val = await FilterExpressions.eval("first + second + 3", {
+ first: 1,
+ second: 2,
+ });
+ equal(val, 6, "context is available to filter expressions");
+});
+
+// Date tests
+add_task(async function () {
+ let val;
+ // Test has a date transform
+ val = await FilterExpressions.eval('"2016-04-22"|date');
+ const d = new Date(Date.UTC(2016, 3, 22)); // months are 0 based
+ equal(val.toString(), d.toString(), "Date transform works");
+
+ // Test dates are comparable
+ const context = { someTime: Date.UTC(2016, 0, 1) };
+ val = await FilterExpressions.eval('"2015-01-01"|date < someTime', context);
+ ok(val, "dates are comparable with less-than");
+ val = await FilterExpressions.eval('"2017-01-01"|date > someTime', context);
+ ok(val, "dates are comparable with greater-than");
+});
+
+// Sampling tests
+add_task(async function () {
+ let val;
+ // Test stable sample returns true for matching samples
+ val = await FilterExpressions.eval('["test"]|stableSample(1)');
+ ok(val, "Stable sample returns true for 100% sample");
+
+ // Test stable sample returns true for matching samples
+ val = await FilterExpressions.eval('["test"]|stableSample(0)');
+ ok(!val, "Stable sample returns false for 0% sample");
+
+ // Test stable sample for known samples
+ val = await FilterExpressions.eval('["test-1"]|stableSample(0.5)');
+ ok(val, "Stable sample returns true for a known sample");
+ val = await FilterExpressions.eval('["test-4"]|stableSample(0.5)');
+ ok(!val, "Stable sample returns false for a known sample");
+
+ // Test bucket sample for known samples
+ val = await FilterExpressions.eval('["test-1"]|bucketSample(0, 5, 10)');
+ ok(val, "Bucket sample returns true for a known sample");
+ val = await FilterExpressions.eval('["test-4"]|bucketSample(0, 5, 10)');
+ ok(!val, "Bucket sample returns false for a known sample");
+});
+
+// Preference tests
+add_task(async function () {
+ let val;
+ // Compare the value of the preference
+ Services.prefs.setIntPref("normandy.test.value", 3);
+ registerCleanupFunction(() =>
+ Services.prefs.clearUserPref("normandy.test.value")
+ );
+
+ val = await FilterExpressions.eval(
+ '"normandy.test.value"|preferenceValue == 3'
+ );
+ ok(val, "preferenceValue expression compares against preference values");
+ val = await FilterExpressions.eval(
+ '"normandy.test.value"|preferenceValue == "test"'
+ );
+ ok(!val, "preferenceValue expression fails value checks appropriately");
+
+ // preferenceValue can take a default value as an optional argument, which
+ // defaults to `undefined`.
+ val = await FilterExpressions.eval(
+ '"normandy.test.default"|preferenceValue(false) == false'
+ );
+ ok(
+ val,
+ "preferenceValue takes optional 'default value' param for prefs without set values"
+ );
+ val = await FilterExpressions.eval(
+ '"normandy.test.value"|preferenceValue(5) == 5'
+ );
+ ok(
+ !val,
+ "preferenceValue default param is not returned for prefs with set values"
+ );
+
+ // Compare if the preference is user set
+ val = await FilterExpressions.eval(
+ '"normandy.test.isSet"|preferenceIsUserSet == true'
+ );
+ ok(
+ !val,
+ "preferenceIsUserSet expression determines if preference is set at all"
+ );
+ val = await FilterExpressions.eval(
+ '"normandy.test.value"|preferenceIsUserSet == true'
+ );
+ ok(
+ val,
+ "preferenceIsUserSet expression determines if user's preference has been set"
+ );
+
+ // Compare if the preference has _any_ value, whether it's user-set or default,
+ val = await FilterExpressions.eval(
+ '"normandy.test.nonexistant"|preferenceExists == true'
+ );
+ ok(
+ !val,
+ "preferenceExists expression determines if preference exists at all"
+ );
+ val = await FilterExpressions.eval(
+ '"normandy.test.value"|preferenceExists == true'
+ );
+ ok(val, "preferenceExists expression fails existence check appropriately");
+});
+
+// keys tests
+add_task(async function testKeys() {
+ let val;
+
+ // Test an object defined in JEXL
+ val = await FilterExpressions.eval("{foo: 1, bar: 2}|keys");
+ Assert.deepEqual(
+ new Set(val),
+ new Set(["foo", "bar"]),
+ "keys returns the keys from an object in JEXL"
+ );
+
+ // Test an object in the context
+ let context = { ctxObject: { baz: "string", biff: NaN } };
+ val = await FilterExpressions.eval("ctxObject|keys", context);
+
+ Assert.deepEqual(
+ new Set(val),
+ new Set(["baz", "biff"]),
+ "keys returns the keys from an object in the context"
+ );
+
+ // Test that values from the prototype are not included
+ context = { ctxObject: Object.create({ fooProto: 7 }) };
+ context.ctxObject.baz = 8;
+ context.ctxObject.biff = 5;
+ equal(
+ await FilterExpressions.eval("ctxObject.fooProto", context),
+ 7,
+ "Prototype properties are accessible via property access"
+ );
+ val = await FilterExpressions.eval("ctxObject|keys", context);
+ Assert.deepEqual(
+ new Set(val),
+ new Set(["baz", "biff"]),
+ "keys does not return properties from the object's prototype chain"
+ );
+
+ // Return undefined for non-objects
+ equal(
+ await FilterExpressions.eval("ctxObject|keys", { ctxObject: 45 }),
+ undefined,
+ "keys returns undefined for numbers"
+ );
+ equal(
+ await FilterExpressions.eval("ctxObject|keys", { ctxObject: null }),
+ undefined,
+ "keys returns undefined for null"
+ );
+
+ // Object properties are not cached
+ let pong = 0;
+ context = {
+ ctxObject: {
+ get ping() {
+ return ++pong;
+ },
+ },
+ };
+ await FilterExpressions.eval(
+ "ctxObject.ping == 0 || ctxObject.ping == 1",
+ context
+ );
+ equal(pong, 2, "Properties are not reifed");
+});
+
+add_task(async function testLength() {
+ equal(
+ await FilterExpressions.eval("[1, null, {a: 2, b: 3}, Infinity]|length"),
+ 4,
+ "length returns the length of the array it's applied to"
+ );
+
+ equal(
+ await FilterExpressions.eval("[]|length"),
+ 0,
+ "length is zero for an empty array"
+ );
+
+ // Should be undefined for non-Arrays
+ equal(
+ await FilterExpressions.eval("5|length"),
+ undefined,
+ "length is undefined when applied to numbers"
+ );
+ equal(
+ await FilterExpressions.eval("null|length"),
+ undefined,
+ "length is undefined when applied to null"
+ );
+ equal(
+ await FilterExpressions.eval("undefined|length"),
+ undefined,
+ "length is undefined when applied to undefined"
+ );
+ equal(
+ await FilterExpressions.eval("{a: 1, b: 2, c: 3}|length"),
+ undefined,
+ "length is undefined when applied to non-Array objects"
+ );
+});
+
+add_task(async function testMapToProperty() {
+ Assert.deepEqual(
+ await FilterExpressions.eval(
+ '[{a: 1}, {a: {b: 10}}, {a: [5,6,7,8]}]|mapToProperty("a")'
+ ),
+ [1, { b: 10 }, [5, 6, 7, 8]],
+ "mapToProperty returns an array of values when applied to an array of objects all with the property defined"
+ );
+
+ Assert.deepEqual(
+ await FilterExpressions.eval('[]|mapToProperty("a")'),
+ [],
+ "mapToProperty returns an empty array when applied to an empty array"
+ );
+
+ Assert.deepEqual(
+ await FilterExpressions.eval('[{a: 1}, {b: 2}, {a: 3}]|mapToProperty("a")'),
+ [1, undefined, 3],
+ "mapToProperty returns an array with undefined entries where the property is undefined"
+ );
+
+ // Should be undefined for non-Arrays
+ equal(
+ await FilterExpressions.eval('5|mapToProperty("a")'),
+ undefined,
+ "mapToProperty returns undefined when applied numbers"
+ );
+ equal(
+ await FilterExpressions.eval('null|mapToProperty("a")'),
+ undefined,
+ "mapToProperty returns undefined when applied null"
+ );
+ equal(
+ await FilterExpressions.eval('undefined|mapToProperty("a")'),
+ undefined,
+ "mapToProperty returns undefined when applied undefined"
+ );
+ equal(
+ await FilterExpressions.eval('{a: 1, b: 2, c: 3}|mapToProperty("a")'),
+ undefined,
+ "mapToProperty returns undefined when applied non-Array objects"
+ );
+});
+
+// intersect tests
+add_task(async function testIntersect() {
+ let val;
+
+ val = await FilterExpressions.eval("[1, 2, 3] intersect [4, 2, 6, 7, 3]");
+ Assert.deepEqual(
+ new Set(val),
+ new Set([2, 3]),
+ "intersect finds the common elements between two lists in JEXL"
+ );
+
+ const context = { left: [5, 7], right: [4, 5, 3] };
+ val = await FilterExpressions.eval("left intersect right", context);
+ Assert.deepEqual(
+ new Set(val),
+ new Set([5]),
+ "intersect finds the common elements between two lists in the context"
+ );
+
+ val = await FilterExpressions.eval(
+ "['string', 2] intersect [4, 'string', 'other', 3]"
+ );
+ Assert.deepEqual(
+ new Set(val),
+ new Set(["string"]),
+ "intersect can compare strings"
+ );
+
+ // Return undefined when intersecting things that aren't lists.
+ equal(
+ await FilterExpressions.eval("5 intersect 7"),
+ undefined,
+ "intersect returns undefined for numbers"
+ );
+ equal(
+ await FilterExpressions.eval("val intersect other", {
+ val: null,
+ other: null,
+ }),
+ undefined,
+ "intersect returns undefined for null"
+ );
+ equal(
+ await FilterExpressions.eval("5 intersect [1, 2, 5]"),
+ undefined,
+ "intersect returns undefined if only one operand is a list"
+ );
+});
+
+add_task(async function test_regExpMatch() {
+ let val;
+
+ val = await FilterExpressions.eval('"foobar"|regExpMatch("^foo(.+?)$")');
+ Assert.deepEqual(
+ new Set(val),
+ new Set(["foobar", "bar"]),
+ "regExpMatch returns the matches in an array"
+ );
+
+ val = await FilterExpressions.eval('"FOObar"|regExpMatch("^foo(.+?)$", "i")');
+ Assert.deepEqual(
+ new Set(val),
+ new Set(["FOObar", "bar"]),
+ "regExpMatch accepts flags for matching"
+ );
+
+ val = await FilterExpressions.eval('"F00bar"|regExpMatch("^foo(.+?)$", "i")');
+ Assert.equal(val, null, "regExpMatch returns null if there are no matches");
+});
+
+add_task(async function test_versionCompare() {
+ let val;
+
+ // 1.0.0 === 1
+ val = await FilterExpressions.eval('"1.0.0"|versionCompare("1")');
+ Assert.strictEqual(val, 0);
+
+ // 1.0.0 < 1.1
+ val = await FilterExpressions.eval('"1.0.0"|versionCompare("1.1")');
+ Assert.less(val, 0);
+
+ // 1.0.0 > 0.1
+ val = await FilterExpressions.eval('"1.0.0"|versionCompare("0.1")');
+ Assert.greater(val, 0);
+
+ // 111.0.1 < 110
+ val = await FilterExpressions.eval(`'111.0.1'|versionCompare('110') < 0`);
+ Assert.strictEqual(val, false);
+
+ // 111.0.1 < 111
+ val = await FilterExpressions.eval(`'111.0.1'|versionCompare('111') < 0`);
+ Assert.strictEqual(val, false);
+
+ // 111.0.1 < 111.0.1
+ val = await FilterExpressions.eval(`'111.0.1'|versionCompare('111.0.1') < 0`);
+ Assert.strictEqual(val, false);
+
+ // 111.0.1 < 111.0.2
+ val = await FilterExpressions.eval(`'111.0.1'|versionCompare('111.0.2') < 0`);
+ Assert.strictEqual(val, true);
+
+ // 111.0.1 is < 112
+ val = await FilterExpressions.eval(`'111.0.1'|versionCompare('112') < 0`);
+ Assert.strictEqual(val, true);
+
+ // 113.0a1 < 113
+ val = await FilterExpressions.eval(`'113.0a1'|versionCompare('113') < 0`);
+ Assert.strictEqual(val, true);
+
+ // 113.0a1 < 113.0a1
+ val = await FilterExpressions.eval(`'113.0a1'|versionCompare('113.0a1') < 0`);
+ Assert.strictEqual(val, false);
+
+ // 113.0a1 > 113.0a0
+ val = await FilterExpressions.eval(`'113.0a1'|versionCompare('113.0a0') > 0`);
+ Assert.strictEqual(val, true);
+
+ // 113 > 113.0a0
+ val = await FilterExpressions.eval(`'113'|versionCompare('113.0a0') > 0`);
+ Assert.strictEqual(val, true);
+
+ // 114 > 113.0a0
+ val = await FilterExpressions.eval(`'114'|versionCompare('113.0a0') > 0`);
+ Assert.strictEqual(val, true);
+
+ // 112 > 113.0a0
+ val = await FilterExpressions.eval(`'112'|versionCompare('113.0a0') > 0`);
+ Assert.strictEqual(val, false);
+});
diff --git a/toolkit/components/utils/test/unit/test_JsonSchemaValidator.js b/toolkit/components/utils/test/unit/test_JsonSchemaValidator.js
new file mode 100644
index 0000000000..2aa634149d
--- /dev/null
+++ b/toolkit/components/utils/test/unit/test_JsonSchemaValidator.js
@@ -0,0 +1,1990 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { JsonSchemaValidator } = ChromeUtils.importESModule(
+ "resource://gre/modules/components-utils/JsonSchemaValidator.sys.mjs"
+);
+
+add_task(async function test_boolean_values() {
+ let schema = {
+ type: "boolean",
+ };
+
+ // valid values
+ validate({
+ value: true,
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: true,
+ },
+ });
+ validate({
+ value: false,
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: false,
+ },
+ });
+ validate({
+ value: 0,
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: false,
+ },
+ });
+ validate({
+ value: 1,
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: true,
+ },
+ });
+
+ // Invalid values:
+ validate({
+ value: "0",
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: "0",
+ error: {
+ invalidValue: "0",
+ invalidPropertyNameComponents: [],
+ message: `The value '"0"' does not match the expected type 'boolean'`,
+ },
+ },
+ });
+ validate({
+ value: "true",
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: "true",
+ error: {
+ invalidValue: "true",
+ invalidPropertyNameComponents: [],
+ message: `The value '"true"' does not match the expected type 'boolean'`,
+ },
+ },
+ });
+ validate({
+ value: 2,
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: 2,
+ error: {
+ invalidValue: 2,
+ invalidPropertyNameComponents: [],
+ message: `The value '2' does not match the expected type 'boolean'`,
+ },
+ },
+ });
+ validate({
+ value: undefined,
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: undefined,
+ error: {
+ invalidValue: undefined,
+ invalidPropertyNameComponents: [],
+ message: `The value 'undefined' does not match the expected type 'boolean'`,
+ },
+ },
+ });
+ validate({
+ value: {},
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: {},
+ error: {
+ invalidValue: {},
+ invalidPropertyNameComponents: [],
+ message: `The value '{}' does not match the expected type 'boolean'`,
+ },
+ },
+ });
+ validate({
+ value: null,
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: null,
+ error: {
+ invalidValue: null,
+ invalidPropertyNameComponents: [],
+ message: `The value 'null' does not match the expected type 'boolean'`,
+ },
+ },
+ });
+});
+
+add_task(async function test_number_values() {
+ let schema = {
+ type: "number",
+ };
+
+ validate({
+ value: 1,
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: 1,
+ },
+ });
+
+ // Invalid values:
+ validate({
+ value: "1",
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: "1",
+ error: {
+ invalidValue: "1",
+ invalidPropertyNameComponents: [],
+ message: `The value '"1"' does not match the expected type 'number'`,
+ },
+ },
+ });
+ validate({
+ value: true,
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: true,
+ error: {
+ invalidValue: true,
+ invalidPropertyNameComponents: [],
+ message: `The value 'true' does not match the expected type 'number'`,
+ },
+ },
+ });
+ validate({
+ value: {},
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: {},
+ error: {
+ invalidValue: {},
+ invalidPropertyNameComponents: [],
+ message: `The value '{}' does not match the expected type 'number'`,
+ },
+ },
+ });
+ validate({
+ value: null,
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: null,
+ error: {
+ invalidValue: null,
+ invalidPropertyNameComponents: [],
+ message: `The value 'null' does not match the expected type 'number'`,
+ },
+ },
+ });
+});
+
+add_task(async function test_integer_values() {
+ // Integer is an alias for number
+ let schema = {
+ type: "integer",
+ };
+
+ validate({
+ value: 1,
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: 1,
+ },
+ });
+
+ // Invalid values:
+ validate({
+ value: "1",
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: "1",
+ error: {
+ invalidValue: "1",
+ invalidPropertyNameComponents: [],
+ message: `The value '"1"' does not match the expected type 'integer'`,
+ },
+ },
+ });
+ validate({
+ value: true,
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: true,
+ error: {
+ invalidValue: true,
+ invalidPropertyNameComponents: [],
+ message: `The value 'true' does not match the expected type 'integer'`,
+ },
+ },
+ });
+ validate({
+ value: {},
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: {},
+ error: {
+ invalidValue: {},
+ invalidPropertyNameComponents: [],
+ message: `The value '{}' does not match the expected type 'integer'`,
+ },
+ },
+ });
+ validate({
+ value: null,
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: null,
+ error: {
+ invalidValue: null,
+ invalidPropertyNameComponents: [],
+ message: `The value 'null' does not match the expected type 'integer'`,
+ },
+ },
+ });
+});
+
+add_task(async function test_null_values() {
+ let schema = {
+ type: "null",
+ };
+
+ validate({
+ value: null,
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: null,
+ },
+ });
+
+ // Invalid values:
+ validate({
+ value: 1,
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: 1,
+ error: {
+ invalidValue: 1,
+ invalidPropertyNameComponents: [],
+ message: `The value '1' does not match the expected type 'null'`,
+ },
+ },
+ });
+ validate({
+ value: "1",
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: "1",
+ error: {
+ invalidValue: "1",
+ invalidPropertyNameComponents: [],
+ message: `The value '"1"' does not match the expected type 'null'`,
+ },
+ },
+ });
+ validate({
+ value: true,
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: true,
+ error: {
+ invalidValue: true,
+ invalidPropertyNameComponents: [],
+ message: `The value 'true' does not match the expected type 'null'`,
+ },
+ },
+ });
+ validate({
+ value: {},
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: {},
+ error: {
+ invalidValue: {},
+ invalidPropertyNameComponents: [],
+ message: `The value '{}' does not match the expected type 'null'`,
+ },
+ },
+ });
+ validate({
+ value: [],
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: [],
+ error: {
+ invalidValue: [],
+ invalidPropertyNameComponents: [],
+ message: `The value '[]' does not match the expected type 'null'`,
+ },
+ },
+ });
+});
+
+add_task(async function test_string_values() {
+ let schema = {
+ type: "string",
+ };
+
+ validate({
+ value: "foobar",
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: "foobar",
+ },
+ });
+
+ // Invalid values:
+ validate({
+ value: 1,
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: 1,
+ error: {
+ invalidValue: 1,
+ invalidPropertyNameComponents: [],
+ message: `The value '1' does not match the expected type 'string'`,
+ },
+ },
+ });
+ validate({
+ value: true,
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: true,
+ error: {
+ invalidValue: true,
+ invalidPropertyNameComponents: [],
+ message: `The value 'true' does not match the expected type 'string'`,
+ },
+ },
+ });
+ validate({
+ value: undefined,
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: undefined,
+ error: {
+ invalidValue: undefined,
+ invalidPropertyNameComponents: [],
+ message: `The value 'undefined' does not match the expected type 'string'`,
+ },
+ },
+ });
+ validate({
+ value: {},
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: {},
+ error: {
+ invalidValue: {},
+ invalidPropertyNameComponents: [],
+ message: `The value '{}' does not match the expected type 'string'`,
+ },
+ },
+ });
+ validate({
+ value: null,
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: null,
+ error: {
+ invalidValue: null,
+ invalidPropertyNameComponents: [],
+ message: `The value 'null' does not match the expected type 'string'`,
+ },
+ },
+ });
+});
+
+add_task(async function test_URL_values() {
+ let schema = {
+ type: "URL",
+ };
+
+ let result = validate({
+ value: "https://www.example.com/foo#bar",
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: new URL("https://www.example.com/foo#bar"),
+ },
+ });
+ Assert.ok(URL.isInstance(result.parsedValue), "parsedValue is a URL");
+ Assert.equal(
+ result.parsedValue.origin,
+ "https://www.example.com",
+ "origin is correct"
+ );
+ Assert.equal(
+ result.parsedValue.pathname + result.parsedValue.hash,
+ "/foo#bar",
+ "pathname is correct"
+ );
+
+ // Invalid values:
+ validate({
+ value: "",
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: "",
+ error: {
+ invalidValue: "",
+ invalidPropertyNameComponents: [],
+ message: `The value '""' does not match the expected type 'URL'`,
+ },
+ },
+ });
+ validate({
+ value: "www.example.com",
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: "www.example.com",
+ error: {
+ invalidValue: "www.example.com",
+ invalidPropertyNameComponents: [],
+ message:
+ `The value '"www.example.com"' does not match the expected ` +
+ `type 'URL'`,
+ },
+ },
+ });
+ validate({
+ value: "https://:!$%",
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: "https://:!$%",
+ error: {
+ invalidValue: "https://:!$%",
+ invalidPropertyNameComponents: [],
+ message: `The value '"https://:!$%"' does not match the expected type 'URL'`,
+ },
+ },
+ });
+ validate({
+ value: {},
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: {},
+ error: {
+ invalidValue: {},
+ invalidPropertyNameComponents: [],
+ message: `The value '{}' does not match the expected type 'URL'`,
+ },
+ },
+ });
+});
+
+add_task(async function test_URLorEmpty_values() {
+ let schema = {
+ type: "URLorEmpty",
+ };
+
+ let result = validate({
+ value: "https://www.example.com/foo#bar",
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: new URL("https://www.example.com/foo#bar"),
+ },
+ });
+ Assert.ok(URL.isInstance(result.parsedValue), "parsedValue is a URL");
+ Assert.equal(
+ result.parsedValue.origin,
+ "https://www.example.com",
+ "origin is correct"
+ );
+ Assert.equal(
+ result.parsedValue.pathname + result.parsedValue.hash,
+ "/foo#bar",
+ "pathname is correct"
+ );
+
+ // Test that this type also accept empty strings
+ result = validate({
+ value: "",
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: "",
+ },
+ });
+ Assert.equal(typeof result.parsedValue, "string", "parsedValue is a string");
+
+ // Invalid values:
+ validate({
+ value: " ",
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: " ",
+ error: {
+ invalidValue: " ",
+ invalidPropertyNameComponents: [],
+ message: `The value '" "' does not match the expected type 'URLorEmpty'`,
+ },
+ },
+ });
+ validate({
+ value: "www.example.com",
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: "www.example.com",
+ error: {
+ invalidValue: "www.example.com",
+ invalidPropertyNameComponents: [],
+ message:
+ `The value '"www.example.com"' does not match the expected ` +
+ `type 'URLorEmpty'`,
+ },
+ },
+ });
+ validate({
+ value: "https://:!$%",
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: "https://:!$%",
+ error: {
+ invalidValue: "https://:!$%",
+ invalidPropertyNameComponents: [],
+ message:
+ `The value '"https://:!$%"' does not match the expected ` +
+ `type 'URLorEmpty'`,
+ },
+ },
+ });
+ validate({
+ value: {},
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: {},
+ error: {
+ invalidValue: {},
+ invalidPropertyNameComponents: [],
+ message: `The value '{}' does not match the expected type 'URLorEmpty'`,
+ },
+ },
+ });
+});
+
+add_task(async function test_origin_values() {
+ // Origin is a URL that doesn't contain a path/query string (i.e., it's only scheme + host + port)
+ let schema = {
+ type: "origin",
+ };
+
+ let result = validate({
+ value: "https://www.example.com",
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: new URL("https://www.example.com/"),
+ },
+ });
+ Assert.ok(URL.isInstance(result.parsedValue), "parsedValue is a URL");
+ Assert.equal(
+ result.parsedValue.origin,
+ "https://www.example.com",
+ "origin is correct"
+ );
+ Assert.equal(
+ result.parsedValue.pathname + result.parsedValue.hash,
+ "/",
+ "pathname is correct"
+ );
+
+ // Invalid values:
+ validate({
+ value: "https://www.example.com/foobar",
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: new URL("https://www.example.com/foobar"),
+ error: {
+ invalidValue: "https://www.example.com/foobar",
+ invalidPropertyNameComponents: [],
+ message:
+ `The value '"https://www.example.com/foobar"' does not match the ` +
+ `expected type 'origin'`,
+ },
+ },
+ });
+ validate({
+ value: "https://:!$%",
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: "https://:!$%",
+ error: {
+ invalidValue: "https://:!$%",
+ invalidPropertyNameComponents: [],
+ message:
+ `The value '"https://:!$%"' does not match the expected ` +
+ `type 'origin'`,
+ },
+ },
+ });
+ validate({
+ value: {},
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: {},
+ error: {
+ invalidValue: {},
+ invalidPropertyNameComponents: [],
+ message: `The value '{}' does not match the expected type 'origin'`,
+ },
+ },
+ });
+});
+
+add_task(async function test_origin_file_values() {
+ // File URLs can also be origins
+ let schema = {
+ type: "origin",
+ };
+
+ let result = validate({
+ value: "file:///foo/bar",
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: new URL("file:///foo/bar"),
+ },
+ });
+ Assert.ok(URL.isInstance(result.parsedValue), "parsedValue is a URL");
+ Assert.equal(
+ result.parsedValue.href,
+ "file:///foo/bar",
+ "Should get what we passed in"
+ );
+});
+
+add_task(async function test_origin_file_values() {
+ // File URLs can also be origins
+ let schema = {
+ type: "origin",
+ };
+
+ let result = validate({
+ value: "file:///foo/bar/foobar.html",
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: new URL("file:///foo/bar/foobar.html"),
+ },
+ });
+ Assert.ok(URL.isInstance(result.parsedValue), "parsedValue is a URL");
+ Assert.equal(
+ result.parsedValue.href,
+ "file:///foo/bar/foobar.html",
+ "Should get what we passed in"
+ );
+});
+
+add_task(async function test_array_values() {
+ // The types inside an array object must all be the same
+ let schema = {
+ type: "array",
+ items: {
+ type: "number",
+ },
+ };
+
+ validate({
+ value: [1, 2, 3],
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: [1, 2, 3],
+ },
+ });
+
+ // An empty array is also valid
+ validate({
+ value: [],
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: [],
+ },
+ });
+
+ // Invalid values:
+ validate({
+ value: [1, true, 3],
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: true,
+ error: {
+ invalidValue: true,
+ invalidPropertyNameComponents: [1],
+ message:
+ `The value 'true' does not match the expected type 'number'. The ` +
+ `invalid value is property '1' in [1,true,3]`,
+ },
+ },
+ });
+ validate({
+ value: 2,
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: undefined,
+ error: {
+ invalidValue: 2,
+ invalidPropertyNameComponents: [],
+ message: `The value '2' does not match the expected type 'array'`,
+ },
+ },
+ });
+ validate({
+ value: {},
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: undefined,
+ error: {
+ invalidValue: {},
+ invalidPropertyNameComponents: [],
+ message: `The value '{}' does not match the expected type 'array'`,
+ },
+ },
+ });
+});
+
+add_task(async function test_non_strict_arrays() {
+ // Non-strict arrays ignores invalid values (don't include
+ // them in the parsed output), instead of failing the validation.
+ // Note: invalid values might still report errors to the console.
+ let schema = {
+ type: "array",
+ strict: false,
+ items: {
+ type: "string",
+ },
+ };
+
+ validate({
+ value: ["valid1", "valid2", false, 3, "valid3"],
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: ["valid1", "valid2", "valid3"],
+ },
+ });
+
+ // Checks that strict defaults to true;
+ delete schema.strict;
+ validate({
+ value: ["valid1", "valid2", false, 3, "valid3"],
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: false,
+ error: {
+ invalidValue: false,
+ invalidPropertyNameComponents: [2],
+ message:
+ `The value 'false' does not match the expected type 'string'. The ` +
+ `invalid value is property '2' in ` +
+ `["valid1","valid2",false,3,"valid3"]`,
+ },
+ },
+ });
+
+ // Pass allowArrayNonMatchingItems, should be valid
+ validate({
+ value: ["valid1", "valid2", false, 3, "valid3"],
+ schema,
+ options: {
+ allowArrayNonMatchingItems: true,
+ },
+ expectedResult: {
+ valid: true,
+ parsedValue: ["valid1", "valid2", "valid3"],
+ },
+ });
+});
+
+add_task(async function test_object_values() {
+ // valid values below
+
+ validate({
+ value: {
+ foo: "hello",
+ bar: 123,
+ },
+ schema: {
+ type: "object",
+ properties: {
+ foo: {
+ type: "string",
+ },
+ bar: {
+ type: "number",
+ },
+ },
+ },
+ expectedResult: {
+ valid: true,
+ parsedValue: {
+ foo: "hello",
+ bar: 123,
+ },
+ },
+ });
+
+ validate({
+ value: {
+ foo: "hello",
+ bar: {
+ baz: 123,
+ },
+ },
+ schema: {
+ type: "object",
+ properties: {
+ foo: {
+ type: "string",
+ },
+ bar: {
+ type: "object",
+ properties: {
+ baz: {
+ type: "number",
+ },
+ },
+ },
+ },
+ },
+ expectedResult: {
+ valid: true,
+ parsedValue: {
+ foo: "hello",
+ bar: {
+ baz: 123,
+ },
+ },
+ },
+ });
+
+ validate({
+ value: {
+ foo: "hello",
+ bar: 123,
+ baz: "an additional property",
+ },
+ schema: {
+ type: "object",
+ additionalProperties: true,
+ properties: {
+ foo: {
+ type: "string",
+ },
+ bar: {
+ type: "number",
+ },
+ },
+ },
+ expectedResult: {
+ valid: true,
+ parsedValue: {
+ foo: "hello",
+ bar: 123,
+ },
+ },
+ });
+
+ // allowAdditionalProperties
+ let result = validate({
+ value: {
+ url: "https://www.example.com/foo#bar",
+ title: "Foo",
+ alias: "Bar",
+ },
+ schema: {
+ type: "object",
+ properties: {
+ url: {
+ type: "URL",
+ },
+ title: {
+ type: "string",
+ },
+ },
+ },
+ options: {
+ allowAdditionalProperties: true,
+ },
+ expectedResult: {
+ valid: true,
+ parsedValue: {
+ url: new URL("https://www.example.com/foo#bar"),
+ title: "Foo",
+ },
+ },
+ });
+ Assert.ok(
+ URL.isInstance(result.parsedValue.url),
+ "types inside the object are also parsed"
+ );
+ Assert.equal(
+ result.parsedValue.url.href,
+ "https://www.example.com/foo#bar",
+ "URL was correctly parsed"
+ );
+
+ // allowExplicitUndefinedProperties
+ validate({
+ value: {
+ foo: undefined,
+ },
+ schema: {
+ type: "object",
+ properties: {
+ foo: {
+ type: "string",
+ },
+ },
+ },
+ options: {
+ allowExplicitUndefinedProperties: true,
+ },
+ expectedResult: {
+ valid: true,
+ parsedValue: {},
+ },
+ });
+
+ // allowNullAsUndefinedProperties
+ validate({
+ value: {
+ foo: null,
+ },
+ schema: {
+ type: "object",
+ properties: {
+ foo: {
+ type: "string",
+ },
+ },
+ },
+ options: {
+ allowNullAsUndefinedProperties: true,
+ },
+ expectedResult: {
+ valid: true,
+ parsedValue: {},
+ },
+ });
+
+ // invalid values below
+
+ validate({
+ value: null,
+ schema: {
+ type: "object",
+ },
+ expectedResult: {
+ valid: false,
+ parsedValue: null,
+ error: {
+ invalidValue: null,
+ invalidPropertyNameComponents: [],
+ message: `The value 'null' does not match the expected type 'object'`,
+ },
+ },
+ });
+
+ validate({
+ value: {
+ url: "not a URL",
+ },
+ schema: {
+ type: "object",
+ properties: {
+ url: {
+ type: "URL",
+ },
+ },
+ },
+ expectedResult: {
+ valid: false,
+ parsedValue: "not a URL",
+ error: {
+ invalidValue: "not a URL",
+ invalidPropertyNameComponents: ["url"],
+ message:
+ `The value '"not a URL"' does not match the expected type 'URL'. ` +
+ `The invalid value is property 'url' in {"url":"not a URL"}`,
+ },
+ },
+ });
+
+ validate({
+ value: "test",
+ schema: {
+ type: "object",
+ properties: {
+ foo: {
+ type: "string",
+ },
+ },
+ },
+ expectedResult: {
+ valid: false,
+ error: {
+ invalidValue: "test",
+ invalidPropertyNameComponents: [],
+ message: `The value '"test"' does not match the expected type 'object'`,
+ },
+ },
+ });
+
+ validate({
+ value: {
+ foo: 123,
+ },
+ schema: {
+ type: "object",
+ properties: {
+ foo: {
+ type: "string",
+ },
+ },
+ },
+ expectedResult: {
+ valid: false,
+ parsedValue: 123,
+ error: {
+ invalidValue: 123,
+ invalidPropertyNameComponents: ["foo"],
+ message:
+ `The value '123' does not match the expected type 'string'. ` +
+ `The invalid value is property 'foo' in {"foo":123}`,
+ },
+ },
+ });
+
+ validate({
+ value: {
+ foo: {
+ bar: 456,
+ },
+ },
+ schema: {
+ type: "object",
+ properties: {
+ foo: {
+ type: "object",
+ properties: {
+ bar: {
+ type: "string",
+ },
+ },
+ },
+ },
+ },
+ expectedResult: {
+ valid: false,
+ parsedValue: 456,
+ error: {
+ invalidValue: 456,
+ invalidPropertyNameComponents: ["foo", "bar"],
+ message:
+ `The value '456' does not match the expected type 'string'. ` +
+ `The invalid value is property 'foo.bar' in {"foo":{"bar":456}}`,
+ },
+ },
+ });
+
+ // null non-required property with strict=true: invalid
+ validate({
+ value: {
+ foo: null,
+ },
+ schema: {
+ type: "object",
+ properties: {
+ foo: {
+ type: "string",
+ },
+ },
+ },
+ expectedResult: {
+ valid: false,
+ parsedValue: null,
+ error: {
+ invalidValue: null,
+ invalidPropertyNameComponents: ["foo"],
+ message:
+ `The value 'null' does not match the expected type 'string'. ` +
+ `The invalid value is property 'foo' in {"foo":null}`,
+ },
+ },
+ });
+ validate({
+ value: {
+ foo: null,
+ },
+ schema: {
+ type: "object",
+ strict: true,
+ properties: {
+ foo: {
+ type: "string",
+ },
+ },
+ },
+ options: {
+ allowNullAsUndefinedProperties: true,
+ },
+ expectedResult: {
+ valid: false,
+ parsedValue: null,
+ error: {
+ invalidValue: null,
+ invalidPropertyNameComponents: ["foo"],
+ message:
+ `The value 'null' does not match the expected type 'string'. ` +
+ `The invalid value is property 'foo' in {"foo":null}`,
+ },
+ },
+ });
+
+ // non-null falsey non-required property with strict=false: invalid
+ validate({
+ value: {
+ foo: false,
+ },
+ schema: {
+ type: "object",
+ properties: {
+ foo: {
+ type: "string",
+ },
+ },
+ },
+ options: {
+ allowExplicitUndefinedProperties: true,
+ allowNullAsUndefinedProperties: true,
+ },
+ expectedResult: {
+ valid: false,
+ parsedValue: false,
+ error: {
+ invalidValue: false,
+ invalidPropertyNameComponents: ["foo"],
+ message:
+ `The value 'false' does not match the expected type 'string'. ` +
+ `The invalid value is property 'foo' in {"foo":false}`,
+ },
+ },
+ });
+ validate({
+ value: {
+ foo: false,
+ },
+ schema: {
+ type: "object",
+ strict: false,
+ properties: {
+ foo: {
+ type: "string",
+ },
+ },
+ },
+ expectedResult: {
+ valid: false,
+ parsedValue: false,
+ error: {
+ invalidValue: false,
+ invalidPropertyNameComponents: ["foo"],
+ message:
+ `The value 'false' does not match the expected type 'string'. ` +
+ `The invalid value is property 'foo' in {"foo":false}`,
+ },
+ },
+ });
+
+ validate({
+ value: {
+ bogus: "test",
+ },
+ schema: {
+ type: "object",
+ properties: {
+ foo: {
+ type: "string",
+ },
+ },
+ },
+ expectedResult: {
+ valid: false,
+ parsedValue: undefined,
+ error: {
+ invalidValue: { bogus: "test" },
+ invalidPropertyNameComponents: [],
+ message: `Object has unexpected property 'bogus'`,
+ },
+ },
+ });
+
+ validate({
+ value: {
+ foo: {
+ bogus: "test",
+ },
+ },
+ schema: {
+ type: "object",
+ properties: {
+ foo: {
+ type: "object",
+ properties: {
+ bar: {
+ type: "string",
+ },
+ },
+ },
+ },
+ },
+ expectedResult: {
+ valid: false,
+ parsedValue: undefined,
+ error: {
+ invalidValue: { bogus: "test" },
+ invalidPropertyNameComponents: ["foo"],
+ message:
+ `Object has unexpected property 'bogus'. The invalid value is ` +
+ `property 'foo' in {"foo":{"bogus":"test"}}`,
+ },
+ },
+ });
+});
+
+add_task(async function test_array_of_objects() {
+ // This schema is used, for example, for bookmarks
+ let schema = {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ url: {
+ type: "URL",
+ },
+ title: {
+ type: "string",
+ },
+ },
+ },
+ };
+
+ validate({
+ value: [
+ {
+ url: "https://www.example.com/bookmark1",
+ title: "Foo",
+ },
+ {
+ url: "https://www.example.com/bookmark2",
+ title: "Bar",
+ },
+ ],
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: [
+ {
+ url: new URL("https://www.example.com/bookmark1"),
+ title: "Foo",
+ },
+ {
+ url: new URL("https://www.example.com/bookmark2"),
+ title: "Bar",
+ },
+ ],
+ },
+ });
+});
+
+add_task(async function test_missing_arrays_inside_objects() {
+ let schema = {
+ type: "object",
+ properties: {
+ allow: {
+ type: "array",
+ items: {
+ type: "boolean",
+ },
+ },
+ block: {
+ type: "array",
+ items: {
+ type: "boolean",
+ },
+ },
+ },
+ };
+
+ validate({
+ value: {
+ allow: [true, true, true],
+ },
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: {
+ allow: [true, true, true],
+ },
+ },
+ });
+});
+
+add_task(async function test_required_vs_nonrequired_properties() {
+ let schema = {
+ type: "object",
+ properties: {
+ "non-required-property": {
+ type: "number",
+ },
+
+ "required-property": {
+ type: "number",
+ },
+ },
+ required: ["required-property"],
+ };
+
+ validate({
+ value: {
+ "required-property": 5,
+ "non-required-property": undefined,
+ },
+ schema,
+ options: {
+ allowExplicitUndefinedProperties: true,
+ },
+ expectedResult: {
+ valid: true,
+ parsedValue: {
+ "required-property": 5,
+ },
+ },
+ });
+
+ validate({
+ value: {
+ "non-required-property": 5,
+ },
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: undefined,
+ error: {
+ invalidValue: {
+ "non-required-property": 5,
+ },
+ invalidPropertyNameComponents: [],
+ message: `Object is missing required property 'required-property'`,
+ },
+ },
+ });
+});
+
+add_task(async function test_number_or_string_values() {
+ let schema = {
+ type: ["number", "string"],
+ };
+
+ validate({
+ value: 1,
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: 1,
+ },
+ });
+ validate({
+ value: "foobar",
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: "foobar",
+ },
+ });
+ validate({
+ value: "1",
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: "1",
+ },
+ });
+
+ // Invalid values:
+ validate({
+ value: true,
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: undefined,
+ error: {
+ invalidValue: true,
+ invalidPropertyNameComponents: [],
+ message: `The value 'true' does not match any type in ["number","string"]`,
+ },
+ },
+ });
+ validate({
+ value: {},
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: undefined,
+ error: {
+ invalidValue: {},
+ invalidPropertyNameComponents: [],
+ message: `The value '{}' does not match any type in ["number","string"]`,
+ },
+ },
+ });
+ validate({
+ value: null,
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: undefined,
+ error: {
+ invalidValue: null,
+ invalidPropertyNameComponents: [],
+ message: `The value 'null' does not match any type in ["number","string"]`,
+ },
+ },
+ });
+});
+
+add_task(async function test_number_or_array_values() {
+ let schema = {
+ type: ["number", "array"],
+ items: {
+ type: "number",
+ },
+ };
+
+ validate({
+ value: 1,
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: 1,
+ },
+ });
+ validate({
+ value: [1, 2, 3],
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: [1, 2, 3],
+ },
+ });
+
+ // Invalid values:
+ validate({
+ value: true,
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: undefined,
+ error: {
+ invalidValue: true,
+ invalidPropertyNameComponents: [],
+ message: `The value 'true' does not match any type in ["number","array"]`,
+ },
+ },
+ });
+ validate({
+ value: {},
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: undefined,
+ error: {
+ invalidValue: {},
+ invalidPropertyNameComponents: [],
+ message: `The value '{}' does not match any type in ["number","array"]`,
+ },
+ },
+ });
+ validate({
+ value: null,
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: undefined,
+ error: {
+ invalidValue: null,
+ invalidPropertyNameComponents: [],
+ message: `The value 'null' does not match any type in ["number","array"]`,
+ },
+ },
+ });
+ validate({
+ value: ["a", "b"],
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: undefined,
+ error: {
+ invalidValue: ["a", "b"],
+ invalidPropertyNameComponents: [],
+ message: `The value '["a","b"]' does not match any type in ["number","array"]`,
+ },
+ },
+ });
+ validate({
+ value: [[]],
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: undefined,
+ error: {
+ invalidValue: [[]],
+ invalidPropertyNameComponents: [],
+ message: `The value '[[]]' does not match any type in ["number","array"]`,
+ },
+ },
+ });
+ validate({
+ value: [0, 1, [2, 3]],
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: undefined,
+ error: {
+ invalidValue: [0, 1, [2, 3]],
+ invalidPropertyNameComponents: [],
+ message:
+ `The value '[0,1,[2,3]]' does not match any type in ` +
+ `["number","array"]`,
+ },
+ },
+ });
+});
+
+add_task(function test_number_or_null_Values() {
+ let schema = {
+ type: ["number", "null"],
+ };
+
+ validate({
+ value: 1,
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: 1,
+ },
+ });
+ validate({
+ value: null,
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: null,
+ },
+ });
+
+ // Invalid values:
+ validate({
+ value: true,
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: undefined,
+ error: {
+ invalidValue: true,
+ invalidPropertyNameComponents: [],
+ message: `The value 'true' does not match any type in ["number","null"]`,
+ },
+ },
+ });
+ validate({
+ value: "string",
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: undefined,
+ error: {
+ invalidValue: "string",
+ invalidPropertyNameComponents: [],
+ message: `The value '"string"' does not match any type in ["number","null"]`,
+ },
+ },
+ });
+ validate({
+ value: {},
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: undefined,
+ error: {
+ invalidValue: {},
+ invalidPropertyNameComponents: [],
+ message: `The value '{}' does not match any type in ["number","null"]`,
+ },
+ },
+ });
+ validate({
+ value: ["a", "b"],
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: undefined,
+ error: {
+ invalidValue: ["a", "b"],
+ invalidPropertyNameComponents: [],
+ message: `The value '["a","b"]' does not match any type in ["number","null"]`,
+ },
+ },
+ });
+});
+
+add_task(async function test_patternProperties() {
+ let schema = {
+ type: "object",
+ properties: {
+ "S-bool-property": { type: "boolean" },
+ },
+ patternProperties: {
+ "^S-": { type: "string" },
+ "^N-": { type: "number" },
+ "^B-": { type: "boolean" },
+ },
+ };
+
+ validate({
+ value: {
+ "S-string": "test",
+ "N-number": 5,
+ "B-boolean": true,
+ "S-bool-property": false,
+ },
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: {
+ "S-string": "test",
+ "N-number": 5,
+ "B-boolean": true,
+ "S-bool-property": false,
+ },
+ },
+ });
+
+ validate({
+ value: {
+ "N-string": "test",
+ },
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: "test",
+ error: {
+ invalidValue: "test",
+ invalidPropertyNameComponents: ["N-string"],
+ message:
+ `The value '"test"' does not match the expected type 'number'. ` +
+ `The invalid value is property 'N-string' in {"N-string":"test"}`,
+ },
+ },
+ });
+
+ validate({
+ value: {
+ "S-number": 5,
+ },
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: 5,
+ error: {
+ invalidValue: 5,
+ invalidPropertyNameComponents: ["S-number"],
+ message:
+ `The value '5' does not match the expected type 'string'. ` +
+ `The invalid value is property 'S-number' in {"S-number":5}`,
+ },
+ },
+ });
+
+ schema = {
+ type: "object",
+ patternProperties: {
+ "[": { " type": "string" },
+ },
+ };
+
+ Assert.throws(
+ () => JsonSchemaValidator.validate({}, schema),
+ /Invalid property pattern/,
+ "Checking that invalid property patterns throw"
+ );
+});
+
+add_task(async function test_JSON_type() {
+ let schema = {
+ type: "JSON",
+ };
+
+ validate({
+ value: {
+ a: "b",
+ },
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: {
+ a: "b",
+ },
+ },
+ });
+ validate({
+ value: '{"a": "b"}',
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: {
+ a: "b",
+ },
+ },
+ });
+
+ validate({
+ value: "{This{is{not{JSON}}}}",
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: undefined,
+ error: {
+ invalidValue: "{This{is{not{JSON}}}}",
+ invalidPropertyNameComponents: [],
+ message: `JSON string could not be parsed: "{This{is{not{JSON}}}}"`,
+ },
+ },
+ });
+ validate({
+ value: "0",
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: undefined,
+ error: {
+ invalidValue: "0",
+ invalidPropertyNameComponents: [],
+ message: `JSON was not an object: "0"`,
+ },
+ },
+ });
+ validate({
+ value: "true",
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: undefined,
+ error: {
+ invalidValue: "true",
+ invalidPropertyNameComponents: [],
+ message: `JSON was not an object: "true"`,
+ },
+ },
+ });
+});
+
+add_task(async function test_enum() {
+ let schema = {
+ type: "string",
+ enum: ["one", "two"],
+ };
+
+ validate({
+ value: "one",
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: "one",
+ },
+ });
+
+ validate({
+ value: "three",
+ schema,
+ expectedResult: {
+ valid: false,
+ parsedValue: undefined,
+ error: {
+ invalidValue: "three",
+ invalidPropertyNameComponents: [],
+ message:
+ `The value '"three"' is not one of the enumerated values ` +
+ `["one","two"]`,
+ },
+ },
+ });
+});
+
+add_task(async function test_bool_enum() {
+ let schema = {
+ type: "boolean",
+ enum: ["one", "two"],
+ };
+
+ // `enum` is ignored because `type` is boolean.
+ validate({
+ value: true,
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: true,
+ },
+ });
+});
+
+add_task(async function test_boolint_enum() {
+ let schema = {
+ type: "boolean",
+ enum: ["one", "two"],
+ };
+
+ // `enum` is ignored because `type` is boolean and the integer value was
+ // coerced to boolean.
+ validate({
+ value: 1,
+ schema,
+ expectedResult: {
+ valid: true,
+ parsedValue: true,
+ },
+ });
+});
+
+/**
+ * Validates a value against a schema and asserts that the result is as
+ * expected.
+ *
+ * @param {*} value
+ * The value to validate.
+ * @param {object} schema
+ * The schema to validate against.
+ * @param {object} expectedResult
+ * The expected result. See JsonSchemaValidator.validate for what this object
+ * should look like. If the expected result is invalid, then this object
+ * should have an `error` property with all the properties of validation
+ * errors, including `message`, except that `rootValue` and `rootSchema` are
+ * unnecessary because this function will add them for you.
+ * @param {object} options
+ * Options to pass to JsonSchemaValidator.validate.
+ * @return {object} The return value of JsonSchemaValidator.validate, which is
+ * a result.
+ */
+function validate({ value, schema, expectedResult, options = undefined }) {
+ let result = JsonSchemaValidator.validate(value, schema, options);
+
+ checkObject(
+ result,
+ expectedResult,
+ {
+ valid: false,
+ parsedValue: true,
+ },
+ "Checking result property: "
+ );
+
+ Assert.equal("error" in result, "error" in expectedResult, "result.error");
+ if (result.error && expectedResult.error) {
+ expectedResult.error = Object.assign(expectedResult.error, {
+ rootValue: value,
+ rootSchema: schema,
+ });
+ checkObject(
+ result.error,
+ expectedResult.error,
+ {
+ rootValue: true,
+ rootSchema: false,
+ invalidPropertyNameComponents: false,
+ invalidValue: true,
+ message: false,
+ },
+ "Checking result.error property: "
+ );
+ }
+
+ return result;
+}
+
+/**
+ * Asserts that an object is the same as an expected object.
+ *
+ * @param {*} actual
+ * The actual object.
+ * @param {*} expected
+ * The expected object.
+ * @param {object} properties
+ * The properties to compare in the two objects. This value should be an
+ * object. The keys are the names of properties in the two objects. The
+ * values are booleans: true means that the property should be compared using
+ * strict equality and false means deep equality. Deep equality is used if
+ * the property is an object.
+ */
+function checkObject(actual, expected, properties, message) {
+ for (let [name, strict] of Object.entries(properties)) {
+ let assertFunc =
+ !strict || typeof expected[name] == "object"
+ ? "deepEqual"
+ : "strictEqual";
+ Assert[assertFunc](actual[name], expected[name], message + name);
+ }
+}
diff --git a/toolkit/components/utils/test/unit/test_Sampling.js b/toolkit/components/utils/test/unit/test_Sampling.js
new file mode 100644
index 0000000000..c698ddf8b1
--- /dev/null
+++ b/toolkit/components/utils/test/unit/test_Sampling.js
@@ -0,0 +1,127 @@
+"use strict";
+
+const { Sampling } = ChromeUtils.importESModule(
+ "resource://gre/modules/components-utils/Sampling.sys.mjs"
+);
+
+add_task(async function testStableSample() {
+ // Absolute samples
+ equal(
+ await Sampling.stableSample("test", 1),
+ true,
+ "stableSample returns true for 100% sample"
+ );
+ equal(
+ await Sampling.stableSample("test", 0),
+ false,
+ "stableSample returns false for 0% sample"
+ );
+
+ // Known samples. The numbers are nonces to make the tests pass
+ equal(
+ await Sampling.stableSample("test-0", 0.5),
+ true,
+ "stableSample returns true for known matching sample"
+ );
+ equal(
+ await Sampling.stableSample("test-1", 0.5),
+ false,
+ "stableSample returns false for known non-matching sample"
+ );
+});
+
+add_task(async function testBucketSample() {
+ // Absolute samples
+ equal(
+ await Sampling.bucketSample("test", 0, 10, 10),
+ true,
+ "bucketSample returns true for 100% sample"
+ );
+ equal(
+ await Sampling.bucketSample("test", 0, 0, 10),
+ false,
+ "bucketSample returns false for 0% sample"
+ );
+
+ // Known samples. The numbers are nonces to make the tests pass
+ equal(
+ await Sampling.bucketSample("test-0", 0, 5, 10),
+ true,
+ "bucketSample returns true for known matching sample"
+ );
+ equal(
+ await Sampling.bucketSample("test-1", 0, 5, 10),
+ false,
+ "bucketSample returns false for known non-matching sample"
+ );
+});
+
+add_task(async function testRatioSample() {
+ // Invalid input
+ await Assert.rejects(
+ Sampling.ratioSample("test", []),
+ /ratios must be at least 1 element long/,
+ "ratioSample rejects for a list with no ratios"
+ );
+
+ // Absolute samples
+ equal(
+ await Sampling.ratioSample("test", [1]),
+ 0,
+ "ratioSample returns 0 for a list with only 1 ratio"
+ );
+ equal(
+ await Sampling.ratioSample("test", [0, 0, 1, 0]),
+ 2,
+ "ratioSample returns the only non-zero bucket if all other buckets are zero"
+ );
+
+ // Known samples. The numbers are nonces to make the tests pass
+ equal(
+ await Sampling.ratioSample("test-0", [1, 1]),
+ 0,
+ "ratioSample returns the correct index for known matching sample"
+ );
+ equal(
+ await Sampling.ratioSample("test-1", [1, 1]),
+ 1,
+ "ratioSample returns the correct index for known non-matching sample"
+ );
+});
+
+add_task(async function testFractionToKey() {
+ // Test that results are always 12 character hexadecimal strings.
+ const expected_regex = /[0-9a-f]{12}/;
+ const count = 100;
+ let successes = 0;
+ for (let i = 0; i < count; i++) {
+ const p = Sampling.fractionToKey(Math.random());
+ if (expected_regex.test(p)) {
+ successes++;
+ }
+ }
+ equal(successes, count, "fractionToKey makes keys the right length");
+});
+
+add_task(async function testTruncatedHash() {
+ const expected_regex = /[0-9a-f]{12}/;
+ const count = 100;
+ let successes = 0;
+ for (let i = 0; i < count; i++) {
+ const h = await Sampling.truncatedHash(Math.random());
+ if (expected_regex.test(h)) {
+ successes++;
+ }
+ }
+ equal(successes, count, "truncatedHash makes hashes the right length");
+});
+
+add_task(async function testBufferToHex() {
+ const data = new ArrayBuffer(4);
+ const view = new DataView(data);
+ view.setUint8(0, 0xff);
+ view.setUint8(1, 0x7f);
+ view.setUint8(2, 0x3f);
+ view.setUint8(3, 0x1f);
+ equal(Sampling.bufferToHex(data), "ff7f3f1f");
+});
diff --git a/toolkit/components/utils/test/unit/xpcshell.toml b/toolkit/components/utils/test/unit/xpcshell.toml
new file mode 100644
index 0000000000..4de6940699
--- /dev/null
+++ b/toolkit/components/utils/test/unit/xpcshell.toml
@@ -0,0 +1,10 @@
+[DEFAULT]
+
+["test_ClientEnvironment.js"]
+skip-if = ["os == 'android' && release_or_beta"] # Bug 1707041
+
+["test_FilterExpressions.js"]
+
+["test_JsonSchemaValidator.js"]
+
+["test_Sampling.js"]