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