diff options
Diffstat (limited to 'toolkit/components/utils')
-rw-r--r-- | toolkit/components/utils/ClientEnvironment.sys.mjs | 264 | ||||
-rw-r--r-- | toolkit/components/utils/FilterExpressions.sys.mjs | 121 | ||||
-rw-r--r-- | toolkit/components/utils/JsonSchemaValidator.sys.mjs | 580 | ||||
-rw-r--r-- | toolkit/components/utils/PreferenceFilters.sys.mjs | 23 | ||||
-rw-r--r-- | toolkit/components/utils/Sampling.sys.mjs | 170 | ||||
-rw-r--r-- | toolkit/components/utils/SimpleServices.sys.mjs | 177 | ||||
-rw-r--r-- | toolkit/components/utils/WindowsInstallsInfo.sys.mjs | 91 | ||||
-rw-r--r-- | toolkit/components/utils/WindowsVersionInfo.sys.mjs | 112 | ||||
-rw-r--r-- | toolkit/components/utils/components.conf | 21 | ||||
-rw-r--r-- | toolkit/components/utils/moz.build | 29 | ||||
-rw-r--r-- | toolkit/components/utils/mozjexl.js | 1 | ||||
-rw-r--r-- | toolkit/components/utils/test/unit/test_ClientEnvironment.js | 147 | ||||
-rw-r--r-- | toolkit/components/utils/test/unit/test_FilterExpressions.js | 410 | ||||
-rw-r--r-- | toolkit/components/utils/test/unit/test_JsonSchemaValidator.js | 1963 | ||||
-rw-r--r-- | toolkit/components/utils/test/unit/test_Sampling.js | 127 | ||||
-rw-r--r-- | toolkit/components/utils/test/unit/xpcshell.ini | 5 |
16 files changed, 4241 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..422463bc16 --- /dev/null +++ b/toolkit/components/utils/FilterExpressions.sys.mjs @@ -0,0 +1,121 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PreferenceFilters: + "resource://gre/modules/components-utils/PreferenceFilters.sys.mjs", + Sampling: "resource://gre/modules/components-utils/Sampling.sys.mjs", +}); +ChromeUtils.defineModuleGetter( + lazy, + "mozjexl", + "resource://gre/modules/components-utils/mozjexl.js" +); + +XPCOMUtils.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: lazy.PreferenceFilters.preferenceValue, + preferenceIsUserSet: lazy.PreferenceFilters.preferenceIsUserSet, + preferenceExists: lazy.PreferenceFilters.preferenceExists, + 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..148683a320 --- /dev/null +++ b/toolkit/components/utils/JsonSchemaValidator.sys.mjs @@ -0,0 +1,580 @@ +/* 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. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.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} allowExtraProperties + * 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. + * @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, + allowExtraProperties = false, + } = {} + ) { + let validator = new JsonSchemaValidator({ + allowArrayNonMatchingItems, + allowExplicitUndefinedProperties, + allowNullAsUndefinedProperties, + allowExtraProperties, + }); + 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} allowExtraProperties + * See the static `validate` method above. + */ + constructor({ + allowArrayNonMatchingItems = false, + allowExplicitUndefinedProperties = false, + allowNullAsUndefinedProperties = false, + allowExtraProperties = false, + } = {}) { + this.allowArrayNonMatchingItems = allowArrayNonMatchingItems; + this.allowExplicitUndefinedProperties = allowExplicitUndefinedProperties; + this.allowNullAsUndefinedProperties = allowNullAsUndefinedProperties; + this.allowExtraProperties = allowExtraProperties; + } + + /** + * 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 allowExtraProperties = + !properties.strict && this.allowExtraProperties; + if (allowExtraProperties) { + 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/PreferenceFilters.sys.mjs b/toolkit/components/utils/PreferenceFilters.sys.mjs new file mode 100644 index 0000000000..6691d848ef --- /dev/null +++ b/toolkit/components/utils/PreferenceFilters.sys.mjs @@ -0,0 +1,23 @@ +/* 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 { Preferences } from "resource://gre/modules/Preferences.sys.mjs"; + +export var PreferenceFilters = { + // Compare the value of a given preference. Takes a `default` value as an + // optional argument to pass into `Preferences.get`. + preferenceValue(prefKey, defaultValue) { + return Preferences.get(prefKey, defaultValue); + }, + + // Compare if the preference is user set. + preferenceIsUserSet(prefKey) { + return Preferences.isSet(prefKey); + }, + + // Compare if the preference has _any_ value, whether it's user-set or default. + preferenceExists(prefKey) { + return Preferences.has(prefKey); + }, +}; 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..bfd81724f6 --- /dev/null +++ b/toolkit/components/utils/SimpleServices.sys.mjs @@ -0,0 +1,177 @@ +/* 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.defineModuleGetter( + lazy, + "NetUtil", + "resource://gre/modules/NetUtil.jsm" +); + +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 + ) { + aChannel.contentType = "text/html"; + + let contract = Services.catMan.getCategoryEntry( + "Gecko-Content-Viewers", + "text/html" + ); + let factory = Cc[contract].getService(Ci.nsIDocumentLoaderFactory); + + let listener = {}; + let res = factory.createInstance( + "view", + aChannel, + aLoadGroup, + "text/html", + aContainer, + aExtraInfo, + listener + ); + + aDocListenerResult.value = lazy.streamConv.asyncConvertData( + "application/http-index-format", + "text/html", + listener.value, + null + ); + + 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..b71a9f6f02 --- /dev/null +++ b/toolkit/components/utils/moz.build @@ -0,0 +1,29 @@ +# -*- 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", + "PreferenceFilters.sys.mjs", + "Sampling.sys.mjs", + "WindowsInstallsInfo.sys.mjs", + "WindowsVersionInfo.sys.mjs", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.ini"] 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..7d0c8f9d51 --- /dev/null +++ b/toolkit/components/utils/test/unit/test_ClientEnvironment.js @@ -0,0 +1,147 @@ +/* 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; + ok(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; + } + ok(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() { + ok( + ClientEnvironmentBase.appinfo !== undefined, + "appinfo should be available in the context" + ); + ok( + typeof ClientEnvironmentBase.appinfo === "object", + "appinfo should be an object" + ); + ok( + 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..c0f99463dc --- /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")'); + ok(val === 0); + + // 1.0.0 < 1.1 + val = await FilterExpressions.eval('"1.0.0"|versionCompare("1.1")'); + ok(val < 0); + + // 1.0.0 > 0.1 + val = await FilterExpressions.eval('"1.0.0"|versionCompare("0.1")'); + ok(val > 0); + + // 111.0.1 < 110 + val = await FilterExpressions.eval(`'111.0.1'|versionCompare('110') < 0`); + ok(val === false); + + // 111.0.1 < 111 + val = await FilterExpressions.eval(`'111.0.1'|versionCompare('111') < 0`); + ok(val === false); + + // 111.0.1 < 111.0.1 + val = await FilterExpressions.eval(`'111.0.1'|versionCompare('111.0.1') < 0`); + ok(val === false); + + // 111.0.1 < 111.0.2 + val = await FilterExpressions.eval(`'111.0.1'|versionCompare('111.0.2') < 0`); + ok(val === true); + + // 111.0.1 is < 112 + val = await FilterExpressions.eval(`'111.0.1'|versionCompare('112') < 0`); + ok(val === true); + + // 113.0a1 < 113 + val = await FilterExpressions.eval(`'113.0a1'|versionCompare('113') < 0`); + ok(val === true); + + // 113.0a1 < 113.0a1 + val = await FilterExpressions.eval(`'113.0a1'|versionCompare('113.0a1') < 0`); + ok(val === false); + + // 113.0a1 > 113.0a0 + val = await FilterExpressions.eval(`'113.0a1'|versionCompare('113.0a0') > 0`); + ok(val === true); + + // 113 > 113.0a0 + val = await FilterExpressions.eval(`'113'|versionCompare('113.0a0') > 0`); + ok(val === true); + + // 114 > 113.0a0 + val = await FilterExpressions.eval(`'114'|versionCompare('113.0a0') > 0`); + ok(val === true); + + // 112 > 113.0a0 + val = await FilterExpressions.eval(`'112'|versionCompare('113.0a0') > 0`); + ok(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..46e1f6de8b --- /dev/null +++ b/toolkit/components/utils/test/unit/test_JsonSchemaValidator.js @@ -0,0 +1,1963 @@ +/* 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, + }, + }, + }, + }); + + // allowExtraProperties + 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: { + allowExtraProperties: 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.ini b/toolkit/components/utils/test/unit/xpcshell.ini new file mode 100644 index 0000000000..314fcdd79c --- /dev/null +++ b/toolkit/components/utils/test/unit/xpcshell.ini @@ -0,0 +1,5 @@ +[test_ClientEnvironment.js] +skip-if = os == "android" && release_or_beta # Bug 1707041 +[test_FilterExpressions.js] +[test_JsonSchemaValidator.js] +[test_Sampling.js] |