Skip to content

Commit

Permalink
feat: z.string.cidr() - support CIDR notation (#3820)
Browse files Browse the repository at this point in the history
* feat: support cidr

* docs

* feat: z.string().cidr()

* fix

* Simplify

---------

Co-authored-by: Colin McDonnell <[email protected]>
  • Loading branch information
wataryooou and colinhacks authored Dec 10, 2024
1 parent d50976a commit f82f817
Show file tree
Hide file tree
Showing 8 changed files with 236 additions and 2 deletions.
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
- [Dates](#dates)
- [Times](#times)
- [IP addresses](#ip-addresses)
- [IP ranges](#ip-ranges-cidr)
- [Numbers](#numbers)
- [BigInts](#bigints)
- [NaNs](#nans)
Expand Down Expand Up @@ -777,6 +778,7 @@ z.string().startsWith(string);
z.string().endsWith(string);
z.string().datetime(); // ISO 8601; by default only `Z` timezone allowed
z.string().ip(); // defaults to allow both IPv4 and IPv6
z.string().cidr(); // defaults to allow both IPv4 and IPv6

// transforms
z.string().trim(); // trim whitespace
Expand Down Expand Up @@ -818,6 +820,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().cidr({ message: "Invalid CIDR" });
```

### Datetimes
Expand Down Expand Up @@ -900,7 +903,7 @@ time.parse("00:00:00"); // fail

### IP addresses

The `z.string().ip()` method by default validate IPv4 and IPv6.
By default `.ip()` allows both IPv4 and IPv6.

```ts
const ip = z.string().ip();
Expand All @@ -923,6 +926,26 @@ const ipv6 = z.string().ip({ version: "v6" });
ipv6.parse("192.168.1.1"); // fail
```

### IP ranges (CIDR)

Validate IP address ranges specified with [CIDR notation](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing). By default, `.cidr()` allows both IPv4 and IPv6.

```ts
const cidr = z.string().cidr();
cidr.parse("192.168.0.0/24"); // pass
cidr.parse("2001:db8::/32"); // pass
```

You can specify a version with the `version` parameter.

```ts
const ipv4Cidr = z.string().cidr({ version: "v4" });
ipv4Cidr.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003"); // fail

const ipv6Cidr = z.string().cidr({ version: "v6" });
ipv6Cidr.parse("192.168.1.1"); // fail
```

## Numbers

You can customize certain error messages when creating a number schema.
Expand Down
25 changes: 24 additions & 1 deletion deno/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
- [Dates](#dates)
- [Times](#times)
- [IP addresses](#ip-addresses)
- [IP ranges](#ip-ranges-cidr)
- [Numbers](#numbers)
- [BigInts](#bigints)
- [NaNs](#nans)
Expand Down Expand Up @@ -787,6 +788,7 @@ z.string().startsWith(string);
z.string().endsWith(string);
z.string().datetime(); // ISO 8601; by default only `Z` timezone allowed
z.string().ip(); // defaults to allow both IPv4 and IPv6
z.string().cidr(); // defaults to allow both IPv4 and IPv6

// transforms
z.string().trim(); // trim whitespace
Expand Down Expand Up @@ -828,6 +830,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().cidr({ message: "Invalid CIDR" });
```

### Datetimes
Expand Down Expand Up @@ -910,7 +913,7 @@ time.parse("00:00:00"); // fail

### IP addresses

The `z.string().ip()` method by default validate IPv4 and IPv6.
By default `.ip()` allows both IPv4 and IPv6.

```ts
const ip = z.string().ip();
Expand All @@ -933,6 +936,26 @@ const ipv6 = z.string().ip({ version: "v6" });
ipv6.parse("192.168.1.1"); // fail
```

### IP ranges (CIDR)

Validate IP address ranges specified with [CIDR notation](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing). By default, `.cidr()` allows both IPv4 and IPv6.

```ts
const cidr = z.string().cidr();
cidr.parse("192.168.0.0/24"); // pass
cidr.parse("2001:db8::/32"); // pass
```

You can specify a version with the `version` parameter.

```ts
const ipv4Cidr = z.string().cidr({ version: "v4" });
ipv4Cidr.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003"); // fail

const ipv6Cidr = z.string().cidr({ version: "v6" });
ipv6Cidr.parse("192.168.1.1"); // fail
```

## Numbers

You can customize certain error messages when creating a number schema.
Expand Down
1 change: 1 addition & 0 deletions deno/lib/ZodError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export type StringValidation =
| "time"
| "duration"
| "ip"
| "cidr"
| "base64"
| { includes: string; position?: number }
| { startsWith: string }
Expand Down
60 changes: 60 additions & 0 deletions deno/lib/__tests__/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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().isCIDR).toEqual(false);
expect(z.string().email().isULID).toEqual(false);

expect(z.string().url().isEmail).toEqual(false);
Expand All @@ -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().isCIDR).toEqual(false);
expect(z.string().url().isULID).toEqual(false);

expect(z.string().cuid().isEmail).toEqual(false);
Expand All @@ -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().isCIDR).toEqual(false);
expect(z.string().cuid().isULID).toEqual(false);

expect(z.string().cuid2().isEmail).toEqual(false);
Expand All @@ -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().isCIDR).toEqual(false);
expect(z.string().cuid2().isULID).toEqual(false);

expect(z.string().uuid().isEmail).toEqual(false);
Expand All @@ -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().isCIDR).toEqual(false);
expect(z.string().uuid().isULID).toEqual(false);

expect(z.string().nanoid().isEmail).toEqual(false);
Expand All @@ -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().isCIDR).toEqual(false);
expect(z.string().nanoid().isULID).toEqual(false);

expect(z.string().ip().isEmail).toEqual(false);
Expand All @@ -427,15 +433,27 @@ 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().isCIDR).toEqual(false);
expect(z.string().ip().isULID).toEqual(false);

expect(z.string().cidr().isEmail).toEqual(false);
expect(z.string().cidr().isURL).toEqual(false);
expect(z.string().cidr().isCUID).toEqual(false);
expect(z.string().cidr().isCUID2).toEqual(false);
expect(z.string().cidr().isUUID).toEqual(false);
expect(z.string().cidr().isNANOID).toEqual(false);
expect(z.string().cidr().isIP).toEqual(false);
expect(z.string().cidr().isCIDR).toEqual(true);
expect(z.string().cidr().isULID).toEqual(false);

expect(z.string().ulid().isEmail).toEqual(false);
expect(z.string().ulid().isURL).toEqual(false);
expect(z.string().ulid().isCUID).toEqual(false);
expect(z.string().ulid().isCUID2).toEqual(false);
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().isCIDR).toEqual(false);
expect(z.string().ulid().isULID).toEqual(true);
});

