SparqlService.java

/*
 * Copyright (c) 2007-2026 MetaSolutions AB
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.entrystore.rest.springboot.service;

import lombok.extern.slf4j.Slf4j;
import org.eclipse.rdf4j.model.IRI;
import org.eclipse.rdf4j.query.MalformedQueryException;
import org.eclipse.rdf4j.query.QueryEvaluationException;
import org.eclipse.rdf4j.query.QueryInterruptedException;
import org.eclipse.rdf4j.query.QueryLanguage;
import org.eclipse.rdf4j.query.TupleQuery;
import org.eclipse.rdf4j.query.TupleQueryResultHandler;
import org.eclipse.rdf4j.query.TupleQueryResultHandlerException;
import org.eclipse.rdf4j.query.algebra.TupleExpr;
import org.eclipse.rdf4j.query.algebra.helpers.AbstractQueryModelVisitor;
import org.eclipse.rdf4j.query.impl.SimpleDataset;
import org.eclipse.rdf4j.query.resultio.binary.BinaryQueryResultWriter;
import org.eclipse.rdf4j.query.resultio.sparqljson.SPARQLResultsJSONWriter;
import org.eclipse.rdf4j.query.resultio.sparqlxml.SPARQLResultsXMLWriter;
import org.eclipse.rdf4j.query.resultio.text.csv.SPARQLResultsCSVWriter;
import org.eclipse.rdf4j.query.resultio.text.tsv.SPARQLResultsTSVWriter;
import org.eclipse.rdf4j.repository.RepositoryConnection;
import org.eclipse.rdf4j.repository.RepositoryException;
import org.eclipse.rdf4j.repository.sail.SailTupleQuery;
import org.entrystore.Context;
import org.entrystore.impl.PublicRepository;
import org.entrystore.impl.RepositoryManagerImpl;
import org.entrystore.rest.springboot.model.exception.BadRequestException;
import org.entrystore.rest.springboot.model.exception.CustomResponseException;
import org.entrystore.rest.springboot.model.exception.InternalServerErrorException;
import org.entrystore.rest.springboot.model.exception.NotImplementedException;
import org.entrystore.rest.springboot.util.SparqlResultFormat;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.io.OutputStream;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashSet;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;

@Slf4j
@Service
public class SparqlService {

	private static final int LOGGED_QUERY_PREFIX_LENGTH = 256;

	// Generic message for all internal failures on the anonymous SPARQL endpoint. Avoids leaking
	// architecture details (repository class, writer state, Sail-vs-non-Sail). Specific cause is
	// preserved in the cause chain for server-side debugging.
	private static final String INTERNAL_ERROR_MESSAGE = "Public SPARQL endpoint encountered an internal error";

	private final RepositoryManagerImpl repositoryManager;
	private final ContextService contextService;
	private final ReservedNamesService reservedNames;
	private final int maxExecutionTime;
	private final long maxResponseBytes;

	public SparqlService(
			RepositoryManagerImpl repositoryManager,
			ContextService contextService,
			ReservedNamesService reservedNames,
			@Value("${entrystore.repository.public.sparql.max-execution-time:10}") int maxExecutionTime,
			@Value("${entrystore.repository.public.sparql.max-response-bytes:67108864}") long maxResponseBytes) {
		// Reject misconfigured limits at construction so the app fails to start rather than silently
		// disabling timeouts/caps on this anonymous endpoint (RDF4J treats setMaxExecutionTime(0) as unlimited).
		if (maxExecutionTime <= 0) {
			throw new IllegalStateException(
					"entrystore.repository.public.sparql.max-execution-time must be > 0, was " + maxExecutionTime);
		}
		if (maxResponseBytes <= 0) {
			throw new IllegalStateException(
					"entrystore.repository.public.sparql.max-response-bytes must be > 0, was " + maxResponseBytes);
		}
		this.repositoryManager = repositoryManager;
		this.contextService = contextService;
		this.reservedNames = reservedNames;
		this.maxExecutionTime = maxExecutionTime;
		this.maxResponseBytes = maxResponseBytes;
		log.debug("Public SPARQL max execution time: {} seconds; max response bytes: {}",
				maxExecutionTime, maxResponseBytes);
	}

	/**
	 * Runs the given SPARQL tuple query against the public repository, optionally
	 * restricting the dataset to a context's named graph, and writes the serialised
	 * result for {@code format} directly to {@code out}.
	 *
	 * <p>The result is streamed through a {@code SizeLimitedOutputStream} that caps the
	 * total to {@code maxResponseBytes}. Pre-{@code evaluate} validation (public-repo
	 * enablement, query parse, SERVICE-clause rejection, fail-closed non-Sail check)
	 * happens before any byte is written, so those failures produce clean 4xx/5xx
	 * responses via {@code AppExceptionHandler}. A missing or reserved {@code contextId}
	 * is silently mapped to a synthesised named graph (see {@code resolveNamedGraphUri})
	 * to avoid context-existence disclosure (CWE-204), so unknown contexts produce the
	 * same 200/empty response as existing-but-private ones rather than 404.
	 *
	 * <p>Whether a fault during {@code evaluate} (size cap, evaluator error) reaches the
	 * client as a clean status code or as a mid-stream connection drop depends on whether
	 * the servlet response buffer (Jetty default ~32 KB) has flushed. Below that threshold
	 * — single-row aggregates, narrow projections, per-namespace bindings — the same
	 * fault still produces a clean 413/500 via {@code AppExceptionHandler}. Above it,
	 * the client sees a connection drop instead.
	 *
	 * @param format      the SPARQL tuple-result format
	 * @param queryString the SPARQL query
	 * @param contextId   when non-null, both the default graph and the named-graph set are replaced with the
	 *                    context's resource URI (or a synthesised URI for missing/reserved contexts), so the
	 *                    dataset is exactly that one graph in both positions; when null the query runs against
	 *                    the full public repository
	 * @param out         the stream that the serialised tuple result is written to; the caller owns its
	 *                    lifecycle (the wrapping {@code SizeLimitedOutputStream} does not close it)
	 * @throws NotImplementedException     when the public repository is disabled
	 * @throws InternalServerErrorException 500 with a generic message. For RDF4J evaluator/writer/repository
	 *                                      faults the underlying exception is preserved in the cause chain
	 *                                      (server-side only). For fail-closed paths (connection unavailable,
	 *                                      non-Sail repository) no cause is attached and the failure must be
	 *                                      diagnosed via the operator-facing log line.
	 * @throws BadRequestException         {@code "Malformed SPARQL query"} on parse failure;
	 *                                      {@code "SPARQL SERVICE clauses are not permitted on the public endpoint"}
	 *                                      when a federated SERVICE clause is present
	 * @throws CustomResponseException     504 if the query times out; 413 if the result exceeds
	 *                                      {@code maxResponseBytes}
	 */
	public void runQuery(SparqlResultFormat format, String queryString, String contextId, OutputStream out) {
		PublicRepository publicRepository = repositoryManager.getPublicRepository();
		if (publicRepository == null) {
			throw new NotImplementedException("Public SPARQL endpoint is not enabled");
		}

		// Don't disclose context existence on the anonymous endpoint. Both missing and
		// existing-but-private contexts must produce the same observable response (200 with
		// empty bindings); returning 404 for missing contexts would let a client enumerate
		// context IDs by observing the status difference (CWE-204). For a missing or reserved
		// contextId we synthesise the URI a context with this ID would have so the named-graph
		// filter still applies — to a graph that simply has no triples in the public repo.
		String namedGraphUri = resolveNamedGraphUri(contextId);

		try (RepositoryConnection rc = acquireConnection(publicRepository)) {
			TupleQuery query = rc.prepareTupleQuery(QueryLanguage.SPARQL, queryString);
			rejectFederatedServices(extractTupleExpr(query));
			query.setMaxExecutionTime(maxExecutionTime);
			query.setIncludeInferred(false);

			if (namedGraphUri != null) {
				IRI contextURI = rc.getValueFactory().createIRI(namedGraphUri);
				log.debug("Restricting query to named graph {}", contextURI);
				SimpleDataset ds = new SimpleDataset();
				ds.addDefaultGraph(contextURI);
				ds.addNamedGraph(contextURI);
				query.setDataset(ds);
			}

			TupleQueryResultHandler resultHandler = createWriter(format,
					new SizeLimitedOutputStream(out, maxResponseBytes));

			log.debug("Executing query (first {} chars): {}",
					LOGGED_QUERY_PREFIX_LENGTH, truncate(queryString, LOGGED_QUERY_PREFIX_LENGTH));
			long before = System.currentTimeMillis();
			query.evaluate(resultHandler);
			log.debug("SPARQL query execution took {} ms", System.currentTimeMillis() - before);
		} catch (MalformedQueryException e) {
			throw new BadRequestException("Malformed SPARQL query", e);
		} catch (QueryInterruptedException e) {
			throw new CustomResponseException("SPARQL query timed out", HttpStatus.GATEWAY_TIMEOUT, e);
		} catch (QueryEvaluationException | TupleQueryResultHandlerException | RepositoryException e) {
			// findSizeCap also runs on the RepositoryException path because RDF4J writers can wrap a
			// writer-side IOException into different exception types depending on which writer is in use,
			// so the 413 cap must not rely on JSON-writer-specific wrapping.
			SparqlResultTooLargeException sizeCap = findSizeCap(e);
			if (sizeCap != null) {
				CustomResponseException tooLarge = new CustomResponseException(
						sizeCap.getMessage(), HttpStatus.PAYLOAD_TOO_LARGE, sizeCap);
				tooLarge.addSuppressed(e);
				throw tooLarge;
			}
			// Single generic message for all three caught types. The specific cause class (Repository /
			// TupleQueryResultHandler / QueryEvaluation) is preserved in the cause chain for server-side
			// debugging; the client only needs to know it is a 500.
			throw new InternalServerErrorException(INTERNAL_ERROR_MESSAGE, e);
		}
	}

	private static TupleExpr extractTupleExpr(TupleQuery query) {
		// SailTupleQuery exposes the already-parsed algebra so the federated-service detector
		// runs against exactly what the executor will run. A non-Sail backend (e.g. an HTTP-
		// or SPARQL-endpoint repository) could produce a different algebra at prepare time,
		// weakening the SERVICE-clause prohibition — fail closed so a future config change
		// cannot silently undermine the SSRF defense.
		if (query instanceof SailTupleQuery sailQuery) {
			return sailQuery.getParsedQuery().getTupleExpr();
		}
		// Generic client message; log the actual class name so an operator can diagnose
		// without source-diving.
		log.error("Public repository TupleQuery is not Sail-backed (got {}); refusing to run query "
						+ "because the federated-service detector cannot inspect its algebra",
				query.getClass().getName());
		throw new InternalServerErrorException(INTERNAL_ERROR_MESSAGE);
	}

	private String resolveNamedGraphUri(String contextId) {
		if (contextId == null) {
			return null;
		}
		// Reserved names skip contextService.getContext to avoid the false-alert ERROR log it
		// emits for them (the routing-error message it logs does not apply — SparqlController
		// is a legitimate caller). The synthesised URI below produces the same uniform-200
		// empty-bindings response the context-existence non-disclosure contract requires.
		if (!reservedNames.isReservedName(contextId.toLowerCase(Locale.ROOT))) {
			Context context = contextService.getContext(contextId);
			if (context != null) {
				return context.getURI().toString();
			}
		}
		// Synthesise the URI a real context with this id would have ({baseUrl}/{contextId} per
		// the project's URI conventions) so a missing or reserved context produces the same
		// shape of named-graph filter as an existing one — just against a graph with no triples.
		String base = repositoryManager.getRepositoryURL().toString();
		if (!base.endsWith("/")) {
			base += "/";
		}
		return base + contextId;
	}

	private static void rejectFederatedServices(TupleExpr expr) {
		FederatedServiceDetector detector = new FederatedServiceDetector();
		expr.visit(detector);
		if (detector.found) {
			throw new BadRequestException("SPARQL SERVICE clauses are not permitted on the public endpoint");
		}
	}

	private static SparqlResultTooLargeException findSizeCap(Throwable root) {
		// DFS over getCause()/getSuppressed() with a seen-set guards against:
		//  - the size-cap exception being attached as the outer throwable itself,
		//  - it being wrapped multiple levels deep,
		//  - it appearing in a suppressed branch instead of a cause chain,
		//  - cyclic exception graphs (e.g. a Throwable transitively suppressed by itself, which
		//    initCause forbids but addSuppressed does not) that would otherwise loop indefinitely,
		//  - shared sub-graphs reachable via multiple paths that would otherwise be re-traversed.
		Set<Throwable> seen = new HashSet<>();
		Deque<Throwable> stack = new ArrayDeque<>();
		stack.push(root);
		while (!stack.isEmpty()) {
			Throwable t = stack.pop();
			if (t == null || !seen.add(t)) {
				continue;
			}
			if (t instanceof SparqlResultTooLargeException sizeCap) {
				return sizeCap;
			}
			if (t.getCause() != null) {
				stack.push(t.getCause());
			}
			for (Throwable s : t.getSuppressed()) {
				stack.push(s);
			}
		}
		return null;
	}

	private static String truncate(String value, int max) {
		// Strip control chars and Unicode line/paragraph separators so a query containing CR/LF
		// or U+2028/U+2029 cannot forge log lines (CWE-117). \p{Cntrl} only catches Cc; U+2028
		// (LINE SEPARATOR, Zl) and U+2029 (PARAGRAPH SEPARATOR, Zp) are line terminators in JS
		// log viewers and BufferedReader.readLine but slip through Cc-only regexes.
		String visible = value.replaceAll("[\\p{Cntrl}\\u2028\\u2029]", "?");
		return visible.length() <= max ? visible : visible.substring(0, max) + "…";
	}

	private static RepositoryConnection acquireConnection(PublicRepository publicRepository) {
		RepositoryConnection rc = publicRepository.getConnection();
		if (rc == null) {
			throw new InternalServerErrorException(INTERNAL_ERROR_MESSAGE);
		}
		return rc;
	}

	private static TupleQueryResultHandler createWriter(SparqlResultFormat format, OutputStream out) {
		return switch (format) {
			case SPARQL_RESULTS_JSON -> new SPARQLResultsJSONWriter(out);
			case SPARQL_RESULTS_XML -> new SPARQLResultsXMLWriter(out);
			case CSV -> new SPARQLResultsCSVWriter(out);
			case TSV -> new SPARQLResultsTSVWriter(out);
			case BINARY -> new BinaryQueryResultWriter(out);
		};
	}

	private static final class FederatedServiceDetector extends AbstractQueryModelVisitor<RuntimeException> {
		boolean found;

		@Override
		public void meet(org.eclipse.rdf4j.query.algebra.Service node) {
			found = true;
		}
	}

	private static final class SparqlResultTooLargeException extends IOException {
		SparqlResultTooLargeException(long maxBytes) {
			super("SPARQL result exceeds maximum allowed size of " + maxBytes + " bytes");
		}
	}

	private static final class SizeLimitedOutputStream extends OutputStream {

		private final OutputStream delegate;
		private final long maxBytes;
		private long written;

		SizeLimitedOutputStream(OutputStream delegate, long maxBytes) {
			// Defense-in-depth on the cross-class invariant: the outer SparqlService constructor
			// already rejects maxBytes <= 0, but a future caller that bypasses it would otherwise
			// turn every successful query into a 413 (or risk underflow on long-running streams).
			if (maxBytes <= 0) {
				throw new IllegalArgumentException("maxBytes must be positive, got " + maxBytes);
			}
			this.delegate = Objects.requireNonNull(delegate, "delegate");
			this.maxBytes = maxBytes;
		}

		@Override
		public void write(int b) throws IOException {
			ensureCapacity(1);
			delegate.write(b);
			written++;
		}

		@Override
		public void write(byte[] b, int off, int len) throws IOException {
			// Without the bounds check, a negative len would slip past ensureCapacity
			// (negative > maxBytes - written is always false) and reach the delegate
			// with the cap silently violated. Objects.checkFromIndexSize also gives us
			// NPE-on-null via b.length.
			Objects.checkFromIndexSize(off, len, b.length);
			ensureCapacity(len);
			delegate.write(b, off, len);
			written += len;
		}

		@Override
		public void flush() throws IOException {
			delegate.flush();
		}

		@Override
		public void close() throws IOException {
			// flush()-only: the wrapper does not own the delegate's lifecycle. Important
			// because the delegate is the response output stream — closing it here would
			// terminate the response prematurely if an RDF4J writer calls close() on the
			// handler.
			delegate.flush();
		}

		private void ensureCapacity(long inc) throws IOException {
			if (inc > maxBytes - written) {
				throw new SparqlResultTooLargeException(maxBytes);
			}
		}
	}
}