diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 869cc59e50..633597bd72 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -7717,6 +7717,56 @@ declare module "bun" { timestamp?: number | Date, ): Buffer; + /** + * Generate a UUIDv5, which is a name-based UUID based on the SHA-1 hash of a namespace UUID and a name. + * + * @param name The name to use for the UUID + * @param namespace The namespace to use for the UUID + * @param encoding The encoding to use for the UUID + * + * + * @example + * ```js + * import { randomUUIDv5 } from "bun"; + * const uuid = randomUUIDv5("www.example.com", "dns"); + * console.log(uuid); // "6ba7b810-9dad-11d1-80b4-00c04fd430c8" + * ``` + * + * ```js + * import { randomUUIDv5 } from "bun"; + * const uuid = randomUUIDv5("www.example.com", "url"); + * console.log(uuid); // "6ba7b811-9dad-11d1-80b4-00c04fd430c8" + * ``` + */ + function randomUUIDv5( + name: string | BufferSource, + namespace: string | BufferSource | "dns" | "url" | "oid" | "x500", + /** + * @default "hex" + */ + encoding?: "hex" | "base64" | "base64url", + ): string; + + /** + * Generate a UUIDv5 as a Buffer + * + * @param name The name to use for the UUID + * @param namespace The namespace to use for the UUID + * @param encoding The encoding to use for the UUID + * + * @example + * ```js + * import { randomUUIDv5 } from "bun"; + * const uuid = randomUUIDv5("www.example.com", "url", "buffer"); + * console.log(uuid); // + * ``` + */ + function randomUUIDv5( + name: string | BufferSource, + namespace: string | BufferSource | "dns" | "url" | "oid" | "x500", + encoding: "buffer", + ): Buffer; + /** * Types for `bun.lock` */ diff --git a/src/bun.js/bindings/BunObject.cpp b/src/bun.js/bindings/BunObject.cpp index 25e207d27a..f45743b2b4 100644 --- a/src/bun.js/bindings/BunObject.cpp +++ b/src/bun.js/bindings/BunObject.cpp @@ -1,4 +1,3 @@ - #include "root.h" #include "JavaScriptCore/HeapProfiler.h" @@ -71,6 +70,7 @@ BUN_DECLARE_HOST_FUNCTION(Bun__DNSResolver__cancel); BUN_DECLARE_HOST_FUNCTION(Bun__fetch); BUN_DECLARE_HOST_FUNCTION(Bun__fetchPreconnect); BUN_DECLARE_HOST_FUNCTION(Bun__randomUUIDv7); +BUN_DECLARE_HOST_FUNCTION(Bun__randomUUIDv5); using namespace JSC; using namespace WebCore; @@ -758,6 +758,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj peek constructBunPeekObject DontDelete|PropertyCallback plugin constructPluginObject ReadOnly|DontDelete|PropertyCallback randomUUIDv7 Bun__randomUUIDv7 DontDelete|Function 2 + randomUUIDv5 Bun__randomUUIDv5 DontDelete|Function 3 readableStreamToArray JSBuiltin Builtin|Function 1 readableStreamToArrayBuffer JSBuiltin Builtin|Function 1 readableStreamToBytes JSBuiltin Builtin|Function 1 diff --git a/src/bun.js/uuid.zig b/src/bun.js/uuid.zig index 3c24955d01..5f803dd4f1 100644 --- a/src/bun.js/uuid.zig +++ b/src/bun.js/uuid.zig @@ -205,3 +205,81 @@ pub const UUID7 = struct { return self.toUUID().format(layout, options, writer); } }; + +/// UUID v5 implementation using SHA-1 hashing +/// This is a name-based UUID that uses SHA-1 for hashing +pub const UUID5 = struct { + bytes: [16]u8, + + pub const namespaces = struct { + pub const dns: *const [16]u8 = &.{ 0x6b, 0xa7, 0xb8, 0x10, 0x9d, 0xad, 0x11, 0xd1, 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, 0x30, 0xc8 }; + pub const url: *const [16]u8 = &.{ 0x6b, 0xa7, 0xb8, 0x11, 0x9d, 0xad, 0x11, 0xd1, 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, 0x30, 0xc8 }; + pub const oid: *const [16]u8 = &.{ 0x6b, 0xa7, 0xb8, 0x12, 0x9d, 0xad, 0x11, 0xd1, 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, 0x30, 0xc8 }; + pub const x500: *const [16]u8 = &.{ 0x6b, 0xa7, 0xb8, 0x14, 0x9d, 0xad, 0x11, 0xd1, 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, 0x30, 0xc8 }; + + pub fn get(namespace: []const u8) ?*const [16]u8 { + if (bun.strings.eqlCaseInsensitiveASCII(namespace, "dns", true)) { + return dns; + } else if (bun.strings.eqlCaseInsensitiveASCII(namespace, "url", true)) { + return url; + } else if (bun.strings.eqlCaseInsensitiveASCII(namespace, "oid", true)) { + return oid; + } else if (bun.strings.eqlCaseInsensitiveASCII(namespace, "x500", true)) { + return x500; + } + + return null; + } + }; + + /// Generate a UUID v5 from a namespace UUID and name data + pub fn init(namespace: *const [16]u8, name: []const u8) UUID5 { + const hash = brk: { + var sha1_hasher = bun.sha.SHA1.init(); + defer sha1_hasher.deinit(); + + sha1_hasher.update(namespace); + sha1_hasher.update(name); + + var hash: [20]u8 = undefined; + sha1_hasher.final(&hash); + + break :brk hash; + }; + + // Take first 16 bytes of the hash + var bytes: [16]u8 = hash[0..16].*; + + // Set version to 5 (bits 12-15 of time_hi_and_version) + bytes[6] = (bytes[6] & 0x0F) | 0x50; + + // Set variant bits (bits 6-7 of clock_seq_hi_and_reserved) + bytes[8] = (bytes[8] & 0x3F) | 0x80; + + return UUID5{ + .bytes = bytes, + }; + } + + pub fn toBytes(self: UUID5) [16]u8 { + return self.bytes; + } + + pub fn print(self: UUID5, buf: *[36]u8) void { + return printBytes(&self.toBytes(), buf); + } + + pub fn toUUID(self: UUID5) UUID { + const bytes: [16]u8 = self.toBytes(); + return .{ .bytes = bytes }; + } + + pub fn format( + self: UUID5, + comptime layout: []const u8, + options: fmt.FormatOptions, + writer: anytype, + ) !void { + return self.toUUID().format(layout, options, writer); + } +}; diff --git a/src/bun.js/webcore/Crypto.zig b/src/bun.js/webcore/Crypto.zig index 2c0577cd06..4524509f53 100644 --- a/src/bun.js/webcore/Crypto.zig +++ b/src/bun.js/webcore/Crypto.zig @@ -163,6 +163,93 @@ pub fn Bun__randomUUIDv7_(globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallF return encoding.encodeWithMaxSize(globalThis, 32, &uuid.bytes); } +comptime { + const Bun__randomUUIDv5 = JSC.toJSHostFn(Bun__randomUUIDv5_); + @export(&Bun__randomUUIDv5, .{ .name = "Bun__randomUUIDv5" }); +} + +pub fn Bun__randomUUIDv5_(globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSC.JSValue { + const arguments: []const JSC.JSValue = callframe.argumentsUndef(3).slice(); + + if (arguments.len == 0 or arguments[0].isUndefinedOrNull()) { + return globalThis.ERR(.INVALID_ARG_TYPE, "The \"name\" argument must be specified", .{}).throw(); + } + + if (arguments.len < 2 or arguments[1].isUndefinedOrNull()) { + return globalThis.ERR(.INVALID_ARG_TYPE, "The \"namespace\" argument must be specified", .{}).throw(); + } + + const encoding: JSC.Node.Encoding = brk: { + if (arguments.len > 2 and !arguments[2].isUndefined()) { + if (arguments[2].isString()) { + break :brk try JSC.Node.Encoding.fromJS(arguments[2], globalThis) orelse { + return globalThis.ERR(.UNKNOWN_ENCODING, "Encoding must be one of base64, base64url, hex, or buffer", .{}).throw(); + }; + } + } + + break :brk JSC.Node.Encoding.hex; + }; + + const name_value = arguments[0]; + const namespace_value = arguments[1]; + + const name = brk: { + if (name_value.isString()) { + const name_str = try name_value.toBunString(globalThis); + defer name_str.deref(); + const result = name_str.toUTF8(bun.default_allocator); + + break :brk result; + } else if (name_value.asArrayBuffer(globalThis)) |array_buffer| { + break :brk JSC.ZigString.Slice.fromUTF8NeverFree(array_buffer.byteSlice()); + } else { + return globalThis.ERR(.INVALID_ARG_TYPE, "The \"name\" argument must be of type string or BufferSource", .{}).throw(); + } + }; + defer name.deinit(); + + const namespace = brk: { + if (namespace_value.isString()) { + const namespace_str = try namespace_value.toBunString(globalThis); + defer namespace_str.deref(); + const namespace_slice = namespace_str.toUTF8(bun.default_allocator); + defer namespace_slice.deinit(); + + if (namespace_slice.slice().len != 36) { + if (UUID5.namespaces.get(namespace_slice.slice())) |namespace| { + break :brk namespace.*; + } + + return globalThis.ERR(.INVALID_ARG_VALUE, "Invalid UUID format for namespace", .{}).throw(); + } + + const parsed_uuid = UUID.parse(namespace_slice.slice()) catch { + return globalThis.ERR(.INVALID_ARG_VALUE, "Invalid UUID format for namespace", .{}).throw(); + }; + break :brk parsed_uuid.bytes; + } else if (namespace_value.asArrayBuffer(globalThis)) |*array_buffer| { + const slice = array_buffer.byteSlice(); + if (slice.len != 16) { + return globalThis.ERR(.INVALID_ARG_VALUE, "Namespace must be exactly 16 bytes", .{}).throw(); + } + break :brk slice[0..16].*; + } + + return globalThis.ERR(.INVALID_ARG_TYPE, "The \"namespace\" argument must be a string or buffer", .{}).throw(); + }; + + const uuid = UUID5.init(&namespace, name.slice()); + + if (encoding == .hex) { + var str, var bytes = bun.String.createUninitialized(.latin1, 36); + uuid.print(bytes[0..36]); + return str.transferToJS(globalThis); + } + + return encoding.encodeWithMaxSize(globalThis, 32, &uuid.bytes); +} + pub fn randomUUIDWithoutTypeChecks( _: *Crypto, globalThis: *JSC.JSGlobalObject, @@ -193,6 +280,8 @@ pub export fn CryptoObject__create(globalThis: *JSC.JSGlobalObject) JSC.JSValue } const UUID7 = @import("../uuid.zig").UUID7; +const UUID = @import("../uuid.zig"); +const UUID5 = @import("../uuid.zig").UUID5; const std = @import("std"); const bun = @import("bun"); diff --git a/test/bun.lock b/test/bun.lock index 47993d82c0..fb4796cfb1 100644 --- a/test/bun.lock +++ b/test/bun.lock @@ -84,7 +84,8 @@ "typeorm": "0.3.20", "typescript": "5.0.2", "undici": "5.20.0", - "unzipper": "^0.12.3", + "unzipper": "0.12.3", + "uuid": "11.1.0", "v8-heapsnapshot": "1.3.1", "verdaccio": "6.0.0", "vitest": "0.32.2", @@ -2543,7 +2544,7 @@ "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], - "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], "v8-compile-cache": ["v8-compile-cache@2.4.0", "", {}, "sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw=="], @@ -3207,6 +3208,8 @@ "typeorm/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "typeorm/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "unbzip2-stream/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "unstorage/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], diff --git a/test/js/bun/util/randomUUIDv5.test.ts b/test/js/bun/util/randomUUIDv5.test.ts new file mode 100644 index 0000000000..769a983997 --- /dev/null +++ b/test/js/bun/util/randomUUIDv5.test.ts @@ -0,0 +1,382 @@ +import { describe, expect, test } from "bun:test"; +import * as uuid from "uuid"; + +describe("randomUUIDv5", () => { + const dnsNamespace = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"; + const urlNamespace = "6ba7b811-9dad-11d1-80b4-00c04fd430c8"; + + test("basic functionality", () => { + const result = Bun.randomUUIDv5("www.example.com", dnsNamespace); + expect(result).toBeTypeOf("string"); + expect(result).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + + // Check that it's version 5 + expect(result[14]).toBe("5"); + }); + + test("deterministic output", () => { + const uuid1 = Bun.randomUUIDv5("www.example.com", dnsNamespace); + const uuid2 = Bun.randomUUIDv5("www.example.com", dnsNamespace); + + // Should always generate the same UUID for the same namespace + name + expect(uuid1).toBe(uuid2); + }); + + test("compatibility with uuid library", () => { + const name = "www.example.com"; + const bunUuid = Bun.randomUUIDv5(name, dnsNamespace); + const uuidLibUuid = uuid.v5(name, dnsNamespace); + + expect(bunUuid).toBe(uuidLibUuid); + }); + + test("predefined namespace strings", () => { + // Test with predefined namespace strings + const uuid1 = Bun.randomUUIDv5("www.example.com", "dns"); + const uuid2 = Bun.randomUUIDv5("www.example.com", dnsNamespace); + + expect(uuid1).toBe(uuid2); + + const uuid3 = Bun.randomUUIDv5("http://example.com", "url"); + const uuid4 = Bun.randomUUIDv5("http://example.com", urlNamespace); + + expect(uuid3).toBe(uuid4); + }); + + test("empty name", () => { + const result = Bun.randomUUIDv5("", dnsNamespace); + expect(result).toBeTypeOf("string"); + expect(result[14]).toBe("5"); + }); + + test("long name", () => { + const longName = "a".repeat(1000); + const result = Bun.randomUUIDv5(longName, dnsNamespace); + expect(result).toBeTypeOf("string"); + expect(result[14]).toBe("5"); + }); + + test("unicode name", () => { + const unicodeName = "测试.example.com"; + const result = Bun.randomUUIDv5(unicodeName, dnsNamespace); + expect(result).toBeTypeOf("string"); + expect(result[14]).toBe("5"); + + // Should be deterministic + const uuid2 = Bun.randomUUIDv5(unicodeName, dnsNamespace); + expect(result).toBe(uuid2); + }); + + test("name as ArrayBuffer", () => { + const nameString = "test"; + const nameBuffer = new TextEncoder().encode(nameString); + + const uuid1 = Bun.randomUUIDv5(nameString, dnsNamespace); + const uuid2 = Bun.randomUUIDv5(nameBuffer, dnsNamespace); + + expect(uuid1).toBe(uuid2); + }); + + test("name as TypedArray", () => { + const nameString = "test"; + const nameArray = new Uint8Array(new TextEncoder().encode(nameString)); + + const uuid1 = Bun.randomUUIDv5(nameString, dnsNamespace); + const uuid2 = Bun.randomUUIDv5(nameArray, dnsNamespace); + + expect(uuid1).toBe(uuid2); + }); + + test("error handling - invalid namespace", () => { + expect(() => { + Bun.randomUUIDv5("test", "invalid-uuid"); + }).toThrow(); + }); + + test("error handling - wrong namespace buffer size", () => { + const wrongSizeBuffer = new Uint8Array(15); // Should be 16 bytes + expect(() => { + Bun.randomUUIDv5("test", wrongSizeBuffer); + }).toThrow(); + }); + + test("error handling - invalid encoding", () => { + expect(() => { + // @ts-expect-error - testing invalid encoding + Bun.randomUUIDv5("test", dnsNamespace, "invalid"); + }).toThrow(); + }); + + test("variant bits are correct", () => { + const result = Bun.randomUUIDv5("test", dnsNamespace); + const bytes = result.replace(/-/g, ""); + + // Extract the variant byte (17th hex character, index 16) + const variantByte = parseInt(bytes.substr(16, 2), 16); + + // Variant bits should be 10xxxxxx (0x80-0xBF) + expect(variantByte & 0xc0).toBe(0x80); + }); + + test("version bits are correct", () => { + const result = Bun.randomUUIDv5("test", dnsNamespace); + const bytes = result.replace(/-/g, ""); + + // Extract the version byte (13th hex character, index 12) + const versionByte = parseInt(bytes.substr(12, 2), 16); + + // Version bits should be 0101xxxx (0x50-0x5F) + expect(versionByte & 0xf0).toBe(0x50); + }); + + test("case insensitive namespace strings", () => { + const uuid1 = Bun.randomUUIDv5("test", "DNS"); + const uuid2 = Bun.randomUUIDv5("test", "dns"); + const uuid3 = Bun.randomUUIDv5("test", "Dns"); + + expect(uuid1).toBe(uuid2); + expect(uuid2).toBe(uuid3); + }); + + test("all predefined namespaces", () => { + const name = "test"; + + const dnsUuid = Bun.randomUUIDv5(name, "dns"); + const urlUuid = Bun.randomUUIDv5(name, "url"); + const oidUuid = Bun.randomUUIDv5(name, "oid"); + const x500Uuid = Bun.randomUUIDv5(name, "x500"); + + // All should be different + expect(dnsUuid).not.toBe(urlUuid); + expect(urlUuid).not.toBe(oidUuid); + expect(oidUuid).not.toBe(x500Uuid); + + // All should be valid UUIDs + [dnsUuid, urlUuid, oidUuid, x500Uuid].forEach(result => { + expect(result).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + expect(result[14]).toBe("5"); + }); + }); + + test("different namespaces produce different UUIDs", () => { + const uuid1 = Bun.randomUUIDv5("www.example.com", dnsNamespace); + const uuid2 = Bun.randomUUIDv5("www.example.com", urlNamespace); + + expect(uuid1).not.toBe(uuid2); + expect(uuid.v5("www.example.com", dnsNamespace)).toBe(uuid1); + expect(uuid.v5("www.example.com", urlNamespace)).toBe(uuid2); + }); + + test("different names produce different UUIDs", () => { + const uuid1 = Bun.randomUUIDv5("www.example.com", dnsNamespace); + const uuid2 = Bun.randomUUIDv5("api.example.com", dnsNamespace); + + expect(uuid1).not.toBe(uuid2); + }); + + test("hex encoding (default)", () => { + const result = Bun.randomUUIDv5("test", dnsNamespace); + expect(result).toMatch(/^[0-9a-f-]+$/); + expect(result.length).toBe(36); // Standard UUID string length + }); + + test("buffer encoding", () => { + const result = Bun.randomUUIDv5("test", dnsNamespace, "buffer"); + expect(result).toBeInstanceOf(Uint8Array); + expect(result.byteLength).toBe(16); + }); + + test("base64 encoding", () => { + const result = Bun.randomUUIDv5("test", dnsNamespace, "base64"); + expect(result).toBeTypeOf("string"); + expect(result).toMatch(/^[A-Za-z0-9+/=]+$/); + }); + + test("base64url encoding", () => { + const result = Bun.randomUUIDv5("test", dnsNamespace, "base64url"); + expect(result).toBeTypeOf("string"); + expect(result).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + test("namespace as Buffer", () => { + // Convert UUID string to buffer + const nsBytes = new Uint8Array(16); + const nsString = dnsNamespace.replace(/-/g, ""); + for (let i = 0; i < 16; i++) { + nsBytes[i] = parseInt(nsString.substr(i * 2, 2), 16); + } + + const uuid1 = Bun.randomUUIDv5("test", dnsNamespace); + const uuid2 = Bun.randomUUIDv5("test", nsBytes); + + expect(uuid1).toBe(uuid2); + }); + + test("name as Buffer", () => { + const nameBuffer = new TextEncoder().encode("test"); + const uuid1 = Bun.randomUUIDv5("test", dnsNamespace); + const uuid2 = Bun.randomUUIDv5(nameBuffer, dnsNamespace); + + expect(uuid1).toBe(uuid2); + }); + + // Ported v5 tests from uuid library test suite + test("v5 - hello.example.com with DNS namespace", () => { + expect(Bun.randomUUIDv5("hello.example.com", dnsNamespace)).toBe("fdda765f-fc57-5604-a269-52a7df8164ec"); + }); + + test("v5 - http://example.com/hello with URL namespace", () => { + expect(Bun.randomUUIDv5("http://example.com/hello", urlNamespace)).toBe("3bbcee75-cecc-5b56-8031-b6641c1ed1f1"); + }); + + test("v5 - hello with custom namespace", () => { + expect(Bun.randomUUIDv5("hello", "0f5abcd1-c194-47f3-905b-2df7263a084b")).toBe( + "90123e1c-7512-523e-bb28-76fab9f2f73d", + ); + }); + + test("v5 namespace.toUpperCase", () => { + expect(Bun.randomUUIDv5("hello.example.com", dnsNamespace.toUpperCase())).toBe( + "fdda765f-fc57-5604-a269-52a7df8164ec", + ); + expect(Bun.randomUUIDv5("http://example.com/hello", urlNamespace.toUpperCase())).toBe( + "3bbcee75-cecc-5b56-8031-b6641c1ed1f1", + ); + expect(Bun.randomUUIDv5("hello", "0f5abcd1-c194-47f3-905b-2df7263a084b".toUpperCase())).toBe( + "90123e1c-7512-523e-bb28-76fab9f2f73d", + ); + }); + + test("v5 namespace string validation", () => { + expect(() => { + Bun.randomUUIDv5("hello.example.com", "zyxwvuts-rqpo-nmlk-jihg-fedcba000000"); + }).toThrow(); + + expect(() => { + Bun.randomUUIDv5("hello.example.com", "invalid uuid value"); + }).toThrow(); + + expect(Bun.randomUUIDv5("hello.example.com", "00000000-0000-0000-0000-000000000000")).toBeTypeOf("string"); + }); + + test("v5 namespace buffer validation", () => { + expect(() => { + Bun.randomUUIDv5("hello.example.com", new Uint8Array(15)); + }).toThrow(); + + expect(() => { + Bun.randomUUIDv5("hello.example.com", new Uint8Array(17)); + }).toThrow(); + + expect(Bun.randomUUIDv5("hello.example.com", new Uint8Array(16).fill(0))).toBeTypeOf("string"); + }); + + test("v5 fill buffer", () => { + const expectedUuid = Buffer.from([ + 0xfd, 0xda, 0x76, 0x5f, 0xfc, 0x57, 0x56, 0x04, 0xa2, 0x69, 0x52, 0xa7, 0xdf, 0x81, 0x64, 0xec, + ]); + + const result = Bun.randomUUIDv5("hello.example.com", dnsNamespace, "buffer"); + expect(result.toString("hex")).toEqual(expectedUuid.toString("hex")); + }); + + test("v5 undefined/null", () => { + // @ts-expect-error testing invalid input + expect(() => Bun.randomUUIDv5()).toThrow(); + // @ts-expect-error testing invalid input + expect(() => Bun.randomUUIDv5("hello")).toThrow(); + // @ts-expect-error testing invalid input + expect(() => Bun.randomUUIDv5("hello.example.com", undefined)).toThrow(); + // @ts-expect-error testing invalid input + expect(() => Bun.randomUUIDv5("hello.example.com", null)).toThrow(); + }); + + test("RFC 4122 test vectors", () => { + // These should be deterministic + const uuid1 = Bun.randomUUIDv5("http://www.example.com/", dnsNamespace); + const uuid2 = Bun.randomUUIDv5("http://www.example.com/", urlNamespace); + + // Both should be valid version 5 UUIDs + expect(uuid1).toEqual("b50f73c9-e407-5ea4-8540-70886e8aa2cd"); + expect(uuid2).toEqual("fcde3c85-2270-590f-9e7c-ee003d65e0e2"); + }); + + test("error cases", () => { + // Missing namespace + // @ts-expect-error + expect(() => Bun.randomUUIDv5()).toThrow(); + + // Missing name + // @ts-expect-error + expect(() => Bun.randomUUIDv5(dnsNamespace)).toThrow(); + + // Invalid namespace format + expect(() => Bun.randomUUIDv5("test", "invalid-uuid")).toThrow(); + + // Invalid encoding + // @ts-expect-error + expect(() => Bun.randomUUIDv5("test", dnsNamespace, "invalid")).toThrow(); + + // Namespace buffer wrong size + expect(() => Bun.randomUUIDv5("test", new Uint8Array(10))).toThrow(); + }); + + test("long names", () => { + const longName = "a".repeat(10000); + const result = Bun.randomUUIDv5(longName, dnsNamespace); + expect(result).toBeTypeOf("string"); + expect(result[14]).toBe("5"); + }); + + test("unicode names", () => { + const unicodeName = "测试🌟"; + const result = Bun.randomUUIDv5(unicodeName, dnsNamespace); + expect(result).toBeTypeOf("string"); + expect(result[14]).toBe("5"); + + // Should be deterministic + const uuid2 = Bun.randomUUIDv5(unicodeName, dnsNamespace); + expect(result).toBe(uuid2); + + expect(uuid.v5(unicodeName, dnsNamespace)).toBe(result); + }); + + test("variant bits are set correctly", () => { + const result = Bun.randomUUIDv5("test", dnsNamespace, "buffer"); + + // Check variant bits (bits 6-7 of clock_seq_hi_and_reserved should be 10) + const variantByte = result[8]; + const variantBits = (variantByte & 0xc0) >> 6; + expect(variantBits).toBe(2); // Binary 10 + + expect(uuid.v5("test", dnsNamespace).replace(/-/g, "")).toEqual(result.toString("hex")); + }); + + test("url namespace", () => { + const result = Bun.randomUUIDv5("test", "6ba7b811-9dad-11d1-80b4-00c04fd430c8"); + expect(result).toBeTypeOf("string"); + expect(result).toEqual("da5b8893-d6ca-5c1c-9a9c-91f40a2a3649"); + + expect(uuid.v5("test", urlNamespace)).toEqual(result); + }); + + test("dns namespace", () => { + const result = Bun.randomUUIDv5("test", "dns"); + expect(result).toBeTypeOf("string"); + expect(result[14]).toBe("5"); + expect(result).toEqual(uuid.v5("test", uuid.v5.DNS)); + }); + + test("consistent across multiple calls", () => { + const results: string[] = []; + for (let i = 0; i < 100; i++) { + results.push(Bun.randomUUIDv5("consistency-test", dnsNamespace)); + } + + // All results should be identical + const first = results[0]; + for (const result of results) { + expect(result).toBe(first); + } + }); +}); diff --git a/test/no-validate-exceptions.txt b/test/no-validate-exceptions.txt index 73749cb957..034173a045 100644 --- a/test/no-validate-exceptions.txt +++ b/test/no-validate-exceptions.txt @@ -244,6 +244,7 @@ test/js/bun/util/inspect-error.test.js test/js/bun/util/inspect.test.js test/js/bun/util/mmap.test.js test/js/bun/util/password.test.ts +test/js/bun/util/randomUUIDv5.test.ts test/js/bun/util/readablestreamtoarraybuffer.test.ts test/js/bun/util/stringWidth.test.ts test/js/bun/util/text-loader.test.ts diff --git a/test/package.json b/test/package.json index 7cc769bfd6..afa24d6521 100644 --- a/test/package.json +++ b/test/package.json @@ -89,6 +89,7 @@ "typeorm": "0.3.20", "typescript": "5.0.2", "undici": "5.20.0", + "uuid": "11.1.0", "unzipper": "0.12.3", "v8-heapsnapshot": "1.3.1", "verdaccio": "6.0.0",