diff --git a/.gitignore b/.gitignore index 574d708ce85..2b3ab2c0b5d 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,5 @@ e2e/node_modules e2e/playwright-report e2e/test-results .aider* +/tools/server/.lwjgl/ +/tools/server/.lwjgl/ diff --git a/AGENTS.md b/AGENTS.md index 32592908ba8..e5e3a90f3c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -591,6 +591,18 @@ Do **not** modify existing headers’ years. * Entire repo: * `mvn -o verify` (long; only when appropriate) +* Slow tests (entire repo): + + * `mvn -o verify -PslowTestsOnly,-skipSlowTests,-formatting -Dmaven.javadoc.skip -Djapicmp.skip -Denforcer.skip -Danimal.sniffer.skip | tail -500` +* Slow tests (by module): + + * `mvn -o -pl verify -PslowTestsOnly,-skipSlowTests,-formatting -Dmaven.javadoc.skip -Djapicmp.skip -Denforcer.skip -Danimal.sniffer.skip | tail -500` +* Integration tests (entire repo): + + * `mvn -o verify -PskipUnitTests,-formatting -Dmaven.javadoc.skip -Denforcer.skip -Danimal.sniffer.skip | tail -500` +* Integration tests (by module): + + * `mvn -o -pl verify -PskipUnitTests,-formatting -Dmaven.javadoc.skip -Denforcer.skip -Danimal.sniffer.skip | tail -500` * Useful flags: * `-Dtest=ClassName` diff --git a/core/http/client/src/main/java/org/eclipse/rdf4j/http/client/SPARQLProtocolSession.java b/core/http/client/src/main/java/org/eclipse/rdf4j/http/client/SPARQLProtocolSession.java index a8ab7d2d921..c4aee6459bd 100644 --- a/core/http/client/src/main/java/org/eclipse/rdf4j/http/client/SPARQLProtocolSession.java +++ b/core/http/client/src/main/java/org/eclipse/rdf4j/http/client/SPARQLProtocolSession.java @@ -49,6 +49,7 @@ import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpPost; import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.client.methods.RequestBuilder; import org.apache.http.client.protocol.HttpClientContext; import org.apache.http.client.utils.URIBuilder; import org.apache.http.entity.ContentType; @@ -1058,44 +1059,61 @@ protected void executeNoContent(HttpUriRequest method) throws IOException, RDF4J } protected HttpResponse execute(HttpUriRequest method) throws IOException, RDF4JException { + return executeWithRedirects(method, 5); + } + + private HttpResponse executeWithRedirects(HttpUriRequest method, int redirectsLeft) + throws IOException, RDF4JException { boolean consume = true; if (params != null) { method.setParams(params); } HttpResponse response = httpClient.execute(method, httpContext); - try { int httpCode = response.getStatusLine().getStatusCode(); if (httpCode >= 200 && httpCode < 300 || httpCode == HttpURLConnection.HTTP_NOT_FOUND) { consume = false; return response; // everything OK, control flow can continue - } else { - switch (httpCode) { - case HttpURLConnection.HTTP_UNAUTHORIZED: // 401 - throw new UnauthorizedException(); - case HttpURLConnection.HTTP_UNAVAILABLE: // 503 - throw new QueryInterruptedException(); - default: - ErrorInfo errInfo = getErrorInfo(response); - // Throw appropriate exception - if (errInfo.getErrorType() == ErrorType.MALFORMED_DATA) { - throw new RDFParseException(errInfo.getErrorMessage()); - } else if (errInfo.getErrorType() == ErrorType.UNSUPPORTED_FILE_FORMAT) { - throw new UnsupportedRDFormatException(errInfo.getErrorMessage()); - } else if (errInfo.getErrorType() == ErrorType.MALFORMED_QUERY) { - throw new MalformedQueryException(errInfo.getErrorMessage()); - } else if (errInfo.getErrorType() == ErrorType.UNSUPPORTED_QUERY_LANGUAGE) { - throw new UnsupportedQueryLanguageException(errInfo.getErrorMessage()); - } else if (contentTypeIs(response, "application/shacl-validation-report")) { - RDFFormat format = getContentTypeSerialisation(response); - throw new RepositoryException(new RemoteShaclValidationException( - new StringReader(errInfo.toString()), "", format)); - - } else if (!errInfo.toString().isEmpty()) { - throw new RepositoryException(errInfo.toString()); - } else { - throw new RepositoryException(response.getStatusLine().getReasonPhrase()); - } + } + + // Follow redirects for any method (preserving method and entity) + if (redirectsLeft > 0 && (httpCode == HttpURLConnection.HTTP_MOVED_PERM + || httpCode == HttpURLConnection.HTTP_MOVED_TEMP || httpCode == 307 || httpCode == 308)) { + Header location = response.getFirstHeader("Location"); + if (location != null) { + // consume and follow + EntityUtils.consumeQuietly(response.getEntity()); + java.net.URI uri = java.net.URI.create(location.getValue()); + HttpUriRequest redirect = RequestBuilder.copy(method).setUri(uri).build(); + return executeWithRedirects(redirect, redirectsLeft - 1); + } + } + + switch (httpCode) { + case HttpURLConnection.HTTP_UNAUTHORIZED: // 401 + throw new UnauthorizedException(); + case HttpURLConnection.HTTP_UNAVAILABLE: // 503 + throw new QueryInterruptedException(); + default: + ErrorInfo errInfo = getErrorInfo(response); + // Throw appropriate exception + if (errInfo.getErrorType() == ErrorType.MALFORMED_DATA) { + throw new RDFParseException(errInfo.getErrorMessage()); + } else if (errInfo.getErrorType() == ErrorType.UNSUPPORTED_FILE_FORMAT) { + throw new UnsupportedRDFormatException(errInfo.getErrorMessage()); + } else if (errInfo.getErrorType() == ErrorType.MALFORMED_QUERY) { + throw new MalformedQueryException(errInfo.getErrorMessage()); + } else if (errInfo.getErrorType() == ErrorType.UNSUPPORTED_QUERY_LANGUAGE) { + throw new UnsupportedQueryLanguageException(errInfo.getErrorMessage()); + } else if (contentTypeIs(response, "application/shacl-validation-report")) { + RDFFormat format = getContentTypeSerialisation(response); + throw new RepositoryException(new RemoteShaclValidationException( + new StringReader(errInfo.toString()), "", format)); + + } else if (!errInfo.toString().isEmpty()) { + throw new RepositoryException(errInfo.toString()); + } else { + throw new RepositoryException(response.getStatusLine().getReasonPhrase()); } } } finally { diff --git a/core/http/client/src/main/java/org/eclipse/rdf4j/http/client/SharedHttpClientSessionManager.java b/core/http/client/src/main/java/org/eclipse/rdf4j/http/client/SharedHttpClientSessionManager.java index eabcd1c51e2..8dea0ddfe13 100644 --- a/core/http/client/src/main/java/org/eclipse/rdf4j/http/client/SharedHttpClientSessionManager.java +++ b/core/http/client/src/main/java/org/eclipse/rdf4j/http/client/SharedHttpClientSessionManager.java @@ -12,6 +12,7 @@ import java.io.IOException; import java.net.HttpURLConnection; +import java.net.URI; import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; @@ -24,15 +25,20 @@ import java.util.concurrent.atomic.AtomicLong; import org.apache.http.HttpConnection; +import org.apache.http.HttpRequest; import org.apache.http.HttpResponse; +import org.apache.http.ProtocolException; import org.apache.http.client.HttpClient; import org.apache.http.client.HttpRequestRetryHandler; import org.apache.http.client.ServiceUnavailableRetryStrategy; import org.apache.http.client.config.CookieSpecs; import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.client.methods.RequestBuilder; import org.apache.http.client.protocol.HttpClientContext; import org.apache.http.client.utils.HttpClientUtils; import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.DefaultRedirectStrategy; import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.protocol.HttpContext; import org.eclipse.rdf4j.http.client.util.HttpClientBuilders; @@ -579,6 +585,7 @@ private CloseableHttpClient createHttpClient() { .setMaxConnPerRoute(MAX_CONN_PER_ROUTE) .setMaxConnTotal(MAX_CONN_TOTAL) .useSystemProperties() + .setRedirectStrategy(new SameMethodRedirectStrategy()) .setDefaultRequestConfig(requestConfig) .build(); } @@ -593,10 +600,43 @@ public RequestConfig getDefaultRequestConfig() { .setConnectTimeout(currentConnectionTimeout) .setConnectionRequestTimeout(currentConnectionRequestTimeout) .setSocketTimeout(currentSocketTimeout) + .setRedirectsEnabled(true) + .setRelativeRedirectsAllowed(true) + .setExpectContinueEnabled(true) .setCookieSpec(CookieSpecs.STANDARD) .build(); } + /** + * Redirect strategy that follows 301/302/307/308 for any HTTP method and preserves the original method and entity. + */ + private static class SameMethodRedirectStrategy extends DefaultRedirectStrategy { + private static final String[] REDIRECT_METHODS = new String[] { "GET", "HEAD", "POST", "PUT", "DELETE", + "PATCH" }; + + @Override + protected boolean isRedirectable(String method) { + for (String m : REDIRECT_METHODS) { + if (m.equalsIgnoreCase(method)) { + return true; + } + } + return false; + } + + @Override + public HttpUriRequest getRedirect(HttpRequest request, + HttpResponse response, HttpContext context) + throws ProtocolException { + URI uri = getLocationURI(request, response, context); + // Preserve original method and entity + RequestBuilder rb = RequestBuilder + .copy(request); + rb.setUri(uri); + return rb.build(); + } + } + /** * Switches the current timeout settings to use the SPARQL-specific timeouts. This method should be called when * making SPARQL SERVICE calls to apply shorter timeout values. diff --git a/core/http/client/src/test/java/org/eclipse/rdf4j/http/client/RDF4JProtocolSessionTest.java b/core/http/client/src/test/java/org/eclipse/rdf4j/http/client/RDF4JProtocolSessionTest.java index 72a075bf4de..98354a646bb 100644 --- a/core/http/client/src/test/java/org/eclipse/rdf4j/http/client/RDF4JProtocolSessionTest.java +++ b/core/http/client/src/test/java/org/eclipse/rdf4j/http/client/RDF4JProtocolSessionTest.java @@ -14,6 +14,8 @@ import static org.mockserver.model.HttpRequest.request; import static org.mockserver.model.HttpResponse.response; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; import java.util.HashMap; import org.eclipse.rdf4j.common.transaction.IsolationLevels; @@ -77,6 +79,111 @@ public void testCreateRepositoryExecutesPut(MockServerClient client) throws Exce ); } + @Test + public void testCreateRepositoryFollowsRedirectOnPut(MockServerClient client) throws Exception { + // Simulate reverse-proxy forcing redirect on state-changing PUT + String originalPath = "/rdf4j-server/repositories/test"; + String redirectedPath = "/https/rdf4j-server/repositories/test"; + String redirectLocation = "http://localhost:" + client.getPort() + redirectedPath; + + // First request responds with 301 and Location header + client.when( + request() + .withMethod("PUT") + .withPath(originalPath), + Times.once() + ) + .respond( + response() + .withStatusCode(301) + .withHeader("Location", redirectLocation) + ); + + // Redirect target responds successfully + client.when( + request() + .withMethod("PUT") + .withPath(redirectedPath), + Times.once() + ) + .respond( + response() + ); + + RepositoryConfig config = new RepositoryConfig("test"); + + // Expected: client should follow the 301 redirect and succeed without throwing + getRDF4JSession().createRepository(config); + + // Verify both the original and redirected requests were made with additional headers preserved + client.verify( + request() + .withMethod("PUT") + .withPath(originalPath) + .withHeader(testHeader, testValue) + ); + client.verify( + request() + .withMethod("PUT") + .withPath(redirectedPath) + .withHeader(testHeader, testValue) + ); + } + + @Test + public void testRemoveDataTransactionFollowsRedirectOnDelete(MockServerClient client) throws Exception { + // Start transaction and get transaction URL + String transactionStartUrl = Protocol.getTransactionsLocation(getRDF4JSession().getRepositoryURL()); + HttpRequest transactionCreateRequest = request() + .withMethod("POST") + .withPath("/rdf4j-server/repositories/test/transactions"); + client.when(transactionCreateRequest, Times.once()) + .respond(response().withStatusCode(201).withHeader("Location", transactionStartUrl + "/1")); + + // First attempt: PUT .../transactions/1?action=DELETE responds with 301 and Location header + String originalPath = "/rdf4j-server/repositories/test/transactions/1"; + String redirectedPath = "/https/rdf4j-server/repositories/test/transactions/1"; + String redirectLocation = "http://localhost:" + client.getPort() + redirectedPath + "?action=DELETE"; + + client.when( + request() + .withMethod("PUT") + .withPath(originalPath) + .withQueryStringParameter("action", "DELETE"), + Times.once()) + .respond(response().withStatusCode(301).withHeader("Location", redirectLocation)); + + // Redirect target responds successfully (204 No Content) + client.when( + request() + .withMethod("PUT") + .withPath(redirectedPath) + .withQueryStringParameter("action", "DELETE"), + Times.once()) + .respond(response().withStatusCode(204)); + + // Begin transaction, then attempt removeData (DELETE action) which should follow redirect + getRDF4JSession().beginTransaction(IsolationLevels.SERIALIZABLE); + ByteArrayInputStream data = new ByteArrayInputStream("

.".getBytes(StandardCharsets.UTF_8)); + getRDF4JSession().removeData(data, null, RDFFormat.NTRIPLES); + + // Verify original and redirected requests occurred with header preserved + client.verify( + request() + .withMethod("PUT") + .withPath(originalPath) + .withQueryStringParameter("action", "DELETE") + .withHeader(testHeader, testValue) + ); + client.verify( + request() + .withMethod("PUT") + .withPath(redirectedPath) + .withQueryStringParameter("action", "DELETE") + .withHeader(testHeader, testValue) + ); + } + @Test public void testUpdateRepositoryExecutesPost(MockServerClient client) throws Exception { RepositoryConfig config = new RepositoryConfig("test"); diff --git a/tools/server-spring/src/main/java/org/eclipse/rdf4j/http/server/repository/RepositoryInterceptor.java b/tools/server-spring/src/main/java/org/eclipse/rdf4j/http/server/repository/RepositoryInterceptor.java index 98d50d996dc..3e0c487b3f1 100644 --- a/tools/server-spring/src/main/java/org/eclipse/rdf4j/http/server/repository/RepositoryInterceptor.java +++ b/tools/server-spring/src/main/java/org/eclipse/rdf4j/http/server/repository/RepositoryInterceptor.java @@ -105,6 +105,13 @@ protected void setRequestAttributes(HttpServletRequest request) throws ClientHTT request.setAttribute(REPOSITORY_KEY, new RepositoryConfigRepository(repositoryManager)); } else if (nextRepositoryID != null) { try { + // For requests to delete a repository, we must not attempt to initialize the repository. Otherwise a + // corrupt/invalid configuration can block deletion. + if ("DELETE".equals(request.getMethod()) && request.getPathInfo().equals("/" + nextRepositoryID)) { + request.setAttribute(REPOSITORY_ID_KEY, nextRepositoryID); + return; + } + Repository repository = repositoryManager.getRepository(nextRepositoryID); if (repository == null && !"PUT".equals(request.getMethod())) { throw new ClientHTTPException(SC_NOT_FOUND, "Unknown repository: " + nextRepositoryID); diff --git a/tools/server/src/test/java/org/eclipse/rdf4j/http/server/LmdbInvalidIndexDeletionIT.java b/tools/server/src/test/java/org/eclipse/rdf4j/http/server/LmdbInvalidIndexDeletionIT.java new file mode 100644 index 00000000000..2fffd5cc95b --- /dev/null +++ b/tools/server/src/test/java/org/eclipse/rdf4j/http/server/LmdbInvalidIndexDeletionIT.java @@ -0,0 +1,118 @@ +/** + * Copyright (c) 2025 Eclipse RDF4J contributors. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Distribution License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.rdf4j.http.server; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.eclipse.rdf4j.http.protocol.Protocol; +import org.eclipse.rdf4j.model.IRI; +import org.eclipse.rdf4j.model.Model; +import org.eclipse.rdf4j.model.Resource; +import org.eclipse.rdf4j.model.ValueFactory; +import org.eclipse.rdf4j.model.impl.SimpleValueFactory; +import org.eclipse.rdf4j.repository.Repository; +import org.eclipse.rdf4j.repository.RepositoryConnection; +import org.eclipse.rdf4j.repository.RepositoryException; +import org.eclipse.rdf4j.repository.config.RepositoryConfig; +import org.eclipse.rdf4j.repository.http.HTTPRepository; +import org.eclipse.rdf4j.repository.manager.RemoteRepositoryManager; +import org.eclipse.rdf4j.repository.sail.config.SailRepositoryConfig; +import org.eclipse.rdf4j.sail.config.AbstractSailImplConfig; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Jetty-based integration test that reproduces a user report: Creating an LMDB repository with an invalid triple index + * (e.g., "cposc") then attempting to delete it via HTTP fails. + * + * Expected behavior: either reject creation upfront or allow deletion. This test asserts deletion succeeds; it + * currently fails, exposing the bug. + */ +public class LmdbInvalidIndexDeletionIT { + + private static TestServer server; + + @BeforeAll + public static void startServer() throws Exception { + server = new TestServer(); + try { + server.start(); + } catch (Exception e) { + server.stop(); + throw e; + } + } + + @AfterAll + public static void stopServer() throws Exception { + server.stop(); + } + + @Test + void deletionSucceedsAfterInvalidLmdbInit() throws Exception { + String id = "badlmdb-server"; + + // Build a minimal LMDB Sail config without depending on LMDB classes + // by exporting with the LMDB sail type and the tripleIndexes property. + class GenericLmdbConfig extends AbstractSailImplConfig { + private final String tripleIndexes; + + GenericLmdbConfig(String type, String tripleIndexes) { + super(type); + this.tripleIndexes = tripleIndexes; + } + + @Override + public Resource export(Model m) { + Resource node = super.export(m); + ValueFactory vf = SimpleValueFactory.getInstance(); + IRI tripleIdx = vf.createIRI("http://rdf4j.org/config/sail/lmdb#tripleIndexes"); + m.add(node, tripleIdx, vf.createLiteral(tripleIndexes)); + return node; + } + } + + GenericLmdbConfig lmdbConfig = new GenericLmdbConfig("rdf4j:LmdbStore", "cposc"); + RepositoryConfig repoConfig = new RepositoryConfig(id, new SailRepositoryConfig(lmdbConfig)); + + RemoteRepositoryManager manager = RemoteRepositoryManager.getInstance(TestServer.SERVER_URL); + try { + try { + // Create config on server (does not initialize the underlying store yet) + manager.addRepositoryConfig(repoConfig); + } catch (Exception ignored) { + } + + // Trigger initialization by opening a connection; expected to fail due to invalid index + Repository httpRepo = new HTTPRepository( + Protocol.getRepositoryLocation(TestServer.SERVER_URL, id)); + try (RepositoryConnection conn = httpRepo.getConnection()) { + // attempt a trivial call to ensure init + conn.size(); + } catch (RepositoryException expected) { + // initialization fails as LMDB rejects invalid index spec + } + + // Now attempt to delete the repository; expected to succeed + boolean removed = manager.removeRepository(id); + assertThat(removed).isTrue(); + } finally { + // best-effort cleanup if assertion failed + try { + manager.removeRepository(id); + } catch (Exception ignore) { + } finally { + manager.shutDown(); + } + } + } +}