Skip to content

Commit 9c25b17

Browse files
committed
fix(serde): reject lossy float-to-int coercion and document the SerdeException contract
The strict-coercion config rejected string/boolean into integer fields but had no rule for a floating-point JSON value into an integer field, so {"n":1.5} silently bound into an Int as 1. That truncation turns a contract-violating payload into a quietly wrong value, the exact class of bug the lockdown exists to prevent. Add CoercionInputShape.Float to the LogicalType.Integer fail list and pin the rejection with a test. Also document the failure contract on the SPI itself: the core Serializer / Deserializer interfaces now carry @throws SerializationException / DeserializationException notes, and the internal Jackson overrides mirror them, so a reader of the SPI sees the expected stable failure type rather than learning it only from the Jackson adapter.
1 parent 2e3da56 commit 9c25b17

5 files changed

Lines changed: 69 additions & 8 deletions

File tree

sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/Deserializer.kt

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,27 @@ import java.io.InputStream
2323
* For parametric targets (`List<MyDto>`, `Map<String, MyDto>`) the raw [Class] token is
2424
* insufficient; adapter modules expose their own type-reference entry points (e.g.
2525
* `sdk-serde-jackson`'s `JacksonSerde.deserializeAs`).
26+
*
27+
* Implementations surface decode failures as [DeserializationException] (a [SerdeException]
28+
* subtype), chaining the backing codec's error as the cause, so callers catch a single stable SDK
29+
* type without naming the underlying library.
2630
*/
2731
public interface Deserializer {
28-
/** Decode a complete document of [type] from the in-memory [input] string. */
32+
/**
33+
* Decode a complete document of [type] from the in-memory [input] string.
34+
*
35+
* @throws DeserializationException if [input] is malformed or does not match [type].
36+
*/
2937
public fun <T> deserialize(
3038
input: String,
3139
type: Class<T>,
3240
): T
3341

34-
/** Decode a complete document of [type] from the in-memory [input] byte array. */
42+
/**
43+
* Decode a complete document of [type] from the in-memory [input] byte array.
44+
*
45+
* @throws DeserializationException if [input] is malformed or does not match [type].
46+
*/
3547
public fun <T> deserialize(
3648
input: ByteArray,
3749
type: Class<T>,
@@ -40,6 +52,9 @@ public interface Deserializer {
4052
/**
4153
* Decode a complete document of [type] by streaming from [inputStream]. The implementation owns
4254
* reading to EOF but **does not** close the stream — the caller retains ownership.
55+
*
56+
* @throws DeserializationException if the payload is malformed or does not match [type]. A
57+
* genuine stream-read [java.io.IOException] propagates unwrapped.
4358
*/
4459
public fun <T> deserialize(
4560
inputStream: InputStream,

sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/Serializer.kt

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,32 @@ import java.io.OutputStream
1717
* fresh `ByteArray`, stream into a caller-owned `OutputStream`, or stream into a caller-owned
1818
* scratch buffer. Stream / buffer overloads do **not** close their targets — the caller retains
1919
* ownership.
20+
*
21+
* Implementations surface encode failures as [SerializationException] (a [SerdeException] subtype),
22+
* chaining the backing codec's error as the cause, so callers catch a single stable SDK type
23+
* without naming the underlying library.
2024
*/
2125
public interface Serializer {
22-
/** Encode [input] and return the result as a new string. */
26+
/**
27+
* Encode [input] and return the result as a new string.
28+
*
29+
* @throws SerializationException if [input] cannot be encoded.
30+
*/
2331
public fun serialize(input: Any): String
2432

25-
/** Encode [input] and return the result as a freshly-allocated byte array. */
33+
/**
34+
* Encode [input] and return the result as a freshly-allocated byte array.
35+
*
36+
* @throws SerializationException if [input] cannot be encoded.
37+
*/
2638
public fun serializeToByteArray(input: Any): ByteArray
2739

28-
/** Stream [input]'s encoding into [outputStream]. The caller owns closing the stream. */
40+
/**
41+
* Stream [input]'s encoding into [outputStream]. The caller owns closing the stream.
42+
*
43+
* @throws SerializationException if [input] cannot be encoded. A genuine stream-write
44+
* [java.io.IOException] propagates unwrapped.
45+
*/
2946
public fun serialize(
3047
input: Any,
3148
outputStream: OutputStream,
@@ -38,6 +55,7 @@ public interface Serializer {
3855
* @throws IndexOutOfBoundsException when [offset] is negative or beyond [buffer]'s length, or
3956
* when the encoded payload does not fit in the remaining space (`buffer.size - offset`). The
4057
* buffer contents are unspecified after an overflow throw.
58+
* @throws SerializationException if [input] cannot be encoded.
4159
*/
4260
public fun serialize(
4361
input: Any,

sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/JacksonObjectMappers.kt

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,12 +95,14 @@ public object JacksonObjectMappers {
9595
* so the rejection holds regardless of how a given scalar target consults coercion config:
9696
*
9797
* - string → integer / floating-point / boolean (the headline `"5"` → numeric case),
98+
* - floating-point → integer (the lossy `1.5` → `1` narrowing),
9899
* - boolean ↔ integer,
99100
* - integer / floating-point / boolean → string.
100101
*
101102
* Untouched on purpose: numeric **widening** (an integer JSON value into a floating-point
102103
* field) stays legal because it is a representation-preserving conversion, not a shape mismatch;
103-
* and genuinely typed values bind exactly as before.
104+
* and genuinely typed values bind exactly as before. The inverse — a floating-point JSON value
105+
* into an integer field — is rejected because it silently truncates (`1.5` → `1`).
104106
*/
105107
private fun applyStrictScalarCoercion(builder: JsonMapper.Builder) {
106108
fun JsonMapper.Builder.failOn(
@@ -112,8 +114,14 @@ public object JacksonObjectMappers {
112114
}
113115

114116
builder
115-
// string "5"/"1.5"/"true" must not flow into numeric or boolean fields.
116-
.failOn(LogicalType.Integer, CoercionInputShape.String, CoercionInputShape.Boolean)
117+
// string "5"/"1.5"/"true" must not flow into numeric or boolean fields, and a
118+
// floating-point value must not be lossily narrowed into an integer field (1.5 -> 1).
119+
.failOn(
120+
LogicalType.Integer,
121+
CoercionInputShape.String,
122+
CoercionInputShape.Boolean,
123+
CoercionInputShape.Float,
124+
)
117125
.failOn(LogicalType.Float, CoercionInputShape.String)
118126
.failOn(LogicalType.Boolean, CoercionInputShape.String, CoercionInputShape.Integer)
119127
// a non-string scalar must not be stringified into a textual field.

sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/JacksonSerde.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,10 +112,13 @@ public class JacksonSerde private constructor(
112112
internal class JacksonSerializer internal constructor(
113113
private val mapper: ObjectMapper,
114114
) : Serializer {
115+
/** @throws SerializationException if [input] cannot be encoded. */
115116
override fun serialize(input: Any): String = serializing { mapper.writeValueAsString(input) }
116117

118+
/** @throws SerializationException if [input] cannot be encoded. */
117119
override fun serializeToByteArray(input: Any): ByteArray = serializing { mapper.writeValueAsBytes(input) }
118120

121+
/** @throws SerializationException if [input] cannot be encoded. */
119122
override fun serialize(
120123
input: Any,
121124
outputStream: OutputStream,
@@ -129,6 +132,11 @@ internal class JacksonSerializer internal constructor(
129132
}
130133
}
131134

135+
/**
136+
* @throws IndexOutOfBoundsException for a bad [offset] or overflow (thrown unwrapped, outside
137+
* the encode wrapper).
138+
* @throws SerializationException if [input] cannot be encoded.
139+
*/
132140
override fun serialize(
133141
input: Any,
134142
buffer: ByteArray,
@@ -168,16 +176,19 @@ internal class JacksonSerializer internal constructor(
168176
internal class JacksonDeserializer internal constructor(
169177
private val mapper: ObjectMapper,
170178
) : Deserializer {
179+
/** @throws DeserializationException if [input] is malformed or does not match [type]. */
171180
override fun <T> deserialize(
172181
input: String,
173182
type: Class<T>,
174183
): T = deserializing { mapper.readValue(input, type) }
175184

185+
/** @throws DeserializationException if [input] is malformed or does not match [type]. */
176186
override fun <T> deserialize(
177187
input: ByteArray,
178188
type: Class<T>,
179189
): T = deserializing { mapper.readValue(input, type) }
180190

191+
/** @throws DeserializationException if the payload is malformed or does not match [type]. */
181192
override fun <T> deserialize(
182193
inputStream: InputStream,
183194
type: Class<T>,

sdk-serde-jackson/src/test/kotlin/org/dexpace/sdk/serde/jackson/JacksonObjectMappersTest.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,13 @@ class JacksonObjectMappersTest {
116116
// int -> double is a numeric widening, not a cross-shape coercion: it must keep working.
117117
assertEquals(Numbers(1, 2.0), mapper.readValue<Numbers>("""{"count":1,"ratio":2}"""))
118118
}
119+
120+
@Test
121+
fun `floating-point for an integer field is rejected, not truncated`() {
122+
val mapper = JacksonObjectMappers.defaultObjectMapper()
123+
// 1.5 -> Int would silently truncate to 1; the lossy narrowing must fail loudly instead.
124+
assertFailsWith<MismatchedInputException> {
125+
mapper.readValue<Numbers>("""{"count":1.5,"ratio":2.0}""")
126+
}
127+
}
119128
}

0 commit comments

Comments
 (0)