Skip to content

Commit 4dfd6cd

Browse files
authored
Fix #247: add optional enforcement of root element name (#841)
1 parent 1c36fee commit 4dfd6cd

7 files changed

Lines changed: 293 additions & 57 deletions

File tree

release-notes/CREDITS

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ Christopher McVay (@mcvayc)
3939
* Fixed #306: Can not use `@JacksonXmlText` for Creator property (creator parameter)
4040
(3.2.0)
4141

42+
Leonard Meyer (@LeonardMeyer)
43+
* Reported #247: `@JacksonXmlRootElement` does not enforce the local name during
44+
deserialization (add `XmlReadFeature.ENFORCE_ROOT_ELEMENT_NAME`)
45+
(3.2.0)
46+
4247
Tobias M (@lightbringer)
4348
* Reported #248: `@JacksonXmlProperty` with attributes raises an exception
4449
when used with `@JsonIdentityInfo`

release-notes/VERSION

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ Version: 3.x (for earlier see VERSION-2.x)
1818
#192: Two wrapped lists with items of same name conflict
1919
(reported by @TeemuStenhammar)
2020
(fix by @cowtowncoder, w/ Claude code)
21+
#247: `@JacksonXmlRootElement` does not enforce the local name during
22+
deserialization (add `XmlReadFeature.ENFORCE_ROOT_ELEMENT_NAME`)
23+
(reported by Leonard M)
24+
(fix by @cowtowncoder, w/ Claude code)
2125
#248: `@JacksonXmlProperty` with attributes raises an exception when used
2226
with `@JsonIdentityInfo`
2327
(reported by Tobias M)

src/main/java/tools/jackson/dataformat/xml/XmlReadFeature.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,24 @@ public enum XmlReadFeature implements FormatFeature
4242
*/
4343
EMPTY_ELEMENT_AS_NULL(false),
4444

45+
/**
46+
* Feature that controls whether the name of the root XML element is
47+
* verified against the expected root name during deserialization. The expected
48+
* root name is determined from {@code @JsonRootName}, {@code @JacksonXmlRootElement},
49+
* or the simple class name of the target type (in that priority order).
50+
*<p>
51+
* When enabled, a mismatch between the actual root element name and the expected
52+
* name will result in a {@link tools.jackson.databind.exc.MismatchedInputException}.
53+
* When disabled (the default), any root element name is accepted.
54+
* Note that Fully-Qualified Names (FQN) comparison is used: that is, both local name
55+
* and namespace URI must match.
56+
*<p>
57+
* Default setting is {@code false} for backwards-compatibility.
58+
*
59+
* @since 3.2
60+
*/
61+
ENFORCE_ROOT_ELEMENT_NAME(false),
62+
4563
/**
4664
* Feature that indicates whether XML Schema Instance attribute
4765
* {@code xsi:nil} will be processed automatically -- to indicate {@code null}

src/main/java/tools/jackson/dataformat/xml/deser/XmlDeserializationContext.java

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package tools.jackson.dataformat.xml.deser;
22

3+
import javax.xml.namespace.QName;
4+
35
import tools.jackson.core.FormatSchema;
46
import tools.jackson.core.JacksonException;
57
import tools.jackson.core.TokenStreamFactory;
@@ -10,8 +12,11 @@
1012
import tools.jackson.databind.deser.DeserializationContextExt;
1113
import tools.jackson.databind.deser.DeserializerCache;
1214
import tools.jackson.databind.deser.DeserializerFactory;
15+
import tools.jackson.databind.util.ClassUtil;
1316
import tools.jackson.databind.util.TokenBuffer;
1417
import tools.jackson.dataformat.xml.XmlFactory;
18+
import tools.jackson.dataformat.xml.XmlReadFeature;
19+
import tools.jackson.dataformat.xml.util.XmlRootNameLookup;
1520

1621
/**
1722
* XML-specific {@link DeserializationContext} needed to override certain
@@ -22,26 +27,39 @@ public class XmlDeserializationContext
2227
{
2328
private final String _xmlTextElementName;
2429

30+
/**
31+
* @since 3.2
32+
*/
33+
protected final XmlRootNameLookup _rootNameLookup;
34+
2535
public XmlDeserializationContext(TokenStreamFactory tsf,
2636
DeserializerFactory deserializerFactory, DeserializerCache cache,
2737
DeserializationConfig config, FormatSchema schema,
28-
InjectableValues values) {
38+
InjectableValues values,
39+
XmlRootNameLookup rootNameLookup) {
2940
super(tsf, deserializerFactory, cache,
3041
config, schema, values);
3142
_xmlTextElementName = ((XmlFactory) tsf).getXMLTextElementName();
43+
_rootNameLookup = rootNameLookup;
3244
}
3345

