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

16 files modified
2 files added
1029 ■■■■■ changed files
opendj-packages/opendj-docker/Dockerfile 5 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/backends/ChangelogBackend.java 14 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/backends/MemoryBackend.java 11 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/BackendImpl.java 24 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/core/SearchOperation.java 9 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/core/SearchOperationBasis.java 105 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/core/SearchOperationWrapper.java 7 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/replication/server/DataServerHandler.java 5 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/replication/server/ReplicationServerDomain.java 69 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/replication/server/ReplicationServerHandler.java 20 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/replication/server/ServerHandler.java 69 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendSearchOperation.java 9 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/backends/ChangelogBackendTestCase.java 80 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/backends/MemoryBackendTestCase.java 77 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/PluggableBackendImplTestCase.java 18 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/replication/InitOnLineTest.java 26 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/replication/server/HandshakeAbortGenerationIdTest.java 175 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/AliasTestCase.java 306 ●●●●● patch | view | raw | blame | history
opendj-packages/opendj-docker/Dockerfile
@@ -42,13 +42,14 @@
#COPY opendj-*.zip $OPENDJ_DIST_FILENAME
RUN  apt-get update \
RUN  printf 'Acquire::ForceIPv4 "true";\nAcquire::Retries "5";\n' > /etc/apt/apt.conf.d/99-ci-network \
 && apt-get update \
 && apt-get install -y --no-install-recommends curl unzip \
 && if [ -z "$VERSION" ] ; then VERSION="$(curl -i -o - --silent https://api.github.com/repos/OpenIdentityPlatform/OpenDJ/releases/latest | grep -m1 "\"name\"" | cut -d\" -f4)"; fi \
 && if [ ! -f "$OPENDJ_DIST_FILENAME" ]; then echo file exists && curl -L https://github.com/OpenIdentityPlatform/OpenDJ/releases/download/$VERSION/opendj-$VERSION.zip --output $OPENDJ_DIST_FILENAME; fi \
 && unzip $OPENDJ_DIST_FILENAME \
 && apt-get remove -y --purge curl unzip \
 && rm -rf /var/lib/apt/lists/* \
 && rm -rf /var/lib/apt/lists/* /etc/apt/apt.conf.d/99-ci-network \
 && rm -r $OPENDJ_DIST_FILENAME \
 && groupadd $OPENDJ_USER \
 && useradd -m -r -u 1001 -g $OPENDJ_USER $OPENDJ_USER \
opendj-server-legacy/src/main/java/org/opends/server/backends/ChangelogBackend.java
@@ -297,6 +297,14 @@
    return true;
  }
  /**
   * {@inheritDoc}
   * <p>
   * Only the base changelog entry can be retrieved by DN. Change records are streamed by
   * {@link #search(SearchOperation)}, which needs the search filter and the change number range to
   * position its cursors, so they cannot be looked up individually and {@code null} is returned for
   * them. None of them is ever an alias, so callers dereferencing aliases get the answer they need.
   */
  @Override
  public Entry getEntry(final DN entryDN) throws DirectoryException
  {
@@ -305,7 +313,11 @@
      throw new DirectoryException(DirectoryServer.getCoreConfigManager().getServerErrorResultCode(),
          ERR_BACKEND_GET_ENTRY_NULL.get(getBackendID()));
    }
    throw new RuntimeException("Not implemented");
    if (CHANGELOG_BASE_DN.equals(entryDN))
    {
      return buildBaseChangelogEntry();
    }
    return null;
  }
  @Override
opendj-server-legacy/src/main/java/org/opends/server/backends/MemoryBackend.java
@@ -13,6 +13,7 @@
 *
 * Copyright 2006-2008 Sun Microsystems, Inc.
 * Portions Copyright 2014-2016 ForgeRock AS.
 * Portions Copyrighted 2026 3A Systems, LLC.
 */
package org.opends.server.backends;
@@ -494,9 +495,10 @@
    SearchScope  scope  = searchOperation.getScope();
    SearchFilter filter = searchOperation.getFilter();
    // Make sure the base entry exists if it's supposed to be in this backend.
    // Make sure the base entry exists. A base DN this backend does not hold has no entry
    // either, so it must be reported as missing rather than searched for.
    Entry baseEntry = entryMap.get(baseDN);
    if (baseEntry == null && handlesEntry(baseDN))
    if (baseEntry == null)
    {
      DN matchedDN = serverContext.getBackendConfigManager().getParentDNInSuffix(baseDN);
      while (matchedDN != null)
@@ -515,10 +517,7 @@
              ResultCode.NO_SUCH_OBJECT, message, matchedDN, null);
    }
    if (baseEntry != null)
    {
      baseEntry = baseEntry.duplicate(true);
    }
    baseEntry = baseEntry.duplicate(true);
    // If it's a base-level search, then just get that entry and return it if it
    // matches the filter.
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/BackendImpl.java
@@ -13,7 +13,7 @@
 *
 * Copyright 2007-2010 Sun Microsystems, Inc.
 * Portions Copyright 2013-2016 ForgeRock AS.
 * Portions Copyright 2025 3A Systems, LLC
 * Portions Copyright 2025-2026 3A Systems, LLC
 */
