From 35a4e8a46adf60988cc29fd82c0115126c1660a3 Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Tue, 22 Sep 2026 09:07:01 +0000
Subject: [PATCH] [#992] Apply the confidentiality of a backend index to the running backend (#1000)

---
 opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/ReplayedConfigChangeTest.java           |  268 ++++++++++++++
 opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/DefaultIndex.java                       |   47 ++
 opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/EntryContainer.java                     |   14 
 opendj-server-legacy/src/test/java/org/opends/server/backends/pdb/PDBIndexConfidentialityChangeTest.java        |   49 ++
 opendj-server-legacy/src/test/java/org/opends/server/backends/jeb/JEIndexConfidentialityChangeTest.java         |   54 +++
 opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/IndexConfidentialityChangeTestCase.java |  449 ++++++++++++++++++++++++
 opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/Index.java                              |    2 
 opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/AttributeIndex.java                     |  178 ++++++++-
 8 files changed, 1,026 insertions(+), 35 deletions(-)

diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/AttributeIndex.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/AttributeIndex.java
index fa7e27e..0dd3608 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/AttributeIndex.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/AttributeIndex.java
@@ -1039,7 +1039,7 @@
       newIndexIdToIndexes.putAll(updatedIndexes);
 
       // What the new configuration asks of the indexes which stay is decided here, before any of
-      // the three writes below, and reported here as well. Decided before the write which applies
+      // the writes below, and reported here as well. Decided before the write which applies
       // it: neither the entry limit an index holds nor its in-memory trusted flag is rolled back
       // with the transaction, while the removal of the persisted TRUSTED flag is, so an attempt
       // which rolls back would leave the raised limit in place, and a replay of it would compare
@@ -1048,10 +1048,18 @@
       // rather than once they have committed, because the instruction holds whichever way they go:
       // the configuration entry already holds the raised limit when this listener runs, and the
       // next open of the index applies it to a tree whose keys were given up under the lower one.
-      // Only the limit itself waits for the write which untrusts the index to commit.
+      // Only the limit itself waits for the write which untrusts the index to commit. The
+      // confidentiality is asked of the indexes rather than of the configuration this attribute
+      // index holds, or of the suite they share: the suite is switched outside the writes below and
+      // is not rolled back with them, so from the moment the change is asked for it reads as
+      // applied, while a tree stays in the encoding it was opened under until those writes have
+      // given it up and opened it again. What an index was opened under is what the index alone
+      // carries - and what a give-up of the reopen puts back - so it is the index which answers
+      // whether the change asked for is still to be made.
       final List<Index> indexesToUntrust = new ArrayList<>();
+      final List<MatchingRuleIndex> treesToGiveUp = new ArrayList<>();
       final List<LocalizableMessage> rebuildMessages = new ArrayList<>();
-      planIndexUpdates(updatedIndexes.values(), newConfiguration, indexesToUntrust, rebuildMessages);
+      planIndexUpdates(updatedIndexes.values(), newConfiguration, indexesToUntrust, treesToGiveUp, rebuildMessages);
       for (LocalizableMessage rebuildMessage : rebuildMessages)
       {
         ccr.setAdminActionRequired(true);
@@ -1079,6 +1087,16 @@
         });
       }
 
+      // The confidentiality of an index is carried by the CryptoSuite the indexes of its attribute
+      // share, and read from that suite by every index which opens a tree, so the new setting has to
+      // be in force before the indexes added below bind their codecs to it. Applied outside the
+      // writes, which the storage may replay: it changes a live object rather than the storage, and
+      // is idempotent - so it is compared with what the suite carries, not with the configuration.
+      if (cryptoSuite.isEncrypted() != newConfiguration.isConfidentialityEnabled())
+      {
+        entryContainer.setIndexConfidentiality(cryptoSuite, newConfiguration.isConfidentialityEnabled());
+      }
+
       // Open added indexes *before* adding them to indexIdToIndexes
       final List<TreeName> addedIndexesToRebuild = new ArrayList<>();
       entryContainer.getRootContainer().getStorage().write(new WriteOperation()
@@ -1139,23 +1157,66 @@
         entryContainer.unlock();
       }
 
