gg-json-hash
Version:
Add hashes to nested json objects.
490 lines (489 loc) • 17.4 kB
JavaScript
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
};