This is an automated email from the ASF dual-hosted git repository. coheigea pushed a commit to branch coheigea/valid in repository https://gitbox.apache.org/repos/asf/ws-xmlschema.git
commit 5505d561cbe05f04ad0d2aa0e53a702ea32b3ac9 Author: Colm O hEigeartaigh <[email protected]> AuthorDate: Thu Sep 24 12:00:29 2026 +0100 Fix the path finder refusing or failing on common valid documents --- .../schema/docpath/XmlSchemaNamespaceContext.java | 13 +- .../schema/docpath/XmlSchemaPathFinder.java | 119 +++++++++++--- .../docpath/TestPathFinderValidDocuments.java | 177 +++++++++++++++++++++ .../docpath/TestXmlSchemaNamespaceContext.java | 13 ++ 4 files changed, 296 insertions(+), 26 deletions(-) diff --git a/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/docpath/XmlSchemaNamespaceContext.java b/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/docpath/XmlSchemaNamespaceContext.java index 012dc587..3edf9a91 100644 --- a/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/docpath/XmlSchemaNamespaceContext.java +++ b/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/docpath/XmlSchemaNamespaceContext.java @@ -137,18 +137,21 @@ public final class XmlSchemaNamespaceContext implements NamespacePrefixList { /** * Adds a new prefix mapping to the context. The prefix may be an empty * string to represent the default namespace, but it cannot be null. The - * namespace URI can never be empty or null. + * namespace URI cannot be null, and can only be empty for the default + * namespace, where it undeclares the default namespace for the scope of + * the mapping as <code>xmlns=""</code> does. * * @param prefix The prefix to represent the namespace URI. * @param namespaceUri the namespace URI represented by the prefix. * @throws IllegalArgumentException if the prefix is null, or if the - * namespace URI is null or empty. + * namespace URI is null, or empty for a non-empty prefix. */ public void addNamespace(String prefix, String namespaceUri) { - if ((prefix == null) || (namespaceUri == null) || (namespaceUri.length() == 0)) { + if ((prefix == null) || (namespaceUri == null) + || ((namespaceUri.length() == 0) && (prefix.length() > 0))) { - throw new IllegalArgumentException("The prefix may not be null, and the namespace URI " - + "may neither be null nor empty."); + throw new IllegalArgumentException("The prefix may not be null, and the namespace URI may" + + " not be null, nor empty unless the prefix is."); } else if (isRecognizedPrefix(prefix)) { // These are already mapped. diff --git a/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/docpath/XmlSchemaPathFinder.java b/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/docpath/XmlSchemaPathFinder.java index 0fa67992..a0cc239f 100644 --- a/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/docpath/XmlSchemaPathFinder.java +++ b/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/docpath/XmlSchemaPathFinder.java @@ -20,6 +20,7 @@ package org.apache.ws.commons.schema.docpath; import java.util.ArrayList; +import java.util.IdentityHashMap; import java.util.List; import java.util.Map; @@ -93,6 +94,22 @@ public final class XmlSchemaPathFinder<U, V> extends DefaultHandler { private ArrayList<QName> elementStack; private ArrayList<QName> anyStack; + /* + * The document node of the element most recently ended, until the next + * element starts. The current position can stay on an element after it + * ends, and a recursive type puts an element of the same name above it, + * so the name alone cannot say whether the current element is the one + * being ended. + */ + private XmlSchemaDocumentNode<U> endedElement; + + /* + * Whether a state machine node's content can match no elements at all, + * cached as the question is asked repeatedly for the same nodes. + */ + private final Map<XmlSchemaStateMachineNode, Boolean> emptyContentCache = + new IdentityHashMap<XmlSchemaStateMachineNode, Boolean>(); + private XmlSchemaPathManager<U, V> pathMgr; /* @@ -513,6 +530,7 @@ public final class XmlSchemaPathFinder<U, V> extends DefaultHandler { @Override public void startDocument() throws SAXException { currentPath = null; + endedElement = null; traversedElements.clear(); elementStack.clear(); @@ -1008,8 +1026,8 @@ public final class XmlSchemaPathFinder<U, V> extends DefaultHandler { return rootPathNode; } - private static <U, V> Fulfillment isPositionFulfilled(XmlSchemaPathNode<U, V> currentPath, - List<Integer> possiblePaths) { + private Fulfillment isPositionFulfilled(XmlSchemaPathNode<U, V> currentPath, + List<Integer> possiblePaths) { boolean completelyFulfilled = true; boolean partiallyFulfilled = true; @@ -1018,7 +1036,9 @@ public final class XmlSchemaPathFinder<U, V> extends DefaultHandler { if (currentPath.getDocumentNode() == null) { // This is the root node. It is not fulfilled. partiallyFulfilled = false; - } else if (currentPath.getDocIteration() >= state.getMinOccurs()) { + } else if ((currentPath.getDocIteration() >= state.getMinOccurs()) + || canMatchEmptyContent(state)) { + // The remaining occurrences, if any, can each match nothing. partiallyFulfilled = true; } else { partiallyFulfilled = false; @@ -1066,7 +1086,7 @@ public final class XmlSchemaPathFinder<U, V> extends DefaultHandler { if ((children != null) && children.containsKey(stateIndex)) { final XmlSchemaDocumentNode<U> child = children.get(stateIndex); final int iteration = child.getIteration(); - if (iteration >= nextState.getMinOccurs()) { + if ((iteration >= nextState.getMinOccurs()) || canMatchEmptyContent(nextState)) { groupPartiallyFulfilled = true; if (possiblePaths != null) { possiblePaths.clear(); @@ -1081,7 +1101,7 @@ public final class XmlSchemaPathFinder<U, V> extends DefaultHandler { possiblePaths.add(stateIndex); } } else { - if (nextState.getMinOccurs() == 0) { + if ((nextState.getMinOccurs() == 0) || canMatchEmptyContent(nextState)) { groupPartiallyFulfilled = true; } if (nextState.getMaxOccurs() == 0) { @@ -1139,7 +1159,8 @@ public final class XmlSchemaPathFinder<U, V> extends DefaultHandler { if ((children != null) && children.containsKey(stateIndex)) { final XmlSchemaDocumentNode<U> child = children.get(stateIndex); - if (child.getIteration() < nextState.getMinOccurs()) { + if ((child.getIteration() < nextState.getMinOccurs()) + && !canMatchEmptyContent(nextState)) { partiallyFulfilled = false; } if (child.getIteration() < nextState.getMaxOccurs()) { @@ -1149,7 +1170,7 @@ public final class XmlSchemaPathFinder<U, V> extends DefaultHandler { } } } else { - if (nextState.getMinOccurs() > 0) { + if ((nextState.getMinOccurs() > 0) && !canMatchEmptyContent(nextState)) { partiallyFulfilled = false; } if (nextState.getMaxOccurs() > 0) { @@ -1375,14 +1396,10 @@ public final class XmlSchemaPathFinder<U, V> extends DefaultHandler { return null; } - // If this is a group, confirm it has children. - if (!state.getNodeType().equals(XmlSchemaStateMachineNode.Type.ELEMENT) - && !state.getNodeType().equals(XmlSchemaStateMachineNode.Type.ANY) - && ((state.getPossibleNextStates() == null) || state.getPossibleNextStates().isEmpty())) { - - throw new IllegalStateException("Group " + state.getNodeType() - + " has no children. Found when processing " + elemQName); - } + /* + * A group with no children, such as an empty xs:sequence, is legal: + * it matches no elements, which the loops below find for themselves. + */ List<PathSegment<U, V>> choices = null; @@ -1433,7 +1450,12 @@ public final class XmlSchemaPathFinder<U, V> extends DefaultHandler { + startNode.getDocSequencePosition()); } - final boolean reachedMinOccurs = (nextPath.getDocIteration() >= nextPath.getMinOccurs()); + /* + * Content that can match nothing, such as a group of optional + * elements, satisfies its remaining occurrences without any. + */ + final boolean reachedMinOccurs = (nextPath.getDocIteration() >= nextPath.getMinOccurs()) + || canMatchEmptyContent(nextPath.getStateMachineNode()); final List<PathSegment<U, V>> seqPaths = find(nextPath, elemQName, currDepth + 1); @@ -1521,10 +1543,9 @@ public final class XmlSchemaPathFinder<U, V> extends DefaultHandler { * If the XmlSchemaAny namespace and processing rules apply, this * element matches. False otherwise. */ - if (traversedElements.size() < 2) { + if (traversedElements.isEmpty()) { throw new IllegalStateException("Reached a wildcard element while searching for " + elemQName - + ", but we've only seen " + traversedElements.size() - + " element(s)!"); + + ", but that is the root element!"); } final XmlSchemaAny any = state.getAny(); @@ -1557,7 +1578,7 @@ public final class XmlSchemaPathFinder<U, V> extends DefaultHandler { if ("##targetNamespace".equals(namespace)) { needTargetNamespace = true; - } else if ("##local".equals(namespace) && (elemQName.getNamespaceURI() == null)) { + } else if ("##local".equals(namespace) && (elemQName.getNamespaceURI().length() == 0)) { matches = true; @@ -1654,8 +1675,10 @@ public final class XmlSchemaPathFinder<U, V> extends DefaultHandler { XmlSchemaDocumentNode<U> iter = currentPath.getDocumentNode(); if (iter.getStateMachineNode().getNodeType().equals(XmlSchemaStateMachineNode.Type.ELEMENT) - && iter.getStateMachineNode().getElement().getQName().equals(element)) { + && iter.getStateMachineNode().getElement().getQName().equals(element) + && (iter != endedElement)) { // We are already at the element! + endedElement = iter; return; } @@ -1678,6 +1701,7 @@ public final class XmlSchemaPathFinder<U, V> extends DefaultHandler { + currentPath.getStateMachineNode() + ", which does not represent element " + element); } + endedElement = currentPath.getDocumentNode(); } private void followPath(PathSegment<U, V> path) { @@ -1699,6 +1723,59 @@ public final class XmlSchemaPathFinder<U, V> extends DefaultHandler { pathMgr.followPath(startNode); currentPath = path.getEnd(); + + // An element has started, so none is the one just ended. + endedElement = null; + } + + /* + * Whether a node's content can match no elements at all: an xs:sequence + * or xs:all whose particles can each occur zero times, or an xs:choice + * or substitution group with one such option. An element or wildcard + * always matches an element. Group nesting ends at elements, which this + * does not look inside, so the recursion is bounded by the walker's own + * limit on nested groups. + */ + private boolean canMatchEmptyContent(XmlSchemaStateMachineNode state) { + final Boolean cached = emptyContentCache.get(state); + if (cached != null) { + return cached.booleanValue(); + } + + boolean result; + final List<XmlSchemaStateMachineNode> children = state.getPossibleNextStates(); + + switch (state.getNodeType()) { + case SEQUENCE: + case ALL: + result = true; + if (children != null) { + for (XmlSchemaStateMachineNode child : children) { + if ((child.getMinOccurs() > 0) && !canMatchEmptyContent(child)) { + result = false; + break; + } + } + } + break; + case CHOICE: + case SUBSTITUTION_GROUP: + result = false; + if (children != null) { + for (XmlSchemaStateMachineNode child : children) { + if ((child.getMinOccurs() == 0) || canMatchEmptyContent(child)) { + result = true; + break; + } + } + } + break; + default: + result = false; + } + + emptyContentCache.put(state, Boolean.valueOf(result)); + return result; } /* diff --git a/xmlschema-walker/src/test/java/org/apache/ws/commons/schema/docpath/TestPathFinderValidDocuments.java b/xmlschema-walker/src/test/java/org/apache/ws/commons/schema/docpath/TestPathFinderValidDocuments.java new file mode 100644 index 00000000..e318d3e3 --- /dev/null +++ b/xmlschema-walker/src/test/java/org/apache/ws/commons/schema/docpath/TestPathFinderValidDocuments.java @@ -0,0 +1,177 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.ws.commons.schema.docpath; + +import java.io.StringReader; + +import javax.xml.namespace.QName; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; + +import org.apache.ws.commons.schema.XmlSchemaCollection; +import org.apache.ws.commons.schema.walker.XmlSchemaWalker; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.xml.sax.InputSource; + +/** + * Documents that are valid against their schema - each checked against the JDK's own validator - + * must be walked without error, and ones that are not must still be refused. + */ +public class TestPathFinderValidDocuments extends Assert { + + private DocumentBuilder docBuilder; + + @Before + public void setUp() throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + docBuilder = factory.newDocumentBuilder(); + } + + private void walk(String schemaBody, String xml) throws Exception { + XmlSchemaCollection collection = new XmlSchemaCollection(); + collection.read(new StringReader( + "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" xmlns:t=\"urn:t\"" + + " targetNamespace=\"urn:t\" elementFormDefault=\"qualified\">" + + schemaBody + "</xs:schema>")); + + XmlSchemaStateMachineGenerator generator = new XmlSchemaStateMachineGenerator(); + new XmlSchemaWalker(collection, generator) + .walk(collection.getElementByQName(new QName("urn:t", "root"))); + + XmlSchemaPathFinder<Object, Object> pathFinder = + new XmlSchemaPathFinder<Object, Object>(generator.getStartNode()); + new SaxWalkerOverDom(pathFinder) + .walk(docBuilder.parse(new InputSource(new StringReader(xml)))); + } + + private void assertRefused(String schemaBody, String xml) throws Exception { + try { + walk(schemaBody, xml); + } catch (RuntimeException expected) { + return; + } + fail("expected the document to be refused: " + xml); + } + + private static final String LOCAL_WILDCARD = + "<xs:element name=\"root\"><xs:complexType><xs:sequence>" + + "<xs:element name=\"a\" type=\"xs:string\"/>" + + "<xs:any namespace=\"##local\" processContents=\"skip\"/>" + + "</xs:sequence></xs:complexType></xs:element>"; + + /** + * xmlns="" undeclares the default namespace, which the namespace context used to refuse. + */ + @Test + public void testUndeclaredDefaultNamespace() throws Exception { + walk(LOCAL_WILDCARD, "<root xmlns=\"urn:t\"><a>x</a><x xmlns=\"\"/></root>"); + } + + @Test + public void testLocalWildcardMatchesAnElementInNoNamespace() throws Exception { + walk(LOCAL_WILDCARD, "<t:root xmlns:t=\"urn:t\"><t:a>x</t:a><x/></t:root>"); + } + + @Test + public void testLocalWildcardRefusesAnElementInANamespace() throws Exception { + assertRefused(LOCAL_WILDCARD, "<root xmlns=\"urn:t\"><a>x</a><x xmlns=\"urn:other\"/></root>"); + } + + @Test + public void testWildcardAsTheFirstChildOfTheRoot() throws Exception { + walk("<xs:element name=\"root\"><xs:complexType><xs:sequence>" + + "<xs:any processContents=\"skip\"/>" + + "</xs:sequence></xs:complexType></xs:element>", + "<root xmlns=\"urn:t\"><x/></root>"); + } + + private static final String EMPTY_BASE_EXTENDED = + "<xs:complexType name=\"base\"><xs:sequence/>" + + "<xs:attribute name=\"id\" type=\"xs:string\"/></xs:complexType>" + + "<xs:complexType name=\"derived\"><xs:complexContent><xs:extension base=\"t:base\">" + + "<xs:sequence><xs:element name=\"a\" type=\"xs:int\"/></xs:sequence>" + + "</xs:extension></xs:complexContent></xs:complexType>" + + "<xs:element name=\"root\" type=\"t:derived\"/>"; + + @Test + public void testEmptyBaseSequenceExtendedWithContent() throws Exception { + walk(EMPTY_BASE_EXTENDED, "<root xmlns=\"urn:t\" id=\"1\"><a>1</a></root>"); + } + + @Test + public void testEmptyBaseSequenceExtendedWithContentStillChecksIt() throws Exception { + assertRefused(EMPTY_BASE_EXTENDED, "<root xmlns=\"urn:t\" id=\"1\"><a>notint</a></root>"); + } + + /** + * A required group whose content can match nothing - here a base type whose elements are all + * optional - must not stop the document moving on to what follows it. + */ + @Test + public void testBaseOfOptionalElementsExtendedWithContent() throws Exception { + walk("<xs:complexType name=\"base\"><xs:sequence>" + + "<xs:element name=\"a\" type=\"xs:string\" minOccurs=\"0\"/>" + + "</xs:sequence></xs:complexType>" + + "<xs:complexType name=\"derived\"><xs:complexContent><xs:extension base=\"t:base\">" + + "<xs:sequence><xs:element name=\"b\" type=\"xs:string\"/></xs:sequence>" + + "</xs:extension></xs:complexContent></xs:complexType>" + + "<xs:element name=\"root\" type=\"t:derived\"/>", + "<root xmlns=\"urn:t\"><b>x</b></root>"); + } + + @Test + public void testChoiceWithAnEmptyOption() throws Exception { + String schema = "<xs:element name=\"root\"><xs:complexType><xs:sequence>" + + "<xs:choice><xs:sequence/><xs:element name=\"a\" type=\"xs:string\"/></xs:choice>" + + "<xs:element name=\"b\" type=\"xs:string\"/>" + + "</xs:sequence></xs:complexType></xs:element>"; + walk(schema, "<root xmlns=\"urn:t\"><b>x</b></root>"); + walk(schema, "<root xmlns=\"urn:t\"><a>x</a><b>x</b></root>"); + assertRefused(schema, "<root xmlns=\"urn:t\"><c>x</c></root>"); + } + + @Test + public void testChoiceWithAnOptionalOption() throws Exception { + walk("<xs:element name=\"root\"><xs:complexType><xs:sequence>" + + "<xs:choice><xs:element name=\"a\" type=\"xs:string\" minOccurs=\"0\"/>" + + "<xs:element name=\"c\" type=\"xs:string\"/></xs:choice>" + + "<xs:element name=\"b\" type=\"xs:string\"/>" + + "</xs:sequence></xs:complexType></xs:element>", + "<root xmlns=\"urn:t\"><b>x</b></root>"); + } + + /** + * Ending an element used to stop at the nearest element of the same name, which in a + * recursive type is the one just ended rather than its parent. + */ + @Test + public void testSelfRecursiveType() throws Exception { + String schema = "<xs:complexType name=\"T0\"><xs:sequence>" + + "<xs:element name=\"c\" type=\"t:T0\" minOccurs=\"0\" maxOccurs=\"3\"/>" + + "</xs:sequence></xs:complexType>" + + "<xs:element name=\"root\" type=\"t:T0\"/>"; + walk(schema, "<root xmlns=\"urn:t\"><c><c/></c></root>"); + walk(schema, "<root xmlns=\"urn:t\"><c><c><c/></c></c><c><c/></c><c/></root>"); + assertRefused(schema, "<root xmlns=\"urn:t\"><c/><c/><c/><c/></root>"); + } +} diff --git a/xmlschema-walker/src/test/java/org/apache/ws/commons/schema/docpath/TestXmlSchemaNamespaceContext.java b/xmlschema-walker/src/test/java/org/apache/ws/commons/schema/docpath/TestXmlSchemaNamespaceContext.java index 8f7b5222..a87ca7a2 100644 --- a/xmlschema-walker/src/test/java/org/apache/ws/commons/schema/docpath/TestXmlSchemaNamespaceContext.java +++ b/xmlschema-walker/src/test/java/org/apache/ws/commons/schema/docpath/TestXmlSchemaNamespaceContext.java @@ -188,6 +188,19 @@ public class TestXmlSchemaNamespaceContext { nsContext.addNamespace("avro", ""); } + /** + * xmlns="" undeclares the default namespace for the scope of the mapping. + */ + @Test + public void testUndeclareDefaultNamespace() { + XmlSchemaNamespaceContext nsContext = new XmlSchemaNamespaceContext(); + nsContext.addNamespace("", "urn:outer"); + nsContext.addNamespace("", ""); + assertEquals("", nsContext.getNamespaceURI("")); + nsContext.removeNamespace(""); + assertEquals("urn:outer", nsContext.getNamespaceURI("")); + } + @Test(expected = IllegalArgumentException.class) public void testGetPrefixesWithNullNamespace() { XmlSchemaNamespaceContext nsContext = new XmlSchemaNamespaceContext();
