Skip to content

Commit d5b4bba

Browse files
committed
Add ability to disallow repeated keys in CBOR
Fixes Kotlin#2662 by adding a `visitKey` method to `CompositeDecoder`; map and set serializers should call this so that decoders have an opportunity to throw an error when a duplicate key is detected. A new config option `forbidDuplicateKeys` can be set to true to enable this new behavior. This can form the basis of a Strict Mode in the future.
1 parent 1b0accd commit d5b4bba

File tree

12 files changed

+125
-6
lines changed

12 files changed

+125
-6
lines changed

core/api/kotlinx-serialization-core.api

+6
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ public abstract interface class kotlinx/serialization/DeserializationStrategy {
1919
public abstract fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor;
2020
}
2121

22+
public final class kotlinx/serialization/DuplicateKeyException : kotlinx/serialization/SerializationException {
23+
public fun <init> (Ljava/lang/Object;)V
24+
}
25+
2226
public abstract interface annotation class kotlinx/serialization/EncodeDefault : java/lang/annotation/Annotation {
2327
public abstract fun mode ()Lkotlinx/serialization/EncodeDefault$Mode;
2428
}
@@ -445,6 +449,7 @@ public abstract interface class kotlinx/serialization/encoding/CompositeDecoder
445449
public abstract fun decodeStringElement (Lkotlinx/serialization/descriptors/SerialDescriptor;I)Ljava/lang/String;
446450
public abstract fun endStructure (Lkotlinx/serialization/descriptors/SerialDescriptor;)V
447451
public abstract fun getSerializersModule ()Lkotlinx/serialization/modules/SerializersModule;
452+
public fun visitKey (Ljava/lang/Object;)V
448453
}
449454

450455
public final class kotlinx/serialization/encoding/CompositeDecoder$Companion {
@@ -457,6 +462,7 @@ public final class kotlinx/serialization/encoding/CompositeDecoder$DefaultImpls
457462
public static synthetic fun decodeNullableSerializableElement$default (Lkotlinx/serialization/encoding/CompositeDecoder;Lkotlinx/serialization/descriptors/SerialDescriptor;ILkotlinx/serialization/DeserializationStrategy;Ljava/lang/Object;ILjava/lang/Object;)Ljava/lang/Object;
458463
public static fun decodeSequentially (Lkotlinx/serialization/encoding/CompositeDecoder;)Z
459464
public static synthetic fun decodeSerializableElement$default (Lkotlinx/serialization/encoding/CompositeDecoder;Lkotlinx/serialization/descriptors/SerialDescriptor;ILkotlinx/serialization/DeserializationStrategy;Ljava/lang/Object;ILjava/lang/Object;)Ljava/lang/Object;
465+
public static fun visitKey (Lkotlinx/serialization/encoding/CompositeDecoder;Ljava/lang/Object;)V
460466
}
461467

