diff --git a/@vates/json-hash/.USAGE.md b/@vates/json-hash/.USAGE.md new file mode 100644 index 0000000000..642b89f6ac --- /dev/null +++ b/@vates/json-hash/.USAGE.md @@ -0,0 +1,16 @@ +The use for this librariy is to create a composite key from a JSON value, ignoring objects identity and properties order. + +The hash algorithm used is intended to be fast and with low collisions and is not guaranteed to be be secure. + +```js +import { jsonHash } from '@vates/json-hash' + +console.log(jsonHash('foo')) +// → "siEyldVkkW+JpqQkVVZ8h8P0gPzXocFeIg8X1xaaeQs=" + +// order of properties is ignored +console.log(jsonHash({ foo: 0, bar: 1 })) +// → "JckoRSMIBjNlgEWIXhgpBOuyLQYqABZqvf1ccb3BPg0=" +console.log(jsonHash({ bar: 1, foo: 0 })) +// → "JckoRSMIBjNlgEWIXhgpBOuyLQYqABZqvf1ccb3BPg0=" +``` diff --git a/@vates/json-hash/.npmignore b/@vates/json-hash/.npmignore new file mode 120000 index 0000000000..008d1b9b98 --- /dev/null +++ b/@vates/json-hash/.npmignore @@ -0,0 +1 @@ +../../scripts/npmignore \ No newline at end of file diff --git a/@vates/json-hash/README.md b/@vates/json-hash/README.md new file mode 100644 index 0000000000..a188753967 --- /dev/null +++ b/@vates/json-hash/README.md @@ -0,0 +1,49 @@ + + +# @vates/json-hash + +[![Package Version](https://badgen.net/npm/v/@vates/json-hash)](https://npmjs.org/package/@vates/json-hash) ![License](https://badgen.net/npm/license/@vates/json-hash) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/json-hash)](https://bundlephobia.com/result?p=@vates/json-hash) [![Node compatibility](https://badgen.net/npm/node/@vates/json-hash)](https://npmjs.org/package/@vates/json-hash) + +> Compute a stable hash from a JSON-ifiable value + +## Install + +Installation of the [npm package](https://npmjs.org/package/@vates/json-hash): + +```sh +npm install --save @vates/json-hash +``` + +## Usage + +The use for this librariy is to create a composite key from a JSON value, ignoring objects identity and properties order. + +The hash algorithm used is intended to be fast and with low collisions and is not guaranteed to be be secure. + +```js +import { jsonHash } from '@vates/json-hash' + +console.log(jsonHash('foo')) +// → "siEyldVkkW+JpqQkVVZ8h8P0gPzXocFeIg8X1xaaeQs=" + +// order of properties is ignored +console.log(jsonHash({ foo: 0, bar: 1 })) +// → "JckoRSMIBjNlgEWIXhgpBOuyLQYqABZqvf1ccb3BPg0=" +console.log(jsonHash({ bar: 1, foo: 0 })) +// → "JckoRSMIBjNlgEWIXhgpBOuyLQYqABZqvf1ccb3BPg0=" +``` + +## Contributions + +Contributions are _very_ welcomed, either on the documentation or on +the code. + +You may: + +- report any [issue](https://github.com/vatesfr/xen-orchestra/issues) + you've encountered; +- fork and create a pull request. + +## License + +[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr) diff --git a/@vates/json-hash/index.js b/@vates/json-hash/index.js new file mode 100644 index 0000000000..04d7ff0600 --- /dev/null +++ b/@vates/json-hash/index.js @@ -0,0 +1,38 @@ +'use strict' + +const { createHash } = require('node:crypto') + +function updateJsonHash(value, hash) { + if (value !== null && typeof value === 'object') { + if (Array.isArray(value)) { + hash.update('[') + for (const item of value) { + updateJsonHash(item, hash) + + // trailing is not a big deal because it does not need to be valid JSON + hash.update(',') + } + hash.update(']') + } else { + hash.update('{') + for (const key of Object.keys(value).sort()) { + updateJsonHash(key, hash) + hash.update(':') + updateJsonHash(value[key], hash) + + // trailing is not a big deal because it does not need to be valid JSON + hash.update(',') + } + hash.update('}') + } + } else { + hash.update(JSON.stringify(value)) + } +} + +exports.jsonHash = function jsonHash(value) { + // this hash does not need to be secure, it just needs to be fast and with low collisions + const hash = createHash('sha256') + updateJsonHash(value, hash) + return hash.digest('base64') +} diff --git a/@vates/json-hash/index.test.js b/@vates/json-hash/index.test.js new file mode 100644 index 0000000000..ac7a0da71f --- /dev/null +++ b/@vates/json-hash/index.test.js @@ -0,0 +1,19 @@ +const { jsonHash } = require('@vates/json-hash') +const assert = require('node:assert/strict') +const test = require('test') + +const cases = [ + [null, 'dCNOmK/nSY+12vHzasLXiswzlGT5UHA7jAGYkvmCuQs='], + [42, 'c0dctApWjo2ooEXO0RATfhWfiQrE2og7axfcZRs6gEk='], + ['foo', 'siEyldVkkW+JpqQkVVZ8h8P0gPzXocFeIg8X1xaaeQs='], + [[], 'T1PNoYwrqgwDVLtfmj7L5e0Sq02OEbqHPC8RFhICuUU='], + [{}, 'RBNvo1WzZ4oRRq0W9+hknpT7T8If536DEMBg9hyq/4o='], + [{ a: 1, b: 2 }, 'LMjVxyj2vtMZ3W1cZ2cJrQij5zn7FBOlPiyBB38OmFE='], + [{ b: 2, a: 1 }, 'LMjVxyj2vtMZ3W1cZ2cJrQij5zn7FBOlPiyBB38OmFE='], +] + +for (const [value, hash] of cases) { + test(JSON.stringify(value), function () { + assert.equal(jsonHash(value), hash) + }) +} diff --git a/@vates/json-hash/package.json b/@vates/json-hash/package.json new file mode 100644 index 0000000000..fdfb79b2f2 --- /dev/null +++ b/@vates/json-hash/package.json @@ -0,0 +1,33 @@ +{ + "private": false, + "name": "@vates/json-hash", + "description": "Compute a stable hash from a JSON-ifiable value", + "keywords": [ + "hash", + "json", + "stable" + ], + "homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/json-hash", + "bugs": "https://github.com/vatesfr/xen-orchestra/issues", + "repository": { + "directory": "@vates/json-hash", + "type": "git", + "url": "https://github.com/vatesfr/xen-orchestra.git" + }, + "author": { + "name": "Vates SAS", + "url": "https://vates.fr" + }, + "license": "ISC", + "version": "0.0.0", + "engines": { + "node": ">=8.10" + }, + "devDependencies": { + "test": "^3.3.0" + }, + "scripts": { + "postversion": "npm publish --access public", + "test": "node--test" + } +} diff --git a/packages/xen-api/index.mjs b/packages/xen-api/index.mjs index 0678773193..eb1c980595 100644 --- a/packages/xen-api/index.mjs +++ b/packages/xen-api/index.mjs @@ -10,10 +10,10 @@ import { Agent, ProxyAgent, request } from 'undici' import { coalesceCalls } from '@vates/coalesce-calls' import { Collection } from 'xo-collection' import { compose } from '@vates/compose' -import { createHash } from 'node:crypto' import { createLogger } from '@xen-orchestra/log' import { EventEmitter } from 'events' import { Index } from 'xo-collection/index.js' +import { jsonHash } from '@vates/json-hash' import { cancelable, defer, fromCallback, ignoreErrors, pDelay, pRetry, pTimeout } from 'promise-toolbox' import { limitConcurrency } from 'limit-concurrency-decorator' import { decorateClass } from '@vates/decorate-with' @@ -90,41 +90,6 @@ const addSyncStackTrace = async promise => { } } -function updateJsonHash(value, hash) { - if (value !== null && typeof value === 'object') { - if (Array.isArray(value)) { - hash.update('[') - for (const item of value) { - updateJsonHash(item, hash) - - // trailing is not a big deal because it does not need to be valid JSON - hash.update(',') - } - hash.update(']') - } else { - hash.update('{') - for (const key of Object.keys(value).sort()) { - updateJsonHash(key, hash) - hash.update(':') - updateJsonHash(value[key], hash) - - // trailing is not a big deal because it does not need to be valid JSON - hash.update(',') - } - hash.update('}') - } - } else { - hash.update(JSON.stringify(value)) - } -} - -export function jsonHash(value) { - // this hash does not need to be secure, it just needs to be fast and with low collisions - const hash = createHash('sha256') - updateJsonHash(value, hash) - return hash.digest('base64') -} - // ------------------------------------------------------------------- export class Xapi extends EventEmitter { diff --git a/packages/xen-api/package.json b/packages/xen-api/package.json index 6a53cafac8..4f98fa7943 100644 --- a/packages/xen-api/package.json +++ b/packages/xen-api/package.json @@ -34,6 +34,7 @@ "@vates/coalesce-calls": "^0.1.0", "@vates/compose": "^2.1.0", "@vates/decorate-with": "^2.1.0", + "@vates/json-hash": "^0.0.0", "@vates/obfuscate": "^0.1.0", "@vates/xml": "^2.0.0", "@vates/xml-rpc": "^1.0.0",