| 7 days ago | Valery Kharseko | ![]() |
| 7 days ago | Valery Kharseko | ![]() |
opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLServlet.java
@@ -42,8 +42,6 @@ import java.util.StringTokenizer; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.logging.Level; import java.util.logging.Logger; import jakarta.servlet.ServletConfig; import jakarta.servlet.ServletException; @@ -106,6 +104,12 @@ * It parses the SOAP request, calls the appropriate class * which performs the LDAP operation, and returns the response * as a DSML response. * <p> * Everything is logged through {@code getServletContext().log()}: it is the * only sink which survives at runtime, as * {@code LDAPConnection.connectToHost()} turns {@code java.util.logging} off * for the whole JVM on every non-verbose connection, and the war ships * {@code slf4j-api} without any provider. */ public class DSMLServlet extends HttpServlet { private static final String PKG_NAME = "org.opends.dsml.protocol"; @@ -165,6 +169,9 @@ */ @Override public void init(ServletConfig config) throws ServletException { // Let GenericServlet keep the configuration: getServletContext() relies on // it, and it is the only logging facility available at runtime. super.init(config); try { hostName = stringValue(config, HOST); port = Integer.valueOf(stringValue(config, PORT)); @@ -333,7 +340,6 @@ connOptions.setUseSSL(useSSL); connOptions.setStartTLS(useStartTLS); LDAPConnection connection = null; BatchRequest batchRequest = null; // Keep the Servlet input stream buffered in case the SOAP un-marshalling @@ -400,9 +406,8 @@ messageFactory = MessageFactory.newInstance(SOAPConstants.SOAP_1_2_PROTOCOL); messageContentType = SOAPConstants.SOAP_1_2_CONTENT_TYPE; } else { throw new ServletException("Content-Type does not match SOAP 1.1 or SOAP 1.2"); } // An unsupported Content-Type leaves the message factory unset: the // request is rejected as malformed once all the headers are read. } catch (SOAPException e) { @@ -430,12 +435,14 @@ bindPassword = unencoded.substring(colon + 1); } } catch (final LocalizedIllegalArgumentException ex) { // user/DN:password parsing error // user/DN:password parsing error. Keep reading the headers: the // Content-Type may still be ahead, and it decides which SOAP // version the error is reported with. batchResponses.add( createErrorResponse(objFactory, new LDAPException(LDAPResultCode.INVALID_CREDENTIALS, LocalizableMessage.raw(ex.getMessage())))); break; continue; } } StringTokenizer tk = new StringTokenizer(headerVal, ","); @@ -477,6 +484,31 @@ } } if ( messageFactory == null ) { // The request carries no Content-Type header, or one which matches // neither SOAP 1.1 nor SOAP 1.2: it cannot be parsed. Fall back to // SOAP 1.1 for the response and reject the request as malformed, // unless an error has already been reported. try { messageFactory = MessageFactory.newInstance(SOAPConstants.SOAP_1_1_PROTOCOL); messageContentType = SOAPConstants.SOAP_1_1_CONTENT_TYPE; } catch (SOAPException e) { throw new ServletException(e.getMessage()); } if ( batchResponses.isEmpty() ) { // Nothing has been read from the stream yet, so the SAX pass can still // recover the requestID and let the client correlate the reply. batchResponses.add( createXMLParsingErrorResponse(is, objFactory, batchResponse, "Content-Type does not match SOAP 1.1 or SOAP 1.2")); } } // if an error already occurred, the list is not empty if ( batchResponses.isEmpty() ) { try { @@ -520,6 +552,13 @@ boolean authzInControl = false; batchRequest = batchRequestElement.getValue(); // The connection options are shared by all the batch requests of this // SOAP body, so the authzid of the previous one must not survive into // the bind of this one: it would run under an authorization identity // it never asked for, and addSASLProperty() appends to the values of // a key, which SASL PLAIN rejects as a multi-valued authzid. connOptions.getSASLProperties().remove("authzid"); /* * Process optional authRequest (i.e. use authz) */ @@ -541,10 +580,12 @@ boolean connected = false; if ( connection == null ) { connection = new LDAPConnection(hostName, port, connOptions); // Each batch request gets its own connection: the previous one has // been closed by the finally block below. LDAPConnection connection = new LDAPConnection(hostName, port, connOptions); try { try { connection.connectToHost(bindDN, bindPassword); if (authzInControl) { @@ -565,35 +606,37 @@ // if connection failed, return appropriate error response batchResponses.add(createErrorResponse(objFactory, e)); } } if ( connected ) { List<DsmlMessage> list = batchRequest.getBatchRequests(); if ( connected ) { List<DsmlMessage> list = batchRequest.getBatchRequests(); for (DsmlMessage request : list) { JAXBElement<?> result = performLDAPRequest(connection, objFactory, proxyAuthzControl, request); if ( result != null ) { batchResponses.add(result); } // evaluate response to check if an error occurred Object o = result.getValue(); if ( o instanceof ErrorResponse ) { if ( ON_ERROR_EXIT.equals(batchRequest.getOnError()) ) { break; for (DsmlMessage request : list) { JAXBElement<?> result = performLDAPRequest(connection, objFactory, proxyAuthzControl, request); if ( result == null ) { // an abandon request does not produce any response element continue; } } else if ( o instanceof LDAPResult ) { int code = ((LDAPResult)o).getResultCode().getCode(); if ( code != LDAPResultCode.SUCCESS && code != LDAPResultCode.REFERRAL && code != LDAPResultCode.COMPARE_TRUE && code != LDAPResultCode.COMPARE_FALSE && ON_ERROR_EXIT.equals(batchRequest.getOnError()) ) { break; batchResponses.add(result); // evaluate response to check if an error occurred Object o = result.getValue(); if ( o instanceof ErrorResponse ) { if ( ON_ERROR_EXIT.equals(batchRequest.getOnError()) ) { break; } } else if ( o instanceof LDAPResult ) { int code = ((LDAPResult)o).getResultCode().getCode(); if ( code != LDAPResultCode.SUCCESS && code != LDAPResultCode.REFERRAL && code != LDAPResultCode.COMPARE_TRUE && code != LDAPResultCode.COMPARE_FALSE && ON_ERROR_EXIT.equals(batchRequest.getOnError()) ) { break; } } } } } // close connection to LDAP server if ( connection != null ) { } finally { // close connection to LDAP server, whatever happened while // processing the batch connection.close(nextMessageID); } } @@ -604,7 +647,8 @@ marshaller.marshal(objFactory.createBatchResponse(batchResponse), doc); sendResponse(doc, messageFactory, messageContentType, res); } catch (Exception e) { e.printStackTrace(); // The client gets an empty response: at least make the cause visible. getServletContext().log("Unable to send the DSML response", e); } } @@ -628,14 +672,14 @@ { if (logFeatureWarnings.compareAndSet(false, true)) { Logger.getLogger(PKG_NAME).log(Level.SEVERE, "XMLReader unsupported feature " + feature); getServletContext().log("XMLReader unsupported feature " + feature); } } catch (SAXNotRecognizedException e) { if (logFeatureWarnings.compareAndSet(false, true)) { Logger.getLogger(PKG_NAME).log(Level.SEVERE, "XMLReader unrecognized feature " + feature); getServletContext().log("XMLReader unrecognized feature " + feature); } } } @@ -893,7 +937,7 @@ catch (ParserConfigurationException e) { if (logFeatureWarnings.compareAndSet(false, true)) { Logger.getLogger(PKG_NAME).log(Level.SEVERE, "DocumentBuilderFactory unsupported feature " + feature); getServletContext().log("DocumentBuilderFactory unsupported feature " + feature); } } } @@ -916,7 +960,7 @@ catch (ParserConfigurationException e) { if (logFeatureWarnings.compareAndSet(false, true)) { Logger.getLogger(PKG_NAME).log(Level.SEVERE, "DocumentBuilderFactory cannot be configured securely"); getServletContext().log("DocumentBuilderFactory cannot be configured securely"); } } dbf.setXIncludeAware(false); opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/DSMLServletTestCase.java
New file @@ -0,0 +1,655 @@ /* * The contents of this file are subject to the terms of the Common Development and * Distribution License (the License). You may not use this file except in compliance with the * License. * * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the * specific language governing permission and limitations under the License. * * When distributing Covered Software, include this CDDL Header Notice in each file and include * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL * Header, with the fields enclosed by brackets [] replaced by your own identifying * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2026 3A Systems, LLC. */ package org.opends.dsml.protocol; import static java.util.Arrays.asList; import static org.opends.server.protocols.ldap.LDAPConstants.OP_TYPE_ABANDON_REQUEST; import static org.opends.server.protocols.ldap.LDAPConstants.OP_TYPE_BIND_REQUEST; import static org.opends.server.protocols.ldap.LDAPConstants.OP_TYPE_UNBIND_REQUEST; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertNull; import static org.testng.Assert.assertTrue; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.Closeable; import java.io.IOException; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; import java.net.InetAddress; import java.net.ServerSocket; import java.net.Socket; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Base64; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; import jakarta.servlet.ReadListener; import jakarta.servlet.ServletConfig; import jakarta.servlet.ServletContext; import jakarta.servlet.ServletInputStream; import jakarta.servlet.ServletOutputStream; import jakarta.servlet.WriteListener; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.forgerock.opendj.ldap.ByteString; import org.forgerock.testng.ForgeRockTestCase; import org.opends.server.protocols.ldap.BindRequestProtocolOp; import org.opends.server.protocols.ldap.BindResponseProtocolOp; import org.opends.server.protocols.ldap.LDAPMessage; import org.opends.server.protocols.ldap.LDAPResultCode; import org.opends.server.tools.LDAPReader; import org.opends.server.tools.LDAPWriter; import org.testng.annotations.Test; /** * Tests the error handling of {@link DSMLServlet#doPost}: an abandon request * used to trigger a {@code NullPointerException} which leaked the LDAP * connection, a request without a usable Content-Type header used to trigger a * {@code NullPointerException} as well, and the second batch request of a SOAP * body used to be silently skipped. */ @SuppressWarnings("javadoc") @Test(groups = { "precommit", "dsml" }) public class DSMLServletTestCase extends ForgeRockTestCase { /** SOAP 1.1 content type. */ private static final String SOAP_1_1_CONTENT_TYPE = "text/xml"; /** SOAP 1.2 content type. */ private static final String SOAP_1_2_CONTENT_TYPE = "application/soap+xml"; /** SOAP 1.2 envelope namespace, as it appears in the reply. */ private static final String SOAP_1_2_NAMESPACE = "http://www.w3.org/2003/05/soap-envelope"; private static final String ABANDON_BATCH = soap11(abandonBatch("1", null)); /** Two batch requests in a single SOAP body, each carrying an abandon request. */ private static final String TWO_ABANDON_BATCHES = soap11(abandonBatch("1", null) + abandonBatch("2", null)); /** Same, with an authRequest which turns into a SASL authzid on each bind. */ private static final String TWO_AUTHZ_BATCHES = soap11(abandonBatch("1", "dn:cn=first") + abandonBatch("2", "dn:cn=second")); /** Same, but only the first batch request asks for an authorization identity. */ private static final String MIXED_AUTHZ_BATCHES = soap11(abandonBatch("1", "dn:cn=first") + abandonBatch("2", null)); private static String abandonBatch(String requestID, String authzPrincipal) { return "<batchRequest xmlns=\"urn:oasis:names:tc:DSML:2:0:core\" requestID=\"" + requestID + "\">" + (authzPrincipal != null ? "<authRequest principal=\"" + authzPrincipal + "\"/>" : "") + "<abandonRequest abandonID=\"1\"/>" + "</batchRequest>"; } private static String soap11(String body) { return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + "<se:Envelope xmlns:se=\"http://schemas.xmlsoap.org/soap/envelope/\">" + "<se:Body>" + body + "</se:Body>" + "</se:Envelope>"; } private static String soap12(String body) { return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + "<se:Envelope xmlns:se=\"" + SOAP_1_2_NAMESPACE + "\">" + "<se:Body>" + body + "</se:Body>" + "</se:Envelope>"; } /** * An abandon request produces no response element: the servlet must neither * fail nor leave the connection to the directory server open. */ @Test public void testAbandonRequestIsProcessedAndConnectionIsClosed() throws Exception { try (FakeLdapServer server = new FakeLdapServer()) { Map<String, String> headers = new LinkedHashMap<>(); headers.put("Content-Type", SOAP_1_1_CONTENT_TYPE); String response = doPost(server.getPort(), headers, ABANDON_BATCH); assertTrue(response.contains("batchResponse"), response); assertFalse(response.contains("errorResponse"), response); // no response element is defined for an abandon request assertFalse(response.contains("abandonResponse"), response); server.awaitDisconnect(); assertEquals(server.getReceivedOpTypes(), list(OP_TYPE_BIND_REQUEST, OP_TYPE_ABANDON_REQUEST, OP_TYPE_UNBIND_REQUEST), "the abandon request was not forwarded, or the connection was leaked"); } } /** * A request without any Content-Type header must be rejected as malformed, * keeping the requestID so that the client can correlate the reply. */ @Test public void testMissingContentTypeIsRejectedAsMalformedRequest() throws Exception { try (FakeLdapServer server = new FakeLdapServer()) { String response = doPost(server.getPort(), new LinkedHashMap<String, String>(), ABANDON_BATCH); assertTrue(response.contains("malformedRequest"), response); assertTrue(response.contains("requestID=\"1\""), response); assertTrue(server.getReceivedOpTypes().isEmpty(), "no connection to the directory server should have been opened"); } } /** A Content-Type header matching neither SOAP 1.1 nor SOAP 1.2 is malformed too. */ @Test public void testUnsupportedContentTypeIsRejectedAsMalformedRequest() throws Exception { try (FakeLdapServer server = new FakeLdapServer()) { Map<String, String> headers = new LinkedHashMap<>(); headers.put("Content-Type", "application/json"); String response = doPost(server.getPort(), headers, ABANDON_BATCH); assertTrue(response.contains("malformedRequest"), response); assertTrue(server.getReceivedOpTypes().isEmpty(), "no connection to the directory server should have been opened"); } } /** * An error detected before the request is parsed must still reach the client * when the Content-Type header is missing. */ @Test public void testMissingContentTypeStillReportsCredentialsError() throws Exception { try (FakeLdapServer server = new FakeLdapServer()) { Map<String, String> headers = new LinkedHashMap<>(); // credentials without the ':' separator: the password cannot be retrieved headers.put("Authorization", "Basic " + Base64.getEncoder() .encodeToString("cn=directory manager".getBytes(StandardCharsets.UTF_8))); String response = doPost(server.getPort(), headers, ABANDON_BATCH); assertTrue(response.contains("authenticationFailed"), response); assertTrue(server.getReceivedOpTypes().isEmpty(), "no connection to the directory server should have been opened"); } } /** * A malformed Authorization header must not stop the header scan: the * Content-Type still decides which SOAP version the error is reported with. */ @Test public void testMalformedAuthorizationKeepsTheRequestSoapVersion() throws Exception { try (FakeLdapServer server = new FakeLdapServer()) { Map<String, String> headers = new LinkedHashMap<>(); // credentials which are not valid Base64, read before the Content-Type headers.put("Authorization", "Basic !!!"); headers.put("Content-Type", SOAP_1_2_CONTENT_TYPE); String response = doPost(server.getPort(), headers, soap12(abandonBatch("1", null))); assertTrue(response.contains("authenticationFailed"), response); assertTrue(response.contains(SOAP_1_2_NAMESPACE), response); } } /** The SOAP 1.2 request path must work as the SOAP 1.1 one does. */ @Test public void testSoap12RequestIsProcessed() throws Exception { try (FakeLdapServer server = new FakeLdapServer()) { Map<String, String> headers = new LinkedHashMap<>(); headers.put("Content-Type", SOAP_1_2_CONTENT_TYPE); String response = doPost(server.getPort(), headers, soap12(abandonBatch("1", null))); assertTrue(response.contains("batchResponse"), response); assertFalse(response.contains("errorResponse"), response); assertTrue(response.contains(SOAP_1_2_NAMESPACE), response); server.awaitDisconnect(); assertEquals(server.getReceivedOpTypes(), list(OP_TYPE_BIND_REQUEST, OP_TYPE_ABANDON_REQUEST, OP_TYPE_UNBIND_REQUEST), "the abandon request was not forwarded, or the connection was leaked"); } } /** * Every batch request of a SOAP body gets its own connection: the second one * used to be silently skipped because the first connection was left assigned. */ @Test public void testEachBatchRequestGetsItsOwnConnection() throws Exception { try (FakeLdapServer server = new FakeLdapServer()) { Map<String, String> headers = new LinkedHashMap<>(); headers.put("Content-Type", SOAP_1_1_CONTENT_TYPE); String response = doPost(server.getPort(), headers, TWO_ABANDON_BATCHES); assertFalse(response.contains("errorResponse"), response); server.awaitDisconnect(2); assertEquals(server.getReceivedOpTypes(), list(OP_TYPE_BIND_REQUEST, OP_TYPE_ABANDON_REQUEST, OP_TYPE_UNBIND_REQUEST, OP_TYPE_BIND_REQUEST, OP_TYPE_ABANDON_REQUEST, OP_TYPE_UNBIND_REQUEST), "the second batch request was not processed on its own connection"); } } /** * The connection options are shared by all the batch requests of a SOAP body, * and the SASL authzid they carry is single valued: the authzid of a batch * request must not survive into the bind of the next one. */ @Test public void testAuthzIdIsNotAccumulatedAcrossBatchRequests() throws Exception { try (FakeLdapServer server = new FakeLdapServer()) { String response = doAuthzPost(server, TWO_AUTHZ_BATCHES); assertFalse(response.contains("errorResponse"), response); server.awaitDisconnect(2); assertEquals(server.getReceivedOpTypes(), list(OP_TYPE_BIND_REQUEST, OP_TYPE_ABANDON_REQUEST, OP_TYPE_UNBIND_REQUEST, OP_TYPE_BIND_REQUEST, OP_TYPE_ABANDON_REQUEST, OP_TYPE_UNBIND_REQUEST), "the bind of the second batch request did not happen"); assertEquals(server.getReceivedAuthzIds(), asList("dn:cn=first", "dn:cn=second"), "each batch request must bind under the authzid of its own authRequest"); } } /** * A batch request which carries no authRequest must not inherit the * authorization identity of the previous one: the shared connection options * have to be cleared whether or not this batch request sets an authzid. */ @Test public void testAuthzIdDoesNotSurviveIntoBatchRequestWithoutAuthRequest() throws Exception { try (FakeLdapServer server = new FakeLdapServer()) { String response = doAuthzPost(server, MIXED_AUTHZ_BATCHES); assertFalse(response.contains("errorResponse"), response); server.awaitDisconnect(2); assertEquals(server.getReceivedAuthzIds(), asList("dn:cn=first", ""), "the second batch request ran under the authorization identity of the first one"); } } /** * Posts the given SOAP body with HTTP credentials turned into a SASL PLAIN * authid, so that the authRequest of a batch request becomes an authzid. */ private String doAuthzPost(FakeLdapServer server, String body) throws Exception { Map<String, String> params = new LinkedHashMap<>(); params.put("ldap.authzidtypeisid", "true"); Map<String, String> headers = new LinkedHashMap<>(); headers.put("Content-Type", SOAP_1_1_CONTENT_TYPE); headers.put("Authorization", "Basic " + Base64.getEncoder() .encodeToString("user:password".getBytes(StandardCharsets.UTF_8))); return doPost(server.getPort(), params, headers, body); } /** Runs {@code doPost} against a servlet configured to use the given LDAP port. */ private String doPost(int ldapPort, Map<String, String> headers, String body) throws Exception { return doPost(ldapPort, Collections.<String, String> emptyMap(), headers, body); } private String doPost(int ldapPort, Map<String, String> extraParams, Map<String, String> headers, String body) throws Exception { Map<String, String> params = new LinkedHashMap<>(); params.put("ldap.host", InetAddress.getLoopbackAddress().getHostAddress()); params.put("ldap.port", String.valueOf(ldapPort)); params.putAll(extraParams); DSMLServlet servlet = new DSMLServlet(); servlet.init(servletConfig(params)); ByteArrayOutputStream out = new ByteArrayOutputStream(); servlet.doPost(httpRequest(headers, body.getBytes(StandardCharsets.UTF_8)), httpResponse(out)); return new String(out.toByteArray(), StandardCharsets.UTF_8); } private static List<Byte> list(byte... opTypes) { List<Byte> result = new ArrayList<>(opTypes.length); for (byte opType : opTypes) { result.add(opType); } return result; } /** * A minimal LDAP endpoint which answers the bind request with a success * result and records the type of every message it receives, as well as the * authorization identity of every SASL bind. Connections are served one after * the other, so that a SOAP body holding several batch requests can be * exercised. */ private static final class FakeLdapServer implements Closeable { private final ServerSocket serverSocket; private final List<Byte> receivedOpTypes = new CopyOnWriteArrayList<>(); private final List<String> receivedAuthzIds = new CopyOnWriteArrayList<>(); private final Object lock = new Object(); private int closedConnections; private volatile Exception failure; private volatile boolean stopped; FakeLdapServer() throws IOException { serverSocket = new ServerSocket(0, 16, InetAddress.getLoopbackAddress()); Thread thread = new Thread(this::serve, "fake-ldap-server"); thread.setDaemon(true); thread.start(); } int getPort() { return serverSocket.getLocalPort(); } List<Byte> getReceivedOpTypes() { return new ArrayList<>(receivedOpTypes); } /** The authorization identity of every SASL bind, in the order received. */ List<String> getReceivedAuthzIds() { return new ArrayList<>(receivedAuthzIds); } void awaitDisconnect() throws InterruptedException { awaitDisconnect(1); } /** Waits for the given number of connections to have been served. */ void awaitDisconnect(int expectedConnections) throws InterruptedException { final long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(30); synchronized (lock) { while (closedConnections < expectedConnections) { final long remaining = deadline - System.currentTimeMillis(); assertTrue(remaining > 0, "the client did not disconnect: " + closedConnections + " connection(s) served out of " + expectedConnections); lock.wait(remaining); } } assertNull(failure, "the fake LDAP server failed: " + failure); } private void serve() { while (!stopped) { final Socket socket; try { socket = serverSocket.accept(); } catch (IOException e) { if (!stopped) { recordFailure(e); } return; } try (Socket connection = socket) { serveConnection(connection); } catch (Exception e) { recordFailure(e); } finally { synchronized (lock) { closedConnections++; lock.notifyAll(); } } } } private void serveConnection(Socket socket) throws Exception { LDAPReader reader = new LDAPReader(socket); LDAPWriter writer = new LDAPWriter(socket); LDAPMessage message; while ((message = reader.readMessage()) != null) { receivedOpTypes.add(message.getProtocolOpType()); if (message.getProtocolOpType() == OP_TYPE_BIND_REQUEST) { recordAuthzId(message.getBindRequestProtocolOp()); writer.writeMessage(new LDAPMessage(message.getMessageID(), new BindResponseProtocolOp(LDAPResultCode.SUCCESS))); } } } /** * Records the authorization identity of a SASL bind. The credentials of * SASL PLAIN are "authzid NUL authid NUL password", with an empty authzid * when the client asked for none. */ private void recordAuthzId(BindRequestProtocolOp bindRequest) { ByteString credentials = bindRequest.getSASLCredentials(); if (credentials == null) { return; } String plain = credentials.toString(); int separator = plain.indexOf('\0'); receivedAuthzIds.add(separator >= 0 ? plain.substring(0, separator) : plain); } private void recordFailure(Exception e) { if (!stopped && failure == null) { failure = e; } } @Override public void close() throws IOException { stopped = true; serverSocket.close(); } } private static ServletConfig servletConfig(final Map<String, String> params) { final ServletContext context = stub(ServletContext.class, (proxy, method, args) -> { switch (method.getName()) { case "getInitParameter": return params.get(args[0]); case "getInitParameterNames": return Collections.enumeration(params.keySet()); default: return defaultValue(method); } }); return stub(ServletConfig.class, (proxy, method, args) -> "getServletContext".equals(method.getName()) ? context : defaultValue(method)); } private static HttpServletRequest httpRequest(final Map<String, String> headers, final byte[] body) { final ByteArrayInputStream content = new ByteArrayInputStream(body); final ServletInputStream in = new ServletInputStream() { @Override public int read() { return content.read(); } @Override public boolean isFinished() { return content.available() == 0; } @Override public boolean isReady() { return true; } @Override public void setReadListener(ReadListener readListener) { // not used } }; return stub(HttpServletRequest.class, (proxy, method, args) -> { switch (method.getName()) { case "getInputStream": return in; case "getHeaderNames": return Collections.enumeration(headers.keySet()); case "getHeader": return headers.get(args[0]); default: return defaultValue(method); } }); } private static HttpServletResponse httpResponse(final ByteArrayOutputStream out) { final ServletOutputStream os = new ServletOutputStream() { @Override public void write(int b) { out.write(b); } @Override public boolean isReady() { return true; } @Override public void setWriteListener(WriteListener writeListener) { // not used } }; return stub(HttpServletResponse.class, (proxy, method, args) -> "getOutputStream".equals(method.getName()) ? os : defaultValue(method)); } private static <T> T stub(Class<T> type, InvocationHandler handler) { return type.cast(Proxy.newProxyInstance( DSMLServletTestCase.class.getClassLoader(), new Class<?>[] { type }, handler)); } /** * A proxy must return a value assignable to the return type of the invoked * method: {@code null} is only acceptable for a reference or {@code void} * return type, so every primitive has to be covered here. */ private static Object defaultValue(Method method) { Class<?> returnType = method.getReturnType(); if (returnType == boolean.class) { return Boolean.FALSE; } else if (returnType == char.class) { return (char) 0; } else if (returnType == byte.class) { return (byte) 0; } else if (returnType == short.class) { return (short) 0; } else if (returnType == int.class) { return 0; } else if (returnType == long.class) { return 0L; } else if (returnType == float.class) { return 0f; } else if (returnType == double.class) { return 0d; } else if ("toString".equals(method.getName())) { return "stub"; } return null; } } opendj-server-legacy/pom.xml
@@ -1274,7 +1274,8 @@ <org.opends.test.pauseOnFailure>false</org.opends.test.pauseOnFailure> <org.opends.test.copyClassesToTestPackage>false</org.opends.test.copyClassesToTestPackage> <org.opends.test.timeout>600000</org.opends.test.timeout><!--15 mins--> <org.opends.test.trace.pattern>(org\.opends\.server\.replication\.service\..*)|(org\.opends\.server\.replication\.GenerationIdTest)|(org\.opends\.server\.types.\HostPortTest)</org.opends.test.trace.pattern> <!-- Matched against the name of the test class, see org.opends.server.TestListener.onStart(). --> <org.opends.test.trace.pattern>(org\.opends\.server\.replication\.service\..*)|(org\.opends\.server\.replication\.GenerationIdTest)|(org\.opends\.server\.types\.HostPortTest)|(org\.openidentityplatform\.opendj\.AliasTestCase)</org.opends.test.trace.pattern> </systemPropertyVariables> <argLine>@{argLine}</argLine> <reuseForks>false</reuseForks> opendj-server-legacy/src/main/java/org/forgerock/opendj/reactive/LDAPClientConnection2.java
@@ -13,6 +13,7 @@ * * Copyright 2006-2010 Sun Microsystems, Inc. * Portions Copyright 2010-2016 ForgeRock AS. * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.opendj.reactive; @@ -405,7 +406,11 @@ // if operation processing encounters a run-time exception after sending the // response: the worker thread exception handling code will attempt to send // an error result to the client indicating that a problem occurred. if (removeOperationInProgress(operation.getMessageID())) { // A persistent search is the other way around: its search operation is no longer in // progress once the search phase is over, and yet it still owes the client a response if // the server terminates it. if (removeOperationInProgress(operation.getMessageID()) || hasPersistentSearch(operation.getMessageID())) { final Response response = operationToResponse(operation); final FlowableEmitter<Response> out = getAttachedEmitter(operation); if (response != null) { opendj-server-legacy/src/main/java/org/opends/server/api/ClientConnection.java
@@ -13,7 +13,7 @@ * * Copyright 2006-2009 Sun Microsystems, Inc. * Portions Copyright 2011-2016 ForgeRock AS. * Portions Copyright 2025 3A Systems, LLC. * Portions Copyright 2025-2026 3A Systems, LLC. */ package org.opends.server.api; @@ -656,6 +656,27 @@ return persistentSearches; } /** * Indicates whether a persistent search is registered on this connection for the provided message * ID. A persistent search outlives the operation which started it: that operation leaves the set * of operations in progress as soon as its search phase is over, but the server can still have a * final response to send for it, when the search is terminated on the server side. * * @param messageID The message ID to look for. * @return {@code true} if a persistent search is registered for the provided message ID. */ protected final boolean hasPersistentSearch(int messageID) { for (PersistentSearch psearch : persistentSearches) { if (psearch.getMessageID() == messageID) { return true; } } return false; } /** opendj-server-legacy/src/main/java/org/opends/server/api/LocalBackend.java
@@ -24,6 +24,8 @@ import java.util.Set; import java.util.concurrent.ConcurrentLinkedQueue; import org.forgerock.i18n.LocalizableMessage; import org.forgerock.i18n.slf4j.LocalizedLogger; import org.forgerock.opendj.config.Configuration; import org.forgerock.opendj.config.server.ConfigException; import org.forgerock.opendj.ldap.ConditionResult; @@ -72,6 +74,8 @@ public abstract class LocalBackend<C extends Configuration> extends Backend<C> // should have been BackendCfg instead of Configuration { private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass(); /** Indicates whether this is a private backend or one that holds user data. */ private boolean isPrivateBackend; @@ -103,7 +107,11 @@ { for (PersistentSearch psearch : persistentSearches) { psearch.cancel(); // Tell the clients that no more changes are coming: this backend will not notify them any // more, and a cancelled persistent search which sends nothing leaves them waiting forever. final LocalizableMessage reason = WARN_PSEARCH_BACKEND_UNAVAILABLE.get(getBackendID()); logger.warn(reason); psearch.cancelAndNotifyClient(reason); } persistentSearches.clear(); closeBackend(); opendj-server-legacy/src/main/java/org/opends/server/backends/ChangelogBackend.java
@@ -35,6 +35,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Queue; import java.util.Set; import java.util.TimeZone; import java.util.concurrent.ConcurrentLinkedQueue; @@ -399,7 +400,10 @@ { final SearchOperation searchOp = pSearch.getSearchOperation(); final CookieEntrySender entrySender = searchOp.getAttachment(ENTRY_SENDER_ATTACHMENT); entrySender.persistentSearchSendEntry(baseDN, updateMsg); if (!entrySender.persistentSearchSendEntry(baseDN, updateMsg)) { stopPersistentSearch(pSearch); } } } catch (DirectoryException e) @@ -447,7 +451,10 @@ { final SearchOperation searchOp = pSearch.getSearchOperation(); final ChangeNumberEntrySender entrySender = searchOp.getAttachment(ENTRY_SENDER_ATTACHMENT); entrySender.persistentSearchSendEntry(changeNumber, changeNumberEntry); if (!entrySender.persistentSearchSendEntry(changeNumber, changeNumberEntry)) { stopPersistentSearch(pSearch); } } } catch (DirectoryException e) @@ -875,14 +882,20 @@ { initializePersistentSearch(pSearch); if (isCookieBased(pSearch.getSearchOperation())) final Queue<PersistentSearch> psearches = isCookieBased(pSearch.getSearchOperation()) ? cookieBasedPersistentSearches : changeNumberBasedPersistentSearches; psearches.add(pSearch); // Without this, a cancelled persistent search keeps being handed the changes it can no longer // report, for as long as this backend lives. pSearch.registerCancellationCallback(new PersistentSearch.CancellationCallback() { cookieBasedPersistentSearches.add(pSearch); } else { changeNumberBasedPersistentSearches.add(pSearch); } @Override public void persistentSearchCancelled(PersistentSearch psearch) { psearches.remove(psearch); } }); super.registerPersistentSearch(pSearch); } @@ -1400,6 +1413,47 @@ return true; } /** * Sends a change reported by the "persistent search" phase, if it matches the base, scope and * filter of the current search operation. Contrary to the "initial search" phase, the change goes * through the persistent search path: it is not bound by the size and time limits of the search, * which are only lifted once the initial phase is over, and a change published in the meantime * would be dropped without the client ever hearing about it. * * @return {@code true} if the persistent search should keep reporting changes, {@code false} * otherwise */ private static boolean sendNotificationIfMatches(SearchOperation searchOp, Entry entry, String cookie) throws DirectoryException { if (matchBaseAndScopeAndFilter(searchOp, entry)) { return searchOp.returnPersistentSearchEntry(entry, getControls(cookie)); } // maybe the next entry will match? return true; } /** * Stops the provided persistent search and tells the client, which would otherwise wait forever * for changes on a search which no longer reports any. */ private static void stopPersistentSearch(PersistentSearch pSearch) { try { // Before the cancellation, which deregisters this persistent search from the connection: the // search operation left the operations in progress when its initial phase ended, so nothing // would be left to hang the response on afterwards. pSearch.getSearchOperation().sendSearchResultDone(); } catch (Exception e) { logger.traceException(e); } pSearch.cancel(); } /** Indicates if the provided entry matches the filter, base and scope. */ private static boolean matchBaseAndScopeAndFilter(SearchOperation searchOp, Entry entry) throws DirectoryException { @@ -1647,12 +1701,17 @@ return sendEntryIfMatches(searchOp, entry, null); } private void persistentSearchSendEntry(long changeNumber, Entry entry) throws DirectoryException /** * @return {@code true} if the persistent search should keep reporting changes, {@code false} * otherwise */ private boolean persistentSearchSendEntry(long changeNumber, Entry entry) throws DirectoryException { if (sendEntryData.persistentSearchCanSendEntry(changeNumber)) { sendEntryIfMatches(searchOp, entry, null); return sendNotificationIfMatches(searchOp, entry, null); } return true; } } @@ -1713,7 +1772,11 @@ return sendEntryIfMatches(searchOp, entry, cookieString); } private void persistentSearchSendEntry(DN baseDN, UpdateMsg updateMsg) /** * @return {@code true} if the persistent search should keep reporting changes, {@code false} * otherwise */ private boolean persistentSearchSendEntry(DN baseDN, UpdateMsg updateMsg) throws DirectoryException { final CSN csn = updateMsg.getCSN(); @@ -1725,8 +1788,9 @@ final Entry cookieEntry = createEntryFromMsg(baseDN, 0, cookieString, updateMsg); // FIXME JNR use this instead of previous line: // entry.replaceAttribute(Attributes.create("changelogcookie", cookieString)); sendEntryIfMatches(searchOp, cookieEntry, cookieString); return sendNotificationIfMatches(searchOp, cookieEntry, cookieString); } return true; } private String updateCookie(DN baseDN, final CSN csn) opendj-server-legacy/src/main/java/org/opends/server/core/PersistentSearch.java
@@ -13,6 +13,7 @@ * * Copyright 2006-2010 Sun Microsystems, Inc. * Portions Copyright 2014-2016 ForgeRock AS. * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.server.core; @@ -21,6 +22,7 @@ import java.util.Set; import java.util.concurrent.CopyOnWriteArrayList; import org.forgerock.i18n.LocalizableMessage; import org.forgerock.i18n.slf4j.LocalizedLogger; import org.forgerock.opendj.ldap.ResultCode; import org.opends.server.controls.EntryChangeNotificationControl; @@ -190,6 +192,42 @@ } /** * Cancels this persistent search and tells the client that no more changes will be reported for * it. Contrary to {@link #cancel()}, which leaves the search open as far as the client can tell, * this is meant for cancellations decided by the server: without a search result done, the client * waits forever for changes on a search which no longer exists. * * @param reason * The reason why this persistent search is terminated, reported to the client. * @return The result of the cancellation. */ public synchronized CancelResult cancelAndNotifyClient(LocalizableMessage reason) { if (isCancelled) { // Whoever cancelled this search first is responsible for what the client was told: a second // search result done for the same message ID would break the protocol. return new CancelResult(ResultCode.CANCELLED, null); } try { searchOperation.setResultCode(ResultCode.UNAVAILABLE); searchOperation.appendErrorMessage(reason); // The response is sent before the cancellation on purpose: the search operation left the set // of operations in progress when its search phase ended, so the connection only knows it as // this persistent search, which cancelling deregisters. searchOperation.sendSearchResultDone(); } catch (Exception e) { // The client may be gone already: the persistent search is cancelled either way. logger.traceException(e); } return cancel(); } /** * Gets the message ID associated with this persistent search. * * @return The message ID associated with this persistent search. @@ -388,18 +426,21 @@ { try { if (!searchOperation.returnEntry(entry, entryControls)) // Notifications go through their own path: a change must be reported whether or not the // entry was already returned by the search phase, and for as long as this search lives. if (!searchOperation.returnPersistentSearchEntry(entry, entryControls)) { cancel(); // Send the response first: cancelling deregisters this persistent search, and the search // operation is no longer in progress on the connection either, so there would be nothing // left to hang the response on. searchOperation.sendSearchResultDone(); cancel(); } } catch (Exception e) { logger.traceException(e); cancel(); try { searchOperation.sendSearchResultDone(); @@ -408,6 +449,8 @@ { logger.traceException(e2); } cancel(); } } opendj-server-legacy/src/main/java/org/opends/server/core/SearchOperation.java
@@ -264,10 +264,27 @@ boolean evaluateAci); /** * Used as a callback for persistent searches to send an entry which has just changed to the * client. Contrary to {@link #returnEntry(Entry, List)}, the entry is not matched against the * state kept to dereference aliases during the search phase, and neither the size limit nor the * time limit of the search applies to it: a persistent search must report every change it is * notified of, for as long as it is alive, whether or not the entry was returned before. * * @param entry The entry which has changed and should be sent to the client. * @param controls The set of controls to include with the entry (may be <CODE>null</CODE> if * none are needed). * * @return <CODE>true</CODE> if the persistent search should keep reporting changes, or * <CODE>false</CODE> if it should stop for some reason (e.g. the search has been * abandoned). */ boolean returnPersistentSearchEntry(Entry entry, List<Control> controls); /** * Indicates that the search phase is over and that any further entry comes from a persistent * search. State kept to dereference aliases during the search phase is released, and no further * entry is matched against it: a persistent search must report every change it is notified of, * whether or not the entry was returned by the search phase. * search. State kept to dereference aliases during the search phase is released. Entries can * still reach {@link #returnEntry(Entry, List)} afterwards, as backends are free to report their * own results from another thread, so that method keeps track of this phase being over. */ void endSearchPhase(); opendj-server-legacy/src/main/java/org/opends/server/core/SearchOperationBasis.java
@@ -20,6 +20,7 @@ import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import org.forgerock.i18n.LocalizedIllegalArgumentException; import org.forgerock.i18n.slf4j.LocalizedLogger; @@ -128,8 +129,11 @@ /** The proxied authorization target DN for this operation. */ private DN proxiedAuthorizationDN; /** The number of entries that have been sent to the client. */ private int entriesSent; /** * The number of entries that have been sent to the client. Persistent search notifications are * sent by the threads which apply the changes, so several of them can be counted concurrently. */ private final AtomicInteger entriesSent = new AtomicInteger(); /** * The number of search result references that have been sent to the client. @@ -436,7 +440,7 @@ @Override public final int getEntriesSent() { return entriesSent; return entriesSent.get(); } @Override @@ -454,14 +458,31 @@ /** * The DNs of the entries already returned by the search phase. An alias may be dereferenced onto * an entry which is in the scope of the search as well, and that entry must only be returned once. * It is emptied once the search phase is over, because a persistent search must report every * change it is notified of, whether or not the entry was returned before. * It is emptied once the search phase is over, because it is only meaningful while that phase * runs. */ private final Set<DN> returnedDNs = ConcurrentHashMap.newKeySet(); /** Whether the search phase is over and only persistent search notifications remain. */ /** * Whether the search phase is over. Entries still sent through {@link #returnEntry(Entry, List)} * after it, as the external changelog backend does for its own notifications, are no longer * matched against {@link #returnedDNs}, which has been emptied by then. */ private volatile boolean searchPhaseOver; /** Why an entry is being returned to the client. */ private enum EntrySource { /** The entry is a result of the search phase. */ SEARCH_PHASE, /** * The entry reports a change to a persistent search. It must be sent whether or not the same * entry was returned before, and neither the size limit nor the time limit of the search * applies to it. */ PSEARCH_NOTIFICATION } @Override public final void endSearchPhase() { @@ -473,7 +494,13 @@ public final boolean returnEntry(Entry entry, List<Control> controls, boolean evaluateAci) { return returnEntry(entry, controls, evaluateAci, null); return returnEntry(entry, controls, evaluateAci, EntrySource.SEARCH_PHASE, null); } @Override public final boolean returnPersistentSearchEntry(Entry entry, List<Control> controls) { return returnEntry(entry, controls, true, EntrySource.PSEARCH_NOTIFICATION, null); } /** @@ -483,33 +510,40 @@ * @param entry The entry to return. * @param controls The controls to attach to the entry. * @param evaluateAci Whether the access control handler must be consulted. * @param source Whether the entry is a persistent search notification rather than a result * of the search phase. * @param aliasChain The DNs of the aliases already dereferenced on the way to this entry, or * {@code null} if no alias was dereferenced yet. It only spans the current * chain, so it cannot grow beyond the length of that chain. * @return {@code true} if the search should continue, {@code false} if it should stop. */ private boolean returnEntry(Entry entry, List<Control> controls, boolean evaluateAci, Set<DN> aliasChain) boolean evaluateAci, EntrySource source, Set<DN> aliasChain) { boolean typesOnly = getTypesOnly(); // See if the size limit has been exceeded. If so, then don't send the // entry and indicate that the search should end. if (getSizeLimit() > 0 && getEntriesSent() >= getSizeLimit()) // Both limits only bound the search phase: they are lifted for the rest of a persistent search // once that phase is over, but a notification can reach this point before that happens. if (source == EntrySource.SEARCH_PHASE) { setResultCode(ResultCode.SIZE_LIMIT_EXCEEDED); appendErrorMessage(ERR_SEARCH_SIZE_LIMIT_EXCEEDED.get(getSizeLimit())); return false; } // See if the size limit has been exceeded. If so, then don't send the // entry and indicate that the search should end. if (getSizeLimit() > 0 && getEntriesSent() >= getSizeLimit()) { setResultCode(ResultCode.SIZE_LIMIT_EXCEEDED); appendErrorMessage(ERR_SEARCH_SIZE_LIMIT_EXCEEDED.get(getSizeLimit())); return false; } // See if the time limit has expired. If so, then don't send the entry and // indicate that the search should end. if (getTimeLimit() > 0 && TimeThread.getTime() >= getTimeLimitExpiration()) { setResultCode(ResultCode.TIME_LIMIT_EXCEEDED); appendErrorMessage(ERR_SEARCH_TIME_LIMIT_EXCEEDED.get(getTimeLimit())); return false; // See if the time limit has expired. If so, then don't send the entry and // indicate that the search should end. if (getTimeLimit() > 0 && TimeThread.getTime() >= getTimeLimitExpiration()) { setResultCode(ResultCode.TIME_LIMIT_EXCEEDED); appendErrorMessage(ERR_SEARCH_TIME_LIMIT_EXCEEDED.get(getTimeLimit())); return false; } } // Determine whether the provided entry is a subentry and if so whether it @@ -526,12 +560,15 @@ && !filterIncludesSubentries && !isReturnSubentriesOnly()) { logger.trace("Not sending entry %s: it is a subentry and this search does not ask for " + "subentries", entry.getName()); return true; } } else if (isReturnSubentriesOnly()) { // Subentries are visible and normal entries are not. logger.trace("Not sending entry %s: this search only asks for subentries", entry.getName()); return true; } @@ -596,16 +633,18 @@ SearchResultEntry unfilteredSearchEntry = new SearchResultEntry(entry, controls); if (evaluateAci && !getACIHandler().maySend(this, unfilteredSearchEntry)) { logger.trace("Not sending entry %s: access control forbids it", entry.getName()); return true; } //DereferenceAliasesPolicy if ( DereferenceAliasesPolicy.ALWAYS.equals(getDerefPolicy()) || DereferenceAliasesPolicy.IN_SEARCHING.equals(getDerefPolicy()) ) { if (entry.isAlias() && !baseDN.equals(entry.getName())) { return returnAliasedEntry(entry, controls, aliasChain); return returnAliasedEntry(entry, controls, source, aliasChain); } if (!searchPhaseOver && !returnedDNs.add(entry.getName())) { if (source == EntrySource.SEARCH_PHASE && !searchPhaseOver && !returnedDNs.add(entry.getName())) { // This entry was already returned by the search, through an alias or on its own. logger.trace("Not sending entry %s: it was already returned by the search phase", entry.getName()); return true; } } @@ -721,7 +760,7 @@ { sendSearchEntry(filteredSearchEntry); entriesSent++; entriesSent.incrementAndGet(); } catch (DirectoryException de) { @@ -731,6 +770,10 @@ return false; } } else { logger.trace("Not sending entry %s: a search result entry plugin suppressed it", entry.getName()); } return pluginResult.continueProcessing(); } @@ -740,11 +783,14 @@ * * @param alias The alias entry to dereference. * @param controls The controls to attach to the entry. * @param source Whether the alias is reported by a persistent search notification rather * than by the search phase. * @param aliasChain The DNs of the aliases already dereferenced on the way to this alias, or * {@code null} if this alias is the first one of the chain. * @return {@code true} if the search should continue, {@code false} if it should stop. */ private boolean returnAliasedEntry(Entry alias, List<Control> controls, Set<DN> aliasChain) private boolean returnAliasedEntry(Entry alias, List<Control> controls, EntrySource source, Set<DN> aliasChain) { final DN aliasedDN; final Entry aliasedEntry; @@ -765,11 +811,14 @@ if (aliasedEntry == null) { // The alias points to an entry which does not exist: there is nothing to return for it. logger.trace("Not dereferencing alias %s: %s does not exist", alias.getName(), aliasedDN); return true; } if (!searchPhaseOver && returnedDNs.contains(aliasedDN)) if (source == EntrySource.SEARCH_PHASE && !searchPhaseOver && returnedDNs.contains(aliasedDN)) { // The aliased entry was already returned by the search. logger.trace("Not dereferencing alias %s: %s was already returned by the search phase", alias.getName(), aliasedDN); return true; } if (aliasChain == null) @@ -779,9 +828,11 @@ if (!aliasChain.add(aliasedDN)) { // The aliases point at each other: stop before looping forever. logger.trace("Not dereferencing alias %s: %s is already part of the alias chain %s", alias.getName(), aliasedDN, aliasChain); return true; } return returnEntry(aliasedEntry, controls, true, aliasChain); return returnEntry(aliasedEntry, controls, true, source, aliasChain); } private AccessControlHandler<?> getACIHandler() opendj-server-legacy/src/main/java/org/opends/server/core/SearchOperationWrapper.java
@@ -59,6 +59,12 @@ } @Override public boolean returnPersistentSearchEntry(Entry entry, List<Control> controls) { return getOperation().returnPersistentSearchEntry(entry, controls); } @Override public void endSearchPhase() { getOperation().endSearchPhase(); opendj-server-legacy/src/main/java/org/opends/server/protocols/ldap/LDAPClientConnection.java
@@ -13,6 +13,7 @@ * * Copyright 2006-2010 Sun Microsystems, Inc. * Portions Copyright 2010-2016 ForgeRock AS. * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.server.protocols.ldap; @@ -678,7 +679,11 @@ // if operation processing encounters a run-time exception after sending the // response: the worker thread exception handling code will attempt to send // an error result to the client indicating that a problem occurred. if (removeOperationInProgress(operation.getMessageID())) // A persistent search is the other way around: its search operation is no longer in progress // once the search phase is over, and yet it still owes the client a response if the server // terminates it. if (removeOperationInProgress(operation.getMessageID()) || hasPersistentSearch(operation.getMessageID())) { LDAPMessage message = operationToResponseLDAPMessage(operation); if (message != null) opendj-server-legacy/src/main/java/org/opends/server/tools/LDAPConnection.java
@@ -443,10 +443,10 @@ { try { final Socket s=new Socket(); s.setReuseAddress(true); s.bind( new InetSocketAddress(inetAddress, portNumber)); return s; final Socket s = new Socket(); s.setReuseAddress(true); s.connect(new InetSocketAddress(inetAddress, portNumber)); return s; } catch (ConnectException ce2) { opendj-server-legacy/src/messages/org/opends/messages/backend.properties
@@ -1106,3 +1106,5 @@ ERR_SERVICE_DISCOVERY_CONFIG_MANAGER_INIT_MECHANISM_614=Service Discovery Mechanism '%s' initialization failed : %s ERR_SERVICE_DISCOVERY_CONFIG_MANAGER_LISTENER_615=Registering Service Discovery Manager's listener failed : %s NOTE_IMPORT_MIGRATION_START_616=Migrating %s entries for base DN %s so that they are preserved by the partial import WARN_PSEARCH_BACKEND_UNAVAILABLE_617=The persistent search is being terminated because backend %s is \ no longer available opendj-server-legacy/src/test/java/org/opends/server/controls/PersistentSearchControlTest.java
@@ -47,6 +47,7 @@ import org.forgerock.util.Utils; import org.opends.server.TestCaseUtils; import org.opends.server.core.ModifyOperation; import org.opends.server.core.PersistentSearch; import org.opends.server.protocols.internal.InternalSearchOperation; import org.opends.server.protocols.internal.SearchRequest; import org.opends.server.protocols.ldap.LDAPControl; @@ -557,8 +558,29 @@ "(objectClass=*)" }; assertEquals(LDAPSearch.run(nullPrintStream(), System.err, args), 11); //cancel the persisting persistent search. search.cancel(new CancelRequest(true,LocalizableMessage.EMPTY)); try { assertEquals(LDAPSearch.run(nullPrintStream(), System.err, args), 11); } finally { // Cancel the persistent search itself: search.cancel() only records a cancellation request // for the operation, which nothing acts upon now that the thread running it is gone, so the // persistent search would stay registered and keep holding the limit set above against // whatever runs next in this JVM (a failing test class is rerun in it). for (PersistentSearch psearch : search.getClientConnection().getPersistentSearches()) { if (psearch.getMessageID() == search.getMessageID()) { psearch.cancel(); } } search.cancel(new CancelRequest(true, LocalizableMessage.EMPTY)); //Restore the limit configured for the tests. ModifyRequest restoreRequest = newModifyRequest("cn=config") .addModification(ModificationType.REPLACE, "ds-cfg-max-psearches", "-1"); assertEquals(getRootConnection().processModify(restoreRequest).getResultCode(), ResultCode.SUCCESS); } } } opendj-server-legacy/src/test/java/org/opends/server/tools/LDAPConnectionTestCase.java
New file @@ -0,0 +1,96 @@ /* * The contents of this file are subject to the terms of the Common Development and * Distribution License (the License). You may not use this file except in compliance with the * License. * * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the * specific language governing permission and limitations under the License. * * When distributing Covered Software, include this CDDL Header Notice in each file and include * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL * Header, with the fields enclosed by brackets [] replaced by your own identifying * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2026 3A Systems, LLC. */ package org.opends.server.tools; import static org.opends.server.protocols.ldap.LDAPResultCode.CLIENT_SIDE_CONNECT_ERROR; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertNotNull; import static org.testng.Assert.fail; import java.net.InetAddress; import java.util.concurrent.atomic.AtomicInteger; import org.opends.server.DirectoryServerTestCase; import org.opends.server.TestCaseUtils; import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; /** * Tests the plain (neither SSL nor StartTLS) connection path of * {@link LDAPConnection}, which is the one used by the DSML gateway and by the * tools built on {@code LDAPConnectionArgumentParser}. Those are the only * callers reaching {@code createSocket()}: a caller which installs an * {@code SSLConnectionFactory} goes through {@code createSSLSocket()} instead. */ @SuppressWarnings("javadoc") @Test(groups = { "precommit", "tools" }, sequential = true) public class LDAPConnectionTestCase extends DirectoryServerTestCase { @BeforeClass public void startServer() throws Exception { TestCaseUtils.startServer(); } /** * The socket must be connected to the directory server: binding it to the * server address instead makes every plain connection fail with * "Address already in use". */ @Test public void testConnectToHostConnectsThePlainSocket() throws Exception { LDAPConnection connection = new LDAPConnection( InetAddress.getLoopbackAddress().getHostAddress(), TestCaseUtils.getServerLdapPort(), new LDAPConnectionOptions()); try { connection.connectToHost("cn=Directory Manager", "password"); assertNotNull(connection.getLDAPReader(), "the connection was not established"); assertNotNull(connection.getLDAPWriter(), "the connection was not established"); } finally { connection.close(new AtomicInteger(1)); } } /** * A port with nothing behind it must be reported as a connect error: it is * the {@code ConnectException} of each candidate address which drives the * failover of {@code createSocket()}. */ @Test public void testConnectToClosedPortIsAConnectError() throws Exception { LDAPConnection connection = new LDAPConnection( InetAddress.getLoopbackAddress().getHostAddress(), TestCaseUtils.findFreePort(), new LDAPConnectionOptions()); try { connection.connectToHost("cn=Directory Manager", "password"); fail("connecting to a closed port should have failed"); } catch (LDAPConnectionException e) { assertEquals(e.getResultCode(), CLIENT_SIDE_CONNECT_ERROR, String.valueOf(e)); } finally { connection.close(new AtomicInteger(1)); } } } opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/AliasTestCase.java
@@ -21,6 +21,7 @@ import org.forgerock.opendj.ldap.controls.PersistentSearchRequestControl; import org.forgerock.opendj.ldap.requests.Requests; import org.forgerock.opendj.ldap.requests.SearchRequest; import org.forgerock.opendj.ldap.responses.Result; import org.forgerock.opendj.ldap.responses.SearchResultEntry; import org.forgerock.opendj.ldap.responses.SearchResultReference; import org.forgerock.opendj.ldif.ConnectionEntryReader; @@ -32,6 +33,9 @@ import org.opends.server.api.LocalBackend; import org.opends.server.backends.MemoryBackend; import org.opends.server.core.DirectoryServer; import org.opends.server.core.PersistentSearch; import org.opends.server.protocols.internal.InternalClientConnection; import org.opends.server.protocols.internal.InternalSearchOperation; import org.opends.server.types.AcceptRejectWarn; import org.opends.server.types.Entry; import org.testng.annotations.AfterClass; @@ -562,7 +566,9 @@ psearch.searchAsync(request, new SearchResultHandler() { @Override public boolean handleEntry(SearchResultEntry entry) { notified.add(entry.getName().toString()); // Every notification carries the same DN, so the DN alone cannot tell a lost // notification from a duplicated one: record which change is being reported. notified.add(entry.getName() + " " + entry.parseAttribute("description").asString()); return true; } @@ -574,25 +580,150 @@ // searchAsync returns before the server has registered the persistent search, so wait // until the backend reports it; otherwise the first modification below can be notified // before the search is listening and be missed. // before the search is listening and be missed. A failed test is rerun in the same JVM // (rerunFailingTestsCount), which can leave the persistent search of the previous run // behind, hence the wait for a persistent search on our own base DN. final LocalBackend<?> backend = TestCaseUtils.getServerContext() .getBackendConfigManager().getLocalBackendById(TestCaseUtils.TEST_BACKEND_ID); for (int i = 0; backend.getPersistentSearches().isEmpty() && i < 500; i++) { for (int i = 0; !isPersistentSearchRegistered(backend, "ou=psearch,o=test") && i < 500; i++) { Thread.sleep(10); } assertThat(backend.getPersistentSearches()).isNotEmpty(); assertThat(isPersistentSearchRegistered(backend, "ou=psearch,o=test")) .as("the persistent search was never registered with backend %s", backend.getBackendID()) .isTrue(); // The same entry is modified repeatedly: each change must reach the persistent search. for (int i = 1; i <= 3; i++) { connection.modify(Requests.newModifyRequest("cn=changing,ou=psearch,o=test") .addModification(ModificationType.REPLACE, "description", "change " + i)); assertThat(notified.poll(30, TimeUnit.SECONDS)) .as("notification for change " + i) .isEqualTo("cn=changing,ou=psearch,o=test"); .as("notification for change %d, persistent search still registered: %s, " + "notifications received afterwards: %s", i, isPersistentSearchRegistered(backend, "ou=psearch,o=test"), notified) .isEqualTo("cn=changing,ou=psearch,o=test change " + i); } } } // A persistent search notification is not a search result: it must be reported whether or not // the entry was returned before, and it is not bound by the size and time limits of the search. // In a real persistent search the search phase is only open for a few instructions after the // search is registered with the backend, so the notification path is driven directly here. @Test public void test_persistent_search_notification_ignores_search_phase_state() throws Exception { TestCaseUtils.addEntries( "dn: ou=psearch-notify,o=test", "objectClass: top", "objectClass: organizationalUnit", "ou: psearch-notify", "" ); final Entry entry = DirectoryServer.getEntry(DN.valueOf("ou=psearch-notify,o=test")); final InternalSearchOperation search = new InternalSearchOperation( InternalClientConnection.getRootConnection(), InternalClientConnection.nextOperationID(), InternalClientConnection.nextMessageID(), org.opends.server.protocols.internal.Requests .newSearchRequest(DN.valueOf("o=test"), SearchScope.WHOLE_SUBTREE) .setDereferenceAliasesPolicy(DereferenceAliasesPolicy.ALWAYS)); // The search phase returns the entry once, and drops it when it reaches it a second time // through an alias ... assertThat(search.returnEntry(entry, null)).isTrue(); assertThat(search.returnEntry(entry, null)).isTrue(); assertThat(search.getSearchEntries()).hasSize(1); // ... but a change reported to a persistent search is never a duplicate, whether the search // phase is still open (the entry was just returned by it) or already over. assertThat(search.returnPersistentSearchEntry(entry, null)).isTrue(); search.endSearchPhase(); assertThat(search.returnPersistentSearchEntry(entry, null)).isTrue(); assertThat(search.getSearchEntries()).hasSize(3); // The size limit of the search does not bound a notification: it only bounds the search // phase, and is lifted for the rest of a persistent search once that phase is over. search.setSizeLimit(1); assertThat(search.returnPersistentSearchEntry(entry, null)).isTrue(); assertThat(search.getSearchEntries()).hasSize(4); // The search phase itself is still bound by it. assertThat(search.returnEntry(entry, null)).isFalse(); assertThat(search.getResultCode()).isEqualTo(ResultCode.SIZE_LIMIT_EXCEEDED); // Same for the time limit, checked on its own: with a size limit left in the way the search // phase would stop on that one and the time limit would never be reached. search.setSizeLimit(0); search.setTimeLimit(1); search.setTimeLimitExpiration(0); assertThat(search.returnPersistentSearchEntry(entry, null)).isTrue(); assertThat(search.getSearchEntries()).hasSize(5); assertThat(search.returnEntry(entry, null)).isFalse(); assertThat(search.getResultCode()).isEqualTo(ResultCode.TIME_LIMIT_EXCEEDED); } // A persistent search which is cancelled because its backend goes away, as happens when the // backend is disabled or re-initialized, must be told so: without a search result done the // client waits forever for changes on a search which no longer exists. @Test public void test_persistent_search_is_told_when_its_backend_goes_away() throws Exception { final String backendID = "psearchUnavailable"; final String baseDN = "o=psearch-unavailable"; TestCaseUtils.initializeMemoryBackend(backendID, baseDN, true); final MemoryBackend backend = (MemoryBackend) TestCaseUtils.getServerContext() .getBackendConfigManager().getLocalBackendById(backendID); final SearchRequest request = Requests.newSearchRequest(baseDN, SearchScope.WHOLE_SUBTREE, "(objectclass=*)") .addControl(PersistentSearchRequestControl.newControl( true, true, false, PersistentSearchChangeType.MODIFY)); final LDAPConnectionFactory factory = new LDAPConnectionFactory("localhost", TestCaseUtils.getServerLdapPort()); try (Connection psearch = factory.getConnection()) { psearch.bind("cn=Directory Manager", "password".toCharArray()); final LdapPromise<Result> searchDone = psearch.searchAsync(request, new SearchResultHandler() { @Override public boolean handleEntry(SearchResultEntry entry) { return true; } @Override public boolean handleReference(SearchResultReference reference) { return true; } }); for (int i = 0; !isPersistentSearchRegistered(backend, baseDN) && i < 500; i++) { Thread.sleep(10); } assertThat(isPersistentSearchRegistered(backend, baseDN)) .as("the persistent search was never registered with backend %s", backendID) .isTrue(); backend.finalizeBackend(); try { final Result result = searchDone.getOrThrow(30, TimeUnit.SECONDS); fail("the persistent search should have been terminated, it returned " + result); } catch (LdapException e) { assertThat(e.getResult().getResultCode()).isEqualTo(ResultCode.UNAVAILABLE); assertThat(e.getResult().getDiagnosticMessage()).contains(backendID); } } finally { TestCaseUtils.getServerContext().getBackendConfigManager().deregisterLocalBackend(backend); } } /** Whether the provided backend has a persistent search registered for the provided base DN. */ private static boolean isPersistentSearchRegistered(LocalBackend<?> backend, String baseDN) { for (PersistentSearch psearch : backend.getPersistentSearches()) { if (psearch.getSearchOperation().getBaseDN().equals(DN.valueOf(baseDN))) { return true; } } return false; } // An alias is dereferenced before its target is reached on its own: the target must still be // returned, and exactly once. The original regression was order-sensitive, dropping the target // when the alias reached it first, so this pins the alias-before-target order specifically. The