@bufbuild/protovalidate
Version:
Protocol Buffer Validation for ECMAScript
383 lines (382 loc) • 13.7 kB
JavaScript
// 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;
}