package org.opends.server.backends.pluggable;
@@ -123,12 +123,28 @@
   */
  private EntryContainer accessBegin(Operation operation, DN entryDN) throws DirectoryException
  {
    return accessBegin(operation, entryDN, ResultCode.UNDEFINED);
  }
  /**
   * Begin a Backend API method that accesses the {@link EntryContainer} for <code>entryDN</code>
   * and returns it.
   * @param operation requesting the storage
   * @param entryDN the target DN for the operation
   * @param noEntryContainerResultCode the result code to report when this backend holds no
   *                                   entry container for <code>entryDN</code>
   * @return <code>EntryContainer</code> where <code>entryDN</code> resides
   */
  private EntryContainer accessBegin(Operation operation, DN entryDN, ResultCode noEntryContainerResultCode)
      throws DirectoryException
  {
    checkRootContainerInitialized();
    rootContainer.checkForEnoughResources(operation);
    EntryContainer ec = rootContainer.getEntryContainer(entryDN);
    if (ec == null)
    {
      throw new DirectoryException(ResultCode.UNDEFINED, ERR_BACKEND_ENTRY_DOESNT_EXIST.get(entryDN, getBackendID()));
      throw new DirectoryException(
          noEntryContainerResultCode, ERR_BACKEND_ENTRY_DOESNT_EXIST.get(entryDN, getBackendID()));
    }
    threadTotalCount.getAndIncrement();
    return ec;
@@ -557,7 +573,9 @@
  @Override
  public void search(SearchOperation searchOperation) throws DirectoryException, CanceledOperationException
  {
    EntryContainer ec = accessBegin(searchOperation, searchOperation.getBaseDN());
    // a base DN held by no entry container of this backend does not exist as far as a client
    // is concerned: report it as such instead of the UNDEFINED result code used internally.
    EntryContainer ec = accessBegin(searchOperation, searchOperation.getBaseDN(), ResultCode.NO_SUCH_OBJECT);
    ec.sharedLock.lock();
opendj-server-legacy/src/main/java/org/opends/server/core/SearchOperation.java
@@ -13,6 +13,7 @@
 *
 * Copyright 2006-2009 Sun Microsystems, Inc.
 * Portions Copyright 2011-2016 ForgeRock AS.
 * Portions Copyright 2026 3A Systems, LLC.
 */
package org.opends.server.core;
@@ -263,6 +264,14 @@
                                      boolean evaluateAci);
  /**
   * 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.
   */
  void endSearchPhase();
  /**
   * Used as a callback for backends to indicate that the provided search
   * reference was encountered during processing and that additional processing
   * should be performed to potentially send it back to the client.
opendj-server-legacy/src/main/java/org/opends/server/core/SearchOperationBasis.java
@@ -13,11 +13,12 @@
 *
 * Copyright 2006-2010 Sun Microsystems, Inc.
 * Portions Copyright 2011-2016 ForgeRock AS.
 * Portions Copyright 2024 3A Systems, LLC.
 * Portions Copyright 2024-2026 3A Systems, LLC.
 */
package org.opends.server.core;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import org.forgerock.i18n.LocalizedIllegalArgumentException;
@@ -449,12 +450,47 @@
  {
    return returnEntry(entry, controls, true);
  }
  Set<DN> dereferenced=new HashSet<>();
  /**
   * 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.
   */
  private final Set<DN> returnedDNs = ConcurrentHashMap.newKeySet();
  /** Whether the search phase is over and only persistent search notifications remain. */
  private volatile boolean searchPhaseOver;
  @Override
  public final void endSearchPhase()
  {
    searchPhaseOver = true;
    returnedDNs.clear();
  }
  @Override
  public final boolean returnEntry(Entry entry, List<Control> controls,
                                   boolean evaluateAci)
  {
    return returnEntry(entry, controls, evaluateAci, null);
  }
  /**
   * Returns the provided entry to the client, dereferencing it first if it is an alias and the
   * deref policy asks for it.
   *
   * @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  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 typesOnly = getTypesOnly();
    // See if the size limit has been exceeded.  If so, then don't send the
@@ -566,22 +602,12 @@
    //DereferenceAliasesPolicy
    if ( DereferenceAliasesPolicy.ALWAYS.equals(getDerefPolicy()) || DereferenceAliasesPolicy.IN_SEARCHING.equals(getDerefPolicy()) ) {
      if (entry.isAlias() && !baseDN.equals(entry.getName())) {
        try {
          final DN dn = entry.getAliasedDN();
          final Entry dereference = DirectoryServer.getEntry(dn);
          if (dereferenced.contains(dn)) {
            return true;
          }
          dereferenced.add(dn);
          return returnEntry(dereference, controls, true);
        } catch (DirectoryException e) {
          throw new RuntimeException(e);
        }
        return returnAliasedEntry(entry, controls, aliasChain);
      }
      if (dereferenced.contains(entry.getName())) {
      if (!searchPhaseOver && !returnedDNs.add(entry.getName())) {
        // This entry was already returned by the search, through an alias or on its own.
        return true;
      }
      dereferenced.add(entry.getName());
    }
    // Make a copy of the entry and pare it down to only include the set
@@ -709,6 +735,55 @@
    return pluginResult.continueProcessing();
  }
  /**
   * Returns the entry the provided alias points to, in place of the alias itself.
   *
   * @param  alias       The alias entry to dereference.
   * @param  controls    The controls to attach to the entry.
   * @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)
  {
    final DN aliasedDN;
    final Entry aliasedEntry;
    try
    {
      // getAliasedDN() parses the aliasedObjectName value, which throws the unchecked
      // LocalizedIllegalArgumentException when that value is not a valid DN.
      aliasedDN = alias.getAliasedDN();
      aliasedEntry = DirectoryServer.getEntry(aliasedDN);
    }
    catch (DirectoryException | LocalizedIllegalArgumentException e)
    {
      // A single alias which cannot be read must not fail the whole search.
      logger.traceException(e);
      return true;
    }
    if (aliasedEntry == null)
    {
      // The alias points to an entry which does not exist: there is nothing to return for it.
      return true;
    }
    if (!searchPhaseOver && returnedDNs.contains(aliasedDN))
    {
      // The aliased entry was already returned by the search.
      return true;
    }
    if (aliasChain == null)
    {
      aliasChain = new HashSet<>();
    }
    if (!aliasChain.add(aliasedDN))
    {
      // The aliases point at each other: stop before looping forever.
      return true;
    }
    return returnEntry(aliasedEntry, controls, true, aliasChain);
  }
  private AccessControlHandler<?> getACIHandler()
  {
    return AccessControlConfigManager.getInstance().getAccessControlHandler();
opendj-server-legacy/src/main/java/org/opends/server/core/SearchOperationWrapper.java
@@ -13,6 +13,7 @@
 *
 * Copyright 2008-2009 Sun Microsystems, Inc.
 * Portions Copyright 2011-2016 ForgeRock AS.
 * Portions Copyright 2026 3A Systems, LLC.
 */
package org.opends.server.core;
@@ -58,6 +59,12 @@
  }
  @Override
  public void endSearchPhase()
  {
    getOperation().endSearchPhase();
  }
  @Override
  public boolean returnReference(DN dn, SearchResultReference reference)
  {
    return getOperation().returnReference(dn, reference);
opendj-server-legacy/src/main/java/org/opends/server/replication/server/DataServerHandler.java
@@ -13,6 +13,7 @@
 *
 * Copyright 2006-2010 Sun Microsystems, Inc.
 * Portions Copyright 2011-2016 ForgeRock AS.
 * Portions Copyright 2026 3A Systems, LLC
 */
package org.opends.server.replication.server;
@@ -365,7 +366,6 @@
    {
      // initializations
      localGenerationId = -1;
      oldGenerationId = -100;
      // processes the ServerStart message received
      boolean sessionInitiatorSSLEncryption =
@@ -395,7 +395,6 @@
      lockDomainNoTimeout();
      localGenerationId = replicationServerDomain.getGenerationId();
      oldGenerationId = localGenerationId;
      if (replicationServerDomain.isAlreadyConnectedToDS(this))
      {
@@ -601,7 +600,7 @@
        // WARNING: Must be done before computing topo message to send
        // to peer server as topo message must embed valid generation id
        // for our server
        oldGenerationId = replicationServerDomain.changeGenerationId(generationId);
        setDomainGenerationIdOnStart(generationId);
      }
    }
    return startSessionMsg;
opendj-server-legacy/src/main/java/org/opends/server/replication/server/ReplicationServerDomain.java
@@ -1739,18 +1739,68 @@
        this.generationIdSavedStatus = false;
        // generationId gossip is purely event-driven: it only travels in the
        // topology messages sent on connect/disconnect/status events. Re-advertise
        // on every real transition so a peer that missed one converges on the next.
        if (generationId > 0)
        {
          sendTopoInfoToAll();
        }
        // topology messages sent on connect/disconnect/status events.
        // Re-advertise on every real transition â€” including a reset or
        // rollback to -1 â€” so peers that recorded the previous value converge
        // instead of diverging until the next unrelated topology event.
        sendTopoInfoToAll();
      }
      return oldGenerationId;
    }
  }
  /**
   * Sets the provided value as the new in memory generationId, but only if the
   * current generation id still equals {@code expectedGenerationId}: a
   * decision made on a stale snapshot must never overwrite a value another
   * thread legitimately set in the meantime.
   *
   * @param expectedGenerationId The generation id the caller based its
   *        decision on.
   * @param newGenerationId The new value of generationId.
   * @return whether the generation id was changed
   */
  public boolean changeGenerationIdIfUnchanged(long expectedGenerationId,
      long newGenerationId)
  {
    synchronized (generationIDLock)
    {
      if (this.generationId != expectedGenerationId)
      {
        return false;
      }
      changeGenerationId(newGenerationId);
      return true;
    }
  }
  /**
   * Rolls the generation id back to {@code oldGenerationId}, but only if it
   * still has the {@code expectedGenerationId} value the caller previously set.
   * An aborting handshake must undo its own change without overwriting a value
   * that another thread legitimately set in the meantime (e.g. adopted from a
   * peer topology message). The rollback is also skipped once the generation
   * id has been saved to the changelog or while data servers are connected â€”
   * the same invariant {@link #resetGenerationIdIfPossible()} enforces â€”
   * because rolling back would clear a changelog that now holds real changes.
   *
   * @param expectedGenerationId The generation id the caller set and expects
   *        to still be in place.
   * @param oldGenerationId The value to restore.
   */
  public void rollbackGenerationIdIfUnchanged(long expectedGenerationId,
      long oldGenerationId)
  {
    synchronized (generationIDLock)
    {
      if (!generationIdSavedStatus && connectedDSs.isEmpty())
      {
        changeGenerationIdIfUnchanged(expectedGenerationId, oldGenerationId);
      }
    }
  }
  /**
   * Resets the generationID.
   *
   * @param senderHandler The handler associated to the server
@@ -2123,9 +2173,12 @@
  private void setGenerationIdIfUnset(long generationId)
  {
    if (this.generationId < 0)
    synchronized (generationIDLock)
    {
      this.generationId = generationId;
      if (this.generationId < 0)
      {
        this.generationId = generationId;
      }
    }
  }
opendj-server-legacy/src/main/java/org/opends/server/replication/server/ReplicationServerHandler.java
@@ -13,6 +13,7 @@
 *
 * Copyright 2006-2010 Sun Microsystems, Inc.
 * Portions Copyright 2011-2016 ForgeRock AS.
 * Portions Copyright 2026 3A Systems, LLC
 */
