diff --git a/pom.xml b/pom.xml
index b463a19d..6dd0c8f0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -118,7 +118,7 @@
${project.groupId}
core
- 5.0.1
+ 5.0.2-SNAPSHOT
diff --git a/src/main/java/com/atomgraph/client/Application.java b/src/main/java/com/atomgraph/client/Application.java
index 48368cff..4208615f 100644
--- a/src/main/java/com/atomgraph/client/Application.java
+++ b/src/main/java/com/atomgraph/client/Application.java
@@ -103,7 +103,6 @@ public Application(@Context ServletConfig servletConfig) throws URISyntaxExcepti
{
this(new MediaTypes(), getClient(new ClientConfig()),
servletConfig.getServletContext().getInitParameter(A.maxGetRequestSize.getURI()) != null ? Integer.valueOf(servletConfig.getServletContext().getInitParameter(A.maxGetRequestSize.getURI())) : null,
- servletConfig.getServletContext().getInitParameter(A.preemptiveAuth.getURI()) != null ? Boolean.parseBoolean(servletConfig.getServletContext().getInitParameter(A.preemptiveAuth.getURI())) : false,
getResolver(servletConfig.getServletContext().getInitParameter(AC.prefixMapping.getURI()),
com.atomgraph.client.Application.getClient(new ClientConfig()),
new MediaTypes(),
@@ -114,7 +113,7 @@ public Application(@Context ServletConfig servletConfig) throws URISyntaxExcepti
);
}
- public Application(final MediaTypes mediaTypes, final Client client, final Integer maxGetRequestSize, final boolean preemptiveAuth,
+ public Application(final MediaTypes mediaTypes, final Client client, final Integer maxGetRequestSize,
final RDFSourceResolver resolver, final Source stylesheet, final boolean cacheStylesheet, final boolean resolvingUncached)
{
this.mediaTypes = mediaTypes;
@@ -135,7 +134,7 @@ public Application(final MediaTypes mediaTypes, final Client client, final Integ
try
{
XsltCompiler xsltComp = xsltProc.newXsltCompiler();
- xsltComp.setURIResolver(new StylesheetResolver(resolver.getRepository(), GraphStoreClient.create(client, mediaTypes)));
+ xsltComp.setURIResolver(new StylesheetResolver(client));
xsltExec = xsltComp.compile(stylesheet);
}
catch (SaxonApiException ex)
diff --git a/src/main/java/com/atomgraph/client/util/StylesheetResolver.java b/src/main/java/com/atomgraph/client/util/StylesheetResolver.java
index 083138d6..5c90d07c 100644
--- a/src/main/java/com/atomgraph/client/util/StylesheetResolver.java
+++ b/src/main/java/com/atomgraph/client/util/StylesheetResolver.java
@@ -16,8 +16,8 @@
*/
package com.atomgraph.client.util;
-import com.atomgraph.core.client.GraphStoreClient;
-import com.atomgraph.client.util.jena.PrefixGraphRepository;
+import com.atomgraph.client.MediaType;
+import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.core.Response;
import java.io.ByteArrayInputStream;
import java.io.IOException;
@@ -28,37 +28,30 @@
import javax.xml.transform.URIResolver;
import javax.xml.transform.stream.StreamSource;
import org.apache.commons.io.IOUtils;
-import org.apache.jena.atlas.web.TypedInputStream;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
/**
* Resolves XSLT {@code xsl:import}/{@code xsl:include} URIs to raw stylesheet {@link Source}s
- * during compilation. Mapped/classpath/file locations are opened via the repository's RIOT stream
- * manager; HTTP via the {@link GraphStoreClient}. Unlike {@link RDFSourceResolver} the bytes are
- * returned verbatim (stylesheets are XML, not RDF). Replaces the legacy {@code XsltResolver}
- * (which extended the FileManager-based {@code DataManagerImpl}).
+ * during compilation. HTTP(S) locations are fetched with the SSL-configured JAX-RS {@link Client}
+ * (app stylesheets served over HTTPS need its trust store / client certificate), requesting
+ * {@code text/xsl}; local ({@code file:}/{@code jar:}/classpath) locations are handed back to Saxon
+ * by returning {@code null}, so its default resolver opens them. Bytes are returned verbatim
+ * (stylesheets are XML, not RDF).
*
* @author Martynas Jusevičius {@literal }
*/
public class StylesheetResolver implements URIResolver
{
- private static final Logger log = LoggerFactory.getLogger(StylesheetResolver.class);
-
- private final PrefixGraphRepository repository;
- private final GraphStoreClient gsc;
+ private final Client client;
/**
* Constructs the resolver.
*
- * @param repository graph repository for URI→location mapping and classpath/file opening
- * @param gsc Graph Store client for HTTP retrieval
+ * @param client SSL-configured JAX-RS client for HTTP(S) stylesheet retrieval
*/
- public StylesheetResolver(PrefixGraphRepository repository, GraphStoreClient gsc)
+ public StylesheetResolver(Client client)
{
- this.repository = repository;
- this.gsc = gsc;
+ this.client = client;
}
@Override
@@ -66,27 +59,21 @@ public Source resolve(String href, String base) throws TransformerException
{
URI baseURI = URI.create(base);
URI uri = href.isEmpty() ? baseURI : baseURI.resolve(href);
- String location = getRepository().resolve(uri.toString());
- try
+ if (!"http".equals(uri.getScheme()) && !"https".equals(uri.getScheme()))
+ return null; // non-HTTP (file:/jar:/classpath): let Saxon's default resolver open it
+
+ try (Response cr = getClient().target(uri).request().accept(MediaType.TEXT_XSL_TYPE).get())
{
- if (location.startsWith("http://") || location.startsWith("https://"))
- {
- try (Response cr = getGraphStoreClient().getClient().target(URI.create(location)).request().get();
- InputStream is = cr.readEntity(InputStream.class))
- {
- byte[] bytes = IOUtils.toByteArray(is); // buffer so we can close the Response
- return new StreamSource(new ByteArrayInputStream(bytes), uri.toString());
- }
- }
+ if (!cr.getStatusInfo().getFamily().equals(Response.Status.Family.SUCCESSFUL))
+ throw new IOException("XSLT stylesheet could not be loaded over HTTP. Status: " + cr.getStatus() + ", URI: " + uri);
- TypedInputStream in = getRepository().getStreamManager().open(location);
- if (in == null)
+ // buffer the stylesheet stream so we can close the Response
+ try (InputStream is = cr.readEntity(InputStream.class))
{
- if (log.isWarnEnabled()) log.warn("Could not resolve stylesheet location: {}", location);
- return null;
+ byte[] bytes = IOUtils.toByteArray(is);
+ return new StreamSource(new ByteArrayInputStream(bytes), uri.toString());
}
- return new StreamSource(in, uri.toString());
}
catch (IOException ex)
{
@@ -95,23 +82,13 @@ public Source resolve(String href, String base) throws TransformerException
}
/**
- * Returns the graph repository.
- *
- * @return repository
- */
- public PrefixGraphRepository getRepository()
- {
- return repository;
- }
-
- /**
- * Returns the Graph Store client.
+ * Returns the JAX-RS client used for HTTP(S) retrieval.
*
* @return client
*/
- public GraphStoreClient getGraphStoreClient()
+ public Client getClient()
{
- return gsc;
+ return client;
}
}
diff --git a/src/main/java/com/atomgraph/client/util/jena/PrefixGraphRepository.java b/src/main/java/com/atomgraph/client/util/jena/PrefixGraphRepository.java
index 22a42db0..3106871f 100644
--- a/src/main/java/com/atomgraph/client/util/jena/PrefixGraphRepository.java
+++ b/src/main/java/com/atomgraph/client/util/jena/PrefixGraphRepository.java
@@ -17,9 +17,12 @@
package com.atomgraph.client.util.jena;
import com.atomgraph.core.client.GraphStoreClient;
+import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
+import java.util.List;
import java.util.Map;
+import java.util.stream.Stream;
import org.apache.jena.graph.Graph;
import org.apache.jena.ontapi.impl.repositories.DocumentGraphRepository;
import org.apache.jena.rdf.model.Model;
@@ -44,7 +47,17 @@
* caching), the {@code PrefixMapper} ({@code LocationMapper} subclass with prefix matching), and
* the ontology {@code ModelGetter} used for {@code owl:imports} resolution. HTTP/HTTPS locations
* are loaded via the {@link GraphStoreClient}; other locations (classpath, file) via a RIOT
- * {@link StreamManager}. Loaded graphs are cached by ID in the inherited repository store.
+ * {@link StreamManager}. Dynamically loaded graphs are cached by ID in a store obtained from
+ * {@link #createStore()} (overridable, e.g. to supply a bounded/evicting map).
+ *
+ * The repository is shared across request threads while the store is a plain unsynchronized map, so
+ * all store access is synchronized here; loading itself happens outside the lock.
+ *
+ * Bundled (non-HTTP mapped) documents are cached by their resolved location rather than by graph
+ * ID, so that every URI in a mapped namespace shares a single graph. This bounds the cache to the
+ * number of bundled files regardless of how many distinct URIs are requested — otherwise an actor
+ * minting distinct URIs under a mapped prefix (e.g. {@code http://xmlns.com/foaf/0.1/}) would
+ * grow the store without bound.
*
* @author Martynas Jusevičius {@literal }
*/
@@ -55,9 +68,24 @@ public class PrefixGraphRepository extends DocumentGraphRepository
private final Map exactLocations = new HashMap<>();
private final Map prefixLocations = new HashMap<>();
+ private final Map store = createStore(); // dynamically loaded graphs, keyed by graph ID
+ private final Map mappedGraphs = new HashMap<>(); // bundled documents cached by resolved location, shared across all URIs in the namespace
private final GraphStoreClient gsc;
private final StreamManager streamManager;
+ /**
+ * Creates the backing store for dynamically loaded graphs (keyed by graph ID). The default is an
+ * unbounded {@link HashMap}; subclasses may override to supply a bounded/evicting map. The returned
+ * map must not depend on subclass state — it is created during construction. The location-keyed
+ * bundled-document cache is separate and always unbounded (bounded by the number of bundled files).
+ *
+ * @return dynamic-graph store
+ */
+ protected Map createStore()
+ {
+ return new HashMap<>();
+ }
+
/**
* Constructs the repository with the Graph Store client used for HTTP loading.
*
@@ -137,13 +165,35 @@ public String resolve(String id)
for (Iterator it = prefixLocations.keySet().iterator(); it.hasNext();)
{
String candidate = it.next();
- if (id.startsWith(candidate) && (prefix == null || candidate.length() > prefix.length())) prefix = candidate;
+ if (matchesPrefix(id, candidate) && (prefix == null || candidate.length() > prefix.length())) prefix = candidate;
}
if (prefix != null) return prefixLocations.get(prefix);
return id;
}
+ /**
+ * Returns true if the id falls under the namespace prefix at a URI boundary: the id equals the
+ * prefix, the prefix already ends at a delimiter, or the character following the prefix in the id
+ * is a {@code /} or {@code #}. Prevents an unrelated URI (e.g. {@code …/foobar}) from matching a
+ * shorter prefix (e.g. {@code …/foo}) and resolving to the wrong mapped location.
+ *
+ * @param id graph ID
+ * @param prefix candidate namespace prefix
+ * @return true if the id is within the prefix namespace
+ */
+ protected static boolean matchesPrefix(String id, String prefix)
+ {
+ if (!id.startsWith(prefix)) return false;
+ if (id.length() == prefix.length()) return true;
+
+ char last = prefix.charAt(prefix.length() - 1);
+ if (last == '/' || last == '#') return true;
+
+ char next = id.charAt(prefix.length());
+ return next == '/' || next == '#';
+ }
+
/**
* Returns the exact URI→location mappings (live view).
*
@@ -182,21 +232,87 @@ public boolean isMapped(String id)
* @param id graph ID
* @return true if cached
*/
- public boolean isCached(String id)
+ public synchronized boolean isCached(String id)
{
- return getIds().contains(id);
+ return store.containsKey(id) || mappedGraphs.containsKey(resolve(id));
}
@Override
public Graph get(String id)
{
- if (getIds().contains(id)) return super.get(id); // already loaded into the store
+ String location;
+ synchronized (this)
+ {
+ Graph cached = store.get(id); // explicitly cached (e.g. a materialized ontology) or non-mapped
+ if (cached != null) return cached;
+ location = resolve(id);
+ Graph mapped = mappedGraphs.get(location);
+ if (mapped != null) return mapped; // bundled document already loaded — shared across the namespace
+ }
- String location = resolve(id);
if (log.isDebugEnabled()) log.debug("Loading graph '{}' from location '{}'", id, location);
- Graph graph = load(id, location);
- put(id, graph);
- return graph;
+ Graph graph = load(id, location); // I/O outside the lock — concurrent first loads may duplicate work
+
+ // bundled (non-HTTP mapped) documents are cached by location so every URI in the namespace shares one
+ // graph, bounding the store; everything else (HTTP, identity) is cached by ID
+ boolean mapped = !location.equals(id) && !location.startsWith("http://") && !location.startsWith("https://");
+
+ synchronized (this)
+ {
+ if (mapped) return mappedGraphs.computeIfAbsent(location, k -> graph); // loser of a race discards its graph
+
+ Graph raced = store.get(id);
+ if (raced != null) return raced; // lost the race — another thread loaded it first
+ store.put(id, graph);
+ return graph;
+ }
+ }
+
+ @Override
+ public synchronized Graph put(String id, Graph graph)
+ {
+ return store.put(id, graph);
+ }
+
+ @Override
+ public synchronized Graph remove(String id)
+ {
+ Graph removed = store.remove(id);
+ Graph mapped = mappedGraphs.remove(resolve(id));
+ return removed != null ? removed : mapped;
+ }
+
+ @Override
+ public synchronized void clear()
+ {
+ store.clear();
+ mappedGraphs.clear();
+ }
+
+ @Override
+ public synchronized boolean contains(String id)
+ {
+ return store.containsKey(id) || mappedGraphs.containsKey(resolve(id));
+ }
+
+ @Override
+ public synchronized long count()
+ {
+ return store.size() + mappedGraphs.size();
+ }
+
+ @Override
+ public synchronized Stream ids()
+ {
+ return List.copyOf(store.keySet()).stream(); // snapshot under the lock — a live stream would read the store unsynchronized
+ }
+
+ @Override
+ public synchronized Stream loadedGraphs()
+ {
+ List loaded = new ArrayList<>(store.values());
+ loaded.addAll(mappedGraphs.values());
+ return loaded.stream(); // snapshot under the lock — a live stream would read the store unsynchronized
}
/**
diff --git a/src/main/java/com/atomgraph/client/writer/function/ConstructForClass.java b/src/main/java/com/atomgraph/client/writer/function/ConstructForClass.java
index 39eaa855..c7168da2 100644
--- a/src/main/java/com/atomgraph/client/writer/function/ConstructForClass.java
+++ b/src/main/java/com/atomgraph/client/writer/function/ConstructForClass.java
@@ -39,6 +39,8 @@
import org.apache.jena.rdf.model.ModelFactory;
import org.apache.jena.riot.RDFFormat;
import org.apache.jena.riot.RDFWriter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
/**
* ac:construct() XSLT function that constructs instances for given classes from their constructors.
@@ -49,7 +51,9 @@
*/
public class ConstructForClass implements ExtensionFunction
{
-
+
+ private static final Logger log = LoggerFactory.getLogger(ConstructForClass.class);
+
private final Processor processor;
private final PrefixGraphRepository repository;
@@ -91,13 +95,20 @@ public XdmValue call(XdmValue[] arguments) throws SaxonApiException
String base = arguments[2].itemAt(0).getStringValue();
Model instances = ModelFactory.createDefaultModel();
- OntModel ontModel = OntModelFactory.createModel(getRepository().get(ontology), OntSpecification.OWL2_FULL_MEM, getRepository());
+ try
+ {
+ OntModel ontModel = OntModelFactory.createModel(getRepository().get(ontology), OntSpecification.OWL2_FULL_MEM, getRepository());
+
+ arguments[1].stream().
+ map(forClass -> ontModel.getOntClass(checkURI(forClass.getStringValue()).toString())).
+ filter(forClass -> forClass != null).
+ forEach(forClass -> new Constructor().construct(forClass, instances, base));
+ }
+ catch (RuntimeException ex) // ontology or one of its imports could not be loaded — return empty result instead of failing the transform
+ {
+ if (log.isWarnEnabled()) log.warn("Could not construct instances for ontology '{}': {}", ontology, ex.toString());
+ }
- arguments[1].stream().
- map(forClass -> ontModel.getOntClass(checkURI(forClass.getStringValue()).toString())).
- filter(forClass -> forClass != null).
- forEach(forClass -> new Constructor().construct(forClass, instances, base));
-
return getProcessor().newDocumentBuilder().build(getSource(instances));
}
catch (IOException ex)
diff --git a/src/test/java/com/atomgraph/client/util/jena/PrefixGraphRepositoryTest.java b/src/test/java/com/atomgraph/client/util/jena/PrefixGraphRepositoryTest.java
index c389f5cd..a614f4a0 100644
--- a/src/test/java/com/atomgraph/client/util/jena/PrefixGraphRepositoryTest.java
+++ b/src/test/java/com/atomgraph/client/util/jena/PrefixGraphRepositoryTest.java
@@ -16,6 +16,7 @@
*/
package com.atomgraph.client.util.jena;
+import org.apache.jena.graph.Graph;
import org.apache.jena.rdf.model.Model;
import org.apache.jena.rdf.model.ModelFactory;
import org.apache.jena.rdf.model.Resource;
@@ -96,4 +97,38 @@ public void testProcessConfigLoadsExactAndPrefixMappings()
assertEquals("file:prefix.ttl", repo.resolve("http://example.org/prefix/Term"));
}
+ /**
+ * A prefix only matches at a namespace boundary — the id equals the prefix, or the character
+ * after the prefix is a {@code /} or {@code #}. An unrelated URI that merely shares the prefix
+ * string (e.g. {@code …/ns-evil}) must not resolve to the mapped location.
+ */
+ @Test
+ public void testResolveRespectsPrefixBoundary()
+ {
+ repo.addPrefixMapping("http://example.org/ns", "file:ns.ttl"); // no trailing delimiter
+
+ assertEquals("file:ns.ttl", repo.resolve("http://example.org/ns"), "exact namespace root");
+ assertEquals("file:ns.ttl", repo.resolve("http://example.org/ns#Term"), "hash boundary");
+ assertEquals("file:ns.ttl", repo.resolve("http://example.org/ns/Term"), "slash boundary");
+ assertEquals("http://example.org/nsEvil", repo.resolve("http://example.org/nsEvil"), "no boundary — must not match");
+ assertEquals("http://example.org/ns-evil", repo.resolve("http://example.org/ns-evil"), "no boundary — must not match");
+ }
+
+ /**
+ * A malicious actor minting an unbounded number of distinct URIs under a mapped prefix must not
+ * grow the cache: every URI in a mapped namespace resolves to the same bundled document, so they
+ * share one graph instance and the store stays bounded by the number of bundled files.
+ */
+ @Test
+ public void testMappedGraphCacheDoesNotGrowWithDistinctURIs()
+ {
+ repo.addPrefixMapping("http://example.org/ns/", "com/atomgraph/client/test/mint-ontology.ttl");
+
+ Graph first = repo.get("http://example.org/ns/term0");
+ for (int i = 1; i < 1000; i++)
+ assertSame(first, repo.get("http://example.org/ns/term" + i), "all URIs in a mapped namespace must share one graph instance");
+
+ assertEquals(1, repo.count(), "1000 distinct mapped URIs must not grow the cache beyond the single bundled document");
+ }
+
}
diff --git a/src/test/resources/com/atomgraph/client/test/mint-ontology.ttl b/src/test/resources/com/atomgraph/client/test/mint-ontology.ttl
new file mode 100644
index 00000000..ccd189ed
--- /dev/null
+++ b/src/test/resources/com/atomgraph/client/test/mint-ontology.ttl
@@ -0,0 +1,8 @@
+@prefix rdfs: .
+@prefix owl: .
+@base .
+
+<> a owl:Ontology .
+
+<#Thing> a owl:Class ;
+ rdfs:label "Thing" .