462468
public abstract interface class kotlinx/serialization/encoding/CompositeEncoder {

core/api/kotlinx-serialization-core.klib.api

+5
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ abstract interface kotlinx.serialization.encoding/CompositeDecoder { // kotlinx.
184184
abstract fun endStructure(kotlinx.serialization.descriptors/SerialDescriptor) // kotlinx.serialization.encoding/CompositeDecoder.endStructure|endStructure(kotlinx.serialization.descriptors.SerialDescriptor){}[0]
185185
open fun decodeCollectionSize(kotlinx.serialization.descriptors/SerialDescriptor): kotlin/Int // kotlinx.serialization.encoding/CompositeDecoder.decodeCollectionSize|decodeCollectionSize(kotlinx.serialization.descriptors.SerialDescriptor){}[0]
186186
open fun decodeSequentially(): kotlin/Boolean // kotlinx.serialization.encoding/CompositeDecoder.decodeSequentially|decodeSequentially(){}[0]
187+
open fun visitKey(kotlin/Any?) // kotlinx.serialization.encoding/CompositeDecoder.visitKey|visitKey(kotlin.Any?){}[0]
187188

188189
final object Companion { // kotlinx.serialization.encoding/CompositeDecoder.Companion|null[0]
189190
final const val DECODE_DONE // kotlinx.serialization.encoding/CompositeDecoder.Companion.DECODE_DONE|{}DECODE_DONE[0]
@@ -735,6 +736,10 @@ final class kotlinx.serialization.modules/SerializersModuleBuilder : kotlinx.ser
735736
final fun include(kotlinx.serialization.modules/SerializersModule) // kotlinx.serialization.modules/SerializersModuleBuilder.include|include(kotlinx.serialization.modules.SerializersModule){}[0]
736737
}
737738

739+
final class kotlinx.serialization/DuplicateKeyException : kotlinx.serialization/SerializationException { // kotlinx.serialization/DuplicateKeyException|null[0]
740+
constructor <init>(kotlin/Any?) // kotlinx.serialization/DuplicateKeyException.<init>|<init>(kotlin.Any?){}[0]
741+
}
742+
738743
final class kotlinx.serialization/MissingFieldException : kotlinx.serialization/SerializationException { // kotlinx.serialization/MissingFieldException|null[0]
739744
constructor <init>(kotlin.collections/List<kotlin/String>, kotlin/String) // kotlinx.serialization/MissingFieldException.<init>|<init>(kotlin.collections.List<kotlin.String>;kotlin.String){}[0]
740745
constructor <init>(kotlin.collections/List<kotlin/String>, kotlin/String?, kotlin/Throwable?) // kotlinx.serialization/MissingFieldException.<init>|<init>(kotlin.collections.List<kotlin.String>;kotlin.String?;kotlin.Throwable?){}[0]

core/commonMain/src/kotlinx/serialization/SerializationExceptions.kt

+7
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,10 @@ internal constructor(message: String?) : SerializationException(message) {
135135
// This constructor is used by the generated serializers
136136
constructor(index: Int) : this("An unknown field for index $index")
137137
}
138+
139+
/**
140+
* Thrown when a deserializer encounters a repeated key (and configuration disallows this.)
141+
*/
142+
@ExperimentalSerializationApi
143+
public class DuplicateKeyException(key: Any?) :
144+
SerializationException("Duplicate keys not allowed. Key appeared twice: $key")

core/commonMain/src/kotlinx/serialization/encoding/Decoding.kt

+12
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,18 @@ public interface CompositeDecoder {
559559
deserializer: DeserializationStrategy<T?>,
560560
previousValue: T? = null
561561
): T?
562+
563+
/**
564+
* Called after a key has been read.
565+
*
566+
* This could be a map or set key, or anything otherwise intended to be
567+
* distinct within the collection under normal circumstances.
568+
*
569+
* Implementations might use this as a hook for throwing an exception when
570+
* duplicate keys are encountered.
571+
*/
572+
@ExperimentalSerializationApi
573+
public fun visitKey(key: Any?) { }
562574
}
563575

564576
/**

core/commonMain/src/kotlinx/serialization/internal/CollectionSerializers.kt

+1
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ public sealed class MapLikeSerializer<Key, Value, Collection, Builder : MutableM
9898

9999
final override fun readElement(decoder: CompositeDecoder, index: Int, builder: Builder, checkIndex: Boolean) {
100100
val key: Key = decoder.decodeSerializableElement(descriptor, index, keySerializer)
101+
decoder.visitKey(key)
101102
val vIndex = if (checkIndex) {
102103
decoder.decodeElementIndex(descriptor).also {
103104
require(it == index + 1) { "Value must follow key in a map, index for key: $index, returned index for value: $it" }

formats/cbor/api/kotlinx-serialization-cbor.api

+3
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public final class kotlinx/serialization/cbor/CborBuilder {
3131
public final fun getEncodeKeyTags ()Z
3232
public final fun getEncodeObjectTags ()Z
3333
public final fun getEncodeValueTags ()Z
34+
public final fun getForbidDuplicateKeys ()Z
3435
public final fun getIgnoreUnknownKeys ()Z
3536
public final fun getPreferCborLabelsOverNames ()Z
3637
public final fun getSerializersModule ()Lkotlinx/serialization/modules/SerializersModule;
@@ -43,6 +44,7 @@ public final class kotlinx/serialization/cbor/CborBuilder {
4344
public final fun setEncodeKeyTags (Z)V
4445
public final fun setEncodeObjectTags (Z)V
4546
public final fun setEncodeValueTags (Z)V
47+
public final fun setForbidDuplicateKeys (Z)V
4648
public final fun setIgnoreUnknownKeys (Z)V
4749
public final fun setPreferCborLabelsOverNames (Z)V
4850
public final fun setSerializersModule (Lkotlinx/serialization/modules/SerializersModule;)V
@@ -58,6 +60,7 @@ public final class kotlinx/serialization/cbor/CborConfiguration {
5860
public final fun getEncodeKeyTags ()Z
5961
public final fun getEncodeObjectTags ()Z
6062
public final fun getEncodeValueTags ()Z
63+
public final fun getForbidDuplicateKeys ()Z
6164
public final fun getIgnoreUnknownKeys ()Z
6265
public final fun getPreferCborLabelsOverNames ()Z
6366
public final fun getUseDefiniteLengthEncoding ()Z

formats/cbor/api/kotlinx-serialization-cbor.klib.api

+5
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ final class kotlinx.serialization.cbor/CborBuilder { // kotlinx.serialization.cb
6868
final var encodeValueTags // kotlinx.serialization.cbor/CborBuilder.encodeValueTags|{}encodeValueTags[0]
6969
final fun <get-encodeValueTags>(): kotlin/Boolean // kotlinx.serialization.cbor/CborBuilder.encodeValueTags.<get-encodeValueTags>|<get-encodeValueTags>(){}[0]
7070
final fun <set-encodeValueTags>(kotlin/Boolean) // kotlinx.serialization.cbor/CborBuilder.encodeValueTags.<set-encodeValueTags>|<set-encodeValueTags>(kotlin.Boolean){}[0]
71+
final var forbidDuplicateKeys // kotlinx.serialization.cbor/CborBuilder.forbidDuplicateKeys|{}forbidDuplicateKeys[0]
72+
final fun <get-forbidDuplicateKeys>(): kotlin/Boolean // kotlinx.serialization.cbor/CborBuilder.forbidDuplicateKeys.<get-forbidDuplicateKeys>|<get-forbidDuplicateKeys>(){}[0]
73+
final fun <set-forbidDuplicateKeys>(kotlin/Boolean) // kotlinx.serialization.cbor/CborBuilder.forbidDuplicateKeys.<set-forbidDuplicateKeys>|<set-forbidDuplicateKeys>(kotlin.Boolean){}[0]
7174
final var ignoreUnknownKeys // kotlinx.serialization.cbor/CborBuilder.ignoreUnknownKeys|{}ignoreUnknownKeys[0]
7275
final fun <get-ignoreUnknownKeys>(): kotlin/Boolean // kotlinx.serialization.cbor/CborBuilder.ignoreUnknownKeys.<get-ignoreUnknownKeys>|<get-ignoreUnknownKeys>(){}[0]
7376
final fun <set-ignoreUnknownKeys>(kotlin/Boolean) // kotlinx.serialization.cbor/CborBuilder.ignoreUnknownKeys.<set-ignoreUnknownKeys>|<set-ignoreUnknownKeys>(kotlin.Boolean){}[0]
@@ -102,6 +105,8 @@ final class kotlinx.serialization.cbor/CborConfiguration { // kotlinx.serializat
102105
final fun <get-encodeObjectTags>(): kotlin/Boolean // kotlinx.serialization.cbor/CborConfiguration.encodeObjectTags.<get-encodeObjectTags>|<get-encodeObjectTags>(){}[0]
103106
final val encodeValueTags // kotlinx.serialization.cbor/CborConfiguration.encodeValueTags|{}encodeValueTags[0]
104107
final fun <get-encodeValueTags>(): kotlin/Boolean // kotlinx.serialization.cbor/CborConfiguration.encodeValueTags.<get-encodeValueTags>|<get-encodeValueTags>(){}[0]
108+
final val forbidDuplicateKeys // kotlinx.serialization.cbor/CborConfiguration.forbidDuplicateKeys|{}forbidDuplicateKeys[0]
109+
final fun <get-forbidDuplicateKeys>(): kotlin/Boolean // kotlinx.serialization.cbor/CborConfiguration.forbidDuplicateKeys.<get-forbidDuplicateKeys>|<get-forbidDuplicateKeys>(){}[0]
105110
final val ignoreUnknownKeys // kotlinx.serialization.cbor/CborConfiguration.ignoreUnknownKeys|{}ignoreUnknownKeys[0]
106111
final fun <get-ignoreUnknownKeys>(): kotlin/Boolean // kotlinx.serialization.cbor/CborConfiguration.ignoreUnknownKeys.<get-ignoreUnknownKeys>|<get-ignoreUnknownKeys>(){}[0]
107112
final val preferCborLabelsOverNames // kotlinx.serialization.cbor/CborConfiguration.preferCborLabelsOverNames|{}preferCborLabelsOverNames[0]

formats/cbor/commonMain/src/kotlinx/serialization/cbor/Cbor.kt

+18-4
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ public sealed class Cbor(
2727
) : BinaryFormat {
2828

2929
/**
30-
* The default instance of [Cbor]. Neither writes nor verifies tags. Uses indefinite length encoding by default.
30+
* The default instance of [Cbor]. Neither writes nor verifies tags,
31+
* and does not forbid reading duplicate map keys.
32+
* Uses indefinite length encoding by default.
3133
*/
3234
public companion object Default :
3335
Cbor(
@@ -42,13 +44,15 @@ public sealed class Cbor(
4244
verifyObjectTags = false,
4345
useDefiniteLengthEncoding = false,
4446
preferCborLabelsOverNames = false,
45-
alwaysUseByteString = false
47+
alwaysUseByteString = false,
48+
forbidDuplicateKeys = false,
4649
), EmptySerializersModule()
4750
) {
4851

4952
/**
5053
* Preconfigured instance of [Cbor] for COSE compliance. Encodes and verifies all tags, uses definite length
51-
* encoding and prefers labels to serial names. **DOES NOT** sort CBOR map keys; declare them in canonical order
54+
* encoding, prefers labels to serial names, and forbids reading duplicate map keys.
55+
* **DOES NOT** sort CBOR map keys; declare them in canonical order
5256
* for full cbor compliance!
5357
*/
5458
public val CoseCompliant: Cbor =
@@ -64,6 +68,7 @@ public sealed class Cbor(
6468
useDefiniteLengthEncoding = true
6569
preferCborLabelsOverNames = true
6670
alwaysUseByteString = false
71+
forbidDuplicateKeys = true
6772
serializersModule = EmptySerializersModule()
6873
}
6974
}
@@ -119,7 +124,8 @@ public fun Cbor(from: Cbor = Cbor, builderAction: CborBuilder.() -> Unit): Cbor
119124
builder.verifyObjectTags,
120125
builder.useDefiniteLengthEncoding,
121126
builder.preferCborLabelsOverNames,
122-
builder.alwaysUseByteString),
127+
builder.alwaysUseByteString,
128+
builder.forbidDuplicateKeys),
123129
builder.serializersModule
124130
)
125131
}
@@ -243,6 +249,14 @@ public class CborBuilder internal constructor(cbor: Cbor) {
243249
*/
244250
public var alwaysUseByteString: Boolean = cbor.configuration.alwaysUseByteString
245251

252+
/**
253+
* Specifies whether it is an error to read a map with duplicate keys.
254+
*
255+
* If this is set to true, decoding a map with two keys that compare as equal
256+
* will cause a [DuplicateKeyException] error to be thrown.
257+
*/
258+
public var forbidDuplicateKeys: Boolean = cbor.configuration.forbidDuplicateKeys
259+
246260
/**
247261
* Module with contextual and polymorphic serializers to be used in the resulting [Cbor] instance.
248262
*/

formats/cbor/commonMain/src/kotlinx/serialization/cbor/CborConfiguration.kt

+7-2
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ import kotlinx.serialization.*
8989
* basis. The [alwaysUseByteString] configuration switch allows for globally preferring **major type 2** without needing
9090
* to annotate every `ByteArray` in a class hierarchy.
9191
*
92+
* @param forbidDuplicateKeys Specifies whether it is an error to read a map with duplicate keys.
93+
* If this is set to true, decoding a map with two keys that compare as equal
94+
* will cause a [DuplicateKeyException] error to be thrown.
9295
*/
9396
@ExperimentalSerializationApi
9497
public class CborConfiguration internal constructor(
@@ -103,12 +106,14 @@ public class CborConfiguration internal constructor(
103106
public val useDefiniteLengthEncoding: Boolean,
104107
public val preferCborLabelsOverNames: Boolean,
105108
public val alwaysUseByteString: Boolean,
109+
public val forbidDuplicateKeys: Boolean,
106110
) {
107111
override fun toString(): String {
108112
return "CborConfiguration(encodeDefaults=$encodeDefaults, ignoreUnknownKeys=$ignoreUnknownKeys, " +
109113
"encodeKeyTags=$encodeKeyTags, encodeValueTags=$encodeValueTags, encodeObjectTags=$encodeObjectTags, " +
110114
"verifyKeyTags=$verifyKeyTags, verifyValueTags=$verifyValueTags, verifyObjectTags=$verifyObjectTags, " +
111115
"useDefiniteLengthEncoding=$useDefiniteLengthEncoding, " +
112-
"preferCborLabelsOverNames=$preferCborLabelsOverNames, alwaysUseByteString=$alwaysUseByteString)"
116+
"preferCborLabelsOverNames=$preferCborLabelsOverNames, alwaysUseByteString=$alwaysUseByteString, " +
117+
"forbidDuplicateKeys=$forbidDuplicateKeys)"
113118
}
114-
}
119+
}

formats/cbor/commonMain/src/kotlinx/serialization/cbor/internal/Decoder.kt

+15
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ internal open class CborReader(override val cbor: Cbor, protected val parser: Cb
2424
protected var decodeByteArrayAsByteString = false
2525
protected var tags: ULongArray? = null
2626

27+
/**
28+
* Keys that have been seen so far while reading this map.
29+
*
30+
* Only used if [Cbor.configuration.forbidDuplicateKeys] is in effect.
31+
*/
32+
private val seenKeys = mutableSetOf<Any?>()
33+
2734
protected fun setSize(size: Int) {
2835
if (size >= 0) {
2936
finiteMode = true
@@ -54,12 +61,19 @@ internal open class CborReader(override val cbor: Cbor, protected val parser: Cb
5461
if (!finiteMode) parser.end()
5562
}
5663

64+
override fun visitKey(key: Any?) {
65+
if (cbor.configuration.forbidDuplicateKeys) {
66+
seenKeys.add(key) || throw DuplicateKeyException(key)
67+
}
68+
}
69+
5770
override fun decodeElementIndex(descriptor: SerialDescriptor): Int {
5871
val index = if (cbor.configuration.ignoreUnknownKeys) {
5972
val knownIndex: Int
6073
while (true) {
6174
if (isDone()) return CompositeDecoder.DECODE_DONE
6275
val (elemName, tags) = decodeElementNameWithTagsLenient(descriptor)
76+
visitKey(elemName)
6377
readProperties++
6478

6579
val index = elemName?.let { descriptor.getElementIndex(it) } ?: CompositeDecoder.UNKNOWN_NAME
@@ -75,6 +89,7 @@ internal open class CborReader(override val cbor: Cbor, protected val parser: Cb
7589
} else {
7690
if (isDone()) return CompositeDecoder.DECODE_DONE
7791
val (elemName, tags) = decodeElementNameWithTags(descriptor)
92+
visitKey(elemName)
7893
readProperties++
7994
descriptor.getElementIndexOrThrow(elemName).also { index ->
8095
verifyKeyTags(descriptor, index, tags)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package kotlinx.serialization.cbor
2+
3+
import kotlinx.serialization.assertFailsWithMessage
4+
import kotlinx.serialization.decodeFromByteArray
5+
import kotlinx.serialization.HexConverter
6+
import kotlinx.serialization.DuplicateKeyException
7+
import kotlinx.serialization.Serializable
8+
import kotlin.test.Test
9+
10+
class CborStrictModeTest {
11+
private val strict = Cbor { forbidDuplicateKeys = true }
12+
13+
/** Duplicate keys are rejected in generic maps. */
14+
@Test
15+
fun testDuplicateKeysInMap() {
16+
val duplicateKeys = HexConverter.parseHexBinary("A2617805617806")
17+
assertFailsWithMessage<DuplicateKeyException>("Duplicate keys not allowed. Key appeared twice: x") {
18+
strict.decodeFromByteArray<Map<String, Long>>(duplicateKeys)
19+
}
20+
}
21+
22+
@Serializable
23+
data class ExampleClass(val x: Long)
24+
25+
/** Duplicate keys are rejected in classes. */
26+
@Test
27+
fun testDuplicateKeysInDataClass() {
28+
// {"x": 5, "x", 6}
29+
val duplicateKeys = HexConverter.parseHexBinary("A2617805617806")
30+
assertFailsWithMessage<DuplicateKeyException>("Duplicate keys not allowed. Key appeared twice: x") {
31+
strict.decodeFromByteArray<ExampleClass>(duplicateKeys)
32+
}
33+
}
34+
35+
/** Duplicate unknown keys are rejected as well. */
36+
@Test
37+
fun testDuplicateUnknownKeys() {
38+
// {"a": 1, "a", 2, "x", 6}
39+
val duplicateKeys = HexConverter.parseHexBinary("A3616101616102617806")
40+
val cbor = Cbor(strict) { ignoreUnknownKeys = true }
41+
assertFailsWithMessage<DuplicateKeyException>("Duplicate keys not allowed. Key appeared twice: a") {
42+
cbor.decodeFromByteArray<ExampleClass>(duplicateKeys)
43+
}
44+
}
45+
}

formats/json/api/kotlinx-serialization-json.api

+1
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ public final class kotlinx/serialization/json/JsonDecoder$DefaultImpls {
182182
public static fun decodeNullableSerializableValue (Lkotlinx/serialization/json/JsonDecoder;Lkotlinx/serialization/DeserializationStrategy;)Ljava/lang/Object;
183183
public static fun decodeSequentially (Lkotlinx/serialization/json/JsonDecoder;)Z
184184
public static fun decodeSerializableValue (Lkotlinx/serialization/json/JsonDecoder;Lkotlinx/serialization/DeserializationStrategy;)Ljava/lang/Object;
185+
public static fun visitKey (Lkotlinx/serialization/json/JsonDecoder;Ljava/lang/Object;)V
185186
}
186187

187188
public abstract class kotlinx/serialization/json/JsonElement {

0 commit comments

Comments
 (0)