package org.opends.server.replication.server;
@@ -87,8 +88,6 @@
        // Only V2 protocol has the group id in repl server start message
        this.groupId = inReplServerStartMsg.getGroupId();
      }
      oldGenerationId = -100;
    }
    catch(Exception e)
    {
@@ -150,13 +149,17 @@
    setBaseDNAndDomain(baseDN, false);
    localGenerationId = replicationServerDomain.getGenerationId();
    oldGenerationId = localGenerationId;
    try
    {
      lockDomainNoTimeout();
      // Read under the domain lock so the start message advertises any change
      // made by a previous handshake (handshakes serialize on this lock). The
      // field itself is only guarded by generationIDLock and lock-free
      // adopters can still move it â€” the arming CAS in
      // setDomainGenerationIdOnStart handles that residual race.
      localGenerationId = replicationServerDomain.getGenerationId();
      ReplServerStartMsg outReplServerStartMsg = sendStartToRemote();
      // Wait answer
@@ -196,8 +199,7 @@
      */
      if (localGenerationId < 0 && generationId > 0)
      {
        oldGenerationId =
            replicationServerDomain.changeGenerationId(generationId);
        setDomainGenerationIdOnStart(generationId);
      }
      logStartHandshakeSNDandRCV(outReplServerStartMsg,(ReplServerStartMsg)msg);
@@ -283,7 +285,6 @@
  public void startFromRemoteRS(ReplServerStartMsg inReplServerStartMsg)
  {
    localGenerationId = -1;
    oldGenerationId = -100;
    try
    {
      // The initiator decides if the session is encrypted
@@ -479,8 +480,7 @@
      // The local RS is not initialized - take the one received
      // WARNING: Must be done before computing topo message to send to peer
      // server as topo message must embed valid generation id for our server
      oldGenerationId =
          replicationServerDomain.changeGenerationId(generationId);
      setDomainGenerationIdOnStart(generationId);
      return;
    }
opendj-server-legacy/src/main/java/org/opends/server/replication/server/ServerHandler.java
@@ -148,8 +148,20 @@
  protected long generationId = -1;
  /** The generation id of the hosting RS. */
  protected long localGenerationId = -1;
  /** The generation id before processing a new start handshake. */
  protected long oldGenerationId = -1;
  /**
   * The domain generation id that this handler replaced during the start
   * handshake, or -100 when this handler has not changed the domain generation
   * id and there is nothing to roll back on {@link #abortStart(LocalizableMessage)}.
   */
  protected long oldGenerationId = -100;
  /**
   * The generation id this handler wrote into the domain during the start
   * handshake, or -100 when it wrote none. Used by
   * {@link #abortStart(LocalizableMessage)} to roll back only this handler's
   * own change: a value concurrently set by another thread must not be
   * overwritten with the stale {@link #oldGenerationId}.
   */
  private long generationIdSetOnStart = -100;
  /** Group id of this remote server. */
  protected byte groupId = -1;
  /** The SSL encryption after the negotiation with the peer. */
@@ -219,15 +231,54 @@
      localSession.close();
    }
    releaseDomainLock();
    // If generation id of domain was changed, set it back to old value
    // We may have changed it as it was -1 and we received a value >0 from peer
    // server and the last topo message sent may have failed being sent: in that
    // case retrieve old value of generation id for replication server domain
    // If this handler changed the domain generation id during the handshake,
    // set it back to the old value. Only undo our own change: another thread
    // may have legitimately changed the generation id in the meantime (e.g.
    // adopted it from a peer topology message) and that value must not be
    // overwritten with our stale snapshot. Do it BEFORE releasing the domain
    // lock, so a handshake queued on the lock cannot read, advertise or arm on
    // the doomed value in between.
    if (oldGenerationId != -100)
    {
      replicationServerDomain.changeGenerationId(oldGenerationId);
      replicationServerDomain.rollbackGenerationIdIfUnchanged(
          generationIdSetOnStart, oldGenerationId);
      oldGenerationId = -100;
      generationIdSetOnStart = -100;
    }
    releaseDomainLock();
  }
  /**
   * Changes the domain generation id during the start handshake, remembering
   * the replaced value so that {@link #abortStart(LocalizableMessage)} can
   * undo this handler's own change (and only it) if the handshake
   * subsequently fails.
   * <p>
   * The change is applied only if the domain generation id still equals
   * {@link #localGenerationId}, the value this handler based its decision on:
   * a generation id concurrently adopted by a lock-free path (an update
   * received from an already connected server) must not be stomped. This also
   * makes a repeated call within one handshake a no-op, preserving the first
   * arming's rollback point. Wire values below -1 are rejected â€” they can only
   * come from a malformed peer and would collide with the -100 sentinel.
   *
   * @param generationId the generation id to set on the domain
   */
  protected void setDomainGenerationIdOnStart(long generationId)
  {
    if (generationId < -1)
    {
      return;
    }
    if (replicationServerDomain.changeGenerationIdIfUnchanged(
        localGenerationId, generationId))
    {
      if (oldGenerationId == -100)
      {
        oldGenerationId = localGenerationId;
      }
      generationIdSetOnStart = generationId;
    }
  }
opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendSearchOperation.java
@@ -13,7 +13,7 @@
 *
 * Copyright 2008-2010 Sun Microsystems, Inc.
 * Portions Copyright 2011-2016 ForgeRock AS.
 * Portions Copyright 2024-2025 3A Systems, LLC.
 * Portions Copyright 2024-2026 3A Systems, LLC.
 */
package org.opends.server.workflowelement.localbackend;
@@ -216,6 +216,10 @@
          if(!dereferencingDNs.contains(aliasedDn)) { //detect recursive search
            dereferencingDNs.add(aliasedDn);
            setBaseDN(aliasedDn);
            // the alias may point into another backend: a null backend is reported
            // as NO_SUCH_OBJECT by the recursive call
            backend = DirectoryServer.getInstance().getServerContext()
                    .getBackendConfigManager().findLocalBackendForEntry(aliasedDn);
            try {
              processSearch(executePostOpPlugins);
            } catch (StackOverflowError error) {
@@ -254,6 +258,9 @@
        // Process the search in the backend and all its subordinates.
        backend.search(this);
      }
      // Any entry returned from now on comes from the persistent search, if there is one.
      endSearchPhase();
    }
    catch (DirectoryException de)
    {
opendj-server-legacy/src/test/java/org/opends/server/backends/ChangelogBackendTestCase.java
@@ -12,6 +12,7 @@
 * information: "Portions Copyright [year] [name of copyright owner]".
 *
 * Copyright 2014-2016 ForgeRock AS.
 * Portions Copyright 2026 3A Systems, LLC.
 */
package org.opends.server.backends;
@@ -47,6 +48,7 @@
import org.forgerock.i18n.slf4j.LocalizedLogger;
import org.forgerock.opendj.ldap.ByteString;
import org.forgerock.opendj.ldap.DN;
import org.forgerock.opendj.ldap.DereferenceAliasesPolicy;
import org.forgerock.opendj.ldap.RDN;
import org.forgerock.opendj.ldap.ResultCode;
import org.forgerock.opendj.ldap.SearchScope;
@@ -783,6 +785,84 @@
    debugInfo(testName, "Ending test successfully");
  }
  /**
   * The alias dereferencing policies which make the server look the search base entry up before
   * handing the search over to the backend.
   */
  @DataProvider
  Object[][] getBaseEntryLookupDerefPolicies()
  {
    return new Object[][] {
      { DereferenceAliasesPolicy.ALWAYS },
      { DereferenceAliasesPolicy.FINDING_BASE },
      // only looks the base entry up for subtree searches, which newSearchRequest() uses
      { DereferenceAliasesPolicy.IN_SEARCHING },
    };
  }
  /**
   * Dereferencing aliases makes the server read the base entry before searching, so the changelog
   * backend must be able to return it rather than failing the whole search.
   *
   * @see <a href="https://github.com/OpenIdentityPlatform/OpenDJ/issues/737">issue #737</a>
   */
  @Test(dataProvider = "getBaseEntryLookupDerefPolicies")
  public void searchWhenNoChangesShouldReturnRootEntryOnlyWhenDereferencingAliases(
      DereferenceAliasesPolicy derefPolicy) throws Exception
  {
    String testName = "EmptyRSDeref/" + derefPolicy;
    debugInfo(testName, "Starting test\n\n");
    SearchRequest request = newSearchRequest("(objectclass=*)").setDereferenceAliasesPolicy(derefPolicy);
    searchChangelog(request, 1, SUCCESS, testName);
    debugInfo(testName, "Ending test successfully");
  }
  /**
   * Dereferencing aliases used to fail change number searches with an unexpected error, which broke
   * every client defaulting to it, JNDI-based ones in particular.
   *
   * @see <a href="https://github.com/OpenIdentityPlatform/OpenDJ/issues/737">issue #737</a>
   */
  @Test(dataProvider = "getBaseEntryLookupDerefPolicies")
  public void searchInChangeNumberModeWhenDereferencingAliases(DereferenceAliasesPolicy derefPolicy) throws Exception
  {
    String testName = "ChangeNumberDeref/" + derefPolicy;
    debugInfo(testName, "Starting test\n\n");
    CSN[] csns = generateAndPublishUpdateMsgForEachOperationType(testName, false);
    SearchRequest request = newSearchRequest("(changenumber>=1)").setDereferenceAliasesPolicy(derefPolicy);
    InternalSearchOperation searchOp = searchChangelog(request, csns.length, SUCCESS, testName);
    assertEntriesForEachOperationType(searchOp.getSearchEntries(), 1, testName, USER1_ENTRY_UUID, csns);
    debugInfo(testName, "Ending test successfully");
  }
  /**
   * The base changelog entry is the only one the backend can return by DN: change records need the
   * search parameters to be located, so they are only reachable through a search.
   */
  @Test
  public void getEntryShouldReturnBaseEntryOnly() throws Exception
  {
    String testName = "getEntry";
    debugInfo(testName, "Starting test\n\n");
    generateAndPublishUpdateMsgForEachOperationType(testName, false);
    final LocalBackend<?> backend = TestCaseUtils.getServerContext().getBackendConfigManager()
        .getLocalBackendById(ChangelogBackend.BACKEND_ID);
    assertEquals(backend.getEntry(ChangelogBackend.CHANGELOG_BASE_DN).getName(),
        ChangelogBackend.CHANGELOG_BASE_DN);
    assertNull(backend.getEntry(DN.valueOf("changeNumber=1,cn=changelog")));
    assertNull(backend.getEntry(DN.valueOf("changeNumber=1000,cn=changelog")));
    debugInfo(testName, "Ending test successfully");
  }
  @Test
  public void operationalAndVirtualAttributesShouldNotBeVisibleOutsideRootDSE() throws Exception
  {
opendj-server-legacy/src/test/java/org/opends/server/backends/MemoryBackendTestCase.java
New file
@@ -0,0 +1,77 @@
/*
 * 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.backends;
import static org.opends.server.protocols.internal.InternalClientConnection.getRootConnection;
import static org.opends.server.protocols.internal.Requests.newSearchRequest;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.fail;
import org.forgerock.opendj.ldap.DN;
import org.forgerock.opendj.ldap.ResultCode;
import org.forgerock.opendj.ldap.SearchScope;
import org.opends.server.TestCaseUtils;
import org.opends.server.protocols.internal.InternalSearchOperation;
import org.opends.server.protocols.internal.SearchRequest;
import org.opends.server.types.DirectoryException;
import org.opends.server.workflowelement.localbackend.LocalBackendSearchOperation;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
/** Test cases for the memory backend. */
@SuppressWarnings("javadoc")
public class MemoryBackendTestCase extends BackendTestCase
{
  private MemoryBackend backend;
  @BeforeClass
  public void startServer() throws Exception
  {
    TestCaseUtils.startServer();
    TestCaseUtils.initializeTestBackend(true);
    backend = (MemoryBackend) TestCaseUtils.getServerContext().getBackendConfigManager()
        .getLocalBackendById(TestCaseUtils.TEST_BACKEND_ID);
  }
  /** A base DN this backend does not hold has no entry either. */
  @Test
  public void testSearchBaseDNNotHeldByBackend() throws Exception
  {
    assertSearchFailsWithNoSuchObject(DN.valueOf("o=not-held-by-this-backend"));
  }
  /** A base DN this backend holds, but which has never been added. */
  @Test
  public void testSearchBaseDNWithoutEntry() throws Exception
  {
    assertSearchFailsWithNoSuchObject(DN.valueOf("ou=missing,o=test"));
  }
  private void assertSearchFailsWithNoSuchObject(DN baseDN) throws Exception
  {
    final SearchRequest request = newSearchRequest(baseDN, SearchScope.BASE_OBJECT);
    final InternalSearchOperation search = new InternalSearchOperation(getRootConnection(), -1, -1, request);
    try
    {
      backend.search(new LocalBackendSearchOperation(search));
      fail("Searching base DN " + baseDN + " should have failed.");
    }
    catch (DirectoryException e)
    {
      assertEquals(e.getResultCode(), ResultCode.NO_SUCH_OBJECT);
    }
  }
}
opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/PluggableBackendImplTestCase.java
@@ -12,7 +12,7 @@
 * information: "Portions Copyright [year] [name of copyright owner]".
 *
 * Copyright 2015-2016 ForgeRock AS.
 * Portions Copyright 2023-2025 3A Systems, LLC.
 * Portions Copyright 2023-2026 3A Systems, LLC.
 */
package org.opends.server.backends.pluggable;
@@ -645,6 +645,22 @@
        "Leaf entry should not have any subordinates.");
  }
  /** A base DN held by no entry container of this backend has no entry either. */
  @Test
  public void testSearchBaseDNNotHeldByBackend() throws Exception
  {
    try
    {
      backend.search(createSearchOperation(
          DN.valueOf("dc=a"), SearchScope.BASE_OBJECT, "(objectClass=*)", new ArrayList<Entry>()));
      fail("Searching a base DN not held by this backend should have failed.");
    }
    catch (DirectoryException e)
    {
      assertEquals(e.getResultCode(), ResultCode.NO_SUCH_OBJECT);
    }
  }
  private List<SearchResultEntry> runSearch(SearchRequest request, boolean useInternalConnection) throws Exception
  {
    InternalClientConnection conn = getRootConnection();
opendj-server-legacy/src/test/java/org/opends/server/replication/InitOnLineTest.java
@@ -555,7 +555,7 @@
      if (server2 == null)
      {
        server2 = openReplicationSession(baseDN,
          server2ID, 100, getReplServerPort(replServer1ID), 1000);
          server2ID, 100, getReplServerPort(replServer1ID), 10000);
      }
      // In S1 launch the total update
