mirror of https://github.com/OpenIdentityPlatform/OpenDJ.git

15 files modified
2 files added
1385 ■■■■■ changed files
opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLServlet.java 124 ●●●●● patch | view | raw | blame | history
opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/DSMLServletTestCase.java 655 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/pom.xml 3 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/forgerock/opendj/reactive/LDAPClientConnection2.java 7 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/api/ClientConnection.java 23 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/api/LocalBackend.java 10 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/backends/ChangelogBackend.java 90 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/core/PersistentSearch.java 51 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/core/SearchOperation.java 23 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/core/SearchOperationBasis.java 109 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/core/SearchOperationWrapper.java 6 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/protocols/ldap/LDAPClientConnection.java 7 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/tools/LDAPConnection.java 8 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/messages/org/opends/messages/backend.properties 2 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/controls/PersistentSearchControlTest.java 28 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/tools/LDAPConnectionTestCase.java 96 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/AliasTestCase.java 143 ●●●●● patch | view | raw | blame | history
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