├── conformance ├── expected-failures.yaml ├── src │ └── main │ │ └── java │ │ └── build │ │ ├── .DS_Store │ │ └── buf │ │ └── protovalidate │ │ └── conformance │ │ ├── FileDescriptorUtil.java │ │ └── Main.java ├── buf.gen.yaml └── build.gradle.kts ├── settings.gradle.kts ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── .editorconfig ├── buf.gen.yaml ├── buf.yaml ├── src ├── test │ ├── resources │ │ ├── proto │ │ │ ├── buf.gen.cel.yaml │ │ │ ├── buf.gen.cel.testtypes.yaml │ │ │ ├── buf.gen.imports.yaml │ │ │ ├── buf.gen.noimports.yaml │ │ │ └── validationtest │ │ │ │ ├── import_test.proto │ │ │ │ ├── required.proto │ │ │ │ ├── predefined.proto │ │ │ │ ├── custom_rules.proto │ │ │ │ └── validationtest.proto │ │ └── testdata │ │ │ └── string_ext_supplemental.textproto │ └── java │ │ └── build │ │ └── buf │ │ └── protovalidate │ │ ├── ValidationResultTest.java │ │ ├── ValidatorImportTest.java │ │ ├── ValidatorCelExpressionTest.java │ │ └── FormatTest.java └── main │ └── java │ └── build │ └── buf │ └── protovalidate │ ├── exceptions │ ├── ExecutionException.java │ ├── ValidationException.java │ └── CompilationException.java │ ├── EmbeddedMessageEvaluator.java │ ├── UnknownDescriptorEvaluator.java │ ├── Evaluator.java │ ├── NowVariable.java │ ├── RuleViolationHelper.java │ ├── MessageValue.java │ ├── Validator.java │ ├── MessageEvaluator.java │ ├── Violation.java │ ├── Expression.java │ ├── ValidateLibrary.java │ ├── Value.java │ ├── CelPrograms.java │ ├── OneofEvaluator.java │ ├── ListElementValue.java │ ├── MessageOneofEvaluator.java │ ├── Variable.java │ ├── AstExpression.java │ ├── ListEvaluator.java │ ├── ValidatorImpl.java │ ├── ValidationResult.java │ ├── ObjectValue.java │ ├── ValueEvaluator.java │ ├── EnumEvaluator.java │ ├── ValidatorFactory.java │ ├── ProtoAdapter.java │ ├── FieldPathUtils.java │ ├── FieldEvaluator.java │ ├── CompiledProgram.java │ ├── AnyEvaluator.java │ ├── Ipv4.java │ ├── RuleResolver.java │ ├── Config.java │ ├── MapEvaluator.java │ └── CustomDeclarations.java ├── .github ├── dependabot.yml ├── workflows │ ├── notify-approval-bypass.yaml │ ├── emergency-review-bypass.yaml │ ├── add-to-project.yaml │ ├── pr-hygiene.yaml │ ├── conformance.yaml │ ├── ci.yaml │ └── release.yaml ├── buf-logo.svg ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── CODE_OF_CONDUCT.md ├── .gitignore ├── gradle.properties ├── Makefile ├── gradlew.bat └── README.md /conformance/expected-failures.yaml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "protovalidate" 2 | include("conformance") 3 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bufbuild/protovalidate-java/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /conformance/src/main/java/build/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bufbuild/protovalidate-java/HEAD/conformance/src/main/java/build/.DS_Store -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | 4 | [*.java] 5 | ij_java_class_count_to_use_import_on_demand = 999 6 | ij_java_use_single_class_imports = true 7 | -------------------------------------------------------------------------------- /buf.gen.yaml: -------------------------------------------------------------------------------- 1 | version: v2 2 | clean: true 3 | plugins: 4 | - remote: buf.build/protocolbuffers/java:$protocJavaPluginVersion 5 | out: build/generated/sources/bufgen 6 | -------------------------------------------------------------------------------- /buf.yaml: -------------------------------------------------------------------------------- 1 | version: v2 2 | modules: 3 | - path: src/main/resources 4 | - path: src/test/resources/proto 5 | lint: 6 | use: 7 | - DEFAULT 8 | breaking: 9 | use: 10 | - FILE 11 | -------------------------------------------------------------------------------- /src/test/resources/proto/buf.gen.cel.yaml: -------------------------------------------------------------------------------- 1 | version: v2 2 | managed: 3 | enabled: true 4 | plugins: 5 | - remote: buf.build/protocolbuffers/java:$protocJavaPluginVersion 6 | out: build/generated/test-sources/bufgen 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gradle" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | 12 | -------------------------------------------------------------------------------- /conformance/buf.gen.yaml: -------------------------------------------------------------------------------- 1 | version: v2 2 | clean: true 3 | managed: 4 | enabled: true 5 | override: 6 | - file_option: java_package_prefix 7 | value: build 8 | plugins: 9 | - remote: buf.build/protocolbuffers/java:$protocJavaPluginVersion 10 | out: build/generated/sources/bufgen 11 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /**/build/* 2 | !/**/proto/build/* 3 | !/**/src/**/build/* 4 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 5 | !gradle-wrapper.jar 6 | /.gradle 7 | *.iml 8 | /.idea 9 | /.tmp 10 | /bin/ 11 | # Cache of project 12 | /.gradletasknamecache 13 | # Ignore Gradle GUI config 14 | /gradle-app.setting -------------------------------------------------------------------------------- /src/test/resources/proto/buf.gen.cel.testtypes.yaml: -------------------------------------------------------------------------------- 1 | version: v2 2 | managed: 3 | enabled: true 4 | override: 5 | - file_option: java_package 6 | value: cel.expr.conformance.proto3 7 | plugins: 8 | - remote: buf.build/protocolbuffers/java:$protocJavaPluginVersion 9 | out: build/generated/test-sources/bufgen 10 | -------------------------------------------------------------------------------- /src/test/resources/proto/buf.gen.imports.yaml: -------------------------------------------------------------------------------- 1 | version: v2 2 | managed: 3 | enabled: true 4 | override: 5 | - file_option: java_package_prefix 6 | value: com.example.imports 7 | plugins: 8 | - remote: buf.build/protocolbuffers/java:$protocJavaPluginVersion 9 | out: build/generated/test-sources/bufgen 10 | inputs: 11 | - directory: src/test/resources/proto 12 | -------------------------------------------------------------------------------- /.github/workflows/notify-approval-bypass.yaml: -------------------------------------------------------------------------------- 1 | name: Approval bypass notifier 2 | on: 3 | pull_request: 4 | types: 5 | - closed 6 | branches: 7 | - main 8 | permissions: 9 | pull-requests: read 10 | jobs: 11 | notify: 12 | name: Notify 13 | uses: bufbuild/base-workflows/.github/workflows/notify-approval-bypass.yaml@main 14 | secrets: inherit 15 | -------------------------------------------------------------------------------- /.github/workflows/emergency-review-bypass.yaml: -------------------------------------------------------------------------------- 1 | name: Emergency review bypass 2 | on: 3 | pull_request: 4 | types: 5 | - labeled 6 | permissions: 7 | pull-requests: write 8 | jobs: 9 | approve: 10 | name: Approve 11 | if: github.event.label.name == 'Emergency Bypass Review' 12 | uses: bufbuild/base-workflows/.github/workflows/emergency-review-bypass.yaml@main 13 | secrets: inherit 14 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Version of buf.build/bufbuild/protovalidate to use. 2 | protovalidate.version = 895eefca6d1346f742fc18b9983d40478820906d 3 | 4 | # Arguments to the protovalidate-conformance CLI 5 | protovalidate.conformance.args = --strict_message --strict_error --expected_failures=expected-failures.yaml 6 | 7 | # Argument to the license-header CLI 8 | license-header.years = 2023-2025 9 | 10 | # Version of the cel-spec that this implementation is conformant with 11 | cel.spec.version = v0.24.0 12 | -------------------------------------------------------------------------------- /src/test/resources/proto/buf.gen.noimports.yaml: -------------------------------------------------------------------------------- 1 | version: v2 2 | managed: 3 | enabled: true 4 | disable: 5 | - file_option: java_package 6 | module: buf.build/bufbuild/protovalidate 7 | override: 8 | - file_option: java_package_prefix 9 | value: com.example.noimports 10 | plugins: 11 | - remote: buf.build/protocolbuffers/java:$protocJavaPluginVersion 12 | out: build/generated/test-sources/bufgen 13 | inputs: 14 | - directory: src/main/resources 15 | - directory: src/test/resources/proto 16 | -------------------------------------------------------------------------------- /.github/workflows/add-to-project.yaml: -------------------------------------------------------------------------------- 1 | name: Add issues and PRs to project 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | - reopened 8 | - transferred 9 | pull_request_target: 10 | types: 11 | - opened 12 | - reopened 13 | issue_comment: 14 | types: 15 | - created 16 | 17 | jobs: 18 | call-workflow-add-to-project: 19 | name: Call workflow to add issue to project 20 | uses: bufbuild/base-workflows/.github/workflows/add-to-project.yaml@main 21 | secrets: inherit 22 | -------------------------------------------------------------------------------- /.github/buf-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/test/resources/proto/validationtest/import_test.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | syntax = "proto3"; 16 | 17 | package validationtest; 18 | 19 | import "buf/validate/validate.proto"; 20 | 21 | message ExampleImportedMessage { 22 | string hex_string = 1 [(buf.validate.field).string.pattern = "^[0-9a-fA-F]+$"]; 23 | } 24 | -------------------------------------------------------------------------------- /src/test/resources/testdata/string_ext_supplemental.textproto: -------------------------------------------------------------------------------- 1 | # proto-file: ../../../proto/cel/expr/conformance/test/simple.proto 2 | # proto-message: cel.expr.conformance.test.SimpleTestFile 3 | 4 | # Ideally these tests should be in the cel-spec conformance test suite. 5 | # Until they are added, we can use this to test for additional functionality 6 | # listed in the spec. 7 | 8 | name: "string_ext_supplemental" 9 | description: "Supplemental tests for the strings extension library." 10 | section: { 11 | name: "format" 12 | test: { 13 | name: "bytes support for string with invalid utf-8 encoding" 14 | expr: '"%s".format([b"\\xF0abc\\x8C\\xF0xyz"])' 15 | value: { 16 | string_value: '\ufffdabc\ufffdxyz', 17 | } 18 | } 19 | test: { 20 | name: "bytes support for string with only invalid utf-8 sequences" 21 | expr: '"%s".format([b"\\xF0\\x8C\\xF0"])' 22 | value: { 23 | string_value: '\ufffd', 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/test/resources/proto/validationtest/required.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | syntax = "proto2"; 16 | 17 | package validationtest; 18 | 19 | import "buf/validate/validate.proto"; 20 | 21 | message ExampleRequiredFieldRules { 22 | required string regex_string_field = 1 [(buf.validate.field).string.pattern = "^[a-z0-9]{1,9}$"]; 23 | optional string unconstrained = 2; 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/exceptions/ExecutionException.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate.exceptions; 16 | 17 | /** ExecutionException is returned when a rule fails to execute. This is a fatal error. */ 18 | public class ExecutionException extends ValidationException { 19 | /** 20 | * Creates an ExecutionException with the specified message. 21 | * 22 | * @param message Exception message. 23 | */ 24 | public ExecutionException(String message) { 25 | super(message); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/test/resources/proto/validationtest/predefined.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | syntax = "proto2"; 16 | 17 | package validationtest; 18 | 19 | import "buf/validate/validate.proto"; 20 | 21 | extend buf.validate.StringRules { 22 | optional bool is_ident = 1161 [(buf.validate.predefined).cel = { 23 | id: "string.is_ident" 24 | expression: "(rule && !this.matches('^[a-z0-9]{1,9}$')) ? 'invalid identifier' : ''" 25 | }]; 26 | } 27 | 28 | message ExamplePredefinedFieldRules { 29 | optional string ident_field = 1 [(buf.validate.field).string.(is_ident) = true]; 30 | } 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Feature Request]" 5 | labels: Feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Feature description:** 11 | 12 | 13 | **Problem it solves or use case:** 14 | 15 | 16 | **Proposed implementation or solution:** 17 | 18 | 19 | **Contribution:** 20 | 21 | 22 | **Examples or references:** 23 | 24 | 25 | **Additional context:** 26 | 27 | -------------------------------------------------------------------------------- /.github/workflows/pr-hygiene.yaml: -------------------------------------------------------------------------------- 1 | name: PR Hygiene 2 | # Prevent writing to the repository using the CI token. 3 | # Ref: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#permissions 4 | permissions: 5 | pull-requests: read 6 | on: 7 | workflow_call: 8 | pull_request: 9 | # By default, a workflow only runs when a pull_request's activity type is opened, 10 | # synchronize, or reopened. We explicity override here so that PR titles are 11 | # re-linted when the PR text content is edited. 12 | types: 13 | - opened 14 | - edited 15 | - reopened 16 | - synchronize 17 | jobs: 18 | lint-title: 19 | name: Lint title 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Lint title 23 | uses: morrisoncole/pr-lint-action@51f3cfabaf5d46f94e54524214e45685f0401b2a # v1.7.1 24 | with: 25 | repo-token: "${{ secrets.GITHUB_TOKEN }}" 26 | # https://regex101.com/r/I6oK5v/1 27 | title-regex: "^[A-Z].*[^.?!,\\-;:]$" 28 | on-failed-regex-fail-action: true 29 | on-failed-regex-create-review: false 30 | on-failed-regex-request-changes: false 31 | on-failed-regex-comment: "PR titles must start with a capital letter and not end with punctuation." 32 | on-succeeded-regex-dismiss-review-comment: "Thanks for helping keep our PR titles consistent!" 33 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/exceptions/ValidationException.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate.exceptions; 16 | 17 | /** ValidationException is the base exception for all validation errors. */ 18 | public class ValidationException extends Exception { 19 | /** 20 | * Creates a ValidationException with the specified message. 21 | * 22 | * @param message Exception message. 23 | */ 24 | public ValidationException(String message) { 25 | super(message); 26 | } 27 | 28 | /** 29 | * Creates a ValidationException with the specified message and cause. 30 | * 31 | * @param message Exception message. 32 | * @param cause Underlying cause of the exception. 33 | */ 34 | public ValidationException(String message, Throwable cause) { 35 | super(message, cause); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/exceptions/CompilationException.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate.exceptions; 16 | 17 | /** CompilationException is returned when a rule fails to compile. This is a fatal error. */ 18 | public class CompilationException extends ValidationException { 19 | /** 20 | * Creates a CompilationException with the specified message. 21 | * 22 | * @param message Exception message. 23 | */ 24 | public CompilationException(String message) { 25 | super(message); 26 | } 27 | 28 | /** 29 | * Creates a CompilationException with the specified message and cause. 30 | * 31 | * @param message Exception message. 32 | * @param cause Underlying cause of the exception. 33 | */ 34 | public CompilationException(String message, Throwable cause) { 35 | super(message, cause); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/test/resources/proto/validationtest/custom_rules.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | syntax = "proto3"; 16 | 17 | package validationtest; 18 | 19 | import "buf/validate/validate.proto"; 20 | 21 | message FieldExpressionRepeatedMessage { 22 | repeated Msg val = 1 [(buf.validate.field).cel = { 23 | id: "field_expression.repeated.message" 24 | message: "test message field_expression.repeated.message" 25 | expression: "this.all(e, e.a == 1)" 26 | }]; 27 | message Msg { 28 | int32 a = 1; 29 | } 30 | } 31 | 32 | message FieldExpressionRepeatedMessageItems { 33 | repeated Msg val = 1 [(buf.validate.field).repeated.items.cel = { 34 | id: "field_expression.repeated.message.items" 35 | message: "test message field_expression.repeated.message.items" 36 | expression: "this.a == 1" 37 | }]; 38 | message Msg { 39 | int32 a = 1; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | assertj = "3.27.6" 3 | buf = "1.61.0" 4 | cel = "0.11.1" 5 | error-prone = "2.45.0" 6 | junit = "5.14.1" 7 | maven-publish = "0.35.0" 8 | protobuf = "4.33.2" 9 | 10 | [libraries] 11 | assertj = { module = "org.assertj:assertj-core", version.ref = "assertj" } 12 | buf = { module = "build.buf:buf", version.ref = "buf" } 13 | cel = { module = "dev.cel:cel", version.ref = "cel" } 14 | errorprone-annotations = { module = "com.google.errorprone:error_prone_annotations", version.ref = "error-prone" } 15 | errorprone-core = { module = "com.google.errorprone:error_prone_core", version.ref = "error-prone" } 16 | grpc-protobuf = { module = "io.grpc:grpc-protobuf", version = "1.77.0" } 17 | jspecify = { module ="org.jspecify:jspecify", version = "1.0.0" } 18 | junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" } 19 | maven-plugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "maven-publish" } 20 | nullaway = { module = "com.uber.nullaway:nullaway", version = "0.12.14" } 21 | protobuf-java = { module = "com.google.protobuf:protobuf-java", version.ref = "protobuf" } 22 | spotless = { module = "com.diffplug.spotless:spotless-plugin-gradle", version = "8.1.0" } 23 | 24 | [plugins] 25 | errorprone = { id = "net.ltgt.errorprone", version = "4.3.0" } 26 | git = { id = "com.palantir.git-version", version = "4.2.0" } 27 | maven = { id = "com.vanniktech.maven.publish.base", version.ref = "maven-publish" } 28 | osdetector = { id = "com.google.osdetector", version = "1.7.3" } 29 | -------------------------------------------------------------------------------- /.github/workflows/conformance.yaml: -------------------------------------------------------------------------------- 1 | name: Conformance 2 | on: 3 | pull_request: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | branches: 8 | - 'main' 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | conformance: 15 | name: Conformance 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 20 | - name: Cache Go Modules 21 | uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 22 | with: 23 | path: | 24 | ~/.cache/go-build 25 | ~/go/pkg/mod 26 | key: ${{ runner.os }}-gomod-conformance-${{ hashFiles('gradle.properties', 'gradle/libs.versions.toml') }} 27 | restore-keys: 28 | ${{ runner.os }}-gomod-conformance- 29 | - uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0 30 | with: 31 | distribution: 'temurin' 32 | java-version: '21' 33 | cache: 'gradle' 34 | - uses: bufbuild/buf-action@8f4a1456a0ab6a1eb80ba68e53832e6fcfacc16c # v1.3.0 35 | with: 36 | setup_only: true 37 | - env: 38 | BUF_TOKEN: ${{ secrets.BUF_TOKEN }} 39 | run: echo ${BUF_TOKEN} | buf registry login buf.build --token-stdin 40 | - name: Validate Gradle Wrapper 41 | uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0 42 | - name: Test conformance 43 | run: make conformance 44 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/EmbeddedMessageEvaluator.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import build.buf.protovalidate.exceptions.ExecutionException; 18 | import java.util.Collections; 19 | import java.util.List; 20 | 21 | final class EmbeddedMessageEvaluator implements Evaluator { 22 | private final RuleViolationHelper helper; 23 | private final MessageEvaluator messageEvaluator; 24 | 25 | EmbeddedMessageEvaluator(ValueEvaluator valueEvaluator, MessageEvaluator messageEvaluator) { 26 | this.helper = new RuleViolationHelper(valueEvaluator); 27 | this.messageEvaluator = messageEvaluator; 28 | } 29 | 30 | @Override 31 | public boolean tautology() { 32 | return messageEvaluator.tautology(); 33 | } 34 | 35 | @Override 36 | public List evaluate(Value val, boolean failFast) 37 | throws ExecutionException { 38 | return FieldPathUtils.updatePaths( 39 | messageEvaluator.evaluate(val, failFast), 40 | helper.getFieldPathElement(), 41 | Collections.emptyList()); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [main] 5 | tags: ['v*'] 6 | pull_request: 7 | branches: [main] 8 | schedule: 9 | - cron: '15 22 * * *' 10 | workflow_dispatch: {} # support manual runs 11 | permissions: 12 | contents: read 13 | jobs: 14 | test: 15 | name: Unit tests 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 20 | - name: Cache Go Modules 21 | uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 22 | with: 23 | path: | 24 | ~/.cache/go-build 25 | ~/go/pkg/mod 26 | key: ${{ runner.os }}-gomod-ci-${{ hashFiles('gradle.properties', 'gradle/libs.versions.toml') }} 27 | restore-keys: 28 | ${{ runner.os }}-gomod-ci- 29 | - uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0 30 | with: 31 | distribution: 'temurin' 32 | java-version: '21' 33 | cache: 'gradle' 34 | - uses: bufbuild/buf-action@8f4a1456a0ab6a1eb80ba68e53832e6fcfacc16c # v1.3.0 35 | with: 36 | setup_only: true 37 | - env: 38 | BUF_TOKEN: ${{ secrets.BUF_TOKEN }} 39 | run: echo ${BUF_TOKEN} | buf registry login buf.build --token-stdin 40 | - name: Validate Gradle Wrapper 41 | uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0 42 | - name: Lint 43 | run: make lint 44 | - name: Generate 45 | run: make checkgenerate 46 | - name: Build 47 | run: make build 48 | - name: Docs 49 | run: make docs 50 | - name: Execute tests 51 | run: make test 52 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/UnknownDescriptorEvaluator.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import build.buf.protovalidate.exceptions.ExecutionException; 18 | import com.google.protobuf.Descriptors.Descriptor; 19 | import java.util.Collections; 20 | import java.util.List; 21 | 22 | /** 23 | * An {@link Evaluator} for an unknown descriptor. This is returned only if lazy-building of 24 | * evaluators has been disabled and an unknown descriptor is encountered. 25 | */ 26 | final class UnknownDescriptorEvaluator implements Evaluator { 27 | /** The descriptor targeted by this evaluator. */ 28 | private final Descriptor desc; 29 | 30 | /** Constructs a new {@link UnknownDescriptorEvaluator}. */ 31 | UnknownDescriptorEvaluator(Descriptor desc) { 32 | this.desc = desc; 33 | } 34 | 35 | @Override 36 | public boolean tautology() { 37 | return false; 38 | } 39 | 40 | @Override 41 | public List evaluate(Value val, boolean failFast) 42 | throws ExecutionException { 43 | return Collections.singletonList( 44 | RuleViolation.newBuilder().setMessage("No evaluator available for " + desc.getFullName())); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: Bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Description 11 | 12 | 13 | ## Steps to Reproduce 14 | 20 | 21 | ## Expected Behavior 22 | 23 | 24 | 25 | ## Actual Behavior 26 | 27 | 28 | 29 | ## Screenshots/Logs 30 | 31 | 32 | 33 | ## Environment 34 | 35 | - **Operating System**: 36 | - **Version**: 37 | - **Compiler/Toolchain**: 38 | - **Protobuf Compiler & Version**: 39 | - **Protovalidate Version**: 40 | 41 | ## Possible Solution 42 | 43 | 44 | ## Additional Context 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/Evaluator.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import build.buf.protovalidate.exceptions.ExecutionException; 18 | import java.util.List; 19 | 20 | /** 21 | * {@link Evaluator} defines a validation evaluator. evaluator implementations may elide type 22 | * checking of the passed in value, as the types have been guaranteed during the build phase. 23 | */ 24 | interface Evaluator { 25 | /** 26 | * Tautology returns true if the evaluator always succeeds. 27 | * 28 | * @return True if the evaluator always succeeds. 29 | */ 30 | boolean tautology(); 31 | 32 | /** 33 | * Checks that the provided val is valid. Unless failFast is true, evaluation attempts to find all 34 | * {@link RuleViolation} present in val instead of returning only the first {@link RuleViolation}. 35 | * 36 | * @param val The value to validate. 37 | * @param failFast If true, validation stops after the first failure. 38 | * @return The result of validation on the specified value. 39 | * @throws ExecutionException If evaluation fails to complete. 40 | */ 41 | List evaluate(Value val, boolean failFast) throws ExecutionException; 42 | } 43 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # See https://tech.davis-hansson.com/p/make/ 2 | SHELL := bash 3 | .DELETE_ON_ERROR: 4 | .SHELLFLAGS := -eu -o pipefail -c 5 | .DEFAULT_GOAL := all 6 | MAKEFLAGS += --warn-undefined-variables 7 | MAKEFLAGS += --no-builtin-rules 8 | MAKEFLAGS += --no-print-directory 9 | GRADLE ?= ./gradlew 10 | 11 | .PHONY: all 12 | all: lint generate build docs conformance ## Run all tests and lint (default) 13 | 14 | .PHONY: build 15 | build: ## Build the entire project. 16 | $(GRADLE) build 17 | 18 | .PHONY: docs 19 | docs: ## Build javadocs for the project. 20 | $(GRADLE) javadoc 21 | 22 | .PHONY: checkgenerate 23 | checkgenerate: generate ## Checks if `make generate` produces a diff. 24 | @# Used in CI to verify that `make generate` doesn't produce a diff. 25 | test -z "$$(git status --porcelain | tee /dev/stderr)" 26 | 27 | .PHONY: clean 28 | clean: ## Delete intermediate build artifacts 29 | $(GRADLE) clean 30 | 31 | .PHONY: conformance 32 | conformance: ## Execute conformance tests. 33 | $(GRADLE) conformance:conformance 34 | 35 | .PHONY: help 36 | help: ## Describe useful make targets 37 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "%-15s %s\n", $$1, $$2}' 38 | 39 | .PHONY: generate 40 | generate: ## Regenerate code and license headers 41 | $(GRADLE) generate 42 | 43 | .PHONY: lint 44 | lint: ## Lint code 45 | $(GRADLE) spotlessCheck 46 | 47 | .PHONY: lintfix 48 | lintfix: ## Applies the lint changes. 49 | $(GRADLE) spotlessApply 50 | 51 | .PHONY: release 52 | release: ## Upload artifacts to Maven Central. 53 | $(GRADLE) --info publishAndReleaseToMavenCentral --stacktrace --no-daemon --no-parallel --no-configuration-cache 54 | 55 | .PHONY: releaselocal 56 | releaselocal: ## Release artifacts to local maven repository. 57 | $(GRADLE) --info publishToMavenLocal 58 | 59 | .PHONY: test 60 | test: ## Run all tests. 61 | $(GRADLE) test 62 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/NowVariable.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import com.google.protobuf.Timestamp; 18 | import dev.cel.runtime.CelVariableResolver; 19 | import java.time.Instant; 20 | import java.util.Optional; 21 | import org.jspecify.annotations.Nullable; 22 | 23 | /** 24 | * {@link NowVariable} implements {@link CelVariableResolver}, providing a lazily produced timestamp 25 | * for accessing the variable `now` that's constant within an evaluation. 26 | */ 27 | final class NowVariable implements CelVariableResolver { 28 | /** The name of the 'now' variable. */ 29 | static final String NOW_NAME = "now"; 30 | 31 | /** The resolved value of the 'now' variable. */ 32 | @Nullable private Timestamp now; 33 | 34 | /** Creates an instance of a "now" variable. */ 35 | NowVariable() {} 36 | 37 | @Override 38 | public Optional find(String name) { 39 | if (!name.equals(NOW_NAME)) { 40 | return Optional.empty(); 41 | } 42 | if (this.now == null) { 43 | Instant nowInstant = Instant.now(); 44 | now = 45 | Timestamp.newBuilder() 46 | .setSeconds(nowInstant.getEpochSecond()) 47 | .setNanos(nowInstant.getNano()) 48 | .build(); 49 | } 50 | return Optional.of(this.now); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/RuleViolationHelper.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import build.buf.validate.FieldPath; 18 | import build.buf.validate.FieldPathElement; 19 | import java.util.Collections; 20 | import java.util.List; 21 | import org.jspecify.annotations.Nullable; 22 | 23 | final class RuleViolationHelper { 24 | private static final List EMPTY_PREFIX = Collections.emptyList(); 25 | 26 | private final @Nullable FieldPath rulePrefix; 27 | 28 | private final @Nullable FieldPathElement fieldPathElement; 29 | 30 | RuleViolationHelper(@Nullable ValueEvaluator evaluator) { 31 | if (evaluator != null) { 32 | this.rulePrefix = evaluator.getNestedRule(); 33 | if (evaluator.getDescriptor() != null) { 34 | this.fieldPathElement = FieldPathUtils.fieldPathElement(evaluator.getDescriptor()); 35 | } else { 36 | this.fieldPathElement = null; 37 | } 38 | } else { 39 | this.rulePrefix = null; 40 | this.fieldPathElement = null; 41 | } 42 | } 43 | 44 | @Nullable FieldPathElement getFieldPathElement() { 45 | return fieldPathElement; 46 | } 47 | 48 | List getRulePrefixElements() { 49 | if (rulePrefix == null) { 50 | return EMPTY_PREFIX; 51 | } 52 | return rulePrefix.getElementsList(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/MessageValue.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import com.google.protobuf.Descriptors; 18 | import com.google.protobuf.Message; 19 | import java.util.Collections; 20 | import java.util.List; 21 | import java.util.Map; 22 | import org.jspecify.annotations.Nullable; 23 | 24 | /** The {@link Value} type that contains a {@link com.google.protobuf.Message}. */ 25 | final class MessageValue implements Value { 26 | 27 | /** Object type since the object type is inferred from the field descriptor. */ 28 | private final Object value; 29 | 30 | /** 31 | * Constructs a {@link MessageValue} with the provided message value. 32 | * 33 | * @param value The message value. 34 | */ 35 | MessageValue(Message value) { 36 | this.value = value; 37 | } 38 | 39 | @Override 40 | public Descriptors.@Nullable FieldDescriptor fieldDescriptor() { 41 | return null; 42 | } 43 | 44 | @Override 45 | public Message messageValue() { 46 | return (Message) value; 47 | } 48 | 49 | @Override 50 | public T value(Class clazz) { 51 | return clazz.cast(value); 52 | } 53 | 54 | @Override 55 | public List repeatedValue() { 56 | return Collections.emptyList(); 57 | } 58 | 59 | @Override 60 | public Map mapValue() { 61 | return Collections.emptyMap(); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/Validator.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import build.buf.protovalidate.exceptions.CompilationException; 18 | import build.buf.protovalidate.exceptions.ExecutionException; 19 | import build.buf.protovalidate.exceptions.ValidationException; 20 | import com.google.protobuf.Message; 21 | 22 | /** A validator that can be used to validate messages */ 23 | public interface Validator { 24 | /** 25 | * Checks that message satisfies its rules. Rules are defined within the Protobuf file as options 26 | * from the buf.validate package. A {@link ValidationResult} is returned which contains a list of 27 | * violations. If the list is empty, the message is valid. If the list is non-empty, the message 28 | * is invalid. An exception is thrown if the message cannot be validated because the evaluation 29 | * logic for the message cannot be built ({@link CompilationException}), or there is a type error 30 | * when attempting to evaluate a CEL expression associated with the message ({@link 31 | * ExecutionException}). 32 | * 33 | * @param msg the {@link Message} to be validated. 34 | * @return the {@link ValidationResult} from the evaluation. 35 | * @throws ValidationException if there are any compilation or validation execution errors. 36 | */ 37 | ValidationResult validate(Message msg) throws ValidationException; 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/MessageEvaluator.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import build.buf.protovalidate.exceptions.ExecutionException; 18 | import java.util.ArrayList; 19 | import java.util.List; 20 | 21 | /** Performs validation on a {@link com.google.protobuf.Message}. */ 22 | final class MessageEvaluator implements Evaluator { 23 | /** List of {@link Evaluator}s that are applied to a message. */ 24 | private final List evaluators = new ArrayList<>(); 25 | 26 | @Override 27 | public boolean tautology() { 28 | for (Evaluator evaluator : evaluators) { 29 | if (!evaluator.tautology()) { 30 | return false; 31 | } 32 | } 33 | return true; 34 | } 35 | 36 | @Override 37 | public List evaluate(Value val, boolean failFast) 38 | throws ExecutionException { 39 | List allViolations = new ArrayList<>(); 40 | for (Evaluator evaluator : evaluators) { 41 | List violations = evaluator.evaluate(val, failFast); 42 | if (failFast && !violations.isEmpty()) { 43 | return violations; 44 | } 45 | allViolations.addAll(violations); 46 | } 47 | if (allViolations.isEmpty()) { 48 | return RuleViolation.NO_VIOLATIONS; 49 | } 50 | return allViolations; 51 | } 52 | 53 | /** 54 | * Appends an {@link Evaluator} to the list of evaluators. 55 | * 56 | * @param eval The evaluator to append. 57 | */ 58 | void append(Evaluator eval) { 59 | evaluators.add(eval); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/Violation.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import com.google.protobuf.Descriptors; 18 | import org.jspecify.annotations.Nullable; 19 | 20 | /** {@link Violation} provides all the collected information about an individual rule violation. */ 21 | public interface Violation { 22 | /** {@link FieldValue} represents a Protobuf field value inside a Protobuf message. */ 23 | interface FieldValue { 24 | /** 25 | * Gets the value of the field, which may be null, a primitive, a Map or a List. 26 | * 27 | * @return The value of the protobuf field. 28 | */ 29 | @Nullable Object getValue(); 30 | 31 | /** 32 | * Gets the field descriptor of the field this value is from. 33 | * 34 | * @return A FieldDescriptor pertaining to this field. 35 | */ 36 | Descriptors.FieldDescriptor getDescriptor(); 37 | } 38 | 39 | /** 40 | * Gets the protobuf form of this violation. 41 | * 42 | * @return The protobuf form of this violation. 43 | */ 44 | build.buf.validate.Violation toProto(); 45 | 46 | /** 47 | * Gets the value of the field this violation pertains to, or null if there is none. 48 | * 49 | * @return Value of the field associated with the violation, or null if there is none. 50 | */ 51 | @Nullable FieldValue getFieldValue(); 52 | 53 | /** 54 | * Gets the value of the rule this violation pertains to, or null if there is none. 55 | * 56 | * @return Value of the rule associated with the violation, or null if there is none. 57 | */ 58 | @Nullable FieldValue getRuleValue(); 59 | } 60 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/Expression.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import build.buf.validate.Rule; 18 | import java.util.ArrayList; 19 | import java.util.List; 20 | 21 | /** Expression represents a single CEL expression. */ 22 | final class Expression { 23 | /** The id of the rule. */ 24 | final String id; 25 | 26 | /** The message of the rule. */ 27 | final String message; 28 | 29 | /** The expression of the rule. */ 30 | final String expression; 31 | 32 | /** 33 | * Constructs a new Expression. 34 | * 35 | * @param id The ID of the rule. 36 | * @param message The message of the rule. 37 | * @param expression The expression of the rule. 38 | */ 39 | private Expression(String id, String message, String expression) { 40 | this.id = id; 41 | this.message = message; 42 | this.expression = expression; 43 | } 44 | 45 | /** 46 | * Constructs a new Expression from the given rule. 47 | * 48 | * @param rule The rule to create the expression from. 49 | */ 50 | private Expression(Rule rule) { 51 | this(rule.getId(), rule.getMessage(), rule.getExpression()); 52 | } 53 | 54 | /** 55 | * Constructs a new list of {@link Expression} from the given list of rules. 56 | * 57 | * @param rules The list of rules. 58 | * @return The list of expressions. 59 | */ 60 | static List fromRules(List rules) { 61 | List expressions = new ArrayList<>(); 62 | for (build.buf.validate.Rule rule : rules) { 63 | expressions.add(new Expression(rule)); 64 | } 65 | return expressions; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/ValidateLibrary.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import dev.cel.checker.CelCheckerBuilder; 18 | import dev.cel.common.CelVarDecl; 19 | import dev.cel.common.types.SimpleType; 20 | import dev.cel.compiler.CelCompilerLibrary; 21 | import dev.cel.parser.CelParserBuilder; 22 | import dev.cel.parser.CelStandardMacro; 23 | import dev.cel.runtime.CelRuntimeBuilder; 24 | import dev.cel.runtime.CelRuntimeLibrary; 25 | 26 | /** 27 | * Custom {@link CelCompilerLibrary} and {@link CelRuntimeLibrary}. Provides all the custom 28 | * extension function definitions and overloads. 29 | */ 30 | final class ValidateLibrary implements CelCompilerLibrary, CelRuntimeLibrary { 31 | 32 | /** Creates a ValidateLibrary with all custom declarations and overloads. */ 33 | ValidateLibrary() {} 34 | 35 | @Override 36 | public void setParserOptions(CelParserBuilder parserBuilder) { 37 | parserBuilder.setStandardMacros( 38 | CelStandardMacro.ALL, 39 | CelStandardMacro.EXISTS, 40 | CelStandardMacro.EXISTS_ONE, 41 | CelStandardMacro.FILTER, 42 | CelStandardMacro.HAS, 43 | CelStandardMacro.MAP, 44 | CelStandardMacro.MAP_FILTER); 45 | } 46 | 47 | @Override 48 | public void setCheckerOptions(CelCheckerBuilder checkerBuilder) { 49 | checkerBuilder 50 | .addVarDeclarations( 51 | CelVarDecl.newVarDeclaration(NowVariable.NOW_NAME, SimpleType.TIMESTAMP)) 52 | .addFunctionDeclarations(CustomDeclarations.create()); 53 | } 54 | 55 | @Override 56 | public void setRuntimeOptions(CelRuntimeBuilder runtimeBuilder) { 57 | runtimeBuilder.addFunctionBindings(CustomOverload.create()); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/Value.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import com.google.protobuf.Descriptors; 18 | import com.google.protobuf.Message; 19 | import java.util.List; 20 | import java.util.Map; 21 | import org.jspecify.annotations.Nullable; 22 | 23 | /** 24 | * {@link Value} is a wrapper around a protobuf value that provides helper methods for accessing the 25 | * value. 26 | */ 27 | interface Value { 28 | /** 29 | * Get the field descriptor that corresponds to the underlying Value, if it is a message field. 30 | * 31 | * @return The underlying {@link Descriptors.FieldDescriptor}. null if the underlying value is not 32 | * a message field. 33 | */ 34 | Descriptors.@Nullable FieldDescriptor fieldDescriptor(); 35 | 36 | /** 37 | * Get the underlying value as a {@link Message} type. 38 | * 39 | * @return The underlying {@link Message} value. null if the underlying value is not a {@link 40 | * Message} type. 41 | */ 42 | @Nullable Message messageValue(); 43 | 44 | /** 45 | * Get the underlying value and cast it to the class type. 46 | * 47 | * @param clazz The inferred class. 48 | * @return The value casted to the inferred class type. 49 | * @param The class type. 50 | */ 51 | T value(Class clazz); 52 | 53 | /** 54 | * Get the underlying value as a list. 55 | * 56 | * @return The underlying value as a list. Empty list is returned if the underlying type is not a 57 | * list. 58 | */ 59 | List repeatedValue(); 60 | 61 | /** 62 | * Get the underlying value as a map. 63 | * 64 | * @return The underlying value as a map. Empty map is returned if the underlying type is not a 65 | * list. 66 | */ 67 | Map mapValue(); 68 | } 69 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/CelPrograms.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import build.buf.protovalidate.exceptions.ExecutionException; 18 | import dev.cel.runtime.CelVariableResolver; 19 | import java.util.ArrayList; 20 | import java.util.List; 21 | import org.jspecify.annotations.Nullable; 22 | 23 | /** Evaluator that executes a {@link CompiledProgram}. */ 24 | final class CelPrograms implements Evaluator { 25 | private final RuleViolationHelper helper; 26 | 27 | /** A list of {@link CompiledProgram} that will be executed against the input message. */ 28 | private final List programs; 29 | 30 | /** 31 | * Constructs a new {@link CelPrograms}. 32 | * 33 | * @param compiledPrograms The programs to execute. 34 | */ 35 | CelPrograms(@Nullable ValueEvaluator valueEvaluator, List compiledPrograms) { 36 | this.helper = new RuleViolationHelper(valueEvaluator); 37 | this.programs = compiledPrograms; 38 | } 39 | 40 | @Override 41 | public boolean tautology() { 42 | return programs.isEmpty(); 43 | } 44 | 45 | @Override 46 | public List evaluate(Value val, boolean failFast) 47 | throws ExecutionException { 48 | CelVariableResolver bindings = Variable.newThisVariable(val.value(Object.class)); 49 | List violations = new ArrayList<>(); 50 | for (CompiledProgram program : programs) { 51 | RuleViolation.Builder violation = program.eval(val, bindings); 52 | if (violation != null) { 53 | violations.add(violation); 54 | if (failFast) { 55 | break; 56 | } 57 | } 58 | } 59 | return FieldPathUtils.updatePaths( 60 | violations, helper.getFieldPathElement(), helper.getRulePrefixElements()); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/OneofEvaluator.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import build.buf.protovalidate.exceptions.ExecutionException; 18 | import build.buf.validate.FieldPathElement; 19 | import com.google.protobuf.Descriptors.OneofDescriptor; 20 | import com.google.protobuf.Message; 21 | import java.util.Collections; 22 | import java.util.List; 23 | 24 | /** {@link OneofEvaluator} performs validation on a oneof union. */ 25 | final class OneofEvaluator implements Evaluator { 26 | /** The {@link OneofDescriptor} targeted by this evaluator. */ 27 | private final OneofDescriptor descriptor; 28 | 29 | /** Indicates that a member of the oneof must be set. */ 30 | private final boolean required; 31 | 32 | /** 33 | * Constructs a {@link OneofEvaluator}. 34 | * 35 | * @param descriptor The targeted oneof descriptor. 36 | * @param required Indicates whether a member of the oneof must be set. 37 | */ 38 | OneofEvaluator(OneofDescriptor descriptor, boolean required) { 39 | this.descriptor = descriptor; 40 | this.required = required; 41 | } 42 | 43 | @Override 44 | public boolean tautology() { 45 | return !required; 46 | } 47 | 48 | @Override 49 | public List evaluate(Value val, boolean failFast) 50 | throws ExecutionException { 51 | Message message = val.messageValue(); 52 | if (message == null || !required || (message.getOneofFieldDescriptor(descriptor) != null)) { 53 | return RuleViolation.NO_VIOLATIONS; 54 | } 55 | return Collections.singletonList( 56 | RuleViolation.newBuilder() 57 | .addFirstFieldPathElement( 58 | FieldPathElement.newBuilder().setFieldName(descriptor.getName()).build()) 59 | .setRuleId("required") 60 | .setMessage("exactly one field is required in oneof")); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/ListElementValue.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import com.google.protobuf.Descriptors; 18 | import com.google.protobuf.Message; 19 | import java.util.Collections; 20 | import java.util.List; 21 | import java.util.Map; 22 | import org.jspecify.annotations.Nullable; 23 | 24 | /** 25 | * The {@link Value} type that contains a field descriptor for repeated field and the value of an 26 | * element. 27 | */ 28 | final class ListElementValue implements Value { 29 | /** Object type since the object type is inferred from the field descriptor. */ 30 | private final Object value; 31 | 32 | /** 33 | * {@link com.google.protobuf.Descriptors.FieldDescriptor} is the field descriptor for the value. 34 | */ 35 | private final Descriptors.FieldDescriptor fieldDescriptor; 36 | 37 | ListElementValue(Descriptors.FieldDescriptor fieldDescriptor, Object value) { 38 | this.value = value; 39 | this.fieldDescriptor = fieldDescriptor; 40 | } 41 | 42 | @Override 43 | public Descriptors.@Nullable FieldDescriptor fieldDescriptor() { 44 | return fieldDescriptor; 45 | } 46 | 47 | @Override 48 | public @Nullable Message messageValue() { 49 | if (fieldDescriptor.getJavaType() == Descriptors.FieldDescriptor.JavaType.MESSAGE) { 50 | return (Message) value; 51 | } 52 | return null; 53 | } 54 | 55 | @Override 56 | public T value(Class clazz) { 57 | Descriptors.FieldDescriptor.Type type = fieldDescriptor.getType(); 58 | if (type == Descriptors.FieldDescriptor.Type.MESSAGE) { 59 | return clazz.cast(value); 60 | } 61 | return clazz.cast(ProtoAdapter.scalarToCel(type, value)); 62 | } 63 | 64 | @Override 65 | public List repeatedValue() { 66 | return Collections.emptyList(); 67 | } 68 | 69 | @Override 70 | public Map mapValue() { 71 | return Collections.emptyMap(); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/MessageOneofEvaluator.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import build.buf.protovalidate.exceptions.ExecutionException; 18 | import com.google.protobuf.Descriptors.FieldDescriptor; 19 | import com.google.protobuf.Message; 20 | import java.util.Collections; 21 | import java.util.List; 22 | import java.util.stream.Collectors; 23 | 24 | /** 25 | * A specialized {@link Evaluator} for applying {@code buf.validate.MessageOneofRule} to a {@link 26 | * com.google.protobuf.Message}. 27 | */ 28 | final class MessageOneofEvaluator implements Evaluator { 29 | /** List of fields that are part of the oneof */ 30 | final List fields; 31 | 32 | /** If at least one must be set. */ 33 | final boolean required; 34 | 35 | MessageOneofEvaluator(List fields, boolean required) { 36 | this.fields = fields; 37 | this.required = required; 38 | } 39 | 40 | @Override 41 | public boolean tautology() { 42 | return false; 43 | } 44 | 45 | @Override 46 | public List evaluate(Value val, boolean failFast) 47 | throws ExecutionException { 48 | Message msg = val.messageValue(); 49 | if (msg == null) { 50 | return RuleViolation.NO_VIOLATIONS; 51 | } 52 | int hasCount = 0; 53 | for (FieldDescriptor field : fields) { 54 | if (msg.hasField(field)) { 55 | hasCount++; 56 | } 57 | } 58 | if (hasCount > 1) { 59 | return Collections.singletonList( 60 | RuleViolation.newBuilder() 61 | .setRuleId("message.oneof") 62 | .setMessage(String.format("only one of %s can be set", fieldNames()))); 63 | } 64 | if (this.required && hasCount == 0) { 65 | return Collections.singletonList( 66 | RuleViolation.newBuilder() 67 | .setRuleId("message.oneof") 68 | .setMessage(String.format("one of %s must be set", fieldNames()))); 69 | } 70 | return Collections.emptyList(); 71 | } 72 | 73 | String fieldNames() { 74 | return fields.stream().map(FieldDescriptor::getName).collect(Collectors.joining(", ")); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Gradle Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | workflow_dispatch: {} # support manual runs 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 17 | 18 | - name: Cache Go Modules 19 | uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 20 | with: 21 | path: | 22 | ~/.cache/go-build 23 | ~/go/pkg/mod 24 | key: ${{ runner.os }}-gomod-ci-${{ hashFiles('gradle.properties', 'gradle/libs.versions.toml') }} 25 | restore-keys: 26 | ${{ runner.os }}-gomod-ci- 27 | 28 | - name: Set up JDK 29 | uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0 30 | with: 31 | distribution: 'temurin' 32 | java-version: '21' 33 | 34 | - uses: bufbuild/buf-action@8f4a1456a0ab6a1eb80ba68e53832e6fcfacc16c # v1.3.0 35 | with: 36 | setup_only: true 37 | - env: 38 | BUF_TOKEN: ${{ secrets.BUF_TOKEN }} 39 | run: echo ${BUF_TOKEN} | buf registry login buf.build --token-stdin 40 | 41 | - name: Validate Gradle Wrapper 42 | uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0 43 | 44 | - name: Configure GPG signing & publish 45 | env: 46 | GPG_KEY: ${{ secrets.GPG_KEY }} 47 | GPG_KEY_NAME: ${{ secrets.GPG_KEY_NAME }} 48 | GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} 49 | SONATYPE_USER: ${{ secrets.SONATYPE_USER }} 50 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 51 | run: | 52 | export ORG_GRADLE_PROJECT_mavenCentralUsername=$SONATYPE_USER 53 | export ORG_GRADLE_PROJECT_mavenCentralPassword=$SONATYPE_PASSWORD 54 | export ORG_GRADLE_PROJECT_signingInMemoryKeyPassword=$GPG_PASSPHRASE 55 | release_version=$(git describe --tags --abbrev=0 --exact-match) 56 | export ORG_GRADLE_PROJECT_releaseVersion="${release_version:1}" 57 | # https://github.com/keybase/keybase-issues/issues/2798 58 | export GPG_TTY=$(tty) 59 | # Import gpg keys and warm the passphrase to avoid the gpg 60 | # passphrase prompt when initiating a deploy 61 | # `--pinentry-mode=loopback` could be needed to ensure we 62 | # suppress the gpg prompt 63 | echo $GPG_KEY | base64 --decode > signing-key 64 | gpg --passphrase $GPG_PASSPHRASE --batch --import signing-key 65 | export ORG_GRADLE_PROJECT_signingInMemoryKey=$(gpg --armor --passphrase $GPG_PASSPHRASE --pinentry-mode=loopback --export-secret-keys $GPG_KEY_NAME signing-key | grep -v '\-\-' | grep -v '^=.' | tr -d '\n') 66 | shred signing-key 67 | make release 68 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/Variable.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import dev.cel.runtime.CelVariableResolver; 18 | import java.util.Optional; 19 | import org.jspecify.annotations.Nullable; 20 | 21 | /** 22 | * {@link Variable} implements {@link CelVariableResolver}, providing a lightweight named variable 23 | * to cel.Program executions. 24 | */ 25 | final class Variable implements CelVariableResolver { 26 | /** The {@value} variable in CEL. */ 27 | static final String THIS_NAME = "this"; 28 | 29 | /** The {@value} variable in CEL. */ 30 | static final String RULES_NAME = "rules"; 31 | 32 | /** The {@value} variable in CEL. */ 33 | static final String RULE_NAME = "rule"; 34 | 35 | /** The variable's name */ 36 | private final String name; 37 | 38 | /** The value for this variable */ 39 | @Nullable private final Object val; 40 | 41 | /** Creates a variable with the given name and value. */ 42 | private Variable(String name, @Nullable Object val) { 43 | this.name = name; 44 | this.val = val; 45 | } 46 | 47 | /** 48 | * Creates a "this" variable. 49 | * 50 | * @param val the value. 51 | * @return {@link Variable}. 52 | */ 53 | static CelVariableResolver newThisVariable(@Nullable Object val) { 54 | return CelVariableResolver.hierarchicalVariableResolver( 55 | new NowVariable(), new Variable(THIS_NAME, val)); 56 | } 57 | 58 | /** 59 | * Creates a "rules" variable. 60 | * 61 | * @param val the value. 62 | * @return {@link Variable}. 63 | */ 64 | static CelVariableResolver newRulesVariable(Object val) { 65 | return new Variable(RULES_NAME, val); 66 | } 67 | 68 | /** 69 | * Creates a "rule" variable. 70 | * 71 | * @param rules the value of the "rules" variable. 72 | * @param val the value of the "rule" variable. 73 | * @return {@link Variable}. 74 | */ 75 | static CelVariableResolver newRuleVariable(Object rules, Object val) { 76 | return CelVariableResolver.hierarchicalVariableResolver( 77 | newRulesVariable(rules), new Variable(RULE_NAME, val)); 78 | } 79 | 80 | @Override 81 | public Optional find(String name) { 82 | if (!this.name.equals(name) || val == null) { 83 | return Optional.empty(); 84 | } 85 | return Optional.of(val); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/AstExpression.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import build.buf.protovalidate.exceptions.CompilationException; 18 | import dev.cel.common.CelAbstractSyntaxTree; 19 | import dev.cel.common.CelValidationException; 20 | import dev.cel.common.CelValidationResult; 21 | import dev.cel.common.types.CelKind; 22 | import dev.cel.compiler.CelCompiler; 23 | 24 | /** {@link AstExpression} is a compiled CEL {@link CelAbstractSyntaxTree}. */ 25 | final class AstExpression { 26 | /** The compiled CEL AST. */ 27 | final CelAbstractSyntaxTree ast; 28 | 29 | /** Contains the original expression from the proto file. */ 30 | final Expression source; 31 | 32 | /** Constructs a new {@link AstExpression}. */ 33 | private AstExpression(CelAbstractSyntaxTree ast, Expression source) { 34 | this.ast = ast; 35 | this.source = source; 36 | } 37 | 38 | /** 39 | * Compiles the given expression to a {@link AstExpression}. 40 | * 41 | * @param cel The CEL compiler. 42 | * @param expr The expression to compile. 43 | * @return The compiled {@link AstExpression}. 44 | * @throws CompilationException if the expression compilation fails. 45 | */ 46 | static AstExpression newAstExpression(CelCompiler cel, Expression expr) 47 | throws CompilationException { 48 | CelValidationResult compileResult = cel.compile(expr.expression); 49 | if (!compileResult.getAllIssues().isEmpty()) { 50 | throw new CompilationException( 51 | "Failed to compile expression " + expr.id + ":\n" + compileResult.getIssueString()); 52 | } 53 | CelAbstractSyntaxTree ast; 54 | try { 55 | ast = compileResult.getAst(); 56 | } catch (CelValidationException e) { 57 | // This will not happen as we checked for issues, and it only throws when 58 | // it has at least one issue of error severity. 59 | throw new CompilationException( 60 | "Failed to compile expression " + expr.id + ":\n" + compileResult.getIssueString()); 61 | } 62 | CelKind outKind = ast.getResultType().kind(); 63 | if (outKind != CelKind.BOOL && outKind != CelKind.STRING) { 64 | throw new CompilationException( 65 | String.format( 66 | "Expression outputs, wanted either bool or string: %s %s", expr.id, outKind)); 67 | } 68 | return new AstExpression(ast, expr); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/ListEvaluator.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import build.buf.protovalidate.exceptions.ExecutionException; 18 | import build.buf.validate.FieldPath; 19 | import build.buf.validate.FieldPathElement; 20 | import build.buf.validate.FieldRules; 21 | import build.buf.validate.RepeatedRules; 22 | import java.util.ArrayList; 23 | import java.util.List; 24 | import java.util.Objects; 25 | 26 | /** Performs validation on the elements of a repeated field. */ 27 | final class ListEvaluator implements Evaluator { 28 | /** Rule path to repeated rules */ 29 | private static final FieldPath REPEATED_ITEMS_RULE_PATH = 30 | FieldPath.newBuilder() 31 | .addElements( 32 | FieldPathUtils.fieldPathElement( 33 | FieldRules.getDescriptor().findFieldByNumber(FieldRules.REPEATED_FIELD_NUMBER))) 34 | .addElements( 35 | FieldPathUtils.fieldPathElement( 36 | RepeatedRules.getDescriptor() 37 | .findFieldByNumber(RepeatedRules.ITEMS_FIELD_NUMBER))) 38 | .build(); 39 | 40 | private final RuleViolationHelper helper; 41 | 42 | /** Rules are checked on every item of the list. */ 43 | final ValueEvaluator itemRules; 44 | 45 | /** Constructs a {@link ListEvaluator}. */ 46 | ListEvaluator(ValueEvaluator valueEvaluator) { 47 | this.helper = new RuleViolationHelper(valueEvaluator); 48 | this.itemRules = new ValueEvaluator(null, REPEATED_ITEMS_RULE_PATH); 49 | } 50 | 51 | @Override 52 | public boolean tautology() { 53 | return itemRules.tautology(); 54 | } 55 | 56 | @Override 57 | public List evaluate(Value val, boolean failFast) 58 | throws ExecutionException { 59 | List allViolations = new ArrayList<>(); 60 | List repeatedValues = val.repeatedValue(); 61 | for (int i = 0; i < repeatedValues.size(); i++) { 62 | List violations = itemRules.evaluate(repeatedValues.get(i), failFast); 63 | if (violations.isEmpty()) { 64 | continue; 65 | } 66 | FieldPathElement fieldPathElement = 67 | Objects.requireNonNull(helper.getFieldPathElement()).toBuilder().setIndex(i).build(); 68 | FieldPathUtils.updatePaths(violations, fieldPathElement, helper.getRulePrefixElements()); 69 | if (failFast && !violations.isEmpty()) { 70 | return violations; 71 | } 72 | allViolations.addAll(violations); 73 | } 74 | return allViolations; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH= 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/ValidatorImpl.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import build.buf.protovalidate.exceptions.CompilationException; 18 | import build.buf.protovalidate.exceptions.ValidationException; 19 | import com.google.protobuf.Descriptors.Descriptor; 20 | import com.google.protobuf.Message; 21 | import dev.cel.bundle.Cel; 22 | import dev.cel.bundle.CelFactory; 23 | import dev.cel.common.CelOptions; 24 | import java.util.ArrayList; 25 | import java.util.List; 26 | 27 | final class ValidatorImpl implements Validator { 28 | /** evaluatorBuilder is the builder used to construct the evaluator for a given message. */ 29 | private final EvaluatorBuilder evaluatorBuilder; 30 | 31 | /** 32 | * failFast indicates whether the validator should stop evaluating rules after the first 33 | * violation. 34 | */ 35 | private final boolean failFast; 36 | 37 | ValidatorImpl(Config config) { 38 | ValidateLibrary validateLibrary = new ValidateLibrary(); 39 | Cel cel = 40 | CelFactory.standardCelBuilder() 41 | .addCompilerLibraries(validateLibrary) 42 | .addRuntimeLibraries(validateLibrary) 43 | .setOptions( 44 | CelOptions.DEFAULT.toBuilder().evaluateCanonicalTypesToNativeValues(true).build()) 45 | .build(); 46 | this.evaluatorBuilder = new EvaluatorBuilder(cel, config); 47 | this.failFast = config.isFailFast(); 48 | } 49 | 50 | ValidatorImpl(Config config, List descriptors, boolean disableLazy) 51 | throws CompilationException { 52 | ValidateLibrary validateLibrary = new ValidateLibrary(); 53 | Cel cel = 54 | CelFactory.standardCelBuilder() 55 | .addCompilerLibraries(validateLibrary) 56 | .addRuntimeLibraries(validateLibrary) 57 | .setOptions( 58 | CelOptions.DEFAULT.toBuilder().evaluateCanonicalTypesToNativeValues(true).build()) 59 | .build(); 60 | this.evaluatorBuilder = new EvaluatorBuilder(cel, config, descriptors, disableLazy); 61 | this.failFast = config.isFailFast(); 62 | } 63 | 64 | @Override 65 | public ValidationResult validate(Message msg) throws ValidationException { 66 | if (msg == null) { 67 | return ValidationResult.EMPTY; 68 | } 69 | Descriptor descriptor = msg.getDescriptorForType(); 70 | Evaluator evaluator = evaluatorBuilder.load(descriptor); 71 | List result = evaluator.evaluate(new MessageValue(msg), this.failFast); 72 | if (result.isEmpty()) { 73 | return ValidationResult.EMPTY; 74 | } 75 | List violations = new ArrayList<>(result.size()); 76 | for (RuleViolation.Builder builder : result) { 77 | violations.add(builder.build()); 78 | } 79 | return new ValidationResult(violations); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/ValidationResult.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import build.buf.validate.Violations; 18 | import java.util.ArrayList; 19 | import java.util.Collections; 20 | import java.util.List; 21 | 22 | /** 23 | * {@link ValidationResult} is returned when a rule is executed. It contains a list of violations. 24 | * This is non-fatal. If there are no violations, the rule is considered to have passed. 25 | */ 26 | public class ValidationResult { 27 | 28 | /** 29 | * violations is a list of {@link Violation} that occurred during the validations of a message. 30 | */ 31 | private final List violations; 32 | 33 | /** A violation result with an empty violation list. */ 34 | public static final ValidationResult EMPTY = new ValidationResult(Collections.emptyList()); 35 | 36 | /** 37 | * Creates a violation result from a list of violations. 38 | * 39 | * @param violations violation list for the result. 40 | */ 41 | public ValidationResult(List violations) { 42 | this.violations = violations; 43 | } 44 | 45 | /** 46 | * Check if the result is successful. 47 | * 48 | * @return if the validation result was a success. 49 | */ 50 | public boolean isSuccess() { 51 | return violations.isEmpty(); 52 | } 53 | 54 | /** 55 | * Get the list of violations in the result. 56 | * 57 | * @return the violation list. 58 | */ 59 | public List getViolations() { 60 | return violations; 61 | } 62 | 63 | /** 64 | * Returns a string representation of the validation result, including all the violations. 65 | * 66 | * @return a string representation of the validation result. 67 | */ 68 | @Override 69 | public String toString() { 70 | StringBuilder builder = new StringBuilder(); 71 | if (isSuccess()) { 72 | builder.append("Validation OK"); 73 | } else { 74 | builder.append("Validation error:"); 75 | for (Violation violation : violations) { 76 | builder.append("\n - "); 77 | if (violation.toProto().hasField()) { 78 | builder.append(FieldPathUtils.fieldPathString(violation.toProto().getField())); 79 | builder.append(": "); 80 | } 81 | builder.append( 82 | String.format( 83 | "%s [%s]", violation.toProto().getMessage(), violation.toProto().getRuleId())); 84 | } 85 | } 86 | return builder.toString(); 87 | } 88 | 89 | /** 90 | * Converts the validation result to its equivalent protobuf form. 91 | * 92 | * @return The protobuf form of this validation result. 93 | */ 94 | public build.buf.validate.Violations toProto() { 95 | List protoViolations = new ArrayList<>(); 96 | for (Violation violation : violations) { 97 | protoViolations.add(violation.toProto()); 98 | } 99 | return Violations.newBuilder().addAllViolations(protoViolations).build(); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/ObjectValue.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import com.google.protobuf.AbstractMessage; 18 | import com.google.protobuf.Descriptors; 19 | import com.google.protobuf.Message; 20 | import java.util.ArrayList; 21 | import java.util.Collections; 22 | import java.util.HashMap; 23 | import java.util.List; 24 | import java.util.Map; 25 | import org.jspecify.annotations.Nullable; 26 | 27 | /** The {@link Value} type that contains a field descriptor and its value. */ 28 | final class ObjectValue implements Value { 29 | 30 | /** 31 | * {@link com.google.protobuf.Descriptors.FieldDescriptor} is the field descriptor for the value. 32 | */ 33 | private final Descriptors.FieldDescriptor fieldDescriptor; 34 | 35 | /** Object type since the object type is inferred from the field descriptor. */ 36 | private final Object value; 37 | 38 | /** 39 | * Constructs a new {@link ObjectValue}. 40 | * 41 | * @param fieldDescriptor The field descriptor for the value. 42 | * @param value The value associated with the field descriptor. 43 | */ 44 | ObjectValue(Descriptors.FieldDescriptor fieldDescriptor, Object value) { 45 | this.fieldDescriptor = fieldDescriptor; 46 | this.value = value; 47 | } 48 | 49 | @Override 50 | public Descriptors.FieldDescriptor fieldDescriptor() { 51 | return fieldDescriptor; 52 | } 53 | 54 | @Nullable 55 | @Override 56 | public Message messageValue() { 57 | if (fieldDescriptor.getJavaType() == Descriptors.FieldDescriptor.JavaType.MESSAGE) { 58 | return (Message) value; 59 | } 60 | return null; 61 | } 62 | 63 | @Override 64 | public T value(Class clazz) { 65 | return clazz.cast(ProtoAdapter.toCel(fieldDescriptor, value)); 66 | } 67 | 68 | @Override 69 | public List repeatedValue() { 70 | List out = new ArrayList<>(); 71 | if (fieldDescriptor.isRepeated()) { 72 | List list = (List) value; 73 | for (Object o : list) { 74 | out.add(new ListElementValue(fieldDescriptor, o)); 75 | } 76 | } 77 | return out; 78 | } 79 | 80 | @Override 81 | public Map mapValue() { 82 | List input = 83 | value instanceof List 84 | ? (List) value 85 | : Collections.singletonList((AbstractMessage) value); 86 | 87 | Descriptors.FieldDescriptor keyDesc = fieldDescriptor.getMessageType().findFieldByNumber(1); 88 | Descriptors.FieldDescriptor valDesc = fieldDescriptor.getMessageType().findFieldByNumber(2); 89 | Map out = new HashMap<>(input.size()); 90 | for (AbstractMessage entry : input) { 91 | Object keyValue = entry.getField(keyDesc); 92 | Value keyJavaValue = new ObjectValue(keyDesc, keyValue); 93 | 94 | Object valValue = entry.getField(valDesc); 95 | Value valJavaValue = new ObjectValue(valDesc, valValue); 96 | 97 | out.put(keyJavaValue, valJavaValue); 98 | } 99 | 100 | return out; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/ValueEvaluator.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import build.buf.protovalidate.exceptions.ExecutionException; 18 | import build.buf.validate.FieldPath; 19 | import com.google.protobuf.Descriptors; 20 | import java.util.ArrayList; 21 | import java.util.List; 22 | import java.util.Objects; 23 | import org.jspecify.annotations.Nullable; 24 | 25 | /** 26 | * {@link ValueEvaluator} performs validation on any concrete value contained within a singular 27 | * field, repeated elements, or the keys/values of a map. 28 | */ 29 | final class ValueEvaluator implements Evaluator { 30 | /** The {@link Descriptors.FieldDescriptor} targeted by this evaluator */ 31 | private final Descriptors.@Nullable FieldDescriptor descriptor; 32 | 33 | /** The nested rule path that this value evaluator is for */ 34 | @Nullable private final FieldPath nestedRule; 35 | 36 | /** The default or zero-value for this value's type. */ 37 | @Nullable private Object zero; 38 | 39 | /** The evaluators applied to a value. */ 40 | private final List evaluators = new ArrayList<>(); 41 | 42 | /** 43 | * Indicates that the Rules should not be applied if the field is unset or the default (typically 44 | * zero) value. 45 | */ 46 | private boolean ignoreEmpty; 47 | 48 | /** Constructs a {@link ValueEvaluator}. */ 49 | ValueEvaluator(Descriptors.@Nullable FieldDescriptor descriptor, @Nullable FieldPath nestedRule) { 50 | this.descriptor = descriptor; 51 | this.nestedRule = nestedRule; 52 | } 53 | 54 | Descriptors.@Nullable FieldDescriptor getDescriptor() { 55 | return descriptor; 56 | } 57 | 58 | @Nullable FieldPath getNestedRule() { 59 | return nestedRule; 60 | } 61 | 62 | boolean hasNestedRule() { 63 | return this.nestedRule != null; 64 | } 65 | 66 | @Override 67 | public boolean tautology() { 68 | return evaluators.isEmpty(); 69 | } 70 | 71 | @Override 72 | public List evaluate(Value val, boolean failFast) 73 | throws ExecutionException { 74 | if (this.shouldIgnore(val.value(Object.class))) { 75 | return RuleViolation.NO_VIOLATIONS; 76 | } 77 | List allViolations = new ArrayList<>(); 78 | for (Evaluator evaluator : evaluators) { 79 | List violations = evaluator.evaluate(val, failFast); 80 | if (failFast && !violations.isEmpty()) { 81 | return violations; 82 | } 83 | allViolations.addAll(violations); 84 | } 85 | if (allViolations.isEmpty()) { 86 | return RuleViolation.NO_VIOLATIONS; 87 | } 88 | return allViolations; 89 | } 90 | 91 | /** 92 | * Appends an evaluator to the list of evaluators. 93 | * 94 | * @param eval The evaluator to append. 95 | */ 96 | void append(Evaluator eval) { 97 | if (!eval.tautology()) { 98 | this.evaluators.add(eval); 99 | } 100 | } 101 | 102 | void setIgnoreEmpty(Object zero) { 103 | this.ignoreEmpty = true; 104 | this.zero = zero; 105 | } 106 | 107 | private boolean shouldIgnore(Object value) { 108 | return this.ignoreEmpty && Objects.equals(value, this.zero); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/EnumEvaluator.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import build.buf.protovalidate.exceptions.ExecutionException; 18 | import build.buf.validate.EnumRules; 19 | import build.buf.validate.FieldPath; 20 | import build.buf.validate.FieldRules; 21 | import com.google.protobuf.Descriptors; 22 | import java.util.Collections; 23 | import java.util.List; 24 | import java.util.Set; 25 | import java.util.stream.Collectors; 26 | 27 | /** 28 | * {@link EnumEvaluator} checks an enum value being a member of the defined values exclusively. This 29 | * check is handled outside CEL as enums are completely type erased to integers. 30 | */ 31 | final class EnumEvaluator implements Evaluator { 32 | private final RuleViolationHelper helper; 33 | 34 | /** Captures all the defined values for this enum */ 35 | private final Set values; 36 | 37 | private static final Descriptors.FieldDescriptor DEFINED_ONLY_DESCRIPTOR = 38 | EnumRules.getDescriptor().findFieldByNumber(EnumRules.DEFINED_ONLY_FIELD_NUMBER); 39 | 40 | private static final FieldPath DEFINED_ONLY_RULE_PATH = 41 | FieldPath.newBuilder() 42 | .addElements( 43 | FieldPathUtils.fieldPathElement( 44 | FieldRules.getDescriptor().findFieldByNumber(FieldRules.ENUM_FIELD_NUMBER))) 45 | .addElements(FieldPathUtils.fieldPathElement(DEFINED_ONLY_DESCRIPTOR)) 46 | .build(); 47 | 48 | /** 49 | * Constructs a new evaluator for enum values. 50 | * 51 | * @param valueDescriptors the list of {@link Descriptors.EnumValueDescriptor} for the enum. 52 | */ 53 | EnumEvaluator( 54 | ValueEvaluator valueEvaluator, List valueDescriptors) { 55 | this.helper = new RuleViolationHelper(valueEvaluator); 56 | if (valueDescriptors.isEmpty()) { 57 | this.values = Collections.emptySet(); 58 | } else { 59 | this.values = 60 | valueDescriptors.stream().map(it -> (long) it.getNumber()).collect(Collectors.toSet()); 61 | } 62 | } 63 | 64 | @Override 65 | public boolean tautology() { 66 | return false; 67 | } 68 | 69 | /** 70 | * Evaluates an enum value. 71 | * 72 | * @param val the value to evaluate. 73 | * @param failFast indicates if the evaluation should stop on the first violation. 74 | * @return the {@link ValidationResult} of the evaluation. 75 | * @throws ExecutionException if an error occurs during the evaluation. 76 | */ 77 | @Override 78 | public List evaluate(Value val, boolean failFast) 79 | throws ExecutionException { 80 | Object enumValue = val.value(Object.class); 81 | if (enumValue == null) { 82 | return RuleViolation.NO_VIOLATIONS; 83 | } 84 | if (!values.contains(enumValue)) { 85 | return Collections.singletonList( 86 | RuleViolation.newBuilder() 87 | .addAllRulePathElements(helper.getRulePrefixElements()) 88 | .addAllRulePathElements(DEFINED_ONLY_RULE_PATH.getElementsList()) 89 | .addFirstFieldPathElement(helper.getFieldPathElement()) 90 | .setRuleId("enum.defined_only") 91 | .setMessage("value must be one of the defined enum values") 92 | .setFieldValue(new RuleViolation.FieldValue(val)) 93 | .setRuleValue(new RuleViolation.FieldValue(true, DEFINED_ONLY_DESCRIPTOR))); 94 | } 95 | return RuleViolation.NO_VIOLATIONS; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/ValidatorFactory.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import build.buf.protovalidate.exceptions.CompilationException; 18 | import com.google.protobuf.Descriptors.Descriptor; 19 | import java.util.List; 20 | import org.jspecify.annotations.Nullable; 21 | 22 | /** 23 | * ValidatorFactory is used to create a validator. 24 | * 25 | *