@@ -608,7 +608,7 @@
      if (server2 == null)
      {
        server2 = openReplicationSession(baseDN,
          server2ID, 100, getReplServerPort(replServer1ID), 1000);
          server2ID, 100, getReplServerPort(replServer1ID), 10000);
      }
      InitializeRequestMsg initMsg = new InitializeRequestMsg(baseDN, server2ID, server1ID, 100);
@@ -652,7 +652,7 @@
      if (server2 == null)
      {
        server2 = openReplicationSession(baseDN,
          server2ID, 100, getReplServerPort(replServer1ID), 1000);
          server2ID, 100, getReplServerPort(replServer1ID), 10000);
      }
      // Launch in S1 the task that will initialize S2
@@ -705,13 +705,13 @@
      if (server2 == null)
      {
        server2 = openReplicationSession(baseDN,
          server2ID, 100, getReplServerPort(replServer1ID), 1000);
          server2ID, 100, getReplServerPort(replServer1ID), 10000);
      }
      if (server3 == null)
      {
        server3 = openReplicationSession(baseDN,
          server3ID, 100, getReplServerPort(replServer1ID), 1000);
          server3ID, 100, getReplServerPort(replServer1ID), 10000);
      }
      // Launch in S1 the task that will initialize S2
@@ -752,7 +752,7 @@
      if (server2==null)
      {
        server2 = openReplicationSession(baseDN,
          server2ID, 100, getReplServerPort(replServer1ID), 1000);
          server2ID, 100, getReplServerPort(replServer1ID), 10000);
      }
      // Creates config to synchronize suffix
