Skip to content

Commit d4b72fb

Browse files
committed
Disallow repeated keys in CBOR (WIP, need to add config switches)
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. Also fixes a typo in an unrelated method docstring.
1 parent 194a188 commit d4b72fb

File tree

6 files changed

+71
-6
lines changed

6 files changed

+71
-6
lines changed

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,10 @@ internal constructor(message: String?) : SerializationException(message) {
133133
// This constructor is used by the generated serializers
134134
constructor(index: Int) : this("An unknown field for index $index")
135135
}
136+
137+
/**
138+
* Thrown when a map deserializer encounters a repeated map key (and configuration disallows this.)
139+
*/
140+
@ExperimentalSerializationApi
141+
public class DuplicateMapKeyException(public val key: Any?) :
142+
SerializationException("Duplicate keys not allowed in maps. Key appeared twice: $key")

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ internal inline fun <T : Any> Decoder.decodeIfNullable(deserializer: Deserializa
275275
* [CompositeDecoder] is a part of decoding process that is bound to a particular structured part of
276276
* the serialized form, described by the serial descriptor passed to [Decoder.beginStructure].
277277
*
278-
* Typically, for unordered data, [CompositeDecoder] is used by a serializer withing a [decodeElementIndex]-based
278+
* Typically, for unordered data, [CompositeDecoder] is used by a serializer within a [decodeElementIndex]-based
279279
* loop that decodes all the required data one-by-one in any order and then terminates by calling [endStructure].
280280
* Please refer to [decodeElementIndex] for example of such loop.
281281
*
@@ -558,6 +558,17 @@ public interface CompositeDecoder {
558558
deserializer: DeserializationStrategy<T?>,
559559
previousValue: T? = null
560560
): T?
561+
562+
/**
563+
* Called after a key has been read.
564+
*
565+
* This could be a map or set key, or anything otherwise intended to be
566+
* distinct within the collection under normal circumstances.
567+
*
568+
* Implementations might use this as a hook for throwing an exception when
569+
* duplicate keys are encountered.
570+
*/
571+
public fun visitKey(key: Any?) { }
561572
}
562573

563574
/**

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

Lines changed: 1 addition & 0 deletions
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/commonMain/src/kotlinx/serialization/cbor/Cbor.kt

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,14 @@ import kotlinx.serialization.modules.*
3232
public sealed class Cbor(
3333
internal val encodeDefaults: Boolean,
3434
internal val ignoreUnknownKeys: Boolean,
35+
internal val allowDuplicateKeys: Boolean,
3536
override val serializersModule: SerializersModule
3637
) : BinaryFormat {
3738

3839
/**
3940
* The default instance of [Cbor]
4041
*/
41-
public companion object Default : Cbor(false, false, EmptySerializersModule())
42+
public companion object Default : Cbor(false, false, true, EmptySerializersModule())
4243

4344
override fun <T> encodeToByteArray(serializer: SerializationStrategy<T>, value: T): ByteArray {
4445
val output = ByteArrayOutput()
@@ -55,8 +56,11 @@ public sealed class Cbor(
5556
}
5657

5758
@OptIn(ExperimentalSerializationApi::class)
58-
private class CborImpl(encodeDefaults: Boolean, ignoreUnknownKeys: Boolean, serializersModule: SerializersModule) :
59-
Cbor(encodeDefaults, ignoreUnknownKeys, serializersModule)
59+
private class CborImpl(
60+
encodeDefaults: Boolean, ignoreUnknownKeys: Boolean, allowDuplicateKeys: Boolean,
61+
serializersModule: SerializersModule,
62+
) :
63+
Cbor(encodeDefaults, ignoreUnknownKeys, allowDuplicateKeys, serializersModule)
6064

6165
/**
6266
* Creates an instance of [Cbor] configured from the optionally given [Cbor instance][from]
@@ -66,7 +70,7 @@ private class CborImpl(encodeDefaults: Boolean, ignoreUnknownKeys: Boolean, seri
6670
public fun Cbor(from: Cbor = Cbor, builderAction: CborBuilder.() -> Unit): Cbor {
6771
val builder = CborBuilder(from)
6872
builder.builderAction()
69-
return CborImpl(builder.encodeDefaults, builder.ignoreUnknownKeys, builder.serializersModule)
73+
return CborImpl(builder.encodeDefaults, builder.ignoreUnknownKeys, builder.allowDuplicateKeys, builder.serializersModule)
7074
}
7175

7276
/**
@@ -87,6 +91,14 @@ public class CborBuilder internal constructor(cbor: Cbor) {
8791
*/
8892
public var ignoreUnknownKeys: Boolean = cbor.ignoreUnknownKeys
8993

94+
/**
95+
* Specifies whether it is an error to read a map with duplicate keys.
96+
*
97+
* If this is set to false, decoding a map with two keys that compare as equal
98+
* will cause a [DuplicateMapKeyException] error to be thrown.
99+
*/
100+
public var allowDuplicateKeys: Boolean = cbor.allowDuplicateKeys
101+
90102
/**
91103
* Module with contextual and polymorphic serializers to be used in the resulting [Cbor] instance.
92104
*/

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,8 +197,21 @@ internal class CborEncoder(private val output: ByteArrayOutput) {
197197
}
198198
}
199199

200-
private class CborMapReader(cbor: Cbor, decoder: CborDecoder) : CborListReader(cbor, decoder) {
200+
private class CborMapReader(val cbor: Cbor, decoder: CborDecoder) : CborListReader(cbor, decoder) {
201+
/** Keys that have been seen so far while reading this map. */
202+
private val seenKeys = mutableSetOf<Any?>()
203+
201204
override fun skipBeginToken() = setSize(decoder.startMap() * 2)
205+
206+
override fun visitKey(key: Any?) {
207+
if (cbor.allowDuplicateKeys)
208+
return
209+
210+
val added = seenKeys.add(key)
211+
if (!added) {
212+
throw DuplicateMapKeyException(key)
213+
}
214+
}
202215
}
203216

204217
private open class CborListReader(cbor: Cbor, decoder: CborDecoder) : CborReader(cbor, decoder) {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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.DuplicateMapKeyException
7+
import kotlin.test.Test
8+
import kotlin.test.assertEquals
9+
10+
class CborStrictModeTest {
11+
private val strict = Cbor { allowDuplicateKeys = false }
12+
13+
/** Duplicate keys are rejected in generic maps. */
14+
@Test
15+
fun testDuplicateKeysInMap() {
16+
val duplicateKeys = HexConverter.parseHexBinary("A2617805617806")
17+
assertFailsWithMessage<DuplicateMapKeyException>("Duplicate keys not allowed") {
18+
strict.decodeFromByteArray<Map<String, Long>>(duplicateKeys)
19+
}
20+
}
21+
}

0 commit comments

Comments
 (0)