From f93487455cc1b5d7062ea6577d99dea25bfd81b3 Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Wed, 14 Oct 2026 07:27:37 +0000
Subject: [PATCH] [#1188] Serialize and replace atomically the writes of the ads-truststore, and report a certificate it cannot take or drop (#1189)

---
 opendj-server-legacy/src/test/java/org/opends/server/util/CertificateManagerTestCase.java |  398 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 398 insertions(+), 0 deletions(-)

diff --git a/opendj-server-legacy/src/test/java/org/opends/server/util/CertificateManagerTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/util/CertificateManagerTestCase.java
index 774d735..c982f90 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/util/CertificateManagerTestCase.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/util/CertificateManagerTestCase.java
@@ -22,11 +22,26 @@
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.FileOutputStream;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFileAttributeView;
+import java.nio.file.attribute.PosixFilePermissions;
 import java.security.KeyStore;
 import java.security.KeyStoreException;
 import java.security.cert.Certificate;
 import java.util.Arrays;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
 
+import com.sun.security.auth.module.UnixSystem;
+
+import org.testng.SkipException;
 import org.testng.annotations.BeforeClass;
 import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
@@ -1416,6 +1431,389 @@
 
 
   /**
+   * Tests that a reader of a key store file never finds it half written while it is rewritten:
+   * the trust store of the server is read from its file for each SSL context, and at the next
+   * start, by readers which do not share any lock with the writer, and a server stopped part way
+   * through a write must not be left with a truncated file it cannot start with (#1188). On a
+   * POSIX file system only: elsewhere (Windows) the key store is written in place.
+   *
+   * @throws  Exception  If a problem occurs.
+   */
+  @Test
+  public void testKeyStoreFileIsNeverSeenHalfWritten()
+         throws Exception
+  {
+    skipUnlessPosix();
+    final File tmpDir = TestCaseUtils.createTemporaryDirectory("halfWritten");
+    final ExecutorService executor = Executors.newSingleThreadExecutor();
+    final AtomicBoolean writing = new AtomicBoolean(true);
+    try
+    {
+      final Certificate certificate = new CertificateFixture("CN=Example CA,O=Example").getCaCertificate();
+      final File destination = new File(tmpDir, "ads-truststore");
+      final CertificateManager manager =
+           new CertificateManager(destination.getAbsolutePath(), "JKS", "password");
+      manager.addTrustedCertificate("cert-0", certificate);
+
+      final CountDownLatch started = new CountDownLatch(1);
+      final AtomicInteger loads = new AtomicInteger();
+      final Future<String> reads = executor.submit(() -> {
+        final KeyStore keyStore = KeyStore.getInstance("JKS");
+        started.countDown();
+        while (writing.get())
+        {
+          try (FileInputStream in = new FileInputStream(destination))
+          {
+            keyStore.load(in, "password".toCharArray());
+            loads.incrementAndGet();
+          }
+          catch (Exception e)
+          {
+            return e.toString();
+          }
+        }
+        return null;
+      });
+      assertTrue(started.await(60, TimeUnit.SECONDS), "the reader started");
+      for (int i = 1; i <= 200; i++)
+      {
+        manager.addTrustedCertificate("cert-" + i, certificate);
+      }
+      writing.set(false);
+      assertNull(reads.get(60, TimeUnit.SECONDS), "a read of the key store while it was rewritten");
+      assertTrue(loads.get() > 0, "the reader loaded the key store while it was rewritten");
+    }
+    finally
+    {
+      writing.set(false);
+      executor.shutdownNow();
+      executor.awaitTermination(60, TimeUnit.SECONDS);
+      TestCaseUtils.deleteDirectory(tmpDir);
+    }
+  }
+
+
+
+  /**
+   * Tests that the rewrite of a key store keeps the file it replaces as it was: its permissions,
+   * which the file it is written to first does not have, and the link the key store path may be,
+   * which must go on pointing where the administrator put the key store.
+   *
+   * @throws  Exception  If a problem occurs.
+   */
+  @Test
+  public void testKeyStoreRewriteKeepsPermissionsAndLink()
+         throws Exception
+  {
+    skipUnlessPosix();
+    final File tmpDir = TestCaseUtils.createTemporaryDirectory("rewriteKeeps");
+    try
+    {
+      final Certificate certificate = new CertificateFixture("CN=Example CA,O=Example").getCaCertificate();
+      final Path store = Files.createDirectory(tmpDir.toPath().resolve("store"));
+      final Path target = store.resolve("ads-truststore");
+      new CertificateManager(target.toString(), "JKS", "password").addTrustedCertificate("cert-0", certificate);
+      Files.setPosixFilePermissions(target, PosixFilePermissions.fromString("rw-r-----"));
+      final Path link = Files.createSymbolicLink(tmpDir.toPath().resolve("ads-truststore"), target);
+
+      new CertificateManager(link.toString(), "JKS", "password").addTrustedCertificate("cert-1", certificate);
+
+      assertTrue(Files.isSymbolicLink(link), "the key store path is a link still");
+      assertEquals(PosixFilePermissions.toString(Files.getPosixFilePermissions(target)), "rw-r-----");
+      assertTrue(loadJks(target.toFile(), "password").containsAlias("cert-1"), "the key store the link points to");
+    }
+    finally
+    {
+      // The link first: once the file it points to is gone, it no longer exists for deleteDirectory.
+      Files.deleteIfExists(tmpDir.toPath().resolve("ads-truststore"));
+      TestCaseUtils.deleteDirectory(tmpDir);
+    }
+  }
+
+
+
+  /**
+   * Tests that a key store in a directory this process cannot write to, as one mounted on its own
+   * into a container, is still rewritten: in place, there being nowhere else to write it.
+   *
+   * @throws  Exception  If a problem occurs.
+   */
+  @Test
+  public void testKeyStoreInADirectoryNotWritableIsRewrittenInPlace()
+         throws Exception
+  {
+    skipUnlessPosix();
+    final File tmpDir = TestCaseUtils.createTemporaryDirectory("rewriteInPlace");
+    final Path store = tmpDir.toPath().resolve("store");
+    try
+    {
+      final Certificate certificate = new CertificateFixture("CN=Example CA,O=Example").getCaCertificate();
+      Files.createDirectory(store);
+      final Path target = store.resolve("ads-truststore");
+      final CertificateManager manager = new CertificateManager(target.toString(), "JKS", "password");
+      manager.addTrustedCertificate("cert-0", certificate);
+      Files.setPosixFilePermissions(store, PosixFilePermissions.fromString("r-xr-xr-x"));
+      if (Files.isWritable(store))
+      {
+        throw new SkipException("this process writes to a directory whatever its permissions");
+      }
+
+      manager.addTrustedCertificate("cert-1", certificate);
+
+      assertTrue(loadJks(target.toFile(), "password").containsAlias("cert-1"), "the key store");
+    }
+    finally
+    {
+      if (Files.exists(store))
+      {
+        Files.setPosixFilePermissions(store, PosixFilePermissions.fromString("rwxr-xr-x"));
+      }
+      TestCaseUtils.deleteDirectory(tmpDir);
+    }
+  }
+
+
+
+  /**
+   * Tests that the rewrite of a key store keeps its group, a group of this process other than the
+   * one a new file gets, and still replaces the file at once rather than writing it in place.
+   *
+   * @throws  Exception  If a problem occurs.
+   */
+  @Test
+  public void testKeyStoreRewriteKeepsTheGroup()
+         throws Exception
+  {
+    skipUnlessPosix();
+    final File tmpDir = TestCaseUtils.createTemporaryDirectory("rewriteKeepsGroup");
+    try
+    {
+      final Certificate certificate = new CertificateFixture("CN=Example CA,O=Example").getCaCertificate();
+      final Path target = tmpDir.toPath().resolve("ads-truststore");
+      new CertificateManager(target.toString(), "JKS", "password").addTrustedCertificate("cert-0", certificate);
+      final long group = moveToAnotherGroup(target);
+      final Object file = Files.getAttribute(target, "unix:ino");
+
+      new CertificateManager(target.toString(), "JKS", "password").addTrustedCertificate("cert-1", certificate);
+
+      assertEquals(((Integer) Files.getAttribute(target, "unix:gid")).longValue(), group,
+          "the group of the key store");
+      assertNotEquals(Files.getAttribute(target, "unix:ino"), file, "the key store replaced, not written in place");
+      assertTrue(loadJks(target.toFile(), "password").containsAlias("cert-1"), "the key store");
+    }
+    finally
+    {
+      TestCaseUtils.deleteDirectory(tmpDir);
+    }
+  }
+
+
+
+  @DataProvider
+  public Object[][] groupNotTaken()
+  {
+    return new Object[][] { { "ignored" }, { "refused" } };
+  }
+
+  /**
+   * Tests that a key store whose group the new file cannot take - on a file system which ignores
+   * a change of group, or for a group this process is not a member of - is rewritten in place,
+   * where it keeps its group, rather than replaced by a file of another group.
+   *
+   * @param  how  How the group is not taken.
+   *
+   * @throws  Exception  If a problem occurs.
+   */
+  @Test(dataProvider = "groupNotTaken")
+  public void testKeyStoreWhoseGroupCannotBeTakenIsRewrittenInPlace(String how)
+         throws Exception
+  {
+    skipUnlessPosix();
+    final File tmpDir = TestCaseUtils.createTemporaryDirectory("rewriteGroupNotTaken");
+    try
+    {
+      final Certificate certificate = new CertificateFixture("CN=Example CA,O=Example").getCaCertificate();
+      final Path target = tmpDir.toPath().resolve("ads-truststore");
+      new CertificateManager(target.toString(), "JKS", "password").addTrustedCertificate("cert-0", certificate);
+      final long group = moveToAnotherGroup(target);
+      final Object file = Files.getAttribute(target, "unix:ino");
+
+      try (AutoCloseable notTaken = "refused".equals(how)
+          ? KeyStoreReplaceFailure.refuseKeyStoreGroup()
+          : KeyStoreReplaceFailure.ignoreKeyStoreGroup())
+      {
+        new CertificateManager(target.toString(), "JKS", "password").addTrustedCertificate("cert-1", certificate);
+      }
+
+      assertEquals(((Integer) Files.getAttribute(target, "unix:gid")).longValue(), group,
+          "the group of the key store");
+      assertEquals(Files.getAttribute(target, "unix:ino"), file, "the key store written in place, not replaced");
+      assertTrue(loadJks(target.toFile(), "password").containsAlias("cert-1"), "the key store");
+      assertEquals(tmpDir.list(), new String[] { "ads-truststore" }, "the files next to the key store");
+    }
+    finally
+    {
+      TestCaseUtils.deleteDirectory(tmpDir);
+    }
+  }
+
+
+
+  /**
+   * Gives a file a group of this process other than the one a new file gets.
+   *
+   * @param  target  The file.
+   *
+   * @return  the group given
+   */
+  private static long moveToAnotherGroup(Path target) throws Exception
+  {
+    final long newFileGroup = ((Integer) Files.getAttribute(target, "unix:gid")).longValue();
+    final UnixSystem process = new UnixSystem();
+    long group = process.getGid();
+    for (long g : process.getGroups())
+    {
+      if (group == newFileGroup)
+      {
+        group = g;
+      }
+    }
+    if (group == newFileGroup)
+    {
+      throw new SkipException("this process is a member of no group but the one a new file gets");
+    }
+    Files.getFileAttributeView(target, PosixFileAttributeView.class).setGroup(FileSystems.getDefault()
+        .getUserPrincipalLookupService().lookupPrincipalByGroupName(String.valueOf(group)));
+    return group;
+  }
+
+
+
+  /**
+   * Tests that a key store file with another hard link is rewritten in place: a new file in its
+   * place would leave the other name with the old key store.
+   *
+   * @throws  Exception  If a problem occurs.
+   */
+  @Test
+  public void testKeyStoreWithAnotherHardLinkIsRewrittenInPlace()
+         throws Exception
+  {
+    skipUnlessPosix();
+    final File tmpDir = TestCaseUtils.createTemporaryDirectory("rewriteHardLink");
+    try
+    {
+      final Certificate certificate = new CertificateFixture("CN=Example CA,O=Example").getCaCertificate();
+      final Path target = tmpDir.toPath().resolve("ads-truststore");
+      new CertificateManager(target.toString(), "JKS", "password").addTrustedCertificate("cert-0", certificate);
+      final Path other = Files.createLink(tmpDir.toPath().resolve("ads-truststore.link"), target);
+
+      new CertificateManager(target.toString(), "JKS", "password").addTrustedCertificate("cert-1", certificate);
+
+      assertTrue(loadJks(other.toFile(), "password").containsAlias("cert-1"), "the key store by its other name");
+    }
+    finally
+    {
+      TestCaseUtils.deleteDirectory(tmpDir);
+    }
+  }
+
+
+
+  /**
+   * Tests that a key store file this process may not write to is not rewritten, even in a
+   * directory it may write to, where the file could be replaced.
+   *
+   * @throws  Exception  If a problem occurs.
+   */
+  @Test
+  public void testKeyStoreNotWritableIsRefused()
+         throws Exception
+  {
+    skipUnlessPosix();
+    final File tmpDir = TestCaseUtils.createTemporaryDirectory("rewriteRefused");
+    try
+    {
+      final Certificate certificate = new CertificateFixture("CN=Example CA,O=Example").getCaCertificate();
+      final Path target = tmpDir.toPath().resolve("ads-truststore");
+      final CertificateManager manager = new CertificateManager(target.toString(), "JKS", "password");
+      manager.addTrustedCertificate("cert-0", certificate);
+      Files.setPosixFilePermissions(target, PosixFilePermissions.fromString("r--r--r--"));
+      if (Files.isWritable(target))
+      {
+        throw new SkipException("this process writes to a file whatever its permissions");
+      }
+
+      assertThrows(KeyStoreException.class, () -> manager.addTrustedCertificate("cert-1", certificate));
+
+      assertFalse(loadJks(target.toFile(), "password").containsAlias("cert-1"), "the key store");
+      assertEquals(PosixFilePermissions.toString(Files.getPosixFilePermissions(target)), "r--r--r--");
+    }
+    finally
+    {
+      TestCaseUtils.deleteDirectory(tmpDir);
+    }
+  }
+
+
+
+  /**
+   * Tests that a key store which cannot be replaced at once, as a file mounted on its own into a
+   * container (EBUSY, EXDEV), is still rewritten: in place.
+   *
+   * @throws  Exception  If a problem occurs.
+   */
+  @Test
+  public void testKeyStoreWhichCannotBeReplacedIsRewrittenInPlace()
+         throws Exception
+  {
+    final File tmpDir = TestCaseUtils.createTemporaryDirectory("rewriteNotReplaced");
+    try
+    {
+      final Certificate certificate = new CertificateFixture("CN=Example CA,O=Example").getCaCertificate();
+      final File target = new File(tmpDir, "ads-truststore");
+      final CertificateManager manager = new CertificateManager(target.getAbsolutePath(), "JKS", "password");
+      manager.addTrustedCertificate("cert-0", certificate);
+
+      try (AutoCloseable inPlace = KeyStoreReplaceFailure.failKeyStoreReplace())
+      {
+        manager.addTrustedCertificate("cert-1", certificate);
+      }
+
+      assertTrue(loadJks(target, "password").containsAlias("cert-1"), "the key store");
+      assertEquals(tmpDir.list(), new String[] { "ads-truststore" }, "the files next to the key store");
+    }
+    finally
+    {
+      TestCaseUtils.deleteDirectory(tmpDir);
+    }
+  }
+
+
+
+  private static void skipUnlessPosix()
+  {
+    if (!FileSystems.getDefault().supportedFileAttributeViews().contains("posix"))
+    {
+      throw new SkipException("POSIX file permissions and links are not available here");
+    }
+  }
+
+
+
+  private static KeyStore loadJks(File file, String password) throws Exception
+  {
+    final KeyStore keyStore = KeyStore.getInstance("JKS");
+    try (FileInputStream in = new FileInputStream(file))
+    {
+      keyStore.load(in, password.toCharArray());
+    }
+    return keyStore;
+  }
+
+
+
+  /**
    * Exports a certificate to a temporary file.
    *
    * @throws  Exception  If a problem occurs.

--
Gitblit v1.10.0