@@ -941,11 +941,11 @@
      // Connects lDAP2 to replServer2
      broker2 = openReplicationSession(baseDN,
        server2ID, 100, getReplServerPort(replServer2ID), 1000);
        server2ID, 100, getReplServerPort(replServer2ID), 10000);
      // Connects lDAP3 to replServer2
      broker3 = openReplicationSession(baseDN,
        server3ID, 100, getReplServerPort(replServer2ID), 1000);
        server3ID, 100, getReplServerPort(replServer2ID), 10000);
      // Check that the list of connected LDAP servers is correct in each replication servers
      Assertions.assertThat(getConnectedDSServerIds(replServer1)).containsExactly(server1ID);
@@ -958,7 +958,7 @@
      Assertions.assertThat(getConnectedDSServerIds(replServer2)).containsExactly(server2ID);
      broker3 = openReplicationSession(baseDN,
        server3ID, 100, getReplServerPort(replServer2ID), 1000);
        server3ID, 100, getReplServerPort(replServer2ID), 10000);
      broker2.stop();
      Thread.sleep(1000);
      Assertions.assertThat(getConnectedDSServerIds(replServer2)).containsExactly(server3ID);
@@ -1001,7 +1001,7 @@
      {
        log(testCase + " Will connect server 2 to " + replServer2ID);
        server2 = openReplicationSession(baseDN,
            server2ID, 100, getReplServerPort(replServer2ID), 1000);
            server2ID, 100, getReplServerPort(replServer2ID), 10000);
      }
      // Launch in S1 the task that will initialize S2
@@ -1108,7 +1108,7 @@
        log(testCase + " Will connect server 2 to " + replServer2ID);
        server2 = openReplicationSession(baseDN,
          server2ID, 100, getReplServerPort(replServer2ID),
          1000, replServer1.getGenerationId(baseDN));
          10000, replServer1.getGenerationId(baseDN));
      }
      // Connect a broker acting as server 3 to Repl Server 3
