diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 7097514..cfe5e06 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -24,6 +24,7 @@ jobs:
task:
- format
- lint
+ - typecheck
- attw
- build
- license-header
@@ -75,3 +76,9 @@ jobs:
restore-keys: ${{ runner.os }}/test
- run: npm ci
- run: npx turbo run test
+ # Re-run the conformance suite against the pure-CEL path to prove it
+ # stays equivalent to the (default-on) native rules.
+ - name: conformance (native rules disabled)
+ run: npx turbo run test --filter=@bufbuild/protovalidate-testing
+ env:
+ PROTOVALIDATE_DISABLE_NATIVE_RULES: "1"
diff --git a/README.md b/README.md
index 4a66ae4..36e371a 100644
--- a/README.md
+++ b/README.md
@@ -50,9 +50,9 @@ if (result.kind !== "valid") {
>
> The `string.pattern` rule supports regular expressions with CEL's standard [RE2 syntax](https://github.com/google/re2/wiki/syntax).
>
-> Protovalidate translates RE2 to ECMAScript's regular expressions. This works except for some RE2 flags, but it cannot support RE2's most important property: Execution in linear time, which guards against [ReDoS](https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS).
+> Protovalidate evaluates patterns with [@bufbuild/re2](https://www.npmjs.com/package/@bufbuild/re2), an RE2-compatible engine that executes in linear time, guarding against [ReDoS](https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS).
>
-> If you need full support for RE2, you can bring your own RE2 implementation:
+> If you prefer a different engine, you can bring your own RE2 implementation:
>
> ```ts
> const validator = createValidator({
diff --git a/package-lock.json b/package-lock.json
index 8c13ade..75159ad 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1850,7 +1850,8 @@
"version": "1.2.0",
"license": "Apache-2.0",
"dependencies": {
- "@bufbuild/cel": "^0.6.1"
+ "@bufbuild/cel": "0.6.1",
+ "@bufbuild/re2": "0.6.1"
},
"devDependencies": {
"@bufbuild/protobuf": "^2.11.0",
diff --git a/package.json b/package.json
index d9788a9..7b4d9c2 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"private": true,
"scripts": {
"clean": "git clean -Xdf",
- "all": "turbo run --ui tui build format test lint attw license-header update-readme",
+ "all": "turbo run --ui tui build format test typecheck lint attw license-header update-readme",
"setversion": "node scripts/set-workspace-version.js",
"postsetversion": "npm run all",
"format": "biome format --write",
diff --git a/packages/protovalidate-bench/README.md b/packages/protovalidate-bench/README.md
index c0796c4..ff7bd67 100644
--- a/packages/protovalidate-bench/README.md
+++ b/packages/protovalidate-bench/README.md
@@ -9,10 +9,13 @@ so that runtime cost can be tracked across changes and compared cross-language.
With turborepo:
```shell
-npx turbo run bench -- [regex] --dir
+npx turbo run bench --filter=@bufbuild/protovalidate-bench -- [regex]
```
-With npm (make sure to generate proto and build dependencies first):
+This command will rebuild the `protovalidate` package and run the benchmarks. This is preferred to make sure
+the latest changes are reflected in the results.
+
+You can also run the benchmarks directly from the `protovalidate-bench` package:
```shell
npm run bench
diff --git a/packages/protovalidate-bench/src/bench.ts b/packages/protovalidate-bench/src/bench.ts
index 73850b7..8966bc0 100644
--- a/packages/protovalidate-bench/src/bench.ts
+++ b/packages/protovalidate-bench/src/bench.ts
@@ -15,7 +15,10 @@
import * as console from "node:console";
import { writeFileSync } from "node:fs";
import { parseArgs } from "node:util";
-import { createValidator } from "@bufbuild/protovalidate";
+import {
+ createValidator,
+ type ValidatorOptions,
+} from "@bufbuild/protovalidate";
import { Bench, type Task } from "tinybench";
import { cases } from "./cases.js";
@@ -62,7 +65,10 @@ if (tests.length == 0) {
}
const bench = new Bench({ name: "protovalidate benchmarks", time: 100 });
-const validator = createValidator();
+const disableNative = process.env.PROTOVALIDATE_DISABLE_NATIVE_RULES;
+const opts: ValidatorOptions =
+ disableNative !== undefined ? { disableNativeRules: true } : {};
+const validator = createValidator(opts);
for (const test of tests) {
bench.add(test.name, () => {
validator.validate(test.schema, test.fixture);
diff --git a/packages/protovalidate-testing/src/executor.ts b/packages/protovalidate-testing/src/executor.ts
index e77fd85..cfc0168 100644
--- a/packages/protovalidate-testing/src/executor.ts
+++ b/packages/protovalidate-testing/src/executor.ts
@@ -38,7 +38,13 @@ if (!request.fdset) {
throw new Error(`Empty request field "fdset"`);
}
const registry = createFileRegistry(request.fdset);
-const validator = createValidator({ registry });
+// Set PROTOVALIDATE_DISABLE_NATIVE_RULES=1 to run the conformance suite
+// against the pure-CEL path, proving equivalence with the (default-on)
+// native rules.
+const validator = createValidator({
+ registry,
+ disableNativeRules: process.env.PROTOVALIDATE_DISABLE_NATIVE_RULES === "1",
+});
const response = create(TestConformanceResponseSchema);
for (const [name, any] of Object.entries(request.cases)) {
const testResult = create(TestResultSchema);
diff --git a/packages/protovalidate/package.json b/packages/protovalidate/package.json
index 4c14c59..cdeb9ea 100644
--- a/packages/protovalidate/package.json
+++ b/packages/protovalidate/package.json
@@ -20,7 +20,8 @@
"postfetch-proto": "license-header proto",
"generate": "buf generate",
"postgenerate": "license-header src/gen",
- "test": "npx tsx --test ./src/*.test.ts",
+ "test": "npx tsx --test ./src/*.test.ts ./src/**/*.test.ts",
+ "typecheck": "tsc --project tsconfig.test.json",
"prebuild": "rm -rf ./dist/*",
"build": "npm run build:cjs && npm run build:esm",
"build:cjs": "tsc --project tsconfig.json --module commonjs --verbatimModuleSyntax false --moduleResolution node10 --outDir ./dist/cjs && echo >./dist/cjs/package.json '{\"type\":\"commonjs\"}'",
@@ -42,7 +43,8 @@
}
},
"dependencies": {
- "@bufbuild/cel": "0.6.1"
+ "@bufbuild/cel": "0.6.1",
+ "@bufbuild/re2": "0.6.1"
},
"peerDependencies": {
"@bufbuild/protobuf": "^2.8.0"
diff --git a/packages/protovalidate/src/native/bool.test.ts b/packages/protovalidate/src/native/bool.test.ts
new file mode 100644
index 0000000..b3abdca
--- /dev/null
+++ b/packages/protovalidate/src/native/bool.test.ts
@@ -0,0 +1,47 @@
+// Copyright 2024-2026 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 { suite, test } from "node:test";
+import { create } from "@bufbuild/protobuf";
+import { compile, diff } from "./testing.js";
+
+void suite("native bool rules", () => {
+ void suite("bool.const", () => {
+ const schema = compile(
+ `message M {
+ bool b = 1 [(buf.validate.field).bool.const = true];
+ }`,
+ );
+ void test("matches: valid", () => {
+ diff(schema, create(schema, { b: true }));
+ });
+ void test("mismatches: invalid", () => {
+ diff(schema, create(schema, { b: false }));
+ });
+ });
+
+ void suite("BoolValue wrapper", () => {
+ const schema = compile(
+ `message M {
+ google.protobuf.BoolValue b = 1 [(buf.validate.field).bool.const = true];
+ }`,
+ );
+ void test("inner value matches: valid", () => {
+ diff(schema, create(schema, { b: true }));
+ });
+ void test("inner value mismatches: invalid", () => {
+ diff(schema, create(schema, { b: false }));
+ });
+ });
+});
diff --git a/packages/protovalidate/src/native/bool.ts b/packages/protovalidate/src/native/bool.ts
new file mode 100644
index 0000000..c1fb909
--- /dev/null
+++ b/packages/protovalidate/src/native/bool.ts
@@ -0,0 +1,78 @@
+// Copyright 2024-2026 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 { isFieldSet } from "@bufbuild/protobuf";
+import type {
+ Path,
+ PathBuilder,
+ ScalarValue,
+} from "@bufbuild/protobuf/reflect";
+import type { Cursor } from "../cursor.js";
+import type { Eval } from "../eval.js";
+import {
+ type BoolRules,
+ BoolRulesSchema,
+} from "../gen/buf/validate/validate_pb.js";
+import type { ScalarNativeResult } from "./dispatcher.js";
+
+/**
+ * Native evaluator for `bool.const`.
+ *
+ * Bool only supports the `const` rule. Anything else on a BoolRules instance
+ * falls through to CEL via the dispatcher.
+ */
+class EvalNativeBoolRules implements Eval {
+ constructor(
+ private readonly forMapKey: boolean,
+ private readonly constVal: boolean,
+ private readonly rulePath: Path,
+ ) {}
+
+ eval(val: ScalarValue, cursor: Cursor): void {
+ if (val !== this.constVal) {
+ cursor.violate(
+ `must equal ${this.constVal}`,
+ "bool.const",
+ this.rulePath,
+ this.forMapKey,
+ );
+ }
+ }
+
+ prune(): boolean {
+ return false;
+ }
+}
+
+/**
+ * Try to build a native evaluator for BoolRules. Returns `undefined` if no
+ * native handler applies (no const set, or unknown extensions present).
+ */
+export function tryBuildNativeBoolRules(
+ rules: BoolRules,
+ rulePath: PathBuilder,
+ forMapKey: boolean,
+): ScalarNativeResult | undefined {
+ if (rules.$unknown && rules.$unknown.length > 0) {
+ return undefined;
+ }
+ if (!isFieldSet(rules, BoolRulesSchema.field.const)) {
+ return undefined;
+ }
+ const path = rulePath.clone().field(BoolRulesSchema.field.const).toPath();
+ return {
+ eval: new EvalNativeBoolRules(forMapKey, rules.const, path),
+ handledFields: new Set([BoolRulesSchema.field.const]),
+ };
+}
diff --git a/packages/protovalidate/src/native/bytes.test.ts b/packages/protovalidate/src/native/bytes.test.ts
new file mode 100644
index 0000000..9975305
--- /dev/null
+++ b/packages/protovalidate/src/native/bytes.test.ts
@@ -0,0 +1,361 @@
+// Copyright 2024-2026 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 { suite, test } from "node:test";
+import * as assert from "node:assert/strict";
+import { create } from "@bufbuild/protobuf";
+import { pathToString } from "@bufbuild/protobuf/reflect";
+import { compile, diff, native } from "./testing.js";
+import { RuntimeError } from "../error.js";
+import { createValidator } from "../validator.js";
+
+function bytes(...vs: number[]): Uint8Array {
+ return new Uint8Array(vs);
+}
+
+void suite("native bytes rules", () => {
+ void test("bytes.const passes and fails", () => {
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes.const = "\\x01\\x02"]; }`,
+ );
+ diff(s, create(s, { b: bytes(0x01, 0x02) }));
+ diff(s, create(s, { b: bytes(0x01, 0x03) }));
+ diff(s, create(s, { b: bytes() }));
+ });
+
+ void test("bytes.len passes and fails", () => {
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes.len = 4]; }`,
+ );
+ diff(s, create(s, { b: bytes(1, 2, 3, 4) }));
+ diff(s, create(s, { b: bytes(1, 2, 3) }));
+ diff(s, create(s, { b: bytes(1, 2, 3, 4, 5) }));
+ });
+
+ void test("bytes.min_len + max_len", () => {
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes = { min_len: 2, max_len: 4 }]; }`,
+ );
+ diff(s, create(s, { b: bytes(1, 2) }));
+ diff(s, create(s, { b: bytes(1, 2, 3, 4) }));
+ diff(s, create(s, { b: bytes(1) }));
+ diff(s, create(s, { b: bytes(1, 2, 3, 4, 5) }));
+ });
+
+ void test("bytes.prefix", () => {
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes.prefix = "\\xaa\\xbb"]; }`,
+ );
+ diff(s, create(s, { b: bytes(0xaa, 0xbb, 0xcc) }));
+ diff(s, create(s, { b: bytes(0xaa, 0xcc) }));
+ diff(s, create(s, { b: bytes() }));
+ });
+
+ void test("bytes.suffix", () => {
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes.suffix = "\\xee\\xff"]; }`,
+ );
+ diff(s, create(s, { b: bytes(0xdd, 0xee, 0xff) }));
+ diff(s, create(s, { b: bytes(0xee, 0xfe) }));
+ });
+
+ void test("bytes.contains", () => {
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes.contains = "\\xab\\xcd"]; }`,
+ );
+ diff(s, create(s, { b: bytes(0x00, 0xab, 0xcd, 0xff) }));
+ diff(s, create(s, { b: bytes(0x00, 0xab, 0xff) }));
+ });
+
+ void test("bytes.in / not_in", () => {
+ const s = compile(
+ `message M {
+ bytes b = 1 [(buf.validate.field).bytes = {
+ in: ["\\x01", "\\x02"],
+ not_in: ["\\x03"]
+ }];
+ }`,
+ );
+ diff(s, create(s, { b: bytes(0x01) }));
+ diff(s, create(s, { b: bytes(0x02) }));
+ diff(s, create(s, { b: bytes(0x03) })); // violates both
+ diff(s, create(s, { b: bytes(0x04) })); // violates in only
+ });
+
+ void suite("bytes.pattern", () => {
+ void test("valid match passes", () => {
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes.pattern = "^[a-z]+$"]; }`,
+ );
+ diff(s, create(s, { b: new TextEncoder().encode("hello") }));
+ });
+ void test("mismatch fails", () => {
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes.pattern = "^[a-z]+$"]; }`,
+ );
+ diff(s, create(s, { b: new TextEncoder().encode("HELLO") }));
+ });
+ void test("non-UTF-8 input is a RuntimeError, not a violation", () => {
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes.pattern = ".+"]; }`,
+ );
+ // 0xff alone is not valid UTF-8.
+ diff(s, create(s, { b: bytes(0xff) }));
+ const r = native.validate(s, create(s, { b: bytes(0xff) }));
+ assert.equal(r.kind, "error");
+ assert.ok(r.error instanceof RuntimeError);
+ });
+ void test("custom regexMatch override is honored", () => {
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes.pattern = ".+"]; }`,
+ );
+ let calledWith: { pattern: string; against: string } | undefined;
+ const v = createValidator({
+ regexMatch: (pattern, against) => {
+ calledWith = { pattern, against };
+ return false; // always fail
+ },
+ });
+ const r = v.validate(s, create(s, { b: new TextEncoder().encode("x") }));
+ assert.equal(r.kind, "invalid");
+ assert.equal(calledWith?.pattern, ".+");
+ assert.equal(calledWith?.against, "x");
+ });
+ });
+
+ void suite("well-known formats", () => {
+ void test("bytes.ip accepts 4-byte and 16-byte values", () => {
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes.ip = true]; }`,
+ );
+ diff(s, create(s, { b: bytes(1, 2, 3, 4) }));
+ diff(s, create(s, { b: bytes(...new Array(16).fill(0)) }));
+ });
+ void test("bytes.ip rejects 5-byte values", () => {
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes.ip = true]; }`,
+ );
+ diff(s, create(s, { b: bytes(1, 2, 3, 4, 5) }));
+ });
+ void test("bytes.ip rejects empty (emits *_empty rule)", () => {
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes.ip = true]; }`,
+ );
+ diff(s, create(s, { b: bytes() }));
+ const r = native.validate(s, create(s, { b: bytes() }));
+ assert.equal(r.kind, "invalid");
+ const v = r.violations?.[0];
+ assert.ok(v);
+ assert.equal(v.ruleId, "bytes.ip_empty");
+ });
+ void test("bytes.ipv4 requires exactly 4 bytes", () => {
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes.ipv4 = true]; }`,
+ );
+ diff(s, create(s, { b: bytes(1, 2, 3, 4) }));
+ diff(s, create(s, { b: bytes(1, 2, 3) })); // too short
+ diff(s, create(s, { b: bytes(...new Array(16).fill(0)) })); // v6 size
+ });
+ void test("bytes.ipv6 requires exactly 16 bytes", () => {
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes.ipv6 = true]; }`,
+ );
+ diff(s, create(s, { b: bytes(...new Array(16).fill(0)) }));
+ diff(s, create(s, { b: bytes(1, 2, 3, 4) })); // v4 size
+ });
+ void test("bytes.uuid requires exactly 16 bytes", () => {
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes.uuid = true]; }`,
+ );
+ diff(s, create(s, { b: bytes(...new Array(16).fill(0)) }));
+ diff(s, create(s, { b: bytes(1, 2, 3, 4) }));
+ });
+ void test("explicit ip=false is a no-op claim — never emits a violation", () => {
+ // `ip: false` is field-set, so the native handler claims the well-known
+ // field and emits nothing. Behavior is identical to CEL (whose
+ // predicate `!rules.ip` short-circuits to no violation).
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes.ip = false]; }`,
+ );
+ diff(s, create(s, { b: bytes() }));
+ diff(s, create(s, { b: bytes(1, 2, 3, 4, 5) }));
+ // Direct assertions on the native path so a future refactor that
+ // breaks claim semantics surfaces immediately.
+ assert.equal(native.validate(s, create(s, { b: bytes() })).kind, "valid");
+ assert.equal(
+ native.validate(s, create(s, { b: bytes(1, 2, 3, 4, 5) })).kind,
+ "valid",
+ );
+ });
+
+ void test("bytes.ip with 1-byte input fails as wrong-size", () => {
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes.ip = true]; }`,
+ );
+ diff(s, create(s, { b: bytes(0x01) }));
+ // Confirm it hits `bytes.ip` (wrong-size), not `bytes.ip_empty`.
+ const r = native.validate(s, create(s, { b: bytes(0x01) }));
+ assert.equal(r.kind, "invalid");
+ assert.equal(r.violations?.[0]?.ruleId, "bytes.ip");
+ });
+ });
+
+ void suite("BytesValue wrapper", () => {
+ void test("inner value validated against bytes.len", () => {
+ const s = compile(
+ `message M {
+ google.protobuf.BytesValue b = 1 [(buf.validate.field).bytes.len = 2];
+ }`,
+ );
+ diff(s, create(s, { b: bytes(1, 2) }));
+ diff(s, create(s, { b: bytes(1) }));
+ });
+ void test("inner value validated against bytes.ip", () => {
+ const s = compile(
+ `message M {
+ google.protobuf.BytesValue b = 1 [(buf.validate.field).bytes.ip = true];
+ }`,
+ );
+ diff(s, create(s, { b: bytes(1, 2, 3, 4) }));
+ diff(s, create(s, { b: bytes(1, 2, 3) }));
+ });
+ });
+
+ void suite("rule path assertions", () => {
+ void test("path lands at bytes.const", () => {
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes.const = "\\x01"]; }`,
+ );
+ const r = native.validate(s, create(s, { b: bytes(0x02) }));
+ assert.equal(r.kind, "invalid");
+ const v = r.violations?.[0];
+ assert.ok(v);
+ assert.equal(pathToString(v.rule), "bytes.const");
+ });
+ void test("path lands at bytes.pattern", () => {
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes.pattern = "^x$"]; }`,
+ );
+ const r = native.validate(
+ s,
+ create(s, { b: new TextEncoder().encode("y") }),
+ );
+ assert.equal(r.kind, "invalid");
+ const v = r.violations?.[0];
+ assert.ok(v);
+ assert.equal(pathToString(v.rule), "bytes.pattern");
+ });
+ void test("path lands at bytes.ip (non-empty wrong size)", () => {
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes.ip = true]; }`,
+ );
+ const r = native.validate(s, create(s, { b: bytes(1, 2, 3) }));
+ assert.equal(r.kind, "invalid");
+ const v = r.violations?.[0];
+ assert.ok(v);
+ assert.equal(pathToString(v.rule), "bytes.ip");
+ assert.equal(v.ruleId, "bytes.ip");
+ });
+ });
+
+ void test("combined len + pattern fires both", () => {
+ const s = compile(
+ `message M {
+ bytes b = 1 [(buf.validate.field).bytes = {
+ len: 3, pattern: "^[a-z]+$"
+ }];
+ }`,
+ );
+ diff(s, create(s, { b: new TextEncoder().encode("ab") })); // len fails, pattern passes
+ diff(s, create(s, { b: new TextEncoder().encode("AB") })); // both fail
+ diff(s, create(s, { b: new TextEncoder().encode("abc") })); // both pass
+ });
+
+ // Review follow-up: gaps surfaced by the code review.
+ void suite("review gap coverage", () => {
+ void test("bytes.const with empty rule value vs empty input", () => {
+ // `const = ""` matches an empty input; the violation message for any
+ // mismatch is `"must be "` (trailing space, empty hex).
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes.const = ""]; }`,
+ );
+ diff(s, create(s, { b: bytes() }));
+ diff(s, create(s, { b: bytes(0x01) }));
+ });
+
+ void test("bytes.pattern with empty pattern matches any input", () => {
+ // `new RegExp("")` matches the empty string at every position. Confirm
+ // native and CEL agree.
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes.pattern = ""]; }`,
+ );
+ diff(s, create(s, { b: bytes() }));
+ diff(s, create(s, { b: new TextEncoder().encode("hello") }));
+ });
+
+ void test("bytes.in with explicitly-set empty list is a no-op", () => {
+ // `in: []` (proto3 repeated, length 0) is treated as unset by both
+ // native and CEL — no violation regardless of input.
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes = { in: [] }]; }`,
+ );
+ diff(s, create(s, { b: bytes() }));
+ diff(s, create(s, { b: bytes(0x01) }));
+ });
+
+ void test("bytes.not_in with explicitly-set empty list is a no-op", () => {
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes = { not_in: [] }]; }`,
+ );
+ diff(s, create(s, { b: bytes() }));
+ diff(s, create(s, { b: bytes(0x01) }));
+ });
+
+ void test("bytes.contains with empty needle accepts every input", () => {
+ // Matches Go's `bytes.Contains(_, []byte{})` returning true.
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes.contains = ""]; }`,
+ );
+ diff(s, create(s, { b: bytes() }));
+ diff(s, create(s, { b: bytes(0x01) }));
+ diff(s, create(s, { b: bytes(0x01, 0x02, 0x03) }));
+ });
+
+ void test("BytesValue wrapper with absent inner value", () => {
+ // Wrapper field unset on the parent — EvalField's presence check
+ // skips validation entirely, so no spurious bytes.len violation.
+ const s = compile(
+ `message M {
+ google.protobuf.BytesValue b = 1 [(buf.validate.field).bytes.len = 2];
+ }`,
+ );
+ diff(s, create(s, {})); // wrapper absent
+ assert.equal(native.validate(s, create(s, {})).kind, "valid");
+ });
+
+ void test("custom regexMatch that throws is wrapped in RuntimeError", () => {
+ const s = compile(
+ `message M { bytes b = 1 [(buf.validate.field).bytes.pattern = ".+"]; }`,
+ );
+ const v = createValidator({
+ regexMatch: () => {
+ throw new Error("synthetic engine failure");
+ },
+ });
+ const r = v.validate(s, create(s, { b: new TextEncoder().encode("x") }));
+ assert.equal(r.kind, "error");
+ assert.ok(r.error instanceof RuntimeError);
+ });
+ });
+});
diff --git a/packages/protovalidate/src/native/bytes.ts b/packages/protovalidate/src/native/bytes.ts
new file mode 100644
index 0000000..221105c
--- /dev/null
+++ b/packages/protovalidate/src/native/bytes.ts
@@ -0,0 +1,495 @@
+// Copyright 2024-2026 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 { type DescField, isFieldSet } from "@bufbuild/protobuf";
+import type {
+ Path,
+ PathBuilder,
+ ScalarValue,
+} from "@bufbuild/protobuf/reflect";
+import type { Cursor } from "../cursor.js";
+import type { Eval } from "../eval.js";
+import { RuntimeError } from "../error.js";
+import {
+ type BytesRules,
+ BytesRulesSchema,
+} from "../gen/buf/validate/validate_pb.js";
+import type { ScalarNativeResult } from "./dispatcher.js";
+import { formatList } from "./format.js";
+import type { RegexMatcher } from "../func.js";
+
+const F = BytesRulesSchema.field;
+
+/** A rule with a Uint8Array operand: const, prefix, suffix, contains. */
+type BytesRule = { readonly val: Uint8Array; readonly path: Path };
+/** A rule with a numeric size operand: len, min_len, max_len. */
+type SizeRule = { readonly val: bigint; readonly path: Path };
+/** A rule with a Uint8Array list operand: in, not_in. */
+type BytesListRule = {
+ readonly vals: readonly Uint8Array[];
+ readonly path: Path;
+};
+type PatternRule = {
+ readonly src: string;
+ readonly test: (against: string) => boolean;
+ readonly path: Path;
+};
+
+type WellKnownKind = "ip" | "ipv4" | "ipv6" | "uuid";
+
+/**
+ * Spec carried alongside an active well-known constraint, so `eval()` does
+ * no per-call table lookups.
+ */
+type WellKnownRule = {
+ readonly kind: WellKnownKind;
+ readonly validSizes: readonly number[];
+ readonly msg: string;
+ readonly emptyMsg: string;
+ readonly path: Path;
+};
+
+/**
+ * Per-kind specs for the well-known bytes formats. Matches Go's
+ * `bytesWellKnown` structs in `native_bytes.go` and CEL's predefined
+ * annotations on the corresponding `BytesRules` fields.
+ */
+const WELL_KNOWN: Record<
+ WellKnownKind,
+ {
+ readonly validSizes: readonly number[];
+ readonly msg: string;
+ readonly emptyMsg: string;
+ }
+> = {
+ ip: {
+ validSizes: [4, 16],
+ msg: "must be a valid IP address",
+ emptyMsg: "value is empty, which is not a valid IP address",
+ },
+ ipv4: {
+ validSizes: [4],
+ msg: "must be a valid IPv4 address",
+ emptyMsg: "value is empty, which is not a valid IPv4 address",
+ },
+ ipv6: {
+ validSizes: [16],
+ msg: "must be a valid IPv6 address",
+ emptyMsg: "value is empty, which is not a valid IPv6 address",
+ },
+ uuid: {
+ validSizes: [16],
+ msg: "must be a valid UUID",
+ emptyMsg: "value is empty, which is not a valid UUID",
+ },
+};
+
+/**
+ * Strict UTF-8 decoder. `bytes.pattern` requires valid UTF-8 — non-UTF-8
+ * input surfaces as a RuntimeError to match CEL's `string(bytes)` cast,
+ * which uses `TextDecoder` with `fatal: true`.
+ */
+const utf8FatalDecoder = new TextDecoder("utf-8", { fatal: true });
+
+/**
+ * Non-fatal UTF-8 decoder for formatting bytes in error messages. Matches
+ * CEL-TS's `formatString` on a Uint8Array, which uses a default
+ * `new TextDecoder()` (non-fatal — invalid sequences become U+FFFD).
+ */
+const utf8NonFatalDecoder = new TextDecoder();
+
+/**
+ * Configuration for {@link EvalNativeBytesRules}. Bundled into a single
+ * object so callers don't have to track ~12 positional constructor args.
+ */
+type BytesRulesConfig = {
+ readonly forMapKey: boolean;
+ readonly constRule?: BytesRule;
+ readonly exactLen?: SizeRule;
+ readonly minLen?: SizeRule;
+ readonly maxLen?: SizeRule;
+ readonly pattern?: PatternRule;
+ readonly prefix?: BytesRule;
+ readonly suffix?: BytesRule;
+ readonly containsRule?: BytesRule;
+ readonly inRule?: BytesListRule;
+ readonly notInRule?: BytesListRule;
+ readonly wellKnown?: WellKnownRule;
+};
+
+class EvalNativeBytesRules implements Eval {
+ constructor(private readonly cfg: BytesRulesConfig) {}
+
+ eval(val: ScalarValue, cursor: Cursor): void {
+ const v = val as Uint8Array;
+ const len = BigInt(v.length);
+ const c = this.cfg;
+
+ if (c.constRule !== undefined && !bytesEqual(v, c.constRule.val)) {
+ cursor.violate(
+ `must be ${toHex(c.constRule.val)}`,
+ "bytes.const",
+ c.constRule.path,
+ c.forMapKey,
+ );
+ }
+
+ if (c.exactLen !== undefined && len !== c.exactLen.val) {
+ cursor.violate(
+ `must be ${c.exactLen.val} bytes`,
+ "bytes.len",
+ c.exactLen.path,
+ c.forMapKey,
+ );
+ }
+
+ if (c.minLen !== undefined && len < c.minLen.val) {
+ cursor.violate(
+ `must be at least ${c.minLen.val} bytes`,
+ "bytes.min_len",
+ c.minLen.path,
+ c.forMapKey,
+ );
+ }
+
+ if (c.maxLen !== undefined && len > c.maxLen.val) {
+ cursor.violate(
+ `must be at most ${c.maxLen.val} bytes`,
+ "bytes.max_len",
+ c.maxLen.path,
+ c.forMapKey,
+ );
+ }
+
+ if (c.pattern !== undefined) {
+ let decoded: string;
+ try {
+ decoded = utf8FatalDecoder.decode(v);
+ } catch (cause) {
+ throw new RuntimeError("must be valid UTF-8 to apply regexp", {
+ cause,
+ });
+ }
+ // Wrap test() — if a user-supplied regexMatch throws, surface it as a
+ // RuntimeError so CEL's behavior is preserved end-to-end. The default
+ // RegExp engine doesn't throw at match time.
+ let matched: boolean;
+ try {
+ matched = c.pattern.test(decoded);
+ } catch (cause) {
+ throw new RuntimeError(`regex match failed for ${c.pattern.src}`, {
+ cause,
+ });
+ }
+ if (!matched) {
+ cursor.violate(
+ `must match regex pattern \`${c.pattern.src}\``,
+ "bytes.pattern",
+ c.pattern.path,
+ c.forMapKey,
+ );
+ }
+ }
+
+ if (c.prefix !== undefined && !startsWith(v, c.prefix.val)) {
+ cursor.violate(
+ `does not have prefix ${toHex(c.prefix.val)}`,
+ "bytes.prefix",
+ c.prefix.path,
+ c.forMapKey,
+ );
+ }
+
+ if (c.suffix !== undefined && !endsWith(v, c.suffix.val)) {
+ cursor.violate(
+ `does not have suffix ${toHex(c.suffix.val)}`,
+ "bytes.suffix",
+ c.suffix.path,
+ c.forMapKey,
+ );
+ }
+
+ if (c.containsRule !== undefined && !containsBytes(v, c.containsRule.val)) {
+ cursor.violate(
+ `does not contain ${toHex(c.containsRule.val)}`,
+ "bytes.contains",
+ c.containsRule.path,
+ c.forMapKey,
+ );
+ }
+
+ if (c.inRule !== undefined && !bytesListContains(c.inRule.vals, v)) {
+ cursor.violate(
+ `must be in list ${formatList(c.inRule.vals, (b) => utf8NonFatalDecoder.decode(b))}`,
+ "bytes.in",
+ c.inRule.path,
+ c.forMapKey,
+ );
+ }
+
+ if (c.notInRule !== undefined && bytesListContains(c.notInRule.vals, v)) {
+ cursor.violate(
+ `must not be in list ${formatList(c.notInRule.vals, (b) => utf8NonFatalDecoder.decode(b))}`,
+ "bytes.not_in",
+ c.notInRule.path,
+ c.forMapKey,
+ );
+ }
+
+ if (c.wellKnown !== undefined) {
+ const wk = c.wellKnown;
+ const size = v.length;
+ if (size === 0) {
+ cursor.violate(
+ wk.emptyMsg,
+ `bytes.${wk.kind}_empty`,
+ wk.path,
+ c.forMapKey,
+ );
+ } else if (!wk.validSizes.includes(size)) {
+ cursor.violate(wk.msg, `bytes.${wk.kind}`, wk.path, c.forMapKey);
+ }
+ }
+ }
+
+ prune(): boolean {
+ return false;
+ }
+}
+
+function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
+ if (a.length !== b.length) return false;
+ for (let i = 0; i < a.length; i++) {
+ if (a[i] !== b[i]) return false;
+ }
+ return true;
+}
+
+function startsWith(haystack: Uint8Array, needle: Uint8Array): boolean {
+ if (needle.length > haystack.length) return false;
+ for (let i = 0; i < needle.length; i++) {
+ if (haystack[i] !== needle[i]) return false;
+ }
+ return true;
+}
+
+function endsWith(haystack: Uint8Array, needle: Uint8Array): boolean {
+ if (needle.length > haystack.length) return false;
+ const offset = haystack.length - needle.length;
+ for (let i = 0; i < needle.length; i++) {
+ if (haystack[offset + i] !== needle[i]) return false;
+ }
+ return true;
+}
+
+function containsBytes(haystack: Uint8Array, needle: Uint8Array): boolean {
+ // Empty needle is contained in every byte slice — matches Go's
+ // `bytes.Contains(_, []byte{})` (returns true).
+ if (needle.length === 0) return true;
+ if (needle.length > haystack.length) return false;
+ const limit = haystack.length - needle.length;
+ outer: for (let i = 0; i <= limit; i++) {
+ for (let j = 0; j < needle.length; j++) {
+ if (haystack[i + j] !== needle[j]) continue outer;
+ }
+ return true;
+ }
+ return false;
+}
+
+function bytesListContains(
+ list: readonly Uint8Array[],
+ v: Uint8Array,
+): boolean {
+ for (let i = 0; i < list.length; i++) {
+ if (bytesEqual(list[i] as Uint8Array, v)) return true;
+ }
+ return false;
+}
+
+/**
+ * Format bytes as concatenated lowercase hex pairs without separators or
+ * prefix. Matches Go's `%x` formatter for `[]byte` and cel-es's `%x`
+ * formatting for Uint8Array.
+ */
+function toHex(bytes: Uint8Array): string {
+ let out = "";
+ for (let i = 0; i < bytes.length; i++) {
+ out += (bytes[i] as number).toString(16).padStart(2, "0");
+ }
+ return out;
+}
+
+/**
+ * Default regex test using the platform `RegExp` engine. Used when no
+ * `regexMatch` override is supplied. Phase 4 swaps this for the cel-es
+ * `re2` package.
+ */
+function defaultRegexTest(pattern: string): (against: string) => boolean {
+ const re = new RegExp(pattern);
+ return (against) => re.test(against);
+}
+
+/**
+ * Try to build a native evaluator for BytesRules. Returns `undefined` if no
+ * native handler applies (no fields set, unknown extensions, or an
+ * uncompilable pattern that we let CEL surface as a CompilationError).
+ */
+export function tryBuildNativeBytesRules(
+ rules: BytesRules,
+ rulePath: PathBuilder,
+ forMapKey: boolean,
+ regexMatch: RegexMatcher | undefined,
+): ScalarNativeResult | undefined {
+ if (rules.$unknown && rules.$unknown.length > 0) {
+ return undefined;
+ }
+
+ const handled = new Set();
+ const cfg: { -readonly [K in keyof BytesRulesConfig]: BytesRulesConfig[K] } =
+ { forMapKey };
+
+ if (isFieldSet(rules, F.const)) {
+ cfg.constRule = {
+ val: rules.const,
+ path: rulePath.clone().field(F.const).toPath(),
+ };
+ handled.add(F.const);
+ }
+
+ if (isFieldSet(rules, F.len)) {
+ cfg.exactLen = {
+ val: rules.len,
+ path: rulePath.clone().field(F.len).toPath(),
+ };
+ handled.add(F.len);
+ }
+
+ if (isFieldSet(rules, F.minLen)) {
+ cfg.minLen = {
+ val: rules.minLen,
+ path: rulePath.clone().field(F.minLen).toPath(),
+ };
+ handled.add(F.minLen);
+ }
+
+ if (isFieldSet(rules, F.maxLen)) {
+ cfg.maxLen = {
+ val: rules.maxLen,
+ path: rulePath.clone().field(F.maxLen).toPath(),
+ };
+ handled.add(F.maxLen);
+ }
+
+ if (isFieldSet(rules, F.pattern)) {
+ const src = rules.pattern;
+ let test: (against: string) => boolean;
+ try {
+ if (regexMatch) {
+ // Probe the user-supplied engine at plan time so an invalid pattern
+ // surfaces here, symmetric with the default engine's eager compile.
+ // Empty input is the contract-safe probe — a regex engine must be
+ // able to test any pattern against the empty string.
+ regexMatch(src, "");
+ test = (against) => regexMatch(src, against);
+ } else {
+ test = defaultRegexTest(src);
+ }
+ } catch {
+ // The pattern doesn't compile under the active engine. Fall through
+ // to CEL, whose own `matches()` call hits the same throw at eval
+ // time and surfaces it as a RuntimeError.
+ return undefined;
+ }
+ cfg.pattern = {
+ src,
+ test,
+ path: rulePath.clone().field(F.pattern).toPath(),
+ };
+ handled.add(F.pattern);
+ }
+
+ if (isFieldSet(rules, F.prefix)) {
+ cfg.prefix = {
+ val: rules.prefix,
+ path: rulePath.clone().field(F.prefix).toPath(),
+ };
+ handled.add(F.prefix);
+ }
+
+ if (isFieldSet(rules, F.suffix)) {
+ cfg.suffix = {
+ val: rules.suffix,
+ path: rulePath.clone().field(F.suffix).toPath(),
+ };
+ handled.add(F.suffix);
+ }
+
+ if (isFieldSet(rules, F.contains)) {
+ cfg.containsRule = {
+ val: rules.contains,
+ path: rulePath.clone().field(F.contains).toPath(),
+ };
+ handled.add(F.contains);
+ }
+
+ if (rules.in.length > 0) {
+ cfg.inRule = {
+ vals: rules.in,
+ path: rulePath.clone().field(F.in).toPath(),
+ };
+ handled.add(F.in);
+ }
+
+ // Note: `bytes.not_in`'s CEL expression doesn't include a `size() > 0`
+ // guard like `bytes.in` does, but `this in []` is always false in CEL,
+ // so an empty `not_in` list never fires. We treat both the same — skip
+ // when the list is empty.
+ if (rules.notIn.length > 0) {
+ cfg.notInRule = {
+ vals: rules.notIn,
+ path: rulePath.clone().field(F.notIn).toPath(),
+ };
+ handled.add(F.notIn);
+ }
+
+ // Well-known: at most one of ip/ipv4/ipv6/uuid is set (oneof). Claim the
+ // leaf field on isFieldSet regardless of value — explicit `ip: false` is
+ // a no-op rule, matching `repeated.unique: false` (`repeated.ts`) and
+ // `float.finite: false` (`numeric.ts`).
+ const wkCase = rules.wellKnown.case;
+ if (wkCase !== undefined) {
+ const desc = F[wkCase];
+ handled.add(desc);
+ if (rules.wellKnown.value) {
+ const spec = WELL_KNOWN[wkCase];
+ cfg.wellKnown = {
+ kind: wkCase,
+ validSizes: spec.validSizes,
+ msg: spec.msg,
+ emptyMsg: spec.emptyMsg,
+ path: rulePath.clone().field(desc).toPath(),
+ };
+ }
+ }
+
+ if (handled.size === 0) {
+ return undefined;
+ }
+
+ return {
+ eval: new EvalNativeBytesRules(cfg),
+ handledFields: handled,
+ };
+}
diff --git a/packages/protovalidate/src/native/dispatcher.ts b/packages/protovalidate/src/native/dispatcher.ts
index 041fed3..42b6544 100644
--- a/packages/protovalidate/src/native/dispatcher.ts
+++ b/packages/protovalidate/src/native/dispatcher.ts
@@ -16,40 +16,86 @@ import type { DescField } from "@bufbuild/protobuf";
import type {
PathBuilder,
ReflectMessageGet,
+ ScalarValue,
} from "@bufbuild/protobuf/reflect";
-import type { FieldRules } from "../gen/buf/validate/validate_pb.js";
+import type {
+ BoolRules,
+ BytesRules,
+ EnumRules,
+ FieldRules,
+ MapRules,
+ RepeatedRules,
+ StringRules,
+} from "../gen/buf/validate/validate_pb.js";
+import {
+ BoolRulesSchema,
+ BytesRulesSchema,
+ EnumRulesSchema,
+ MapRulesSchema,
+ RepeatedRulesSchema,
+ StringRulesSchema,
+} from "../gen/buf/validate/validate_pb.js";
import type { Eval } from "../eval.js";
import type { RegexMatcher } from "../func.js";
+import { tryBuildNativeBoolRules } from "./bool.js";
+import { tryBuildNativeBytesRules } from "./bytes.js";
+import { tryBuildNativeEnumRules } from "./enum.js";
+import { tryBuildNativeMapRules } from "./map.js";
+import { tryBuildNativeNumericRules } from "./numeric.js";
+import { tryBuildNativeRepeatedRules } from "./repeated.js";
+import { tryBuildNativeStringRules } from "./string.js";
+import { WrappedValueEval } from "./wrapper.js";
/**
- * Result of {@link tryBuildNative}.
+ * A successful native dispatch result. The planner skips CEL enrollment for
+ * the fields in `handledFields` and appends `eval` to the rule's `EvalMany`.
*
- * - "none": no native handler applies; the planner enrolls every set field in
- * the CEL evaluator as it does today.
- * - "partial" / "full": at least one field is handled natively. The planner
- * skips CEL enrollment for fields in `handledFields` and appends `eval` to
- * the rule's `EvalMany`. "full" indicates every set field on the rules
- * message was handled natively, so the trailing `EvalStandardRulesCel` will
- * be empty and pruned.
+ * `tryBuildNative` returns `undefined` to mean "no native handler applies" —
+ * the planner enrolls every set field in the CEL evaluator as before.
*/
-export type NativeDispatchResult =
- | { kind: "none" }
- | {
- kind: "partial" | "full";
- eval: Eval;
- handledFields: ReadonlySet;
- };
+export type NativeDispatchResult = {
+ eval: Eval;
+ handledFields: ReadonlySet;
+};
+
+/**
+ * Internal dispatch result used by the scalar/enum/bool per-rules-type
+ * builders. They produce a `Eval`; {@link tryBuildNative} either
+ * lifts it directly into `Eval` (the scalar case) or wraps
+ * it in a `WrappedValueEval` for WKT wrapper messages.
+ */
+export type ScalarNativeResult = {
+ eval: Eval;
+ handledFields: ReadonlySet;
+};
/**
* Inputs to the native rule dispatcher.
- *
- * Future phases add per-field-type evaluators here. Phase 0 wires the seam
- * but always returns `{ kind: "none" }`.
*/
export type NativeDispatchInput = {
rules: Exclude;
rulePath: PathBuilder;
forMapKey: boolean;
+ /**
+ * When the rules are being applied to a `google.protobuf.*Value` wrapper
+ * field, this is the descriptor of the wrapper's inner `value` field. The
+ * dispatcher wraps the native scalar evaluator in an unwrap adapter so the
+ * runtime can read the inner scalar before delegating. Undefined for
+ * direct scalar fields.
+ */
+ wrappedValueField: DescField | undefined;
+ /**
+ * For RepeatedRules dispatch (from `Planner.planList`), the list field
+ * descriptor. The repeated builder uses it to decide whether the `unique`
+ * rule is native-handleable for the element kind. Undefined for non-list
+ * call sites.
+ */
+ listField: (DescField & { fieldKind: "list" }) | undefined;
+ /**
+ * Regex matcher to use for rules that compile a pattern (bytes.pattern,
+ * string.pattern). When undefined, handlers use the same ECMAScript regex
+ * engine the CEL path falls back to. Phase 4 swaps the default to RE2.
+ */
regexMatch: RegexMatcher | undefined;
};
@@ -58,11 +104,104 @@ export type NativeDispatchInput = {
* return an `Eval` for the handled subset plus the set of rule fields that
* have been claimed (so the planner skips them on the CEL path).
*
- * Phase 0: stub. Returns `{ kind: "none" }` so the CEL path handles every
- * rule exactly as before.
+ * Returns `undefined` if no native handler applies.
*/
export function tryBuildNative(
- _input: NativeDispatchInput,
-): NativeDispatchResult {
- return { kind: "none" };
+ input: NativeDispatchInput,
+): NativeDispatchResult | undefined {
+ const {
+ rules,
+ rulePath,
+ forMapKey,
+ wrappedValueField,
+ listField,
+ regexMatch,
+ } = input;
+ switch (rules.$typeName) {
+ case BoolRulesSchema.typeName: {
+ const r = tryBuildNativeBoolRules(
+ rules as BoolRules,
+ rulePath,
+ forMapKey,
+ );
+ return liftScalar(r, wrappedValueField);
+ }
+ case StringRulesSchema.typeName: {
+ const r = tryBuildNativeStringRules(
+ rules as StringRules,
+ rulePath,
+ forMapKey,
+ regexMatch,
+ );
+ return liftScalar(r, wrappedValueField);
+ }
+ case BytesRulesSchema.typeName: {
+ const r = tryBuildNativeBytesRules(
+ rules as BytesRules,
+ rulePath,
+ forMapKey,
+ regexMatch,
+ );
+ return liftScalar(r, wrappedValueField);
+ }
+ case EnumRulesSchema.typeName: {
+ const r = tryBuildNativeEnumRules(
+ rules as EnumRules,
+ rulePath,
+ forMapKey,
+ );
+ return liftScalar(r, wrappedValueField);
+ }
+ case RepeatedRulesSchema.typeName: {
+ const r = tryBuildNativeRepeatedRules(
+ rules as RepeatedRules,
+ rulePath,
+ forMapKey,
+ listField,
+ );
+ if (r === undefined) return undefined;
+ // Eval is invariant in its parameter; ReflectList is a valid runtime
+ // ReflectMessageGet at this call site.
+ return {
+ eval: r.eval as unknown as Eval,
+ handledFields: r.handledFields,
+ };
+ }
+ case MapRulesSchema.typeName: {
+ const r = tryBuildNativeMapRules(rules as MapRules, rulePath);
+ if (r === undefined) return undefined;
+ // Eval is invariant; ReflectMap is a valid runtime ReflectMessageGet here.
+ return {
+ eval: r.eval as unknown as Eval,
+ handledFields: r.handledFields,
+ };
+ }
+ default: {
+ // Numeric rule types: int32/int64/uint32/uint64/sint32/sint64/
+ // fixed32/fixed64/sfixed32/sfixed64/float/double. Anything else
+ // (Duration, Timestamp, Any, FieldMask, custom) returns undefined.
+ const r = tryBuildNativeNumericRules(rules, rulePath, forMapKey);
+ return liftScalar(r, wrappedValueField);
+ }
+ }
+}
+
+function liftScalar(
+ result: ScalarNativeResult | undefined,
+ wrappedValueField: DescField | undefined,
+): NativeDispatchResult | undefined {
+ if (result === undefined) return undefined;
+ // Eval is invariant in its parameter; the cast is safe because every
+ // ScalarValue is also a valid ReflectMessageGet at runtime.
+ const lifted =
+ wrappedValueField === undefined
+ ? (result.eval as unknown as Eval)
+ : (new WrappedValueEval(
+ wrappedValueField,
+ result.eval,
+ ) as unknown as Eval);
+ return {
+ eval: lifted,
+ handledFields: result.handledFields,
+ };
}
diff --git a/packages/protovalidate/src/native/enum.test.ts b/packages/protovalidate/src/native/enum.test.ts
new file mode 100644
index 0000000..7dcdd48
--- /dev/null
+++ b/packages/protovalidate/src/native/enum.test.ts
@@ -0,0 +1,98 @@
+// Copyright 2024-2026 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 { suite, test } from "node:test";
+import { create, type DescMessage } from "@bufbuild/protobuf";
+import { compile as compileWithPreamble, diff } from "./testing.js";
+
+const COLOR_PREAMBLE = `
+ enum Color {
+ COLOR_UNSPECIFIED = 0;
+ COLOR_RED = 1;
+ COLOR_GREEN = 2;
+ COLOR_BLUE = 3;
+ }
+`;
+
+function compile(definition: string): DescMessage {
+ return compileWithPreamble(definition, { preamble: COLOR_PREAMBLE });
+}
+
+void suite("native enum rules", () => {
+ void test("enum.const passes and fails", () => {
+ const s = compile(
+ `message M { Color c = 1 [(buf.validate.field).enum.const = 1]; }`,
+ );
+ diff(s, create(s, { c: 1 }));
+ diff(s, create(s, { c: 2 }));
+ });
+
+ void test("enum.in passes and fails", () => {
+ const s = compile(
+ `message M { Color c = 1 [(buf.validate.field).enum = { in: [1, 3] }]; }`,
+ );
+ diff(s, create(s, { c: 1 }));
+ diff(s, create(s, { c: 3 }));
+ diff(s, create(s, { c: 2 }));
+ });
+
+ void test("enum.in with default-zero field violates", () => {
+ // Proto3 default enum value is 0. With `in: [1, 3]`, an unset field
+ // (which validates as 0) must produce a violation. Diff confirms native
+ // and CEL agree on this realistic scenario.
+ const s = compile(
+ `message M { Color c = 1 [(buf.validate.field).enum = { in: [1, 3] }]; }`,
+ );
+ diff(s, create(s, {})); // c defaults to 0
+ diff(s, create(s, { c: 0 })); // explicit zero
+ });
+
+ void test("enum.not_in passes and fails", () => {
+ const s = compile(
+ `message M { Color c = 1 [(buf.validate.field).enum = { not_in: [0] }]; }`,
+ );
+ diff(s, create(s, { c: 1 }));
+ diff(s, create(s, { c: 0 }));
+ });
+
+ void test("enum.const + in together both report violations", () => {
+ const s = compile(
+ `message M {
+ Color c = 1 [(buf.validate.field).enum = { const: 1, in: [1, 2] }];
+ }`,
+ );
+ diff(s, create(s, { c: 3 })); // violates const + in
+ diff(s, create(s, { c: 2 })); // violates const only
+ });
+
+ void test("enum.defined_only still works (handled by EvalEnumDefinedOnly)", () => {
+ const s = compile(
+ `message M { Color c = 1 [(buf.validate.field).enum.defined_only = true]; }`,
+ );
+ diff(s, create(s, { c: 1 }));
+ diff(s, create(s, { c: 99 })); // undefined
+ });
+
+ void test("enum.defined_only + const both fire when applicable", () => {
+ const s = compile(
+ `message M {
+ Color c = 1 [(buf.validate.field).enum = { defined_only: true, const: 1 }];
+ }`,
+ );
+ // Defined but not const
+ diff(s, create(s, { c: 2 }));
+ // Undefined: should fire defined_only and const
+ diff(s, create(s, { c: 99 }));
+ });
+});
diff --git a/packages/protovalidate/src/native/enum.ts b/packages/protovalidate/src/native/enum.ts
new file mode 100644
index 0000000..545df02
--- /dev/null
+++ b/packages/protovalidate/src/native/enum.ts
@@ -0,0 +1,144 @@
+// Copyright 2024-2026 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 { type DescField, isFieldSet } from "@bufbuild/protobuf";
+import type {
+ Path,
+ PathBuilder,
+ ScalarValue,
+} from "@bufbuild/protobuf/reflect";
+import type { Cursor } from "../cursor.js";
+import type { Eval } from "../eval.js";
+import {
+ type EnumRules,
+ EnumRulesSchema,
+} from "../gen/buf/validate/validate_pb.js";
+import type { ScalarNativeResult } from "./dispatcher.js";
+import { formatList } from "./format.js";
+
+const F = EnumRulesSchema.field;
+
+type ConstRule = { readonly val: number; readonly path: Path };
+type ListRule = { readonly vals: readonly number[]; readonly path: Path };
+
+/**
+ * Native evaluator for the `enum.const`, `enum.in`, and `enum.not_in` rules.
+ *
+ * `enum.defined_only` keeps its existing dedicated evaluator (`EvalEnumDefinedOnly`).
+ */
+class EvalNativeEnumRules implements Eval {
+ constructor(
+ private readonly forMapKey: boolean,
+ private readonly constRule: ConstRule | undefined,
+ private readonly inRule: ListRule | undefined,
+ private readonly notInRule: ListRule | undefined,
+ ) {}
+
+ eval(val: ScalarValue, cursor: Cursor): void {
+ const v = val as number;
+
+ if (this.constRule !== undefined && v !== this.constRule.val) {
+ cursor.violate(
+ `must equal ${this.constRule.val}`,
+ "enum.const",
+ this.constRule.path,
+ this.forMapKey,
+ );
+ }
+
+ if (this.inRule !== undefined && !contains(this.inRule.vals, v)) {
+ cursor.violate(
+ `must be in list ${formatList(this.inRule.vals, String)}`,
+ "enum.in",
+ this.inRule.path,
+ this.forMapKey,
+ );
+ }
+
+ if (this.notInRule !== undefined && contains(this.notInRule.vals, v)) {
+ cursor.violate(
+ `must not be in list ${formatList(this.notInRule.vals, String)}`,
+ "enum.not_in",
+ this.notInRule.path,
+ this.forMapKey,
+ );
+ }
+ }
+
+ prune(): boolean {
+ return false;
+ }
+}
+
+function contains(arr: readonly number[], v: number): boolean {
+ for (let i = 0; i < arr.length; i++) {
+ if (arr[i] === v) return true;
+ }
+ return false;
+}
+
+/**
+ * Try to build a native evaluator for EnumRules. Returns `undefined` if no
+ * native handler applies (no const/in/not_in set, or unknown extensions).
+ *
+ * `defined_only` is intentionally not handled here — `EvalEnumDefinedOnly`
+ * keeps that path.
+ */
+export function tryBuildNativeEnumRules(
+ rules: EnumRules,
+ rulePath: PathBuilder,
+ forMapKey: boolean,
+): ScalarNativeResult | undefined {
+ if (rules.$unknown && rules.$unknown.length > 0) {
+ return undefined;
+ }
+
+ const handled = new Set();
+
+ let constRule: ConstRule | undefined;
+ if (isFieldSet(rules, F.const)) {
+ constRule = {
+ val: rules.const,
+ path: rulePath.clone().field(F.const).toPath(),
+ };
+ handled.add(F.const);
+ }
+
+ let inRule: ListRule | undefined;
+ if (rules.in.length > 0) {
+ inRule = {
+ vals: rules.in,
+ path: rulePath.clone().field(F.in).toPath(),
+ };
+ handled.add(F.in);
+ }
+
+ let notInRule: ListRule | undefined;
+ if (rules.notIn.length > 0) {
+ notInRule = {
+ vals: rules.notIn,
+ path: rulePath.clone().field(F.notIn).toPath(),
+ };
+ handled.add(F.notIn);
+ }
+
+ if (handled.size === 0) {
+ return undefined;
+ }
+
+ return {
+ eval: new EvalNativeEnumRules(forMapKey, constRule, inRule, notInRule),
+ handledFields: handled,
+ };
+}
diff --git a/packages/protovalidate/src/native/format.test.ts b/packages/protovalidate/src/native/format.test.ts
index 4f71d6a..40c8f88 100644
--- a/packages/protovalidate/src/native/format.test.ts
+++ b/packages/protovalidate/src/native/format.test.ts
@@ -14,7 +14,7 @@
import { suite, test } from "node:test";
import * as assert from "node:assert/strict";
-import { codepointLength, printFloat } from "./format.js";
+import { codepointLength, printFloat, utf8ByteLength } from "./format.js";
void suite("codepointLength", () => {
void test("counts ASCII as one per char", () => {
@@ -44,6 +44,44 @@ void suite("codepointLength", () => {
});
});
+void suite("utf8ByteLength", () => {
+ void test("counts ASCII as one byte per char", () => {
+ assert.strictEqual(utf8ByteLength(""), 0);
+ assert.strictEqual(utf8ByteLength("a"), 1);
+ assert.strictEqual(utf8ByteLength("abc"), 3);
+ });
+
+ void test("counts 2-, 3-, and 4-byte sequences", () => {
+ assert.strictEqual(utf8ByteLength("é"), 2); // U+00E9
+ assert.strictEqual(utf8ByteLength("€"), 3); // U+20AC
+ assert.strictEqual(utf8ByteLength("𝑎"), 4); // U+1D44E, surrogate pair
+ });
+
+ void test("counts unpaired surrogates as U+FFFD (3 bytes)", () => {
+ assert.strictEqual(utf8ByteLength("\ud800"), 3); // lone high surrogate
+ assert.strictEqual(utf8ByteLength("\udc00"), 3); // lone low surrogate
+ assert.strictEqual(utf8ByteLength("\ud800x"), 4); // high surrogate + ASCII
+ });
+
+ void test("matches TextEncoder for mixed inputs", () => {
+ const cases = [
+ "",
+ "x",
+ "𝑎b",
+ "🇺🇸",
+ "héllo",
+ "߿ࠀ",
+ "\ud800",
+ "\udc00😀",
+ "a𐀀b",
+ ];
+ const encoder = new TextEncoder();
+ for (const s of cases) {
+ assert.strictEqual(utf8ByteLength(s), encoder.encode(s).length);
+ }
+ });
+});
+
void suite("printFloat", () => {
void test("formats finite numbers via toString", () => {
assert.strictEqual(printFloat(0), "0");
diff --git a/packages/protovalidate/src/native/format.ts b/packages/protovalidate/src/native/format.ts
index 370407b..18c24cb 100644
--- a/packages/protovalidate/src/native/format.ts
+++ b/packages/protovalidate/src/native/format.ts
@@ -28,10 +28,53 @@ export function codepointLength(s: string): number {
}
/**
- * Format a number for inclusion in a violation message.
+ * Number of bytes in the UTF-8 encoding of a string.
*
- * Mirrors protovalidate-go's `printFloat` so error messages match the CEL
- * implementation byte-for-byte.
+ * Matches CEL's `bytes(string).size()` semantics — i.e. what
+ * `new TextEncoder().encode(s).length` returns, including the replacement of
+ * unpaired surrogates with U+FFFD (3 bytes) — without allocating the encoded
+ * buffer.
+ */
+export function utf8ByteLength(s: string): number {
+ let n = 0;
+ for (let i = 0; i < s.length; i++) {
+ const c = s.charCodeAt(i);
+ if (c < 0x80) {
+ n += 1;
+ } else if (c < 0x800) {
+ n += 2;
+ } else if (c >= 0xd800 && c < 0xdc00 && i + 1 < s.length) {
+ const d = s.charCodeAt(i + 1);
+ if (d >= 0xdc00 && d < 0xe000) {
+ // Surrogate pair: one code point above U+FFFF, 4 bytes.
+ n += 4;
+ i++;
+ } else {
+ // Unpaired high surrogate: encoded as U+FFFD, 3 bytes.
+ n += 3;
+ }
+ } else {
+ // BMP code point at U+0800 and above, or an unpaired surrogate
+ // (encoded as U+FFFD) — 3 bytes either way.
+ n += 3;
+ }
+ }
+ return n;
+}
+
+/**
+ * Format a finite double for inclusion in a violation message.
+ *
+ * Matches what `@bufbuild/cel`'s `%s` formatter produces for a `number` — i.e.
+ * `Number.prototype.toString()` — so the native and CEL evaluators emit
+ * byte-identical messages today.
+ *
+ * NOTE: This diverges from protovalidate-go, which uses
+ * `strconv.FormatFloat(v, 'f', -1, 64)` (always fixed-point). For values in
+ * the JS scientific-notation zone (outside `[1e-7, 1e21)`) the TS impls
+ * produce `1e+21` where Go produces `1000000000000000000000`. Both cel-es and
+ * this helper need to change together to close the gap; do not "fix" one in
+ * isolation. See protovalidate-es plan and the cel-es `formatFloating` impl.
*/
export function printFloat(n: number): string {
if (Number.isNaN(n)) {
@@ -45,3 +88,18 @@ export function printFloat(n: number): string {
}
return n.toString();
}
+
+/**
+ * Format a list of values as `"[v1, v2, ...]"` for inclusion in a violation
+ * message. The per-element formatter is supplied by the caller so each rule
+ * family controls its own element formatting (e.g. numeric uses
+ * `config.format`, enum uses `String`).
+ */
+export function formatList(vs: readonly T[], fmt: (v: T) => string): string {
+ let out = "[";
+ for (let i = 0; i < vs.length; i++) {
+ if (i > 0) out += ", ";
+ out += fmt(vs[i] as T);
+ }
+ return `${out}]`;
+}
diff --git a/packages/protovalidate/src/native/map.test.ts b/packages/protovalidate/src/native/map.test.ts
new file mode 100644
index 0000000..2180496
--- /dev/null
+++ b/packages/protovalidate/src/native/map.test.ts
@@ -0,0 +1,98 @@
+// Copyright 2024-2026 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 { suite, test } from "node:test";
+import * as assert from "node:assert/strict";
+import { create } from "@bufbuild/protobuf";
+import { pathToString } from "@bufbuild/protobuf/reflect";
+import { compile, diff, native } from "./testing.js";
+
+void suite("native map rules", () => {
+ void test("map.min_pairs passes and fails", () => {
+ const s = compile(
+ `message M {
+ map kv = 1 [(buf.validate.field).map.min_pairs = 2];
+ }`,
+ );
+ diff(s, create(s, { kv: { a: 1, b: 2 } }));
+ diff(s, create(s, { kv: { a: 1 } }));
+ diff(s, create(s, { kv: {} }));
+ });
+
+ void test("map.max_pairs passes and fails", () => {
+ const s = compile(
+ `message M {
+ map kv = 1 [(buf.validate.field).map.max_pairs = 2];
+ }`,
+ );
+ diff(s, create(s, { kv: { a: 1, b: 2 } }));
+ diff(s, create(s, { kv: { a: 1, b: 2, c: 3 } }));
+ });
+
+ void test("min + max together", () => {
+ const s = compile(
+ `message M {
+ map kv = 1 [(buf.validate.field).map = {
+ min_pairs: 1, max_pairs: 3
+ }];
+ }`,
+ );
+ diff(s, create(s, { kv: { a: 1 } }));
+ diff(s, create(s, { kv: { a: 1, b: 2, c: 3 } }));
+ diff(s, create(s, { kv: {} }));
+ diff(s, create(s, { kv: { a: 1, b: 2, c: 3, d: 4 } }));
+ });
+
+ void test("map size rules with key/value rules together", () => {
+ const s = compile(
+ `message M {
+ map kv = 1 [(buf.validate.field).map = {
+ min_pairs: 2,
+ values: { int32: { gt: 0 } }
+ }];
+ }`,
+ );
+ diff(s, create(s, { kv: { a: 1, b: 2 } }));
+ diff(s, create(s, { kv: { a: 1, b: -1 } })); // min ok, value bad
+ diff(s, create(s, { kv: { a: -1 } })); // both bad
+ });
+
+ void test("rule path lands at map.min_pairs", () => {
+ const s = compile(
+ `message M {
+ map kv = 1 [(buf.validate.field).map.min_pairs = 2];
+ }`,
+ );
+ const r = native.validate(s, create(s, { kv: { a: 1 } }));
+ assert.equal(r.kind, "invalid");
+ const v = r.violations?.[0];
+ assert.ok(v);
+ assert.equal(v.ruleId, "map.min_pairs");
+ assert.equal(pathToString(v.rule), "map.min_pairs");
+ });
+
+ void test("rule path lands at map.max_pairs", () => {
+ const s = compile(
+ `message M {
+ map kv = 1 [(buf.validate.field).map.max_pairs = 1];
+ }`,
+ );
+ const r = native.validate(s, create(s, { kv: { a: 1, b: 2 } }));
+ assert.equal(r.kind, "invalid");
+ const v = r.violations?.[0];
+ assert.ok(v);
+ assert.equal(v.ruleId, "map.max_pairs");
+ assert.equal(pathToString(v.rule), "map.max_pairs");
+ });
+});
diff --git a/packages/protovalidate/src/native/map.ts b/packages/protovalidate/src/native/map.ts
new file mode 100644
index 0000000..df4a945
--- /dev/null
+++ b/packages/protovalidate/src/native/map.ts
@@ -0,0 +1,109 @@
+// Copyright 2024-2026 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 { type DescField, isFieldSet } from "@bufbuild/protobuf";
+import type { Path, PathBuilder, ReflectMap } from "@bufbuild/protobuf/reflect";
+import type { Cursor } from "../cursor.js";
+import type { Eval } from "../eval.js";
+import {
+ type MapRules,
+ MapRulesSchema,
+} from "../gen/buf/validate/validate_pb.js";
+
+const F = MapRulesSchema.field;
+
+/**
+ * Internal dispatch result for map-shaped native handlers.
+ *
+ * @internal
+ */
+export type MapNativeResult = {
+ eval: Eval;
+ handledFields: ReadonlySet;
+};
+
+type SizeRule = { readonly val: bigint; readonly path: Path };
+
+class EvalNativeMapRules implements Eval {
+ constructor(
+ private readonly minPairsRule: SizeRule | undefined,
+ private readonly maxPairsRule: SizeRule | undefined,
+ ) {}
+
+ eval(val: ReflectMap, cursor: Cursor): void {
+ const size = BigInt(val.size);
+
+ if (this.minPairsRule !== undefined && size < this.minPairsRule.val) {
+ cursor.violate(
+ `map must be at least ${this.minPairsRule.val} entries`,
+ "map.min_pairs",
+ this.minPairsRule.path,
+ );
+ }
+
+ if (this.maxPairsRule !== undefined && size > this.maxPairsRule.val) {
+ cursor.violate(
+ `map must be at most ${this.maxPairsRule.val} entries`,
+ "map.max_pairs",
+ this.maxPairsRule.path,
+ );
+ }
+ }
+
+ prune(): boolean {
+ return false;
+ }
+}
+
+/**
+ * Try to build a native evaluator for MapRules (min_pairs, max_pairs).
+ * Returns `undefined` if no native handler applies.
+ */
+export function tryBuildNativeMapRules(
+ rules: MapRules,
+ rulePath: PathBuilder,
+): MapNativeResult | undefined {
+ if (rules.$unknown && rules.$unknown.length > 0) {
+ return undefined;
+ }
+
+ const handled = new Set();
+
+ let minPairsRule: SizeRule | undefined;
+ if (isFieldSet(rules, F.minPairs)) {
+ minPairsRule = {
+ val: rules.minPairs,
+ path: rulePath.clone().field(F.minPairs).toPath(),
+ };
+ handled.add(F.minPairs);
+ }
+
+ let maxPairsRule: SizeRule | undefined;
+ if (isFieldSet(rules, F.maxPairs)) {
+ maxPairsRule = {
+ val: rules.maxPairs,
+ path: rulePath.clone().field(F.maxPairs).toPath(),
+ };
+ handled.add(F.maxPairs);
+ }
+
+ if (handled.size === 0) {
+ return undefined;
+ }
+
+ return {
+ eval: new EvalNativeMapRules(minPairsRule, maxPairsRule),
+ handledFields: handled,
+ };
+}
diff --git a/packages/protovalidate/src/native/numeric.test.ts b/packages/protovalidate/src/native/numeric.test.ts
new file mode 100644
index 0000000..062e4af
--- /dev/null
+++ b/packages/protovalidate/src/native/numeric.test.ts
@@ -0,0 +1,354 @@
+// Copyright 2024-2026 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 { suite, test } from "node:test";
+import { create } from "@bufbuild/protobuf";
+import { compile, diff } from "./testing.js";
+
+void suite("native numeric rules", () => {
+ void suite("int32", () => {
+ void test("const passes and fails", () => {
+ const s = compile(
+ `message M { int32 n = 1 [(buf.validate.field).int32.const = 42]; }`,
+ );
+ diff(s, create(s, { n: 42 }));
+ diff(s, create(s, { n: 41 }));
+ });
+
+ void test("gt passes and fails", () => {
+ const s = compile(
+ `message M { int32 n = 1 [(buf.validate.field).int32.gt = 5]; }`,
+ );
+ diff(s, create(s, { n: 6 }));
+ diff(s, create(s, { n: 5 }));
+ diff(s, create(s, { n: 4 }));
+ });
+
+ void test("gte boundary", () => {
+ const s = compile(
+ `message M { int32 n = 1 [(buf.validate.field).int32.gte = 5]; }`,
+ );
+ diff(s, create(s, { n: 5 }));
+ diff(s, create(s, { n: 4 }));
+ });
+
+ void test("lt and lte", () => {
+ const lt = compile(
+ `message M { int32 n = 1 [(buf.validate.field).int32.lt = 10]; }`,
+ );
+ diff(lt, create(lt, { n: 9 }));
+ diff(lt, create(lt, { n: 10 }));
+ const lte = compile(
+ `message M { int32 n = 1 [(buf.validate.field).int32.lte = 10]; }`,
+ );
+ diff(lte, create(lte, { n: 10 }));
+ diff(lte, create(lte, { n: 11 }));
+ });
+
+ void test("gt + lt normal range", () => {
+ const s = compile(
+ `message M { int32 n = 1 [(buf.validate.field).int32 = { gt: 0, lt: 10 }]; }`,
+ );
+ diff(s, create(s, { n: 5 }));
+ diff(s, create(s, { n: 0 }));
+ diff(s, create(s, { n: 10 }));
+ diff(s, create(s, { n: -1 }));
+ });
+
+ void test("gt + lt exclusive range (lt < gt)", () => {
+ const s = compile(
+ `message M { int32 n = 1 [(buf.validate.field).int32 = { gt: 10, lt: 5 }]; }`,
+ );
+ // Inside the gap [5..10] is the rejected zone for exclusive ranges
+ diff(s, create(s, { n: 7 }));
+ // Outside: passes
+ diff(s, create(s, { n: 4 }));
+ diff(s, create(s, { n: 11 }));
+ });
+
+ void test("gte + lte normal range", () => {
+ const s = compile(
+ `message M { int32 n = 1 [(buf.validate.field).int32 = { gte: 1, lte: 3 }]; }`,
+ );
+ diff(s, create(s, { n: 0 }));
+ diff(s, create(s, { n: 1 }));
+ diff(s, create(s, { n: 4 }));
+ });
+
+ void test("in and not_in", () => {
+ const s = compile(
+ `message M { int32 n = 1 [(buf.validate.field).int32 = { in: [1, 2, 3], not_in: [4, 5] }]; }`,
+ );
+ diff(s, create(s, { n: 2 }));
+ diff(s, create(s, { n: 4 }));
+ diff(s, create(s, { n: 9 }));
+ });
+ });
+
+ void suite("uint32 / sint32 / fixed32 / sfixed32", () => {
+ void test("uint32.gt", () => {
+ const s = compile(
+ `message M { uint32 n = 1 [(buf.validate.field).uint32.gt = 5]; }`,
+ );
+ diff(s, create(s, { n: 6 }));
+ diff(s, create(s, { n: 5 }));
+ });
+ void test("sint32.const", () => {
+ const s = compile(
+ `message M { sint32 n = 1 [(buf.validate.field).sint32.const = -7]; }`,
+ );
+ diff(s, create(s, { n: -7 }));
+ diff(s, create(s, { n: -8 }));
+ });
+ void test("fixed32.lte", () => {
+ const s = compile(
+ `message M { fixed32 n = 1 [(buf.validate.field).fixed32.lte = 100]; }`,
+ );
+ diff(s, create(s, { n: 100 }));
+ diff(s, create(s, { n: 101 }));
+ });
+ void test("sfixed32.in", () => {
+ const s = compile(
+ `message M { sfixed32 n = 1 [(buf.validate.field).sfixed32 = { in: [-1, 0, 1] }]; }`,
+ );
+ diff(s, create(s, { n: 0 }));
+ diff(s, create(s, { n: 2 }));
+ });
+ });
+
+ void suite("int64 / uint64 / sint64 / fixed64 / sfixed64 (bigint)", () => {
+ void test("int64.gt", () => {
+ const s = compile(
+ `message M { int64 n = 1 [(buf.validate.field).int64.gt = 5]; }`,
+ );
+ diff(s, create(s, { n: 6n }));
+ diff(s, create(s, { n: 5n }));
+ });
+ void test("uint64.const", () => {
+ const s = compile(
+ `message M { uint64 n = 1 [(buf.validate.field).uint64.const = 42]; }`,
+ );
+ diff(s, create(s, { n: 42n }));
+ diff(s, create(s, { n: 41n }));
+ });
+ void test("sint64.lt", () => {
+ const s = compile(
+ `message M { sint64 n = 1 [(buf.validate.field).sint64.lt = 0]; }`,
+ );
+ diff(s, create(s, { n: -1n }));
+ diff(s, create(s, { n: 0n }));
+ });
+ void test("fixed64.not_in", () => {
+ const s = compile(
+ `message M { fixed64 n = 1 [(buf.validate.field).fixed64 = { not_in: [1, 2] }]; }`,
+ );
+ diff(s, create(s, { n: 3n }));
+ diff(s, create(s, { n: 1n }));
+ });
+ void test("sfixed64 range", () => {
+ const s = compile(
+ `message M { sfixed64 n = 1 [(buf.validate.field).sfixed64 = { gt: -10, lt: 10 }]; }`,
+ );
+ diff(s, create(s, { n: 0n }));
+ diff(s, create(s, { n: -10n }));
+ diff(s, create(s, { n: 10n }));
+ });
+ });
+
+ void suite("float / double", () => {
+ void test("float.const", () => {
+ const s = compile(
+ `message M { float x = 1 [(buf.validate.field).float.const = 1.5]; }`,
+ );
+ diff(s, create(s, { x: 1.5 }));
+ diff(s, create(s, { x: 1.25 }));
+ });
+ void test("double.gt", () => {
+ const s = compile(
+ `message M { double x = 1 [(buf.validate.field).double.gt = 0.5]; }`,
+ );
+ diff(s, create(s, { x: 1 }));
+ diff(s, create(s, { x: 0.5 }));
+ });
+ void test("float.finite passes finite", () => {
+ const s = compile(
+ `message M { float x = 1 [(buf.validate.field).float.finite = true]; }`,
+ );
+ diff(s, create(s, { x: 1.5 }));
+ });
+ void test("float.finite fails NaN", () => {
+ const s = compile(
+ `message M { float x = 1 [(buf.validate.field).float.finite = true]; }`,
+ );
+ diff(s, create(s, { x: Number.NaN }));
+ });
+ void test("float.finite fails Infinity", () => {
+ const s = compile(
+ `message M { float x = 1 [(buf.validate.field).float.finite = true]; }`,
+ );
+ diff(s, create(s, { x: Number.POSITIVE_INFINITY }));
+ diff(s, create(s, { x: Number.NEGATIVE_INFINITY }));
+ });
+ void test("double.lte NaN fails range (nanFailsRange)", () => {
+ const s = compile(
+ `message M { double x = 1 [(buf.validate.field).double.lte = 10]; }`,
+ );
+ diff(s, create(s, { x: Number.NaN }));
+ });
+ void test("double range NaN fails", () => {
+ const s = compile(
+ `message M { double x = 1 [(buf.validate.field).double = { gt: 0, lt: 10 }]; }`,
+ );
+ diff(s, create(s, { x: Number.NaN }));
+ });
+ });
+
+ void suite("wrapper types", () => {
+ void test("Int32Value with int32.gte", () => {
+ const s = compile(
+ `message M {
+ google.protobuf.Int32Value n = 1 [(buf.validate.field).int32.gte = 5];
+ }`,
+ );
+ diff(s, create(s, { n: 5 }));
+ diff(s, create(s, { n: 4 }));
+ });
+ void test("Int64Value with int64.const", () => {
+ const s = compile(
+ `message M {
+ google.protobuf.Int64Value n = 1 [(buf.validate.field).int64.const = 42];
+ }`,
+ );
+ diff(s, create(s, { n: 42n }));
+ diff(s, create(s, { n: 41n }));
+ });
+ void test("UInt32Value with uint32.lt", () => {
+ const s = compile(
+ `message M {
+ google.protobuf.UInt32Value n = 1 [(buf.validate.field).uint32.lt = 100];
+ }`,
+ );
+ diff(s, create(s, { n: 50 }));
+ diff(s, create(s, { n: 100 }));
+ });
+ void test("UInt64Value with uint64.in", () => {
+ const s = compile(
+ `message M {
+ google.protobuf.UInt64Value n = 1 [(buf.validate.field).uint64 = { in: [1, 2, 3] }];
+ }`,
+ );
+ diff(s, create(s, { n: 2n }));
+ diff(s, create(s, { n: 4n }));
+ });
+ void test("FloatValue with float.finite", () => {
+ const s = compile(
+ `message M {
+ google.protobuf.FloatValue n = 1 [(buf.validate.field).float.finite = true];
+ }`,
+ );
+ diff(s, create(s, { n: 1.5 }));
+ diff(s, create(s, { n: Number.NaN }));
+ });
+ void test("DoubleValue with double range", () => {
+ const s = compile(
+ `message M {
+ google.protobuf.DoubleValue n = 1 [(buf.validate.field).double = { gte: 0, lte: 1 }];
+ }`,
+ );
+ diff(s, create(s, { n: 0.5 }));
+ diff(s, create(s, { n: 1.5 }));
+ });
+ });
+
+ void suite("repeated scalar items", () => {
+ void test("repeated int32 with item rules", () => {
+ const s = compile(
+ `message M {
+ repeated int32 n = 1 [(buf.validate.field).repeated.items.int32.gt = 0];
+ }`,
+ );
+ diff(s, create(s, { n: [1, 2, 3] }));
+ diff(s, create(s, { n: [1, 0, 3] }));
+ diff(s, create(s, { n: [-1, -2] }));
+ });
+ });
+
+ void suite("fallthrough cases", () => {
+ void test("NaN gt bound on float falls through to CEL with same error", () => {
+ // Compile-time CEL would normally throw. To exercise the fallthrough path
+ // we'd need a NaN-bound rule message — protovalidate-conformance covers
+ // this; here we just confirm the native+CEL paths produce the same
+ // result on a non-NaN rule with NaN input value.
+ const s = compile(
+ `message M { float x = 1 [(buf.validate.field).float.gt = 0]; }`,
+ );
+ diff(s, create(s, { x: Number.NaN }));
+ });
+ });
+
+ void suite("review gap coverage", () => {
+ void test("T1: NaN value with float.in list", () => {
+ // NaN is never === to any list element, so the violation must fire.
+ const s = compile(
+ `message M { float x = 1 [(buf.validate.field).float = { in: [1.0, 2.0] }]; }`,
+ );
+ diff(s, create(s, { x: Number.NaN }));
+ });
+
+ void test("T2: const and range together both report violations", () => {
+ const s = compile(
+ `message M {
+ int32 n = 1 [(buf.validate.field).int32 = { const: 5, gt: 3, lt: 100 }];
+ }`,
+ );
+ // 4 satisfies the range (gt 3, lt 100) but violates const = 5.
+ diff(s, create(s, { n: 4 }));
+ // 200 violates both const and the range.
+ diff(s, create(s, { n: 200 }));
+ });
+
+ void test("T3: BoolValue wrapper unset on parent", () => {
+ const s = compile(
+ `message M {
+ google.protobuf.BoolValue b = 1 [(buf.validate.field).bool.const = true];
+ }`,
+ );
+ // Wrapper field absent — EvalField's presence check skips validation.
+ diff(s, create(s, {}));
+ });
+
+ void test("T4: int64 max-boundary values", () => {
+ // 9_223_372_036_854_775_807 is max int64. Validate const at and around it.
+ const s = compile(
+ `message M {
+ int64 n = 1 [(buf.validate.field).int64.const = 9223372036854775807];
+ }`,
+ );
+ diff(s, create(s, { n: 9223372036854775807n }));
+ diff(s, create(s, { n: 9223372036854775806n }));
+ });
+
+ void test("T5: explicit float.finite=false claims the field but emits nothing", () => {
+ const s = compile(
+ `message M {
+ float x = 1 [(buf.validate.field).float.finite = false];
+ }`,
+ );
+ // finite=false means "no constraint" — every value passes, even NaN.
+ diff(s, create(s, { x: Number.NaN }));
+ diff(s, create(s, { x: Number.POSITIVE_INFINITY }));
+ diff(s, create(s, { x: 1.5 }));
+ });
+ });
+});
diff --git a/packages/protovalidate/src/native/numeric.ts b/packages/protovalidate/src/native/numeric.ts
new file mode 100644
index 0000000..add2260
--- /dev/null
+++ b/packages/protovalidate/src/native/numeric.ts
@@ -0,0 +1,524 @@
+// Copyright 2024-2026 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 { type DescField, isFieldSet, type Message } from "@bufbuild/protobuf";
+import type {
+ Path,
+ PathBuilder,
+ ScalarValue,
+} from "@bufbuild/protobuf/reflect";
+import type { Cursor } from "../cursor.js";
+import type { Eval } from "../eval.js";
+import {
+ DoubleRulesSchema,
+ Fixed32RulesSchema,
+ Fixed64RulesSchema,
+ FloatRulesSchema,
+ Int32RulesSchema,
+ Int64RulesSchema,
+ SFixed32RulesSchema,
+ SFixed64RulesSchema,
+ SInt32RulesSchema,
+ SInt64RulesSchema,
+ UInt32RulesSchema,
+ UInt64RulesSchema,
+} from "../gen/buf/validate/validate_pb.js";
+import type { ScalarNativeResult } from "./dispatcher.js";
+import { formatList, printFloat } from "./format.js";
+
+type NumericRulesDescs = {
+ readonly const: DescField;
+ readonly gt: DescField;
+ readonly gte: DescField;
+ readonly lt: DescField;
+ readonly lte: DescField;
+ readonly in: DescField;
+ readonly notIn: DescField;
+ /** Only present on FloatRulesSchema and DoubleRulesSchema. */
+ readonly finite?: DescField;
+};
+
+/**
+ * Per-scalar configuration for the numeric native evaluator.
+ *
+ * `T` is `number` for 32-bit ints + float/double, `bigint` for 64-bit ints.
+ */
+type NumericConfig = {
+ readonly typeName: string;
+ readonly descs: NumericRulesDescs;
+ /** Format a value into the user-facing error string. */
+ readonly format: (v: T) => string;
+ /** True only for float/double: a NaN field value fails every range check. */
+ readonly nanFailsRange: boolean;
+};
+
+const stringFormat = (v: number | bigint): string => v.toString();
+const floatFormat = (v: number): string => printFloat(v);
+
+const int32Config: NumericConfig = {
+ typeName: "int32",
+ descs: Int32RulesSchema.field,
+ format: stringFormat,
+ nanFailsRange: false,
+};
+const int64Config: NumericConfig = {
+ typeName: "int64",
+ descs: Int64RulesSchema.field,
+ format: stringFormat,
+ nanFailsRange: false,
+};
+const uint32Config: NumericConfig = {
+ typeName: "uint32",
+ descs: UInt32RulesSchema.field,
+ format: stringFormat,
+ nanFailsRange: false,
+};
+const uint64Config: NumericConfig = {
+ typeName: "uint64",
+ descs: UInt64RulesSchema.field,
+ format: stringFormat,
+ nanFailsRange: false,
+};
+const sint32Config: NumericConfig = {
+ typeName: "sint32",
+ descs: SInt32RulesSchema.field,
+ format: stringFormat,
+ nanFailsRange: false,
+};
+const sint64Config: NumericConfig = {
+ typeName: "sint64",
+ descs: SInt64RulesSchema.field,
+ format: stringFormat,
+ nanFailsRange: false,
+};
+const fixed32Config: NumericConfig = {
+ typeName: "fixed32",
+ descs: Fixed32RulesSchema.field,
+ format: stringFormat,
+ nanFailsRange: false,
+};
+const fixed64Config: NumericConfig = {
+ typeName: "fixed64",
+ descs: Fixed64RulesSchema.field,
+ format: stringFormat,
+ nanFailsRange: false,
+};
+const sfixed32Config: NumericConfig = {
+ typeName: "sfixed32",
+ descs: SFixed32RulesSchema.field,
+ format: stringFormat,
+ nanFailsRange: false,
+};
+const sfixed64Config: NumericConfig = {
+ typeName: "sfixed64",
+ descs: SFixed64RulesSchema.field,
+ format: stringFormat,
+ nanFailsRange: false,
+};
+const floatConfig: NumericConfig = {
+ typeName: "float",
+ descs: FloatRulesSchema.field,
+ format: floatFormat,
+ nanFailsRange: true,
+};
+const doubleConfig: NumericConfig = {
+ typeName: "double",
+ descs: DoubleRulesSchema.field,
+ format: floatFormat,
+ nanFailsRange: true,
+};
+
+/**
+ * The shape all 12 numeric rules messages share (after accounting for the
+ * `greater_than` / `less_than` oneofs).
+ */
+type NumericRulesShape = Message & {
+ const: T;
+ in: readonly T[];
+ notIn: readonly T[];
+ greaterThan:
+ | { case: "gt"; value: T }
+ | { case: "gte"; value: T }
+ | { case: undefined; value?: undefined };
+ lessThan:
+ | { case: "lt"; value: T }
+ | { case: "lte"; value: T }
+ | { case: undefined; value?: undefined };
+};
+
+/** Float and Double additionally carry the `finite` flag. */
+type NumericRulesWithFinite = NumericRulesShape & {
+ finite: boolean;
+};
+
+type ConstRule = { readonly val: T; readonly path: Path };
+type ListRule = { readonly vals: readonly T[]; readonly path: Path };
+type LowerRule = {
+ readonly kind: "gt" | "gte";
+ readonly val: T;
+ readonly path: Path;
+};
+type UpperRule = {
+ readonly kind: "lt" | "lte";
+ readonly val: T;
+ readonly path: Path;
+};
+
+class EvalNativeNumericRules
+ implements Eval
+{
+ constructor(
+ private readonly config: NumericConfig,
+ private readonly forMapKey: boolean,
+ private readonly constRule: ConstRule | undefined,
+ private readonly inRule: ListRule | undefined,
+ private readonly notInRule: ListRule | undefined,
+ private readonly lowerRule: LowerRule | undefined,
+ private readonly upperRule: UpperRule | undefined,
+ private readonly finitePath: Path | undefined,
+ ) {}
+
+ eval(val: ScalarValue, cursor: Cursor): void {
+ const v = val as T;
+
+ if (this.constRule !== undefined && v !== this.constRule.val) {
+ cursor.violate(
+ `must equal ${this.config.format(this.constRule.val)}`,
+ `${this.config.typeName}.const`,
+ this.constRule.path,
+ this.forMapKey,
+ );
+ }
+
+ this.evalRange(v, cursor);
+
+ if (this.inRule !== undefined && !includesT(this.inRule.vals, v)) {
+ cursor.violate(
+ `must be in list ${formatList(this.inRule.vals, this.config.format)}`,
+ `${this.config.typeName}.in`,
+ this.inRule.path,
+ this.forMapKey,
+ );
+ }
+
+ if (this.notInRule !== undefined && includesT(this.notInRule.vals, v)) {
+ cursor.violate(
+ `must not be in list ${formatList(this.notInRule.vals, this.config.format)}`,
+ `${this.config.typeName}.not_in`,
+ this.notInRule.path,
+ this.forMapKey,
+ );
+ }
+
+ if (
+ this.finitePath !== undefined &&
+ typeof v === "number" &&
+ !Number.isFinite(v)
+ ) {
+ cursor.violate(
+ "must be finite",
+ `${this.config.typeName}.finite`,
+ this.finitePath,
+ this.forMapKey,
+ );
+ }
+ }
+
+ prune(): boolean {
+ return false;
+ }
+
+ private evalRange(v: T, cursor: Cursor): void {
+ const { lowerRule: lo, upperRule: hi } = this;
+ if (lo === undefined && hi === undefined) {
+ return;
+ }
+ const isNaNVal =
+ this.config.nanFailsRange && typeof v === "number" && Number.isNaN(v);
+
+ if (lo !== undefined && hi !== undefined) {
+ const isNormal = hi.val >= lo.val;
+ const fail = isNormal
+ ? isNaNVal || aboveHi(v, hi) || belowLo(v, lo)
+ : isNaNVal || (aboveHi(v, hi) && belowLo(v, lo));
+ if (fail) {
+ const suffix = isNormal ? "" : "_exclusive";
+ cursor.violate(
+ `must be ${loMessage(lo, this.config)} ${
+ isNormal ? "and" : "or"
+ } ${hiMessage(hi, this.config)}`,
+ `${this.config.typeName}.${lo.kind}_${hi.kind}${suffix}`,
+ lo.path,
+ this.forMapKey,
+ );
+ }
+ return;
+ }
+ if (lo !== undefined) {
+ if (isNaNVal || belowLo(v, lo)) {
+ cursor.violate(
+ `must be ${loMessage(lo, this.config)}`,
+ `${this.config.typeName}.${lo.kind}`,
+ lo.path,
+ this.forMapKey,
+ );
+ }
+ return;
+ }
+ // hi must be defined since we returned early when both are undefined.
+ if (hi !== undefined && (isNaNVal || aboveHi(v, hi))) {
+ cursor.violate(
+ `must be ${hiMessage(hi, this.config)}`,
+ `${this.config.typeName}.${hi.kind}`,
+ hi.path,
+ this.forMapKey,
+ );
+ }
+ }
+}
+
+function belowLo(v: T, lo: LowerRule): boolean {
+ return lo.kind === "gt" ? v <= lo.val : v < lo.val;
+}
+
+function aboveHi(v: T, hi: UpperRule): boolean {
+ return hi.kind === "lt" ? v >= hi.val : v > hi.val;
+}
+
+function loMessage(
+ lo: LowerRule,
+ config: NumericConfig,
+): string {
+ return lo.kind === "gt"
+ ? `greater than ${config.format(lo.val)}`
+ : `greater than or equal to ${config.format(lo.val)}`;
+}
+
+function hiMessage(
+ hi: UpperRule,
+ config: NumericConfig,
+): string {
+ return hi.kind === "lt"
+ ? `less than ${config.format(hi.val)}`
+ : `less than or equal to ${config.format(hi.val)}`;
+}
+
+function includesT(
+ arr: readonly T[],
+ v: T,
+): boolean {
+ for (let i = 0; i < arr.length; i++) {
+ if (arr[i] === v) return true;
+ }
+ return false;
+}
+
+function isNaNValue(v: number | bigint): boolean {
+ return typeof v === "number" && Number.isNaN(v);
+}
+
+function build(
+ rules: NumericRulesShape,
+ config: NumericConfig,
+ rulePath: PathBuilder,
+ forMapKey: boolean,
+): ScalarNativeResult | undefined {
+ if (rules.$unknown && rules.$unknown.length > 0) {
+ return undefined;
+ }
+
+ const handled = new Set();
+
+ let constRule: ConstRule | undefined;
+ if (isFieldSet(rules, config.descs.const)) {
+ constRule = {
+ val: rules.const,
+ path: rulePath.clone().field(config.descs.const).toPath(),
+ };
+ handled.add(config.descs.const);
+ }
+
+ let inRule: ListRule | undefined;
+ if (rules.in.length > 0) {
+ inRule = {
+ vals: rules.in,
+ path: rulePath.clone().field(config.descs.in).toPath(),
+ };
+ handled.add(config.descs.in);
+ }
+
+ let notInRule: ListRule | undefined;
+ if (rules.notIn.length > 0) {
+ notInRule = {
+ vals: rules.notIn,
+ path: rulePath.clone().field(config.descs.notIn).toPath(),
+ };
+ handled.add(config.descs.notIn);
+ }
+
+ let lowerRule: LowerRule | undefined;
+ if (rules.greaterThan.case !== undefined) {
+ const kind = rules.greaterThan.case;
+ const val = rules.greaterThan.value;
+ if (isNaNValue(val)) return undefined;
+ const desc = kind === "gt" ? config.descs.gt : config.descs.gte;
+ lowerRule = {
+ kind,
+ val,
+ path: rulePath.clone().field(desc).toPath(),
+ };
+ handled.add(desc);
+ }
+
+ let upperRule: UpperRule | undefined;
+ if (rules.lessThan.case !== undefined) {
+ const kind = rules.lessThan.case;
+ const val = rules.lessThan.value;
+ if (isNaNValue(val)) return undefined;
+ const desc = kind === "lt" ? config.descs.lt : config.descs.lte;
+ upperRule = {
+ kind,
+ val,
+ path: rulePath.clone().field(desc).toPath(),
+ };
+ handled.add(desc);
+ }
+
+ let finitePath: Path | undefined;
+ if (config.descs.finite && isFieldSet(rules, config.descs.finite)) {
+ const finite = (rules as unknown as NumericRulesWithFinite).finite;
+ if (finite) {
+ finitePath = rulePath.clone().field(config.descs.finite).toPath();
+ }
+ handled.add(config.descs.finite);
+ }
+
+ if (handled.size === 0) {
+ return undefined;
+ }
+
+ return {
+ eval: new EvalNativeNumericRules(
+ config,
+ forMapKey,
+ constRule,
+ inRule,
+ notInRule,
+ lowerRule,
+ upperRule,
+ finitePath,
+ ),
+ handledFields: handled,
+ };
+}
+
+/**
+ * Build a native evaluator for any of the 12 numeric rules messages.
+ * Returns `undefined` for any unrecognized type or for rules that bail out
+ * (NaN bound, unknown extensions, no fields set).
+ */
+export function tryBuildNativeNumericRules(
+ rules: Message,
+ rulePath: PathBuilder,
+ forMapKey: boolean,
+): ScalarNativeResult | undefined {
+ switch (rules.$typeName) {
+ case Int32RulesSchema.typeName:
+ return build(
+ rules as NumericRulesShape,
+ int32Config,
+ rulePath,
+ forMapKey,
+ );
+ case Int64RulesSchema.typeName:
+ return build(
+ rules as NumericRulesShape,
+ int64Config,
+ rulePath,
+ forMapKey,
+ );
+ case UInt32RulesSchema.typeName:
+ return build(
+ rules as NumericRulesShape,
+ uint32Config,
+ rulePath,
+ forMapKey,
+ );
+ case UInt64RulesSchema.typeName:
+ return build(
+ rules as NumericRulesShape,
+ uint64Config,
+ rulePath,
+ forMapKey,
+ );
+ case SInt32RulesSchema.typeName:
+ return build(
+ rules as NumericRulesShape,
+ sint32Config,
+ rulePath,
+ forMapKey,
+ );
+ case SInt64RulesSchema.typeName:
+ return build(
+ rules as NumericRulesShape,
+ sint64Config,
+ rulePath,
+ forMapKey,
+ );
+ case Fixed32RulesSchema.typeName:
+ return build(
+ rules as NumericRulesShape,
+ fixed32Config,
+ rulePath,
+ forMapKey,
+ );
+ case Fixed64RulesSchema.typeName:
+ return build(
+ rules as NumericRulesShape,
+ fixed64Config,
+ rulePath,
+ forMapKey,
+ );
+ case SFixed32RulesSchema.typeName:
+ return build(
+ rules as NumericRulesShape,
+ sfixed32Config,
+ rulePath,
+ forMapKey,
+ );
+ case SFixed64RulesSchema.typeName:
+ return build(
+ rules as NumericRulesShape,
+ sfixed64Config,
+ rulePath,
+ forMapKey,
+ );
+ case FloatRulesSchema.typeName:
+ return build(
+ rules as NumericRulesShape,
+ floatConfig,
+ rulePath,
+ forMapKey,
+ );
+ case DoubleRulesSchema.typeName:
+ return build(
+ rules as NumericRulesShape,
+ doubleConfig,
+ rulePath,
+ forMapKey,
+ );
+ default:
+ return undefined;
+ }
+}
diff --git a/packages/protovalidate/src/native/repeated.test.ts b/packages/protovalidate/src/native/repeated.test.ts
new file mode 100644
index 0000000..befe51e
--- /dev/null
+++ b/packages/protovalidate/src/native/repeated.test.ts
@@ -0,0 +1,238 @@
+// Copyright 2024-2026 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 { suite, test } from "node:test";
+import * as assert from "node:assert/strict";
+import { create, type DescMessage } from "@bufbuild/protobuf";
+import { pathToString } from "@bufbuild/protobuf/reflect";
+import { compile as compileWithPreamble, diff, native } from "./testing.js";
+
+const PREAMBLE = `
+ enum Color { COLOR_UNSPECIFIED = 0; COLOR_RED = 1; COLOR_GREEN = 2; }
+ message Inner { int32 x = 1; }
+`;
+
+function compile(definition: string): DescMessage {
+ return compileWithPreamble(definition, { preamble: PREAMBLE });
+}
+
+void suite("native repeated rules", () => {
+ void test("repeated.min_items passes and fails", () => {
+ const s = compile(
+ `message M { repeated int32 xs = 1 [(buf.validate.field).repeated.min_items = 2]; }`,
+ );
+ diff(s, create(s, { xs: [1, 2] }));
+ diff(s, create(s, { xs: [1] }));
+ diff(s, create(s, { xs: [] }));
+ });
+
+ void test("repeated.max_items passes and fails", () => {
+ const s = compile(
+ `message M { repeated int32 xs = 1 [(buf.validate.field).repeated.max_items = 2]; }`,
+ );
+ diff(s, create(s, { xs: [1, 2] }));
+ diff(s, create(s, { xs: [1, 2, 3] }));
+ });
+
+ void test("min_items + max_items together", () => {
+ const s = compile(
+ `message M {
+ repeated int32 xs = 1 [(buf.validate.field).repeated = { min_items: 2, max_items: 4 }];
+ }`,
+ );
+ diff(s, create(s, { xs: [1, 2, 3] }));
+ diff(s, create(s, { xs: [1] }));
+ diff(s, create(s, { xs: [1, 2, 3, 4, 5] }));
+ });
+
+ void suite("repeated.unique", () => {
+ void test("scalar (int32)", () => {
+ const s = compile(
+ `message M {
+ repeated int32 xs = 1 [(buf.validate.field).repeated.unique = true];
+ }`,
+ );
+ diff(s, create(s, { xs: [1, 2, 3] }));
+ diff(s, create(s, { xs: [1, 2, 1] }));
+ diff(s, create(s, { xs: [] }));
+ diff(s, create(s, { xs: [42] }));
+ });
+
+ void test("string", () => {
+ const s = compile(
+ `message M {
+ repeated string xs = 1 [(buf.validate.field).repeated.unique = true];
+ }`,
+ );
+ diff(s, create(s, { xs: ["a", "b", "c"] }));
+ diff(s, create(s, { xs: ["a", "b", "a"] }));
+ });
+
+ void test("bytes", () => {
+ const s = compile(
+ `message M {
+ repeated bytes xs = 1 [(buf.validate.field).repeated.unique = true];
+ }`,
+ );
+ diff(
+ s,
+ create(s, {
+ xs: [new Uint8Array([1, 2]), new Uint8Array([3, 4])],
+ }),
+ );
+ diff(
+ s,
+ create(s, {
+ xs: [new Uint8Array([1, 2]), new Uint8Array([1, 2])],
+ }),
+ );
+ // Lookalike sequences that differ only in one byte
+ diff(
+ s,
+ create(s, {
+ xs: [new Uint8Array([1, 2]), new Uint8Array([1, 3])],
+ }),
+ );
+ // Empty Uint8Array elements: two equal-length empty buffers must
+ // collide; one is trivially unique.
+ diff(s, create(s, { xs: [new Uint8Array([])] }));
+ diff(
+ s,
+ create(s, {
+ xs: [new Uint8Array([]), new Uint8Array([])],
+ }),
+ );
+ });
+
+ void test("enum", () => {
+ const s = compile(
+ `message M {
+ repeated Color xs = 1 [(buf.validate.field).repeated.unique = true];
+ }`,
+ );
+ diff(s, create(s, { xs: [1, 2] }));
+ diff(s, create(s, { xs: [1, 1] }));
+ });
+
+ void test("int64 (bigint)", () => {
+ const s = compile(
+ `message M {
+ repeated int64 xs = 1 [(buf.validate.field).repeated.unique = true];
+ }`,
+ );
+ diff(s, create(s, { xs: [1n, 2n] }));
+ diff(s, create(s, { xs: [1n, 1n] }));
+ });
+
+ void test("bool", () => {
+ const s = compile(
+ `message M {
+ repeated bool xs = 1 [(buf.validate.field).repeated.unique = true];
+ }`,
+ );
+ diff(s, create(s, { xs: [true, false] }));
+ diff(s, create(s, { xs: [true, true] }));
+ });
+
+ void test("message elements fall through to CEL", () => {
+ // For `unique` on message-element lists, the native dispatcher returns
+ // undefined for the unique field and CEL handles it. min/max_items still
+ // works natively; output must still match CEL byte-for-byte.
+ const s = compile(
+ `message M {
+ repeated Inner xs = 1 [(buf.validate.field).repeated = {
+ min_items: 1, unique: true
+ }];
+ }`,
+ );
+ diff(s, create(s, { xs: [] }));
+ diff(s, create(s, { xs: [{ x: 1 }] }));
+ // Two identical messages — exercises the CEL-handled unique path so we
+ // confirm fallthrough actually triggers the violation.
+ diff(s, create(s, { xs: [{ x: 1 }, { x: 1 }] }));
+ });
+ });
+
+ void test("repeated.max_items with empty list passes", () => {
+ const s = compile(
+ `message M { repeated int32 xs = 1 [(buf.validate.field).repeated.max_items = 2]; }`,
+ );
+ diff(s, create(s, { xs: [] }));
+ });
+
+ void test("min_items + max_items + unique together", () => {
+ const s = compile(
+ `message M {
+ repeated int32 xs = 1 [(buf.validate.field).repeated = {
+ min_items: 2, max_items: 4, unique: true
+ }];
+ }`,
+ );
+ diff(s, create(s, { xs: [1, 2, 3] })); // valid
+ diff(s, create(s, { xs: [1] })); // min fails
+ diff(s, create(s, { xs: [1, 2, 3, 4, 5] })); // max fails
+ diff(s, create(s, { xs: [1, 2, 2] })); // unique fails
+ diff(s, create(s, { xs: [1, 1, 1, 1, 1] })); // max + unique fail
+ diff(s, create(s, { xs: [1, 1] })); // unique fails (min satisfied)
+ });
+
+ void test("rule path lands at repeated.min_items", () => {
+ const s = compile(
+ `message M { repeated int32 xs = 1 [(buf.validate.field).repeated.min_items = 2]; }`,
+ );
+ const r = native.validate(s, create(s, { xs: [] }));
+ assert.equal(r.kind, "invalid");
+ assert.equal(r.violations?.length, 1);
+ const v = r.violations?.[0];
+ assert.ok(v);
+ assert.equal(v.ruleId, "repeated.min_items");
+ assert.equal(pathToString(v.rule), "repeated.min_items");
+ });
+
+ void test("rule path lands at repeated.max_items", () => {
+ const s = compile(
+ `message M { repeated int32 xs = 1 [(buf.validate.field).repeated.max_items = 1]; }`,
+ );
+ const r = native.validate(s, create(s, { xs: [1, 2] }));
+ assert.equal(r.kind, "invalid");
+ const v = r.violations?.[0];
+ assert.ok(v);
+ assert.equal(v.ruleId, "repeated.max_items");
+ assert.equal(pathToString(v.rule), "repeated.max_items");
+ });
+
+ void test("rule path lands at repeated.unique", () => {
+ const s = compile(
+ `message M { repeated int32 xs = 1 [(buf.validate.field).repeated.unique = true]; }`,
+ );
+ const r = native.validate(s, create(s, { xs: [1, 1] }));
+ assert.equal(r.kind, "invalid");
+ const v = r.violations?.[0];
+ assert.ok(v);
+ assert.equal(v.ruleId, "repeated.unique");
+ assert.equal(pathToString(v.rule), "repeated.unique");
+ });
+
+ void test("repeated.unique = false claims the field (no-op rule)", () => {
+ // Explicit unique=false is a no-op; the native handler claims the field
+ // so CEL doesn't re-evaluate. Behavior is unchanged from CEL.
+ const s = compile(
+ `message M {
+ repeated int32 xs = 1 [(buf.validate.field).repeated.unique = false];
+ }`,
+ );
+ diff(s, create(s, { xs: [1, 1] })); // duplicates allowed
+ diff(s, create(s, { xs: [] }));
+ });
+});
diff --git a/packages/protovalidate/src/native/repeated.ts b/packages/protovalidate/src/native/repeated.ts
new file mode 100644
index 0000000..ef1d067
--- /dev/null
+++ b/packages/protovalidate/src/native/repeated.ts
@@ -0,0 +1,205 @@
+// Copyright 2024-2026 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 { type DescField, isFieldSet, ScalarType } from "@bufbuild/protobuf";
+import type {
+ Path,
+ PathBuilder,
+ ReflectList,
+} from "@bufbuild/protobuf/reflect";
+import type { Cursor } from "../cursor.js";
+import type { Eval } from "../eval.js";
+import {
+ type RepeatedRules,
+ RepeatedRulesSchema,
+} from "../gen/buf/validate/validate_pb.js";
+
+const F = RepeatedRulesSchema.field;
+
+/**
+ * Internal dispatch result for list-shaped native handlers.
+ *
+ * @internal
+ */
+export type ListNativeResult = {
+ eval: Eval;
+ handledFields: ReadonlySet;
+};
+
+type UniqueKind = "scalar" | "bytes" | "enum";
+
+type SizeRule = { readonly val: bigint; readonly path: Path };
+type UniqueRule = { readonly kind: UniqueKind; readonly path: Path };
+
+class EvalNativeRepeatedRules implements Eval {
+ constructor(
+ private readonly minItemsRule: SizeRule | undefined,
+ private readonly maxItemsRule: SizeRule | undefined,
+ private readonly uniqueRule: UniqueRule | undefined,
+ ) {}
+
+ eval(val: ReflectList, cursor: Cursor): void {
+ const size = BigInt(val.size);
+
+ if (this.minItemsRule !== undefined && size < this.minItemsRule.val) {
+ cursor.violate(
+ `must contain at least ${this.minItemsRule.val} item(s)`,
+ "repeated.min_items",
+ this.minItemsRule.path,
+ );
+ }
+
+ if (this.maxItemsRule !== undefined && size > this.maxItemsRule.val) {
+ cursor.violate(
+ `must contain no more than ${this.maxItemsRule.val} item(s)`,
+ "repeated.max_items",
+ this.maxItemsRule.path,
+ );
+ }
+
+ if (this.uniqueRule !== undefined && !isUnique(val, this.uniqueRule.kind)) {
+ cursor.violate(
+ "repeated value must contain unique items",
+ "repeated.unique",
+ this.uniqueRule.path,
+ );
+ }
+ }
+
+ prune(): boolean {
+ return false;
+ }
+}
+
+function isUnique(list: ReflectList, kind: UniqueKind): boolean {
+ if (list.size <= 1) return true;
+ if (kind === "bytes") {
+ const seen = new Set();
+ for (const item of list) {
+ const key = bytesKey(item as Uint8Array);
+ if (seen.has(key)) return false;
+ seen.add(key);
+ }
+ return true;
+ }
+ // scalar (number/bigint/string/boolean) and enum (number) — strict-equal Set works.
+ const seen = new Set();
+ for (const item of list) {
+ if (seen.has(item)) return false;
+ seen.add(item);
+ }
+ return true;
+}
+
+/**
+ * Build a deterministic string key for a Uint8Array. Each byte becomes one
+ * UTF-16 code unit so equal byte sequences hash to equal keys.
+ */
+function bytesKey(bytes: Uint8Array): string {
+ let out = "";
+ for (let i = 0; i < bytes.length; i++) {
+ out += String.fromCharCode(bytes[i]);
+ }
+ return out;
+}
+
+/**
+ * Decide whether the list element type supports native unique handling.
+ *
+ * Message-typed elements (including WKT scalar wrappers) need protobuf-level
+ * equality, which the CEL path covers. Returning undefined here signals the
+ * caller to fall back to CEL for `unique`.
+ */
+function uniqueKindForListField(
+ field: DescField & { fieldKind: "list" },
+): UniqueKind | undefined {
+ switch (field.listKind) {
+ case "message":
+ return undefined;
+ case "enum":
+ return "enum";
+ case "scalar":
+ return field.scalar === ScalarType.BYTES ? "bytes" : "scalar";
+ }
+}
+
+/**
+ * Try to build a native evaluator for RepeatedRules (list-level rules:
+ * min_items, max_items, unique). Returns `undefined` if no native handler
+ * applies.
+ */
+export function tryBuildNativeRepeatedRules(
+ rules: RepeatedRules,
+ rulePath: PathBuilder,
+ forMapKey: boolean,
+ listField: (DescField & { fieldKind: "list" }) | undefined,
+): ListNativeResult | undefined {
+ if (rules.$unknown && rules.$unknown.length > 0) {
+ return undefined;
+ }
+ // Type-level invariant: the planner only routes RepeatedRules from
+ // planList(), which always passes forMapKey=false. Kept as a tripwire.
+ if (forMapKey) return undefined;
+
+ const handled = new Set();
+
+ let minItemsRule: SizeRule | undefined;
+ if (isFieldSet(rules, F.minItems)) {
+ minItemsRule = {
+ val: rules.minItems,
+ path: rulePath.clone().field(F.minItems).toPath(),
+ };
+ handled.add(F.minItems);
+ }
+
+ let maxItemsRule: SizeRule | undefined;
+ if (isFieldSet(rules, F.maxItems)) {
+ maxItemsRule = {
+ val: rules.maxItems,
+ path: rulePath.clone().field(F.maxItems).toPath(),
+ };
+ handled.add(F.maxItems);
+ }
+
+ let uniqueRule: UniqueRule | undefined;
+ if (isFieldSet(rules, F.unique)) {
+ if (!rules.unique) {
+ // Explicit `unique: false` is a no-op rule. Claim the field so CEL
+ // doesn't bother re-evaluating it. Matches numeric.ts's treatment of
+ // `finite: false`.
+ handled.add(F.unique);
+ } else if (listField !== undefined) {
+ const kind = uniqueKindForListField(listField);
+ if (kind !== undefined) {
+ uniqueRule = {
+ kind,
+ path: rulePath.clone().field(F.unique).toPath(),
+ };
+ handled.add(F.unique);
+ } else {
+ // if we can't handle unique, don't partially handle repeated rules
+ return undefined;
+ }
+ }
+ }
+
+ if (handled.size === 0) {
+ return undefined;
+ }
+
+ return {
+ eval: new EvalNativeRepeatedRules(minItemsRule, maxItemsRule, uniqueRule),
+ handledFields: handled,
+ };
+}
diff --git a/packages/protovalidate/src/native/string.test.ts b/packages/protovalidate/src/native/string.test.ts
new file mode 100644
index 0000000..51526ac
--- /dev/null
+++ b/packages/protovalidate/src/native/string.test.ts
@@ -0,0 +1,554 @@
+// Copyright 2024-2026 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 { suite, test } from "node:test";
+import * as assert from "node:assert/strict";
+import { create, createRegistry } from "@bufbuild/protobuf";
+import { buildPath, pathToString } from "@bufbuild/protobuf/reflect";
+import { compileFile } from "@bufbuild/protocompile";
+import { bufCompileOptions, cel, compile, diff, native } from "./testing.js";
+import { RuntimeError } from "../error.js";
+import { createValidator } from "../validator.js";
+import {
+ KnownRegex,
+ StringRulesSchema,
+} from "../gen/buf/validate/validate_pb.js";
+import { re2RegexMatch } from "../regex.js";
+import { tryBuildNativeStringRules } from "./string.js";
+
+void suite("native string rules", () => {
+ void test("string.const passes and fails", () => {
+ const s = compile(
+ `message M { string v = 1 [(buf.validate.field).string.const = "hello"]; }`,
+ );
+ diff(s, create(s, { v: "hello" }));
+ diff(s, create(s, { v: "world" }));
+ diff(s, create(s, { v: "" }));
+ });
+
+ void test("string.len counts code points, not UTF-16 units", () => {
+ const s = compile(
+ `message M { string v = 1 [(buf.validate.field).string.len = 2]; }`,
+ );
+ diff(s, create(s, { v: "ab" }));
+ // "𝑎" is a surrogate pair: .length is 2, but it is 1 code point.
+ diff(s, create(s, { v: "𝑎" }));
+ diff(s, create(s, { v: "𝑎b" })); // 2 code points — passes
+ diff(s, create(s, { v: "abc" }));
+ });
+
+ void test("string.min_len + max_len", () => {
+ const s = compile(
+ `message M { string v = 1 [(buf.validate.field).string = { min_len: 2, max_len: 4 }]; }`,
+ );
+ diff(s, create(s, { v: "ab" }));
+ diff(s, create(s, { v: "abcd" }));
+ diff(s, create(s, { v: "a" }));
+ diff(s, create(s, { v: "abcde" }));
+ diff(s, create(s, { v: "𝑎𝑏" })); // 2 code points — passes
+ });
+
+ void test("string.len_bytes / min_bytes / max_bytes count UTF-8 bytes", () => {
+ const s = compile(
+ `message M { string v = 1 [(buf.validate.field).string = { min_bytes: 2, max_bytes: 4 }]; }`,
+ );
+ diff(s, create(s, { v: "ab" }));
+ diff(s, create(s, { v: "a" })); // 1 byte — too short
+ diff(s, create(s, { v: "é" })); // 2 bytes — passes
+ diff(s, create(s, { v: "𝑎" })); // 4 bytes — passes
+ diff(s, create(s, { v: "𝑎b" })); // 5 bytes — too long
+ const exact = compile(
+ `message M { string v = 1 [(buf.validate.field).string.len_bytes = 3]; }`,
+ );
+ diff(exact, create(exact, { v: "abc" }));
+ diff(exact, create(exact, { v: "€" })); // 3 bytes — passes
+ diff(exact, create(exact, { v: "ab" }));
+ });
+
+ void suite("string.pattern", () => {
+ void test("valid match passes, mismatch fails", () => {
+ const s = compile(
+ `message M { string v = 1 [(buf.validate.field).string.pattern = "^[a-z]+$"]; }`,
+ );
+ diff(s, create(s, { v: "hello" }));
+ diff(s, create(s, { v: "HELLO" }));
+ diff(s, create(s, { v: "" }));
+ });
+
+ void test("RE2-only syntax works under the default engine", () => {
+ // "(?i)" mid-pattern is valid RE2 but invalid ECMAScript — this is
+ // the engine swap in action.
+ const s = compile(
+ `message M { string v = 1 [(buf.validate.field).string.pattern = "(?i)^hello$"]; }`,
+ );
+ diff(s, create(s, { v: "HELLO" }));
+ diff(s, create(s, { v: "nope" }));
+ assert.equal(native.validate(s, create(s, { v: "HeLLo" })).kind, "valid");
+ });
+
+ void test("pattern invalid under RE2 errors on both paths", () => {
+ // Lookahead is valid ECMAScript but not RE2. The native handler
+ // bails to CEL, whose matches() hits the same engine failure.
+ const s = compile(
+ `message M { string v = 1 [(buf.validate.field).string.pattern = "(?=a)a"]; }`,
+ );
+ diff(s, create(s, { v: "a" }));
+ assert.equal(native.validate(s, create(s, { v: "a" })).kind, "error");
+ assert.equal(cel.validate(s, create(s, { v: "a" })).kind, "error");
+ });
+
+ void test("empty pattern matches any input", () => {
+ const s = compile(
+ `message M { string v = 1 [(buf.validate.field).string.pattern = ""]; }`,
+ );
+ diff(s, create(s, { v: "" }));
+ diff(s, create(s, { v: "hello" }));
+ });
+
+ void test("custom regexMatch override is honored", () => {
+ const s = compile(
+ `message M { string v = 1 [(buf.validate.field).string.pattern = ".+"]; }`,
+ );
+ let calledWith: { pattern: string; against: string } | undefined;
+ const v = createValidator({
+ regexMatch: (pattern, against) => {
+ if (against !== "") {
+ calledWith = { pattern, against };
+ }
+ return false; // always fail
+ },
+ });
+ const r = v.validate(s, create(s, { v: "x" }));
+ assert.equal(r.kind, "invalid");
+ assert.equal(calledWith?.pattern, ".+");
+ assert.equal(calledWith?.against, "x");
+ });
+
+ void test("custom regexMatch that throws at eval is a RuntimeError", () => {
+ const s = compile(
+ `message M { string v = 1 [(buf.validate.field).string.pattern = ".+"]; }`,
+ );
+ let probing = true;
+ const v = createValidator({
+ regexMatch: () => {
+ if (probing) {
+ return true; // pass the plan-time probe
+ }
+ throw new Error("synthetic engine failure");
+ },
+ });
+ probing = false;
+ const r = v.validate(s, create(s, { v: "x" }));
+ assert.equal(r.kind, "error");
+ assert.ok(r.error instanceof RuntimeError);
+ });
+ });
+
+ void test("string.prefix", () => {
+ const s = compile(
+ `message M { string v = 1 [(buf.validate.field).string.prefix = "ab"]; }`,
+ );
+ diff(s, create(s, { v: "abc" }));
+ diff(s, create(s, { v: "bc" }));
+ diff(s, create(s, { v: "" }));
+ });
+
+ void test("string.suffix", () => {
+ const s = compile(
+ `message M { string v = 1 [(buf.validate.field).string.suffix = "yz"]; }`,
+ );
+ diff(s, create(s, { v: "xyz" }));
+ diff(s, create(s, { v: "xy" }));
+ });
+
+ void test("string.contains / not_contains", () => {
+ const s = compile(
+ `message M {
+ string v = 1 [(buf.validate.field).string = {
+ contains: "needle",
+ not_contains: "thorn"
+ }];
+ }`,
+ );
+ diff(s, create(s, { v: "a needle here" }));
+ diff(s, create(s, { v: "no match" })); // contains fails
+ diff(s, create(s, { v: "needle and thorn" })); // not_contains fails
+ diff(s, create(s, { v: "just a thorn" })); // both fail
+ });
+
+ void test("string.in / not_in", () => {
+ const s = compile(
+ `message M {
+ string v = 1 [(buf.validate.field).string = {
+ in: ["foo", "bar"],
+ not_in: ["baz"]
+ }];
+ }`,
+ );
+ diff(s, create(s, { v: "foo" }));
+ diff(s, create(s, { v: "bar" }));
+ diff(s, create(s, { v: "baz" })); // violates both
+ diff(s, create(s, { v: "qux" })); // violates in only
+ // Lock the bare-CSV list formatting in the message.
+ const r = native.validate(s, create(s, { v: "qux" }));
+ assert.equal(r.kind, "invalid");
+ assert.equal(r.violations?.[0]?.message, "must be in list [foo, bar]");
+ });
+
+ void test("empty in / not_in lists are no-ops", () => {
+ const s = compile(
+ `message M {
+ string v = 1 [(buf.validate.field).string = { in: [], not_in: [] }];
+ }`,
+ );
+ diff(s, create(s, { v: "" }));
+ diff(s, create(s, { v: "anything" }));
+ });
+
+ void test("combined rules fire in CEL order", () => {
+ const s = compile(
+ `message M {
+ string v = 1 [(buf.validate.field).string = {
+ const: "abcd", min_len: 10, pattern: "^[a-z]+$", suffix: "zz"
+ }];
+ }`,
+ );
+ diff(s, create(s, { v: "XY" })); // all four fail
+ diff(s, create(s, { v: "abcd" })); // min_len + suffix fail
+ });
+
+ void suite("StringValue wrapper", () => {
+ void test("inner value validated against string.min_len", () => {
+ const s = compile(
+ `message M {
+ google.protobuf.StringValue v = 1 [(buf.validate.field).string.min_len = 3];
+ }`,
+ );
+ diff(s, create(s, { v: "abc" }));
+ diff(s, create(s, { v: "ab" }));
+ });
+ void test("inner value validated against string.email", () => {
+ const s = compile(
+ `message M {
+ google.protobuf.StringValue v = 1 [(buf.validate.field).string.email = true];
+ }`,
+ );
+ diff(s, create(s, { v: "foo@example.com" }));
+ diff(s, create(s, { v: "nope" }));
+ });
+ void test("absent wrapper skips validation", () => {
+ const s = compile(
+ `message M {
+ google.protobuf.StringValue v = 1 [(buf.validate.field).string.min_len = 3];
+ }`,
+ );
+ diff(s, create(s, {}));
+ assert.equal(native.validate(s, create(s, {})).kind, "valid");
+ });
+ });
+
+ void test("map keys validated with forMapKey", () => {
+ const s = compile(
+ `message M {
+ map m = 1 [(buf.validate.field).map.keys.string.min_len = 3];
+ }`,
+ );
+ diff(s, create(s, { m: { abc: "x" } }));
+ diff(s, create(s, { m: { ab: "x" } }));
+ });
+
+ void suite("rule path assertions", () => {
+ void test("path lands at string.const", () => {
+ const s = compile(
+ `message M { string v = 1 [(buf.validate.field).string.const = "a"]; }`,
+ );
+ const r = native.validate(s, create(s, { v: "b" }));
+ assert.equal(r.kind, "invalid");
+ const v = r.violations?.[0];
+ assert.ok(v);
+ assert.equal(pathToString(v.rule), "string.const");
+ });
+ void test("path lands at string.pattern", () => {
+ const s = compile(
+ `message M { string v = 1 [(buf.validate.field).string.pattern = "^x$"]; }`,
+ );
+ const r = native.validate(s, create(s, { v: "y" }));
+ assert.equal(r.kind, "invalid");
+ const v = r.violations?.[0];
+ assert.ok(v);
+ assert.equal(pathToString(v.rule), "string.pattern");
+ });
+ void test("path lands at string.uuid (well-known oneof)", () => {
+ const s = compile(
+ `message M { string v = 1 [(buf.validate.field).string.uuid = true]; }`,
+ );
+ const r = native.validate(s, create(s, { v: "nope" }));
+ assert.equal(r.kind, "invalid");
+ const v = r.violations?.[0];
+ assert.ok(v);
+ assert.equal(pathToString(v.rule), "string.uuid");
+ assert.equal(v.ruleId, "string.uuid");
+ });
+ });
+
+ void suite("well-known formats", () => {
+ // Each entry: [proto rule name, valid input, invalid input].
+ // Empty input is exercised separately to cover the *_empty rule ids.
+ const cases: [string, string, string][] = [
+ ["email", "foo@example.com", "not-an-email"],
+ ["hostname", "example.com", "-bad-.example"],
+ ["ip", "192.168.0.1", "999.0.0.1"],
+ ["ipv4", "192.168.0.1", "::1"],
+ ["ipv6", "::1", "192.168.0.1"],
+ ["uri", "https://example.com/path", "not a uri"],
+ ["uri_ref", "./relative/path", "::"],
+ ["address", "example.com", "!!!"],
+ ["uuid", "8badf0d8-2cab-4dcb-94ee-fa6f4e5d4a4a", "not-a-uuid"],
+ ["tuuid", "8badf0d82cab4dcb94eefa6f4e5d4a4a", "not-a-tuuid"],
+ ["ulid", "01ARZ3NDEKTSV4RRFFQ69G5FAV", "not-a-ulid"],
+ ["ip_with_prefixlen", "192.168.1.5/24", "192.168.1.5"],
+ ["ipv4_with_prefixlen", "192.168.1.5/24", "2001:db8::1/64"],
+ ["ipv6_with_prefixlen", "2001:db8::1/64", "192.168.1.5/24"],
+ ["ip_prefix", "192.168.1.0/24", "192.168.1.5/24"],
+ ["ipv4_prefix", "192.168.1.0/24", "2001:db8::/64"],
+ ["ipv6_prefix", "2001:db8::/64", "2001:db8::1/64"],
+ ["host_and_port", "example.com:8080", "example.com"],
+ ["protobuf_fqn", "buf.validate.StringRules", ".leading.dot"],
+ ["protobuf_dot_fqn", ".buf.validate.StringRules", "no.leading.dot"],
+ ];
+ for (const [rule, valid, invalid] of cases) {
+ void test(`string.${rule} valid, invalid, and empty`, () => {
+ const s = compile(
+ `message M { string v = 1 [(buf.validate.field).string.${rule} = true]; }`,
+ );
+ diff(s, create(s, { v: valid }));
+ diff(s, create(s, { v: invalid }));
+ diff(s, create(s, { v: "" }));
+ });
+ }
+
+ void test("host_and_port accepts bracketed IPv6 with port", () => {
+ const s = compile(
+ `message M { string v = 1 [(buf.validate.field).string.host_and_port = true]; }`,
+ );
+ diff(s, create(s, { v: "[::1]:8080" }));
+ diff(s, create(s, { v: "::1" })); // no port
+ });
+
+ void test("empty input emits the *_empty rule id", () => {
+ const s = compile(
+ `message M { string v = 1 [(buf.validate.field).string.email = true]; }`,
+ );
+ const r = native.validate(s, create(s, { v: "" }));
+ assert.equal(r.kind, "invalid");
+ const v = r.violations?.[0];
+ assert.ok(v);
+ assert.equal(v.ruleId, "string.email_empty");
+ assert.equal(
+ v.message,
+ "value is empty, which is not a valid email address",
+ );
+ assert.equal(pathToString(v.rule), "string.email");
+ });
+
+ void test("uri_ref has no *_empty rule", () => {
+ const s = compile(
+ `message M { string v = 1 [(buf.validate.field).string.uri_ref = true]; }`,
+ );
+ diff(s, create(s, { v: "" }));
+ const r = native.validate(s, create(s, { v: "" }));
+ // Whatever the verdict, an empty uri_ref never produces a
+ // "string.uri_ref_empty" id — there is no such rule.
+ if (r.kind === "invalid") {
+ assert.equal(r.violations?.[0]?.ruleId, "string.uri_ref");
+ }
+ });
+
+ void test("explicit email=false is a no-op claim — never emits a violation", () => {
+ const s = compile(
+ `message M { string v = 1 [(buf.validate.field).string.email = false]; }`,
+ );
+ diff(s, create(s, { v: "" }));
+ diff(s, create(s, { v: "not-an-email" }));
+ assert.equal(native.validate(s, create(s, { v: "" })).kind, "valid");
+ assert.equal(
+ native.validate(s, create(s, { v: "not-an-email" })).kind,
+ "valid",
+ );
+ });
+ });
+
+ void suite("string.well_known_regex", () => {
+ void test("header name, strict by default", () => {
+ const s = compile(
+ `message M { string v = 1 [(buf.validate.field).string.well_known_regex = KNOWN_REGEX_HTTP_HEADER_NAME]; }`,
+ );
+ diff(s, create(s, { v: "Content-Type" }));
+ diff(s, create(s, { v: ":authority" }));
+ diff(s, create(s, { v: "bad name" })); // space not allowed
+ diff(s, create(s, { v: "bad\u0000name" }));
+ });
+
+ void test("header name empty emits header_name_empty", () => {
+ const s = compile(
+ `message M { string v = 1 [(buf.validate.field).string.well_known_regex = KNOWN_REGEX_HTTP_HEADER_NAME]; }`,
+ );
+ diff(s, create(s, { v: "" }));
+ const r = native.validate(s, create(s, { v: "" }));
+ assert.equal(r.kind, "invalid");
+ assert.equal(
+ r.violations?.[0]?.ruleId,
+ "string.well_known_regex.header_name_empty",
+ );
+ });
+
+ void test("header name with strict=false uses the loose pattern", () => {
+ const s = compile(
+ `message M {
+ string v = 1 [(buf.validate.field).string = {
+ well_known_regex: KNOWN_REGEX_HTTP_HEADER_NAME, strict: false
+ }];
+ }`,
+ );
+ diff(s, create(s, { v: "anything goes ()" })); // loose allows spaces
+ diff(s, create(s, { v: "bad\u0000name" })); // NUL still rejected
+ diff(s, create(s, { v: "bad\nname" })); // LF still rejected
+ diff(s, create(s, { v: "" }));
+ });
+
+ void test("header value, strict by default", () => {
+ const s = compile(
+ `message M { string v = 1 [(buf.validate.field).string.well_known_regex = KNOWN_REGEX_HTTP_HEADER_VALUE]; }`,
+ );
+ diff(s, create(s, { v: "application/json" }));
+ diff(s, create(s, { v: "tab\tis fine" }));
+ diff(s, create(s, { v: "bad\u0000value" }));
+ diff(s, create(s, { v: "bad\u001fvalue" }));
+ diff(s, create(s, { v: "" })); // empty matches the * pattern
+ assert.equal(native.validate(s, create(s, { v: "" })).kind, "valid");
+ });
+
+ void test("header value with strict=false uses the loose * pattern", () => {
+ const s = compile(
+ `message M {
+ string v = 1 [(buf.validate.field).string = {
+ well_known_regex: KNOWN_REGEX_HTTP_HEADER_VALUE, strict: false
+ }];
+ }`,
+ );
+ // The loose header-value pattern is anchored with * (unlike header
+ // name's +), so empty input is valid — a spot where CEL and
+ // protovalidate-go's native path historically diverged.
+ diff(s, create(s, { v: "" }));
+ diff(s, create(s, { v: "ctl\u0001is fine when loose" }));
+ diff(s, create(s, { v: "bad\u0000value" }));
+ diff(s, create(s, { v: "bad\rvalue" }));
+ assert.equal(native.validate(s, create(s, { v: "" })).kind, "valid");
+ });
+
+ void test("explicit strict=true behaves like the default", () => {
+ const s = compile(
+ `message M {
+ string v = 1 [(buf.validate.field).string = {
+ well_known_regex: KNOWN_REGEX_HTTP_HEADER_VALUE, strict: true
+ }];
+ }`,
+ );
+ diff(s, create(s, { v: "good value" }));
+ diff(s, create(s, { v: "bad\u001fvalue" }));
+ });
+
+ void test("KNOWN_REGEX_UNSPECIFIED is a no-op claim", () => {
+ const s = compile(
+ `message M { string v = 1 [(buf.validate.field).string.well_known_regex = KNOWN_REGEX_UNSPECIFIED]; }`,
+ );
+ diff(s, create(s, { v: "" }));
+ diff(s, create(s, { v: "anything\u0000at all" }));
+ assert.equal(
+ native.validate(s, create(s, { v: "anything\u0000at all" })).kind,
+ "valid",
+ );
+ });
+ // Regression: these header patterns originally spelled their control
+ // characters as `backslash-u` escapes, which RE2 rejects as an invalid
+ // escape sequence. `makePatternTest` swallows that error and returns
+ // undefined, so the entire rules message fell through to CEL — output
+ // stayed correct (so `diff` above still passed) while the native path
+ // silently never ran. Assert engagement directly, against the default
+ // RE2 engine.
+ for (const [label, wk] of [
+ ["header name", KnownRegex.HTTP_HEADER_NAME],
+ ["header value", KnownRegex.HTTP_HEADER_VALUE],
+ ] as const) {
+ for (const strict of [true, false]) {
+ void test(`${label} (strict=${strict}) is valid RE2 and stays native`, () => {
+ const rules = create(StringRulesSchema, {
+ wellKnown: { case: "wellKnownRegex", value: wk },
+ strict,
+ });
+ const built = tryBuildNativeStringRules(
+ rules,
+ buildPath(StringRulesSchema),
+ false,
+ re2RegexMatch,
+ );
+ assert.notEqual(
+ built,
+ undefined,
+ "native path fell back to CEL — the well_known_regex pattern is not valid RE2",
+ );
+ });
+ }
+ }
+ });
+
+ void suite("fallthrough", () => {
+ void test("custom predefined extension falls back to CEL entirely", () => {
+ const descFile = compileFile(
+ `
+ syntax = "proto2";
+ import "buf/validate/validate.proto";
+ message M {
+ optional string v = 1 [
+ (buf.validate.field).string.min_len = 5,
+ (buf.validate.field).string.(starts_x) = true
+ ];
+ }
+ extend buf.validate.StringRules {
+ optional bool starts_x = 81048953 [(buf.validate.predefined).cel = {
+ id: "string.starts_x"
+ message: "value must start with x"
+ expression: "!rules.starts_x || this.startsWith('x')"
+ }];
+ }
+ `,
+ bufCompileOptions,
+ );
+ const s = descFile.messages[0];
+ const ext = descFile.extensions[0];
+ const nativeV = createValidator({ registry: createRegistry(ext) });
+ const celV = createValidator({
+ registry: createRegistry(ext),
+ disableNativeRules: true,
+ });
+ const fmt = (v: { toString(): string }) => v.toString();
+ for (const input of ["xhello", "hello", "x", "abc"]) {
+ const a = nativeV.validate(s, create(s, { v: input }));
+ const b = celV.validate(s, create(s, { v: input }));
+ assert.equal(a.kind, b.kind, `kind mismatch for ${input}`);
+ assert.deepEqual(a.violations?.map(fmt), b.violations?.map(fmt));
+ }
+ });
+ });
+});
diff --git a/packages/protovalidate/src/native/string.ts b/packages/protovalidate/src/native/string.ts
new file mode 100644
index 0000000..2ebdfdb
--- /dev/null
+++ b/packages/protovalidate/src/native/string.ts
@@ -0,0 +1,669 @@
+// Copyright 2024-2026 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 { type DescField, isFieldSet } from "@bufbuild/protobuf";
+import type {
+ Path,
+ PathBuilder,
+ ScalarValue,
+} from "@bufbuild/protobuf/reflect";
+import type { Cursor } from "../cursor.js";
+import type { Eval } from "../eval.js";
+import { RuntimeError } from "../error.js";
+import {
+ KnownRegex,
+ type StringRules,
+ StringRulesSchema,
+} from "../gen/buf/validate/validate_pb.js";
+import {
+ isEmail,
+ isHostAndPort,
+ isHostname,
+ isIp,
+ isIpPrefix,
+ isUri,
+ isUriRef,
+} from "../lib.js";
+import type { RegexMatcher } from "../func.js";
+import type { ScalarNativeResult } from "./dispatcher.js";
+import { codepointLength, formatList, utf8ByteLength } from "./format.js";
+
+const F = StringRulesSchema.field;
+
+/** A rule with a string operand: const, prefix, suffix, contains, not_contains. */
+type StrRule = { readonly val: string; readonly path: Path };
+/** A rule with a numeric size operand: len, min_len, max_len, len_bytes, min_bytes, max_bytes. */
+type SizeRule = { readonly val: bigint; readonly path: Path };
+/** A rule with a string list operand: in, not_in. */
+type StrListRule = { readonly vals: readonly string[]; readonly path: Path };
+type PatternRule = {
+ readonly src: string;
+ readonly test: (against: string) => boolean;
+ readonly path: Path;
+};
+
+/**
+ * Spec carried alongside an active well-known constraint, so `eval()` does
+ * no per-call table lookups.
+ */
+type WellKnownRule = {
+ readonly check: (s: string) => boolean;
+ readonly ruleId: string;
+ readonly msg: string;
+ /**
+ * When set, an empty input emits this violation instead of running
+ * `check`, matching the dedicated `*_empty` CEL rules. Unset for
+ * `uri_ref` and `well_known_regex.header_value`, which validate the
+ * empty string like any other input.
+ */
+ readonly empty?: { readonly ruleId: string; readonly msg: string };
+ readonly path: Path;
+};
+
+/**
+ * The boolean members of the StringRules `well_known` oneof —
+ * everything except `well_known_regex`, which carries a KnownRegex enum
+ * and is dispatched separately.
+ */
+type BoolWellKnownCase = Exclude<
+ StringRules["wellKnown"]["case"],
+ "wellKnownRegex" | undefined
+>;
+
+/**
+ * Per-kind specs for the boolean well-known string formats. Messages and
+ * rule ids mirror the predefined CEL annotations on the corresponding
+ * `StringRules` fields. Kinds backed by a fixed regex carry the exact
+ * pattern string the CEL expression compiles, so both paths share one
+ * compiled regex and one behavior under any engine; the rest call the same
+ * `lib.ts` helpers CEL's custom functions are built on.
+ */
+const WELL_KNOWN: Record<
+ BoolWellKnownCase,
+ {
+ readonly msg: string;
+ /** Unset for uri_ref, which has no `*_empty` CEL rule. */
+ readonly emptyMsg?: string;
+ /** Exactly one of check / pattern is set. */
+ readonly check?: (s: string) => boolean;
+ readonly pattern?: string;
+ }
+> = {
+ email: {
+ msg: "must be a valid email address",
+ emptyMsg: "value is empty, which is not a valid email address",
+ check: (s) => isEmail.call(s),
+ },
+ hostname: {
+ msg: "must be a valid hostname",
+ emptyMsg: "value is empty, which is not a valid hostname",
+ check: (s) => isHostname.call(s),
+ },
+ ip: {
+ msg: "must be a valid IP address",
+ emptyMsg: "value is empty, which is not a valid IP address",
+ check: (s) => isIp.call(s),
+ },
+ ipv4: {
+ msg: "must be a valid IPv4 address",
+ emptyMsg: "value is empty, which is not a valid IPv4 address",
+ check: (s) => isIp.call(s, 4),
+ },
+ ipv6: {
+ msg: "must be a valid IPv6 address",
+ emptyMsg: "value is empty, which is not a valid IPv6 address",
+ check: (s) => isIp.call(s, 6),
+ },
+ uri: {
+ msg: "must be a valid URI",
+ emptyMsg: "value is empty, which is not a valid URI",
+ check: (s) => isUri.call(s),
+ },
+ uriRef: {
+ msg: "must be a valid URI Reference",
+ check: (s) => isUriRef.call(s),
+ },
+ address: {
+ msg: "must be a valid hostname, or ip address",
+ emptyMsg: "value is empty, which is not a valid hostname, or ip address",
+ check: (s) => isHostname.call(s) || isIp.call(s),
+ },
+ uuid: {
+ msg: "must be a valid UUID",
+ emptyMsg: "value is empty, which is not a valid UUID",
+ pattern:
+ "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
+ },
+ tuuid: {
+ msg: "must be a valid trimmed UUID",
+ emptyMsg: "value is empty, which is not a valid trimmed UUID",
+ pattern: "^[0-9a-fA-F]{32}$",
+ },
+ ipWithPrefixlen: {
+ msg: "must be a valid IP prefix",
+ emptyMsg: "value is empty, which is not a valid IP prefix",
+ check: (s) => isIpPrefix.call(s),
+ },
+ ipv4WithPrefixlen: {
+ msg: "must be a valid IPv4 address with prefix length",
+ emptyMsg:
+ "value is empty, which is not a valid IPv4 address with prefix length",
+ check: (s) => isIpPrefix.call(s, 4),
+ },
+ ipv6WithPrefixlen: {
+ msg: "must be a valid IPv6 address with prefix length",
+ emptyMsg:
+ "value is empty, which is not a valid IPv6 address with prefix length",
+ check: (s) => isIpPrefix.call(s, 6),
+ },
+ ipPrefix: {
+ msg: "must be a valid IP prefix",
+ emptyMsg: "value is empty, which is not a valid IP prefix",
+ check: (s) => isIpPrefix.call(s, undefined, true),
+ },
+ ipv4Prefix: {
+ msg: "must be a valid IPv4 prefix",
+ emptyMsg: "value is empty, which is not a valid IPv4 prefix",
+ check: (s) => isIpPrefix.call(s, 4, true),
+ },
+ ipv6Prefix: {
+ msg: "must be a valid IPv6 prefix",
+ emptyMsg: "value is empty, which is not a valid IPv6 prefix",
+ check: (s) => isIpPrefix.call(s, 6, true),
+ },
+ hostAndPort: {
+ msg: "must be a valid host (hostname or IP address) and port pair",
+ emptyMsg: "value is empty, which is not a valid host and port pair",
+ check: (s) => isHostAndPort.call(s, true),
+ },
+ ulid: {
+ msg: "must be a valid ULID",
+ emptyMsg: "value is empty, which is not a valid ULID",
+ pattern: "^[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$",
+ },
+ protobufFqn: {
+ msg: "must be a valid fully-qualified Protobuf name",
+ emptyMsg:
+ "value is empty, which is not a valid fully-qualified Protobuf name",
+ pattern: "^[A-Za-z_][A-Za-z_0-9]*(\\.[A-Za-z_][A-Za-z_0-9]*)*$",
+ },
+ protobufDotFqn: {
+ msg: "must be a valid fully-qualified Protobuf name with a leading dot",
+ emptyMsg:
+ "value is empty, which is not a valid fully-qualified Protobuf name with a leading dot",
+ pattern: "^\\.[A-Za-z_][A-Za-z_0-9]*(\\.[A-Za-z_][A-Za-z_0-9]*)*$",
+ },
+};
+
+// The `well_known_regex` patterns, matching the regexes the CEL expressions
+// on `StringRules.well_known_regex` build. CEL unescapes its string literals
+// before handing them to the regex engine (`\\x60` becomes a literal
+// backtick, `\\u0000` a literal NUL), so the CEL path's control characters
+// arrive raw. RE2 rejects a regex-level `\u` as an invalid escape sequence,
+// so the equivalent `\xHH` escapes are used here — they denote the same
+// codepoints and are valid in both RE2 and ECMAScript.
+// The loose patterns differ between header name (`+`) and header value
+// (`*`); CEL is the source of truth here, not protovalidate-go's shared
+// loose regex.
+const headerNameStrictPattern = "^:?[0-9a-zA-Z!#$%&'*+-.^_|~`]+$";
+const headerNameLoosePattern = "^[^\\x00\\x0A\\x0D]+$";
+const headerValueStrictPattern = "^[^\\x00-\\x08\\x0A-\\x1F\\x7F]*$";
+const headerValueLoosePattern = "^[^\\x00\\x0A\\x0D]*$";
+
+/**
+ * Configuration for {@link EvalNativeStringRules}. Bundled into a single
+ * object so callers don't have to track ~15 positional constructor args.
+ */
+type StringRulesConfig = {
+ readonly forMapKey: boolean;
+ readonly constRule?: StrRule;
+ readonly exactLen?: SizeRule;
+ readonly minLen?: SizeRule;
+ readonly maxLen?: SizeRule;
+ readonly exactBytes?: SizeRule;
+ readonly minBytes?: SizeRule;
+ readonly maxBytes?: SizeRule;
+ readonly pattern?: PatternRule;
+ readonly prefix?: StrRule;
+ readonly suffix?: StrRule;
+ readonly containsRule?: StrRule;
+ readonly notContainsRule?: StrRule;
+ readonly inRule?: StrListRule;
+ readonly notInRule?: StrListRule;
+ readonly wellKnown?: WellKnownRule;
+};
+
+// Checks run in the declaration order of the StringRules fields, which is
+// the order the CEL path evaluates the predefined rules in — keeping the
+// violation order identical between the two paths.
+class EvalNativeStringRules implements Eval {
+ constructor(private readonly cfg: StringRulesConfig) {}
+
+ eval(val: ScalarValue, cursor: Cursor): void {
+ const v = val as string;
+ const c = this.cfg;
+
+ if (c.constRule !== undefined && v !== c.constRule.val) {
+ cursor.violate(
+ `must equal \`${c.constRule.val}\``,
+ "string.const",
+ c.constRule.path,
+ c.forMapKey,
+ );
+ }
+
+ if (
+ c.exactLen !== undefined ||
+ c.minLen !== undefined ||
+ c.maxLen !== undefined
+ ) {
+ const len = BigInt(codepointLength(v));
+ if (c.exactLen !== undefined && len !== c.exactLen.val) {
+ cursor.violate(
+ `must be ${c.exactLen.val} characters`,
+ "string.len",
+ c.exactLen.path,
+ c.forMapKey,
+ );
+ }
+ if (c.minLen !== undefined && len < c.minLen.val) {
+ cursor.violate(
+ `must be at least ${c.minLen.val} characters`,
+ "string.min_len",
+ c.minLen.path,
+ c.forMapKey,
+ );
+ }
+ if (c.maxLen !== undefined && len > c.maxLen.val) {
+ cursor.violate(
+ `must be at most ${c.maxLen.val} characters`,
+ "string.max_len",
+ c.maxLen.path,
+ c.forMapKey,
+ );
+ }
+ }
+
+ if (
+ c.exactBytes !== undefined ||
+ c.minBytes !== undefined ||
+ c.maxBytes !== undefined
+ ) {
+ const len = BigInt(utf8ByteLength(v));
+ if (c.exactBytes !== undefined && len !== c.exactBytes.val) {
+ cursor.violate(
+ `must be ${c.exactBytes.val} bytes`,
+ "string.len_bytes",
+ c.exactBytes.path,
+ c.forMapKey,
+ );
+ }
+ if (c.minBytes !== undefined && len < c.minBytes.val) {
+ cursor.violate(
+ `must be at least ${c.minBytes.val} bytes`,
+ "string.min_bytes",
+ c.minBytes.path,
+ c.forMapKey,
+ );
+ }
+ if (c.maxBytes !== undefined && len > c.maxBytes.val) {
+ cursor.violate(
+ `must be at most ${c.maxBytes.val} bytes`,
+ "string.max_bytes",
+ c.maxBytes.path,
+ c.forMapKey,
+ );
+ }
+ }
+
+ if (c.pattern !== undefined) {
+ // Wrap test() — if a user-supplied regexMatch throws, surface it as a
+ // RuntimeError so CEL's behavior is preserved end-to-end. The default
+ // RE2 engine doesn't throw at match time.
+ let matched: boolean;
+ try {
+ matched = c.pattern.test(v);
+ } catch (cause) {
+ throw new RuntimeError(`regex match failed for ${c.pattern.src}`, {
+ cause,
+ });
+ }
+ if (!matched) {
+ cursor.violate(
+ `does not match regex pattern \`${c.pattern.src}\``,
+ "string.pattern",
+ c.pattern.path,
+ c.forMapKey,
+ );
+ }
+ }
+
+ if (c.prefix !== undefined && !v.startsWith(c.prefix.val)) {
+ cursor.violate(
+ `does not have prefix \`${c.prefix.val}\``,
+ "string.prefix",
+ c.prefix.path,
+ c.forMapKey,
+ );
+ }
+
+ if (c.suffix !== undefined && !v.endsWith(c.suffix.val)) {
+ cursor.violate(
+ `does not have suffix \`${c.suffix.val}\``,
+ "string.suffix",
+ c.suffix.path,
+ c.forMapKey,
+ );
+ }
+
+ if (c.containsRule !== undefined && !v.includes(c.containsRule.val)) {
+ cursor.violate(
+ `does not contain substring \`${c.containsRule.val}\``,
+ "string.contains",
+ c.containsRule.path,
+ c.forMapKey,
+ );
+ }
+
+ if (c.notContainsRule !== undefined && v.includes(c.notContainsRule.val)) {
+ cursor.violate(
+ `contains substring \`${c.notContainsRule.val}\``,
+ "string.not_contains",
+ c.notContainsRule.path,
+ c.forMapKey,
+ );
+ }
+
+ if (c.inRule !== undefined && !c.inRule.vals.includes(v)) {
+ cursor.violate(
+ `must be in list ${formatList(c.inRule.vals, (s) => s)}`,
+ "string.in",
+ c.inRule.path,
+ c.forMapKey,
+ );
+ }
+
+ if (c.notInRule?.vals.includes(v)) {
+ cursor.violate(
+ `must not be in list ${formatList(c.notInRule.vals, (s) => s)}`,
+ "string.not_in",
+ c.notInRule.path,
+ c.forMapKey,
+ );
+ }
+
+ if (c.wellKnown !== undefined) {
+ const wk = c.wellKnown;
+ if (wk.empty !== undefined && v === "") {
+ cursor.violate(wk.empty.msg, wk.empty.ruleId, wk.path, c.forMapKey);
+ } else if (!wk.check(v)) {
+ cursor.violate(wk.msg, wk.ruleId, wk.path, c.forMapKey);
+ }
+ }
+ }
+
+ prune(): boolean {
+ return false;
+ }
+}
+
+/**
+ * Build a match predicate for a pattern under the active regex engine.
+ * Returns `undefined` if the pattern doesn't compile — the caller falls
+ * through to CEL, which surfaces the same failure the way it already does
+ * today.
+ */
+function makePatternTest(
+ src: string,
+ regexMatch: RegexMatcher | undefined,
+): ((against: string) => boolean) | undefined {
+ try {
+ if (regexMatch) {
+ // Probe the engine at plan time so an invalid pattern surfaces here,
+ // symmetric with the default engine's eager compile. Empty input is
+ // the contract-safe probe — a regex engine must be able to test any
+ // pattern against the empty string.
+ regexMatch(src, "");
+ return (against) => regexMatch(src, against);
+ }
+ const re = new RegExp(src);
+ return (against) => re.test(against);
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * Try to build a native evaluator for StringRules. Returns `undefined` if
+ * no native handler applies (no fields set, unknown extensions, or an
+ * uncompilable pattern that we let CEL surface).
+ *
+ * String rules are handled all-or-nothing: a single set field the native
+ * path can't take falls the entire rules message through to CEL, so the
+ * violation order always matches the pure-CEL path.
+ */
+export function tryBuildNativeStringRules(
+ rules: StringRules,
+ rulePath: PathBuilder,
+ forMapKey: boolean,
+ regexMatch: RegexMatcher | undefined,
+): ScalarNativeResult | undefined {
+ if (rules.$unknown && rules.$unknown.length > 0) {
+ return undefined;
+ }
+
+ const handled = new Set();
+ const cfg: {
+ -readonly [K in keyof StringRulesConfig]: StringRulesConfig[K];
+ } = { forMapKey };
+
+ if (isFieldSet(rules, F.const)) {
+ cfg.constRule = {
+ val: rules.const,
+ path: rulePath.clone().field(F.const).toPath(),
+ };
+ handled.add(F.const);
+ }
+
+ if (isFieldSet(rules, F.len)) {
+ cfg.exactLen = {
+ val: rules.len,
+ path: rulePath.clone().field(F.len).toPath(),
+ };
+ handled.add(F.len);
+ }
+
+ if (isFieldSet(rules, F.minLen)) {
+ cfg.minLen = {
+ val: rules.minLen,
+ path: rulePath.clone().field(F.minLen).toPath(),
+ };
+ handled.add(F.minLen);
+ }
+
+ if (isFieldSet(rules, F.maxLen)) {
+ cfg.maxLen = {
+ val: rules.maxLen,
+ path: rulePath.clone().field(F.maxLen).toPath(),
+ };
+ handled.add(F.maxLen);
+ }
+
+ if (isFieldSet(rules, F.lenBytes)) {
+ cfg.exactBytes = {
+ val: rules.lenBytes,
+ path: rulePath.clone().field(F.lenBytes).toPath(),
+ };
+ handled.add(F.lenBytes);
+ }
+
+ if (isFieldSet(rules, F.minBytes)) {
+ cfg.minBytes = {
+ val: rules.minBytes,
+ path: rulePath.clone().field(F.minBytes).toPath(),
+ };
+ handled.add(F.minBytes);
+ }
+
+ if (isFieldSet(rules, F.maxBytes)) {
+ cfg.maxBytes = {
+ val: rules.maxBytes,
+ path: rulePath.clone().field(F.maxBytes).toPath(),
+ };
+ handled.add(F.maxBytes);
+ }
+
+ if (isFieldSet(rules, F.pattern)) {
+ const test = makePatternTest(rules.pattern, regexMatch);
+ if (test === undefined) {
+ return undefined;
+ }
+ cfg.pattern = {
+ src: rules.pattern,
+ test,
+ path: rulePath.clone().field(F.pattern).toPath(),
+ };
+ handled.add(F.pattern);
+ }
+
+ if (isFieldSet(rules, F.prefix)) {
+ cfg.prefix = {
+ val: rules.prefix,
+ path: rulePath.clone().field(F.prefix).toPath(),
+ };
+ handled.add(F.prefix);
+ }
+
+ if (isFieldSet(rules, F.suffix)) {
+ cfg.suffix = {
+ val: rules.suffix,
+ path: rulePath.clone().field(F.suffix).toPath(),
+ };
+ handled.add(F.suffix);
+ }
+
+ if (isFieldSet(rules, F.contains)) {
+ cfg.containsRule = {
+ val: rules.contains,
+ path: rulePath.clone().field(F.contains).toPath(),
+ };
+ handled.add(F.contains);
+ }
+
+ if (isFieldSet(rules, F.notContains)) {
+ cfg.notContainsRule = {
+ val: rules.notContains,
+ path: rulePath.clone().field(F.notContains).toPath(),
+ };
+ handled.add(F.notContains);
+ }
+
+ if (rules.in.length > 0) {
+ cfg.inRule = {
+ vals: rules.in,
+ path: rulePath.clone().field(F.in).toPath(),
+ };
+ handled.add(F.in);
+ }
+
+ if (rules.notIn.length > 0) {
+ cfg.notInRule = {
+ vals: rules.notIn,
+ path: rulePath.clone().field(F.notIn).toPath(),
+ };
+ handled.add(F.notIn);
+ }
+
+ const wk = rules.wellKnown;
+ if (wk.case === "wellKnownRegex") {
+ // Claim the field on isFieldSet regardless of value — for UNSPECIFIED
+ // (or an unrecognized enum number) the CEL predicates `!= 1` / `!= 2`
+ // never fire, so the claim is a no-op, matching `bytes.ip: false`.
+ handled.add(F.wellKnownRegex);
+ const path = rulePath.clone().field(F.wellKnownRegex).toPath();
+ // strict is on by default; only an explicit `strict: false` loosens.
+ const strict = !isFieldSet(rules, F.strict) || rules.strict;
+ if (wk.value === KnownRegex.HTTP_HEADER_NAME) {
+ const test = makePatternTest(
+ strict ? headerNameStrictPattern : headerNameLoosePattern,
+ regexMatch,
+ );
+ if (test === undefined) {
+ return undefined;
+ }
+ cfg.wellKnown = {
+ check: test,
+ ruleId: "string.well_known_regex.header_name",
+ msg: "must be a valid HTTP header name",
+ empty: {
+ ruleId: "string.well_known_regex.header_name_empty",
+ msg: "value is empty, which is not a valid HTTP header name",
+ },
+ path,
+ };
+ } else if (wk.value === KnownRegex.HTTP_HEADER_VALUE) {
+ const test = makePatternTest(
+ strict ? headerValueStrictPattern : headerValueLoosePattern,
+ regexMatch,
+ );
+ if (test === undefined) {
+ return undefined;
+ }
+ cfg.wellKnown = {
+ check: test,
+ ruleId: "string.well_known_regex.header_value",
+ msg: "must be a valid HTTP header value",
+ path,
+ };
+ }
+ } else if (wk.case !== undefined) {
+ // Claim the leaf field on isFieldSet regardless of value — explicit
+ // `email: false` is a no-op rule, matching `bytes.ip: false`
+ // (`bytes.ts`) and `float.finite: false` (`numeric.ts`).
+ const desc = F[wk.case];
+ handled.add(desc);
+ if (wk.value) {
+ const spec = WELL_KNOWN[wk.case];
+ let check = spec.check;
+ if (check === undefined) {
+ check = makePatternTest(spec.pattern as string, regexMatch);
+ if (check === undefined) {
+ return undefined;
+ }
+ }
+ cfg.wellKnown = {
+ check,
+ ruleId: `string.${desc.name}`,
+ msg: spec.msg,
+ empty:
+ spec.emptyMsg === undefined
+ ? undefined
+ : {
+ ruleId: `string.${desc.name}_empty`,
+ msg: spec.emptyMsg,
+ },
+ path: rulePath.clone().field(desc).toPath(),
+ };
+ }
+ }
+
+ if (handled.size === 0) {
+ return undefined;
+ }
+
+ return {
+ eval: new EvalNativeStringRules(cfg),
+ handledFields: handled,
+ };
+}
diff --git a/packages/protovalidate/src/native/testing.ts b/packages/protovalidate/src/native/testing.ts
new file mode 100644
index 0000000..3c742c8
--- /dev/null
+++ b/packages/protovalidate/src/native/testing.ts
@@ -0,0 +1,100 @@
+// Copyright 2024-2026 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 { readFileSync } from "node:fs";
+import * as assert from "node:assert/strict";
+import type { DescMessage, Message } from "@bufbuild/protobuf";
+import { pathToString } from "@bufbuild/protobuf/reflect";
+import { compileFile } from "@bufbuild/protocompile";
+import { createValidator } from "../validator.js";
+import type { Violation } from "../error.js";
+
+/**
+ * Shared compile options that hand the inline-proto compiler the
+ * `buf.validate.validate.proto` from this package's `proto/` directory.
+ */
+export const bufCompileOptions = {
+ imports: {
+ "buf/validate/validate.proto": readFileSync(
+ "proto/buf/validate/validate.proto",
+ "utf-8",
+ ),
+ },
+};
+
+/** Validator running the native path (default). */
+export const native = createValidator();
+
+/** Validator with native rules disabled — i.e., the pure-CEL reference. */
+export const cel = createValidator({ disableNativeRules: true });
+
+/**
+ * Validate a fixture under both the native and CEL paths and assert their
+ * Violation arrays are identical across every field: message, ruleId,
+ * field path, rule path, and forKey.
+ *
+ * This compares the whole Violation rather than `Violation.toString()`,
+ * which renders only `: []` and so is blind to
+ * `rule` and `forKey`. Two violations that differ only in those — a map with
+ * the same rule on its key and value, say — stringify identically, and a
+ * native handler that mixed them up would slip through.
+ *
+ * This is the workhorse assertion for native-rule unit tests — every native
+ * handler must reproduce CEL output exactly, and the simplest way to prove
+ * that is to run the same input through both paths and compare.
+ */
+export function diff(schema: DescMessage, msg: Message): void {
+ const a = native.validate(schema, msg);
+ const b = cel.validate(schema, msg);
+ assert.equal(a.kind, b.kind, "kind mismatch");
+ // Paths are arrays of descriptors; render them so deepEqual compares the
+ // paths themselves rather than descriptor object identity.
+ const fmt = (v: Violation) => ({
+ ...v,
+ field: pathToString(v.field),
+ rule: pathToString(v.rule),
+ });
+ assert.deepEqual(a.violations?.map(fmt), b.violations?.map(fmt));
+}
+
+/**
+ * Compile an inline proto3 schema and return the message named `M`.
+ *
+ * The supplied `definition` must declare a `message M { ... }`. Tests that
+ * need helper types (extra messages, enums) declare them via `preamble`.
+ * Imports for `buf.validate.validate.proto` and the WKT scalar wrappers are
+ * always included; they are harmless when unused.
+ */
+export function compile(
+ definition: string,
+ opts?: { preamble?: string },
+): DescMessage {
+ const file = compileFile(
+ `
+ syntax = "proto3";
+ import "buf/validate/validate.proto";
+ import "google/protobuf/wrappers.proto";
+ ${opts?.preamble ?? ""}
+ ${definition}
+ `,
+ bufCompileOptions,
+ );
+ const m = file.messages.find((m) => m.name === "M");
+ if (!m) {
+ throw new Error(
+ "native-rule tests must define `message M { ... }` as the validation target",
+ );
+ }
+ return m;
+}
diff --git a/packages/protovalidate/src/native/wrapper.ts b/packages/protovalidate/src/native/wrapper.ts
new file mode 100644
index 0000000..1ee5c48
--- /dev/null
+++ b/packages/protovalidate/src/native/wrapper.ts
@@ -0,0 +1,42 @@
+// Copyright 2024-2026 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 type { DescField } from "@bufbuild/protobuf";
+import type { ReflectMessage, ScalarValue } from "@bufbuild/protobuf/reflect";
+import type { Cursor } from "../cursor.js";
+import type { Eval } from "../eval.js";
+
+/**
+ * Adapter that bridges a scalar-typed native evaluator to the wrapper message
+ * eval site.
+ *
+ * When a `google.protobuf.{Int32,Int64,...}Value` field is validated against
+ * scalar rules (Int32Rules, etc.), the planner hands the eval a
+ * `ReflectMessage` rather than a `ScalarValue`. This adapter reads the inner
+ * `value` field and delegates to the scalar-typed evaluator.
+ */
+export class WrappedValueEval implements Eval {
+ constructor(
+ private readonly valueField: DescField,
+ private readonly inner: Eval,
+ ) {}
+
+ eval(val: ReflectMessage, cursor: Cursor): void {
+ this.inner.eval(val.get(this.valueField) as ScalarValue, cursor);
+ }
+
+ prune(): boolean {
+ return this.inner.prune();
+ }
+}
diff --git a/packages/protovalidate/src/planner.ts b/packages/protovalidate/src/planner.ts
index 020e36f..d290f9a 100644
--- a/packages/protovalidate/src/planner.ts
+++ b/packages/protovalidate/src/planner.ts
@@ -23,7 +23,10 @@ import {
isMessage,
ScalarType,
} from "@bufbuild/protobuf";
-import { FeatureSet_FieldPresence } from "@bufbuild/protobuf/wkt";
+import {
+ FeatureSet_FieldPresence,
+ isWrapperDesc,
+} from "@bufbuild/protobuf/wkt";
import {
type FieldRules,
type MessageRules,
@@ -256,7 +259,7 @@ export class Planner {
field,
);
if (rules) {
- evals.add(this.rules(rules, rulePath, false));
+ evals.add(this.rules(rules, rulePath, false, undefined, field));
}
const itemsRules = rules?.items;
switch (field.listKind) {
@@ -423,7 +426,10 @@ export class Planner {
if (isMessage(rules, AnyRulesSchema)) {
evals.add(new EvalAnyRules(rulePath, rules));
}
- evals.add(this.rules(rules, rulePath, false));
+ const wrappedValueField = isWrapperDesc(descMessage)
+ ? descMessage.field.value
+ : undefined;
+ evals.add(this.rules(rules, rulePath, false, wrappedValueField));
}
return evals;
}
@@ -432,40 +438,47 @@ export class Planner {
rules: Exclude,
rulePath: PathBuilder,
forMapKey: boolean,
+ wrappedValueField: DescField | undefined = undefined,
+ listField: (DescField & { fieldKind: "list" }) | undefined = undefined,
) {
const ruleDesc = getRuleDescriptor(rules.$typeName);
const prepared = this.celMan.compileRules(ruleDesc);
const native = this.disableNativeRules
- ? ({ kind: "none" } as const)
+ ? undefined
: tryBuildNative({
rules,
rulePath,
forMapKey,
+ wrappedValueField,
+ listField,
regexMatch: this.regexMatch,
});
- const evalStandard = new EvalStandardRulesCel(
- this.celMan,
- rules,
- forMapKey,
- );
- const handled = native.kind === "none" ? undefined : native.handledFields;
+ // Standard CEL plans: enroll every field whose rule isn't claimed by
+ // the native dispatcher. In situations where native rules handle every specified
+ // rule, the CEL evaluator stays empty. Leave it out of the tree entirely so
+ // its per-iteration setEnv() calls don't run. The handledFields set from
+ // tryBuildNative is what tells us which plans the native path took.
+ let evalStandard: EvalStandardRulesCel | undefined;
for (const plan of prepared.standard) {
if (!isFieldSet(rules, plan.field)) {
continue;
}
- if (handled?.has(plan.field)) {
+ if (native?.handledFields.has(plan.field)) {
continue;
}
+ evalStandard ??= new EvalStandardRulesCel(this.celMan, rules, forMapKey);
evalStandard.add(
plan.compiled,
rulePath.clone().field(plan.field).toPath(),
);
}
- const evalExtended = new EvalExtendedRulesCel(
- this.celMan,
- rules,
- forMapKey,
- );
+ // Extended CEL plans: only allocate the wrapper when an $unknown
+ // extension actually contributes a rule. Native handlers bail on rules
+ // with $unknown fields, so this evaluator is mutually exclusive with
+ // the native path — but constructing it eagerly costs ~13-34% per
+ // validate across the suite because EvalExtendedRulesCel.eval() still
+ // runs two setEnv calls per field even when empty.
+ let evalExtended: EvalExtendedRulesCel | undefined;
if (rules.$unknown) {
for (const uf of rules.$unknown) {
const plans = prepared.extensions.get(uf.no);
@@ -475,6 +488,11 @@ export class Planner {
);
}
for (const plan of plans) {
+ evalExtended ??= new EvalExtendedRulesCel(
+ this.celMan,
+ rules,
+ forMapKey,
+ );
evalExtended.add(
plan.compiled,
rulePath.clone().extension(plan.ext).toPath(),
@@ -484,11 +502,10 @@ export class Planner {
}
}
}
- const combined = new EvalMany(
- evalStandard,
- evalExtended,
- );
- if (native.kind !== "none") {
+ const combined = new EvalMany();
+ if (evalStandard !== undefined) combined.add(evalStandard);
+ if (evalExtended !== undefined) combined.add(evalExtended);
+ if (native !== undefined) {
combined.add(native.eval);
}
return combined;
diff --git a/packages/protovalidate/src/regex.ts b/packages/protovalidate/src/regex.ts
new file mode 100644
index 0000000..d02727b
--- /dev/null
+++ b/packages/protovalidate/src/regex.ts
@@ -0,0 +1,44 @@
+// Copyright 2024-2026 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 { RE2JS } from "@bufbuild/re2";
+import type { RegexMatcher } from "./func.js";
+
+// Most validators see a fixed set of patterns (they come from schema rules),
+// so a simple compile cache makes repeat matches cheap. User CEL rules can
+// construct patterns from input data, though, so the cache is reset when it
+// grows past this bound to keep pathological inputs from leaking memory.
+const cacheLimit = 1024;
+
+const cache = new Map();
+
+/**
+ * The default {@link RegexMatcher}, backed by an RE2 engine.
+ *
+ * Patterns are compiled with RE2 syntax and matched in linear time,
+ * fulfilling protovalidate's RE2 contract for `string.pattern`,
+ * `bytes.pattern`, and the CEL `matches()` function. Throws on patterns
+ * that are not valid RE2 syntax.
+ */
+export function re2RegexMatch(pattern: string, against: string): boolean {
+ let re = cache.get(pattern);
+ if (re === undefined) {
+ if (cache.size >= cacheLimit) {
+ cache.clear();
+ }
+ re = RE2JS.compile(pattern);
+ cache.set(pattern, re);
+ }
+ return re.test(against);
+}
diff --git a/packages/protovalidate/src/validator.ts b/packages/protovalidate/src/validator.ts
index 33c2a94..0138a34 100644
--- a/packages/protovalidate/src/validator.ts
+++ b/packages/protovalidate/src/validator.ts
@@ -33,6 +33,7 @@ import {
import { Planner } from "./planner.js";
import { CelManager } from "./cel.js";
import type { RegexMatcher } from "./func.js";
+import { re2RegexMatch } from "./regex.js";
import { file_buf_validate_validate } from "./gen/buf/validate/validate_pb.js";
/**
@@ -60,12 +61,15 @@ export type ValidatorOptions = {
/**
* RE2 compliant regex matcher to use.
*
- * ECMAScript supports most, but not all RE expressions. You can use a custom
- * regex engine to support the unsupported features of RE2.
+ * By default, regular expressions are evaluated with an RE2 engine
+ * (the @bufbuild/re2 package), matching protovalidate's documented RE2
+ * contract: RE2 syntax, linear-time matching, no backreferences or
+ * lookaround.
*
- * Know limitations of default RE (ECMAScript) matcher:
- * * Cannot change flags mid-sequence e.g. 'John(?i)Doe'.
- * * Doesn't support the 'U' flag.
+ * This option is the bring-your-own-engine hook: a matcher supplied here
+ * replaces the default for every regex the validator evaluates — the
+ * `string.pattern` and `bytes.pattern` rules, and the CEL `matches()`
+ * function.
*/
regexMatch?: RegexMatcher;
@@ -147,7 +151,7 @@ export function createValidator(opt?: ValidatorOptions): Validator {
? createMutableRegistry(opt.registry, file_buf_validate_validate)
: createMutableRegistry(file_buf_validate_validate);
const failFast = opt?.failFast ?? false;
- const regexMatch = opt?.regexMatch;
+ const regexMatch = opt?.regexMatch ?? re2RegexMatch;
const celMan = new CelManager(registry, regexMatch);
const planner = new Planner(
celMan,
diff --git a/packages/protovalidate/tsconfig.json b/packages/protovalidate/tsconfig.json
index 485440c..96384cd 100644
--- a/packages/protovalidate/tsconfig.json
+++ b/packages/protovalidate/tsconfig.json
@@ -1,9 +1,12 @@
{
- "include": ["src/index.ts", "src/**/*.test.ts"],
+ // The emitted program: only what is reachable from the public entry point.
+ // Tests and their helpers are typechecked by tsconfig.test.json instead, so
+ // they never land in dist/ (and therefore never in the published tarball).
+ "include": ["src/index.ts"],
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": [
- // For Error.cause in error.ts and error.test.ts
+ // For Error.cause in error.ts
"ES2022.Error"
]
}
diff --git a/turbo.json b/turbo.json
index ce63b99..9ceb38f 100644
--- a/turbo.json
+++ b/turbo.json
@@ -1,5 +1,6 @@
{
"$schema": "https://turbo.build/schema.json",
+ "globalPassThroughEnv": ["PROTOVALIDATE_DISABLE_NATIVE_RULES"],
"tasks": {
"build": {
"dependsOn": ["^build", "generate"],
@@ -22,6 +23,10 @@
"dependsOn": ["^build", "generate"],
"cache": false
},
+ "typecheck": {
+ "dependsOn": ["^build", "generate"],
+ "outputLogs": "new-only"
+ },
"format": {
"outputLogs": "new-only"
},
@@ -32,6 +37,10 @@
"dependsOn": ["format", "^build", "generate"],
"cache": false
},
+ "bench": {
+ "dependsOn": ["^build"],
+ "cache": false
+ },
"attw": {
"dependsOn": ["build"],
"outputLogs": "new-only"