3446
/*
35-
/**********************************************************
47+
/**********************************************************************
3648
/* Overrides we need
37-
/**********************************************************
49+
/**********************************************************************
3850
*/
3951

4052
@Override
4153
public Object readRootValue(JsonParser p, JavaType valueType,
4254
ValueDeserializer<Object> deser, Object valueToUpdate)
4355
throws JacksonException
4456
{
57+
// [dataformat-xml#247]: Verify root element name if feature enabled
58+
if (p instanceof FromXmlParser xp
59+
&& xp.isEnabled(XmlReadFeature.ENFORCE_ROOT_ELEMENT_NAME)) {
60+
_verifyRootElementName(xp, valueType);
61+
}
62+
4563
// 18-Sep-2021, tatu: Complicated mess; with 2.12, had [dataformat-xml#374]
4664
// to disable handling. With 2.13, via [dataformat-xml#485] undid this change
4765
if (_config.useRootWrapping()) {
@@ -95,4 +113,53 @@ public String extractScalarFromObject(JsonParser p, ValueDeserializer<?> deser,
95113
public TokenBuffer bufferForInputBuffering(JsonParser p) {
96114
return XmlTokenBuffer.xmlBufferForInputBuffering(p, this);
97115
}
116+
117+
/*
118+
/**********************************************************************
119+
/* Internal helper methods
120+
/**********************************************************************
121+
*/
122+
123+
/**
124+
* Helper method for [dataformat-xml#247]: verify that the root element name
125+
* matches the expected name when {@link XmlReadFeature#ENFORCE_ROOT_ELEMENT_NAME}
126+
* is enabled.
127+
*
128+
* @since 3.2
129+
*/
130+
protected void _verifyRootElementName(FromXmlParser xp, JavaType valueType)
131+
throws JacksonException
132+
{
133+
QName rootName = xp.getRootElementName();
134+
if (rootName == null) {
135+
return;
136+
}
137+
String actualName = rootName.getLocalPart();
138+
QName expectedQName = _rootNameLookup.findRootName(this, valueType);
139+
String expectedName = expectedQName.getLocalPart();
140+
141+
if (!expectedName.equals(actualName)) {
142+
reportPropertyInputMismatch(valueType, actualName,
143+
"Root name \"%s\" does not match expected (\"%s\") for type %s",
144+
actualName, expectedName, ClassUtil.getTypeDescription(valueType));
145+
}
146+
147+
// Also verify namespace URI: must match both ways (unexpected namespace
148+
// present, or expected namespace missing)
149+
String expectedNs = expectedQName.getNamespaceURI();
150+
String actualNs = rootName.getNamespaceURI();
151+
boolean expectedEmpty = (expectedNs == null || expectedNs.isEmpty());
152+
boolean actualEmpty = (actualNs == null || actualNs.isEmpty());
153+
154+
if (expectedEmpty != actualEmpty || (!expectedEmpty && !expectedNs.equals(actualNs))) {
155+
reportPropertyInputMismatch(valueType, actualName,
156+
"Root namespace \"%s\" does not match expected (\"%s\") for type %s",
157+
_nsDesc(actualNs), _nsDesc(expectedNs),
158+
ClassUtil.getTypeDescription(valueType));
159+
}
160+
}
161+
162+
private static String _nsDesc(String ns) {
163+
return (ns == null || ns.isEmpty()) ? "" : ns;
164+
}
98165
}

src/main/java/tools/jackson/dataformat/xml/deser/XmlDeserializationContexts.java

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,30 +8,40 @@
88
import tools.jackson.databind.deser.DeserializationContextExt;
99
import tools.jackson.databind.deser.DeserializerCache;
1010
import tools.jackson.databind.deser.DeserializerFactory;
11+
import tools.jackson.dataformat.xml.util.XmlRootNameLookup;
1112

1213
public class XmlDeserializationContexts
1314
extends DeserializationContexts
1415
{
1516
private static final long serialVersionUID = 3L;
1617

17-
public XmlDeserializationContexts() { super(); }
18-
public XmlDeserializationContexts(TokenStreamFactory tsf,
19-
DeserializerFactory serializerFactory, DeserializerCache cache) {
18+
protected final transient XmlRootNameLookup _rootNameLookup;
19+
20+
public XmlDeserializationContexts() {
21+
_rootNameLookup = null;
22+
}
23+
24+
protected XmlDeserializationContexts(TokenStreamFactory tsf,
25+
DeserializerFactory serializerFactory, DeserializerCache cache,
26+
XmlRootNameLookup roots) {
2027
super(tsf, serializerFactory, cache);
28+
_rootNameLookup = roots;
2129
}
2230

2331
@Override
2432
public DeserializationContexts forMapper(Object mapper,
2533
TokenStreamFactory tsf, DeserializerFactory serializerFactory,
2634
DeserializerCache cache) {
27-
return new XmlDeserializationContexts(tsf, serializerFactory, cache);
35+
return new XmlDeserializationContexts(tsf, serializerFactory, cache,
36+
new XmlRootNameLookup());
2837
}
2938

3039
@Override
3140
public DeserializationContextExt createContext(DeserializationConfig config,
3241
FormatSchema schema, InjectableValues injectables) {
3342
return new XmlDeserializationContext(_streamFactory,
3443
_deserializerFactory, _cache,
35-
config, schema, injectables);
44+
config, schema, injectables,
45+
_rootNameLookup);
3646
}
3747
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package tools.jackson.dataformat.xml.deser;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import com.fasterxml.jackson.annotation.JsonRootName;
6+
7+
import tools.jackson.databind.DatabindException;
8+
9+
import tools.jackson.dataformat.xml.XmlMapper;
10+
import tools.jackson.dataformat.xml.XmlReadFeature;
11+
import tools.jackson.dataformat.xml.XmlTestUtil;
12+
import tools.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
13+
14+
import static org.junit.jupiter.api.Assertions.*;
15+
16+
/**
17+
* Tests for [dataformat-xml#247]: verification of root element name
18+
* against expected (from annotation or class name) when
19+
* {@link XmlReadFeature#ENFORCE_ROOT_ELEMENT_NAME} is enabled.
20+
*/
21+
public class RootElementNameValidation247Test extends XmlTestUtil
22+
{
23+
static class Root {
24+
public int value;
25+
}
26+
27+
@JsonRootName("MyRoot")
28+
static class AnnotatedRoot {
29+
public int value;
30+
}
31+
32+
@JacksonXmlRootElement(localName = "XmlRoot")
33+
static class XmlAnnotatedRoot {
34+
public int value;
35+
}
36+
37+
@JacksonXmlRootElement(localName = "NsRoot", namespace = "http://example.com/test")
38+
static class NamespacedRoot {
39+
public int value;
40+
}
41+
42+
private final XmlMapper ENFORCING_MAPPER = XmlMapper.builder()
43+
.enable(XmlReadFeature.ENFORCE_ROOT_ELEMENT_NAME)
44+
.build();
45+
46+
private final XmlMapper DEFAULT_MAPPER = newMapper();
47+
48+
// Matching class name should pass
49+
@Test
50+
public void testMatchingClassNameSucceeds() throws Exception
51+
{
52+
Root root = ENFORCING_MAPPER.readValue(
53+
"<Root><value>42</value></Root>", Root.class);
54+
assertEquals(42, root.value);
55+
}
56+
57+
// Mismatched root name should fail when feature is enabled
58+
@Test
59+
public void testMismatchedNameFails() throws Exception
60+
{
61+
DatabindException e = assertThrows(DatabindException.class, () ->
62+
ENFORCING_MAPPER.readValue(
63+
"<Boot><value>42</value></Boot>", Root.class));
64+
verifyException(e, "Root name");
65+
verifyException(e, "Boot");
66+
verifyException(e, "Root");
67+
}
68+
69+
// Mismatched root name should succeed when feature is disabled (default)
70+
@Test
71+
public void testMismatchedNameSucceedsWhenDisabled() throws Exception
72+
{
73+
Root root = DEFAULT_MAPPER.readValue(
74+
"<Boot><value>42</value></Boot>", Root.class);
75+
assertEquals(42, root.value);
76+
}
77+
78+
// @JsonRootName annotation should be used for expected name
79+
@Test
80+
public void testJsonRootNameAnnotation() throws Exception
81+
{
82+
AnnotatedRoot root = ENFORCING_MAPPER.readValue(
83+
"<MyRoot><value>42</value></MyRoot>", AnnotatedRoot.class);
84+
assertEquals(42, root.value);
85+
}
86+
87+
@Test
88+
public void testJsonRootNameAnnotationMismatch() throws Exception
89+
{
90+
DatabindException e = assertThrows(DatabindException.class, () ->
91+
ENFORCING_MAPPER.readValue(
92+
"<AnnotatedRoot><value>42</value></AnnotatedRoot>",
93+
AnnotatedRoot.class));
94+
verifyException(e, "Root name");
95+
verifyException(e, "AnnotatedRoot");
96+
verifyException(e, "MyRoot");
97+
}
98+
99+
// @JacksonXmlRootElement annotation should be used for expected name
100+
@Test
101+
public void testJacksonXmlRootElementAnnotation() throws Exception
102+
{
103+
XmlAnnotatedRoot root = ENFORCING_MAPPER.readValue(
104+
"<XmlRoot><value>42</value></XmlRoot>", XmlAnnotatedRoot.class);
105+
assertEquals(42, root.value);
106+
}
107+
108+
@Test
109+
public void testJacksonXmlRootElementAnnotationMismatch() throws Exception
110+
{
111+
DatabindException e = assertThrows(DatabindException.class, () ->
112+
ENFORCING_MAPPER.readValue(
113+
"<WrongName><value>42</value></WrongName>",
114+
XmlAnnotatedRoot.class));
115+
verifyException(e, "Root name");
116+
verifyException(e, "WrongName");
117+
verifyException(e, "XmlRoot");
118+
}
119+
120+
// Empty element should also be validated
121+
@Test
122+
public void testMismatchedEmptyElement() throws Exception
123+
{
124+
assertThrows(DatabindException.class, () ->
125+
ENFORCING_MAPPER.readValue("<Wrong/>", Root.class));
126+
}
127+
128+
// Namespace URI verification: matching namespace should pass
129+
@Test
130+
public void testMatchingNamespaceSucceeds() throws Exception
131+
{
132+
NamespacedRoot root = ENFORCING_MAPPER.readValue(
133+
"<ns:NsRoot xmlns:ns=\"http://example.com/test\"><ns:value>42</ns:value></ns:NsRoot>",
134+
NamespacedRoot.class);
135+
assertEquals(42, root.value);
136+
}
137+
138+
// Namespace URI verification: wrong namespace should fail
139+
@Test
140+
public void testMismatchedNamespaceFails() throws Exception
141+
{
142+
DatabindException e = assertThrows(DatabindException.class, () ->
143+
ENFORCING_MAPPER.readValue(
144+
"<ns:NsRoot xmlns:ns=\"http://example.com/wrong\"><ns:value>42</ns:value></ns:NsRoot>",
145+
NamespacedRoot.class));
146+
verifyException(e, "Root namespace");
147+
verifyException(e, "http://example.com/wrong");
148+
verifyException(e, "http://example.com/test");
149+
}
150+
151+
// No namespace in XML when one is expected should fail
152+
@Test
153+
public void testMissingNamespaceFails() throws Exception
154+
{
155+
DatabindException e = assertThrows(DatabindException.class, () ->
156+
ENFORCING_MAPPER.readValue(
157+
"<NsRoot><value>42</value></NsRoot>",
158+
NamespacedRoot.class));
159+
verifyException(e, "Root namespace");
160+
}
161+
162+
// Unexpected namespace in XML when none is expected should fail
163+
@Test
164+
public void testUnexpectedNamespaceFails() throws Exception
165+
{
166+
DatabindException e = assertThrows(DatabindException.class, () ->
167+
ENFORCING_MAPPER.readValue(
168+
"<ns:Root xmlns:ns=\"http://example.com/unexpected\"><ns:value>42</ns:value></ns:Root>",
169+
Root.class));
170+
verifyException(e, "Root namespace");
171+
}
172+
173+
// No namespace expected, none present should pass
174+
@Test
175+
public void testNoNamespaceExpectedNonePresentSucceeds() throws Exception
176+
{
177+
Root root = ENFORCING_MAPPER.readValue(
178+
"<Root><value>42</value></Root>", Root.class);
179+
assertEquals(42, root.value);
180+
}
181+
}

0 commit comments

Comments
 (0)