@@ -1120,7 +1120,7 @@
        log(testCase + " Will connect server 3 to " + replServer3ID);
        server3 = openReplicationSession(baseDN,
          server3ID, 100, getReplServerPort(replServer3ID),
          1000, replServer1.getGenerationId(baseDN));
          10000, replServer1.getGenerationId(baseDN));
      }
      // S3 sends init request
@@ -1279,7 +1279,7 @@
      if (server2 == null)
      {
        server2 = openReplicationSession(baseDN,
          server2ID, 100, getReplServerPort(replServer1ID), 1000);
          server2ID, 100, getReplServerPort(replServer1ID), 10000);
      }
      // Creates config to synchronize suffix
opendj-server-legacy/src/test/java/org/opends/server/replication/server/HandshakeAbortGenerationIdTest.java
New file
@@ -0,0 +1,175 @@
/*
 * 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.replication.server;
import static org.opends.server.TestCaseUtils.*;
import static org.opends.server.util.CollectionUtils.*;
import static org.testng.Assert.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.TreeSet;
import org.forgerock.opendj.ldap.DN;
import org.opends.server.TestCaseUtils;
import org.opends.server.replication.ReplicationTestCase;
import org.opends.server.replication.protocol.ReplServerStartMsg;
import org.opends.server.replication.protocol.ReplSessionSecurity;
import org.opends.server.replication.protocol.Session;
import org.opends.server.replication.protocol.StopMsg;
import org.testng.annotations.Test;
/**
 * Deterministic reproducer for the generation id rollback race fixed in the
 * same change (issue #735): an outgoing RS to RS handshake that is aborted
 * (e.g. rejected by the peer on a simultaneous cross-connect) must not roll
 * the domain generation id back to the value it had when the handshake
 * started, wiping a value that was legitimately adopted in the meantime.
 * <p>
 * The test plays the remote peer itself, so it fully controls the interleaving:
 * the connecting RS snapshots the domain generation id before sending its start
 * message, the test then changes the generation id while the RS is blocked
 * waiting for the answer, and only then rejects the handshake with a StopMsg.
 * The generation id advertised on the next connection attempt is sent strictly
 * after the abort completed on the same connector thread, so it exposes a
 * rollback deterministically, without any timing assumptions.
 */
@SuppressWarnings("javadoc")
public class HandshakeAbortGenerationIdTest extends ReplicationTestCase
{
  private static final long ADOPTED_GEN_ID = 4801;
  private static final int SOCKET_TIMEOUT_MS = 30000;
  @Test
  public void abortedConnectMustNotRollBackConcurrentlyAdoptedGenId() throws Exception
  {
    final DN baseDN = DN.valueOf(TEST_ROOT_DN_STRING);
    ReplicationServer replicationServer = null;
    try (ServerSocket fakePeer = TestCaseUtils.bindFreePort())
    {
      fakePeer.setSoTimeout(SOCKET_TIMEOUT_MS);
      final int rsPort = TestCaseUtils.findFreePort();
      replicationServer = new ReplicationServer(new ReplServerFakeConfiguration(
          rsPort, "handshakeAbortGenIdTestDb", 0, 811, 0, 100,
          newTreeSet("127.0.0.1:" + fakePeer.getLocalPort())));
      // Creating the domain makes the RS connect thread dial the fake peer.
      final ReplicationServerDomain domain =
          replicationServer.getReplicationServerDomain(baseDN, true);
      final ReplSessionSecurity security = getReplSessionSecurity();
      try (Session session = accept(fakePeer, security))
      {
        // The RS is now inside connect(): it snapshotted the domain generation
        // id (-1) before sending its start message.
        final ReplServerStartMsg startMsg =
            waitForSpecificMsg(session, ReplServerStartMsg.class);
        assertEquals(startMsg.getGenerationId(), -1);
        // While connect() is blocked waiting for our answer, the domain adopts
        // a generation id, as happens when gossip arrives from another peer RS.
        domain.changeGenerationId(ADOPTED_GEN_ID);
        // Reject the handshake as a peer does on a simultaneous cross-connect.
        session.publish(new StopMsg());
      }
      // The second connection attempt is made by the same connector thread
      // strictly after abortStart() returned, so the generation id it
      // advertises deterministically shows whether the abort rolled back the
      // concurrently adopted value.
      try (Session session = accept(fakePeer, security))
      {
        final ReplServerStartMsg startMsg =
            waitForSpecificMsg(session, ReplServerStartMsg.class);
        assertEquals(startMsg.getGenerationId(), ADOPTED_GEN_ID,
            "the aborted handshake rolled back a generation id it did not set");
        session.publish(new StopMsg());
      }
    }
    finally
    {
      removeQuietly(replicationServer);
    }
  }
  /**
   * The guards on the generation id primitives introduced for the abort
   * rollback: a compare-and-set never overwrites a value it did not expect,
   * and a rollback never clears a generation id that has been saved to the
   * changelog.
   */
  @Test
  public void generationIdPrimitivesHonorGuards() throws Exception
  {
    final DN baseDN = DN.valueOf(TEST_ROOT_DN_STRING);
    ReplicationServer replicationServer = null;
    try
    {
      replicationServer = new ReplicationServer(new ReplServerFakeConfiguration(
          TestCaseUtils.findFreePort(), "handshakeAbortGenIdPrimitivesDb", 0,
          812, 0, 100, new TreeSet<String>()));
      final ReplicationServerDomain domain =
          replicationServer.getReplicationServerDomain(baseDN, true);
      assertFalse(domain.changeGenerationIdIfUnchanged(123, 456),
          "CAS with a stale expected value must not fire");
      assertEquals(domain.getGenerationId(), -1);
      assertTrue(domain.changeGenerationIdIfUnchanged(-1, ADOPTED_GEN_ID));
      assertEquals(domain.getGenerationId(), ADOPTED_GEN_ID);
      domain.rollbackGenerationIdIfUnchanged(999, -1);
      assertEquals(domain.getGenerationId(), ADOPTED_GEN_ID,
          "rollback with a stale expected value must not fire");
      domain.rollbackGenerationIdIfUnchanged(ADOPTED_GEN_ID, -1);
      assertEquals(domain.getGenerationId(), -1,
          "rollback must fire when the value is unchanged and unsaved");
      domain.initGenerationID(ADOPTED_GEN_ID);
      domain.rollbackGenerationIdIfUnchanged(ADOPTED_GEN_ID, -1);
      assertEquals(domain.getGenerationId(), ADOPTED_GEN_ID,
          "rollback must never clear a saved generation id");
    }
    finally
    {
      removeQuietly(replicationServer);
    }
  }
  /** Teardown must never mask the primary assertion failure. */
  private void removeQuietly(ReplicationServer replicationServer)
  {
    try
    {
      remove(replicationServer);
    }
    catch (Exception ignored)
    {
    }
  }
  private Session accept(ServerSocket listenSocket, ReplSessionSecurity security)
      throws Exception
  {
    final Socket socket = listenSocket.accept();
    socket.setTcpNoDelay(true);
    final Session session = security.createServerSession(socket, SOCKET_TIMEOUT_MS);
    assertNotNull(session, "could not create a session with the connecting RS");
    return session;
  }
}
opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/AliasTestCase.java
@@ -11,34 +11,53 @@
 * Header, with the fields enclosed by brackets [] replaced by your own identifying
 * information: "Portions Copyright [year] [name of copyright owner]".
 *
 * Copyright 2024-2025 3A Systems, LLC.
 * Copyright 2024-2026 3A Systems, LLC.
 */
