diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/transform/Convert.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/transform/Convert.java
index 98a02f5aadb..93fd22fbb15 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/fn/transform/Convert.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/transform/Convert.java
@@ -158,6 +158,7 @@ private XdmValue ofNode(final Node node) throws XPathException {
final DocumentBuilder sourceBuilder = newDocumentBuilder();
try {
if (node instanceof Document) {
+ // a document node (in-memory or persistent) can be built directly
return sourceBuilder.build(new DOMSource(node));
} else {
//The source must be part of a document
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/transform/Delivery.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/transform/Delivery.java
index 682e910e2d0..7d58d70008c 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/fn/transform/Delivery.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/transform/Delivery.java
@@ -57,15 +57,15 @@ enum Format {
this.serializationProperties = serializationProperties;
}
- final Destination createDestination(final Xslt30Transformer xslt30Transformer, final boolean forceCreation) {
+ final Destination createDestination(final Xslt30Transformer xslt30Transformer) {
switch (format) {
case DOCUMENT:
- if (!forceCreation) {
- this.builder = context.getDocumentBuilder();
- } else {
- this.builder = new MemTreeBuilder(context);
- this.builder.startDocument();
- }
+ // NOTE: Always build the result into a fresh document builder.
+ // The shared builder of the XQueryContext may already be in use
+ // by an enclosing expression (e.g. an element constructor).
+ // convert() returns the builder's whole document - using the shared builder corrupts both.
+ this.builder = new MemTreeBuilder(context);
+ this.builder.startDocument();
return new SAXDestination(new DocumentBuilderReceiver(builder));
case SERIALIZED:
final Serializer serializer = xslt30Transformer.newSerializer();
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/transform/Options.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/transform/Options.java
index e59e6567a1b..8543648c730 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/fn/transform/Options.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/transform/Options.java
@@ -32,9 +32,12 @@
import net.sf.saxon.s9api.XdmValue;
import org.apache.commons.lang3.StringUtils;
import org.exist.dom.memtree.NamespaceNode;
+import org.exist.dom.persistent.NodeProxy;
+import org.exist.security.PermissionDeniedException;
import org.exist.xquery.ErrorCodes;
import org.exist.xquery.XPathException;
import org.exist.xquery.XQueryContext;
+import org.exist.xquery.util.DocUtils;
import org.exist.xquery.functions.array.ArrayType;
import org.exist.xquery.functions.fn.FnTransform;
import org.exist.xquery.functions.map.MapType;
@@ -166,7 +169,12 @@ class Options {
stylesheetBaseUri = xsltSource._1;
}
if (!StringUtils.isEmpty(stylesheetBaseUri)) {
- resolvedStylesheetBaseURI = Optional.of(resolveURI(new AnyURIValue(stylesheetBaseUri), context.getBaseURI()));
+ // Only resolve if it's not already absolute (database URIs start with "/" or "xmldb:")
+ if (stylesheetBaseUri.startsWith("/") || stylesheetBaseUri.startsWith("xmldb:") || stylesheetBaseUri.startsWith("exist://")) {
+ resolvedStylesheetBaseURI = Optional.of(new AnyURIValue(stylesheetBaseUri));
+ } else {
+ resolvedStylesheetBaseURI = Optional.of(resolveURI(new AnyURIValue(stylesheetBaseUri), context.getBaseURI()));
+ }
} else {
resolvedStylesheetBaseURI = Optional.empty();
}
@@ -467,7 +475,7 @@ private Tuple2
+ * A relative location is first resolved the way {@code fn:doc} resolves + * relative paths: against the base URI of the query (where a collection + * path is treated as a "directory") and/or the location of the querying + * module within the database. If that does not find a document, the + * location is resolved strictly against the static base URI according + * to RFC 3986 (e.g. for file: or http: base URIs). + * See issue 5052. + *
* @param stylesheetLocation path or URI of stylesheet - * @return a source wrapping the contents of the stylesheet + * @return a Tuple whose first value is the actual location of the resolved + * stylesheet, and whose second value is a source wrapping its contents * @throws XPathException if there is a problem resolving the location. */ - private Source resolveStylesheetLocation(final String stylesheetLocation) throws XPathException { + private Tuple2+ * A location within the database is resolved against the base as if the base + * were a collection whenever {@link #assumeCollection(String)} holds, and as + * if it were a document otherwise. A base outside the database (for instance a + * {@code file:} or {@code http:} URI) is always resolved strictly according to + * RFC 3986, that is, as if it were a document. + *
* @param relative URI to resolve * @param base to resolve against * @return resolved URI * @throws URISyntaxException if resolution is not possible */ static AnyURIValue resolveURI(final AnyURIValue relative, final AnyURIValue base) throws URISyntaxException, XPathException { - var relativeURI = new URI(relative.getStringValue()); + final URI relativeURI = new URI(relative.getStringValue()); if (relativeURI.isAbsolute()) { return relative; } - var baseURI = new URI(base.getStringValue() ); - if (!baseURI.isAbsolute()) { + final String baseString = base.getStringValue(); + final URI baseURI = new URI(baseString); + // a database path such as /db/apps/app has no scheme, so URI#isAbsolute is false, + // yet it is an absolute location within the database and can be resolved against + final boolean isAbsoluteBase = baseURI.isAbsolute() || baseString.startsWith("/"); + if (!isAbsoluteBase) { return relative; } try { - var xBase = XmldbURI.xmldbUriFor(baseURI); - var resolved = xBase.getURI().resolve(relativeURI); + final XmldbURI xBase = XmldbURI.xmldbUriFor(baseURI); + // NOTE: for an xmldb: base, XmldbURI#getURI has already stripped the xmldb: prefix, + // but for the short form (xmldb:/db/...) it has not; only add the prefix if it is absent, + // otherwise the result doubles up as xmldb:xmldb:/db/... + final URI resolved = asResolutionBase(xBase.getURI()).resolve(relativeURI); + if (XmldbURI.XMLDB_SCHEME.equals(resolved.getScheme())) { + return new AnyURIValue(resolved.toString()); + } return new AnyURIValue(XmldbURI.XMLDB_URI_PREFIX + resolved); - } catch (URISyntaxException e) { + } catch (final URISyntaxException e) { return new AnyURIValue(baseURI.resolve(relativeURI)); } } + /** + * Prepare a location within the database to be resolved against. + *+ * RFC 3986 discards the last segment of the base unless it is empty, which is + * correct for a document but not for a collection: resolving {@code style.xsl} + * against the collection {@code /db/apps/app} would yield {@code /db/apps/style.xsl}. + * A collection is therefore given the trailing slash that marks it as a + * "directory" before it is resolved against. + *
+ * + * @param base location within the database + * @return the location to resolve against + */ + private static URI asResolutionBase(final URI base) { + final String baseString = base.toString(); + if (!baseString.endsWith("/") && assumeCollection(baseString)) { + return URI.create(baseString + "/"); + } + return base; + } + + /** + * Whether a location within the database is assumed to be a collection. + *+ * A collection and a document are not distinguishable by their path alone, so + * the absence of an extension in the last segment is taken to mean a collection. + * This is a heuristic: a document stored without an extension (which is legal, + * if unusual) is mistaken for a collection. + *
+ * + * @param location within the database + * @return true if the location is assumed to be a collection + */ + private static boolean assumeCollection(final String location) { + final String lastSegment = location.substring(location.lastIndexOf('/') + 1); + return lastSegment.indexOf('.') == -1; + } + public static class CompileTimeURIResolver implements URIResolver { private final XQueryContext xQueryContext; @@ -85,7 +140,7 @@ public Source resolve(final String href, final String base) throws TransformerEx try { final AnyURIValue baseURI = new AnyURIValue(base); final AnyURIValue hrefURI = new AnyURIValue(href); - var resolved = resolveURI(hrefURI, baseURI); + final AnyURIValue resolved = resolveURI(hrefURI, baseURI); return resolveDocument(resolved.getStringValue()); } catch (URISyntaxException e) { throw new TransformerException( @@ -123,10 +178,14 @@ static Source resolveDocument(final String location, final XQueryContext xQueryC } if (document.hasOne() && Type.subTypeOf(document.getItemType(), Type.NODE)) { if (document instanceof NodeProxy proxy) { - return new DOMSource(proxy.getNode()); + final DOMSource source = new DOMSource(proxy.getNode()); + source.setSystemId(location); + return source; } else if (document.itemAt(0) instanceof Node node) { - return new DOMSource(node); + final DOMSource source = new DOMSource(node); + source.setSystemId(location); + return source; } } throw new XPathException(containingExpression, ErrorCodes.FODC0002, diff --git a/exist-core/src/test/java/org/exist/xquery/functions/fn/transform/FunTransformTest.java b/exist-core/src/test/java/org/exist/xquery/functions/fn/transform/FunTransformTest.java index 0a6f6b84099..6a095aac65f 100644 --- a/exist-core/src/test/java/org/exist/xquery/functions/fn/transform/FunTransformTest.java +++ b/exist-core/src/test/java/org/exist/xquery/functions/fn/transform/FunTransformTest.java @@ -114,6 +114,59 @@ public void resolution() throws XPathException, URISyntaxException { URIResolution.resolveURI(relative5, base5)); } + @Test + public void resolutionAgainstDatabasePath() throws XPathException, URISyntaxException { + final AnyURIValue relative = new AnyURIValue("functions1.xsl"); + + // a database path has no scheme, yet it is an absolute location within the database + final AnyURIValue databaseBase = new AnyURIValue("/db/apps/fn_transform/tei-toc2.xsl"); + final AnyURIValue resolved = URIResolution.resolveURI(relative, databaseBase); + assertEquals(new AnyURIValue("xmldb:/db/apps/fn_transform/functions1.xsl"), resolved); + + // a resolved stylesheet becomes the base for the relative xsl:import it contains, + // so resolving against an xmldb: base must not prepend a second xmldb: prefix + assertEquals(new AnyURIValue("xmldb:/db/apps/fn_transform/functions2.xsl"), + URIResolution.resolveURI(new AnyURIValue("functions2.xsl"), resolved)); + + final AnyURIValue shortBase = new AnyURIValue("xmldb:/db/apps/fn_transform/tei-toc2.xsl"); + assertEquals(new AnyURIValue("xmldb:/db/apps/fn_transform/functions1.xsl"), + URIResolution.resolveURI(relative, shortBase)); + + // the instance name and authority of an xmldb: base are preserved + final AnyURIValue remoteBase = new AnyURIValue("xmldb:exist://localhost:8080/db/apps/fn_transform/tei-toc2.xsl"); + assertEquals(new AnyURIValue("xmldb:exist://localhost:8080/db/apps/fn_transform/functions1.xsl"), + URIResolution.resolveURI(relative, remoteBase)); + } + + @Test + public void resolutionAgainstCollection() throws XPathException, URISyntaxException { + final AnyURIValue relative = new AnyURIValue("functions1.xsl"); + + // the last segment of a collection has no extension, so it is kept when resolving against it + final AnyURIValue collectionBase = new AnyURIValue("/db/apps/fn_transform"); + assertEquals(new AnyURIValue("xmldb:/db/apps/fn_transform/functions1.xsl"), + URIResolution.resolveURI(relative, collectionBase)); + + final AnyURIValue xmldbCollectionBase = new AnyURIValue("xmldb:exist:///db/apps/fn_transform"); + assertEquals(new AnyURIValue("xmldb:exist:/db/apps/fn_transform/functions1.xsl"), + URIResolution.resolveURI(relative, xmldbCollectionBase)); + + // a collection that already ends in a slash is not given a second one + final AnyURIValue slashedBase = new AnyURIValue("/db/apps/fn_transform/"); + assertEquals(new AnyURIValue("xmldb:/db/apps/fn_transform/functions1.xsl"), + URIResolution.resolveURI(relative, slashedBase)); + + // the last segment of a document has an extension, so it is discarded when resolving against it + final AnyURIValue documentBase = new AnyURIValue("/db/apps/fn_transform/tei-toc2.xsl"); + assertEquals(new AnyURIValue("xmldb:/db/apps/fn_transform/functions1.xsl"), + URIResolution.resolveURI(relative, documentBase)); + + // outside of the database RFC 3986 applies, so the last segment is discarded regardless + final AnyURIValue httpBase = new AnyURIValue("https://127.0.0.1:8088/db/apps/fn_transform"); + assertEquals(new AnyURIValue("https://127.0.0.1:8088/db/apps/functions1.xsl"), + URIResolution.resolveURI(relative, httpBase)); + } + /** * Create some UT coverage of the CompileTimeURIResolver * This is more significantly exercised by XQTS tests diff --git a/exist-core/src/test/xquery/xquery3/transform/fnTransform5052.xqm b/exist-core/src/test/xquery/xquery3/transform/fnTransform5052.xqm new file mode 100644 index 00000000000..f13cd5f8fd6 --- /dev/null +++ b/exist-core/src/test/xquery/xquery3/transform/fnTransform5052.xqm @@ -0,0 +1,199 @@ +(: + : 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 + :) +xquery version "3.1"; + +(:~ + : Tests for issue 5052: fn:transform does not resolve relative URIs + : against the database. + : + : Covers: + : - relative xsl:include / xsl:import hrefs in stylesheets stored in the + : database (resolved from the containing collection of the stylesheet) + : - a relative "stylesheet-location" in a query stored in the database + : (resolved from the containing collection of the query, consistent + : with fn:doc and transform:transform) + : + : @see https://github.com/eXist-db/exist/issues/5052 + :) +module namespace t5052="http://exist-db.org/xquery/test/fn-transform-5052"; + +import module namespace xmldb="http://exist-db.org/xquery/xmldb"; +import module namespace util="http://exist-db.org/xquery/util"; + +declare namespace test="http://exist-db.org/xquery/xqsuite"; +declare namespace xsl="http://www.w3.org/1999/XSL/Transform"; + +declare variable $t5052:coll-name := "fn-transform-5052"; +declare variable $t5052:coll := "/db/" || $t5052:coll-name; + +declare variable $t5052:plain-xsl := +