Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions release-notes/CREDITS
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,7 @@ Christian Beikov (@beikov)
* Fixed #915: Retain constraints and stream features in
`XmlFactory.readResolve()`
(3.1.7)
* Fixed #918: Encode collection wrapper element name via configured
`XmlNameProcessor` (wrapper name written verbatim while item names encoded,
breaking round-trip / well-formedness)
(3.3.0)
2 changes: 2 additions & 0 deletions release-notes/VERSION
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ Version: 3.x (for earlier see VERSION-2.x)
#913: `XmlMapper.Builder.defaultUseWrapper()` changes mapper that builder was
created from (via `rebuild()`), or has already built
(fix by @Sahana2524)
#918: Encode collection wrapper element name via configured `XmlNameProcessor`
(fix by @Sahana2524)

3.2.3 (21-Sep-2026)

Expand Down
12 changes: 10 additions & 2 deletions src/main/java/tools/jackson/dataformat/xml/ser/ToXmlGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -561,12 +561,20 @@ public QName encodeContentName(String namespaceURI, String localName)
public void startWrappedValue(QName wrapperName, QName wrappedName) throws JacksonException
{
if (wrapperName != null) {
// The wrapper element is written directly here rather than via
// `writeName()`, so it must be run through the configured
// `XmlNameProcessor` explicitly -- otherwise the wrapper name is
// emitted verbatim while the wrapped item names (which do go through
// `writeName()`) are encoded, and the reader decodes every element
// name on read, breaking the round trip.
final QName encoded = encodeContentName(wrapperName.getNamespaceURI(),
wrapperName.getLocalPart());
try {
if (_xmlPrettyPrinter != null) {
_xmlPrettyPrinter.writeStartElement(_xmlWriter,
wrapperName.getNamespaceURI(), wrapperName.getLocalPart());
encoded.getNamespaceURI(), encoded.getLocalPart());
} else {
_xmlWriter.writeStartElement(wrapperName.getNamespaceURI(), wrapperName.getLocalPart());
_xmlWriter.writeStartElement(encoded.getNamespaceURI(), encoded.getLocalPart());
}
} catch (XMLStreamException e) {
StaxUtil.throwAsWriteException(e, this);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.annotation.JsonRootName;

import tools.jackson.core.TokenStreamLocation;
import tools.jackson.core.exc.StreamReadException;
import tools.jackson.dataformat.xml.*;
Expand Down Expand Up @@ -334,4 +336,75 @@ public void testReplacementNullFailsAtConstruction() throws Exception {
protected XmlFactory xmlFactory(XmlNameProcessor proc) {
return XmlFactory.builder().xmlNameProcessor(proc).build();
}

// [dataformat-xml] Collection wrapper element name has to go through the same
// processor as the wrapped item names. The wrapper is written directly (not via
// `writeName()`), so it used to be emitted verbatim while the items were encoded;
// the reader decodes every element name, so the round trip threw on read.
// Root element names are not (yet) passed through the processor, so with
// AlwaysOn the reader would try to decode the raw class name -- which fails for
// many names (e.g. any whose length % 4 == 1). Pin the root to a name that is
// already a valid encoding ("cm9vdA" == base64url("root")) so this test does not
// depend on the class name; see tofix/RootNameEncodingTest for the root-name gap.
@JsonRootName("cm9vdA")
public static class WrappedListDTO {
public List<String> values = new ArrayList<>();

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
return Objects.equals(values, ((WrappedListDTO) o).values);
}

@Override
public int hashCode() { return Objects.hash(values); }
}

@Test
public void testAlwaysOnBase64WrappedCollectionRoundTrip() throws Exception {
WrappedListDTO dto = new WrappedListDTO();
dto.values.add("a");
dto.values.add("b");

XmlMapper mapper = XmlMapper.builder(
xmlFactory(XmlNameProcessors.newAlwaysOnBase64Processor())
).build();

final String res = mapper.writeValueAsString(dto);
assertTrue(res.startsWith("<cm9vdA>"), res);
// wrapper and item share the name "values", so both must be encoded the same
assertTrue(res.contains("<dmFsdWVz><dmFsdWVz>a</dmFsdWVz>"), res);

WrappedListDTO reversed = mapper.readValue(res, WrappedListDTO.class);
assertEquals(dto, reversed);
}

public static class InvalidWrapperNameDTO {
@tools.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper(localName = "we ird")
@JacksonXmlProperty(localName = "it em")
public List<String> vals = new ArrayList<>();
}

@Test
public void testReplacementInvalidWrapperNameStaysWellFormed() throws Exception {
InvalidWrapperNameDTO dto = new InvalidWrapperNameDTO();
dto.vals.add("a");

XmlMapper mapper = XmlMapper.builder(
xmlFactory(XmlNameProcessors.newReplacementProcessor())
).build();

final String res = mapper.writeValueAsString(dto);
// the space (invalid in an XML name) must have been replaced in the wrapper too,
// the same way it already was for the item name
assertTrue(res.contains("<we_ird>"), res);
assertTrue(res.contains("<it_em>a</it_em>"), res);
assertTrue(res.indexOf("we ird") < 0, res);

// ReplacementProcessor is not a reversible transform, so we do not assert a
// value round-trip here; the guarantee that broke was well-formedness -- the
// output must parse back without a "space in element name" error
assertNotNull(new XmlMapper().readTree(res));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package tools.jackson.dataformat.xml.tofix;

import java.util.*;

import org.junit.jupiter.api.Test;

import tools.jackson.dataformat.xml.*;
import tools.jackson.dataformat.xml.testutil.failure.JacksonTestFailureExpected;

import static org.junit.jupiter.api.Assertions.assertEquals;

// Root element name (class name, `@JsonRootName`, `ObjectWriter.withRootName()`)
// is written without going through the configured `XmlNameProcessor`, unlike
// property and (since #918) collection wrapper names. With the AlwaysOn base64
// processor the reader decodes every element name, so the round trip fails for
// root names that are not valid base64url (e.g. length % 4 == 1).
public class RootNameEncodingTest extends XmlTestUtil
{
public static class Point {
public List<String> values = new ArrayList<>();
}

@JacksonTestFailureExpected
@Test
public void testAlwaysOnBase64RootNameRoundTrip() throws Exception {
Point p = new Point();
p.values.add("a");

XmlMapper mapper = XmlMapper.builder(
XmlFactory.builder()
.xmlNameProcessor(XmlNameProcessors.newAlwaysOnBase64Processor())
.build()
).build();

final String res = mapper.writeValueAsString(p);
Point result = mapper.readValue(res, Point.class);
assertEquals(p.values, result.values);
}
}
Loading