Skip to content

Commit

Permalink
Adds base64 string validation (#3047)
Browse files Browse the repository at this point in the history
* Add base64 string validation

* Build deno

* Update README

---------

Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
  • Loading branch information
StefanTerdell and colinhacks authored Apr 17, 2024
1 parent 8835cd3 commit c54e109
Show file tree
Hide file tree
Showing 8 changed files with 121 additions and 3 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,7 @@ z.string().datetime(); // ISO 8601; by default only `Z` timezone allowed
z.string().date(); // ISO date format (YYYY-MM-DD)
z.string().time(); // ISO time format (HH:mm:ss[.SSSSSS])
z.string().ip(); // defaults to allow both IPv4 and IPv6
z.string().base64();

// transformations
z.string().trim(); // trim whitespace
Expand Down
1 change: 1 addition & 0 deletions deno/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,7 @@ z.string().datetime(); // ISO 8601; by default only `Z` timezone allowed
z.string().date(); // ISO date format (YYYY-MM-DD)
z.string().time(); // ISO time format (HH:mm:ss[.SSSSSS])
z.string().ip(); // defaults to allow both IPv4 and IPv6
z.string().base64();

// transformations
z.string().trim(); // trim whitespace
Expand Down
1 change: 1 addition & 0 deletions deno/lib/ZodError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export type StringValidation =
| "date"
| "time"
| "ip"
| "base64"
| { includes: string; position?: number }
| { startsWith: string }
| { endsWith: string };
Expand Down
35 changes: 35 additions & 0 deletions deno/lib/__tests__/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,41 @@ test("email validations", () => {
).toBe(true);
});

test("base64 validations", () => {
const validBase64Strings = [
"SGVsbG8gV29ybGQ=", // "Hello World"
"VGhpcyBpcyBhbiBlbmNvZGVkIHN0cmluZw==", // "This is an encoded string"
"TWFueSBoYW5kcyBtYWtlIGxpZ2h0IHdvcms=", // "Many hands make light work"
"UGF0aWVuY2UgaXMgdGhlIGtleSB0byBzdWNjZXNz", // "Patience is the key to success"
"QmFzZTY0IGVuY29kaW5nIGlzIGZ1bg==", // "Base64 encoding is fun"
"MTIzNDU2Nzg5MA==", // "1234567890"
"YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo=", // "abcdefghijklmnopqrstuvwxyz"
"QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo=", // "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"ISIkJSMmJyonKCk=", // "!\"#$%&'()*"
"", // Empty string is technically a valid base64
];

for (const str of validBase64Strings) {
expect(str + z.string().base64().safeParse(str).success).toBe(str + "true");
}

const invalidBase64Strings = [
"12345", // Not padded correctly, not a multiple of 4 characters
"SGVsbG8gV29ybGQ", // Missing padding
"VGhpcyBpcyBhbiBlbmNvZGVkIHN0cmluZw", // Missing padding
"!UGF0aWVuY2UgaXMgdGhlIGtleSB0byBzdWNjZXNz", // Invalid character '!'
"?QmFzZTY0IGVuY29kaW5nIGlzIGZ1bg==", // Invalid character '?'
".MTIzND2Nzg5MC4=", // Invalid character '.'
"QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo", // Missing padding
];

for (const str of invalidBase64Strings) {
expect(str + z.string().base64().safeParse(str).success).toBe(
str + "false"
);
}
});

test("url validations", () => {
const url = z.string().url();
url.parse("http://google.com");
Expand Down
26 changes: 24 additions & 2 deletions deno/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,8 @@ export type ZodStringCheck =
precision: number | null;
message?: string;
}
| { kind: "ip"; version?: IpVersion; message?: string };
| { kind: "ip"; version?: IpVersion; message?: string }
| { kind: "base64"; message?: string };

export interface ZodStringDef extends ZodTypeDef {
checks: ZodStringCheck[];
Expand All @@ -572,7 +573,7 @@ export interface ZodStringDef extends ZodTypeDef {
}

const cuidRegex = /^c[^\s-]{8,}$/i;
const cuid2Regex = /^[a-z][a-z0-9]*$/;
const cuid2Regex = /^[0-9a-z]+$/;
const ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/;
// const uuidRegex =
// /^([a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[a-f0-9]{4}-[a-f0-9]{12}|00000000-0000-0000-0000-000000000000)$/i;
Expand Down Expand Up @@ -606,13 +607,18 @@ 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})))$/;

// https://stackoverflow.com/questions/7860392/determine-if-string-is-in-base64-using-javascript
const base64Regex =
/^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/;

// const dateRegexSource = `\\d{4}-\\d{2}-\\d{2}`;
const dateRegexSource = `\\d{4}-((0[13578]|10|12)-31|(0[13-9]|1[0-2])-30|(0[1-9]|1[0-2])-(0[1-9]|1\\d|2\\d))`;
const dateRegex = new RegExp(`^${dateRegexSource}$`);

