From 7acaa1c7a8284bcc056b74ad058958ef77e37909 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sat, 18 Jul 2026 04:11:34 +0300 Subject: [PATCH 1/3] fix: reject nested objects inside list elements in the query format QueryListEncoder/QueryListDecoder are the delegates used while a list property is being encoded or decoded, but unlike the top-level encoder and decoder they never overrode beginStructure(), so a nested @Serializable object (or a nested list) inside a list element fell through to the AbstractEncoder/AbstractDecoder defaults instead of being rejected. A List-shaped property silently flattened its fields into repeated values on encode, and could throw an unrelated IndexOutOfBoundsException on decode, rather than raising the documented SerializationException. Give both list delegates the same "structure begun here is rejected" guard the top-level encoder/decoder already carry, so nesting is caught uniformly whether it happens at the top level or inside a list element. Also add coverage for the nullable-vs-absent distinction on a non-collection property: the existing nullable test used a defaulted field, so both the encoder's shouldEncodeElementDefault skip and the decoder's isElementOptional short-circuit meant decodeNotNullMark() and encodeNull() were never actually invoked by any test. A required (no-default) nullable property now exercises both the absent (decodes to null) and present-but-empty ("key=" with no value, decodes to "") cases end to end. Closes #106, #127 --- .../org/dexpace/kuri/serde/QueryDecoder.kt | 8 +++ .../org/dexpace/kuri/serde/QueryEncoder.kt | 8 +++ .../org/dexpace/kuri/serde/SerdeTest.kt | 66 +++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryDecoder.kt b/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryDecoder.kt index b532d3a..89fc4e4 100644 --- a/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryDecoder.kt +++ b/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryDecoder.kt @@ -89,6 +89,14 @@ internal class QueryListDecoder( override fun decodeElementIndex(descriptor: SerialDescriptor): Int = if (cursor < values.size) cursor else CompositeDecoder.DECODE_DONE + /** + * A list element is always a scalar/enum in this format's scope, so any call here — a nested + * `@Serializable` object or a nested list — is out of scope and rejected, mirroring + * [QueryDecoder.beginStructure]'s top-level guard. + */ + override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder = + throw SerializationException("nested objects are not supported by the query format") + private fun next(): String = values[cursor++] ?: throw SerializationException("null list element") override fun decodeString(): String = next() diff --git a/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryEncoder.kt b/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryEncoder.kt index d634eb2..7390758 100644 --- a/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryEncoder.kt +++ b/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryEncoder.kt @@ -84,6 +84,14 @@ internal class QueryListEncoder( ) : AbstractEncoder() { override val serializersModule: SerializersModule = EmptySerializersModule() + /** + * A list element is always a scalar/enum in this format's scope, so any call here — a nested + * `@Serializable` object or a nested list — is out of scope and rejected, mirroring + * [QueryEncoder.beginStructure]'s top-level guard. + */ + override fun beginStructure(descriptor: SerialDescriptor): CompositeEncoder = + throw SerializationException("nested objects are not supported by the query format") + override fun encodeValue(value: Any) { builder.add(name, value.toString()) } diff --git a/kuri-serde-kotlinx/src/commonTest/kotlin/org/dexpace/kuri/serde/SerdeTest.kt b/kuri-serde-kotlinx/src/commonTest/kotlin/org/dexpace/kuri/serde/SerdeTest.kt index 345d6cc..0ead26e 100644 --- a/kuri-serde-kotlinx/src/commonTest/kotlin/org/dexpace/kuri/serde/SerdeTest.kt +++ b/kuri-serde-kotlinx/src/commonTest/kotlin/org/dexpace/kuri/serde/SerdeTest.kt @@ -74,6 +74,22 @@ private data class Nested( val inner: Search, ) +@Serializable +private data class Holder( + val friends: List, +) + +@Serializable +private data class Matrix( + val rows: List>, +) + +@Serializable +private data class Profile( + val handle: String, + val bio: String?, +) + class SerdeTest { @Test fun `url serializes as its canonical string in json`() { @@ -211,6 +227,28 @@ class SerdeTest { assertEquals(withNickname, QueryParametersFormat.decodeFromQueryString(query)) } + // Profile.bio has no default, unlike Contact.nickname: decodeElementIndex's isElementOptional + // short-circuit only applies to a defaulted element, so these cases force an actual + // decodeNotNullMark()/encodeNull() call instead of the property being skipped upstream. + + @Test + fun `encoding a required nullable property that is null omits its key`() { + val query = QueryParametersFormat.encodeToQueryString(Profile(handle = "ada", bio = null)) + assertEquals("handle=ada", query) + } + + @Test + fun `decoding a required nullable property absent from the query yields null`() { + val profile = QueryParametersFormat.decodeFromQueryString("handle=ada") + assertEquals(Profile(handle = "ada", bio = null), profile) + } + + @Test + fun `decoding a required nullable property present with no value yields an empty string`() { + val profile = QueryParametersFormat.decodeFromQueryString("handle=ada&bio=") + assertEquals(Profile(handle = "ada", bio = ""), profile) + } + @Test fun `decoding an unrecognized enum value throws`() { assertFailsWith { @@ -245,4 +283,32 @@ class SerdeTest { QueryParametersFormat.encodeToQueryParameters(Nested(Search(q = "x"))) } } + + @Test + fun `encoding a serializable object nested inside a list is rejected`() { + assertFailsWith { + QueryParametersFormat.encodeToQueryParameters(Holder(listOf(Contact(name = "ada", nickname = "countess")))) + } + } + + @Test + fun `decoding a serializable object nested inside a list is rejected`() { + assertFailsWith { + QueryParametersFormat.decodeFromQueryString("friends=ada") + } + } + + @Test + fun `encoding a list nested inside a list is rejected`() { + assertFailsWith { + QueryParametersFormat.encodeToQueryParameters(Matrix(listOf(listOf(1, 2)))) + } + } + + @Test + fun `decoding a list nested inside a list is rejected`() { + assertFailsWith { + QueryParametersFormat.decodeFromQueryString("rows=1") + } + } } From 6df890880ee226e9dc182717aea5fbddea397051 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 20 Jul 2026 01:24:50 +0300 Subject: [PATCH 2/3] refactor: dedupe the nested-objects rejection message QueryEncoder/QueryListEncoder and QueryDecoder/QueryListDecoder each threw the same literal SerializationException message from two call sites per file. Pull it into one private const per file so the four copies can't drift out of sync. --- .../kotlin/org/dexpace/kuri/serde/QueryDecoder.kt | 7 +++++-- .../kotlin/org/dexpace/kuri/serde/QueryEncoder.kt | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryDecoder.kt b/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryDecoder.kt index a810eb2..40ae5c6 100644 --- a/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryDecoder.kt +++ b/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryDecoder.kt @@ -68,7 +68,7 @@ internal class QueryDecoder( override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder { if (descriptor.kind == StructureKind.LIST) return QueryListDecoder(params.getAll(currentName)) - if (entered) throw SerializationException("nested objects are not supported by the query format") + if (entered) throw SerializationException(NESTED_OBJECTS_REJECTED_MESSAGE) entered = true return this } @@ -132,7 +132,7 @@ internal class QueryListDecoder( * [QueryDecoder.beginStructure]'s top-level guard. */ override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder = - throw SerializationException("nested objects are not supported by the query format") + throw SerializationException(NESTED_OBJECTS_REJECTED_MESSAGE) private fun next(): String = values[cursor++] ?: throw SerializationException("null list element") @@ -157,6 +157,9 @@ internal class QueryListDecoder( override fun decodeEnum(enumDescriptor: SerialDescriptor): Int = enumIndex(enumDescriptor, next()) } +/** Shared by [QueryDecoder.beginStructure] and [QueryListDecoder.beginStructure]'s nesting rejection. */ +private const val NESTED_OBJECTS_REJECTED_MESSAGE: String = "nested objects are not supported by the query format" + /** Converts [raw] with [convert], failing with a [SerializationException] describing [kind] and [context] on error. */ private fun scalarOrFail( kind: String, diff --git a/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryEncoder.kt b/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryEncoder.kt index 440a012..65c45c7 100644 --- a/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryEncoder.kt +++ b/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryEncoder.kt @@ -32,7 +32,7 @@ internal class QueryEncoder : AbstractEncoder() { fun build(): QueryParameters = builder.build() override fun beginStructure(descriptor: SerialDescriptor): CompositeEncoder { - if (entered) throw SerializationException("nested objects are not supported by the query format") + if (entered) throw SerializationException(NESTED_OBJECTS_REJECTED_MESSAGE) entered = true return this } @@ -104,7 +104,7 @@ internal class QueryListEncoder( * [QueryEncoder.beginStructure]'s top-level guard. */ override fun beginStructure(descriptor: SerialDescriptor): CompositeEncoder = - throw SerializationException("nested objects are not supported by the query format") + throw SerializationException(NESTED_OBJECTS_REJECTED_MESSAGE) override fun encodeValue(value: Any) { builder.add(name, value.toString()) @@ -118,6 +118,9 @@ internal class QueryListEncoder( } } +/** Shared by [QueryEncoder.beginStructure] and [QueryListEncoder.beginStructure]'s nesting rejection. */ +private const val NESTED_OBJECTS_REJECTED_MESSAGE: String = "nested objects are not supported by the query format" + /** * Suffix marking the wire-level "present but empty" sentinel for a list property, appended to its * declared name (e.g. `tags` -> `tags[]`). `[`/`]` are never percent-encoded by the query name encode From cd681ece0830adee46b33b8bf4c8691a6a4407ca Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 20 Jul 2026 03:26:11 +0300 Subject: [PATCH 3/3] test: cover the encode direction of a present-but-empty nullable property The required-nullable-property tests covered encode(null), decode(absent), and decode(present-but-empty), but never encode(present-but-empty) - leaving one leg of the round-trip unverified. Also reword the new shared constant's KDoc; the original phrasing read as if only the second method's rejection was covered. --- .../kotlin/org/dexpace/kuri/serde/QueryDecoder.kt | 2 +- .../kotlin/org/dexpace/kuri/serde/QueryEncoder.kt | 2 +- .../commonTest/kotlin/org/dexpace/kuri/serde/SerdeTest.kt | 8 ++++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryDecoder.kt b/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryDecoder.kt index 40ae5c6..ce3fe3a 100644 --- a/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryDecoder.kt +++ b/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryDecoder.kt @@ -157,7 +157,7 @@ internal class QueryListDecoder( override fun decodeEnum(enumDescriptor: SerialDescriptor): Int = enumIndex(enumDescriptor, next()) } -/** Shared by [QueryDecoder.beginStructure] and [QueryListDecoder.beginStructure]'s nesting rejection. */ +/** The nesting-rejection message shared by [QueryDecoder.beginStructure] and [QueryListDecoder.beginStructure]. */ private const val NESTED_OBJECTS_REJECTED_MESSAGE: String = "nested objects are not supported by the query format" /** Converts [raw] with [convert], failing with a [SerializationException] describing [kind] and [context] on error. */ diff --git a/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryEncoder.kt b/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryEncoder.kt index 65c45c7..f26e713 100644 --- a/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryEncoder.kt +++ b/kuri-serde-kotlinx/src/commonMain/kotlin/org/dexpace/kuri/serde/QueryEncoder.kt @@ -118,7 +118,7 @@ internal class QueryListEncoder( } } -/** Shared by [QueryEncoder.beginStructure] and [QueryListEncoder.beginStructure]'s nesting rejection. */ +/** The nesting-rejection message shared by [QueryEncoder.beginStructure] and [QueryListEncoder.beginStructure]. */ private const val NESTED_OBJECTS_REJECTED_MESSAGE: String = "nested objects are not supported by the query format" /** diff --git a/kuri-serde-kotlinx/src/commonTest/kotlin/org/dexpace/kuri/serde/SerdeTest.kt b/kuri-serde-kotlinx/src/commonTest/kotlin/org/dexpace/kuri/serde/SerdeTest.kt index dc54063..cf47872 100644 --- a/kuri-serde-kotlinx/src/commonTest/kotlin/org/dexpace/kuri/serde/SerdeTest.kt +++ b/kuri-serde-kotlinx/src/commonTest/kotlin/org/dexpace/kuri/serde/SerdeTest.kt @@ -311,6 +311,14 @@ class SerdeTest { assertEquals(Profile(handle = "ada", bio = ""), profile) } + @Test + fun `a required nullable property present but empty round-trips through encode and decode`() { + val original = Profile(handle = "ada", bio = "") + val query = QueryParametersFormat.encodeToQueryString(original) + assertEquals("handle=ada&bio=", query) + assertEquals(original, QueryParametersFormat.decodeFromQueryString(query)) + } + @Test fun `decoding an unrecognized enum value throws`() { assertFailsWith {