package org.openidentityplatform.opendj;
import org.forgerock.opendj.config.client.ManagementContext;
import org.forgerock.opendj.ldap.*;
import org.forgerock.opendj.ldap.controls.PersistentSearchChangeType;
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.SearchResultEntry;
import org.forgerock.opendj.ldap.responses.SearchResultReference;
import org.forgerock.opendj.ldif.ConnectionEntryReader;
import org.forgerock.opendj.server.config.client.GlobalCfgClient;
import org.forgerock.opendj.server.config.meta.GlobalCfgDefn;
import org.opends.server.DirectoryServerTestCase;
import org.opends.server.TestCaseUtils;
import org.opends.server.api.LocalBackend;
import org.opends.server.backends.MemoryBackend;
import org.opends.server.core.DirectoryServer;
import org.opends.server.types.AcceptRejectWarn;
import org.opends.server.types.Entry;
import org.testng.annotations.AfterClass;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
import java.util.HashMap;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.fail;
@Test(sequential = true)
public class AliasTestCase extends DirectoryServerTestCase {
    /** Backend holding a naming context other than o=test, to check aliases crossing a backend boundary. */
    static final String OTHER_BACKEND_ID = "test2";
    Connection connection;
    @BeforeClass
    public void startServer() throws Exception {
        TestCaseUtils.startServer();
        TestCaseUtils.initializeTestBackend(true);
        TestCaseUtils.initializeMemoryBackend(OTHER_BACKEND_ID, "o=test2", true);
        TestCaseUtils.addEntries(
                 "dn: o=MyCompany, o=test",
@@ -116,6 +135,32 @@
                        "objectClass: extensibleObject",
                        "aliasedObjectName: uid=janedoe,ou=students,o=test",
                        "uid: janedoe",
                        "",
                        //an alias pointing into the o=test2 backend, and one pointing nowhere at all
                        "dn: ou=people,o=test2",
                        "objectClass: top",
                        "objectClass: organizationalUnit",
                        "ou: people",
                        "",
                        "dn: cn=Jane Roe,ou=people,o=test2",
                        "cn: Jane Roe",
                        "sn: Roe",
                        "objectclass: person",
                        "",
                        "dn: ou=external,o=test",
                        "objectClass: alias",
                        "objectClass: top",
                        "objectClass: extensibleObject",
                        "ou: external",
                        "aliasedObjectName: ou=people,o=test2",
                        "",
                        "dn: ou=nowhere,o=test",
                        "objectClass: alias",
                        "objectClass: top",
                        "objectClass: extensibleObject",
                        "ou: nowhere",
                        "aliasedObjectName: ou=people,o=missing",
                        ""
        );
@@ -125,6 +170,20 @@
        assertThat(connection.isValid()).isTrue();
    }
    @AfterClass
    public void stopServer() throws Exception {
        if (connection != null) {
            connection.close();
        }
        final MemoryBackend otherBackend = (MemoryBackend) TestCaseUtils.getServerContext()
                .getBackendConfigManager().getLocalBackendById(OTHER_BACKEND_ID);
        if (otherBackend != null) {
            otherBackend.clearMemoryBackend();
            otherBackend.finalizeBackend();
            TestCaseUtils.getServerContext().getBackendConfigManager().deregisterLocalBackend(otherBackend);
        }
    }
    public HashMap<String,SearchResultEntry> search(SearchScope scope,DereferenceAliasesPolicy policy) throws SearchResultReferenceIOException, LdapException {
        return search("ou=Area1,o=test", scope, policy);
    }
@@ -386,6 +445,251 @@
        assertThat(res.containsKey("uid=janedoe,ou=employees,o=test")).isFalse();
    }
    //    The alias target does not have to live in the backend holding the alias:
    //    the search must be handed over to the backend holding the target.
    @Test
    public void test_cross_backend_alias_base_always() throws LdapException, SearchResultReferenceIOException {
        HashMap<String, SearchResultEntry> res = search("ou=external,o=test", SearchScope.BASE_OBJECT, DereferenceAliasesPolicy.ALWAYS);
        assertThat(res.containsKey("ou=external,o=test")).isFalse();
        assertThat(res.containsKey("ou=people,o=test2")).isTrue();
        assertThat(res.containsKey("cn=Jane Roe,ou=people,o=test2")).isFalse();
    }
    @Test
    public void test_cross_backend_alias_sub_always() throws LdapException, SearchResultReferenceIOException {
        HashMap<String, SearchResultEntry> res = search("ou=external,o=test", SearchScope.WHOLE_SUBTREE, DereferenceAliasesPolicy.ALWAYS);
        assertThat(res.containsKey("ou=external,o=test")).isFalse();
        assertThat(res.containsKey("ou=people,o=test2")).isTrue();
        assertThat(res.containsKey("cn=Jane Roe,ou=people,o=test2")).isTrue();
    }
    //    No backend at all holds the alias target: this is a base DN that does not exist.
    @Test
    public void test_alias_target_without_backend() throws SearchResultReferenceIOException {
        try {
            search("ou=nowhere,o=test", SearchScope.BASE_OBJECT, DereferenceAliasesPolicy.ALWAYS);
            fail("dereferencing an alias whose target has no backend must fail");
        } catch (LdapException e) {
            assertThat(e.getResult().getResultCode()).isEqualTo(ResultCode.NO_SUCH_OBJECT);
        }
    }
    // An alias whose target lies outside the search scope: the target is only
    // reachable by dereferencing, so it must be returned by the search.
    @Test
    public void test_deref_target_outside_scope() throws Exception {
        TestCaseUtils.addEntries(
                "dn: ou=pointers,o=test",
                "objectClass: top",
                "objectClass: organizationalUnit",
                "ou: pointers",
                "",
                "dn: cn=ptr,ou=pointers,o=test",
                "objectClass: alias",
                "objectClass: top",
                "objectClass: extensibleObject",
                "cn: ptr",
                "aliasedObjectName: cn=John Doe,o=MyCompany,o=test",
                ""
        );
        HashMap<String, SearchResultEntry> res =
                search("ou=pointers,o=test", SearchScope.WHOLE_SUBTREE, DereferenceAliasesPolicy.ALWAYS);
        assertThat(res.containsKey("cn=John Doe,o=MyCompany,o=test")).isTrue();
    }
    // An alias pointing at an entry which does not exist is skipped, and the entries next to it are
    // still returned: aliases are not checked when they are written, so a dangling one is expected.
    @Test
    public void test_dangling_alias() throws Exception {
        TestCaseUtils.addEntries(
                "dn: ou=dangling,o=test",
                "objectClass: top",
                "objectClass: organizationalUnit",
                "ou: dangling",
                "",
                "dn: cn=broken,ou=dangling,o=test",
                "objectClass: alias",
                "objectClass: top",
                "objectClass: extensibleObject",
                "cn: broken",
                "aliasedObjectName: cn=does-not-exist,ou=dangling,o=test",
                "",
                "dn: cn=intact,ou=dangling,o=test",
                "cn: intact",
                "sn: intact",
                "objectClass: person",
                ""
        );
        HashMap<String, SearchResultEntry> res =
                search("ou=dangling,o=test", SearchScope.WHOLE_SUBTREE, DereferenceAliasesPolicy.ALWAYS);
        assertThat(res.containsKey("cn=does-not-exist,ou=dangling,o=test")).isFalse();
        assertThat(res.containsKey("ou=dangling,o=test")).isTrue();
        assertThat(res.containsKey("cn=intact,ou=dangling,o=test")).isTrue();
    }
    // A persistent search reports every change it is notified of. Dereferencing aliases must not
    // make it drop a change because the entry was notified before.
    @Test
    public void test_persistent_search_reports_repeated_changes() throws Exception {
        TestCaseUtils.addEntries(
                "dn: ou=psearch,o=test",
                "objectClass: top",
                "objectClass: organizationalUnit",
                "ou: psearch",
                "",
                "dn: cn=changing,ou=psearch,o=test",
                "cn: changing",
                "sn: changing",
                "objectClass: person",
                ""
        );
        final BlockingQueue<String> notified = new LinkedBlockingQueue<>();
        final SearchRequest request =
                Requests.newSearchRequest("ou=psearch,o=test", SearchScope.WHOLE_SUBTREE, "(objectclass=*)")
                        .setDereferenceAliasesPolicy(DereferenceAliasesPolicy.ALWAYS)
                        .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());
            psearch.searchAsync(request, new SearchResultHandler() {
                @Override
                public boolean handleEntry(SearchResultEntry entry) {
                    notified.add(entry.getName().toString());
                    return true;
                }
                @Override
                public boolean handleReference(SearchResultReference reference) {
                    return true;
                }
            });
            // 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.
            final LocalBackend<?> backend = TestCaseUtils.getServerContext()
                    .getBackendConfigManager().getLocalBackendById(TestCaseUtils.TEST_BACKEND_ID);
            for (int i = 0; backend.getPersistentSearches().isEmpty() && i < 500; i++) {
                Thread.sleep(10);
            }
            assertThat(backend.getPersistentSearches()).isNotEmpty();
            // 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");
            }
        }
    }
    // 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
    // MemoryBackend serving o=test walks entries in insertion order, so listing the alias before the
    // target below guarantees the alias is visited first.
    @Test
    public void test_deref_alias_before_target_returns_target_once() throws Exception {
        TestCaseUtils.addEntries(
                "dn: ou=order,o=test",
                "objectClass: top",
                "objectClass: organizationalUnit",
                "ou: order",
                "",
                "dn: cn=aaa-alias,ou=order,o=test",
                "objectClass: alias",
                "objectClass: top",
                "objectClass: extensibleObject",
                "cn: aaa-alias",
                "aliasedObjectName: cn=zzz-target,ou=order,o=test",
                "",
                "dn: cn=zzz-target,ou=order,o=test",
                "cn: zzz-target",
                "sn: target",
                "objectClass: person",
                ""
        );
        // search() asserts every returned DN is unique, so a duplicated target would fail this call.
        HashMap<String, SearchResultEntry> res =
                search("ou=order,o=test", SearchScope.WHOLE_SUBTREE, DereferenceAliasesPolicy.ALWAYS);
        // The target is returned, reached through the alias visited before it ...
        assertThat(res.containsKey("cn=zzz-target,ou=order,o=test")).isTrue();
        // ... and the alias entry itself is replaced by its target, never returned as-is.
        assertThat(res.containsKey("cn=aaa-alias,ou=order,o=test")).isFalse();
    }
    // An alias whose aliasedObjectName is not a valid DN cannot be dereferenced. Reading it throws a
    // DirectoryException, which must be logged and the alias skipped, not turned into a failed search.
    @Test
    public void test_alias_with_malformed_target_is_skipped() throws Exception {
        // A malformed DN can only be stored while syntax enforcement is relaxed; the default REJECT
        // policy refuses the add outright.
        final AcceptRejectWarn previous = DirectoryServer.getCoreConfigManager().getSyntaxEnforcementPolicy();
        setSyntaxEnforcementPolicy(GlobalCfgDefn.InvalidAttributeSyntaxBehavior.WARN);
        try {
            TestCaseUtils.addEntries(
                    "dn: ou=malformed,o=test",
                    "objectClass: top",
                    "objectClass: organizationalUnit",
                    "ou: malformed",
                    "",
                    "dn: cn=badptr,ou=malformed,o=test",
                    "objectClass: alias",
                    "objectClass: top",
                    "objectClass: extensibleObject",
                    "cn: badptr",
                    "aliasedObjectName: not a valid dn",
                    "",
                    "dn: cn=neighbour,ou=malformed,o=test",
                    "cn: neighbour",
                    "sn: neighbour",
                    "objectClass: person",
                    ""
            );
        } finally {
            setSyntaxEnforcementPolicy(asSyntaxBehavior(previous));
        }
        // The whole search still succeeds: the broken alias is skipped, its neighbours returned.
        HashMap<String, SearchResultEntry> res =
                search("ou=malformed,o=test", SearchScope.WHOLE_SUBTREE, DereferenceAliasesPolicy.ALWAYS);
        assertThat(res.containsKey("cn=badptr,ou=malformed,o=test")).isFalse();
        assertThat(res.containsKey("ou=malformed,o=test")).isTrue();
        assertThat(res.containsKey("cn=neighbour,ou=malformed,o=test")).isTrue();
    }
    private static void setSyntaxEnforcementPolicy(GlobalCfgDefn.InvalidAttributeSyntaxBehavior value) throws Exception {
        try (ManagementContext conf = TestCaseUtils.getServer().getConfiguration()) {
            GlobalCfgClient globalCfg = conf.getRootConfiguration().getGlobalConfiguration();
            globalCfg.setInvalidAttributeSyntaxBehavior(value);
            globalCfg.commit();
        }
    }
    private static GlobalCfgDefn.InvalidAttributeSyntaxBehavior asSyntaxBehavior(AcceptRejectWarn policy) {
        switch (policy) {
        case ACCEPT:
            return GlobalCfgDefn.InvalidAttributeSyntaxBehavior.ACCEPT;
        case WARN:
            return GlobalCfgDefn.InvalidAttributeSyntaxBehavior.WARN;
        case REJECT:
        default:
            return GlobalCfgDefn.InvalidAttributeSyntaxBehavior.REJECT;
        }
    }
    @Test(expectedExceptions = LdapException.class)
    public void test_stackoverflow() throws Exception {