Skip to content

Commit d22f07d

Browse files
committed
Improve SSL related error messages
1 parent aef5b3d commit d22f07d

File tree

8 files changed

+160
-27
lines changed

8 files changed

+160
-27
lines changed

pkl-cli/src/test/kotlin/org/pkl/cli/CliEvaluatorTest.kt

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,13 @@ import java.net.URI
2121
import java.nio.file.Files
2222
import java.nio.file.Path
2323
import java.time.Duration
24-
import kotlin.io.path.createDirectories
25-
import kotlin.io.path.listDirectoryEntries
24+
import kotlin.io.path.*
2625
import org.assertj.core.api.Assertions.assertThat
2726
import org.assertj.core.api.Assertions.assertThatCode
2827
import org.junit.jupiter.api.AfterEach
29-
import org.junit.jupiter.api.Disabled
3028
import org.junit.jupiter.api.Test
3129
import org.junit.jupiter.api.assertThrows
30+
import org.junit.jupiter.api.io.TempDir
3231
import org.junit.jupiter.params.ParameterizedTest
3332
import org.junit.jupiter.params.provider.EnumSource
3433
import org.pkl.commons.*
@@ -1158,9 +1157,45 @@ result = someLib.x
11581157
}
11591158

11601159
@Test
1161-
@Disabled("flaky because CliEvaluator falls back to ~/.pkl/cacerts if no certs are given")
1162-
fun `not including the self signed certificate will result in a error`() {
1160+
fun `gives decent error message if certificate file contains random text`() {
1161+
val certsFile = tempDir.writeFile("random.pem", "RANDOM")
1162+
val err = assertThrows<CliException> { evalModuleThatImportsPackage(certsFile) }
1163+
assertThat(err)
1164+
.hasMessageContaining("Error parsing CA certificate file `${certsFile.pathString}`:")
1165+
.hasMessageContaining("No certificate data found")
1166+
.hasMessageNotContainingAny("java.", "sun.") // class names have been filtered out
1167+
}
1168+
1169+
@Test
1170+
fun `gives decent error message if certificate file is emtpy`(@TempDir tempDir: Path) {
1171+
val emptyCerts = tempDir.writeEmptyFile("empty.pem")
1172+
val err = assertThrows<CliException> { evalModuleThatImportsPackage(emptyCerts) }
1173+
assertThat(err).hasMessageContaining("CA certificate file `${emptyCerts.pathString}` is empty.")
1174+
}
1175+
1176+
@Test
1177+
fun `gives decent error message if certificate cannot be parsed`(@TempDir tempDir: Path) {
1178+
val invalidCerts = FileTestUtils.writeCertificateWithMissingLines(tempDir)
1179+
val err = assertThrows<CliException> { evalModuleThatImportsPackage(invalidCerts) }
1180+
assertThat(err)
1181+
.hasMessageContaining("Error parsing CA certificate file `${invalidCerts.pathString}`:")
1182+
.hasMessageContaining("not enough content")
1183+
.hasMessageNotContainingAny("java.", "sun.") // class names have been filtered out
1184+
}
1185+
1186+
@Test
1187+
fun `gives decent error message if CLI doesn't have the required CA certificate`() {
11631188
PackageServer.ensureStarted()
1189+
// provide SOME certs to prevent CliEvaluator from falling back to ~/.pkl/cacerts
1190+
val builtInCerts = FileTestUtils.writePklBuiltInCertificates(tempDir)
1191+
val err = assertThrows<CliException> { evalModuleThatImportsPackage(builtInCerts) }
1192+
assertThat(err)
1193+
.hasMessageContaining("Error during SSL handshake with host `localhost`:")
1194+
.hasMessageContaining("unable to find valid certification path to requested target")
1195+
.hasMessageNotContainingAny("java.", "sun.") // class names have been filtered out
1196+
}
1197+
1198+
private fun evalModuleThatImportsPackage(certsFile: Path) {
11641199
val moduleUri =
11651200
writePklFile(
11661201
"test.pkl",
@@ -1169,20 +1204,17 @@ result = someLib.x
11691204
11701205
res = Swallow
11711206
"""
1172-
.trimIndent()
11731207
)
1174-
val buffer = StringWriter()
1208+
11751209
val options =
11761210
CliEvaluatorOptions(
11771211
CliBaseOptions(
11781212
sourceModules = listOf(moduleUri),
1179-
workingDir = tempDir,
1180-
moduleCacheDir = tempDir,
1213+
caCertificates = listOf(certsFile),
11811214
noCache = true
11821215
),
11831216
)
1184-
val err = assertThrows<CliException> { CliEvaluator(options, consoleWriter = buffer).run() }
1185-
assertThat(err.message).contains("unable to find valid certification path to requested target")
1217+
CliEvaluator(options).run()
11861218
}
11871219

11881220
private fun writePklFile(fileName: String, contents: String = defaultContents): URI {

pkl-commons-test/src/main/kotlin/org/pkl/commons/test/FileTestUtils.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ package org.pkl.commons.test
1717

1818
import java.nio.file.Path
1919
import kotlin.io.path.*
20-
import kotlin.streams.toList
2120
import org.assertj.core.api.Assertions.fail
2221
import org.pkl.commons.*
2322

@@ -32,6 +31,17 @@ object FileTestUtils {
3231
val selfSignedCertificate: Path by lazy {
3332
rootProjectDir.resolve("pkl-commons-test/build/keystore/localhost.pem")
3433
}
34+
35+
fun writeCertificateWithMissingLines(dir: Path): Path {
36+
val lines = selfSignedCertificate.readLines()
37+
// drop some lines in the middle
38+
return dir.resolve("invalidCerts.pem").writeLines(lines.take(5) + lines.takeLast(5))
39+
}
40+
41+
fun writePklBuiltInCertificates(dir: Path): Path {
42+
val text = javaClass.getResource("/org/pkl/core/http/IncludedCARoots.pem")!!.readText()
43+
return dir.resolve("IncludedCARoots.pem").apply { writeText(text) }
44+
}
3545
}
3646

3747
fun Path.listFilesRecursively(): List<Path> =

pkl-core/src/main/java/org/pkl/core/http/JdkHttpClient.java

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,10 @@
4646
import javax.annotation.concurrent.ThreadSafe;
4747
import javax.net.ssl.CertPathTrustManagerParameters;
4848
import javax.net.ssl.SSLContext;
49+
import javax.net.ssl.SSLHandshakeException;
4950
import javax.net.ssl.TrustManagerFactory;
5051
import org.pkl.core.util.ErrorMessages;
52+
import org.pkl.core.util.ExceptionUtils;
5153

5254
@ThreadSafe
5355
final class JdkHttpClient implements HttpClient {
@@ -89,6 +91,10 @@ public <T> HttpResponse<T> send(HttpRequest request, BodyHandler<T> responseBody
8991
// original exception has no message
9092
throw new ConnectException(
9193
ErrorMessages.create("errorConnectingToHost", request.uri().getHost()));
94+
} catch (SSLHandshakeException e) {
95+
throw new SSLHandshakeException(
96+
ErrorMessages.create(
97+
"errorSslHandshake", request.uri().getHost(), ExceptionUtils.getRootReason(e)));
9298
} catch (InterruptedException e) {
9399
// next best thing after letting (checked) InterruptedException bubble up
94100
Thread.currentThread().interrupt();
@@ -134,7 +140,7 @@ private static SSLContext createSslContext(
134140
return sslContext;
135141
} catch (GeneralSecurityException e) {
136142
throw new HttpClientInitException(
137-
ErrorMessages.create("cannotInitHttpClient", e.getMessage()), e);
143+
ErrorMessages.create("cannotInitHttpClient", ExceptionUtils.getRootReason(e)), e);
138144
}
139145
}
140146

@@ -149,7 +155,7 @@ private static Set<TrustAnchor> createTrustAnchors(
149155
throw new HttpClientInitException(ErrorMessages.create("cannotFindCertFile", file));
150156
} catch (IOException e) {
151157
throw new HttpClientInitException(
152-
ErrorMessages.create("cannotReadCertFile", e.getMessage()));
158+
ErrorMessages.create("cannotReadCertFile", ExceptionUtils.getRootReason(e)));
153159
}
154160
}
155161

@@ -158,7 +164,7 @@ private static Set<TrustAnchor> createTrustAnchors(
158164
collectTrustAnchors(anchors, factory, stream, url);
159165
} catch (IOException e) {
160166
throw new HttpClientInitException(
161-
ErrorMessages.create("cannotReadCertFile", e.getMessage()));
167+
ErrorMessages.create("cannotReadCertFile", ExceptionUtils.getRootReason(e)));
162168
}
163169
}
164170

@@ -176,7 +182,7 @@ private static void collectTrustAnchors(
176182
certificates = (Collection<X509Certificate>) factory.generateCertificates(stream);
177183
} catch (CertificateException e) {
178184
throw new HttpClientInitException(
179-
ErrorMessages.create("cannotParseCertFile", source, e.getMessage()));
185+
ErrorMessages.create("cannotParseCertFile", source, ExceptionUtils.getRootReason(e)));
180186
}
181187
if (certificates.isEmpty()) {
182188
throw new HttpClientInitException(ErrorMessages.create("emptyCertFile", source));
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.pkl.core.util;
17+
18+
public final class ExceptionUtils {
19+
private ExceptionUtils() {}
20+
21+
public static Throwable getRootCause(Throwable t) {
22+
var result = t;
23+
var cause = result.getCause();
24+
while (cause != null) {
25+
result = cause;
26+
cause = cause.getCause();
27+
}
28+
return result;
29+
}
30+
31+
public static String getRootReason(Throwable t) {
32+
var reason = getRootCause(t).getMessage();
33+
if (reason == null || reason.isEmpty()) reason = "(unknown reason)";
34+
return reason;
35+
}
36+
}

pkl-core/src/main/resources/org/pkl/core/errorMessages.properties

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,6 +943,10 @@ Exception when making request `GET {0}`:\n\
943943
errorConnectingToHost=\
944944
Error connecting to host `{0}`.
945945

946+
errorSslHandshake=\
947+
Error during SSL handshake with host `{0}`:\n\
948+
{1}
949+
946950
cannotInitHttpClient=\
947951
Error initializing HTTP client:\n\
948952
{0}

pkl-core/src/test/kotlin/org/pkl/core/http/HttpClientTest.kt

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,14 @@ import java.net.URI
1111
import java.net.http.HttpRequest
1212
import java.net.http.HttpResponse
1313
import java.nio.file.Path
14-
import java.security.cert.CertificateException
1514
import java.time.Duration
1615
import kotlin.io.path.copyTo
1716
import kotlin.io.path.createDirectories
1817
import kotlin.io.path.createFile
1918

2019
class HttpClientTest {
2120
@Test
22-
fun `build default client`() {
21+
fun `can build default client`() {
2322
val client = assertDoesNotThrow {
2423
HttpClient.builder().build()
2524
} // means we loaded some (built-in) certificates
@@ -38,7 +37,7 @@ class HttpClientTest {
3837
}
3938

4039
@Test
41-
fun `build custom client`() {
40+
fun `can build custom client`() {
4241
val client = HttpClient.builder()
4342
.setUserAgent("Agent 1")
4443
.setRequestTimeout(Duration.ofHours(86))
@@ -53,7 +52,7 @@ class HttpClientTest {
5352
}
5453

5554
@Test
56-
fun `load certificates from file system`() {
55+
fun `can load certificates from file system`() {
5756
assertDoesNotThrow {
5857
HttpClient.builder().addCertificates(FileTestUtils.selfSignedCertificate).build()
5958
}
@@ -71,7 +70,7 @@ class HttpClientTest {
7170
}
7271

7372
@Test
74-
fun `load certificates from class path`() {
73+
fun `can load certificates from class path`() {
7574
assertDoesNotThrow {
7675
HttpClient.builder().addCertificates(javaClass.getResource("IncludedCARoots.pem")).build()
7776
}
@@ -89,14 +88,14 @@ class HttpClientTest {
8988
}
9089

9190
@Test
92-
fun `load built-in certificates`() {
91+
fun `can load built-in certificates`() {
9392
assertDoesNotThrow {
9493
HttpClient.builder().addBuiltInCertificates().build()
9594
}
9695
}
9796

9897
@Test
99-
fun `load certificates from default location`(@TempDir userHome: Path) {
98+
fun `can load certificates from default location`(@TempDir userHome: Path) {
10099
val certFile = userHome.resolve(".pkl")
101100
.resolve("cacerts")
102101
.createDirectories()
@@ -116,7 +115,7 @@ class HttpClientTest {
116115
}
117116

118117
@Test
119-
fun `client can be closed multiple times`() {
118+
fun `can be closed multiple times`() {
120119
val client = HttpClient.builder().build()
121120

122121
assertDoesNotThrow {
@@ -126,7 +125,7 @@ class HttpClientTest {
126125
}
127126

128127
@Test
129-
fun `client refuses to send messages once closed`() {
128+
fun `refuses to send messages once closed`() {
130129
val client = HttpClient.builder().build()
131130
val request = HttpRequest.newBuilder(URI("https://example.com")).build()
132131

pkl-core/src/test/kotlin/org/pkl/core/http/LazyHttpClientTest.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
package org.pkl.core.http
22

3-
import org.assertj.core.api.Assertions.assertThat
43
import org.junit.jupiter.api.Test
54
import org.junit.jupiter.api.assertDoesNotThrow
65
import org.junit.jupiter.api.assertThrows
76
import java.net.URI
87
import java.net.http.HttpRequest
98
import java.net.http.HttpResponse.BodyHandlers
10-
import java.security.cert.CertificateException
119

1210
class LazyHttpClientTest {
1311
@Test
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package org.pkl.core.util
2+
3+
import org.assertj.core.api.Assertions.assertThat
4+
import org.junit.jupiter.api.Test
5+
import java.io.IOException
6+
import java.lang.Error
7+
8+
class ExceptionUtilsTest {
9+
@Test
10+
fun `get root cause of simple exception`() {
11+
val e = IOException("io")
12+
assertThat(ExceptionUtils.getRootCause(e)).isSameAs(e)
13+
}
14+
15+
@Test
16+
fun `get root cause of nested exception`() {
17+
val e = IOException("io")
18+
val e2 = RuntimeException("runtime")
19+
val e3 = Error("error")
20+
e.initCause(e2)
21+
e2.initCause(e3)
22+
assertThat(ExceptionUtils.getRootCause(e)).isSameAs(e3)
23+
}
24+
25+
@Test
26+
fun `get root reason`() {
27+
val e = IOException("io")
28+
val e2 = RuntimeException("the root reason")
29+
e.initCause(e2)
30+
assertThat(ExceptionUtils.getRootReason(e)).isEqualTo("the root reason")
31+
}
32+
33+
@Test
34+
fun `get root reason if null`() {
35+
val e = IOException("io")
36+
val e2 = RuntimeException(null as String?)
37+
e.initCause(e2)
38+
assertThat(ExceptionUtils.getRootReason(e)).isEqualTo("(unknown reason)")
39+
}
40+
41+
@Test
42+
fun `get root reason if empty`() {
43+
val e = IOException("io")
44+
val e2 = RuntimeException("")
45+
e.initCause(e2)
46+
assertThat(ExceptionUtils.getRootReason(e)).isEqualTo("(unknown reason)")
47+
}
48+
}

0 commit comments

Comments
 (0)