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());
+ }
+}