From ec2d1e0051d596ecf05372ece01e3d6ec89342bf Mon Sep 17 00:00:00 2001 From: Sahana Bogar Date: Sat, 22 Aug 2026 14:41:34 +0530 Subject: [PATCH] Handle negative (unknown) length in ToXmlGenerator.writeBinary(InputStream) Per the JsonGenerator contract a negative dataLength means "read to end of stream"; the XML backend passed it straight through to new byte[len] / a bounded read, escaping as NegativeArraySizeException or IndexOutOfBoundsException. writeStreamAsBinary() now streams until EOF for negative length (through the recycled base64 buffer instead of 3 bytes per read) and returns the byte count; the attribute and pretty-printer paths, which need a full buffer for Stax2 anyway, read to end via ByteArrayBuilder on the buffer recycler. Non-negative lengths behave as before. --- release-notes/CREDITS-2.x | 3 + release-notes/VERSION-2.x | 3 + .../dataformat/xml/ser/ToXmlGenerator.java | 91 ++++++-- .../stream/BinaryUnknownLengthWriteTest.java | 205 ++++++++++++++++++ 4 files changed, 278 insertions(+), 24 deletions(-) create mode 100644 src/test/java/com/fasterxml/jackson/dataformat/xml/stream/BinaryUnknownLengthWriteTest.java diff --git a/release-notes/CREDITS-2.x b/release-notes/CREDITS-2.x index 15f05928..48ed2c69 100644 --- a/release-notes/CREDITS-2.x +++ b/release-notes/CREDITS-2.x @@ -290,3 +290,6 @@ Sahana (@Sahana2524) * Fixed #899: Return `null` from `nextTextValue()` at end-of-input (instead of throwing `IllegalStateException`) (2.23.0) +* Fixed #894: `ToXmlGenerator.writeBinary(Base64Variant, InputStream, int)` fails with + `NegativeArraySizeException`/`IndexOutOfBoundsException` for unknown (negative) length + (2.23.0) diff --git a/release-notes/VERSION-2.x b/release-notes/VERSION-2.x index 712f5503..3bbd9e74 100644 --- a/release-notes/VERSION-2.x +++ b/release-notes/VERSION-2.x @@ -9,6 +9,9 @@ Project: jackson-dataformat-xml #899: Return `null` from `nextTextValue()` at end-of-input (instead of throwing `IllegalStateException`) (fix by @Sahana2524) +#894: `ToXmlGenerator.writeBinary(Base64Variant, InputStream, int)` fails with + `NegativeArraySizeException`/`IndexOutOfBoundsException` for unknown (negative) length + (fix by @Sahana2524) 2.22.2 (16-Aug-2026) 2.22.1 (07-Jul-2026) diff --git a/src/main/java/com/fasterxml/jackson/dataformat/xml/ser/ToXmlGenerator.java b/src/main/java/com/fasterxml/jackson/dataformat/xml/ser/ToXmlGenerator.java index 136af1f0..883085e1 100644 --- a/src/main/java/com/fasterxml/jackson/dataformat/xml/ser/ToXmlGenerator.java +++ b/src/main/java/com/fasterxml/jackson/dataformat/xml/ser/ToXmlGenerator.java @@ -18,6 +18,7 @@ import com.fasterxml.jackson.core.base.GeneratorBase; import com.fasterxml.jackson.core.io.IOContext; import com.fasterxml.jackson.core.json.JsonWriteContext; +import com.fasterxml.jackson.core.util.ByteArrayBuilder; import com.fasterxml.jackson.core.util.JacksonFeatureSet; import com.fasterxml.jackson.dataformat.xml.XmlPrettyPrinter; import com.fasterxml.jackson.dataformat.xml.util.DefaultXmlPrettyPrinter; @@ -1023,24 +1024,30 @@ public int writeBinary(Base64Variant b64variant, InputStream data, int dataLengt handleMissingName(); } final org.codehaus.stax2.typed.Base64Variant stax2base64v = StaxUtil.toStax2Base64Variant(b64variant); + // 22-Aug-2026, sahana: negative `dataLength` means "unknown, read to the end"; + // stream where we can, buffer only where Stax2 needs a full buffer + int written = dataLength; try { if (_nextIsAttribute) { // Stax2 API only has 'full buffer' write method: - byte[] fullBuffer = toFullBuffer(data, dataLength); + byte[] fullBuffer = (dataLength < 0) ? toFullBuffer(data) : toFullBuffer(data, dataLength); + written = fullBuffer.length; _xmlWriter.writeBinaryAttribute(stax2base64v, "", _nextName.getNamespaceURI(), _nextName.getLocalPart(), fullBuffer); } else if (checkNextIsUnwrapped()) { // should we consider pretty-printing or not? - writeStreamAsBinary(stax2base64v, data, dataLength); + written = writeStreamAsBinary(stax2base64v, data, dataLength); } else { if (_xmlPrettyPrinter != null) { + byte[] fullBuffer = (dataLength < 0) ? toFullBuffer(data) : toFullBuffer(data, dataLength); + written = fullBuffer.length; _xmlPrettyPrinter.writeLeafElement(_xmlWriter, _nextName.getNamespaceURI(), _nextName.getLocalPart(), - stax2base64v, toFullBuffer(data, dataLength), 0, dataLength); + stax2base64v, fullBuffer, 0, written); } else { _xmlWriter.writeStartElement(_nextName.getNamespaceURI(), _nextName.getLocalPart()); - writeStreamAsBinary(stax2base64v, data, dataLength); + written = writeStreamAsBinary(stax2base64v, data, dataLength); _xmlWriter.writeEndElement(); } } @@ -1048,32 +1055,52 @@ public int writeBinary(Base64Variant b64variant, InputStream data, int dataLengt StaxUtil.throwAsGenerationException(e, this); } - return dataLength; + return written; } - private void writeStreamAsBinary(org.codehaus.stax2.typed.Base64Variant stax2base64v, + /** + * Helper method for encoding contents of given stream: at most {@code len} + * bytes if non-negative, or until end-of-stream if negative. + * + * @return Number of bytes read and encoded + */ + private int writeStreamAsBinary(org.codehaus.stax2.typed.Base64Variant stax2base64v, InputStream data, int len) throws IOException, XMLStreamException { - // base64 encodes up to 3 bytes into a 4 bytes string - byte[] tmp = new byte[3]; - int offset = 0; - int read; - while((read = data.read(tmp, offset, Math.min(3 - offset, len))) != -1) { - offset += read; - len -= read; - if(offset == 3) { - offset = 0; - _xmlWriter.writeBinary(stax2base64v, tmp, 0, 3); - } - if (len == 0) { - break; + final byte[] buf = _ioContext.allocBase64Buffer(); + int total = 0; + try { + int end = 0; // number of buffered bytes not yet written + while (true) { + int max = buf.length - end; + if (len >= 0 && len < max) { + max = len; + } + int count = (max == 0) ? -1 : data.read(buf, end, max); + if (count < 0) { // end-of-stream, or requested length reached + if (end > 0) { + _xmlWriter.writeBinary(stax2base64v, buf, 0, end); + } + break; + } + end += count; + total += count; + if (len > 0) { + len -= count; + } + // base64 encodes 3 bytes into 4 characters: write complete triplets, + // keep the remainder for the next round + int full = end - (end % 3); + if (full > 0) { + _xmlWriter.writeBinary(stax2base64v, buf, 0, full); + end -= full; + System.arraycopy(buf, full, buf, 0, end); + } } + } finally { + _ioContext.releaseBase64Buffer(buf); } - - // we still have < 3 bytes in the buffer - if(offset > 0) { - _xmlWriter.writeBinary(stax2base64v, tmp, 0, offset); - } + return total; } private byte[] toFullBuffer(byte[] data, int offset, int len) @@ -1104,6 +1131,22 @@ private byte[] toFullBuffer(InputStream data, final int len) throws IOException return result; } + // Variant for "unknown length": read until end-of-stream + private byte[] toFullBuffer(InputStream data) throws IOException + { + final ByteArrayBuilder bb = new ByteArrayBuilder(_ioContext.bufferRecycler()); + final byte[] tmp = _ioContext.allocBase64Buffer(); + try { + int count; + while ((count = data.read(tmp)) >= 0) { + bb.write(tmp, 0, count); + } + } finally { + _ioContext.releaseBase64Buffer(tmp); + } + return bb.getClearAndRelease(); + } + /* /********************************************************** /* Output method implementations, primitive diff --git a/src/test/java/com/fasterxml/jackson/dataformat/xml/stream/BinaryUnknownLengthWriteTest.java b/src/test/java/com/fasterxml/jackson/dataformat/xml/stream/BinaryUnknownLengthWriteTest.java new file mode 100644 index 00000000..770cf2b1 --- /dev/null +++ b/src/test/java/com/fasterxml/jackson/dataformat/xml/stream/BinaryUnknownLengthWriteTest.java @@ -0,0 +1,205 @@ +package com.fasterxml.jackson.dataformat.xml.stream; + +import java.io.ByteArrayInputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.StringWriter; + +import javax.xml.namespace.QName; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.core.Base64Variant; +import com.fasterxml.jackson.core.Base64Variants; + +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import com.fasterxml.jackson.dataformat.xml.XmlTestUtil; +import com.fasterxml.jackson.dataformat.xml.ser.ToXmlGenerator; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +// [dataformat-xml#894]: `JsonGenerator.writeBinary(InputStream, dataLength)` documents a +// negative `dataLength` as "length unknown, read to end" (the JSON backend honors it); +// verify the XML backend streams to end instead of failing with a raw runtime exception. +public class BinaryUnknownLengthWriteTest extends XmlTestUtil +{ + // Stream that hands out a single byte per read() call + static class ShortReadInputStream extends FilterInputStream + { + ShortReadInputStream(InputStream in) { super(in); } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + return super.read(b, off, Math.min(len, 1)); + } + } + + private final XmlMapper MAPPER = newMapper(); + + private final byte[] DATA = utf8Bytes("hello, binary world"); + // base64 of DATA + private final String ENCODED = "aGVsbG8sIGJpbmFyeSB3b3JsZA=="; + + @Test + public void testElementUnknownLength() throws Exception + { + StringWriter out = new StringWriter(); + try (ToXmlGenerator gen = (ToXmlGenerator) MAPPER.createGenerator(out)) { + gen.setNextName(new QName("root")); + gen.writeStartObject(); + gen.writeFieldName("bin"); + assertEquals(DATA.length, + gen.writeBinary(Base64Variants.MIME, new ByteArrayInputStream(DATA), -1)); + gen.writeEndObject(); + } + assertEquals("" + ENCODED + "", removeSjsxpNamespace(out.toString())); + } + + @Test + public void testAttributeUnknownLength() throws Exception + { + StringWriter out = new StringWriter(); + try (ToXmlGenerator gen = (ToXmlGenerator) MAPPER.createGenerator(out)) { + gen.setNextName(new QName("root")); + gen.writeStartObject(); + gen.setNextIsAttribute(true); + gen.writeFieldName("bin"); + assertEquals(DATA.length, + gen.writeBinary(Base64Variants.MIME, new ByteArrayInputStream(DATA), -1)); + gen.writeEndObject(); + } + assertEquals("", removeSjsxpNamespace(out.toString())); + } + + @Test + public void testUnwrappedUnknownLength() throws Exception + { + StringWriter out = new StringWriter(); + try (ToXmlGenerator gen = (ToXmlGenerator) MAPPER.createGenerator(out)) { + gen.setNextName(new QName("root")); + gen.writeStartObject(); + gen.writeFieldName("bin"); + gen.setNextIsUnwrapped(true); + assertEquals(DATA.length, + gen.writeBinary(Base64Variants.MIME, new ByteArrayInputStream(DATA), -1)); + gen.writeEndObject(); + } + assertEquals("" + ENCODED + "", removeSjsxpNamespace(out.toString())); + } + + @Test + public void testPrettyPrintedUnknownLength() throws Exception + { + StringWriter out = new StringWriter(); + try (ToXmlGenerator gen = (ToXmlGenerator) MAPPER.writerWithDefaultPrettyPrinter() + .createGenerator(out)) { + gen.setNextName(new QName("root")); + gen.writeStartObject(); + gen.writeFieldName("bin"); + assertEquals(DATA.length, + gen.writeBinary(Base64Variants.MIME, new ByteArrayInputStream(DATA), -1)); + gen.writeEndObject(); + } + assertEquals("\n " + ENCODED + "\n\n", + removeSjsxpNamespace(out.toString())); + } + + // Empty stream should render the same whether length is given as 0 or unknown + @Test + public void testEmptyStream() throws Exception + { + final byte[] empty = new byte[0]; + assertEquals("", _writeElement(Base64Variants.MIME, empty, 0)); + assertEquals("", _writeElement(Base64Variants.MIME, empty, -1)); + assertEquals("", _writeAttribute(Base64Variants.MIME, empty, 0)); + assertEquals("", _writeAttribute(Base64Variants.MIME, empty, -1)); + } + + // Payload bigger than the recycled read buffer, and not a multiple of 3, so that + // partial triplets have to be carried over between reads + @Test + public void testLargePayloadUnknownLength() throws Exception + { + final byte[] big = new byte[7001]; + for (int i = 0; i < big.length; i++) { + big[i] = (byte) i; + } + // Use variant without line feeds: the Stax2 writer starts a new line counter + // for each chunk written, so with MIME only the line break positions would differ + final Base64Variant b64v = Base64Variants.MIME_NO_LINEFEEDS; + final String expected = _writeElement(b64v, big); + final String actual = _writeElement(b64v, big, -1); + assertEquals(expected, actual); + assertEquals(expected, _writeElement(b64v, big, big.length)); + assertEquals(_writeAttribute(b64v, big, big.length), _writeAttribute(b64v, big, -1)); + + // and finally, make sure it decodes back + String encoded = actual.substring("".length(), actual.length() - "".length()); + assertArrayEquals(big, b64v.decode(encoded)); + } + + @Test + public void testShortReadsUnknownLength() throws Exception + { + StringWriter out = new StringWriter(); + try (ToXmlGenerator gen = (ToXmlGenerator) MAPPER.createGenerator(out)) { + gen.setNextName(new QName("root")); + gen.writeStartObject(); + gen.writeFieldName("bin"); + assertEquals(DATA.length, gen.writeBinary(Base64Variants.MIME, + new ShortReadInputStream(new ByteArrayInputStream(DATA)), -1)); + gen.writeEndObject(); + } + assertEquals("" + ENCODED + "", removeSjsxpNamespace(out.toString())); + } + + /* + /********************************************************************** + /* Helper methods + /********************************************************************** + */ + + private String _writeElement(Base64Variant b64v, byte[] data) throws Exception + { + StringWriter out = new StringWriter(); + try (ToXmlGenerator gen = (ToXmlGenerator) MAPPER.createGenerator(out)) { + gen.setNextName(new QName("root")); + gen.writeStartObject(); + gen.writeFieldName("bin"); + gen.writeBinary(b64v, data, 0, data.length); + gen.writeEndObject(); + } + return removeSjsxpNamespace(out.toString()); + } + + private String _writeElement(Base64Variant b64v, byte[] data, int dataLength) throws Exception + { + StringWriter out = new StringWriter(); + try (ToXmlGenerator gen = (ToXmlGenerator) MAPPER.createGenerator(out)) { + gen.setNextName(new QName("root")); + gen.writeStartObject(); + gen.writeFieldName("bin"); + assertEquals(data.length, + gen.writeBinary(b64v, new ByteArrayInputStream(data), dataLength)); + gen.writeEndObject(); + } + return removeSjsxpNamespace(out.toString()); + } + + private String _writeAttribute(Base64Variant b64v, byte[] data, int dataLength) throws Exception + { + StringWriter out = new StringWriter(); + try (ToXmlGenerator gen = (ToXmlGenerator) MAPPER.createGenerator(out)) { + gen.setNextName(new QName("root")); + gen.writeStartObject(); + gen.setNextIsAttribute(true); + gen.writeFieldName("bin"); + assertEquals(data.length, + gen.writeBinary(b64v, new ByteArrayInputStream(data), dataLength)); + gen.writeEndObject(); + } + return removeSjsxpNamespace(out.toString()); + } +}