From 00387b0bd6e56decb4a8c39ef5b1377a076937df Mon Sep 17 00:00:00 2001 From: sagar kumar gupta Date: Tue, 13 Oct 2026 11:52:55 +0530 Subject: [PATCH] fix: validate alphabets for duplicates, surrogates, and 256-character limit (#54) --- CHANGELOG.md | 5 ++++ README.md | 7 ++++++ src/generators/generate.ts | 7 ++---- src/generators/sortable.ts | 7 ++---- src/generators/template.ts | 13 +++++++---- src/internal/alphabet.ts | 26 +++++++++++++++++++++ src/utils/entropy.ts | 9 +------- test/alphabet.test.ts | 47 ++++++++++++++++++++++++++++++++++++++ test/entropy.test.ts | 32 ++++++++++++++++++++++++++ test/generate.test.ts | 22 ++++++++++++++++++ test/sortable.test.ts | 14 ++++++++++++ test/template.test.ts | 23 ++++++++++++++++++- 12 files changed, 188 insertions(+), 24 deletions(-) create mode 100644 src/internal/alphabet.ts create mode 100644 test/alphabet.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 410cd40..2a3a323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Validate custom alphabets in one shared place across `createId()`, `createSortableId()`, `template()`, `entropyBits()`, `sizeForEntropy()`, and `collisionProbability()`. Alphabets with duplicate characters, surrogate code units (emoji outside the BMP), or exceeding 256 characters now throw a `RangeError` instead of producing silently bad or biased IDs. +- `template()` placeholder validation now rejects non-BMP characters with the error `"prefid: \`placeholder\` must be a single BMP character."`. + ## [1.2.0] - 2026-10-11 Opt-in additions only. Existing calls behave as in 1.1.2: for the same random diff --git a/README.md b/README.md index af7d79f..6646fdb 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,13 @@ const eventId = createSortableId({ alphabet: BASE32_CROCKFORD }); eventId("evt"); // => "evt_00VQ5A1K0MBJGJFX6PWYY6WKY" — no 0/O or 1/l confusion ``` +### Custom alphabets + +You can pass a custom `alphabet` to `createId`, `createSortableId`, `template`, and entropy utilities. +Alphabets must contain between 2 and 256 unique characters within the Basic Multilingual Plane (BMP). +Duplicates, surrogate code units (such as emoji), and alphabets exceeding 256 characters throw a `RangeError`. +For `createSortableId`, characters must additionally be in strictly ascending code-point order to guarantee chronological sorting. + ## Strict prefixes By default any non-empty prefix without the separator is accepted. Pass diff --git a/src/generators/generate.ts b/src/generators/generate.ts index 8a033e9..b52f727 100644 --- a/src/generators/generate.ts +++ b/src/generators/generate.ts @@ -5,6 +5,7 @@ import { MAX_BATCH, MAX_SIZE, } from "../constants/index.js"; +import { assertAlphabet } from "../internal/alphabet.js"; import { assertValidCount } from "../internal/count.js"; import { assertValidPrefix } from "../internal/prefix.js"; import { randomString, randomStrings } from "../internal/random.js"; @@ -27,11 +28,7 @@ export function createId( `prefid: \`size\` must be an integer between 1 and ${MAX_SIZE}.`, ); } - if (typeof alphabet !== "string" || alphabet.length < 2) { - throw new RangeError( - "prefid: `alphabet` must contain at least 2 characters.", - ); - } + assertAlphabet(alphabet); if (typeof separator !== "string" || separator.length === 0) { throw new TypeError("prefid: `separator` must be a non-empty string."); } diff --git a/src/generators/sortable.ts b/src/generators/sortable.ts index 8a3170d..c35d918 100644 --- a/src/generators/sortable.ts +++ b/src/generators/sortable.ts @@ -7,6 +7,7 @@ import { MAX_SIZE, SORTABLE_TIME_MAX, } from "../constants/index.js"; +import { assertAlphabet } from "../internal/alphabet.js"; import { assertValidCount } from "../internal/count.js"; import { assertValidPrefix } from "../internal/prefix.js"; import { randomIndices } from "../internal/random.js"; @@ -113,11 +114,7 @@ export function createSortableId( const clock = options.now ?? Date.now; const randomSize = options.randomSize ?? DEFAULT_SORTABLE_RANDOM_SIZE; - if (typeof alphabet !== "string" || alphabet.length < 2) { - throw new RangeError( - "prefid: `alphabet` must contain at least 2 characters.", - ); - } + assertAlphabet(alphabet); if (typeof separator !== "string" || separator.length === 0) { throw new TypeError("prefid: `separator` must be a non-empty string."); } diff --git a/src/generators/template.ts b/src/generators/template.ts index dc91019..1d9b2bb 100644 --- a/src/generators/template.ts +++ b/src/generators/template.ts @@ -1,4 +1,5 @@ import { DEFAULT_ALPHABET, MAX_SIZE } from "../constants/index.js"; +import { assertAlphabet, SURROGATE_PATTERN } from "../internal/alphabet.js"; import { randomString } from "../internal/random.js"; export interface TemplateOptions { @@ -25,14 +26,16 @@ export function template( if (typeof pattern !== "string" || pattern.length === 0) { throw new TypeError("prefid: template pattern must be a non-empty string."); } - if (typeof placeholder !== "string" || placeholder.length !== 1) { - throw new RangeError("prefid: `placeholder` must be a single character."); - } - if (typeof alphabet !== "string" || alphabet.length < 2) { + if ( + typeof placeholder !== "string" || + placeholder.length !== 1 || + SURROGATE_PATTERN.test(placeholder) + ) { throw new RangeError( - "prefid: `alphabet` must contain at least 2 characters.", + "prefid: `placeholder` must be a single BMP character.", ); } + assertAlphabet(alphabet); let slots = 0; for (const char of pattern) { diff --git a/src/internal/alphabet.ts b/src/internal/alphabet.ts new file mode 100644 index 0000000..0710404 --- /dev/null +++ b/src/internal/alphabet.ts @@ -0,0 +1,26 @@ +const MIN_ALPHABET_LENGTH = 2; +const MAX_ALPHABET_LENGTH = 256; +export const SURROGATE_PATTERN = /[\uD800-\uDFFF]/; + +export function assertAlphabet(alphabet: unknown): asserts alphabet is string { + if (typeof alphabet !== "string" || alphabet.length < MIN_ALPHABET_LENGTH) { + throw new RangeError( + "prefid: `alphabet` must contain at least 2 characters.", + ); + } + if (alphabet.length > MAX_ALPHABET_LENGTH) { + throw new RangeError( + `prefid: \`alphabet\` must not contain more than ${MAX_ALPHABET_LENGTH} characters.`, + ); + } + if (SURROGATE_PATTERN.test(alphabet)) { + throw new RangeError( + "prefid: `alphabet` must not contain surrogate code units.", + ); + } + if (new Set(alphabet).size !== alphabet.length) { + throw new RangeError( + "prefid: `alphabet` must not contain duplicate characters.", + ); + } +} diff --git a/src/utils/entropy.ts b/src/utils/entropy.ts index ef9620e..249120a 100644 --- a/src/utils/entropy.ts +++ b/src/utils/entropy.ts @@ -3,16 +3,9 @@ import { DEFAULT_SIZE, MAX_SIZE, } from "../constants/index.js"; +import { assertAlphabet } from "../internal/alphabet.js"; import type { IdOptions } from "../types/index.js"; -function assertAlphabet(alphabet: unknown): asserts alphabet is string { - if (typeof alphabet !== "string" || alphabet.length < 2) { - throw new RangeError( - "prefid: `alphabet` must contain at least 2 characters.", - ); - } -} - function resolve(options: IdOptions): { size: number; radix: number } { const size = options.size ?? DEFAULT_SIZE; const alphabet = options.alphabet ?? DEFAULT_ALPHABET; diff --git a/test/alphabet.test.ts b/test/alphabet.test.ts new file mode 100644 index 0000000..6e6b1b9 --- /dev/null +++ b/test/alphabet.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { assertAlphabet } from "../src/internal/alphabet.js"; + +describe("assertAlphabet()", () => { + it("accepts valid alphabets between 2 and 256 unique BMP characters", () => { + expect(() => assertAlphabet("ab")).not.toThrow(); + expect(() => assertAlphabet("0123456789abcdef")).not.toThrow(); + expect(() => assertAlphabet("абвгдежзийклмноп")).not.toThrow(); + expect(() => assertAlphabet("αβγδεζηθικλμνξοπ")).not.toThrow(); + + const maxAlphabet = Array.from({ length: 256 }, (_, i) => + String.fromCharCode(i < 128 ? i + 33 : i + 200), + ).join(""); + expect(maxAlphabet.length).toBe(256); + expect(() => assertAlphabet(maxAlphabet)).not.toThrow(); + }); + + it("rejects non-strings or alphabets with fewer than 2 characters", () => { + expect(() => assertAlphabet(undefined)).toThrow(RangeError); + expect(() => assertAlphabet(null)).toThrow(RangeError); + expect(() => assertAlphabet(123)).toThrow(RangeError); + expect(() => assertAlphabet("")).toThrow(RangeError); + expect(() => assertAlphabet("a")).toThrow(RangeError); + }); + + it("rejects alphabets longer than 256 characters", () => { + const longAlphabet = Array.from({ length: 257 }, (_, i) => + String.fromCharCode(0x0400 + i), + ).join(""); + expect(longAlphabet.length).toBe(257); + expect(() => assertAlphabet(longAlphabet)).toThrow(RangeError); + }); + + it("rejects alphabets with surrogate code units (emoji / characters outside BMP)", () => { + expect(() => assertAlphabet("😀😁🙂🙃")).toThrow(RangeError); + expect(() => assertAlphabet("ab\uD800c")).toThrow(RangeError); + expect(() => assertAlphabet("ab\uDFFFc")).toThrow(RangeError); + expect(() => assertAlphabet("abc🎉")).toThrow(RangeError); + }); + + it("rejects alphabets with duplicate characters", () => { + expect(() => assertAlphabet("aa")).toThrow(RangeError); + expect(() => assertAlphabet("aab")).toThrow(RangeError); + expect(() => assertAlphabet("aba")).toThrow(RangeError); + expect(() => assertAlphabet("01234567890")).toThrow(RangeError); + }); +}); diff --git a/test/entropy.test.ts b/test/entropy.test.ts index e3af56f..da4305d 100644 --- a/test/entropy.test.ts +++ b/test/entropy.test.ts @@ -21,6 +21,15 @@ describe("entropyBits()", () => { expect(() => entropyBits({ size: 1.5 })).toThrow(RangeError); expect(() => entropyBits({ size: 4097 })).toThrow(RangeError); expect(() => entropyBits({ alphabet: "a" })).toThrow(RangeError); + expect(() => entropyBits({ alphabet: "aa" })).toThrow(RangeError); + expect(() => entropyBits({ alphabet: "😀😁" })).toThrow(RangeError); + expect(() => + entropyBits({ + alphabet: Array.from({ length: 257 }, (_, i) => + String.fromCharCode(0x0400 + i), + ).join(""), + }), + ).toThrow(RangeError); }); }); @@ -47,6 +56,16 @@ describe("sizeForEntropy()", () => { expect(() => sizeForEntropy(0)).toThrow(RangeError); expect(() => sizeForEntropy(Number.NaN)).toThrow(RangeError); expect(() => sizeForEntropy(64, "a")).toThrow(RangeError); + expect(() => sizeForEntropy(64, "aa")).toThrow(RangeError); + expect(() => sizeForEntropy(64, "😀😁")).toThrow(RangeError); + expect(() => + sizeForEntropy( + 64, + Array.from({ length: 257 }, (_, i) => + String.fromCharCode(0x0400 + i), + ).join(""), + ), + ).toThrow(RangeError); }); }); @@ -80,5 +99,18 @@ describe("collisionProbability()", () => { expect(() => collisionProbability(10, { alphabet: "a" })).toThrow( RangeError, ); + expect(() => collisionProbability(10, { alphabet: "aa" })).toThrow( + RangeError, + ); + expect(() => collisionProbability(10, { alphabet: "😀😁" })).toThrow( + RangeError, + ); + expect(() => + collisionProbability(10, { + alphabet: Array.from({ length: 257 }, (_, i) => + String.fromCharCode(0x0400 + i), + ).join(""), + }), + ).toThrow(RangeError); }); }); diff --git a/test/generate.test.ts b/test/generate.test.ts index 4e64ccb..314aa57 100644 --- a/test/generate.test.ts +++ b/test/generate.test.ts @@ -81,6 +81,28 @@ describe("createId()", () => { expect(() => createId({ alphabet: "x" })).toThrow(RangeError); }); + it("rejects an alphabet longer than 256 characters", () => { + const long = Array.from({ length: 257 }, (_, i) => + String.fromCharCode(0x0400 + i), + ).join(""); + expect(() => createId({ alphabet: long })).toThrow(RangeError); + }); + + it("rejects an alphabet with surrogate code units (emoji)", () => { + expect(() => createId({ alphabet: "😀😁🙂🙃" })).toThrow(RangeError); + expect(() => createId({ alphabet: "ab\uD800c" })).toThrow(RangeError); + }); + + it("rejects an alphabet with duplicate characters", () => { + expect(() => createId({ alphabet: "aa" })).toThrow(RangeError); + expect(() => createId({ alphabet: "aab" })).toThrow(RangeError); + }); + + it("accepts valid custom alphabets between 2 and 256 characters", () => { + const gen = createId({ alphabet: "01" }); + expect(gen("u")).toMatch(/^u_[01]+$/); + }); + it("rejects an empty separator", () => { expect(() => createId({ separator: "" })).toThrow(TypeError); }); diff --git a/test/sortable.test.ts b/test/sortable.test.ts index e168310..8d7de6b 100644 --- a/test/sortable.test.ts +++ b/test/sortable.test.ts @@ -267,6 +267,20 @@ describe("createSortableId() — validation", () => { expect(() => createSortableId({ alphabet: "x" })).toThrow(RangeError); }); + it("rejects an alphabet longer than 256 characters", () => { + const long = Array.from({ length: 257 }, (_, i) => + String.fromCharCode(0x0400 + i), + ).join(""); + expect(() => createSortableId({ alphabet: long })).toThrow(RangeError); + }); + + it("rejects an alphabet with surrogate code units (emoji)", () => { + expect(() => createSortableId({ alphabet: "😀😁" })).toThrow(RangeError); + expect(() => createSortableId({ alphabet: "ab\uD800" })).toThrow( + RangeError, + ); + }); + it("rejects an empty separator", () => { expect(() => createSortableId({ separator: "" })).toThrow(TypeError); }); diff --git a/test/template.test.ts b/test/template.test.ts index eb17d6e..5f12d88 100644 --- a/test/template.test.ts +++ b/test/template.test.ts @@ -63,14 +63,35 @@ describe("template()", () => { expect(gen().length).toBe(4096); }); - it("throws on a multi-character placeholder", () => { + it("throws on a multi-character or non-BMP placeholder", () => { expect(() => template("a##", { placeholder: "##" })).toThrow(RangeError); + expect(() => template("a##", { placeholder: "" })).toThrow(RangeError); + expect(() => template("a##", { placeholder: "😀" })).toThrow(RangeError); + expect(() => template("a##", { placeholder: "\uD800" })).toThrow( + RangeError, + ); }); it("throws on an alphabet shorter than 2 characters", () => { expect(() => template("a#", { alphabet: "x" })).toThrow(RangeError); }); + it("throws on an alphabet longer than 256 characters", () => { + const long = Array.from({ length: 257 }, (_, i) => + String.fromCharCode(0x0400 + i), + ).join(""); + expect(() => template("a#", { alphabet: long })).toThrow(RangeError); + }); + + it("throws on an alphabet with surrogate code units (emoji)", () => { + expect(() => template("a#", { alphabet: "😀😁" })).toThrow(RangeError); + }); + + it("throws on an alphabet with duplicate characters", () => { + expect(() => template("a#", { alphabet: "aa" })).toThrow(RangeError); + expect(() => template("a#", { alphabet: "aba" })).toThrow(RangeError); + }); + it("preserves the literal prefix in the type", () => { const value = template("user_####")(); const typed: `user_${string}` = value;