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" .