UNPKG

@bufbuild/protovalidate

Version:

Protocol Buffer Validation for ECMAScript

240 lines (239 loc) 8.33 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 { create, getOption, hasOption, } from "@bufbuild/protobuf"; import { timestampNow } from "@bufbuild/protobuf/wkt"; import { celEnv, celFromScalar, isCelError, parse, plan, } from "@bufbuild/cel"; import { predefined, RuleSchema, } from "./gen/buf/validate/validate_pb.js"; import { CompilationError, RuntimeError } from "./error.js"; import { getRuleScalarType } from "./rules.js"; import { createCustomFuncions } from "./func.js"; import { STRINGS_EXT_FUNCS } from "@bufbuild/cel/ext/strings"; import { isReflectMessage } from "@bufbuild/protobuf/reflect"; export class CelManager { constructor(registry, regexMatcher) { this.registry = registry; this.rulesCache = new Map(); this.bindings = {}; this.env = celEnv({ registry, funcs: [...STRINGS_EXT_FUNCS, ...createCustomFuncions(regexMatcher)], }); this.bindings.now = timestampNow(); } /** * Update the CEL variable "now" to the current time. */ updateCelNow() { this.bindings.now = timestampNow(); } setEnv(key, value) { if (value === undefined) { delete this.bindings[key]; return; } this.bindings[key] = value; } eval(compiled) { if (compiled.kind == "compilation_error") { throw compiled.error; } const rule = compiled.rule; const result = compiled.interpretable(this.bindings); 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. let message = rule.message; if (message === "") { message = typeof result === "string" ? result : `"${rule.expression}" returned false`; } return { message, ruleId: rule.id, }; } if (isCelError(result)) { throw new RuntimeError(result.message, { cause: result }); } throw new RuntimeError(`expression ${rule.id} outputs ${typeof result}, wanted either bool or string`); } compileRule(ruleOrExpr) { const rule = typeof ruleOrExpr == "string" ? create(RuleSchema, { id: ruleOrExpr, expression: ruleOrExpr }) : ruleOrExpr; try { return { kind: "interpretable", interpretable: 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) { // Wrappers are treated as scalars in standard rules so we let CEL handle them. if (isReflectMessage(val)) { return val; } if (scalarType) { return celFromScalar(scalarType, val); } return val; }