diff --git a/serialization-djvm/src/main/kotlin/net/corda/serialization/djvm/serializers/SandboxEnumSerializer.kt b/serialization-djvm/src/main/kotlin/net/corda/serialization/djvm/serializers/SandboxEnumSerializer.kt index 87946820da..361b467c08 100644 --- a/serialization-djvm/src/main/kotlin/net/corda/serialization/djvm/serializers/SandboxEnumSerializer.kt +++ b/serialization-djvm/src/main/kotlin/net/corda/serialization/djvm/serializers/SandboxEnumSerializer.kt @@ -86,6 +86,7 @@ private class ConcreteEnumSerializer( declaredType, TypeIdentifier.forGenericType(declaredType), memberNames, + emptyMap(), emptyList(), EnumTransforms.empty ) diff --git a/serialization-djvm/src/test/kotlin/net/corda/serialization/djvm/DeserializeRemoteCustomisedEnumTest.kt b/serialization-djvm/src/test/kotlin/net/corda/serialization/djvm/DeserializeRemoteCustomisedEnumTest.kt new file mode 100644 index 0000000000..7c2f5ffee7 --- /dev/null +++ b/serialization-djvm/src/test/kotlin/net/corda/serialization/djvm/DeserializeRemoteCustomisedEnumTest.kt @@ -0,0 +1,144 @@ +package net.corda.serialization.djvm + +import net.corda.core.serialization.CordaSerializable +import net.corda.core.serialization.SerializedBytes +import net.corda.core.serialization.internal._contextSerializationEnv +import net.corda.core.serialization.serialize +import net.corda.serialization.djvm.SandboxType.KOTLIN +import net.corda.serialization.internal.amqp.CompositeType +import net.corda.serialization.internal.amqp.DeserializationInput +import net.corda.serialization.internal.amqp.RestrictedType +import net.corda.serialization.internal.amqp.TypeNotation +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.fail +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource +import java.util.function.Function + +/** + * Corda 4.4 briefly serialised [Enum] values using [Enum.toString] rather + * than [Enum.name]. We need to be able to deserialise these values now + * that the bug has been fixed. + */ +@ExtendWith(LocalSerialization::class) +class DeserializeRemoteCustomisedEnumTest : TestBase(KOTLIN) { + @ParameterizedTest + @EnumSource(Broken::class) + fun `test deserialize broken enum with custom toString`(broken: Broken) { + val workingData = broken.serialize().rewriteEnumAsWorking() + + sandbox { + _contextSerializationEnv.set(createSandboxSerializationEnv(classLoader)) + + val sandboxWorkingClass = classLoader.toSandboxClass(Working::class.java) + val sandboxWorkingValue = workingData.deserializeFor(classLoader) + assertThat(sandboxWorkingValue::class.java).isSameAs(sandboxWorkingClass) + assertThat(sandboxWorkingValue.toString()).isEqualTo(broken.label) + } + } + + /** + * This function rewrites the [SerializedBytes] for a naked [Broken] object + * into the [SerializedBytes] that Corda 4.4 would generate for an equivalent + * [Working] object. + */ + @Suppress("unchecked_cast") + private fun SerializedBytes.rewriteEnumAsWorking(): SerializedBytes { + val envelope = DeserializationInput.getEnvelope(this).apply { + val restrictedType = schema.types[0] as RestrictedType + (schema.types as MutableList)[0] = restrictedType.copy( + name = toWorking(restrictedType.name) + ) + } + return SerializedBytes(envelope.write()) + } + + @ParameterizedTest + @EnumSource(Broken::class) + fun `test deserialize composed broken enum with custom toString`(broken: Broken) { + val brokenContainer = BrokenContainer(broken) + val workingData = brokenContainer.serialize().rewriteContainerAsWorking() + + sandbox { + _contextSerializationEnv.set(createSandboxSerializationEnv(classLoader)) + + val sandboxContainer = workingData.deserializeFor(classLoader) + + val taskFactory = classLoader.createRawTaskFactory() + val showWorkingData = taskFactory.compose(classLoader.createSandboxFunction()).apply(ShowWorkingData::class.java) + val result = showWorkingData.apply(sandboxContainer) ?: fail("Result cannot be null") + + assertEquals("Working: label='${broken.label}', ordinal='${broken.ordinal}'", result.toString()) + assertEquals(SANDBOX_STRING, result::class.java.name) + } + } + + class ShowWorkingData : Function { + override fun apply(input: WorkingContainer): String { + return with(input) { + "Working: label='${value.label}', ordinal='${value.ordinal}'" + } + } + } + + /** + * This function rewrites the [SerializedBytes] for a [Broken] + * property that has been composed inside a [BrokenContainer]. + * It will generate the [SerializedBytes] that Corda 4.4 would + * generate for an equivalent [WorkingContainer]. + */ + @Suppress("unchecked_cast") + private fun SerializedBytes.rewriteContainerAsWorking(): SerializedBytes { + val envelope = DeserializationInput.getEnvelope(this).apply { + val compositeType = schema.types[0] as CompositeType + (schema.types as MutableList)[0] = compositeType.copy( + name = toWorking(compositeType.name), + fields = compositeType.fields.map { it.copy(type = toWorking(it.type)) } + ) + val restrictedType = schema.types[1] as RestrictedType + (schema.types as MutableList)[1] = restrictedType.copy( + name = toWorking(restrictedType.name) + ) + } + return SerializedBytes(envelope.write()) + } + + private fun toWorking(oldName: String): String = oldName.replace("Broken", "Working") + + /** + * This is the enumerated type, as it actually exist. + */ + @Suppress("unused") + enum class Working(val label: String) { + ZERO("None"), + ONE("Once"), + TWO("Twice"); + + @Override + override fun toString(): String = label + } + + @CordaSerializable + data class WorkingContainer(val value: Working) + + /** + * This represents a broken serializer's view of the [Working] + * enumerated type, which would serialize using [Enum.toString] + * rather than [Enum.name]. + */ + @Suppress("unused") + @CordaSerializable + enum class Broken(val label: String) { + None("None"), + Once("Once"), + Twice("Twice"); + + @Override + override fun toString(): String = label + } + + @CordaSerializable + data class BrokenContainer(val value: Broken) +} diff --git a/serialization/src/main/kotlin/net/corda/serialization/internal/amqp/EvolutionSerializerFactory.kt b/serialization/src/main/kotlin/net/corda/serialization/internal/amqp/EvolutionSerializerFactory.kt index 4d2b710bf0..b16a6d2213 100644 --- a/serialization/src/main/kotlin/net/corda/serialization/internal/amqp/EvolutionSerializerFactory.kt +++ b/serialization/src/main/kotlin/net/corda/serialization/internal/amqp/EvolutionSerializerFactory.kt @@ -155,16 +155,16 @@ class DefaultEvolutionSerializerFactory( val localTransforms = localTypeInformation.transforms val transforms = if (remoteTransforms.size > localTransforms.size) remoteTransforms else localTransforms - val localOrdinals = localTypeInformation.members.asSequence().mapIndexed { ord, member -> member to ord }.toMap() - val remoteOrdinals = members.asSequence().mapIndexed { ord, member -> member to ord }.toMap() + val localOrdinals = localTypeInformation.members.mapIndexed { ord, member -> member to ord }.toMap() + val remoteOrdinals = members.mapIndexed { ord, member -> member to ord }.toMap() val rules = transforms.defaults + transforms.renames // We just trust our transformation rules not to contain cycles here. tailrec fun findLocal(remote: String): String = - if (remote in localOrdinals) remote - else findLocal(rules[remote] ?: throw EvolutionSerializationException( - this, - "Cannot resolve local enum member $remote to a member of ${localOrdinals.keys} using rules $rules" + if (remote in localOrdinals.keys) remote + else localTypeInformation.fallbacks[remote] ?: findLocal(rules[remote] ?: throw EvolutionSerializationException( + this, + "Cannot resolve local enum member $remote to a member of ${localOrdinals.keys} using rules $rules" )) val conversions = members.associate { it to findLocal(it) } diff --git a/serialization/src/main/kotlin/net/corda/serialization/internal/model/LocalTypeInformation.kt b/serialization/src/main/kotlin/net/corda/serialization/internal/model/LocalTypeInformation.kt index fd1814ff80..302cb24ce4 100644 --- a/serialization/src/main/kotlin/net/corda/serialization/internal/model/LocalTypeInformation.kt +++ b/serialization/src/main/kotlin/net/corda/serialization/internal/model/LocalTypeInformation.kt @@ -36,7 +36,7 @@ typealias PropertyName = String * If a concrete type does not have a unique deserialization constructor, it is represented by [NonComposable], meaning * that we know how to take it apart but do not know how to put it back together again. * - * An array of any type is represented by [ArrayOf]. Enums are represented by [AnEnum]. + * An array of any type is represented by [AnArray]. Enums are represented by [AnEnum]. * * The type of [Any]/[java.lang.Object] is represented by [Top]. Unbounded wildcards, or wildcards whose upper bound is * [Top], are represented by [Unknown]. Bounded wildcards are always resolved to their upper bounds, e.g. @@ -178,6 +178,7 @@ sealed class LocalTypeInformation { override val observedType: Class<*>, override val typeIdentifier: TypeIdentifier, val members: List, + val fallbacks: Map, val interfaces: List, val transforms: EnumTransforms): LocalTypeInformation() diff --git a/serialization/src/main/kotlin/net/corda/serialization/internal/model/LocalTypeInformationBuilder.kt b/serialization/src/main/kotlin/net/corda/serialization/internal/model/LocalTypeInformationBuilder.kt index 95d2e594e0..c868193354 100644 --- a/serialization/src/main/kotlin/net/corda/serialization/internal/model/LocalTypeInformationBuilder.kt +++ b/serialization/src/main/kotlin/net/corda/serialization/internal/model/LocalTypeInformationBuilder.kt @@ -120,6 +120,15 @@ internal data class LocalTypeInformationBuilder(val lookup: LocalTypeLookup, type, typeIdentifier, enumConstantNames, + /** + * Calculate "fallbacks" for any [Enum] incorrectly serialised + * as its [Enum.toString] value. We are only interested in the + * cases where these are different from [Enum.name]. + * These fallbacks DO NOT contribute to this type's fingerprint. + */ + baseTypes.enumConstants.apply(type).map(Any::toString).mapIndexed { ord, fallback -> + fallback to enumConstantNames[ord] + }.filterNot { it.first == it.second }.toMap(), buildInterfaceInformation(type), getEnumTransforms(type, enumConstantNames) ) diff --git a/serialization/src/test/kotlin/net/corda/serialization/internal/amqp/EnumTests.kt b/serialization/src/test/kotlin/net/corda/serialization/internal/amqp/EnumTests.kt index b406283d9f..0a76f19751 100644 --- a/serialization/src/test/kotlin/net/corda/serialization/internal/amqp/EnumTests.kt +++ b/serialization/src/test/kotlin/net/corda/serialization/internal/amqp/EnumTests.kt @@ -3,7 +3,6 @@ package net.corda.serialization.internal.amqp import net.corda.core.serialization.ClassWhitelist import net.corda.core.serialization.CordaSerializable import net.corda.core.serialization.SerializedBytes -import net.corda.core.serialization.deserialize import net.corda.serialization.internal.EmptyWhitelist import net.corda.serialization.internal.amqp.testutils.TestSerializationOutput import net.corda.serialization.internal.amqp.testutils.deserialize @@ -184,7 +183,7 @@ class EnumTests { data class C(val a: OldBras2) // DO NOT CHANGE THIS, it's important we serialise with a value that doesn't - // change position in the upated enum class + // change position in the updated enum class // Original version of the class for the serialised version of this class // diff --git a/serialization/src/test/kotlin/net/corda/serialization/internal/amqp/EnumToStringFallbackTest.kt b/serialization/src/test/kotlin/net/corda/serialization/internal/amqp/EnumToStringFallbackTest.kt new file mode 100644 index 0000000000..6389f78591 --- /dev/null +++ b/serialization/src/test/kotlin/net/corda/serialization/internal/amqp/EnumToStringFallbackTest.kt @@ -0,0 +1,100 @@ +package net.corda.serialization.internal.amqp + +import net.corda.core.serialization.SerializationContext +import net.corda.core.serialization.SerializationContext.UseCase.Testing +import net.corda.core.serialization.SerializedBytes +import net.corda.serialization.internal.AllWhitelist +import net.corda.serialization.internal.SerializationContextImpl +import net.corda.serialization.internal.amqp.testutils.TestSerializationOutput +import net.corda.serialization.internal.amqp.testutils.testDefaultFactory +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +/** + * Corda 4.4 briefly serialised [Enum] values using [Enum.toString] rather + * than [Enum.name]. We need to be able to deserialise these values now + * that the bug has been fixed. + */ +class EnumToStringFallbackTest { + private lateinit var serializationOutput: TestSerializationOutput + + private fun createTestContext(): SerializationContext = SerializationContextImpl( + preferredSerializationVersion = amqpMagic, + deserializationClassLoader = ClassLoader.getSystemClassLoader(), + whitelist = AllWhitelist, + properties = emptyMap(), + objectReferencesEnabled = false, + useCase = Testing, + encoding = null + ) + + @Before + fun setup() { + serializationOutput = TestSerializationOutput(verbose = false) + } + + @Test(timeout = 300_000) + fun deserializeEnumWithToString() { + val broken = BrokenContainer(Broken.Twice) + val brokenData = serializationOutput.serialize(broken, createTestContext()) + val workingData = brokenData.rewriteAsWorking() + val working = DeserializationInput(testDefaultFactory()).deserialize(workingData, createTestContext()) + assertEquals(Working.TWO, working.value) + } + + /** + * This function rewrites the [SerializedBytes] for a [Broken] + * property that has been composed inside a [BrokenContainer]. + * It will generate the [SerializedBytes] that Corda 4.4 would + * generate for an equivalent [WorkingContainer]. + */ + @Suppress("unchecked_cast") + private fun SerializedBytes.rewriteAsWorking(): SerializedBytes { + val envelope = DeserializationInput.getEnvelope(this).apply { + val compositeType = schema.types[0] as CompositeType + (schema.types as MutableList)[0] = compositeType.copy( + name = toWorking(compositeType.name), + fields = compositeType.fields.map { it.copy(type = toWorking(it.type)) } + ) + val restrictedType = schema.types[1] as RestrictedType + (schema.types as MutableList)[1] = restrictedType.copy( + name = toWorking(restrictedType.name) + ) + } + return SerializedBytes(envelope.write()) + } + + private fun toWorking(oldName: String): String = oldName.replace("Broken", "Working") + + /** + * This is the enumerated type, as it actually exist. + */ + @Suppress("unused") + enum class Working(private val label: String) { + ZERO("None"), + ONE("Once"), + TWO("Twice"); + + @Override + override fun toString(): String = label + } + + /** + * This represents a broken serializer's view of the [Working] + * enumerated type, which would serialize using [Enum.toString] + * rather than [Enum.name]. + */ + @Suppress("unused") + enum class Broken(private val label: String) { + None("None"), + Once("Once"), + Twice("Twice"); + + @Override + override fun toString(): String = label + } + + data class WorkingContainer(val value: Working) + data class BrokenContainer(val value: Broken) +} \ No newline at end of file diff --git a/serialization/src/test/kotlin/net/corda/serialization/internal/amqp/EnvelopeHelpers.kt b/serialization/src/test/kotlin/net/corda/serialization/internal/amqp/EnvelopeHelpers.kt new file mode 100644 index 0000000000..4b46ad2183 --- /dev/null +++ b/serialization/src/test/kotlin/net/corda/serialization/internal/amqp/EnvelopeHelpers.kt @@ -0,0 +1,24 @@ +@file:JvmName("EnvelopeHelpers") +package net.corda.serialization.internal.amqp + +import net.corda.serialization.internal.SectionId +import net.corda.serialization.internal.amqp.Envelope.Companion.DESCRIPTOR_OBJECT +import org.apache.qpid.proton.codec.Data +import java.io.ByteArrayOutputStream + +fun Envelope.write(): ByteArray { + val data = Data.Factory.create() + data.withDescribed(DESCRIPTOR_OBJECT) { + withList { + putObject(obj) + putObject(schema) + putObject(transformsSchema) + } + } + return ByteArrayOutputStream().use { + amqpMagic.writeTo(it) + SectionId.DATA_AND_STOP.writeTo(it) + it.alsoAsByteBuffer(data.encodedSize().toInt(), data::encode) + it.toByteArray() + } +} \ No newline at end of file