diff --git a/Makefile b/Makefile index 9849c9a9..2aa35c07 100644 --- a/Makefile +++ b/Makefile @@ -29,8 +29,9 @@ clean: ## Delete intermediate build artifacts $(GRADLE) clean .PHONY: conformance -conformance: ## Execute conformance tests. - $(GRADLE) conformance:conformance +conformance: ## Execute conformance tests with native rule evaluators enabled and disabled. + ENABLE_NATIVE_RULES=true $(GRADLE) conformance:conformance + ENABLE_NATIVE_RULES=false $(GRADLE) conformance:conformance .PHONY: help help: ## Describe useful make targets diff --git a/README.md b/README.md index 48a6796a..7a535db8 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,19 @@ Highlights for Java developers include: * A comprehensive RPC quickstart for [Java and gRPC][grpc-java] * A [migration guide for protoc-gen-validate][migration-guide] users +## Native rule evaluators (opt-out) + +The standard rules can be evaluated either through CEL or through native Java code. Native evaluation is functionally identical (the conformance suite passes in both modes) but skips CEL compilation and runtime overhead for the rules it covers — a single `validate()` call on a complex message can run an order of magnitude faster and allocate ~10× less. + +Native rules are **opt-out**. Disable them by configuring the validator: + +```java +Config config = Config.newBuilder().setEnableNativeRules(false).build(); +Validator validator = ValidatorFactory.newBuilder().withConfig(config).build(); +``` + +Forward compatibility is preserved by a clone-and-clear contract: when protovalidate adds a new rule that this codebase hasn't yet implemented natively, the rule remains on the residual `FieldRules` and CEL enforces it. Native evaluation is an optimization, never a replacement. + ## Additional languages and repositories Protovalidate isn't just for Java! You might be interested in sibling repositories for other languages: diff --git a/benchmarks/README.md b/benchmarks/README.md index 903120cd..fe12957e 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -54,6 +54,26 @@ compileValidatorForRepeated alloc 12950196.95 B/op 3262651.61 B/op -74.8% `jmhCompare` diffs `results-before.json` against `results.json` by default. Pass explicit paths with `-Pbefore= -Pafter=`. +## Comparing native rules vs CEL + +Benchmarks A/B the `enableNativeRules` flag via `@Param({"false", "true"})`, so a single run produces both variants. +Diff them in place: + +``` +./gradlew :benchmarks:jmh +./gradlew :benchmarks:jmhCompareNativeRules +``` + +Output (`before` = CEL, `after` = native; negative delta means native is faster / allocates less): + +``` +benchmark metric cel native delta +buildBenchInt32GT time 1234567.89 ns/op 456789.01 ns/op -63.0% +buildBenchInt32GT alloc 123456.78 B/op 45678.90 B/op -63.0% +``` + +Override the input file with `-Presults=`. + ## Adding a new benchmark Benchmarks live in `src/jmh/java/...` and target proto messages in `src/jmh/proto/...`. diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts index 30bf1e1d..8186a0d7 100644 --- a/benchmarks/build.gradle.kts +++ b/benchmarks/build.gradle.kts @@ -143,3 +143,26 @@ tasks.register("jmhCompare") { after, // $3 ) } + +// Diffs the two enableNativeRules variants within a single results.json. +// `before` column is CEL (enableNativeRules=false), `after` is native +// (enableNativeRules=true), so a negative delta means native is faster / +// allocates less. +// +// Override the input file: +// ./gradlew :benchmarks:jmhCompareNative -Presults=path/to/results.json +tasks.register("jmhCompareNativeRules") { + description = "Diffs enableNativeRules=true vs false from a single JMH results.json." + val results = + project.findProperty("results")?.toString() + ?: jmhResults.get().asFile.absolutePath + val jqScript = file("jmh-compare-native-rules.jq").absolutePath + commandLine( + "bash", + "-c", + "jq --raw-output --from-file \"\$1\" \"\$2\" | column -t -s \$'\\t'", + "jmh-compare-native-rules", // $0 + jqScript, // $1 + results, // $2 + ) +} diff --git a/benchmarks/jmh-compare-native-rules.jq b/benchmarks/jmh-compare-native-rules.jq new file mode 100644 index 00000000..fcec2004 --- /dev/null +++ b/benchmarks/jmh-compare-native-rules.jq @@ -0,0 +1,32 @@ +# this script builds a comparison between runs that used the CEL interpreter for +# protovalidate rule evaluation vs runs that used native Java code for protovalidate +# rule evaluation. The differentiator is the value of the "native" field (true for +# native, false for CEL) and this script groups rows that have the same benchmark +# and metric name, but different values for native. +def pct(a; b): + if a == null or b == null or b == 0 then "~" + else (((a - b) / b * 100) * 10 | round / 10) as $d + | if $d > 0 then "+\($d)%" elif $d == 0 then "~" else "\($d)%" end + end; +def num(x): + if x == null then "-" + else (x * 100 | round / 100 | tostring) + end; + +def row: { + key: (.benchmark | split(".") | last), + native: .params.enableNativeRules, + time: .primaryMetric.score, + unit: .primaryMetric.scoreUnit, + alloc: (.secondaryMetrics["·gc.alloc.rate.norm"].score // null) +}; + +map(row) +| group_by(.key) +| (["benchmark", "metric", "cel", "native", "delta"] | @tsv), + (.[] + | (map(select(.native == "false"))[0]) as $b + | (map(select(.native == "true"))[0]) as $a + | select($b and $a) + | ([$b.key, "time", "\(num($b.time)) \($b.unit)", "\(num($a.time)) \($a.unit)", pct($a.time; $b.time)] | @tsv), + ([$b.key, "alloc", "\(num($b.alloc)) B/op", "\(num($a.alloc)) B/op", pct($a.alloc; $b.alloc)] | @tsv)) \ No newline at end of file diff --git a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/BenchFixtures.java b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/BenchFixtures.java new file mode 100644 index 00000000..95dd914f --- /dev/null +++ b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/BenchFixtures.java @@ -0,0 +1,307 @@ +// Copyright 2023-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. + +package build.buf.protovalidate.benchmarks; + +import build.buf.protovalidate.benchmarks.gen.BenchBoolConst; +import build.buf.protovalidate.benchmarks.gen.BenchBytesConst; +import build.buf.protovalidate.benchmarks.gen.BenchBytesIn; +import build.buf.protovalidate.benchmarks.gen.BenchComplexSchema; +import build.buf.protovalidate.benchmarks.gen.BenchDoubleIn; +import build.buf.protovalidate.benchmarks.gen.BenchEnum; +import build.buf.protovalidate.benchmarks.gen.BenchEnumConst; +import build.buf.protovalidate.benchmarks.gen.BenchEnumNotIn; +import build.buf.protovalidate.benchmarks.gen.BenchEnumRules; +import build.buf.protovalidate.benchmarks.gen.BenchGT; +import build.buf.protovalidate.benchmarks.gen.BenchInt64Const; +import build.buf.protovalidate.benchmarks.gen.BenchInt64In; +import build.buf.protovalidate.benchmarks.gen.BenchMap; +import build.buf.protovalidate.benchmarks.gen.BenchPhaseEnum; +import build.buf.protovalidate.benchmarks.gen.BenchRepeatedBytesUnique; +import build.buf.protovalidate.benchmarks.gen.BenchRepeatedInt32Unique; +import build.buf.protovalidate.benchmarks.gen.BenchRepeatedMessage; +import build.buf.protovalidate.benchmarks.gen.BenchRepeatedScalar; +import build.buf.protovalidate.benchmarks.gen.BenchRepeatedScalarUnique; +import build.buf.protovalidate.benchmarks.gen.BenchRepeatedStringUnique; +import build.buf.protovalidate.benchmarks.gen.BenchScalar; +import build.buf.protovalidate.benchmarks.gen.BenchStringConst; +import build.buf.protovalidate.benchmarks.gen.BenchStringContains; +import build.buf.protovalidate.benchmarks.gen.BenchStringIn; +import build.buf.protovalidate.benchmarks.gen.BenchStringLen; +import build.buf.protovalidate.benchmarks.gen.BenchStringMinLen; +import build.buf.protovalidate.benchmarks.gen.BenchStringPrefix; +import build.buf.protovalidate.benchmarks.gen.BenchUint32In; +import build.buf.protovalidate.benchmarks.gen.MultiRule; +import build.buf.protovalidate.benchmarks.gen.StringMatching; +import build.buf.protovalidate.benchmarks.gen.TestByteMatching; +import build.buf.protovalidate.benchmarks.gen.WrapperTesting; +import com.google.protobuf.BoolValue; +import com.google.protobuf.ByteString; +import com.google.protobuf.BytesValue; +import com.google.protobuf.DoubleValue; +import com.google.protobuf.FloatValue; +import com.google.protobuf.Int32Value; +import com.google.protobuf.Int64Value; +import com.google.protobuf.StringValue; +import com.google.protobuf.UInt32Value; +import com.google.protobuf.UInt64Value; + +/** + * Hand-built deterministic fixtures for the native-rules benchmark suite. + * + *

