View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one or more
3    * contributor license agreements.  See the NOTICE file distributed with
4    * this work for additional information regarding copyright ownership.
5    * The ASF licenses this file to You under the Apache License, Version 2.0
6    * (the "License"); you may not use this file except in compliance with
7    * the License.  You may obtain a copy of the License at
8    *
9    *      https://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  
18  package org.apache.commons.xml.secure;
19  
20  import static org.junit.jupiter.api.Assertions.assertEquals;
21  import static org.junit.jupiter.api.Assertions.assertFalse;
22  import static org.junit.jupiter.api.Assertions.assertInstanceOf;
23  import static org.junit.jupiter.api.Assertions.assertNotNull;
24  import static org.junit.jupiter.api.Assertions.assertNotSame;
25  import static org.junit.jupiter.api.Assertions.assertNull;
26  import static org.junit.jupiter.api.Assertions.assertSame;
27  import static org.junit.jupiter.api.Assertions.assertThrows;
28  import static org.junit.jupiter.api.Assertions.assertTrue;
29  
30  import java.io.ByteArrayInputStream;
31  import java.io.InputStream;
32  import java.io.Reader;
33  import java.io.StringReader;
34  import java.lang.reflect.Constructor;
35  import java.lang.reflect.Proxy;
36  import java.nio.charset.StandardCharsets;
37  import java.util.ArrayList;
38  import java.util.List;
39  
40  import javax.xml.stream.EventFilter;
41  import javax.xml.stream.StreamFilter;
42  import javax.xml.stream.XMLEventReader;
43  import javax.xml.stream.XMLInputFactory;
44  import javax.xml.stream.XMLReporter;
45  import javax.xml.stream.XMLResolver;
46  import javax.xml.stream.XMLStreamConstants;
47  import javax.xml.stream.XMLStreamException;
48  import javax.xml.stream.XMLStreamReader;
49  import javax.xml.stream.events.XMLEvent;
50  import javax.xml.stream.util.XMLEventAllocator;
51  import javax.xml.transform.Source;
52  import javax.xml.transform.stream.StreamSource;
53  
54  import org.junit.jupiter.api.Assumptions;
55  import org.junit.jupiter.api.Tag;
56  import org.junit.jupiter.api.Test;
57  
58  /**
59   * Unit tests for {@link SecureXMLInputFactory} and the {@link XMLInputFactory} wrapper it installs.
60   * <p>
61   * The wrapper's delegation and resolver-routing logic is exercised against a recording stand-in factory, so every branch is deterministic on every platform.
62   * The public factory methods are additionally exercised end-to-end against the platform's real implementation, including the Woodstox-specific resolver hooks
63   * where that implementation is present.
64   * </p>
65   */
66  @Tag("stax")
67  class SecureXMLInputFactoryTest {
68  
69      /**
70       * A recording stand-in {@link XMLInputFactory} for the delegation tests.
71       * <p>
72       * 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
73       * forward the caller's exact arguments. The resolver hook the fake reports through {@code getProperty} and {@code getXMLResolver} is whatever the wrapper
74       * or a test last installed, so the tests can steer the wrapper into each routing branch deterministically.
75       * </p>
76       */
77      private static final class RecordingXMLInputFactory extends XMLInputFactory {
78  
79          /**
80           * The {@link XMLEventReader} every event-flavor creation method returns.
81           */
82          static final XMLEventReader EVENT_SENTINEL = proxy(XMLEventReader.class);
83  
84          /**
85           * The {@link XMLStreamReader} every stream-flavor creation method returns.
86           */
87          static final XMLStreamReader STREAM_SENTINEL = proxy(XMLStreamReader.class);
88  
89          /**
90           * A stand-in {@link XMLEventAllocator} for the round-trip tests.
91           */
92          static final XMLEventAllocator ALLOCATOR_SENTINEL = proxy(XMLEventAllocator.class);
93  
94          /**
95           * A stand-in {@link XMLReporter} for the round-trip tests.
96           */
97          static final XMLReporter REPORTER_SENTINEL = proxy(XMLReporter.class);
98  
99          /**
100          * A stand-in {@link EventFilter} for the delegation tests.
101          */
102         static final EventFilter EVENT_FILTER_SENTINEL = proxy(EventFilter.class);
103 
104         /**
105          * A stand-in {@link StreamFilter} for the delegation tests.
106          */
107         static final StreamFilter STREAM_FILTER_SENTINEL = proxy(StreamFilter.class);
108 
109         /**
110          * Formats a recorded call so a test can assert the wrapper forwarded the exact arguments.
111          */
112         static String call(final String method, final Object... args) {
113             final StringBuilder entry = new StringBuilder(method).append('(');
114             for (int i = 0; i < args.length; i++) {
115                 if (i > 0) {
116                     entry.append(", ");
117                 }
118                 final Object arg = args[i];
119                 entry.append(arg == null ? "null" : arg.getClass().getName()).append('@').append(System.identityHashCode(arg));
120             }
121             return entry.append(')').toString();
122         }
123 
124         /**
125          * Builds an unbacked instance of the given interface whose boolean and int methods answer their neutral values and whose other methods answer
126          * {@code null}. These instances serve as the sentinel readers returned from the fake's creation methods.
127          */
128         private static <T> T proxy(final Class<T> type) {
129             return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[] { type }, (p, method, args) -> {
130                 if (method.getReturnType() == boolean.class) {
131                     return Boolean.FALSE;
132                 }
133                 if (method.getReturnType() == int.class) {
134                     return 0;
135                 }
136                 return null;
137             });
138         }
139 
140         /**
141          * Recorded calls in order, each formatted by {@link #call}.
142          */
143         final List<String> calls = new ArrayList<>();
144 
145         /**
146          * The resolver-valued hook this factory reports; the wrapper's floor or whatever a test installs.
147          */
148         Object resolverHook;
149 
150         /**
151          * The allocator last installed via {@code setEventAllocator}.
152          */
153         XMLEventAllocator allocator;
154 
155         /**
156          * The reporter last installed via {@code setXMLReporter}.
157          */
158         XMLReporter reporter;
159 
160         /**
161          * The answer {@code isPropertySupported} gives; {@code true} by default.
162          */
163         boolean supported = true;
164 
165         @Override
166         public XMLEventReader createFilteredReader(final XMLEventReader reader, final EventFilter filter) {
167             record("createFilteredReader", reader, filter);
168             return EVENT_SENTINEL;
169         }
170 
171         @Override
172         public XMLStreamReader createFilteredReader(final XMLStreamReader reader, final StreamFilter filter) {
173             record("createFilteredReader", reader, filter);
174             return STREAM_SENTINEL;
175         }
176 
177         @Override
178         public XMLEventReader createXMLEventReader(final InputStream stream) {
179             record("createXMLEventReader", stream);
180             return EVENT_SENTINEL;
181         }
182 
183         @Override
184         public XMLEventReader createXMLEventReader(final InputStream stream, final String encoding) {
185             record("createXMLEventReader", stream, encoding);
186             return EVENT_SENTINEL;
187         }
188 
189         @Override
190         public XMLEventReader createXMLEventReader(final Reader reader) {
191             record("createXMLEventReader", reader);
192             return EVENT_SENTINEL;
193         }
194 
195         @Override
196         public XMLEventReader createXMLEventReader(final Source source) {
197             record("createXMLEventReader", source);
198             return EVENT_SENTINEL;
199         }
200 
201         @Override
202         public XMLEventReader createXMLEventReader(final String systemId, final InputStream stream) {
203             record("createXMLEventReader", systemId, stream);
204             return EVENT_SENTINEL;
205         }
206 
207         @Override
208         public XMLEventReader createXMLEventReader(final String systemId, final Reader reader) {
209             record("createXMLEventReader", systemId, reader);
210             return EVENT_SENTINEL;
211         }
212 
213         @Override
214         public XMLEventReader createXMLEventReader(final XMLStreamReader reader) {
215             record("createXMLEventReader", reader);
216             return EVENT_SENTINEL;
217         }
218 
219         @Override
220         public XMLStreamReader createXMLStreamReader(final InputStream stream) {
221             record("createXMLStreamReader", stream);
222             return STREAM_SENTINEL;
223         }
224 
225         @Override
226         public XMLStreamReader createXMLStreamReader(final InputStream stream, final String encoding) {
227             record("createXMLStreamReader", stream, encoding);
228             return STREAM_SENTINEL;
229         }
230 
231         @Override
232         public XMLStreamReader createXMLStreamReader(final Reader reader) {
233             record("createXMLStreamReader", reader);
234             return STREAM_SENTINEL;
235         }
236 
237         @Override
238         public XMLStreamReader createXMLStreamReader(final Source source) {
239             record("createXMLStreamReader", source);
240             return STREAM_SENTINEL;
241         }
242 
243         @Override
244         public XMLStreamReader createXMLStreamReader(final String systemId, final InputStream stream) {
245             record("createXMLStreamReader", systemId, stream);
246             return STREAM_SENTINEL;
247         }
248 
249         @Override
250         public XMLStreamReader createXMLStreamReader(final String systemId, final Reader reader) {
251             record("createXMLStreamReader", systemId, reader);
252             return STREAM_SENTINEL;
253         }
254 
255         @Override
256         public XMLEventAllocator getEventAllocator() {
257             record("getEventAllocator");
258             return allocator;
259         }
260 
261         @Override
262         public Object getProperty(final String name) {
263             record("getProperty", name);
264             return resolverHook;
265         }
266 
267         @Override
268         public XMLReporter getXMLReporter() {
269             record("getXMLReporter");
270             return reporter;
271         }
272 
273         @Override
274         public XMLResolver getXMLResolver() {
275             record("getXMLResolver");
276             return (XMLResolver) resolverHook;
277         }
278 
279         @Override
280         public boolean isPropertySupported(final String name) {
281             record("isPropertySupported", name);
282             return supported;
283         }
284 
285         /**
286          * Records a call with the runtime class and identity hash code of each argument, the format {@link #calls} entries use.
287          */
288         private void record(final String method, final Object... args) {
289             calls.add(call(method, args));
290         }
291 
292         @Override
293         public void setEventAllocator(final XMLEventAllocator eventAllocator) {
294             record("setEventAllocator", eventAllocator);
295             allocator = eventAllocator;
296         }
297 
298         @Override
299         public void setProperty(final String name, final Object value) {
300             record("setProperty", name, value);
301             if (value == null || value instanceof XMLResolver) {
302                 resolverHook = value;
303             }
304         }
305 
306         @Override
307         public void setXMLReporter(final XMLReporter xmlReporter) {
308             record("setXMLReporter", xmlReporter);
309             reporter = xmlReporter;
310         }
311 
312         @Override
313         public void setXMLResolver(final XMLResolver resolver) {
314             record("setXMLResolver", resolver);
315             resolverHook = resolver;
316         }
317     }
318 
319     private static final String BENIGN_XML = "<?xml version=\"1.0\"?>\n<root><child>hello</child></root>\n";
320 
321     private static final String SYSTEM_ID = "http://example.invalid/document.xml";
322 
323     /**
324      * Drains every event from the event reader and returns the accumulated character and CDATA data.
325      */
326     private static String drainEvents(final XMLEventReader reader) throws XMLStreamException {
327         final StringBuilder text = new StringBuilder();
328         try {
329             while (reader.hasNext()) {
330                 final XMLEvent event = reader.nextEvent();
331                 if (event.isCharacters() || event.getEventType() == XMLStreamConstants.CDATA) {
332                     text.append(event.asCharacters().getData());
333                 }
334             }
335         } finally {
336             reader.close();
337         }
338         return text.toString();
339     }
340 
341     /**
342      * Drains every event from the stream reader and returns the accumulated character data.
343      */
344     private static String drainStream(final XMLStreamReader reader) throws XMLStreamException {
345         final StringBuilder text = new StringBuilder();
346         try {
347             while (reader.hasNext()) {
348                 if (reader.next() == XMLStreamConstants.CHARACTERS) {
349                     text.append(reader.getText());
350                 }
351             }
352         } finally {
353             reader.close();
354         }
355         return text.toString();
356     }
357 
358     @Test
359     void getPropertyReportsForeignResolverUnchanged() {
360         final RecordingXMLInputFactory fake = new RecordingXMLInputFactory();
361         final XMLInputFactory secure = SecureXMLInputFactory.secure(fake);
362         final XMLResolver foreign = (publicID, systemID, baseURI, namespace) -> "foreign";
363         fake.setProperty(XMLInputFactory.RESOLVER, foreign);
364         assertSame(foreign, secure.getProperty(XMLInputFactory.RESOLVER), "a non-floor resolver must be reported unchanged");
365         assertSame(foreign, secure.getXMLResolver(), "getXMLResolver must report a non-floor resolver unchanged");
366     }
367 
368     @Test
369     void getPropertyReportsNullResolver() {
370         final RecordingXMLInputFactory fake = new RecordingXMLInputFactory();
371         final XMLInputFactory secure = SecureXMLInputFactory.secure(fake);
372         fake.setProperty(XMLInputFactory.RESOLVER, null);
373         assertNull(secure.getProperty(XMLInputFactory.RESOLVER), "an empty hook must report no resolver");
374         assertNull(secure.getXMLResolver(), "an empty hook must report no resolver");
375     }
376 
377     @Test
378     void getXMLResolverInitiallyNull() {
379         assertNull(SecureXMLInputFactory.newInstance().getXMLResolver(), "a fresh secure factory must report no caller resolver");
380         assertNull(SecureXMLInputFactory.newDefaultFactory().getXMLResolver(), "a fresh secure factory must report no caller resolver");
381     }
382 
383     @Test
384     void newDefaultFactoryParsesBenignDocument() throws Exception {
385         final XMLInputFactory factory = SecureXMLInputFactory.newDefaultFactory();
386         assertEquals(Boolean.TRUE, factory.getProperty(XMLInputFactory.SUPPORT_DTD), "a secure factory must keep the implementation's DTD default");
387         assertTrue(drainStream(factory.createXMLStreamReader(new StringReader(BENIGN_XML))).contains("hello"), "stream reader must parse the document");
388         assertTrue(drainEvents(factory.createXMLEventReader(new StringReader(BENIGN_XML))).contains("hello"), "event reader must parse the document");
389     }
390 
391     @Test
392     void newFactoryNullFactoryIdThrows() {
393         assertThrows(NullPointerException.class, () -> SecureXMLInputFactory.newFactory(null, null), "a null factory id must be rejected");
394     }
395 
396     @Test
397     void newFactoryParsesBenignDocument() throws Exception {
398         final XMLInputFactory factory = SecureXMLInputFactory.newFactory();
399         assertTrue(drainStream(factory.createXMLStreamReader(new StringReader(BENIGN_XML))).contains("hello"), "stream reader must parse the document");
400         assertTrue(drainEvents(factory.createXMLEventReader(new StringReader(BENIGN_XML))).contains("hello"), "event reader must parse the document");
401     }
402 
403     @Test
404     void newFactoryWithFactoryIdReturnsUsableSecureFactory() throws Exception {
405         final String factoryId = "org.apache.commons.xml.secure.test.inputFactory";
406         System.setProperty(factoryId, XMLInputFactory.newInstance().getClass().getName());
407         try {
408             final XMLInputFactory factory = SecureXMLInputFactory.newFactory(factoryId, getClass().getClassLoader());
409             assertNull(factory.getXMLResolver(), "a fresh secure factory must report no caller resolver");
410             assertTrue(drainStream(factory.createXMLStreamReader(new StringReader(BENIGN_XML))).contains("hello"), "factory must parse the document");
411         } finally {
412             System.clearProperty(factoryId);
413         }
414     }
415 
416     @Test
417     void newInstanceParsesBenignDocument() throws Exception {
418         final XMLInputFactory factory = SecureXMLInputFactory.newInstance();
419         assertTrue(drainStream(factory.createXMLStreamReader(new StringReader(BENIGN_XML))).contains("hello"), "stream reader must parse the document");
420         assertTrue(drainEvents(factory.createXMLEventReader(new StringReader(BENIGN_XML))).contains("hello"), "event reader must parse the document");
421     }
422 
423     @Test
424     void privateConstructorIsInvokable() throws Exception {
425         final Constructor<SecureXMLInputFactory> constructor = SecureXMLInputFactory.class.getDeclaredConstructor();
426         constructor.setAccessible(true);
427         assertNotNull(constructor.newInstance(), "the private constructor must exist and be invokable");
428     }
429 
430     @Test
431     void secureNullDelegateThrows() {
432         assertThrows(NullPointerException.class, () -> SecureXMLInputFactory.secure(null), "a null delegate must be rejected");
433     }
434 
435     @Test
436     void setPropertyCallerFloorTakesControl() {
437         final RecordingXMLInputFactory fake = new RecordingXMLInputFactory();
438         final XMLInputFactory secure = SecureXMLInputFactory.secure(fake);
439         final XMLResolver caller = (publicID, systemID, baseURI, namespace) -> "resolved";
440         final FallbackIgnoreXMLResolver ownFloor = new FallbackIgnoreXMLResolver(caller);
441         secure.setProperty(XMLInputFactory.RESOLVER, ownFloor);
442         assertSame(ownFloor, fake.resolverHook, "the caller's own floor must be handed to the delegate as-is");
443         assertSame(caller, secure.getXMLResolver(), "getXMLResolver must report the delegate of the caller's floor");
444         assertSame(caller, secure.getProperty(XMLInputFactory.RESOLVER), "getProperty must report the delegate of the caller's floor");
445     }
446 
447     @Test
448     void setPropertyNullResolverClearsCallerDelegate() {
449         final RecordingXMLInputFactory fake = new RecordingXMLInputFactory();
450         final XMLInputFactory secure = SecureXMLInputFactory.secure(fake);
451         final FallbackIgnoreXMLResolver floor = (FallbackIgnoreXMLResolver) fake.resolverHook;
452         final XMLResolver caller = (publicID, systemID, baseURI, namespace) -> null;
453         secure.setXMLResolver(caller);
454         secure.setProperty(XMLInputFactory.RESOLVER, null);
455         assertNull(floor.getDelegate(), "a null resolver property must clear the floor's delegate");
456         assertNull(secure.getXMLResolver(), "getXMLResolver must report no caller resolver");
457     }
458 
459     @Test
460     void setPropertyRoutesEveryResolverHookUniformly() {
461         final RecordingXMLInputFactory fake = new RecordingXMLInputFactory();
462         final XMLInputFactory secure = SecureXMLInputFactory.secure(fake);
463         final XMLResolver caller = (publicID, systemID, baseURI, namespace) -> "resolved";
464         for (final String hook : new String[] { XMLInputFactory.RESOLVER, SecureXMLInputFactory.WSTX_DTD_RESOLVER, SecureXMLInputFactory.WSTX_ENTITY_RESOLVER,
465                 SecureXMLInputFactory.WSTX_UNDECLARED_ENTITY_RESOLVER }) {
466             fake.setProperty(hook, null);
467             secure.setProperty(hook, caller);
468             assertSame(caller, secure.getProperty(hook), "the caller's resolver must be reported unwrapped on " + hook);
469         }
470     }
471 
472     @Test
473     void setPropertyWrapsCallerWhenHookIsNotAFloor() {
474         final RecordingXMLInputFactory fake = new RecordingXMLInputFactory();
475         final XMLInputFactory secure = SecureXMLInputFactory.secure(fake);
476         final XMLResolver caller = (publicID, systemID, baseURI, namespace) -> "resolved";
477         for (final Object foreign : new Object[] { null, (XMLResolver) (publicID, systemID, baseURI, namespace) -> "foreign" }) {
478             fake.setProperty(XMLInputFactory.RESOLVER, foreign);
479             secure.setProperty(XMLInputFactory.RESOLVER, caller);
480             assertInstanceOf(FallbackIgnoreXMLResolver.class, fake.resolverHook,
481               "a caller resolver must land behind a floor");
482             assertSame(caller, ((FallbackIgnoreXMLResolver) fake.resolverHook).getDelegate(), "the floor must delegate to the caller's resolver");
483             assertSame(caller, secure.getXMLResolver(), "getXMLResolver must report the caller's resolver unwrapped");
484         }
485     }
486 
487     @Test
488     void setPropertyWrongTypeForResolverHookReachesDelegate() {
489         final RecordingXMLInputFactory fake = new RecordingXMLInputFactory();
490         final XMLInputFactory secure = SecureXMLInputFactory.secure(fake);
491         final Object wrongType = "not a resolver";
492         secure.setProperty(XMLInputFactory.RESOLVER, wrongType);
493         assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("setProperty", XMLInputFactory.RESOLVER, wrongType)),
494                 "a wrong-typed value must reach the delegate so it can reject it");
495     }
496 
497     @Test
498     void setPropertyWrongTypeForResolverHookSurfacesDelegateException() {
499         final XMLInputFactory factory = SecureXMLInputFactory.newInstance();
500         assertThrows(ClassCastException.class, () -> factory.setProperty(XMLInputFactory.RESOLVER, "not a resolver"),
501                 "the delegate must surface its own rejection of a wrong-typed resolver");
502     }
503 
504     @Test
505     void settingAResolverInstallsAFreshFloorInsteadOfMutatingTheInstalledOne() {
506         // The implementations copy the floor reference into every reader they create, so mutating the installed floor would change the resolution policy of
507         // readers created before the call, including ones already parsing. Replacing it leaves what those readers captured alone.
508         final RecordingXMLInputFactory fake = new RecordingXMLInputFactory();
509         final XMLInputFactory secure = SecureXMLInputFactory.secure(fake);
510         final Object captured = fake.resolverHook;
511         secure.setXMLResolver((publicID, systemID, baseURI, namespace) -> null);
512         assertNotSame(captured, fake.resolverHook, "setting a resolver must install a fresh floor, not re-delegate the one already on the hook");
513         assertNull(((FallbackIgnoreXMLResolver) captured).getDelegate(), "the floor an existing reader captured must keep resolving to empty");
514     }
515 
516     @Test
517     void setXMLResolverNullClearsCallerDelegate() {
518         final RecordingXMLInputFactory fake = new RecordingXMLInputFactory();
519         final XMLInputFactory secure = SecureXMLInputFactory.secure(fake);
520         final FallbackIgnoreXMLResolver floor = (FallbackIgnoreXMLResolver) fake.resolverHook;
521         final XMLResolver caller = (publicID, systemID, baseURI, namespace) -> null;
522         secure.setXMLResolver(caller);
523         secure.setXMLResolver(null);
524         assertNull(floor.getDelegate(), "a null caller resolver must clear the floor's delegate");
525         assertNull(secure.getXMLResolver(), "getXMLResolver must report no caller resolver");
526     }
527 
528     @Test
529     void setXMLResolverRoutesCallerBehindInstalledFloor() {
530         final RecordingXMLInputFactory fake = new RecordingXMLInputFactory();
531         final XMLInputFactory secure = SecureXMLInputFactory.secure(fake);
532         final XMLResolver caller = (publicID, systemID, baseURI, namespace) -> null;
533         secure.setXMLResolver(caller);
534         // The hook keeps a floor with the caller behind it; whether that is the floor already there or a fresh one is the subject of
535         // settingAResolverInstallsAFreshFloorInsteadOfMutatingTheInstalledOne.
536         assertInstanceOf(FallbackIgnoreXMLResolver.class, fake.resolverHook,
537           "a caller resolver must land behind a floor, not replace it on the delegate's hook");
538         assertSame(caller, ((FallbackIgnoreXMLResolver) fake.resolverHook).getDelegate(), "the caller's resolver must be the floor's delegate");
539         assertSame(caller, secure.getXMLResolver(), "getXMLResolver must report the caller's resolver unwrapped");
540         assertSame(caller, secure.getProperty(XMLInputFactory.RESOLVER), "getProperty must report the caller's resolver unwrapped");
541     }
542 
543     @Test
544     void unsupportedResolverHookSurfacesDelegateError() {
545         final XMLInputFactory factory = SecureXMLInputFactory.newInstance();
546         Assumptions.assumeFalse(factory.isPropertySupported(SecureXMLInputFactory.WSTX_DTD_RESOLVER), "requires an implementation without the Woodstox hooks");
547         final XMLResolver caller = (publicID, systemID, baseURI, namespace) -> null;
548         assertThrows(IllegalArgumentException.class, () -> factory.setProperty(SecureXMLInputFactory.WSTX_DTD_RESOLVER, caller),
549                 "the delegate must surface its own rejection of an unknown resolver hook");
550         assertThrows(IllegalArgumentException.class, () -> factory.getProperty(SecureXMLInputFactory.WSTX_DTD_RESOLVER),
551                 "the delegate must surface its own rejection of an unknown resolver hook");
552     }
553 
554     @Test
555     void woodstoxDtdHookRoutesBehindInstalledFloor() {
556         final XMLInputFactory factory = SecureXMLInputFactory.newInstance();
557         Assumptions.assumeTrue(factory.isPropertySupported(SecureXMLInputFactory.WSTX_DTD_RESOLVER), "requires the Woodstox DTD resolver hook");
558         final XMLResolver first = (publicID, systemID, baseURI, namespace) -> null;
559         factory.setProperty(SecureXMLInputFactory.WSTX_DTD_RESOLVER, first);
560         assertSame(first, factory.getProperty(SecureXMLInputFactory.WSTX_DTD_RESOLVER), "the Woodstox hook must report the caller's resolver unwrapped");
561         final XMLResolver second = (publicID, systemID, baseURI, namespace) -> "resolved";
562         factory.setProperty(SecureXMLInputFactory.WSTX_DTD_RESOLVER, second);
563         assertSame(second, factory.getProperty(SecureXMLInputFactory.WSTX_DTD_RESOLVER), "a second caller resolver must replace the first behind the floor");
564     }
565 
566     @Test
567     void woodstoxResolverHooksStayIndependent() {
568         // Woodstox routes setXMLResolver to both its DTD-subset and entity hooks, so one floor object sits on several of them. Setting one hook must not
569         // answer the others, which it would if the shared floor were mutated in place.
570         final XMLInputFactory secure = SecureXMLInputFactory.newInstance();
571         final XMLResolver dtd = (publicID, systemID, baseURI, namespace) -> null;
572         try {
573             secure.setProperty(SecureXMLInputFactory.WSTX_DTD_RESOLVER, dtd);
574         } catch (final IllegalArgumentException notWoodstox) {
575             Assumptions.abort("the implementation does not support " + SecureXMLInputFactory.WSTX_DTD_RESOLVER);
576             return;
577         }
578         assertSame(dtd, secure.getProperty(SecureXMLInputFactory.WSTX_DTD_RESOLVER), "the hook the caller named must report their resolver");
579         assertNull(secure.getProperty(SecureXMLInputFactory.WSTX_ENTITY_RESOLVER), "a resolver set on the DTD hook must not answer the entity hook");
580     }
581 
582     @Test
583     void woodstoxUndeclaredEntityHookWrapsCallerResolver() {
584         final XMLInputFactory factory = SecureXMLInputFactory.newInstance();
585         Assumptions.assumeTrue(factory.isPropertySupported(SecureXMLInputFactory.WSTX_UNDECLARED_ENTITY_RESOLVER),
586                 "requires the Woodstox undeclared-entity resolver hook");
587         final XMLResolver caller = (publicID, systemID, baseURI, namespace) -> null;
588         factory.setProperty(SecureXMLInputFactory.WSTX_UNDECLARED_ENTITY_RESOLVER, caller);
589         assertSame(caller, factory.getProperty(SecureXMLInputFactory.WSTX_UNDECLARED_ENTITY_RESOLVER),
590                 "the Woodstox hook must report the caller's resolver unwrapped");
591     }
592 
593     @Test
594     void wrapperDelegatesAllocatorAndReporter() {
595         final RecordingXMLInputFactory fake = new RecordingXMLInputFactory();
596         final XMLInputFactory secure = SecureXMLInputFactory.secure(fake);
597         final XMLEventAllocator allocator = RecordingXMLInputFactory.ALLOCATOR_SENTINEL;
598         secure.setEventAllocator(allocator);
599         assertSame(allocator, secure.getEventAllocator(), "the allocator must round-trip through the delegate");
600         assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("setEventAllocator", allocator)), "the exact allocator must be forwarded");
601         final XMLReporter reporter = RecordingXMLInputFactory.REPORTER_SENTINEL;
602         secure.setXMLReporter(reporter);
603         assertSame(reporter, secure.getXMLReporter(), "the reporter must round-trip through the delegate");
604         assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("setXMLReporter", reporter)), "the exact reporter must be forwarded");
605     }
606 
607     @Test
608     void wrapperDelegatesIsPropertySupported() {
609         final RecordingXMLInputFactory fake = new RecordingXMLInputFactory();
610         final XMLInputFactory secure = SecureXMLInputFactory.secure(fake);
611         assertTrue(secure.isPropertySupported(XMLInputFactory.SUPPORT_DTD), "the delegate's answer must be reported");
612         fake.supported = false;
613         assertFalse(secure.isPropertySupported(XMLInputFactory.SUPPORT_DTD), "the delegate's answer must be reported");
614         assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("isPropertySupported", XMLInputFactory.SUPPORT_DTD)),
615                 "the exact property name must be forwarded");
616     }
617 
618     @Test
619     void wrapperDelegatesNonResolverProperties() {
620         final RecordingXMLInputFactory fake = new RecordingXMLInputFactory();
621         final XMLInputFactory secure = SecureXMLInputFactory.secure(fake);
622         secure.setProperty(XMLInputFactory.SUPPORT_DTD, Boolean.TRUE);
623         assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("setProperty", XMLInputFactory.SUPPORT_DTD, Boolean.TRUE)),
624                 "a non-resolver property must reach the delegate unmodified");
625         fake.resolverHook = Boolean.TRUE;
626         assertEquals(Boolean.TRUE, secure.getProperty(XMLInputFactory.SUPPORT_DTD), "a non-resolver property must be reported unmodified");
627     }
628 
629     @Test
630     void wrapperDelegatesReaderCreationToDelegate() throws Exception {
631         final RecordingXMLInputFactory fake = new RecordingXMLInputFactory();
632         final XMLInputFactory secure = SecureXMLInputFactory.secure(fake);
633         final XMLStreamReader streamSentinel = RecordingXMLInputFactory.STREAM_SENTINEL;
634         final XMLEventReader eventSentinel = RecordingXMLInputFactory.EVENT_SENTINEL;
635         final EventFilter eventFilter = RecordingXMLInputFactory.EVENT_FILTER_SENTINEL;
636         final StreamFilter streamFilter = RecordingXMLInputFactory.STREAM_FILTER_SENTINEL;
637         final InputStream stream = new ByteArrayInputStream(BENIGN_XML.getBytes(StandardCharsets.UTF_8));
638         final StringReader reader = new StringReader(BENIGN_XML);
639         final Source source = new StreamSource(new StringReader(BENIGN_XML));
640         assertSame(streamSentinel, secure.createXMLStreamReader(stream));
641         assertSame(streamSentinel, secure.createXMLStreamReader(stream, "UTF-8"));
642         assertSame(streamSentinel, secure.createXMLStreamReader(reader));
643         assertSame(streamSentinel, secure.createXMLStreamReader(source));
644         assertSame(streamSentinel, secure.createXMLStreamReader(SYSTEM_ID, stream));
645         assertSame(streamSentinel, secure.createXMLStreamReader(SYSTEM_ID, reader));
646         assertSame(eventSentinel, secure.createXMLEventReader(stream));
647         assertSame(eventSentinel, secure.createXMLEventReader(stream, "UTF-8"));
648         assertSame(eventSentinel, secure.createXMLEventReader(reader));
649         assertSame(eventSentinel, secure.createXMLEventReader(source));
650         assertSame(eventSentinel, secure.createXMLEventReader(SYSTEM_ID, stream));
651         assertSame(eventSentinel, secure.createXMLEventReader(SYSTEM_ID, reader));
652         assertSame(eventSentinel, secure.createXMLEventReader(streamSentinel));
653         assertSame(eventSentinel, secure.createFilteredReader(eventSentinel, eventFilter));
654         assertSame(streamSentinel, secure.createFilteredReader(streamSentinel, streamFilter));
655         assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("createXMLStreamReader", stream)), "the exact stream must be forwarded");
656         assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("createXMLStreamReader", reader)), "the exact reader must be forwarded");
657         assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("createXMLStreamReader", source)), "the exact source must be forwarded");
658         assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("createXMLStreamReader", SYSTEM_ID, stream)), "the exact system id must be forwarded");
659         assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("createXMLEventReader", stream, "UTF-8")), "the exact encoding must be forwarded");
660         assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("createFilteredReader", eventSentinel, eventFilter)),
661                 "the exact event filter must be forwarded");
662         assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("createFilteredReader", streamSentinel, streamFilter)),
663                 "the exact stream filter must be forwarded");
664     }
665 
666     @Test
667     void wrapperInstallsFloorOnDelegateHook() {
668         final RecordingXMLInputFactory fake = new RecordingXMLInputFactory();
669         final XMLInputFactory secure = SecureXMLInputFactory.secure(fake);
670         assertNotNull(secure);
671         assertTrue(fake.calls.contains(RecordingXMLInputFactory.call("setXMLResolver", fake.resolverHook)),
672                 "the floor must be installed through the delegate's setXMLResolver");
673         assertInstanceOf(FallbackIgnoreXMLResolver.class, fake.resolverHook,
674           "the constructor must install the ignore-all floor on the delegate's resolver hook");
675         assertNull(((FallbackIgnoreXMLResolver) fake.resolverHook).getDelegate(), "the installed floor must have no caller delegate");
676     }
677 }