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"