Each factory returns a fully-populated message that satisfies all of its validation rules. + * Values are literal (no Random) so benchmarks are reproducible run-to-run. Chosen to match the + * intent of the gofakeit annotations in the Go reference protos without depending on a faker + * library. + */ +final class BenchFixtures { + private BenchFixtures() {} + + static BenchScalar benchScalar() { + return BenchScalar.newBuilder().setX(42).build(); + } + + static BenchRepeatedScalar benchRepeatedScalar() { + BenchRepeatedScalar.Builder b = BenchRepeatedScalar.newBuilder(); + for (int i = 1; i <= 5; i++) { + b.addX(i); + } + return b.build(); + } + + static BenchRepeatedMessage benchRepeatedMessage() { + BenchRepeatedMessage.Builder b = BenchRepeatedMessage.newBuilder(); + for (int i = 1; i <= 5; i++) { + b.addX(BenchScalar.newBuilder().setX(i).build()); + } + return b.build(); + } + + static BenchRepeatedScalarUnique benchRepeatedScalarUnique() { + BenchRepeatedScalarUnique.Builder b = BenchRepeatedScalarUnique.newBuilder(); + for (int i = 1; i <= 8; i++) { + b.addX((float) i); + } + return b.build(); + } + + static BenchRepeatedBytesUnique benchRepeatedBytesUnique() { + BenchRepeatedBytesUnique.Builder b = BenchRepeatedBytesUnique.newBuilder(); + for (int i = 1; i <= 8; i++) { + b.addX(ByteString.copyFromUtf8("entry-" + i)); + } + return b.build(); + } + + static BenchMap benchMap() { + BenchMap.Builder b = BenchMap.newBuilder(); + for (int i = 1; i <= 5; i++) { + b.putEntries("key-" + i, "value-" + i); + } + return b.build(); + } + + static BenchComplexSchema benchComplexSchema() { + BenchComplexSchema.Builder b = + BenchComplexSchema.newBuilder() + .setS1("hello") + .setS2("world") + .setI32(42) + .setI64(42L) + .setU32(42) + .setU64(42L) + .setSi32(42) + .setSi64(42L) + .setF32(42) + .setF64(42L) + .setSf32(42) + .setSf64(42L) + .setFl(42.0f) + .setDb(42.0) + .setBl(true) + .setBy(ByteString.copyFromUtf8("payload")) + .setNested(BenchScalar.newBuilder().setX(1).build()) + // self_ref intentionally left null; proto3 message fields default to absent + .setEnumField(BenchEnum.BENCH_ENUM_ONE) + .setOneofStr("hello"); + + for (int i = 1; i <= 3; i++) { + b.addRepStr("item-" + i); + b.addRepI32(i); + b.addRepBytes(ByteString.copyFromUtf8("bytes-" + i)); + b.addRepMsg(BenchScalar.newBuilder().setX(i).build()); + } + + for (int i = 1; i <= 3; i++) { + b.putMapStrStr("k" + i, "v" + i); + b.putMapI32I64(i, (long) i); + b.putMapU64Bool((long) i, i % 2 == 0); + b.putMapStrBytes("k" + i, ByteString.copyFromUtf8("v" + i)); + b.putMapStrMsg("k" + i, BenchScalar.newBuilder().setX(i).build()); + b.putMapI64Msg((long) i, BenchScalar.newBuilder().setX(i).build()); + } + + return b.build(); + } + + static BenchGT benchGT() { + // For gt > lt / gte > lte cases, protovalidate interprets the range as + // exclusive (value not in [lt, gt]). 50 is outside [-20, 0] for all four. + return BenchGT.newBuilder() + .setGt(50) + .setGte(50) + .setLt(50) + .setLte(50) + .setGtltin(50) + .setGtltein(50) + .setGtltex(50) + .setGtlteex(50) + .setGteltin(50) + .setGteltein(50) + .setGteltex(50) + .setGtelteex(50) + .setConst(10) + .setConstgt(10) + .setInTest(3) + .setNotInTest(4) + .build(); + } + + static TestByteMatching testByteMatching() { + return TestByteMatching.newBuilder() + .setIpAddr(ByteString.copyFrom(new byte[16])) // any 16 bytes (ip rule = 4 or 16) + .setIpv4Addr(ByteString.copyFrom(new byte[4])) + .setIpv6Addr(ByteString.copyFrom(new byte[16])) + .setUuid(ByteString.copyFrom(new byte[16])) + .build(); + } + + static StringMatching stringMatching() { + return StringMatching.newBuilder() + .setHostname("example.com") + .setHostAndPort("example.com:8080") + .setEmail("alice@example.com") + .setUuid("550e8400-e29b-41d4-a716-446655440000") + .build(); + } + + static WrapperTesting wrapperTesting() { + return WrapperTesting.newBuilder() + .setI32(Int32Value.of(11)) + .setD(DoubleValue.of(11)) + .setF(FloatValue.of(11)) + .setI64(Int64Value.of(11)) + .setU64(UInt64Value.of(11)) + .setU32(UInt32Value.of(11)) + .setB(BoolValue.of(true)) + .setS(StringValue.of("hello")) + .setBs(BytesValue.of(ByteString.copyFromUtf8("hello"))) + .build(); + } + + /** Multi-rule fixture that PASSES — many=10 satisfies const=10 and gt=5. */ + static MultiRule multiRuleNoError() { + return MultiRule.newBuilder().setMany(10).build(); + } + + /** Multi-rule fixture that FAILS both rules — many=1 violates const=10 and gt=5. */ + static MultiRule multiRuleError() { + return MultiRule.newBuilder().setMany(1).build(); + } + + /** Phase 2 measurement target — exercises BoolRulesEvaluator on bool.const. */ + static BenchBoolConst benchBoolConst() { + return BenchBoolConst.newBuilder().setFlag(true).build(); + } + + /** Phase 4 measurement target — exercises EnumRulesEvaluator on enum.in. */ + static BenchEnumRules benchEnumRules() { + return BenchEnumRules.newBuilder().setVal(BenchPhaseEnum.BENCH_PHASE_ENUM_TWO).build(); + } + + // --- Single-rule fixtures for previously unbenchmarked rules --- + // Each fixture's value satisfies its rule — benchmarks measure the happy path. + + static BenchStringConst benchStringConst() { + return BenchStringConst.newBuilder().setS("hello").build(); + } + + static BenchStringLen benchStringLen() { + return BenchStringLen.newBuilder().setS("hello").build(); + } + + static BenchStringMinLen benchStringMinLen() { + return BenchStringMinLen.newBuilder().setS("x").build(); + } + + static BenchStringPrefix benchStringPrefix() { + return BenchStringPrefix.newBuilder().setS("user-alice").build(); + } + + static BenchStringContains benchStringContains() { + return BenchStringContains.newBuilder().setS("alice@example.com").build(); + } + + static BenchStringIn benchStringIn() { + return BenchStringIn.newBuilder().setS("bar").build(); + } + + static BenchBytesConst benchBytesConst() { + return BenchBytesConst.newBuilder().setB(ByteString.copyFromUtf8("abc")).build(); + } + + static BenchBytesIn benchBytesIn() { + return BenchBytesIn.newBuilder().setB(ByteString.copyFromUtf8("bar")).build(); + } + + static BenchInt64Const benchInt64Const() { + return BenchInt64Const.newBuilder().setV(42L).build(); + } + + static BenchInt64In benchInt64In() { + return BenchInt64In.newBuilder().setV(2L).build(); + } + + static BenchUint32In benchUint32In() { + return BenchUint32In.newBuilder().setV(2).build(); + } + + static BenchDoubleIn benchDoubleIn() { + return BenchDoubleIn.newBuilder().setV(2.0).build(); + } + + static BenchEnumConst benchEnumConst() { + return BenchEnumConst.newBuilder().setVal(BenchPhaseEnum.BENCH_PHASE_ENUM_ONE).build(); + } + + static BenchEnumNotIn benchEnumNotIn() { + return BenchEnumNotIn.newBuilder().setVal(BenchPhaseEnum.BENCH_PHASE_ENUM_ONE).build(); + } + + static BenchRepeatedStringUnique benchRepeatedStringUnique() { + BenchRepeatedStringUnique.Builder b = BenchRepeatedStringUnique.newBuilder(); + for (int i = 1; i <= 8; i++) { + b.addX("entry-" + i); + } + return b.build(); + } + + static BenchRepeatedInt32Unique benchRepeatedInt32Unique() { + BenchRepeatedInt32Unique.Builder b = BenchRepeatedInt32Unique.newBuilder(); + for (int i = 1; i <= 8; i++) { + b.addX(i); + } + return b.build(); + } +} diff --git a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java new file mode 100644 index 00000000..8f22a121 --- /dev/null +++ b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java @@ -0,0 +1,73 @@ +// Copyright 2023-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. + +package build.buf.protovalidate.benchmarks; + +import build.buf.protovalidate.Config; +import build.buf.protovalidate.Validator; +import build.buf.protovalidate.ValidatorFactory; +import build.buf.protovalidate.benchmarks.gen.BenchComplexSchema; +import build.buf.protovalidate.benchmarks.gen.BenchGT; +import build.buf.protovalidate.exceptions.ValidationException; +import com.google.protobuf.Message; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Compile-time evaluator construction benchmarks. Mirrors Go's {@code BenchmarkCompile} and {@code + * BenchmarkCompileInt32GT}. These measure how long it takes to build a validator (compile rules, + * cache evaluators) for a given message type — the cost paid once per descriptor. + */ +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@State(Scope.Benchmark) +public class EvaluatorBuildBenchmark { + + @Param({"false", "true"}) + public boolean enableNativeRules; + + private Config config; + private Message benchComplexSchema; + private Message benchGT; + + @Setup + public void setup() { + config = Config.newBuilder().setEnableNativeRules(enableNativeRules).build(); + benchComplexSchema = BenchComplexSchema.getDefaultInstance(); + benchGT = BenchGT.getDefaultInstance(); + } + + @Benchmark + public Validator buildBenchComplexSchema(Blackhole bh) throws ValidationException { + Validator v = ValidatorFactory.newBuilder().withConfig(config).build(); + // Force evaluator construction by validating the default instance. + bh.consume(v.validate(benchComplexSchema)); + return v; + } + + @Benchmark + public Validator buildBenchInt32GT(Blackhole bh) throws ValidationException { + Validator v = ValidatorFactory.newBuilder().withConfig(config).build(); + bh.consume(v.validate(benchGT)); + return v; + } +} diff --git a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java index d058b20b..93d34bc9 100644 --- a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java +++ b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java @@ -14,12 +14,43 @@ package build.buf.protovalidate.benchmarks; +import build.buf.protovalidate.Config; import build.buf.protovalidate.Validator; import build.buf.protovalidate.ValidatorFactory; +import build.buf.protovalidate.benchmarks.gen.BenchBoolConst; +import build.buf.protovalidate.benchmarks.gen.BenchBytesConst; +import build.buf.protovalidate.benchmarks.gen.BenchBytesIn; +import build.buf.protovalidate.benchmarks.gen.BenchComplexSchema; +import build.buf.protovalidate.benchmarks.gen.BenchDoubleIn; +import build.buf.protovalidate.benchmarks.gen.BenchEnumConst; +import build.buf.protovalidate.benchmarks.gen.BenchEnumNotIn; +import build.buf.protovalidate.benchmarks.gen.BenchEnumRules; +import build.buf.protovalidate.benchmarks.gen.BenchGT; +import build.buf.protovalidate.benchmarks.gen.BenchInt64Const; +import build.buf.protovalidate.benchmarks.gen.BenchInt64In; +import build.buf.protovalidate.benchmarks.gen.BenchMap; +import build.buf.protovalidate.benchmarks.gen.BenchRepeatedBytesUnique; +import build.buf.protovalidate.benchmarks.gen.BenchRepeatedInt32Unique; +import build.buf.protovalidate.benchmarks.gen.BenchRepeatedMessage; +import build.buf.protovalidate.benchmarks.gen.BenchRepeatedScalar; +import build.buf.protovalidate.benchmarks.gen.BenchRepeatedScalarUnique; +import build.buf.protovalidate.benchmarks.gen.BenchRepeatedStringUnique; +import build.buf.protovalidate.benchmarks.gen.BenchScalar; +import build.buf.protovalidate.benchmarks.gen.BenchStringConst; +import build.buf.protovalidate.benchmarks.gen.BenchStringContains; +import build.buf.protovalidate.benchmarks.gen.BenchStringIn; +import build.buf.protovalidate.benchmarks.gen.BenchStringLen; +import build.buf.protovalidate.benchmarks.gen.BenchStringMinLen; +import build.buf.protovalidate.benchmarks.gen.BenchStringPrefix; +import build.buf.protovalidate.benchmarks.gen.BenchUint32In; import build.buf.protovalidate.benchmarks.gen.ManyUnruledFieldsMessage; +import build.buf.protovalidate.benchmarks.gen.MultiRule; import build.buf.protovalidate.benchmarks.gen.RegexPatternMessage; import build.buf.protovalidate.benchmarks.gen.RepeatedRuleMessage; import build.buf.protovalidate.benchmarks.gen.SimpleStringMessage; +import build.buf.protovalidate.benchmarks.gen.StringMatching; +import build.buf.protovalidate.benchmarks.gen.TestByteMatching; +import build.buf.protovalidate.benchmarks.gen.WrapperTesting; import build.buf.protovalidate.exceptions.ValidationException; import com.google.protobuf.Descriptors.FieldDescriptor; import java.util.concurrent.TimeUnit; @@ -27,25 +58,80 @@ import org.openjdk.jmh.annotations.BenchmarkMode; import org.openjdk.jmh.annotations.Mode; import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; import org.openjdk.jmh.annotations.Scope; import org.openjdk.jmh.annotations.Setup; import org.openjdk.jmh.annotations.State; import org.openjdk.jmh.infra.Blackhole; +/** + * Steady-state validation benchmarks. Exercises the hot path after the evaluator cache is warm. + * + *

The set of {@code validateBench*} methods mirrors the Go benchmark suite in protovalidate-go's + * {@code validator_bench_test.go} and provides the baseline against which the native-rules port + * measures its improvements. The original {@code validate*} methods exercise past PR fixes + * (tautology skip, AST cache, etc.) and remain as regression guards. + * + *

The {@code enableNativeRules} parameter A/Bs the native-rules flag: {@code "false"} matches + * the Phase 0 CEL-only baseline; {@code "true"} measures native evaluation. Each subsequent phase + * reports the gap between the two modes for its covered benchmarks. + */ @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) @State(Scope.Benchmark) public class ValidationBenchmark { + @Param({"false", "true"}) + public boolean enableNativeRules; + private Validator validator; + + // --- Existing regression-guard fixtures --- private SimpleStringMessage simple; private ManyUnruledFieldsMessage manyUnruled; private RepeatedRuleMessage repeatedRule; private RegexPatternMessage regexPattern; + // --- Native-rules port fixtures --- + private BenchScalar benchScalar; + private BenchRepeatedScalar benchRepeatedScalar; + private BenchRepeatedMessage benchRepeatedMessage; + private BenchRepeatedScalarUnique benchRepeatedScalarUnique; + private BenchRepeatedBytesUnique benchRepeatedBytesUnique; + private BenchMap benchMap; + private BenchComplexSchema benchComplexSchema; + private BenchGT benchGT; + private TestByteMatching testByteMatching; + private StringMatching stringMatching; + private WrapperTesting wrapperTesting; + private MultiRule multiRuleNoError; + private MultiRule multiRuleError; + private BenchBoolConst benchBoolConst; + private BenchEnumRules benchEnumRules; + + // Single-rule fixtures filling earlier coverage gaps. + private BenchStringConst benchStringConst; + private BenchStringLen benchStringLen; + private BenchStringMinLen benchStringMinLen; + private BenchStringPrefix benchStringPrefix; + private BenchStringContains benchStringContains; + private BenchStringIn benchStringIn; + private BenchBytesConst benchBytesConst; + private BenchBytesIn benchBytesIn; + private BenchInt64Const benchInt64Const; + private BenchInt64In benchInt64In; + private BenchUint32In benchUint32In; + private BenchDoubleIn benchDoubleIn; + private BenchEnumConst benchEnumConst; + private BenchEnumNotIn benchEnumNotIn; + private BenchRepeatedStringUnique benchRepeatedStringUnique; + private BenchRepeatedInt32Unique benchRepeatedInt32Unique; + @Setup public void setup() throws ValidationException { - validator = ValidatorFactory.newBuilder().build(); + Config config = Config.newBuilder().setEnableNativeRules(enableNativeRules).build(); + + validator = ValidatorFactory.newBuilder().withConfig(config).build(); simple = SimpleStringMessage.newBuilder().setEmail("alice@example.com").build(); @@ -71,15 +157,78 @@ public void setup() throws ValidationException { regexPattern = RegexPatternMessage.newBuilder().setName("Alice Example").build(); + benchScalar = BenchFixtures.benchScalar(); + benchRepeatedScalar = BenchFixtures.benchRepeatedScalar(); + benchRepeatedMessage = BenchFixtures.benchRepeatedMessage(); + benchRepeatedScalarUnique = BenchFixtures.benchRepeatedScalarUnique(); + benchRepeatedBytesUnique = BenchFixtures.benchRepeatedBytesUnique(); + benchMap = BenchFixtures.benchMap(); + benchComplexSchema = BenchFixtures.benchComplexSchema(); + benchGT = BenchFixtures.benchGT(); + testByteMatching = BenchFixtures.testByteMatching(); + stringMatching = BenchFixtures.stringMatching(); + wrapperTesting = BenchFixtures.wrapperTesting(); + multiRuleNoError = BenchFixtures.multiRuleNoError(); + multiRuleError = BenchFixtures.multiRuleError(); + benchBoolConst = BenchFixtures.benchBoolConst(); + benchEnumRules = BenchFixtures.benchEnumRules(); + + benchStringConst = BenchFixtures.benchStringConst(); + benchStringLen = BenchFixtures.benchStringLen(); + benchStringMinLen = BenchFixtures.benchStringMinLen(); + benchStringPrefix = BenchFixtures.benchStringPrefix(); + benchStringContains = BenchFixtures.benchStringContains(); + benchStringIn = BenchFixtures.benchStringIn(); + benchBytesConst = BenchFixtures.benchBytesConst(); + benchBytesIn = BenchFixtures.benchBytesIn(); + benchInt64Const = BenchFixtures.benchInt64Const(); + benchInt64In = BenchFixtures.benchInt64In(); + benchUint32In = BenchFixtures.benchUint32In(); + benchDoubleIn = BenchFixtures.benchDoubleIn(); + benchEnumConst = BenchFixtures.benchEnumConst(); + benchEnumNotIn = BenchFixtures.benchEnumNotIn(); + benchRepeatedStringUnique = BenchFixtures.benchRepeatedStringUnique(); + benchRepeatedInt32Unique = BenchFixtures.benchRepeatedInt32Unique(); + // Warm evaluator cache for steady-state benchmarks. validator.validate(simple); validator.validate(manyUnruled); validator.validate(repeatedRule); validator.validate(regexPattern); + validator.validate(benchScalar); + validator.validate(benchRepeatedScalar); + validator.validate(benchRepeatedMessage); + validator.validate(benchRepeatedScalarUnique); + validator.validate(benchRepeatedBytesUnique); + validator.validate(benchMap); + validator.validate(benchComplexSchema); + validator.validate(benchGT); + validator.validate(testByteMatching); + validator.validate(stringMatching); + validator.validate(wrapperTesting); + validator.validate(multiRuleNoError); + validator.validate(multiRuleError); + validator.validate(benchBoolConst); + validator.validate(benchEnumRules); + validator.validate(benchStringConst); + validator.validate(benchStringLen); + validator.validate(benchStringMinLen); + validator.validate(benchStringPrefix); + validator.validate(benchStringContains); + validator.validate(benchStringIn); + validator.validate(benchBytesConst); + validator.validate(benchBytesIn); + validator.validate(benchInt64Const); + validator.validate(benchInt64In); + validator.validate(benchUint32In); + validator.validate(benchDoubleIn); + validator.validate(benchEnumConst); + validator.validate(benchEnumNotIn); + validator.validate(benchRepeatedStringUnique); + validator.validate(benchRepeatedInt32Unique); } - // Steady-state validate() benchmarks. These exercise the hot path after the - // evaluator cache is warm. + // --- Existing regression-guard benchmarks --- @Benchmark public void validateSimple(Blackhole bh) throws ValidationException { @@ -100,4 +249,163 @@ public void validateRepeatedRule(Blackhole bh) throws ValidationException { public void validateRegexPattern(Blackhole bh) throws ValidationException { bh.consume(validator.validate(regexPattern)); } + + // --- Native-rules port benchmarks (mirror Go BenchmarkXxx names) --- + + @Benchmark + public void validateBenchScalar(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchScalar)); + } + + @Benchmark + public void validateBenchRepeatedScalar(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchRepeatedScalar)); + } + + @Benchmark + public void validateBenchRepeatedMessage(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchRepeatedMessage)); + } + + @Benchmark + public void validateBenchRepeatedScalarUnique(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchRepeatedScalarUnique)); + } + + @Benchmark + public void validateBenchRepeatedBytesUnique(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchRepeatedBytesUnique)); + } + + @Benchmark + public void validateBenchMap(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchMap)); + } + + @Benchmark + public void validateBenchComplexSchema(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchComplexSchema)); + } + + @Benchmark + public void validateBenchInt32GT(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchGT)); + } + + @Benchmark + public void validateTestByteMatching(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(testByteMatching)); + } + + @Benchmark + public void validateStringMatching(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(stringMatching)); + } + + @Benchmark + public void validateWrapperTesting(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(wrapperTesting)); + } + + @Benchmark + public void validateMultiRuleNoError(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(multiRuleNoError)); + } + + @Benchmark + public void validateMultiRuleError(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(multiRuleError)); + } + + @Benchmark + public void validateBenchBoolConst(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchBoolConst)); + } + + @Benchmark + public void validateBenchEnumRules(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchEnumRules)); + } + + // --- Single-rule fixtures filling earlier coverage gaps --- + + @Benchmark + public void validateBenchStringConst(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchStringConst)); + } + + @Benchmark + public void validateBenchStringLen(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchStringLen)); + } + + @Benchmark + public void validateBenchStringMinLen(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchStringMinLen)); + } + + @Benchmark + public void validateBenchStringPrefix(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchStringPrefix)); + } + + @Benchmark + public void validateBenchStringContains(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchStringContains)); + } + + @Benchmark + public void validateBenchStringIn(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchStringIn)); + } + + @Benchmark + public void validateBenchBytesConst(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchBytesConst)); + } + + @Benchmark + public void validateBenchBytesIn(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchBytesIn)); + } + + @Benchmark + public void validateBenchInt64Const(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchInt64Const)); + } + + @Benchmark + public void validateBenchInt64In(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchInt64In)); + } + + @Benchmark + public void validateBenchUint32In(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchUint32In)); + } + + @Benchmark + public void validateBenchDoubleIn(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchDoubleIn)); + } + + @Benchmark + public void validateBenchEnumConst(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchEnumConst)); + } + + @Benchmark + public void validateBenchEnumNotIn(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchEnumNotIn)); + } + + @Benchmark + public void validateBenchRepeatedStringUnique(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchRepeatedStringUnique)); + } + + @Benchmark + public void validateBenchRepeatedInt32Unique(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchRepeatedInt32Unique)); + } } diff --git a/benchmarks/src/jmh/proto/bench/v1/native_bench.proto b/benchmarks/src/jmh/proto/bench/v1/native_bench.proto new file mode 100644 index 00000000..57ec69e4 --- /dev/null +++ b/benchmarks/src/jmh/proto/bench/v1/native_bench.proto @@ -0,0 +1,330 @@ +// Copyright 2023-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. + +// Messages ported from protovalidate-go's proto/tests/example/v1/bench.proto +// and proto/tests/example/v1/native_test.proto for the native-rules port. +// gofakeit annotations have been stripped; benchmark fixtures are hand-built +// in BenchFixtures.java. + +syntax = "proto3"; + +package bench.v1; + +import "buf/validate/validate.proto"; +import "google/protobuf/wrappers.proto"; + +option java_multiple_files = true; +option java_package = "build.buf.protovalidate.benchmarks.gen"; + +// --- Messages ported from bench.proto --- + +message BenchScalar { + int32 x = 1 [(buf.validate.field).int32.gt = 0]; +} + +message BenchRepeatedScalar { + repeated int32 x = 1 [(buf.validate.field).repeated.max_items = 10]; +} + +message BenchRepeatedMessage { + repeated BenchScalar x = 1 [(buf.validate.field).repeated.max_items = 10]; +} + +message BenchRepeatedScalarUnique { + repeated float x = 1 [(buf.validate.field).repeated.unique = true]; +} + +message BenchRepeatedBytesUnique { + repeated bytes x = 1 [(buf.validate.field).repeated.unique = true]; +} + +message BenchMap { + map entries = 1 [(buf.validate.field).map.min_pairs = 1]; +} + +message BenchComplexSchema { + string s1 = 1 [(buf.validate.field).string.min_len = 1]; + string s2 = 2 [(buf.validate.field).string.max_len = 100]; + int32 i32 = 3 [(buf.validate.field).int32.gt = 0]; + int64 i64 = 4 [(buf.validate.field).int64.lt = 1000]; + uint32 u32 = 5 [(buf.validate.field).uint32.gte = 1]; + uint64 u64 = 6 [(buf.validate.field).uint64.lte = 1000]; + sint32 si32 = 7 [(buf.validate.field).sint32.gt = 0]; + sint64 si64 = 8 [(buf.validate.field).sint64.lt = 1000]; + fixed32 f32 = 9 [(buf.validate.field).fixed32.gte = 1]; + fixed64 f64 = 10 [(buf.validate.field).fixed64.lte = 1000]; + sfixed32 sf32 = 11 [(buf.validate.field).sfixed32.gt = 0]; + sfixed64 sf64 = 12 [(buf.validate.field).sfixed64.lt = 1000]; + float fl = 13 [(buf.validate.field).float.finite = true]; + double db = 14 [(buf.validate.field).double.finite = true]; + bool bl = 15; + bytes by = 16 [(buf.validate.field).bytes.min_len = 1]; + + BenchScalar nested = 17; + + BenchComplexSchema self_ref = 18; + + repeated string rep_str = 19 [(buf.validate.field).repeated.max_items = 10]; + repeated int32 rep_i32 = 20 [(buf.validate.field).repeated.min_items = 1]; + repeated bytes rep_bytes = 21 [(buf.validate.field).repeated.unique = true]; + + repeated BenchScalar rep_msg = 22 [(buf.validate.field).repeated.max_items = 5]; + + map map_str_str = 23 [(buf.validate.field).map.min_pairs = 1]; + map map_i32_i64 = 24 [(buf.validate.field).map.max_pairs = 10]; + map map_u64_bool = 25; + map map_str_bytes = 26 [(buf.validate.field).map.keys = { + string: {min_len: 1} + }]; + + map map_str_msg = 27 [(buf.validate.field).map.values = {required: true}]; + map map_i64_msg = 28; + + BenchEnum enum_field = 29 [(buf.validate.field).enum.defined_only = true]; + + oneof choice { + string oneof_str = 30 [(buf.validate.field).string.min_len = 1]; + int32 oneof_i32 = 31 [(buf.validate.field).int32.gt = 0]; + BenchScalar oneof_msg = 32; + } +} + +enum BenchEnum { + BENCH_ENUM_UNSPECIFIED = 0; + BENCH_ENUM_ONE = 1; + BENCH_ENUM_TWO = 2; +} + +// --- Messages ported from native_test.proto --- + +message BenchGT { + int32 gt = 1 [(buf.validate.field).int32.gt = 0]; + int32 gte = 2 [(buf.validate.field).int32.gte = 0]; + int32 lt = 3 [(buf.validate.field).int32.lt = 101]; + int32 lte = 4 [(buf.validate.field).int32.lte = 101]; + int32 gtltin = 5 [ + (buf.validate.field).int32.gt = 0, + (buf.validate.field).int32.lt = 101 + ]; + int32 gtltein = 6 [ + (buf.validate.field).int32.gt = 0, + (buf.validate.field).int32.lt = 101 + ]; + int32 gtltex = 7 [ + (buf.validate.field).int32.gt = 0, + (buf.validate.field).int32.lt = -20 + ]; + int32 gtlteex = 8 [ + (buf.validate.field).int32.gt = 0, + (buf.validate.field).int32.lte = -20 + ]; + int32 gteltin = 9 [ + (buf.validate.field).int32.gte = 0, + (buf.validate.field).int32.lt = 101 + ]; + int32 gteltein = 10 [ + (buf.validate.field).int32.gte = 0, + (buf.validate.field).int32.lt = 101 + ]; + int32 gteltex = 11 [ + (buf.validate.field).int32.gte = 0, + (buf.validate.field).int32.lt = -20 + ]; + int32 gtelteex = 12 [ + (buf.validate.field).int32.gte = 0, + (buf.validate.field).int32.lte = -20 + ]; + int32 const = 13 [(buf.validate.field).int32.const = 10]; + int32 constgt = 14 [ + (buf.validate.field).int32.const = 10, + (buf.validate.field).int32.gte = 0 + ]; + int32 in_test = 15 [(buf.validate.field).int32 = { + in: [ + 1, + 3, + 5 + ] + }]; + int32 not_in_test = 16 [(buf.validate.field).int32 = { + not_in: [ + 1, + 3, + 5 + ] + }]; +} + +message TestByteMatching { + bytes ip_addr = 1 [(buf.validate.field).bytes.ip = true]; + bytes ipv4_addr = 2 [(buf.validate.field).bytes.ipv4 = true]; + bytes ipv6_addr = 3 [(buf.validate.field).bytes.ipv6 = true]; + bytes uuid = 4 [(buf.validate.field).bytes.uuid = true]; +} + +message StringMatching { + string hostname = 1 [(buf.validate.field).string.hostname = true]; + string host_and_port = 2 [(buf.validate.field).string.host_and_port = true]; + string email = 3 [(buf.validate.field).string.email = true]; + string uuid = 4 [(buf.validate.field).string.uuid = true]; +} + +message WrapperTesting { + google.protobuf.Int32Value i32 = 1 [(buf.validate.field).int32.gt = 10]; + google.protobuf.DoubleValue d = 2 [(buf.validate.field).double.gt = 10]; + google.protobuf.FloatValue f = 3 [(buf.validate.field).float.gt = 10]; + google.protobuf.Int64Value i64 = 4 [(buf.validate.field).int64.gt = 10]; + google.protobuf.UInt64Value u64 = 5 [(buf.validate.field).uint64.gt = 10]; + google.protobuf.UInt32Value u32 = 6 [(buf.validate.field).uint32.gt = 10]; + google.protobuf.BoolValue b = 7 [(buf.validate.field).bool.const = true]; + google.protobuf.StringValue s = 8 [(buf.validate.field).string.const = "hello"]; + google.protobuf.BytesValue bs = 9 [(buf.validate.field).bytes.len = 5]; +} + +message MultiRule { + int64 many = 1 [ + (buf.validate.field).int64.const = 10, + (buf.validate.field).int64.gt = 5 + ]; +} + +// Phase 2 measurement target. Single bool field with a const rule so the bench has a direct +// hit on BoolRulesEvaluator. +message BenchBoolConst { + bool flag = 1 [(buf.validate.field).bool.const = true]; +} + +enum BenchPhaseEnum { + BENCH_PHASE_ENUM_UNSPECIFIED = 0; + BENCH_PHASE_ENUM_ONE = 1; + BENCH_PHASE_ENUM_TWO = 2; + BENCH_PHASE_ENUM_THREE = 3; +} + +// Phase 4 measurement target. Single enum field with const + in rules so the bench has a direct +// hit on EnumRulesEvaluator. +message BenchEnumRules { + BenchPhaseEnum val = 1 [(buf.validate.field).enum = { + in: [ + 1, + 2, + 3 + ] + }]; +} + +// --- Single-rule fixtures for previously unbenchmarked rules --- +// +// These messages each isolate a single rule so the benchmark measures that rule's per-validation +// cost. They cover gaps identified in the second-pass review: string scalar rules (const, len, +// min_len, prefix, contains, in), bytes scalar rules (const, in), numeric in/const for non-int32 +// kinds, enum const/not_in, and repeated.unique on string/int32. + +message BenchStringConst { + string s = 1 [(buf.validate.field).string.const = "hello"]; +} + +message BenchStringLen { + string s = 1 [(buf.validate.field).string.len = 5]; +} + +message BenchStringMinLen { + string s = 1 [(buf.validate.field).string.min_len = 1]; +} + +message BenchStringPrefix { + string s = 1 [(buf.validate.field).string.prefix = "user-"]; +} + +message BenchStringContains { + string s = 1 [(buf.validate.field).string.contains = "@"]; +} + +message BenchStringIn { + string s = 1 [(buf.validate.field).string = { + in: [ + "foo", + "bar", + "baz" + ] + }]; +} + +message BenchBytesConst { + bytes b = 1 [(buf.validate.field).bytes.const = "abc"]; +} + +message BenchBytesIn { + bytes b = 1 [(buf.validate.field).bytes = { + in: [ + "foo", + "bar" + ] + }]; +} + +message BenchInt64Const { + int64 v = 1 [(buf.validate.field).int64.const = 42]; +} + +message BenchInt64In { + int64 v = 1 [(buf.validate.field).int64 = { + in: [ + 1, + 2, + 3 + ] + }]; +} + +message BenchUint32In { + uint32 v = 1 [(buf.validate.field).uint32 = { + in: [ + 1, + 2, + 3 + ] + }]; +} + +message BenchDoubleIn { + double v = 1 [(buf.validate.field).double = { + in: [ + 1.0, + 2.0, + 3.0 + ] + }]; +} + +message BenchEnumConst { + BenchPhaseEnum val = 1 [(buf.validate.field).enum.const = 1]; +} + +message BenchEnumNotIn { + BenchPhaseEnum val = 1 [(buf.validate.field).enum = { + not_in: [ + 2, + 3 + ] + }]; +} + +message BenchRepeatedStringUnique { + repeated string x = 1 [(buf.validate.field).repeated.unique = true]; +} + +message BenchRepeatedInt32Unique { + repeated int32 x = 1 [(buf.validate.field).repeated.unique = true]; +} \ No newline at end of file diff --git a/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java b/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java index bf2c5d63..902de0a6 100644 --- a/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java +++ b/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java @@ -61,11 +61,13 @@ static TestConformanceResponse testConformance(TestConformanceRequest request) { TypeRegistry typeRegistry = FileDescriptorUtil.createTypeRegistry(fileDescriptorMap.values()); ExtensionRegistry extensionRegistry = FileDescriptorUtil.createExtensionRegistry(fileDescriptorMap.values()); - Config cfg = - Config.newBuilder() - .setTypeRegistry(typeRegistry) - .setExtensionRegistry(extensionRegistry) - .build(); + String envFlag = System.getenv("ENABLE_NATIVE_RULES"); + Config.Builder cfgBuilder = + Config.newBuilder().setTypeRegistry(typeRegistry).setExtensionRegistry(extensionRegistry); + if (envFlag != null) { + cfgBuilder.setEnableNativeRules(Boolean.parseBoolean(envFlag)); + } + Config cfg = cfgBuilder.build(); Validator validator = ValidatorFactory.newBuilder().withConfig(cfg).build(); TestConformanceResponse.Builder responseBuilder = TestConformanceResponse.newBuilder(); diff --git a/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java b/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java new file mode 100644 index 00000000..88f6266c --- /dev/null +++ b/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java @@ -0,0 +1,139 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import static org.assertj.core.api.Assertions.assertThat; + +import build.buf.protovalidate.exceptions.ValidationException; +import build.buf.validate.conformance.cases.AnEnum; +import build.buf.validate.conformance.cases.BoolConstTrue; +import build.buf.validate.conformance.cases.BytesContains; +import build.buf.validate.conformance.cases.BytesIn; +import build.buf.validate.conformance.cases.ComplexTestMsg; +import build.buf.validate.conformance.cases.EnumDefined; +import build.buf.validate.conformance.cases.Fixed32LT; +import build.buf.validate.conformance.cases.Int32In; +import build.buf.validate.conformance.cases.KitchenSinkMessage; +import build.buf.validate.conformance.cases.RepeatedEnumIn; +import build.buf.validate.conformance.cases.RepeatedExact; +import build.buf.validate.conformance.cases.RepeatedUnique; +import build.buf.validate.conformance.cases.SFixed64In; +import build.buf.validate.conformance.cases.StringContains; +import build.buf.validate.conformance.cases.StringLen; +import build.buf.validate.conformance.cases.StringPrefix; +import build.buf.validate.conformance.cases.WrapperDouble; +import com.google.protobuf.ByteString; +import com.google.protobuf.DoubleValue; +import com.google.protobuf.Message; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Parity test: runs a representative slice of conformance fixtures through both modes ({@code + * enableNativeRules=true} and {@code false}) and asserts the resulting {@code Violation} protos are + * byte-equal. The conformance suite proves each mode is correct in isolation; this test proves they + * don't drift from each other on the same input. + * + *

Conformance message text is excluded from the suite's default comparison (only {@code + * rule_id}, {@code field}, {@code rule}, {@code for_key} are compared unless {@code + * --strict_message} is set), but {@code toProto()} captures all of those plus the message text. + * Asserting full {@code toProto()} equality here is therefore stricter than conformance. + */ +class NativeRulesParityTest { + + private final Validator nativeValidator = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setEnableNativeRules(true).build()) + .build(); + private final Validator celValidator = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) + .build(); + + /** + * Each entry exercises a different rule type. JUnit reports per-fixture pass/fail, so adding a + * fixture and watching CI is a single-line change. Order: bool, enum, bytes, numeric (signed + + * unsigned), string (scalar + format), repeated, map. + */ + static Stream fixtures() { + return Stream.of( + // Bool — fails const=true. + Arguments.of("BoolConstTrue", BoolConstTrue.newBuilder().build()), + // Enum defined_only — fails (2147483647 not in defined values). + Arguments.of( + "EnumDefined.undefined", EnumDefined.newBuilder().setValValue(2147483647).build()), + // Bytes contains — pass case. + Arguments.of( + "BytesContains.pass", + BytesContains.newBuilder().setVal(ByteString.copyFromUtf8("candy bars")).build()), + // Bytes in — pass case (empty matches none of the in list, but the field is + // implicit-presence so the rule is skipped on empty value). + Arguments.of( + "BytesIn.pass", BytesIn.newBuilder().setVal(ByteString.copyFromUtf8("bar")).build()), + // Fixed32 (unsigned) lt — fails (val=5, lt=5). + Arguments.of("Fixed32LT.fail", Fixed32LT.newBuilder().setVal(5).build()), + // Int32 in — fails (4 not in list). + Arguments.of("Int32In.fail", Int32In.newBuilder().setVal(4).build()), + // SFixed64 in — fails (5 not in list). + Arguments.of("SFixed64In.fail", SFixed64In.newBuilder().setVal(5).build()), + // String prefix — pass case. + Arguments.of("StringPrefix.pass", StringPrefix.newBuilder().setVal("foo").build()), + // String contains — pass case. + Arguments.of("StringContains.pass", StringContains.newBuilder().setVal("foobar").build()), + // String length with code points — emoji counts as 1 each. + Arguments.of("StringLen.emoji", StringLen.newBuilder().setVal("😅😄👾").build()), + // Repeated exact — fails (2 items, exact=3). + Arguments.of( + "RepeatedExact.fail", + RepeatedExact.newBuilder().addAllVal(Arrays.asList(1, 2)).build()), + // Repeated unique — fails (duplicate "foo"). + Arguments.of( + "RepeatedUnique.fail", + RepeatedUnique.newBuilder() + .addAllVal(Arrays.asList("foo", "bar", "foo", "baz")) + .build()), + // Repeated enum in — fails. + Arguments.of( + "RepeatedEnumIn.fail", RepeatedEnumIn.newBuilder().addVal(AnEnum.AN_ENUM_X).build()), + // Wrapper-typed double (google.protobuf.DoubleValue) — exercises the native + // wrapper-unwrap path. Empty wrapper = value 0.0, fails the rule. + Arguments.of( + "WrapperDouble.emptyInner", + WrapperDouble.newBuilder().setVal(DoubleValue.newBuilder().build()).build()), + // KitchenSinkMessage with empty inner ComplexTestMsg — many violations. + Arguments.of( + "KitchenSinkMessage.emptyInner", + KitchenSinkMessage.newBuilder().setVal(ComplexTestMsg.newBuilder().build()).build())); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("fixtures") + void parityForFixture(String name, Message msg) throws ValidationException { + ValidationResult nativeResult = nativeValidator.validate(msg); + ValidationResult celResult = celValidator.validate(msg); + assertThat(toProtoList(nativeResult)) + .as("toProto() parity for %s", name) + .isEqualTo(toProtoList(celResult)); + } + + private static List toProtoList(ValidationResult result) { + return result.getViolations().stream().map(Violation::toProto).collect(Collectors.toList()); + } +} diff --git a/src/main/java/build/buf/protovalidate/BoolRulesEvaluator.java b/src/main/java/build/buf/protovalidate/BoolRulesEvaluator.java new file mode 100644 index 00000000..0ed7430f --- /dev/null +++ b/src/main/java/build/buf/protovalidate/BoolRulesEvaluator.java @@ -0,0 +1,82 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import build.buf.validate.BoolRules; +import build.buf.validate.FieldRules; +import com.google.protobuf.Descriptors.FieldDescriptor; +import java.util.Collections; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Native evaluator for {@code bool} rules. Currently covers {@code bool.const}; the only standard + * rule defined for bool fields. + */ +final class BoolRulesEvaluator implements Evaluator { + private static final FieldDescriptor BOOL_RULES_DESC = + FieldRules.getDescriptor().findFieldByNumber(FieldRules.BOOL_FIELD_NUMBER); + private static final FieldDescriptor CONST_DESC = + BoolRules.getDescriptor().findFieldByNumber(BoolRules.CONST_FIELD_NUMBER); + private static final RuleSite CONST_SITE = + RuleSite.of(BOOL_RULES_DESC, CONST_DESC, "bool.const", null); + + private final RuleBase base; + private final boolean expected; + + private BoolRulesEvaluator(RuleBase base, boolean expected) { + this.base = base; + this.expected = expected; + } + + /** + * Attempts to build a {@link BoolRulesEvaluator} for the bool sub-rules on the given {@code + * FieldRules.Builder}. Returns null if the rules aren't natively handleable (no bool oneof case + * set, no covered rule set, or unknown fields present); on success, clears the covered rule on + * the builder so CEL won't recompile it. + */ + static @Nullable Evaluator tryBuild(RuleBase base, FieldRules.Builder rulesBuilder) { + if (!rulesBuilder.hasBool()) { + return null; + } + BoolRules boolRules = rulesBuilder.getBool(); + if (!boolRules.getUnknownFields().isEmpty()) { + return null; + } + if (!boolRules.hasConst()) { + return null; + } + boolean expected = boolRules.getConst(); + rulesBuilder.setBool(boolRules.toBuilder().clearConst().build()); + return new BoolRulesEvaluator(base, expected); + } + + @Override + public boolean tautology() { + return false; + } + + @Override + public List evaluate(Value val, boolean failFast) { + boolean actual = (Boolean) val.rawValue(); + if (actual == expected) { + return RuleViolation.NO_VIOLATIONS; + } + return base.done( + Collections.singletonList( + NativeViolations.newViolation( + CONST_SITE, null, "must equal " + expected, val, expected))); + } +} diff --git a/src/main/java/build/buf/protovalidate/BytesRulesEvaluator.java b/src/main/java/build/buf/protovalidate/BytesRulesEvaluator.java new file mode 100644 index 00000000..afa24ba1 --- /dev/null +++ b/src/main/java/build/buf/protovalidate/BytesRulesEvaluator.java @@ -0,0 +1,515 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import build.buf.protovalidate.exceptions.ExecutionException; +import build.buf.validate.BytesRules; +import build.buf.validate.FieldRules; +import com.google.protobuf.ByteString; +import com.google.protobuf.Descriptors.FieldDescriptor; +import com.google.re2j.Pattern; +import com.google.re2j.PatternSyntaxException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Native evaluator for the standard bytes rules: {@code const}, {@code len}, {@code min_len}, + * {@code max_len}, {@code pattern}, {@code prefix}, {@code suffix}, {@code contains}, {@code in}, + * {@code not_in}, plus the well-known size-only formats {@code ip}, {@code ipv4}, {@code ipv6}, + * {@code uuid}. Mirrors {@code nativeBytesEval} in protovalidate-go's {@code native_bytes.go}. + */ +final class BytesRulesEvaluator implements Evaluator { + + /** Well-known bytes format constraint — purely size-based per protovalidate spec. */ + private enum WellKnown { + IP( + "bytes.ip", + "must be a valid IP address", + "bytes.ip_empty", + "value is empty, which is not a valid IP address", + Arrays.asList(4, 16), + BytesRules.IP_FIELD_NUMBER), + IPV4( + "bytes.ipv4", + "must be a valid IPv4 address", + "bytes.ipv4_empty", + "value is empty, which is not a valid IPv4 address", + Collections.singletonList(4), + BytesRules.IPV4_FIELD_NUMBER), + IPV6( + "bytes.ipv6", + "must be a valid IPv6 address", + "bytes.ipv6_empty", + "value is empty, which is not a valid IPv6 address", + Collections.singletonList(16), + BytesRules.IPV6_FIELD_NUMBER), + UUID( + "bytes.uuid", + "must be a valid UUID", + "bytes.uuid_empty", + "value is empty, which is not a valid UUID", + Collections.singletonList(16), + BytesRules.UUID_FIELD_NUMBER); + + final RuleSite site; + final RuleSite emptySite; + final List validSizes; + + WellKnown( + String ruleId, + String message, + String emptyRuleId, + String emptyMessage, + List validSizes, + int fieldNumber) { + FieldDescriptor leaf = BytesRules.getDescriptor().findFieldByNumber(fieldNumber); + this.site = RuleSite.of(BYTES_RULES_DESC, leaf, ruleId, message); + this.emptySite = RuleSite.of(BYTES_RULES_DESC, leaf, emptyRuleId, emptyMessage); + this.validSizes = Collections.unmodifiableList(validSizes); + } + + boolean sizeIsValid(int size) { + return validSizes.contains(size); + } + } + + private static final FieldDescriptor BYTES_RULES_DESC = + FieldRules.getDescriptor().findFieldByNumber(FieldRules.BYTES_FIELD_NUMBER); + + private static final RuleSite CONST_SITE = + RuleSite.of( + BYTES_RULES_DESC, + BytesRules.getDescriptor().findFieldByNumber(BytesRules.CONST_FIELD_NUMBER), + "bytes.const", + null); + private static final RuleSite LEN_SITE = + RuleSite.of( + BYTES_RULES_DESC, + BytesRules.getDescriptor().findFieldByNumber(BytesRules.LEN_FIELD_NUMBER), + "bytes.len", + null); + private static final RuleSite MIN_LEN_SITE = + RuleSite.of( + BYTES_RULES_DESC, + BytesRules.getDescriptor().findFieldByNumber(BytesRules.MIN_LEN_FIELD_NUMBER), + "bytes.min_len", + null); + private static final RuleSite MAX_LEN_SITE = + RuleSite.of( + BYTES_RULES_DESC, + BytesRules.getDescriptor().findFieldByNumber(BytesRules.MAX_LEN_FIELD_NUMBER), + "bytes.max_len", + null); + private static final RuleSite PATTERN_SITE = + RuleSite.of( + BYTES_RULES_DESC, + BytesRules.getDescriptor().findFieldByNumber(BytesRules.PATTERN_FIELD_NUMBER), + "bytes.pattern", + null); + private static final RuleSite PREFIX_SITE = + RuleSite.of( + BYTES_RULES_DESC, + BytesRules.getDescriptor().findFieldByNumber(BytesRules.PREFIX_FIELD_NUMBER), + "bytes.prefix", + null); + private static final RuleSite SUFFIX_SITE = + RuleSite.of( + BYTES_RULES_DESC, + BytesRules.getDescriptor().findFieldByNumber(BytesRules.SUFFIX_FIELD_NUMBER), + "bytes.suffix", + null); + private static final RuleSite CONTAINS_SITE = + RuleSite.of( + BYTES_RULES_DESC, + BytesRules.getDescriptor().findFieldByNumber(BytesRules.CONTAINS_FIELD_NUMBER), + "bytes.contains", + null); + private static final RuleSite IN_SITE = + RuleSite.of( + BYTES_RULES_DESC, + BytesRules.getDescriptor().findFieldByNumber(BytesRules.IN_FIELD_NUMBER), + "bytes.in", + null); + private static final RuleSite NOT_IN_SITE = + RuleSite.of( + BYTES_RULES_DESC, + BytesRules.getDescriptor().findFieldByNumber(BytesRules.NOT_IN_FIELD_NUMBER), + "bytes.not_in", + null); + + private final RuleBase base; + private final @Nullable ByteString constVal; + private final @Nullable Long exactLen; + private final @Nullable Long minLen; + private final @Nullable Long maxLen; + private final @Nullable Pattern pattern; + private final @Nullable String patternStr; + private final @Nullable ByteString prefix; + private final @Nullable ByteString suffix; + private final @Nullable ByteString contains; + private final List inVals; + private final List notInVals; + private final @Nullable WellKnown wellKnown; + + private BytesRulesEvaluator( + RuleBase base, + @Nullable ByteString constVal, + @Nullable Long exactLen, + @Nullable Long minLen, + @Nullable Long maxLen, + @Nullable Pattern pattern, + @Nullable String patternStr, + @Nullable ByteString prefix, + @Nullable ByteString suffix, + @Nullable ByteString contains, + List inVals, + List notInVals, + @Nullable WellKnown wellKnown) { + this.base = base; + this.constVal = constVal; + this.exactLen = exactLen; + this.minLen = minLen; + this.maxLen = maxLen; + this.pattern = pattern; + this.patternStr = patternStr; + this.prefix = prefix; + this.suffix = suffix; + this.contains = contains; + this.inVals = inVals; + this.notInVals = notInVals; + this.wellKnown = wellKnown; + } + + static @Nullable Evaluator tryBuild(RuleBase base, FieldRules.Builder rulesBuilder) { + if (!rulesBuilder.hasBytes()) { + return null; + } + BytesRules rules = rulesBuilder.getBytes(); + if (!rules.getUnknownFields().isEmpty()) { + return null; + } + + BytesRules.Builder bb = rules.toBuilder(); + boolean hasRule = false; + + WellKnown wellKnown = null; + // Mirror Go's switch — earlier cases win. Setting ip=true takes precedence over + // ipv4/ipv6/uuid if multiple are set; protovalidate considers that a misconfiguration but + // we follow Go's order to keep behavior identical. + if (rules.getIp()) { + wellKnown = WellKnown.IP; + bb.clearIp(); + hasRule = true; + } else if (rules.getIpv4()) { + wellKnown = WellKnown.IPV4; + bb.clearIpv4(); + hasRule = true; + } else if (rules.getIpv6()) { + wellKnown = WellKnown.IPV6; + bb.clearIpv6(); + hasRule = true; + } else if (rules.getUuid()) { + wellKnown = WellKnown.UUID; + bb.clearUuid(); + hasRule = true; + } + + ByteString constVal = null; + if (rules.hasConst()) { + constVal = rules.getConst(); + bb.clearConst(); + hasRule = true; + } + + Long exactLen = null; + if (rules.hasLen()) { + exactLen = rules.getLen(); + bb.clearLen(); + hasRule = true; + } + + Long minLen = null; + if (rules.hasMinLen()) { + minLen = rules.getMinLen(); + bb.clearMinLen(); + hasRule = true; + } + + Long maxLen = null; + if (rules.hasMaxLen()) { + maxLen = rules.getMaxLen(); + bb.clearMaxLen(); + hasRule = true; + } + + Pattern compiledPattern = null; + String patternStr = null; + if (rules.hasPattern()) { + patternStr = rules.getPattern(); + try { + compiledPattern = Pattern.compile(patternStr); + } catch (PatternSyntaxException e) { + // Bail to CEL — it produces the same compilation error. + return null; + } + bb.clearPattern(); + hasRule = true; + } + + ByteString prefix = null; + if (rules.hasPrefix()) { + prefix = rules.getPrefix(); + bb.clearPrefix(); + hasRule = true; + } + + ByteString suffix = null; + if (rules.hasSuffix()) { + suffix = rules.getSuffix(); + bb.clearSuffix(); + hasRule = true; + } + + ByteString contains = null; + if (rules.hasContains()) { + contains = rules.getContains(); + bb.clearContains(); + hasRule = true; + } + + // Proto returns immutable views; we only read them. + List inVals = rules.getInList(); + if (!inVals.isEmpty()) { + bb.clearIn(); + hasRule = true; + } + + List notInVals = rules.getNotInList(); + if (!notInVals.isEmpty()) { + bb.clearNotIn(); + hasRule = true; + } + + if (!hasRule) { + return null; + } + rulesBuilder.setBytes(bb.build()); + return new BytesRulesEvaluator( + base, + constVal, + exactLen, + minLen, + maxLen, + compiledPattern, + patternStr, + prefix, + suffix, + contains, + inVals, + notInVals, + wellKnown); + } + + @Override + public boolean tautology() { + return false; + } + + @Override + public List evaluate(Value val, boolean failFast) + throws ExecutionException { + ByteString bytesVal = (ByteString) val.rawValue(); + long byteLen = bytesVal.size(); + List violations = null; + + if (constVal != null && !bytesVal.equals(constVal)) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + CONST_SITE, null, "must be " + hex(constVal), val, constVal)); + if (failFast) return base.done(violations); + } + + if (exactLen != null && byteLen != exactLen) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + LEN_SITE, null, "must be " + exactLen + " bytes", val, exactLen)); + if (failFast) return base.done(violations); + } + + if (minLen != null && byteLen < minLen) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + MIN_LEN_SITE, null, "must be at least " + minLen + " bytes", val, minLen)); + if (failFast) return base.done(violations); + } + + if (maxLen != null && byteLen > maxLen) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + MAX_LEN_SITE, null, "must be at most " + maxLen + " bytes", val, maxLen)); + if (failFast) return base.done(violations); + } + + if (pattern != null) { + if (!bytesVal.isValidUtf8()) { + // Match Go: surface this as an execution error rather than a violation. The conformance + // suite expects pattern checks to fail loudly on non-UTF-8 input. + throw new ExecutionException("must be valid UTF-8 to apply regexp"); + } + if (!pattern.matches(bytesVal.toStringUtf8())) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + PATTERN_SITE, + null, + "must match regex pattern `" + patternStr + "`", + val, + patternStr)); + if (failFast) return base.done(violations); + } + } + + if (prefix != null && !bytesVal.startsWith(prefix)) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + PREFIX_SITE, null, "does not have prefix " + hex(prefix), val, prefix)); + if (failFast) return base.done(violations); + } + + if (suffix != null && !bytesVal.endsWith(suffix)) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + SUFFIX_SITE, null, "does not have suffix " + hex(suffix), val, suffix)); + if (failFast) return base.done(violations); + } + + if (contains != null && !containsBytes(bytesVal, contains)) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + CONTAINS_SITE, null, "does not contain " + hex(contains), val, contains)); + if (failFast) return base.done(violations); + } + + if (!inVals.isEmpty() && !inVals.contains(bytesVal)) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + IN_SITE, null, "must be in list " + formatList(inVals), val, bytesVal)); + if (failFast) return base.done(violations); + } + + if (!notInVals.isEmpty() && notInVals.contains(bytesVal)) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + NOT_IN_SITE, + null, + "must not be in list " + formatList(notInVals), + val, + bytesVal)); + if (failFast) return base.done(violations); + } + + if (wellKnown != null) { + RuleViolation.Builder wkViolation = evaluateWellKnown(bytesVal, val); + if (wkViolation != null) { + violations = RuleBase.add(violations, wkViolation); + if (failFast) return base.done(violations); + } + } + + return base.done(violations); + } + + private RuleViolation.@Nullable Builder evaluateWellKnown(ByteString bytesVal, Value val) { + int size = bytesVal.size(); + WellKnown wk = wellKnown; + if (wk == null) { + return null; + } + if (size == 0) { + // Rule value is the bool 'true' (the rule was enabled). Site has the rule id and message + // pre-baked. + return NativeViolations.newViolation(wk.emptySite, null, null, val, true); + } + if (wk.sizeIsValid(size)) { + return null; + } + return NativeViolations.newViolation(wk.site, null, null, val, true); + } + + /** {@code ByteString} doesn't have a {@code contains} method; implement it directly. */ + private static boolean containsBytes(ByteString haystack, ByteString needle) { + int hLen = haystack.size(); + int nLen = needle.size(); + if (nLen == 0) { + return true; + } + if (nLen > hLen) { + return false; + } + outer: + for (int i = 0; i <= hLen - nLen; i++) { + for (int j = 0; j < nLen; j++) { + if (haystack.byteAt(i + j) != needle.byteAt(j)) { + continue outer; + } + } + return true; + } + return false; + } + + private static final char[] HEX_DIGITS = "0123456789abcdef".toCharArray(); + + /** Lowercase hex encoding to match Go's {@code fmt.Sprintf("%x", ...)} for byte slices. */ + private static String hex(ByteString bs) { + int len = bs.size(); + char[] out = new char[len * 2]; + for (int i = 0; i < len; i++) { + int b = bs.byteAt(i) & 0xff; + out[i * 2] = HEX_DIGITS[b >>> 4]; + out[i * 2 + 1] = HEX_DIGITS[b & 0xf]; + } + return new String(out); + } + + /** + * Formats a list of bytes the way CEL does — each element rendered as its raw string (UTF-8). + * Mirrors Go's {@code formatBytesList}. + */ + private static String formatList(List vals) { + return RuleBase.formatList(vals, ByteString::toStringUtf8); + } +} diff --git a/src/main/java/build/buf/protovalidate/Config.java b/src/main/java/build/buf/protovalidate/Config.java index 5317c503..e1282321 100644 --- a/src/main/java/build/buf/protovalidate/Config.java +++ b/src/main/java/build/buf/protovalidate/Config.java @@ -27,16 +27,19 @@ public final class Config { private final TypeRegistry typeRegistry; private final ExtensionRegistry extensionRegistry; private final boolean allowUnknownFields; + private final boolean enableNativeRules; private Config( boolean failFast, TypeRegistry typeRegistry, ExtensionRegistry extensionRegistry, - boolean allowUnknownFields) { + boolean allowUnknownFields, + boolean enableNativeRules) { this.failFast = failFast; this.typeRegistry = typeRegistry; this.extensionRegistry = extensionRegistry; this.allowUnknownFields = allowUnknownFields; + this.enableNativeRules = enableNativeRules; } /** @@ -84,12 +87,27 @@ public boolean isAllowingUnknownFields() { return allowUnknownFields; } + /** + * Checks whether native (non-CEL) rule evaluators are enabled. + * + *

When true, standard rules with a native Java implementation bypass CEL evaluation. When + * false, all rules go through CEL. Defaults to true; applications opt out by calling {@link + * Builder#setEnableNativeRules(boolean) setEnableNativeRules(false)}. + * + * @return true if native rules are enabled. + */ + public boolean isNativeRulesEnabled() { + return enableNativeRules; + } + /** Builder for configuration. Provides a forward compatible API for users. */ public static final class Builder { private boolean failFast; private TypeRegistry typeRegistry = DEFAULT_TYPE_REGISTRY; private ExtensionRegistry extensionRegistry = DEFAULT_EXTENSION_REGISTRY; private boolean allowUnknownFields; + // native rules are enabled by default + private boolean enableNativeRules = true; private Builder() {} @@ -157,13 +175,27 @@ public Builder setAllowUnknownFields(boolean allowUnknownFields) { return this; } + /** + * Enables or disables native (non-CEL) rule evaluators. Native rules are enabled by default. + * Forward-compatible: any rule not yet implemented natively continues to be enforced via CEL + * regardless of this setting. + * + * @param enableNativeRules whether to enable native rules + * @return this builder + */ + public Builder setEnableNativeRules(boolean enableNativeRules) { + this.enableNativeRules = enableNativeRules; + return this; + } + /** * Build the corresponding {@link Config}. * * @return the configuration. */ public Config build() { - return new Config(failFast, typeRegistry, extensionRegistry, allowUnknownFields); + return new Config( + failFast, typeRegistry, extensionRegistry, allowUnknownFields, enableNativeRules); } } } diff --git a/src/main/java/build/buf/protovalidate/CustomOverload.java b/src/main/java/build/buf/protovalidate/CustomOverload.java index dd631eed..d5db6ec0 100644 --- a/src/main/java/build/buf/protovalidate/CustomOverload.java +++ b/src/main/java/build/buf/protovalidate/CustomOverload.java @@ -417,7 +417,7 @@ private static boolean matches( *

The port is separated by a colon. It must be non-empty, with a decimal number in the range * of 0-65535, inclusive. */ - private static boolean isHostAndPort(String str, boolean portRequired) { + static boolean isHostAndPort(String str, boolean portRequired) { if (str.isEmpty()) { return false; } @@ -503,7 +503,7 @@ private static boolean uniqueList(List list) throws CelEvaluationException { * @param addr The input string to validate as an email address. * @return {@code true} if the input string is a valid email address, {@code false} otherwise. */ - private static boolean isEmail(String addr) { + static boolean isEmail(String addr) { return EMAIL_REGEX.matcher(addr).matches(); } @@ -521,7 +521,7 @@ private static boolean isEmail(String addr) { *

  • The name can be 253 characters at most, excluding the optional trailing dot. * */ - private static boolean isHostname(String val) { + static boolean isHostname(String val) { if (val.length() > 253) { return false; } @@ -595,7 +595,7 @@ static boolean isIp(String addr, long ver) { *

    URI is defined in the internet standard RFC 3986. Zone Identifiers in IPv6 address literals * are supported (RFC 6874). */ - private static boolean isUri(String str) { + static boolean isUri(String str) { return new Uri(str).uri(); } @@ -607,7 +607,7 @@ private static boolean isUri(String str) { *

    URI, URI Reference, and Relative Reference are defined in the internet standard RFC 3986. * Zone Identifiers in IPv6 address literals are supported (RFC 6874). */ - private static boolean isUriRef(String str) { + static boolean isUriRef(String str) { return new Uri(str).uriReference(); } @@ -628,7 +628,7 @@ private static boolean isUriRef(String str) { *

    The same principle applies to IPv4 addresses. "192.168.1.0/24" designates the first 24 bits * of the 32-bit IPv4 as the network prefix. */ - private static boolean isIpPrefix(String str, long version, boolean strict) { + static boolean isIpPrefix(String str, long version, boolean strict) { if (version == 6L) { Ipv6 ip = new Ipv6(str); return ip.addressPrefix() && (!strict || ip.isPrefixOnly()); diff --git a/src/main/java/build/buf/protovalidate/EnumRulesEvaluator.java b/src/main/java/build/buf/protovalidate/EnumRulesEvaluator.java new file mode 100644 index 00000000..ba26aad3 --- /dev/null +++ b/src/main/java/build/buf/protovalidate/EnumRulesEvaluator.java @@ -0,0 +1,171 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import build.buf.validate.EnumRules; +import build.buf.validate.FieldRules; +import com.google.protobuf.Descriptors.EnumValueDescriptor; +import com.google.protobuf.Descriptors.FieldDescriptor; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Native evaluator for enum {@code const}/{@code in}/{@code not_in}. The {@code defined_only} rule + * is handled separately by the existing {@link build.buf.protovalidate.EnumEvaluator}; both can be + * active simultaneously and the {@link build.buf.protovalidate.ValueEvaluator} runs them in order. + */ +final class EnumRulesEvaluator implements Evaluator { + private static final FieldDescriptor ENUM_RULES_DESC = + FieldRules.getDescriptor().findFieldByNumber(FieldRules.ENUM_FIELD_NUMBER); + private static final FieldDescriptor CONST_DESC = + EnumRules.getDescriptor().findFieldByNumber(EnumRules.CONST_FIELD_NUMBER); + private static final FieldDescriptor IN_DESC = + EnumRules.getDescriptor().findFieldByNumber(EnumRules.IN_FIELD_NUMBER); + private static final FieldDescriptor NOT_IN_DESC = + EnumRules.getDescriptor().findFieldByNumber(EnumRules.NOT_IN_FIELD_NUMBER); + private static final RuleSite CONST_SITE = + RuleSite.of(ENUM_RULES_DESC, CONST_DESC, "enum.const", null); + private static final RuleSite IN_SITE = RuleSite.of(ENUM_RULES_DESC, IN_DESC, "enum.in", null); + private static final RuleSite NOT_IN_SITE = + RuleSite.of(ENUM_RULES_DESC, NOT_IN_DESC, "enum.not_in", null); + + private final RuleBase base; + private final @Nullable Integer constVal; + private final List inVals; + private final List notInVals; + + private EnumRulesEvaluator( + RuleBase base, @Nullable Integer constVal, List inVals, List notInVals) { + this.base = base; + this.constVal = constVal; + this.inVals = inVals; + this.notInVals = notInVals; + } + + /** + * Builds a {@link EnumRulesEvaluator} for the {@code const}/{@code in}/{@code not_in} rules on + * the supplied {@code FieldRules.Builder}'s enum sub-message. Returns null when the enum sub- + * message is unset, has unknown fields, or has none of the covered rules. The {@code + * defined_only} field is left untouched on the residual so the existing {@link + * build.buf.protovalidate.EnumEvaluator} continues to handle it. + */ + static @Nullable Evaluator tryBuild(RuleBase base, FieldRules.Builder rulesBuilder) { + if (!rulesBuilder.hasEnum()) { + return null; + } + EnumRules enumRules = rulesBuilder.getEnum(); + if (!enumRules.getUnknownFields().isEmpty()) { + return null; + } + + EnumRules.Builder eb = enumRules.toBuilder(); + boolean hasRule = false; + + Integer constVal = null; + if (enumRules.hasConst()) { + constVal = enumRules.getConst(); + eb.clearConst(); + hasRule = true; + } + + // Proto returns immutable views; we only read them. + List inVals = enumRules.getInList(); + if (!inVals.isEmpty()) { + eb.clearIn(); + hasRule = true; + } + + List notInVals = enumRules.getNotInList(); + if (!notInVals.isEmpty()) { + eb.clearNotIn(); + hasRule = true; + } + + if (!hasRule) { + return null; + } + rulesBuilder.setEnum(eb.build()); + return new EnumRulesEvaluator(base, constVal, inVals, notInVals); + } + + @Override + public boolean tautology() { + return false; + } + + @Override + public List evaluate(Value val, boolean failFast) { + int actual = enumNumber(val.rawValue()); + List violations = null; + + if (constVal != null && actual != constVal) { + RuleViolation.Builder b = + NativeViolations.newViolation(CONST_SITE, null, "must equal " + constVal, val, constVal); + violations = RuleBase.add(violations, b); + if (failFast) { + return base.done(violations); + } + } + + if (!inVals.isEmpty() && !inVals.contains(actual)) { + RuleViolation.Builder b = + NativeViolations.newViolation( + IN_SITE, null, "must be in list " + RuleBase.formatList(inVals), val, actual); + violations = RuleBase.add(violations, b); + if (failFast) { + return base.done(violations); + } + } + + if (!notInVals.isEmpty() && notInVals.contains(actual)) { + RuleViolation.Builder b = + NativeViolations.newViolation( + NOT_IN_SITE, + null, + "must not be in list " + RuleBase.formatList(notInVals), + val, + actual); + violations = RuleBase.add(violations, b); + if (failFast) { + return base.done(violations); + } + } + + return base.done(violations); + } + + /** + * Extracts the enum's numeric value from {@link Value#rawValue()}. Java protobuf normally returns + * an {@link EnumValueDescriptor}, but unknown enum values may surface as {@link Integer} + * depending on the proto edition; handle both. + */ + private static int enumNumber(Object raw) { + if (raw instanceof EnumValueDescriptor) { + return ((EnumValueDescriptor) raw).getNumber(); + } + if (raw instanceof Integer) { + return (Integer) raw; + } + // the enum wire format says that enums are encoded as though they are int32s. + // https://protobuf.dev/programming-guides/encoding/ I don't know if the value could + // end up in a Long somehow, so coding defensively around that. If a value out of + // 32-bit int range shows up, Math.toIntExact will throw an exception. + if (raw instanceof Long) { + return Math.toIntExact((Long) raw); + } + throw new IllegalStateException( + "unexpected enum value representation: " + raw.getClass().getName()); + } +} diff --git a/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java b/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java index 05f8c1df..00348f4c 100644 --- a/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java +++ b/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java @@ -60,6 +60,7 @@ final class EvaluatorBuilder { private final Cel cel; private final boolean disableLazy; + private final boolean enableNativeRules; private final RuleCache rules; /** @@ -69,8 +70,13 @@ final class EvaluatorBuilder { * @param config The configuration to use for the evaluation. */ EvaluatorBuilder(Cel cel, Config config) { + this(cel, config, false); + } + + private EvaluatorBuilder(Cel cel, Config config, boolean disableLazy) { this.cel = cel; - this.disableLazy = false; + this.disableLazy = disableLazy; + this.enableNativeRules = config.isNativeRulesEnabled(); this.rules = new RuleCache(cel, config); } @@ -79,14 +85,14 @@ final class EvaluatorBuilder { * * @param cel The CEL environment for evaluation. * @param config The configuration to use for the evaluation. + * @param descriptors The descriptors to build evaluators for. Must be non-null. + * @param disableLazy If true, the builder will not cache evaluators for descriptors that are not + * @throws CompilationException If an evaluator can't be built for a descriptor. */ EvaluatorBuilder(Cel cel, Config config, List descriptors, boolean disableLazy) throws CompilationException { + this(cel, config, disableLazy); Objects.requireNonNull(descriptors, "descriptors must not be null"); - this.cel = cel; - this.disableLazy = disableLazy; - this.rules = new RuleCache(cel, config); - for (Descriptor descriptor : descriptors) { this.build(descriptor); } @@ -126,7 +132,7 @@ private Evaluator build(Descriptor desc) throws CompilationException { } // Rebuild cache with this descriptor (and any of its dependencies). Map updatedCache = - new DescriptorCacheBuilder(cel, rules, evaluatorCache).build(desc); + new DescriptorCacheBuilder(cel, rules, enableNativeRules, evaluatorCache).build(desc); evaluatorCache = updatedCache; eval = updatedCache.get(desc); if (eval == null) { @@ -141,12 +147,17 @@ private static class DescriptorCacheBuilder { private final RuleResolver resolver = new RuleResolver(); private final Cel cel; private final RuleCache ruleCache; + private final boolean enableNativeRules; private final HashMap cache; private DescriptorCacheBuilder( - Cel cel, RuleCache ruleCache, Map previousCache) { + Cel cel, + RuleCache ruleCache, + boolean enableNativeRules, + Map previousCache) { this.cel = Objects.requireNonNull(cel, "cel"); this.ruleCache = Objects.requireNonNull(ruleCache, "ruleCache"); + this.enableNativeRules = enableNativeRules; this.cache = new HashMap<>(previousCache); } @@ -463,6 +474,18 @@ private void processStandardRules( } } + // Try native rule evaluators when opted in. Any rule covered natively is cleared on the + // residual builder so CEL only compiles what's left; rules without a native implementation + // remain on the residual and CEL handles them. + if (enableNativeRules) { + FieldRules.Builder rulesBuilder = fieldRules.toBuilder(); + Evaluator nativeEval = Rules.tryBuild(fieldDescriptor, rulesBuilder, valueEvaluatorEval); + if (nativeEval != null) { + valueEvaluatorEval.append(nativeEval); + fieldRules = rulesBuilder.build(); + } + } + List compile = ruleCache.compile(fieldDescriptor, fieldRules, valueEvaluatorEval.hasNestedRule()); if (compile.isEmpty()) { diff --git a/src/main/java/build/buf/protovalidate/ListElementValue.java b/src/main/java/build/buf/protovalidate/ListElementValue.java index fd9d0a11..8e397d32 100644 --- a/src/main/java/build/buf/protovalidate/ListElementValue.java +++ b/src/main/java/build/buf/protovalidate/ListElementValue.java @@ -61,6 +61,11 @@ public T value(Class clazz) { return clazz.cast(ProtoAdapter.scalarToCel(type, value)); } + @Override + public Object rawValue() { + return value; + } + @Override public List repeatedValue() { return Collections.emptyList(); diff --git a/src/main/java/build/buf/protovalidate/MapRulesEvaluator.java b/src/main/java/build/buf/protovalidate/MapRulesEvaluator.java new file mode 100644 index 00000000..39455b9e --- /dev/null +++ b/src/main/java/build/buf/protovalidate/MapRulesEvaluator.java @@ -0,0 +1,133 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import build.buf.validate.FieldRules; +import build.buf.validate.MapRules; +import com.google.protobuf.Descriptors.FieldDescriptor; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Native evaluator for map-level rules: {@code min_pairs} and {@code max_pairs}. Key/value rules + * continue to flow through {@link build.buf.protovalidate.MapEvaluator} and the inner key/value + * {@link build.buf.protovalidate.ValueEvaluator}s. Mirrors {@code nativeMapEval} in + * protovalidate-go's {@code native_map.go}. + */ +final class MapRulesEvaluator implements Evaluator { + private static final FieldDescriptor MAP_RULES_DESC = + FieldRules.getDescriptor().findFieldByNumber(FieldRules.MAP_FIELD_NUMBER); + + private static final RuleSite MIN_PAIRS_SITE = + RuleSite.of( + MAP_RULES_DESC, + MapRules.getDescriptor().findFieldByNumber(MapRules.MIN_PAIRS_FIELD_NUMBER), + "map.min_pairs", + null); + private static final RuleSite MAX_PAIRS_SITE = + RuleSite.of( + MAP_RULES_DESC, + MapRules.getDescriptor().findFieldByNumber(MapRules.MAX_PAIRS_FIELD_NUMBER), + "map.max_pairs", + null); + + private final RuleBase base; + private final @Nullable Long minPairs; + private final @Nullable Long maxPairs; + + private MapRulesEvaluator(RuleBase base, @Nullable Long minPairs, @Nullable Long maxPairs) { + this.base = base; + this.minPairs = minPairs; + this.maxPairs = maxPairs; + } + + static @Nullable Evaluator tryBuild(RuleBase base, FieldRules.Builder rulesBuilder) { + if (!rulesBuilder.hasMap()) { + return null; + } + MapRules rules = rulesBuilder.getMap(); + if (!rules.getUnknownFields().isEmpty()) { + return null; + } + + MapRules.Builder mb = rules.toBuilder(); + boolean hasRule = false; + + Long minPairs = null; + if (rules.hasMinPairs()) { + minPairs = rules.getMinPairs(); + mb.clearMinPairs(); + hasRule = true; + } + + Long maxPairs = null; + if (rules.hasMaxPairs()) { + maxPairs = rules.getMaxPairs(); + mb.clearMaxPairs(); + hasRule = true; + } + + if (!hasRule) { + return null; + } + rulesBuilder.setMap(mb.build()); + return new MapRulesEvaluator(base, minPairs, maxPairs); + } + + @Override + public boolean tautology() { + // tryBuild returns null when neither field is set, so this evaluator is never built + // without at least one rule active. Match the rest of the rules package by returning false + // unconditionally. + return false; + } + + @Override + public List evaluate(Value val, boolean failFast) { + // Java protobuf returns map fields as a List of synthetic key/value entry messages; the size + // is the pair count. + List entries = (List) val.rawValue(); + long size = entries.size(); + List violations = null; + + if (minPairs != null && size < minPairs) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + MIN_PAIRS_SITE, + null, + "map must be at least " + minPairs + " entries", + val, + minPairs)); + if (failFast) return base.done(violations); + } + + if (maxPairs != null && size > maxPairs) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + MAX_PAIRS_SITE, + null, + "map must be at most " + maxPairs + " entries", + val, + maxPairs)); + if (failFast) return base.done(violations); + } + + return base.done(violations); + } +} diff --git a/src/main/java/build/buf/protovalidate/MessageValue.java b/src/main/java/build/buf/protovalidate/MessageValue.java index ee15f4a2..fa96e3b8 100644 --- a/src/main/java/build/buf/protovalidate/MessageValue.java +++ b/src/main/java/build/buf/protovalidate/MessageValue.java @@ -51,6 +51,11 @@ public T value(Class clazz) { return clazz.cast(value); } + @Override + public Object rawValue() { + return value; + } + @Override public List repeatedValue() { return Collections.emptyList(); diff --git a/src/main/java/build/buf/protovalidate/NativeViolations.java b/src/main/java/build/buf/protovalidate/NativeViolations.java new file mode 100644 index 00000000..da345b8f --- /dev/null +++ b/src/main/java/build/buf/protovalidate/NativeViolations.java @@ -0,0 +1,69 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import org.jspecify.annotations.Nullable; + +/** + * Builds {@link RuleViolation.Builder} instances for native rule evaluators. + * + *

    The resulting builder carries only rule-relative state: rule id, message, the rule path suffix + * from {@link RuleSite}, and optional field/rule values. Field path and any nested-rule prefix are + * prepended later by {@code FieldPathUtils.updatePaths} when the violations leave the native + * evaluator's {@code evaluate} — the same pattern {@code CelPrograms} uses. + */ +final class NativeViolations { + private NativeViolations() {} + + /** + * Builds a violation for a rule failure. If {@link RuleSite#getRuleId()} or {@link + * RuleSite#getMessage()} return non-null they take precedence over the supplied {@code ruleId} + * and {@code message} arguments; this lets a {@link RuleSite} pre-bake constant text and skip the + * per-call argument when there's nothing dynamic to report. + * + * @param site the rule site (rule path suffix + leaf descriptor + optional pre-baked id/message) + * @param ruleId rule id to use when the site doesn't have one pre-baked + * @param message violation message to use when the site doesn't have one pre-baked + * @param fieldValue the failing field value (its descriptor is used to populate {@code + * field_value} on the violation); pass null to omit + * @param ruleValue the rule's bound value (e.g. the {@code 5} in {@code min_len = 5}), bound to + * the site's leaf descriptor; pass null to omit + */ + static RuleViolation.Builder newViolation( + RuleSite site, + @Nullable String ruleId, + @Nullable String message, + @Nullable Value fieldValue, + @Nullable Object ruleValue) { + String effectiveRuleId = (site.getRuleId() != null) ? site.getRuleId() : ruleId; + String effectiveMessage = (site.getMessage() != null) ? site.getMessage() : message; + + RuleViolation.Builder builder = RuleViolation.newBuilder(); + if (effectiveRuleId != null) { + builder.setRuleId(effectiveRuleId); + } + if (effectiveMessage != null) { + builder.setMessage(effectiveMessage); + } + builder.addAllRulePathElements(site.getPathElements()); + if (fieldValue != null && fieldValue.fieldDescriptor() != null) { + builder.setFieldValue(new RuleViolation.FieldValue(fieldValue)); + } + if (ruleValue != null) { + builder.setRuleValue(new RuleViolation.FieldValue(ruleValue, site.getLeafDescriptor())); + } + return builder; + } +} diff --git a/src/main/java/build/buf/protovalidate/NumericDescriptors.java b/src/main/java/build/buf/protovalidate/NumericDescriptors.java new file mode 100644 index 00000000..b5487038 --- /dev/null +++ b/src/main/java/build/buf/protovalidate/NumericDescriptors.java @@ -0,0 +1,127 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import com.google.protobuf.Descriptors.Descriptor; +import com.google.protobuf.Descriptors.FieldDescriptor; +import org.jspecify.annotations.Nullable; + +/** + * Pre-built {@link RuleSite}s for a single numeric rule type ({@code Int32Rules}, {@code + * UInt64Rules}, {@code FloatRules}, etc.). Built once per type at class-init time so violation + * construction at validation time avoids re-building path-element protos. + * + *

    {@code finiteSite} is null for non-float kinds. + */ +final class NumericDescriptors { + final RuleSite gtSite; + final RuleSite gteSite; + final RuleSite ltSite; + final RuleSite lteSite; + final RuleSite constSite; + final RuleSite inSite; + final RuleSite notInSite; + final @Nullable RuleSite finiteSite; + // Leaf descriptors used to look up rule fields on the *Rules message at build time. + final FieldDescriptor gtField; + final FieldDescriptor gteField; + final FieldDescriptor ltField; + final FieldDescriptor lteField; + final FieldDescriptor constField; + final FieldDescriptor inField; + final FieldDescriptor notInField; + final @Nullable FieldDescriptor finiteField; + + private NumericDescriptors( + RuleSite gtSite, + RuleSite gteSite, + RuleSite ltSite, + RuleSite lteSite, + RuleSite constSite, + RuleSite inSite, + RuleSite notInSite, + @Nullable RuleSite finiteSite, + FieldDescriptor gtField, + FieldDescriptor gteField, + FieldDescriptor ltField, + FieldDescriptor lteField, + FieldDescriptor constField, + FieldDescriptor inField, + FieldDescriptor notInField, + @Nullable FieldDescriptor finiteField) { + this.gtSite = gtSite; + this.gteSite = gteSite; + this.ltSite = ltSite; + this.lteSite = lteSite; + this.constSite = constSite; + this.inSite = inSite; + this.notInSite = notInSite; + this.finiteSite = finiteSite; + this.gtField = gtField; + this.gteField = gteField; + this.ltField = ltField; + this.lteField = lteField; + this.constField = constField; + this.inField = inField; + this.notInField = notInField; + this.finiteField = finiteField; + } + + /** + * Builds the descriptor bundle for a numeric rule type. + * + * @param fieldRulesField the {@link FieldDescriptor} of the {@code FieldRules} oneof case (e.g. + * the {@code int32} field on {@code FieldRules}) + * @param rulesDescriptor the {@link Descriptor} of the rules message (e.g. {@code Int32Rules}) + * @param typeName the proto rule prefix used in rule ids (e.g. {@code "int32"}) + * @param hasFinite whether this kind supports the {@code finite} rule (only float/double do) + */ + static NumericDescriptors build( + FieldDescriptor fieldRulesField, + Descriptor rulesDescriptor, + String typeName, + boolean hasFinite) { + FieldDescriptor gt = rulesDescriptor.findFieldByName("gt"); + FieldDescriptor gte = rulesDescriptor.findFieldByName("gte"); + FieldDescriptor lt = rulesDescriptor.findFieldByName("lt"); + FieldDescriptor lte = rulesDescriptor.findFieldByName("lte"); + FieldDescriptor constant = rulesDescriptor.findFieldByName("const"); + FieldDescriptor inField = rulesDescriptor.findFieldByName("in"); + FieldDescriptor notInField = rulesDescriptor.findFieldByName("not_in"); + FieldDescriptor finiteField = hasFinite ? rulesDescriptor.findFieldByName("finite") : null; + return new NumericDescriptors( + // Sites carry rule-id and rule-path for violation building. Where the rule id is + // computed dynamically (gt/gte/lt/lte combine into different ids depending on which + // bounds are active), pass null and let the caller supply per-violation. + RuleSite.of(fieldRulesField, gt, null, null), + RuleSite.of(fieldRulesField, gte, null, null), + RuleSite.of(fieldRulesField, lt, null, null), + RuleSite.of(fieldRulesField, lte, null, null), + RuleSite.of(fieldRulesField, constant, typeName + ".const", null), + RuleSite.of(fieldRulesField, inField, typeName + ".in", null), + RuleSite.of(fieldRulesField, notInField, typeName + ".not_in", null), + finiteField != null + ? RuleSite.of(fieldRulesField, finiteField, typeName + ".finite", "must be finite") + : null, + gt, + gte, + lt, + lte, + constant, + inField, + notInField, + finiteField); + } +} diff --git a/src/main/java/build/buf/protovalidate/NumericRulesEvaluator.java b/src/main/java/build/buf/protovalidate/NumericRulesEvaluator.java new file mode 100644 index 00000000..71f92739 --- /dev/null +++ b/src/main/java/build/buf/protovalidate/NumericRulesEvaluator.java @@ -0,0 +1,407 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import build.buf.validate.FieldRules; +import com.google.protobuf.Descriptors.FieldDescriptor; +import com.google.protobuf.Message; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Native evaluator for the standard numeric rules ({@code gt}, {@code gte}, {@code lt}, {@code + * lte}, {@code const}, {@code in}, {@code not_in}, plus {@code finite} for float/double). Mirrors + * {@code nativeNumericCompare} in protovalidate-go's {@code native_numeric.go}, parameterized over + * the boxed Java numeric type ({@code Integer}, {@code Long}, {@code Float}, {@code Double}). + * + *

    Signed vs unsigned semantics are encoded in the supplied {@link NumericTypeConfig}: the + * config's comparator decides ordering and its formatter decides how values render in messages. The + * same {@code Integer}-typed evaluator is shared between {@code int32} (signed comparator, {@code + * String.valueOf} formatter) and {@code uint32} ({@code Integer::compareUnsigned}, {@code + * Integer::toUnsignedString}). + */ +final class NumericRulesEvaluator> implements Evaluator { + + /** Lower bound active on this evaluator. */ + enum LowerBound { + NONE, + GTE, // inclusive + GT // exclusive + } + + /** Upper bound active on this evaluator. */ + enum UpperBound { + NONE, + LT, + LTE + } + + private final RuleBase base; + private final NumericTypeConfig config; + private final @Nullable T constVal; + private final List inVals; + private final List notInVals; + private final @Nullable T loVal; + private final LowerBound lowerKind; + private final @Nullable T hiVal; + private final UpperBound upperKind; + private final boolean finite; + + private NumericRulesEvaluator( + RuleBase base, + NumericTypeConfig config, + @Nullable T constVal, + List inVals, + List notInVals, + @Nullable T loVal, + LowerBound lowerKind, + @Nullable T hiVal, + UpperBound upperKind, + boolean finite) { + this.base = base; + this.config = config; + this.constVal = constVal; + this.inVals = inVals; + this.notInVals = notInVals; + this.loVal = loVal; + this.lowerKind = lowerKind; + this.hiVal = hiVal; + this.upperKind = upperKind; + this.finite = finite; + } + + /** + * Attempts to build a {@link NumericRulesEvaluator} for the rules under {@code rulesField} on + * {@code rulesBuilder}. Returns null when the typed sub-message is unset, has unknown fields, or + * carries no rule we cover. On success, clears the covered fields on the builder so CEL doesn't + * also compile programs for them. + * + * @param base the base rule evaluator. + * @param rulesBuilder the builder for the rules sub-message. + * @param config the config for the numeric type this evaluator is for. + * @return a new evaluator, or null if the sub-message is unset, has unknown fields, or carries no + * rule we cover. + */ + static > @Nullable Evaluator tryBuild( + RuleBase base, FieldRules.Builder rulesBuilder, NumericTypeConfig config) { + FieldDescriptor rulesField = config.rulesField; + if (!rulesBuilder.hasField(rulesField)) { + return null; + } + Message rulesMsg = (Message) rulesBuilder.getField(rulesField); + if (!rulesMsg.getUnknownFields().isEmpty()) { + return null; + } + + NumericDescriptors descs = config.descriptors; + Message.Builder typedBuilder = rulesMsg.toBuilder(); + boolean hasRule = false; + + T loVal = null; + LowerBound lowerKind = LowerBound.NONE; + if (rulesMsg.hasField(descs.gtField)) { + lowerKind = LowerBound.GT; + loVal = config.valueClass.cast(rulesMsg.getField(descs.gtField)); + typedBuilder.clearField(descs.gtField); + hasRule = true; + } else if (rulesMsg.hasField(descs.gteField)) { + lowerKind = LowerBound.GTE; + loVal = config.valueClass.cast(rulesMsg.getField(descs.gteField)); + typedBuilder.clearField(descs.gteField); + hasRule = true; + } + + T hiVal = null; + UpperBound upperKind = UpperBound.NONE; + if (rulesMsg.hasField(descs.ltField)) { + upperKind = UpperBound.LT; + hiVal = config.valueClass.cast(rulesMsg.getField(descs.ltField)); + typedBuilder.clearField(descs.ltField); + hasRule = true; + } else if (rulesMsg.hasField(descs.lteField)) { + upperKind = UpperBound.LTE; + hiVal = config.valueClass.cast(rulesMsg.getField(descs.lteField)); + typedBuilder.clearField(descs.lteField); + hasRule = true; + } + + T constVal = null; + if (rulesMsg.hasField(descs.constField)) { + constVal = config.valueClass.cast(rulesMsg.getField(descs.constField)); + typedBuilder.clearField(descs.constField); + hasRule = true; + } + + @SuppressWarnings("unchecked") + List rawInVals = (List) rulesMsg.getField(descs.inField); + List inVals = rawInVals.isEmpty() ? Collections.emptyList() : rawInVals; + if (!inVals.isEmpty()) { + typedBuilder.clearField(descs.inField); + hasRule = true; + } + + @SuppressWarnings("unchecked") + List rawNotInVals = (List) rulesMsg.getField(descs.notInField); + List notInVals = rawNotInVals.isEmpty() ? Collections.emptyList() : rawNotInVals; + if (!notInVals.isEmpty()) { + typedBuilder.clearField(descs.notInField); + hasRule = true; + } + + boolean finite = false; + if (descs.finiteField != null && rulesMsg.hasField(descs.finiteField)) { + finite = (Boolean) rulesMsg.getField(descs.finiteField); + typedBuilder.clearField(descs.finiteField); + hasRule = true; + } + + if (!hasRule) { + return null; + } + rulesBuilder.setField(rulesField, typedBuilder.build()); + return new NumericRulesEvaluator( + base, config, constVal, inVals, notInVals, loVal, lowerKind, hiVal, upperKind, finite); + } + + @Override + public boolean tautology() { + return false; + } + + @Override + public List evaluate(Value val, boolean failFast) { + T actual = config.valueClass.cast(val.rawValue()); + List violations = null; + + if (constVal != null && config.comparator.compare(actual, constVal) != 0) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + config.descriptors.constSite, + null, + "must equal " + config.formatter.apply(constVal), + val, + constVal)); + if (failFast) { + return base.done(violations); + } + } + + if (!inVals.isEmpty() && !containsValue(inVals, actual)) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + config.descriptors.inSite, + null, + "must be in list " + formatList(inVals), + val, + actual)); + if (failFast) { + return base.done(violations); + } + } + + if (!notInVals.isEmpty() && containsValue(notInVals, actual)) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + config.descriptors.notInSite, + null, + "must not be in list " + formatList(notInVals), + val, + actual)); + if (failFast) { + return base.done(violations); + } + } + + if (finite && !isFinite(actual)) { + // descriptors.finiteSite is non-null whenever finite==true (set up at builder time). + RuleSite site = + Objects.requireNonNull( + config.descriptors.finiteSite, "finiteSite must be set when finite is true"); + violations = + RuleBase.add(violations, NativeViolations.newViolation(site, null, null, val, actual)); + if (failFast) { + return base.done(violations); + } + } + + if (lowerKind != LowerBound.NONE || upperKind != UpperBound.NONE) { + RuleViolation.Builder rangeViolation = buildRangeViolation(val, actual); + if (rangeViolation != null) { + violations = RuleBase.add(violations, rangeViolation); + if (failFast) { + return base.done(violations); + } + } + } + + return base.done(violations); + } + + // --- Per-rule violation builders --- + + /** + * Builds a violation for the lower/upper bound check, or returns null if the value is in range. + * Mirrors {@code nativeNumericCompare.evaluateRange} in protovalidate-go, including the + * exclusive-range semantics where {@code gt > lt} (or equivalents) means "value not in [lt, gt]". + */ + private RuleViolation.@Nullable Builder buildRangeViolation(Value val, T actual) { + boolean isNaN = config.nanFailsRange && isNaN(actual); + if (lowerKind == LowerBound.NONE) { + if (isNaN || aboveHi(actual)) { + return NativeViolations.newViolation( + hiSite(), gtltRule(), "must be " + hiMessage(), val, hiVal); + } + return null; + } + if (upperKind == UpperBound.NONE) { + if (isNaN || belowLo(actual)) { + return NativeViolations.newViolation( + loSite(), gtltRule(), "must be " + loMessage(), val, loVal); + } + return null; + } + boolean failure; + if (isNormalRange()) { + failure = isNaN || aboveHi(actual) || belowLo(actual); + } else { + failure = isNaN || (aboveHi(actual) && belowLo(actual)); + } + if (failure) { + String message = "must be " + loMessage() + " " + conjunction() + " " + hiMessage(); + return NativeViolations.newViolation(loSite(), gtltRule(), message, val, loVal); + } + return null; + } + + // --- Comparison helpers (depend on the comparator from config) --- + + private boolean belowLo(T value) { + int cmp = config.comparator.compare(value, loVal); + return lowerKind == LowerBound.GT ? cmp <= 0 : cmp < 0; + } + + private boolean aboveHi(T value) { + int cmp = config.comparator.compare(value, hiVal); + return upperKind == UpperBound.LT ? cmp >= 0 : cmp > 0; + } + + private boolean isNormalRange() { + // hi >= lo means a normal range. For unsigned kinds this uses the unsigned comparator. + return config.comparator.compare(hiVal, loVal) >= 0; + } + + private boolean containsValue(List list, T value) { + // Use the comparator for equality so unsigned/signed semantics agree. Java's List.contains + // would use Object.equals, which is fine for boxed primitives but we keep a single source of + // truth. + for (T t : list) { + if (config.comparator.compare(t, value) == 0) { + return true; + } + } + return false; + } + + private static boolean isFinite(T value) { + if (value instanceof Float) { + return Float.isFinite(value.floatValue()); + } + if (value instanceof Double) { + return Double.isFinite(value.doubleValue()); + } + // Integer kinds are always finite. + return true; + } + + private static boolean isNaN(T value) { + if (value instanceof Float) { + return Float.isNaN(value.floatValue()); + } + if (value instanceof Double) { + return Double.isNaN(value.doubleValue()); + } + return false; + } + + // --- Rule-id and message helpers (mirror Go's gtltRule / loMessage / hiMessage / conjunction) + // --- + + private RuleSite loSite() { + return lowerKind == LowerBound.GT ? config.descriptors.gtSite : config.descriptors.gteSite; + } + + private RuleSite hiSite() { + return upperKind == UpperBound.LT ? config.descriptors.ltSite : config.descriptors.lteSite; + } + + private String gtRulePrefix() { + return lowerKind == LowerBound.GT ? config.typeName + ".gt" : config.typeName + ".gte"; + } + + private String ltRulePrefix() { + return upperKind == UpperBound.LT ? config.typeName + ".lt" : config.typeName + ".lte"; + } + + /** Combined rule id, e.g. {@code int32.gt_lt_exclusive}. Mirrors Go's {@code gtltRule}. */ + private String gtltRule() { + if (lowerKind == LowerBound.NONE) { + return ltRulePrefix(); + } + String prefix = gtRulePrefix(); + if (upperKind == UpperBound.LT) { + prefix += "_lt"; + if (!isNormalRange()) { + prefix += "_exclusive"; + } + } else if (upperKind == UpperBound.LTE) { + prefix += "_lte"; + if (!isNormalRange()) { + prefix += "_exclusive"; + } + } + return prefix; + } + + private String loMessage() { + String formatted = config.formatter.apply(loVal); + return lowerKind == LowerBound.GT + ? "greater than " + formatted + : "greater than or equal to " + formatted; + } + + private String hiMessage() { + String formatted = config.formatter.apply(hiVal); + return upperKind == UpperBound.LT + ? "less than " + formatted + : "less than or equal to " + formatted; + } + + private String conjunction() { + return isNormalRange() ? "and" : "or"; + } + + /** Renders {@code vals} using this kind's typed formatter. */ + private String formatList(List vals) { + return RuleBase.formatList(vals, config.formatter); + } +} diff --git a/src/main/java/build/buf/protovalidate/NumericTypeConfig.java b/src/main/java/build/buf/protovalidate/NumericTypeConfig.java new file mode 100644 index 00000000..acf88727 --- /dev/null +++ b/src/main/java/build/buf/protovalidate/NumericTypeConfig.java @@ -0,0 +1,323 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import build.buf.validate.DoubleRules; +import build.buf.validate.FieldRules; +import build.buf.validate.Fixed32Rules; +import build.buf.validate.Fixed64Rules; +import build.buf.validate.FloatRules; +import build.buf.validate.Int32Rules; +import build.buf.validate.Int64Rules; +import build.buf.validate.SFixed32Rules; +import build.buf.validate.SFixed64Rules; +import build.buf.validate.SInt32Rules; +import build.buf.validate.SInt64Rules; +import build.buf.validate.UInt32Rules; +import build.buf.validate.UInt64Rules; +import com.google.protobuf.Descriptors.FieldDescriptor; +import java.math.BigDecimal; +import java.util.Comparator; +import java.util.function.Function; + +/** + * Per-kind type config for native numeric rule evaluation. Bundles everything that varies between + * proto numeric kinds (int32, uint32, float, etc.): descriptor lookups, the boxed Java type, the + * comparator (signed vs unsigned), the value-to-string formatter, and a flag for whether NaN fails + * range checks. + * + *

    One static instance per kind, shared across every {@link NumericRulesEvaluator} for that kind. + */ +final class NumericTypeConfig> { + /** Proto rule prefix used in rule ids ({@code "int32"}, {@code "uint64"}, etc.). */ + final String typeName; + + /** Pre-built rule sites and field descriptors for this kind. */ + final NumericDescriptors descriptors; + + /** Boxed Java class for values of this kind. Used to extract values from {@code Value}. */ + final Class valueClass; + + /** + * Comparator over values of this kind. For signed/float kinds this is the natural order; for + * unsigned kinds it is {@code Integer.compareUnsigned}/{@code Long.compareUnsigned} since Java + * stores unsigned protobuf values in signed primitives. + */ + final Comparator comparator; + + /** + * Renders a value to the string used in violation messages. {@code String::valueOf} for signed + * and float kinds; {@code Integer::toUnsignedString}/{@code Long::toUnsignedString} for unsigned. + * Critical for unsigned kinds — {@code String.valueOf(int)} would print a negative integer for + * values whose unsigned representation exceeds {@code Integer.MAX_VALUE}. + */ + final Function formatter; + + /** + * True for {@code float}/{@code double}: NaN fails range checks (matches CEL semantics). False + * for integer kinds — they have no NaN. + */ + final boolean nanFailsRange; + + /** + * The {@link FieldRules} field descriptor for this numeric kind (e.g. the {@code int32} field on + * {@code FieldRules}). Used by the dispatcher to read and clear the typed rules sub-message. + */ + final FieldDescriptor rulesField; + + private NumericTypeConfig( + String typeName, + NumericDescriptors descriptors, + Class valueClass, + Comparator comparator, + Function formatter, + boolean nanFailsRange, + FieldDescriptor rulesField) { + this.typeName = typeName; + this.descriptors = descriptors; + this.valueClass = valueClass; + this.comparator = comparator; + this.formatter = formatter; + this.nanFailsRange = nanFailsRange; + this.rulesField = rulesField; + } + + // --- Static configs, one per proto numeric kind --- + // + // Class-init invariant: every static config below transitively calls FieldRules.getDescriptor() + // and the per-kind *Rules.getDescriptor() through frField() and NumericDescriptors.build(). For + // class-loading to succeed, FieldRules and the per-kind rules messages must be loadable when + // NumericTypeConfig is. They are — FieldRules is touched by every entry into EvaluatorBuilder, + // and the per-kind rules messages live in the same generated bundle. If a future change moves + // NumericTypeConfig's initialization earlier (e.g. via a static reference from a class loaded + // before FieldRules), expect NoClassDefFoundError on this class. + + private static FieldDescriptor frField(int number) { + return FieldRules.getDescriptor().findFieldByNumber(number); + } + + /** Builds a NumericTypeConfig and exposes the FieldRules-level field descriptor on it. */ + private static > NumericTypeConfig create( + String typeName, + int fieldRulesFieldNumber, + com.google.protobuf.Descriptors.Descriptor rulesDescriptor, + Class valueClass, + Comparator comparator, + Function formatter, + boolean nanFailsRange) { + FieldDescriptor rulesField = frField(fieldRulesFieldNumber); + return new NumericTypeConfig<>( + typeName, + NumericDescriptors.build(rulesField, rulesDescriptor, typeName, nanFailsRange), + valueClass, + comparator, + formatter, + nanFailsRange, + rulesField); + } + + static final NumericTypeConfig INT32 = + create( + "int32", + FieldRules.INT32_FIELD_NUMBER, + Int32Rules.getDescriptor(), + Integer.class, + Integer::compare, + String::valueOf, + false); + + static final NumericTypeConfig SINT32 = + create( + "sint32", + FieldRules.SINT32_FIELD_NUMBER, + SInt32Rules.getDescriptor(), + Integer.class, + Integer::compare, + String::valueOf, + false); + + static final NumericTypeConfig SFIXED32 = + create( + "sfixed32", + FieldRules.SFIXED32_FIELD_NUMBER, + SFixed32Rules.getDescriptor(), + Integer.class, + Integer::compare, + String::valueOf, + false); + + static final NumericTypeConfig UINT32 = + create( + "uint32", + FieldRules.UINT32_FIELD_NUMBER, + UInt32Rules.getDescriptor(), + Integer.class, + Integer::compareUnsigned, + Integer::toUnsignedString, + false); + + static final NumericTypeConfig FIXED32 = + create( + "fixed32", + FieldRules.FIXED32_FIELD_NUMBER, + Fixed32Rules.getDescriptor(), + Integer.class, + Integer::compareUnsigned, + Integer::toUnsignedString, + false); + + static final NumericTypeConfig INT64 = + create( + "int64", + FieldRules.INT64_FIELD_NUMBER, + Int64Rules.getDescriptor(), + Long.class, + Long::compare, + String::valueOf, + false); + + static final NumericTypeConfig SINT64 = + create( + "sint64", + FieldRules.SINT64_FIELD_NUMBER, + SInt64Rules.getDescriptor(), + Long.class, + Long::compare, + String::valueOf, + false); + + static final NumericTypeConfig SFIXED64 = + create( + "sfixed64", + FieldRules.SFIXED64_FIELD_NUMBER, + SFixed64Rules.getDescriptor(), + Long.class, + Long::compare, + String::valueOf, + false); + + static final NumericTypeConfig UINT64 = + create( + "uint64", + FieldRules.UINT64_FIELD_NUMBER, + UInt64Rules.getDescriptor(), + Long.class, + Long::compareUnsigned, + Long::toUnsignedString, + false); + + static final NumericTypeConfig FIXED64 = + create( + "fixed64", + FieldRules.FIXED64_FIELD_NUMBER, + Fixed64Rules.getDescriptor(), + Long.class, + Long::compareUnsigned, + Long::toUnsignedString, + false); + + static final NumericTypeConfig FLOAT = + create( + "float", + FieldRules.FLOAT_FIELD_NUMBER, + FloatRules.getDescriptor(), + Float.class, + NumericTypeConfig::floatCompare, + NumericTypeConfig::floatFormatter, + true); + + static final NumericTypeConfig DOUBLE = + create( + "double", + FieldRules.DOUBLE_FIELD_NUMBER, + DoubleRules.getDescriptor(), + Double.class, + NumericTypeConfig::doubleCompare, + NumericTypeConfig::doubleFormatter, + true); + + // Float and double comparators treat +0.0 and -0.0 as equal, matching IEEE-754. NaN keeps + // Java's compareTo semantics (NaN.compareTo(NaN) == 0) so behavior matches the existing + // protovalidate-java CEL path, which uses the same Object.equals semantics. + + private static int floatCompare(Float f1, Float f2) { + if (f1 == 0.0f && f2 == 0.0f) { + return 0; + } + // NaN != Nan, but Java thinks it does. + if (f1.isNaN() && f2.isNaN()) { + return -1; + } + return f1.compareTo(f2); + } + + private static int doubleCompare(Double d1, Double d2) { + if (d1 == 0.0 && d2 == 0.0) { + return 0; + } + // NaN != Nan, but Java thinks it does. + if (d1.isNaN() && d2.isNaN()) { + return -1; + } + return d1.compareTo(d2); + } + + private static final int FLOAT_NEG_ZERO_BITS = Float.floatToIntBits(-0.0f); + private static final long DOUBLE_NEG_ZERO_BITS = Double.doubleToLongBits(-0.0); + + private static String floatFormatter(Float f) { + if (Float.floatToIntBits(f) == FLOAT_NEG_ZERO_BITS) { + return "-0"; + } + if (f.isNaN()) { + return "NaN"; + } + if (f.isInfinite()) { + if (Math.signum(f) < 0) { + return "-Infinity"; + } + return "Infinity"; + } + // closest way to get to strconv.FormatFloat(d, 'f', -1, 64) in Go + String out = BigDecimal.valueOf(f).toPlainString(); + // cut off .0 at the end for whole numbers + if (out.endsWith(".0")) { + out = out.substring(0, out.length() - 2); + } + return out; + } + + private static String doubleFormatter(Double d) { + if (Double.doubleToLongBits(d) == DOUBLE_NEG_ZERO_BITS) { + return "-0"; + } + if (d.isNaN()) { + return "NaN"; + } + if (d.isInfinite()) { + if (Math.signum(d) < 0) { + return "-Infinity"; + } + return "Infinity"; + } + // closest way to get to strconv.FormatFloat(d, 'f', -1, 64) in Go + String out = BigDecimal.valueOf(d).toPlainString(); + // cut off .0 at the end for whole numbers + if (out.endsWith(".0")) { + out = out.substring(0, out.length() - 2); + } + return out; + } +} diff --git a/src/main/java/build/buf/protovalidate/ObjectValue.java b/src/main/java/build/buf/protovalidate/ObjectValue.java index 73d320e4..9a53574a 100644 --- a/src/main/java/build/buf/protovalidate/ObjectValue.java +++ b/src/main/java/build/buf/protovalidate/ObjectValue.java @@ -65,6 +65,11 @@ public T value(Class clazz) { return clazz.cast(ProtoAdapter.toCel(fieldDescriptor, value)); } + @Override + public Object rawValue() { + return value; + } + @Override public List repeatedValue() { List out = new ArrayList<>(); diff --git a/src/main/java/build/buf/protovalidate/RepeatedRulesEvaluator.java b/src/main/java/build/buf/protovalidate/RepeatedRulesEvaluator.java new file mode 100644 index 00000000..83399bb3 --- /dev/null +++ b/src/main/java/build/buf/protovalidate/RepeatedRulesEvaluator.java @@ -0,0 +1,207 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import build.buf.validate.FieldRules; +import build.buf.validate.RepeatedRules; +import com.google.protobuf.Descriptors.FieldDescriptor; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.jspecify.annotations.Nullable; + +/** + * Native evaluator for repeated list-level rules: {@code min_items}, {@code max_items}, {@code + * unique}. Element-level rules continue to flow through {@code ListEvaluator} and the inner {@link + * build.buf.protovalidate.ValueEvaluator}. + * + *

    The {@code unique} rule is only supported when the element kind has well-defined value + * equality (scalars, strings, bytes, bools, enums). Message/group element kinds fall back to CEL. + * Mirrors {@code nativeRepeatedEval} in protovalidate-go's {@code native_repeated.go}. + */ +final class RepeatedRulesEvaluator implements Evaluator { + private static final FieldDescriptor REPEATED_RULES_DESC = + FieldRules.getDescriptor().findFieldByNumber(FieldRules.REPEATED_FIELD_NUMBER); + + private static final RuleSite MIN_ITEMS_SITE = + RuleSite.of( + REPEATED_RULES_DESC, + RepeatedRules.getDescriptor().findFieldByNumber(RepeatedRules.MIN_ITEMS_FIELD_NUMBER), + "repeated.min_items", + null); + private static final RuleSite MAX_ITEMS_SITE = + RuleSite.of( + REPEATED_RULES_DESC, + RepeatedRules.getDescriptor().findFieldByNumber(RepeatedRules.MAX_ITEMS_FIELD_NUMBER), + "repeated.max_items", + null); + private static final RuleSite UNIQUE_SITE = + RuleSite.of( + REPEATED_RULES_DESC, + RepeatedRules.getDescriptor().findFieldByNumber(RepeatedRules.UNIQUE_FIELD_NUMBER), + "repeated.unique", + "repeated value must contain unique items"); + + private final RuleBase base; + private final @Nullable Long minItems; + private final @Nullable Long maxItems; + private final boolean unique; + + private RepeatedRulesEvaluator( + RuleBase base, @Nullable Long minItems, @Nullable Long maxItems, boolean unique) { + this.base = base; + this.minItems = minItems; + this.maxItems = maxItems; + this.unique = unique; + } + + static @Nullable Evaluator tryBuild(RuleBase base, FieldRules.Builder rulesBuilder) { + if (!rulesBuilder.hasRepeated()) { + return null; + } + RepeatedRules rules = rulesBuilder.getRepeated(); + if (!rules.getUnknownFields().isEmpty()) { + return null; + } + + RepeatedRules.Builder rb = rules.toBuilder(); + boolean hasRule = false; + + Long minItems = null; + if (rules.hasMinItems()) { + minItems = rules.getMinItems(); + rb.clearMinItems(); + hasRule = true; + } + + Long maxItems = null; + if (rules.hasMaxItems()) { + maxItems = rules.getMaxItems(); + rb.clearMaxItems(); + hasRule = true; + } + + boolean unique = false; + if (rules.getUnique()) { + // Element kind must support reliable Object.equals — scalars, strings, bools, bytes, enums + // all do (ByteString and EnumValueDescriptor have correct equals/hashCode). Messages don't, + // so fall through to CEL. + FieldDescriptor descriptor = base.getDescriptor(); + if (descriptor == null || !isUniqueSupported(descriptor.getType())) { + return null; + } + unique = true; + rb.clearUnique(); + hasRule = true; + } + + if (!hasRule) { + return null; + } + rulesBuilder.setRepeated(rb.build()); + return new RepeatedRulesEvaluator(base, minItems, maxItems, unique); + } + + private static boolean isUniqueSupported(FieldDescriptor.Type type) { + switch (type) { + case INT32: + case SINT32: + case SFIXED32: + case INT64: + case SINT64: + case SFIXED64: + case UINT32: + case FIXED32: + case UINT64: + case FIXED64: + case FLOAT: + case DOUBLE: + case BOOL: + case STRING: + case BYTES: + case ENUM: + return true; + case MESSAGE: + case GROUP: + default: + return false; + } + } + + @Override + public boolean tautology() { + return false; + } + + @Override + public List evaluate(Value val, boolean failFast) { + List list = (List) val.rawValue(); + long size = list.size(); + List violations = null; + + if (minItems != null && size < minItems) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + MIN_ITEMS_SITE, + null, + "must contain at least " + minItems + " item(s)", + val, + minItems)); + if (failFast) return base.done(violations); + } + + if (maxItems != null && size > maxItems) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + MAX_ITEMS_SITE, + null, + "must contain no more than " + maxItems + " item(s)", + val, + maxItems)); + if (failFast) return base.done(violations); + } + + if (unique && !isUnique(list)) { + violations = + RuleBase.add( + violations, NativeViolations.newViolation(UNIQUE_SITE, null, null, val, true)); + if (failFast) return base.done(violations); + } + + return base.done(violations); + } + + /** + * Returns true iff every element in {@code list} is distinct. Uses a {@link HashSet} to test for + * uniqueness. + */ + private static boolean isUnique(List list) { + int size = list.size(); + if (size <= 1) { + return true; + } + Set seen = new HashSet<>(size); + for (Object element : list) { + if (!seen.add(element)) { + return false; + } + } + return true; + } +} diff --git a/src/main/java/build/buf/protovalidate/RuleBase.java b/src/main/java/build/buf/protovalidate/RuleBase.java new file mode 100644 index 00000000..1abfd2bf --- /dev/null +++ b/src/main/java/build/buf/protovalidate/RuleBase.java @@ -0,0 +1,132 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import build.buf.validate.FieldPath; +import build.buf.validate.FieldPathElement; +import com.google.protobuf.Descriptors.FieldDescriptor; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Function; +import org.jspecify.annotations.Nullable; + +/** + * Common context shared across native rule evaluators: the field's descriptor, its single + * containing-message field path element, and any nested-rule prefix that must be prepended to rule + * paths in violations. + * + *

    Mirrors the {@code base} struct in protovalidate-go's {@code base.go}, adapted to Java's + * existing pattern of letting violations bubble up the call stack with prepended path elements (see + * {@link FieldPathUtils#updatePaths}). + */ +final class RuleBase { + private static final List EMPTY_PREFIX = Collections.emptyList(); + + private final @Nullable FieldDescriptor descriptor; + private final @Nullable FieldPathElement fieldPathElement; + private final @Nullable FieldPath rulePrefix; + + private RuleBase( + @Nullable FieldDescriptor descriptor, + @Nullable FieldPathElement fieldPathElement, + @Nullable FieldPath rulePrefix) { + this.descriptor = descriptor; + this.fieldPathElement = fieldPathElement; + this.rulePrefix = rulePrefix; + } + + /** + * Builds a {@link RuleBase} from the given {@link ValueEvaluator}, computing the field path + * element from its descriptor and capturing its nested-rule prefix. + */ + static RuleBase of(ValueEvaluator valueEvaluator) { + FieldDescriptor desc = valueEvaluator.getDescriptor(); + FieldPathElement fpe = (desc != null) ? FieldPathUtils.fieldPathElement(desc) : null; + return new RuleBase(desc, fpe, valueEvaluator.getNestedRule()); + } + + /** The descriptor of the field being validated, or null when validating a non-field value. */ + @Nullable FieldDescriptor getDescriptor() { + return descriptor; + } + + /** + * The {@link FieldPathElement} for prepending to violation field paths, or null when there is no + * field context (e.g. the value being validated is not a message field). + */ + @Nullable FieldPathElement getFieldPathElement() { + return fieldPathElement; + } + + /** + * The nested-rule path elements (e.g. {@code repeated.items}, {@code map.keys}) to prepend to + * violation rule paths. Empty when there is no nested-rule context. + */ + List getRulePrefixElements() { + if (rulePrefix == null) { + return EMPTY_PREFIX; + } + return rulePrefix.getElementsList(); + } + + // --- Shared violation-list helpers --- + // + // Every native evaluator uses the same lazy null → ArrayList growth pattern and the same + // tail-call to FieldPathUtils.updatePaths. These live here so each evaluator doesn't + // re-implement them. + + /** + * Lazily appends {@code v} to {@code violations}, allocating an {@link ArrayList} only on the + * first append. + */ + static List add( + @Nullable List violations, RuleViolation.Builder v) { + if (violations == null) { + violations = new ArrayList<>(2); + } + violations.add(v); + return violations; + } + + /** + * Finalizes a violation list: returns the empty constant when there's nothing to report, + * otherwise prepends this base's field-path element and rule-prefix elements. + */ + List done(@Nullable List violations) { + if (violations == null || violations.isEmpty()) { + return RuleViolation.NO_VIOLATIONS; + } + return FieldPathUtils.updatePaths(violations, fieldPathElement, getRulePrefixElements()); + } + + /** Renders a list as {@code "[a, b, c]"} using {@code toString} on each element. */ + static String formatList(List vals) { + return formatList(vals, Object::toString); + } + + /** Renders a list as {@code "[a, b, c]"} using {@code formatter} on each element. */ + static String formatList(List vals, Function formatter) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < vals.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(formatter.apply(vals.get(i))); + } + sb.append("]"); + return sb.toString(); + } +} diff --git a/src/main/java/build/buf/protovalidate/RuleSite.java b/src/main/java/build/buf/protovalidate/RuleSite.java new file mode 100644 index 00000000..aa4a81a8 --- /dev/null +++ b/src/main/java/build/buf/protovalidate/RuleSite.java @@ -0,0 +1,91 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import build.buf.validate.FieldPathElement; +import com.google.protobuf.Descriptors.FieldDescriptor; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * A pre-built bundle for a single rule site: the two-element rule-path suffix ({@code + * [FieldRules., Rules.]}), the leaf rule descriptor, and an optional constant + * rule id and message. + * + *

    Each native rule type instantiates one {@link RuleSite} per supported rule at class init time + * so violation construction at validation time only allocates the violation builder itself, not the + * path-element protos. Mirrors the {@code ruleSite} struct in protovalidate-go's {@code base.go}. + */ +final class RuleSite { + private final List pathElements; + private final FieldDescriptor leafDescriptor; + private final @Nullable String ruleId; + private final @Nullable String message; + + private RuleSite( + List pathElements, + FieldDescriptor leafDescriptor, + @Nullable String ruleId, + @Nullable String message) { + this.pathElements = pathElements; + this.leafDescriptor = leafDescriptor; + this.ruleId = ruleId; + this.message = message; + } + + /** + * Builds a {@link RuleSite} from a rule-type field descriptor (e.g. {@code FieldRules.string}) + * and a leaf rule field descriptor (e.g. {@code StringRules.min_len}). + * + * @param ruleTypeDescriptor descriptor of the {@code FieldRules} oneof case (e.g. the {@code + * string} field on {@code FieldRules}). + * @param leafDescriptor descriptor of the specific rule (e.g. {@code min_len}). + * @param ruleId optional constant rule id for this site (e.g. {@code "string.min_len"}); may be + * null when the rule id is computed per violation (e.g. well-known formats with empty/error + * variants). + * @param message optional constant violation message; may be null when the message is built per + * violation from the failing value. + */ + static RuleSite of( + FieldDescriptor ruleTypeDescriptor, + FieldDescriptor leafDescriptor, + @Nullable String ruleId, + @Nullable String message) { + List elements = + Collections.unmodifiableList( + Arrays.asList( + FieldPathUtils.fieldPathElement(ruleTypeDescriptor), + FieldPathUtils.fieldPathElement(leafDescriptor))); + return new RuleSite(elements, leafDescriptor, ruleId, message); + } + + List getPathElements() { + return pathElements; + } + + FieldDescriptor getLeafDescriptor() { + return leafDescriptor; + } + + @Nullable String getRuleId() { + return ruleId; + } + + @Nullable String getMessage() { + return message; + } +} diff --git a/src/main/java/build/buf/protovalidate/Rules.java b/src/main/java/build/buf/protovalidate/Rules.java new file mode 100644 index 00000000..9f824bb3 --- /dev/null +++ b/src/main/java/build/buf/protovalidate/Rules.java @@ -0,0 +1,147 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import build.buf.validate.FieldRules; +import com.google.protobuf.Descriptors.FieldDescriptor; +import org.jspecify.annotations.Nullable; + +/** + * Entry point for native rule evaluators. {@code EvaluatorBuilder} calls {@link #tryBuild} once per + * field; if a native evaluator covers some rules, those rules are cleared on the supplied {@code + * FieldRules.Builder} and the residual is then handed to CEL compilation. Returns null when no + * native evaluator applies — CEL handles the field unchanged. + * + *

    The clone-and-clear contract ensures forward compatibility: when protovalidate adds a new rule + * that this codebase hasn't yet implemented natively, the rule remains on the residual {@code + * FieldRules} and CEL enforces it. Native rules are an optimization, not a replacement. + */ +final class Rules { + private Rules() {} + + /** + * Attempts to build a native evaluator for the standard rules on {@code fieldDescriptor}. + * + *

    Any rule covered natively is cleared on {@code rulesBuilder} so that {@code RuleCache} + * compiles CEL programs only for rules left untouched. The caller is expected to pass a builder + * it owns (typically obtained via {@code fieldRules.toBuilder()} on a clone) and to call {@code + * build()} on the residual before handing it to {@code RuleCache.compile}. + * + * @param fieldDescriptor the field being evaluated + * @param rulesBuilder a mutable builder of the field's {@link FieldRules}; covered rules are + * cleared in place + * @param valueEvaluator the value evaluator the native evaluator will be appended to + * @return a native {@link Evaluator}, or null if no native evaluator applies (CEL handles + * everything) + */ + static @Nullable Evaluator tryBuild( + FieldDescriptor fieldDescriptor, + FieldRules.Builder rulesBuilder, + ValueEvaluator valueEvaluator) { + boolean hasNestedRule = valueEvaluator.hasNestedRule(); + if (fieldDescriptor.isMapField() && !hasNestedRule) { + return MapRulesEvaluator.tryBuild(RuleBase.of(valueEvaluator), rulesBuilder); + } + if (fieldDescriptor.isRepeated() && !hasNestedRule) { + return RepeatedRulesEvaluator.tryBuild(RuleBase.of(valueEvaluator), rulesBuilder); + } + if (!fieldDescriptor.isMapField() && !fieldDescriptor.isRepeated()) { + Evaluator scalar = tryBuildScalarRules(fieldDescriptor, rulesBuilder, valueEvaluator); + if (scalar == null) { + return null; + } + // When processWrapperRules recurses with the inner "value" field, the ValueEvaluator's + // descriptor is still the OUTER wrapper field. Detect that and wrap the scalar evaluator + // so it unwraps the wrapper Message at evaluation time before delegating. + FieldDescriptor outerDescriptor = valueEvaluator.getDescriptor(); + if (outerDescriptor != null + && outerDescriptor.getJavaType() == FieldDescriptor.JavaType.MESSAGE) { + return new WrappedValueEvaluator(fieldDescriptor, scalar); + } + return scalar; + } + return null; + } + + private static @Nullable Evaluator tryBuildScalarRules( + FieldDescriptor fieldDescriptor, + FieldRules.Builder rulesBuilder, + ValueEvaluator valueEvaluator) { + RuleBase base = RuleBase.of(valueEvaluator); + switch (fieldDescriptor.getJavaType()) { + case BOOLEAN: + return BoolRulesEvaluator.tryBuild(base, rulesBuilder); + case ENUM: + return EnumRulesEvaluator.tryBuild(base, rulesBuilder); + case BYTE_STRING: + return BytesRulesEvaluator.tryBuild(base, rulesBuilder); + case STRING: + return StringRulesEvaluator.tryBuild(base, rulesBuilder); + case INT: + case LONG: + case FLOAT: + case DOUBLE: + NumericTypeConfig config = numericConfigFor(fieldDescriptor); + if (config == null) { + return null; + } + return numericTryBuild(base, rulesBuilder, config); + default: + return null; + } + } + + private static @Nullable NumericTypeConfig numericConfigFor(FieldDescriptor fd) { + switch (fd.getType()) { + case INT32: + return NumericTypeConfig.INT32; + case SINT32: + return NumericTypeConfig.SINT32; + case SFIXED32: + return NumericTypeConfig.SFIXED32; + case UINT32: + return NumericTypeConfig.UINT32; + case FIXED32: + return NumericTypeConfig.FIXED32; + case INT64: + return NumericTypeConfig.INT64; + case SINT64: + return NumericTypeConfig.SINT64; + case SFIXED64: + return NumericTypeConfig.SFIXED64; + case UINT64: + return NumericTypeConfig.UINT64; + case FIXED64: + return NumericTypeConfig.FIXED64; + case FLOAT: + return NumericTypeConfig.FLOAT; + case DOUBLE: + return NumericTypeConfig.DOUBLE; + default: + return null; + } + } + + /** + * Helper that captures the {@code } on {@link NumericTypeConfig} so {@link + * NumericRulesEvaluator#tryBuild} compiles cleanly. The unchecked cast is sound because the + * config's generic parameter is the same as the evaluator's. + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + private static @Nullable Evaluator numericTryBuild( + RuleBase base, FieldRules.Builder rulesBuilder, NumericTypeConfig config) { + return NumericRulesEvaluator.tryBuild(base, rulesBuilder, (NumericTypeConfig) config); + } +} diff --git a/src/main/java/build/buf/protovalidate/StringRulesEvaluator.java b/src/main/java/build/buf/protovalidate/StringRulesEvaluator.java new file mode 100644 index 00000000..094ffff3 --- /dev/null +++ b/src/main/java/build/buf/protovalidate/StringRulesEvaluator.java @@ -0,0 +1,832 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import build.buf.validate.FieldRules; +import build.buf.validate.KnownRegex; +import build.buf.validate.StringRules; +import com.google.protobuf.Descriptors.FieldDescriptor; +import com.google.re2j.Pattern; +import java.util.List; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Native evaluator for the standard string rules: scalar rules (`const`, `len`, `min_len`, + * `max_len`, `len_bytes`, `min_bytes`, `max_bytes`, `pattern`, `prefix`, `suffix`, `contains`, + * `not_contains`, `in`, `not_in`), well-known formats (`email`, `hostname`, `ip`, `ipv4`, `ipv6`, + * `uri`, `uri_ref`, `address`, `uuid`, `tuuid`, `ulid`, `host_and_port`, the {@code _prefix} / + * {@code _with_prefixlen} variants), and the {@code well_known_regex} oneof case (HTTP header + * name/value with optional strict mode). + * + *

    Mirrors {@code nativeStringEval} in protovalidate-go's {@code native_string.go}. Format + * helpers from {@link CustomOverload} are reused so native and CEL paths share the same + * email/hostname/IP/URI parsers. + */ +final class StringRulesEvaluator implements Evaluator { + + // --- Static descriptors and rule sites --- + + private static final FieldDescriptor STRING_RULES_DESC = + FieldRules.getDescriptor().findFieldByNumber(FieldRules.STRING_FIELD_NUMBER); + + private static final RuleSite CONST_SITE = site(StringRules.CONST_FIELD_NUMBER, "string.const"); + private static final RuleSite LEN_SITE = site(StringRules.LEN_FIELD_NUMBER, "string.len"); + private static final RuleSite MIN_LEN_SITE = + site(StringRules.MIN_LEN_FIELD_NUMBER, "string.min_len"); + private static final RuleSite MAX_LEN_SITE = + site(StringRules.MAX_LEN_FIELD_NUMBER, "string.max_len"); + private static final RuleSite LEN_BYTES_SITE = + site(StringRules.LEN_BYTES_FIELD_NUMBER, "string.len_bytes"); + private static final RuleSite MIN_BYTES_SITE = + site(StringRules.MIN_BYTES_FIELD_NUMBER, "string.min_bytes"); + private static final RuleSite MAX_BYTES_SITE = + site(StringRules.MAX_BYTES_FIELD_NUMBER, "string.max_bytes"); + private static final RuleSite PATTERN_SITE = + site(StringRules.PATTERN_FIELD_NUMBER, "string.pattern"); + private static final RuleSite PREFIX_SITE = + site(StringRules.PREFIX_FIELD_NUMBER, "string.prefix"); + private static final RuleSite SUFFIX_SITE = + site(StringRules.SUFFIX_FIELD_NUMBER, "string.suffix"); + private static final RuleSite CONTAINS_SITE = + site(StringRules.CONTAINS_FIELD_NUMBER, "string.contains"); + private static final RuleSite NOT_CONTAINS_SITE = + site(StringRules.NOT_CONTAINS_FIELD_NUMBER, "string.not_contains"); + private static final RuleSite IN_SITE = site(StringRules.IN_FIELD_NUMBER, "string.in"); + private static final RuleSite NOT_IN_SITE = + site(StringRules.NOT_IN_FIELD_NUMBER, "string.not_in"); + private static final FieldDescriptor WELL_KNOWN_REGEX_DESC = + StringRules.getDescriptor().findFieldByNumber(StringRules.WELL_KNOWN_REGEX_FIELD_NUMBER); + + // well_known_regex sites: pre-built once. Header-name and header-value have a normal-failure + // site and an empty-input site (header_name); pattern selection at evaluation time picks + // between strict and loose matchers. + private static final RuleSite HEADER_NAME_SITE = + RuleSite.of( + STRING_RULES_DESC, + WELL_KNOWN_REGEX_DESC, + "string.well_known_regex.header_name", + "must be a valid HTTP header name"); + private static final RuleSite HEADER_NAME_EMPTY_SITE = + RuleSite.of( + STRING_RULES_DESC, + WELL_KNOWN_REGEX_DESC, + "string.well_known_regex.header_name_empty", + "value is empty, which is not a valid HTTP header name"); + private static final RuleSite HEADER_VALUE_SITE = + RuleSite.of( + STRING_RULES_DESC, + WELL_KNOWN_REGEX_DESC, + "string.well_known_regex.header_value", + "must be a valid HTTP header value"); + + private static RuleSite site(int fieldNumber, String ruleId) { + FieldDescriptor leaf = StringRules.getDescriptor().findFieldByNumber(fieldNumber); + return RuleSite.of(STRING_RULES_DESC, leaf, ruleId, null); + } + + // --- Static regexes (compile once) --- + + private static final Pattern UUID_REGEX = + Pattern.compile( + "^[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}$"); + private static final Pattern TUUID_REGEX = Pattern.compile("^[0-9a-fA-F]{32}$"); + private static final Pattern ULID_REGEX = + Pattern.compile("^[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$"); + private static final Pattern HEADER_NAME_REGEX = + Pattern.compile("^:?[0-9a-zA-Z!#$%&'*+.\\-^_|~`]+$"); + private static final Pattern HEADER_VALUE_REGEX = + Pattern.compile("^[^\\x00-\\x08\\x0A-\\x1F\\x7F]*$"); + private static final Pattern LOOSE_REGEX = Pattern.compile("^[^\\x00\\x0A\\x0D]+$"); + + // --- Well-known string formats --- + + /** + * Each constant carries the rule id, the main violation message, the empty-value variant message, + * and the validation. Empty messages are stored verbatim from the proto spec rather than derived + * by string substitution: {@code host_and_port}, for example, has a main message of {@code "must + * be a valid host (hostname or IP address) and port pair"} but an empty message of {@code "value + * is empty, which is not a valid host and port pair"} (without the parenthetical) — substring + * derivation produced the wrong text. + */ + @SuppressWarnings("ImmutableEnumChecker") // RuleSite is logically immutable; not annotated. + enum WellKnownFormat { + EMAIL( + StringRules.EMAIL_FIELD_NUMBER, + "email", + "must be a valid email address", + "value is empty, which is not a valid email address") { + @Override + boolean validate(String s) { + return CustomOverload.isEmail(s); + } + }, + HOSTNAME( + StringRules.HOSTNAME_FIELD_NUMBER, + "hostname", + "must be a valid hostname", + "value is empty, which is not a valid hostname") { + @Override + boolean validate(String s) { + return CustomOverload.isHostname(s); + } + }, + IP( + StringRules.IP_FIELD_NUMBER, + "ip", + "must be a valid IP address", + "value is empty, which is not a valid IP address") { + @Override + boolean validate(String s) { + return CustomOverload.isIp(s, 0); + } + }, + IPV4( + StringRules.IPV4_FIELD_NUMBER, + "ipv4", + "must be a valid IPv4 address", + "value is empty, which is not a valid IPv4 address") { + @Override + boolean validate(String s) { + return CustomOverload.isIp(s, 4); + } + }, + IPV6( + StringRules.IPV6_FIELD_NUMBER, + "ipv6", + "must be a valid IPv6 address", + "value is empty, which is not a valid IPv6 address") { + @Override + boolean validate(String s) { + return CustomOverload.isIp(s, 6); + } + }, + URI( + StringRules.URI_FIELD_NUMBER, + "uri", + "must be a valid URI", + "value is empty, which is not a valid URI") { + @Override + boolean validate(String s) { + return CustomOverload.isUri(s); + } + }, + URI_REF(StringRules.URI_REF_FIELD_NUMBER, "uri_ref", "must be a valid URI Reference", null) { + @Override + boolean validate(String s) { + return CustomOverload.isUriRef(s); + } + + @Override + boolean checksEmpty() { + return false; + } + }, + ADDRESS( + StringRules.ADDRESS_FIELD_NUMBER, + "address", + "must be a valid hostname, or ip address", + "value is empty, which is not a valid hostname, or ip address") { + @Override + boolean validate(String s) { + return CustomOverload.isHostname(s) || CustomOverload.isIp(s, 0); + } + }, + UUID( + StringRules.UUID_FIELD_NUMBER, + "uuid", + "must be a valid UUID", + "value is empty, which is not a valid UUID") { + @Override + boolean validate(String s) { + return UUID_REGEX.matches(s); + } + }, + TUUID( + StringRules.TUUID_FIELD_NUMBER, + "tuuid", + "must be a valid trimmed UUID", + "value is empty, which is not a valid trimmed UUID") { + @Override + boolean validate(String s) { + return TUUID_REGEX.matches(s); + } + }, + IP_WITH_PREFIXLEN( + StringRules.IP_WITH_PREFIXLEN_FIELD_NUMBER, + "ip_with_prefixlen", + "must be a valid IP prefix", + "value is empty, which is not a valid IP prefix") { + @Override + boolean validate(String s) { + return CustomOverload.isIpPrefix(s, 0, false); + } + }, + IPV4_WITH_PREFIXLEN( + StringRules.IPV4_WITH_PREFIXLEN_FIELD_NUMBER, + "ipv4_with_prefixlen", + "must be a valid IPv4 address with prefix length", + "value is empty, which is not a valid IPv4 address with prefix length") { + @Override + boolean validate(String s) { + return CustomOverload.isIpPrefix(s, 4, false); + } + }, + IPV6_WITH_PREFIXLEN( + StringRules.IPV6_WITH_PREFIXLEN_FIELD_NUMBER, + "ipv6_with_prefixlen", + "must be a valid IPv6 address with prefix length", + "value is empty, which is not a valid IPv6 address with prefix length") { + @Override + boolean validate(String s) { + return CustomOverload.isIpPrefix(s, 6, false); + } + }, + IP_PREFIX( + StringRules.IP_PREFIX_FIELD_NUMBER, + "ip_prefix", + "must be a valid IP prefix", + "value is empty, which is not a valid IP prefix") { + @Override + boolean validate(String s) { + return CustomOverload.isIpPrefix(s, 0, true); + } + }, + IPV4_PREFIX( + StringRules.IPV4_PREFIX_FIELD_NUMBER, + "ipv4_prefix", + "must be a valid IPv4 prefix", + "value is empty, which is not a valid IPv4 prefix") { + @Override + boolean validate(String s) { + return CustomOverload.isIpPrefix(s, 4, true); + } + }, + IPV6_PREFIX( + StringRules.IPV6_PREFIX_FIELD_NUMBER, + "ipv6_prefix", + "must be a valid IPv6 prefix", + "value is empty, which is not a valid IPv6 prefix") { + @Override + boolean validate(String s) { + return CustomOverload.isIpPrefix(s, 6, true); + } + }, + HOST_AND_PORT( + StringRules.HOST_AND_PORT_FIELD_NUMBER, + "host_and_port", + "must be a valid host (hostname or IP address) and port pair", + "value is empty, which is not a valid host and port pair") { + @Override + boolean validate(String s) { + return CustomOverload.isHostAndPort(s, true); + } + }, + ULID( + StringRules.ULID_FIELD_NUMBER, + "ulid", + "must be a valid ULID", + "value is empty, which is not a valid ULID") { + @Override + boolean validate(String s) { + return ULID_REGEX.matches(s); + } + }; + + final FieldDescriptor field; + final RuleSite site; + final @Nullable RuleSite emptySite; + + WellKnownFormat( + int fieldNumber, String ruleSuffix, String message, @Nullable String emptyMessage) { + FieldDescriptor leaf = StringRules.getDescriptor().findFieldByNumber(fieldNumber); + this.field = leaf; + this.site = RuleSite.of(STRING_RULES_DESC, leaf, "string." + ruleSuffix, message); + this.emptySite = + emptyMessage == null + ? null + : RuleSite.of( + STRING_RULES_DESC, leaf, "string." + ruleSuffix + "_empty", emptyMessage); + } + + /** Whether this format reports an empty-value violation distinctly from the format failure. */ + boolean checksEmpty() { + return true; + } + + abstract boolean validate(String s); + } + + // --- Fields --- + + private final RuleBase base; + private final @Nullable String constVal; + private final @Nullable Long exactLen; + private final @Nullable Long minLen; + private final @Nullable Long maxLen; + private final @Nullable Long exactBytes; + private final @Nullable Long minBytes; + private final @Nullable Long maxBytes; + private final @Nullable Pattern pattern; + private final @Nullable String patternStr; + private final @Nullable String prefix; + private final @Nullable String suffix; + private final @Nullable String contains; + private final @Nullable String notContains; + private final List inVals; + private final List notInVals; + private final @Nullable WellKnownFormat wellKnown; + private final KnownRegex knownRegex; + private final boolean knownRegexStrict; + + private StringRulesEvaluator( + RuleBase base, + @Nullable String constVal, + @Nullable Long exactLen, + @Nullable Long minLen, + @Nullable Long maxLen, + @Nullable Long exactBytes, + @Nullable Long minBytes, + @Nullable Long maxBytes, + @Nullable Pattern pattern, + @Nullable String patternStr, + @Nullable String prefix, + @Nullable String suffix, + @Nullable String contains, + @Nullable String notContains, + List inVals, + List notInVals, + @Nullable WellKnownFormat wellKnown, + KnownRegex knownRegex, + boolean knownRegexStrict) { + this.base = base; + this.constVal = constVal; + this.exactLen = exactLen; + this.minLen = minLen; + this.maxLen = maxLen; + this.exactBytes = exactBytes; + this.minBytes = minBytes; + this.maxBytes = maxBytes; + this.pattern = pattern; + this.patternStr = patternStr; + this.prefix = prefix; + this.suffix = suffix; + this.contains = contains; + this.notContains = notContains; + this.inVals = inVals; + this.notInVals = notInVals; + this.wellKnown = wellKnown; + this.knownRegex = knownRegex; + this.knownRegexStrict = knownRegexStrict; + } + + static @Nullable Evaluator tryBuild(RuleBase base, FieldRules.Builder rulesBuilder) { + if (!rulesBuilder.hasString()) { + return null; + } + StringRules rules = rulesBuilder.getString(); + if (!rules.getUnknownFields().isEmpty()) { + return null; + } + + StringRules.Builder sb = rules.toBuilder(); + boolean hasRule = false; + + // Well-known oneof: at most one of the format fields, OR well_known_regex, can be set. + WellKnownFormat wellKnown = null; + KnownRegex knownRegex = KnownRegex.KNOWN_REGEX_UNSPECIFIED; + boolean knownRegexStrict = false; + for (WellKnownFormat fmt : WellKnownFormat.values()) { + if (rules.hasField(fmt.field)) { + boolean enabled = (Boolean) rules.getField(fmt.field); + if (enabled) { + wellKnown = fmt; + sb.clearField(fmt.field); + hasRule = true; + } + break; + } + } + if (wellKnown == null && rules.hasWellKnownRegex()) { + knownRegex = rules.getWellKnownRegex(); + // strict defaults to true when not explicitly set. + knownRegexStrict = !rules.hasStrict() || rules.getStrict(); + if (knownRegex != KnownRegex.KNOWN_REGEX_UNSPECIFIED) { + sb.clearWellKnownRegex(); + if (rules.hasStrict()) { + sb.clearStrict(); + } + hasRule = true; + } + } + + String constVal = null; + if (rules.hasConst()) { + constVal = rules.getConst(); + sb.clearConst(); + hasRule = true; + } + Long exactLen = null; + if (rules.hasLen()) { + exactLen = rules.getLen(); + sb.clearLen(); + hasRule = true; + } + Long minLen = null; + if (rules.hasMinLen()) { + minLen = rules.getMinLen(); + sb.clearMinLen(); + hasRule = true; + } + Long maxLen = null; + if (rules.hasMaxLen()) { + maxLen = rules.getMaxLen(); + sb.clearMaxLen(); + hasRule = true; + } + Long exactBytes = null; + if (rules.hasLenBytes()) { + exactBytes = rules.getLenBytes(); + sb.clearLenBytes(); + hasRule = true; + } + Long minBytes = null; + if (rules.hasMinBytes()) { + minBytes = rules.getMinBytes(); + sb.clearMinBytes(); + hasRule = true; + } + Long maxBytes = null; + if (rules.hasMaxBytes()) { + maxBytes = rules.getMaxBytes(); + sb.clearMaxBytes(); + hasRule = true; + } + + Pattern compiledPattern = null; + String patternStr = null; + if (rules.hasPattern()) { + patternStr = rules.getPattern(); + try { + compiledPattern = Pattern.compile(patternStr); + } catch (com.google.re2j.PatternSyntaxException e) { + return null; // bail to CEL — same compilation error + } + sb.clearPattern(); + hasRule = true; + } + + String prefix = null; + if (rules.hasPrefix()) { + prefix = rules.getPrefix(); + sb.clearPrefix(); + hasRule = true; + } + String suffix = null; + if (rules.hasSuffix()) { + suffix = rules.getSuffix(); + sb.clearSuffix(); + hasRule = true; + } + String contains = null; + if (rules.hasContains()) { + contains = rules.getContains(); + sb.clearContains(); + hasRule = true; + } + String notContains = null; + if (rules.hasNotContains()) { + notContains = rules.getNotContains(); + sb.clearNotContains(); + hasRule = true; + } + + // getInList()/getNotInList() return immutable views from the proto runtime; we only read + // them, so no defensive copy is needed. + List inVals = rules.getInList(); + if (!inVals.isEmpty()) { + sb.clearIn(); + hasRule = true; + } + + List notInVals = rules.getNotInList(); + if (!notInVals.isEmpty()) { + sb.clearNotIn(); + hasRule = true; + } + + if (!hasRule) { + return null; + } + rulesBuilder.setString(sb.build()); + return new StringRulesEvaluator( + base, + constVal, + exactLen, + minLen, + maxLen, + exactBytes, + minBytes, + maxBytes, + compiledPattern, + patternStr, + prefix, + suffix, + contains, + notContains, + inVals, + notInVals, + wellKnown, + knownRegex, + knownRegexStrict); + } + + @Override + public boolean tautology() { + return false; + } + + @Override + public List evaluate(Value val, boolean failFast) { + String strVal = (String) val.rawValue(); + List violations = null; + + if (exactLen != null || minLen != null || maxLen != null) { + long runeCount = strVal.codePointCount(0, strVal.length()); + violations = applyLength(violations, val, runeCount, failFast); + if (failFast && violations != null) { + return base.done(violations); + } + } + + if (exactBytes != null || minBytes != null || maxBytes != null) { + long byteCount = utf8ByteLength(strVal); + violations = applyByteLength(violations, val, byteCount, failFast); + if (failFast && violations != null) { + return base.done(violations); + } + } + + if (constVal != null && !strVal.equals(constVal)) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + CONST_SITE, null, "must equal `" + constVal + "`", val, constVal)); + if (failFast) return base.done(violations); + } + + if (pattern != null && !pattern.matches(strVal)) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + PATTERN_SITE, + null, + "does not match regex pattern `" + patternStr + "`", + val, + patternStr)); + if (failFast) return base.done(violations); + } + + if (prefix != null && !strVal.startsWith(prefix)) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + PREFIX_SITE, null, "does not have prefix `" + prefix + "`", val, prefix)); + if (failFast) return base.done(violations); + } + + if (suffix != null && !strVal.endsWith(suffix)) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + SUFFIX_SITE, null, "does not have suffix `" + suffix + "`", val, suffix)); + if (failFast) return base.done(violations); + } + + if (contains != null && !strVal.contains(contains)) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + CONTAINS_SITE, + null, + "does not contain substring `" + contains + "`", + val, + contains)); + if (failFast) return base.done(violations); + } + + if (notContains != null && strVal.contains(notContains)) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + NOT_CONTAINS_SITE, + null, + "contains substring `" + notContains + "`", + val, + notContains)); + if (failFast) return base.done(violations); + } + + if (!inVals.isEmpty() && !inVals.contains(strVal)) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + IN_SITE, null, "must be in list " + RuleBase.formatList(inVals), val, strVal)); + if (failFast) return base.done(violations); + } + + if (!notInVals.isEmpty() && notInVals.contains(strVal)) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + NOT_IN_SITE, + null, + "must not be in list " + RuleBase.formatList(notInVals), + val, + strVal)); + if (failFast) return base.done(violations); + } + + if (wellKnown != null) { + RuleViolation.Builder wkv = checkWellKnown(strVal, val); + if (wkv != null) { + violations = RuleBase.add(violations, wkv); + if (failFast) return base.done(violations); + } + } else if (knownRegex != KnownRegex.KNOWN_REGEX_UNSPECIFIED) { + RuleViolation.Builder krv = checkKnownRegex(strVal, val); + if (krv != null) { + violations = RuleBase.add(violations, krv); + if (failFast) return base.done(violations); + } + } + + return base.done(violations); + } + + // --- Length checks --- + + private @Nullable List applyLength( + @Nullable List violations, + Value val, + long runeCount, + boolean failFast) { + if (exactLen != null && runeCount != exactLen) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + LEN_SITE, null, "must be " + exactLen + " characters", val, exactLen)); + if (failFast) return violations; + } + if (minLen != null && runeCount < minLen) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + MIN_LEN_SITE, null, "must be at least " + minLen + " characters", val, minLen)); + if (failFast) return violations; + } + if (maxLen != null && runeCount > maxLen) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + MAX_LEN_SITE, null, "must be at most " + maxLen + " characters", val, maxLen)); + if (failFast) return violations; + } + return violations; + } + + private @Nullable List applyByteLength( + @Nullable List violations, + Value val, + long byteCount, + boolean failFast) { + if (exactBytes != null && byteCount != exactBytes) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + LEN_BYTES_SITE, null, "must be " + exactBytes + " bytes", val, exactBytes)); + if (failFast) return violations; + } + if (minBytes != null && byteCount < minBytes) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + MIN_BYTES_SITE, null, "must be at least " + minBytes + " bytes", val, minBytes)); + if (failFast) return violations; + } + if (maxBytes != null && byteCount > maxBytes) { + violations = + RuleBase.add( + violations, + NativeViolations.newViolation( + MAX_BYTES_SITE, null, "must be at most " + maxBytes + " bytes", val, maxBytes)); + if (failFast) return violations; + } + return violations; + } + + // --- Well-known format check --- + + private RuleViolation.@Nullable Builder checkWellKnown(String strVal, Value val) { + WellKnownFormat fmt = wellKnown; + if (fmt == null) { + return null; + } + if (fmt.checksEmpty() && strVal.isEmpty()) { + // checksEmpty() returning true implies emptySite is non-null (the WellKnownFormat + // constructor's contract). + RuleSite emptySite = Objects.requireNonNull(fmt.emptySite); + return NativeViolations.newViolation(emptySite, null, null, val, true); + } + if (fmt.validate(strVal)) { + return null; + } + return NativeViolations.newViolation(fmt.site, null, null, val, true); + } + + private RuleViolation.@Nullable Builder checkKnownRegex(String strVal, Value val) { + Pattern matcher; + RuleSite site; + switch (knownRegex) { + case KNOWN_REGEX_HTTP_HEADER_NAME: + if (strVal.isEmpty()) { + return NativeViolations.newViolation( + HEADER_NAME_EMPTY_SITE, null, null, val, knownRegex.getNumber()); + } + matcher = HEADER_NAME_REGEX; + site = HEADER_NAME_SITE; + break; + case KNOWN_REGEX_HTTP_HEADER_VALUE: + matcher = HEADER_VALUE_REGEX; + site = HEADER_VALUE_SITE; + break; + default: + return null; + } + if (!knownRegexStrict) { + matcher = LOOSE_REGEX; + } + if (!matcher.matches(strVal)) { + return NativeViolations.newViolation(site, null, null, val, knownRegex.getNumber()); + } + return null; + } + + // --- Helpers --- + + /** + * Returns the number of bytes in the UTF-8 encoding of {@code s} without allocating the byte + * array. Counts each code point's encoded byte length: 1 for U+0000–U+007F, 2 for U+0080–U+07FF, + * 3 for U+0800–U+FFFF (excluding the surrogate range, which is consumed as a pair), 4 for + * supplementary characters (U+10000–U+10FFFF). + */ + private static long utf8ByteLength(String s) { + long count = 0; + int i = 0; + int len = s.length(); + while (i < len) { + char c = s.charAt(i); + if (c < 0x80) { + count += 1; + i += 1; + } else if (c < 0x800) { + count += 2; + i += 1; + } else if (Character.isHighSurrogate(c) + && i + 1 < len + && Character.isLowSurrogate(s.charAt(i + 1))) { + count += 4; + i += 2; + } else { + count += 3; + i += 1; + } + } + return count; + } +} diff --git a/src/main/java/build/buf/protovalidate/Value.java b/src/main/java/build/buf/protovalidate/Value.java index 6adac431..a2bb96b2 100644 --- a/src/main/java/build/buf/protovalidate/Value.java +++ b/src/main/java/build/buf/protovalidate/Value.java @@ -50,6 +50,22 @@ interface Value { */ T value(Class clazz); + /** + * Returns the underlying protobuf Java value without any CEL-specific adaptation. + * + *

    {@link #value(Class)} routes scalars through {@code ProtoAdapter.toCel}, which converts + * {@code int32→Long}, {@code uint32→UnsignedLong}, {@code float→Double}, {@code bytes→ + * CelByteString}, etc. — appropriate for the CEL evaluation path but lossy for native rule + * evaluators that compare against raw protobuf field values. Native evaluators in {@code + * build.buf.protovalidate.rules} use this method to obtain values they can compare directly with + * the values they read off the typed rule message. + * + * @return The underlying value as protobuf-java provides it. Non-null for all values produced by + * the evaluator pipeline (field reads, list elements, message wrappers — all guarantee a + * value). + */ + Object rawValue(); + /** * Get the underlying value as a list. * diff --git a/src/main/java/build/buf/protovalidate/WrappedValueEvaluator.java b/src/main/java/build/buf/protovalidate/WrappedValueEvaluator.java new file mode 100644 index 00000000..6cba9a24 --- /dev/null +++ b/src/main/java/build/buf/protovalidate/WrappedValueEvaluator.java @@ -0,0 +1,69 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import build.buf.protovalidate.exceptions.ExecutionException; +import com.google.protobuf.Descriptors.FieldDescriptor; +import com.google.protobuf.Message; +import java.util.List; + +/** + * Adapter that lets a scalar-rule evaluator run against a {@code google.protobuf.*Value} wrapper + * field. At evaluation time it pulls the inner {@code value} field off the wrapper {@link Message} + * and delegates to the wrapped scalar evaluator. + * + *

    CEL's runtime auto-unwraps wrappers to their inner primitive type, so the CEL path doesn't + * need this adapter. Native evaluators expect the underlying scalar (e.g. {@code Long} for {@code + * Int64Value.value}); without unwrapping they'd see the wrapper {@link Message} and misbehave. + * Mirrors {@code wrappedValueEval} in protovalidate-go's {@code builder.go}. + * + *

    The wrapped evaluator's {@link RuleBase} is constructed against the OUTER wrapper field's + * {@code ValueEvaluator}, so violation field paths point at the user's wrapper-typed field rather + * than the synthetic inner {@code value}. + */ +final class WrappedValueEvaluator implements Evaluator { + private final FieldDescriptor innerField; + private final Evaluator inner; + + WrappedValueEvaluator(FieldDescriptor innerField, Evaluator inner) { + // innerField must be the synthetic "value" field of a google.protobuf.*Value wrapper. Its + // containing message holds exactly one field at number 1 named "value"; if any of those + // assumptions is violated the evaluator would silently misbehave at runtime. + if (innerField.getNumber() != 1 || !"value".equals(innerField.getName())) { + throw new IllegalArgumentException( + "WrappedValueEvaluator requires the wrapper's inner 'value' field, got " + + innerField.getFullName()); + } + this.innerField = innerField; + this.inner = inner; + } + + @Override + public boolean tautology() { + return inner.tautology(); + } + + @Override + public List evaluate(Value val, boolean failFast) + throws ExecutionException { + Message message = val.messageValue(); + if (message == null) { + // proto3 message-typed field absent — no value to validate. + return RuleViolation.NO_VIOLATIONS; + } + Object innerValue = message.getField(innerField); + return inner.evaluate(new ObjectValue(innerField, innerValue), failFast); + } +} diff --git a/src/test/java/build/buf/protovalidate/BoolRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/BoolRulesEvaluatorTest.java new file mode 100644 index 00000000..ddb32fa7 --- /dev/null +++ b/src/test/java/build/buf/protovalidate/BoolRulesEvaluatorTest.java @@ -0,0 +1,109 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import static org.assertj.core.api.Assertions.assertThat; + +import build.buf.protovalidate.exceptions.ValidationException; +import build.buf.validate.BoolRules; +import com.example.noimports.validationtest.ExampleBoolConst; +import com.google.protobuf.Descriptors.FieldDescriptor; +import org.junit.jupiter.api.Test; + +/** + * Validator-level integration tests for {@link BoolRulesEvaluator}. Mirrors the per-rule tests in + * protovalidate-go's {@code native_bool_test.go}, plus an explicit assertion on {@link + * Violation#getRuleValue()} since the conformance suite cannot detect divergence on that field (it + * is not part of the {@code Violation} proto schema — see CHANGELOG Phase 1). + */ +class BoolRulesEvaluatorTest { + + private static Validator nativeValidator() { + Config config = Config.newBuilder().setEnableNativeRules(true).build(); + return ValidatorFactory.newBuilder().withConfig(config).build(); + } + + @Test + void boolConstPasses() throws ValidationException { + Validator validator = nativeValidator(); + ExampleBoolConst msg = ExampleBoolConst.newBuilder().setFlag(true).build(); + ValidationResult result = validator.validate(msg); + assertThat(result.getViolations()).isEmpty(); + assertThat(result.isSuccess()).isTrue(); + } + + @Test + void boolConstFailsAndCarriesExpectedViolationShape() throws ValidationException { + Validator validator = nativeValidator(); + // Default value (false) fails the const=true rule. Bool fields without explicit-presence in + // proto3 still apply rules at default; bool has no IGNORE_IF_ZERO_VALUE behavior here. + ExampleBoolConst msg = ExampleBoolConst.newBuilder().setFlag(false).build(); + ValidationResult result = validator.validate(msg); + + assertThat(result.isSuccess()).isFalse(); + assertThat(result.getViolations()).hasSize(1); + + Violation violation = result.getViolations().get(0); + build.buf.validate.Violation proto = violation.toProto(); + assertThat(proto.getRuleId()).isEqualTo("bool.const"); + assertThat(proto.getMessage()).isEqualTo("must equal true"); + assertThat(proto.getField().getElementsList()).hasSize(1); + assertThat(proto.getField().getElements(0).getFieldName()).isEqualTo("flag"); + assertThat(proto.getRule().getElementsList()).hasSize(2); + assertThat(proto.getRule().getElements(0).getFieldName()).isEqualTo("bool"); + assertThat(proto.getRule().getElements(1).getFieldName()).isEqualTo("const"); + + // getRuleValue is NOT in the Violation proto — it's only on the Java wrapper. Conformance + // can't catch divergence on this field, so assert it explicitly. See Phase 1 CHANGELOG. + Violation.FieldValue ruleValue = violation.getRuleValue(); + assertThat(ruleValue).isNotNull(); + assertThat(ruleValue.getValue()).isEqualTo(true); + FieldDescriptor expectedRuleDesc = + BoolRules.getDescriptor().findFieldByNumber(BoolRules.CONST_FIELD_NUMBER); + assertThat(ruleValue.getDescriptor()).isEqualTo(expectedRuleDesc); + + Violation.FieldValue fieldValue = violation.getFieldValue(); + assertThat(fieldValue).isNotNull(); + assertThat(fieldValue.getValue()).isEqualTo(false); + } + + @Test + void nativeAndCelProducePartiallyEqualViolations() throws ValidationException { + // Same input, both modes — toProto() must match exactly. (rule_value isn't in the proto so + // CEL/native can disagree there; that's covered by the dedicated assertion above.) + ExampleBoolConst msg = ExampleBoolConst.newBuilder().setFlag(false).build(); + + ValidationResult nativeResult = nativeValidator().validate(msg); + Validator celValidator = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) + .build(); + ValidationResult celResult = celValidator.validate(msg); + + assertThat(nativeResult.getViolations()).hasSize(1); + assertThat(celResult.getViolations()).hasSize(1); + assertThat(nativeResult.getViolations().get(0).toProto()) + .isEqualTo(celResult.getViolations().get(0).toProto()); + } + + @Test + void nativeDispatchClearsRuleSoCelDoesNotDuplicateIt() throws ValidationException { + // If the dispatcher failed to clear bool.const on the residual FieldRules, CEL would also + // produce a violation and we'd see two. One violation proves clone-and-clear works. + ExampleBoolConst msg = ExampleBoolConst.newBuilder().setFlag(false).build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + } +} diff --git a/src/test/java/build/buf/protovalidate/BytesRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/BytesRulesEvaluatorTest.java new file mode 100644 index 00000000..eb8efbb2 --- /dev/null +++ b/src/test/java/build/buf/protovalidate/BytesRulesEvaluatorTest.java @@ -0,0 +1,123 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import build.buf.protovalidate.exceptions.ExecutionException; +import build.buf.protovalidate.exceptions.ValidationException; +import build.buf.validate.BytesRules; +import com.example.noimports.validationtest.ExampleBytesConst; +import com.example.noimports.validationtest.ExampleBytesIPv4; +import com.example.noimports.validationtest.ExampleBytesPattern; +import com.google.protobuf.ByteString; +import com.google.protobuf.Descriptors.FieldDescriptor; +import org.junit.jupiter.api.Test; + +/** Validator-level tests for {@link BytesRulesEvaluator}. */ +class BytesRulesEvaluatorTest { + + private static Validator nativeValidator() { + Config config = Config.newBuilder().setEnableNativeRules(true).build(); + return ValidatorFactory.newBuilder().withConfig(config).build(); + } + + @Test + void bytesConstFailsAndCarriesExpectedShape() throws ValidationException { + // const = "\x00\x99". Empty value (default) doesn't match. + ExampleBytesConst msg = ExampleBytesConst.newBuilder().setVal(ByteString.EMPTY).build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + Violation v = result.getViolations().get(0); + + build.buf.validate.Violation proto = v.toProto(); + assertThat(proto.getRuleId()).isEqualTo("bytes.const"); + assertThat(proto.getMessage()).isEqualTo("must be 0099"); + assertThat(proto.getRule().getElements(0).getFieldName()).isEqualTo("bytes"); + assertThat(proto.getRule().getElements(1).getFieldName()).isEqualTo("const"); + + // rule_value isn't in the proto — assert directly. + Violation.FieldValue ruleValue = v.getRuleValue(); + assertThat(ruleValue).isNotNull(); + assertThat(ruleValue.getValue()).isEqualTo(ByteString.copyFrom(new byte[] {0x00, (byte) 0x99})); + FieldDescriptor expectedDesc = + BytesRules.getDescriptor().findFieldByNumber(BytesRules.CONST_FIELD_NUMBER); + assertThat(ruleValue.getDescriptor()).isEqualTo(expectedDesc); + } + + @Test + void bytesPatternMatchesAlphanumericOnly() throws ValidationException { + Validator v = nativeValidator(); + assertThat( + v.validate( + ExampleBytesPattern.newBuilder().setVal(ByteString.copyFromUtf8("abc")).build()) + .isSuccess()) + .isTrue(); + assertThat( + v.validate( + ExampleBytesPattern.newBuilder() + .setVal(ByteString.copyFromUtf8("abc1")) + .build()) + .isSuccess()) + .isFalse(); + } + + @Test + void bytesPatternThrowsOnInvalidUtf8() { + // Non-UTF-8 input + pattern rule → ExecutionException, matching Go's RuntimeError. + ByteString invalidUtf8 = ByteString.copyFrom(new byte[] {(byte) 0xFF, (byte) 0xFE}); + ExampleBytesPattern msg = ExampleBytesPattern.newBuilder().setVal(invalidUtf8).build(); + Validator v = nativeValidator(); + assertThatThrownBy(() -> v.validate(msg)) + .isInstanceOf(ExecutionException.class) + .hasMessageContaining("UTF-8"); + } + + @Test + void bytesIpv4WellKnownAcceptsFourBytes() throws ValidationException { + Validator v = nativeValidator(); + // 4 bytes — valid IPv4 size. + assertThat( + v.validate( + ExampleBytesIPv4.newBuilder().setVal(ByteString.copyFrom(new byte[4])).build()) + .isSuccess()) + .isTrue(); + } + + @Test + void bytesIpv4WellKnownRejectsWrongSize() throws ValidationException { + // 8 bytes — neither 0 nor 4, fails with the non-empty rule id. + ExampleBytesIPv4 msg = + ExampleBytesIPv4.newBuilder().setVal(ByteString.copyFrom(new byte[8])).build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation proto = result.getViolations().get(0).toProto(); + assertThat(proto.getRuleId()).isEqualTo("bytes.ipv4"); + assertThat(proto.getMessage()).isEqualTo("must be a valid IPv4 address"); + } + + @Test + void nativeAndCelProduceEqualViolationProto() throws ValidationException { + ExampleBytesConst msg = ExampleBytesConst.newBuilder().setVal(ByteString.EMPTY).build(); + Validator nativeV = nativeValidator(); + Validator celV = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) + .build(); + assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) + .isEqualTo(celV.validate(msg).getViolations().get(0).toProto()); + } +} diff --git a/src/test/java/build/buf/protovalidate/EnumRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/EnumRulesEvaluatorTest.java new file mode 100644 index 00000000..c3aad8e9 --- /dev/null +++ b/src/test/java/build/buf/protovalidate/EnumRulesEvaluatorTest.java @@ -0,0 +1,87 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import static org.assertj.core.api.Assertions.assertThat; + +import build.buf.protovalidate.exceptions.ValidationException; +import build.buf.validate.EnumRules; +import com.example.noimports.validationtest.ExampleColor; +import com.example.noimports.validationtest.ExampleEnumConst; +import com.example.noimports.validationtest.ExampleEnumIn; +import com.google.protobuf.Descriptors.FieldDescriptor; +import org.junit.jupiter.api.Test; + +/** Validator-level tests for {@link EnumRulesEvaluator}. */ +class EnumRulesEvaluatorTest { + + private static Validator nativeValidator() { + Config config = Config.newBuilder().setEnableNativeRules(true).build(); + return ValidatorFactory.newBuilder().withConfig(config).build(); + } + + @Test + void enumConstFailsAndCarriesExpectedShape() throws ValidationException { + // Default UNSPECIFIED (0) != const 2 (GREEN). + ExampleEnumConst msg = ExampleEnumConst.newBuilder().build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + Violation v = result.getViolations().get(0); + + build.buf.validate.Violation proto = v.toProto(); + assertThat(proto.getRuleId()).isEqualTo("enum.const"); + assertThat(proto.getMessage()).isEqualTo("must equal 2"); + assertThat(proto.getRule().getElements(0).getFieldName()).isEqualTo("enum"); + assertThat(proto.getRule().getElements(1).getFieldName()).isEqualTo("const"); + + // rule_value isn't in the Violation proto — assert it directly here. See Phase 1 CHANGELOG. + Violation.FieldValue ruleValue = v.getRuleValue(); + assertThat(ruleValue).isNotNull(); + assertThat(ruleValue.getValue()).isEqualTo(2); + FieldDescriptor expectedDesc = + EnumRules.getDescriptor().findFieldByNumber(EnumRules.CONST_FIELD_NUMBER); + assertThat(ruleValue.getDescriptor()).isEqualTo(expectedDesc); + } + + @Test + void enumInFailsForValueNotInList() throws ValidationException { + // Default UNSPECIFIED (0) not in [1, 3]. + ExampleEnumIn msg = ExampleEnumIn.newBuilder().build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation proto = result.getViolations().get(0).toProto(); + assertThat(proto.getRuleId()).isEqualTo("enum.in"); + assertThat(proto.getMessage()).isEqualTo("must be in list [1, 3]"); + } + + @Test + void enumInPassesForValueInList() throws ValidationException { + ExampleEnumIn msg = ExampleEnumIn.newBuilder().setVal(ExampleColor.EXAMPLE_COLOR_RED).build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.isSuccess()).isTrue(); + } + + @Test + void nativeAndCelProduceEqualViolationProto() throws ValidationException { + ExampleEnumConst msg = ExampleEnumConst.newBuilder().build(); + Validator nativeV = nativeValidator(); + Validator celV = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) + .build(); + assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) + .isEqualTo(celV.validate(msg).getViolations().get(0).toProto()); + } +} diff --git a/src/test/java/build/buf/protovalidate/FailFastTest.java b/src/test/java/build/buf/protovalidate/FailFastTest.java new file mode 100644 index 00000000..292ec699 --- /dev/null +++ b/src/test/java/build/buf/protovalidate/FailFastTest.java @@ -0,0 +1,71 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import static org.assertj.core.api.Assertions.assertThat; + +import build.buf.protovalidate.exceptions.ValidationException; +import com.example.noimports.validationtest.BytesMultiRule; +import com.example.noimports.validationtest.Int32MultiRule; +import com.example.noimports.validationtest.StringMultiRule; +import com.google.protobuf.ByteString; +import com.google.protobuf.Message; +import org.junit.jupiter.api.Test; + +/** + * failFast tests for the native rule evaluators. Each fixture is constructed so the input violates + * two rules. Without failFast, both violations are reported; with failFast=true, the validator must + * short-circuit after the first. + */ +class FailFastTest { + + private static Validator validator(boolean failFast) { + Config config = Config.newBuilder().setEnableNativeRules(true).setFailFast(failFast).build(); + return ValidatorFactory.newBuilder().withConfig(config).build(); + } + + @Test + void stringEvaluator_failFastSkipsLaterRules() throws ValidationException { + // "ab" — fails min_len=4, would also fail pattern .*[0-9].* + StringMultiRule msg = StringMultiRule.newBuilder().setVal("ab").build(); + assertTwoViolationsWithoutFailFastOneWith(msg); + } + + @Test + void numericEvaluator_failFastSkipsLaterRules() throws ValidationException { + // val=0: fails const=5 and fails gt=10 + Int32MultiRule msg = Int32MultiRule.newBuilder().setVal(0).build(); + assertTwoViolationsWithoutFailFastOneWith(msg); + } + + @Test + void bytesEvaluator_failFastSkipsLaterRules() throws ValidationException { + // 1-byte value — fails min_len=4 AND fails ipv4 size requirement. + BytesMultiRule msg = + BytesMultiRule.newBuilder().setVal(ByteString.copyFrom(new byte[] {0x01})).build(); + assertTwoViolationsWithoutFailFastOneWith(msg); + } + + private void assertTwoViolationsWithoutFailFastOneWith(Message msg) throws ValidationException { + ValidationResult full = validator(false).validate(msg); + ValidationResult fast = validator(true).validate(msg); + assertThat(full.getViolations()) + .as("without failFast, both violations should be reported") + .hasSize(2); + assertThat(fast.getViolations()) + .as("with failFast, only the first violation should be reported") + .hasSize(1); + } +} diff --git a/src/test/java/build/buf/protovalidate/FloatBugConfirmationTest.java b/src/test/java/build/buf/protovalidate/FloatBugConfirmationTest.java new file mode 100644 index 00000000..d02f9d76 --- /dev/null +++ b/src/test/java/build/buf/protovalidate/FloatBugConfirmationTest.java @@ -0,0 +1,187 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import static org.assertj.core.api.Assertions.assertThat; + +import build.buf.protovalidate.exceptions.ValidationException; +import com.example.imports.validationtest.FloatDoubleNaNNegZero; +import com.example.noimports.validationtest.ExampleDoubleConstNegZero; +import com.example.noimports.validationtest.ExampleDoubleRepeatedUnique; +import com.example.noimports.validationtest.ExampleFloatConstNegZero; +import com.example.noimports.validationtest.ExampleFloatRepeatedUnique; +import com.google.protobuf.Message; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +/** + * Comparative tests for the floating-point findings in NATIVE_RULES_REVIEW.md (B1, B2). Each test + * runs the same input through the native and CEL evaluation paths and asserts on the resulting + * {@code Violation} protos. + * + *

    Outcomes after running: + * + *

      + *
    • B1 — fixed. {@code floatFormatter}/{@code doubleFormatter} now check the sign bit on + * entry and return {@code "-0"} for negative zero, so {@code float.const = -0.0} produces the + * same violation message in both modes. The tests below lock in that parity. + *
    • B2 — reclassified. Original review claimed native diverges from CEL on {@code + * repeated.unique} for {@code NaN}/{@code -0.0}. Investigation showed CEL's {@code unique()} + * is registered by protovalidate-java itself (see {@code CustomOverload.uniqueList}) and uses + * {@code Object.equals} on a {@link java.util.HashSet} — the same defect as {@code + * RepeatedRulesEvaluator.isUnique}. So both paths agree (both deviate from the CEL spec, + * which mandates IEEE-754 equality on doubles). The tests below lock in that agreement so any + * future fix has to touch both paths together. + *
    + */ +class FloatBugConfirmationTest { + + private final Validator nativeValidator = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setEnableNativeRules(true).build()) + .build(); + private final Validator celValidator = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) + .build(); + + // --- B1: floatFormatter renders -0.0 as "0", losing the sign -------------------------------- + + @Test + void floatConstNegZero_messageMatchesBetweenNativeAndCel() throws ValidationException { + // After the floatFormatter fix, both modes report a violation (1.0 != -0.0) with the same + // message — the rule value is rendered as "-0" in both paths. This test locks in parity; + // a regression in floatFormatter (e.g. the sign-bit short-circuit being removed) will fail + // here. + ExampleFloatConstNegZero msg = ExampleFloatConstNegZero.newBuilder().setVal(1.0f).build(); + String nativeMsg = singleViolationMessage(nativeValidator, msg); + String celMsg = singleViolationMessage(celValidator, msg); + + assertThat(nativeMsg).isEqualTo("must equal -0"); + assertThat(celMsg).isEqualTo("must equal -0"); + } + + @Test + void doubleConstNegZero_messageMatchesBetweenNativeAndCel() throws ValidationException { + ExampleDoubleConstNegZero msg = ExampleDoubleConstNegZero.newBuilder().setVal(1.0).build(); + String nativeMsg = singleViolationMessage(nativeValidator, msg); + String celMsg = singleViolationMessage(celValidator, msg); + + assertThat(nativeMsg).isEqualTo("must equal -0"); + assertThat(celMsg).isEqualTo("must equal -0"); + } + + // --- B2: repeated.unique on floats — native and CEL agree (both wrong vs spec) -------------- + // + // CustomOverload.uniqueList (the CEL-side `unique()` registered by protovalidate-java) is + // implemented with HashSet + Object.equals — the same approach as RepeatedRulesEvaluator's + // native path. Both treat NaN as duplicate (Java equality) and +0.0/-0.0 as distinct + // (floatToIntBits), contradicting CEL's spec (IEEE-754: NaN != NaN, +0.0 == -0.0). + // + // These tests assert the agreement so any IEEE-754 fix has to ship in CustomOverload.uniqueList + // and RepeatedRulesEvaluator.isUnique together — otherwise NativeRulesParityTest will start + // failing. + + @Test + void floatRepeatedUnique_NaNNaN_bothPathsAgreeBothWrongVsSpec() throws ValidationException { + ExampleFloatRepeatedUnique msg = + ExampleFloatRepeatedUnique.newBuilder().addVal(Float.NaN).addVal(Float.NaN).build(); + assertViolationsEqual(msg); + } + + @Test + void doubleRepeatedUnique_NaNNaN_bothPathsAgreeBothWrongVsSpec() throws ValidationException { + ExampleDoubleRepeatedUnique msg = + ExampleDoubleRepeatedUnique.newBuilder().addVal(Double.NaN).addVal(Double.NaN).build(); + assertViolationsEqual(msg); + } + + @Test + void floatRepeatedUnique_PlusZeroMinusZero_bothPathsAgreeBothWrongVsSpec() + throws ValidationException { + ExampleFloatRepeatedUnique msg = + ExampleFloatRepeatedUnique.newBuilder().addVal(0.0f).addVal(-0.0f).build(); + assertViolationsEqual(msg); + } + + @Test + void doubleRepeatedUnique_PlusZeroMinusZero_bothPathsAgreeBothWrongVsSpec() + throws ValidationException { + ExampleDoubleRepeatedUnique msg = + ExampleDoubleRepeatedUnique.newBuilder().addVal(0.0).addVal(-0.0).build(); + assertViolationsEqual(msg); + } + + @Test + void floatDoubleNaNNegZero() throws ValidationException { + // these tests are also checking that an unset (zero) field is equal to -0 + FloatDoubleNaNNegZero nanMsg = + FloatDoubleNaNNegZero.newBuilder() + .addDvals(Double.NaN) + .addDvals(Double.NaN) + .addFvals(Float.NaN) + .addFvals(Float.NaN) + .build(); + // should both be no error, since NaN is not equal to itself + // it's not because Java CEL is broken so replicate broken behavior + ValidationResult nanMsgResultNative = nativeValidator.validate(nanMsg); + ValidationResult nanMsgResultCEL = celValidator.validate(nanMsg); + assertViolationsEqual(nanMsg); + assertThat(nanMsgResultNative.getViolations()).isNotEmpty(); + assertThat(nanMsgResultCEL.getViolations()).isNotEmpty(); + + // now check -0 and 0 for uniqueness (should not be) + FloatDoubleNaNNegZero zeroMsg = + FloatDoubleNaNNegZero.newBuilder() + .addDvals(0.0) + .addDvals(-0.0) + .addFvals(0.0F) + .addFvals(-0.0F) + .build(); + // should both be error, since 0 == -0 + // but it's not because Java CEL is broken on unique tests for -0 so replicate broken behavior + nanMsgResultNative = nativeValidator.validate(zeroMsg); + nanMsgResultCEL = celValidator.validate(zeroMsg); + assertViolationsEqual(zeroMsg); + assertThat(nanMsgResultNative.getViolations()).isEmpty(); + assertThat(nanMsgResultCEL.getViolations()).isEmpty(); + } + + // --- helpers ---------------------------------------------------------------------------------- + + private static String singleViolationMessage(Validator v, Message msg) + throws ValidationException { + ValidationResult result = v.validate(msg); + List messages = + result.getViolations().stream() + .map(violation -> violation.toProto().getMessage()) + .collect(Collectors.toList()); + assertThat(messages).hasSize(1); + return messages.get(0); + } + + private void assertViolationsEqual(Message msg) throws ValidationException { + List nativeProtos = toProtoList(nativeValidator.validate(msg)); + List celProtos = toProtoList(celValidator.validate(msg)); + assertThat(nativeProtos) + .as("native and CEL must produce identical Violation protos for %s", msg) + .isEqualTo(celProtos); + } + + private static List toProtoList(ValidationResult result) { + return result.getViolations().stream().map(v -> v.toProto()).collect(Collectors.toList()); + } +} diff --git a/src/test/java/build/buf/protovalidate/NotInRulesTest.java b/src/test/java/build/buf/protovalidate/NotInRulesTest.java new file mode 100644 index 00000000..d1221952 --- /dev/null +++ b/src/test/java/build/buf/protovalidate/NotInRulesTest.java @@ -0,0 +1,86 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import static org.assertj.core.api.Assertions.assertThat; + +import build.buf.protovalidate.exceptions.ValidationException; +import com.example.noimports.validationtest.BytesNotIn; +import com.example.noimports.validationtest.EnumNotIn; +import com.example.noimports.validationtest.ExampleColor; +import com.example.noimports.validationtest.Int32NotIn; +import com.example.noimports.validationtest.StringNotIn; +import com.example.noimports.validationtest.Uint32NotIn; +import com.google.protobuf.ByteString; +import org.junit.jupiter.api.Test; + +/** + * Targeted not_in coverage for each native evaluator. The conformance suite covers behavior; what + * these tests pin down is the rule_id and message text the native path produces, since those are + * the contract observable from {@code Violation.toProto()}. + */ +class NotInRulesTest { + + private final Validator validator = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setEnableNativeRules(true).build()) + .build(); + + @Test + void int32NotIn() throws ValidationException { + Int32NotIn msg = Int32NotIn.newBuilder().setVal(2).build(); + ValidationResult result = validator.validate(msg); + assertThat(result.getViolations()).hasSize(1); + assertThat(result.getViolations().get(0).toProto().getRuleId()).isEqualTo("int32.not_in"); + assertThat(result.getViolations().get(0).toProto().getMessage()) + .isEqualTo("must not be in list [1, 2, 3]"); + } + + @Test + void uint32NotIn() throws ValidationException { + Uint32NotIn msg = Uint32NotIn.newBuilder().setVal(1).build(); + ValidationResult result = validator.validate(msg); + assertThat(result.getViolations()).hasSize(1); + assertThat(result.getViolations().get(0).toProto().getRuleId()).isEqualTo("uint32.not_in"); + } + + @Test + void stringNotIn() throws ValidationException { + StringNotIn msg = StringNotIn.newBuilder().setVal("foo").build(); + ValidationResult result = validator.validate(msg); + assertThat(result.getViolations()).hasSize(1); + assertThat(result.getViolations().get(0).toProto().getRuleId()).isEqualTo("string.not_in"); + assertThat(result.getViolations().get(0).toProto().getMessage()) + .isEqualTo("must not be in list [foo, bar]"); + } + + @Test + void bytesNotIn() throws ValidationException { + BytesNotIn msg = BytesNotIn.newBuilder().setVal(ByteString.copyFromUtf8("AA")).build(); + ValidationResult result = validator.validate(msg); + assertThat(result.getViolations()).hasSize(1); + assertThat(result.getViolations().get(0).toProto().getRuleId()).isEqualTo("bytes.not_in"); + } + + @Test + void enumNotIn() throws ValidationException { + EnumNotIn msg = EnumNotIn.newBuilder().setVal(ExampleColor.EXAMPLE_COLOR_RED).build(); + ValidationResult result = validator.validate(msg); + assertThat(result.getViolations()).hasSize(1); + assertThat(result.getViolations().get(0).toProto().getRuleId()).isEqualTo("enum.not_in"); + assertThat(result.getViolations().get(0).toProto().getMessage()) + .isEqualTo("must not be in list [1, 2]"); + } +} diff --git a/src/test/java/build/buf/protovalidate/NumericRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/NumericRulesEvaluatorTest.java new file mode 100644 index 00000000..4be6f76d --- /dev/null +++ b/src/test/java/build/buf/protovalidate/NumericRulesEvaluatorTest.java @@ -0,0 +1,164 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import static org.assertj.core.api.Assertions.assertThat; + +import build.buf.protovalidate.exceptions.ValidationException; +import build.buf.validate.DoubleRules; +import build.buf.validate.Int32Rules; +import build.buf.validate.UInt32Rules; +import com.example.noimports.validationtest.ExampleDoubleIn; +import com.example.noimports.validationtest.ExampleFloatFinite; +import com.example.noimports.validationtest.ExampleInt32Const; +import com.example.noimports.validationtest.ExampleInt32GtLt; +import com.example.noimports.validationtest.ExampleUint32Gt; +import com.google.protobuf.Descriptors.FieldDescriptor; +import org.junit.jupiter.api.Test; + +/** + * Validator-level tests for {@link NumericRulesEvaluator}. Per-kind comparison correctness is + * already covered comprehensively by the conformance suite (44 cases × 12 kinds); these tests focus + * on the things conformance can't catch: + * + *
      + *
    • {@link Violation#getRuleValue()} shape — not part of the {@code Violation} proto so the + * conformance harness can't assert on it. + *
    • Unsigned comparison correctness with values above {@code Integer.MAX_VALUE} as a targeted + * regression test for {@code Integer.compareUnsigned} wiring. + *
    • {@code finite}-rule {@code NaN}/{@code Inf} dispatch. + *
    + */ +class NumericRulesEvaluatorTest { + + private static Validator nativeValidator() { + Config config = Config.newBuilder().setEnableNativeRules(true).build(); + return ValidatorFactory.newBuilder().withConfig(config).build(); + } + + @Test + void int32ConstFailsAndCarriesExpectedShape() throws ValidationException { + // Default 0 != const 5. + ExampleInt32Const msg = ExampleInt32Const.newBuilder().setVal(0).build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + + Violation v = result.getViolations().get(0); + build.buf.validate.Violation proto = v.toProto(); + assertThat(proto.getRuleId()).isEqualTo("int32.const"); + assertThat(proto.getMessage()).isEqualTo("must equal 5"); + assertThat(proto.getField().getElements(0).getFieldName()).isEqualTo("val"); + assertThat(proto.getRule().getElements(0).getFieldName()).isEqualTo("int32"); + assertThat(proto.getRule().getElements(1).getFieldName()).isEqualTo("const"); + + // Action item from Phase 1 / Task #3: rule_value isn't in the Violation proto, so the + // conformance suite can't catch divergence on it. Assert directly here. + Violation.FieldValue ruleValue = v.getRuleValue(); + assertThat(ruleValue).isNotNull(); + assertThat(ruleValue.getValue()).isEqualTo(5); + FieldDescriptor expectedDesc = + Int32Rules.getDescriptor().findFieldByNumber(Int32Rules.CONST_FIELD_NUMBER); + assertThat(ruleValue.getDescriptor()).isEqualTo(expectedDesc); + } + + @Test + void int32GtLtRangeProducesCombinedRuleId() throws ValidationException { + // Default 0 violates gt=0 (lower bound). Combined gt+lt produces "int32.gt_lt" rule id with + // a single combined message. + ExampleInt32GtLt msg = ExampleInt32GtLt.newBuilder().setVal(0).build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation proto = result.getViolations().get(0).toProto(); + assertThat(proto.getRuleId()).isEqualTo("int32.gt_lt"); + assertThat(proto.getMessage()).isEqualTo("must be greater than 0 and less than 10"); + } + + @Test + void uint32UnsignedComparisonHandlesValuesAboveSignedMax() throws ValidationException { + // Rule: gt = 2147483648 (which is Integer.MAX_VALUE + 1 as unsigned). A naive signed compare + // would interpret this threshold as -2147483648 and accept any positive int. With unsigned + // semantics, 1 must NOT satisfy gt=2147483648. + ExampleUint32Gt msg = ExampleUint32Gt.newBuilder().setVal(1).build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation proto = result.getViolations().get(0).toProto(); + assertThat(proto.getRuleId()).isEqualTo("uint32.gt"); + assertThat(proto.getMessage()).isEqualTo("must be greater than 2147483648"); + + Violation.FieldValue ruleValue = result.getViolations().get(0).getRuleValue(); + assertThat(ruleValue).isNotNull(); + // Stored as Java's signed Integer with the bit pattern of unsigned 2147483648. + assertThat(ruleValue.getValue()).isEqualTo(Integer.MIN_VALUE); + FieldDescriptor expectedDesc = + UInt32Rules.getDescriptor().findFieldByNumber(UInt32Rules.GT_FIELD_NUMBER); + assertThat(ruleValue.getDescriptor()).isEqualTo(expectedDesc); + } + + @Test + void uint32UnsignedComparisonAcceptsValueAboveThreshold() throws ValidationException { + // 3000000000 (unsigned) must satisfy gt=2147483648 (unsigned). + ExampleUint32Gt msg = ExampleUint32Gt.newBuilder().setVal((int) 3_000_000_000L).build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.isSuccess()).isTrue(); + assertThat(result.getViolations()).isEmpty(); + } + + @Test + void floatFiniteFailsForNaNAndInf() throws ValidationException { + Validator v = nativeValidator(); + assertThat(v.validate(ExampleFloatFinite.newBuilder().setVal(Float.NaN).build()).isSuccess()) + .isFalse(); + assertThat( + v.validate(ExampleFloatFinite.newBuilder().setVal(Float.POSITIVE_INFINITY).build()) + .isSuccess()) + .isFalse(); + assertThat(v.validate(ExampleFloatFinite.newBuilder().setVal(1.0f).build()).isSuccess()) + .isTrue(); + } + + @Test + void doubleInRuleValueShape() throws ValidationException { + // 0.0 not in [1.5, 2.5]. + ExampleDoubleIn msg = ExampleDoubleIn.newBuilder().setVal(0.0).build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + Violation v = result.getViolations().get(0); + assertThat(v.toProto().getRuleId()).isEqualTo("double.in"); + + // For in-list violations the rule_value is the failing value (matches Go's behavior). + Violation.FieldValue ruleValue = v.getRuleValue(); + assertThat(ruleValue).isNotNull(); + assertThat(ruleValue.getValue()).isEqualTo(0.0); + FieldDescriptor expectedDesc = + DoubleRules.getDescriptor().findFieldByNumber(DoubleRules.IN_FIELD_NUMBER); + assertThat(ruleValue.getDescriptor()).isEqualTo(expectedDesc); + } + + @Test + void nativeAndCelProduceEqualViolationProtos() throws ValidationException { + // Same input, both modes — toProto() must match exactly. Excludes rule_value since that's + // not in the proto. + ExampleInt32GtLt msg = ExampleInt32GtLt.newBuilder().setVal(0).build(); + + Validator nativeV = nativeValidator(); + Validator celV = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) + .build(); + + assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) + .isEqualTo(celV.validate(msg).getViolations().get(0).toProto()); + } +} diff --git a/src/test/java/build/buf/protovalidate/RepeatedAndMapRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/RepeatedAndMapRulesEvaluatorTest.java new file mode 100644 index 00000000..78a1d2ae --- /dev/null +++ b/src/test/java/build/buf/protovalidate/RepeatedAndMapRulesEvaluatorTest.java @@ -0,0 +1,121 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import static org.assertj.core.api.Assertions.assertThat; + +import build.buf.protovalidate.exceptions.ValidationException; +import com.example.noimports.validationtest.ExampleMapMinMax; +import com.example.noimports.validationtest.ExampleRepeatedMinMax; +import com.example.noimports.validationtest.ExampleRepeatedUnique; +import org.junit.jupiter.api.Test; + +/** Validator-level tests for {@link RepeatedRulesEvaluator} and {@link MapRulesEvaluator}. */ +class RepeatedAndMapRulesEvaluatorTest { + + private static Validator nativeValidator() { + Config config = Config.newBuilder().setEnableNativeRules(true).build(); + return ValidatorFactory.newBuilder().withConfig(config).build(); + } + + @Test + void repeatedMinItemsViolation() throws ValidationException { + // 1 item, min_items=2. + ExampleRepeatedMinMax msg = ExampleRepeatedMinMax.newBuilder().addVal(1).build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation proto = result.getViolations().get(0).toProto(); + assertThat(proto.getRuleId()).isEqualTo("repeated.min_items"); + assertThat(proto.getMessage()).isEqualTo("must contain at least 2 item(s)"); + } + + @Test + void repeatedMaxItemsViolation() throws ValidationException { + // 6 items, max_items=5. + ExampleRepeatedMinMax msg = + ExampleRepeatedMinMax.newBuilder() + .addVal(1) + .addVal(2) + .addVal(3) + .addVal(4) + .addVal(5) + .addVal(6) + .build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation proto = result.getViolations().get(0).toProto(); + assertThat(proto.getRuleId()).isEqualTo("repeated.max_items"); + assertThat(proto.getMessage()).isEqualTo("must contain no more than 5 item(s)"); + } + + @Test + void repeatedUniqueValid() throws ValidationException { + ExampleRepeatedUnique msg = + ExampleRepeatedUnique.newBuilder().addVal("a").addVal("b").addVal("c").build(); + assertThat(nativeValidator().validate(msg).isSuccess()).isTrue(); + } + + @Test + void repeatedUniqueViolation() throws ValidationException { + ExampleRepeatedUnique msg = + ExampleRepeatedUnique.newBuilder().addVal("a").addVal("b").addVal("a").build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation proto = result.getViolations().get(0).toProto(); + assertThat(proto.getRuleId()).isEqualTo("repeated.unique"); + assertThat(proto.getMessage()).isEqualTo("repeated value must contain unique items"); + } + + @Test + void mapMinPairsViolation() throws ValidationException { + // Empty map, min_pairs=1. + ExampleMapMinMax msg = ExampleMapMinMax.newBuilder().build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation proto = result.getViolations().get(0).toProto(); + assertThat(proto.getRuleId()).isEqualTo("map.min_pairs"); + assertThat(proto.getMessage()).isEqualTo("map must be at least 1 entries"); + } + + @Test + void mapMaxPairsViolation() throws ValidationException { + // 4 entries, max_pairs=3. + ExampleMapMinMax msg = + ExampleMapMinMax.newBuilder() + .putVal("a", "1") + .putVal("b", "2") + .putVal("c", "3") + .putVal("d", "4") + .build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation proto = result.getViolations().get(0).toProto(); + assertThat(proto.getRuleId()).isEqualTo("map.max_pairs"); + assertThat(proto.getMessage()).isEqualTo("map must be at most 3 entries"); + } + + @Test + void nativeAndCelProduceEqualViolationProto() throws ValidationException { + // Repeated unique violation in both modes. + ExampleRepeatedUnique msg = ExampleRepeatedUnique.newBuilder().addVal("a").addVal("a").build(); + Validator nativeV = nativeValidator(); + Validator celV = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) + .build(); + assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) + .isEqualTo(celV.validate(msg).getViolations().get(0).toProto()); + } +} diff --git a/src/test/java/build/buf/protovalidate/ResidualClearingTest.java b/src/test/java/build/buf/protovalidate/ResidualClearingTest.java new file mode 100644 index 00000000..524cbcd5 --- /dev/null +++ b/src/test/java/build/buf/protovalidate/ResidualClearingTest.java @@ -0,0 +1,87 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import static org.assertj.core.api.Assertions.assertThat; + +import build.buf.protovalidate.exceptions.ValidationException; +import com.example.noimports.validationtest.ExampleBoolConst; +import com.example.noimports.validationtest.ExampleBytesConst; +import com.example.noimports.validationtest.ExampleEnumConst; +import com.example.noimports.validationtest.ExampleInt32Const; +import com.example.noimports.validationtest.ExampleMapMinMax; +import com.example.noimports.validationtest.ExampleRepeatedMinMax; +import com.example.noimports.validationtest.ExampleStringConst; +import com.google.protobuf.Message; +import org.junit.jupiter.api.Test; + +/** + * Residual-clearing contract tests. When a native evaluator handles a rule, the dispatcher must + * clear that rule on the residual {@code FieldRules} so {@code RuleCache} doesn't compile a CEL + * program that fires a duplicate violation. Each test below uses a fixture that fails exactly one + * native rule and asserts that the validator produces exactly one violation, not two. + */ +class ResidualClearingTest { + + private final Validator nativeValidator = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setEnableNativeRules(true).build()) + .build(); + + @Test + void boolConstFiresOnce() throws ValidationException { + assertExactlyOneViolation(ExampleBoolConst.newBuilder().setFlag(false).build()); + } + + @Test + void int32ConstFiresOnce() throws ValidationException { + assertExactlyOneViolation(ExampleInt32Const.newBuilder().setVal(0).build()); + } + + @Test + void enumConstFiresOnce() throws ValidationException { + assertExactlyOneViolation(ExampleEnumConst.newBuilder().build()); + } + + @Test + void bytesConstFiresOnce() throws ValidationException { + assertExactlyOneViolation(ExampleBytesConst.newBuilder().build()); + } + + @Test + void stringConstFiresOnce() throws ValidationException { + assertExactlyOneViolation(ExampleStringConst.newBuilder().setVal("nope").build()); + } + + @Test + void repeatedMinItemsFiresOnce() throws ValidationException { + assertExactlyOneViolation(ExampleRepeatedMinMax.newBuilder().build()); + } + + @Test + void mapMinPairsFiresOnce() throws ValidationException { + assertExactlyOneViolation(ExampleMapMinMax.newBuilder().build()); + } + + private void assertExactlyOneViolation(Message msg) throws ValidationException { + ValidationResult result = nativeValidator.validate(msg); + assertThat(result.getViolations()) + .as( + "native dispatcher must clear the rule from the residual; expected exactly one " + + "violation but got: %s", + result.getViolations()) + .hasSize(1); + } +} diff --git a/src/test/java/build/buf/protovalidate/StringRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/StringRulesEvaluatorTest.java new file mode 100644 index 00000000..1f2b5392 --- /dev/null +++ b/src/test/java/build/buf/protovalidate/StringRulesEvaluatorTest.java @@ -0,0 +1,224 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import static org.assertj.core.api.Assertions.assertThat; + +import build.buf.protovalidate.exceptions.ValidationException; +import build.buf.validate.StringRules; +import com.example.noimports.validationtest.ExampleStringConst; +import com.example.noimports.validationtest.ExampleStringEmail; +import com.example.noimports.validationtest.ExampleStringHostAndPort; +import com.example.noimports.validationtest.ExampleStringMinMaxLen; +import com.example.noimports.validationtest.HttpHeaderName; +import com.google.protobuf.Descriptors.FieldDescriptor; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** Validator-level tests for {@link StringRulesEvaluator}. */ +class StringRulesEvaluatorTest { + + private static Validator nativeValidator() { + Config config = Config.newBuilder().setEnableNativeRules(true).build(); + return ValidatorFactory.newBuilder().withConfig(config).build(); + } + + @Test + void stringConstFailsAndCarriesExpectedShape() throws ValidationException { + ExampleStringConst msg = ExampleStringConst.newBuilder().setVal("nope").build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + Violation v = result.getViolations().get(0); + + build.buf.validate.Violation proto = v.toProto(); + assertThat(proto.getRuleId()).isEqualTo("string.const"); + assertThat(proto.getMessage()).isEqualTo("must equal `abcd`"); + + Violation.FieldValue ruleValue = v.getRuleValue(); + assertThat(ruleValue).isNotNull(); + assertThat(ruleValue.getValue()).isEqualTo("abcd"); + FieldDescriptor expectedDesc = + StringRules.getDescriptor().findFieldByNumber(StringRules.CONST_FIELD_NUMBER); + assertThat(ruleValue.getDescriptor()).isEqualTo(expectedDesc); + } + + @Test + void emailWellKnownAcceptsValidAndRejectsInvalid() throws ValidationException { + Validator v = nativeValidator(); + assertThat( + v.validate(ExampleStringEmail.newBuilder().setVal("alice@example.com").build()) + .isSuccess()) + .isTrue(); + assertThat( + v.validate(ExampleStringEmail.newBuilder().setVal("not-an-email").build()).isSuccess()) + .isFalse(); + } + + @Test + void emailWellKnownReportsEmptyVariant() throws ValidationException { + // Empty string fires the *_empty variant rule id. + ExampleStringEmail msg = ExampleStringEmail.newBuilder().setVal("").build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation proto = result.getViolations().get(0).toProto(); + assertThat(proto.getRuleId()).isEqualTo("string.email_empty"); + assertThat(proto.getMessage()).contains("value is empty"); + } + + @Test + void minMaxLenAppliesCharacterCounts() throws ValidationException { + Validator v = nativeValidator(); + // min_len=2, max_len=5 + assertThat(v.validate(ExampleStringMinMaxLen.newBuilder().setVal("ab").build()).isSuccess()) + .isTrue(); + assertThat(v.validate(ExampleStringMinMaxLen.newBuilder().setVal("abcde").build()).isSuccess()) + .isTrue(); + // 1 character — too short. + ValidationResult tooShort = v.validate(ExampleStringMinMaxLen.newBuilder().setVal("a").build()); + assertThat(tooShort.getViolations()).hasSize(1); + assertThat(tooShort.getViolations().get(0).toProto().getRuleId()).isEqualTo("string.min_len"); + // 6 characters — too long. + ValidationResult tooLong = + v.validate(ExampleStringMinMaxLen.newBuilder().setVal("abcdef").build()); + assertThat(tooLong.getViolations()).hasSize(1); + assertThat(tooLong.getViolations().get(0).toProto().getRuleId()).isEqualTo("string.max_len"); + } + + @Test + void minLenCountsCodePointsNotJavaChars() throws ValidationException { + // Each emoji is a single code point but two Java chars (surrogate pair). min_len=2 should + // count code points, not chars. + Validator v = nativeValidator(); + assertThat(v.validate(ExampleStringMinMaxLen.newBuilder().setVal("😀😀").build()).isSuccess()) + .isTrue(); + // One emoji = 1 code point, fails min_len=2. + assertThat(v.validate(ExampleStringMinMaxLen.newBuilder().setVal("😀").build()).isSuccess()) + .isFalse(); + } + + @Test + void hostAndPortAcceptsValidAndRejectsInvalid() throws ValidationException { + Validator v = nativeValidator(); + assertThat( + v.validate(ExampleStringHostAndPort.newBuilder().setVal("example.com:8080").build()) + .isSuccess()) + .isTrue(); + assertThat( + v.validate(ExampleStringHostAndPort.newBuilder().setVal("not-a-host-and-port").build()) + .isSuccess()) + .isFalse(); + } + + @Test + void nativeAndCelProduceEqualViolationProto() throws ValidationException { + ExampleStringConst msg = ExampleStringConst.newBuilder().setVal("nope").build(); + Validator nativeV = nativeValidator(); + Validator celV = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) + .build(); + assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) + .isEqualTo(celV.validate(msg).getViolations().get(0).toProto()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "Content-Type", + "Content-Length", + "Accept", + "User-Agent", + "X-Forwarded-For", + "WWW-Authenticate", + "If-None-Match", + "Cache-Control", + "Set-Cookie", + "ETag", + ":method", + ":path", + ":status", + ":authority", + ":scheme", + "!", + "#", + "$", + "%", + "&", + "'", + "*", + "+", + "-", + ".", + "^", + "_", + "`", + "|", + "~", + "a", + "0", + ":a", + "A1!#$%&'*+-.^_|~`", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "MiXeDcAsE" + }) + void headerNameRegexValidationGood(String headerName) throws ValidationException { + HttpHeaderName msg = HttpHeaderName.newBuilder().setVal(headerName).build(); + Validator v = nativeValidator(); + assertThat(v.validate(msg).isSuccess()).isTrue(); + } + + @ParameterizedTest + @ValueSource( + strings = { + "", + ":", + " ", + "Content-Type ", + " Content-Type", + "Content Type", + "\tContent-Type", + "Content:Type", + "Content/Type", + "Content\\Type", + "Content,Type", + "Content;Type", + "Content=Type", + "Content(Type)", + "Content[Type]", + "Content{Type}", + "Content", + "Content\"Type", + "Content?Type", + "Content@Type", + "::method", + "method:", + ":method:extra", + "Conténg-Type", + "内容类型", + "Header™", + "naïve", + "Content\000Type", + "Content\177Type", + "Content\nType", + "Content\rType", + "Valid-Name\nAnother-Name", + }) + void headerNameRegexValidationBad(String headerName) throws ValidationException { + HttpHeaderName msg = HttpHeaderName.newBuilder().setVal(headerName).build(); + Validator v = nativeValidator(); + assertThat(v.validate(msg).isSuccess()).isFalse(); + } +} diff --git a/src/test/java/build/buf/protovalidate/ValidationResultTest.java b/src/test/java/build/buf/protovalidate/ValidationResultTest.java index 24490929..8ac01491 100644 --- a/src/test/java/build/buf/protovalidate/ValidationResultTest.java +++ b/src/test/java/build/buf/protovalidate/ValidationResultTest.java @@ -18,6 +18,8 @@ import build.buf.validate.FieldPathElement; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import org.junit.jupiter.api.Test; @@ -43,9 +45,7 @@ void testToStringSingleViolation() { .setMessage("must equal 42") .addFirstFieldPathElement(elem) .build(); - List violations = new ArrayList<>(); - violations.add(violation); - ValidationResult result = new ValidationResult(violations); + ValidationResult result = new ValidationResult(Collections.singletonList(violation)); assertThat(result.toString()) .isEqualTo("Validation error:\n - test_field_name: must equal 42 [int32.const]"); @@ -69,10 +69,7 @@ void testToStringMultipleViolations() { .setMessage("value is required") .addFirstFieldPathElement(elem) .build(); - List violations = new ArrayList<>(); - violations.add(violation1); - violations.add(violation2); - ValidationResult result = new ValidationResult(violations); + ValidationResult result = new ValidationResult(Arrays.asList(violation1, violation2)); assertThat(result.toString()) .isEqualTo( @@ -86,20 +83,14 @@ void testToStringSingleViolationMultipleFieldPathElements() { FieldPathElement elem2 = FieldPathElement.newBuilder().setFieldNumber(5).setFieldName("nested_name").build(); - List elems = new ArrayList<>(); - elems.add(elem1); - elems.add(elem2); - RuleViolation violation1 = RuleViolation.newBuilder() .setRuleId("int32.const") .setMessage("must equal 42") - .addAllFieldPathElements(elems) + .addAllFieldPathElements(Arrays.asList(elem1, elem2)) .build(); - List violations = new ArrayList<>(); - violations.add(violation1); - ValidationResult result = new ValidationResult(violations); + ValidationResult result = new ValidationResult(Collections.singletonList(violation1)); assertThat(result.toString()) .isEqualTo( @@ -110,9 +101,7 @@ void testToStringSingleViolationMultipleFieldPathElements() { void testToStringSingleViolationNoFieldPathElements() { RuleViolation violation = RuleViolation.newBuilder().setRuleId("int32.const").setMessage("must equal 42").build(); - List violations = new ArrayList<>(); - violations.add(violation); - ValidationResult result = new ValidationResult(violations); + ValidationResult result = new ValidationResult(Collections.singletonList(violation)); assertThat(result.toString()).isEqualTo("Validation error:\n - must equal 42 [int32.const]"); } diff --git a/src/test/java/build/buf/protovalidate/WellKnownRegexTest.java b/src/test/java/build/buf/protovalidate/WellKnownRegexTest.java new file mode 100644 index 00000000..e4aafdca --- /dev/null +++ b/src/test/java/build/buf/protovalidate/WellKnownRegexTest.java @@ -0,0 +1,95 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import static org.assertj.core.api.Assertions.assertThat; + +import build.buf.protovalidate.exceptions.ValidationException; +import com.example.noimports.validationtest.HttpHeaderName; +import com.example.noimports.validationtest.HttpHeaderNameLoose; +import com.example.noimports.validationtest.HttpHeaderValue; +import org.junit.jupiter.api.Test; + +/** + * Tests for the {@code well_known_regex} oneof case in {@link StringRulesEvaluator}: HTTP header + * name and value, in both strict and loose modes, plus the empty-header-name special case. + */ +class WellKnownRegexTest { + + private final Validator nativeValidator = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setEnableNativeRules(true).build()) + .build(); + + @Test + void headerName_strict_passesValidName() throws ValidationException { + HttpHeaderName msg = HttpHeaderName.newBuilder().setVal("X-Request-Id").build(); + assertThat(nativeValidator.validate(msg).isSuccess()).isTrue(); + } + + @Test + void headerName_strict_failsInvalidName() throws ValidationException { + HttpHeaderName msg = HttpHeaderName.newBuilder().setVal("not a header").build(); + ValidationResult result = nativeValidator.validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation v = result.getViolations().get(0).toProto(); + assertThat(v.getRuleId()).isEqualTo("string.well_known_regex.header_name"); + assertThat(v.getMessage()).isEqualTo("must be a valid HTTP header name"); + } + + @Test + void headerName_emptyValue_firesEmptyVariant() throws ValidationException { + // Empty header name is a separate rule id with its own message. + HttpHeaderName msg = HttpHeaderName.newBuilder().setVal("").build(); + ValidationResult result = nativeValidator.validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation v = result.getViolations().get(0).toProto(); + assertThat(v.getRuleId()).isEqualTo("string.well_known_regex.header_name_empty"); + assertThat(v.getMessage()).isEqualTo("value is empty, which is not a valid HTTP header name"); + } + + @Test + void headerName_loose_acceptsValueStrictWouldReject() throws ValidationException { + // Strict regex would reject spaces; loose just forbids null/CR/LF. + HttpHeaderNameLoose msg = + HttpHeaderNameLoose.newBuilder().setVal("any header with spaces").build(); + assertThat(nativeValidator.validate(msg).isSuccess()).isTrue(); + } + + @Test + void headerValue_strict_passesValidValue() throws ValidationException { + HttpHeaderValue msg = HttpHeaderValue.newBuilder().setVal("text/plain").build(); + assertThat(nativeValidator.validate(msg).isSuccess()).isTrue(); + } + + @Test + void headerValue_strict_failsControlChar() throws ValidationException { + // 0x01 is in the forbidden range for strict header values. + HttpHeaderValue msg = HttpHeaderValue.newBuilder().setVal("").build(); + ValidationResult result = nativeValidator.validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation v = result.getViolations().get(0).toProto(); + assertThat(v.getRuleId()).isEqualTo("string.well_known_regex.header_value"); + assertThat(v.getMessage()).isEqualTo("must be a valid HTTP header value"); + } + + @Test + void headerValue_emptyValueIsValid() throws ValidationException { + // Header value pattern is '*' (zero-or-more), so empty is allowed under strict mode and + // there is no header_value_empty variant. + HttpHeaderValue msg = HttpHeaderValue.newBuilder().setVal("").build(); + assertThat(nativeValidator.validate(msg).isSuccess()).isTrue(); + } +} diff --git a/src/test/java/build/buf/protovalidate/WrappedValueEvaluatorTest.java b/src/test/java/build/buf/protovalidate/WrappedValueEvaluatorTest.java new file mode 100644 index 00000000..22a4f182 --- /dev/null +++ b/src/test/java/build/buf/protovalidate/WrappedValueEvaluatorTest.java @@ -0,0 +1,81 @@ +// Copyright 2023-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. + +package build.buf.protovalidate; + +import static org.assertj.core.api.Assertions.assertThat; + +import build.buf.protovalidate.exceptions.ValidationException; +import com.example.noimports.validationtest.Int64WrapperConst; +import com.example.noimports.validationtest.StringWrapperLen; +import com.google.protobuf.Int64Value; +import com.google.protobuf.StringValue; +import org.junit.jupiter.api.Test; + +/** + * Targeted tests for {@link WrappedValueEvaluator}. Mirrors the wrapper-unwrap path that the native + * dispatcher takes for {@code google.protobuf.*Value} fields. The conformance suite covers one + * wrapper kind (DoubleValue) via parity tests; these cover the unwrap invariant directly. + */ +class WrappedValueEvaluatorTest { + + private final Validator nativeValidator = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setEnableNativeRules(true).build()) + .build(); + + @Test + void absentWrapperFieldProducesNoViolation() throws ValidationException { + // Field unset (proto3 message-typed field absent): there is no value to validate, so the + // wrapped scalar evaluator should not be invoked. WrappedValueEvaluator's contract returns + // NO_VIOLATIONS in that case. + Int64WrapperConst msg = Int64WrapperConst.newBuilder().build(); + ValidationResult result = nativeValidator.validate(msg); + assertThat(result.isSuccess()).isTrue(); + } + + @Test + void presentWrapperWithDefaultInnerValueFiresRule() throws ValidationException { + // Wrapper present with default inner (0). Rule is const=5 — 0 != 5 so the rule fires. + // This proves the unwrap reaches the inner field rather than seeing the wrapper Message. + Int64WrapperConst msg = + Int64WrapperConst.newBuilder().setVal(Int64Value.newBuilder().build()).build(); + ValidationResult result = nativeValidator.validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation proto = result.getViolations().get(0).toProto(); + assertThat(proto.getRuleId()).isEqualTo("int64.const"); + assertThat(proto.getField().getElements(0).getFieldName()).isEqualTo("val"); + } + + @Test + void presentWrapperWithViolatingValueProducesExpectedShape() throws ValidationException { + StringWrapperLen msg = + StringWrapperLen.newBuilder() + .setVal(StringValue.newBuilder().setValue("ab").build()) + .build(); + ValidationResult result = nativeValidator.validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation proto = result.getViolations().get(0).toProto(); + assertThat(proto.getRuleId()).isEqualTo("string.min_len"); + // Field path points at the wrapper-typed field, not the synthetic inner "value". + assertThat(proto.getField().getElements(0).getFieldName()).isEqualTo("val"); + } + + @Test + void presentWrapperWithPassingValueProducesNoViolation() throws ValidationException { + Int64WrapperConst msg = + Int64WrapperConst.newBuilder().setVal(Int64Value.newBuilder().setValue(5).build()).build(); + assertThat(nativeValidator.validate(msg).isSuccess()).isTrue(); + } +} diff --git a/src/test/resources/proto/validationtest/validationtest.proto b/src/test/resources/proto/validationtest/validationtest.proto index cd6b30f7..a49c8fc6 100644 --- a/src/test/resources/proto/validationtest/validationtest.proto +++ b/src/test/resources/proto/validationtest/validationtest.proto @@ -17,6 +17,7 @@ syntax = "proto3"; package validationtest; import "buf/validate/validate.proto"; +import "google/protobuf/wrappers.proto"; import "validationtest/import_test.proto"; message ExampleFieldRules { @@ -134,3 +135,247 @@ message ExampleImportMessageInMapFieldRule { } ]; } + +message ExampleBoolConst { + bool flag = 1 [(buf.validate.field).bool.const = true]; +} + +message ExampleInt32Const { + int32 val = 1 [(buf.validate.field).int32.const = 5]; +} + +message ExampleInt32GtLt { + int32 val = 1 [(buf.validate.field).int32 = { + gt: 0 + lt: 10 + }]; +} + +message ExampleUint32Gt { + // Threshold above Integer.MAX_VALUE — verifies unsigned comparison: a uint32 value of + // 3000000000 must satisfy gt=2147483648, even though both render as negative when + // interpreted as signed int. + uint32 val = 1 [(buf.validate.field).uint32.gt = 2147483648]; +} + +message ExampleFloatFinite { + float val = 1 [(buf.validate.field).float.finite = true]; +} + +message ExampleDoubleIn { + double val = 1 [(buf.validate.field).double = { + in: [ + 1.5, + 2.5 + ] + }]; +} + +enum ExampleColor { + EXAMPLE_COLOR_UNSPECIFIED = 0; + EXAMPLE_COLOR_RED = 1; + EXAMPLE_COLOR_GREEN = 2; + EXAMPLE_COLOR_BLUE = 3; +} + +message ExampleEnumConst { + ExampleColor val = 1 [(buf.validate.field).enum.const = 2]; +} + +message ExampleEnumIn { + ExampleColor val = 1 [(buf.validate.field).enum = { + in: [ + 1, + 3 + ] + }]; +} + +message ExampleBytesConst { + bytes val = 1 [(buf.validate.field).bytes.const = "\x00\x99"]; +} + +message ExampleBytesPattern { + bytes val = 1 [(buf.validate.field).bytes.pattern = "^[a-z]+$"]; +} + +message ExampleBytesIPv4 { + bytes val = 1 [(buf.validate.field).bytes.ipv4 = true]; +} + +message ExampleStringConst { + string val = 1 [(buf.validate.field).string.const = "abcd"]; +} + +message ExampleStringEmail { + string val = 1 [(buf.validate.field).string.email = true]; +} + +message ExampleStringMinMaxLen { + string val = 1 [(buf.validate.field).string = { + min_len: 2 + max_len: 5 + }]; +} + +message ExampleStringHostAndPort { + string val = 1 [(buf.validate.field).string.host_and_port = true]; +} + +message ExampleRepeatedMinMax { + repeated int32 val = 1 [(buf.validate.field).repeated = { + min_items: 2 + max_items: 5 + }]; +} + +message ExampleRepeatedUnique { + repeated string val = 1 [(buf.validate.field).repeated.unique = true]; +} + +message ExampleMapMinMax { + map val = 1 [(buf.validate.field).map = { + min_pairs: 1 + max_pairs: 3 + }]; +} + +// --- Float/double parity fixtures --- +// +// See NATIVE_RULES_REVIEW.md sections B1 (fixed) and B2 (reclassified). The tests in +// FloatBugConfirmationTest now lock in parity rather than confirm divergence. + +// Originally documented the floatFormatter sign-strip bug; now used by the regression test +// asserting native and CEL both render -0.0 as "must equal -0". +message ExampleFloatConstNegZero { + float val = 1 [(buf.validate.field).float.const = -0.0]; +} + +message ExampleDoubleConstNegZero { + double val = 1 [(buf.validate.field).double.const = -0.0]; +} + +// Originally claimed a parity divergence on repeated.unique for floats/doubles. Investigation +// showed CustomOverload.uniqueList (the CEL-side impl in protovalidate-java) and +// RepeatedRulesEvaluator.isUnique both use HashSet + Object.equals, so they agree — both +// deviate from CEL's IEEE-754 spec for NaN and +/- 0 in the same direction. The agreement +// tests pin that contract until a coordinated fix lands. +message ExampleFloatRepeatedUnique { + repeated float val = 1 [(buf.validate.field).repeated.unique = true]; +} + +message ExampleDoubleRepeatedUnique { + repeated double val = 1 [(buf.validate.field).repeated.unique = true]; +} + +message FloatDoubleNaNNegZero { + repeated float fvals = 1 [(buf.validate.field).repeated.unique = true]; + repeated double dvals = 2 [(buf.validate.field).repeated.unique = true]; + float fneg_zero = 3 [(buf.validate.field).float.const = -0.0]; + double dneg_zero = 4 [(buf.validate.field).double.const = -0.0]; +} + +// Multi-rule fixtures used to exercise failFast and residual-clearing semantics. +// +// Each field has two scalar rules (or a count + element rule) so a value that violates the +// first rule will also violate the second. With failFast=true the validator should report only +// one violation; with failFast=false (the default), both should be reported. + +// String with min_len=4 and a "must contain digit" pattern. +message StringMultiRule { + string val = 1 [(buf.validate.field).string = { + min_len: 4 + pattern: ".*[0-9].*" + }]; +} + +// Int with gt=10 and in=[5,15]. Default 0 violates both. +message Int32MultiRule { + int32 val = 1 [(buf.validate.field).int32 = { + gt: 10 + in: [ + 5, + 15 + ] + }]; +} + +// Bytes with min_len=4 and ipv4=true. Empty value violates both. +message BytesMultiRule { + bytes val = 1 [(buf.validate.field).bytes = { + min_len: 4 + ipv4: true + }]; +} + +// not_in fixtures, one per kind, used by NotInRulesTest. +message Int32NotIn { + int32 val = 1 [(buf.validate.field).int32 = { + not_in: [ + 1, + 2, + 3 + ] + }]; +} + +message Uint32NotIn { + uint32 val = 1 [(buf.validate.field).uint32 = { + not_in: [ + 1, + 2 + ] + }]; +} + +message StringNotIn { + string val = 1 [(buf.validate.field).string = { + not_in: [ + "foo", + "bar" + ] + }]; +} + +message BytesNotIn { + bytes val = 1 [(buf.validate.field).bytes = { + not_in: [ + "AA", + "BB" + ] + }]; +} + +message EnumNotIn { + ExampleColor val = 1 [(buf.validate.field).enum = { + not_in: [ + 1, + 2 + ] + }]; +} + +// well_known_regex fixtures. +message HttpHeaderName { + string val = 1 [(buf.validate.field).string.well_known_regex = KNOWN_REGEX_HTTP_HEADER_NAME]; +} + +message HttpHeaderNameLoose { + string val = 1 [(buf.validate.field).string = { + well_known_regex: KNOWN_REGEX_HTTP_HEADER_NAME + strict: false + }]; +} + +message HttpHeaderValue { + string val = 1 [(buf.validate.field).string.well_known_regex = KNOWN_REGEX_HTTP_HEADER_VALUE]; +} + +// google.protobuf.*Value wrapper fixtures for WrappedValueEvaluator coverage. +message Int64WrapperConst { + google.protobuf.Int64Value val = 1 [(buf.validate.field).int64.const = 5]; +} + +message StringWrapperLen { + google.protobuf.StringValue val = 1 [(buf.validate.field).string.min_len = 3]; +}