From c409670015e139ed38b1b7cfc06f17cdb279493a Mon Sep 17 00:00:00 2001 From: wataryooou Date: Sat, 14 Sep 2024 19:03:23 +0900 Subject: [PATCH 1/7] feat: add ip range validation --- deno/lib/README.md | 35 ++++++++++++++- deno/lib/ZodError.ts | 1 + deno/lib/__tests__/string.test.ts | 74 +++++++++++++++++++++++++++++++ deno/lib/types.ts | 53 ++++++++++++++++++++++ src/ZodError.ts | 1 + src/types.ts | 53 ++++++++++++++++++++++ 6 files changed, 215 insertions(+), 2 deletions(-) diff --git a/deno/lib/README.md b/deno/lib/README.md index 91d97beb1..3abda6463 100644 --- a/deno/lib/README.md +++ b/deno/lib/README.md @@ -13,7 +13,7 @@ Created by Colin McDonnell License npm -stars +stars

@@ -76,6 +76,7 @@ - [Dates](#dates) - [Times](#times) - [IP addresses](#ip-addresses) + - [IP addresses range](#ip-addresses-range) - [Numbers](#numbers) - [BigInts](#bigints) - [NaNs](#nans) @@ -219,7 +220,7 @@ Sponsorship at any level is appreciated and encouraged. If you built a paid prod - stainless + Neon
@@ -806,6 +807,7 @@ z.string().datetime({ message: "Invalid datetime string! Must be UTC." }); z.string().date({ message: "Invalid date string!" }); z.string().time({ message: "Invalid time string!" }); z.string().ip({ message: "Invalid IP address" }); +z.string().ipRange({ message: "Invalid IP address range" }); ``` ### Datetimes @@ -911,6 +913,35 @@ const ipv6 = z.string().ip({ version: "v6" }); ipv6.parse("192.168.1.1"); // fail ``` +### IP addresses range + +The `z.string().ipRange()` method by default validate IPv4 and IPv6. + +```ts +const ipRange = z.string().ipRange(); + +ipRange.parse("192.168.1.1/32"); // pass +ipRange.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/128"); // pass +ipRange.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:192.168.1.1/64"); // pass + +ipRange.parse("256.1.1.1/32"); // fail +ipRange.parse("192.168.1.1/33"); // fail +ipRange.parse("84d5:51a0:9114:gggg:4cfa:f2d7:1f12:7003/128"); // fail +ipRange.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/129"); // fail +``` + +You can additionally set the IP `version`. + +```ts +const ipv4Range = z.string().ipRange({ version: "v4" }); +ipv4Range.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/128"); // fail +ipv4Range.parse("192.168.1.1/128"); // fail + +const ipv6Range = z.string().ipRange({ version: "v6" }); +ipv6Range.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/32"); // fail +ipv6Range.parse("192.168.1.1/128"); // fail +``` + ## Numbers You can customize certain error messages when creating a number schema. diff --git a/deno/lib/ZodError.ts b/deno/lib/ZodError.ts index e757cd8ba..906aac8e2 100644 --- a/deno/lib/ZodError.ts +++ b/deno/lib/ZodError.ts @@ -103,6 +103,7 @@ export type StringValidation = | "time" | "duration" | "ip" + | "ipRange" | "base64" | { includes: string; position?: number } | { startsWith: string } diff --git a/deno/lib/__tests__/string.test.ts b/deno/lib/__tests__/string.test.ts index b70152750..dd9232100 100644 --- a/deno/lib/__tests__/string.test.ts +++ b/deno/lib/__tests__/string.test.ts @@ -373,6 +373,7 @@ test("checks getters", () => { expect(z.string().email().isUUID).toEqual(false); expect(z.string().email().isNANOID).toEqual(false); expect(z.string().email().isIP).toEqual(false); + expect(z.string().email().isIPRange).toEqual(false); expect(z.string().email().isULID).toEqual(false); expect(z.string().url().isEmail).toEqual(false); @@ -382,6 +383,7 @@ test("checks getters", () => { expect(z.string().url().isUUID).toEqual(false); expect(z.string().url().isNANOID).toEqual(false); expect(z.string().url().isIP).toEqual(false); + expect(z.string().url().isIPRange).toEqual(false); expect(z.string().url().isULID).toEqual(false); expect(z.string().cuid().isEmail).toEqual(false); @@ -391,6 +393,7 @@ test("checks getters", () => { expect(z.string().cuid().isUUID).toEqual(false); expect(z.string().cuid().isNANOID).toEqual(false); expect(z.string().cuid().isIP).toEqual(false); + expect(z.string().cuid().isIPRange).toEqual(false); expect(z.string().cuid().isULID).toEqual(false); expect(z.string().cuid2().isEmail).toEqual(false); @@ -400,6 +403,7 @@ test("checks getters", () => { expect(z.string().cuid2().isUUID).toEqual(false); expect(z.string().cuid2().isNANOID).toEqual(false); expect(z.string().cuid2().isIP).toEqual(false); + expect(z.string().cuid2().isIPRange).toEqual(false); expect(z.string().cuid2().isULID).toEqual(false); expect(z.string().uuid().isEmail).toEqual(false); @@ -409,6 +413,7 @@ test("checks getters", () => { expect(z.string().uuid().isUUID).toEqual(true); expect(z.string().uuid().isNANOID).toEqual(false); expect(z.string().uuid().isIP).toEqual(false); + expect(z.string().uuid().isIPRange).toEqual(false); expect(z.string().uuid().isULID).toEqual(false); expect(z.string().nanoid().isEmail).toEqual(false); @@ -418,6 +423,7 @@ test("checks getters", () => { expect(z.string().nanoid().isUUID).toEqual(false); expect(z.string().nanoid().isNANOID).toEqual(true); expect(z.string().nanoid().isIP).toEqual(false); + expect(z.string().nanoid().isIPRange).toEqual(false); expect(z.string().nanoid().isULID).toEqual(false); expect(z.string().ip().isEmail).toEqual(false); @@ -427,8 +433,19 @@ test("checks getters", () => { expect(z.string().ip().isUUID).toEqual(false); expect(z.string().ip().isNANOID).toEqual(false); expect(z.string().ip().isIP).toEqual(true); + expect(z.string().ip().isIPRange).toEqual(false); expect(z.string().ip().isULID).toEqual(false); + expect(z.string().ipRange().isEmail).toEqual(false); + expect(z.string().ipRange().isURL).toEqual(false); + expect(z.string().ipRange().isCUID).toEqual(false); + expect(z.string().ipRange().isCUID2).toEqual(false); + expect(z.string().ipRange().isUUID).toEqual(false); + expect(z.string().ipRange().isNANOID).toEqual(false); + expect(z.string().ipRange().isIP).toEqual(false); + expect(z.string().ipRange().isIPRange).toEqual(true); + expect(z.string().ipRange().isULID).toEqual(false); + expect(z.string().ulid().isEmail).toEqual(false); expect(z.string().ulid().isURL).toEqual(false); expect(z.string().ulid().isCUID).toEqual(false); @@ -436,6 +453,7 @@ test("checks getters", () => { expect(z.string().ulid().isUUID).toEqual(false); expect(z.string().ulid().isNANOID).toEqual(false); expect(z.string().ulid().isIP).toEqual(false); + expect(z.string().ulid().isIPRange).toEqual(false); expect(z.string().ulid().isULID).toEqual(true); }); @@ -767,3 +785,59 @@ test("IP validation", () => { invalidIPs.every((ip) => ipSchema.safeParse(ip).success === false) ).toBe(true); }); + +test("IP Range validation", () => { + const ipRange = z.string().ipRange(); + expect(ipRange.safeParse("122.122.122.122/0").success).toBe(true); + expect(ipRange.safeParse("122.122.122.122/32").success).toBe(true); + expect(ipRange.safeParse("122.122.122.122/-1").success).toBe(false); + expect(ipRange.safeParse("122.122.122.122/33").success).toBe(false); + + const ipv4Range = z.string().ipRange({ version: "v4" }); + expect(() => + ipv4Range.parse("6097:adfa:6f0b:220d:db08:5021:6191:7990/128") + ).toThrow(); + + const ipv6Range = z.string().ipRange({ version: "v6" }); + expect(() => ipv6Range.parse("254.164.77.1/32")).toThrow(); + + const validIPRanges = [ + "1e5e:e6c8:daac:514b:114b:e360:d8c0:682c/0", + "9d4:c956:420f:5788:4339:9b3b:2418:75c3/128", + "a6ea::2454:a5ce:94.105.123.75/32", + "474f:4c83::4e40:a47:ff95:0cda/16", + "d329:0:25b4:db47:a9d1:0:4926:0000/64", + "e48:10fb:1499:3e28:e4b6:dea5:4692:912c/8", + "114.71.82.94/0", + "0.0.0.0/0", + "37.85.236.115/32", + ]; + + const invalidIPRanges = [ + "1e5e:e6c8:daac:514b:114b:e360:d8c0:682c/129", + "d329:1be4:25b4:db47:a9d1:dc71:4926:992c:14af/128", + "474f:4c83::4e40:a47:ff95:0cda/129", + "8f69::c757:395e:976e::3441/64", + "54cb::473f:d516:0.255.256.22/64", + "54cb::473f:d516:192.168.1/64", + "474f:4c83::4e40:a47:ff95:0cda/129", + "d329:0:25b4:db47:a9d1:0:4926:0000/129", + "e48:10fb:1499:3e28:e4b6:dea5:4692:912c/129", + "256.0.4.4/16", + "-1.0.555.4/32", + "0.0.0.0.0/0", + "1.1.1/16", + "114.71.82.94/128", + "0.0.0.0/33", + ]; + // no parameters check IPv4 or IPv6 + const ipRangeSchema = z.string().ipRange(); + expect( + validIPRanges.every((ipRange) => ipRangeSchema.safeParse(ipRange).success) + ).toBe(true); + expect( + invalidIPRanges.every( + (ipRange) => ipRangeSchema.safeParse(ipRange).success === false + ) + ).toBe(true); +}); diff --git a/deno/lib/types.ts b/deno/lib/types.ts index d634e524f..4dc5412a5 100644 --- a/deno/lib/types.ts +++ b/deno/lib/types.ts @@ -565,6 +565,7 @@ export type ZodStringCheck = } | { kind: "duration"; message?: string } | { kind: "ip"; version?: IpVersion; message?: string } + | { kind: "ipRange"; version?: IpVersion; message?: string } | { kind: "base64"; message?: string }; export interface ZodStringDef extends ZodTypeDef { @@ -608,9 +609,11 @@ let emojiRegex: RegExp; // faster, simpler, safer const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/; +const ipv4CidrRegex = /^(3[0-2]|[12]?[0-9])$/; 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 ipv6CidrRegex = /^(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/; // https://stackoverflow.com/questions/7860392/determine-if-string-is-in-base64-using-javascript const base64Regex = @@ -669,6 +672,36 @@ function isValidIP(ip: string, version?: IpVersion) { return false; } +function isValidIPRange(ip: string, version?: IpVersion) { + const [ipAddress, mask] = ip.split("/"); + if (ipAddress === undefined || mask === undefined) { + return false; + } + if ( + !version && + ((ipv4Regex.test(ipAddress) && ipv4CidrRegex.test(mask)) || + (ipv6Regex.test(ipAddress) && ipv6CidrRegex.test(mask))) + ) { + return true; + } + if ( + version === "v4" && + ipv4Regex.test(ipAddress) && + ipv4CidrRegex.test(mask) + ) { + return true; + } + if ( + version === "v6" && + ipv6Regex.test(ipAddress) && + ipv6CidrRegex.test(mask) + ) { + return true; + } + + return false; +} + export class ZodString extends ZodType { _parse(input: ParseInput): ParseReturnType { if (this._def.coerce) { @@ -931,6 +964,16 @@ export class ZodString extends ZodType { }); status.dirty(); } + } else if (check.kind === "ipRange") { + if (!isValidIPRange(input.data, check.version)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "ipRange", + code: ZodIssueCode.invalid_string, + message: check.message, + }); + status.dirty(); + } } else if (check.kind === "base64") { if (!base64Regex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); @@ -1004,6 +1047,13 @@ export class ZodString extends ZodType { return this._addCheck({ kind: "ip", ...errorUtil.errToObj(options) }); } + ipRange(options?: string | { version?: IpVersion; message?: string }) { + return this._addCheck({ + kind: "ipRange", + ...errorUtil.errToObj(options), + }); + } + datetime( options?: | string @@ -1197,6 +1247,9 @@ export class ZodString extends ZodType { get isIP() { return !!this._def.checks.find((ch) => ch.kind === "ip"); } + get isIPRange() { + return !!this._def.checks.find((ch) => ch.kind === "ipRange"); + } get isBase64() { return !!this._def.checks.find((ch) => ch.kind === "base64"); } diff --git a/src/ZodError.ts b/src/ZodError.ts index c1f7aa3ee..d9c6df2e7 100644 --- a/src/ZodError.ts +++ b/src/ZodError.ts @@ -103,6 +103,7 @@ export type StringValidation = | "time" | "duration" | "ip" + | "ipRange" | "base64" | { includes: string; position?: number } | { startsWith: string } diff --git a/src/types.ts b/src/types.ts index 0767073c5..85bc7efe3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -565,6 +565,7 @@ export type ZodStringCheck = } | { kind: "duration"; message?: string } | { kind: "ip"; version?: IpVersion; message?: string } + | { kind: "ipRange"; version?: IpVersion; message?: string } | { kind: "base64"; message?: string }; export interface ZodStringDef extends ZodTypeDef { @@ -608,9 +609,11 @@ let emojiRegex: RegExp; // faster, simpler, safer const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/; +const ipv4CidrRegex = /^(3[0-2]|[12]?[0-9])$/; 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 ipv6CidrRegex = /^(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/; // https://stackoverflow.com/questions/7860392/determine-if-string-is-in-base64-using-javascript const base64Regex = @@ -669,6 +672,36 @@ function isValidIP(ip: string, version?: IpVersion) { return false; } +function isValidIPRange(ip: string, version?: IpVersion) { + const [ipAddress, mask] = ip.split("/"); + if (ipAddress === undefined || mask === undefined) { + return false; + } + if ( + !version && + ((ipv4Regex.test(ipAddress) && ipv4CidrRegex.test(mask)) || + (ipv6Regex.test(ipAddress) && ipv6CidrRegex.test(mask))) + ) { + return true; + } + if ( + version === "v4" && + ipv4Regex.test(ipAddress) && + ipv4CidrRegex.test(mask) + ) { + return true; + } + if ( + version === "v6" && + ipv6Regex.test(ipAddress) && + ipv6CidrRegex.test(mask) + ) { + return true; + } + + return false; +} + export class ZodString extends ZodType { _parse(input: ParseInput): ParseReturnType { if (this._def.coerce) { @@ -931,6 +964,16 @@ export class ZodString extends ZodType { }); status.dirty(); } + } else if (check.kind === "ipRange") { + if (!isValidIPRange(input.data, check.version)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "ipRange", + code: ZodIssueCode.invalid_string, + message: check.message, + }); + status.dirty(); + } } else if (check.kind === "base64") { if (!base64Regex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); @@ -1004,6 +1047,13 @@ export class ZodString extends ZodType { return this._addCheck({ kind: "ip", ...errorUtil.errToObj(options) }); } + ipRange(options?: string | { version?: IpVersion; message?: string }) { + return this._addCheck({ + kind: "ipRange", + ...errorUtil.errToObj(options), + }); + } + datetime( options?: | string @@ -1197,6 +1247,9 @@ export class ZodString extends ZodType { get isIP() { return !!this._def.checks.find((ch) => ch.kind === "ip"); } + get isIPRange() { + return !!this._def.checks.find((ch) => ch.kind === "ipRange"); + } get isBase64() { return !!this._def.checks.find((ch) => ch.kind === "base64"); } From bd5dde8825e8c2c3a764cd4eaf5284f4aeba6a44 Mon Sep 17 00:00:00 2001 From: wataryooou Date: Sat, 14 Sep 2024 19:03:49 +0900 Subject: [PATCH 2/7] test: add ip range validation test --- src/__tests__/string.test.ts | 74 ++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/__tests__/string.test.ts b/src/__tests__/string.test.ts index 24f10ee45..cc4419e12 100644 --- a/src/__tests__/string.test.ts +++ b/src/__tests__/string.test.ts @@ -372,6 +372,7 @@ test("checks getters", () => { expect(z.string().email().isUUID).toEqual(false); expect(z.string().email().isNANOID).toEqual(false); expect(z.string().email().isIP).toEqual(false); + expect(z.string().email().isIPRange).toEqual(false); expect(z.string().email().isULID).toEqual(false); expect(z.string().url().isEmail).toEqual(false); @@ -381,6 +382,7 @@ test("checks getters", () => { expect(z.string().url().isUUID).toEqual(false); expect(z.string().url().isNANOID).toEqual(false); expect(z.string().url().isIP).toEqual(false); + expect(z.string().url().isIPRange).toEqual(false); expect(z.string().url().isULID).toEqual(false); expect(z.string().cuid().isEmail).toEqual(false); @@ -390,6 +392,7 @@ test("checks getters", () => { expect(z.string().cuid().isUUID).toEqual(false); expect(z.string().cuid().isNANOID).toEqual(false); expect(z.string().cuid().isIP).toEqual(false); + expect(z.string().cuid().isIPRange).toEqual(false); expect(z.string().cuid().isULID).toEqual(false); expect(z.string().cuid2().isEmail).toEqual(false); @@ -399,6 +402,7 @@ test("checks getters", () => { expect(z.string().cuid2().isUUID).toEqual(false); expect(z.string().cuid2().isNANOID).toEqual(false); expect(z.string().cuid2().isIP).toEqual(false); + expect(z.string().cuid2().isIPRange).toEqual(false); expect(z.string().cuid2().isULID).toEqual(false); expect(z.string().uuid().isEmail).toEqual(false); @@ -408,6 +412,7 @@ test("checks getters", () => { expect(z.string().uuid().isUUID).toEqual(true); expect(z.string().uuid().isNANOID).toEqual(false); expect(z.string().uuid().isIP).toEqual(false); + expect(z.string().uuid().isIPRange).toEqual(false); expect(z.string().uuid().isULID).toEqual(false); expect(z.string().nanoid().isEmail).toEqual(false); @@ -417,6 +422,7 @@ test("checks getters", () => { expect(z.string().nanoid().isUUID).toEqual(false); expect(z.string().nanoid().isNANOID).toEqual(true); expect(z.string().nanoid().isIP).toEqual(false); + expect(z.string().nanoid().isIPRange).toEqual(false); expect(z.string().nanoid().isULID).toEqual(false); expect(z.string().ip().isEmail).toEqual(false); @@ -426,8 +432,19 @@ test("checks getters", () => { expect(z.string().ip().isUUID).toEqual(false); expect(z.string().ip().isNANOID).toEqual(false); expect(z.string().ip().isIP).toEqual(true); + expect(z.string().ip().isIPRange).toEqual(false); expect(z.string().ip().isULID).toEqual(false); + expect(z.string().ipRange().isEmail).toEqual(false); + expect(z.string().ipRange().isURL).toEqual(false); + expect(z.string().ipRange().isCUID).toEqual(false); + expect(z.string().ipRange().isCUID2).toEqual(false); + expect(z.string().ipRange().isUUID).toEqual(false); + expect(z.string().ipRange().isNANOID).toEqual(false); + expect(z.string().ipRange().isIP).toEqual(false); + expect(z.string().ipRange().isIPRange).toEqual(true); + expect(z.string().ipRange().isULID).toEqual(false); + expect(z.string().ulid().isEmail).toEqual(false); expect(z.string().ulid().isURL).toEqual(false); expect(z.string().ulid().isCUID).toEqual(false); @@ -435,6 +452,7 @@ test("checks getters", () => { expect(z.string().ulid().isUUID).toEqual(false); expect(z.string().ulid().isNANOID).toEqual(false); expect(z.string().ulid().isIP).toEqual(false); + expect(z.string().ulid().isIPRange).toEqual(false); expect(z.string().ulid().isULID).toEqual(true); }); @@ -766,3 +784,59 @@ test("IP validation", () => { invalidIPs.every((ip) => ipSchema.safeParse(ip).success === false) ).toBe(true); }); + +test("IP Range validation", () => { + const ipRange = z.string().ipRange(); + expect(ipRange.safeParse("122.122.122.122/0").success).toBe(true); + expect(ipRange.safeParse("122.122.122.122/32").success).toBe(true); + expect(ipRange.safeParse("122.122.122.122/-1").success).toBe(false); + expect(ipRange.safeParse("122.122.122.122/33").success).toBe(false); + + const ipv4Range = z.string().ipRange({ version: "v4" }); + expect(() => + ipv4Range.parse("6097:adfa:6f0b:220d:db08:5021:6191:7990/128") + ).toThrow(); + + const ipv6Range = z.string().ipRange({ version: "v6" }); + expect(() => ipv6Range.parse("254.164.77.1/32")).toThrow(); + + const validIPRanges = [ + "1e5e:e6c8:daac:514b:114b:e360:d8c0:682c/0", + "9d4:c956:420f:5788:4339:9b3b:2418:75c3/128", + "a6ea::2454:a5ce:94.105.123.75/32", + "474f:4c83::4e40:a47:ff95:0cda/16", + "d329:0:25b4:db47:a9d1:0:4926:0000/64", + "e48:10fb:1499:3e28:e4b6:dea5:4692:912c/8", + "114.71.82.94/0", + "0.0.0.0/0", + "37.85.236.115/32", + ]; + + const invalidIPRanges = [ + "1e5e:e6c8:daac:514b:114b:e360:d8c0:682c/129", + "d329:1be4:25b4:db47:a9d1:dc71:4926:992c:14af/128", + "474f:4c83::4e40:a47:ff95:0cda/129", + "8f69::c757:395e:976e::3441/64", + "54cb::473f:d516:0.255.256.22/64", + "54cb::473f:d516:192.168.1/64", + "474f:4c83::4e40:a47:ff95:0cda/129", + "d329:0:25b4:db47:a9d1:0:4926:0000/129", + "e48:10fb:1499:3e28:e4b6:dea5:4692:912c/129", + "256.0.4.4/16", + "-1.0.555.4/32", + "0.0.0.0.0/0", + "1.1.1/16", + "114.71.82.94/128", + "0.0.0.0/33", + ]; + // no parameters check IPv4 or IPv6 + const ipRangeSchema = z.string().ipRange(); + expect( + validIPRanges.every((ipRange) => ipRangeSchema.safeParse(ipRange).success) + ).toBe(true); + expect( + invalidIPRanges.every( + (ipRange) => ipRangeSchema.safeParse(ipRange).success === false + ) + ).toBe(true); +}); From fba8c667766cdeeca6af79ee6c57eebb5504ec2f Mon Sep 17 00:00:00 2001 From: wataryooou Date: Sat, 14 Sep 2024 19:04:07 +0900 Subject: [PATCH 3/7] docs: add ip range validation in docs --- README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.md b/README.md index a721693b1..3abda6463 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ - [Dates](#dates) - [Times](#times) - [IP addresses](#ip-addresses) + - [IP addresses range](#ip-addresses-range) - [Numbers](#numbers) - [BigInts](#bigints) - [NaNs](#nans) @@ -806,6 +807,7 @@ z.string().datetime({ message: "Invalid datetime string! Must be UTC." }); z.string().date({ message: "Invalid date string!" }); z.string().time({ message: "Invalid time string!" }); z.string().ip({ message: "Invalid IP address" }); +z.string().ipRange({ message: "Invalid IP address range" }); ``` ### Datetimes @@ -911,6 +913,35 @@ const ipv6 = z.string().ip({ version: "v6" }); ipv6.parse("192.168.1.1"); // fail ``` +### IP addresses range + +The `z.string().ipRange()` method by default validate IPv4 and IPv6. + +```ts +const ipRange = z.string().ipRange(); + +ipRange.parse("192.168.1.1/32"); // pass +ipRange.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/128"); // pass +ipRange.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:192.168.1.1/64"); // pass + +ipRange.parse("256.1.1.1/32"); // fail +ipRange.parse("192.168.1.1/33"); // fail +ipRange.parse("84d5:51a0:9114:gggg:4cfa:f2d7:1f12:7003/128"); // fail +ipRange.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/129"); // fail +``` + +You can additionally set the IP `version`. + +```ts +const ipv4Range = z.string().ipRange({ version: "v4" }); +ipv4Range.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/128"); // fail +ipv4Range.parse("192.168.1.1/128"); // fail + +const ipv6Range = z.string().ipRange({ version: "v6" }); +ipv6Range.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/32"); // fail +ipv6Range.parse("192.168.1.1/128"); // fail +``` + ## Numbers You can customize certain error messages when creating a number schema. From 68a379c30b9a736d9829f9f271557f5ea25014d9 Mon Sep 17 00:00:00 2001 From: wataryooou Date: Sat, 14 Sep 2024 19:27:05 +0900 Subject: [PATCH 4/7] docs: fix typo --- README.md | 2 +- deno/lib/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3abda6463..1de067936 100644 --- a/README.md +++ b/README.md @@ -939,7 +939,7 @@ ipv4Range.parse("192.168.1.1/128"); // fail const ipv6Range = z.string().ipRange({ version: "v6" }); ipv6Range.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/32"); // fail -ipv6Range.parse("192.168.1.1/128"); // fail +ipv6Range.parse("192.168.1.1/32"); // fail ``` ## Numbers diff --git a/deno/lib/README.md b/deno/lib/README.md index 3abda6463..1de067936 100644 --- a/deno/lib/README.md +++ b/deno/lib/README.md @@ -939,7 +939,7 @@ ipv4Range.parse("192.168.1.1/128"); // fail const ipv6Range = z.string().ipRange({ version: "v6" }); ipv6Range.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/32"); // fail -ipv6Range.parse("192.168.1.1/128"); // fail +ipv6Range.parse("192.168.1.1/32"); // fail ``` ## Numbers From 189c1999402c310ea81994d6277b24c5ac8a3810 Mon Sep 17 00:00:00 2001 From: wataryooou Date: Sat, 19 Oct 2024 15:56:48 +0900 Subject: [PATCH 5/7] fix: failed test --- deno/lib/README.md | 4 ++++ deno/lib/__tests__/string.test.ts | 1 - src/__tests__/string.test.ts | 1 - 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/deno/lib/README.md b/deno/lib/README.md index a721693b1..cdae9403c 100644 --- a/deno/lib/README.md +++ b/deno/lib/README.md @@ -491,6 +491,7 @@ There are a growing number of tools that are built atop or support Zod natively! - [`sveltekit-superforms`](https://github.com/ciscoheat/sveltekit-superforms): Supercharged form library for SvelteKit with Zod validation. - [`mobx-zod-form`](https://github.com/MonoidDev/mobx-zod-form): Data-first form builder based on MobX & Zod. - [`@vee-validate/zod`](https://github.com/logaretm/vee-validate/tree/main/packages/zod): Form library for Vue.js with Zod schema validation. +- [`zod-form-renderer`](https://github.com/thepeaklab/zod-form-renderer): Auto-infer form fields from zod schema and render them with react-hook-form with E2E type safety. #### Zod to X @@ -505,6 +506,7 @@ There are a growing number of tools that are built atop or support Zod natively! - [`zod-openapi`](https://github.com/samchungy/zod-openapi): Create full OpenAPI v3.x documentation from Zod schemas. - [`fastify-zod-openapi`](https://github.com/samchungy/fastify-zod-openapi): Fastify type provider, validation, serialization and @fastify/swagger support for Zod schemas. - [`typeschema`](https://typeschema.com/): Universal adapter for schema validation. +- [`zodex`](https://github.com/commonbaseapp/zodex): (De)serialization for zod schemas #### X to Zod @@ -538,11 +540,13 @@ There are a growing number of tools that are built atop or support Zod natively! - [`freerstore`](https://github.com/JacobWeisenburger/freerstore): Firestore cost optimizer. - [`slonik`](https://github.com/gajus/slonik/tree/gajus/add-zod-validation-backwards-compatible#runtime-validation-and-static-type-inference): Node.js Postgres client with strong Zod integration. +- [`schemql`](https://github.com/a2lix/schemql): Enhances your SQL workflow by combining raw SQL with targeted type safety and schema validation. - [`soly`](https://github.com/mdbetancourt/soly): Create CLI applications with zod. - [`pastel`](https://github.com/vadimdemedes/pastel): Create CLI applications with react, zod, and ink. - [`zod-xlsx`](https://github.com/sidwebworks/zod-xlsx): A xlsx based resource validator using Zod schemas. - [`znv`](https://github.com/lostfictions/znv): Type-safe environment parsing and validation for Node.js with Zod schemas. - [`zod-config`](https://github.com/alexmarqs/zod-config): Load configurations across multiple sources with flexible adapters, ensuring type safety with Zod. +- [`unplugin-environment`](https://github.com/r17x/js/tree/main/packages/unplugin-environment#readme): A plugin for loading enviroment variables safely with schema validation, simple with virtual module, type-safe with intellisense, and better DX 🔥 🚀 👷. Powered by Zod. #### Utilities for Zod diff --git a/deno/lib/__tests__/string.test.ts b/deno/lib/__tests__/string.test.ts index 564fa84d6..64438717a 100644 --- a/deno/lib/__tests__/string.test.ts +++ b/deno/lib/__tests__/string.test.ts @@ -740,7 +740,6 @@ test("IP validation", () => { const validIPs = [ "1e5e:e6c8:daac:514b:114b:e360:d8c0:682c", "9d4:c956:420f:5788:4339:9b3b:2418:75c3", - "a6ea::2454:a5ce:94.105.123.75", "474f:4c83::4e40:a47:ff95:0cda", "d329:0:25b4:db47:a9d1:0:4926:0000", "e48:10fb:1499:3e28:e4b6:dea5:4692:912c", diff --git a/src/__tests__/string.test.ts b/src/__tests__/string.test.ts index 368dace7f..f7037fcc2 100644 --- a/src/__tests__/string.test.ts +++ b/src/__tests__/string.test.ts @@ -739,7 +739,6 @@ test("IP validation", () => { const validIPs = [ "1e5e:e6c8:daac:514b:114b:e360:d8c0:682c", "9d4:c956:420f:5788:4339:9b3b:2418:75c3", - "a6ea::2454:a5ce:94.105.123.75", "474f:4c83::4e40:a47:ff95:0cda", "d329:0:25b4:db47:a9d1:0:4926:0000", "e48:10fb:1499:3e28:e4b6:dea5:4692:912c", From db35eaa59ba4d9726014af2c77810f580854dd79 Mon Sep 17 00:00:00 2001 From: wataryooou Date: Sat, 19 Oct 2024 16:12:09 +0900 Subject: [PATCH 6/7] fix: test --- deno/lib/__tests__/string.test.ts | 1 - src/__tests__/string.test.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/deno/lib/__tests__/string.test.ts b/deno/lib/__tests__/string.test.ts index 41c4d66d2..6e0b2a05a 100644 --- a/deno/lib/__tests__/string.test.ts +++ b/deno/lib/__tests__/string.test.ts @@ -806,7 +806,6 @@ test("IP Range validation", () => { const validIPRanges = [ "1e5e:e6c8:daac:514b:114b:e360:d8c0:682c/0", "9d4:c956:420f:5788:4339:9b3b:2418:75c3/128", - "a6ea::2454:a5ce:94.105.123.75/32", "474f:4c83::4e40:a47:ff95:0cda/16", "d329:0:25b4:db47:a9d1:0:4926:0000/64", "e48:10fb:1499:3e28:e4b6:dea5:4692:912c/8", diff --git a/src/__tests__/string.test.ts b/src/__tests__/string.test.ts index e3897507a..5d59969de 100644 --- a/src/__tests__/string.test.ts +++ b/src/__tests__/string.test.ts @@ -805,7 +805,6 @@ test("IP Range validation", () => { const validIPRanges = [ "1e5e:e6c8:daac:514b:114b:e360:d8c0:682c/0", "9d4:c956:420f:5788:4339:9b3b:2418:75c3/128", - "a6ea::2454:a5ce:94.105.123.75/32", "474f:4c83::4e40:a47:ff95:0cda/16", "d329:0:25b4:db47:a9d1:0:4926:0000/64", "e48:10fb:1499:3e28:e4b6:dea5:4692:912c/8", From 45428a3959375fe8a8b5eaa82902ed78ba7dc313 Mon Sep 17 00:00:00 2001 From: wataryooou Date: Sun, 27 Oct 2024 17:09:28 +0900 Subject: [PATCH 7/7] refactor --- README.md | 29 ++----- deno/lib/README.md | 29 ++----- deno/lib/__tests__/string.test.ts | 123 +++++++++++++++++------------- deno/lib/types.ts | 80 ++++++++++++------- src/__tests__/string.test.ts | 123 +++++++++++++++++------------- src/types.ts | 80 ++++++++++++------- 6 files changed, 260 insertions(+), 204 deletions(-) diff --git a/README.md b/README.md index 1a2da4f26..0c5b42834 100644 --- a/README.md +++ b/README.md @@ -811,7 +811,6 @@ z.string().datetime({ message: "Invalid datetime string! Must be UTC." }); z.string().date({ message: "Invalid date string!" }); z.string().time({ message: "Invalid time string!" }); z.string().ip({ message: "Invalid IP address" }); -z.string().ipRange({ message: "Invalid IP address range" }); ``` ### Datetimes @@ -919,31 +918,19 @@ ipv6.parse("192.168.1.1"); // fail ### IP addresses range -The `z.string().ipRange()` method by default validate IPv4 and IPv6. +The `z.string().ipRange()` method checks if a given IP address falls within a specified CIDR range. ```ts -const ipRange = z.string().ipRange(); +const ipv4Range = z.string().ipRange({ cidr: "192.168.1.0/24", version: "v4" }); -ipRange.parse("192.168.1.1/32"); // pass -ipRange.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/128"); // pass -ipRange.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:192.168.1.1/64"); // pass +ipv4Range.parse("192.168.1.10"); // pass +ipv4Range.parse("192.168.1.20"); // pass +ipv4Range.parse("192.168.2.10"); // fail -ipRange.parse("256.1.1.1/32"); // fail -ipRange.parse("192.168.1.1/33"); // fail -ipRange.parse("84d5:51a0:9114:gggg:4cfa:f2d7:1f12:7003/128"); // fail -ipRange.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/129"); // fail -``` - -You can additionally set the IP `version`. - -```ts -const ipv4Range = z.string().ipRange({ version: "v4" }); -ipv4Range.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/128"); // fail -ipv4Range.parse("192.168.1.1/128"); // fail +const ipv6Range = z.string().ipRange({ cidr: "2001:db8::/32", version: "v6" }); -const ipv6Range = z.string().ipRange({ version: "v6" }); -ipv6Range.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/32"); // fail -ipv6Range.parse("192.168.1.1/32"); // fail +ipv6Range.parse("2001:db8::1"); // pass +ipv6Range.parse("2001:db9::1"); // fail ``` ## Numbers diff --git a/deno/lib/README.md b/deno/lib/README.md index 1a2da4f26..0c5b42834 100644 --- a/deno/lib/README.md +++ b/deno/lib/README.md @@ -811,7 +811,6 @@ z.string().datetime({ message: "Invalid datetime string! Must be UTC." }); z.string().date({ message: "Invalid date string!" }); z.string().time({ message: "Invalid time string!" }); z.string().ip({ message: "Invalid IP address" }); -z.string().ipRange({ message: "Invalid IP address range" }); ``` ### Datetimes @@ -919,31 +918,19 @@ ipv6.parse("192.168.1.1"); // fail ### IP addresses range -The `z.string().ipRange()` method by default validate IPv4 and IPv6. +The `z.string().ipRange()` method checks if a given IP address falls within a specified CIDR range. ```ts -const ipRange = z.string().ipRange(); +const ipv4Range = z.string().ipRange({ cidr: "192.168.1.0/24", version: "v4" }); -ipRange.parse("192.168.1.1/32"); // pass -ipRange.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/128"); // pass -ipRange.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:192.168.1.1/64"); // pass +ipv4Range.parse("192.168.1.10"); // pass +ipv4Range.parse("192.168.1.20"); // pass +ipv4Range.parse("192.168.2.10"); // fail -ipRange.parse("256.1.1.1/32"); // fail -ipRange.parse("192.168.1.1/33"); // fail -ipRange.parse("84d5:51a0:9114:gggg:4cfa:f2d7:1f12:7003/128"); // fail -ipRange.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/129"); // fail -``` - -You can additionally set the IP `version`. - -```ts -const ipv4Range = z.string().ipRange({ version: "v4" }); -ipv4Range.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/128"); // fail -ipv4Range.parse("192.168.1.1/128"); // fail +const ipv6Range = z.string().ipRange({ cidr: "2001:db8::/32", version: "v6" }); -const ipv6Range = z.string().ipRange({ version: "v6" }); -ipv6Range.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/32"); // fail -ipv6Range.parse("192.168.1.1/32"); // fail +ipv6Range.parse("2001:db8::1"); // pass +ipv6Range.parse("2001:db9::1"); // fail ``` ## Numbers diff --git a/deno/lib/__tests__/string.test.ts b/deno/lib/__tests__/string.test.ts index 6e0b2a05a..c1dd0c9d9 100644 --- a/deno/lib/__tests__/string.test.ts +++ b/deno/lib/__tests__/string.test.ts @@ -436,16 +436,6 @@ test("checks getters", () => { expect(z.string().ip().isIPRange).toEqual(false); expect(z.string().ip().isULID).toEqual(false); - expect(z.string().ipRange().isEmail).toEqual(false); - expect(z.string().ipRange().isURL).toEqual(false); - expect(z.string().ipRange().isCUID).toEqual(false); - expect(z.string().ipRange().isCUID2).toEqual(false); - expect(z.string().ipRange().isUUID).toEqual(false); - expect(z.string().ipRange().isNANOID).toEqual(false); - expect(z.string().ipRange().isIP).toEqual(false); - expect(z.string().ipRange().isIPRange).toEqual(true); - expect(z.string().ipRange().isULID).toEqual(false); - expect(z.string().ulid().isEmail).toEqual(false); expect(z.string().ulid().isURL).toEqual(false); expect(z.string().ulid().isCUID).toEqual(false); @@ -789,56 +779,83 @@ test("IP validation", () => { }); test("IP Range validation", () => { - const ipRange = z.string().ipRange(); - expect(ipRange.safeParse("122.122.122.122/0").success).toBe(true); - expect(ipRange.safeParse("122.122.122.122/32").success).toBe(true); - expect(ipRange.safeParse("122.122.122.122/-1").success).toBe(false); - expect(ipRange.safeParse("122.122.122.122/33").success).toBe(false); + const ipv4Range = z + .string() + .ipRange({ cidr: "192.168.1.0/33", version: "v4" }); + expect(() => ipv4Range.parse("192.168.1.1")).toThrow(); - const ipv4Range = z.string().ipRange({ version: "v4" }); - expect(() => - ipv4Range.parse("6097:adfa:6f0b:220d:db08:5021:6191:7990/128") - ).toThrow(); + const ipv6Range = z + .string() + .ipRange({ cidr: "2001:db8::/129", version: "v6" }); + expect(() => ipv6Range.parse("2001:db9::1")).toThrow(); + + const validIPv4Ranges = [ + { ip: "192.168.1.10", cidr: "192.168.1.0/24" }, + { ip: "192.168.1.20", cidr: "192.168.1.0/24" }, + { ip: "172.16.0.5", cidr: "172.16.0.0/12" }, + { ip: "10.0.0.1", cidr: "10.0.0.0/8" }, + { ip: "2001:db8::1", cidr: "2001:db8::/32" }, + { ip: "2001:db8:1234:5678:abcd:ef01:2345:6789", cidr: "2001:db8::/32" }, + { ip: "2001:db8:85a3::8a2e:370:7334", cidr: "2001:db8::/32" }, + { ip: "0.0.0.0", cidr: "0.0.0.0/0" }, + { ip: "37.85.236.115", cidr: "37.85.236.0/24" }, + { ip: "114.71.82.94", cidr: "114.71.82.0/24" }, + ]; - const ipv6Range = z.string().ipRange({ version: "v6" }); - expect(() => ipv6Range.parse("254.164.77.1/32")).toThrow(); - - const validIPRanges = [ - "1e5e:e6c8:daac:514b:114b:e360:d8c0:682c/0", - "9d4:c956:420f:5788:4339:9b3b:2418:75c3/128", - "474f:4c83::4e40:a47:ff95:0cda/16", - "d329:0:25b4:db47:a9d1:0:4926:0000/64", - "e48:10fb:1499:3e28:e4b6:dea5:4692:912c/8", - "114.71.82.94/0", - "0.0.0.0/0", - "37.85.236.115/32", + const invalidIPv4Ranges = [ + { ip: "192.168.2.10", cidr: "192.168.1.0/24" }, + { ip: "192.168.2.5", cidr: "192.168.1.0/24" }, + { ip: "10.1.1.1", cidr: "11.0.0.0/8" }, + { ip: "37.85.237.115", cidr: "37.85.236.0/24" }, + { ip: "114.71.83.94", cidr: "114.71.82.0/24" }, + { ip: "172.32.0.1", cidr: "172.16.0.0/12" }, ]; - const invalidIPRanges = [ - "1e5e:e6c8:daac:514b:114b:e360:d8c0:682c/129", - "d329:1be4:25b4:db47:a9d1:dc71:4926:992c:14af/128", - "474f:4c83::4e40:a47:ff95:0cda/129", - "8f69::c757:395e:976e::3441/64", - "54cb::473f:d516:0.255.256.22/64", - "54cb::473f:d516:192.168.1/64", - "474f:4c83::4e40:a47:ff95:0cda/129", - "d329:0:25b4:db47:a9d1:0:4926:0000/129", - "e48:10fb:1499:3e28:e4b6:dea5:4692:912c/129", - "256.0.4.4/16", - "-1.0.555.4/32", - "0.0.0.0.0/0", - "1.1.1/16", - "114.71.82.94/128", - "0.0.0.0/33", + expect( + validIPv4Ranges.every(({ ip, cidr }) => { + const ipRangeSchema = z.string().ipRange({ cidr, version: "v4" }); + + return ipRangeSchema.safeParse(ip).success; + }) + ).toBe(true); + expect( + invalidIPv4Ranges.every(({ ip, cidr }) => { + const ipRangeSchema = z.string().ipRange({ cidr, version: "v4" }); + + return ipRangeSchema.safeParse(ip).success === false; + }) + ).toBe(true); + + const validIPv6Ranges = [ + { ip: "2001:db8::1", cidr: "2001:db8::/32" }, + { ip: "2001:db8:1234:5678:abcd:ef01:2345:6789", cidr: "2001:db8::/32" }, + { ip: "2001:db8:85a3::8a2e:370:7334", cidr: "2001:db8::/32" }, + { ip: "2001:db8:abcd:ef01:2345:6789:abcd:ef01", cidr: "2001:db8::/32" }, + { ip: "2001:db8:0:1:0:0:0:1", cidr: "2001:db8:0:1::/64" }, + { ip: "fe80::1", cidr: "fe80::/10" }, + { ip: "2001:4888:50:ff00:500:d::", cidr: "2001:4888:50:ff00::/64" }, ]; - // no parameters check IPv4 or IPv6 - const ipRangeSchema = z.string().ipRange(); + + const invalidIPv6Ranges = [ + { ip: "::1", cidr: "::/128" }, + { ip: "2001:db9::1", cidr: "2001:db8::/32" }, + { ip: "fe80::1", cidr: "2001:db8::/32" }, + { ip: "ff00::1", cidr: "2001:db8::/32" }, + { ip: "2001:db8:abcd:1234::1", cidr: "2001:db8:abcd:5678::/64" }, + ]; + expect( - validIPRanges.every((ipRange) => ipRangeSchema.safeParse(ipRange).success) + validIPv6Ranges.every(({ ip, cidr }) => { + const ipRangeSchema = z.string().ipRange({ cidr, version: "v6" }); + + return ipRangeSchema.safeParse(ip).success; + }) ).toBe(true); expect( - invalidIPRanges.every( - (ipRange) => ipRangeSchema.safeParse(ipRange).success === false - ) + invalidIPv6Ranges.every(({ ip, cidr }) => { + const ipRangeSchema = z.string().ipRange({ cidr, version: "v6" }); + + return ipRangeSchema.safeParse(ip).success === false; + }) ).toBe(true); }); diff --git a/deno/lib/types.ts b/deno/lib/types.ts index 94b78ec68..841ea6231 100644 --- a/deno/lib/types.ts +++ b/deno/lib/types.ts @@ -565,7 +565,7 @@ export type ZodStringCheck = } | { kind: "duration"; message?: string } | { kind: "ip"; version?: IpVersion; message?: string } - | { kind: "ipRange"; version?: IpVersion; message?: string } + | { kind: "ipRange"; cidr: string; version: IpVersion; message?: string } | { kind: "base64"; message?: string }; export interface ZodStringDef extends ZodTypeDef { @@ -609,11 +609,9 @@ let emojiRegex: RegExp; // faster, simpler, safer const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/; -const ipv4CidrRegex = /^(3[0-2]|[12]?[0-9])$/; const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/; -const ipv6CidrRegex = /^(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/; // https://stackoverflow.com/questions/7860392/determine-if-string-is-in-base64-using-javascript const base64Regex = @@ -672,34 +670,55 @@ function isValidIP(ip: string, version?: IpVersion) { return false; } -function isValidIPRange(ip: string, version?: IpVersion) { - const [ipAddress, mask] = ip.split("/"); - if (ipAddress === undefined || mask === undefined) { - return false; - } - if ( - !version && - ((ipv4Regex.test(ipAddress) && ipv4CidrRegex.test(mask)) || - (ipv6Regex.test(ipAddress) && ipv6CidrRegex.test(mask))) - ) { - return true; - } - if ( - version === "v4" && - ipv4Regex.test(ipAddress) && - ipv4CidrRegex.test(mask) - ) { - return true; +const ipToBinary = (ip: string, version: IpVersion): string => { + if (version === "v4") { + return ip + .split(".") + .map((octet) => parseInt(octet, 10).toString(2).padStart(8, "0")) + .join(""); + } else if (version === "v6") { + const segments = ip.split(":"); + const fullSegments: string[] = []; + let zeroFillCount = 0; + + for (const segment of segments) { + if (segment === "") { + zeroFillCount = 8 - (segments.length - 1); + continue; + } + fullSegments.push(segment.padStart(4, "0")); + } + + for (let i = 0; i < zeroFillCount; i++) { + fullSegments.splice(segments.indexOf("") + i, 0, "0000"); + } + + return fullSegments + .map((segment) => parseInt(segment, 16).toString(2).padStart(16, "0")) + .join(""); + } else { + throw new Error("Invalid IP version"); } +}; + +function isValidIPRange(ip: string, cidr: string, version: IpVersion) { + const [rangeIp, prefixLengthStr] = cidr.split("/"); + const prefixLength = parseInt(prefixLengthStr, 10); + if ( - version === "v6" && - ipv6Regex.test(ipAddress) && - ipv6CidrRegex.test(mask) + (version === "v4" && (prefixLength < 0 || prefixLength > 32)) || + (version === "v6" && (prefixLength < 0 || prefixLength > 128)) ) { - return true; + throw new Error("Invalid prefix length"); } - return false; + const ipBinary = ipToBinary(ip, version); + const rangeBinary = ipToBinary(rangeIp, version); + + return ( + ipBinary.substring(0, prefixLength) === + rangeBinary.substring(0, prefixLength) + ); } export class ZodString extends ZodType { @@ -965,7 +984,7 @@ export class ZodString extends ZodType { status.dirty(); } } else if (check.kind === "ipRange") { - if (!isValidIPRange(input.data, check.version)) { + if (!isValidIPRange(input.data, check.cidr, check.version)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { validation: "ipRange", @@ -1047,9 +1066,14 @@ export class ZodString extends ZodType { return this._addCheck({ kind: "ip", ...errorUtil.errToObj(options) }); } - ipRange(options?: string | { version?: IpVersion; message?: string }) { + ipRange( + args: { cidr: string; version: IpVersion }, + options?: string | { message?: string } + ) { return this._addCheck({ kind: "ipRange", + cidr: args.cidr, + version: args.version, ...errorUtil.errToObj(options), }); } diff --git a/src/__tests__/string.test.ts b/src/__tests__/string.test.ts index 5d59969de..dec5c6bf7 100644 --- a/src/__tests__/string.test.ts +++ b/src/__tests__/string.test.ts @@ -435,16 +435,6 @@ test("checks getters", () => { expect(z.string().ip().isIPRange).toEqual(false); expect(z.string().ip().isULID).toEqual(false); - expect(z.string().ipRange().isEmail).toEqual(false); - expect(z.string().ipRange().isURL).toEqual(false); - expect(z.string().ipRange().isCUID).toEqual(false); - expect(z.string().ipRange().isCUID2).toEqual(false); - expect(z.string().ipRange().isUUID).toEqual(false); - expect(z.string().ipRange().isNANOID).toEqual(false); - expect(z.string().ipRange().isIP).toEqual(false); - expect(z.string().ipRange().isIPRange).toEqual(true); - expect(z.string().ipRange().isULID).toEqual(false); - expect(z.string().ulid().isEmail).toEqual(false); expect(z.string().ulid().isURL).toEqual(false); expect(z.string().ulid().isCUID).toEqual(false); @@ -788,56 +778,83 @@ test("IP validation", () => { }); test("IP Range validation", () => { - const ipRange = z.string().ipRange(); - expect(ipRange.safeParse("122.122.122.122/0").success).toBe(true); - expect(ipRange.safeParse("122.122.122.122/32").success).toBe(true); - expect(ipRange.safeParse("122.122.122.122/-1").success).toBe(false); - expect(ipRange.safeParse("122.122.122.122/33").success).toBe(false); + const ipv4Range = z + .string() + .ipRange({ cidr: "192.168.1.0/33", version: "v4" }); + expect(() => ipv4Range.parse("192.168.1.1")).toThrow(); - const ipv4Range = z.string().ipRange({ version: "v4" }); - expect(() => - ipv4Range.parse("6097:adfa:6f0b:220d:db08:5021:6191:7990/128") - ).toThrow(); + const ipv6Range = z + .string() + .ipRange({ cidr: "2001:db8::/129", version: "v6" }); + expect(() => ipv6Range.parse("2001:db9::1")).toThrow(); + + const validIPv4Ranges = [ + { ip: "192.168.1.10", cidr: "192.168.1.0/24" }, + { ip: "192.168.1.20", cidr: "192.168.1.0/24" }, + { ip: "172.16.0.5", cidr: "172.16.0.0/12" }, + { ip: "10.0.0.1", cidr: "10.0.0.0/8" }, + { ip: "2001:db8::1", cidr: "2001:db8::/32" }, + { ip: "2001:db8:1234:5678:abcd:ef01:2345:6789", cidr: "2001:db8::/32" }, + { ip: "2001:db8:85a3::8a2e:370:7334", cidr: "2001:db8::/32" }, + { ip: "0.0.0.0", cidr: "0.0.0.0/0" }, + { ip: "37.85.236.115", cidr: "37.85.236.0/24" }, + { ip: "114.71.82.94", cidr: "114.71.82.0/24" }, + ]; - const ipv6Range = z.string().ipRange({ version: "v6" }); - expect(() => ipv6Range.parse("254.164.77.1/32")).toThrow(); - - const validIPRanges = [ - "1e5e:e6c8:daac:514b:114b:e360:d8c0:682c/0", - "9d4:c956:420f:5788:4339:9b3b:2418:75c3/128", - "474f:4c83::4e40:a47:ff95:0cda/16", - "d329:0:25b4:db47:a9d1:0:4926:0000/64", - "e48:10fb:1499:3e28:e4b6:dea5:4692:912c/8", - "114.71.82.94/0", - "0.0.0.0/0", - "37.85.236.115/32", + const invalidIPv4Ranges = [ + { ip: "192.168.2.10", cidr: "192.168.1.0/24" }, + { ip: "192.168.2.5", cidr: "192.168.1.0/24" }, + { ip: "10.1.1.1", cidr: "11.0.0.0/8" }, + { ip: "37.85.237.115", cidr: "37.85.236.0/24" }, + { ip: "114.71.83.94", cidr: "114.71.82.0/24" }, + { ip: "172.32.0.1", cidr: "172.16.0.0/12" }, ]; - const invalidIPRanges = [ - "1e5e:e6c8:daac:514b:114b:e360:d8c0:682c/129", - "d329:1be4:25b4:db47:a9d1:dc71:4926:992c:14af/128", - "474f:4c83::4e40:a47:ff95:0cda/129", - "8f69::c757:395e:976e::3441/64", - "54cb::473f:d516:0.255.256.22/64", - "54cb::473f:d516:192.168.1/64", - "474f:4c83::4e40:a47:ff95:0cda/129", - "d329:0:25b4:db47:a9d1:0:4926:0000/129", - "e48:10fb:1499:3e28:e4b6:dea5:4692:912c/129", - "256.0.4.4/16", - "-1.0.555.4/32", - "0.0.0.0.0/0", - "1.1.1/16", - "114.71.82.94/128", - "0.0.0.0/33", + expect( + validIPv4Ranges.every(({ ip, cidr }) => { + const ipRangeSchema = z.string().ipRange({ cidr, version: "v4" }); + + return ipRangeSchema.safeParse(ip).success; + }) + ).toBe(true); + expect( + invalidIPv4Ranges.every(({ ip, cidr }) => { + const ipRangeSchema = z.string().ipRange({ cidr, version: "v4" }); + + return ipRangeSchema.safeParse(ip).success === false; + }) + ).toBe(true); + + const validIPv6Ranges = [ + { ip: "2001:db8::1", cidr: "2001:db8::/32" }, + { ip: "2001:db8:1234:5678:abcd:ef01:2345:6789", cidr: "2001:db8::/32" }, + { ip: "2001:db8:85a3::8a2e:370:7334", cidr: "2001:db8::/32" }, + { ip: "2001:db8:abcd:ef01:2345:6789:abcd:ef01", cidr: "2001:db8::/32" }, + { ip: "2001:db8:0:1:0:0:0:1", cidr: "2001:db8:0:1::/64" }, + { ip: "fe80::1", cidr: "fe80::/10" }, + { ip: "2001:4888:50:ff00:500:d::", cidr: "2001:4888:50:ff00::/64" }, ]; - // no parameters check IPv4 or IPv6 - const ipRangeSchema = z.string().ipRange(); + + const invalidIPv6Ranges = [ + { ip: "::1", cidr: "::/128" }, + { ip: "2001:db9::1", cidr: "2001:db8::/32" }, + { ip: "fe80::1", cidr: "2001:db8::/32" }, + { ip: "ff00::1", cidr: "2001:db8::/32" }, + { ip: "2001:db8:abcd:1234::1", cidr: "2001:db8:abcd:5678::/64" }, + ]; + expect( - validIPRanges.every((ipRange) => ipRangeSchema.safeParse(ipRange).success) + validIPv6Ranges.every(({ ip, cidr }) => { + const ipRangeSchema = z.string().ipRange({ cidr, version: "v6" }); + + return ipRangeSchema.safeParse(ip).success; + }) ).toBe(true); expect( - invalidIPRanges.every( - (ipRange) => ipRangeSchema.safeParse(ipRange).success === false - ) + invalidIPv6Ranges.every(({ ip, cidr }) => { + const ipRangeSchema = z.string().ipRange({ cidr, version: "v6" }); + + return ipRangeSchema.safeParse(ip).success === false; + }) ).toBe(true); }); diff --git a/src/types.ts b/src/types.ts index 50676e1c3..00c2882a7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -565,7 +565,7 @@ export type ZodStringCheck = } | { kind: "duration"; message?: string } | { kind: "ip"; version?: IpVersion; message?: string } - | { kind: "ipRange"; version?: IpVersion; message?: string } + | { kind: "ipRange"; cidr: string; version: IpVersion; message?: string } | { kind: "base64"; message?: string }; export interface ZodStringDef extends ZodTypeDef { @@ -609,11 +609,9 @@ let emojiRegex: RegExp; // faster, simpler, safer const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/; -const ipv4CidrRegex = /^(3[0-2]|[12]?[0-9])$/; const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/; -const ipv6CidrRegex = /^(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/; // https://stackoverflow.com/questions/7860392/determine-if-string-is-in-base64-using-javascript const base64Regex = @@ -672,34 +670,55 @@ function isValidIP(ip: string, version?: IpVersion) { return false; } -function isValidIPRange(ip: string, version?: IpVersion) { - const [ipAddress, mask] = ip.split("/"); - if (ipAddress === undefined || mask === undefined) { - return false; - } - if ( - !version && - ((ipv4Regex.test(ipAddress) && ipv4CidrRegex.test(mask)) || - (ipv6Regex.test(ipAddress) && ipv6CidrRegex.test(mask))) - ) { - return true; - } - if ( - version === "v4" && - ipv4Regex.test(ipAddress) && - ipv4CidrRegex.test(mask) - ) { - return true; +const ipToBinary = (ip: string, version: IpVersion): string => { + if (version === "v4") { + return ip + .split(".") + .map((octet) => parseInt(octet, 10).toString(2).padStart(8, "0")) + .join(""); + } else if (version === "v6") { + const segments = ip.split(":"); + const fullSegments: string[] = []; + let zeroFillCount = 0; + + for (const segment of segments) { + if (segment === "") { + zeroFillCount = 8 - (segments.length - 1); + continue; + } + fullSegments.push(segment.padStart(4, "0")); + } + + for (let i = 0; i < zeroFillCount; i++) { + fullSegments.splice(segments.indexOf("") + i, 0, "0000"); + } + + return fullSegments + .map((segment) => parseInt(segment, 16).toString(2).padStart(16, "0")) + .join(""); + } else { + throw new Error("Invalid IP version"); } +}; + +function isValidIPRange(ip: string, cidr: string, version: IpVersion) { + const [rangeIp, prefixLengthStr] = cidr.split("/"); + const prefixLength = parseInt(prefixLengthStr, 10); + if ( - version === "v6" && - ipv6Regex.test(ipAddress) && - ipv6CidrRegex.test(mask) + (version === "v4" && (prefixLength < 0 || prefixLength > 32)) || + (version === "v6" && (prefixLength < 0 || prefixLength > 128)) ) { - return true; + throw new Error("Invalid prefix length"); } - return false; + const ipBinary = ipToBinary(ip, version); + const rangeBinary = ipToBinary(rangeIp, version); + + return ( + ipBinary.substring(0, prefixLength) === + rangeBinary.substring(0, prefixLength) + ); } export class ZodString extends ZodType { @@ -965,7 +984,7 @@ export class ZodString extends ZodType { status.dirty(); } } else if (check.kind === "ipRange") { - if (!isValidIPRange(input.data, check.version)) { + if (!isValidIPRange(input.data, check.cidr, check.version)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { validation: "ipRange", @@ -1047,9 +1066,14 @@ export class ZodString extends ZodType { return this._addCheck({ kind: "ip", ...errorUtil.errToObj(options) }); } - ipRange(options?: string | { version?: IpVersion; message?: string }) { + ipRange( + args: { cidr: string; version: IpVersion }, + options?: string | { message?: string } + ) { return this._addCheck({ kind: "ipRange", + cidr: args.cidr, + version: args.version, ...errorUtil.errToObj(options), }); }