Skip to content

Commit

Permalink
Add base64 string validation
Browse files Browse the repository at this point in the history
  • Loading branch information
Stefan Terdell committed Dec 12, 2023
1 parent 0e89027 commit 802b885
Show file tree
Hide file tree
Showing 8 changed files with 118 additions and 2 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,7 @@ z.string().startsWith(string);
z.string().endsWith(string);
z.string().datetime(); // ISO 8601; default is without UTC offset, see below for options
z.string().ip(); // defaults to IPv4 and IPv6, see below for options
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 @@ -747,6 +747,7 @@ z.string().startsWith(string);
z.string().endsWith(string);
z.string().datetime(); // ISO 8601; default is without UTC offset, see below for options
z.string().ip(); // defaults to IPv4 and IPv6, see below for options
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 @@ -99,6 +99,7 @@ export type StringValidation =
| "ulid"
| "datetime"
| "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 @@ -162,6 +162,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
23 changes: 22 additions & 1 deletion deno/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,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 @@ -579,6 +580,10 @@ 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}=))?$/;

// Adapted from https://stackoverflow.com/a/3143231
const datetimeRegex = (args: { precision: number | null; offset: boolean }) => {
if (args.precision) {
Expand Down Expand Up @@ -845,6 +850,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 @@ -893,6 +908,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 @@ -1038,6 +1056,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 @@ -99,6 +99,7 @@ export type StringValidation =
| "ulid"
| "datetime"
| "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 @@ -161,6 +161,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
23 changes: 22 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,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 @@ -579,6 +580,10 @@ 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}=))?$/;

// Adapted from https://stackoverflow.com/a/3143231
const datetimeRegex = (args: { precision: number | null; offset: boolean }) => {
if (args.precision) {
Expand Down Expand Up @@ -845,6 +850,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 @@ -893,6 +908,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 @@ -1038,6 +1056,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 802b885

Please sign in to comment.