Expand Down Expand Up @@ -769,3 +787,45 @@ test("IP validation", () => {
invalidIPs.every((ip) => ipSchema.safeParse(ip).success === false)
).toBe(true);
});

test("CIDR validation", () => {
const ipv4Cidr = z.string().cidr({ version: "v4" });
expect(() => ipv4Cidr.parse("2001:0db8:85a3::8a2e:0370:7334/64")).toThrow();

const ipv6Cidr = z.string().cidr({ version: "v6" });
expect(() => ipv6Cidr.parse("192.168.0.1/24")).toThrow();

const validCidrs = [
"192.168.0.0/24",
"10.0.0.0/8",
"203.0.113.0/24",
"192.0.2.0/24",
"127.0.0.0/8",
"172.16.0.0/12",
"192.168.1.0/24",
"fc00::/7",
"fd00::/8",
"2001:db8::/32",
"2607:f0d0:1002:51::4/64",
"2001:0db8:85a3:0000:0000:8a2e:0370:7334/128",
"2001:0db8:1234:0000::/64",
];

const invalidCidrs = [
"192.168.1.1/33",
"10.0.0.1/-1",
"192.168.1.1/24/24",
"192.168.1.0/abc",
"2001:db8::1/129",
"2001:db8::1/-1",
"2001:db8::1/64/64",
"2001:db8::1/abc",
];

// no parameters check IPv4 or IPv6
const cidrSchema = z.string().cidr();
expect(validCidrs.every((ip) => cidrSchema.safeParse(ip).success)).toBe(true);
expect(
invalidCidrs.every((ip) => cidrSchema.safeParse(ip).success === false)
).toBe(true);
});
33 changes: 33 additions & 0 deletions deno/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,7 @@ export type ZodStringCheck =
}
| { kind: "duration"; message?: string }
| { kind: "ip"; version?: IpVersion; message?: string }
| { kind: "cidr"; version?: IpVersion; message?: string }
| { kind: "base64"; message?: string };

export interface ZodStringDef extends ZodTypeDef {
Expand Down Expand Up @@ -608,11 +609,15 @@ 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 =
/^(?:(?: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])\/(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 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 =
/^(([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]))\/(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 =
Expand Down Expand Up @@ -671,6 +676,17 @@ function isValidIP(ip: string, version?: IpVersion) {
return false;
}

function isValidCidr(ip: string, version?: IpVersion) {
if ((version === "v4" || !version) && ipv4CidrRegex.test(ip)) {
return true;
}
if ((version === "v6" || !version) && ipv6CidrRegex.test(ip)) {
return true;
}

return false;
}

export class ZodString extends ZodType<string, ZodStringDef, string> {
_parse(input: ParseInput): ParseReturnType<string> {
if (this._def.coerce) {
Expand Down Expand Up @@ -933,6 +949,16 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
});
status.dirty();
}
} else if (check.kind === "cidr") {
if (!isValidCidr(input.data, check.version)) {
ctx = this._getOrReturnCtx(input, ctx);
addIssueToContext(ctx, {
validation: "cidr",
code: ZodIssueCode.invalid_string,
message: check.message,
});
status.dirty();
}
} else if (check.kind === "base64") {
if (!base64Regex.test(input.data)) {
ctx = this._getOrReturnCtx(input, ctx);
Expand Down Expand Up @@ -1006,6 +1032,10 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
return this._addCheck({ kind: "ip", ...errorUtil.errToObj(options) });
}

cidr(options?: string | { version?: IpVersion; message?: string }) {
return this._addCheck({ kind: "cidr", ...errorUtil.errToObj(options) });
}

datetime(
options?:
| string
Expand Down Expand Up @@ -1199,6 +1229,9 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
get isIP() {
return !!this._def.checks.find((ch) => ch.kind === "ip");
}
get isCIDR() {
return !!this._def.checks.find((ch) => ch.kind === "cidr");
}
get isBase64() {
return !!this._def.checks.find((ch) => ch.kind === "base64");
}
Expand Down
1 change: 1 addition & 0 deletions src/ZodError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export type StringValidation =
| "time"
| "duration"
| "ip"
| "cidr"
| "base64"
| { includes: string; position?: number }
| { startsWith: string }
Expand Down
Loading

0 comments on commit f82f817

Please sign in to comment.