function timeRegexSource(args: { precision?: number | null }) {
// let regex = `\\d{2}:\\d{2}:\\d{2}`;
let regex = `([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d`;

if (args.precision) {
regex = `${regex}\\.\\d{${args.precision}}`;
} else if (args.precision == null) {
Expand Down Expand Up @@ -906,6 +912,16 @@ export class ZodString extends ZodType<string, ZodStringDef> {
});
status.dirty();
}
} else if (check.kind === "base64") {
if (!base64Regex.test(input.data)) {
ctx = this._getOrReturnCtx(input, ctx);
addIssueToContext(ctx, {
validation: "base64",
code: ZodIssueCode.invalid_string,
message: check.message,
});
status.dirty();
}
} else {
util.assertNever(check);
}
Expand Down Expand Up @@ -961,6 +977,9 @@ export class ZodString extends ZodType<string, ZodStringDef> {
ulid(message?: errorUtil.ErrMessage) {
return this._addCheck({ kind: "ulid", ...errorUtil.errToObj(message) });
}
base64(message?: errorUtil.ErrMessage) {
return this._addCheck({ kind: "base64", ...errorUtil.errToObj(message) });
}

ip(options?: string | { version?: "v4" | "v6"; message?: string }) {
return this._addCheck({ kind: "ip", ...errorUtil.errToObj(options) });
Expand Down Expand Up @@ -1152,6 +1171,9 @@ export class ZodString extends ZodType<string, ZodStringDef> {
get isIP() {
return !!this._def.checks.find((ch) => ch.kind === "ip");
}
get isBase64() {
return !!this._def.checks.find((ch) => ch.kind === "base64");
}

get minLength() {
let min: number | null = null;
Expand Down
1 change: 1 addition & 0 deletions src/ZodError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export type StringValidation =
| "date"
| "time"
| "ip"
| "base64"
| { includes: string; position?: number }
| { startsWith: string }
| { endsWith: string };
Expand Down
35 changes: 35 additions & 0 deletions src/__tests__/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,41 @@ test("email validations", () => {
).toBe(true);
});

test("base64 validations", () => {
const validBase64Strings = [
"SGVsbG8gV29ybGQ=", // "Hello World"
"VGhpcyBpcyBhbiBlbmNvZGVkIHN0cmluZw==", // "This is an encoded string"
"TWFueSBoYW5kcyBtYWtlIGxpZ2h0IHdvcms=", // "Many hands make light work"
"UGF0aWVuY2UgaXMgdGhlIGtleSB0byBzdWNjZXNz", // "Patience is the key to success"
"QmFzZTY0IGVuY29kaW5nIGlzIGZ1bg==", // "Base64 encoding is fun"
"MTIzNDU2Nzg5MA==", // "1234567890"
"YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo=", // "abcdefghijklmnopqrstuvwxyz"
"QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo=", // "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"ISIkJSMmJyonKCk=", // "!\"#$%&'()*"
"", // Empty string is technically a valid base64
];

for (const str of validBase64Strings) {
expect(str + z.string().base64().safeParse(str).success).toBe(str + "true");
}

const invalidBase64Strings = [
"12345", // Not padded correctly, not a multiple of 4 characters
"SGVsbG8gV29ybGQ", // Missing padding
"VGhpcyBpcyBhbiBlbmNvZGVkIHN0cmluZw", // Missing padding
"!UGF0aWVuY2UgaXMgdGhlIGtleSB0byBzdWNjZXNz", // Invalid character '!'
"?QmFzZTY0IGVuY29kaW5nIGlzIGZ1bg==", // Invalid character '?'
".MTIzND2Nzg5MC4=", // Invalid character '.'
"QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo", // Missing padding
];

for (const str of invalidBase64Strings) {
expect(str + z.string().base64().safeParse(str).success).toBe(
str + "false"
);
}
});

test("url validations", () => {
const url = z.string().url();
url.parse("http://google.com");
Expand Down
24 changes: 23 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,8 @@ export type ZodStringCheck =
precision: number | null;
message?: string;
}
| { kind: "ip"; version?: IpVersion; message?: string };
| { kind: "ip"; version?: IpVersion; message?: string }
| { kind: "base64"; message?: string };

export interface ZodStringDef extends ZodTypeDef {
checks: ZodStringCheck[];
Expand Down Expand Up @@ -606,13 +607,18 @@ 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})))$/;

// https://stackoverflow.com/questions/7860392/determine-if-string-is-in-base64-using-javascript
const base64Regex =
/^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/;

// const dateRegexSource = `\\d{4}-\\d{2}-\\d{2}`;
const dateRegexSource = `\\d{4}-((0[13578]|10|12)-31|(0[13-9]|1[0-2])-30|(0[1-9]|1[0-2])-(0[1-9]|1\\d|2\\d))`;
const dateRegex = new RegExp(`^${dateRegexSource}$`);

function timeRegexSource(args: { precision?: number | null }) {
// let regex = `\\d{2}:\\d{2}:\\d{2}`;
let regex = `([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d`;

if (args.precision) {
regex = `${regex}\\.\\d{${args.precision}}`;
} else if (args.precision == null) {
Expand Down Expand Up @@ -906,6 +912,16 @@ export class ZodString extends ZodType<string, ZodStringDef> {
});
status.dirty();
}
} else if (check.kind === "base64") {
if (!base64Regex.test(input.data)) {
ctx = this._getOrReturnCtx(input, ctx);
addIssueToContext(ctx, {
validation: "base64",
code: ZodIssueCode.invalid_string,
message: check.message,
});
status.dirty();
}
} else {
util.assertNever(check);
}
Expand Down Expand Up @@ -961,6 +977,9 @@ export class ZodString extends ZodType<string, ZodStringDef> {
ulid(message?: errorUtil.ErrMessage) {
return this._addCheck({ kind: "ulid", ...errorUtil.errToObj(message) });
}
base64(message?: errorUtil.ErrMessage) {
return this._addCheck({ kind: "base64", ...errorUtil.errToObj(message) });
}

ip(options?: string | { version?: "v4" | "v6"; message?: string }) {
return this._addCheck({ kind: "ip", ...errorUtil.errToObj(options) });
Expand Down Expand Up @@ -1152,6 +1171,9 @@ export class ZodString extends ZodType<string, ZodStringDef> {
get isIP() {
return !!this._def.checks.find((ch) => ch.kind === "ip");
}
get isBase64() {
return !!this._def.checks.find((ch) => ch.kind === "base64");
}

get minLength() {
let min: number | null = null;
Expand Down

0 comments on commit c54e109

Please sign in to comment.