diff --git a/exist-core/src/main/java/org/exist/resolver/XercesXmlResolverAdapter.java b/exist-core/src/main/java/org/exist/resolver/XercesXmlResolverAdapter.java index 7c58786b6c5..10c853191fa 100644 --- a/exist-core/src/main/java/org/exist/resolver/XercesXmlResolverAdapter.java +++ b/exist-core/src/main/java/org/exist/resolver/XercesXmlResolverAdapter.java @@ -43,6 +43,7 @@ import org.apache.xerces.xni.parser.XMLEntityResolver; import org.apache.xerces.xni.parser.XMLInputSource; import org.exist.util.XMLReaderObjectFactory; +import org.w3c.dom.ls.LSResourceResolver; import org.xml.sax.*; import org.xmlresolver.Resolver; @@ -165,4 +166,45 @@ public static void setXmlReaderEntityResolver(final XMLReader xmlReader, @Nullab public Resolver getResolver() { return resolver; } + + /** + * Wraps {@code resolver} as an {@link LSResourceResolver}, falling back to {@code namespaceURI} + * as the system id whenever the caller doesn't supply one -- the same fallback {@link + * #resolveEntity} already performs for the XNI/SAX pipeline (see its {@code systemId} lookup). + * + *

This fallback matters because {@link Resolver#resolveResource} requires a non-null system + * id before it will consult its catalog at all; without it, an OASIS catalog's {@code } + * entry keyed purely by namespace (no {@code schemaLocation} hint on the instance) is invisible + * to the XSD 1.1 dynamic-discovery {@link javax.xml.validation.Validator}, which calls {@link + * LSResourceResolver#resolveResource} with the root element's namespace but no system id -- + * even though the same catalog entry already works for the default SAX pipeline via {@link + * #resolveEntity}.

+ * + * @param resolver the resolver to wrap. + * + * @return an {@link LSResourceResolver} view of {@code resolver} with the namespace fallback applied. + */ + public static LSResourceResolver asLSResourceResolver(final Resolver resolver) { + return (type, namespaceURI, publicId, systemId, baseURI) -> { + final String effectiveSystemId = systemId != null ? systemId : namespaceURI; + return resolver.resolveResource(type, namespaceURI, publicId, effectiveSystemId, baseURI); + }; + } + + /** + * As {@link #asLSResourceResolver(Resolver)}, but accepting whichever concrete resolver type + * {@code Shared#resolveCatalogArgument} returns -- the namespace fallback is only applied when + * {@code resolver} is an {@link Resolver} (the implementation that needs it); any other {@link + * LSResourceResolver} (e.g. {@link org.exist.validation.resolver.SearchResourceResolver}, which + * already resolves purely by namespace on its own) is returned unchanged, as is {@code null}. + * + * @param resolver the resolver to adapt, or null. + * + * @return an {@link LSResourceResolver} with the namespace fallback applied where needed, or + * {@code null} if {@code resolver} was {@code null}. + */ + @Nullable + public static LSResourceResolver asLSResourceResolver(@Nullable final LSResourceResolver resolver) { + return resolver instanceof Resolver xmlResolver ? asLSResourceResolver(xmlResolver) : resolver; + } } diff --git a/exist-core/src/main/java/org/exist/xquery/functions/validation/Jaxp.java b/exist-core/src/main/java/org/exist/xquery/functions/validation/Jaxp.java index c288358fa32..a71ed10c136 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/validation/Jaxp.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/validation/Jaxp.java @@ -565,8 +565,10 @@ private GrammarPool getGrammarPool() { * @return a {@link Validator} for the only XSD 1.1-capable pipeline this * Xerces fork supports: {@link SchemaFactory}/{@link Schema} with no * pre-supplied schema documents, so it dynamically discovers the schema - * from the instance's own schemaLocation hint, mirroring how the default - * SAXParser pipeline behaves for XSD 1.0. + * from the instance's own schemaLocation hint or, when {@code resolver} is + * an OASIS/system catalog, purely from the root element's namespace via + * {@link XercesXmlResolverAdapter#asLSResourceResolver}, mirroring how the + * default SAXParser pipeline behaves for XSD 1.0. */ private Validator newXsd11Validator(@Nullable final LSResourceResolver resolver, final boolean useCache) throws SAXException { final SchemaFactory schemaFactory = SchemaFactory.newInstance(XSD_1_1_NS); @@ -581,7 +583,12 @@ private Validator newXsd11Validator(@Nullable final LSResourceResolver resolver, final Schema schema = schemaFactory.newSchema(); final Validator validator = schema.newValidator(); if (resolver != null) { - validator.setResourceResolver(resolver); + // org.xmlresolver.Resolver#resolveResource requires a non-null systemId before it will + // consult its catalog at all (unlike SearchResourceResolver, which resolves purely by + // namespace), so an OASIS catalog's entry keyed by namespace alone is otherwise + // invisible to this dynamic-discovery validator when the instance carries no + // schemaLocation hint. + validator.setResourceResolver(XercesXmlResolverAdapter.asLSResourceResolver(resolver)); } return validator; } diff --git a/exist-core/src/main/java/org/exist/xquery/functions/validation/Jaxv.java b/exist-core/src/main/java/org/exist/xquery/functions/validation/Jaxv.java index 010f626955b..f881c964ae0 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/validation/Jaxv.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/validation/Jaxv.java @@ -32,6 +32,7 @@ import org.exist.dom.QName; import org.exist.dom.memtree.MemTreeBuilder; import org.exist.dom.memtree.NodeImpl; +import org.exist.resolver.XercesXmlResolverAdapter; import org.exist.storage.BrokerPool; import org.exist.validation.ValidationReport; import org.exist.xquery.BasicFunction; @@ -237,10 +238,13 @@ public Sequence eval(Sequence[] args, Sequence contextSequence) throws XPathExce // directly, and SearchResourceResolver also implements it (in addition to the // Xerces-specific XMLEntityResolver/XNI interface it uses for validation:jaxp()'s // SAX pipeline), so the directory-search/collection case (a catalog URL ending - // in '/') works here too. + // in '/') works here too. Wrapped via XercesXmlResolverAdapter#asLSResourceResolver + // so an xs:import with no schemaLocation (resolved purely by namespace) also works + // through an org.xmlresolver.Resolver-backed (OASIS/system) catalog -- see that + // method's Javadoc for why the raw Resolver alone can't do this. if (args.length == 4) { final LSResourceResolver resolver = Shared.resolveCatalogArgument(this, brokerPool, context.getBroker(), context.getSubject(), args[3]); - factory.setResourceResolver(resolver); + factory.setResourceResolver(XercesXmlResolverAdapter.asLSResourceResolver(resolver)); } // Create grammar -- xs:import/xs:include resolution (via the resolver se diff --git a/exist-core/src/test/java/org/exist/xquery/functions/validate/NativeSchemaJaxpCatalogGapTest.java b/exist-core/src/test/java/org/exist/xquery/functions/validate/NativeSchemaJaxpCatalogGapTest.java new file mode 100644 index 00000000000..dddc1359f03 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/functions/validate/NativeSchemaJaxpCatalogGapTest.java @@ -0,0 +1,207 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.functions.validate; + +import org.custommonkey.xmlunit.exceptions.XpathException; +import org.exist.test.ExistXmldbEmbeddedServer; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.xml.sax.SAXException; +import org.xmldb.api.base.Collection; +import org.xmldb.api.base.ResourceSet; +import org.xmldb.api.base.XMLDBException; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.custommonkey.xmlunit.XMLAssert.assertXpathEvaluatesTo; +import static org.exist.collections.CollectionConfiguration.DEFAULT_COLLECTION_CONFIG_FILE; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Closes the integration gap left by #6528: + * native XSDs ship at {@code $EXIST_HOME/schema/} and {@code WEB-INF/catalog.xml} maps + * {@code http://exist-db.org/collection-config/1.0} to {@code collection.xconf.xsd}, but nothing + * asserts that {@code validation:jaxp*} can apply that grammar via an OASIS catalog the way + * tools (e.g. eXide) are expected to. + * + *

{@link #jaxvXsd11AgainstNativeCollectionSchemaValid} / + * {@link #jaxvXsd11AgainstNativeCollectionSchemaEmptyFailsAssert} are the control: the schema + * file itself is fine when handed to {@code validation:jaxv-report} with the XSD 1.1 language URI. + * {@link #jaxpViaOasisCatalogAgainstNativeCollectionSchemaValid} was the missing path — it used to + * fail with {@code cvc-elt.1.a} (declaration not found) even when the catalog {@code } entry + * pointed at an absolute {@code file:} URI of the same XSD, because {@code + * org.xmlresolver.Resolver#resolveResource} requires a non-null system id before consulting its + * catalog, and the XSD 1.1 dynamic-discovery validator calls it with the root namespace but no + * system id when the instance carries no {@code schemaLocation} hint. Fixed by {@link + * org.exist.resolver.XercesXmlResolverAdapter#asLSResourceResolver}.

+ * + *

Existing XSD 1.1 coverage in {@link JaxpXsdCatalogTest} only exercises + * directory-search catalogs over {@code /db/.../}; {@link + * org.exist.validation.CollectionConfigSchema6000Test} validates the native XSD via raw + * {@code SchemaFactory}, not {@code validation:jaxp}.

+ * + * @see #6686 + * @see eXide#842 + */ +public class NativeSchemaJaxpCatalogGapTest { + + private static final String COLLECTION_CONFIG_NS = "http://exist-db.org/collection-config/1.0"; + private static final String XSD_1_1 = "http://www.w3.org/XML/XMLSchema/v1.1"; + + private static final String VALID_COLLECTION_XCONF = """ + + + + """; + + private static final String EMPTY_COLLECTION_XCONF = """ + + """; + + private static final String NO_VALIDATION = """ + + + + + """; + + @ClassRule + public static final ExistXmldbEmbeddedServer existEmbeddedServer = + new ExistXmldbEmbeddedServer(false, true, true); + + private static Path nativeSchemaPath; + private static String nativeSchemaFileUri; + + @BeforeClass + public static void prepareResources() throws XMLDBException, IOException { + nativeSchemaPath = resolveSchemaPath(); + assertTrue(""" + Native schema not found at %s \ + (run from repo root: mvn test -pl exist-core -Dtest=NativeSchemaJaxpCatalogGapTest) + """.formatted(nativeSchemaPath).strip(), + Files.exists(nativeSchemaPath)); + nativeSchemaFileUri = nativeSchemaPath.toAbsolutePath().toUri().toString(); + + try (Collection conf = existEmbeddedServer.createCollection(existEmbeddedServer.getRoot(), + "system/config/db/native-schema-gap")) { + ExistXmldbEmbeddedServer.storeResource(conf, DEFAULT_COLLECTION_CONFIG_FILE, + NO_VALIDATION.getBytes()); + } + + try (Collection col = existEmbeddedServer.createCollection(existEmbeddedServer.getRoot(), + "native-schema-gap")) { + // Mirrors WEB-INF/catalog.xml's collection-config mapping, but with an absolute + // file: URI so the embedded harness does not depend on $EXIST_HOME layout. + final String catalog = """ + + + + + """.formatted(COLLECTION_CONFIG_NS, escapeXmlAttribute(nativeSchemaFileUri)); + ExistXmldbEmbeddedServer.storeResource(col, "catalog.xml", catalog.getBytes()); + ExistXmldbEmbeddedServer.storeResource(col, "valid.xml", VALID_COLLECTION_XCONF.getBytes()); + ExistXmldbEmbeddedServer.storeResource(col, "empty.xml", EMPTY_COLLECTION_XCONF.getBytes()); + } + } + + @Before + public void clearGrammarCache() throws XMLDBException { + final ResourceSet results = existEmbeddedServer.executeQuery("validation:clear-grammar-cache()"); + results.getResource(0).getContent(); + } + + @Test + public void jaxvXsd11AgainstNativeCollectionSchemaValid() throws Exception { + final String query = """ + validation:jaxv-report( + doc('/db/native-schema-gap/valid.xml'), + xs:anyURI('%s'), + '%s') + """.formatted(nativeSchemaFileUri, XSD_1_1); + assertReportStatus(query, "valid"); + } + + @Test + public void jaxvXsd11AgainstNativeCollectionSchemaEmptyFailsAssert() throws Exception { + final String query = """ + validation:jaxv-report( + doc('/db/native-schema-gap/empty.xml'), + xs:anyURI('%s'), + '%s') + """.formatted(nativeSchemaFileUri, XSD_1_1); + final String report = executeOne(query); + assertXpathEvaluatesTo("invalid", "//status/text()", report); + assertTrue("expected xs:assert failure, got: " + report, + report.contains("cvc-assertion") || report.contains("count(*)")); + } + + /** + * OASIS catalog → native {@code collection.xconf.xsd}, resolved purely by namespace (no + * {@code schemaLocation} hint on the instance). + */ + @Test + public void jaxpViaOasisCatalogAgainstNativeCollectionSchemaValid() throws Exception { + final String query = """ + validation:jaxp-report( + doc('/db/native-schema-gap/valid.xml'), + false(), + xs:anyURI('/db/native-schema-gap/catalog.xml')) + """; + assertReportStatus(query, "valid"); + } + + private static void assertReportStatus(final String query, final String expected) + throws XMLDBException, SAXException, IOException, XpathException { + assertXpathEvaluatesTo(expected, "//status/text()", executeOne(query)); + } + + private static String executeOne(final String query) throws XMLDBException { + final ResourceSet results = existEmbeddedServer.executeQuery(query); + assertEquals(1, results.getSize()); + return (String) results.getResource(0).getContent(); + } + + private static Path resolveSchemaPath() { + final Path base = Path.of(System.getProperty("user.dir")); + Path p = base.resolve("schema").resolve("collection.xconf.xsd"); + if (!Files.exists(p)) { + p = base.getParent().resolve("schema").resolve("collection.xconf.xsd"); + } + return p; + } + + /** + * Escapes {@code value} for use inside a double-quoted XML attribute. Needed because {@link + * #nativeSchemaFileUri} is derived from the checkout's own filesystem path via {@link + * Path#toUri()}, which does not escape XML-attribute-special characters (e.g. {@code &}) that + * a directory name could legally contain -- without this, such a path would produce a + * malformed {@code catalog.xml}. + */ + private static String escapeXmlAttribute(final String value) { + return value.replace("&", "&").replace("<", "<").replace("\"", """); + } +}