-      // The only part of what the indexes which stay are asked for that is written down. A change
-      // which untrusts none of them - a lowered limit - opens no transaction, rather than one a
-      // bounded storage could give up on with nothing to give up; VLVIndex guards its write the
-      // same way.
-      if (!indexesToUntrust.isEmpty())
+      if (!treesToGiveUp.isEmpty())
       {
-        entryContainer.getRootContainer().getStorage().write(new WriteOperation()
+        // No query may be reading a tree which is being given up and opened again below - the same
+        // exclusive access the removal of an index takes.
+        entryContainer.lock();
+        try
         {
-          @Override
-          public void run(WriteableTransaction txn) throws Exception
+          writeUntrust(indexesToUntrust, treesToGiveUp);
+          // Held so that a give-up of the reopen below can be put back to what it answered before:
+          // what afterOpen binds is memory, and outlives a write the storage rolled back, while the
+          // tree it opened is not - so a re-apply which trusted that binding would agree with the
+          // new setting and never open the tree the write above just deleted.
+          final List<IndexBinding> bindings = new ArrayList<>(treesToGiveUp.size());
+          for (final MatchingRuleIndex givenUp : treesToGiveUp)
           {
-            for (final Index updatedIndex : indexesToUntrust)
-            {
-              updatedIndex.setTrusted(txn, false);
-            }
+            bindings.add(new IndexBinding(givenUp));
           }
-        });
+          try
+          {
+            // In a write of its own, since the storage engines delete and create the tree of an index
+            // as operations of their own - as removing and adding an index does - rather than as a
+            // deletion and a creation one transaction carries together. The write above is the one
+            // which untrusts and deletes, so a change interrupted in between leaves an index the next
+            // open of this container creates empty and keeps degraded, rather than a trusted one
+            // holding nothing.
+            entryContainer.getRootContainer().getStorage().write(new WriteOperation()
+            {
+              @Override
+              public void run(WriteableTransaction txn) throws Exception
+              {
+                for (final MatchingRuleIndex givenUp : treesToGiveUp)
+                {
+                  // Which is what binds the codec of the index to the confidentiality now in force.
+                  givenUp.open(txn, true);
+                }
+              }
+            });
+          }
+          catch (Exception e)
+          {
+            for (IndexBinding binding : bindings)
+            {
+              binding.revert();
+            }
+            throw e;
+          }
+        }
+        finally
+        {
+          entryContainer.unlock();
+        }
+      }
+      else if (!indexesToUntrust.isEmpty())
+      {
+        // The only part of what the indexes which stay are asked for that is written down. A change
+        // which untrusts none of them - a lowered limit - opens no transaction, rather than one a
+        // bounded storage could give up on with nothing to give up; VLVIndex guards its write the
+        // same way. A change of the entry limit alone leaves the trees where they are, and needs no
+        // exclusive access of its own.
+        writeUntrust(indexesToUntrust, treesToGiveUp);
       }
       for (final Index updatedIndex : updatedIndexes.values())
       {
@@ -1190,6 +1251,77 @@
   }
 
   /**
+   * Untrusts the indexes which are kept and may no longer be trusted, in a write of its own, and
+   * gives up the trees of those whose confidentiality changes in that same write.
+   */
+  private void writeUntrust(final List<Index> indexesToUntrust, final List<MatchingRuleIndex> treesToGiveUp)
+      throws Exception
+  {
+    entryContainer.getRootContainer().getStorage().write(new WriteOperation()
+    {
+      @Override
+      public void run(WriteableTransaction txn) throws Exception
+      {
+        for (final Index updatedIndex : indexesToUntrust)
+        {
+          updatedIndex.setTrusted(txn, false);
+        }
+        for (final MatchingRuleIndex givenUp : treesToGiveUp)
+        {
+          /*
+           * What this tree holds was written in the encoding of the setting which has just been given
+           * up, and the codec of the new one does not read it back as what it is: an encrypted record
+           * read as clear text decodes to an empty set of entry IDs rather than failing, which is a
+           * search answered with no entries at all. So the tree is given up here rather than left to
+           * the rebuild this change asks for, leaving an index which answers "undefined" - and is
+           * therefore not used - until that rebuild has run.
+           *
+           * Deleted rather than emptied record by record, which for an index of any size would be a
+           * transaction of its own making. The state record of the index is kept, so the tree the
+           * caller opens again is written back in the serialization this one was created with.
+           *
+           * Guarded by whether the tree is still there: a change asked for again after a give-up of
+           * the write which reopens it finds this index still to give up, since the setting it was
+           * opened under was put back, but the write which deleted it already committed the first
+           * time - deleting an already given-up tree a second time is what the storage answers this
+           * with otherwise.
+           */
+          if (txn.treeExists(givenUp.getName()))
+          {
+            givenUp.delete(txn);
+          }
+        }
+      }
+    });
+  }
+
+  /**
+   * What an index answered before its tree was given up, kept so it can be put back if the reopen
+   * which follows gives its commit up. Taken after the write which untrusts, so the trust it holds
+   * is the one that write committed.
+   */
+  private static final class IndexBinding
+  {
+    private final MatchingRuleIndex index;
+    private final boolean encrypted;
+    private final EntryIDSetCodec codec;
+    private final boolean trusted;
+
+    IndexBinding(MatchingRuleIndex index)
+    {
+      this.index = index;
+      this.encrypted = index.isEncrypted();
+      this.codec = index.codec();
+      this.trusted = index.isTrusted();
+    }
+
+    void revert()
+    {
+      index.revertFailedReopen(encrypted, codec, trusted);
+    }
+  }
+
+  /**
    * Opens an index this change adds, and answers whether it has to be rebuilt before it is used.
    * Answered to the caller rather than reported from here: this runs inside a {@link WriteOperation}
    * the storage may replay, and the report belongs to the attempt which commits.
@@ -1207,9 +1339,9 @@
    * the same answer on every attempt.
    */
   private static void planIndexUpdates(Collection<MatchingRuleIndex> updatedIndexes, BackendIndexCfg newConfig,
-      List<Index> indexesToUntrust, List<LocalizableMessage> rebuildMessages)
+      List<Index> indexesToUntrust, List<MatchingRuleIndex> treesToGiveUp, List<LocalizableMessage> rebuildMessages)
   {
-    for (Index updatedIndex : updatedIndexes)
+    for (MatchingRuleIndex updatedIndex : updatedIndexes)
     {
       // This index could still be used since a new smaller index size limit doesn't impact validity of the results.
       boolean newLimitRequiresRebuild = updatedIndex.getIndexEntryLimit() < newConfig.getIndexEntryLimit();
@@ -1217,12 +1349,16 @@
       {
         rebuildMessages.add(NOTE_CONFIG_INDEX_ENTRY_LIMIT_REQUIRES_REBUILD.get(updatedIndex.getName()));
       }
-      // This index could still be used when disabling confidentiality. Asked rather than told: for an
-      // index this only compares the configuration with the parameters its crypto suite holds.
-      boolean newConfidentialityRequiresRebuild = updatedIndex.setConfidential(newConfig.isConfidentialityEnabled());
+      // A change of the confidentiality gives up the tree of this index, whichever way it goes. The
+      // index answers whether it writes under the setting asked for: the tree it holds is in the
+      // encoding it was opened under, until the change gives it up and opens it again. An index whose
+      // every tree is replaced, as enabling the confidentiality of an equality index does, is not
+      // among those asked, and so has nothing to give up or to open again.
+      boolean newConfidentialityRequiresRebuild = updatedIndex.isEncrypted() != newConfig.isConfidentialityEnabled();
       if (newConfidentialityRequiresRebuild)
       {
         rebuildMessages.add(NOTE_CONFIG_INDEX_CONFIDENTIALITY_REQUIRES_REBUILD.get(updatedIndex.getName()));
+        treesToGiveUp.add(updatedIndex);
       }
       if (newLimitRequiresRebuild || newConfidentialityRequiresRebuild)
       {
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/DefaultIndex.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/DefaultIndex.java
index 88edf18..f1799b5 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/DefaultIndex.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/DefaultIndex.java
@@ -53,7 +53,18 @@
   /** The limit on the number of entry IDs that may be indexed by one key. */
   private int indexEntryLimit;
 
-  private EntryIDSetCodec codec;
+  /**
+   * Volatile because {@link #afterOpen} binds it to the confidentiality then in force, and an index
+   * whose configuration gives that setting up - or asks for it - is opened again while operations of
+   * other threads are holding this instance.
+   */
+  private volatile EntryIDSetCodec codec;
+  /**
+   * Whether that codec encrypts: the confidentiality this index was opened under. Held here rather
+   * than read from the suite, since the suite carries the setting now in force for the attribute,
+   * which this index writes under only once its tree has been given up and opened again.
+   */
+  private volatile boolean encrypted;
   private CryptoSuite cryptoSuite;
 
   /**
@@ -98,7 +109,8 @@
   {
     final EnumSet<IndexFlag> flags = state.getIndexFlags(txn, getName());
     codec = flags.contains(COMPACTED) ? CODEC_V2 : CODEC_V1;
-    if (cryptoSuite.isEncrypted())
+    encrypted = cryptoSuite.isEncrypted();
+    if (encrypted)
     {
       codec = new EntryIDSet.EntryIDSetCodecV3(codec, cryptoSuite);
     }
@@ -307,12 +319,6 @@
   }
 
   @Override
-  public boolean setConfidential(boolean indexConfidential)
-  {
-    return cryptoSuite.isEncrypted() != indexConfidential;
-  }
-
-  @Override
   public final int getIndexEntryLimit()
   {
     return indexEntryLimit;
@@ -338,8 +344,31 @@
     return trusted;
   }
 
+  /** Whether this index encrypts what it writes: the confidentiality its tree was opened under. */
   final boolean isEncrypted()
   {
-    return cryptoSuite.isEncrypted();
+    return encrypted;
+  }
+
+  /** The codec this index currently reads and writes under. */
+  final EntryIDSetCodec codec()
+  {
+    return codec;
+  }
+
+  /**
+   * Puts this index back to the confidentiality, codec and trust it answered before its tree was
+   * given up and opened again, for a reopen whose commit the storage gave up: what
+   * {@link #afterOpen} binds is memory, and outlives a write the storage rolled back, so a change
+   * asked for again would otherwise find this index already agreeing with the setting that write
+   * never durably reached. The trust is bound the same way - an index opened over an empty entry
+   * container is trusted on the spot - and, left behind, is what an operation which then fails
+   * writes down for every index in its buffer, for a tree the give-up took with it.
+   */
+  final void revertFailedReopen(boolean encrypted, EntryIDSetCodec codec, boolean trusted)
+  {
+    this.encrypted = encrypted;
+    this.codec = codec;
+    this.trusted = trusted;
   }
 }
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/EntryContainer.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/EntryContainer.java
index 4c6d71b..5825abe 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/EntryContainer.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/EntryContainer.java
@@ -565,6 +565,20 @@
         config.getCipherKeyLength(), confidentiality);
   }
 
