This is an automated email from the ASF dual-hosted git repository. garydgregory pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/commons-xml.git
commit cd2a7a4087fe448372acfc92d2e6094cd9c0e417 Author: Gary Gregory <[email protected]> AuthorDate: Sun Aug 30 07:53:33 2026 -0400 Add missing unit tests. - Total coverage is now 75% (70% branches). - Sort members --- .../apache/commons/xml/MethodHandleFactory.java | 2 +- .../commons/xml/SecureTransformerFactory.java | 64 +-- .../org/apache/commons/xml/SecureXMLFilter.java | 38 +- .../commons/xml/MethodHandleFactoryTest.java | 74 +++ .../commons/xml/SecureXMLInputFactoryTest.java | 622 +++++++++++++++++++++ .../java/org/apache/commons/xml/XMLFilterTest.java | 16 +- 6 files changed, 756 insertions(+), 60 deletions(-) diff --git a/src/main/java/org/apache/commons/xml/MethodHandleFactory.java b/src/main/java/org/apache/commons/xml/MethodHandleFactory.java index 671819d..1468d0c 100644 --- a/src/main/java/org/apache/commons/xml/MethodHandleFactory.java +++ b/src/main/java/org/apache/commons/xml/MethodHandleFactory.java @@ -54,7 +54,7 @@ static <T, E extends Throwable> T invokeExact(final ThrowableCallable<T> methodH try { return methodHandle.call(); } catch (final Throwable e) { - if (e.getClass().isInstance(rethrow)) { + if (rethrow.isInstance(e)) { throw rethrow.cast(e); } // Unreachable: the looked-up method declares no other exceptions. diff --git a/src/main/java/org/apache/commons/xml/SecureTransformerFactory.java b/src/main/java/org/apache/commons/xml/SecureTransformerFactory.java index f6db179..4e5b98c 100644 --- a/src/main/java/org/apache/commons/xml/SecureTransformerFactory.java +++ b/src/main/java/org/apache/commons/xml/SecureTransformerFactory.java @@ -214,38 +214,6 @@ public URIResolver getURIResolver() { return floor.getDelegate(); } - private TransformerHandler secure(final TransformerHandler handler) { - return handler == null ? null : new SecureTransformerHandler(handler, getURIResolver(), emptySource, overrideDefaultParser()); - } - - /** - * Parses a reader-less source into a DOM through a secure, namespace-aware {@link javax.xml.parsers.DocumentBuilder} and returns a {@link DOMSource} - * carrying its system id, so the consumer walks the tree instead of provisioning its own reader. Any other source is left to - * {@link SecureSAXParserFactory#secure(Source, boolean)}. - * - * @param source The source to scan for an associated stylesheet. - * @return A {@link DOMSource} for a reader-less source, otherwise the result of {@link SecureSAXParserFactory#secure(Source, boolean)}. - * @throws TransformerConfigurationException if the source cannot be parsed. - * @throws FactoryConfigurationError Thrown from a factory in case of a {@link java.util.ServiceConfigurationError service - * configuration error} or if the implementation is not available or cannot be instantiated. - * @throws SecureException Thrown if a (non-Android) factory cannot support the secure processing feature {@link XMLConstants#FEATURE_SECURE_PROCESSING}. - */ - private Source secureSourceToDom(final Source source) throws TransformerConfigurationException { - if (source instanceof StreamSource || source instanceof SAXSource && ((SAXSource) source).getXMLReader() == null) { - final InputSource inputSource = SAXSource.sourceToInputSource(source); - if (inputSource != null) { - try { - final DocumentBuilderFactory factory = SecureDocumentBuilderFactory.newNSInstance(overrideDefaultParser()); - final Document document = factory.newDocumentBuilder().parse(inputSource); - return new DOMSource(document, inputSource.getSystemId()); - } catch (final ParserConfigurationException | SAXException | IOException e) { - throw new TransformerConfigurationException("Failed to parse the source for associated-stylesheet lookup", e); - } - } - } - return SecureSAXParserFactory.secure(source, overrideDefaultParser()); - } - /** * {@inheritDoc} * @@ -335,6 +303,38 @@ private boolean overrideDefaultParser() { return !supportsOverrideDefaultParser || delegate.getFeature(SecureSAXParserFactory.OVERRIDE_DEFAULT_PARSER); } + private TransformerHandler secure(final TransformerHandler handler) { + return handler == null ? null : new SecureTransformerHandler(handler, getURIResolver(), emptySource, overrideDefaultParser()); + } + + /** + * Parses a reader-less source into a DOM through a secure, namespace-aware {@link javax.xml.parsers.DocumentBuilder} and returns a {@link DOMSource} + * carrying its system id, so the consumer walks the tree instead of provisioning its own reader. Any other source is left to + * {@link SecureSAXParserFactory#secure(Source, boolean)}. + * + * @param source The source to scan for an associated stylesheet. + * @return A {@link DOMSource} for a reader-less source, otherwise the result of {@link SecureSAXParserFactory#secure(Source, boolean)}. + * @throws TransformerConfigurationException if the source cannot be parsed. + * @throws FactoryConfigurationError Thrown from a factory in case of a {@link java.util.ServiceConfigurationError service + * configuration error} or if the implementation is not available or cannot be instantiated. + * @throws SecureException Thrown if a (non-Android) factory cannot support the secure processing feature {@link XMLConstants#FEATURE_SECURE_PROCESSING}. + */ + private Source secureSourceToDom(final Source source) throws TransformerConfigurationException { + if (source instanceof StreamSource || source instanceof SAXSource && ((SAXSource) source).getXMLReader() == null) { + final InputSource inputSource = SAXSource.sourceToInputSource(source); + if (inputSource != null) { + try { + final DocumentBuilderFactory factory = SecureDocumentBuilderFactory.newNSInstance(overrideDefaultParser()); + final Document document = factory.newDocumentBuilder().parse(inputSource); + return new DOMSource(document, inputSource.getSystemId()); + } catch (final ParserConfigurationException | SAXException | IOException e) { + throw new TransformerConfigurationException("Failed to parse the source for associated-stylesheet lookup", e); + } + } + } + return SecureSAXParserFactory.secure(source, overrideDefaultParser()); + } + @Override public void setAttribute(final String name, final Object value) { delegate.setAttribute(name, value); diff --git a/src/main/java/org/apache/commons/xml/SecureXMLFilter.java b/src/main/java/org/apache/commons/xml/SecureXMLFilter.java index 87e5e33..42a45f9 100644 --- a/src/main/java/org/apache/commons/xml/SecureXMLFilter.java +++ b/src/main/java/org/apache/commons/xml/SecureXMLFilter.java @@ -49,6 +49,25 @@ */ final class SecureXMLFilter extends XMLFilterImpl implements ErrorListener { + /** + * Bridges a TrAX report to the SAX callback shape. + * + * @param e the reported exception. + * @return The original {@link SAXParseException} where one is the cause, otherwise a synthetic one carrying the locator. + */ + private static SAXParseException toSAXParseException(final TransformerException e) { + final Throwable cause = e.getCause(); + if (cause instanceof SAXParseException) { + return (SAXParseException) cause; + } + // Embed the cause rather than the TrAX wrapper, so the originating exception stays directly reachable in the reported chain. + final Exception embedded = cause instanceof Exception ? (Exception) cause : e; + final SourceLocator locator = e.getLocator(); + return locator == null + ? new SAXParseException(e.getMessage(), null, null, -1, -1, embedded) + : new SAXParseException(e.getMessage(), locator.getPublicId(), locator.getSystemId(), locator.getLineNumber(), locator.getColumnNumber(), embedded); + } + private final SecureTemplates templates; /** @@ -132,25 +151,6 @@ public void parse(final InputSource input) throws SAXException, IOException { } } - /** - * Bridges a TrAX report to the SAX callback shape. - * - * @param e the reported exception. - * @return The original {@link SAXParseException} where one is the cause, otherwise a synthetic one carrying the locator. - */ - private static SAXParseException toSAXParseException(final TransformerException e) { - final Throwable cause = e.getCause(); - if (cause instanceof SAXParseException) { - return (SAXParseException) cause; - } - // Embed the cause rather than the TrAX wrapper, so the originating exception stays directly reachable in the reported chain. - final Exception embedded = cause instanceof Exception ? (Exception) cause : e; - final SourceLocator locator = e.getLocator(); - return locator == null - ? new SAXParseException(e.getMessage(), null, null, -1, -1, embedded) - : new SAXParseException(e.getMessage(), locator.getPublicId(), locator.getSystemId(), locator.getLineNumber(), locator.getColumnNumber(), embedded); - } - /** Forwards a transformation warning to the caller-set {@link org.xml.sax.ErrorHandler}; the transformation continues unless that handler throws. */ @Override public void warning(final TransformerException e) throws TransformerException { diff --git a/src/test/java/org/apache/commons/xml/MethodHandleFactoryTest.java b/src/test/java/org/apache/commons/xml/MethodHandleFactoryTest.java new file mode 100644 index 0000000..bb934e4 --- /dev/null +++ b/src/test/java/org/apache/commons/xml/MethodHandleFactoryTest.java @@ -0,0 +1,74 @@ +/* + * 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 + * + * https://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.commons.xml; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodType; + +import javax.xml.parsers.FactoryConfigurationError; +import javax.xml.stream.XMLInputFactory; + +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link MethodHandleFactory}, the reflective lookup helper behind {@link SecureXMLInputFactory#newDefaultFactory()}. + * <p> + * The helper is exercised directly, including the miss and rethrow paths that no standard JVM factory lookup reaches, so the platform-specific fallback logic + * of the secure factory has full branch coverage. + * </p> + */ +class MethodHandleFactoryTest { + + @Test + void findStaticReturnsHandleForExistingMethod() { + final MethodHandle handle = MethodHandleFactory.findStatic(XMLInputFactory.class, "newDefaultFactory", MethodType.methodType(XMLInputFactory.class)); + assertNotNull(handle, "an existing static method must resolve to a handle"); + } + + @Test + void findStaticReturnsNullForMissingMethod() { + assertNull(MethodHandleFactory.findStatic(XMLInputFactory.class, "noSuchMethod", MethodType.methodType(void.class)), + "a missing method must resolve to null"); + } + + @Test + void invokeExactRethrowsDeclaredException() { + assertThrows(FactoryConfigurationError.class, () -> MethodHandleFactory.invokeExact(() -> { + throw new FactoryConfigurationError("boom"); + }, FactoryConfigurationError.class), "an exception of the declared type must be rethrown"); + } + + @Test + void invokeExactReturnsValue() { + assertEquals("value", MethodHandleFactory.invokeExact(() -> "value", IllegalStateException.class), "the callable's value must be returned"); + } + + @Test + void invokeExactWrapsUnexpectedException() { + final IllegalStateException wrapped = assertThrows(IllegalStateException.class, () -> MethodHandleFactory.invokeExact(() -> { + throw new IllegalArgumentException("boom"); + }, FactoryConfigurationError.class), "an undeclared exception must be wrapped"); + assertInstanceOf(IllegalArgumentException.class, wrapped.getCause(), "the unexpected exception must be the wrapper's cause"); + } +} diff --git a/src/test/java/org/apache/commons/xml/SecureXMLInputFactoryTest.java b/src/test/java/org/apache/commons/xml/SecureXMLInputFactoryTest.java new file mode 100644 index 0000000..82988dc --- /dev/null +++ b/src/test/java/org/apache/commons/xml/SecureXMLInputFactoryTest.java @@ -0,0 +1,622 @@ +/* + * 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 + * + * https://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.commons.xml; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.Reader; +import java.io.StringReader; +import java.lang.reflect.Constructor; +import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import javax.xml.stream.EventFilter; +import javax.xml.stream.StreamFilter; +import javax.xml.stream.XMLEventReader; +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLReporter; +import javax.xml.stream.XMLResolver; +import javax.xml.stream.XMLStreamConstants; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamReader; +import javax.xml.stream.events.XMLEvent; +import javax.xml.stream.util.XMLEventAllocator; +import javax.xml.transform.Source; +import javax.xml.transform.stream.StreamSource; + +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link SecureXMLInputFactory} and the {@link XMLInputFactory} wrapper it installs. + * <p> + * The wrapper's delegation and resolver-routing logic is exercised against a recording stand-in factory, so every branch is deterministic on every platform. + * The public factory methods are additionally exercised end-to-end against the platform's real implementation, including the Woodstox-specific resolver hooks + * where that implementation is present. + * </p> + */ +@Tag("stax") +class SecureXMLInputFactoryTest { + + /** + * A recording stand-in {@link XMLInputFactory} for the delegation tests. + * <p> + * Every call is recorded in {@link #calls} together with the runtime class and identity hash code of each argument, so the wrapper can be asserted to + * forward the caller's exact arguments. The resolver hook the fake reports through {@code getProperty} and {@code getXMLResolver} is whatever the wrapper + * or a test last installed, so the tests can steer the wrapper into each routing branch deterministically. + * </p> + */ + private static final class RecordingXMLInputFactory extends XMLInputFactory { + + /** The {@link XMLEventReader} every event-flavor creation method returns. */ + static final XMLEventReader EVENT_SENTINEL = proxy(XMLEventReader.class); + + /** The {@link XMLStreamReader} every stream-flavor creation method returns. */ + static final XMLStreamReader STREAM_SENTINEL = proxy(XMLStreamReader.class); + + /** A stand-in {@link XMLEventAllocator} for the round-trip tests. */ + static final XMLEventAllocator ALLOCATOR_SENTINEL = proxy(XMLEventAllocator.class); + + /** A stand-in {@link XMLReporter} for the round-trip tests. */ + static final XMLReporter REPORTER_SENTINEL = proxy(XMLReporter.class); + + /** A stand-in {@link EventFilter} for the delegation tests. */ + static final EventFilter EVENT_FILTER_SENTINEL = proxy(EventFilter.class); + + /** A stand-in {@link StreamFilter} for the delegation tests. */ + static final StreamFilter STREAM_FILTER_SENTINEL = proxy(StreamFilter.class); + + /** + * Formats a recorded call so a test can assert the wrapper forwarded the exact arguments. + */ + static String call(final String method, final Object... args) { + final StringBuilder entry = new StringBuilder(method).append('('); + for (int i = 0; i < args.length; i++) { + if (i > 0) { + entry.append(", "); + } + final Object arg = args[i]; + entry.append(arg == null ? "null" : arg.getClass().getName()).append('@').append(System.identityHashCode(arg)); + } + return entry.append(')').toString(); + } + + /** + * Builds an unbacked instance of the given interface whose boolean and int methods answer their neutral values and whose other methods answer + * {@code null}; the sentinel readers returned from the fake's creation methods. + */ + private static <T> T proxy(final Class<T> type) { + return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[] { type }, (p, method, args) -> { + if (method.getReturnType() == boolean.class) { + return Boolean.FALSE; + } + if (method.getReturnType() == int.class) { + return 0; + } + return null; + }); + } + + /** Recorded calls in order, each formatted by {@link #call}. */ + final List<String> calls = new ArrayList<>(); + + /** The resolver-valued hook this factory reports; the wrapper's floor or whatever a test installs. */ + Object resolverHook; + + /** The allocator last installed via {@code setEventAllocator}. */ + XMLEventAllocator allocator; + + /** The reporter last installed via {@code setXMLReporter}. */ + XMLReporter reporter; + + /** The answer {@code isPropertySupported} gives; {@code true} by default. */ + boolean supported = true; + + @Override + public XMLEventReader createFilteredReader(final XMLEventReader reader, final EventFilter filter) { + record("createFilteredReader", reader, filter); + return EVENT_SENTINEL; + } + + @Override + public XMLStreamReader createFilteredReader(final XMLStreamReader reader, final StreamFilter filter) { + record("createFilteredReader", reader, filter); + return STREAM_SENTINEL; + } + + @Override + public XMLEventReader createXMLEventReader(final InputStream stream) { + record("createXMLEventReader", stream); + return EVENT_SENTINEL; + } + + @Override + public XMLEventReader createXMLEventReader(final InputStream stream, final String encoding) { + record("createXMLEventReader", stream, encoding); + return EVENT_SENTINEL; + } + + @Override + public XMLEventReader createXMLEventReader(final Reader reader) { + record("createXMLEventReader", reader); + return EVENT_SENTINEL; + } + + @Override + public XMLEventReader createXMLEventReader(final Source source) { + record("createXMLEventReader", source); + return EVENT_SENTINEL; + } + + @Override + public XMLEventReader createXMLEventReader(final String systemId, final InputStream stream) { + record("createXMLEventReader", systemId, stream); + return EVENT_SENTINEL; + } + + @Override + public XMLEventReader createXMLEventReader(final String systemId, final Reader reader) { + record("createXMLEventReader", systemId, reader); + return EVENT_SENTINEL; + } + + @Override + public XMLEventReader createXMLEventReader(final XMLStreamReader reader) { + record("createXMLEventReader", reader); + return EVENT_SENTINEL; + } + + @Override + public XMLStreamReader createXMLStreamReader(final InputStream stream) { + record("createXMLStreamReader", stream); + return STREAM_SENTINEL; + } + + @Override + public XMLStreamReader createXMLStreamReader(final InputStream stream, final String encoding) { + record("createXMLStreamReader", stream, encoding); + return STREAM_SENTINEL; + } + + @Override + public XMLStreamReader createXMLStreamReader(final Reader reader) { + record("createXMLStreamReader", reader); + return STREAM_SENTINEL; + } + + @Override + public XMLStreamReader createXMLStreamReader(final Source source) { + record("createXMLStreamReader", source); + return STREAM_SENTINEL; + } + + @Override + public XMLStreamReader createXMLStreamReader(final String systemId, final InputStream stream) { + record("createXMLStreamReader", systemId, stream); + return STREAM_SENTINEL; + } + + @Override + public XMLStreamReader createXMLStreamReader(final String systemId, final Reader reader) { + record("createXMLStreamReader", systemId, reader); + return STREAM_SENTINEL; + } + + @Override + public XMLEventAllocator getEventAllocator() { + record("getEventAllocator"); + return allocator; + } + + @Override + public Object getProperty(final String name) { + record("getProperty", name); + return resolverHook; + } + + @Override + public XMLReporter getXMLReporter() { + record("getXMLReporter"); + return reporter; + } + + @Override + public XMLResolver getXMLResolver() { + record("getXMLResolver"); + return (XMLResolver) resolverHook; + } + + @Override + public boolean isPropertySupported(final String name) { + record("isPropertySupported", name); + return supported; + } + + /** + * Records a call with the runtime class and identity hash code of each argument, the format {@link #calls} entries use. + */ + private void record(final String method, final Object... args) { + calls.add(call(method, args)); + } + + @Override + public void setEventAllocator(final XMLEventAllocator eventAllocator) { + record("setEventAllocator", eventAllocator); + allocator = eventAllocator; + } + + @Override + public void setProperty(final String name, final Object value) { + record("setProperty", name, value); + if (value == null || value instanceof XMLResolver) { + resolverHook = value; + } + } + + @Override + public void setXMLReporter(final XMLReporter xmlReporter) { + record("setXMLReporter", xmlReporter); + reporter = xmlReporter; + } + + @Override + public void setXMLResolver(final XMLResolver resolver) { + record("setXMLResolver", resolver); + resolverHook = resolver; + } + } + + private static final String BENIGN_XML = "<?xml version=\"1.0\"?>\n<root><child>hello</child></root>\n"; + + private static final String SYSTEM_ID = "http://example.invalid/document.xml"; + + /** + * Drains every event from the event reader and returns the accumulated character and CDATA data. + */ + private static String drainEvents(final XMLEventReader reader) throws XMLStreamException { + final StringBuilder text = new StringBuilder(); + try { + while (reader.hasNext()) { + final XMLEvent event = reader.nextEvent(); + if (event.isCharacters() || event.getEventType() == XMLStreamConstants.CDATA) { + text.append(event.asCharacters().getData()); + } + } + } finally { + reader.close(); + } + return text.toString(); + } + + /** + * Drains every event from the stream reader and returns the accumulated character data. + */ + private static String drainStream(final XMLStreamReader reader) throws XMLStreamException { + final StringBuilder text = new StringBuilder(); + try { + while (reader.hasNext()) { + if (reader.next() == XMLStreamConstants.CHARACTERS) { + text.append(reader.getText()); + } + } + } finally { + reader.close(); + } + return text.toString(); + } + + @Test + void getPropertyReportsForeignResolverUnchanged() { + final RecordingXMLInputFactory fake = new RecordingXMLInputFactory(); + final XMLInputFactory secure = SecureXMLInputFactory.secure(fake); + final XMLResolver foreign = (publicID, systemID, baseURI, namespace) -> "foreign"; + fake.setProperty(XMLInputFactory.RESOLVER, foreign); + assertSame(foreign, secure.getProperty(XMLInputFactory.RESOLVER), "a non-floor resolver must be reported unchanged"); + assertSame(foreign, secure.getXMLResolver(), "getXMLResolver must report a non-floor resolver unchanged"); + } + + @Test + void getPropertyReportsNullResolver() { + final RecordingXMLInputFactory fake = new RecordingXMLInputFactory(); + final XMLInputFactory secure = SecureXMLInputFactory.secure(fake); + fake.setProperty(XMLInputFactory.RESOLVER, null); + assertNull(secure.getProperty(XMLInputFactory.RESOLVER), "an empty hook must report no resolver"); + assertNull(secure.getXMLResolver(), "an empty hook must report no resolver"); + } + + @Test + void getXMLResolverInitiallyNull() { + assertNull(SecureXMLInputFactory.newInstance().getXMLResolver(), "a fresh secure factory must report no caller resolver"); + assertNull(SecureXMLInputFactory.newDefaultFactory().getXMLResolver(), "a fresh secure factory must report no caller resolver"); + } + + @Test + void newDefaultFactoryParsesBenignDocument() throws Exception { + final XMLInputFactory factory = SecureXMLInputFactory.newDefaultFactory(); + assertEquals(Boolean.TRUE, factory.getProperty(XMLInputFactory.SUPPORT_DTD), "a secure factory must keep the implementation's DTD default"); + assertTrue(drainStream(factory.createXMLStreamReader(new StringReader(BENIGN_XML))).contains("hello"), "stream reader must parse the document"); + assertTrue(drainEvents(factory.createXMLEventReader(new StringReader(BENIGN_XML))).contains("hello"), "event reader must parse the document"); + } + + @Test + void newFactoryNullFactoryIdThrows() { + assertThrows(NullPointerException.class, () -> SecureXMLInputFactory.newFactory(null, null), "a null factory id must be rejected"); + } + + @Test + void newFactoryParsesBenignDocument() throws Exception { + final XMLInputFactory factory = SecureXMLInputFactory.newFactory(); + assertTrue(drainStream(factory.createXMLStreamReader(new StringReader(BENIGN_XML))).contains("hello"), "stream reader must parse the document"); + assertTrue(drainEvents(factory.createXMLEventReader(new StringReader(BENIGN_XML))).contains("hello"), "event reader must parse the document"); + } + + @Test + void newFactoryWithFactoryIdReturnsUsableSecureFactory() throws Exception { + final String factoryId = "org.apache.commons.xml.test.inputFactory"; + System.setProperty(factoryId, XMLInputFactory.newInstance().getClass().getName()); + try { + final XMLInputFactory factory = SecureXMLInputFactory.newFactory(factoryId, getClass().getClassLoader()); + assertNull(factory.getXMLResolver(), "a fresh secure factory must report no caller resolver"); + assertTrue(drainStream(factory.createXMLStreamReader(new StringReader(BENIGN_XML))).contains("hello"), "factory must parse the document"); + } finally { + System.clearProperty(factoryId); + } + } + + @Test + void newInstanceParsesBenignDocument() throws Exception { + final XMLInputFactory factory = SecureXMLInputFactory.newInstance(); + assertTrue(drainStream(factory.createXMLStreamReader(new StringReader(BENIGN_XML))).contains("hello"), "stream reader must parse the document"); + assertTrue(drainEvents(factory.createXMLEventReader(new StringReader(BENIGN_XML))).contains("hello"), "event reader must parse the document"); + } + + @Test + void privateConstructorIsInvokable() throws Exception { + final Constructor<SecureXMLInputFactory> constructor = SecureXMLInputFactory.class.getDeclaredConstructor(); + constructor.setAccessible(true); + assertNotNull(constructor.newInstance(), "the private constructor must exist and be invokable"); + } + + @Test + void secureNullDelegateThrows() { + assertThrows(NullPointerException.class, () -> SecureXMLInputFactory.secure(null), "a null delegate must be rejected"); + } + + @Test + void setPropertyCallerFloorTakesControl() { + final RecordingXMLInputFactory fake = new RecordingXMLInputFactory(); + final XMLInputFactory secure = SecureXMLInputFactory.secure(fake); + final XMLResolver caller = (publicID, systemID, baseURI, namespace) -> "resolved"; + final FallbackIgnoreXMLResolver ownFloor = new FallbackIgnoreXMLResolver(caller); + secure.setProperty(XMLInputFactory.RESOLVER, ownFloor); + assertSame(ownFloor, fake.resolverHook, "the caller's own floor must be handed to the delegate as-is"); + assertSame(caller, secure.getXMLResolver(), "getXMLResolver must report the delegate of the caller's floor"); + assertSame(caller, secure.getProperty(XMLInputFactory.RESOLVER), "getProperty must report the delegate of the caller's floor"); + } + + @Test + void setPropertyNullResolverClearsCallerDelegate() { + final RecordingXMLInputFactory fake = new RecordingXMLInputFactory(); + final XMLInputFactory secure = SecureXMLInputFactory.secure(fake); + final FallbackIgnoreXMLResolver floor = (FallbackIgnoreXMLResolver) fake.resolverHook; + final XMLResolver caller = (publicID, systemID, baseURI, namespace) -> null; + secure.setXMLResolver(caller); + secure.setProperty(XMLInputFactory.RESOLVER, null); + assertNull(floor.getDelegate(), "a null resolver property must clear the floor's delegate"); + assertNull(secure.getXMLResolver(), "getXMLResolver must report no caller resolver"); + } + + @Test + void setPropertyRoutesEveryResolverHookUniformly() { + final RecordingXMLInputFactory fake = new RecordingXMLInputFactory(); + final XMLInputFactory secure = SecureXMLInputFactory.secure(fake); + final XMLResolver caller = (publicID, systemID, baseURI, namespace) -> "resolved"; + for (final String hook : new String[] { XMLInputFactory.RESOLVER, SecureXMLInputFactory.WSTX_DTD_RESOLVER, SecureXMLInputFactory.WSTX_ENTITY_RESOLVER, + SecureXMLInputFactory.WSTX_UNDECLARED_ENTITY_RESOLVER }) { + fake.setProperty(hook, null); + secure.setProperty(hook, caller); + assertSame(caller, secure.getProperty(hook), "the caller's resolver must be reported unwrapped on " + hook); + } + } + + @Test + void setPropertyWrapsCallerWhenHookIsNotAFloor() { + final RecordingXMLInputFactory fake = new RecordingXMLInputFactory(); + final XMLInputFactory secure = SecureXMLInputFactory.secure(fake); + final XMLResolver caller = (publicID, systemID, baseURI, namespace) -> "resolved"; + for (final Object foreign : new Object[] { null, (XMLResolver) (publicID, systemID, baseURI, namespace) -> "foreign" }) { + fake.setProperty(XMLInputFactory.RESOLVER, foreign); + secure.setProperty(XMLInputFactory.RESOLVER, caller); + assertTrue(fake.resolverHook instanceof FallbackIgnoreXMLResolver, "a caller resolver must land behind a floor"); + assertSame(caller, ((FallbackIgnoreXMLResolver) fake.resolverHook).getDelegate(), "the floor must delegate to the caller's resolver"); + assertSame(caller, secure.getXMLResolver(), "getXMLResolver must report the caller's resolver unwrapped"); + } + } + + @Test + void setPropertyWrongTypeForResolverHookReachesDelegate() { + final RecordingXMLInputFactory fake = new RecordingXMLInputFactory(); + final XMLInputFactory secure = SecureXMLInputFactory.secure(fake); + final Object wrongType = "not a resolver"; + secure.setProperty(XMLInputFactory.RESOLVER, wrongType); + assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("setProperty", XMLInputFactory.RESOLVER, wrongType)), + "a wrong-typed value must reach the delegate so it can reject it"); + } + + @Test + void setPropertyWrongTypeForResolverHookSurfacesDelegateException() { + final XMLInputFactory factory = SecureXMLInputFactory.newInstance(); + assertThrows(ClassCastException.class, () -> factory.setProperty(XMLInputFactory.RESOLVER, "not a resolver"), + "the delegate must surface its own rejection of a wrong-typed resolver"); + } + + @Test + void setXMLResolverNullClearsCallerDelegate() { + final RecordingXMLInputFactory fake = new RecordingXMLInputFactory(); + final XMLInputFactory secure = SecureXMLInputFactory.secure(fake); + final FallbackIgnoreXMLResolver floor = (FallbackIgnoreXMLResolver) fake.resolverHook; + final XMLResolver caller = (publicID, systemID, baseURI, namespace) -> null; + secure.setXMLResolver(caller); + secure.setXMLResolver(null); + assertNull(floor.getDelegate(), "a null caller resolver must clear the floor's delegate"); + assertNull(secure.getXMLResolver(), "getXMLResolver must report no caller resolver"); + } + + @Test + void setXMLResolverRoutesCallerBehindInstalledFloor() { + final RecordingXMLInputFactory fake = new RecordingXMLInputFactory(); + final XMLInputFactory secure = SecureXMLInputFactory.secure(fake); + final FallbackIgnoreXMLResolver floor = (FallbackIgnoreXMLResolver) fake.resolverHook; + final XMLResolver caller = (publicID, systemID, baseURI, namespace) -> null; + secure.setXMLResolver(caller); + assertSame(caller, floor.getDelegate(), "the caller's resolver must become the delegate of the installed floor"); + assertSame(caller, secure.getXMLResolver(), "getXMLResolver must report the caller's resolver unwrapped"); + assertSame(caller, secure.getProperty(XMLInputFactory.RESOLVER), "getProperty must report the caller's resolver unwrapped"); + assertFalse(fake.calls.stream().anyMatch(c -> c.startsWith("setProperty(" + XMLInputFactory.RESOLVER)), + "a caller resolver must not replace the floor on the delegate's hook"); + } + + @Test + void unsupportedResolverHookSurfacesDelegateError() { + final XMLInputFactory factory = SecureXMLInputFactory.newInstance(); + Assumptions.assumeFalse(factory.isPropertySupported(SecureXMLInputFactory.WSTX_DTD_RESOLVER), "requires an implementation without the Woodstox hooks"); + final XMLResolver caller = (publicID, systemID, baseURI, namespace) -> null; + assertThrows(IllegalArgumentException.class, () -> factory.setProperty(SecureXMLInputFactory.WSTX_DTD_RESOLVER, caller), + "the delegate must surface its own rejection of an unknown resolver hook"); + assertThrows(IllegalArgumentException.class, () -> factory.getProperty(SecureXMLInputFactory.WSTX_DTD_RESOLVER), + "the delegate must surface its own rejection of an unknown resolver hook"); + } + + @Test + void woodstoxDtdHookRoutesBehindInstalledFloor() { + final XMLInputFactory factory = SecureXMLInputFactory.newInstance(); + Assumptions.assumeTrue(factory.isPropertySupported(SecureXMLInputFactory.WSTX_DTD_RESOLVER), "requires the Woodstox DTD resolver hook"); + final XMLResolver first = (publicID, systemID, baseURI, namespace) -> null; + factory.setProperty(SecureXMLInputFactory.WSTX_DTD_RESOLVER, first); + assertSame(first, factory.getProperty(SecureXMLInputFactory.WSTX_DTD_RESOLVER), "the Woodstox hook must report the caller's resolver unwrapped"); + final XMLResolver second = (publicID, systemID, baseURI, namespace) -> "resolved"; + factory.setProperty(SecureXMLInputFactory.WSTX_DTD_RESOLVER, second); + assertSame(second, factory.getProperty(SecureXMLInputFactory.WSTX_DTD_RESOLVER), "a second caller resolver must replace the first behind the floor"); + } + + @Test + void woodstoxUndeclaredEntityHookWrapsCallerResolver() { + final XMLInputFactory factory = SecureXMLInputFactory.newInstance(); + Assumptions.assumeTrue(factory.isPropertySupported(SecureXMLInputFactory.WSTX_UNDECLARED_ENTITY_RESOLVER), + "requires the Woodstox undeclared-entity resolver hook"); + final XMLResolver caller = (publicID, systemID, baseURI, namespace) -> null; + factory.setProperty(SecureXMLInputFactory.WSTX_UNDECLARED_ENTITY_RESOLVER, caller); + assertSame(caller, factory.getProperty(SecureXMLInputFactory.WSTX_UNDECLARED_ENTITY_RESOLVER), + "the Woodstox hook must report the caller's resolver unwrapped"); + } + + @Test + void wrapperDelegatesAllocatorAndReporter() { + final RecordingXMLInputFactory fake = new RecordingXMLInputFactory(); + final XMLInputFactory secure = SecureXMLInputFactory.secure(fake); + final XMLEventAllocator allocator = RecordingXMLInputFactory.ALLOCATOR_SENTINEL; + secure.setEventAllocator(allocator); + assertSame(allocator, secure.getEventAllocator(), "the allocator must round-trip through the delegate"); + assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("setEventAllocator", allocator)), "the exact allocator must be forwarded"); + final XMLReporter reporter = RecordingXMLInputFactory.REPORTER_SENTINEL; + secure.setXMLReporter(reporter); + assertSame(reporter, secure.getXMLReporter(), "the reporter must round-trip through the delegate"); + assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("setXMLReporter", reporter)), "the exact reporter must be forwarded"); + } + + @Test + void wrapperDelegatesIsPropertySupported() { + final RecordingXMLInputFactory fake = new RecordingXMLInputFactory(); + final XMLInputFactory secure = SecureXMLInputFactory.secure(fake); + assertTrue(secure.isPropertySupported(XMLInputFactory.SUPPORT_DTD), "the delegate's answer must be reported"); + fake.supported = false; + assertFalse(secure.isPropertySupported(XMLInputFactory.SUPPORT_DTD), "the delegate's answer must be reported"); + assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("isPropertySupported", XMLInputFactory.SUPPORT_DTD)), + "the exact property name must be forwarded"); + } + + @Test + void wrapperDelegatesNonResolverProperties() { + final RecordingXMLInputFactory fake = new RecordingXMLInputFactory(); + final XMLInputFactory secure = SecureXMLInputFactory.secure(fake); + secure.setProperty(XMLInputFactory.SUPPORT_DTD, Boolean.TRUE); + assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("setProperty", XMLInputFactory.SUPPORT_DTD, Boolean.TRUE)), + "a non-resolver property must reach the delegate unmodified"); + fake.resolverHook = Boolean.TRUE; + assertEquals(Boolean.TRUE, secure.getProperty(XMLInputFactory.SUPPORT_DTD), "a non-resolver property must be reported unmodified"); + } + + @Test + void wrapperDelegatesReaderCreationToDelegate() throws Exception { + final RecordingXMLInputFactory fake = new RecordingXMLInputFactory(); + final XMLInputFactory secure = SecureXMLInputFactory.secure(fake); + final XMLStreamReader streamSentinel = RecordingXMLInputFactory.STREAM_SENTINEL; + final XMLEventReader eventSentinel = RecordingXMLInputFactory.EVENT_SENTINEL; + final EventFilter eventFilter = RecordingXMLInputFactory.EVENT_FILTER_SENTINEL; + final StreamFilter streamFilter = RecordingXMLInputFactory.STREAM_FILTER_SENTINEL; + final InputStream stream = new ByteArrayInputStream(BENIGN_XML.getBytes(StandardCharsets.UTF_8)); + final StringReader reader = new StringReader(BENIGN_XML); + final Source source = new StreamSource(new StringReader(BENIGN_XML)); + assertSame(streamSentinel, secure.createXMLStreamReader(stream)); + assertSame(streamSentinel, secure.createXMLStreamReader(stream, "UTF-8")); + assertSame(streamSentinel, secure.createXMLStreamReader(reader)); + assertSame(streamSentinel, secure.createXMLStreamReader(source)); + assertSame(streamSentinel, secure.createXMLStreamReader(SYSTEM_ID, stream)); + assertSame(streamSentinel, secure.createXMLStreamReader(SYSTEM_ID, reader)); + assertSame(eventSentinel, secure.createXMLEventReader(stream)); + assertSame(eventSentinel, secure.createXMLEventReader(stream, "UTF-8")); + assertSame(eventSentinel, secure.createXMLEventReader(reader)); + assertSame(eventSentinel, secure.createXMLEventReader(source)); + assertSame(eventSentinel, secure.createXMLEventReader(SYSTEM_ID, stream)); + assertSame(eventSentinel, secure.createXMLEventReader(SYSTEM_ID, reader)); + assertSame(eventSentinel, secure.createXMLEventReader(streamSentinel)); + assertSame(eventSentinel, secure.createFilteredReader(eventSentinel, eventFilter)); + assertSame(streamSentinel, secure.createFilteredReader(streamSentinel, streamFilter)); + assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("createXMLStreamReader", stream)), "the exact stream must be forwarded"); + assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("createXMLStreamReader", reader)), "the exact reader must be forwarded"); + assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("createXMLStreamReader", source)), "the exact source must be forwarded"); + assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("createXMLStreamReader", SYSTEM_ID, stream)), "the exact system id must be forwarded"); + assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("createXMLEventReader", stream, "UTF-8")), "the exact encoding must be forwarded"); + assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("createFilteredReader", eventSentinel, eventFilter)), + "the exact event filter must be forwarded"); + assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("createFilteredReader", streamSentinel, streamFilter)), + "the exact stream filter must be forwarded"); + } + + @Test + void wrapperInstallsFloorOnDelegateHook() { + final RecordingXMLInputFactory fake = new RecordingXMLInputFactory(); + final XMLInputFactory secure = SecureXMLInputFactory.secure(fake); + assertNotNull(secure); + assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("setXMLResolver", fake.resolverHook)), + "the floor must be installed through the delegate's setXMLResolver"); + assertTrue(fake.resolverHook instanceof FallbackIgnoreXMLResolver, "the constructor must install the ignore-all floor on the delegate's resolver hook"); + assertNull(((FallbackIgnoreXMLResolver) fake.resolverHook).getDelegate(), "the installed floor must have no caller delegate"); + } +} diff --git a/src/test/java/org/apache/commons/xml/XMLFilterTest.java b/src/test/java/org/apache/commons/xml/XMLFilterTest.java index 7230e57..cee17de 100644 --- a/src/test/java/org/apache/commons/xml/XMLFilterTest.java +++ b/src/test/java/org/apache/commons/xml/XMLFilterTest.java @@ -184,14 +184,6 @@ public Object getProperty(final String name) { return null; } - @Override - public void setFeature(final String name, final boolean value) { - } - - @Override - public void setProperty(final String name, final Object value) { - } - @Override public void parse(final InputSource input) throws SAXException { // Minimal well-formed document for the transformation to consume; no real parser behind this parent. @@ -219,6 +211,14 @@ public void setErrorHandler(final ErrorHandler handler) { wired.add(handler); super.setErrorHandler(handler); } + + @Override + public void setFeature(final String name, final boolean value) { + } + + @Override + public void setProperty(final String name, final Object value) { + } }; filter.setParent(parent); assertEquals("", filterAndCapture(filter, "<ignored/>"));
