UNPKG

gg-json-hash

Version:
490 lines (489 loc) 17.4 kB
var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); import { Sha256 } from "@aws-crypto/sha256-js"; import { fromUint8Array } from "js-base64"; class NumberHashingConfig { constructor() { __publicField(this, "precision", 1e-3); __publicField(this, "maxNum", 1e3 * 1e3 * 1e3); __publicField(this, "minNum", -this.maxNum); __publicField(this, "throwOnRangeError", true); } /** * Default configuration. * * @type {NumberHashingConfig} */ static get default() { return new NumberHashingConfig(); } } class ApplyJsonHashConfig { /** * Constructor * @param {boolean} [inPlace=false] - Whether to modify the JSON object in place. * @param {boolean} [updateExistingHashes=true] - Whether to update existing hashes. * @param {boolean} [throwOnHashMismatch=true] - Whether to throw an error if existing hashes are wrong. */ constructor(inPlace, updateExistingHashes, throwOnHashMismatch) { __publicField(this, "inPlace"); __publicField(this, "updateExistingHashes"); __publicField(this, "throwIfOnWrongHashes"); this.inPlace = inPlace ?? false; this.updateExistingHashes = updateExistingHashes ?? true; this.throwIfOnWrongHashes = throwOnHashMismatch ?? true; } /** * Default configuration. * * @type {ApplyJsonHashConfig} */ static get default() { return new ApplyJsonHashConfig(); } } class HashConfig { // ........................................................................... /** * Constructor * @param {number} [hashLength=22] - Length of the hash. * @param {string} [hashAlgorithm='SHA-256'] - Algorithm to use for hashing. * @param {NumberHashingConfig} [numberConfig=HashNumberHashingConfig.default] - Configuration for hashing numbers. */ constructor(hashLength, hashAlgorithm, numberConfig) { __publicField(this, "hashLength"); __publicField(this, "hashAlgorithm"); __publicField(this, "numberConfig"); this.hashLength = hashLength ?? 22; this.hashAlgorithm = hashAlgorithm ?? "SHA-256"; this.numberConfig = numberConfig ?? NumberHashingConfig.default; } /** * Default configuration. * * @type {HashConfig} */ static get default() { return new HashConfig(); } } const _JsonHash = class _JsonHash { // ........................................................................... /** * Constructor * @param {HashConfig} [config=HashConfig.default] - Configuration for the hash. */ constructor(config) { __publicField(this, "config"); /** * Checks an basic type. Throws an error if the type is not supported. */ __publicField(this, "checkBasicType", (value) => this._checkBasicType(value)); this.config = config ?? HashConfig.default; } /** * Default instance. * * @type {JsonHash} */ static get default() { return new _JsonHash(); } // ........................................................................... /** * Writes hashes into the JSON object. * @param {Record<string, any>} json - The JSON object to hash. * @param {ApplyJsonHashConfig} [applyConfig=HashApplyToConfig.default] - Options for the operation. * @returns {Record<string, any>} The JSON object with hashes added. */ apply(json, applyConfig) { applyConfig = applyConfig ?? ApplyJsonHashConfig.default; const copy = applyConfig.inPlace ? json : _JsonHash._copyJson(json); this._addHashesToObject(copy, applyConfig); if (applyConfig.throwIfOnWrongHashes) { this.validate(copy); } return copy; } // ........................................................................... applyInPlace(json, updateExistingHashes = false, throwIfWrongHashes = true) { const applyConfig = new ApplyJsonHashConfig( true, updateExistingHashes, throwIfWrongHashes ); return this.apply(json, applyConfig); } // ........................................................................... /** * Writes hashes into a JSON string. * @param {string} jsonString - The JSON string to hash. * @returns {string} The JSON string with hashes added. */ applyToJsonString(jsonString) { const json = JSON.parse(jsonString); const applyConfig = ApplyJsonHashConfig.default; applyConfig.inPlace = true; const hashedJson = this.apply(json, applyConfig); return JSON.stringify(hashedJson); } // ........................................................................... /** * Calculates a SHA-256 hash of a string with base64 url. * @param {string} value - The string to hash. * @returns {string} The calculated hash. */ calcHash(value) { if (typeof value === "string") { return this._calcStringHash(value); } else if (Array.isArray(value)) { return this._calcArrayHash(value); } else { return this.apply(value)._hash; } } // ........................................................................... /** * Throws if hashes are not correct. * @param {Record<string, any>} json - The JSON object to validate. */ validate(json) { const ac = ApplyJsonHashConfig.default; ac.throwIfOnWrongHashes = false; const jsonWithCorrectHashes = this.apply(json, ac); this._validate(json, jsonWithCorrectHashes, ""); return json; } // ###################### // Private // ###################### // ........................................................................... /** * Validates the hashes of the JSON object. * @param {Record<string, any>} jsonIs - The JSON object to check. * @param {Record<string, any>} jsonShould - The JSON object with correct hashes. * @param {string} path - The current path in the JSON object. */ _validate(jsonIs, jsonShould, path) { const expectedHash = jsonShould["_hash"]; const actualHash = jsonIs["_hash"]; if (actualHash == null) { const pathHint = path ? ` at ${path}` : ""; throw new Error(`Hash${pathHint} is missing.`); } if (expectedHash !== actualHash) { const pathHint = path ? ` at ${path}` : ""; throw new Error( `Hash${pathHint} "${actualHash}" is wrong. Should be "${expectedHash}".` ); } for (const [key, value] of Object.entries(jsonIs)) { if (key === "_hash") continue; if (value !== null && typeof value === "object" && !Array.isArray(value)) { const childIs = value; const childShould = jsonShould[key]; this._validate(childIs, childShould, `${path}/${key}`); } else if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { if (typeof value[i] === "object" && !Array.isArray(value[i])) { const itemIs = value[i]; if (itemIs == null) { continue; } const itemShould = jsonShould[key][i]; this._validate(itemIs, itemShould, `${path}/${key}/${i}`); } } } } } // ........................................................................... _calcStringHash(string) { const hash = new Sha256(); hash.update(string); const bytes = hash.digestSync(); const urlSafe = true; const base64 = fromUint8Array(bytes, urlSafe).substring( 0, this.config.hashLength ); return base64; } // ........................................................................... _calcArrayHash(array) { const object = { array, _hash: "" }; this.applyInPlace(object); return object._hash; } // ........................................................................... /** * Recursively adds hashes to a nested object. * @param {Record<string, any>} obj - The object to add hashes to. * @param {ApplyJsonHashConfig} applyConfig - Whether to process recursively. */ _addHashesToObject(obj, applyConfig) { const updateExisting = applyConfig.updateExistingHashes; const throwIfOnWrongHashes = applyConfig.throwIfOnWrongHashes; const existingHash = obj["_hash"]; if (!updateExisting && existingHash) { return; } for (const [, value] of Object.entries(obj)) { if (value !== null && typeof value === "object" && !Array.isArray(value)) { const existingHash2 = value["_hash"]; if (existingHash2 && !updateExisting) { continue; } this._addHashesToObject(value, applyConfig); } else if (Array.isArray(value)) { this._processList(value, applyConfig); } } const objToHash = {}; for (const [key, value] of Object.entries(obj)) { if (key === "_hash") continue; if (value === null) { objToHash[key] = null; } else if (typeof value === "object" && !Array.isArray(value)) { objToHash[key] = value["_hash"]; } else if (Array.isArray(value)) { objToHash[key] = this._flattenList(value); } else if (_JsonHash._isBasicType(value)) { objToHash[key] = this._checkBasicType(value); } } const sortedMapJson = _JsonHash._jsonString(objToHash); const hash = this.calcHash(sortedMapJson); if (throwIfOnWrongHashes) { const oldHash = obj["_hash"]; if (oldHash && oldHash !== hash) { throw new Error( `Hash "${oldHash}" does not match the newly calculated one "${hash}". Please make sure that all systems are producing the same hashes.` ); } } obj["_hash"] = hash; } _checkBasicType(value) { if (typeof value === "string") { return value; } if (typeof value === "number") { this._checkNumber(value); return value; } else if (typeof value === "boolean") { return value; } else { throw new Error(`Unsupported type: ${typeof value}`); } } // ........................................................................... /** * Builds a representation of a list for hashing. * @param {Array<any>} list - The list to flatten. * @returns {Array<any>} The flattened list. */ _flattenList(list) { const flattenedList = []; for (const element of list) { if (element == null) { flattenedList.push(null); } else if (typeof element === "object" && !Array.isArray(element)) { flattenedList.push(element["_hash"]); } else if (Array.isArray(element)) { flattenedList.push(this._flattenList(element)); } else if (_JsonHash._isBasicType(element)) { flattenedList.push(this._checkBasicType(element)); } } return flattenedList; } // ........................................................................... /** * Recursively processes a list, adding hashes to nested objects and lists. * @param {Array<any>} list - The list to process. * @param {ApplyJsonHashConfig} applyConfig - Whether to process recursively. */ _processList(list, applyConfig) { for (const element of list) { if (element === null) { continue; } else if (typeof element === "object" && !Array.isArray(element)) { this._addHashesToObject(element, applyConfig); } else if (Array.isArray(element)) { this._processList(element, applyConfig); } } } // ........................................................................... /** * Copies the JSON object. * @param {Record<string, any>} json - The JSON object to copy. * @returns {Record<string, any>} The copied JSON object. */ static _copyJson(json) { const copy = {}; for (const [key, value] of Object.entries(json)) { if (value === null) { copy[key] = null; } else if (Array.isArray(value)) { copy[key] = _JsonHash._copyList(value); } else if (_JsonHash._isBasicType(value)) { copy[key] = value; } else if (value.constructor === Object) { copy[key] = _JsonHash._copyJson(value); } else { throw new Error(`Unsupported type: ${typeof value}`); } } return copy; } // ........................................................................... /** * Copies the list. * @param {Array<any>} list - The list to copy. * @returns {Array<any>} The copied list. */ static _copyList(list) { const copy = []; for (const element of list) { if (element == null) { copy.push(null); } else if (Array.isArray(element)) { copy.push(_JsonHash._copyList(element)); } else if (_JsonHash._isBasicType(element)) { copy.push(element); } else if (element.constructor === Object) { copy.push(_JsonHash._copyJson(element)); } else { throw new Error(`Unsupported type: ${typeof element}`); } } return copy; } // ........................................................................... /** * Checks if a value is a basic type. * @param {any} value - The value to check. * @returns {boolean} True if the value is a basic type, false otherwise. */ static _isBasicType(value) { return typeof value === "string" || typeof value === "number" || typeof value === "boolean"; } // ........................................................................... /** * Turns a number into a string with a given precision. * @param {number} value - The number to check. */ _checkNumber(value) { if (isNaN(value)) { throw new Error("NaN is not supported."); } if (Number.isInteger(value)) { return; } if (this._exceedsPrecision(value)) { throw new Error(`Number ${value} has a higher precision than 0.001.`); } if (this._exceedsUpperRange(value)) { throw new Error(`Number ${value} exceeds NumberHashingConfig.maxNum.`); } if (this._exceedsLowerRange(value)) { throw new Error( `Number ${value} is smaller than NumberHashingConfig.minNum.` ); } } // ........................................................................... /** * Checks if a number exceeds the defined range. * @param {number} value - The number to check. * @returns {boolean} True if the number exceeds the given range, false otherwise. */ _exceedsUpperRange(value) { return value > this.config.numberConfig.maxNum; } // ........................................................................... /** * Checks if a number exceeds the defined range. * @param {number} value - The number to check. * @returns {boolean} True if the number exceeds the given range, false otherwise. */ _exceedsLowerRange(value) { return value < this.config.numberConfig.minNum; } // ........................................................................... /** * Checks if a number exceeds the precision. * @param {number} value - The number to check. * @returns {boolean} True if the number exceeds the precision, false otherwise. */ _exceedsPrecision(value) { const precision = this.config.numberConfig.precision; const roundedValue = Math.round(value / precision) * precision; return Math.abs(value - roundedValue) > Number.EPSILON; } // ........................................................................... /** * Converts a map to a JSON string. * @param {Record<string, any>} map - The map to convert. * @returns {string} The JSON string representation of the map. */ static _jsonString(map) { const sortedKeys = Object.keys(map).sort(); const encodeValue = (value) => { if (value == null) { return "null"; } else if (typeof value === "string") { return `"${value.replace(/"/g, '\\"')}"`; } else if (typeof value === "number" || typeof value === "boolean") { return value.toString(); } else if (Array.isArray(value)) { return `[${value.map(encodeValue).join(",")}]`; } else if (value.constructor === Object) { return _JsonHash._jsonString(value); } else { throw new Error(`Unsupported type: ${typeof value}`); } }; var result = []; result.push("{"); for (var i = 0; i < sortedKeys.length; i++) { const key = sortedKeys[i]; const isLast = i == sortedKeys.length - 1; result.push(`"${key}":${encodeValue(map[key])}`); if (!isLast) result.push(","); } result.push("}"); return result.join(""); } }; // ........................................................................... /** * Copies the JSON object. */ __publicField(_JsonHash, "copyJson", _JsonHash._copyJson); /** * Copies the list deeply */ __publicField(_JsonHash, "copyList", _JsonHash._copyList); /** * Returns the value when it is a basic type. Otherwise throws an error. */ __publicField(_JsonHash, "isBasicType", _JsonHash._isBasicType); /** * Converts a map to a JSON string. * @param {Record<string, any>} map - The map to convert. * @returns {string} The JSON string representation of the map. */ __publicField(_JsonHash, "jsonString", _JsonHash._jsonString); let JsonHash = _JsonHash; const jh = JsonHash.default; export { ApplyJsonHashConfig, HashConfig, JsonHash, NumberHashingConfig, jh };