This is an automated email from the ASF dual-hosted git repository. gnodet pushed a commit to branch fix/11760-namespace-prefix-normalization in repository https://gitbox.apache.org/repos/asf/maven.git
commit f1a365a350c73b74176d05f884a00ca5c5a1fbd4 Author: Guillaume Nodet <[email protected]> AuthorDate: Mon Mar 23 11:12:01 2026 +0100 Normalize redundant namespace prefixes on XML attributes (fixes #11760) When a POM uses a namespace prefix that maps to the same URI as the element's default namespace (e.g., xmlns:mvn="http://maven.apache.org/POM/4.0.0"), attributes like mvn:combine.children are now normalized to their unprefixed form (combine.children) at read time. The redundant xmlns declaration is also removed. This fixes two issues: - combine.children/combine.self merge directives were silently ignored when using a namespace prefix, because the merge logic only looks up the unprefixed attribute name - The consumer POM transformation dropped the xmlns:mvn declaration but kept the prefixed attributes, producing invalid XML The write side is also improved to properly handle namespace declarations (xmlns:prefix) and prefixed attributes for foreign namespaces, ensuring valid XML output. Co-Authored-By: Claude Opus 4.6 <[email protected]> --- .../maven/internal/xml/DefaultXmlService.java | 48 +++- .../apache/maven/internal/xml/XmlNodeImplTest.java | 243 +++++++++++++++++++++ 2 files changed, 289 insertions(+), 2 deletions(-) diff --git a/impl/maven-xml/src/main/java/org/apache/maven/internal/xml/DefaultXmlService.java b/impl/maven-xml/src/main/java/org/apache/maven/internal/xml/DefaultXmlService.java index e97a221380..205d6f4061 100644 --- a/impl/maven-xml/src/main/java/org/apache/maven/internal/xml/DefaultXmlService.java +++ b/impl/maven-xml/src/main/java/org/apache/maven/internal/xml/DefaultXmlService.java @@ -106,11 +106,28 @@ private XmlNode doBuild(XMLStreamReader parser, boolean trim, InputLocationBuild String aValue = parser.getAttributeValue(i); String aPrefix = parser.getAttributePrefix(i); if (aPrefix != null && !aPrefix.isEmpty()) { - aName = aPrefix + ":" + aName; + // If the attribute's namespace matches the element's namespace, + // strip the prefix since it's redundant (e.g., mvn:combine.children + // where xmlns:mvn="http://maven.apache.org/POM/4.0.0" is the same + // as the element's default namespace) + String aNamespaceUri = parser.getAttributeNamespace(i); + if (aNamespaceUri == null + || !aNamespaceUri.equals(lNamespaceUri) + || lNamespaceUri.isEmpty()) { + aName = aPrefix + ":" + aName; + } } attrs.put(aName, aValue); spacePreserve = spacePreserve || ("xml:space".equals(aName) && "preserve".equals(aValue)); } + // Remove namespace declarations that are redundant with the element's + // default namespace (e.g., xmlns:mvn when mvn maps to the same URI) + if (lNamespaceUri != null && !lNamespaceUri.isEmpty()) { + String elementNsUri = lNamespaceUri; + attrs.entrySet() + .removeIf( + e -> e.getKey().startsWith("xmlns:") && elementNsUri.equals(e.getValue())); + } } } else { if (children == null) { @@ -162,8 +179,35 @@ public void doWrite(XmlNode node, Writer writer) throws IOException { private void writeNode(XMLStreamWriter xmlWriter, XmlNode node) throws XMLStreamException { xmlWriter.writeStartElement(node.prefix(), node.name(), node.namespaceUri()); + // Write namespace declarations first, then regular attributes + for (Map.Entry<String, String> attr : node.attributes().entrySet()) { + String key = attr.getKey(); + if ("xmlns".equals(key)) { + xmlWriter.writeDefaultNamespace(attr.getValue()); + } else if (key.startsWith("xmlns:")) { + xmlWriter.writeNamespace(key.substring(6), attr.getValue()); + } + } for (Map.Entry<String, String> attr : node.attributes().entrySet()) { - xmlWriter.writeAttribute(attr.getKey(), attr.getValue()); + String key = attr.getKey(); + String value = attr.getValue(); + if (key.startsWith("xmlns:") || "xmlns".equals(key)) { + continue; // already written above + } else if (key.startsWith("xml:")) { + xmlWriter.writeAttribute("http://www.w3.org/XML/1998/namespace", key.substring(4), value); + } else if (key.contains(":")) { + int colon = key.indexOf(':'); + String prefix = key.substring(0, colon); + String localName = key.substring(colon + 1); + String nsUri = node.attributes().get("xmlns:" + prefix); + if (nsUri != null) { + xmlWriter.writeAttribute(prefix, nsUri, localName, value); + } else { + xmlWriter.writeAttribute(key, value); + } + } else { + xmlWriter.writeAttribute(key, value); + } } for (XmlNode child : node.children()) { diff --git a/impl/maven-xml/src/test/java/org/apache/maven/internal/xml/XmlNodeImplTest.java b/impl/maven-xml/src/test/java/org/apache/maven/internal/xml/XmlNodeImplTest.java index e980517194..1c9a54404c 100644 --- a/impl/maven-xml/src/test/java/org/apache/maven/internal/xml/XmlNodeImplTest.java +++ b/impl/maven-xml/src/test/java/org/apache/maven/internal/xml/XmlNodeImplTest.java @@ -36,6 +36,7 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotSame; @@ -715,6 +716,248 @@ public Object toInputLocation(XMLStreamReader parser) { } } + /** + * Verifies that when a POM uses a namespace prefix that maps to the same URI + * as the element's default namespace (e.g., xmlns:mvn="http://maven.apache.org/POM/4.0.0"), + * the prefix is stripped from attributes at read time and the redundant namespace + * declaration is removed. This ensures combine.children/combine.self directives + * work correctly regardless of whether they use a prefix. + */ + @Test + void testNamespacePrefixNormalization() throws Exception { + String xml = """ + <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:mvn="http://maven.apache.org/POM/4.0.0"> + <configuration> + <compilerArgs mvn:combine.children="append"> + <arg>-Xlint:deprecation</arg> + </compilerArgs> + </configuration> + </project> + """; + + XmlNode node = toXmlNode(xml); + + // The xmlns:mvn declaration should be removed (same as default namespace) + assertNull(node.attribute("xmlns:mvn"), "Redundant xmlns:mvn should be removed"); + + // The mvn:combine.children attribute on <compilerArgs> should be normalized + XmlNode compilerArgs = node.child("configuration").child("compilerArgs"); + assertNotNull(compilerArgs); + assertEquals( + "append", + compilerArgs.attribute("combine.children"), + "mvn:combine.children should be normalized to combine.children"); + assertNull( + compilerArgs.attribute("mvn:combine.children"), + "mvn:combine.children should not remain as prefixed attribute"); + } + + /** + * Verifies that prefixed combine.children works correctly during merge + * when the prefix maps to the same namespace as the default. + */ + @Test + void testPrefixedCombineChildrenMerge() throws Exception { + String dominant = """ + <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:mvn="http://maven.apache.org/POM/4.0.0"> + <compilerArgs mvn:combine.children="append"> + <arg>-Xlint:deprecation</arg> + </compilerArgs> + </project> + """; + + String recessive = """ + <project xmlns="http://maven.apache.org/POM/4.0.0"> + <compilerArgs> + <arg>-Xlint:unchecked</arg> + </compilerArgs> + </project> + """; + + XmlNode dominantNode = toXmlNode(dominant); + XmlNode recessiveNode = toXmlNode(recessive); + XmlNode merged = XmlService.merge(dominantNode, recessiveNode); + + // With combine.children="append", both args should be present + XmlNode compilerArgs = merged.child("compilerArgs"); + assertNotNull(compilerArgs); + assertEquals(2, compilerArgs.children().size(), "Both args should be present after append merge"); + } + + /** + * Verifies that namespace prefixes mapping to a different namespace than the + * element's default are preserved (not stripped). + */ + @Test + void testForeignNamespacePrefixPreserved() throws Exception { + String xml = """ + <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:custom="http://example.com/custom"> + <compilerArgs custom:myattr="value"> + <arg>-Xlint:deprecation</arg> + </compilerArgs> + </project> + """; + + XmlNode node = toXmlNode(xml); + + // The xmlns:custom declaration should be preserved (different namespace) + assertEquals("http://example.com/custom", node.attribute("xmlns:custom")); + + // The custom:myattr attribute should be preserved with prefix + XmlNode compilerArgs = node.child("compilerArgs"); + assertNotNull(compilerArgs); + assertEquals("value", compilerArgs.attribute("custom:myattr")); + } + + /** + * Verifies that the write side properly handles namespace declarations + * and prefixed attributes, producing valid XML. + */ + @Test + void testWriteWithNamespaceAttributes() throws Exception { + String xml = """ + <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:custom="http://example.com/custom"> + <compilerArgs custom:myattr="value"> + <arg>-Xlint:deprecation</arg> + </compilerArgs> + </project> + """; + + XmlNode node = toXmlNode(xml); + + // Write and re-read to verify round-trip + java.io.StringWriter writer = new java.io.StringWriter(); + XmlService.write(node, writer); + String output = writer.toString(); + + // The output should be parseable XML + XmlNode reRead = toXmlNode(output); + assertNotNull(reRead); + + // The foreign namespace attribute should survive the round-trip + XmlNode compilerArgs = reRead.child("compilerArgs"); + assertNotNull(compilerArgs); + assertEquals("value", compilerArgs.attribute("custom:myattr")); + } + + /** + * Verifies that prefixed combine.self="override" works correctly when + * the prefix maps to the same namespace as the default. + */ + @Test + void testPrefixedCombineSelfMerge() throws Exception { + String dominant = """ + <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:mvn="http://maven.apache.org/POM/4.0.0"> + <configuration> + <item mvn:combine.self="override"> + <value>dominant</value> + </item> + </configuration> + </project> + """; + + String recessive = """ + <project xmlns="http://maven.apache.org/POM/4.0.0"> + <configuration> + <item> + <value>recessive</value> + <extra>should-be-dropped</extra> + </item> + </configuration> + </project> + """; + + XmlNode dominantNode = toXmlNode(dominant); + XmlNode recessiveNode = toXmlNode(recessive); + XmlNode merged = XmlService.merge(dominantNode, recessiveNode); + + XmlNode item = merged.child("configuration").child("item"); + assertNotNull(item); + assertEquals("dominant", item.child("value").value()); + assertNull(item.child("extra"), "combine.self=override should drop recessive children"); + } + + /** + * Verifies that xml:space="preserve" still works correctly after the + * namespace normalization changes (xml: prefix maps to a different namespace). + */ + @Test + void testXmlSpacePreservedAfterNormalization() throws Exception { + String xml = """ + <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:mvn="http://maven.apache.org/POM/4.0.0"> + <configuration> + <value xml:space="preserve"> spaces </value> + </configuration> + </project> + """; + + XmlNode node = toXmlNode(xml); + XmlNode value = node.child("configuration").child("value"); + assertNotNull(value); + assertEquals(" spaces ", value.value(), "xml:space=preserve should keep whitespace"); + assertEquals("preserve", value.attribute("xml:space"), "xml:space attribute should be preserved"); + } + + /** + * Verifies that namespace prefix normalization works in nested elements + * where the xmlns:mvn declaration is on a parent element. + */ + @Test + void testNestedNamespacePrefixNormalization() throws Exception { + String xml = """ + <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:mvn="http://maven.apache.org/POM/4.0.0"> + <configuration> + <plugins> + <plugin> + <compilerArgs mvn:combine.children="append"> + <arg>-Xlint:deprecation</arg> + </compilerArgs> + </plugin> + </plugins> + </configuration> + </project> + """; + + XmlNode node = toXmlNode(xml); + XmlNode compilerArgs = + node.child("configuration").child("plugins").child("plugin").child("compilerArgs"); + assertNotNull(compilerArgs); + assertEquals("append", compilerArgs.attribute("combine.children")); + assertNull(compilerArgs.attribute("mvn:combine.children")); + } + + /** + * Verifies that the normalized output round-trips correctly through + * write and re-read, producing the same XmlNode. + */ + @Test + void testNormalizedRoundTrip() throws Exception { + String xml = """ + <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:mvn="http://maven.apache.org/POM/4.0.0"> + <compilerArgs mvn:combine.children="append"> + <arg>-Xlint:deprecation</arg> + </compilerArgs> + </project> + """; + + XmlNode node = toXmlNode(xml); + + // Write and re-read + java.io.StringWriter writer = new java.io.StringWriter(); + XmlService.write(node, writer); + XmlNode reRead = toXmlNode(writer.toString()); + + // The normalized attribute should survive the round-trip + XmlNode compilerArgs = reRead.child("compilerArgs"); + assertNotNull(compilerArgs); + assertEquals("append", compilerArgs.attribute("combine.children")); + + // No xmlns:mvn or mvn: prefix should appear in the output + String output = writer.toString(); + assertFalse(output.contains("xmlns:mvn"), "Output should not contain redundant xmlns:mvn"); + assertFalse(output.contains("mvn:combine"), "Output should not contain mvn: prefixed attributes"); + } + public static Xpp3Dom build(Reader reader) throws XmlPullParserException, IOException { try (Reader closeMe = reader) { return new Xpp3Dom(XmlNodeBuilder.build(reader, true, null));
