diff --git a/pkl-cli/src/test/kotlin/org/pkl/cli/CliEvaluatorTest.kt b/pkl-cli/src/test/kotlin/org/pkl/cli/CliEvaluatorTest.kt index a0d4fa269..e831cf268 100644 --- a/pkl-cli/src/test/kotlin/org/pkl/cli/CliEvaluatorTest.kt +++ b/pkl-cli/src/test/kotlin/org/pkl/cli/CliEvaluatorTest.kt @@ -1295,7 +1295,7 @@ result = someLib.x CliBaseOptions( sourceModules = listOf(moduleUri), workingDir = tempDir, - caCertificates = listOf(FileTestUtils.selfSignedCertificate), + caCertificates = listOf(FileTestUtils.selfSignedCertificatePem), ) ) val buffer = ByteArrayOutputStream() @@ -1337,7 +1337,7 @@ result = someLib.x CliBaseOptions( sourceModules = listOf(moduleUri), workingDir = tempDir, - caCertificates = listOf(FileTestUtils.selfSignedCertificate), + caCertificates = listOf(FileTestUtils.selfSignedCertificatePem), settings = settingsFile, ) ) @@ -1367,7 +1367,7 @@ result = someLib.x workingDir = tempDir, moduleCacheDir = tempDir, noCache = true, - caCertificates = listOf(FileTestUtils.selfSignedCertificate), + caCertificates = listOf(FileTestUtils.selfSignedCertificatePem), testPort = packageServer.port, ) ) @@ -1473,7 +1473,7 @@ result = someLib.x sourceModules = listOf(URI("package://localhost:1/birds@0.5.0#/catalog/Ostrich.pkl")), noCache = true, httpProxy = URI(wwRuntimeInfo.httpBaseUrl), - caCertificates = listOf(FileTestUtils.selfSignedCertificate), + caCertificates = listOf(FileTestUtils.selfSignedCertificatePem), allowedModules = SecurityManagers.defaultAllowedModules + Pattern.compile("http:"), ) ) diff --git a/pkl-cli/src/test/kotlin/org/pkl/cli/CliPackageDownloaderTest.kt b/pkl-cli/src/test/kotlin/org/pkl/cli/CliPackageDownloaderTest.kt index b3319ac59..595820604 100644 --- a/pkl-cli/src/test/kotlin/org/pkl/cli/CliPackageDownloaderTest.kt +++ b/pkl-cli/src/test/kotlin/org/pkl/cli/CliPackageDownloaderTest.kt @@ -44,7 +44,7 @@ class CliPackageDownloaderTest { baseOptions = CliBaseOptions( moduleCacheDir = tempDir, - caCertificates = listOf(FileTestUtils.selfSignedCertificate), + caCertificates = listOf(FileTestUtils.selfSignedCertificatePem), testPort = server.port, ), packageUris = @@ -83,7 +83,7 @@ class CliPackageDownloaderTest { baseOptions = CliBaseOptions( workingDir = tempDir, - caCertificates = listOf(FileTestUtils.selfSignedCertificate), + caCertificates = listOf(FileTestUtils.selfSignedCertificatePem), testPort = server.port, ), packageUris = listOf(PackageUri("package://localhost:0/birds@0.5.0")), @@ -103,7 +103,7 @@ class CliPackageDownloaderTest { baseOptions = CliBaseOptions( moduleCacheDir = tempDir, - caCertificates = listOf(FileTestUtils.selfSignedCertificate), + caCertificates = listOf(FileTestUtils.selfSignedCertificatePem), testPort = server.port, ), packageUris = @@ -124,7 +124,7 @@ class CliPackageDownloaderTest { baseOptions = CliBaseOptions( moduleCacheDir = tempDir, - caCertificates = listOf(FileTestUtils.selfSignedCertificate), + caCertificates = listOf(FileTestUtils.selfSignedCertificatePem), testPort = server.port, ), packageUris = @@ -165,7 +165,7 @@ class CliPackageDownloaderTest { baseOptions = CliBaseOptions( moduleCacheDir = tempDir, - caCertificates = listOf(FileTestUtils.selfSignedCertificate), + caCertificates = listOf(FileTestUtils.selfSignedCertificatePem), testPort = server.port, ), packageUris = listOf(PackageUri("package://localhost:0/badChecksum@1.0.0")), @@ -184,7 +184,7 @@ class CliPackageDownloaderTest { baseOptions = CliBaseOptions( moduleCacheDir = tempDir, - caCertificates = listOf(FileTestUtils.selfSignedCertificate), + caCertificates = listOf(FileTestUtils.selfSignedCertificatePem), testPort = server.port, ), packageUris = @@ -221,7 +221,7 @@ class CliPackageDownloaderTest { baseOptions = CliBaseOptions( moduleCacheDir = tempDir, - caCertificates = listOf(FileTestUtils.selfSignedCertificate), + caCertificates = listOf(FileTestUtils.selfSignedCertificatePem), testPort = server.port, ), packageUris = listOf(PackageUri("package://localhost:0/birds@0.5.0")), diff --git a/pkl-cli/src/test/kotlin/org/pkl/cli/CliProjectPackagerTest.kt b/pkl-cli/src/test/kotlin/org/pkl/cli/CliProjectPackagerTest.kt index b057b6633..71d1f6cda 100644 --- a/pkl-cli/src/test/kotlin/org/pkl/cli/CliProjectPackagerTest.kt +++ b/pkl-cli/src/test/kotlin/org/pkl/cli/CliProjectPackagerTest.kt @@ -967,7 +967,7 @@ class CliProjectPackagerTest { CliProjectPackager( CliBaseOptions( workingDir = tempDir, - caCertificates = listOf(FileTestUtils.selfSignedCertificate), + caCertificates = listOf(FileTestUtils.selfSignedCertificatePem), testPort = packageServer.port, ), listOf(tempDir.resolve("project")), @@ -1011,7 +1011,7 @@ class CliProjectPackagerTest { CliProjectPackager( CliBaseOptions( workingDir = tempDir, - caCertificates = listOf(FileTestUtils.selfSignedCertificate), + caCertificates = listOf(FileTestUtils.selfSignedCertificatePem), testPort = packageServer.port, ), listOf(tempDir.resolve("project")), diff --git a/pkl-cli/src/test/kotlin/org/pkl/cli/CliProjectResolverTest.kt b/pkl-cli/src/test/kotlin/org/pkl/cli/CliProjectResolverTest.kt index e432b47c4..3c67be948 100644 --- a/pkl-cli/src/test/kotlin/org/pkl/cli/CliProjectResolverTest.kt +++ b/pkl-cli/src/test/kotlin/org/pkl/cli/CliProjectResolverTest.kt @@ -87,7 +87,7 @@ class CliProjectResolverTest { CliProjectResolver( CliBaseOptions( workingDir = tempDir, - caCertificates = listOf(FileTestUtils.selfSignedCertificate), + caCertificates = listOf(FileTestUtils.selfSignedCertificatePem), testPort = packageServer.port, noCache = true, ), @@ -142,7 +142,7 @@ class CliProjectResolverTest { CliProjectResolver( CliBaseOptions( workingDir = tempDir, - caCertificates = listOf(FileTestUtils.selfSignedCertificate), + caCertificates = listOf(FileTestUtils.selfSignedCertificatePem), testPort = packageServer.port, noCache = true, ), @@ -240,7 +240,7 @@ class CliProjectResolverTest { ) CliProjectResolver( CliBaseOptions( - caCertificates = listOf(FileTestUtils.selfSignedCertificate), + caCertificates = listOf(FileTestUtils.selfSignedCertificatePem), testPort = packageServer.port, noCache = true, ), @@ -322,7 +322,7 @@ class CliProjectResolverTest { val errOut = StringWriter() CliProjectResolver( CliBaseOptions( - caCertificates = listOf(FileTestUtils.selfSignedCertificate), + caCertificates = listOf(FileTestUtils.selfSignedCertificatePem), testPort = packageServer.port, noCache = true, ), @@ -397,7 +397,7 @@ class CliProjectResolverTest { val errOut = StringWriter() CliProjectResolver( CliBaseOptions( - caCertificates = listOf(FileTestUtils.selfSignedCertificate), + caCertificates = listOf(FileTestUtils.selfSignedCertificatePem), testPort = packageServer.port, noCache = true, ), @@ -484,7 +484,7 @@ class CliProjectResolverTest { assertThatCode { CliProjectResolver( CliBaseOptions( - caCertificates = listOf(FileTestUtils.selfSignedCertificate), + caCertificates = listOf(FileTestUtils.selfSignedCertificatePem), testPort = packageServer.port, noCache = true, ), diff --git a/pkl-commons-test/src/main/kotlin/org/pkl/commons/test/FileTestUtils.kt b/pkl-commons-test/src/main/kotlin/org/pkl/commons/test/FileTestUtils.kt index 897cb5922..072a656d3 100644 --- a/pkl-commons-test/src/main/kotlin/org/pkl/commons/test/FileTestUtils.kt +++ b/pkl-commons-test/src/main/kotlin/org/pkl/commons/test/FileTestUtils.kt @@ -1,5 +1,5 @@ /* - * Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved. + * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,12 +31,19 @@ object FileTestUtils { ?: workingDir.parent.parent.takeIf { it.resolve("settings.gradle.kts").exists() } ?: throw AssertionError("Failed to locate root project directory.") } - val selfSignedCertificate: Path by lazy { + + val selfSignedCertificateP12: Path by lazy { + rootProjectDir.resolve("pkl-commons-test/build/keystore/localhost.p12") + } + + val selfSignedCertificatePem: Path by lazy { rootProjectDir.resolve("pkl-commons-test/build/keystore/localhost.pem") } + val selfSignedCertificatePassword = "password" + fun writeCertificateWithMissingLines(dir: Path): Path { - val lines = selfSignedCertificate.readLines() + val lines = selfSignedCertificatePem.readLines() // drop some lines in the middle return dir.resolve("invalidCerts.pem").writeLines(lines.take(5) + lines.takeLast(5)) } diff --git a/pkl-core/pkl-core.gradle.kts b/pkl-core/pkl-core.gradle.kts index d715386dd..dc7d90c53 100644 --- a/pkl-core/pkl-core.gradle.kts +++ b/pkl-core/pkl-core.gradle.kts @@ -67,6 +67,7 @@ dependencies { implementation(libs.snakeYaml) testImplementation(projects.pklCommonsTest) + testImplementation(libs.wiremock) add("generatorImplementation", libs.javaPoet) add("generatorImplementation", libs.truffleApi) diff --git a/pkl-core/src/main/java/org/pkl/core/Analyzer.java b/pkl-core/src/main/java/org/pkl/core/Analyzer.java index dc66ed5cd..b3704ba94 100644 --- a/pkl-core/src/main/java/org/pkl/core/Analyzer.java +++ b/pkl-core/src/main/java/org/pkl/core/Analyzer.java @@ -25,7 +25,7 @@ import org.graalvm.polyglot.Context; import org.jspecify.annotations.Nullable; import org.pkl.core.evaluatorSettings.TraceMode; import org.pkl.core.http.HttpClient; -import org.pkl.core.http.HttpClientInitException; +import org.pkl.core.http.HttpClientException; import org.pkl.core.module.ModuleKeyFactory; import org.pkl.core.module.ProjectDependenciesManager; import org.pkl.core.packages.PackageLoadError; @@ -79,10 +79,7 @@ public class Analyzer { context.enter(); var vmContext = VmContext.get(null); return VmImportAnalyzer.analyze(sources, vmContext); - } catch (SecurityManagerException - | IOException - | PackageLoadError - | HttpClientInitException e) { + } catch (SecurityManagerException | IOException | PackageLoadError | HttpClientException e) { throw new PklException(e.getMessage(), e); } catch (PklException err) { throw err; diff --git a/pkl-core/src/main/java/org/pkl/core/ast/expression/unary/ImportGlobMemberBodyNode.java b/pkl-core/src/main/java/org/pkl/core/ast/expression/unary/ImportGlobMemberBodyNode.java index 2d67ca61b..8988e4642 100644 --- a/pkl-core/src/main/java/org/pkl/core/ast/expression/unary/ImportGlobMemberBodyNode.java +++ b/pkl-core/src/main/java/org/pkl/core/ast/expression/unary/ImportGlobMemberBodyNode.java @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved. + * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,7 +21,7 @@ import com.oracle.truffle.api.source.SourceSection; import java.util.Map; import org.pkl.core.SecurityManagerException; import org.pkl.core.ast.ExpressionNode; -import org.pkl.core.http.HttpClientInitException; +import org.pkl.core.http.HttpClientException; import org.pkl.core.module.ResolvedModuleKey; import org.pkl.core.packages.PackageLoadError; import org.pkl.core.runtime.VmContext; @@ -60,7 +60,7 @@ public final class ImportGlobMemberBodyNode extends ExpressionNode { context.getSecurityManager().checkImportModule(currentModule.getUri(), importUri); var moduleToImport = context.getModuleResolver().resolve(importUri, this); return language.loadModule(moduleToImport, this); - } catch (SecurityManagerException | PackageLoadError | HttpClientInitException e) { + } catch (SecurityManagerException | PackageLoadError | HttpClientException e) { throw exceptionBuilder().withCause(e).build(); } } diff --git a/pkl-core/src/main/java/org/pkl/core/ast/expression/unary/ImportGlobNode.java b/pkl-core/src/main/java/org/pkl/core/ast/expression/unary/ImportGlobNode.java index 952d651e1..7fe41bc5c 100644 --- a/pkl-core/src/main/java/org/pkl/core/ast/expression/unary/ImportGlobNode.java +++ b/pkl-core/src/main/java/org/pkl/core/ast/expression/unary/ImportGlobNode.java @@ -1,5 +1,5 @@ /* - * Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved. + * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,7 +26,7 @@ import java.net.URI; import org.pkl.core.SecurityManagerException; import org.pkl.core.ast.member.SharedMemberNode; import org.pkl.core.externalreader.ExternalReaderProcessException; -import org.pkl.core.http.HttpClientInitException; +import org.pkl.core.http.HttpClientException; import org.pkl.core.module.ResolvedModuleKey; import org.pkl.core.packages.PackageLoadError; import org.pkl.core.runtime.VmContext; @@ -96,7 +96,7 @@ public class ImportGlobNode extends AbstractImportNode { return cachedResult; } catch (IOException e) { throw exceptionBuilder().evalError("ioErrorResolvingGlob", importUri).withCause(e).build(); - } catch (SecurityManagerException | HttpClientInitException e) { + } catch (SecurityManagerException | HttpClientException e) { throw exceptionBuilder().withCause(e).build(); } catch (PackageLoadError e) { throw exceptionBuilder().adhocEvalError(e.getMessage()).build(); diff --git a/pkl-core/src/main/java/org/pkl/core/ast/expression/unary/ImportNode.java b/pkl-core/src/main/java/org/pkl/core/ast/expression/unary/ImportNode.java index 72298b7d9..0622f7e1c 100644 --- a/pkl-core/src/main/java/org/pkl/core/ast/expression/unary/ImportNode.java +++ b/pkl-core/src/main/java/org/pkl/core/ast/expression/unary/ImportNode.java @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved. + * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,7 +22,7 @@ import com.oracle.truffle.api.nodes.NodeInfo; import com.oracle.truffle.api.source.SourceSection; import java.net.URI; import org.pkl.core.SecurityManagerException; -import org.pkl.core.http.HttpClientInitException; +import org.pkl.core.http.HttpClientException; import org.pkl.core.module.ResolvedModuleKey; import org.pkl.core.packages.PackageLoadError; import org.pkl.core.runtime.VmContext; @@ -55,7 +55,7 @@ public final class ImportNode extends AbstractImportNode { context.getSecurityManager().checkImportModule(currentModule.getUri(), importUri); var moduleToImport = context.getModuleResolver().resolve(importUri, this); importedModule = language.loadModule(moduleToImport, this); - } catch (SecurityManagerException | PackageLoadError | HttpClientInitException e) { + } catch (SecurityManagerException | PackageLoadError | HttpClientException e) { throw exceptionBuilder().withCause(e).build(); } } diff --git a/pkl-core/src/main/java/org/pkl/core/ast/expression/unary/ReadGlobNode.java b/pkl-core/src/main/java/org/pkl/core/ast/expression/unary/ReadGlobNode.java index 9331629bc..6c9f15223 100644 --- a/pkl-core/src/main/java/org/pkl/core/ast/expression/unary/ReadGlobNode.java +++ b/pkl-core/src/main/java/org/pkl/core/ast/expression/unary/ReadGlobNode.java @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved. + * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,7 +27,7 @@ import org.graalvm.collections.EconomicMap; import org.pkl.core.SecurityManagerException; import org.pkl.core.ast.member.SharedMemberNode; import org.pkl.core.externalreader.ExternalReaderProcessException; -import org.pkl.core.http.HttpClientInitException; +import org.pkl.core.http.HttpClientException; import org.pkl.core.module.ModuleKey; import org.pkl.core.runtime.VmContext; import org.pkl.core.runtime.VmLanguage; @@ -97,7 +97,7 @@ public abstract class ReadGlobNode extends AbstractReadNode { return cachedResult; } catch (IOException e) { throw exceptionBuilder().evalError("ioErrorResolvingGlob", globPattern).withCause(e).build(); - } catch (SecurityManagerException | HttpClientInitException | URISyntaxException e) { + } catch (SecurityManagerException | HttpClientException | URISyntaxException e) { throw exceptionBuilder().withCause(e).build(); } catch (InvalidGlobPatternException e) { throw exceptionBuilder() diff --git a/pkl-core/src/main/java/org/pkl/core/http/DummyHttpClient.java b/pkl-core/src/main/java/org/pkl/core/http/DummyHttpClient.java index cc9380fe1..fd96bbb4f 100644 --- a/pkl-core/src/main/java/org/pkl/core/http/DummyHttpClient.java +++ b/pkl-core/src/main/java/org/pkl/core/http/DummyHttpClient.java @@ -24,7 +24,10 @@ import java.net.http.HttpResponse.BodyHandler; @ThreadSafe final class DummyHttpClient implements HttpClient { @Override - public HttpResponse send(HttpRequest request, BodyHandler responseBodyHandler) { + public HttpResponse send( + HttpRequest request, + BodyHandler responseBodyHandler, + HttpRequestChecker httpRequestChecker) { throw new AssertionError("Dummy HTTP client cannot send request: " + request); } diff --git a/pkl-core/src/main/java/org/pkl/core/http/HttpClient.java b/pkl-core/src/main/java/org/pkl/core/http/HttpClient.java index c1e336056..dc53ccbe0 100644 --- a/pkl-core/src/main/java/org/pkl/core/http/HttpClient.java +++ b/pkl-core/src/main/java/org/pkl/core/http/HttpClient.java @@ -19,12 +19,14 @@ import java.io.IOException; import java.net.URI; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandler; import java.net.http.HttpTimeoutException; import java.nio.file.Path; import java.util.List; import java.util.Map; import javax.net.ssl.SSLContext; import org.jspecify.annotations.Nullable; +import org.pkl.core.SecurityManagerException; /** * An HTTP client. @@ -190,7 +192,7 @@ public interface HttpClient extends AutoCloseable { /** * Creates a new {@code HttpClient} from the current state of this builder. * - * @throws HttpClientInitException if an error occurs while initializing the client + * @throws HttpClientException if an error occurs while initializing the client */ HttpClient build(); @@ -242,11 +244,14 @@ public interface HttpClient extends AutoCloseable { * java.net.http.HttpClient#send}. * * @throws IOException if an I/O error occurs when sending or receiving - * @throws HttpClientInitException if an error occurs while initializing a {@linkplain - * Builder#buildLazily lazy} client + * @throws HttpClientException if a known (user-presentable) error occurs. + * @throws SecurityManagerException based on {@code httpRequestChecker} */ - HttpResponse send(HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) - throws IOException; + HttpResponse send( + HttpRequest request, + BodyHandler responseBodyHandler, + HttpRequestChecker httpRequestChecker) + throws IOException, SecurityManagerException; /** * Closes this client. @@ -258,4 +263,9 @@ public interface HttpClient extends AutoCloseable { * {@link IllegalStateException}. */ void close(); + + @FunctionalInterface + interface HttpRequestChecker { + void check(URI uri) throws SecurityManagerException; + } } diff --git a/pkl-core/src/main/java/org/pkl/core/http/HttpClientInitException.java b/pkl-core/src/main/java/org/pkl/core/http/HttpClientException.java similarity index 65% rename from pkl-core/src/main/java/org/pkl/core/http/HttpClientInitException.java rename to pkl-core/src/main/java/org/pkl/core/http/HttpClientException.java index 75830f510..1410bedd6 100644 --- a/pkl-core/src/main/java/org/pkl/core/http/HttpClientInitException.java +++ b/pkl-core/src/main/java/org/pkl/core/http/HttpClientException.java @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved. + * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,19 +16,19 @@ package org.pkl.core.http; /** - * Indicates that an error occurred while initializing an HTTP client. A common example is an error - * reading or parsing a certificate. + * Indicates that an error occurred while initializing an HTTP client, or when making an HTTP call. + * The error messages are user-presentable. */ -public final class HttpClientInitException extends RuntimeException { - public HttpClientInitException(String message) { +public final class HttpClientException extends RuntimeException { + public HttpClientException(String message) { super(message); } - public HttpClientInitException(Throwable cause) { + public HttpClientException(Throwable cause) { super(cause); } - public HttpClientInitException(String message, Throwable cause) { + public HttpClientException(String message, Throwable cause) { super(message, cause); } } diff --git a/pkl-core/src/main/java/org/pkl/core/http/JdkHttpClient.java b/pkl-core/src/main/java/org/pkl/core/http/JdkHttpClient.java index 6c2092cf6..9dc4d2af5 100644 --- a/pkl-core/src/main/java/org/pkl/core/http/JdkHttpClient.java +++ b/pkl-core/src/main/java/org/pkl/core/http/JdkHttpClient.java @@ -46,6 +46,7 @@ import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.SSLHandshakeException; import javax.net.ssl.TrustManagerFactory; +import org.pkl.core.SecurityManagerException; import org.pkl.core.util.ErrorMessages; import org.pkl.core.util.Exceptions; @@ -83,13 +84,16 @@ final class JdkHttpClient implements HttpClient { .sslContext(createSslContext(certificateFiles, certificateBytes)) .connectTimeout(connectTimeout) .proxy(proxySelector) - .followRedirects(Redirect.NORMAL) + .followRedirects(Redirect.NEVER) .build(); } @Override - public HttpResponse send(HttpRequest request, BodyHandler responseBodyHandler) - throws IOException { + public HttpResponse send( + HttpRequest request, + BodyHandler responseBodyHandler, + HttpRequestChecker httpRequestChecker) + throws IOException, SecurityManagerException { try { return underlying.send(request, responseBodyHandler); } catch (ConnectException e) { @@ -144,7 +148,7 @@ final class JdkHttpClient implements HttpClient { return sslContext; } catch (GeneralSecurityException | IOException e) { - throw new HttpClientInitException( + throw new HttpClientException( ErrorMessages.create("cannotInitHttpClient", Exceptions.getRootReason(e)), e); } } @@ -156,9 +160,9 @@ final class JdkHttpClient implements HttpClient { try (var stream = Files.newInputStream(file)) { collectCertificates(certificates, factory, stream, file); } catch (NoSuchFileException e) { - throw new HttpClientInitException(ErrorMessages.create("cannotFindCertFile", file)); + throw new HttpClientException(ErrorMessages.create("cannotFindCertFile", file)); } catch (IOException e) { - throw new HttpClientInitException( + throw new HttpClientException( ErrorMessages.create("cannotReadCertFile", Exceptions.getRootReason(e))); } } @@ -179,11 +183,11 @@ final class JdkHttpClient implements HttpClient { //noinspection unchecked certificates = (Collection) factory.generateCertificates(stream); } catch (CertificateException e) { - throw new HttpClientInitException( + throw new HttpClientException( ErrorMessages.create("cannotParseCertFile", source, Exceptions.getRootReason(e))); } if (certificates.isEmpty()) { - throw new HttpClientInitException(ErrorMessages.create("emptyCertFile", source)); + throw new HttpClientException(ErrorMessages.create("emptyCertFile", source)); } anchors.addAll(certificates); } diff --git a/pkl-core/src/main/java/org/pkl/core/http/LazyHttpClient.java b/pkl-core/src/main/java/org/pkl/core/http/LazyHttpClient.java index 5696e2d19..a2163eba7 100644 --- a/pkl-core/src/main/java/org/pkl/core/http/LazyHttpClient.java +++ b/pkl-core/src/main/java/org/pkl/core/http/LazyHttpClient.java @@ -24,6 +24,7 @@ import java.net.http.HttpResponse.BodyHandler; import java.util.Optional; import java.util.function.Supplier; import org.jspecify.annotations.Nullable; +import org.pkl.core.SecurityManagerException; /** * An {@code HttpClient} decorator that defers creating the underlying HTTP client until the first @@ -45,9 +46,12 @@ final class LazyHttpClient implements HttpClient { } @Override - public HttpResponse send(HttpRequest request, BodyHandler responseBodyHandler) - throws IOException { - return getOrCreateClient().send(request, responseBodyHandler); + public HttpResponse send( + HttpRequest request, + BodyHandler responseBodyHandler, + HttpRequestChecker httpRequestChecker) + throws IOException, SecurityManagerException { + return getOrCreateClient().send(request, responseBodyHandler, httpRequestChecker); } @Override diff --git a/pkl-core/src/main/java/org/pkl/core/http/RequestRewritingClient.java b/pkl-core/src/main/java/org/pkl/core/http/RequestRewritingClient.java index 9e453327d..cd99616a4 100644 --- a/pkl-core/src/main/java/org/pkl/core/http/RequestRewritingClient.java +++ b/pkl-core/src/main/java/org/pkl/core/http/RequestRewritingClient.java @@ -34,6 +34,8 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.regex.Pattern; import org.jspecify.annotations.Nullable; import org.pkl.core.PklBugException; +import org.pkl.core.SecurityManagerException; +import org.pkl.core.util.ErrorMessages; import org.pkl.core.util.HttpUtils; import org.pkl.core.util.IoUtils; import org.pkl.core.util.Pair; @@ -42,13 +44,15 @@ import org.pkl.core.util.Pair; * An {@code HttpClient} decorator that * *
    - *
  • overrides the {@code User-Agent} header of {@code HttpRequest}s + *
  • overrides the headers of {@code HttpRequest}s *
  • sets a request timeout if none is present *
  • ensures that {@link #close()} is idempotent. *
  • rewrites outbound URI prefixes with another prefix. + *
  • handles redirects, rewriting URLs after each redirect and checking against {@link + * org.pkl.core.http.HttpClient.HttpRequestChecker}. *
* - *

Both {@code User-Agent} header and default request timeout are configurable through {@link + *

The headers, default request timeout, and URI rewrites are configurable through {@link * HttpClient.Builder}. */ @ThreadSafe @@ -64,6 +68,22 @@ final class RequestRewritingClient implements HttpClient { private final AtomicBoolean closed = new AtomicBoolean(); + private static final int MAX_HTTP_REDIRECTS; + + static { + // allow Java users to configure max redirects the same way they would Java's default HTTP + // client. + var maxRedirectProp = System.getProperty("http.maxRedirects"); + var maxRedirects = 20; + if (maxRedirectProp != null) { + try { + maxRedirects = Math.max(0, Integer.parseInt(maxRedirectProp)); + } catch (NumberFormatException ignored) { + } + } + MAX_HTTP_REDIRECTS = maxRedirects; + } + RequestRewritingClient( String userAgent, Duration requestTimeout, @@ -84,12 +104,56 @@ final class RequestRewritingClient implements HttpClient { this.headers = headers; } + private HttpResponse doSend( + HttpRequest request, + BodyHandler responseBodyHandler, + HttpRequestChecker httpRequestChecker) + throws SecurityManagerException, IOException { + var redirectCount = 0; + var currentRequestUri = rewriteUri(request.uri()); + var currentRequest = rewriteRequest(request, currentRequestUri); + while (true) { + httpRequestChecker.check(currentRequestUri); + var response = delegate.send(currentRequest, responseBodyHandler, httpRequestChecker); + if (!HttpUtils.isRedirectStatusCode(response.statusCode())) { + return response; + } + if (redirectCount >= MAX_HTTP_REDIRECTS) { + throw new HttpClientException( + ErrorMessages.create("httpTooManyRedirects", MAX_HTTP_REDIRECTS)); + } + var location = response.headers().firstValue("Location"); + if (location.isEmpty()) { + throw new HttpClientException( + ErrorMessages.create("httpRedirectNoLocation", currentRequestUri)); + } + URI redirectUri; + try { + redirectUri = currentRequestUri.resolve(location.get()); + } catch (IllegalArgumentException e) { + throw new HttpClientException( + ErrorMessages.create("httpRedirectInvalidUri", currentRequestUri, location.get())); + } + if (currentRequestUri.getScheme().equalsIgnoreCase("https") + && redirectUri.getScheme().equalsIgnoreCase("http")) { + throw new HttpClientException( + ErrorMessages.create("httpRedirectCannotDowngrade", currentRequestUri, redirectUri)); + } + currentRequestUri = rewriteUri(redirectUri); + currentRequest = rewriteRequest(request, currentRequestUri); + redirectCount++; + } + } + @Override - public HttpResponse send(HttpRequest request, BodyHandler responseBodyHandler) - throws IOException { + public HttpResponse send( + HttpRequest request, + BodyHandler responseBodyHandler, + HttpRequestChecker httpRequestChecker) + throws IOException, SecurityManagerException { checkNotClosed(request); try { - return delegate.send(rewriteRequest(request), responseBodyHandler); + return doSend(request, responseBodyHandler, httpRequestChecker); } catch (IOException e) { var rewrittenUri = rewriteUri(request.uri()); if (rewrittenUri != request.uri()) { @@ -108,11 +172,11 @@ final class RequestRewritingClient implements HttpClient { } // Based on JDK 17's implementation of HttpRequest.newBuilder(HttpRequest, filter). - private HttpRequest rewriteRequest(HttpRequest original) { + private HttpRequest rewriteRequest(HttpRequest original, URI newUri) { HttpRequest.Builder builder = HttpRequest.newBuilder(); builder - .uri(rewriteUri(original.uri())) + .uri(newUri) .expectContinue(original.expectContinue()) .timeout(original.timeout().orElse(requestTimeout)) .version(original.version().orElse(java.net.http.HttpClient.Version.HTTP_2)); @@ -122,7 +186,7 @@ final class RequestRewritingClient implements HttpClient { .map() .forEach((name, values) -> values.forEach(value -> builder.header(name, value))); var isUserAgentSet = false; - for (var header : this.getHeaders(original.uri())) { + for (var header : this.getHeaders(newUri)) { var headerName = header.getFirst(); isUserAgentSet = isUserAgentSet || headerName.equalsIgnoreCase("user-agent"); builder.header(header.getFirst(), header.getSecond()); diff --git a/pkl-core/src/main/java/org/pkl/core/module/ModuleKeys.java b/pkl-core/src/main/java/org/pkl/core/module/ModuleKeys.java index 94910f895..1702ab85c 100644 --- a/pkl-core/src/main/java/org/pkl/core/module/ModuleKeys.java +++ b/pkl-core/src/main/java/org/pkl/core/module/ModuleKeys.java @@ -519,13 +519,13 @@ public final class ModuleKeys { @Override public ResolvedModuleKey resolve(SecurityManager securityManager) throws IOException, SecurityManagerException { - securityManager.checkResolveModule(uri); var httpClient = VmContext.get(null).getHttpClient(); var request = HttpRequest.newBuilder(uri).build(); - var response = httpClient.send(request, BodyHandlers.ofInputStream()); + var response = + httpClient.send( + request, BodyHandlers.ofInputStream(), securityManager::checkResolveModule); try (var body = response.body()) { HttpUtils.checkHasStatusCode200(response); - securityManager.checkResolveModule(response.uri()); String text = IoUtils.readString(body); // intentionally use uri instead of response.uri() return ResolvedModuleKeys.virtual(this, uri, text, true); diff --git a/pkl-core/src/main/java/org/pkl/core/packages/PackageResolvers.java b/pkl-core/src/main/java/org/pkl/core/packages/PackageResolvers.java index c6ffbff7b..5cceb215b 100644 --- a/pkl-core/src/main/java/org/pkl/core/packages/PackageResolvers.java +++ b/pkl-core/src/main/java/org/pkl/core/packages/PackageResolvers.java @@ -201,7 +201,9 @@ final class PackageResolvers { var request = HttpRequest.newBuilder(uri).build(); HttpResponse response; try { - response = httpClient.send(request, BodyHandlers.ofInputStream()); + response = + httpClient.send( + request, BodyHandlers.ofInputStream(), securityManager::checkReadResource); } catch (IOException e) { throw new PackageLoadError(e, "ioErrorMakingHttpGet", uri, e.getMessage()); } diff --git a/pkl-core/src/main/java/org/pkl/core/resource/ResourceReaders.java b/pkl-core/src/main/java/org/pkl/core/resource/ResourceReaders.java index 8affd26b0..ce73865db 100644 --- a/pkl-core/src/main/java/org/pkl/core/resource/ResourceReaders.java +++ b/pkl-core/src/main/java/org/pkl/core/resource/ResourceReaders.java @@ -257,7 +257,8 @@ public final class ResourceReaders { static final ResourceReader INSTANCE = new FileResource(); @Override - public Optional read(URI uri) throws IOException, URISyntaxException { + public Optional read(URI uri) + throws IOException, URISyntaxException, SecurityManagerException { IoUtils.validateFileUri(uri); // Use resolveSecurePath to get a symlink-free path verified under rootDir. var securityManager = VmContext.get(null).getSecurityManager(); @@ -307,7 +308,7 @@ public final class ResourceReaders { } } - private static final class HttpResource extends UrlResource { + public static final class HttpResource extends UrlResource { static final ResourceReader INSTANCE = new HttpResource(); @Override @@ -326,7 +327,7 @@ public final class ResourceReaders { } } - private static final class HttpsResource extends UrlResource { + public static final class HttpsResource extends UrlResource { static final ResourceReader INSTANCE = new HttpsResource(); @Override @@ -347,11 +348,16 @@ public final class ResourceReaders { private abstract static class UrlResource implements ResourceReader { @Override - public Optional read(URI uri) throws IOException, URISyntaxException { + public Optional read(URI uri) + throws IOException, URISyntaxException, SecurityManagerException { if (HttpUtils.isHttpUrl(uri)) { - var httpClient = VmContext.get(null).getHttpClient(); + var vmContext = VmContext.get(null); + var securityManager = vmContext.getSecurityManager(); + var httpClient = vmContext.getHttpClient(); var request = HttpRequest.newBuilder(uri).build(); - var response = httpClient.send(request, BodyHandlers.ofByteArray()); + var response = + httpClient.send( + request, BodyHandlers.ofByteArray(), securityManager::checkReadResource); if (response.statusCode() == 404) return Optional.empty(); HttpUtils.checkHasStatusCode200(response); return Optional.of(new Resource(uri, response.body())); diff --git a/pkl-core/src/main/java/org/pkl/core/runtime/ModuleCache.java b/pkl-core/src/main/java/org/pkl/core/runtime/ModuleCache.java index 45b830371..590ecb580 100644 --- a/pkl-core/src/main/java/org/pkl/core/runtime/ModuleCache.java +++ b/pkl-core/src/main/java/org/pkl/core/runtime/ModuleCache.java @@ -31,7 +31,7 @@ import org.jspecify.annotations.Nullable; import org.pkl.core.Release; import org.pkl.core.SecurityManager; import org.pkl.core.SecurityManagerException; -import org.pkl.core.http.HttpClientInitException; +import org.pkl.core.http.HttpClientException; import org.pkl.core.module.ModuleKey; import org.pkl.core.module.ModuleKeys; import org.pkl.core.module.ResolvedModuleKey; @@ -198,7 +198,7 @@ public final class ModuleCache { ModuleKey module, SecurityManager securityManager, @Nullable Node importNode) { try { return module.resolve(securityManager); - } catch (SecurityManagerException | PackageLoadError | HttpClientInitException e) { + } catch (SecurityManagerException | PackageLoadError | HttpClientException e) { throw new VmExceptionBuilder().withOptionalLocation(importNode).withCause(e).build(); } catch (FileNotFoundException | NoSuchFileException e) { var exceptionBuilder = diff --git a/pkl-core/src/main/java/org/pkl/core/runtime/ResourceManager.java b/pkl-core/src/main/java/org/pkl/core/runtime/ResourceManager.java index 3c4003557..aa0ac34aa 100644 --- a/pkl-core/src/main/java/org/pkl/core/runtime/ResourceManager.java +++ b/pkl-core/src/main/java/org/pkl/core/runtime/ResourceManager.java @@ -28,10 +28,11 @@ import org.jspecify.annotations.Nullable; import org.pkl.core.SecurityManager; import org.pkl.core.SecurityManagerException; import org.pkl.core.externalreader.ExternalReaderProcessException; -import org.pkl.core.http.HttpClientInitException; +import org.pkl.core.http.HttpClientException; import org.pkl.core.packages.PackageLoadError; import org.pkl.core.resource.Resource; import org.pkl.core.resource.ResourceReader; +import org.pkl.core.resource.ResourceReaders; import org.pkl.core.stdlib.VmObjectFactory; public final class ResourceManager { @@ -86,7 +87,7 @@ public final class ResourceManager { .build(); } catch (SecurityManagerException | PackageLoadError - | HttpClientInitException + | HttpClientException | ExternalReaderProcessException e) { throw new VmExceptionBuilder().withCause(e).withOptionalLocation(readNode).build(); } @@ -98,12 +99,17 @@ public final class ResourceManager { return resources.computeIfAbsent( resourceUri.normalize(), (uri) -> { - try { - securityManager.checkReadResource(uri); - } catch (SecurityManagerException e) { - throw new VmExceptionBuilder().withCause(e).withOptionalLocation(readNode).build(); - } var reader = getResourceReader(uri); + // hack: we don't want to call `checkReadResource` here for these resources because those + // readers defer to HttpClient to do the actual checks. + if (!(reader instanceof ResourceReaders.HttpResource) + && !(reader instanceof ResourceReaders.HttpsResource)) { + try { + securityManager.checkReadResource(uri); + } catch (SecurityManagerException e) { + throw new VmExceptionBuilder().withCause(e).withOptionalLocation(readNode).build(); + } + } if (reader == null) { throw new VmExceptionBuilder() .withOptionalLocation(readNode) diff --git a/pkl-core/src/main/java/org/pkl/core/util/HttpUtils.java b/pkl-core/src/main/java/org/pkl/core/util/HttpUtils.java index 2048b934e..2fd05b6ee 100644 --- a/pkl-core/src/main/java/org/pkl/core/util/HttpUtils.java +++ b/pkl-core/src/main/java/org/pkl/core/util/HttpUtils.java @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved. + * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,6 +25,17 @@ import org.pkl.core.PklBugException; public final class HttpUtils { private HttpUtils() {} + public static boolean isRedirectStatusCode(int statusCode) { + return switch (statusCode) { + // We can handle each of these status codes exactly the same because: + // + // 1. We don't implement any caching for HTTPS requests. + // 2. Pkl only makes GET requests. + case 301, 302, 303, 307, 308 -> true; + default -> false; + }; + } + public static boolean isHttpUrl(URL url) { var protocol = url.getProtocol(); return "https".equalsIgnoreCase(protocol) || "http".equalsIgnoreCase(protocol); diff --git a/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties b/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties index 37cb3ce0f..0f378bb18 100644 --- a/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties +++ b/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties @@ -1151,3 +1151,23 @@ HTTP header value `{0}` has invalid syntax. invalidHttpHeaderValueTooLong=\ HTTP Header value is invalid because it is longer than 4096 characters. \ Value: `{0}` + +httpTooManyRedirects=\ +Too many redirects, exceeded the redirect threshold ({0}). + +httpRedirectNoLocation=\ +Cannot follow HTTP redirect because no ''Location'' header was found.\n\ +\n\ +HTTP Request: `GET {0}` + +httpRedirectInvalidUri=\ +Cannot follow HTTP redirect because the response ''Location'' header has a malformed URI.\n\ +\n\ +HTTP Request: `GET {0}`\n\ +Location header: `{1}` + +httpRedirectCannotDowngrade=\ +Cannot follow redirect from ''https:'' URL to ''http:'' URL.\ +\n\ +HTTP Request: `GET {0}`\n\ +Redirected to: `{1}` diff --git a/pkl-core/src/test/kotlin/org/pkl/core/LanguageSnippetTestsEngine.kt b/pkl-core/src/test/kotlin/org/pkl/core/LanguageSnippetTestsEngine.kt index 53d2eba10..3d7ff0901 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/LanguageSnippetTestsEngine.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/LanguageSnippetTestsEngine.kt @@ -175,7 +175,7 @@ class LanguageSnippetTestsEngine : AbstractLanguageSnippetTestsEngine() { .setHttpClient( HttpClient.builder() .setTestPort(packageServer.port) - .addCertificates(FileTestUtils.selfSignedCertificate) + .addCertificates(FileTestUtils.selfSignedCertificatePem) .buildLazily() ) .setPowerAssertionsEnabled(true) @@ -287,7 +287,7 @@ abstract class AbstractNativeLanguageSnippetTestsEngine : AbstractLanguageSnippe add("--settings") add("pkl:settings") add("--ca-certificates") - add(FileTestUtils.selfSignedCertificate.toString()) + add(FileTestUtils.selfSignedCertificatePem.toString()) add("--test-mode") add("--test-port") add(packageServer.port.toString()) diff --git a/pkl-core/src/test/kotlin/org/pkl/core/http/DummyHttpClientTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/http/DummyHttpClientTest.kt index 520fd9eb0..4d5c69567 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/http/DummyHttpClientTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/http/DummyHttpClientTest.kt @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved. + * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,9 +28,13 @@ class DummyHttpClientTest { val client = HttpClient.dummyClient() val request = HttpRequest.newBuilder(URI("https://example.com")).build() - assertThrows { client.send(request, HttpResponse.BodyHandlers.discarding()) } + assertThrows { + client.send(request, HttpResponse.BodyHandlers.discarding(), NoopChecker) + } - assertThrows { client.send(request, HttpResponse.BodyHandlers.discarding()) } + assertThrows { + client.send(request, HttpResponse.BodyHandlers.discarding(), NoopChecker) + } } @Test diff --git a/pkl-core/src/test/kotlin/org/pkl/core/http/HttpClientTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/http/HttpClientTest.kt index 0a8b48e85..fe1dc9d74 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/http/HttpClientTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/http/HttpClientTest.kt @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved. + * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,17 +15,31 @@ */ package org.pkl.core.http +import com.github.tomakehurst.wiremock.client.WireMock.get +import com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor +import com.github.tomakehurst.wiremock.client.WireMock.matching +import com.github.tomakehurst.wiremock.client.WireMock.ok +import com.github.tomakehurst.wiremock.client.WireMock.permanentRedirect +import com.github.tomakehurst.wiremock.client.WireMock.stubFor +import com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo +import com.github.tomakehurst.wiremock.client.WireMock.verify +import com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig +import com.github.tomakehurst.wiremock.junit5.WireMockExtension import java.net.URI import java.net.http.HttpRequest import java.net.http.HttpResponse import java.nio.file.Path import java.time.Duration +import kotlin.io.path.absolutePathString import kotlin.io.path.createFile import kotlin.io.path.readBytes import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatCode +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertDoesNotThrow import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.RegisterExtension import org.junit.jupiter.api.io.TempDir import org.pkl.commons.test.FileTestUtils import org.pkl.core.Release @@ -68,14 +82,16 @@ class HttpClientTest { @Test fun `can load certificates from regular file`() { assertDoesNotThrow { - HttpClient.builder().addCertificates(FileTestUtils.selfSignedCertificate).build() + HttpClient.builder().addCertificates(FileTestUtils.selfSignedCertificatePem).build() } } @Test fun `can load certificates from a byte array`() { assertDoesNotThrow { - HttpClient.builder().addCertificates(FileTestUtils.selfSignedCertificate.readBytes()).build() + HttpClient.builder() + .addCertificates(FileTestUtils.selfSignedCertificatePem.readBytes()) + .build() } } @@ -83,8 +99,7 @@ class HttpClientTest { fun `certificate file cannot be empty`(@TempDir tempDir: Path) { val file = tempDir.resolve("certs.pem").createFile() - val e = - assertThrows { HttpClient.builder().addCertificates(file).build() } + val e = assertThrows { HttpClient.builder().addCertificates(file).build() } assertThat(e).hasMessageContaining("empty") } @@ -112,10 +127,185 @@ class HttpClientTest { client.close() assertThrows { - client.send(request, HttpResponse.BodyHandlers.discarding()) + client.send(request, HttpResponse.BodyHandlers.discarding(), NoopChecker) } assertThrows { - client.send(request, HttpResponse.BodyHandlers.discarding()) + client.send(request, HttpResponse.BodyHandlers.discarding(), NoopChecker) + } + } + + @Nested + inner class RedirectsTest { + // incorrect diagnostic + @Suppress("JUnitMalformedDeclaration") + @RegisterExtension + val wireMock: WireMockExtension = + with(WireMockExtension.newInstance()) { + configureStaticDsl(true) + options( + wireMockConfig().apply { + dynamicPort() + dynamicHttpsPort() + keystorePath(FileTestUtils.selfSignedCertificateP12.absolutePathString()) + keystorePassword(FileTestUtils.selfSignedCertificatePassword) + keystoreType("PKCS12") + } + ) + build() + } + + @Test + fun `follows redirects`() { + stubFor(get(urlEqualTo("/foo.pkl")).willReturn(permanentRedirect("/bar.pkl"))) + stubFor(get(urlEqualTo("/bar.pkl")).willReturn(ok("bar = 1"))) + val client = HttpClient.builder().build() + val request = + HttpRequest.newBuilder(URI("${wireMock.runtimeInfo.httpBaseUrl}/foo.pkl")).build() + val response = client.send(request, HttpResponse.BodyHandlers.ofString(), NoopChecker) + assert(response.body() == "bar = 1") + verify(getRequestedFor(urlEqualTo("/foo.pkl"))) + verify(getRequestedFor(urlEqualTo("/bar.pkl"))) + } + + @Test + fun `preserves configured headers across redirects`() { + stubFor(get(urlEqualTo("/foo.pkl")).willReturn(permanentRedirect("/bar.pkl"))) + stubFor(get(urlEqualTo("/bar.pkl")).willReturn(ok("bar = 1"))) + + val client = + HttpClient.builder().addHeaders("**", mapOf("x-foo" to listOf("foo value"))).build() + val request = + HttpRequest.newBuilder(URI("${wireMock.runtimeInfo.httpBaseUrl}/foo.pkl")).build() + val response = client.send(request, HttpResponse.BodyHandlers.ofString(), NoopChecker) + assert(response.body() == "bar = 1") + verify(getRequestedFor(urlEqualTo("/foo.pkl")).withHeader("x-foo", matching("foo value"))) + verify(getRequestedFor(urlEqualTo("/bar.pkl")).withHeader("x-foo", matching("foo value"))) + } + + @Test + fun `respects configured rewrites across redirects`() { + stubFor(get(urlEqualTo("/foo.pkl")).willReturn(permanentRedirect("/orig/bar.pkl"))) + stubFor(get(urlEqualTo("/rewritten/bar.pkl")).willReturn(ok())) + + val client = + HttpClient.builder() + .addRewrite( + URI("${wireMock.runtimeInfo.httpBaseUrl}/orig/"), + URI("${wireMock.runtimeInfo.httpBaseUrl}/rewritten/"), + ) + .build() + val request = + HttpRequest.newBuilder(URI("${wireMock.runtimeInfo.httpBaseUrl}/foo.pkl")).build() + client.send(request, HttpResponse.BodyHandlers.ofString(), NoopChecker) + verify(getRequestedFor(urlEqualTo("/foo.pkl"))) + verify(getRequestedFor(urlEqualTo("/rewritten/bar.pkl"))) + } + + @Test + fun `cannot downgrade HTTPS to HTTP`() { + stubFor( + get(urlEqualTo("/foo.pkl")) + .willReturn(permanentRedirect("${wireMock.runtimeInfo.httpBaseUrl}/bar.pkl")) + ) + + val client = + HttpClient.builder() + .addCertificates(FileTestUtils.selfSignedCertificatePem) + .addHeaders("**", mapOf("x-foo" to listOf("foo value"))) + .build() + val request = + HttpRequest.newBuilder(URI("${wireMock.runtimeInfo.httpsBaseUrl}/foo.pkl")).build() + assertThatCode { client.send(request, HttpResponse.BodyHandlers.ofString(), NoopChecker) } + .hasMessageContaining("Cannot follow redirect from 'https:' URL to 'http:' URL") + } + + @Test + fun `can upgrade HTTP to HTTPS`() { + stubFor( + get(urlEqualTo("/foo.pkl")) + .willReturn(permanentRedirect("${wireMock.runtimeInfo.httpsBaseUrl}/bar.pkl")) + ) + stubFor(get(urlEqualTo("/bar.pkl")).willReturn(ok("hello"))) + + val client = + HttpClient.builder() + .addCertificates(FileTestUtils.selfSignedCertificatePem) + .addHeaders("**", mapOf("x-foo" to listOf("foo value"))) + .build() + val request = + HttpRequest.newBuilder(URI("${wireMock.runtimeInfo.httpBaseUrl}/foo.pkl")).build() + val response = client.send(request, HttpResponse.BodyHandlers.ofString(), NoopChecker) + assertThat(response.body()).isEqualTo("hello") + } + + @Test + fun `infinite redirects fail with VmException`() { + stubFor(get(urlEqualTo("/foo.pkl")).willReturn(permanentRedirect("/bar.pkl"))) + stubFor(get(urlEqualTo("/bar.pkl")).willReturn(permanentRedirect("/foo.pkl"))) + val client = HttpClient.builder().build() + val request = + HttpRequest.newBuilder(URI("${wireMock.runtimeInfo.httpBaseUrl}/foo.pkl")).build() + assertThatCode { client.send(request, HttpResponse.BodyHandlers.ofString(), NoopChecker) } + .hasMessageContaining("Too many redirects") + verify(getRequestedFor(urlEqualTo("/foo.pkl"))) + verify(getRequestedFor(urlEqualTo("/bar.pkl"))) + } + + @Test + fun `invalid redirect URI fails with VmException`() { + stubFor(get(urlEqualTo("/foo.pkl")).willReturn(permanentRedirect("http://not a valid url/"))) + val client = HttpClient.builder().build() + val request = + HttpRequest.newBuilder(URI("${wireMock.runtimeInfo.httpBaseUrl}/foo.pkl")).build() + assertThatCode { client.send(request, HttpResponse.BodyHandlers.ofString(), NoopChecker) } + .hasMessageContaining( + """ + Cannot follow HTTP redirect because the response Location header has a malformed URI. + """ + .trimIndent() + ) + verify(getRequestedFor(urlEqualTo("/foo.pkl"))) + } + + @Test + fun `checks each URL before making a request`() { + stubFor(get(urlEqualTo("/foo.pkl")).willReturn(permanentRedirect("/bar.pkl"))) + stubFor(get(urlEqualTo("/bar.pkl")).willReturn(permanentRedirect("/qux.pkl"))) + stubFor(get(urlEqualTo("/qux.pkl")).willReturn(ok())) + val checkedUrls = mutableListOf() + val checker = HttpClient.HttpRequestChecker { uri -> checkedUrls.add(uri) } + val client = HttpClient.builder().build() + val request = + HttpRequest.newBuilder(URI("${wireMock.runtimeInfo.httpBaseUrl}/foo.pkl")).build() + client.send(request, HttpResponse.BodyHandlers.ofString(), checker) + assertThat(checkedUrls).hasSize(3) + assertThat(checkedUrls) + .usingRecursiveComparison() + .isEqualTo( + listOf( + URI("${wireMock.runtimeInfo.httpBaseUrl}/foo.pkl"), + URI("${wireMock.runtimeInfo.httpBaseUrl}/bar.pkl"), + URI("${wireMock.runtimeInfo.httpBaseUrl}/qux.pkl"), + ) + ) + } + + @Test + fun `redirects only carry their specifically configured headers`() { + stubFor(get(urlEqualTo("/foo.pkl")).willReturn(permanentRedirect("/bar.pkl"))) + stubFor(get(urlEqualTo("/bar.pkl")).willReturn(ok())) + val request = + HttpRequest.newBuilder(URI("${wireMock.runtimeInfo.httpBaseUrl}/foo.pkl")).build() + val client = + with(HttpClient.builder()) { + addHeaders("**/foo.pkl", mapOf("x-foo" to listOf("foo value"))) + addHeaders("**/bar.pkl", mapOf("x-bar" to listOf("bar value"))) + build() + } + client.send(request, HttpResponse.BodyHandlers.discarding(), NoopChecker) + verify(getRequestedFor(urlEqualTo("/foo.pkl")).withHeader("x-foo", matching("foo value"))) + verify(getRequestedFor(urlEqualTo("/bar.pkl")).withoutHeader("x-foo")) + verify(getRequestedFor(urlEqualTo("/bar.pkl")).withHeader("x-bar", matching("bar value"))) } } } diff --git a/pkl-core/src/test/kotlin/org/pkl/core/http/LazyHttpClientTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/http/LazyHttpClientTest.kt index e898dc265..5e6b35b0b 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/http/LazyHttpClientTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/http/LazyHttpClientTest.kt @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved. + * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -33,7 +33,9 @@ class LazyHttpClientTest { val client = HttpClient.builder().addCertificates(certFile).buildLazily() val request = HttpRequest.newBuilder(URI("https://example.com")).build() - assertThrows { client.send(request, BodyHandlers.discarding()) } + assertThrows { + client.send(request, BodyHandlers.discarding(), NoopChecker) + } } @Test diff --git a/pkl-core/src/test/kotlin/org/pkl/core/http/RequestCapturingClient.kt b/pkl-core/src/test/kotlin/org/pkl/core/http/RequestCapturingClient.kt index 3606207d4..df144e1b7 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/http/RequestCapturingClient.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/http/RequestCapturingClient.kt @@ -1,5 +1,5 @@ /* - * Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved. + * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,6 +25,7 @@ class RequestCapturingClient : HttpClient { override fun send( request: HttpRequest, responseBodyHandler: HttpResponse.BodyHandler, + httpRequestChecker: HttpClient.HttpRequestChecker, ): HttpResponse { this.request = request return FakeHttpResponse() diff --git a/pkl-core/src/test/kotlin/org/pkl/core/http/RequestRewritingClientTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/http/RequestRewritingClientTest.kt index 42f4ce639..6e8809c79 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/http/RequestRewritingClientTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/http/RequestRewritingClientTest.kt @@ -45,7 +45,7 @@ class RequestRewritingClientTest { @Test fun `fills in missing User-Agent header`() { - client.send(exampleRequest, BodyHandlers.discarding()) + client.send(exampleRequest, BodyHandlers.discarding(), NoopChecker) assertThatList(captured.request.headers().allValues("User-Agent")).containsOnly("Pkl") } @@ -61,7 +61,7 @@ class RequestRewritingClientTest { mapOf(URI("https://foo/") to URI("https://bar/")), mapOf(IoUtils.doubleStarGlob to mapOf("User-Agent" to listOf("My-User-Agent"))), ) - client.send(exampleRequest, BodyHandlers.discarding()) + client.send(exampleRequest, BodyHandlers.discarding(), NoopChecker) assertThatList(captured.request.headers().allValues("User-Agent")).containsOnly("My-User-Agent") } @@ -73,14 +73,14 @@ class RequestRewritingClientTest { .header("User-Agent", "Agent 2") .build() - client.send(request, BodyHandlers.discarding()) + client.send(request, BodyHandlers.discarding(), NoopChecker) assertThatList(captured.request.headers().allValues("User-Agent")).containsOnly("Pkl") } @Test fun `fills in missing request timeout`() { - client.send(exampleRequest, BodyHandlers.discarding()) + client.send(exampleRequest, BodyHandlers.discarding(), NoopChecker) assertThat(captured.request.timeout()).hasValue(Duration.ofSeconds(42)) } @@ -89,14 +89,14 @@ class RequestRewritingClientTest { fun `leaves existing request timeout intact`() { val request = HttpRequest.newBuilder(exampleUri).timeout(Duration.ofMinutes(33)).build() - client.send(request, BodyHandlers.discarding()) + client.send(request, BodyHandlers.discarding(), NoopChecker) assertThat(captured.request.timeout()).hasValue(Duration.ofMinutes(33)) } @Test fun `fills in missing HTTP version`() { - client.send(exampleRequest, BodyHandlers.discarding()) + client.send(exampleRequest, BodyHandlers.discarding(), NoopChecker) assertThat(captured.request.version()).hasValue(JdkHttpClient.Version.HTTP_2) } @@ -105,7 +105,7 @@ class RequestRewritingClientTest { fun `leaves existing HTTP version intact`() { val request = HttpRequest.newBuilder(exampleUri).version(JdkHttpClient.Version.HTTP_1_1).build() - client.send(request, BodyHandlers.discarding()) + client.send(request, BodyHandlers.discarding(), NoopChecker) assertThat(captured.request.version()).hasValue(JdkHttpClient.Version.HTTP_1_1) } @@ -114,7 +114,7 @@ class RequestRewritingClientTest { fun `leaves default method intact`() { val request = HttpRequest.newBuilder(exampleUri).build() - client.send(request, BodyHandlers.discarding()) + client.send(request, BodyHandlers.discarding(), NoopChecker) assertThat(captured.request.method()).isEqualTo("GET") } @@ -123,7 +123,7 @@ class RequestRewritingClientTest { fun `leaves explicit method intact`() { val request = HttpRequest.newBuilder(exampleUri).DELETE().build() - client.send(request, BodyHandlers.discarding()) + client.send(request, BodyHandlers.discarding(), NoopChecker) assertThat(captured.request.method()).isEqualTo("DELETE") } @@ -133,7 +133,7 @@ class RequestRewritingClientTest { val publisher = BodyPublishers.ofString("body") val request = HttpRequest.newBuilder(exampleUri).PUT(publisher).build() - client.send(request, BodyHandlers.discarding()) + client.send(request, BodyHandlers.discarding(), NoopChecker) assertThat(captured.request.bodyPublisher().get()).isSameAs(publisher) } @@ -145,7 +145,7 @@ class RequestRewritingClientTest { RequestRewritingClient("Pkl", Duration.ofSeconds(42), 5000, captured, mapOf(), mapOf()) val request = HttpRequest.newBuilder(URI("https://example.com:0")).build() - client.send(request, BodyHandlers.discarding()) + client.send(request, BodyHandlers.discarding(), NoopChecker) assertThat(captured.request.uri().port).isEqualTo(5000) } @@ -154,7 +154,7 @@ class RequestRewritingClientTest { fun `leaves port 0 intact if no test port is set`() { val request = HttpRequest.newBuilder(URI("https://example.com:0")).build() - client.send(request, BodyHandlers.discarding()) + client.send(request, BodyHandlers.discarding(), NoopChecker) assertThat(captured.request.uri().port).isEqualTo(0) } @@ -344,7 +344,7 @@ class RequestRewritingClientTest { val captured = RequestCapturingClient() val client = RequestRewritingClient("Pkl", Duration.ofSeconds(42), -1, captured, rules, mapOf()) val request = HttpRequest.newBuilder(URI(uri)).build() - client.send(request, BodyHandlers.discarding()) + client.send(request, BodyHandlers.discarding(), NoopChecker) return captured.request.uri().toString() } @@ -366,7 +366,7 @@ class RequestRewritingClientTest { ) val request = HttpRequest.newBuilder(URI("https://example.com/foo/bar")).build() - client.send(request, BodyHandlers.discarding()) + client.send(request, BodyHandlers.discarding(), NoopChecker) assertThatList(captured.request.headers().allValues("x-one")).containsExactly("one") assertThatList(captured.request.headers().allValues("x-two")).containsExactly("two-a", "two-b") @@ -389,7 +389,7 @@ class RequestRewritingClientTest { ) val request = HttpRequest.newBuilder(URI("https://example.com/foo/bar")).build() - client.send(request, BodyHandlers.discarding()) + client.send(request, BodyHandlers.discarding(), NoopChecker) assertThat(captured.request.headers().firstValue("x-foo")).isEmpty assertThat(captured.request.headers().firstValue("x-bar")).isEmpty @@ -413,7 +413,7 @@ class RequestRewritingClientTest { val request = HttpRequest.newBuilder(URI("https://example.com/foo/bar")).header("x-foo", "request").build() - client.send(request, BodyHandlers.discarding()) + client.send(request, BodyHandlers.discarding(), NoopChecker) assertThatList(captured.request.headers().allValues("x-foo")) .containsExactly("request", "rule-a", "rule-b") @@ -436,7 +436,7 @@ class RequestRewritingClientTest { ) val request = HttpRequest.newBuilder(URI("https://example.com/foo/bar")).build() - client.send(request, BodyHandlers.discarding()) + client.send(request, BodyHandlers.discarding(), NoopChecker) assertThatList(captured.request.headers().allValues("user-agent")) .containsExactly("My User Agent") diff --git a/pkl-core/src/test/kotlin/org/pkl/core/http/util.kt b/pkl-core/src/test/kotlin/org/pkl/core/http/util.kt index e2ff531b4..ed51b474f 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/http/util.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/http/util.kt @@ -28,3 +28,7 @@ fun HttpClient.getConfiguredSettings(): HttpSettings { val requestRewritingClient = this.orCreateClient as RequestRewritingClient return HttpSettings(requestRewritingClient.headers, requestRewritingClient.rewritesMap) } + +object NoopChecker : HttpClient.HttpRequestChecker { + override fun check(uri: URI) {} +} diff --git a/pkl-core/src/test/kotlin/org/pkl/core/packages/PackageResolversTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/packages/PackageResolversTest.kt index 5a51c8c80..170832a36 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/packages/PackageResolversTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/packages/PackageResolversTest.kt @@ -49,7 +49,7 @@ class PackageResolversTest { val httpClient: HttpClient by lazy { HttpClient.builder() - .addCertificates(FileTestUtils.selfSignedCertificate) + .addCertificates(FileTestUtils.selfSignedCertificatePem) .setTestPort(packageServer.port) .build() } diff --git a/pkl-core/src/test/kotlin/org/pkl/core/project/ProjectDependenciesResolverTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/project/ProjectDependenciesResolverTest.kt index 41af4e8c2..985e0133a 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/project/ProjectDependenciesResolverTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/project/ProjectDependenciesResolverTest.kt @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved. + * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -41,7 +41,7 @@ class ProjectDependenciesResolverTest { val httpClient: HttpClient by lazy { HttpClient.builder() - .addCertificates(FileTestUtils.selfSignedCertificate) + .addCertificates(FileTestUtils.selfSignedCertificatePem) .setTestPort(packageServer.port) .build() } diff --git a/pkl-core/src/test/kotlin/org/pkl/core/project/ProjectTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/project/ProjectTest.kt index 8ba576b19..b37c0d09c 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/project/ProjectTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/project/ProjectTest.kt @@ -227,7 +227,7 @@ class ProjectTest { val project = Project.loadFromPath(projectDir.resolve("PklProject")) val httpClient = HttpClient.builder() - .addCertificates(FileTestUtils.selfSignedCertificate) + .addCertificates(FileTestUtils.selfSignedCertificatePem) .setTestPort(server.port) .build() val evaluator = diff --git a/pkl-executor/src/test/kotlin/org/pkl/executor/EmbeddedExecutorTest.kt b/pkl-executor/src/test/kotlin/org/pkl/executor/EmbeddedExecutorTest.kt index ac664e4a2..8c5ee5fad 100644 --- a/pkl-executor/src/test/kotlin/org/pkl/executor/EmbeddedExecutorTest.kt +++ b/pkl-executor/src/test/kotlin/org/pkl/executor/EmbeddedExecutorTest.kt @@ -557,7 +557,7 @@ class EmbeddedExecutorTest { allowedModules("file:", "package:", "https:") allowedResources("prop:", "package:", "https:") moduleCacheDir(cacheDir) - certificateFiles(FileTestUtils.selfSignedCertificate) + certificateFiles(FileTestUtils.selfSignedCertificatePem) testPort(server.port) } } diff --git a/stdlib/EvaluatorSettings.pkl b/stdlib/EvaluatorSettings.pkl index 736c1dbf4..f8761db63 100644 --- a/stdlib/EvaluatorSettings.pkl +++ b/stdlib/EvaluatorSettings.pkl @@ -237,7 +237,7 @@ class Http { /// In the following example, an original request for `https://pkg.pkl-lang.org/my/pkg@1.0.0` is /// replaced with `https://my.internal.mirror/my/pkg@1.0.0`. /// - /// This does not affect `3XX` status code redirect following. + /// This also affects `3XX` status code redirect following. /// /// Example: ///