From 1f241d2872e09f3f6b386d005e8851a1ad71adb1 Mon Sep 17 00:00:00 2001 From: Thomas Randolph <trandolph@gitlab.com> Date: Thu, 4 Jun 2020 09:10:27 -0600 Subject: [PATCH] Add a seedable UUIDv4 generator Most UUID generators assume that you want fully random UUIDs. In most cases, this is true. The `uuid` package allows a consumer to pass in `random` values (an array of 16 numbers 0-255), or a generator that outputs 16 random bytes. This is our hook into being able to provide "random" values. We just need a way to get "random" values that are actually random in most cases, but that we can control if we want to. Enter: the Mersenne Twister. Mersenne Twisters can be seeded with a number to start. They will derive all of their future twisted states from that initial seed. So: we still get "randomness," but we can also seed it to make the output deterministic. This `random.js` file outputs a single function (for now) called `uuids` that will generate a random UUIDv4 string or - if provided seeds - will generate the correct resulting UUIDv4 given those seeds. Consumers can request multiple values to avoid having to constantly call the function and/or constantly reconstruct the internal Twister. --- app/assets/javascripts/diffs/utils/uuids.js | 79 ++++++++++++++++++ config/dependency_decisions.yml | 6 ++ package.json | 3 + spec/frontend/diffs/utils/uuids_spec.js | 92 +++++++++++++++++++++ yarn.lock | 15 ++++ 5 files changed, 195 insertions(+) create mode 100644 app/assets/javascripts/diffs/utils/uuids.js create mode 100644 spec/frontend/diffs/utils/uuids_spec.js diff --git a/app/assets/javascripts/diffs/utils/uuids.js b/app/assets/javascripts/diffs/utils/uuids.js new file mode 100644 index 0000000000000..1a529c07ccc35 --- /dev/null +++ b/app/assets/javascripts/diffs/utils/uuids.js @@ -0,0 +1,79 @@ +/** + * @module uuids + */ + +/** + * A string or number representing a start state for a random generator + * @typedef {(Number|String)} Seed + */ +/** + * A UUIDv4 string in the format <code>Hex{8}-Hex{4}-4Hex{3}-[89ab]Hex{3}-Hex{12}</code> + * @typedef {String} UUIDv4 + */ + +// https://gitlab.com/gitlab-org/frontend/rfcs/-/issues/20 +/* eslint-disable import/prefer-default-export */ + +import MersenneTwister from 'mersenne-twister'; +import stringHash from 'string-hash'; +import { isString } from 'lodash'; +import { v4 } from 'uuid'; + +function getSeed(seeds) { + return seeds.reduce((seedling, seed, i) => { + let thisSeed = 0; + + if (Number.isInteger(seed)) { + thisSeed = seed; + } else if (isString(seed)) { + thisSeed = stringHash(seed); + } + + return seedling + (seeds.length - i) * thisSeed; + }, 0); +} + +function getPseudoRandomNumberGenerator(...seeds) { + let seedNumber; + + if (seeds.length) { + seedNumber = getSeed(seeds); + } else { + seedNumber = Math.floor(Math.random() * 10 ** 15); + } + + return new MersenneTwister(seedNumber); +} + +function randomValuesForUuid(prng) { + const randomValues = []; + + for (let i = 0; i <= 3; i += 1) { + const buffer = new ArrayBuffer(4); + const view = new DataView(buffer); + + view.setUint32(0, prng.random_int()); + + randomValues.push(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3)); + } + + return randomValues; +} + +/** + * Get an array of UUIDv4s + * @param {Object} [options={}] + * @param {Seed[]} [options.seeds=[]] - A list of mixed strings or numbers to seed the UUIDv4 generator + * @param {Number} [options.count=1] - A total number of UUIDv4s to generate + * @returns {UUIDv4[]} An array of UUIDv4s + */ +export function uuids({ seeds = [], count = 1 } = {}) { + const rng = getPseudoRandomNumberGenerator(...seeds); + return ( + // Create an array the same size as the number of UUIDs requested + Array(count) + .fill(0) + // Replace each slot in the array with a UUID which needs 16 (pseudo)random values to generate + .map(() => v4({ random: randomValuesForUuid(rng) })) + ); +} diff --git a/config/dependency_decisions.yml b/config/dependency_decisions.yml index 84db15d65355b..ff5ccbb3c1bb3 100644 --- a/config/dependency_decisions.yml +++ b/config/dependency_decisions.yml @@ -626,3 +626,9 @@ :why: :versions: [] :when: 2019-11-08 10:03:31.787226000 Z +- - :whitelist + - CC0-1.0 + - :who: Thomas Randolph + :why: This license is public domain + :versions: [] + :when: 2020-06-03 05:04:44.632875345 Z diff --git a/package.json b/package.json index 3c1abb083dd27..c1f20c3fc8dd4 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "lodash": "^4.17.15", "marked": "^0.3.12", "mermaid": "^8.5.1", + "mersenne-twister": "1.1.0", "mitt": "^1.2.0", "monaco-editor": "^0.18.1", "monaco-editor-webpack-plugin": "^1.7.0", @@ -120,6 +121,7 @@ "sortablejs": "^1.10.2", "sql.js": "^0.4.0", "stickyfilljs": "^2.1.0", + "string-hash": "1.1.3", "style-loader": "^1.1.3", "svg4everybody": "2.1.9", "swagger-ui-dist": "^3.24.3", @@ -133,6 +135,7 @@ "tributejs": "4.1.3", "unfetch": "^4.1.0", "url-loader": "^3.0.0", + "uuid": "8.1.0", "visibilityjs": "^1.2.4", "vue": "^2.6.10", "vue-apollo": "^3.0.3", diff --git a/spec/frontend/diffs/utils/uuids_spec.js b/spec/frontend/diffs/utils/uuids_spec.js new file mode 100644 index 0000000000000..79d3ebadd4fb9 --- /dev/null +++ b/spec/frontend/diffs/utils/uuids_spec.js @@ -0,0 +1,92 @@ +import { uuids } from '~/diffs/utils/uuids'; + +const HEX = /[a-f0-9]/i; +const HEX_RE = HEX.source; +const UUIDV4 = new RegExp( + `${HEX_RE}{8}-${HEX_RE}{4}-4${HEX_RE}{3}-[89ab]${HEX_RE}{3}-${HEX_RE}{12}`, + 'i', +); + +describe('UUIDs Util', () => { + describe('uuids', () => { + const SEQUENCE_FOR_GITLAB_SEED = [ + 'a1826a44-316c-480e-a93d-8cdfeb36617c', + 'e049db1f-a4cf-4cba-aa60-6d95e3b547dc', + '6e3c737c-13a7-4380-b17d-601f187d7e69', + 'bee5cc7f-c486-45c0-8ad3-d1ac5402632d', + 'af248c9f-a3a6-4d4f-a311-fe151ffab25a', + ]; + const SEQUENCE_FOR_12345_SEED = [ + 'edfb51e2-e3e1-4de5-90fd-fd1d21760881', + '2f154da4-0a2d-4da9-b45e-0ffed391517e', + '91566d65-8836-4222-9875-9e1df4d0bb01', + 'f6ea6c76-7640-4d71-a736-9d3bec7a1a8e', + 'bfb85869-5fb9-4c5b-a750-5af727ac5576', + ]; + + it('returns version 4 UUIDs', () => { + expect(uuids()[0]).toMatch(UUIDV4); + }); + + it('outputs an array of UUIDs', () => { + const ids = uuids({ count: 11 }); + + expect(ids.length).toEqual(11); + expect(ids.every(id => UUIDV4.test(id))).toEqual(true); + }); + + it.each` + seeds | uuid + ${['some', 'special', 'seed']} | ${'6fa53e51-0f70-4072-9c84-1c1eee1b9934'} + ${['magic']} | ${'fafae8cd-7083-44f3-b82d-43b30bd27486'} + ${['seeded']} | ${'e06ed291-46c5-4e42-836b-e7c772d48b49'} + ${['GitLab']} | ${'a1826a44-316c-480e-a93d-8cdfeb36617c'} + ${['JavaScript']} | ${'12dfb297-1560-4c38-9775-7178ef8472fb'} + ${[99, 169834, 2619]} | ${'3ecc8ad6-5b7c-4c9b-94a8-c7271c2fa083'} + ${[12]} | ${'2777374b-723b-469b-bd73-e586df964cfd'} + ${[9876, 'mixed!', 7654]} | ${'865212e0-4a16-4934-96f9-103cf36a6931'} + ${[123, 1234, 12345, 6]} | ${'40aa2ee6-0a11-4e67-8f09-72f5eba04244'} + ${[0]} | ${'8c7f0aac-97c4-4a2f-b716-a675d821ccc0'} + `( + 'should always output the UUID $uuid when the options.seeds argument is $seeds', + ({ uuid, seeds }) => { + expect(uuids({ seeds })[0]).toEqual(uuid); + }, + ); + + describe('unseeded UUID randomness', () => { + const nonRandom = Array(6) + .fill(0) + .map((_, i) => uuids({ seeds: [i] })[0]); + const random = uuids({ count: 6 }); + const moreRandom = uuids({ count: 6 }); + + it('is different from a seeded result', () => { + random.forEach((id, i) => { + expect(id).not.toEqual(nonRandom[i]); + }); + }); + + it('is different from other random results', () => { + random.forEach((id, i) => { + expect(id).not.toEqual(moreRandom[i]); + }); + }); + + it('never produces any duplicates', () => { + expect(new Set(random).size).toEqual(random.length); + }); + }); + + it.each` + seed | sequence + ${'GitLab'} | ${SEQUENCE_FOR_GITLAB_SEED} + ${12345} | ${SEQUENCE_FOR_12345_SEED} + `( + 'should output the same sequence of UUIDs for the given seed "$seed"', + ({ seed, sequence }) => { + expect(uuids({ seeds: [seed], count: 5 })).toEqual(sequence); + }, + ); + }); +}); diff --git a/yarn.lock b/yarn.lock index 542bd369c146b..ef03db616ce3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7774,6 +7774,11 @@ mermaid@^8.5.1: moment-mini "^2.22.1" scope-css "^1.2.1" +mersenne-twister@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/mersenne-twister/-/mersenne-twister-1.1.0.tgz#f916618ee43d7179efcf641bec4531eb9670978a" + integrity sha1-+RZhjuQ9cXnvz2Qb7EUx65Zwl4o= + methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" @@ -10629,6 +10634,11 @@ streamroller@^1.0.6: fs-extra "^7.0.1" lodash "^4.17.14" +string-hash@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/string-hash/-/string-hash-1.1.3.tgz#e8aafc0ac1855b4666929ed7dd1275df5d6c811b" + integrity sha1-6Kr8CsGFW0Zmkp7X3RJ1311sgRs= + string-length@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/string-length/-/string-length-2.0.0.tgz#d40dbb686a3ace960c1cffca562bf2c45f8363ed" @@ -11687,6 +11697,11 @@ uuid@3.3.2, uuid@^3.0.1, uuid@^3.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA== +uuid@8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.1.0.tgz#6f1536eb43249f473abc6bd58ff983da1ca30d8d" + integrity sha512-CI18flHDznR0lq54xBycOVmphdCYnQLKn8abKn7PXUiKUGdEd+/l9LWNJmugXel4hXq7S+RMNl34ecyC9TntWg== + v8-compile-cache@2.0.3, v8-compile-cache@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.0.3.tgz#00f7494d2ae2b688cfe2899df6ed2c54bef91dbe" -- GitLab