diff --git a/formats/cbor/commonMain/src/kotlinx/serialization/cbor/Cbor.kt b/formats/cbor/commonMain/src/kotlinx/serialization/cbor/Cbor.kt index 21293a923..ec888aca2 100644 --- a/formats/cbor/commonMain/src/kotlinx/serialization/cbor/Cbor.kt +++ b/formats/cbor/commonMain/src/kotlinx/serialization/cbor/Cbor.kt @@ -42,7 +42,8 @@ public sealed class Cbor( verifyObjectTags = false, useDefiniteLengthEncoding = false, preferCborLabelsOverNames = false, - alwaysUseByteString = false + alwaysUseByteString = false, + treatNullComplexObjectsAsNull = false, ), EmptySerializersModule() ) { @@ -64,6 +65,7 @@ public sealed class Cbor( useDefiniteLengthEncoding = true preferCborLabelsOverNames = true alwaysUseByteString = false + treatNullComplexObjectsAsNull = false serializersModule = EmptySerializersModule() } } @@ -85,7 +87,7 @@ public sealed class Cbor( override fun decodeFromByteArray(deserializer: DeserializationStrategy, bytes: ByteArray): T { val stream = ByteArrayInput(bytes) - val reader = CborReader(this, CborParser(stream, configuration.verifyObjectTags)) + val reader = CborReader(this, CborParser(stream, configuration)) return reader.decodeSerializableValue(deserializer) } } @@ -119,7 +121,8 @@ public fun Cbor(from: Cbor = Cbor, builderAction: CborBuilder.() -> Unit): Cbor builder.verifyObjectTags, builder.useDefiniteLengthEncoding, builder.preferCborLabelsOverNames, - builder.alwaysUseByteString), + builder.alwaysUseByteString, + builder.treatNullComplexObjectsAsNull), builder.serializersModule ) } @@ -243,6 +246,16 @@ public class CborBuilder internal constructor(cbor: Cbor) { */ public var alwaysUseByteString: Boolean = cbor.configuration.alwaysUseByteString + /** + * Specifies the encoding of null instances of complex types when serializing or deserializing. + * By default, null instances of complex types are encoded as an empty map (i.e. major type 5 with zero elements, 0xA0) and + * all complex types being deserialized will be set to null when null or an empty map is found. + * The [treatNullComplexObjectsAsNull] configuration switch can be used to force only null values be used instead + * (i.e major type 7, value 22, 0xF6). + * See [RFC 8949 Table 3](https://datatracker.ietf.org/doc/html/rfc8949#table-3) + */ + public var treatNullComplexObjectsAsNull: Boolean = cbor.configuration.treatNullComplexObjectsAsNull + /** * Module with contextual and polymorphic serializers to be used in the resulting [Cbor] instance. */ diff --git a/formats/cbor/commonMain/src/kotlinx/serialization/cbor/CborConfiguration.kt b/formats/cbor/commonMain/src/kotlinx/serialization/cbor/CborConfiguration.kt index 3d88627f2..65200f108 100644 --- a/formats/cbor/commonMain/src/kotlinx/serialization/cbor/CborConfiguration.kt +++ b/formats/cbor/commonMain/src/kotlinx/serialization/cbor/CborConfiguration.kt @@ -89,6 +89,12 @@ import kotlinx.serialization.* * basis. The [alwaysUseByteString] configuration switch allows for globally preferring **major type 2** without needing * to annotate every `ByteArray` in a class hierarchy. * + * @param treatNullComplexObjectsAsNull Specifies the encoding of null instances of complex types when serializing or deserializing. + * By default, null instances of complex types are encoded as an empty map (i.e. major type 5 with zero elements, 0xA0) and + * all complex types being deserialized will be set to null when null or an empty map is found. + * The [treatNullComplexObjectsAsNull] configuration switch can be used to force only null values be used instead + * (i.e major type 7, value 22, 0xF6). + * See [RFC 8949 Table 3](https://datatracker.ietf.org/doc/html/rfc8949#table-3) */ @ExperimentalSerializationApi public class CborConfiguration internal constructor( @@ -103,12 +109,14 @@ public class CborConfiguration internal constructor( public val useDefiniteLengthEncoding: Boolean, public val preferCborLabelsOverNames: Boolean, public val alwaysUseByteString: Boolean, + public val treatNullComplexObjectsAsNull: Boolean, ) { override fun toString(): String { return "CborConfiguration(encodeDefaults=$encodeDefaults, ignoreUnknownKeys=$ignoreUnknownKeys, " + "encodeKeyTags=$encodeKeyTags, encodeValueTags=$encodeValueTags, encodeObjectTags=$encodeObjectTags, " + "verifyKeyTags=$verifyKeyTags, verifyValueTags=$verifyValueTags, verifyObjectTags=$verifyObjectTags, " + "useDefiniteLengthEncoding=$useDefiniteLengthEncoding, " + - "preferCborLabelsOverNames=$preferCborLabelsOverNames, alwaysUseByteString=$alwaysUseByteString)" + "preferCborLabelsOverNames=$preferCborLabelsOverNames, alwaysUseByteString=$alwaysUseByteString, " + + "treatNullComplexObjectsAsNull=$treatNullComplexObjectsAsNull)" } } \ No newline at end of file diff --git a/formats/cbor/commonMain/src/kotlinx/serialization/cbor/internal/Decoder.kt b/formats/cbor/commonMain/src/kotlinx/serialization/cbor/internal/Decoder.kt index 88075db26..784bf05e6 100644 --- a/formats/cbor/commonMain/src/kotlinx/serialization/cbor/internal/Decoder.kt +++ b/formats/cbor/commonMain/src/kotlinx/serialization/cbor/internal/Decoder.kt @@ -151,7 +151,7 @@ internal open class CborReader(override val cbor: Cbor, protected val parser: Cb } } -internal class CborParser(private val input: ByteArrayInput, private val verifyObjectTags: Boolean) { +internal class CborParser(private val input: ByteArrayInput, private val configuration: CborConfiguration) { private var curByte: Int = -1 init { @@ -170,13 +170,13 @@ internal class CborParser(private val input: ByteArrayInput, private val verifyO readByte() } - fun isNull() = (curByte == NULL || curByte == EMPTY_MAP) + fun isNull() = (curByte == NULL || curByte == EMPTY_MAP && !configuration.treatNullComplexObjectsAsNull) fun nextNull(tags: ULongArray? = null): Nothing? { processTags(tags) if (curByte == NULL) { skipByte(NULL) - } else if (curByte == EMPTY_MAP) { + } else if (curByte == EMPTY_MAP && !configuration.treatNullComplexObjectsAsNull) { skipByte(EMPTY_MAP) } return null @@ -258,7 +258,7 @@ internal class CborParser(private val input: ByteArrayInput, private val verifyO collectedTags += readTag // value tags and object tags are intermingled (keyTags are always separate) // so this check only holds if we verify both - if (verifyObjectTags) { + if (configuration.verifyObjectTags) { tags?.let { if (index++ >= it.size) throw CborDecodingException("More tags found than the ${it.size} tags specified") } @@ -268,7 +268,7 @@ internal class CborParser(private val input: ByteArrayInput, private val verifyO return (if (collectedTags.isEmpty()) null else collectedTags.toULongArray()).also { collected -> //We only want to compare if tags are actually set, otherwise, we don't care tags?.let { - if (verifyObjectTags) { //again, this check only works if we verify value tags and object tags + if (configuration.verifyObjectTags) { //again, this check only works if we verify value tags and object tags verifyTagsAndThrow(it, collected) } else { // If we don't care for object tags, the best we can do is assure that the collected tags start with diff --git a/formats/cbor/commonMain/src/kotlinx/serialization/cbor/internal/Encoder.kt b/formats/cbor/commonMain/src/kotlinx/serialization/cbor/internal/Encoder.kt index eb5fc556a..05c812841 100644 --- a/formats/cbor/commonMain/src/kotlinx/serialization/cbor/internal/Encoder.kt +++ b/formats/cbor/commonMain/src/kotlinx/serialization/cbor/internal/Encoder.kt @@ -103,7 +103,7 @@ internal sealed class CborWriter( override fun encodeNull() { - if (isClass) getDestination().encodeEmptyMap() + if (isClass && !cbor.configuration.treatNullComplexObjectsAsNull) getDestination().encodeEmptyMap() else getDestination().encodeNull() } diff --git a/formats/cbor/commonTest/src/kotlinx/serialization/cbor/CborArrayNullWithOptionalClassElement.kt b/formats/cbor/commonTest/src/kotlinx/serialization/cbor/CborArrayNullWithOptionalClassElement.kt new file mode 100644 index 000000000..2de9a75fb --- /dev/null +++ b/formats/cbor/commonTest/src/kotlinx/serialization/cbor/CborArrayNullWithOptionalClassElement.kt @@ -0,0 +1,103 @@ +package kotlinx.serialization.cbor + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.HexConverter +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromHexString +import kotlinx.serialization.encodeToByteArray +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFails + +class CborArrayNullWithOptionalClassElement { + + /** + * 82 # array(2) + * 63 # text(3) + * 666F6F # "foo" + * A0 # map(0) <- notice this is an empty map + */ + private val withEmptyMap = "8263666F6FA0" + + + /** + * 82 # array(2) + * 63 # text(3) + * 666F6F # "foo" + * 80 # array(0) <- notice this is an empty array + */ + private val withEmptyArray = "8263666F6F80" + + + /** + * 82 # array(2) + * 63 # text(3) + * 666F6F # "foo" + * F6 # null <- notice this null + */ + private val withNullElement = "8263666F6FF6" + + + @Test + fun withNullEncodesAndDecodes() { + val cbor = Cbor { + useDefiniteLengthEncoding = true + treatNullComplexObjectsAsNull = true + } + + val structureWithNull = SessionTranscript( + someValue = "foo", + nested = null + ) + + val decodedStructureWithNull = + cbor.decodeFromHexString(withNullElement) + // currently functions and this assert passes + assertEquals(structureWithNull, decodedStructureWithNull) + + val encodedStructureWithNull = + cbor.encodeToByteArray(structureWithNull) + // currently, this assert fails - as the last byte is 0xA0 rather than 0xF6 + assertContentEquals(HexConverter.parseHexBinary(withNullElement), encodedStructureWithNull) + } + + @Test + fun withEmptyMap() { + val cbor = Cbor { + useDefiniteLengthEncoding = true + treatNullComplexObjectsAsNull = true + } + + assertFails { + cbor.decodeFromHexString(withEmptyMap) + } + } + + @Test + fun withEmptyArray() { + val cbor = Cbor { + useDefiniteLengthEncoding = true + treatNullComplexObjectsAsNull = true + } + + assertFails { + cbor.decodeFromHexString(withEmptyArray) + } + } + + @OptIn(ExperimentalSerializationApi::class) + @CborArray + @Serializable + data class SessionTranscript( + val someValue: String, + val nested: OtherKind? + ) + + @OptIn(ExperimentalSerializationApi::class) + @CborArray + @Serializable + data class OtherKind( + val otherValue: String, + ) +} \ No newline at end of file diff --git a/formats/cbor/commonTest/src/kotlinx/serialization/cbor/CborParserTest.kt b/formats/cbor/commonTest/src/kotlinx/serialization/cbor/CborParserTest.kt index d8c7c6929..b7e0fcdbb 100644 --- a/formats/cbor/commonTest/src/kotlinx/serialization/cbor/CborParserTest.kt +++ b/formats/cbor/commonTest/src/kotlinx/serialization/cbor/CborParserTest.kt @@ -15,7 +15,7 @@ class CborParserTest { private fun withParser(input: String, block: CborParser.() -> Unit) { val bytes = HexConverter.parseHexBinary(input.uppercase()) - CborParser(ByteArrayInput(bytes), false).block() + CborParser(ByteArrayInput(bytes), Cbor.configuration).block() } @Test