diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /toolkit/components/utils/JsonSchemaValidator.sys.mjs | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/utils/JsonSchemaValidator.sys.mjs')
-rw-r--r-- | toolkit/components/utils/JsonSchemaValidator.sys.mjs | 580 |
1 files changed, 580 insertions, 0 deletions
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); +} |