+  /**
+   * Puts the confidentiality the configuration of a backend index asks for in force on the
+   * {@link CryptoSuite} the indexes of that attribute share, keeping the cipher of this backend.
+   *
+   * @param indexCrypto
+   *          the crypto suite the indexes of one attribute were opened with
+   * @param confidentiality
+   *          whether what those indexes store has to be encrypted from now on
+   */
+  void setIndexConfidentiality(CryptoSuite indexCrypto, boolean confidentiality)
+  {
+    indexCrypto.newParameters(config.getCipherTransformation(), config.getCipherKeyLength(), confidentiality);
+  }
+
   private AttributeIndex newAttributeIndex(BackendIndexCfg cfg, CryptoSuite cryptoSuite) throws ConfigException
   {
     return new AttributeIndex(cfg, state, this, cryptoSuite);
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/Index.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/Index.java
index 078348d..a2cd969 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/Index.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/Index.java
@@ -57,8 +57,6 @@
 
   boolean setIndexEntryLimit(int indexEntryLimit);
 
-  boolean setConfidential(boolean indexConfidential);
-
   void setTrusted(WriteableTransaction txn, boolean trusted);
 
   void update(WriteableTransaction txn, ByteString key, EntryIDSet deletedIDs, EntryIDSet addedIDs);
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/jeb/JEIndexConfidentialityChangeTest.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/jeb/JEIndexConfidentialityChangeTest.java
new file mode 100644
index 0000000..f343da0
--- /dev/null
+++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/jeb/JEIndexConfidentialityChangeTest.java
@@ -0,0 +1,54 @@
+/*
+ * 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.jeb;
+
+import static org.forgerock.opendj.config.ConfigurationMock.mockCfg;
+import static org.mockito.Mockito.when;
+
+import org.forgerock.opendj.config.server.ConfigException;
+import org.forgerock.opendj.server.config.server.JEBackendCfg;
+import org.opends.server.backends.pluggable.IndexConfidentialityChangeTestCase;
+import org.opends.server.backends.pluggable.spi.Storage;
+import org.opends.server.core.ServerContext;
+import org.testng.annotations.Test;
+
+/**
+ * A confidentiality change of a backend index of a {@link JEBackend}, which deletes and creates the
+ * tree of an index through a storage engine of its own.
+ */
+@Test
+public class JEIndexConfidentialityChangeTest extends IndexConfidentialityChangeTestCase<JEBackendCfg>
+{
+  @Override
+  protected JEBackendCfg createBackendCfg()
+  {
+    final JEBackendCfg backendCfg = mockCfg(JEBackendCfg.class);
+    when(backendCfg.getBackendId()).thenReturn("JEIndexConfidentialityChangeTest");
+    when(backendCfg.getDBDirectory()).thenReturn("JEIndexConfidentialityChangeTest");
+    when(backendCfg.getDBDirectoryPermissions()).thenReturn("755");
+    when(backendCfg.getDBCacheSize()).thenReturn(0L);
+    when(backendCfg.getDBCachePercent()).thenReturn(20);
+    when(backendCfg.getDBNumCleanerThreads()).thenReturn(2);
+    when(backendCfg.getDBNumLockTables()).thenReturn(63);
+    return backendCfg;
+  }
+
+  @Override
+  protected Storage createStorage(JEBackendCfg cfg, ServerContext serverContext) throws ConfigException
+  {
+    return new JEStorage(cfg, serverContext);
+  }
+}
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/pdb/PDBIndexConfidentialityChangeTest.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/pdb/PDBIndexConfidentialityChangeTest.java
new file mode 100644
index 0000000..de0ffa1
--- /dev/null
+++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/pdb/PDBIndexConfidentialityChangeTest.java
@@ -0,0 +1,49 @@
+/*
+ * 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.pdb;
+
+import static org.forgerock.opendj.config.ConfigurationMock.mockCfg;
+import static org.mockito.Mockito.when;
+
+import org.forgerock.opendj.config.server.ConfigException;
+import org.forgerock.opendj.server.config.server.PDBBackendCfg;
+import org.opends.server.backends.pluggable.IndexConfidentialityChangeTestCase;
+import org.opends.server.backends.pluggable.spi.Storage;
+import org.opends.server.core.ServerContext;
+import org.testng.annotations.Test;
+
+/** A confidentiality change of a backend index of a {@link PDBBackend}. */
+@Test
+public class PDBIndexConfidentialityChangeTest extends IndexConfidentialityChangeTestCase<PDBBackendCfg>
+{
+  @Override
+  protected PDBBackendCfg createBackendCfg()
+  {
+    final PDBBackendCfg backendCfg = mockCfg(PDBBackendCfg.class);
+    when(backendCfg.getBackendId()).thenReturn("PDBIndexConfidentialityChangeTest");
+    when(backendCfg.getDBDirectory()).thenReturn("PDBIndexConfidentialityChangeTest");
+    when(backendCfg.getDBDirectoryPermissions()).thenReturn("755");
+    when(backendCfg.getDBCacheSize()).thenReturn(0L);
+    when(backendCfg.getDBCachePercent()).thenReturn(20);
+    return backendCfg;
+  }
+
+  @Override
+  protected Storage createStorage(PDBBackendCfg cfg, ServerContext serverContext) throws ConfigException
+  {
+    return new PDBStorage(cfg, serverContext);
+  }
+}
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/IndexConfidentialityChangeTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/IndexConfidentialityChangeTestCase.java
new file mode 100644
index 0000000..756b59b
--- /dev/null
+++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/IndexConfidentialityChangeTestCase.java
@@ -0,0 +1,449 @@
+/*
+ * 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.pluggable;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.opends.messages.BackendMessages.NOTE_CONFIG_INDEX_CONFIDENTIALITY_REQUIRES_REBUILD;
+import static org.opends.server.util.CollectionUtils.newTreeSet;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.forgerock.i18n.LocalizableMessage;
+import org.forgerock.opendj.config.server.ConfigChangeResult;
+import org.forgerock.opendj.config.server.ConfigException;
+import org.forgerock.opendj.ldap.ByteString;
+import org.forgerock.opendj.ldap.DN;
+import org.forgerock.opendj.ldap.ResultCode;
+import org.forgerock.opendj.ldap.schema.AttributeType;
+import org.forgerock.opendj.server.config.meta.BackendIndexCfgDefn.IndexType;
+import org.forgerock.opendj.server.config.server.BackendIndexCfg;
+import org.forgerock.opendj.server.config.server.PluggableBackendCfg;
+import org.opends.server.DirectoryServerTestCase;
+import org.opends.server.TestCaseUtils;
+import org.opends.server.backends.pluggable.AttributeIndex.MatchingRuleIndex;
+import org.opends.server.backends.pluggable.spi.Storage;
+import org.opends.server.core.AddOperation;
+import org.opends.server.core.ServerContext;
+import org.opends.server.types.Entry;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+/**
+ * Tests that a change of {@code confidentiality-enabled} on a backend index is applied to the
+ * running backend, rather than only reported as requiring a rebuild which cannot help - see OpenDJ
+ * issue #992.
+ * <p>
+ * The confidentiality of an index is carried by the {@link org.opends.server.crypto.CryptoSuite} the
+ * indexes of one attribute share, and read from it when an index binds its codec at open time. A
+ * change which does not put the new setting in force on that suite, and does not bind the codecs
+ * again, leaves the stored records in the encoding of the previous setting for the life of the
+ * container.
+ */
+@SuppressWarnings("javadoc")
+@Test(groups = { "precommit", "pluggablebackend" }, sequential = true)
+public abstract class IndexConfidentialityChangeTestCase<C extends PluggableBackendCfg> extends DirectoryServerTestCase
+{
+  private static final DN BASE_DN = DN.valueOf("dc=b992,dc=com");
+  /** Indexed for presence, whose tree a confidentiality change keeps, and for equality, whose it does not. */
+  private static final String INDEXED_ATTRIBUTE = "sn";
+  /** The first byte {@code EntryIDSet.EntryIDSetCodecV3} prepends to a record it encrypted. */
+  private static final byte ENCRYPTED_RECORD_TAG = 0x00;
+  private static final int ENTRY_LIMIT = 4000;
+
+  private ServerContext serverContext;
+  private AttributeType attributeType;
+
+  /**
+   * Factory method for the configuration of the backend under test, with the settings specific to its
+   * storage engine stubbed out.
+   *
+   * @return the new backend configuration
+   */
+  protected abstract C createBackendCfg();
+
+  /**
+   * Factory method for the storage of the backend under test, which the test reads the stored records
+   * back from.
+   *
+   * @param cfg
+   *          the configuration the backend was configured with
+   * @param serverContext
+   *          the server context of the running test server
+   * @return the storage of the backend under test
+   * @throws ConfigException
+   *           if the configuration is not one the storage can be opened with
+   */
+  protected abstract Storage createStorage(C cfg, ServerContext serverContext) throws ConfigException;
+
+  @BeforeClass
+  public void startServer() throws Exception
+  {
+    TestCaseUtils.startServer();
+    serverContext = TestCaseUtils.getServerContext();
+    attributeType = serverContext.getSchema().getAttributeType(INDEXED_ATTRIBUTE);
+  }
+
+  /**
+   * A test which fails before it closes its backend leaves the base DN behind in the server wide
+   * registry, where it would outlive the test and break the next one to use that DN.
+   */
+  @AfterMethod
+  public void deregisterLeftoverBaseDN()
+  {
+    try
+    {
+      serverContext.getBackendConfigManager().deregisterBaseDN(BASE_DN);
+    }
+    catch (Exception alreadyGone)
+    {
+      // Which is what a test that closed its backend has left behind.
+    }
+  }
+
+  /**
+   * The records an index holds are in the encoding of the setting in force when they were written,
+   * and the codec of the new setting cannot read them back. They are given up here rather than left
+   * for the searches which run before the rebuild this change asks for.
+   */
+  @Test
+  public void enablingConfidentialityEmptiesTheIndexItAsksToRebuild() throws Exception
+  {
+    final TestBackend backend = openBackend(false);
+    try
+    {
+      addEntry(backend, "user.0");
+      final AttributeIndex attributeIndex = attributeIndex(backend);
+      assertThat(recordCount(backend, presenceIndex(attributeIndex))).isEqualTo(1);
+
+      final ConfigChangeResult ccr = attributeIndex.applyConfigurationChange(indexCfg(true, ENTRY_LIMIT));
+
+      assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS);
+      assertThat(ccr.adminActionRequired()).isTrue();
+      assertThat(ordinalsOf(ccr)).contains(NOTE_CONFIG_INDEX_CONFIDENTIALITY_REQUIRES_REBUILD.ordinal());
+      final MatchingRuleIndex presence = presenceIndex(attributeIndex);
+      assertThat(recordCount(backend, presence)).isEqualTo(0);
+      assertThat(presence.isTrusted()).isFalse();
+      // Undefined rather than empty, so that a search of it is not answered with no candidates.
+      final ByteString key = keyOf(presence, entryOf("user.0"));
+      final boolean defined = backend.storage.read(txn -> presence.get(txn, key).isDefined());
+      assertThat(defined).isFalse();
+    }
+    finally
+    {
+      backend.finalizeBackend();
+    }
+  }
+
+  /** The setting the operator asked for has to reach what the index writes from then on. */
+  @Test
+  public void enablingConfidentialityEncryptsWhatTheIndexWritesNext() throws Exception
+  {
+    final TestBackend backend = openBackend(false);
+    try
+    {
+      final AttributeIndex attributeIndex = attributeIndex(backend);
+      addEntry(backend, "user.0");
+      assertThat(rawRecord(backend, presenceIndex(attributeIndex), "user.0").byteAt(0))
+          .isNotEqualTo(ENCRYPTED_RECORD_TAG);
+
+      attributeIndex.applyConfigurationChange(indexCfg(true, ENTRY_LIMIT));
+      trust(backend, attributeIndex);
+      addEntry(backend, "user.1");
+
+      final MatchingRuleIndex presence = presenceIndex(attributeIndex);
+      assertThat(rawRecord(backend, presence, "user.1").byteAt(0)).isEqualTo(ENCRYPTED_RECORD_TAG);
+      assertThat(idsOf(backend, presence, "user.1")).hasSize(1);
+    }
+    finally
+    {
+      backend.finalizeBackend();
+    }
+  }
+
+  /**
+   * Enabling confidentiality of an equality index replaces its tree with one whose keys are hashed,
+   * which the change creates and opens itself. That one has to be opened with the new setting in
+   * force, or its keys are protected while its records are not.
+   */
+  @Test
+  public void enablingConfidentialityEncryptsTheKeyHashedIndexItCreates() throws Exception
+  {
+    final TestBackend backend = openBackend(false);
+    try
+    {
+      final AttributeIndex attributeIndex = attributeIndex(backend);
+      addEntry(backend, "user.0");
+      assertThat(keyHashedIndex(attributeIndex)).isNull();
+
+      attributeIndex.applyConfigurationChange(indexCfg(true, ENTRY_LIMIT));
+      trust(backend, attributeIndex);
+      addEntry(backend, "user.1");
+
+      final MatchingRuleIndex hashed = keyHashedIndex(attributeIndex);
+      assertThat(hashed).isNotNull();
+      assertThat(rawRecord(backend, hashed, "user.1").byteAt(0)).isEqualTo(ENCRYPTED_RECORD_TAG);
+      assertThat(idsOf(backend, hashed, "user.1")).hasSize(1);
+    }
+    finally
+    {
+      backend.finalizeBackend();
+    }
+  }
+
+  /** And the same the other way around: giving the setting up has to stop the encryption. */
+  @Test
+  public void disablingConfidentialityStopsEncryptingWhatTheIndexWritesNext() throws Exception
+  {
+    final TestBackend backend = openBackend(true);
+    try
+    {
+      final AttributeIndex attributeIndex = attributeIndex(backend);
+      addEntry(backend, "user.0");
+      assertThat(rawRecord(backend, presenceIndex(attributeIndex), "user.0").byteAt(0))
+          .isEqualTo(ENCRYPTED_RECORD_TAG);
+
+      attributeIndex.applyConfigurationChange(indexCfg(false, ENTRY_LIMIT));
+      // The tree given up carries the record written under the setting just given up, which the
+      // codec of the new one does not read back as what it is - so it is given up here regardless of
+      // which way the setting goes, not only where enabling replaces it with a key hashed twin.
+      assertThat(recordCount(backend, presenceIndex(attributeIndex))).as("the encrypted tree, given up").isEqualTo(0);
+      trust(backend, attributeIndex);
+      addEntry(backend, "user.1");
+
+      final MatchingRuleIndex presence = presenceIndex(attributeIndex);
+      assertThat(rawRecord(backend, presence, "user.1").byteAt(0)).isNotEqualTo(ENCRYPTED_RECORD_TAG);
+      assertThat(idsOf(backend, presence, "user.1")).hasSize(1);
+    }
+    finally
+    {
+      backend.finalizeBackend();
+    }
+  }
+
+  /**
+   * Once the change is applied, the comparison it is reported by converges: an unrelated change of
+   * the same index must not untrust it again, and must not repeat the rebuild message.
+   */
+  @Test
+  public void aLaterUnrelatedChangeLeavesTheIndexTrusted() throws Exception
+  {
+    final TestBackend backend = openBackend(false);
+    try
+    {
+      final AttributeIndex attributeIndex = attributeIndex(backend);
+      addEntry(backend, "user.0");
+      attributeIndex.applyConfigurationChange(indexCfg(true, ENTRY_LIMIT));
+      trust(backend, attributeIndex);
+
+      // A smaller index entry limit does not invalidate what the index holds, so this change has no
+      // rebuild of its own to ask for.
+      final ConfigChangeResult ccr = attributeIndex.applyConfigurationChange(indexCfg(true, ENTRY_LIMIT - 1));
+
+      assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS);
+      assertThat(ccr.getMessages()).isEmpty();
+      assertThat(ccr.adminActionRequired()).isFalse();
+      for (MatchingRuleIndex index : attributeIndex.getNameToIndexes().values())
+      {
+        assertThat(index.isTrusted()).as(index.getName().toString()).isTrue();
+      }
+    }
+    finally
+    {
+      backend.finalizeBackend();
+    }
+  }
+
+  private TestBackend openBackend(boolean indexConfidentiality) throws Exception
+  {
+    final C cfg = backendCfg(indexConfidentiality);
+    final TestBackend backend = new TestBackend();
+    backend.setBackendID(cfg.getBackendId());
+    backend.configureBackend(cfg, serverContext);
+    // Start from a pristine on-disk state, so that a previous run cannot mask the defect.
+    backend.storage.removeStorageFiles();
+    try
+    {
+      backend.openBackend();
+      backend.addEntry(TestCaseUtils.makeEntry(
+          "dn: " + BASE_DN,
+          "objectClass: top",
+          "objectClass: domain",
+          "dc: b992"), mock(AddOperation.class));
+    }
+    catch (Exception e)
+    {
+      // openBackend() registers the base DN and the monitor before it returns, so a failure after
+      // that would leave both behind and break every following test rather than only this one.
+      try
+      {
+        backend.finalizeBackend();
+      }
+      catch (Exception cleanupFailure)
+      {
+        e.addSuppressed(cleanupFailure);
+      }
+      throw e;
+    }
+    return backend;
+  }
+
+  private Entry addEntry(TestBackend backend, String uid) throws Exception
+  {
+    final Entry entry = entryOf(uid);
+    backend.addEntry(entry, mock(AddOperation.class));
+    return entry;
+  }
+
+  /** Trusts every index of the attribute, which is what the rebuild the change asks for leaves behind. */
+  private void trust(TestBackend backend, final AttributeIndex attributeIndex) throws Exception
+  {
+    backend.storage.write(txn -> {
+      for (MatchingRuleIndex index : attributeIndex.getNameToIndexes().values())
+      {
+        index.setTrusted(txn, true);
+      }
+    });
+  }
+
+  private AttributeIndex attributeIndex(TestBackend backend)
+  {
+    return backend.getRootContainer().getEntryContainer(BASE_DN).getAttributeIndex(attributeType);
+  }
+
+  private static MatchingRuleIndex presenceIndex(AttributeIndex attributeIndex)
+  {
+    return attributeIndex.getNameToIndexes().get(IndexType.PRESENCE.toString());
+  }
+
+  private static MatchingRuleIndex keyHashedIndex(AttributeIndex attributeIndex)
+  {
+    for (Map.Entry<String, MatchingRuleIndex> index : attributeIndex.getNameToIndexes().entrySet())
+    {
+      if (index.getKey().endsWith(AttributeIndex.PROTECTED_INDEX_ID))
+      {
+        return index.getValue();
+      }
+    }
+    return null;
+  }
+
+  private static ByteString keyOf(MatchingRuleIndex index, Entry entry)
+  {
+    return index.indexEntry(entry).iterator().next();
+  }
+
+  /** The record as it is stored, which is what says whether it was encrypted. */
+  private ByteString rawRecord(TestBackend backend, final MatchingRuleIndex index, String uid) throws Exception
+  {
+    final ByteString key = keyOf(index, entryOf(uid));
+    final ByteString record = backend.storage.read(txn -> txn.read(index.getName(), key));
+    assertThat(record).as("the record of " + index.getName() + " at key " + key).isNotNull();
+    return record;
+  }
+
+  private List<Long> idsOf(TestBackend backend, final MatchingRuleIndex index, String uid) throws Exception
+  {
+    final ByteString key = keyOf(index, entryOf(uid));
+    final EntryIDSet idSet = backend.storage.read(txn -> index.get(txn, key));
+    assertThat(idSet.isDefined()).as("the entry IDs of " + index.getName() + " at key " + key).isTrue();
+    final List<Long> ids = new ArrayList<>();
+    for (EntryID id : idSet)
+    {
+      ids.add(id.longValue());
+    }
+    return ids;
+  }
+
+  /** The entry as it was added, which the indexers generate the keys of a record from. */
+  private Entry entryOf(String uid) throws Exception
+  {
+    return TestCaseUtils.makeEntry(
+        "dn: uid=" + uid + "," + BASE_DN,
+        "objectClass: top",
+        "objectClass: person",
+        "objectClass: organizationalPerson",
+        "objectClass: inetOrgPerson",
+        "uid: " + uid,
+        "cn: " + uid,
+        "sn: " + uid);
+  }
+
+  private long recordCount(TestBackend backend, final MatchingRuleIndex index) throws Exception
+  {
+    return backend.storage.read(txn -> index.getRecordCount(txn));
+  }
+
+  private static Set<Integer> ordinalsOf(ConfigChangeResult ccr)
+  {
+    final Set<Integer> ordinals = new HashSet<>();
+    for (LocalizableMessage message : ccr.getMessages())
+    {
+      ordinals.add(message.ordinal());
+    }
+    return ordinals;
+  }
+
+  private C backendCfg(boolean indexConfidentiality) throws ConfigException
+  {
+    final C cfg = createBackendCfg();
+    // Read outside the when() below, which calling a mock inside would leave unfinished.
+    final String backendId = cfg.getBackendId();
+    when(cfg.dn()).thenReturn(DN.valueOf("ds-cfg-backend-id=" + backendId + ",cn=Backends,cn=config"));
+    when(cfg.getBaseDN()).thenReturn(newTreeSet(BASE_DN));
+    when(cfg.listBackendIndexes()).thenReturn(new String[] { INDEXED_ATTRIBUTE });
+    when(cfg.listBackendVLVIndexes()).thenReturn(new String[0]);
+    // An index can only be confidential in a backend which is, and the cipher of the backend is what
+    // the crypto suite of an index takes its parameters from.
+    when(cfg.isConfidentialityEnabled()).thenReturn(true);
+    when(cfg.getCipherTransformation()).thenReturn("AES/CBC/PKCS5Padding");
+    when(cfg.getCipherKeyLength()).thenReturn(128);
+    // Stubbed outside the when() below, which stubbing another mock inside would leave unfinished.
+    final BackendIndexCfg indexCfg = indexCfg(indexConfidentiality, ENTRY_LIMIT);
+    when(cfg.getBackendIndex(INDEXED_ATTRIBUTE)).thenReturn(indexCfg);
+    return cfg;
+  }
+
+  private BackendIndexCfg indexCfg(boolean confidentiality, int entryLimit)
+  {
+    final BackendIndexCfg cfg = mock(BackendIndexCfg.class);
+    when(cfg.getIndexType()).thenReturn(newTreeSet(IndexType.PRESENCE, IndexType.EQUALITY));
+    when(cfg.getAttribute()).thenReturn(attributeType);
+    when(cfg.getIndexEntryLimit()).thenReturn(entryLimit);
+    when(cfg.getSubstringLength()).thenReturn(6);
+    when(cfg.isConfidentialityEnabled()).thenReturn(confidentiality);
+    return cfg;
+  }
+
+  /** A backend whose storage the test reads the stored records back from. */
+  private final class TestBackend extends BackendImpl<C>
+  {
+    private Storage storage;
+
+    @Override
+    protected Storage configureStorage(C cfg, ServerContext serverContext) throws ConfigException
+    {
+      storage = createStorage(cfg, serverContext);
+      return storage;
+    }
+  }
+}
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/ReplayedConfigChangeTest.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/ReplayedConfigChangeTest.java
index cc030d8..2d37dea 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/ReplayedConfigChangeTest.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/ReplayedConfigChangeTest.java
@@ -16,9 +16,11 @@
 package org.opends.server.backends.pluggable;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.opends.messages.BackendMessages.ERR_BACKEND_BASEDN_NO_LONGER_HELD;
 import static org.opends.messages.BackendMessages.ERR_BACKEND_CANNOT_LIST_TREES_AFTER_BASEDN_CHANGE;
 import static org.opends.messages.BackendMessages.ERR_BACKEND_CANNOT_REGISTER_BASEDN;
