diff --git a/release-notes/CREDITS b/release-notes/CREDITS index 2c1b2af5..c6457589 100644 --- a/release-notes/CREDITS +++ b/release-notes/CREDITS @@ -205,6 +205,10 @@ Christian Beikov (@beikov) `XmlBeanSerializerBase` (wrong attribute/text/CDATA handling after `@JsonIgnoreProperties`) (3.3.0) + * Fixed #909: Clear forced `xsi:type` attribute state in + `ToXmlGenerator.writeName()` (`Map`/`JsonNode` key `xsi:type` leaked + attribute mode onto following siblings) + (3.3.0) * Fixed #911: Verify Stax factory type before instantiating in `XmlFactory.readResolve()` (3.1.7) diff --git a/release-notes/VERSION b/release-notes/VERSION index 57fbc326..c98588b9 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -44,6 +44,9 @@ Version: 3.x (for earlier see VERSION-2.x) #907: Recompute XML metadata for filtered properties in `XmlBeanSerializerBase` (wrong attribute/text/CDATA handling after `@JsonIgnoreProperties`) (fix by @Sahana2524) +#909: Clear forced `xsi:type` attribute state in `ToXmlGenerator.writeName()` + (`Map`/`JsonNode` key `xsi:type` leaked attribute mode onto following siblings) + (fix by @Sahana2524) 3.2.3 (21-Sep-2026) diff --git a/src/main/java/tools/jackson/dataformat/xml/ser/ToXmlGenerator.java b/src/main/java/tools/jackson/dataformat/xml/ser/ToXmlGenerator.java index 1b2610d4..cb11bdd1 100644 --- a/src/main/java/tools/jackson/dataformat/xml/ser/ToXmlGenerator.java +++ b/src/main/java/tools/jackson/dataformat/xml/ser/ToXmlGenerator.java @@ -192,6 +192,17 @@ public class ToXmlGenerator */ protected boolean _nextIsCData = false; + /** + * Marker set by {@link #writeName(String)} when it forces attribute mode + * and the XSI namespace onto the next value to emit a synthetic + * {@code xsi:type} attribute. Bean serializers clear it by assigning the + * following name via {@link #setNextName}; for content-driven names + * (Map / JsonNode / {@code @JsonAnyGetter}) nothing else does, so the next + * {@link #writeName} clears it to keep the forced state from leaking onto a + * following sibling. + */ + protected boolean _nextIsXsiType = false; + /** * To support proper serialization of arrays it is necessary to keep * stack of element names, so that we can "revert" to earlier @@ -502,6 +513,8 @@ public void setNextIsCData(boolean isCData) public final void setNextName(QName name) { _nextName = name; + // Caller is taking over naming, so drop any pending xsi:type forcing + _nextIsXsiType = false; } /** @@ -610,12 +623,28 @@ public JsonGenerator writeName(String name) throws JacksonException _reportError("Can not write a property name, expecting a value"); } + // A preceding synthetic "xsi:type" name forces attribute mode and the XSI + // namespace onto _nextName so the type-id value can be written as an + // attribute. Bean serializers reset that by assigning the next name via + // setNextName(); content-driven names (Map/JsonNode/@JsonAnyGetter) do not, + // so clear it here. Otherwise the following sibling inherits attribute-ness + // and the XSI namespace, producing xsi:-prefixed attributes (or a duplicate + // xsi:type) that this module can no longer read back. + // Revert to enclosing element name (if any) so the following sibling inherits + // its namespace, same as it would without the preceding "xsi:type". + if (_nextIsXsiType) { + _nextIsXsiType = false; + _nextIsAttribute = false; + _nextName = _elementNameStack.peekLast(); + } + // 30-Jan-2024, tatu: Surprise! if (XmlWriteFeature.AUTO_DETECT_XSI_TYPE.enabledIn(_formatFeatures) && "xsi:type".equals(name)) { setNextName(new QName(XMLConstants.W3C_XML_SCHEMA_INSTANCE_NS_URI, "type", "xsi")); setNextIsAttribute(true); + _nextIsXsiType = true; } else if (name.equals(_cfgNameForTextElement)) { // [dataformat-xml#629]: Name matching the "unnamed text property" marker // (FromXmlParser.DEFAULT_UNNAMED_TEXT_PROPERTY, default "") represents diff --git a/src/test/java/tools/jackson/dataformat/xml/ser/XsiTypeWriteTest.java b/src/test/java/tools/jackson/dataformat/xml/ser/XsiTypeWriteTest.java index c12d2915..bb1a2613 100644 --- a/src/test/java/tools/jackson/dataformat/xml/ser/XsiTypeWriteTest.java +++ b/src/test/java/tools/jackson/dataformat/xml/ser/XsiTypeWriteTest.java @@ -1,14 +1,20 @@ package tools.jackson.dataformat.xml.ser; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + import org.junit.jupiter.api.Test; import com.fasterxml.jackson.annotation.*; import com.fasterxml.jackson.annotation.JsonTypeInfo.As; import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import tools.jackson.databind.node.ObjectNode; import tools.jackson.dataformat.xml.XmlMapper; import tools.jackson.dataformat.xml.XmlTestUtil; import tools.jackson.dataformat.xml.XmlWriteFeature; +import tools.jackson.dataformat.xml.annotation.JacksonXmlProperty; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -27,6 +33,11 @@ static class PolyBean { public int value = 42; } + static class NsMapBean { + @JacksonXmlProperty(namespace = "urn:x") + public Map map = new LinkedHashMap<>(); + } + private final XmlMapper NO_XSI_MAPPER = XmlMapper.builder() .configure(XmlWriteFeature.AUTO_DETECT_XSI_TYPE, false) .build(); @@ -68,4 +79,80 @@ public void testXsiTypeAsTypeIdWriteEnabled() throws Exception +"42"), a2q(XSI_ENABLED_MAPPER.writeValueAsString(new PolyBean()))); } + + // Content-driven names (Map/JsonNode) that happen to include an "xsi:type" + // key must not leave following siblings in attribute mode / the XSI namespace. + // Before the fix the forced state leaked, so siblings became xsi:-prefixed + // attributes -- and a colliding "type" key produced a duplicate xsi:type + // attribute, i.e. XML this module could no longer read back. + @Test + public void testXsiTypeKeyDoesNotLeakOntoSibling() throws Exception + { + XmlMapper mapper = newMapper(); + ObjectNode tree = mapper.createObjectNode(); + tree.put("xsi:type", "A"); + tree.put("type", "B"); + String xml = mapper.writeValueAsString(tree); + assertEquals( + a2q("B"), + a2q(xml)); + // and the emitted document must round-trip through the same module + assertEquals(tree, mapper.readTree(xml)); + } + + @Test + public void testXsiTypeKeyFollowedByNilKey() throws Exception + { + XmlMapper mapper = newMapper(); + ObjectNode tree = mapper.createObjectNode(); + tree.put("xsi:type", "T"); + tree.put("nil", "true"); + String xml = mapper.writeValueAsString(tree); + assertEquals( + a2q("true"), + a2q(xml)); + // previously the leaked attribute mode emitted xsi:nil='true', collapsing + // the whole document to null on read + assertEquals(tree, mapper.readTree(xml)); + } + + // Same for a plain Map; repeated (List-valued) sibling used to become + // duplicate "xsi:list" attributes + @Test + public void testXsiTypeMapKeyFollowedByList() throws Exception + { + XmlMapper mapper = newMapper(); + Map map = new LinkedHashMap<>(); + map.put("xsi:type", "T"); + map.put("list", List.of(1, 2)); + assertEquals( + a2q("12"), + a2q(mapper.writeValueAsString(map))); + } + + // Sibling following "xsi:type" must still inherit enclosing element's namespace, + // same as it would without the "xsi:type" key + @Test + public void testXsiTypeMapKeyKeepsEnclosingNamespace() throws Exception + { + XmlMapper mapper = newMapper(); + NsMapBean bean = new NsMapBean(); + bean.map.put("b", 2); + assertEquals( + a2q("" + +"2"), + a2q(mapper.writeValueAsString(bean))); + + bean = new NsMapBean(); + bean.map.put("xsi:type", "T"); + bean.map.put("b", 2); + assertEquals( + a2q("" + +"2"), + a2q(mapper.writeValueAsString(bean))); + } }