Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -467,7 +475,7 @@ private Tuple2<String, Source> getStylesheet(final MapType options) throws XPath
final List<Tuple2<String, Source>> results = new ArrayList<>(1);
final Optional<String> stylesheetLocation = Options.STYLESHEET_LOCATION.get(options).map(StringValue::getStringValue);
if (stylesheetLocation.isPresent()) {
results.add(Tuple(stylesheetLocation.get(), resolveStylesheetLocation(stylesheetLocation.get())));
results.add(resolveStylesheetLocation(stylesheetLocation.get()));
}

final Optional<Node> stylesheetNode = Options.STYLESHEET_NODE.get(options).map(NodeValue::getNode);
Expand Down Expand Up @@ -496,19 +504,67 @@ private Tuple2<String, Source> getStylesheet(final MapType options) throws XPath
* It may be a dynamically configured document.
* Or a document within the database.
* </p>
* <p>
* 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 <a href="https://github.com/eXist-db/exist/issues/5052">issue 5052</a>.
* </p>
* @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<String, Source> resolveStylesheetLocation(final String stylesheetLocation) throws XPathException {

final URI uri = URI.create(stylesheetLocation);
if (uri.isAbsolute()) {
return URIResolution.resolveDocument(stylesheetLocation, context, fnTransform);
} else {
return resolvePossibleStylesheetLocation(stylesheetLocation);
}

try {
return resolvePossibleStylesheetLocation(stylesheetLocation);
} catch (final XPathException e) {
final AnyURIValue resolved = resolveURI(new AnyURIValue(stylesheetLocation), context.getBaseURI());
return URIResolution.resolveDocument(resolved.getStringValue(), context, fnTransform);
return resolvePossibleStylesheetLocation(resolved.getStringValue());
}
}

/**
* Resolve a stylesheet location
*
* @param location of the stylesheet
* @return a Tuple whose first value is the actual location of the resolved
* stylesheet (used as its base URI), and whose second value is the
* resolved stylesheet as a source
* @throws XPathException if the item does not exist, or is not a document
*/
private Tuple2<String, Source> resolvePossibleStylesheetLocation(final String location) throws XPathException {

Sequence document;
try {
document = DocUtils.getDocument(context, location);
} catch (final PermissionDeniedException e) {
throw new XPathException(fnTransform, ErrorCodes.FODC0002,
"Can not access '" + location + "'" + e.getMessage());
}
if (document != null && document.hasOne() && Type.subTypeOf(document.getItemType(), Type.NODE)) {
if (document instanceof NodeProxy nodeProxy) {
final DOMSource source = new DOMSource(nodeProxy.getNode());
source.setSystemId(location);
return Tuple(location, source);
}
else if (document.itemAt(0) instanceof Node node) {
final DOMSource source = new DOMSource(node);
source.setSystemId(location);
return Tuple(location, source);
}
}
throw new XPathException(fnTransform, ErrorCodes.FODC0002,
"Location '"+ location + "' returns an item which is not a document node");
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ public Sequence eval(final Sequence[] args, final Sequence contextSequence) thro
xslt30Transformer.setResultDocumentHandler(resultDocumentURI -> {
final Delivery resultDelivery = new Delivery(context, options.deliveryFormat, serializationProperties);
resultDocuments.put(resultDocumentURI, resultDelivery);
return resultDelivery.createDestination(xslt30Transformer, true);
return resultDelivery.createDestination(xslt30Transformer);
});

if (options.globalContextItem.isPresent()) {
Expand Down Expand Up @@ -330,7 +330,7 @@ private class TemplateInvocation {
this.options = options;
this.sourceNode = sourceNode;
this.delivery = delivery;
this.destination = delivery.createDestination(xslt30Transformer, false);
this.destination = delivery.createDestination(xslt30Transformer);
this.xslt30Transformer = xslt30Transformer;
this.resultDocuments = resultDocuments;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,29 +46,84 @@ public class URIResolution {

/**
* URI resolution, the core should be the same as for fn:resolve-uri
* <p>
* 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.
* </p>
* @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.
* <p>
* 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.
* </p>
*
* @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.
* <p>
* 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.
* </p>
*
* @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;
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading