Skip to content

Commit 9d5b20e

Browse files
authored
fix(v4): restrict the first ULID character to [0-7] (#6095)
A ULID is 128 bits in 26 Crockford base32 characters, so the encoding carries two more bits than the value does. Those bits are discarded on decode rather than rejected, which is why `0…`, `8…`, `G…` and `R…` all decode to the same 128-bit value. The spec caps the largest ULID at `7ZZZZZZZZZZZZZZZZZZZZZZZZZ` for that reason, and oklog/ulid added its own `v[0] > '7'` guard after the collision was found in production. The regex accepted the full alphabet in the first position, so `z.ulid()` validated strings the reference implementation refuses to decode — `ulid.decodeTime("8AAAAAAAAAAAAAAAAAAAAAAAAA")` throws "timestamp too large" on a string that just passed validation. `validator.js` ships the identical `[0-7]` class, and Go's oklog/ulid and python-ulid enforce the same ceiling. `7ZZZZZZZZZZZZZZZZZZZZZZZZZ` still validates, and a generated ULID does not reach a first character of `1` until the year 3084, so nothing a conforming generator emits is affected. Strings that previously passed and were never ULIDs now fail — that includes every letter first character, not only `8` and `9`, which is what the tests pin. Note that this changes the `pattern` emitted by `z.toJSONSchema(z.ulid())`. Zod 3 is in maintenance mode and is intentionally left untouched.
1 parent 1cf9cd0 commit 9d5b20e

5 files changed

Lines changed: 11 additions & 5 deletions

File tree

packages/zod/src/v4/classic/tests/continuability.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ test("continuability", () => {
318318
"message": "Invalid ULID",
319319
"origin": "string",
320320
"path": [],
321-
"pattern": "/^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$/",
321+
"pattern": "/^[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$/",
322322
},
323323
{
324324
"code": "custom",

packages/zod/src/v4/classic/tests/string.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -730,14 +730,20 @@ test("ulid", () => {
730730
const caseInsensitive = ulid.safeParse("01arZ3nDeKTsV4RRffQ69G5FAV");
731731
expect(caseInsensitive.success).toEqual(true);
732732

733+
// first char is capped at 7: the spec's largest ULID is 7ZZZZZZZZZZZZZZZZZZZZZZZZZ, so 8-9 and every letter overflow 2^48-1
734+
expect(ulid.safeParse("7ZZZZZZZZZZZZZZZZZZZZZZZZZ").success).toEqual(true);
735+
for (const first of ["8", "9", "A", "Z", "z"]) {
736+
expect(ulid.safeParse(`${first}AAAAAAAAAAAAAAAAAAAAAAAAA`).success).toEqual(false);
737+
}
738+
733739
expect(result.error!.issues[0].message).toEqual("Invalid ULID");
734740
expect(result.error).toMatchInlineSnapshot(`
735741
[ZodError: [
736742
{
737743
"origin": "string",
738744
"code": "invalid_format",
739745
"format": "ulid",
740-
"pattern": "/^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$/",
746+
"pattern": "/^[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$/",
741747
"path": [],
742748
"message": "Invalid ULID"
743749
}

packages/zod/src/v4/classic/tests/template-literal.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -579,7 +579,7 @@ test("regexes", () => {
579579
expect(mac._zod.pattern.source).toMatchInlineSnapshot(
580580
`"^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$|^(?:[0-9a-f]{2}:){5}[0-9a-f]{2}$"`
581581
);
582-
expect(ulid._zod.pattern.source).toMatchInlineSnapshot(`"^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$"`);
582+
expect(ulid._zod.pattern.source).toMatchInlineSnapshot(`"^[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$"`);
583583
expect(uuid._zod.pattern.source).toMatchInlineSnapshot(
584584
`"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"`
585585
);

packages/zod/src/v4/classic/tests/to-json-schema.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ describe("toJSONSchema", () => {
255255
{
256256
"$schema": "https://json-schema.org/draft/2020-12/schema",
257257
"format": "ulid",
258-
"pattern": "^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$",
258+
"pattern": "^[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$",
259259
"type": "string",
260260
}
261261
`);

packages/zod/src/v4/core/regexes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import * as util from "./util.js";
77
*/
88
export const cuid: RegExp = /^[cC][0-9a-z]{6,}$/;
99
export const cuid2: RegExp = /^[0-9a-z]+$/;
10-
export const ulid: RegExp = /^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$/;
10+
export const ulid: RegExp = /^[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$/;
1111
export const xid: RegExp = /^[0-9a-vA-V]{20}$/;
1212
export const ksuid: RegExp = /^[A-Za-z0-9]{27}$/;
1313
export const nanoid: RegExp = /^[a-zA-Z0-9_-]{21}$/;

0 commit comments

Comments
 (0)