diff --git a/cbor/src/main/java/com/fasterxml/jackson/dataformat/cbor/CBORParser.java b/cbor/src/main/java/com/fasterxml/jackson/dataformat/cbor/CBORParser.java index fdee657fc..003f78067 100644 --- a/cbor/src/main/java/com/fasterxml/jackson/dataformat/cbor/CBORParser.java +++ b/cbor/src/main/java/com/fasterxml/jackson/dataformat/cbor/CBORParser.java @@ -1454,6 +1454,14 @@ public boolean nextFieldName(SerializableString str) throws IOException } } } + // 24-Jul-2026, tatu: [dataformats-binary#728] Cannot fall back to + // `nextToken()` here: we have already consumed one entry of + // expected-length Object (`expectMoreValues()` above) and + // `nextToken()` would consume another one. So decode name here. + // Note: cannot get `null` for end-of-input here since we are + // within Object, and `_handleEOF()` fails for that case. + return (_updateToken(_decodePropertyName()) == JsonToken.FIELD_NAME) + && str.getValue().equals(getCurrentName()); } // otherwise just fall back to default handling; should occur rarely return (nextToken() == JsonToken.FIELD_NAME) && str.getValue().equals(getCurrentName()); diff --git a/cbor/src/test/java/com/fasterxml/jackson/dataformat/cbor/CBORTestBase.java b/cbor/src/test/java/com/fasterxml/jackson/dataformat/cbor/CBORTestBase.java index 185e5cd49..c5f47c170 100644 --- a/cbor/src/test/java/com/fasterxml/jackson/dataformat/cbor/CBORTestBase.java +++ b/cbor/src/test/java/com/fasterxml/jackson/dataformat/cbor/CBORTestBase.java @@ -12,6 +12,7 @@ import com.fasterxml.jackson.core.*; import com.fasterxml.jackson.dataformat.cbor.databind.CBORMapper; +import com.fasterxml.jackson.dataformat.cbor.testutil.ThrottledInputStream; public abstract class CBORTestBase extends junit.framework.TestCase @@ -42,6 +43,32 @@ protected CBORParser cborParser(CBORFactory f, byte[] input) throws IOException return f.createParser(input); } + /** + * Helper for cases that need to verify handling both with fully buffered + * input (fixed {@code byte[]}) and with input that trickles in, one byte + * at a time, so that parser cannot rely on look-ahead within buffer. + * + * @since 2.18.10 + */ + protected CBORParser cborParser(byte[] input, boolean throttled) throws IOException { + return cborParser(cborFactory(), input, throttled); + } + + /** + * Variant that takes factory to use: needed when multiple parsers are to + * share symbol table (as they do when created by same factory). + * + * @since 2.18.10 + */ + protected CBORParser cborParser(CBORFactory f, byte[] input, boolean throttled) + throws IOException + { + if (throttled) { + return cborParser(f, new ThrottledInputStream(input, 1)); + } + return cborParser(f, input); + } + protected CBORParser cborParser(CBORFactory f, InputStream in) throws IOException { return f.createParser(in); } diff --git a/cbor/src/test/java/com/fasterxml/jackson/dataformat/cbor/parse/NextFieldName727Test.java b/cbor/src/test/java/com/fasterxml/jackson/dataformat/cbor/parse/NextFieldName727Test.java index e6f39d881..59c70cfe5 100644 --- a/cbor/src/test/java/com/fasterxml/jackson/dataformat/cbor/parse/NextFieldName727Test.java +++ b/cbor/src/test/java/com/fasterxml/jackson/dataformat/cbor/parse/NextFieldName727Test.java @@ -1,6 +1,5 @@ package com.fasterxml.jackson.dataformat.cbor.parse; -import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import com.fasterxml.jackson.core.JsonGenerator; @@ -11,14 +10,11 @@ import com.fasterxml.jackson.dataformat.cbor.CBORFactory; import com.fasterxml.jackson.dataformat.cbor.CBORTestBase; -import com.fasterxml.jackson.dataformat.cbor.testutil.ThrottledInputStream; // [dataformats-binary#727]: `nextFieldName(SerializableString)` used to confuse // 5-bit length marker 23 (length as-is) with 24 ("1-byte length suffix follows") public class NextFieldName727Test extends CBORTestBase { - private final CBORFactory F = new CBORFactory(); - // Name of exactly 23 bytes, starting with control character U+0017 (0x17): // the mis-decoded length marker used to consume that first name byte as // the length, which made a 23-character name of a different value match @@ -39,8 +35,8 @@ public void testNoFalseMatchForLength23Name() throws Exception final String actualName = "\u0017" + repeat('a', 22); final SerializableString differentName = new SerializedString(repeat('a', 23)); - for (boolean stream : new boolean[] { false, true }) { - try (JsonParser p = _parser(DOC, stream)) { + for (boolean throttled : new boolean[] { false, true }) { + try (JsonParser p = cborParser(DOC, throttled)) { assertToken(JsonToken.START_OBJECT, p.nextToken()); assertFalse(p.nextFieldName(differentName)); assertToken(JsonToken.FIELD_NAME, p.currentToken()); @@ -52,7 +48,7 @@ public void testNoFalseMatchForLength23Name() throws Exception } // ... and the actual name of course still matches - try (JsonParser p = _parser(DOC, stream)) { + try (JsonParser p = cborParser(DOC, throttled)) { assertToken(JsonToken.START_OBJECT, p.nextToken()); assertTrue(p.nextFieldName(new SerializedString(actualName))); assertToken(JsonToken.FIELD_NAME, p.currentToken()); @@ -68,31 +64,35 @@ public void testNoFalseMatchForLength23Name() throws Exception // fast path may see (inline length, 1-byte suffix, 2-byte suffix) public void testNameLengths() throws Exception { + // NOTE: same factory for all parsers on purpose, so that later parsers + // will find names decoded by earlier ones from (shared) symbol table + final CBORFactory f = cborFactory(); + for (int len : new int[] { 1, 2, 3, 22, 23, 24, 25, 100, 255, 256, 1000 }) { final String name = repeat('a', len - 1) + 'b'; final String differentName = repeat('a', len - 1) + 'c'; ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - try (JsonGenerator g = F.createGenerator(bytes)) { + try (JsonGenerator g = cborGenerator(f, bytes)) { g.writeStartObject(); g.writeStringField(name, "v"); g.writeEndObject(); } final byte[] doc = bytes.toByteArray(); - for (boolean stream : new boolean[] { false, true }) { - try (JsonParser p = _parser(doc, stream)) { + for (boolean throttled : new boolean[] { false, true }) { + try (JsonParser p = cborParser(f, doc, throttled)) { assertToken(JsonToken.START_OBJECT, p.nextToken()); - assertTrue("Should match name of length "+len+" (stream? "+stream+")", + assertTrue("Should match name of length "+len+" (throttled? "+throttled+")", p.nextFieldName(new SerializedString(name))); assertEquals(name, p.currentName()); assertEquals("v", p.nextTextValue()); assertToken(JsonToken.END_OBJECT, p.nextToken()); assertNull(p.nextToken()); } - try (JsonParser p = _parser(doc, stream)) { + try (JsonParser p = cborParser(f, doc, throttled)) { assertToken(JsonToken.START_OBJECT, p.nextToken()); - assertFalse("Should not match different name of length "+len+" (stream? "+stream+")", + assertFalse("Should not match different name of length "+len+" (throttled? "+throttled+")", p.nextFieldName(new SerializedString(differentName))); assertToken(JsonToken.FIELD_NAME, p.currentToken()); assertEquals(name, p.currentName()); @@ -120,7 +120,7 @@ public void testNonCanonicalLengthEncoding() throws Exception bytes.write(0xFF); // end indefinite-length Object final byte[] DOC = bytes.toByteArray(); - try (JsonParser p = _parser(DOC, false)) { + try (JsonParser p = cborParser(DOC)) { assertToken(JsonToken.START_OBJECT, p.nextToken()); assertTrue(p.nextFieldName(new SerializedString(name))); assertEquals(name, p.currentName()); @@ -129,7 +129,7 @@ public void testNonCanonicalLengthEncoding() throws Exception assertNull(p.nextToken()); } - try (JsonParser p = _parser(DOC, false)) { + try (JsonParser p = cborParser(DOC)) { assertToken(JsonToken.START_OBJECT, p.nextToken()); assertFalse(p.nextFieldName(new SerializedString("abcdX"))); assertToken(JsonToken.FIELD_NAME, p.currentToken()); @@ -137,14 +137,6 @@ public void testNonCanonicalLengthEncoding() throws Exception } } - private JsonParser _parser(byte[] doc, boolean stream) throws Exception { - if (!stream) { - return F.createParser(doc); - } - // read one byte at a time, to force the "not enough buffered" fallback - return F.createParser(new ThrottledInputStream(new ByteArrayInputStream(doc), 1)); - } - private String repeat(char c, int count) { StringBuilder sb = new StringBuilder(count); for (int i = 0; i < count; ++i) { diff --git a/cbor/src/test/java/com/fasterxml/jackson/dataformat/cbor/parse/NextFieldName728Test.java b/cbor/src/test/java/com/fasterxml/jackson/dataformat/cbor/parse/NextFieldName728Test.java new file mode 100644 index 000000000..46674a3c1 --- /dev/null +++ b/cbor/src/test/java/com/fasterxml/jackson/dataformat/cbor/parse/NextFieldName728Test.java @@ -0,0 +1,260 @@ +package com.fasterxml.jackson.dataformat.cbor.parse; + +import java.io.ByteArrayOutputStream; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.core.io.SerializedString; + +import com.fasterxml.jackson.dataformat.cbor.CBORTestBase; + +// [dataformats-binary#728]: `nextFieldName(SerializableString)` used to consume +// an entry of expected-length (definite-length) Object twice, when its +// name-matching fast path did not succeed, truncating the Object +public class NextFieldName728Test extends CBORTestBase +{ + // {"a":1, "b":2} as definite-length Object; name not matched + public void testNonMatchingNameDefiniteLength() throws Exception + { + final byte[] DOC = twoEntryObject(false); + + for (boolean throttled : new boolean[] { false, true }) { + try (JsonParser p = cborParser(DOC, throttled)) { + assertToken(JsonToken.START_OBJECT, p.nextToken()); + assertFalse(p.nextFieldName(new SerializedString("zzz"))); + assertToken(JsonToken.FIELD_NAME, p.currentToken()); + assertEquals("a", p.currentName()); + assertToken(JsonToken.VALUE_NUMBER_INT, p.nextToken()); + assertEquals(1, p.getIntValue()); + // and second entry must NOT be lost: + assertToken(JsonToken.FIELD_NAME, p.nextToken()); + assertEquals("b", p.currentName()); + assertToken(JsonToken.VALUE_NUMBER_INT, p.nextToken()); + assertEquals(2, p.getIntValue()); + assertToken(JsonToken.END_OBJECT, p.nextToken()); + assertNull(p.nextToken()); + } + } + } + + // Same, but name of second entry is the one not matched + public void testNonMatchingSecondNameDefiniteLength() throws Exception + { + final byte[] DOC = twoEntryObject(false); + + for (boolean throttled : new boolean[] { false, true }) { + try (JsonParser p = cborParser(DOC, throttled)) { + assertToken(JsonToken.START_OBJECT, p.nextToken()); + assertTrue(p.nextFieldName(new SerializedString("a"))); + assertToken(JsonToken.VALUE_NUMBER_INT, p.nextToken()); + assertEquals(1, p.getIntValue()); + assertFalse(p.nextFieldName(new SerializedString("zzz"))); + assertToken(JsonToken.FIELD_NAME, p.currentToken()); + assertEquals("b", p.currentName()); + assertToken(JsonToken.VALUE_NUMBER_INT, p.nextToken()); + assertEquals(2, p.getIntValue()); + assertToken(JsonToken.END_OBJECT, p.nextToken()); + assertNull(p.nextToken()); + } + } + } + + // Matching name, but read from a stream that only has partial content + // buffered, so that the fast path cannot be used either + public void testMatchingNameDefiniteLengthThrottled() throws Exception + { + final byte[] DOC = twoEntryObject(false); + + try (JsonParser p = cborParser(DOC, true)) { + assertToken(JsonToken.START_OBJECT, p.nextToken()); + assertTrue(p.nextFieldName(new SerializedString("a"))); + assertEquals(1, p.nextIntValue(-1)); + assertTrue(p.nextFieldName(new SerializedString("b"))); + assertEquals(2, p.nextIntValue(-1)); + assertToken(JsonToken.END_OBJECT, p.nextToken()); + assertNull(p.nextToken()); + } + } + + // Single-entry definite-length Object: used to report END_OBJECT (and + // `null` name) instead of the entry it does contain + public void testNonMatchingNameSingleEntry() throws Exception + { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + bytes.write(0xA1); // Object, 1 entry + bytes.write(0x61); bytes.write('a'); + bytes.write(0x01); + final byte[] DOC = bytes.toByteArray(); + + for (boolean throttled : new boolean[] { false, true }) { + try (JsonParser p = cborParser(DOC, throttled)) { + assertToken(JsonToken.START_OBJECT, p.nextToken()); + assertFalse(p.nextFieldName(new SerializedString("zzz"))); + assertToken(JsonToken.FIELD_NAME, p.currentToken()); + assertEquals("a", p.currentName()); + assertEquals(1, p.nextIntValue(-1)); + assertToken(JsonToken.END_OBJECT, p.nextToken()); + assertNull(p.nextToken()); + } + } + } + + // Nested definite-length Objects, to verify context handling + public void testNonMatchingNameNested() throws Exception + { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + bytes.write(0xA2); // Object, 2 entries + bytes.write(0x61); bytes.write('a'); + bytes.write(0xA1); // Object, 1 entry + bytes.write(0x61); bytes.write('c'); + bytes.write(0x03); + bytes.write(0x61); bytes.write('b'); + bytes.write(0x02); + final byte[] DOC = bytes.toByteArray(); + + for (boolean throttled : new boolean[] { false, true }) { + try (JsonParser p = cborParser(DOC, throttled)) { + assertToken(JsonToken.START_OBJECT, p.nextToken()); + assertFalse(p.nextFieldName(new SerializedString("zzz"))); + assertEquals("a", p.currentName()); + assertToken(JsonToken.START_OBJECT, p.nextToken()); + assertFalse(p.nextFieldName(new SerializedString("zzz"))); + assertEquals("c", p.currentName()); + assertEquals(3, p.nextIntValue(-1)); + assertToken(JsonToken.END_OBJECT, p.nextToken()); + assertToken(JsonToken.FIELD_NAME, p.nextToken()); + assertEquals("b", p.currentName()); + assertEquals(2, p.nextIntValue(-1)); + assertToken(JsonToken.END_OBJECT, p.nextToken()); + assertNull(p.nextToken()); + } + } + } + + // Fast path is also skipped for names that are not (definite-length) Strings; + // all of these cases must decode the name without losing the second entry. + // First: name as indefinite-length (chunked) String + public void testChunkedNameDefiniteLength() throws Exception + { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + bytes.write(0xA2); // Object, 2 entries + bytes.write(0x7F); // indefinite-length text... + bytes.write(0x61); bytes.write('a'); + bytes.write(0x61); bytes.write('b'); + bytes.write(0xFF); // ... up to break + bytes.write(0x01); + bytes.write(0x61); bytes.write('b'); + bytes.write(0x02); + + _testNonTextName(bytes.toByteArray(), "ab"); + } + + // Second: name as Integer (CBOR allows non-String Object keys) + public void testIntegerNameDefiniteLength() throws Exception + { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + bytes.write(0xA2); // Object, 2 entries + bytes.write(0x01); // name: 1 + bytes.write(0x01); + bytes.write(0x61); bytes.write('b'); + bytes.write(0x02); + + _testNonTextName(bytes.toByteArray(), "1"); + } + + // Third: tag-prefixed name (fast path only handles untagged text) + public void testTaggedNameDefiniteLength() throws Exception + { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + bytes.write(0xA2); // Object, 2 entries + // Tag 55799 ("Self-Described CBOR"): semantics do not constrain the + // tagged value, unlike f.ex tag 0 (date/time String) + bytes.write(0xD9); bytes.write(0xD9); bytes.write(0xF7); + bytes.write(0x62); bytes.write('a'); bytes.write('b'); + bytes.write(0x01); + bytes.write(0x61); bytes.write('b'); + bytes.write(0x02); + + _testNonTextName(bytes.toByteArray(), "ab"); + } + + private void _testNonTextName(byte[] doc, String expName) throws Exception + { + for (boolean throttled : new boolean[] { false, true }) { + try (JsonParser p = cborParser(doc, throttled)) { + assertToken(JsonToken.START_OBJECT, p.nextToken()); + assertFalse(p.nextFieldName(new SerializedString("zzz"))); + assertToken(JsonToken.FIELD_NAME, p.currentToken()); + assertEquals(expName, p.currentName()); + assertEquals(1, p.nextIntValue(-1)); + // second entry must NOT be lost: + assertToken(JsonToken.FIELD_NAME, p.nextToken()); + assertEquals("b", p.currentName()); + assertEquals(2, p.nextIntValue(-1)); + assertToken(JsonToken.END_OBJECT, p.nextToken()); + assertNull(p.nextToken()); + } + } + } + + // Indefinite-length Object (what `CBORGenerator` writes): was, and + // stays, unaffected + public void testNonMatchingNameIndefiniteLength() throws Exception + { + final byte[] DOC = twoEntryObject(true); + + for (boolean throttled : new boolean[] { false, true }) { + try (JsonParser p = cborParser(DOC, throttled)) { + assertToken(JsonToken.START_OBJECT, p.nextToken()); + assertFalse(p.nextFieldName(new SerializedString("zzz"))); + assertToken(JsonToken.FIELD_NAME, p.currentToken()); + assertEquals("a", p.currentName()); + assertEquals(1, p.nextIntValue(-1)); + assertToken(JsonToken.FIELD_NAME, p.nextToken()); + assertEquals("b", p.currentName()); + assertEquals(2, p.nextIntValue(-1)); + assertToken(JsonToken.END_OBJECT, p.nextToken()); + assertNull(p.nextToken()); + } + } + } + + // Also: for indefinite-length Object, name decoding may find the break + // marker instead of a name, and must then close the Object properly + public void testBreakInPlaceOfNameIndefiniteLength() throws Exception + { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + bytes.write(0xBF); // start indefinite-length Object + bytes.write(0x61); bytes.write('a'); + bytes.write(0x01); + bytes.write(0xFF); // end indefinite-length Object + final byte[] DOC = bytes.toByteArray(); + + for (boolean throttled : new boolean[] { false, true }) { + try (JsonParser p = cborParser(DOC, throttled)) { + assertToken(JsonToken.START_OBJECT, p.nextToken()); + assertTrue(p.nextFieldName(new SerializedString("a"))); + assertEquals(1, p.nextIntValue(-1)); + // no more entries; break marker where name would be + assertFalse(p.nextFieldName(new SerializedString("zzz"))); + assertToken(JsonToken.END_OBJECT, p.currentToken()); + assertNull(p.nextToken()); + } + } + } + + // {"a":1, "b":2}, either as definite- or indefinite-length Object + private byte[] twoEntryObject(boolean indefinite) { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + bytes.write(indefinite ? 0xBF : 0xA2); + bytes.write(0x61); bytes.write('a'); + bytes.write(0x01); + bytes.write(0x61); bytes.write('b'); + bytes.write(0x02); + if (indefinite) { + bytes.write(0xFF); + } + return bytes.toByteArray(); + } + +} diff --git a/release-notes/VERSION-2.x b/release-notes/VERSION-2.x index fbeddaf70..663eae388 100644 --- a/release-notes/VERSION-2.x +++ b/release-notes/VERSION-2.x @@ -18,6 +18,8 @@ Active maintainers: #727: (cbor) `CBORParser.nextFieldName(SerializableString)` confuses 5-bit length marker 23 with 24 ("1-byte length suffix follows") +#728: (cbor) `CBORParser.nextFieldName(SerializableString)` consumes Object entry + slot twice on fast-path miss, truncating definite-length Objects 2.18.9 (07-Jul-2026)