diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d4dfea31..45356249 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ [versions] autoService = "1.1.1" +cel = "0.4.4" grpc-java = "1.64.0" grpc-kotlin = "1.4.1" kotlinLogging = "5.1.0" @@ -23,6 +24,8 @@ ktlint = "1.2.1" protobuf-java = "3.21.7" protobuf-js = "7.2.6" protobufGradlePlugin = "0.9.4" +protovalidate = "0.6.4" +protovalidateJava = "0.2.1" slf4j = "2.0.13" # build @@ -44,6 +47,7 @@ jmh = "1.37" wire = "4.9.9" # test +buf = "1.31.0" classgraph = "4.8.153" grpc-js = "1.8.14" jackson = "2.17.1" @@ -62,6 +66,7 @@ wire = { id = "com.squareup.wire", version.ref = "wire" } [libraries] autoService = { module = "com.google.auto.service:auto-service", version.ref = "autoService" } autoServiceAnnotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "autoService" } +cel = { module = "org.projectnessie.cel:cel-tools", version.ref = "cel" } grpc-kotlin-gen = { module = "io.grpc:protoc-gen-grpc-kotlin", version.ref = "grpc-kotlin" } grpc-netty = { module = "io.grpc:grpc-netty", version.ref = "grpc-java" } grpc-stub = { module = "io.grpc:grpc-stub", version.ref = "grpc-java" } @@ -73,6 +78,7 @@ ktlintRuleSetStandard = { module = "com.pinterest.ktlint:ktlint-ruleset-standard protobuf-gradlePlugin = { module = "com.google.protobuf:protobuf-gradle-plugin", version.ref = "protobufGradlePlugin" } protobuf-java = { module ="com.google.protobuf:protobuf-java", version.ref = "protobuf-java" } protoc = { module = "com.google.protobuf:protoc", version.ref = "protobuf-java" } +protovalidateJava = { module = "build.buf:protovalidate", version.ref = "protovalidateJava" } slf4jSimple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } # build diff --git a/protokt-protovalidate/api/protokt-protovalidate.api b/protokt-protovalidate/api/protokt-protovalidate.api new file mode 100644 index 00000000..8a33e8a4 --- /dev/null +++ b/protokt-protovalidate/api/protokt-protovalidate.api @@ -0,0 +1,8 @@ +public final class protokt/v1/buf/validate/Validator { + public fun ()V + public fun (Lbuild/buf/protovalidate/Config;)V + public synthetic fun (Lbuild/buf/protovalidate/Config;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun load (Lcom/google/protobuf/Descriptors$Descriptor;)V + public final fun validate (Lprotokt/v1/Message;)Lbuild/buf/protovalidate/ValidationResult; +} + diff --git a/protokt-protovalidate/build.gradle.kts b/protokt-protovalidate/build.gradle.kts new file mode 100644 index 00000000..c96fd7ec --- /dev/null +++ b/protokt-protovalidate/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2024 Toast, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("protokt.jvm-conventions") +} + +enablePublishing() +trackKotlinApiCompatibility() + +dependencies { + implementation(project(":protokt-reflect")) + implementation(kotlin("reflect")) + implementation(libs.cel) + implementation(libs.protovalidateJava) +} diff --git a/protokt-protovalidate/src/main/kotlin/protokt/v1/buf/validate/Validator.kt b/protokt-protovalidate/src/main/kotlin/protokt/v1/buf/validate/Validator.kt new file mode 100644 index 00000000..b656f85f --- /dev/null +++ b/protokt-protovalidate/src/main/kotlin/protokt/v1/buf/validate/Validator.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2024 Toast, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package protokt.v1.buf.validate + +import build.buf.protovalidate.Config +import build.buf.protovalidate.ValidationResult +import build.buf.protovalidate.internal.celext.ValidateLibrary +import build.buf.protovalidate.internal.evaluator.Evaluator +import build.buf.protovalidate.internal.evaluator.EvaluatorBuilder +import build.buf.protovalidate.internal.evaluator.MessageValue +import com.google.protobuf.Descriptors.Descriptor +import org.projectnessie.cel.Env +import org.projectnessie.cel.Library +import protokt.v1.Beta +import protokt.v1.GeneratedMessage +import protokt.v1.Message +import protokt.v1.google.protobuf.RuntimeContext +import protokt.v1.google.protobuf.toDynamicMessage +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap +import kotlin.reflect.full.findAnnotation + +@Beta +class Validator @JvmOverloads constructor( + config: Config = Config.newBuilder().build() +) { + private val evaluatorBuilder = + EvaluatorBuilder( + Env.newEnv(Library.Lib(ValidateLibrary())), + config.isDisableLazy + ) + + private val failFast = config.isFailFast + + private val evaluatorsByFullTypeName = ConcurrentHashMap() + private val descriptors = Collections.newSetFromMap(ConcurrentHashMap()) + + @Volatile + private var runtimeContext = RuntimeContext(emptyList()) + + fun load(descriptor: Descriptor) { + doLoad(descriptor) + runtimeContext = RuntimeContext(descriptors) + } + + private fun doLoad(descriptor: Descriptor) { + descriptors.add(descriptor) + evaluatorsByFullTypeName[descriptor.fullName] = evaluatorBuilder.load(descriptor) + descriptor.nestedTypes.forEach(::doLoad) + } + + fun validate(message: Message): ValidationResult = + evaluatorsByFullTypeName + .getValue(message::class.findAnnotation()!!.fullTypeName) + .evaluate(MessageValue(message.toDynamicMessage(runtimeContext)), failFast) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 8a8bc1c6..7ec56b81 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,11 +31,12 @@ include( "protokt-codegen", "protokt-core", "protokt-core-lite", + "protokt-gradle-plugin", + "protokt-protovalidate", "protokt-reflect", "protokt-runtime", "protokt-runtime-grpc", "protokt-runtime-grpc-lite", - "protokt-gradle-plugin", "grpc-kotlin-shim", @@ -70,6 +71,7 @@ include( "testing:protokt-generation", "testing:protokt-generation-2", "testing:protobuf-java", + "testing:protovalidate-conformance", "testing:protobufjs", "testing:testing-util", diff --git a/testing/conformance/runner/src/test/kotlin/protokt/v1/conformance/ConformanceTest.kt b/testing/conformance/runner/src/test/kotlin/protokt/v1/conformance/ConformanceTest.kt index 9770e9f2..06db2dcd 100644 --- a/testing/conformance/runner/src/test/kotlin/protokt/v1/conformance/ConformanceTest.kt +++ b/testing/conformance/runner/src/test/kotlin/protokt/v1/conformance/ConformanceTest.kt @@ -15,10 +15,10 @@ package protokt.v1.conformance +import com.google.common.truth.Truth.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.EnumSource -import protokt.v1.testing.ProcessOutput import protokt.v1.testing.projectRoot import protokt.v1.testing.runCommand import java.io.File @@ -63,9 +63,15 @@ class ConformanceTest { @EnumSource fun `run conformance tests`(runner: ConformanceRunner) { try { - command(runner) - .runCommand(projectRoot.toPath()) - .orFail("Conformance tests failed", ProcessOutput.Src.ERR) + val output = command(runner).runCommand(projectRoot.toPath()) + println(output.stderr) + + assertThat(output.stderr).contains("CONFORMANCE SUITE PASSED") + val matches = " (\\d+) unexpected failures".toRegex().findAll(output.stderr).toList() + // the current implementation runs two conformance suites + assertThat(matches).hasSize(2) + matches.forEach { assertThat(it.groupValues[1].toInt()).isEqualTo(0) } + assertThat(output.exitCode).isEqualTo(0) } catch (t: Throwable) { if (failingTests.exists()) { println("Failing tests:\n" + failingTests.readText()) diff --git a/testing/protovalidate-conformance/build.gradle.kts b/testing/protovalidate-conformance/build.gradle.kts new file mode 100644 index 00000000..3f3e3a4b --- /dev/null +++ b/testing/protovalidate-conformance/build.gradle.kts @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2024 Toast, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import com.google.protobuf.gradle.GenerateProtoTask +import com.google.protobuf.gradle.proto +import org.gradle.api.distribution.plugins.DistributionPlugin.TASK_INSTALL_NAME + +plugins { + id("protokt.jvm-conventions") + application +} + +localProtokt(false) + +dependencies { + implementation(project(":protokt-protovalidate")) + implementation(project(":protokt-reflect")) + implementation(kotlin("reflect")) + implementation(libs.cel) + implementation(libs.classgraph) + implementation(libs.protovalidateJava) + + testImplementation(project(":testing:testing-util")) + testImplementation(libs.truth) +} + +sourceSets.main { + proto { + srcDir(project.layout.buildDirectory.file("protovalidate/export")) + } +} + +val protovalidateVersion = libs.versions.protovalidate.get() +val gobin = project.layout.buildDirectory.file("gobin").get().asFile.absolutePath +val bufExecutable = project.layout.buildDirectory.file("gobin/buf").get().asFile +val conformanceExecutable = project.layout.buildDirectory.file("gobin/protovalidate-conformance").get().asFile + +val installBuf = + tasks.register("installBuf") { + environment("GOBIN", gobin) + outputs.file(bufExecutable) + commandLine("go", "install", "github.com/bufbuild/buf/cmd/buf@v${libs.versions.buf.get()}") + } + +val downloadConformanceProtos = + tasks.register("downloadConformanceProtos") { + dependsOn(installBuf) + commandLine( + bufExecutable, + "export", + "buf.build/bufbuild/protovalidate-testing:v$protovalidateVersion", + "--output=build/protovalidate/export" + ) + } + +tasks.withType { + dependsOn(downloadConformanceProtos) +} + +val installConformance = + tasks.register("installProtovalidateConformance") { + environment("GOBIN", gobin) + outputs.file(conformanceExecutable) + commandLine( + "go", + "install", + "github.com/bufbuild/protovalidate/tools/protovalidate-conformance@v$protovalidateVersion" + ) + } + +application { + mainClass.set("protokt.v1.buf.validate.conformance.Main") +} + +tasks { + test { + systemProperty("conformance-runner", conformanceExecutable.absolutePath) + outputs.upToDateWhen { false } + dependsOn(installConformance, TASK_INSTALL_NAME) + } +} diff --git a/testing/protovalidate-conformance/src/main/kotlin/protokt/v1/buf/validate/conformance/DynamicConcreteMessageDeserializer.kt b/testing/protovalidate-conformance/src/main/kotlin/protokt/v1/buf/validate/conformance/DynamicConcreteMessageDeserializer.kt new file mode 100644 index 00000000..9ef2e779 --- /dev/null +++ b/testing/protovalidate-conformance/src/main/kotlin/protokt/v1/buf/validate/conformance/DynamicConcreteMessageDeserializer.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2024 Toast, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package protokt.v1.buf.validate.conformance + +import com.google.protobuf.ByteString +import io.github.classgraph.ClassGraph +import protokt.v1.Deserializer +import protokt.v1.GeneratedMessage +import protokt.v1.google.protobuf.Empty +import kotlin.reflect.full.findAnnotation + +object DynamicConcreteMessageDeserializer { + private val deserializersByFullTypeName: Map> by lazy { + ClassGraph() + .enableAnnotationInfo() + .acceptPackages( + "protokt.v1.buf.validate.conformance.*", + "protokt.v1.google.protobuf" + ) + .scan() + .use { result -> + result.getClassesWithAnnotation(GeneratedMessage::class.java) + .map { it.loadClass().kotlin } + } + .associate { messageClass -> + messageClass.findAnnotation()!!.fullTypeName to + messageClass + .nestedClasses + .single { it.simpleName == Empty.Deserializer::class.simpleName } + .objectInstance as Deserializer<*> + } + } + + fun parse(fullTypeName: String, bytes: ByteString) = + deserializersByFullTypeName.getValue(fullTypeName).deserialize(bytes.newInput()) +} diff --git a/testing/protovalidate-conformance/src/main/kotlin/protokt/v1/buf/validate/conformance/FileDescriptorUtil.kt b/testing/protovalidate-conformance/src/main/kotlin/protokt/v1/buf/validate/conformance/FileDescriptorUtil.kt new file mode 100644 index 00000000..4ad6a844 --- /dev/null +++ b/testing/protovalidate-conformance/src/main/kotlin/protokt/v1/buf/validate/conformance/FileDescriptorUtil.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024 Toast, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package protokt.v1.buf.validate.conformance + +import com.google.protobuf.DescriptorProtos.FileDescriptorSet +import com.google.protobuf.Descriptors.Descriptor +import com.google.protobuf.Descriptors.FileDescriptor + +fun parse(fileDescriptorSet: FileDescriptorSet): Map = + parseFileDescriptors(fileDescriptorSet) + .values + .flatMap { fileDescriptor -> + fileDescriptor.messageTypes.map { messageType -> + messageType.fullName to messageType + } + } + .toMap() + +private fun parseFileDescriptors(fileDescriptorSet: FileDescriptorSet): Map = + fileDescriptorSet.fileList.fold(mutableMapOf()) { map, fileDescriptorProto -> + map[fileDescriptorProto.getName()] = + FileDescriptor.buildFrom( + fileDescriptorProto, + fileDescriptorProto.dependencyList.mapNotNull(map::get).toTypedArray(), + false + ) + map + } diff --git a/testing/protovalidate-conformance/src/main/kotlin/protokt/v1/buf/validate/conformance/Main.kt b/testing/protovalidate-conformance/src/main/kotlin/protokt/v1/buf/validate/conformance/Main.kt new file mode 100644 index 00000000..ee522495 --- /dev/null +++ b/testing/protovalidate-conformance/src/main/kotlin/protokt/v1/buf/validate/conformance/Main.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2024 Toast, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package protokt.v1.buf.validate.conformance + +import buf.validate.conformance.harness.Harness.TestConformanceRequest +import buf.validate.conformance.harness.Harness.TestConformanceResponse +import buf.validate.conformance.harness.Harness.TestResult +import build.buf.protovalidate.exceptions.CompilationException +import build.buf.protovalidate.exceptions.ExecutionException +import build.buf.validate.ValidateProto +import build.buf.validate.Violations +import com.google.protobuf.Descriptors +import com.google.protobuf.ExtensionRegistry +import protokt.v1.Message +import protokt.v1.buf.validate.Validator + +object Main { + @JvmStatic + fun main(args: Array) { + val extensionRegistry = ExtensionRegistry.newInstance() + extensionRegistry.add(ValidateProto.message) + extensionRegistry.add(ValidateProto.field) + extensionRegistry.add(ValidateProto.oneof) + val request = TestConformanceRequest.parseFrom(System.`in`, extensionRegistry) + testConformance(request).writeTo(System.out) + } + + private fun testConformance(request: TestConformanceRequest): TestConformanceResponse { + val descriptorMap = parse(request.fdset) + val validator = Validator() + loadValidDescriptors(validator, descriptorMap.values) + return TestConformanceResponse + .newBuilder() + .putAllResults( + request.casesMap.mapValues { (_, value) -> + testCase(validator, descriptorMap, value) + } + ) + .build() + } + + private fun loadValidDescriptors(validator: Validator, descriptors: Iterable) { + descriptors.forEach { + try { + validator.load(it) + } catch (_: CompilationException) { + // leave failures for later; they trigger specific conformance results + } + } + } + + private fun testCase( + validator: Validator, + fileDescriptors: Map, + testCase: com.google.protobuf.Any + ): TestResult { + val urlParts = testCase.typeUrl.split('/', limit = 2) + val fullName = urlParts[urlParts.size - 1] + val descriptor = fileDescriptors[fullName] ?: return unexpected("Unable to find descriptor $fullName") + + try { + validator.load(descriptor) + } catch (e: CompilationException) { + return TestResult.newBuilder().setCompilationError(e.message).build() + } + + return validate(validator, DynamicConcreteMessageDeserializer.parse(fullName, testCase.value)) + } + + private fun validate(validator: Validator, message: Message) = + try { + val result = validator.validate(message) + if (result.isSuccess) { + TestResult.newBuilder().setSuccess(true).build() + } else { + TestResult.newBuilder() + .setValidationError(Violations.newBuilder().addAllViolations(result.violations).build()) + .build() + } + } catch (e: ExecutionException) { + TestResult.newBuilder().setRuntimeError(e.message).build() + } catch (e: Exception) { + unexpected("unknown error: $e") + } + + private fun unexpected(message: String) = + TestResult.newBuilder().setUnexpectedError(message).build() +} diff --git a/testing/protovalidate-conformance/src/test/kotlin/protokt/v1/buf/validate/AbstractValidatorTest.kt b/testing/protovalidate-conformance/src/test/kotlin/protokt/v1/buf/validate/AbstractValidatorTest.kt new file mode 100644 index 00000000..d1287759 --- /dev/null +++ b/testing/protovalidate-conformance/src/test/kotlin/protokt/v1/buf/validate/AbstractValidatorTest.kt @@ -0,0 +1,463 @@ +/* + * Copyright (c) 2024 Toast, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package protokt.v1.buf.validate + +import buf.validate.conformance.cases.Bytes +import buf.validate.conformance.cases.Numbers +import buf.validate.conformance.cases.Repeated +import buf.validate.conformance.cases.Strings +import build.buf.protovalidate.ValidationResult +import com.google.common.truth.Truth.assertThat +import com.google.protobuf.ByteString +import com.google.protobuf.DescriptorProtos +import com.google.protobuf.Descriptors +import org.junit.jupiter.api.Test +import protokt.v1.AbstractDeserializer +import protokt.v1.AbstractMessage +import protokt.v1.GeneratedMessage +import protokt.v1.Message +import protokt.v1.Reader +import protokt.v1.UnknownFieldSet +import protokt.v1.Writer +import protokt.v1.buf.validate.conformance.cases.MessageRequiredOneof +import protokt.v1.buf.validate.conformance.cases.Oneof +import protokt.v1.buf.validate.conformance.cases.TestMsg +import protokt.v1.buf.validate.conformance.cases.UInt64In +import protokt.v1.buf.validate.conformance.cases.bytes_file_descriptor +import protokt.v1.buf.validate.conformance.cases.messages_file_descriptor +import protokt.v1.buf.validate.conformance.cases.numbers_file_descriptor +import protokt.v1.buf.validate.conformance.cases.oneofs_file_descriptor +import protokt.v1.buf.validate.conformance.cases.repeated_file_descriptor +import protokt.v1.buf.validate.conformance.cases.strings_file_descriptor +import protokt.v1.google.protobuf.FileDescriptor + +abstract class AbstractValidatorTest { + protected val validator = Validator() + + abstract fun validate(message: Message): ValidationResult + + private fun load(descriptor: FileDescriptor) { + descriptor + .toProtobufJavaDescriptor() + .messageTypes + .forEach { + runCatching { validator.load(it) } + } + } + + private fun FileDescriptor.toProtobufJavaDescriptor(): Descriptors.FileDescriptor = + Descriptors.FileDescriptor.buildFrom( + DescriptorProtos.FileDescriptorProto.parseFrom(proto.serialize()), + dependencies.map { it.toProtobufJavaDescriptor() }.toTypedArray(), + true + ) + + @Test + fun `test required oneof constraint`() { + load(messages_file_descriptor.descriptor) + + val result = + validate( + MessageRequiredOneof { + one = + MessageRequiredOneof.One.Val( + TestMsg { + const = "foo" + } + ) + } + ) + + assertThat(result.violations).isEmpty() + assertThat(result.isSuccess).isTrue() + } + + @Test + fun `test oneof constraint`() { + load(oneofs_file_descriptor.descriptor) + + val result = + validate( + Oneof { + o = Oneof.O.X("foobar") + } + ) + + assertThat(result.violations).isEmpty() + assertThat(result.isSuccess).isTrue() + } + + @Test + fun `test uint64 in constraint`() { + load(numbers_file_descriptor.descriptor) + + val result = + validate( + UInt64In { + `val` = 4u + } + ) + + assertThat(result.isSuccess).isFalse() + } + + @Test + fun `test message with varint non-uint64 encoded purely as unknown fields (dynamic message without a dedicated type)`() { + load(numbers_file_descriptor.descriptor) + + val result = + validate( + Int64.deserialize( + Numbers.Int64In.newBuilder() + .setVal(4) + .build() + .toByteArray() + ) + ) + + assertThat(result.isSuccess).isFalse() + + val result2 = + validate( + Int64.deserialize( + Numbers.Int64In.newBuilder() + .setVal(3) + .build() + .toByteArray() + ) + ) + + assertThat(result2.isSuccess).isTrue() + } + + @Test + fun `test message with varint uint64 encoded purely as unknown fields (dynamic message without a dedicated type)`() { + load(numbers_file_descriptor.descriptor) + + val result = + validate( + UInt64.deserialize( + Numbers.UInt64In.newBuilder() + .setVal(4) + .build() + .toByteArray() + ) + ) + + assertThat(result.isSuccess).isFalse() + + val result2 = + validate( + UInt64.deserialize( + Numbers.UInt64In.newBuilder() + .setVal(3) + .build() + .toByteArray() + ) + ) + + assertThat(result2.isSuccess).isTrue() + } + + @Test + fun `test message with fixed32 encoded purely as unknown fields (dynamic message without a dedicated type)`() { + load(numbers_file_descriptor.descriptor) + + val result = + validate( + Fixed32.deserialize( + Numbers.Fixed32In.newBuilder() + .setVal(4) + .build() + .toByteArray() + ) + ) + + assertThat(result.isSuccess).isFalse() + + val result2 = + validate( + Fixed32.deserialize( + Numbers.Fixed32In.newBuilder() + .setVal(3) + .build() + .toByteArray() + ) + ) + + assertThat(result2.isSuccess).isTrue() + } + + @Test + fun `test message with fixed64 encoded purely as unknown fields (dynamic message without a dedicated type)`() { + load(numbers_file_descriptor.descriptor) + + val result = + validate( + Fixed64.deserialize( + Numbers.Fixed64In.newBuilder() + .setVal(4) + .build() + .toByteArray() + ) + ) + + assertThat(result.isSuccess).isFalse() + + val result2 = + validate( + Fixed64.deserialize( + Numbers.Fixed64In.newBuilder() + .setVal(3) + .build() + .toByteArray() + ) + ) + + assertThat(result2.isSuccess).isTrue() + } + + @Test + fun `test message with length delimited string encoded purely as unknown fields (dynamic message without a dedicated type)`() { + load(strings_file_descriptor.descriptor) + + val result = + validate( + LengthDelimitedString.deserialize( + Strings.StringIn.newBuilder() + .setVal("foo") + .build() + .toByteArray() + ) + ) + + assertThat(result.isSuccess).isFalse() + + val result2 = + validate( + LengthDelimitedString.deserialize( + Strings.StringIn.newBuilder() + .setVal("bar") + .build() + .toByteArray() + ) + ) + + assertThat(result2.isSuccess).isTrue() + } + + @Test + fun `test message with length delimited bytes encoded purely as unknown fields (dynamic message without a dedicated type)`() { + load(bytes_file_descriptor.descriptor) + + val result = + validate( + LengthDelimitedBytes.deserialize( + Bytes.BytesIn.newBuilder() + .setVal(ByteString.copyFromUtf8("foo")) + .build() + .toByteArray() + ) + ) + + assertThat(result.isSuccess).isFalse() + + val result2 = + validate( + LengthDelimitedBytes.deserialize( + Bytes.BytesIn.newBuilder() + .setVal(ByteString.copyFromUtf8("bar")) + .build() + .toByteArray() + ) + ) + + assertThat(result2.isSuccess).isTrue() + } + + @Test + fun `test message with repeated values encoded purely as unknown fields (dynamic message without a dedicated type)`() { + load(repeated_file_descriptor.descriptor) + + val result = + validate( + RepeatedLengthDelimited.deserialize( + Repeated.RepeatedUnique.newBuilder() + .addAllVal(listOf("foo", "foo")) + .build() + .toByteArray() + ) + ) + + assertThat(result.isSuccess).isFalse() + + val result2 = + validate( + RepeatedLengthDelimited.deserialize( + Repeated.RepeatedUnique.newBuilder() + .addAllVal(listOf("foo", "bar")) + .build() + .toByteArray() + ) + ) + + assertThat(result2.isSuccess).isTrue() + } + + abstract class AbstractDynamicMessage : AbstractMessage() { + abstract val unknownFields: UnknownFieldSet + + override fun messageSize() = + unknownFields.size() + + override fun serialize(writer: Writer) { + writer.writeUnknown(unknownFields) + } + } + + @GeneratedMessage("buf.validate.conformance.cases.Int64In") + class Int64( + override val unknownFields: UnknownFieldSet, + ) : AbstractDynamicMessage() { + companion object : AbstractDeserializer() { + @JvmStatic + override fun deserialize(reader: Reader): Int64 { + val unknownFields = UnknownFieldSet.Builder() + + while (true) { + when (reader.readTag()) { + 0u -> return Int64(UnknownFieldSet.from(unknownFields)) + else -> unknownFields.add(reader.readUnknown()) + } + } + } + } + } + + @GeneratedMessage("buf.validate.conformance.cases.UInt64In") + class UInt64( + override val unknownFields: UnknownFieldSet, + ) : AbstractDynamicMessage() { + companion object : AbstractDeserializer() { + @JvmStatic + override fun deserialize(reader: Reader): UInt64 { + val unknownFields = UnknownFieldSet.Builder() + + while (true) { + when (reader.readTag()) { + 0u -> return UInt64(UnknownFieldSet.from(unknownFields)) + else -> unknownFields.add(reader.readUnknown()) + } + } + } + } + } + + @GeneratedMessage("buf.validate.conformance.cases.Fixed32In") + class Fixed32( + override val unknownFields: UnknownFieldSet, + ) : AbstractDynamicMessage() { + companion object : AbstractDeserializer() { + @JvmStatic + override fun deserialize(reader: Reader): Fixed32 { + val unknownFields = UnknownFieldSet.Builder() + + while (true) { + when (reader.readTag()) { + 0u -> return Fixed32(UnknownFieldSet.from(unknownFields)) + else -> unknownFields.add(reader.readUnknown()) + } + } + } + } + } + + @GeneratedMessage("buf.validate.conformance.cases.Fixed64In") + class Fixed64( + override val unknownFields: UnknownFieldSet, + ) : AbstractDynamicMessage() { + companion object : AbstractDeserializer() { + @JvmStatic + override fun deserialize(reader: Reader): Fixed64 { + val unknownFields = UnknownFieldSet.Builder() + + while (true) { + when (reader.readTag()) { + 0u -> return Fixed64(UnknownFieldSet.from(unknownFields)) + else -> unknownFields.add(reader.readUnknown()) + } + } + } + } + } + + @GeneratedMessage("buf.validate.conformance.cases.StringIn") + class LengthDelimitedString( + override val unknownFields: UnknownFieldSet, + ) : AbstractDynamicMessage() { + companion object : AbstractDeserializer() { + @JvmStatic + override fun deserialize(reader: Reader): LengthDelimitedString { + val unknownFields = UnknownFieldSet.Builder() + + while (true) { + when (reader.readTag()) { + 0u -> return LengthDelimitedString(UnknownFieldSet.from(unknownFields)) + else -> unknownFields.add(reader.readUnknown()) + } + } + } + } + } + + @GeneratedMessage("buf.validate.conformance.cases.BytesIn") + class LengthDelimitedBytes( + override val unknownFields: UnknownFieldSet, + ) : AbstractDynamicMessage() { + companion object : AbstractDeserializer() { + @JvmStatic + override fun deserialize(reader: Reader): LengthDelimitedBytes { + val unknownFields = UnknownFieldSet.Builder() + + while (true) { + when (reader.readTag()) { + 0u -> return LengthDelimitedBytes(UnknownFieldSet.from(unknownFields)) + else -> unknownFields.add(reader.readUnknown()) + } + } + } + } + } + + @GeneratedMessage("buf.validate.conformance.cases.RepeatedUnique") + class RepeatedLengthDelimited( + override val unknownFields: UnknownFieldSet, + ) : AbstractDynamicMessage() { + companion object : AbstractDeserializer() { + @JvmStatic + override fun deserialize(reader: Reader): RepeatedLengthDelimited { + val unknownFields = UnknownFieldSet.Builder() + + while (true) { + when (reader.readTag()) { + 0u -> return RepeatedLengthDelimited(UnknownFieldSet.from(unknownFields)) + else -> unknownFields.add(reader.readUnknown()) + } + } + } + } + } +} diff --git a/testing/protovalidate-conformance/src/test/kotlin/protokt/v1/buf/validate/ConformanceTest.kt b/testing/protovalidate-conformance/src/test/kotlin/protokt/v1/buf/validate/ConformanceTest.kt new file mode 100644 index 00000000..2e1615b3 --- /dev/null +++ b/testing/protovalidate-conformance/src/test/kotlin/protokt/v1/buf/validate/ConformanceTest.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2024 Toast, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package protokt.v1.buf.validate + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import protokt.v1.testing.projectRoot +import protokt.v1.testing.runCommand +import java.nio.file.Path +import java.time.Duration + +class ConformanceTest { + @Test + fun `run conformance test`() { + try { + val output = command().runCommand( + projectRoot.toPath(), + env = mapOf( + "JAVA_OPTS" to "-Xmx64M", + "GOMEMLIMIT" to "32000000" + ), + timeout = Duration.ofMinutes(8) + ) + println(output.stderr) + assertThat(output.stderr).startsWith("PASS") + assertThat(output.stderr).contains("failed: 0") + assertThat(output.exitCode).isEqualTo(0) + } catch (t: Throwable) { + t.printStackTrace() + throw t + } + } +} + +private fun command() = + "${System.getProperty("conformance-runner")} --strict_message --strict_error $driver" + +private val driver = + Path.of(projectRoot.absolutePath, "build", "install", "protovalidate-conformance", "bin", "protovalidate-conformance") diff --git a/testing/protovalidate-conformance/src/test/kotlin/protokt/v1/buf/validate/ProtoktDynamicMessageTest.kt b/testing/protovalidate-conformance/src/test/kotlin/protokt/v1/buf/validate/ProtoktDynamicMessageTest.kt new file mode 100644 index 00000000..f0e20c0f --- /dev/null +++ b/testing/protovalidate-conformance/src/test/kotlin/protokt/v1/buf/validate/ProtoktDynamicMessageTest.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2024 Toast, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package protokt.v1.buf.validate + +import protokt.v1.Message + +class ProtoktDynamicMessageTest : AbstractValidatorTest() { + override fun validate(message: Message) = + validator.validate(message) +} diff --git a/testing/testing-util/src/main/kotlin/protokt/v1/testing/ProcessUtils.kt b/testing/testing-util/src/main/kotlin/protokt/v1/testing/ProcessUtils.kt index 5e9b758b..f4f15dce 100644 --- a/testing/testing-util/src/main/kotlin/protokt/v1/testing/ProcessUtils.kt +++ b/testing/testing-util/src/main/kotlin/protokt/v1/testing/ProcessUtils.kt @@ -20,6 +20,7 @@ import protokt.v1.testing.ProcessOutput.Src.ERR import protokt.v1.testing.ProcessOutput.Src.OUT import java.io.File import java.nio.file.Path +import java.time.Duration import java.util.concurrent.TimeUnit val projectRoot = @@ -27,7 +28,8 @@ val projectRoot = fun String.runCommand( workingDir: Path, - env: Map = emptyMap() + env: Map = emptyMap(), + timeout: Duration = Duration.ofSeconds(10) ): ProcessOutput { println("Executing $this in $workingDir with $env") @@ -39,7 +41,7 @@ fun String.runCommand( .apply { environment().putAll(env) } .start() - if (!proc.waitFor(10, TimeUnit.SECONDS)) { + if (!proc.waitFor(timeout.toSeconds(), TimeUnit.SECONDS)) { proc.destroyForcibly() fail("Process '$this' took too long") }