Validators can be created with an optional {@link Config} to customize behavior. They can also 26 | * be created with a list of seed descriptors to warmup the validator cache ahead of time as well as 27 | * an indicator to lazily-load any descriptors not provided into the cache. 28 | */ 29 | public final class ValidatorFactory { 30 | // Prevent instantiation 31 | private ValidatorFactory() {} 32 | 33 | /** A builder class used for building a validator. */ 34 | public static class ValidatorBuilder { 35 | /** The config object to use for instantiating a validator. */ 36 | @Nullable private Config config; 37 | 38 | /** 39 | * Create a validator with the given config 40 | * 41 | * @param config The {@link Config} to configure the validator. 42 | * @return The builder instance 43 | */ 44 | public ValidatorBuilder withConfig(Config config) { 45 | this.config = config; 46 | return this; 47 | } 48 | 49 | // Prevent instantiation 50 | private ValidatorBuilder() {} 51 | 52 | /** 53 | * Build a new validator 54 | * 55 | * @return A new {@link Validator} instance. 56 | */ 57 | public Validator build() { 58 | Config cfg = this.config; 59 | if (cfg == null) { 60 | cfg = Config.newBuilder().build(); 61 | } 62 | return new ValidatorImpl(cfg); 63 | } 64 | 65 | /** 66 | * Build the validator, warming up the cache with any provided descriptors. 67 | * 68 | * @param descriptors the list of descriptors to warm up the cache. 69 | * @param disableLazy whether to disable lazy loading of validation rules. When validation is 70 | * performed, a message's rules will be looked up in a cache. If they are not found, by 71 | * default they will be processed and lazily-loaded into the cache. Setting this to false 72 | * will not attempt to lazily-load descriptor information not found in the cache and 73 | * essentially makes the entire cache read-only, eliminating thread contention. 74 | * @return A new {@link Validator} instance. 75 | * @throws CompilationException If any of the given descriptors' validation rules fail 76 | * processing while warming up the cache. 77 | * @throws IllegalStateException If disableLazy is set to true and no descriptors are passed. 78 | */ 79 | public Validator buildWithDescriptors(List descriptors, boolean disableLazy) 80 | throws CompilationException, IllegalStateException { 81 | if (disableLazy && (descriptors == null || descriptors.isEmpty())) { 82 | throw new IllegalStateException( 83 | "a list of descriptors is required when disableLazy is true"); 84 | } 85 | 86 | Config cfg = this.config; 87 | if (cfg == null) { 88 | cfg = Config.newBuilder().build(); 89 | } 90 | return new ValidatorImpl(cfg, descriptors, disableLazy); 91 | } 92 | } 93 | 94 | /** 95 | * Creates a new builder for a validator. 96 | * 97 | * @return A Validator builder 98 | */ 99 | public static ValidatorBuilder newBuilder() { 100 | return new ValidatorBuilder(); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/test/resources/proto/validationtest/validationtest.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | syntax = "proto3"; 16 | 17 | package validationtest; 18 | 19 | import "buf/validate/validate.proto"; 20 | import "validationtest/import_test.proto"; 21 | 22 | message ExampleFieldRules { 23 | string regex_string_field = 1 [(buf.validate.field).string.pattern = "^[a-z0-9]{1,9}$"]; 24 | string unconstrained = 2; 25 | } 26 | 27 | message ExampleOneofRules { 28 | // contact_info is the user's contact information 29 | oneof contact_info { 30 | // required ensures that exactly one field in oneof is set. Without this 31 | // option, at most one of email and phone_number is set. 32 | option (buf.validate.oneof).required = true; 33 | // email is the user's email 34 | string email = 1; 35 | // phone_number is the user's phone number. 36 | string phone_number = 2; 37 | } 38 | oneof unconstrained { 39 | string field3 = 3; 40 | string field4 = 4; 41 | } 42 | } 43 | 44 | message ExampleMessageRules { 45 | option (buf.validate.message).cel = { 46 | id: "secondary_email_depends_on_primary" 47 | expression: 48 | "has(this.secondary_email) && !has(this.primary_email)" 49 | "? 'cannot set a secondary email without setting a primary one'" 50 | ": ''" 51 | }; 52 | string primary_email = 1; 53 | string secondary_email = 2; 54 | } 55 | 56 | message FieldExpressionMultiple { 57 | string val = 1 [ 58 | (buf.validate.field).string.max_len = 5, 59 | (buf.validate.field).string.pattern = "^[a-z0-9]$" 60 | ]; 61 | } 62 | 63 | message FieldExpressionMapInt32 { 64 | map val = 1 [(buf.validate.field).cel = { 65 | id: "field_expression.map.int32" 66 | message: "all map values must equal 1" 67 | expression: "this.all(k, this[k] == 1)" 68 | }]; 69 | } 70 | 71 | message ExampleImportMessage { 72 | option (buf.validate.message) = { 73 | cel: { 74 | id: "imported_submessage_must_not_be_null" 75 | expression: "this.imported_submessage != null" 76 | } 77 | cel: { 78 | id: "hex_string_must_not_be_empty" 79 | expression: "this.imported_submessage.hex_string != ''" 80 | } 81 | }; 82 | ExampleImportedMessage imported_submessage = 1; 83 | } 84 | 85 | message ExampleImportMessageFieldRule { 86 | ExampleImportMessage message_with_import = 1 [ 87 | (buf.validate.field).cel = { 88 | id: "field_must_not_be_null" 89 | expression: "this.imported_submessage != null" 90 | }, 91 | (buf.validate.field).cel = { 92 | id: "field_string_must_not_be_empty" 93 | expression: "this.imported_submessage.hex_string != ''" 94 | } 95 | ]; 96 | } 97 | 98 | message ExampleImportMessageInMap { 99 | option (buf.validate.message) = { 100 | cel: { 101 | id: "imported_submessage_must_not_be_null" 102 | expression: "this.imported_submessage[0] != null" 103 | } 104 | cel: { 105 | id: "hex_string_must_not_be_empty" 106 | expression: "this.imported_submessage[0].hex_string != ''" 107 | } 108 | }; 109 | map imported_submessage = 1; 110 | } 111 | 112 | message ExampleImportMessageInMapFieldRule { 113 | ExampleImportMessageInMap message_with_import = 1 [ 114 | (buf.validate.field).cel = { 115 | id: "field_must_not_be_null" 116 | expression: "this.imported_submessage[0] != null" 117 | }, 118 | (buf.validate.field).cel = { 119 | id: "field_string_must_not_be_empty" 120 | expression: "this.imported_submessage[0].hex_string != ''" 121 | } 122 | ]; 123 | } 124 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/ProtoAdapter.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import com.google.common.primitives.UnsignedLong; 18 | import com.google.protobuf.AbstractMessage; 19 | import com.google.protobuf.ByteString; 20 | import com.google.protobuf.Descriptors; 21 | import com.google.protobuf.Message; 22 | import com.google.protobuf.Timestamp; 23 | import dev.cel.common.values.CelByteString; 24 | import java.time.Duration; 25 | import java.time.Instant; 26 | import java.util.ArrayList; 27 | import java.util.Collections; 28 | import java.util.HashMap; 29 | import java.util.List; 30 | import java.util.Map; 31 | 32 | /** 33 | * CEL supports protobuf natively but when we pass it field values (like scalars, repeated, and 34 | * maps) it has no way to treat them like a proto message field. This class has methods to convert 35 | * to a cel values. 36 | */ 37 | final class ProtoAdapter { 38 | /** Converts a protobuf field value to CEL compatible value. */ 39 | static Object toCel(Descriptors.FieldDescriptor fieldDescriptor, Object value) { 40 | Descriptors.FieldDescriptor.Type type = fieldDescriptor.getType(); 41 | if (fieldDescriptor.isMapField()) { 42 | List input = 43 | value instanceof List 44 | ? (List) value 45 | : Collections.singletonList((AbstractMessage) value); 46 | Descriptors.FieldDescriptor keyDesc = fieldDescriptor.getMessageType().findFieldByNumber(1); 47 | Descriptors.FieldDescriptor valDesc = fieldDescriptor.getMessageType().findFieldByNumber(2); 48 | Map out = new HashMap<>(input.size()); 49 | 50 | for (AbstractMessage entry : input) { 51 | Object keyValue = entry.getField(keyDesc); 52 | Object valValue = entry.getField(valDesc); 53 | out.put(toCel(keyDesc, keyValue), toCel(valDesc, valValue)); 54 | } 55 | return out; 56 | } 57 | if (fieldDescriptor.isRepeated()) { 58 | List list = (List) value; 59 | List out = new ArrayList<>(list.size()); 60 | for (Object element : list) { 61 | out.add(scalarToCel(type, element)); 62 | } 63 | return out; 64 | } 65 | return scalarToCel(type, value); 66 | } 67 | 68 | /** Converts a scalar type to cel value. */ 69 | static Object scalarToCel(Descriptors.FieldDescriptor.Type type, Object value) { 70 | switch (type) { 71 | case BYTES: 72 | if (value instanceof ByteString) { 73 | return CelByteString.of(((ByteString) value).toByteArray()); 74 | } 75 | return value; 76 | case ENUM: 77 | if (value instanceof Descriptors.EnumValueDescriptor) { 78 | return (long) ((Descriptors.EnumValueDescriptor) value).getNumber(); 79 | } 80 | return value; 81 | case FLOAT: 82 | return Double.valueOf((Float) value); 83 | case INT32: 84 | case SINT32: 85 | case SFIXED32: 86 | return Long.valueOf((Integer) value); 87 | case FIXED32: 88 | case UINT32: 89 | return UnsignedLong.fromLongBits(Long.valueOf((Integer) value)); 90 | case UINT64: 91 | case FIXED64: 92 | return UnsignedLong.fromLongBits((Long) value); 93 | case MESSAGE: 94 | // cel-java 0.11.1 added support for java.time.Instant and java.time.Duration. 95 | Message msg = (Message) value; 96 | if (msg instanceof com.google.protobuf.Timestamp) { 97 | Timestamp timestamp = (Timestamp) value; 98 | return Instant.ofEpochSecond(timestamp.getSeconds(), timestamp.getNanos()); 99 | } 100 | if (msg instanceof com.google.protobuf.Duration) { 101 | com.google.protobuf.Duration duration = (com.google.protobuf.Duration) value; 102 | return Duration.ofSeconds(duration.getSeconds(), duration.getNanos()); 103 | } 104 | return value; 105 | default: 106 | return value; 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/FieldPathUtils.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import build.buf.validate.FieldPath; 18 | import build.buf.validate.FieldPathElement; 19 | import com.google.protobuf.Descriptors; 20 | import java.util.List; 21 | import org.jspecify.annotations.Nullable; 22 | 23 | /** Utility class for manipulating error paths in violations. */ 24 | final class FieldPathUtils { 25 | private FieldPathUtils() {} 26 | 27 | /** 28 | * Converts the provided field path to a string. 29 | * 30 | * @param fieldPath A field path to convert to a string. 31 | * @return The string representation of the provided field path. 32 | */ 33 | static String fieldPathString(FieldPath fieldPath) { 34 | StringBuilder builder = new StringBuilder(); 35 | for (FieldPathElement element : fieldPath.getElementsList()) { 36 | if (builder.length() > 0) { 37 | builder.append("."); 38 | } 39 | builder.append(element.getFieldName()); 40 | switch (element.getSubscriptCase()) { 41 | case INDEX: 42 | builder.append("["); 43 | builder.append(element.getIndex()); 44 | builder.append("]"); 45 | break; 46 | case BOOL_KEY: 47 | if (element.getBoolKey()) { 48 | builder.append("[true]"); 49 | } else { 50 | builder.append("[false]"); 51 | } 52 | break; 53 | case INT_KEY: 54 | builder.append("["); 55 | builder.append(element.getIntKey()); 56 | builder.append("]"); 57 | break; 58 | case UINT_KEY: 59 | builder.append("["); 60 | builder.append(element.getUintKey()); 61 | builder.append("]"); 62 | break; 63 | case STRING_KEY: 64 | builder.append("[\""); 65 | builder.append(element.getStringKey().replace("\\", "\\\\").replace("\"", "\\\"")); 66 | builder.append("\"]"); 67 | break; 68 | case SUBSCRIPT_NOT_SET: 69 | break; 70 | } 71 | } 72 | return builder.toString(); 73 | } 74 | 75 | /** 76 | * Returns the field path element that refers to the provided field descriptor. 77 | * 78 | * @param fieldDescriptor The field descriptor to generate a field path element for. 79 | * @return The field path element that corresponds to the provided field descriptor. 80 | */ 81 | static FieldPathElement fieldPathElement(Descriptors.FieldDescriptor fieldDescriptor) { 82 | String name; 83 | if (fieldDescriptor.isExtension()) { 84 | name = "[" + fieldDescriptor.getFullName() + "]"; 85 | } else { 86 | name = fieldDescriptor.getName(); 87 | } 88 | return FieldPathElement.newBuilder() 89 | .setFieldNumber(fieldDescriptor.getNumber()) 90 | .setFieldName(name) 91 | .setFieldType(fieldDescriptor.getType().toProto()) 92 | .build(); 93 | } 94 | 95 | /** 96 | * Provided a list of violations, adjusts it by prepending rule and field path elements. 97 | * 98 | * @param violations A list of violations. 99 | * @param fieldPathElement A field path element to prepend, or null. 100 | * @param rulePathElements Rule path elements to prepend. 101 | * @return For convenience, the list of violations passed into the violations parameter. 102 | */ 103 | static List updatePaths( 104 | List violations, 105 | @Nullable FieldPathElement fieldPathElement, 106 | List rulePathElements) { 107 | if (fieldPathElement != null || !rulePathElements.isEmpty()) { 108 | for (RuleViolation.Builder violation : violations) { 109 | for (int i = rulePathElements.size() - 1; i >= 0; i--) { 110 | violation.addFirstRulePathElement(rulePathElements.get(i)); 111 | } 112 | if (fieldPathElement != null) { 113 | violation.addFirstFieldPathElement(fieldPathElement); 114 | } 115 | } 116 | } 117 | return violations; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/test/java/build/buf/protovalidate/ValidationResultTest.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import static org.assertj.core.api.Assertions.assertThat; 18 | 19 | import build.buf.validate.FieldPathElement; 20 | import java.util.ArrayList; 21 | import java.util.List; 22 | import org.junit.jupiter.api.Test; 23 | 24 | class ValidationResultTest { 25 | @Test 26 | void testToStringNoViolations() { 27 | 28 | List violations = new ArrayList<>(); 29 | ValidationResult result = new ValidationResult(violations); 30 | 31 | assertThat(result.toString()).isEqualTo("Validation OK"); 32 | assertThat(result.isSuccess()).isTrue(); 33 | } 34 | 35 | @Test 36 | void testToStringSingleViolation() { 37 | FieldPathElement elem = 38 | FieldPathElement.newBuilder().setFieldNumber(5).setFieldName("test_field_name").build(); 39 | 40 | RuleViolation violation = 41 | RuleViolation.newBuilder() 42 | .setRuleId("int32.const") 43 | .setMessage("value must equal 42") 44 | .addFirstFieldPathElement(elem) 45 | .build(); 46 | List violations = new ArrayList<>(); 47 | violations.add(violation); 48 | ValidationResult result = new ValidationResult(violations); 49 | 50 | assertThat(result.toString()) 51 | .isEqualTo("Validation error:\n - test_field_name: value must equal 42 [int32.const]"); 52 | } 53 | 54 | @Test 55 | void testToStringMultipleViolations() { 56 | FieldPathElement elem = 57 | FieldPathElement.newBuilder().setFieldNumber(5).setFieldName("test_field_name").build(); 58 | 59 | RuleViolation violation1 = 60 | RuleViolation.newBuilder() 61 | .setRuleId("int32.const") 62 | .setMessage("value must equal 42") 63 | .addFirstFieldPathElement(elem) 64 | .build(); 65 | 66 | RuleViolation violation2 = 67 | RuleViolation.newBuilder() 68 | .setRuleId("int32.required") 69 | .setMessage("value is required") 70 | .addFirstFieldPathElement(elem) 71 | .build(); 72 | List violations = new ArrayList<>(); 73 | violations.add(violation1); 74 | violations.add(violation2); 75 | ValidationResult result = new ValidationResult(violations); 76 | 77 | assertThat(result.toString()) 78 | .isEqualTo( 79 | "Validation error:\n - test_field_name: value must equal 42 [int32.const]\n - test_field_name: value is required [int32.required]"); 80 | } 81 | 82 | @Test 83 | void testToStringSingleViolationMultipleFieldPathElements() { 84 | FieldPathElement elem1 = 85 | FieldPathElement.newBuilder().setFieldNumber(5).setFieldName("test_field_name").build(); 86 | FieldPathElement elem2 = 87 | FieldPathElement.newBuilder().setFieldNumber(5).setFieldName("nested_name").build(); 88 | 89 | List elems = new ArrayList<>(); 90 | elems.add(elem1); 91 | elems.add(elem2); 92 | 93 | RuleViolation violation1 = 94 | RuleViolation.newBuilder() 95 | .setRuleId("int32.const") 96 | .setMessage("value must equal 42") 97 | .addAllFieldPathElements(elems) 98 | .build(); 99 | 100 | List violations = new ArrayList<>(); 101 | violations.add(violation1); 102 | ValidationResult result = new ValidationResult(violations); 103 | 104 | assertThat(result.toString()) 105 | .isEqualTo( 106 | "Validation error:\n - test_field_name.nested_name: value must equal 42 [int32.const]"); 107 | } 108 | 109 | @Test 110 | void testToStringSingleViolationNoFieldPathElements() { 111 | RuleViolation violation = 112 | RuleViolation.newBuilder() 113 | .setRuleId("int32.const") 114 | .setMessage("value must equal 42") 115 | .build(); 116 | List violations = new ArrayList<>(); 117 | violations.add(violation); 118 | ValidationResult result = new ValidationResult(violations); 119 | 120 | assertThat(result.toString()) 121 | .isEqualTo("Validation error:\n - value must equal 42 [int32.const]"); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/FieldEvaluator.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import build.buf.protovalidate.exceptions.ExecutionException; 18 | import build.buf.validate.FieldPath; 19 | import build.buf.validate.FieldRules; 20 | import build.buf.validate.Ignore; 21 | import com.google.protobuf.Descriptors.FieldDescriptor; 22 | import com.google.protobuf.Message; 23 | import java.util.Collections; 24 | import java.util.List; 25 | 26 | /** Performs validation on a single message field, defined by its descriptor. */ 27 | final class FieldEvaluator implements Evaluator { 28 | private static final FieldDescriptor REQUIRED_DESCRIPTOR = 29 | FieldRules.getDescriptor().findFieldByNumber(FieldRules.REQUIRED_FIELD_NUMBER); 30 | 31 | private static final FieldPath REQUIRED_RULE_PATH = 32 | FieldPath.newBuilder() 33 | .addElements(FieldPathUtils.fieldPathElement(REQUIRED_DESCRIPTOR)) 34 | .build(); 35 | 36 | private final RuleViolationHelper helper; 37 | 38 | /** The {@link ValueEvaluator} to apply to the field's value */ 39 | final ValueEvaluator valueEvaluator; 40 | 41 | /** The {@link FieldDescriptor} targeted by this evaluator */ 42 | private final FieldDescriptor descriptor; 43 | 44 | /** Indicates that the field must have a set value. */ 45 | private final boolean required; 46 | 47 | /** Whether validation should be ignored for certain conditions */ 48 | private final Ignore ignore; 49 | 50 | /** Whether the field distinguishes between unpopulated and default values. */ 51 | private final boolean hasPresence; 52 | 53 | /** Constructs a new {@link FieldEvaluator} */ 54 | FieldEvaluator( 55 | ValueEvaluator valueEvaluator, 56 | FieldDescriptor descriptor, 57 | boolean required, 58 | boolean hasPresence, 59 | Ignore ignore) { 60 | this.helper = new RuleViolationHelper(valueEvaluator); 61 | this.valueEvaluator = valueEvaluator; 62 | this.descriptor = descriptor; 63 | this.required = required; 64 | this.hasPresence = hasPresence; 65 | this.ignore = ignore; 66 | } 67 | 68 | @Override 69 | public boolean tautology() { 70 | return !required && valueEvaluator.tautology(); 71 | } 72 | 73 | /** 74 | * Returns whether a field should always skip validation. 75 | * 76 | *

If true, this will take precedence and all checks are skipped. 77 | */ 78 | private boolean shouldIgnoreAlways() { 79 | return this.ignore == Ignore.IGNORE_ALWAYS; 80 | } 81 | 82 | /** 83 | * Returns whether a field should skip validation on its zero value. 84 | * 85 | *

This is generally true for nullable fields or fields with the ignore_empty rule explicitly 86 | * set. 87 | */ 88 | private boolean shouldIgnoreEmpty() { 89 | return this.hasPresence || this.ignore == Ignore.IGNORE_IF_ZERO_VALUE; 90 | } 91 | 92 | @Override 93 | public List evaluate(Value val, boolean failFast) 94 | throws ExecutionException { 95 | if (this.shouldIgnoreAlways()) { 96 | return RuleViolation.NO_VIOLATIONS; 97 | } 98 | Message message = val.messageValue(); 99 | if (message == null) { 100 | return RuleViolation.NO_VIOLATIONS; 101 | } 102 | boolean hasField; 103 | if (descriptor.isRepeated()) { 104 | hasField = message.getRepeatedFieldCount(descriptor) != 0; 105 | } else { 106 | hasField = message.hasField(descriptor); 107 | } 108 | if (required && !hasField) { 109 | return Collections.singletonList( 110 | RuleViolation.newBuilder() 111 | .addFirstFieldPathElement(FieldPathUtils.fieldPathElement(descriptor)) 112 | .addAllRulePathElements(helper.getRulePrefixElements()) 113 | .addAllRulePathElements(REQUIRED_RULE_PATH.getElementsList()) 114 | .setRuleId("required") 115 | .setMessage("value is required") 116 | .setRuleValue(new RuleViolation.FieldValue(true, REQUIRED_DESCRIPTOR))); 117 | } 118 | if (this.shouldIgnoreEmpty() && !hasField) { 119 | return RuleViolation.NO_VIOLATIONS; 120 | } 121 | return valueEvaluator.evaluate( 122 | new ObjectValue(descriptor, message.getField(descriptor)), failFast); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/CompiledProgram.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import build.buf.protovalidate.exceptions.ExecutionException; 18 | import build.buf.validate.FieldPath; 19 | import dev.cel.runtime.CelEvaluationException; 20 | import dev.cel.runtime.CelRuntime.Program; 21 | import dev.cel.runtime.CelVariableResolver; 22 | import org.jspecify.annotations.Nullable; 23 | 24 | /** 25 | * {@link CompiledProgram} is a parsed and type-checked {@link Program} along with the source {@link 26 | * Expression}. 27 | */ 28 | final class CompiledProgram { 29 | /** A compiled CEL program that can be evaluated against a set of variable bindings. */ 30 | private final Program program; 31 | 32 | /** The original expression that was compiled into the program from the proto file. */ 33 | private final Expression source; 34 | 35 | /** The field path from FieldRules to the rule value. */ 36 | @Nullable private final FieldPath rulePath; 37 | 38 | /** The rule value. */ 39 | @Nullable private final Value ruleValue; 40 | 41 | /** 42 | * Global variables to pass to the evaluation step. Program/CelRuntime doesn't have a concept of 43 | * global variables. 44 | */ 45 | @Nullable private final CelVariableResolver globals; 46 | 47 | /** 48 | * Constructs a new {@link CompiledProgram}. 49 | * 50 | * @param program The compiled CEL program. 51 | * @param source The original expression that was compiled into the program. 52 | * @param rulePath The field path from the FieldRules to the rule value. 53 | * @param ruleValue The rule value. 54 | */ 55 | CompiledProgram( 56 | Program program, 57 | Expression source, 58 | @Nullable FieldPath rulePath, 59 | @Nullable Value ruleValue, 60 | @Nullable CelVariableResolver globals) { 61 | this.program = program; 62 | this.source = source; 63 | this.rulePath = rulePath; 64 | this.ruleValue = ruleValue; 65 | this.globals = globals; 66 | } 67 | 68 | /** 69 | * Evaluate the compiled program with a given set of {@link Variable} variables. 70 | * 71 | * @param variables Variables used for the evaluation. 72 | * @param fieldValue Field value to return in violations. 73 | * @return The {@link build.buf.validate.Violation} from the evaluation, or null if there are no 74 | * violations. 75 | * @throws ExecutionException If the evaluation of the CEL program fails with an error. 76 | */ 77 | RuleViolation.@Nullable Builder eval(Value fieldValue, CelVariableResolver variables) 78 | throws ExecutionException { 79 | Object value; 80 | try { 81 | if (this.globals != null) { 82 | variables = CelVariableResolver.hierarchicalVariableResolver(variables, this.globals); 83 | } 84 | value = program.eval(variables); 85 | } catch (CelEvaluationException e) { 86 | throw new ExecutionException(String.format("error evaluating %s: %s", source.id, e)); 87 | } 88 | if (value instanceof String) { 89 | if ("".equals(value)) { 90 | return null; 91 | } 92 | RuleViolation.Builder builder = 93 | RuleViolation.newBuilder().setRuleId(this.source.id).setMessage(value.toString()); 94 | if (fieldValue.fieldDescriptor() != null) { 95 | builder.setFieldValue(new RuleViolation.FieldValue(fieldValue)); 96 | } 97 | if (rulePath != null) { 98 | builder.addAllRulePathElements(rulePath.getElementsList()); 99 | } 100 | if (ruleValue != null && ruleValue.fieldDescriptor() != null) { 101 | builder.setRuleValue(new RuleViolation.FieldValue(ruleValue)); 102 | } 103 | return builder; 104 | } else if (value instanceof Boolean) { 105 | if (Boolean.TRUE.equals(value)) { 106 | return null; 107 | } 108 | String message = this.source.message; 109 | if (message.isEmpty()) { 110 | message = String.format("\"%s\" returned false", this.source.expression); 111 | } 112 | RuleViolation.Builder builder = 113 | RuleViolation.newBuilder().setRuleId(this.source.id).setMessage(message); 114 | if (rulePath != null) { 115 | builder.addAllRulePathElements(rulePath.getElementsList()); 116 | } 117 | if (ruleValue != null && ruleValue.fieldDescriptor() != null) { 118 | builder.setRuleValue(new RuleViolation.FieldValue(ruleValue)); 119 | } 120 | return builder; 121 | } else { 122 | throw new ExecutionException(String.format("resolved to an unexpected type %s", value)); 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /conformance/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.diffplug.gradle.spotless.SpotlessExtension 2 | import net.ltgt.gradle.errorprone.errorprone 3 | 4 | plugins { 5 | `version-catalog` 6 | 7 | application 8 | java 9 | alias(libs.plugins.errorprone) 10 | alias(libs.plugins.osdetector) 11 | } 12 | 13 | // Conformance tests aren't bound by lowest common library version. 14 | java { 15 | sourceCompatibility = JavaVersion.VERSION_21 16 | targetCompatibility = JavaVersion.VERSION_21 17 | } 18 | 19 | val buf: Configuration by configurations.creating 20 | 21 | tasks.register("configureBuf") { 22 | description = "Installs the Buf CLI." 23 | File(buf.asPath).setExecutable(true) 24 | } 25 | 26 | val conformanceCLIFile = 27 | project.layout.buildDirectory 28 | .file("gobin/protovalidate-conformance") 29 | .get() 30 | .asFile 31 | val conformanceCLIPath: String = conformanceCLIFile.absolutePath 32 | val conformanceAppScript: String = 33 | project.layout.buildDirectory 34 | .file("install/conformance/bin/conformance") 35 | .get() 36 | .asFile.absolutePath 37 | val conformanceArgs = (project.findProperty("protovalidate.conformance.args")?.toString() ?: "").split("\\s+".toRegex()) 38 | 39 | tasks.register("installProtovalidateConformance") { 40 | description = "Installs the Protovalidate Conformance CLI." 41 | environment("GOBIN", conformanceCLIFile.parentFile.absolutePath) 42 | outputs.file(conformanceCLIFile) 43 | commandLine( 44 | "go", 45 | "install", 46 | "github.com/bufbuild/protovalidate/tools/protovalidate-conformance@${project.findProperty("protovalidate.version")}", 47 | ) 48 | } 49 | 50 | tasks.register("conformance") { 51 | dependsOn("installDist", "installProtovalidateConformance") 52 | description = "Runs protovalidate conformance tests." 53 | commandLine(*(listOf(conformanceCLIPath) + conformanceArgs + listOf(conformanceAppScript)).toTypedArray()) 54 | } 55 | 56 | tasks.register("filterBufGenYaml") { 57 | from(".") 58 | include("buf.gen.yaml") 59 | includeEmptyDirs = false 60 | into(layout.buildDirectory.dir("buf-gen-templates")) 61 | expand("protocJavaPluginVersion" to "v${libs.versions.protobuf.get().substringAfter('.')}") 62 | filteringCharset = "UTF-8" 63 | } 64 | 65 | val semverRegex = Regex("""^v\d+\.\d+\.\d+(?:-.+)?$""") 66 | tasks.register("generateConformance") { 67 | dependsOn("configureBuf", "filterBufGenYaml") 68 | description = "Generates sources for the bufbuild/protovalidate-testing module to build/generated/sources/bufgen." 69 | val version = "${project.findProperty("protovalidate.version")}" 70 | var input = "buf.build/bufbuild/protovalidate-testing:$version" 71 | if (!semverRegex.matches(version)) { 72 | input = "https://github.com/bufbuild/protovalidate.git#subdir=proto/protovalidate-testing,ref=$version" 73 | } 74 | commandLine( 75 | buf.asPath, 76 | "generate", 77 | "--template", 78 | "${layout.buildDirectory.get()}/buf-gen-templates/buf.gen.yaml", 79 | input, 80 | ) 81 | } 82 | 83 | sourceSets { 84 | main { 85 | java { 86 | srcDir(layout.buildDirectory.dir("generated/sources/bufgen")) 87 | } 88 | } 89 | } 90 | 91 | tasks.withType { 92 | dependsOn("generateConformance") 93 | if (JavaVersion.current().isJava9Compatible) { 94 | doFirst { 95 | options.compilerArgs = mutableListOf("--release", "8") 96 | } 97 | } 98 | // Disable errorprone on generated code 99 | options.errorprone.excludedPaths.set(".*/build/generated/sources/bufgen/.*") 100 | } 101 | 102 | // Disable javadoc for conformance tests 103 | tasks.withType { 104 | enabled = false 105 | } 106 | 107 | application { 108 | mainClass.set("build.buf.protovalidate.conformance.Main") 109 | } 110 | 111 | tasks { 112 | jar { 113 | dependsOn(":jar") 114 | manifest { 115 | attributes(mapOf("Main-Class" to "build.buf.protovalidate.conformance.Main")) 116 | } 117 | duplicatesStrategy = DuplicatesStrategy.INCLUDE 118 | // This line of code recursively collects and copies all of a project's files 119 | // and adds them to the JAR itself. One can extend this task, to skip certain 120 | // files or particular types at will 121 | val sourcesMain = sourceSets.main.get() 122 | val contents = 123 | configurations.runtimeClasspath 124 | .get() 125 | .map { if (it.isDirectory) it else zipTree(it) } + 126 | sourcesMain.output 127 | from(contents) 128 | } 129 | } 130 | 131 | apply(plugin = "com.diffplug.spotless") 132 | configure { 133 | java { 134 | targetExclude("build/generated/sources/bufgen/**/*.java") 135 | } 136 | } 137 | 138 | dependencies { 139 | implementation(project(":")) 140 | implementation(libs.errorprone.annotations) 141 | implementation(libs.protobuf.java) 142 | 143 | implementation(libs.assertj) 144 | implementation(platform(libs.junit.bom)) 145 | 146 | buf("build.buf:buf:${libs.versions.buf.get()}:${osdetector.classifier}@exe") 147 | 148 | testImplementation("org.junit.jupiter:junit-jupiter") 149 | testRuntimeOnly("org.junit.platform:junit-platform-launcher") 150 | 151 | errorprone(libs.errorprone.core) 152 | } 153 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![The Buf logo](.github/buf-logo.svg)][buf] 2 | 3 | # protovalidate-java 4 | 5 | [![CI](https://github.com/bufbuild/protovalidate-java/actions/workflows/ci.yaml/badge.svg)](https://github.com/bufbuild/protovalidate-java/actions/workflows/ci.yaml) 6 | [![Conformance](https://github.com/bufbuild/protovalidate-java/actions/workflows/conformance.yaml/badge.svg)](https://github.com/bufbuild/protovalidate-java/actions/workflows/conformance.yaml) 7 | [![BSR](https://img.shields.io/badge/BSR-Module-0C65EC)][buf-mod] 8 | 9 | [Protovalidate][protovalidate] is the semantic validation library for Protobuf. It provides standard annotations to validate common rules on messages and fields, as well as the ability to use [CEL][cel] to write custom rules. It's the next generation of [protoc-gen-validate][protoc-gen-validate]. 10 | 11 | With Protovalidate, you can annotate your Protobuf messages with both standard and custom validation rules: 12 | 13 | ```protobuf 14 | syntax = "proto3"; 15 | 16 | package acme.user.v1; 17 | 18 | import "buf/validate/validate.proto"; 19 | 20 | message User { 21 | string id = 1 [(buf.validate.field).string.uuid = true]; 22 | uint32 age = 2 [(buf.validate.field).uint32.lte = 150]; // We can only hope. 23 | string email = 3 [(buf.validate.field).string.email = true]; 24 | string first_name = 4 [(buf.validate.field).string.max_len = 64]; 25 | string last_name = 5 [(buf.validate.field).string.max_len = 64]; 26 | 27 | option (buf.validate.message).cel = { 28 | id: "first_name_requires_last_name" 29 | message: "last_name must be present if first_name is present" 30 | expression: "!has(this.first_name) || has(this.last_name)" 31 | }; 32 | } 33 | ``` 34 | 35 | Once you've added `protovalidate-java` to your project, validation is idiomatic Java: 36 | 37 | ```java 38 | ValidationResult result = validator.validate(message); 39 | if (!result.isSuccess()) { 40 | // Handle failure. 41 | } 42 | ``` 43 | 44 | ## Installation 45 | 46 | > [!TIP] 47 | > The easiest way to get started with Protovalidate for RPC APIs are the quickstarts in Buf's documentation. There's one available for [Java and gRPC][grpc-java]. 48 | 49 | `protovalidate-java` is listed in [Maven Central][maven], which provides installation snippets for Gradle, Maven, and other package managers. In Gradle, it's: 50 | 51 | ```gradle 52 | dependencies { 53 | implementation 'build.buf:protovalidate:' 54 | } 55 | ``` 56 | 57 | ## Documentation 58 | 59 | Comprehensive documentation for Protovalidate is available at [protovalidate.com][protovalidate]. 60 | 61 | Highlights for Java developers include: 62 | 63 | * The [developer quickstart][quickstart] 64 | * A comprehensive RPC quickstart for [Java and gRPC][grpc-java] 65 | * A [migration guide for protoc-gen-validate][migration-guide] users 66 | 67 | ## Additional languages and repositories 68 | 69 | Protovalidate isn't just for Java! You might be interested in sibling repositories for other languages: 70 | 71 | - [`protovalidate-go`][pv-go] (Go) 72 | - [`protovalidate-python`][pv-python] (Python) 73 | - [`protovalidate-cc`][pv-cc] (C++) 74 | - [`protovalidate-es`][pv-es] (TypeScript and JavaScript) 75 | 76 | Additionally, [protovalidate's core repository](https://github.com/bufbuild/protovalidate) provides: 77 | 78 | - [Protovalidate's Protobuf API][validate-proto] 79 | - [Conformance testing utilities][conformance] for acceptance testing of `protovalidate` implementations 80 | 81 | 82 | ## Contributing 83 | 84 | We genuinely appreciate any help! If you'd like to contribute, check out these resources: 85 | 86 | - [Contributing Guidelines][contributing]: Guidelines to make your contribution process straightforward and meaningful 87 | - [Conformance testing utilities](https://github.com/bufbuild/protovalidate/tree/main/docs/conformance.md): Utilities providing acceptance testing of `protovalidate` implementations 88 | 89 | ## Legal 90 | 91 | Offered under the [Apache 2 license][license]. 92 | 93 | [buf]: https://buf.build 94 | [cel]: https://cel.dev 95 | 96 | [pv-go]: https://github.com/bufbuild/protovalidate-go 97 | [pv-java]: https://github.com/bufbuild/protovalidate-java 98 | [pv-python]: https://github.com/bufbuild/protovalidate-python 99 | [pv-cc]: https://github.com/bufbuild/protovalidate-cc 100 | [pv-es]: https://github.com/bufbuild/protovalidate-es 101 | 102 | [license]: LICENSE 103 | [contributing]: .github/CONTRIBUTING.md 104 | [buf-mod]: https://buf.build/bufbuild/protovalidate 105 | 106 | [protoc-gen-validate]: https://github.com/bufbuild/protoc-gen-validate 107 | 108 | [protovalidate]: https://protovalidate.com 109 | [quickstart]: https://protovalidate.com/quickstart/ 110 | [connect-go]: https://protovalidate.com/quickstart/connect-go/ 111 | [grpc-go]: https://protovalidate.com/quickstart/grpc-go/ 112 | [grpc-java]: https://protovalidate.com/quickstart/grpc-java/ 113 | [grpc-python]: https://protovalidate.com/quickstart/grpc-python/ 114 | [migration-guide]: https://protovalidate.com/migration-guides/migrate-from-protoc-gen-validate/ 115 | 116 | [maven]: https://central.sonatype.com/artifact/build.buf/protovalidate/overview 117 | [pkg-go]: https://pkg.go.dev/github.com/bufbuild/protovalidate-go 118 | 119 | [validate-proto]: https://buf.build/bufbuild/protovalidate/docs/main:buf.validate 120 | [conformance]: https://github.com/bufbuild/protovalidate/blob/main/docs/conformance.md 121 | [examples]: https://github.com/bufbuild/protovalidate/tree/main/examples 122 | [migrate]: https://protovalidate.com/migration-guides/migrate-from-protoc-gen-validate/ 123 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/AnyEvaluator.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import build.buf.protovalidate.exceptions.ExecutionException; 18 | import build.buf.validate.AnyRules; 19 | import build.buf.validate.FieldPath; 20 | import build.buf.validate.FieldRules; 21 | import com.google.protobuf.Descriptors; 22 | import com.google.protobuf.Message; 23 | import java.util.ArrayList; 24 | import java.util.Collections; 25 | import java.util.HashSet; 26 | import java.util.List; 27 | import java.util.Set; 28 | 29 | /** 30 | * A specialized evaluator for applying {@link build.buf.validate.AnyRules} to an {@link 31 | * com.google.protobuf.Any} message. This is handled outside CEL which attempts to hydrate {@link 32 | * com.google.protobuf.Any}'s within an expression, breaking evaluation if the type is unknown at 33 | * runtime. 34 | */ 35 | final class AnyEvaluator implements Evaluator { 36 | private final RuleViolationHelper helper; 37 | private final Descriptors.FieldDescriptor typeURLDescriptor; 38 | private final Set in; 39 | private final List inValue; 40 | private final Set notIn; 41 | private final List notInValue; 42 | 43 | private static final Descriptors.FieldDescriptor ANY_DESCRIPTOR = 44 | FieldRules.getDescriptor().findFieldByNumber(FieldRules.ANY_FIELD_NUMBER); 45 | 46 | private static final Descriptors.FieldDescriptor IN_DESCRIPTOR = 47 | AnyRules.getDescriptor().findFieldByNumber(AnyRules.IN_FIELD_NUMBER); 48 | 49 | private static final Descriptors.FieldDescriptor NOT_IN_DESCRIPTOR = 50 | AnyRules.getDescriptor().findFieldByNumber(AnyRules.NOT_IN_FIELD_NUMBER); 51 | 52 | private static final FieldPath IN_RULE_PATH = 53 | FieldPath.newBuilder() 54 | .addElements(FieldPathUtils.fieldPathElement(ANY_DESCRIPTOR)) 55 | .addElements(FieldPathUtils.fieldPathElement(IN_DESCRIPTOR)) 56 | .build(); 57 | 58 | private static final FieldPath NOT_IN_RULE_PATH = 59 | FieldPath.newBuilder() 60 | .addElements(FieldPathUtils.fieldPathElement(ANY_DESCRIPTOR)) 61 | .addElements(FieldPathUtils.fieldPathElement(NOT_IN_DESCRIPTOR)) 62 | .build(); 63 | 64 | /** Constructs a new evaluator for {@link build.buf.validate.AnyRules} messages. */ 65 | AnyEvaluator( 66 | ValueEvaluator valueEvaluator, 67 | Descriptors.FieldDescriptor typeURLDescriptor, 68 | List in, 69 | List notIn) { 70 | this.helper = new RuleViolationHelper(valueEvaluator); 71 | this.typeURLDescriptor = typeURLDescriptor; 72 | this.in = stringsToSet(in); 73 | this.inValue = in; 74 | this.notIn = stringsToSet(notIn); 75 | this.notInValue = notIn; 76 | } 77 | 78 | @Override 79 | public List evaluate(Value val, boolean failFast) 80 | throws ExecutionException { 81 | Message anyValue = val.messageValue(); 82 | if (anyValue == null) { 83 | return RuleViolation.NO_VIOLATIONS; 84 | } 85 | List violationList = new ArrayList<>(); 86 | String typeURL = (String) anyValue.getField(typeURLDescriptor); 87 | if (!in.isEmpty() && !in.contains(typeURL)) { 88 | RuleViolation.Builder violation = 89 | RuleViolation.newBuilder() 90 | .addAllRulePathElements(helper.getRulePrefixElements()) 91 | .addAllRulePathElements(IN_RULE_PATH.getElementsList()) 92 | .addFirstFieldPathElement(helper.getFieldPathElement()) 93 | .setRuleId("any.in") 94 | .setMessage("type URL must be in the allow list") 95 | .setFieldValue(new RuleViolation.FieldValue(val)) 96 | .setRuleValue(new RuleViolation.FieldValue(this.inValue, IN_DESCRIPTOR)); 97 | violationList.add(violation); 98 | if (failFast) { 99 | return violationList; 100 | } 101 | } 102 | if (!notIn.isEmpty() && notIn.contains(typeURL)) { 103 | RuleViolation.Builder violation = 104 | RuleViolation.newBuilder() 105 | .addAllRulePathElements(helper.getRulePrefixElements()) 106 | .addAllRulePathElements(NOT_IN_RULE_PATH.getElementsList()) 107 | .addFirstFieldPathElement(helper.getFieldPathElement()) 108 | .setRuleId("any.not_in") 109 | .setMessage("type URL must not be in the block list") 110 | .setFieldValue(new RuleViolation.FieldValue(val)) 111 | .setRuleValue(new RuleViolation.FieldValue(this.notInValue, NOT_IN_DESCRIPTOR)); 112 | violationList.add(violation); 113 | } 114 | return violationList; 115 | } 116 | 117 | @Override 118 | public boolean tautology() { 119 | return in.isEmpty() && notIn.isEmpty(); 120 | } 121 | 122 | /** stringsToMap converts a string list to a set for fast lookup. */ 123 | private static Set stringsToSet(List strings) { 124 | if (strings.isEmpty()) { 125 | return Collections.emptySet(); 126 | } 127 | return new HashSet<>(strings); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/Ipv4.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import java.util.ArrayList; 18 | import java.util.List; 19 | 20 | /** 21 | * Ipv4 is a class used to parse a given string to determine if it is an IPv4 address or address 22 | * prefix. 23 | */ 24 | final class Ipv4 { 25 | private final String str; 26 | private int index; 27 | private final List octets; 28 | private int prefixLen; 29 | 30 | Ipv4(String str) { 31 | this.str = str; 32 | this.octets = new ArrayList<>(); 33 | } 34 | 35 | /** 36 | * Returns the 32-bit value of an address parsed through address() or addressPrefix(). 37 | * 38 | *

Returns -1 if no address was parsed successfully. 39 | */ 40 | int getBits() { 41 | if (this.octets.size() != 4) { 42 | return -1; 43 | } 44 | return (this.octets.get(0) << 24) 45 | | (this.octets.get(1) << 16) 46 | | (this.octets.get(2) << 8) 47 | | this.octets.get(3); 48 | } 49 | 50 | /** 51 | * Returns true if all bits to the right of the prefix-length are all zeros. 52 | * 53 | *

Behavior is undefined if addressPrefix() has not been called before, or has returned false. 54 | */ 55 | boolean isPrefixOnly() { 56 | int bits = this.getBits(); 57 | 58 | int mask = 0; 59 | if (this.prefixLen == 32) { 60 | mask = 0xffffffff; 61 | } else { 62 | mask = ~(0xffffffff >>> this.prefixLen); 63 | } 64 | 65 | int masked = bits & mask; 66 | 67 | return bits == masked; 68 | } 69 | 70 | // Parses an IPv4 Address in dotted decimal notation. 71 | boolean address() { 72 | return this.addressPart() && this.index == this.str.length(); 73 | } 74 | 75 | // Parses an IPv4 Address prefix. 76 | boolean addressPrefix() { 77 | return this.addressPart() 78 | && this.take('/') 79 | && this.prefixLength() 80 | && this.index == this.str.length(); 81 | } 82 | 83 | // Store value in prefixLen 84 | private boolean prefixLength() { 85 | int start = this.index; 86 | 87 | while (this.index < this.str.length() && this.digit()) { 88 | if (this.index - start > 2) { 89 | // max prefix-length is 32 bits, so anything more than 2 digits is invalid 90 | return false; 91 | } 92 | } 93 | 94 | String str = this.str.substring(start, this.index); 95 | if (str.isEmpty()) { 96 | // too short 97 | return false; 98 | } 99 | 100 | if (str.length() > 1 && str.charAt(0) == '0') { 101 | // bad leading 0 102 | return false; 103 | } 104 | 105 | try { 106 | int val = Integer.parseInt(str); 107 | 108 | if (val > 32) { 109 | // max 32 bits 110 | return false; 111 | } 112 | 113 | this.prefixLen = val; 114 | return true; 115 | } catch (NumberFormatException nfe) { 116 | return false; 117 | } 118 | } 119 | 120 | private boolean addressPart() { 121 | int start = this.index; 122 | 123 | if (this.decOctet() 124 | && this.take('.') 125 | && this.decOctet() 126 | && this.take('.') 127 | && this.decOctet() 128 | && this.take('.') 129 | && this.decOctet()) { 130 | return true; 131 | } 132 | 133 | this.index = start; 134 | 135 | return false; 136 | } 137 | 138 | private boolean decOctet() { 139 | int start = this.index; 140 | 141 | while (this.index < this.str.length() && this.digit()) { 142 | if (this.index - start > 3) { 143 | // decimal octet can be three characters at most 144 | return false; 145 | } 146 | } 147 | 148 | String str = this.str.substring(start, this.index); 149 | if (str.isEmpty()) { 150 | // too short 151 | return false; 152 | } 153 | 154 | if (str.length() > 1 && str.charAt(0) == '0') { 155 | // bad leading 0 156 | return false; 157 | } 158 | 159 | try { 160 | int val = Integer.parseInt(str); 161 | 162 | if (val > 255) { 163 | return false; 164 | } 165 | 166 | this.octets.add((short) val); 167 | 168 | return true; 169 | } catch (NumberFormatException nfe) { 170 | // Error converting to number 171 | return false; 172 | } 173 | } 174 | 175 | /** 176 | * Determines whether the current position is a digit. 177 | * 178 | *

Parses the rule: 179 | * 180 | *

DIGIT = %x30-39 ; 0-9
181 |    */
182 |   private boolean digit() {
183 |     char c = this.str.charAt(this.index);
184 |     if ('0' <= c && c <= '9') {
185 |       this.index++;
186 |       return true;
187 |     }
188 |     return false;
189 |   }
190 | 
191 |   /** Take the given char at the current position, incrementing the index if necessary. */
192 |   private boolean take(char c) {
193 |     if (this.index >= this.str.length()) {
194 |       return false;
195 |     }
196 | 
197 |     if (this.str.charAt(this.index) == c) {
198 |       this.index++;
199 |       return true;
200 |     }
201 | 
202 |     return false;
203 |   }
204 | }
205 | 


--------------------------------------------------------------------------------
/conformance/src/main/java/build/buf/protovalidate/conformance/FileDescriptorUtil.java:
--------------------------------------------------------------------------------
  1 | // Copyright 2023-2025 Buf Technologies, Inc.
  2 | //
  3 | // Licensed under the Apache License, Version 2.0 (the "License");
  4 | // you may not use this file except in compliance with the License.
  5 | // You may obtain a copy of the License at
  6 | //
  7 | //      http://www.apache.org/licenses/LICENSE-2.0
  8 | //
  9 | // Unless required by applicable law or agreed to in writing, software
 10 | // distributed under the License is distributed on an "AS IS" BASIS,
 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 12 | // See the License for the specific language governing permissions and
 13 | // limitations under the License.
 14 | 
 15 | package build.buf.protovalidate.conformance;
 16 | 
 17 | import com.google.protobuf.DescriptorProtos;
 18 | import com.google.protobuf.Descriptors;
 19 | import com.google.protobuf.DynamicMessage;
 20 | import com.google.protobuf.ExtensionRegistry;
 21 | import com.google.protobuf.TypeRegistry;
 22 | import java.util.ArrayList;
 23 | import java.util.HashMap;
 24 | import java.util.List;
 25 | import java.util.Map;
 26 | 
 27 | class FileDescriptorUtil {
 28 |   static Map parse(
 29 |       DescriptorProtos.FileDescriptorSet fileDescriptorSet)
 30 |       throws Descriptors.DescriptorValidationException {
 31 |     Map descriptorMap = new HashMap<>();
 32 |     Map fileDescriptorMap =
 33 |         parseFileDescriptors(fileDescriptorSet);
 34 |     for (Descriptors.FileDescriptor fileDescriptor : fileDescriptorMap.values()) {
 35 |       for (Descriptors.Descriptor messageType : fileDescriptor.getMessageTypes()) {
 36 |         descriptorMap.put(messageType.getFullName(), messageType);
 37 |       }
 38 |     }
 39 |     return descriptorMap;
 40 |   }
 41 | 
 42 |   static Map parseFileDescriptors(
 43 |       DescriptorProtos.FileDescriptorSet fileDescriptorSet)
 44 |       throws Descriptors.DescriptorValidationException {
 45 |     Map fileDescriptorProtoMap = new HashMap<>();
 46 |     for (DescriptorProtos.FileDescriptorProto fileDescriptorProto :
 47 |         fileDescriptorSet.getFileList()) {
 48 |       if (fileDescriptorProtoMap.containsKey(fileDescriptorProto.getName())) {
 49 |         throw new RuntimeException("duplicate files found.");
 50 |       }
 51 |       fileDescriptorProtoMap.put(fileDescriptorProto.getName(), fileDescriptorProto);
 52 |     }
 53 |     Map fileDescriptorMap = new HashMap<>();
 54 |     for (DescriptorProtos.FileDescriptorProto fileDescriptorProto :
 55 |         fileDescriptorSet.getFileList()) {
 56 |       if (fileDescriptorProto.getDependencyList().isEmpty()) {
 57 |         fileDescriptorMap.put(
 58 |             fileDescriptorProto.getName(),
 59 |             Descriptors.FileDescriptor.buildFrom(
 60 |                 fileDescriptorProto, new Descriptors.FileDescriptor[0], false));
 61 |         continue;
 62 |       }
 63 |       List dependencies = new ArrayList<>();
 64 |       for (String dependency : fileDescriptorProto.getDependencyList()) {
 65 |         if (fileDescriptorMap.get(dependency) != null) {
 66 |           dependencies.add(fileDescriptorMap.get(dependency));
 67 |         }
 68 |       }
 69 |       fileDescriptorMap.put(
 70 |           fileDescriptorProto.getName(),
 71 |           Descriptors.FileDescriptor.buildFrom(
 72 |               fileDescriptorProto, dependencies.toArray(new Descriptors.FileDescriptor[0]), false));
 73 |     }
 74 |     return fileDescriptorMap;
 75 |   }
 76 | 
 77 |   static TypeRegistry createTypeRegistry(
 78 |       Iterable fileDescriptors) {
 79 |     TypeRegistry.Builder registryBuilder = TypeRegistry.newBuilder();
 80 |     for (Descriptors.FileDescriptor fileDescriptor : fileDescriptors) {
 81 |       registryBuilder.add(fileDescriptor.getMessageTypes());
 82 |     }
 83 |     return registryBuilder.build();
 84 |   }
 85 | 
 86 |   static ExtensionRegistry createExtensionRegistry(
 87 |       Iterable fileDescriptors) {
 88 |     ExtensionRegistry registry = ExtensionRegistry.newInstance();
 89 |     for (Descriptors.FileDescriptor fileDescriptor : fileDescriptors) {
 90 |       registerFileExtensions(registry, fileDescriptor);
 91 |     }
 92 |     return registry;
 93 |   }
 94 | 
 95 |   private static void registerFileExtensions(
 96 |       ExtensionRegistry registry, Descriptors.FileDescriptor fileDescriptor) {
 97 |     registerExtensions(registry, fileDescriptor.getExtensions());
 98 |     for (Descriptors.Descriptor descriptor : fileDescriptor.getMessageTypes()) {
 99 |       registerMessageExtensions(registry, descriptor);
100 |     }
101 |   }
102 | 
103 |   private static void registerMessageExtensions(
104 |       ExtensionRegistry registry, Descriptors.Descriptor descriptor) {
105 |     registerExtensions(registry, descriptor.getExtensions());
106 |     for (Descriptors.Descriptor nestedDescriptor : descriptor.getNestedTypes()) {
107 |       registerMessageExtensions(registry, nestedDescriptor);
108 |     }
109 |   }
110 | 
111 |   private static void registerExtensions(
112 |       ExtensionRegistry registry, List extensions) {
113 |     for (Descriptors.FieldDescriptor fieldDescriptor : extensions) {
114 |       if (fieldDescriptor.getJavaType() == Descriptors.FieldDescriptor.JavaType.MESSAGE) {
115 |         registry.add(
116 |             fieldDescriptor, DynamicMessage.getDefaultInstance(fieldDescriptor.getMessageType()));
117 |       } else {
118 |         registry.add(fieldDescriptor);
119 |       }
120 |     }
121 |   }
122 | }
123 | 


--------------------------------------------------------------------------------
/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java:
--------------------------------------------------------------------------------
  1 | // Copyright 2023-2025 Buf Technologies, Inc.
  2 | //
  3 | // Licensed under the Apache License, Version 2.0 (the "License");
  4 | // you may not use this file except in compliance with the License.
  5 | // You may obtain a copy of the License at
  6 | //
  7 | //      http://www.apache.org/licenses/LICENSE-2.0
  8 | //
  9 | // Unless required by applicable law or agreed to in writing, software
 10 | // distributed under the License is distributed on an "AS IS" BASIS,
 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 12 | // See the License for the specific language governing permissions and
 13 | // limitations under the License.
 14 | 
 15 | package build.buf.protovalidate.conformance;
 16 | 
 17 | import build.buf.protovalidate.Config;
 18 | import build.buf.protovalidate.ValidationResult;
 19 | import build.buf.protovalidate.Validator;
 20 | import build.buf.protovalidate.ValidatorFactory;
 21 | import build.buf.protovalidate.exceptions.CompilationException;
 22 | import build.buf.protovalidate.exceptions.ExecutionException;
 23 | import build.buf.validate.ValidateProto;
 24 | import build.buf.validate.Violations;
 25 | import build.buf.validate.conformance.harness.TestConformanceRequest;
 26 | import build.buf.validate.conformance.harness.TestConformanceResponse;
 27 | import build.buf.validate.conformance.harness.TestResult;
 28 | import com.google.errorprone.annotations.FormatMethod;
 29 | import com.google.protobuf.Any;
 30 | import com.google.protobuf.ByteString;
 31 | import com.google.protobuf.Descriptors;
 32 | import com.google.protobuf.DynamicMessage;
 33 | import com.google.protobuf.ExtensionRegistry;
 34 | import com.google.protobuf.InvalidProtocolBufferException;
 35 | import com.google.protobuf.TypeRegistry;
 36 | import java.util.HashMap;
 37 | import java.util.Map;
 38 | 
 39 | public class Main {
 40 |   public static void main(String[] args) {
 41 |     try {
 42 |       ExtensionRegistry extensionRegistry = ExtensionRegistry.newInstance();
 43 |       extensionRegistry.add(ValidateProto.message);
 44 |       extensionRegistry.add(ValidateProto.field);
 45 |       extensionRegistry.add(ValidateProto.oneof);
 46 |       TestConformanceRequest request =
 47 |           TestConformanceRequest.parseFrom(System.in, extensionRegistry);
 48 |       TestConformanceResponse response = testConformance(request);
 49 |       response.writeTo(System.out);
 50 |     } catch (Exception e) {
 51 |       throw new RuntimeException(e);
 52 |     }
 53 |   }
 54 | 
 55 |   static TestConformanceResponse testConformance(TestConformanceRequest request) {
 56 |     try {
 57 |       Map descriptorMap =
 58 |           FileDescriptorUtil.parse(request.getFdset());
 59 |       Map fileDescriptorMap =
 60 |           FileDescriptorUtil.parseFileDescriptors(request.getFdset());
 61 |       TypeRegistry typeRegistry = FileDescriptorUtil.createTypeRegistry(fileDescriptorMap.values());
 62 |       ExtensionRegistry extensionRegistry =
 63 |           FileDescriptorUtil.createExtensionRegistry(fileDescriptorMap.values());
 64 |       Config cfg =
 65 |           Config.newBuilder()
 66 |               .setTypeRegistry(typeRegistry)
 67 |               .setExtensionRegistry(extensionRegistry)
 68 |               .build();
 69 |       Validator validator = ValidatorFactory.newBuilder().withConfig(cfg).build();
 70 | 
 71 |       TestConformanceResponse.Builder responseBuilder = TestConformanceResponse.newBuilder();
 72 |       Map resultsMap = new HashMap<>();
 73 |       for (Map.Entry entry : request.getCasesMap().entrySet()) {
 74 |         TestResult testResult = testCase(validator, descriptorMap, entry.getValue());
 75 |         resultsMap.put(entry.getKey(), testResult);
 76 |       }
 77 |       responseBuilder.putAllResults(resultsMap);
 78 |       return responseBuilder.build();
 79 |     } catch (Exception e) {
 80 |       throw new RuntimeException(e);
 81 |     }
 82 |   }
 83 | 
 84 |   static TestResult testCase(
 85 |       Validator validator, Map fileDescriptors, Any testCase)
 86 |       throws InvalidProtocolBufferException {
 87 |     String fullName = testCase.getTypeUrl();
 88 |     int slash = fullName.indexOf('/');
 89 |     if (slash != -1) {
 90 |       fullName = fullName.substring(slash + 1);
 91 |     }
 92 |     Descriptors.Descriptor descriptor = fileDescriptors.get(fullName);
 93 |     if (descriptor == null) {
 94 |       return unexpectedErrorResult("Unable to find descriptor: %s", fullName);
 95 |     }
 96 |     ByteString testCaseValue = testCase.getValue();
 97 |     DynamicMessage dynamicMessage =
 98 |         DynamicMessage.newBuilder(descriptor).mergeFrom(testCaseValue).build();
 99 |     return validate(validator, dynamicMessage);
100 |   }
101 | 
102 |   private static TestResult validate(Validator validator, DynamicMessage dynamicMessage) {
103 |     try {
104 |       ValidationResult result = validator.validate(dynamicMessage);
105 |       if (result.isSuccess()) {
106 |         return TestResult.newBuilder().setSuccess(true).build();
107 |       }
108 |       Violations error =
109 |           Violations.newBuilder().addAllViolations(result.toProto().getViolationsList()).build();
110 |       return TestResult.newBuilder().setValidationError(error).build();
111 |     } catch (CompilationException e) {
112 |       return TestResult.newBuilder().setCompilationError(e.getMessage()).build();
113 |     } catch (ExecutionException e) {
114 |       return TestResult.newBuilder().setRuntimeError(e.getMessage()).build();
115 |     } catch (Exception e) {
116 |       return unexpectedErrorResult("unknown error: %s", e.toString());
117 |     }
118 |   }
119 | 
120 |   @FormatMethod
121 |   static TestResult unexpectedErrorResult(String format, Object... args) {
122 |     String errorMessage = String.format(format, args);
123 |     return TestResult.newBuilder().setUnexpectedError(errorMessage).build();
124 |   }
125 | }
126 | 


--------------------------------------------------------------------------------
/.github/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
  1 | # Contributor Covenant Code of Conduct
  2 | 
  3 | ## Our Pledge
  4 | 
  5 | We as members, contributors, and leaders pledge to make participation in our
  6 | community a harassment-free experience for everyone, regardless of age, body
  7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
  8 | identity and expression, level of experience, education, socio-economic status,
  9 | nationality, personal appearance, race, religion, or sexual identity
 10 | and orientation.
 11 | 
 12 | We pledge to act and interact in ways that contribute to an open, welcoming,
 13 | diverse, inclusive, and healthy community.
 14 | 
 15 | ## Our Standards
 16 | 
 17 | Examples of behavior that contributes to a positive environment for our
 18 | community include:
 19 | 
 20 | * Demonstrating empathy and kindness toward other people
 21 | * Being respectful of differing opinions, viewpoints, and experiences
 22 | * Giving and gracefully accepting constructive feedback
 23 | * Accepting responsibility and apologizing to those affected by our mistakes,
 24 |   and learning from the experience
 25 | * Focusing on what is best not just for us as individuals, but for the
 26 |   overall community
 27 | 
 28 | Examples of unacceptable behavior include:
 29 | 
 30 | * The use of sexualized language or imagery, and sexual attention or
 31 |   advances of any kind
 32 | * Trolling, insulting or derogatory comments, and personal or political attacks
 33 | * Public or private harassment
 34 | * Publishing others' private information, such as a physical or email
 35 |   address, without their explicit permission
 36 | * Other conduct which could reasonably be considered inappropriate in a
 37 |   professional setting
 38 | 
 39 | ## Enforcement Responsibilities
 40 | 
 41 | Community leaders are responsible for clarifying and enforcing our standards of
 42 | acceptable behavior and will take appropriate and fair corrective action in
 43 | response to any behavior that they deem inappropriate, threatening, offensive,
 44 | or harmful.
 45 | 
 46 | Community leaders have the right and responsibility to remove, edit, or reject
 47 | comments, commits, code, wiki edits, issues, and other contributions that are
 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
 49 | decisions when appropriate.
 50 | 
 51 | ## Scope
 52 | 
 53 | This Code of Conduct applies within all community spaces, and also applies when
 54 | an individual is officially representing the community in public spaces.
 55 | Examples of representing our community include using an official e-mail address,
 56 | posting via an official social media account, or acting as an appointed
 57 | representative at an online or offline event.
 58 | 
 59 | ## Enforcement
 60 | 
 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
 62 | reported to the community leaders responsible for enforcement at
 63 | conduct@buf.build. All complaints will be reviewed and investigated promptly
 64 | and fairly.
 65 | 
 66 | All community leaders are obligated to respect the privacy and security of the
 67 | reporter of any incident.
 68 | 
 69 | ## Enforcement Guidelines
 70 | 
 71 | Community leaders will follow these Community Impact Guidelines in determining
 72 | the consequences for any action they deem in violation of this Code of Conduct:
 73 | 
 74 | ### 1. Correction
 75 | 
 76 | **Community Impact**: Use of inappropriate language or other behavior deemed
 77 | unprofessional or unwelcome in the community.
 78 | 
 79 | **Consequence**: A private, written warning from community leaders, providing
 80 | clarity around the nature of the violation and an explanation of why the
 81 | behavior was inappropriate. A public apology may be requested.
 82 | 
 83 | ### 2. Warning
 84 | 
 85 | **Community Impact**: A violation through a single incident or series
 86 | of actions.
 87 | 
 88 | **Consequence**: A warning with consequences for continued behavior. No
 89 | interaction with the people involved, including unsolicited interaction with
 90 | those enforcing the Code of Conduct, for a specified period of time. This
 91 | includes avoiding interactions in community spaces as well as external channels
 92 | like social media. Violating these terms may lead to a temporary or
 93 | permanent ban.
 94 | 
 95 | ### 3. Temporary Ban
 96 | 
 97 | **Community Impact**: A serious violation of community standards, including
 98 | sustained inappropriate behavior.
 99 | 
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 | 
106 | ### 4. Permanent Ban
107 | 
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior,  harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 | 
112 | **Consequence**: A permanent ban from any sort of public interaction within
113 | the community.
114 | 
115 | ## Attribution
116 | 
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.0, available at
119 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
120 | 
121 | Community Impact Guidelines were inspired by
122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123 | 
124 | For answers to common questions about this code of conduct, see the FAQ at
125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available
126 | at [https://www.contributor-covenant.org/translations][translations].
127 | 
128 | [homepage]: https://www.contributor-covenant.org
129 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
130 | [Mozilla CoC]: https://github.com/mozilla/diversity
131 | [FAQ]: https://www.contributor-covenant.org/faq
132 | [translations]: https://www.contributor-covenant.org/translations
133 | 


--------------------------------------------------------------------------------
/src/main/java/build/buf/protovalidate/RuleResolver.java:
--------------------------------------------------------------------------------
  1 | // Copyright 2023-2025 Buf Technologies, Inc.
  2 | //
  3 | // Licensed under the Apache License, Version 2.0 (the "License");
  4 | // you may not use this file except in compliance with the License.
  5 | // You may obtain a copy of the License at
  6 | //
  7 | //      http://www.apache.org/licenses/LICENSE-2.0
  8 | //
  9 | // Unless required by applicable law or agreed to in writing, software
 10 | // distributed under the License is distributed on an "AS IS" BASIS,
 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 12 | // See the License for the specific language governing permissions and
 13 | // limitations under the License.
 14 | 
 15 | package build.buf.protovalidate;
 16 | 
 17 | import build.buf.protovalidate.exceptions.CompilationException;
 18 | import build.buf.validate.FieldRules;
 19 | import build.buf.validate.MessageRules;
 20 | import build.buf.validate.OneofRules;
 21 | import build.buf.validate.ValidateProto;
 22 | import com.google.protobuf.DescriptorProtos;
 23 | import com.google.protobuf.Descriptors.Descriptor;
 24 | import com.google.protobuf.Descriptors.FieldDescriptor;
 25 | import com.google.protobuf.Descriptors.OneofDescriptor;
 26 | import com.google.protobuf.ExtensionRegistry;
 27 | import com.google.protobuf.InvalidProtocolBufferException;
 28 | import com.google.protobuf.MessageLite;
 29 | 
 30 | /** Manages the resolution of protovalidate rules. */
 31 | final class RuleResolver {
 32 |   private static final ExtensionRegistry EXTENSION_REGISTRY = ExtensionRegistry.newInstance();
 33 | 
 34 |   static {
 35 |     EXTENSION_REGISTRY.add(ValidateProto.message);
 36 |     EXTENSION_REGISTRY.add(ValidateProto.oneof);
 37 |     EXTENSION_REGISTRY.add(ValidateProto.field);
 38 |   }
 39 | 
 40 |   /**
 41 |    * Resolves the rules for a message descriptor.
 42 |    *
 43 |    * @param desc the message descriptor.
 44 |    * @return the resolved {@link MessageRules}.
 45 |    */
 46 |   MessageRules resolveMessageRules(Descriptor desc)
 47 |       throws InvalidProtocolBufferException, CompilationException {
 48 |     DescriptorProtos.MessageOptions options = desc.getOptions();
 49 |     // If the protovalidate message extension is unknown, reparse using extension registry.
 50 |     if (options.getUnknownFields().hasField(ValidateProto.message.getNumber())) {
 51 |       options =
 52 |           DescriptorProtos.MessageOptions.parseFrom(options.toByteString(), EXTENSION_REGISTRY);
 53 |     }
 54 |     if (!options.hasExtension(ValidateProto.message)) {
 55 |       return MessageRules.getDefaultInstance();
 56 |     }
 57 |     // Don't use getExtension here to avoid exception if descriptor types don't match.
 58 |     // This can occur if the extension is generated to a different Java package.
 59 |     Object value = options.getField(ValidateProto.message.getDescriptor());
 60 |     if (value instanceof MessageRules) {
 61 |       return ((MessageRules) value);
 62 |     }
 63 |     if (value instanceof MessageLite) {
 64 |       // Possible that this represents the same rule type, just generated to a different
 65 |       // java_package.
 66 |       return MessageRules.parseFrom(((MessageLite) value).toByteString());
 67 |     }
 68 |     throw new CompilationException("unexpected message rule option type: " + value);
 69 |   }
 70 | 
 71 |   /**
 72 |    * Resolves the rules for a oneof descriptor.
 73 |    *
 74 |    * @param desc the oneof descriptor.
 75 |    * @return the resolved {@link OneofRules}.
 76 |    */
 77 |   OneofRules resolveOneofRules(OneofDescriptor desc)
 78 |       throws InvalidProtocolBufferException, CompilationException {
 79 |     DescriptorProtos.OneofOptions options = desc.getOptions();
 80 |     // If the protovalidate oneof extension is unknown, reparse using extension registry.
 81 |     if (options.getUnknownFields().hasField(ValidateProto.oneof.getNumber())) {
 82 |       options = DescriptorProtos.OneofOptions.parseFrom(options.toByteString(), EXTENSION_REGISTRY);
 83 |     }
 84 |     if (!options.hasExtension(ValidateProto.oneof)) {
 85 |       return OneofRules.getDefaultInstance();
 86 |     }
 87 |     // Don't use getExtension here to avoid exception if descriptor types don't match.
 88 |     // This can occur if the extension is generated to a different Java package.
 89 |     Object value = options.getField(ValidateProto.oneof.getDescriptor());
 90 |     if (value instanceof OneofRules) {
 91 |       return ((OneofRules) value);
 92 |     }
 93 |     if (value instanceof MessageLite) {
 94 |       // Possible that this represents the same rule type, just generated to a different
 95 |       // java_package.
 96 |       return OneofRules.parseFrom(((MessageLite) value).toByteString());
 97 |     }
 98 |     throw new CompilationException("unexpected oneof rule option type: " + value);
 99 |   }
100 | 
101 |   /**
102 |    * Resolves the rules for a field descriptor.
103 |    *
104 |    * @param desc the field descriptor.
105 |    * @return the resolved {@link FieldRules}.
106 |    */
107 |   FieldRules resolveFieldRules(FieldDescriptor desc)
108 |       throws InvalidProtocolBufferException, CompilationException {
109 |     DescriptorProtos.FieldOptions options = desc.getOptions();
110 |     // If the protovalidate field option is unknown, reparse using extension registry.
111 |     if (options.getUnknownFields().hasField(ValidateProto.field.getNumber())) {
112 |       options = DescriptorProtos.FieldOptions.parseFrom(options.toByteString(), EXTENSION_REGISTRY);
113 |     }
114 |     if (!options.hasExtension(ValidateProto.field)) {
115 |       return FieldRules.getDefaultInstance();
116 |     }
117 |     // Don't use getExtension here to avoid exception if descriptor types don't match.
118 |     // This can occur if the extension is generated to a different Java package.
119 |     Object value = options.getField(ValidateProto.field.getDescriptor());
120 |     if (value instanceof FieldRules) {
121 |       return ((FieldRules) value);
122 |     }
123 |     if (value instanceof MessageLite) {
124 |       // Possible that this represents the same rule type, just generated to a different
125 |       // java_package.
126 |       return FieldRules.parseFrom(((MessageLite) value).toByteString());
127 |     }
128 |     throw new CompilationException("unexpected field rule option type: " + value);
129 |   }
130 | }
131 | 


--------------------------------------------------------------------------------
/src/test/java/build/buf/protovalidate/ValidatorImportTest.java:
--------------------------------------------------------------------------------
  1 | // Copyright 2023-2025 Buf Technologies, Inc.
  2 | //
  3 | // Licensed under the Apache License, Version 2.0 (the "License");
  4 | // you may not use this file except in compliance with the License.
  5 | // You may obtain a copy of the License at
  6 | //
  7 | //      http://www.apache.org/licenses/LICENSE-2.0
  8 | //
  9 | // Unless required by applicable law or agreed to in writing, software
 10 | // distributed under the License is distributed on an "AS IS" BASIS,
 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 12 | // See the License for the specific language governing permissions and
 13 | // limitations under the License.
 14 | 
 15 | package build.buf.protovalidate;
 16 | 
 17 | import static org.assertj.core.api.Assertions.assertThat;
 18 | 
 19 | import com.example.imports.validationtest.ExampleImportMessage;
 20 | import com.example.imports.validationtest.ExampleImportMessageFieldRule;
 21 | import com.example.imports.validationtest.ExampleImportMessageInMap;
 22 | import com.example.imports.validationtest.ExampleImportMessageInMapFieldRule;
 23 | import com.example.imports.validationtest.ExampleImportedMessage;
 24 | import org.junit.jupiter.api.Test;
 25 | 
 26 | public class ValidatorImportTest {
 27 |   @Test
 28 |   public void testImportedMessageFromAnotherFile() throws Exception {
 29 |     com.example.imports.validationtest.ExampleImportMessage valid =
 30 |         ExampleImportMessage.newBuilder()
 31 |             .setImportedSubmessage(
 32 |                 ExampleImportedMessage.newBuilder().setHexString("0123456789abcdef").build())
 33 |             .build();
 34 |     assertThat(
 35 |             ValidatorFactory.newBuilder()
 36 |                 .build()
 37 |                 .validate(valid)
 38 |                 .toProto()
 39 |                 .getViolationsList()
 40 |                 .size())
 41 |         .isEqualTo(0);
 42 | 
 43 |     com.example.imports.validationtest.ExampleImportMessage invalid =
 44 |         ExampleImportMessage.newBuilder()
 45 |             .setImportedSubmessage(ExampleImportedMessage.newBuilder().setHexString("zyx").build())
 46 |             .build();
 47 |     assertThat(
 48 |             ValidatorFactory.newBuilder()
 49 |                 .build()
 50 |                 .validate(invalid)
 51 |                 .toProto()
 52 |                 .getViolationsList()
 53 |                 .size())
 54 |         .isEqualTo(1);
 55 |   }
 56 | 
 57 |   @Test
 58 |   public void testImportedMessageFromAnotherFileInField() throws Exception {
 59 |     com.example.imports.validationtest.ExampleImportMessageFieldRule valid =
 60 |         ExampleImportMessageFieldRule.newBuilder()
 61 |             .setMessageWithImport(
 62 |                 ExampleImportMessage.newBuilder()
 63 |                     .setImportedSubmessage(
 64 |                         ExampleImportedMessage.newBuilder()
 65 |                             .setHexString("0123456789abcdef")
 66 |                             .build())
 67 |                     .build())
 68 |             .build();
 69 |     assertThat(
 70 |             ValidatorFactory.newBuilder()
 71 |                 .build()
 72 |                 .validate(valid)
 73 |                 .toProto()
 74 |                 .getViolationsList()
 75 |                 .size())
 76 |         .isEqualTo(0);
 77 | 
 78 |     com.example.imports.validationtest.ExampleImportMessageFieldRule invalid =
 79 |         ExampleImportMessageFieldRule.newBuilder()
 80 |             .setMessageWithImport(
 81 |                 ExampleImportMessage.newBuilder()
 82 |                     .setImportedSubmessage(
 83 |                         ExampleImportedMessage.newBuilder().setHexString("zyx").build())
 84 |                     .build())
 85 |             .build();
 86 |     assertThat(
 87 |             ValidatorFactory.newBuilder()
 88 |                 .build()
 89 |                 .validate(invalid)
 90 |                 .toProto()
 91 |                 .getViolationsList()
 92 |                 .size())
 93 |         .isEqualTo(1);
 94 |   }
 95 | 
 96 |   @Test
 97 |   public void testImportedMessageFromAnotherFileInMap() throws Exception {
 98 |     com.example.imports.validationtest.ExampleImportMessageInMap valid =
 99 |         ExampleImportMessageInMap.newBuilder()
100 |             .putImportedSubmessage(
101 |                 0, ExampleImportedMessage.newBuilder().setHexString("0123456789abcdef").build())
102 |             .build();
103 |     assertThat(
104 |             ValidatorFactory.newBuilder()
105 |                 .build()
106 |                 .validate(valid)
107 |                 .toProto()
108 |                 .getViolationsList()
109 |                 .size())
110 |         .isEqualTo(0);
111 | 
112 |     com.example.imports.validationtest.ExampleImportMessageInMap invalid =
113 |         ExampleImportMessageInMap.newBuilder()
114 |             .putImportedSubmessage(
115 |                 0, ExampleImportedMessage.newBuilder().setHexString("zyx").build())
116 |             .build();
117 |     assertThat(
118 |             ValidatorFactory.newBuilder()
119 |                 .build()
120 |                 .validate(invalid)
121 |                 .toProto()
122 |                 .getViolationsList()
123 |                 .size())
124 |         .isEqualTo(1);
125 |   }
126 | 
127 |   @Test
128 |   public void testImportedMessageFromAnotherFileInMapInField() throws Exception {
129 |     com.example.imports.validationtest.ExampleImportMessageInMapFieldRule valid =
130 |         ExampleImportMessageInMapFieldRule.newBuilder()
131 |             .setMessageWithImport(
132 |                 ExampleImportMessageInMap.newBuilder()
133 |                     .putImportedSubmessage(
134 |                         0,
135 |                         ExampleImportedMessage.newBuilder()
136 |                             .setHexString("0123456789abcdef")
137 |                             .build())
138 |                     .build())
139 |             .build();
140 |     assertThat(
141 |             ValidatorFactory.newBuilder()
142 |                 .build()
143 |                 .validate(valid)
144 |                 .toProto()
145 |                 .getViolationsList()
146 |                 .size())
147 |         .isEqualTo(0);
148 | 
149 |     com.example.imports.validationtest.ExampleImportMessageInMapFieldRule invalid =
150 |         ExampleImportMessageInMapFieldRule.newBuilder()
151 |             .setMessageWithImport(
152 |                 ExampleImportMessageInMap.newBuilder()
153 |                     .putImportedSubmessage(
154 |                         0, ExampleImportedMessage.newBuilder().setHexString("zyx").build())
155 |                     .build())
156 |             .build();
157 |     assertThat(
158 |             ValidatorFactory.newBuilder()
159 |                 .build()
160 |                 .validate(invalid)
161 |                 .toProto()
162 |                 .getViolationsList()
163 |                 .size())
164 |         .isEqualTo(1);
165 |   }
166 | }
167 | 


--------------------------------------------------------------------------------
/src/main/java/build/buf/protovalidate/Config.java:
--------------------------------------------------------------------------------
  1 | // Copyright 2023-2025 Buf Technologies, Inc.
  2 | //
  3 | // Licensed under the Apache License, Version 2.0 (the "License");
  4 | // you may not use this file except in compliance with the License.
  5 | // You may obtain a copy of the License at
  6 | //
  7 | //      http://www.apache.org/licenses/LICENSE-2.0
  8 | //
  9 | // Unless required by applicable law or agreed to in writing, software
 10 | // distributed under the License is distributed on an "AS IS" BASIS,
 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 12 | // See the License for the specific language governing permissions and
 13 | // limitations under the License.
 14 | 
 15 | package build.buf.protovalidate;
 16 | 
 17 | import com.google.protobuf.ExtensionRegistry;
 18 | import com.google.protobuf.TypeRegistry;
 19 | 
 20 | /** Config is the configuration for a Validator. */
 21 | public final class Config {
 22 |   private static final TypeRegistry DEFAULT_TYPE_REGISTRY = TypeRegistry.getEmptyTypeRegistry();
 23 |   private static final ExtensionRegistry DEFAULT_EXTENSION_REGISTRY =
 24 |       ExtensionRegistry.getEmptyRegistry();
 25 | 
 26 |   private final boolean failFast;
 27 |   private final TypeRegistry typeRegistry;
 28 |   private final ExtensionRegistry extensionRegistry;
 29 |   private final boolean allowUnknownFields;
 30 | 
 31 |   private Config(
 32 |       boolean failFast,
 33 |       TypeRegistry typeRegistry,
 34 |       ExtensionRegistry extensionRegistry,
 35 |       boolean allowUnknownFields) {
 36 |     this.failFast = failFast;
 37 |     this.typeRegistry = typeRegistry;
 38 |     this.extensionRegistry = extensionRegistry;
 39 |     this.allowUnknownFields = allowUnknownFields;
 40 |   }
 41 | 
 42 |   /**
 43 |    * Create a new Configuration builder.
 44 |    *
 45 |    * @return a new Configuration builder.
 46 |    */
 47 |   public static Builder newBuilder() {
 48 |     return new Builder();
 49 |   }
 50 | 
 51 |   /**
 52 |    * Checks if the configuration for failing fast is enabled.
 53 |    *
 54 |    * @return if failing fast is enabled
 55 |    */
 56 |   public boolean isFailFast() {
 57 |     return failFast;
 58 |   }
 59 | 
 60 |   /**
 61 |    * Gets the type registry used for reparsing protobuf messages.
 62 |    *
 63 |    * @return a type registry
 64 |    */
 65 |   public TypeRegistry getTypeRegistry() {
 66 |     return typeRegistry;
 67 |   }
 68 | 
 69 |   /**
 70 |    * Gets the extension registry used for resolving unknown protobuf extensions.
 71 |    *
 72 |    * @return an extension registry
 73 |    */
 74 |   public ExtensionRegistry getExtensionRegistry() {
 75 |     return extensionRegistry;
 76 |   }
 77 | 
 78 |   /**
 79 |    * Checks if the configuration for allowing unknown rule fields is enabled.
 80 |    *
 81 |    * @return if allowing unknown rule fields is enabled
 82 |    */
 83 |   public boolean isAllowingUnknownFields() {
 84 |     return allowUnknownFields;
 85 |   }
 86 | 
 87 |   /** Builder for configuration. Provides a forward compatible API for users. */
 88 |   public static final class Builder {
 89 |     private boolean failFast;
 90 |     private TypeRegistry typeRegistry = DEFAULT_TYPE_REGISTRY;
 91 |     private ExtensionRegistry extensionRegistry = DEFAULT_EXTENSION_REGISTRY;
 92 |     private boolean allowUnknownFields;
 93 | 
 94 |     private Builder() {}
 95 | 
 96 |     /**
 97 |      * Set the configuration for failing fast.
 98 |      *
 99 |      * @param failFast the boolean for enabling
100 |      * @return this builder
101 |      */
102 |     public Builder setFailFast(boolean failFast) {
103 |       this.failFast = failFast;
104 |       return this;
105 |     }
106 | 
107 |     /**
108 |      * Set the type registry for reparsing protobuf messages. This option should be set alongside
109 |      * setExtensionRegistry to allow dynamic resolution of predefined rule extensions. It should be
110 |      * set to a TypeRegistry with all the message types from your file descriptor set registered. By
111 |      * default, if any unknown field rules are found, compilation of the rules will fail; use
112 |      * setAllowUnknownFields to control this behavior.
113 |      *
114 |      * 

Note that the message types for any extensions in setExtensionRegistry must be present in 115 | * the typeRegistry, and have an exactly-equal Descriptor. If the type registry is not set, the 116 | * extension types in the extension registry must have exactly-equal Descriptor types to the 117 | * protovalidate built-in messages. If these conditions are not met, extensions will not be 118 | * resolved as expected. These conditions will be met when constructing a TypeRegistry and 119 | * ExtensionRegistry using information from the same file descriptor sets. 120 | * 121 | * @param typeRegistry the type registry to use 122 | * @return this builder 123 | */ 124 | public Builder setTypeRegistry(TypeRegistry typeRegistry) { 125 | this.typeRegistry = typeRegistry; 126 | return this; 127 | } 128 | 129 | /** 130 | * Set the extension registry for resolving unknown extensions. This option should be set 131 | * alongside setTypeRegistry to allow dynamic resolution of predefined rule extensions. It 132 | * should be set to an ExtensionRegistry with all the extension types from your file descriptor 133 | * set registered. By default, if any unknown field rules are found, compilation of the rules 134 | * will fail; use setAllowUnknownFields to control this behavior. 135 | * 136 | * @param extensionRegistry the extension registry to use 137 | * @return this builder 138 | */ 139 | public Builder setExtensionRegistry(ExtensionRegistry extensionRegistry) { 140 | this.extensionRegistry = extensionRegistry; 141 | return this; 142 | } 143 | 144 | /** 145 | * Set whether unknown rule fields are allowed. If this setting is set to true, unknown standard 146 | * predefined field rules and predefined field rule extensions will be ignored. This setting 147 | * defaults to false, which will result in a CompilationException being thrown whenever an 148 | * unknown field rule is encountered. Setting this to true will cause some field rules to be 149 | * ignored; if the descriptor is dynamic, you can instead use setExtensionRegistry to provide 150 | * dynamic type information that protovalidate can use to resolve the unknown fields. 151 | * 152 | * @param allowUnknownFields setting to apply 153 | * @return this builder 154 | */ 155 | public Builder setAllowUnknownFields(boolean allowUnknownFields) { 156 | this.allowUnknownFields = allowUnknownFields; 157 | return this; 158 | } 159 | 160 | /** 161 | * Build the corresponding {@link Config}. 162 | * 163 | * @return the configuration. 164 | */ 165 | public Config build() { 166 | return new Config(failFast, typeRegistry, extensionRegistry, allowUnknownFields); 167 | } 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/MapEvaluator.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import build.buf.protovalidate.exceptions.ExecutionException; 18 | import build.buf.validate.FieldPath; 19 | import build.buf.validate.FieldPathElement; 20 | import build.buf.validate.FieldRules; 21 | import build.buf.validate.MapRules; 22 | import com.google.protobuf.Descriptors; 23 | import java.util.ArrayList; 24 | import java.util.Collections; 25 | import java.util.List; 26 | import java.util.Map; 27 | import java.util.Objects; 28 | import java.util.stream.Collectors; 29 | 30 | /** Performs validation on a map field's key-value pairs. */ 31 | final class MapEvaluator implements Evaluator { 32 | /** Rule path to map key rules */ 33 | private static final FieldPath MAP_KEYS_RULE_PATH = 34 | FieldPath.newBuilder() 35 | .addElements( 36 | FieldPathUtils.fieldPathElement( 37 | FieldRules.getDescriptor().findFieldByNumber(FieldRules.MAP_FIELD_NUMBER))) 38 | .addElements( 39 | FieldPathUtils.fieldPathElement( 40 | MapRules.getDescriptor().findFieldByNumber(MapRules.KEYS_FIELD_NUMBER))) 41 | .build(); 42 | 43 | /** Rule path to map value rules */ 44 | private static final FieldPath MAP_VALUES_RULE_PATH = 45 | FieldPath.newBuilder() 46 | .addElements( 47 | FieldPathUtils.fieldPathElement( 48 | FieldRules.getDescriptor().findFieldByNumber(FieldRules.MAP_FIELD_NUMBER))) 49 | .addElements( 50 | FieldPathUtils.fieldPathElement( 51 | MapRules.getDescriptor().findFieldByNumber(MapRules.VALUES_FIELD_NUMBER))) 52 | .build(); 53 | 54 | private final RuleViolationHelper helper; 55 | 56 | /** Rule for checking the map keys */ 57 | private final ValueEvaluator keyEvaluator; 58 | 59 | /** Rule for checking the map values */ 60 | private final ValueEvaluator valueEvaluator; 61 | 62 | /** Field descriptor of the map field */ 63 | final Descriptors.FieldDescriptor fieldDescriptor; 64 | 65 | /** Field descriptor of the map key field */ 66 | final Descriptors.FieldDescriptor keyFieldDescriptor; 67 | 68 | /** Field descriptor of the map value field */ 69 | final Descriptors.FieldDescriptor valueFieldDescriptor; 70 | 71 | /** 72 | * Constructs a {@link MapEvaluator}. 73 | * 74 | * @param valueEvaluator The value evaluator this rule exists under. 75 | */ 76 | MapEvaluator(ValueEvaluator valueEvaluator, Descriptors.FieldDescriptor fieldDescriptor) { 77 | this.helper = new RuleViolationHelper(valueEvaluator); 78 | this.keyEvaluator = new ValueEvaluator(null, MAP_KEYS_RULE_PATH); 79 | this.valueEvaluator = new ValueEvaluator(null, MAP_VALUES_RULE_PATH); 80 | this.fieldDescriptor = fieldDescriptor; 81 | this.keyFieldDescriptor = fieldDescriptor.getMessageType().findFieldByNumber(1); 82 | this.valueFieldDescriptor = fieldDescriptor.getMessageType().findFieldByNumber(2); 83 | } 84 | 85 | /** 86 | * Gets the key evaluator associated with this map evaluator. 87 | * 88 | * @return The key evaluator. 89 | */ 90 | ValueEvaluator getKeyEvaluator() { 91 | return keyEvaluator; 92 | } 93 | 94 | /** 95 | * Gets the value evaluator associated with this map evaluator. 96 | * 97 | * @return The value evaluator. 98 | */ 99 | ValueEvaluator getValueEvaluator() { 100 | return valueEvaluator; 101 | } 102 | 103 | @Override 104 | public boolean tautology() { 105 | return keyEvaluator.tautology() && valueEvaluator.tautology(); 106 | } 107 | 108 | @Override 109 | public List evaluate(Value val, boolean failFast) 110 | throws ExecutionException { 111 | List violations = new ArrayList<>(); 112 | Map mapValue = val.mapValue(); 113 | for (Map.Entry entry : mapValue.entrySet()) { 114 | violations.addAll(evalPairs(entry.getKey(), entry.getValue(), failFast)); 115 | if (failFast && !violations.isEmpty()) { 116 | return violations; 117 | } 118 | } 119 | if (violations.isEmpty()) { 120 | return RuleViolation.NO_VIOLATIONS; 121 | } 122 | return violations; 123 | } 124 | 125 | private List evalPairs(Value key, Value value, boolean failFast) 126 | throws ExecutionException { 127 | List keyViolations = 128 | keyEvaluator.evaluate(key, failFast).stream() 129 | .map(violation -> violation.setForKey(true)) 130 | .collect(Collectors.toList()); 131 | final List valueViolations; 132 | if (failFast && !keyViolations.isEmpty()) { 133 | // Don't evaluate value rules if failFast is enabled and keys failed validation. 134 | // We still need to continue execution to the end to properly prefix violation field paths. 135 | valueViolations = RuleViolation.NO_VIOLATIONS; 136 | } else { 137 | valueViolations = valueEvaluator.evaluate(value, failFast); 138 | } 139 | if (keyViolations.isEmpty() && valueViolations.isEmpty()) { 140 | return Collections.emptyList(); 141 | } 142 | List violations = 143 | new ArrayList<>(keyViolations.size() + valueViolations.size()); 144 | violations.addAll(keyViolations); 145 | violations.addAll(valueViolations); 146 | 147 | FieldPathElement.Builder fieldPathElementBuilder = 148 | Objects.requireNonNull(helper.getFieldPathElement()).toBuilder(); 149 | fieldPathElementBuilder.setKeyType(keyFieldDescriptor.getType().toProto()); 150 | fieldPathElementBuilder.setValueType(valueFieldDescriptor.getType().toProto()); 151 | switch (keyFieldDescriptor.getType().toProto()) { 152 | case TYPE_INT64: 153 | case TYPE_INT32: 154 | case TYPE_SINT32: 155 | case TYPE_SINT64: 156 | case TYPE_SFIXED32: 157 | case TYPE_SFIXED64: 158 | fieldPathElementBuilder.setIntKey(key.value(Number.class).longValue()); 159 | break; 160 | case TYPE_UINT32: 161 | case TYPE_UINT64: 162 | case TYPE_FIXED32: 163 | case TYPE_FIXED64: 164 | fieldPathElementBuilder.setUintKey(key.value(Number.class).longValue()); 165 | break; 166 | case TYPE_BOOL: 167 | fieldPathElementBuilder.setBoolKey(key.value(Boolean.class)); 168 | break; 169 | case TYPE_STRING: 170 | fieldPathElementBuilder.setStringKey(key.value(String.class)); 171 | break; 172 | default: 173 | throw new ExecutionException("Unexpected map key type"); 174 | } 175 | FieldPathElement fieldPathElement = fieldPathElementBuilder.build(); 176 | return FieldPathUtils.updatePaths(violations, fieldPathElement, helper.getRulePrefixElements()); 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/main/java/build/buf/protovalidate/CustomDeclarations.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import static dev.cel.common.CelFunctionDecl.newFunctionDeclaration; 18 | import static dev.cel.common.CelOverloadDecl.newGlobalOverload; 19 | import static dev.cel.common.CelOverloadDecl.newMemberOverload; 20 | 21 | import dev.cel.common.CelFunctionDecl; 22 | import dev.cel.common.CelOverloadDecl; 23 | import dev.cel.common.types.CelType; 24 | import dev.cel.common.types.ListType; 25 | import dev.cel.common.types.SimpleType; 26 | import java.util.ArrayList; 27 | import java.util.Arrays; 28 | import java.util.Collections; 29 | import java.util.List; 30 | import java.util.Locale; 31 | 32 | /** Defines custom declaration functions. */ 33 | final class CustomDeclarations { 34 | 35 | /** 36 | * Create the custom function declaration list. 37 | * 38 | * @return the list of function declarations. 39 | */ 40 | static List create() { 41 | List decls = new ArrayList<>(); 42 | 43 | // Add 'getField' function declaration 44 | decls.add( 45 | newFunctionDeclaration( 46 | "getField", 47 | newGlobalOverload( 48 | "get_field_any_string", 49 | SimpleType.DYN, 50 | Arrays.asList(SimpleType.ANY, SimpleType.STRING)))); 51 | // Add 'isIp' function declaration 52 | decls.add( 53 | newFunctionDeclaration( 54 | "isIp", 55 | newMemberOverload( 56 | "is_ip", SimpleType.BOOL, Arrays.asList(SimpleType.STRING, SimpleType.INT)), 57 | newMemberOverload( 58 | "is_ip_unary", SimpleType.BOOL, Collections.singletonList(SimpleType.STRING)))); 59 | 60 | // Add 'isIpPrefix' function declaration 61 | decls.add( 62 | newFunctionDeclaration( 63 | "isIpPrefix", 64 | newMemberOverload( 65 | "is_ip_prefix_int_bool", 66 | SimpleType.BOOL, 67 | Arrays.asList(SimpleType.STRING, SimpleType.INT, SimpleType.BOOL)), 68 | newMemberOverload( 69 | "is_ip_prefix_int", 70 | SimpleType.BOOL, 71 | Arrays.asList(SimpleType.STRING, SimpleType.INT)), 72 | newMemberOverload( 73 | "is_ip_prefix_bool", 74 | SimpleType.BOOL, 75 | Arrays.asList(SimpleType.STRING, SimpleType.BOOL)), 76 | newMemberOverload( 77 | "is_ip_prefix", SimpleType.BOOL, Collections.singletonList(SimpleType.STRING)))); 78 | 79 | // Add 'isUriRef' function declaration 80 | decls.add( 81 | newFunctionDeclaration( 82 | "isUriRef", 83 | newMemberOverload( 84 | "is_uri_ref", SimpleType.BOOL, Collections.singletonList(SimpleType.STRING)))); 85 | 86 | // Add 'isUri' function declaration 87 | decls.add( 88 | newFunctionDeclaration( 89 | "isUri", 90 | newMemberOverload( 91 | "is_uri", SimpleType.BOOL, Collections.singletonList(SimpleType.STRING)))); 92 | 93 | // Add 'isEmail' function declaration 94 | decls.add( 95 | newFunctionDeclaration( 96 | "isEmail", 97 | newMemberOverload( 98 | "is_email", SimpleType.BOOL, Collections.singletonList(SimpleType.STRING)))); 99 | 100 | // Add 'isHostname' function declaration 101 | decls.add( 102 | newFunctionDeclaration( 103 | "isHostname", 104 | newMemberOverload( 105 | "is_hostname", SimpleType.BOOL, Collections.singletonList(SimpleType.STRING)))); 106 | 107 | decls.add( 108 | newFunctionDeclaration( 109 | "isHostAndPort", 110 | newMemberOverload( 111 | "string_bool_is_host_and_port_bool", 112 | SimpleType.BOOL, 113 | Arrays.asList(SimpleType.STRING, SimpleType.BOOL)))); 114 | 115 | // Add 'startsWith' function declaration 116 | decls.add( 117 | newFunctionDeclaration( 118 | "startsWith", 119 | newMemberOverload( 120 | "starts_with_bytes", 121 | SimpleType.BOOL, 122 | Arrays.asList(SimpleType.BYTES, SimpleType.BYTES)))); 123 | 124 | // Add 'endsWith' function declaration 125 | decls.add( 126 | newFunctionDeclaration( 127 | "endsWith", 128 | newMemberOverload( 129 | "ends_with_bytes", 130 | SimpleType.BOOL, 131 | Arrays.asList(SimpleType.BYTES, SimpleType.BYTES)))); 132 | 133 | // Add 'contains' function declaration 134 | decls.add( 135 | newFunctionDeclaration( 136 | "contains", 137 | newMemberOverload( 138 | "contains_bytes", 139 | SimpleType.BOOL, 140 | Arrays.asList(SimpleType.BYTES, SimpleType.BYTES)))); 141 | 142 | // Add 'isNan' function declaration 143 | decls.add( 144 | newFunctionDeclaration( 145 | "isNan", 146 | newMemberOverload( 147 | "is_nan", SimpleType.BOOL, Collections.singletonList(SimpleType.DOUBLE)))); 148 | 149 | // Add 'isInf' function declaration 150 | decls.add( 151 | newFunctionDeclaration( 152 | "isInf", 153 | newMemberOverload( 154 | "is_inf_unary", SimpleType.BOOL, Collections.singletonList(SimpleType.DOUBLE)), 155 | newMemberOverload( 156 | "is_inf_binary", 157 | SimpleType.BOOL, 158 | Arrays.asList(SimpleType.DOUBLE, SimpleType.INT)))); 159 | 160 | // Add 'unique' function declaration 161 | List uniqueOverloads = new ArrayList<>(); 162 | for (CelType type : 163 | Arrays.asList( 164 | SimpleType.STRING, 165 | SimpleType.INT, 166 | SimpleType.UINT, 167 | SimpleType.DOUBLE, 168 | SimpleType.BYTES, 169 | SimpleType.BOOL)) { 170 | uniqueOverloads.add( 171 | newMemberOverload( 172 | String.format("unique_list_%s", type.name().toLowerCase(Locale.US)), 173 | SimpleType.BOOL, 174 | Collections.singletonList(ListType.create(type)))); 175 | } 176 | decls.add(newFunctionDeclaration("unique", uniqueOverloads)); 177 | 178 | // Add 'format' function declaration 179 | List formatOverloads = new ArrayList<>(); 180 | formatOverloads.add( 181 | newMemberOverload( 182 | "format_list_dyn", 183 | SimpleType.STRING, 184 | Arrays.asList(SimpleType.STRING, ListType.create(SimpleType.DYN)))); 185 | 186 | decls.add(newFunctionDeclaration("format", formatOverloads)); 187 | 188 | return Collections.unmodifiableList(decls); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/test/java/build/buf/protovalidate/ValidatorCelExpressionTest.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import static org.assertj.core.api.Assertions.assertThat; 18 | 19 | import build.buf.validate.FieldPath; 20 | import build.buf.validate.FieldRules; 21 | import build.buf.validate.Violation; 22 | import com.example.imports.buf.validate.RepeatedRules; 23 | import java.util.Arrays; 24 | import org.junit.jupiter.api.Test; 25 | 26 | /** This test verifies that custom (CEL-based) field and/or message rules evaluate as expected. */ 27 | public class ValidatorCelExpressionTest { 28 | 29 | @Test 30 | public void testFieldExpressionRepeatedMessage() throws Exception { 31 | // Nested message wrapping the int 1 32 | com.example.imports.validationtest.FieldExpressionRepeatedMessage.Msg one = 33 | com.example.imports.validationtest.FieldExpressionRepeatedMessage.Msg.newBuilder() 34 | .setA(1) 35 | .build(); 36 | 37 | // Nested message wrapping the int 2 38 | com.example.imports.validationtest.FieldExpressionRepeatedMessage.Msg two = 39 | com.example.imports.validationtest.FieldExpressionRepeatedMessage.Msg.newBuilder() 40 | .setA(2) 41 | .build(); 42 | 43 | // Create a valid message (1, 1) 44 | com.example.imports.validationtest.FieldExpressionRepeatedMessage validMsg = 45 | com.example.imports.validationtest.FieldExpressionRepeatedMessage.newBuilder() 46 | .addAllVal(Arrays.asList(one, one)) 47 | .build(); 48 | 49 | // Create an invalid message (1, 2, 1) 50 | com.example.imports.validationtest.FieldExpressionRepeatedMessage invalidMsg = 51 | com.example.imports.validationtest.FieldExpressionRepeatedMessage.newBuilder() 52 | .addAllVal(Arrays.asList(one, two, one)) 53 | .build(); 54 | 55 | // Build a model of the expected violation 56 | Violation expectedViolation = 57 | Violation.newBuilder() 58 | .setField( 59 | FieldPath.newBuilder() 60 | .addElements( 61 | FieldPathUtils.fieldPathElement( 62 | invalidMsg.getDescriptorForType().findFieldByName("val")) 63 | .toBuilder() 64 | .build())) 65 | .setRule( 66 | FieldPath.newBuilder() 67 | .addElements( 68 | FieldPathUtils.fieldPathElement( 69 | FieldRules.getDescriptor() 70 | .findFieldByNumber(FieldRules.CEL_FIELD_NUMBER)) 71 | .toBuilder() 72 | .setIndex(0) 73 | .build())) 74 | .setRuleId("field_expression.repeated.message") 75 | .setMessage("test message field_expression.repeated.message") 76 | .build(); 77 | 78 | Validator validator = ValidatorFactory.newBuilder().build(); 79 | 80 | // Valid message checks 81 | ValidationResult validResult = validator.validate(validMsg); 82 | assertThat(validResult.isSuccess()).isTrue(); 83 | 84 | // Invalid message checks 85 | ValidationResult invalidResult = validator.validate(invalidMsg); 86 | assertThat(invalidResult.isSuccess()).isFalse(); 87 | assertThat(invalidResult.toProto().getViolationsList()).containsExactly(expectedViolation); 88 | } 89 | 90 | @Test 91 | public void testFieldExpressionRepeatedMessageItems() throws Exception { 92 | // Nested message wrapping the int 1 93 | com.example.imports.validationtest.FieldExpressionRepeatedMessageItems.Msg one = 94 | com.example.imports.validationtest.FieldExpressionRepeatedMessageItems.Msg.newBuilder() 95 | .setA(1) 96 | .build(); 97 | 98 | // Nested message wrapping the int 2 99 | com.example.imports.validationtest.FieldExpressionRepeatedMessageItems.Msg two = 100 | com.example.imports.validationtest.FieldExpressionRepeatedMessageItems.Msg.newBuilder() 101 | .setA(2) 102 | .build(); 103 | 104 | // Create a valid message (1, 1) 105 | com.example.imports.validationtest.FieldExpressionRepeatedMessageItems validMsg = 106 | com.example.imports.validationtest.FieldExpressionRepeatedMessageItems.newBuilder() 107 | .addAllVal(Arrays.asList(one, one)) 108 | .build(); 109 | 110 | // Create an invalid message (1, 2, 1) 111 | com.example.imports.validationtest.FieldExpressionRepeatedMessageItems invalidMsg = 112 | com.example.imports.validationtest.FieldExpressionRepeatedMessageItems.newBuilder() 113 | .addAllVal(Arrays.asList(one, two, one)) 114 | .build(); 115 | 116 | // Build a model of the expected violation 117 | Violation expectedViolation = 118 | Violation.newBuilder() 119 | .setField( 120 | FieldPath.newBuilder() 121 | .addElements( 122 | FieldPathUtils.fieldPathElement( 123 | invalidMsg.getDescriptorForType().findFieldByName("val")) 124 | .toBuilder() 125 | .setIndex(1) 126 | .build())) 127 | .setRule( 128 | FieldPath.newBuilder() 129 | .addElements( 130 | FieldPathUtils.fieldPathElement( 131 | FieldRules.getDescriptor() 132 | .findFieldByNumber(FieldRules.REPEATED_FIELD_NUMBER))) 133 | .addElements( 134 | FieldPathUtils.fieldPathElement( 135 | RepeatedRules.getDescriptor().findFieldByName("items"))) 136 | .addElements( 137 | FieldPathUtils.fieldPathElement( 138 | FieldRules.getDescriptor() 139 | .findFieldByNumber(FieldRules.CEL_FIELD_NUMBER)) 140 | .toBuilder() 141 | .setIndex(0) 142 | .build())) 143 | .setRuleId("field_expression.repeated.message.items") 144 | .setMessage("test message field_expression.repeated.message.items") 145 | .build(); 146 | 147 | Validator validator = ValidatorFactory.newBuilder().build(); 148 | 149 | // Valid message checks 150 | ValidationResult validResult = validator.validate(validMsg); 151 | assertThat(validResult.isSuccess()).isTrue(); 152 | 153 | // Invalid message checks 154 | ValidationResult invalidResult = validator.validate(invalidMsg); 155 | assertThat(invalidResult.isSuccess()).isFalse(); 156 | assertThat(invalidResult.toProto().getViolationsList()).containsExactly(expectedViolation); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/test/java/build/buf/protovalidate/FormatTest.java: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package build.buf.protovalidate; 16 | 17 | import static org.assertj.core.api.Assertions.*; 18 | 19 | import cel.expr.conformance.proto3.TestAllTypes; 20 | import com.cel.expr.Decl; 21 | import com.cel.expr.ExprValue; 22 | import com.cel.expr.Value; 23 | import com.cel.expr.conformance.test.SimpleTest; 24 | import com.cel.expr.conformance.test.SimpleTestFile; 25 | import com.cel.expr.conformance.test.SimpleTestSection; 26 | import com.google.protobuf.TextFormat; 27 | import dev.cel.bundle.Cel; 28 | import dev.cel.bundle.CelBuilder; 29 | import dev.cel.bundle.CelFactory; 30 | import dev.cel.common.CelOptions; 31 | import dev.cel.common.CelValidationException; 32 | import dev.cel.common.CelValidationResult; 33 | import dev.cel.common.types.SimpleType; 34 | import dev.cel.runtime.CelEvaluationException; 35 | import dev.cel.runtime.CelRuntime.Program; 36 | import java.nio.charset.StandardCharsets; 37 | import java.nio.file.Files; 38 | import java.nio.file.Paths; 39 | import java.util.ArrayList; 40 | import java.util.HashMap; 41 | import java.util.List; 42 | import java.util.Map; 43 | import java.util.stream.Collectors; 44 | import java.util.stream.Stream; 45 | import org.junit.jupiter.api.BeforeAll; 46 | import org.junit.jupiter.api.Named; 47 | import org.junit.jupiter.params.ParameterizedTest; 48 | import org.junit.jupiter.params.provider.Arguments; 49 | import org.junit.jupiter.params.provider.MethodSource; 50 | 51 | class FormatTest { 52 | // Version of the cel-spec that this implementation is conformant with 53 | // This should be kept in sync with the version in gradle.properties 54 | private static final String CEL_SPEC_VERSION = "v0.24.0"; 55 | 56 | private static Cel cel; 57 | 58 | private static List formatTests; 59 | private static List formatErrorTests; 60 | 61 | @BeforeAll 62 | public static void setUp() throws Exception { 63 | // The test data from the cel-spec conformance tests 64 | List celSpecSections = 65 | loadTestData("src/test/resources/testdata/string_ext_" + CEL_SPEC_VERSION + ".textproto"); 66 | // Our supplemental tests of functionality not in the cel conformance file, but defined in the 67 | // spec. 68 | List supplementalSections = 69 | loadTestData("src/test/resources/testdata/string_ext_supplemental.textproto"); 70 | 71 | // Combine the test data from both files into one 72 | List sections = 73 | Stream.concat(celSpecSections.stream(), supplementalSections.stream()) 74 | .collect(Collectors.toList()); 75 | 76 | // Find the format tests which test successful formatting 77 | formatTests = 78 | sections.stream() 79 | .filter(s -> s.getName().equals("format")) 80 | .flatMap(s -> s.getTestList().stream()) 81 | .collect(Collectors.toList()); 82 | 83 | // Find the format error tests which test errors during formatting 84 | formatErrorTests = 85 | sections.stream() 86 | .filter(s -> s.getName().equals("format_errors")) 87 | .flatMap(s -> s.getTestList().stream()) 88 | .collect(Collectors.toList()); 89 | 90 | ValidateLibrary validateLibrary = new ValidateLibrary(); 91 | cel = 92 | CelFactory.standardCelBuilder() 93 | .addCompilerLibraries(validateLibrary) 94 | .addRuntimeLibraries(validateLibrary) 95 | .setOptions( 96 | CelOptions.DEFAULT.toBuilder().evaluateCanonicalTypesToNativeValues(true).build()) 97 | .build(); 98 | } 99 | 100 | @ParameterizedTest 101 | @MethodSource("getFormatTests") 102 | void testFormatSuccess(SimpleTest test) throws CelValidationException, CelEvaluationException { 103 | Object result = evaluate(test); 104 | assertThat(result).isEqualTo(getExpectedResult(test)); 105 | assertThat(result).isInstanceOf(String.class); 106 | } 107 | 108 | @ParameterizedTest 109 | @MethodSource("getFormatErrorTests") 110 | void testFormatError(SimpleTest test) { 111 | assertThatThrownBy(() -> evaluate(test)).isInstanceOf(CelEvaluationException.class); 112 | } 113 | 114 | // Loads test data from the given text format file 115 | private static List loadTestData(String fileName) throws Exception { 116 | byte[] encoded = Files.readAllBytes(Paths.get(fileName)); 117 | String data = new String(encoded, StandardCharsets.UTF_8); 118 | SimpleTestFile.Builder bldr = SimpleTestFile.newBuilder(); 119 | TextFormat.getParser().merge(data, bldr); 120 | SimpleTestFile testData = bldr.build(); 121 | 122 | return testData.getSectionList(); 123 | } 124 | 125 | // Runs a test by extending the cel environment with the specified 126 | // types, variables and declarations, then evaluating it with the cel runtime. 127 | private static Object evaluate(SimpleTest test) 128 | throws CelValidationException, CelEvaluationException { 129 | 130 | CelBuilder builder = cel.toCelBuilder().addMessageTypes(TestAllTypes.getDescriptor()); 131 | addDecls(builder, test); 132 | Cel newCel = builder.build(); 133 | 134 | CelValidationResult validationResult = newCel.compile(test.getExpr()); 135 | if (!validationResult.getAllIssues().isEmpty()) { 136 | fail("error building AST for evaluation: " + validationResult.getIssueString()); 137 | } 138 | Program program = newCel.createProgram(validationResult.getAst()); 139 | return program.eval(buildVariables(test.getBindingsMap())); 140 | } 141 | 142 | private static Stream getTestStream(List tests) { 143 | List args = new ArrayList<>(); 144 | for (SimpleTest test : tests) { 145 | args.add(Arguments.arguments(Named.named(test.getName(), test))); 146 | } 147 | 148 | return args.stream(); 149 | } 150 | 151 | private static Stream getFormatTests() { 152 | return getTestStream(formatTests); 153 | } 154 | 155 | private static Stream getFormatErrorTests() { 156 | return getTestStream(formatErrorTests); 157 | } 158 | 159 | // Builds the variable definitions to be used during evaluation 160 | private static Map buildVariables(Map bindings) { 161 | Map vars = new HashMap<>(); 162 | for (Map.Entry entry : bindings.entrySet()) { 163 | ExprValue exprValue = entry.getValue(); 164 | if (exprValue.hasValue()) { 165 | Value val = exprValue.getValue(); 166 | if (val.hasStringValue()) { 167 | vars.put(entry.getKey(), val.getStringValue()); 168 | } 169 | } 170 | } 171 | return vars; 172 | } 173 | 174 | // Gets the expected result for a given test 175 | private static String getExpectedResult(SimpleTest test) { 176 | if (test.hasValue()) { 177 | if (test.getValue().hasStringValue()) { 178 | return test.getValue().getStringValue(); 179 | } 180 | } else if (test.hasEvalError()) { 181 | // Note that we only expect a single eval error for all the conformance tests 182 | if (test.getEvalError().getErrorsList().size() == 1) { 183 | return test.getEvalError().getErrorsList().get(0).getMessage(); 184 | } 185 | } 186 | return ""; 187 | } 188 | 189 | // Builds the declarations for a given test 190 | private static void addDecls(CelBuilder builder, SimpleTest test) { 191 | for (Decl decl : test.getTypeEnvList()) { 192 | if (decl.hasIdent()) { 193 | Decl.IdentDecl ident = decl.getIdent(); 194 | com.cel.expr.Type type = ident.getType(); 195 | if (type.hasPrimitive()) { 196 | if (type.getPrimitive() == com.cel.expr.Type.PrimitiveType.STRING) { 197 | builder.addVar(decl.getName(), SimpleType.STRING); 198 | } 199 | } 200 | } 201 | } 202 | } 203 | } 204 | --------------------------------------------------------------------------------