UNPKG

@bufbuild/protovalidate

Version:

Protocol Buffer Validation for ECMAScript

383 lines (382 loc) 13.7 kB
// Copyright 2024-2025 Buf Technologies, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import { getOption, hasOption, ScalarType, } from "@bufbuild/protobuf"; import { timestampNow } from "@bufbuild/protobuf/wkt"; import { CelError, CelList, CelObject, CelUint, createEnv, Func, FuncRegistry, } from "@bufbuild/cel"; import { predefined, } from "./gen/buf/validate/validate_pb.js"; import { CompilationError, RuntimeError } from "./error.js"; import { getRuleScalarType } from "./rules.js"; import { bytesContains, bytesEndsWith, bytesStartsWith, isEmail, isHostAndPort, isHostname, isInf, isIp, isIpPrefix, isUri, isUriRef, unique, } from "./lib.js"; function createCustomFuncs(regexMatcher) { const reg = new FuncRegistry(); reg.add(Func.unary("isNan", ["double_is_nan_bool"], (_id, arg) => { return typeof arg == "number" && Number.isNaN(arg); })); reg.add(Func.newStrict("isInf", ["double_is_inf_bool", "double_int_is_inf_bool"], (_id, args) => { if (args.length == 1) { return typeof args[0] == "number" && isInf(args[0]); } if (args.length == 2) { return (typeof args[0] == "number" && (typeof args[1] == "number" || typeof args[1] == "bigint") && isInf(args[0], args[1])); } return false; })); reg.add(Func.unary("isHostname", ["string_is_hostname_bool"], (_id, arg) => { if (typeof arg != "string") { return false; } return isHostname(arg); })); reg.add(Func.binary("isHostAndPort", ["string_bool_is_host_and_port_bool"], (_id, lhs, rhs) => { if (typeof lhs != "string" || typeof rhs != "boolean") { return false; } return isHostAndPort(lhs, rhs); })); reg.add(Func.unary("isEmail", ["string_is_email_bool"], (_id, arg) => { if (typeof arg != "string") { return false; } return isEmail(arg); })); reg.add(Func.newStrict("isIp", ["string_is_ip_bool", "string_int_is_ip_bool"], (_id, args) => { if (args.length == 1) { return typeof args[0] == "string" && isIp(args[0]); } if (args.length == 2) { return (typeof args[0] == "string" && (typeof args[1] == "number" || typeof args[1] == "bigint") && isIp(args[0], args[1])); } return false; })); reg.add(Func.newStrict("isIpPrefix", [ "string_is_ip_prefix_bool", "string_int_is_ip_prefix_bool", "string_bool_is_ip_prefix_bool", "string_int_bool_is_ip_prefix_bool", ], (_id, args) => { if (args.length < 1 || typeof args[0] != "string") { return undefined; } if (args.length == 1) { return isIpPrefix(args[0]); } if (args.length == 2) { if (typeof args[1] == "boolean") { return isIpPrefix(args[0], undefined, args[1]); } if (typeof args[1] == "number" || typeof args[1] == "bigint") { return isIpPrefix(args[0], args[1]); } } if (args.length == 3 && (typeof args[1] == "number" || typeof args[1] == "bigint") && typeof args[2] == "boolean") { return isIpPrefix(args[0], args[1], args[2]); } return undefined; })); reg.add(Func.unary("isUri", ["string_is_uri_bool"], (_id, arg) => { return typeof arg == "string" && isUri(arg); })); reg.add(Func.unary("isUriRef", ["string_is_uri_ref_bool"], (_id, arg) => { return typeof arg == "string" && isUriRef(arg); })); reg.add(Func.unary("unique", ["list_unique_bool"], (_id, arg) => { return arg instanceof CelList && unique(arg); })); reg.add(Func.binary("getField", ["dyn_string_get_field_dyn"], (id, lhs, rhs) => { if (typeof rhs == "string" && lhs instanceof CelObject) { return lhs.accessByName(id, rhs); } return undefined; })); reg.add(Func.binary("contains", ["string_contains_string", "bytes_contains_bytes"], (_id, x, y) => { if (x instanceof Uint8Array && y instanceof Uint8Array) { return bytesContains(x, y); } if (typeof x == "string" && typeof y == "string") { return x.includes(y); } return undefined; })); reg.add(Func.binary("endsWith", ["string_ends_with_string", "bytes_ends_with_bytes"], (_id, x, y) => { if (x instanceof Uint8Array && y instanceof Uint8Array) { return bytesEndsWith(x, y); } if (typeof x === "string" && typeof y === "string") { return x.endsWith(y); } return undefined; })); reg.add(Func.binary("startsWith", ["string_starts_with_string", "bytes_starts_with_bytes"], (_id, x, y) => { if (x instanceof Uint8Array && y instanceof Uint8Array) { return bytesStartsWith(x, y); } if (typeof x === "string" && typeof y === "string") { return x.startsWith(y); } return undefined; })); if (regexMatcher) { reg.add(Func.binary("matches", ["matches_string"], (_id, x, y) => { if (typeof x === "string" && typeof y === "string") { return regexMatcher(y, x); } return undefined; })); } return reg; } export class CelManager { constructor(registry, regexMatcher) { this.registry = registry; this.rulesCache = new Map(); this.now = timestampNow(); this.env = createEnv("", registry); this.env.addFuncs(createCustomFuncs(regexMatcher)); this.env.set("now", this.now); } /** * Update the CEL variable "now" to the current time. */ updateCelNow() { const n2 = timestampNow(); this.now.seconds = n2.seconds; this.now.nanos = n2.nanos; } setEnv(key, value) { this.env.set(key, value); } eval(compiled) { if (compiled.kind == "compilation_error") { throw compiled.error; } const rule = compiled.rule; const result = this.env.eval(compiled.interpretable); if (typeof result == "string" || typeof result == "boolean") { const success = typeof result == "boolean" ? result : result.length == 0; if (success) { return undefined; } // From field buf.validate.Rule.message: // > If a non-empty message is provided, any strings resulting from the CEL // > expression evaluation are ignored. return { message: rule.message.length == 0 && typeof result == "string" ? result : rule.message, ruleId: rule.id, }; } if (result instanceof CelError) { throw new RuntimeError(result.message, { cause: result }); } throw new RuntimeError(`expression ${rule.id} outputs ${typeof result}, wanted either bool or string`); } compileRule(rule) { try { return { kind: "interpretable", interpretable: this.env.plan(this.env.parse(rule.expression)), rule, }; } catch (cause) { return { kind: "compilation_error", error: new CompilationError(`failed to compile ${rule.id}: ${String(cause)}`, { cause }), }; } } compileRules(descMessage) { let compiled = this.rulesCache.get(descMessage.typeName); if (!compiled) { compiled = this.compileRulesUncached(descMessage); this.rulesCache.set(descMessage.typeName, compiled); } return compiled; } compileRulesUncached(descMessage) { const standard = []; const extensions = new Map(); for (const field of descMessage.fields) { if (!hasOption(field, predefined)) { continue; } for (const rule of getOption(field, predefined).cel) { standard.push({ field, rule, compiled: this.compileRule(rule), }); } } for (const ext of registryGetExtensionsFor(this.registry, descMessage)) { if (!hasOption(ext, predefined)) { continue; } let list = extensions.get(ext.number); if (!list) { list = []; extensions.set(ext.number, list); } for (const rule of getOption(ext, predefined).cel) { list.push({ ext, rule, compiled: this.compileRule(rule), }); } } return { standard, extensions }; } } function registryGetExtensionsFor(registry, extendee) { const result = []; for (const type of registry) { if (type.kind == "extension" && type.extendee.typeName == extendee.typeName) { result.push(type); } } return result; } export class EvalCustomCel { constructor(celMan, forMapKey, thisScalarType) { this.celMan = celMan; this.forMapKey = forMapKey; this.thisScalarType = thisScalarType; this.children = []; } add(compiled, rulePath) { this.children.push({ compiled, rulePath }); } eval(val, cursor) { this.celMan.setEnv("this", reflectToCel(val, this.thisScalarType)); this.celMan.setEnv("rules", undefined); this.celMan.setEnv("rule", undefined); for (const child of this.children) { const vio = this.celMan.eval(child.compiled); if (vio) { cursor.violate(vio.message, vio.ruleId, child.rulePath, this.forMapKey); } } } prune() { return this.children.length == 0; } } export class EvalExtendedRulesCel { constructor(celMan, rules, forMapKey) { this.celMan = celMan; this.rules = rules; this.forMapKey = forMapKey; this.children = []; this.thisScalarType = getRuleScalarType(rules); } add(compiled, rulePath, ruleValue, ruleScalarType) { this.children.push({ compiled, rulePath, ruleCelValue: reflectToCel(ruleValue, ruleScalarType), }); } eval(val, cursor) { this.celMan.setEnv("rules", this.rules); this.celMan.setEnv("this", reflectToCel(val, this.thisScalarType)); for (const child of this.children) { this.celMan.setEnv("rule", child.ruleCelValue); const vio = this.celMan.eval(child.compiled); if (vio) { cursor.violate(vio.message, vio.ruleId, child.rulePath, this.forMapKey); } } } prune() { return this.children.length == 0; } } export class EvalStandardRulesCel { constructor(celMan, rules, forMapKey) { this.celMan = celMan; this.rules = rules; this.forMapKey = forMapKey; this.children = []; this.thisScalarType = getRuleScalarType(rules); } add(compiled, rulePath) { this.children.push({ compiled, rulePath }); } eval(val, cursor) { this.celMan.setEnv("this", reflectToCel(val, this.thisScalarType)); this.celMan.setEnv("rules", this.rules); this.celMan.setEnv("rule", undefined); for (const child of this.children) { const vio = this.celMan.eval(child.compiled); if (vio) { cursor.violate(vio.message, vio.ruleId, child.rulePath, this.forMapKey); } } } prune() { return this.children.length == 0; } } function reflectToCel(val, scalarType) { switch (scalarType) { case ScalarType.DOUBLE: case ScalarType.FLOAT: case ScalarType.BOOL: case ScalarType.STRING: case ScalarType.BYTES: break; case ScalarType.UINT32: case ScalarType.FIXED32: if (typeof val == "number") { return CelUint.of(BigInt(val)); } break; case ScalarType.UINT64: case ScalarType.FIXED64: switch (typeof val) { case "bigint": return CelUint.of(val); case "number": case "string": return CelUint.of(BigInt(val)); } break; case ScalarType.INT32: case ScalarType.SFIXED32: case ScalarType.SINT32: if (typeof val == "number") { return BigInt(val); } break; case ScalarType.INT64: case ScalarType.SFIXED64: case ScalarType.SINT64: switch (typeof val) { case "number": case "string": return BigInt(val); } break; } return val; }