From 84ac86c529a0638240b76662661a0fa9568333f1 Mon Sep 17 00:00:00 2001 From: samchungy Date: Sun, 1 Oct 2023 20:48:27 +1100 Subject: [PATCH 1/3] Validate dates --- deno/lib/README.md | 2 +- deno/lib/__tests__/string.test.ts | 8 +++++--- deno/lib/types.ts | 7 ++++++- src/__tests__/string.test.ts | 8 +++++--- src/types.ts | 7 ++++++- 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/deno/lib/README.md b/deno/lib/README.md index 78cf45e7b..e693bd099 100644 --- a/deno/lib/README.md +++ b/deno/lib/README.md @@ -1879,7 +1879,7 @@ You can create a Zod schema for any TypeScript type by using `z.custom()`. This ```ts const px = z.custom<`${number}px`>((val) => { - return /^\d+px$/.test(val as string); + return typeof val === "string" ? /^\d+px$/.test(val) : false; }); type px = z.infer; // `${number}px` diff --git a/deno/lib/__tests__/string.test.ts b/deno/lib/__tests__/string.test.ts index e067a1c81..1fd4c019d 100644 --- a/deno/lib/__tests__/string.test.ts +++ b/deno/lib/__tests__/string.test.ts @@ -410,6 +410,11 @@ test("datetime parsing", () => { expect(() => datetime.parse("2020-10-14")).toThrow(); expect(() => datetime.parse("T18:45:12.123")).toThrow(); expect(() => datetime.parse("2020-10-14T17:42:29+00:00")).toThrow(); + expect(() => datetime.parse("1234-12-12T12:12:99.123Z")).toThrow(); + expect(() => datetime.parse("1234-12-12T12:99:12.123Z")).toThrow(); + expect(() => datetime.parse("1234-12-12T99:12:12.123Z")).toThrow(); + expect(() => datetime.parse("1234-12-99T12:12:12.123Z")).toThrow(); + expect(() => datetime.parse("1234-99-12T12:12:12.123Z")).toThrow(); const datetimeNoMs = z.string().datetime({ precision: 0 }); datetimeNoMs.parse("1970-01-01T00:00:00Z"); @@ -435,7 +440,6 @@ test("datetime parsing", () => { datetimeOffset.parse("2020-10-14T17:42:29+00:00"); datetimeOffset.parse("2020-10-14T17:42:29+03:15"); datetimeOffset.parse("2020-10-14T17:42:29+0315"); - datetimeOffset.parse("2020-10-14T17:42:29+03"); expect(() => datetimeOffset.parse("tuna")).toThrow(); expect(() => datetimeOffset.parse("2022-10-13T09:52:31.Z")).toThrow(); @@ -446,7 +450,6 @@ test("datetime parsing", () => { datetimeOffsetNoMs.parse("2022-10-13T09:52:31Z"); datetimeOffsetNoMs.parse("2020-10-14T17:42:29+00:00"); datetimeOffsetNoMs.parse("2020-10-14T17:42:29+0000"); - datetimeOffsetNoMs.parse("2020-10-14T17:42:29+00"); expect(() => datetimeOffsetNoMs.parse("tuna")).toThrow(); expect(() => datetimeOffsetNoMs.parse("1970-01-01T00:00:00.000Z")).toThrow(); expect(() => datetimeOffsetNoMs.parse("1970-01-01T00:00:00.Z")).toThrow(); @@ -459,7 +462,6 @@ test("datetime parsing", () => { datetimeOffset4Ms.parse("1970-01-01T00:00:00.1234Z"); datetimeOffset4Ms.parse("2020-10-14T17:42:29.1234+00:00"); datetimeOffset4Ms.parse("2020-10-14T17:42:29.1234+0000"); - datetimeOffset4Ms.parse("2020-10-14T17:42:29.1234+00"); expect(() => datetimeOffset4Ms.parse("tuna")).toThrow(); expect(() => datetimeOffset4Ms.parse("1970-01-01T00:00:00.123Z")).toThrow(); expect(() => diff --git a/deno/lib/types.ts b/deno/lib/types.ts index 39325a9a9..8eaec92f7 100644 --- a/deno/lib/types.ts +++ b/deno/lib/types.ts @@ -611,6 +611,11 @@ const datetimeRegex = (args: { precision: number | null; offset: boolean }) => { } }; +const isValidDate = (str: string) => { + const date = new Date(str); + return !isNaN(date.getTime()); +}; + function isValidIP(ip: string, version?: IpVersion) { if ((version === "v4" || !version) && ipv4Regex.test(ip)) { return true; @@ -822,7 +827,7 @@ export class ZodString extends ZodType { } else if (check.kind === "datetime") { const regex = datetimeRegex(check); - if (!regex.test(input.data)) { + if (!regex.test(input.data) || !isValidDate(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.invalid_string, diff --git a/src/__tests__/string.test.ts b/src/__tests__/string.test.ts index b84f080fc..a6fed377b 100644 --- a/src/__tests__/string.test.ts +++ b/src/__tests__/string.test.ts @@ -409,6 +409,11 @@ test("datetime parsing", () => { expect(() => datetime.parse("2020-10-14")).toThrow(); expect(() => datetime.parse("T18:45:12.123")).toThrow(); expect(() => datetime.parse("2020-10-14T17:42:29+00:00")).toThrow(); + expect(() => datetime.parse("1234-12-12T12:12:99.123Z")).toThrow(); + expect(() => datetime.parse("1234-12-12T12:99:12.123Z")).toThrow(); + expect(() => datetime.parse("1234-12-12T99:12:12.123Z")).toThrow(); + expect(() => datetime.parse("1234-12-99T12:12:12.123Z")).toThrow(); + expect(() => datetime.parse("1234-99-12T12:12:12.123Z")).toThrow(); const datetimeNoMs = z.string().datetime({ precision: 0 }); datetimeNoMs.parse("1970-01-01T00:00:00Z"); @@ -434,7 +439,6 @@ test("datetime parsing", () => { datetimeOffset.parse("2020-10-14T17:42:29+00:00"); datetimeOffset.parse("2020-10-14T17:42:29+03:15"); datetimeOffset.parse("2020-10-14T17:42:29+0315"); - datetimeOffset.parse("2020-10-14T17:42:29+03"); expect(() => datetimeOffset.parse("tuna")).toThrow(); expect(() => datetimeOffset.parse("2022-10-13T09:52:31.Z")).toThrow(); @@ -445,7 +449,6 @@ test("datetime parsing", () => { datetimeOffsetNoMs.parse("2022-10-13T09:52:31Z"); datetimeOffsetNoMs.parse("2020-10-14T17:42:29+00:00"); datetimeOffsetNoMs.parse("2020-10-14T17:42:29+0000"); - datetimeOffsetNoMs.parse("2020-10-14T17:42:29+00"); expect(() => datetimeOffsetNoMs.parse("tuna")).toThrow(); expect(() => datetimeOffsetNoMs.parse("1970-01-01T00:00:00.000Z")).toThrow(); expect(() => datetimeOffsetNoMs.parse("1970-01-01T00:00:00.Z")).toThrow(); @@ -458,7 +461,6 @@ test("datetime parsing", () => { datetimeOffset4Ms.parse("1970-01-01T00:00:00.1234Z"); datetimeOffset4Ms.parse("2020-10-14T17:42:29.1234+00:00"); datetimeOffset4Ms.parse("2020-10-14T17:42:29.1234+0000"); - datetimeOffset4Ms.parse("2020-10-14T17:42:29.1234+00"); expect(() => datetimeOffset4Ms.parse("tuna")).toThrow(); expect(() => datetimeOffset4Ms.parse("1970-01-01T00:00:00.123Z")).toThrow(); expect(() => diff --git a/src/types.ts b/src/types.ts index 51e188948..9f9a4d64d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -611,6 +611,11 @@ const datetimeRegex = (args: { precision: number | null; offset: boolean }) => { } }; +const isValidDate = (str: string) => { + const date = new Date(str); + return !isNaN(date.getTime()); +}; + function isValidIP(ip: string, version?: IpVersion) { if ((version === "v4" || !version) && ipv4Regex.test(ip)) { return true; @@ -822,7 +827,7 @@ export class ZodString extends ZodType { } else if (check.kind === "datetime") { const regex = datetimeRegex(check); - if (!regex.test(input.data)) { + if (!regex.test(input.data) || !isValidDate(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.invalid_string, From ab7424882369a8237abac8d5b7c216cf657c9b2f Mon Sep 17 00:00:00 2001 From: samchungy Date: Wed, 24 Jan 2024 12:18:16 +1100 Subject: [PATCH 2/3] Add further date validity checks Co-authored-by: Peter Robertson --- deno/lib/__tests__/string.test.ts | 17 ++++++++++++++++ deno/lib/types.ts | 32 +++++++++++++++---------------- src/__tests__/string.test.ts | 17 ++++++++++++++++ src/types.ts | 32 +++++++++++++++---------------- 4 files changed, 66 insertions(+), 32 deletions(-) diff --git a/deno/lib/__tests__/string.test.ts b/deno/lib/__tests__/string.test.ts index 1fd4c019d..6fff036e9 100644 --- a/deno/lib/__tests__/string.test.ts +++ b/deno/lib/__tests__/string.test.ts @@ -405,6 +405,9 @@ test("datetime parsing", () => { datetime.parse("2022-10-13T09:52:31.8162314Z"); datetime.parse("1970-01-01T00:00:00Z"); datetime.parse("2022-10-13T09:52:31Z"); + datetime.parse("2020-02-29T09:52:31.816Z"); + datetime.parse("1988-02-29T00:52:31.816Z"); + datetime.parse("2000-02-29T09:52:31.816Z"); expect(() => datetime.parse("")).toThrow(); expect(() => datetime.parse("foo")).toThrow(); expect(() => datetime.parse("2020-10-14")).toThrow(); @@ -415,6 +418,18 @@ test("datetime parsing", () => { expect(() => datetime.parse("1234-12-12T99:12:12.123Z")).toThrow(); expect(() => datetime.parse("1234-12-99T12:12:12.123Z")).toThrow(); expect(() => datetime.parse("1234-99-12T12:12:12.123Z")).toThrow(); + expect(() => datetime.parse("2021-02-29T09:52:31.816Z")).toThrow(); + expect(() => datetime.parse("1900-02-29T09:52:31.816Z")).toThrow(); + expect(() => datetime.parse("2100-02-29T09:52:31.816Z")).toThrow(); + expect(() => datetime.parse(`2020-04-31T09:52:31.816Z`)).toThrow(); + expect(() => datetime.parse(`2020-06-31T09:52:31.816Z`)).toThrow(); + expect(() => datetime.parse(`2020-04-00T09:52:31.816Z`)).toThrow(); + expect(() => datetime.parse(`2020-06-00T09:52:31.816Z`)).toThrow(); + expect(() => datetime.parse(`2020-10-00T09:52:31.816Z`)).toThrow(); + expect(() => datetime.parse(`2020-05-32T09:52:31.816Z`)).toThrow(); + expect(() => datetime.parse("2020-03-29T25:34:56.789Z")).toThrow(); + expect(() => datetime.parse("2020-03-29T12:60:56.789Z")).toThrow(); + expect(() => datetime.parse("2020-03-29T12:34:60.789Z")).toThrow(); const datetimeNoMs = z.string().datetime({ precision: 0 }); datetimeNoMs.parse("1970-01-01T00:00:00Z"); @@ -469,6 +484,8 @@ test("datetime parsing", () => { ).toThrow(); }); +new Date().toUTCString(); + test("IP validation", () => { const ip = z.string().ip(); expect(ip.safeParse("122.122.122.122").success).toBe(true); diff --git a/deno/lib/types.ts b/deno/lib/types.ts index 8eaec92f7..d29f3397e 100644 --- a/deno/lib/types.ts +++ b/deno/lib/types.ts @@ -578,42 +578,41 @@ const ipv4Regex = const ipv6Regex = /^(([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-f0-9]{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2})))$/; +const baseDateTimeRegex = "^(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2})"; + // Adapted from https://stackoverflow.com/a/3143231 const datetimeRegex = (args: { precision: number | null; offset: boolean }) => { if (args.precision) { if (args.offset) { return new RegExp( - `^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{${args.precision}}(([+-]\\d{2}(:?\\d{2})?)|Z)$` + `${baseDateTimeRegex}\\.\\d{${args.precision}}(([+-]\\d{2}(:?\\d{2})?)|Z)$` ); } else { - return new RegExp( - `^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{${args.precision}}Z$` - ); + return new RegExp(`${baseDateTimeRegex}\\.\\d{${args.precision}}Z$`); } } else if (args.precision === 0) { if (args.offset) { - return new RegExp( - `^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(([+-]\\d{2}(:?\\d{2})?)|Z)$` - ); + return new RegExp(`${baseDateTimeRegex}(([+-]\\d{2}(:?\\d{2})?)|Z)$`); } else { - return new RegExp(`^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$`); + return new RegExp(`${baseDateTimeRegex}Z$`); } } else { if (args.offset) { return new RegExp( - `^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(([+-]\\d{2}(:?\\d{2})?)|Z)$` + `${baseDateTimeRegex}(\\.\\d+)?(([+-]\\d{2}(:?\\d{2})?)|Z)$` ); } else { - return new RegExp( - `^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$` - ); + return new RegExp(`${baseDateTimeRegex}(\\.\\d+)?Z$`); } } }; -const isValidDate = (str: string) => { - const date = new Date(str); - return !isNaN(date.getTime()); +const isValidDate = (input: string, regexResult: RegExpExecArray) => { + const date = new Date(input); + return ( + !isNaN(date.getTime()) && + new Date(`${regexResult[1]}Z`).toISOString().startsWith(regexResult[1]) + ); }; function isValidIP(ip: string, version?: IpVersion) { @@ -826,8 +825,9 @@ export class ZodString extends ZodType { } } else if (check.kind === "datetime") { const regex = datetimeRegex(check); + const regexResult = regex.exec(input.data); - if (!regex.test(input.data) || !isValidDate(input.data)) { + if (!regexResult || !isValidDate(input.data, regexResult)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.invalid_string, diff --git a/src/__tests__/string.test.ts b/src/__tests__/string.test.ts index a6fed377b..bbdbee6f5 100644 --- a/src/__tests__/string.test.ts +++ b/src/__tests__/string.test.ts @@ -404,6 +404,9 @@ test("datetime parsing", () => { datetime.parse("2022-10-13T09:52:31.8162314Z"); datetime.parse("1970-01-01T00:00:00Z"); datetime.parse("2022-10-13T09:52:31Z"); + datetime.parse("2020-02-29T09:52:31.816Z"); + datetime.parse("1988-02-29T00:52:31.816Z"); + datetime.parse("2000-02-29T09:52:31.816Z"); expect(() => datetime.parse("")).toThrow(); expect(() => datetime.parse("foo")).toThrow(); expect(() => datetime.parse("2020-10-14")).toThrow(); @@ -414,6 +417,18 @@ test("datetime parsing", () => { expect(() => datetime.parse("1234-12-12T99:12:12.123Z")).toThrow(); expect(() => datetime.parse("1234-12-99T12:12:12.123Z")).toThrow(); expect(() => datetime.parse("1234-99-12T12:12:12.123Z")).toThrow(); + expect(() => datetime.parse("2021-02-29T09:52:31.816Z")).toThrow(); + expect(() => datetime.parse("1900-02-29T09:52:31.816Z")).toThrow(); + expect(() => datetime.parse("2100-02-29T09:52:31.816Z")).toThrow(); + expect(() => datetime.parse(`2020-04-31T09:52:31.816Z`)).toThrow(); + expect(() => datetime.parse(`2020-06-31T09:52:31.816Z`)).toThrow(); + expect(() => datetime.parse(`2020-04-00T09:52:31.816Z`)).toThrow(); + expect(() => datetime.parse(`2020-06-00T09:52:31.816Z`)).toThrow(); + expect(() => datetime.parse(`2020-10-00T09:52:31.816Z`)).toThrow(); + expect(() => datetime.parse(`2020-05-32T09:52:31.816Z`)).toThrow(); + expect(() => datetime.parse("2020-03-29T25:34:56.789Z")).toThrow(); + expect(() => datetime.parse("2020-03-29T12:60:56.789Z")).toThrow(); + expect(() => datetime.parse("2020-03-29T12:34:60.789Z")).toThrow(); const datetimeNoMs = z.string().datetime({ precision: 0 }); datetimeNoMs.parse("1970-01-01T00:00:00Z"); @@ -468,6 +483,8 @@ test("datetime parsing", () => { ).toThrow(); }); +new Date().toUTCString(); + test("IP validation", () => { const ip = z.string().ip(); expect(ip.safeParse("122.122.122.122").success).toBe(true); diff --git a/src/types.ts b/src/types.ts index 9f9a4d64d..f2aed4d3e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -578,42 +578,41 @@ const ipv4Regex = const ipv6Regex = /^(([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-f0-9]{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2})))$/; +const baseDateTimeRegex = "^(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2})"; + // Adapted from https://stackoverflow.com/a/3143231 const datetimeRegex = (args: { precision: number | null; offset: boolean }) => { if (args.precision) { if (args.offset) { return new RegExp( - `^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{${args.precision}}(([+-]\\d{2}(:?\\d{2})?)|Z)$` + `${baseDateTimeRegex}\\.\\d{${args.precision}}(([+-]\\d{2}(:?\\d{2})?)|Z)$` ); } else { - return new RegExp( - `^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{${args.precision}}Z$` - ); + return new RegExp(`${baseDateTimeRegex}\\.\\d{${args.precision}}Z$`); } } else if (args.precision === 0) { if (args.offset) { - return new RegExp( - `^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(([+-]\\d{2}(:?\\d{2})?)|Z)$` - ); + return new RegExp(`${baseDateTimeRegex}(([+-]\\d{2}(:?\\d{2})?)|Z)$`); } else { - return new RegExp(`^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$`); + return new RegExp(`${baseDateTimeRegex}Z$`); } } else { if (args.offset) { return new RegExp( - `^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(([+-]\\d{2}(:?\\d{2})?)|Z)$` + `${baseDateTimeRegex}(\\.\\d+)?(([+-]\\d{2}(:?\\d{2})?)|Z)$` ); } else { - return new RegExp( - `^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$` - ); + return new RegExp(`${baseDateTimeRegex}(\\.\\d+)?Z$`); } } }; -const isValidDate = (str: string) => { - const date = new Date(str); - return !isNaN(date.getTime()); +const isValidDate = (input: string, regexResult: RegExpExecArray) => { + const date = new Date(input); + return ( + !isNaN(date.getTime()) && + new Date(`${regexResult[1]}Z`).toISOString().startsWith(regexResult[1]) + ); }; function isValidIP(ip: string, version?: IpVersion) { @@ -826,8 +825,9 @@ export class ZodString extends ZodType { } } else if (check.kind === "datetime") { const regex = datetimeRegex(check); + const regexResult = regex.exec(input.data); - if (!regex.test(input.data) || !isValidDate(input.data)) { + if (!regexResult || !isValidDate(input.data, regexResult)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.invalid_string, From a27c513d4b920e148755462df46c05c4f523d334 Mon Sep 17 00:00:00 2001 From: samchungy Date: Wed, 24 Jan 2024 12:25:25 +1100 Subject: [PATCH 3/3] remove debug line --- deno/lib/__tests__/string.test.ts | 2 -- src/__tests__/string.test.ts | 2 -- 2 files changed, 4 deletions(-) diff --git a/deno/lib/__tests__/string.test.ts b/deno/lib/__tests__/string.test.ts index ba697e316..28e0cd616 100644 --- a/deno/lib/__tests__/string.test.ts +++ b/deno/lib/__tests__/string.test.ts @@ -487,8 +487,6 @@ test("datetime parsing", () => { ).toThrow(); }); -new Date().toUTCString(); - test("IP validation", () => { const ip = z.string().ip(); expect(ip.safeParse("122.122.122.122").success).toBe(true); diff --git a/src/__tests__/string.test.ts b/src/__tests__/string.test.ts index c09ee8f77..69daec377 100644 --- a/src/__tests__/string.test.ts +++ b/src/__tests__/string.test.ts @@ -486,8 +486,6 @@ test("datetime parsing", () => { ).toThrow(); }); -new Date().toUTCString(); - test("IP validation", () => { const ip = z.string().ip(); expect(ip.safeParse("122.122.122.122").success).toBe(true);