+import static org.opends.messages.BackendMessages.NOTE_CONFIG_INDEX_CONFIDENTIALITY_REQUIRES_REBUILD;
 import static org.opends.messages.BackendMessages.NOTE_CONFIG_INDEX_ENTRY_LIMIT_REQUIRES_REBUILD;
 import static org.opends.messages.BackendMessages.NOTE_INDEX_ADD_REQUIRES_REBUILD;
 import static org.forgerock.opendj.config.ConfigurationMock.mockCfg;
@@ -58,6 +60,7 @@
 import org.opends.server.TestCaseUtils;
 import org.opends.server.backends.pdb.PDBStorage;
 import org.opends.server.backends.pluggable.AttributeIndex.MatchingRuleIndex;
+import org.opends.server.backends.pluggable.EntryIDSet.EntryIDSetCodec;
 import org.opends.server.backends.pluggable.State.IndexFlag;
 import org.opends.server.backends.pluggable.spi.AccessMode;
 import org.opends.server.backends.pluggable.spi.Cursor;
@@ -862,6 +865,226 @@
   }
 
   /**
+   * A change of the confidentiality gives up the tree of an index it keeps and opens it again under
+   * the new setting, in a write which untrusts and deletes and a write which opens. Whether an index
+   * needs that is asked of the index - what its tree was opened under - so that an attempt the
+   * storage replays reaches the same answer, and the instruction is given before any write, since
+   * the configuration entry holds the new setting whichever way the writes go.
+   */
+  @Test
+  public void aConfidentialityChangeUntrustsTheIndexWhenTheTransactionIsReplayed() throws Exception
+  {
+    final ReplayingBackend backend = openBackendWithPresenceIndex();
+    try
+    {
+      final RootContainer rootContainer = backend.getRootContainer();
+      final EntryContainer ec = rootContainer.getEntryContainer(KEPT);
+      final AttributeIndex index = ec.getAttributeIndex(cnType);
+      final MatchingRuleIndex cnIndex = index.getNameToIndexes().values().iterator().next();
+      assertThat(persistedFlags(rootContainer, ec, cnIndex.getName())).contains(TRUSTED);
+      // Held so that the index opened again below stays untrusted: an index of an empty backend is
+      // trusted when it is opened, whatever its tree holds.
+      addBaseEntry(backend, KEPT, "b907a");
+      assertThat(cnIndex.isEncrypted()).isFalse();
+
+      // The third write is the one which untrusts the index and gives up its tree; the fourth opens
+      // it again, which is what binds its codec to the setting now in force.
+      final int writesBefore = backend.storage.writes();
+      backend.storage.conflictAtCommitOnWrite(3, 1);
+      final ConfigChangeResult ccr = index.applyConfigurationChange(confidentialPresenceIndexCfg());
+
+      assertThat(backend.storage.writes()).as("the armed write was the third of four").isEqualTo(writesBefore + 4);
+      assertThat(backend.storage.attempts()).isEqualTo(2);
+      assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS);
+      assertThat(ccr.adminActionRequired()).isTrue();
+      assertThat(ccr.getMessages()).as("the rebuild the new setting needs, asked for once").hasSize(1);
+      assertThat(ordinalsOf(ccr)).containsOnly(NOTE_CONFIG_INDEX_CONFIDENTIALITY_REQUIRES_REBUILD.ordinal());
+      assertThat(cnIndex.isTrusted()).isFalse();
+      assertThat(persistedFlags(rootContainer, ec, cnIndex.getName()))
+          .as("the flag the attempt which committed had to remove").doesNotContain(TRUSTED);
+      assertThat(cnIndex.isEncrypted()).as("the setting the tree was opened again under").isTrue();
+
+      // The setting the change applied is the one the index writes under from now on, so asking for
+      // it a second time asks for nothing of the index, and untrusts nothing.
+      final int writesAfter = backend.storage.writes();
+      final ConfigChangeResult again = index.applyConfigurationChange(confidentialPresenceIndexCfg());
+      assertThat(again.getResultCode()).isEqualTo(ResultCode.SUCCESS);
+      assertThat(again.adminActionRequired()).as("a change which changes nothing").isFalse();
+      assertThat(again.getMessages()).isEmpty();
+      assertThat(backend.storage.writes()).as("the two writes which add and remove indexes, and no third")
+          .isEqualTo(writesAfter + 2);
+    }
+    finally
+    {
+      backend.finalizeBackend();
+    }
+  }
+
+  /**
+   * The same instruction survives a give-up on the write which untrusts the index and gives up its
+   * tree, and the change is still a change when asked for again: the suite the indexes of the
+   * attribute share was switched before that write and is not rolled back with it, so it already
+   * reads as applied, while the index still writes under the setting its tree was opened under -
+   * which is what it is asked.
+   */
+  @Test
+  public void aConfidentialityChangeIsReportedWhenTheWriteWhichGivesUpTheTreeGivesUp() throws Exception
+  {
+    final ReplayingBackend backend = openBackendWithPresenceIndex();
+    try
+    {
+      final RootContainer rootContainer = backend.getRootContainer();
+      final EntryContainer ec = rootContainer.getEntryContainer(KEPT);
+      final AttributeIndex index = ec.getAttributeIndex(cnType);
+      final MatchingRuleIndex cnIndex = index.getNameToIndexes().values().iterator().next();
+      assertThat(persistedFlags(rootContainer, ec, cnIndex.getName())).contains(TRUSTED);
+      // Held so that the index opened again below stays untrusted: an index of an empty backend is
+      // trusted when it is opened, whatever its tree holds.
+      addBaseEntry(backend, KEPT, "b907a");
+
+      final int writesBefore = backend.storage.writes();
+      backend.storage.failWithoutReplayOnWrite(3);
+      final ConfigChangeResult ccr = index.applyConfigurationChange(confidentialPresenceIndexCfg());
+
+      assertThat(backend.storage.writes()).as("the armed write was the third, and the last one made")
+          .isEqualTo(writesBefore + 3);
+      assertThat(ccr.getResultCode()).isEqualTo(serverErrorResultCode());
+      assertThat(ccr.adminActionRequired()).as("the rebuild the new setting needs, on the road which failed").isTrue();
+      assertThat(ordinalsOf(ccr)).contains(NOTE_CONFIG_INDEX_CONFIDENTIALITY_REQUIRES_REBUILD.ordinal());
+      assertThat(ccr.getMessages().toString()).as("the failure, next to the rebuild")
+          .contains(UnreplayableFailure.class.getSimpleName());
+      assertThat(persistedFlags(rootContainer, ec, cnIndex.getName()))
+          .as("what the restart will read").contains(TRUSTED);
+
+      final ConfigChangeResult again = index.applyConfigurationChange(confidentialPresenceIndexCfg());
+      assertThat(again.getResultCode()).isEqualTo(ResultCode.SUCCESS);
+      assertThat(again.adminActionRequired())
+          .as("the setting the failed write did not apply is still a change").isTrue();
+      assertThat(ordinalsOf(again)).containsOnly(NOTE_CONFIG_INDEX_CONFIDENTIALITY_REQUIRES_REBUILD.ordinal());
+      assertThat(cnIndex.isTrusted()).isFalse();
+      assertThat(persistedFlags(rootContainer, ec, cnIndex.getName())).doesNotContain(TRUSTED);
+      assertThat(cnIndex.isEncrypted()).as("the setting the tree was opened again under").isTrue();
+    }
+    finally
+    {
+      backend.finalizeBackend();
+    }
+  }
+
+  /**
+   * A give-up of the write which opens the tree again - the fourth, once the third has committed -
+   * must not leave the index believing it already writes under the new setting: what
+   * {@link DefaultIndex#afterOpen} binds is memory, and outlives a write the storage rolled back. A
+   * re-apply which agreed with that binding would answer SUCCESS with no instruction, and the tree
+   * the third write deleted would never be reopened.
+   */
+  @Test
+  public void aConfidentialityChangeIsReportedWhenTheWriteWhichReopensTheTreeGivesUp() throws Exception
+  {
+    final ReplayingBackend backend = openBackendWithPresenceIndex();
+    try
+    {
+      final RootContainer rootContainer = backend.getRootContainer();
+      final EntryContainer ec = rootContainer.getEntryContainer(KEPT);
+      final AttributeIndex index = ec.getAttributeIndex(cnType);
+      final MatchingRuleIndex cnIndex = index.getNameToIndexes().values().iterator().next();
+      assertThat(persistedFlags(rootContainer, ec, cnIndex.getName())).contains(TRUSTED);
+      // Held so that the index opened again below stays untrusted: an index of an empty backend is
+      // trusted when it is opened, whatever its tree holds.
+      addBaseEntry(backend, KEPT, "b907a");
+      assertThat(cnIndex.isEncrypted()).isFalse();
+      final EntryIDSetCodec codecBefore = cnIndex.codec();
+
+      final int writesBefore = backend.storage.writes();
+      backend.storage.failWithoutReplayOnWrite(4);
+      final ConfigChangeResult ccr = index.applyConfigurationChange(confidentialPresenceIndexCfg());
+
+      assertThat(backend.storage.writes()).as("the third write committed, the fourth was armed and given up")
+          .isEqualTo(writesBefore + 4);
+      assertThat(ccr.getResultCode()).isEqualTo(serverErrorResultCode());
+      assertThat(ccr.getMessages().toString()).as("the failure, next to the rebuild")
+          .contains(UnreplayableFailure.class.getSimpleName());
+      assertThat(persistedFlags(rootContainer, ec, cnIndex.getName()))
+          .as("the write which untrusts and deletes committed before the reopen was armed").doesNotContain(TRUSTED);
+      assertThat(cnIndex.isEncrypted())
+          .as("the reopen's commit gave up: the setting the tree is still to be opened under").isFalse();
+      // Nothing decodes through the codec between here and the reopen asked for again, which binds
+      // it afresh: pinned on the invariant DefaultIndex states rather than on a road which turns red.
+      assertThat(cnIndex.codec()).as("the codec the given-up reopen bound is put back").isSameAs(codecBefore);
+
+      // The re-apply must still find this index to give up and open again, not one which already
+      // agrees with the setting the failed reopen never durably reached.
+      final ConfigChangeResult again = index.applyConfigurationChange(confidentialPresenceIndexCfg());
+      assertThat(again.getResultCode()).isEqualTo(ResultCode.SUCCESS);
+      assertThat(again.adminActionRequired())
+          .as("the setting the failed reopen did not apply is still a change").isTrue();
+      assertThat(ordinalsOf(again)).containsOnly(NOTE_CONFIG_INDEX_CONFIDENTIALITY_REQUIRES_REBUILD.ordinal());
+      assertThat(cnIndex.isTrusted()).isFalse();
+      assertThat(persistedFlags(rootContainer, ec, cnIndex.getName())).doesNotContain(TRUSTED);
+      assertThat(cnIndex.isEncrypted()).as("the setting the tree was opened again under").isTrue();
+    }
+    finally
+    {
+      backend.finalizeBackend();
+    }
+  }
+
+  /**
+   * The same give-up over an empty backend, where the reopen trusts the index it opens: that trust
+   * is bound in memory before the write commits, as the codec is, and must be put back with it. Left
+   * behind, it is what a failed operation writes down for each index in its buffer - for a tree the
+   * give-up took with it - while the index skips the entries added meanwhile, which a search after
+   * a restart then never finds.
+   */
+  @Test
+  public void aGivenUpReopenOverAnEmptyBackendDoesNotLeaveTheIndexTrusted() throws Exception
+  {
+    final ReplayingBackend backend = openBackendWithPresenceIndex();
+    try
+    {
+      final RootContainer rootContainer = backend.getRootContainer();
+      final EntryContainer ec = rootContainer.getEntryContainer(KEPT);
+      final AttributeIndex index = ec.getAttributeIndex(cnType);
+      final MatchingRuleIndex cnIndex = index.getNameToIndexes().values().iterator().next();
+      assertThat(persistedFlags(rootContainer, ec, cnIndex.getName())).contains(TRUSTED);
+      assertThat(cnIndex.isTrusted()).isTrue();
+
+      backend.storage.failWithoutReplayOnWrite(4);
+      final ConfigChangeResult ccr = index.applyConfigurationChange(confidentialPresenceIndexCfg());
+
+      assertThat(ccr.getResultCode()).isEqualTo(serverErrorResultCode());
+      assertThat(persistedFlags(rootContainer, ec, cnIndex.getName())).doesNotContain(TRUSTED);
+      assertThat(cnIndex.isTrusted())
+          .as("the reopen which trusted the index over an empty backend gave up").isFalse();
+
+      // The road that memory takes to disk: an operation which fails writes down what each index in
+      // its buffer answers, so that one it found corrupt stays untrusted. An add the container
+      // refuses - no parent - has the cn index in its buffer all the same.
+      assertThatThrownBy(() -> backend.addEntry(
+          TestCaseUtils.makeEntry("dn: cn=user.1," + KEPT, "objectClass: top", "objectClass: person",
+              "cn: user.1", "sn: user.1"),
+          mock(AddOperation.class)))
+          .isInstanceOf(DirectoryException.class)
+          .hasFieldOrPropertyWithValue("resultCode", ResultCode.NO_SUCH_OBJECT);
+      assertThat(persistedFlags(rootContainer, ec, cnIndex.getName()))
+          .as("what the failed add wrote down is what the index answers, not what the give-up bound")
+          .doesNotContain(TRUSTED);
+
+      // Over a backend still empty, the reopen asked for again trusts the index it opens, and commits.
+      final ConfigChangeResult again = index.applyConfigurationChange(confidentialPresenceIndexCfg());
+      assertThat(again.getResultCode()).isEqualTo(ResultCode.SUCCESS);
+      assertThat(ordinalsOf(again)).containsOnly(NOTE_CONFIG_INDEX_CONFIDENTIALITY_REQUIRES_REBUILD.ordinal());
+      assertThat(cnIndex.isEncrypted()).isTrue();
+      assertThat(cnIndex.isTrusted()).as("opened again over an empty backend").isTrue();
+      assertThat(persistedFlags(rootContainer, ec, cnIndex.getName())).contains(TRUSTED);
+    }
+    finally
+    {
+      backend.finalizeBackend();
+    }
+  }
+
+  /**
    * The same road for a vlvIndex: the write which untrusts it is the only one the change makes, and
    * a failure of it is reported with the rebuild the change asked for, rather than thrown out of
    * the listener with that result discarded. The change touches all four published fields at once,
@@ -935,14 +1158,30 @@
   /** The vlvIndex is opened only where a test is about one, since every base DN gets a copy of it. */
   private ReplayingBackend openBackendWithVlvIndex() throws Exception
   {
-    return openBackend(newTreeSet(KEPT), true);
+    return openBackend(newTreeSet(KEPT), true, newTreeSet(IndexType.EQUALITY));
+  }
+
+  /**
+   * A cn index of the presence type, whose tree a change of the confidentiality keeps: the equality
+   * type is replaced by a key hashed twin when it is made confidential, and so is added and removed
+   * rather than kept.
+   */
+  private ReplayingBackend openBackendWithPresenceIndex() throws Exception
+  {
+    return openBackend(newTreeSet(KEPT), false, newTreeSet(IndexType.PRESENCE));
   }
 
   private ReplayingBackend openBackend(SortedSet<DN> baseDNs, boolean withVlvIndex) throws Exception
   {
+    return openBackend(baseDNs, withVlvIndex, newTreeSet(IndexType.EQUALITY));
+  }
+
+  private ReplayingBackend openBackend(SortedSet<DN> baseDNs, boolean withVlvIndex, SortedSet<IndexType> cnIndexTypes)
+      throws Exception
+  {
     final ReplayingBackend backend = new ReplayingBackend();
     backend.setBackendID(BACKEND_ID);
-    backend.configuredWith = backendCfg(baseDNs, withVlvIndex);
+    backend.configuredWith = backendCfg(baseDNs, withVlvIndex, cnIndexTypes);
     backend.configureBackend(backend.configuredWith, serverContext);
     // Start from a pristine on-disk state so that a previous run cannot mask the defect.
     backend.storage.removeStorageFiles();
@@ -987,6 +1226,12 @@
 
   private PDBBackendCfg backendCfg(SortedSet<DN> baseDNs, boolean withVlvIndex) throws ConfigException
   {
+    return backendCfg(baseDNs, withVlvIndex, newTreeSet(IndexType.EQUALITY));
+  }
+
+  private PDBBackendCfg backendCfg(SortedSet<DN> baseDNs, boolean withVlvIndex, SortedSet<IndexType> cnIndexTypes)
+      throws ConfigException
+  {
     final PDBBackendCfg cfg = mockCfg(PDBBackendCfg.class);
     when(cfg.dn()).thenReturn(DN.valueOf("ds-cfg-backend-id=" + BACKEND_ID + ",cn=Backends,cn=config"));
     when(cfg.getBackendId()).thenReturn(BACKEND_ID);
@@ -996,9 +1241,14 @@
     when(cfg.getDBCachePercent()).thenReturn(20);
     when(cfg.getBaseDN()).thenReturn(baseDNs);
     when(cfg.listBackendIndexes()).thenReturn(new String[] { "cn" });
+    // An index can only be confidential in a backend which is, and the cipher of the backend is what
+    // the crypto suite of an index takes its parameters from.
+    when(cfg.isConfidentialityEnabled()).thenReturn(true);
+    when(cfg.getCipherTransformation()).thenReturn("AES/CBC/PKCS5Padding");
+    when(cfg.getCipherKeyLength()).thenReturn(128);
     // Built before it is handed over: stubbing a mock from inside a when() of another mock leaves
     // that when() unfinished, and Mockito fails the next test to touch either of them.
-    final BackendIndexCfg cnIndexCfg = indexCfg(newTreeSet(IndexType.EQUALITY), 4000);
+    final BackendIndexCfg cnIndexCfg = indexCfg(cnIndexTypes, 4000);
     when(cfg.getBackendIndex("cn")).thenReturn(cnIndexCfg);
     if (withVlvIndex)
     {
@@ -1015,11 +1265,23 @@
 
   private BackendIndexCfg indexCfg(SortedSet<IndexType> indexTypes, int indexEntryLimit)
   {
+    return indexCfg(indexTypes, indexEntryLimit, false);
+  }
+
+  /** The configuration which makes the presence index of {@link #openBackendWithPresenceIndex()} confidential. */
+  private BackendIndexCfg confidentialPresenceIndexCfg()
+  {
+    return indexCfg(newTreeSet(IndexType.PRESENCE), 4000, true);
+  }
+
+  private BackendIndexCfg indexCfg(SortedSet<IndexType> indexTypes, int indexEntryLimit, boolean confidentiality)
+  {
     final BackendIndexCfg cfg = mock(BackendIndexCfg.class);
     when(cfg.getIndexType()).thenReturn(indexTypes);
     when(cfg.getAttribute()).thenReturn(cnType);
     when(cfg.getIndexEntryLimit()).thenReturn(indexEntryLimit);
     when(cfg.getSubstringLength()).thenReturn(6);
+    when(cfg.isConfidentialityEnabled()).thenReturn(confidentiality);
     return cfg;
   }
 

--
Gitblit v1.10.0