diff --git a/src/main/java/org/cloudfoundry/security/CloudFoundryContainerTrustManagerFactory.java b/src/main/java/org/cloudfoundry/security/CloudFoundryContainerTrustManagerFactory.java index ad1025d..7369bfd 100644 --- a/src/main/java/org/cloudfoundry/security/CloudFoundryContainerTrustManagerFactory.java +++ b/src/main/java/org/cloudfoundry/security/CloudFoundryContainerTrustManagerFactory.java @@ -32,6 +32,7 @@ import java.security.NoSuchProviderException; import java.util.ArrayList; import java.util.List; +import java.util.logging.Level; import java.util.logging.Logger; abstract class CloudFoundryContainerTrustManagerFactory extends TrustManagerFactorySpi { @@ -44,6 +45,8 @@ abstract class CloudFoundryContainerTrustManagerFactory extends TrustManagerFact private static FileWatchingX509ExtendedTrustManager CACHED_CONTAINER_TRUST_MANAGER; + private static boolean SYSTEM_SUMMARY_LOGGED; + private final Logger logger = Logger.getLogger(this.getClass().getName()); private final String algorithm; @@ -54,6 +57,8 @@ abstract class CloudFoundryContainerTrustManagerFactory extends TrustManagerFact private X509ExtendedTrustManager cachedSystemTrustManager; + private String systemTrustSource; + private CloudFoundryContainerTrustManagerFactory(String algorithm, Path certificates) { this.algorithm = algorithm; this.certificates = certificates; @@ -83,13 +88,15 @@ protected final TrustManager[] engineGetTrustManagers() { @Override protected final void engineInit(ManagerFactoryParameters managerFactoryParameters) throws InvalidAlgorithmParameterException { this.systemTrustManagerFactory.init(managerFactoryParameters); - invalidateSystemTrustManager(); + invalidateSystemTrustManager("ManagerFactoryParameters passed to init"); } @Override protected final void engineInit(KeyStore keyStore) throws KeyStoreException { this.systemTrustManagerFactory.init(keyStore); - invalidateSystemTrustManager(); + // init(null) reads the JVM trust store configuration now, so describe it now rather than when the managers are retrieved. + invalidateSystemTrustManager(keyStore != null ? "KeyStore passed to init" + : TrustStoreDiagnostics.systemTrustStore()); } private static Path getCertificatesLocation() { @@ -113,8 +120,8 @@ private X509ExtendedTrustManager getSystemTrustManager() { if (this.cachedSystemTrustManager == null) { for (TrustManager candidate : this.systemTrustManagerFactory.getTrustManagers()) { if (candidate instanceof X509ExtendedTrustManager) { - this.logger.fine("Adding System Trust Manager"); this.cachedSystemTrustManager = (X509ExtendedTrustManager) candidate; + logSystemSummary(); break; } } @@ -124,6 +131,19 @@ private X509ExtendedTrustManager getSystemTrustManager() { } } + // Logged at INFO once per JVM; factories are created often (e.g. per connection), so later ones log at FINE (see #17). + private void logSystemSummary() { + Level level = SYSTEM_SUMMARY_LOGGED ? Level.FINE : Level.INFO; + SYSTEM_SUMMARY_LOGGED = true; + + this.logger.log(level, TrustStoreDiagnostics.safe(() -> String.format("Adding System TrustManager (%s) with %d trusted certificates", + this.algorithm, this.cachedSystemTrustManager.getAcceptedIssuers().length))); + if (this.systemTrustSource != null) { + this.logger.log(level, () -> String.format("System trust store: %s", this.systemTrustSource)); + } + this.logger.log(level, TrustStoreDiagnostics.safe(() -> String.format("Blocked: %s", TrustStoreDiagnostics.blocked()))); + } + private TrustManagerFactory getTrustManagerFactory() { try { return TrustManagerFactory.getInstance(this.algorithm, "SunJSSE"); @@ -132,9 +152,10 @@ private TrustManagerFactory getTrustManagerFactory() { } } - private void invalidateSystemTrustManager() { + private void invalidateSystemTrustManager(String source) { synchronized (MONITOR) { this.cachedSystemTrustManager = null; + this.systemTrustSource = source; } } diff --git a/src/main/java/org/cloudfoundry/security/FileWatchingX509ExtendedTrustManager.java b/src/main/java/org/cloudfoundry/security/FileWatchingX509ExtendedTrustManager.java index a3e16ef..d4fb54c 100644 --- a/src/main/java/org/cloudfoundry/security/FileWatchingX509ExtendedTrustManager.java +++ b/src/main/java/org/cloudfoundry/security/FileWatchingX509ExtendedTrustManager.java @@ -49,7 +49,7 @@ final class FileWatchingX509ExtendedTrustManager extends X509ExtendedTrustManage new FileWatcher(this.certificates, new FileWatcherCallback()).watch(); if (this.trustManager.compareAndSet(null, getTrustManager(getKeyStore()))) { - this.logger.info(() -> String.format("Initialized TrustManager for %s", this.certificates)); + this.logger.info(TrustStoreDiagnostics.safe(() -> String.format("Initialized TrustManager for %s with %d trusted certificates", this.certificates, this.trustManager.get().getAcceptedIssuers().length))); } } @@ -123,9 +123,9 @@ private class FileWatcherCallback implements Runnable { @Override public void run() { if (FileWatchingX509ExtendedTrustManager.this.trustManager.getAndSet(getTrustManager(getKeyStore())) == null) { - FileWatchingX509ExtendedTrustManager.this.logger.info(() -> String.format("Initialized TrustManager for %s", FileWatchingX509ExtendedTrustManager.this.certificates)); + FileWatchingX509ExtendedTrustManager.this.logger.info(TrustStoreDiagnostics.safe(() -> String.format("Initialized TrustManager for %s with %d trusted certificates", FileWatchingX509ExtendedTrustManager.this.certificates, FileWatchingX509ExtendedTrustManager.this.getAcceptedIssuers().length))); } else { - FileWatchingX509ExtendedTrustManager.this.logger.info(() -> String.format("Updated TrustManager for %s", FileWatchingX509ExtendedTrustManager.this.certificates)); + FileWatchingX509ExtendedTrustManager.this.logger.info(TrustStoreDiagnostics.safe(() -> String.format("Updated TrustManager for %s with %d trusted certificates", FileWatchingX509ExtendedTrustManager.this.certificates, FileWatchingX509ExtendedTrustManager.this.getAcceptedIssuers().length))); } } diff --git a/src/main/java/org/cloudfoundry/security/TrustStoreDiagnostics.java b/src/main/java/org/cloudfoundry/security/TrustStoreDiagnostics.java new file mode 100644 index 0000000..f39d642 --- /dev/null +++ b/src/main/java/org/cloudfoundry/security/TrustStoreDiagnostics.java @@ -0,0 +1,104 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.cloudfoundry.security; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.Security; +import java.util.List; +import java.util.function.Supplier; +import java.util.regex.Pattern; + +/** + * Describes, for logging, which system trust store the JVM uses and which certificates the JDK blocks or distrusts. + */ +final class TrustStoreDiagnostics { + + private static final Pattern FINGERPRINT = Pattern.compile("[0-9A-Fa-f]{40,}=?"); + + private TrustStoreDiagnostics() { + } + + // Reports the trust store properties as set, without interpreting them: the JDK does not expose which store it loaded, and its + // rules (defaults, empty or special values, store types) are internal. The certificate count shows what was actually loaded. + // Returns null when neither property is set, which is the default and needs no logging. + static String systemTrustStore() { + return bestEffort(() -> { + if (System.getProperty("javax.net.ssl.trustStore") == null && System.getProperty("javax.net.ssl.trustStoreType") == null) { + return null; + } + + return String.format("javax.net.ssl.trustStore=%s, javax.net.ssl.trustStoreType=%s", + valueOrNotSet("javax.net.ssl.trustStore"), valueOrNotSet("javax.net.ssl.trustStoreType")); + }); + } + + private static String valueOrNotSet(String property) { + String value = System.getProperty(property); + return value != null ? value : "not set"; + } + + static String blocked() { + return bestEffort(() -> blocked(Paths.get(System.getProperty("java.home", ""), "lib", "security"))); + } + + // Diagnostics must never prevent TLS setup, for example when a SecurityManager denies a property or file read or a path is malformed. + static String bestEffort(Supplier diagnostic) { + try { + return diagnostic.get(); + } catch (SecurityException e) { + return "unavailable (access denied)"; + } catch (RuntimeException e) { + return "unavailable (" + e.getClass().getSimpleName() + ")"; + } + } + + static Supplier safe(Supplier message) { + return () -> bestEffort(message); + } + + static String blocked(Path security) { + String distrust = Security.getProperty("jdk.security.caDistrustPolicies"); + String policies = distrust == null || distrust.isEmpty() ? "none" : distrust; + Integer count = countBlockedCertificates(security); + return count == null + ? String.format("unknown (no readable blocked.certs or blacklisted.certs), caDistrustPolicies=%s", policies) + : String.format("%d blocked certificates, caDistrustPolicies=%s", count, policies); + } + + // Java 8 names the file blacklisted.certs; newer JDKs use blocked.certs. Returns null when neither is readable. + private static Integer countBlockedCertificates(Path security) { + for (String name : new String[]{"blocked.certs", "blacklisted.certs"}) { + try { + List lines = Files.readAllLines(security.resolve(name)); + return (int) lines.stream().filter(TrustStoreDiagnostics::isFingerprintLine).count(); + } catch (IOException e) { + // try the next name + } + } + + return null; + } + + // The blocklist holds one hex fingerprint per line (optionally with an empty "=" value), mixed with comments and blank lines. + private static boolean isFingerprintLine(String line) { + return FINGERPRINT.matcher(line.trim()).matches(); + } + +} diff --git a/src/test/java/org/cloudfoundry/security/CloudFoundryContainerTrustManagerFactoryTest.java b/src/test/java/org/cloudfoundry/security/CloudFoundryContainerTrustManagerFactoryTest.java index 71566c0..450e660 100644 --- a/src/test/java/org/cloudfoundry/security/CloudFoundryContainerTrustManagerFactoryTest.java +++ b/src/test/java/org/cloudfoundry/security/CloudFoundryContainerTrustManagerFactoryTest.java @@ -20,12 +20,18 @@ import javax.net.ssl.TrustManager; import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Path; import java.nio.file.Paths; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.NoSuchProviderException; import java.security.cert.CertificateException; +import java.util.ArrayList; import java.util.List; import java.util.logging.Level; import java.util.logging.LogRecord; @@ -54,15 +60,131 @@ public void defaultTrustManagerForNullCertificatesLocation() throws CertificateE assertThat(((DelegatingX509ExtendedTrustManager) trustManager).size()).isEqualTo(1); } + // Logging the summary at INFO for every factory init (e.g. per new connection) was the problem fixed in #17. @Test - public void logsSystemTrustManagerAtFine() throws Exception { + public void logsSummaryAtInfoOnceThenAtFine() throws Exception { + String store = System.getProperty("javax.net.ssl.trustStore"); + String type = System.getProperty("javax.net.ssl.trustStoreType"); + System.clearProperty("javax.net.ssl.trustStore"); + System.clearProperty("javax.net.ssl.trustStoreType"); + List records; + try { + records = recordSummaryOfTwoInitsInIsolatedClassLoader(); + } finally { + restoreProperty("javax.net.ssl.trustStore", store); + restoreProperty("javax.net.ssl.trustStoreType", type); + } + + // With the default configuration there is no trust store line: only the count and the blocklist. + assertThat(records).extracting(LogRecord::getMessage).noneMatch(m -> m.startsWith("System trust store:")); + assertThat(records).extracting(LogRecord::getLevel).containsExactly(Level.INFO, Level.INFO, Level.FINE, Level.FINE); + } + + @Test + public void systemSummaryContainsCertificateCount() throws Exception { + + assertThat(recordSystemSummary()).extracting(LogRecord::getMessage).anyMatch(m -> m.matches("Adding System TrustManager \\(PKIX\\) with \\d+ trusted certificates")); + } + + @Test + public void logsSuppliedKeyStoreAsSystemTrustStoreSource() throws Exception { List records = LogRecorder.record(CloudFoundryContainerTrustManagerFactory.PKIXFactory.class, () -> { CloudFoundryContainerTrustManagerFactory.PKIXFactory factory = new CloudFoundryContainerTrustManagerFactory.PKIXFactory(null); factory.engineInit(getKeyStore()); factory.engineGetTrustManagers(); }); - assertThat(records).filteredOn(r -> "Adding System Trust Manager".equals(r.getMessage())).extracting(LogRecord::getLevel).containsExactly(Level.FINE); + assertThat(records).extracting(LogRecord::getMessage).contains("System trust store: KeyStore passed to init"); + } + + @Test + public void systemTrustStoreSourceIsCapturedAtInit() throws Exception { + String original = System.getProperty("javax.net.ssl.trustStore"); + String cacerts = Paths.get(System.getProperty("java.home"), "lib", "security", "cacerts").toString(); + + List records = LogRecorder.record(CloudFoundryContainerTrustManagerFactory.PKIXFactory.class, () -> { + CloudFoundryContainerTrustManagerFactory.PKIXFactory factory = new CloudFoundryContainerTrustManagerFactory.PKIXFactory(null); + System.setProperty("javax.net.ssl.trustStore", cacerts); + try { + factory.engineInit((KeyStore) null); + } finally { + restoreProperty("javax.net.ssl.trustStore", original); + } + factory.engineGetTrustManagers(); + }); + + assertThat(records).extracting(LogRecord::getMessage) + .contains("System trust store: javax.net.ssl.trustStore=" + cacerts + ", javax.net.ssl.trustStoreType=not set"); + } + + @Test + public void summaryFailureDoesNotBreakInit() throws Exception { + String javaHome = System.getProperty("java.home"); + System.setProperty("java.home", "bad\0path"); + try { + List records = LogRecorder.record(CloudFoundryContainerTrustManagerFactory.PKIXFactory.class, () -> { + CloudFoundryContainerTrustManagerFactory.PKIXFactory factory = new CloudFoundryContainerTrustManagerFactory.PKIXFactory(null); + factory.engineInit((KeyStore) null); + assertThat(factory.engineGetTrustManagers()).isNotEmpty(); + }); + + assertThat(records).extracting(LogRecord::getMessage).contains("Blocked: unavailable (InvalidPathException)"); + } finally { + System.setProperty("java.home", javaHome); + } + } + + // The once-per-JVM state is static, so a fresh class loader gives every run a clean state without test hooks in the main code. + private List recordSummaryOfTwoInitsInIsolatedClassLoader() throws Exception { + URL classes = Paths.get("target/classes").toUri().toURL(); + try (URLClassLoader isolated = new URLClassLoader(new URL[]{classes}, ClassLoader.getSystemClassLoader().getParent())) { + Class factoryClass = isolated.loadClass(CloudFoundryContainerTrustManagerFactory.PKIXFactory.class.getName()); + Constructor constructor = factoryClass.getDeclaredConstructor(Path.class); + constructor.setAccessible(true); + Method init = factoryClass.getSuperclass().getDeclaredMethod("engineInit", KeyStore.class); + init.setAccessible(true); + Method getTrustManagers = factoryClass.getSuperclass().getDeclaredMethod("engineGetTrustManagers"); + getTrustManagers.setAccessible(true); + + return filterSummary(LogRecorder.record(CloudFoundryContainerTrustManagerFactory.PKIXFactory.class, () -> { + for (int i = 0; i < 2; i++) { + Object factory = constructor.newInstance((Path) null); + init.invoke(factory, new Object[]{null}); + getTrustManagers.invoke(factory); + } + })); + } + } + + private static void restoreProperty(String key, String value) { + if (value == null) { + System.clearProperty(key); + } else { + System.setProperty(key, value); + } + } + + private List recordSystemSummary() throws Exception { + List records = LogRecorder.record(CloudFoundryContainerTrustManagerFactory.PKIXFactory.class, () -> { + CloudFoundryContainerTrustManagerFactory.PKIXFactory factory = new CloudFoundryContainerTrustManagerFactory.PKIXFactory(null); + factory.engineInit(getKeyStore()); + factory.engineGetTrustManagers(); + }); + + return filterSummary(records); + } + + private static List filterSummary(List records) { + List summary = new ArrayList<>(); + for (LogRecord record : records) { + String message = record.getMessage(); + if (message.startsWith("Adding System TrustManager") || message.startsWith("System trust store:") + || message.startsWith("Blocked:")) { + summary.add(record); + } + } + + return summary; } private KeyStore getKeyStore() throws KeyStoreException, CertificateException, NoSuchAlgorithmException, IOException { diff --git a/src/test/java/org/cloudfoundry/security/FileWatchingX509ExtendedTrustManagerTest.java b/src/test/java/org/cloudfoundry/security/FileWatchingX509ExtendedTrustManagerTest.java index 0147a9a..541f2fc 100644 --- a/src/test/java/org/cloudfoundry/security/FileWatchingX509ExtendedTrustManagerTest.java +++ b/src/test/java/org/cloudfoundry/security/FileWatchingX509ExtendedTrustManagerTest.java @@ -25,6 +25,8 @@ import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.logging.LogRecord; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.fail; @@ -42,32 +44,36 @@ public void initializedWithWatchedFile() throws IOException, NoSuchAlgorithmExce } @Test - public void watchesWatchedFile() throws IOException, InterruptedException, NoSuchAlgorithmException { + public void watchesWatchedFile() throws Exception { Path watchedCertificates = getWatchedFile(); Files.copy(Paths.get("src/test/resources/server-certificates-48.pem"), watchedCertificates); + String expectedMessage = String.format("Updated TrustManager for %s with 173 trusted certificates", watchedCertificates); - FileWatchingX509ExtendedTrustManager trustManager = new FileWatchingX509ExtendedTrustManager(watchedCertificates, TrustManagerFactory.getInstance("PKIX")); + List records = LogRecorder.record(FileWatchingX509ExtendedTrustManager.class, (List live) -> { + FileWatchingX509ExtendedTrustManager trustManager = new FileWatchingX509ExtendedTrustManager(watchedCertificates, TrustManagerFactory.getInstance("PKIX")); - assertThat(trustManager.getAcceptedIssuers()).hasSize(48); + assertThat(trustManager.getAcceptedIssuers()).hasSize(48); - Thread.sleep(5_000); - // Replace atomically, as a rename, so no update sees a half-written file. - Path replacement = Files.createTempFile(watchedCertificates.getParent(), "certificates-", ".tmp"); - Files.copy(Paths.get("src/test/resources/server-certificates-173.pem"), replacement, StandardCopyOption.REPLACE_EXISTING); - Files.move(replacement, watchedCertificates, StandardCopyOption.ATOMIC_MOVE); + Thread.sleep(5_000); + // Replace atomically, as a rename, so no update sees a half-written file. + Path replacement = Files.createTempFile(watchedCertificates.getParent(), "certificates-", ".tmp"); + Files.copy(Paths.get("src/test/resources/server-certificates-173.pem"), replacement, StandardCopyOption.REPLACE_EXISTING); + Files.move(replacement, watchedCertificates, StandardCopyOption.ATOMIC_MOVE); - long timeout = System.currentTimeMillis() + 30_000; - for (; ; ) { - if (System.currentTimeMillis() > timeout) { - fail("Failed to update within timeout"); - } + // The summary is logged asynchronously, after the manager has been updated. + long timeout = System.currentTimeMillis() + 30_000; + while (live.stream().noneMatch(record -> expectedMessage.equals(record.getMessage()))) { + if (System.currentTimeMillis() > timeout) { + fail("Failed to update within timeout"); + } - if (trustManager.getAcceptedIssuers().length == 173) { - return; + Thread.sleep(1_000); } - Thread.sleep(1_000); - } + assertThat(trustManager.getAcceptedIssuers()).hasSize(173); + }); + + assertThat(records).extracting(LogRecord::getMessage).contains(expectedMessage); } private Path getWatchedFile() throws IOException { diff --git a/src/test/java/org/cloudfoundry/security/LogRecorder.java b/src/test/java/org/cloudfoundry/security/LogRecorder.java index af13fae..032a9d8 100644 --- a/src/test/java/org/cloudfoundry/security/LogRecorder.java +++ b/src/test/java/org/cloudfoundry/security/LogRecorder.java @@ -16,8 +16,8 @@ package org.cloudfoundry.security; -import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.logging.Handler; import java.util.logging.Level; import java.util.logging.LogRecord; @@ -30,8 +30,13 @@ private LogRecorder() { // Records everything logged to the logger named after the given class while the action runs, at all levels. static List record(Class loggerClass, Action action) throws Exception { + return record(loggerClass, records -> action.run()); + } + + // Like record(Class, Action), but hands the live record list to the action so it can wait for asynchronous log output. + static List record(Class loggerClass, RecordingAction action) throws Exception { Logger logger = Logger.getLogger(loggerClass.getName()); - List records = new ArrayList<>(); + List records = new CopyOnWriteArrayList<>(); Handler handler = new Handler() { @Override @@ -53,7 +58,7 @@ public void close() { logger.addHandler(handler); logger.setLevel(Level.ALL); try { - action.run(); + action.run(records); } finally { logger.removeHandler(handler); logger.setLevel(level); @@ -62,6 +67,12 @@ public void close() { return records; } + interface RecordingAction { + + void run(List records) throws Exception; + + } + interface Action { void run() throws Exception; diff --git a/src/test/java/org/cloudfoundry/security/TrustStoreDiagnosticsTest.java b/src/test/java/org/cloudfoundry/security/TrustStoreDiagnosticsTest.java new file mode 100644 index 0000000..f578e61 --- /dev/null +++ b/src/test/java/org/cloudfoundry/security/TrustStoreDiagnosticsTest.java @@ -0,0 +1,152 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.cloudfoundry.security; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +public class TrustStoreDiagnosticsTest { + + private static final String FINGERPRINT_1 = "03DB9E5E79FE6117177F81C11595AF598CB176AF766290DBCEB2C318B32E39A2"; + + private static final String FINGERPRINT_2 = "08C396C006A21055D00826A5781A5CCFCE2C8D053AB3C197637A4A7A5BB9A650"; + + private static final String FINGERPRINT_3 = "14E6D2764A4B06701C6CBC376A253775F79C782FBCB6C0EE6F99DE4BA102B9C0"; + + @Rule + public final TemporaryFolder securityDirectory = new TemporaryFolder(); + + private String originalTrustStore; + + private String originalTrustStoreType; + + @Before + public void saveTrustStoreProperties() { + this.originalTrustStore = System.getProperty("javax.net.ssl.trustStore"); + this.originalTrustStoreType = System.getProperty("javax.net.ssl.trustStoreType"); + System.clearProperty("javax.net.ssl.trustStore"); + System.clearProperty("javax.net.ssl.trustStoreType"); + } + + @After + public void restoreTrustStoreProperties() { + restore("javax.net.ssl.trustStore", this.originalTrustStore); + restore("javax.net.ssl.trustStoreType", this.originalTrustStoreType); + } + + @Test + public void systemTrustStoreReportsConfiguredPropertyAndType() { + System.setProperty("javax.net.ssl.trustStore", "/custom/store.p12"); + System.setProperty("javax.net.ssl.trustStoreType", "PKCS12"); + + assertThat(TrustStoreDiagnostics.systemTrustStore()) + .isEqualTo("javax.net.ssl.trustStore=/custom/store.p12, javax.net.ssl.trustStoreType=PKCS12"); + } + + @Test + public void systemTrustStoreReportsValuesAsSetWithoutInterpreting() { + System.setProperty("javax.net.ssl.trustStore", "NONE"); + + assertThat(TrustStoreDiagnostics.systemTrustStore()) + .isEqualTo("javax.net.ssl.trustStore=NONE, javax.net.ssl.trustStoreType=not set"); + } + + @Test + public void systemTrustStoreReportsTypeEvenWhenStoreIsUnset() { + System.setProperty("javax.net.ssl.trustStoreType", "PKCS11"); + + assertThat(TrustStoreDiagnostics.systemTrustStore()) + .isEqualTo("javax.net.ssl.trustStore=not set, javax.net.ssl.trustStoreType=PKCS11"); + } + + @Test + public void systemTrustStoreIsNullWhenDefault() { + assertThat(TrustStoreDiagnostics.systemTrustStore()).isNull(); + } + + @Test + public void blockedCountsFingerprintsAndIgnoresCommentsAndBlankLines() throws IOException { + writeBlocklist("blocked.certs", "# comment", "", FINGERPRINT_1, " " + FINGERPRINT_2 + " ", FINGERPRINT_3 + "="); + + assertThat(TrustStoreDiagnostics.blocked(this.securityDirectory.getRoot().toPath())) + .startsWith("3 blocked certificates, caDistrustPolicies="); + } + + @Test + public void blockedFallsBackToLegacyJava8FileName() throws IOException { + writeBlocklist("blacklisted.certs", "# comment", FINGERPRINT_1); + + assertThat(TrustStoreDiagnostics.blocked(this.securityDirectory.getRoot().toPath())) + .startsWith("1 blocked certificates, caDistrustPolicies="); + } + + @Test + public void blockedIsUnknownWhenNoBlocklistIsReadable() { + assertThat(TrustStoreDiagnostics.blocked(this.securityDirectory.getRoot().toPath())) + .startsWith("unknown (no readable blocked.certs or blacklisted.certs), caDistrustPolicies="); + } + + @Test + public void bestEffortReturnsDiagnostic() { + assertThat(TrustStoreDiagnostics.bestEffort(() -> "diagnostic")).isEqualTo("diagnostic"); + } + + @Test + public void bestEffortReportsUnavailableWhenAccessIsDenied() { + assertThat(TrustStoreDiagnostics.bestEffort(() -> { + throw new SecurityException("denied"); + })).isEqualTo("unavailable (access denied)"); + } + + @Test + public void bestEffortReportsUnavailableForUnexpectedFailure() { + assertThat(TrustStoreDiagnostics.bestEffort(() -> { + throw new IllegalStateException("boom"); + })).isEqualTo("unavailable (IllegalStateException)"); + } + + @Test + public void safeSupplierNeverThrows() { + assertThat(TrustStoreDiagnostics.safe(() -> { + throw new IllegalArgumentException("bad path"); + }).get()).isEqualTo("unavailable (IllegalArgumentException)"); + } + + private static void restore(String key, String value) { + if (value == null) { + System.clearProperty(key); + } else { + System.setProperty(key, value); + } + } + + private void writeBlocklist(String name, String... lines) throws IOException { + File blocklist = new File(this.securityDirectory.getRoot(), name); + Files.write(blocklist.toPath(), Arrays.asList(lines)); + } + +}