Skip to content

Commit acfa5ee

Browse files
Add infrastructure to support native rules (#160)
1 parent c351baf commit acfa5ee

7 files changed

Lines changed: 282 additions & 3 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright 2024-2026 Buf Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import type { DescField } from "@bufbuild/protobuf";
16+
import type {
17+
PathBuilder,
18+
ReflectMessageGet,
19+
} from "@bufbuild/protobuf/reflect";
20+
import type { FieldRules } from "../gen/buf/validate/validate_pb.js";
21+
import type { Eval } from "../eval.js";
22+
import type { RegexMatcher } from "../func.js";
23+
24+
/**
25+
* Result of {@link tryBuildNative}.
26+
*
27+
* - "none": no native handler applies; the planner enrolls every set field in
28+
* the CEL evaluator as it does today.
29+
* - "partial" / "full": at least one field is handled natively. The planner
30+
* skips CEL enrollment for fields in `handledFields` and appends `eval` to
31+
* the rule's `EvalMany`. "full" indicates every set field on the rules
32+
* message was handled natively, so the trailing `EvalStandardRulesCel` will
33+
* be empty and pruned.
34+
*/
35+
export type NativeDispatchResult =
36+
| { kind: "none" }
37+
| {
38+
kind: "partial" | "full";
39+
eval: Eval<ReflectMessageGet>;
40+
handledFields: ReadonlySet<DescField>;
41+
};
42+
43+
/**
44+
* Inputs to the native rule dispatcher.
45+
*
46+
* Future phases add per-field-type evaluators here. Phase 0 wires the seam
47+
* but always returns `{ kind: "none" }`.
48+
*/
49+
export type NativeDispatchInput = {
50+
rules: Exclude<FieldRules["type"]["value"], undefined>;
51+
rulePath: PathBuilder;
52+
forMapKey: boolean;
53+
regexMatch: RegexMatcher | undefined;
54+
};
55+
56+
/**
57+
* Decide whether the given rules submessage can be evaluated natively, and
58+
* return an `Eval` for the handled subset plus the set of rule fields that
59+
* have been claimed (so the planner skips them on the CEL path).
60+
*
61+
* Phase 0: stub. Returns `{ kind: "none" }` so the CEL path handles every
62+
* rule exactly as before.
63+
*/
64+
export function tryBuildNative(
65+
_input: NativeDispatchInput,
66+
): NativeDispatchResult {
67+
return { kind: "none" };
68+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright 2024-2026 Buf Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import { suite, test } from "node:test";
16+
import * as assert from "node:assert/strict";
17+
import { codepointLength, printFloat } from "./format.js";
18+
19+
void suite("codepointLength", () => {
20+
void test("counts ASCII as one per char", () => {
21+
assert.strictEqual(codepointLength(""), 0);
22+
assert.strictEqual(codepointLength("a"), 1);
23+
assert.strictEqual(codepointLength("abc"), 3);
24+
});
25+
26+
void test("counts surrogate pair as one code point", () => {
27+
// U+1D44E MATHEMATICAL ITALIC SMALL A → surrogate pair "𝑎"
28+
const s = "𝑎";
29+
assert.strictEqual(s.length, 2);
30+
assert.strictEqual(codepointLength(s), 1);
31+
});
32+
33+
void test("counts combining marks separately", () => {
34+
// "é" as e + combining acute accent: 2 code points.
35+
const s = "é";
36+
assert.strictEqual(codepointLength(s), 2);
37+
});
38+
39+
void test("matches CEL size() spread-based count", () => {
40+
const cases = ["", "x", "𝑎b", "🇺🇸", "héllo"];
41+
for (const s of cases) {
42+
assert.strictEqual(codepointLength(s), [...s].length);
43+
}
44+
});
45+
});
46+
47+
void suite("printFloat", () => {
48+
void test("formats finite numbers via toString", () => {
49+
assert.strictEqual(printFloat(0), "0");
50+
assert.strictEqual(printFloat(1), "1");
51+
assert.strictEqual(printFloat(-1.5), "-1.5");
52+
assert.strictEqual(printFloat(1e20), "100000000000000000000");
53+
});
54+
55+
void test("formats NaN", () => {
56+
assert.strictEqual(printFloat(Number.NaN), "NaN");
57+
});
58+
59+
void test("formats +Infinity", () => {
60+
assert.strictEqual(printFloat(Number.POSITIVE_INFINITY), "Infinity");
61+
});
62+
63+
void test("formats -Infinity", () => {
64+
assert.strictEqual(printFloat(Number.NEGATIVE_INFINITY), "-Infinity");
65+
});
66+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Copyright 2024-2026 Buf Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
/**
16+
* Number of Unicode code points in a string.
17+
*
18+
* Matches CEL's `size(string)` semantics — counting code points rather than
19+
* UTF-16 code units. A surrogate pair like "𝑎" counts as 1.
20+
*/
21+
export function codepointLength(s: string): number {
22+
// String iteration yields one element per code point.
23+
let n = 0;
24+
for (const _ of s) {
25+
n++;
26+
}
27+
return n;
28+
}
29+
30+
/**
31+
* Format a number for inclusion in a violation message.
32+
*
33+
* Mirrors protovalidate-go's `printFloat` so error messages match the CEL
34+
* implementation byte-for-byte.
35+
*/
36+
export function printFloat(n: number): string {
37+
if (Number.isNaN(n)) {
38+
return "NaN";
39+
}
40+
if (n === Number.POSITIVE_INFINITY) {
41+
return "Infinity";
42+
}
43+
if (n === Number.NEGATIVE_INFINITY) {
44+
return "-Infinity";
45+
}
46+
return n.toString();
47+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Copyright 2024-2026 Buf Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
export {
16+
tryBuildNative,
17+
type NativeDispatchInput,
18+
type NativeDispatchResult,
19+
} from "./dispatcher.js";

packages/protovalidate/src/planner.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,17 @@ import {
7979
EvalStandardRulesCel,
8080
} from "./cel.js";
8181
import { CompilationError } from "./error.js";
82+
import { tryBuildNative } from "./native/index.js";
83+
import type { RegexMatcher } from "./func.js";
8284

8385
export class Planner {
8486
private readonly messageCache = new Map<DescMessage, Eval<ReflectMessage>>();
8587

8688
constructor(
8789
private readonly celMan: CelManager,
8890
private readonly legacyRequired: boolean,
91+
private readonly disableNativeRules: boolean,
92+
private readonly regexMatch: RegexMatcher | undefined,
8993
) {}
9094

9195
plan(message: DescMessage): Eval<ReflectMessage> {
@@ -431,15 +435,27 @@ export class Planner {
431435
) {
432436
const ruleDesc = getRuleDescriptor(rules.$typeName);
433437
const prepared = this.celMan.compileRules(ruleDesc);
438+
const native = this.disableNativeRules
439+
? ({ kind: "none" } as const)
440+
: tryBuildNative({
441+
rules,
442+
rulePath,
443+
forMapKey,
444+
regexMatch: this.regexMatch,
445+
});
434446
const evalStandard = new EvalStandardRulesCel(
435447
this.celMan,
436448
rules,
437449
forMapKey,
438450
);
451+
const handled = native.kind === "none" ? undefined : native.handledFields;
439452
for (const plan of prepared.standard) {
440453
if (!isFieldSet(rules, plan.field)) {
441454
continue;
442455
}
456+
if (handled?.has(plan.field)) {
457+
continue;
458+
}
443459
evalStandard.add(
444460
plan.compiled,
445461
rulePath.clone().field(plan.field).toPath(),
@@ -468,7 +484,14 @@ export class Planner {
468484
}
469485
}
470486
}
471-
return new EvalMany(evalStandard, evalExtended);
487+
const combined = new EvalMany<ReflectMessageGet>(
488+
evalStandard,
489+
evalExtended,
490+
);
491+
if (native.kind !== "none") {
492+
combined.add(native.eval);
493+
}
494+
return combined;
472495
}
473496

474497
private messageCel(messageRules: MessageRules): Eval<ReflectMessage> {

packages/protovalidate/src/validator.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,45 @@ void suite("Validator", () => {
296296
assert.equal(result.kind, "valid");
297297
});
298298
});
299+
void suite("option disableNativeRules", () => {
300+
const schema = compileMessage(
301+
`
302+
syntax="proto3";
303+
import "buf/validate/validate.proto";
304+
message Example {
305+
int32 n = 1 [(buf.validate.field).int32.gt = 0];
306+
string s = 2 [(buf.validate.field).string.min_len = 3];
307+
}
308+
`,
309+
bufCompileOptions,
310+
);
311+
const invalid = create(schema, { n: 0, s: "ab" });
312+
const valid = create(schema, { n: 1, s: "abc" });
313+
void test("createValidator accepts the option", () => {
314+
const v = createValidator({ disableNativeRules: true });
315+
assert.ok(typeof v.validate == "function");
316+
});
317+
void test("default and disabled paths agree on a valid message", () => {
318+
const def = createValidator().validate(schema, valid);
319+
const off = createValidator({ disableNativeRules: true }).validate(
320+
schema,
321+
valid,
322+
);
323+
assert.equal(def.kind, "valid");
324+
assert.equal(off.kind, "valid");
325+
});
326+
void test("default and disabled paths produce identical violations", () => {
327+
const def = createValidator().validate(schema, invalid);
328+
const off = createValidator({ disableNativeRules: true }).validate(
329+
schema,
330+
invalid,
331+
);
332+
assert.equal(def.kind, "invalid");
333+
assert.equal(off.kind, "invalid");
334+
const fmt = (v: Violation) => v.toString();
335+
assert.deepEqual(def.violations?.map(fmt), off.violations?.map(fmt));
336+
});
337+
});
299338
void suite("predefined rules", () => {
300339
const descFile = compileFile(
301340
`

packages/protovalidate/src/validator.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,17 @@ export type ValidatorOptions = {
7777
* By default, legacy required field are not validated.
7878
*/
7979
legacyRequired?: boolean;
80+
81+
/**
82+
* Disable the native (non-CEL) implementation of standard rules.
83+
*
84+
* By default, validation uses hand-written checks for most standard
85+
* buf.validate.field rules. Setting this option to true forces every
86+
* standard rule through CEL instead. Behavior is identical either way;
87+
* the flag exists to isolate the native path during debugging or to
88+
* compare conformance.
89+
*/
90+
disableNativeRules?: boolean;
8091
};
8192

8293
/**
@@ -136,8 +147,14 @@ export function createValidator(opt?: ValidatorOptions): Validator {
136147
? createMutableRegistry(opt.registry, file_buf_validate_validate)
137148
: createMutableRegistry(file_buf_validate_validate);
138149
const failFast = opt?.failFast ?? false;
139-
const celMan = new CelManager(registry, opt?.regexMatch);
140-
const planner = new Planner(celMan, opt?.legacyRequired ?? false);
150+
const regexMatch = opt?.regexMatch;
151+
const celMan = new CelManager(registry, regexMatch);
152+
const planner = new Planner(
153+
celMan,
154+
opt?.legacyRequired ?? false,
155+
opt?.disableNativeRules ?? false,
156+
regexMatch,
157+
);
141158
return {
142159
validate<
143160
Desc extends DescMessage,

0 commit comments

Comments
 (0)