Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add flag representing strict CBOR null representation #2952

Open
wants to merge 1 commit into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions formats/cbor/commonMain/src/kotlinx/serialization/cbor/Cbor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ public sealed class Cbor(
verifyObjectTags = false,
useDefiniteLengthEncoding = false,
preferCborLabelsOverNames = false,
alwaysUseByteString = false
alwaysUseByteString = false,
treatNullComplexObjectsAsNull = false,
), EmptySerializersModule()
) {

Expand All @@ -64,6 +65,7 @@ public sealed class Cbor(
useDefiniteLengthEncoding = true
preferCborLabelsOverNames = true
alwaysUseByteString = false
treatNullComplexObjectsAsNull = false
serializersModule = EmptySerializersModule()
}
}
Expand All @@ -85,7 +87,7 @@ public sealed class Cbor(

override fun <T> decodeFromByteArray(deserializer: DeserializationStrategy<T>, 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)
}
}
Expand Down Expand Up @@ -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
)
}
Expand Down Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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")
}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<SessionTranscript>(withNullElement)
// currently functions and this assert passes
assertEquals(structureWithNull, decodedStructureWithNull)

val encodedStructureWithNull =
cbor.encodeToByteArray<SessionTranscript>(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<SessionTranscript>(withEmptyMap)
}
}

@Test
fun withEmptyArray() {
val cbor = Cbor {
useDefiniteLengthEncoding = true
treatNullComplexObjectsAsNull = true
}

assertFails {
cbor.decodeFromHexString<SessionTranscript>(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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down