diff --git a/src/binary.ts b/src/binary.ts index 525093ae..580f651a 100644 --- a/src/binary.ts +++ b/src/binary.ts @@ -1,4 +1,3 @@ -import { bufferToUuidHexString, uuidHexStringToBuffer, uuidValidateString } from './uuid_utils'; import { isUint8Array } from './parser/utils'; import type { EJSONOptions } from './extended_json'; import { BSONError } from './error'; @@ -288,7 +287,7 @@ export class Binary extends BSONValue { } } else if ('$uuid' in doc) { type = 4; - data = uuidHexStringToBuffer(doc.$uuid); + data = UUID.bytesFromString(doc.$uuid); } if (!data) { throw new BSONError(`Unexpected Binary Extended JSON format ${JSON.stringify(doc)}`); @@ -311,42 +310,40 @@ export class Binary extends BSONValue { export type UUIDExtended = { $uuid: string; }; + const UUID_BYTE_LENGTH = 16; +const UUID_WITHOUT_DASHES = /^[0-9A-F]{32}$/i; +const UUID_WITH_DASHES = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i; /** * A class representation of the BSON UUID type. * @public */ export class UUID extends Binary { - static cacheHexString: boolean; - - /** UUID hexString cache @internal */ - private __id?: string; - + static cacheHexString = false; /** - * Create an UUID type + * Create a UUID type + * + * When the argument to the constructor is omitted a random v4 UUID will be generated. * * @param input - Can be a 32 or 36 character hex string (dashes excluded/included) or a 16 byte binary Buffer. */ constructor(input?: string | Uint8Array | UUID) { let bytes: Uint8Array; - let hexStr; if (input == null) { bytes = UUID.generate(); } else if (input instanceof UUID) { bytes = ByteUtils.toLocalBufferType(new Uint8Array(input.buffer)); - hexStr = input.__id; } else if (ArrayBuffer.isView(input) && input.byteLength === UUID_BYTE_LENGTH) { bytes = ByteUtils.toLocalBufferType(input); } else if (typeof input === 'string') { - bytes = uuidHexStringToBuffer(input); + bytes = UUID.bytesFromString(input); } else { throw new BSONError( 'Argument passed in UUID constructor must be a UUID, a 16 byte Buffer or a 32/36 character hex string (dashes excluded/included, format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).' ); } super(bytes, BSON_BINARY_SUBTYPE_UUID_NEW); - this.__id = hexStr; } /** @@ -359,10 +356,6 @@ export class UUID extends Binary { set id(value: Uint8Array) { this.buffer = value; - - if (UUID.cacheHexString) { - this.__id = bufferToUuidHexString(value); - } } /** @@ -370,17 +363,16 @@ export class UUID extends Binary { * @param includeDashes - should the string exclude dash-separators. * */ toHexString(includeDashes = true): string { - if (UUID.cacheHexString && this.__id) { - return this.__id; - } - - const uuidHexString = bufferToUuidHexString(this.id, includeDashes); - - if (UUID.cacheHexString) { - this.__id = uuidHexString; + if (includeDashes) { + return [ + ByteUtils.toHex(this.buffer.subarray(0, 4)), + ByteUtils.toHex(this.buffer.subarray(4, 6)), + ByteUtils.toHex(this.buffer.subarray(6, 8)), + ByteUtils.toHex(this.buffer.subarray(8, 10)), + ByteUtils.toHex(this.buffer.subarray(10, 16)) + ].join('-'); } - - return uuidHexString; + return ByteUtils.toHex(this.buffer); } /** @@ -446,29 +438,24 @@ export class UUID extends Binary { * Checks if a value is a valid bson UUID * @param input - UUID, string or Buffer to validate. */ - static isValid(input: string | Uint8Array | UUID): boolean { + static isValid(input: string | Uint8Array | UUID | Binary): boolean { if (!input) { return false; } - if (input instanceof UUID) { - return true; - } - if (typeof input === 'string') { - return uuidValidateString(input); + return UUID.isValidUUIDString(input); } if (isUint8Array(input)) { - // check for length & uuid version (https://tools.ietf.org/html/rfc4122#section-4.1.3) - if (input.byteLength !== UUID_BYTE_LENGTH) { - return false; - } - - return (input[6] & 0xf0) === 0x40 && (input[8] & 0x80) === 0x80; + return input.byteLength === UUID_BYTE_LENGTH; } - return false; + return ( + input._bsontype === 'Binary' && + input.sub_type === this.SUBTYPE_UUID && + input.buffer.byteLength === 16 + ); } /** @@ -476,7 +463,7 @@ export class UUID extends Binary { * @param hexString - 32 or 36 character hex string (dashes excluded/included). */ static override createFromHexString(hexString: string): UUID { - const buffer = uuidHexStringToBuffer(hexString); + const buffer = UUID.bytesFromString(hexString); return new UUID(buffer); } @@ -485,6 +472,26 @@ export class UUID extends Binary { return new UUID(ByteUtils.fromBase64(base64)); } + /** @internal */ + static bytesFromString(representation: string) { + if (!UUID.isValidUUIDString(representation)) { + throw new BSONError( + 'UUID string representation must be 32 hex digits or canonical hyphenated representation' + ); + } + return ByteUtils.fromHex(representation.replace(/-/g, '')); + } + + /** + * @internal + * + * Validates a string to be a hex digit sequence with or without dashes. + * The canonical hyphenated representation of a uuid is hex in 8-4-4-4-12 groups. + */ + static isValidUUIDString(representation: string) { + return UUID_WITHOUT_DASHES.test(representation) || UUID_WITH_DASHES.test(representation); + } + /** * Converts to a string representation of this Id. * diff --git a/src/parser/deserializer.ts b/src/parser/deserializer.ts index 50d7a8d8..0389b0d9 100644 --- a/src/parser/deserializer.ts +++ b/src/parser/deserializer.ts @@ -1,5 +1,5 @@ import { Binary } from '../binary'; -import type { Document } from '../bson'; +import { Document, UUID } from '../bson'; import { Code } from '../code'; import * as constants from '../constants'; import { DBRef, DBRefLike, isDBRefLike } from '../db_ref'; @@ -404,7 +404,7 @@ function deserializeObject( value = ByteUtils.toLocalBufferType(buffer.slice(index, index + binarySize)); } else { value = new Binary(buffer.slice(index, index + binarySize), subType); - if (subType === constants.BSON_BINARY_SUBTYPE_UUID_NEW) { + if (subType === constants.BSON_BINARY_SUBTYPE_UUID_NEW && UUID.isValid(value)) { value = value.toUUID(); } } @@ -432,10 +432,11 @@ function deserializeObject( if (promoteBuffers && promoteValues) { value = _buffer; - } else if (subType === constants.BSON_BINARY_SUBTYPE_UUID_NEW) { - value = new Binary(buffer.slice(index, index + binarySize), subType).toUUID(); } else { value = new Binary(buffer.slice(index, index + binarySize), subType); + if (subType === constants.BSON_BINARY_SUBTYPE_UUID_NEW && UUID.isValid(value)) { + value = value.toUUID(); + } } } diff --git a/src/uuid_utils.ts b/src/uuid_utils.ts deleted file mode 100644 index 37f0dcfd..00000000 --- a/src/uuid_utils.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { BSONError } from './error'; -import { ByteUtils } from './utils/byte_utils'; - -// Validation regex for v4 uuid (validates with or without dashes) -const VALIDATION_REGEX = - /^(?:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|[0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15})$/i; - -export const uuidValidateString = (str: string): boolean => - typeof str === 'string' && VALIDATION_REGEX.test(str); - -export const uuidHexStringToBuffer = (hexString: string): Uint8Array => { - if (!uuidValidateString(hexString)) { - throw new BSONError( - 'UUID string representations must be a 32 or 36 character hex string (dashes excluded/included). Format: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" or "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx".' - ); - } - - const sanitizedHexString = hexString.replace(/-/g, ''); - return ByteUtils.fromHex(sanitizedHexString); -}; - -export function bufferToUuidHexString(buffer: Uint8Array, includeDashes = true): string { - if (includeDashes) { - return [ - ByteUtils.toHex(buffer.subarray(0, 4)), - ByteUtils.toHex(buffer.subarray(4, 6)), - ByteUtils.toHex(buffer.subarray(6, 8)), - ByteUtils.toHex(buffer.subarray(8, 10)), - ByteUtils.toHex(buffer.subarray(10, 16)) - ].join('-'); - } - return ByteUtils.toHex(buffer); -} diff --git a/test/node/tools/utils.js b/test/node/tools/utils.js index b90252f1..2d2432ae 100644 --- a/test/node/tools/utils.js +++ b/test/node/tools/utils.js @@ -128,6 +128,34 @@ const bufferFromHexArray = array => { exports.bufferFromHexArray = bufferFromHexArray; +/** + * A companion helper to bufferFromHexArray to help with constructing bson bytes manually. + * When creating a BSON Binary you need a leading little endian int32 followed by a sequence of bytes + * of that length. + * + * @example + * ```js + * const binAsHex = '000000'; + * const serializedUUID = bufferFromHexArray([ + * '05', // binData type + * '6100', // 'a' & null + * int32ToHex(binAsHex.length / 2), // binary starts with int32 length + * '7F', // user subtype + * binAsHex // uuid bytes + * ]); + * ``` + * + * @param {number | Int32} int32 - + * @returns + */ +function int32LEToHex(int32) { + const buf = Buffer.alloc(4); + buf.writeInt32LE(+int32, 0); + return buf.toString('hex'); +} + +exports.int32LEToHex = int32LEToHex; + /** * A helper to calculate the byte size of a string (including null) * diff --git a/test/node/uuid.test.ts b/test/node/uuid.test.ts index bf94494e..77d6411b 100644 --- a/test/node/uuid.test.ts +++ b/test/node/uuid.test.ts @@ -1,10 +1,11 @@ -import { Binary, UUID } from '../register-bson'; +import { Binary, EJSON, UUID } from '../register-bson'; import { inspect } from 'util'; import { validate as uuidStringValidate, version as uuidStringVersion } from 'uuid'; import { BSON, BSONError } from '../register-bson'; const BSON_DATA_BINARY = BSON.BSONType.binData; import { BSON_BINARY_SUBTYPE_UUID_NEW } from '../../src/constants'; import { expect } from 'chai'; +import { bufferFromHexArray, int32LEToHex } from './tools/utils'; // Test values const UPPERCASE_DASH_SEPARATED_UUID_STRING = 'AAAAAAAA-AAAA-4AAA-AAAA-AAAAAAAAAAAA'; @@ -13,9 +14,6 @@ const LOWERCASE_DASH_SEPARATED_UUID_STRING = 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa const LOWERCASE_VALUES_ONLY_UUID_STRING = 'aaaaaaaaaaaa4aaaaaaaaaaaaaaaaaaa'; describe('UUID', () => { - /** - * @ignore - */ it('should correctly generate a valid UUID v4 from empty constructor', () => { const uuid = new UUID(); const uuidHexStr = uuid.toHexString(); @@ -23,9 +21,6 @@ describe('UUID', () => { expect(uuidStringVersion(uuidHexStr)).to.equal(Binary.SUBTYPE_UUID); }); - /** - * @ignore - */ it('should correctly create UUIDs from UPPERCASE & lowercase 36 char dash-separated hex string', () => { const uuid1 = new UUID(UPPERCASE_DASH_SEPARATED_UUID_STRING); expect(uuid1.equals(UPPERCASE_DASH_SEPARATED_UUID_STRING)).to.be.true; @@ -36,9 +31,6 @@ describe('UUID', () => { expect(uuid2.toString()).to.equal(LOWERCASE_DASH_SEPARATED_UUID_STRING); }); - /** - * @ignore - */ it('should correctly create UUIDs from UPPERCASE & lowercase 32 char hex string (no dash separators)', () => { const uuid1 = new UUID(UPPERCASE_VALUES_ONLY_UUID_STRING); expect(uuid1.equals(UPPERCASE_VALUES_ONLY_UUID_STRING)).to.be.true; @@ -49,9 +41,6 @@ describe('UUID', () => { expect(uuid2.toHexString(false)).to.equal(LOWERCASE_VALUES_ONLY_UUID_STRING); }); - /** - * @ignore - */ it('should correctly create UUID from Buffer', () => { const uuid1 = new UUID(Buffer.from(UPPERCASE_VALUES_ONLY_UUID_STRING, 'hex')); expect(uuid1.equals(UPPERCASE_DASH_SEPARATED_UUID_STRING)).to.be.true; @@ -62,9 +51,6 @@ describe('UUID', () => { expect(uuid2.toString()).to.equal(LOWERCASE_DASH_SEPARATED_UUID_STRING); }); - /** - * @ignore - */ it('should correctly create UUID from UUID (copying existing buffer)', () => { const org = new UUID(); const copy = new UUID(org); @@ -72,41 +58,49 @@ describe('UUID', () => { expect(org.id).to.deep.equal(copy.id); }); - /** - * @ignore - */ - it('should throw if passed invalid 36-char uuid hex string', () => { - expect(() => new UUID(LOWERCASE_DASH_SEPARATED_UUID_STRING)).to.not.throw(); - expect(() => new UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')).to.throw(BSONError); - // Note: The version is missing here ^ - }); - - /** - * @ignore - */ it('should throw if passed unsupported argument', () => { expect(() => new UUID(LOWERCASE_DASH_SEPARATED_UUID_STRING)).to.not.throw(); expect(() => new UUID({})).to.throw(BSONError); }); - /** - * @ignore - */ - it('should correctly check if a buffer isValid', () => { - const validBuffer = Buffer.from(UPPERCASE_VALUES_ONLY_UUID_STRING, 'hex'); - const invalidBuffer1 = Buffer.from('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'hex'); - const invalidBuffer2 = Buffer.alloc(16); + context('isValid()', () => { + it('returns true for hex string with dashes', () => { + expect(UUID.isValid(UPPERCASE_VALUES_ONLY_UUID_STRING)).to.be.true; + }); + + it('returns true for hex string without dashes', () => { + expect(UUID.isValid(LOWERCASE_VALUES_ONLY_UUID_STRING)).to.be.true; + }); + + it('returns true for hex string that is not uuid v4', () => { + expect(UUID.isValid('00'.repeat(16))).to.be.true; + }); + + it('returns true for buffer of length 16', () => { + expect(UUID.isValid(Buffer.alloc(16))).to.be.true; + }); + + it('returns false for buffer not of length 16', () => { + expect(UUID.isValid(Buffer.alloc(10))).to.be.false; + }); + + it('returns false for falsy inputs', () => { + expect(UUID.isValid()).to.be.false; + expect(UUID.isValid(null)).to.be.false; + expect(UUID.isValid(false)).to.be.false; + expect(UUID.isValid('')).to.be.false; + }); + + it('returns true for Binary instances of UUID', () => { + expect(UUID.isValid(new UUID())).to.be.true; + expect(UUID.isValid(Binary.createFromHexString('00'.repeat(16), 4))).to.be.true; + }); - expect(validBuffer.length).to.equal(invalidBuffer1.length); - expect(validBuffer.length).to.equal(invalidBuffer2.length); - expect(UUID.isValid(invalidBuffer1)).to.be.false; - expect(UUID.isValid(invalidBuffer2)).to.be.false; - expect(UUID.isValid(validBuffer)).to.be.true; + it('returns false for Binary instance of the wrong length', () => { + expect(UUID.isValid(Binary.createFromHexString('00', 4))).to.be.false; + }); }); - /** - * @ignore - */ it('should correctly convert to and from a Binary instance', () => { const uuid = new UUID(LOWERCASE_DASH_SEPARATED_UUID_STRING); expect(UUID.isValid(uuid)).to.be.true; @@ -118,9 +112,6 @@ describe('UUID', () => { expect(uuid2.toHexString()).to.equal(LOWERCASE_DASH_SEPARATED_UUID_STRING); }); - /** - * @ignore - */ it('should correctly convert to and from a Binary instance', () => { const uuid = new UUID(LOWERCASE_DASH_SEPARATED_UUID_STRING); expect(UUID.isValid(uuid)).to.be.true; @@ -132,9 +123,6 @@ describe('UUID', () => { expect(uuid.equals(uuid2)).to.be.true; }); - /** - * @ignore - */ it('should throw when converted from an incompatible Binary instance', () => { const validRandomBuffer = Buffer.from('Hello World!'); const binRand = new Binary(validRandomBuffer); @@ -154,9 +142,6 @@ describe('UUID', () => { expect(() => binV4.toUUID()).to.not.throw(); }); - /** - * @ignore - */ it('should correctly allow for node.js inspect to work with UUID', () => { const uuid = new UUID(UPPERCASE_DASH_SEPARATED_UUID_STRING); expect(inspect(uuid)).to.equal(`new UUID("${LOWERCASE_DASH_SEPARATED_UUID_STRING}")`); @@ -199,6 +184,41 @@ describe('UUID', () => { }; expect(deserializedUUID).to.deep.equal(expectedResult); }); + + it('returns Binary when value is subtype 4 but invalid UUID', () => { + const exampleUUID = Binary.createFromHexString('aaaaaaaa', 4); + const serializedUUID = BSON.serialize({ uuid: exampleUUID }); + const deserializedUUID = BSON.deserialize(serializedUUID); + const expectedResult = { + uuid: Binary.createFromHexString('aaaaaaaa', 4) + }; + expect(deserializedUUID).to.deep.equal(expectedResult); + }); + + context('when UUID bytes are not in v4 format', () => { + it('returns UUID instance', () => { + const nullUUID = '00'.repeat(16); + const serializedUUID = bufferFromHexArray([ + '05', // binData type + '6100', // 'a' & null + int32LEToHex(nullUUID.length / 2), // binary starts with int32 length + '04', // uuid subtype + nullUUID // uuid bytes + ]); + const deserializedUUID = BSON.deserialize(serializedUUID); + const expectedResult = { a: new UUID(nullUUID) }; + expect(deserializedUUID).to.deep.equal(expectedResult); + }); + }); + }); + + context('fromExtendedJSON()', () => { + it('returns UUID instance', () => { + const nullUUID = '00'.repeat(16); + const deserializedUUID = EJSON.parse(`{ "a": { "$uuid": "${'00'.repeat(16)}" } }`); + const expectedResult = { a: new UUID(nullUUID) }; + expect(deserializedUUID).to.deep.equal(expectedResult); + }); }); context('createFromHexString()', () => { @@ -217,8 +237,8 @@ describe('UUID', () => { }); context('when called with an incorrect length string', () => { - it('throws an error indicating the expected length of 32 or 36 characters', () => { - expect(() => UUID.createFromHexString('')).to.throw(/32 or 36 character/); + it('throws an error indicating the expected length', () => { + expect(() => UUID.createFromHexString('')).to.throw(/must be 32 hex digits/); }); }); });