From ac16e2fb29ab54ec82a97dd6e9508833ab06fcd8 Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Wed, 9 Sep 2026 10:09:11 +0100 Subject: [PATCH 1/5] (json-org) Fail on non-Object input for `JSONObject` instead of returning empty Object `JSONArrayDeserializer` verifies it is given a START_ARRAY (added for [datatype-json-org#15]), but `JSONObjectDeserializer` never got the equivalent check. It reads `currentToken()` and, when it is not START_OBJECT, simply falls through the property loop -- returning an empty `JSONObject` and leaving the parser pointing in the middle of the value it did not consume. That silently corrupts values rather than reporting an error: [{"a":1},42,{"b":2}] -> [{"a":1}, {}, {"b":2}] [{"a":1},[7,8],{"b":2}] -> [{"a":1}, {}, {}, {}] Add the matching guard, wording it like the Array one. Entry with a PROPERTY_NAME is still allowed, since deserializers may be invoked with the parser already positioned inside the Object. Co-Authored-By: Claude Opus 5 (1M context) --- .../jsonorg/JSONObjectDeserializer.java | 11 ++- .../datatype/jsonorg/FailOnNonObjectTest.java | 99 +++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 json-org/src/test/java/tools/jackson/datatype/jsonorg/FailOnNonObjectTest.java diff --git a/json-org/src/main/java/tools/jackson/datatype/jsonorg/JSONObjectDeserializer.java b/json-org/src/main/java/tools/jackson/datatype/jsonorg/JSONObjectDeserializer.java index e847187d..c65c0cd7 100644 --- a/json-org/src/main/java/tools/jackson/datatype/jsonorg/JSONObjectDeserializer.java +++ b/json-org/src/main/java/tools/jackson/datatype/jsonorg/JSONObjectDeserializer.java @@ -5,6 +5,7 @@ import tools.jackson.databind.*; import tools.jackson.databind.deser.std.StdDeserializer; import tools.jackson.databind.type.LogicalType; +import tools.jackson.databind.util.ClassUtil; import org.json.JSONException; import org.json.JSONObject; @@ -27,11 +28,19 @@ public LogicalType logicalType() { public JSONObject deserialize(JsonParser p, DeserializationContext ctxt) throws JacksonException { - JSONObject ob = new JSONObject(); JsonToken t = p.currentToken(); if (t == JsonToken.START_OBJECT) { t = p.nextToken(); + } else if (t != JsonToken.PROPERTY_NAME) { + // 09-Sep-2026, pjfanning: Need to verify it IS an Object (like + // `JSONArrayDeserializer` does for Arrays); + // otherwise we would quietly return an empty JSONObject and leave the parser + // pointing in the middle of the (non-Object) value + return (JSONObject) ctxt.handleUnexpectedToken(getValueType(ctxt), t, p, + "Unexpected token (%s), expected START_OBJECT for %s value", + t, ClassUtil.nameOf(handledType())); } + JSONObject ob = new JSONObject(); for (; t == JsonToken.PROPERTY_NAME; t = p.nextToken()) { String fieldName = p.currentName(); t = p.nextToken(); diff --git a/json-org/src/test/java/tools/jackson/datatype/jsonorg/FailOnNonObjectTest.java b/json-org/src/test/java/tools/jackson/datatype/jsonorg/FailOnNonObjectTest.java new file mode 100644 index 00000000..cfd2ab11 --- /dev/null +++ b/json-org/src/test/java/tools/jackson/datatype/jsonorg/FailOnNonObjectTest.java @@ -0,0 +1,99 @@ +package tools.jackson.datatype.jsonorg; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.json.JSONObject; + +import tools.jackson.core.type.TypeReference; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.exc.MismatchedInputException; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests to verify that deserialization of {@link JSONObject} fails cleanly for + * non-Object input, instead of quietly returning an empty {@code JSONObject} + * (and, for structured input, leaving the parser mid-value). + */ +public class FailOnNonObjectTest extends ModuleTestBase +{ + private final ObjectMapper MAPPER = newMapper(); + + @Test + public void testFailOnNumber() throws Exception + { + try { + JSONObject ob = MAPPER.readValue("42", JSONObject.class); + fail("Should not pass but got: "+ob); + } catch (MismatchedInputException e) { + verifyException(e, "Unexpected token (VALUE_NUMBER_INT), expected START_OBJECT"); + } + } + + @Test + public void testFailOnString() throws Exception + { + try { + JSONObject ob = MAPPER.readValue("\"abc\"", JSONObject.class); + fail("Should not pass but got: "+ob); + } catch (MismatchedInputException e) { + verifyException(e, "Unexpected token (VALUE_STRING), expected START_OBJECT"); + } + } + + @Test + public void testFailOnArray() throws Exception + { + try { + JSONObject ob = MAPPER.readValue("[1,2]", JSONObject.class); + fail("Should not pass but got: "+ob); + } catch (MismatchedInputException e) { + verifyException(e, "Unexpected token (START_ARRAY), expected START_OBJECT"); + } + } + + // Most importantly: bad element must not desync the parser for the ones that follow + @Test + public void testFailOnScalarWithinList() throws Exception + { + try { + List obs = MAPPER.readValue("[{\"a\":1},42,{\"b\":2}]", + new TypeReference>() { }); + fail("Should not pass but got: "+obs); + } catch (MismatchedInputException e) { + verifyException(e, "Unexpected token (VALUE_NUMBER_INT), expected START_OBJECT"); + } + } + + @Test + public void testFailOnArrayWithinList() throws Exception + { + try { + List obs = MAPPER.readValue("[{\"a\":1},[7,8],{\"b\":2}]", + new TypeReference>() { }); + fail("Should not pass but got: "+obs); + } catch (MismatchedInputException e) { + verifyException(e, "Unexpected token (START_ARRAY), expected START_OBJECT"); + } + } + + // But valid Objects must keep working, including empty ones + @Test + public void testEmptyObjectStillOk() throws Exception + { + assertEquals(0, MAPPER.readValue("{}", JSONObject.class).length()); + } + + @Test + public void testObjectListStillOk() throws Exception + { + List obs = MAPPER.readValue("[{\"a\":1},{\"b\":2}]", + new TypeReference>() { }); + assertEquals(2, obs.size()); + assertEquals(1, obs.get(0).getInt("a")); + assertEquals(2, obs.get(1).getInt("b")); + } +} From 0b107f1efbfd6a6aa4e6fe51d86f0699264597de Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Wed, 9 Sep 2026 10:09:47 +0100 Subject: [PATCH 2/5] Add release notes entry for #90 Co-Authored-By: Claude Opus 5 (1M context) --- .../tools/jackson/datatype/jsonorg/JSONObjectDeserializer.java | 2 +- release-notes/VERSION | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/json-org/src/main/java/tools/jackson/datatype/jsonorg/JSONObjectDeserializer.java b/json-org/src/main/java/tools/jackson/datatype/jsonorg/JSONObjectDeserializer.java index c65c0cd7..e94f54ef 100644 --- a/json-org/src/main/java/tools/jackson/datatype/jsonorg/JSONObjectDeserializer.java +++ b/json-org/src/main/java/tools/jackson/datatype/jsonorg/JSONObjectDeserializer.java @@ -32,7 +32,7 @@ public JSONObject deserialize(JsonParser p, DeserializationContext ctxt) if (t == JsonToken.START_OBJECT) { t = p.nextToken(); } else if (t != JsonToken.PROPERTY_NAME) { - // 09-Sep-2026, pjfanning: Need to verify it IS an Object (like + // 09-Sep-2026, pjfanning: [datatypes-misc#90] Need to verify it IS an Object (like // `JSONArrayDeserializer` does for Arrays); // otherwise we would quietly return an empty JSONObject and leave the parser // pointing in the middle of the (non-Object) value diff --git a/release-notes/VERSION b/release-notes/VERSION index 6c9ef325..440c60d7 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -15,7 +15,8 @@ Modules: 3.3.0 (not yet released) -No changes since 3.2 +#90: (json-org) `JSONObject` deserialization quietly returns empty Object + for non-Object input 3.2.2 (14-Aug-2026) From 16007892fe68a813b3cdb561bdb29325a2174043 Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Wed, 9 Sep 2026 10:15:29 +0100 Subject: [PATCH 3/5] Use `a2q()` helper for JSON content in tests Co-Authored-By: Claude Opus 5 (1M context) --- .../jackson/datatype/jsonorg/FailOnNonObjectTest.java | 8 ++++---- .../tools/jackson/datatype/jsonorg/ModuleTestBase.java | 4 ++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/json-org/src/test/java/tools/jackson/datatype/jsonorg/FailOnNonObjectTest.java b/json-org/src/test/java/tools/jackson/datatype/jsonorg/FailOnNonObjectTest.java index cfd2ab11..34f8c6d2 100644 --- a/json-org/src/test/java/tools/jackson/datatype/jsonorg/FailOnNonObjectTest.java +++ b/json-org/src/test/java/tools/jackson/datatype/jsonorg/FailOnNonObjectTest.java @@ -37,7 +37,7 @@ public void testFailOnNumber() throws Exception public void testFailOnString() throws Exception { try { - JSONObject ob = MAPPER.readValue("\"abc\"", JSONObject.class); + JSONObject ob = MAPPER.readValue(a2q("'abc'"), JSONObject.class); fail("Should not pass but got: "+ob); } catch (MismatchedInputException e) { verifyException(e, "Unexpected token (VALUE_STRING), expected START_OBJECT"); @@ -60,7 +60,7 @@ public void testFailOnArray() throws Exception public void testFailOnScalarWithinList() throws Exception { try { - List obs = MAPPER.readValue("[{\"a\":1},42,{\"b\":2}]", + List obs = MAPPER.readValue(a2q("[{'a':1},42,{'b':2}]"), new TypeReference>() { }); fail("Should not pass but got: "+obs); } catch (MismatchedInputException e) { @@ -72,7 +72,7 @@ public void testFailOnScalarWithinList() throws Exception public void testFailOnArrayWithinList() throws Exception { try { - List obs = MAPPER.readValue("[{\"a\":1},[7,8],{\"b\":2}]", + List obs = MAPPER.readValue(a2q("[{'a':1},[7,8],{'b':2}]"), new TypeReference>() { }); fail("Should not pass but got: "+obs); } catch (MismatchedInputException e) { @@ -90,7 +90,7 @@ public void testEmptyObjectStillOk() throws Exception @Test public void testObjectListStillOk() throws Exception { - List obs = MAPPER.readValue("[{\"a\":1},{\"b\":2}]", + List obs = MAPPER.readValue(a2q("[{'a':1},{'b':2}]"), new TypeReference>() { }); assertEquals(2, obs.size()); assertEquals(1, obs.get(0).getInt("a")); diff --git a/json-org/src/test/java/tools/jackson/datatype/jsonorg/ModuleTestBase.java b/json-org/src/test/java/tools/jackson/datatype/jsonorg/ModuleTestBase.java index 41974471..9a5a9756 100644 --- a/json-org/src/test/java/tools/jackson/datatype/jsonorg/ModuleTestBase.java +++ b/json-org/src/test/java/tools/jackson/datatype/jsonorg/ModuleTestBase.java @@ -30,6 +30,10 @@ public JsonMapper.Builder newMapperBuilder() { .addModule(new JsonOrgModule()); } + protected static String a2q(String json) { + return json.replace("'", "\""); + } + protected void verifyException(Throwable e, String... matches) { String msg = e.getMessage(); From 3b6950c013c560bc1e52d8b17c3135ca8029affb Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Mon, 14 Sep 2026 19:51:09 -0700 Subject: [PATCH 4/5] Update release notes, improve fix --- .../jsonorg/JSONObjectDeserializer.java | 6 ++- .../datatype/jsonorg/TypeInformationTest.java | 38 ++++++++++++++++++- release-notes/CREDITS | 6 +++ release-notes/VERSION | 1 + 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/json-org/src/main/java/tools/jackson/datatype/jsonorg/JSONObjectDeserializer.java b/json-org/src/main/java/tools/jackson/datatype/jsonorg/JSONObjectDeserializer.java index e94f54ef..7fa08866 100644 --- a/json-org/src/main/java/tools/jackson/datatype/jsonorg/JSONObjectDeserializer.java +++ b/json-org/src/main/java/tools/jackson/datatype/jsonorg/JSONObjectDeserializer.java @@ -31,11 +31,13 @@ public JSONObject deserialize(JsonParser p, DeserializationContext ctxt) JsonToken t = p.currentToken(); if (t == JsonToken.START_OBJECT) { t = p.nextToken(); - } else if (t != JsonToken.PROPERTY_NAME) { + } else if (t != JsonToken.PROPERTY_NAME && t != JsonToken.END_OBJECT) { // 09-Sep-2026, pjfanning: [datatypes-misc#90] Need to verify it IS an Object (like // `JSONArrayDeserializer` does for Arrays); // otherwise we would quietly return an empty JSONObject and leave the parser - // pointing in the middle of the (non-Object) value + // pointing in the middle of the (non-Object) value. + // But note: PROPERTY_NAME and END_OBJECT are legal too, since we may be + // called with parser already inside Object (e.g. after As-Property Type Id) return (JSONObject) ctxt.handleUnexpectedToken(getValueType(ctxt), t, p, "Unexpected token (%s), expected START_OBJECT for %s value", t, ClassUtil.nameOf(handledType())); diff --git a/json-org/src/test/java/tools/jackson/datatype/jsonorg/TypeInformationTest.java b/json-org/src/test/java/tools/jackson/datatype/jsonorg/TypeInformationTest.java index 6b30f1e0..57c119db 100644 --- a/json-org/src/test/java/tools/jackson/datatype/jsonorg/TypeInformationTest.java +++ b/json-org/src/test/java/tools/jackson/datatype/jsonorg/TypeInformationTest.java @@ -24,7 +24,12 @@ public ObjectWrapper() { } private final ObjectMapper POLY_MAPPER = newMapperBuilder() .activateDefaultTyping(new NoCheckSubTypeValidator()) .build(); - + + private final ObjectMapper POLY_PROP_MAPPER = newMapperBuilder() + .activateDefaultTypingAsProperty(new NoCheckSubTypeValidator(), + DefaultTyping.NON_FINAL, "@class") + .build(); + @Test public void testWrappedArray() throws Exception { @@ -56,4 +61,35 @@ public void testWrappedObject() throws Exception assertEquals(1, resultOb.length()); assertTrue(resultOb.getBoolean("a")); } + + // [datatypes-misc#90]: with As-Property Type Id, deserializer is called with + // parser positioned past the Type Id: PROPERTY_NAME, or END_OBJECT for empty Object + @Test + public void testObjectWithTypeIdAsProperty() throws Exception + { + JSONObject ob = new JSONObject(); + ob.put("a", true); + + String json = POLY_PROP_MAPPER.writeValueAsString(new ObjectWrapper(ob)); + assertEquals(a2q("{'@class':'"+ObjectWrapper.class.getName()+"'," + +"'value':{'@class':'org.json.JSONObject','a':true}}"), json); + + ObjectWrapper result = POLY_PROP_MAPPER.readValue(json, ObjectWrapper.class); + assertEquals(JSONObject.class, result.value.getClass()); + JSONObject resultOb = (JSONObject) result.value; + assertEquals(1, resultOb.length()); + assertTrue(resultOb.getBoolean("a")); + } + + @Test + public void testEmptyObjectWithTypeIdAsProperty() throws Exception + { + String json = POLY_PROP_MAPPER.writeValueAsString(new ObjectWrapper(new JSONObject())); + assertEquals(a2q("{'@class':'"+ObjectWrapper.class.getName()+"'," + +"'value':{'@class':'org.json.JSONObject'}}"), json); + + ObjectWrapper result = POLY_PROP_MAPPER.readValue(json, ObjectWrapper.class); + assertEquals(JSONObject.class, result.value.getClass()); + assertEquals(0, ((JSONObject) result.value).length()); + } } diff --git a/release-notes/CREDITS b/release-notes/CREDITS index c1598704..3a1c9371 100644 --- a/release-notes/CREDITS +++ b/release-notes/CREDITS @@ -16,3 +16,9 @@ Christopher Smith (chrylis@github): author of `jakarta-mail` module (added in 2. * Contributed #76: (joda-money) Add field-level amount representation for Joda-Money (`@JodaMoney` annotation) (3.1.0) + +@pjfanning + +* Contributed #90: (json-org) `JSONObject` deserialization quietly returns empty Object + for non-Object input + (3.3.0) diff --git a/release-notes/VERSION b/release-notes/VERSION index 440c60d7..14f0327d 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -17,6 +17,7 @@ Modules: #90: (json-org) `JSONObject` deserialization quietly returns empty Object for non-Object input + (contributed by @pjfanning) 3.2.2 (14-Aug-2026) From 93e8751ee4661e432673e39610529278d2e3524e Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Mon, 14 Sep 2026 19:54:16 -0700 Subject: [PATCH 5/5] ... --- .../tools/jackson/datatype/jsonorg/JSONObjectDeserializer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/json-org/src/main/java/tools/jackson/datatype/jsonorg/JSONObjectDeserializer.java b/json-org/src/main/java/tools/jackson/datatype/jsonorg/JSONObjectDeserializer.java index 7fa08866..46f428ca 100644 --- a/json-org/src/main/java/tools/jackson/datatype/jsonorg/JSONObjectDeserializer.java +++ b/json-org/src/main/java/tools/jackson/datatype/jsonorg/JSONObjectDeserializer.java @@ -29,7 +29,7 @@ public JSONObject deserialize(JsonParser p, DeserializationContext ctxt) throws JacksonException { JsonToken t = p.currentToken(); - if (t == JsonToken.START_OBJECT) { + if (p.isExpectedStartObjectToken()) { t = p.nextToken(); } else if (t != JsonToken.PROPERTY_NAME && t != JsonToken.END_OBJECT) { // 09-Sep-2026, pjfanning: [datatypes-misc#90] Need to verify it IS an Object (like