Skip to content

Commit d9afd5d

Browse files
committed
feat: reject empty-string and boolean->float scalar coercions in the default mapper
Extend the default ObjectMapper's coercion lockdown to two cases the initial rules left open: - boolean -> floating-point is now rejected, matching the existing boolean<->integer rejections, so `true` cannot become `1.0`. - an empty string into an integer/floating-point/boolean field is now rejected. Jackson otherwise coerces `""` to a null/zero scalar, which silently masks a malformed payload. An empty string into a textual field is still accepted — `""` is a legitimate string value. Tests cover each new rejection plus the empty-string-binds-to-string case.
1 parent 9c25b17 commit d9afd5d

2 files changed

Lines changed: 65 additions & 8 deletions

File tree

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

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,16 @@ public object JacksonObjectMappers {
9696
*
9797
* - string → integer / floating-point / boolean (the headline `"5"` → numeric case),
9898
* - floating-point → integer (the lossy `1.5` → `1` narrowing),
99-
* - boolean ↔ integer,
100-
* - integer / floating-point / boolean → string.
99+
* - boolean ↔ integer and boolean → floating-point,
100+
* - integer / floating-point / boolean → string,
101+
* - empty string → integer / floating-point / boolean (Jackson otherwise coerces `""` to a
102+
* null/zero scalar, masking a malformed payload).
101103
*
102104
* Untouched on purpose: numeric **widening** (an integer JSON value into a floating-point
103105
* field) stays legal because it is a representation-preserving conversion, not a shape mismatch;
104106
* 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`).
107+
* into an integer field — is rejected because it silently truncates (`1.5` → `1`). An empty
108+
* string into a textual field is left alone: `""` is a legitimate string value.
106109
*/
107110
private fun applyStrictScalarCoercion(builder: JsonMapper.Builder) {
108111
fun JsonMapper.Builder.failOn(
@@ -114,17 +117,30 @@ public object JacksonObjectMappers {
114117
}
115118

116119
builder
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).
120+
// string "5"/"1.5"/"true" (and an empty string) must not flow into numeric or boolean
121+
// fields, and a floating-point value must not be lossily narrowed into an integer field
122+
// (1.5 -> 1).
119123
.failOn(
120124
LogicalType.Integer,
121125
CoercionInputShape.String,
126+
CoercionInputShape.EmptyString,
122127
CoercionInputShape.Boolean,
123128
CoercionInputShape.Float,
124129
)
125-
.failOn(LogicalType.Float, CoercionInputShape.String)
126-
.failOn(LogicalType.Boolean, CoercionInputShape.String, CoercionInputShape.Integer)
127-
// a non-string scalar must not be stringified into a textual field.
130+
.failOn(
131+
LogicalType.Float,
132+
CoercionInputShape.String,
133+
CoercionInputShape.EmptyString,
134+
CoercionInputShape.Boolean,
135+
)
136+
.failOn(
137+
LogicalType.Boolean,
138+
CoercionInputShape.String,
139+
CoercionInputShape.EmptyString,
140+
CoercionInputShape.Integer,
141+
)
142+
// a non-string scalar must not be stringified into a textual field. An empty string is a
143+
// valid string, so EmptyString is intentionally absent here.
128144
.failOn(
129145
LogicalType.Textual,
130146
CoercionInputShape.Integer,

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,4 +125,45 @@ class JacksonObjectMappersTest {
125125
mapper.readValue<Numbers>("""{"count":1.5,"ratio":2.0}""")
126126
}
127127
}
128+
129+
@Test
130+
fun `boolean for a floating-point field is rejected, not coerced`() {
131+
val mapper = JacksonObjectMappers.defaultObjectMapper()
132+
// Symmetric with the boolean<->integer rejections: true must not become 1.0.
133+
assertFailsWith<MismatchedInputException> {
134+
mapper.readValue<Numbers>("""{"count":1,"ratio":true}""")
135+
}
136+
}
137+
138+
@Test
139+
fun `empty string for an integer field is rejected, not coerced to null or zero`() {
140+
val mapper = JacksonObjectMappers.defaultObjectMapper()
141+
// Jackson otherwise turns "" into a null/zero scalar, masking a malformed payload.
142+
assertFailsWith<MismatchedInputException> {
143+
mapper.readValue<Numbers>("""{"count":"","ratio":1.5}""")
144+
}
145+
}
146+
147+
@Test
148+
fun `empty string for a floating-point field is rejected, not coerced to null or zero`() {
149+
val mapper = JacksonObjectMappers.defaultObjectMapper()
150+
assertFailsWith<MismatchedInputException> {
151+
mapper.readValue<Numbers>("""{"count":5,"ratio":""}""")
152+
}
153+
}
154+
155+
@Test
156+
fun `empty string for a boolean field is rejected, not coerced to null`() {
157+
val mapper = JacksonObjectMappers.defaultObjectMapper()
158+
assertFailsWith<MismatchedInputException> {
159+
mapper.readValue<Flag>("""{"enabled":""}""")
160+
}
161+
}
162+
163+
@Test
164+
fun `empty string still binds to a string field`() {
165+
val mapper = JacksonObjectMappers.defaultObjectMapper()
166+
// "" is a legitimate string value: the lockdown must not reject it for a textual target.
167+
assertEquals(Label(""), mapper.readValue<Label>("""{"text":""}"""))
168+
}
128169
}

0 commit comments

Comments
 (0)