Use java.net.http.HttpClient instead of java.net.Http(s)URLConnection (#217)

Moving to java.net.http.HttpClient brings many benefits, including
HTTP/2 support and the ability to make asynchronous requests.

Major additions and changes:
- Introduce a lightweight org.pkl.core.http.HttpClient API.
  This keeps some flexibility and allows to enforce behavior
  such as setting the User-Agent header.
- Provide an implementation that delegates to java.net.http.HttpClient.
- Use HttpClient for all HTTP(s) requests across the codebase.
  This required adding an HttpClient parameter to constructors and
  factory methods of multiple classes, some of which are public APIs.
- Manage CA certificates per HTTP client instead of per JVM.
  This makes it unnecessary to set JVM-wide system/security properties
  and default SSLSocketFactory's.
- Add executor v2 options to the executor SPI
- Add pkl-certs as a new artifact, and remove certs from pkl-commons-cli artifact

Each HTTP client maintains its own connection pool and SSLContext.
For efficiency reasons, It's best to reuse clients whenever feasible.
To avoid memory leaks, clients are not stored in static fields.

HTTP clients are expensive to create. For this reason,
EvaluatorBuilder defaults to a "lazy" client that creates the underlying
java.net.http.HttpClient on the first send (which may never happen).
This commit is contained in:
translatenix
2024-03-06 10:25:56 -08:00
committed by GitHub
parent 106743354c
commit 3f3dfdeb1e
79 changed files with 2376 additions and 395 deletions

View File

@@ -20,6 +20,7 @@ import java.nio.file.Path;
import java.util.*;
import java.util.regex.Pattern;
import org.pkl.core.SecurityManagers.StandardBuilder;
import org.pkl.core.http.HttpClient;
import org.pkl.core.module.ModuleKeyFactories;
import org.pkl.core.module.ModuleKeyFactory;
import org.pkl.core.module.ModulePathResolver;
@@ -38,6 +39,10 @@ public final class EvaluatorBuilder {
private @Nullable SecurityManager securityManager;
// Default to a client with a fixed set of built-in certificates.
// Make it lazy to avoid creating a client unnecessarily.
private HttpClient httpClient = HttpClient.builder().buildLazily();
private Logger logger = Loggers.noop();
private final List<ModuleKeyFactory> moduleKeyFactories = new ArrayList<>();
@@ -226,6 +231,21 @@ public final class EvaluatorBuilder {
return logger;
}
/**
* Sets the HTTP client to be used.
*
* <p>Defaults to {@code HttpClient.builder().buildLazily()}.
*/
public EvaluatorBuilder setHttpClient(HttpClient httpClient) {
this.httpClient = httpClient;
return this;
}
/** Returns the currently set HTTP client. */
public HttpClient getHttpClient() {
return httpClient;
}
/**
* Adds the given module key factory. Factories will be asked to resolve module keys in the order
* they have been added to this builder.
@@ -468,6 +488,7 @@ public final class EvaluatorBuilder {
return new EvaluatorImpl(
stackFrameTransformer,
securityManager,
httpClient,
new LoggerImpl(logger, stackFrameTransformer),
// copy to shield against subsequent modification through builder
new ArrayList<>(moduleKeyFactories),

View File

@@ -30,6 +30,7 @@ import java.util.function.Supplier;
import org.graalvm.polyglot.Context;
import org.pkl.core.ast.ConstantValueNode;
import org.pkl.core.ast.internal.ToStringNodeGen;
import org.pkl.core.http.HttpClient;
import org.pkl.core.module.ModuleKeyFactory;
import org.pkl.core.module.ProjectDependenciesManager;
import org.pkl.core.packages.PackageResolver;
@@ -69,6 +70,7 @@ public class EvaluatorImpl implements Evaluator {
public EvaluatorImpl(
StackFrameTransformer transformer,
SecurityManager manager,
HttpClient httpClient,
Logger logger,
Collection<ModuleKeyFactory> factories,
Collection<ResourceReader> readers,
@@ -83,7 +85,7 @@ public class EvaluatorImpl implements Evaluator {
frameTransformer = transformer;
moduleResolver = new ModuleResolver(factories);
this.logger = new BufferedLogger(logger);
packageResolver = PackageResolver.getInstance(securityManager, moduleCacheDir);
packageResolver = PackageResolver.getInstance(securityManager, httpClient, moduleCacheDir);
polyglotContext =
VmUtils.createContext(
() -> {
@@ -92,6 +94,7 @@ public class EvaluatorImpl implements Evaluator {
new VmContext.Holder(
transformer,
manager,
httpClient,
moduleResolver,
new ResourceManager(manager, readers),
this.logger,

View File

@@ -23,4 +23,8 @@ public class PklException extends RuntimeException {
public PklException(String message) {
super(message);
}
public PklException(Throwable cause) {
super(cause);
}
}

View File

@@ -30,6 +30,7 @@ import org.pkl.core.SecurityManagerException;
import org.pkl.core.ast.VmModifier;
import org.pkl.core.ast.member.ObjectMember;
import org.pkl.core.ast.member.UntypedObjectMemberNode;
import org.pkl.core.http.HttpClientInitException;
import org.pkl.core.module.ResolvedModuleKey;
import org.pkl.core.packages.PackageLoadError;
import org.pkl.core.runtime.BaseModule;
@@ -113,7 +114,7 @@ public class ImportGlobNode extends AbstractImportNode {
frame.materialize(), BaseModule.getMappingClass().getPrototype(), members);
} catch (IOException e) {
throw exceptionBuilder().evalError("ioErrorResolvingGlob", importUri).withCause(e).build();
} catch (SecurityManagerException e) {
} catch (SecurityManagerException | HttpClientInitException e) {
throw exceptionBuilder().withCause(e).build();
} catch (PackageLoadError e) {
throw exceptionBuilder().adhocEvalError(e.getMessage()).build();

View File

@@ -22,6 +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.module.ResolvedModuleKey;
import org.pkl.core.packages.PackageLoadError;
import org.pkl.core.runtime.VmContext;
@@ -60,7 +61,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 e) {
} catch (SecurityManagerException | PackageLoadError | HttpClientInitException e) {
throw exceptionBuilder().withCause(e).build();
}
}

View File

@@ -0,0 +1,33 @@
/**
* Copyright © 2024 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.
* You may obtain a copy of the License at
*
* https://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.pkl.core.http;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandler;
import javax.annotation.concurrent.ThreadSafe;
/** An {@code HttpClient} implementation that throws {@code AssertionError} on every send. */
@ThreadSafe
final class DummyHttpClient implements HttpClient {
@Override
public <T> HttpResponse<T> send(HttpRequest request, BodyHandler<T> responseBodyHandler) {
throw new AssertionError("Dummy HTTP client cannot send request: " + request);
}
@Override
public void close() {}
}

View File

@@ -0,0 +1,182 @@
/**
* Copyright © 2024 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.
* You may obtain a copy of the License at
*
* https://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.pkl.core.http;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.file.Path;
import javax.net.ssl.SSLContext;
/**
* An HTTP client.
*
* <p>To create a new HTTP client, use a {@linkplain #builder() builder}. To send {@linkplain
* HttpRequest requests} and retrieve their {@linkplain HttpResponse responses}, use {@link #send}.
* To release resources held by the client, use {@link #close}.
*
* <p>HTTP clients are thread-safe. Each client maintains its own connection pool and {@link
* SSLContext}. For efficiency reasons, clients should be reused whenever possible.
*/
public interface HttpClient extends AutoCloseable {
/** A builder of {@linkplain HttpClient HTTP clients}. */
interface Builder {
/**
* Sets the {@code User-Agent} header.
*
* <p>Defaults to {@code "Pkl/$version ($os; $flavor)"}.
*/
Builder setUserAgent(String userAgent);
/**
* Sets the timeout for connecting to a server.
*
* <p>Defaults to 60 seconds.
*/
Builder setConnectTimeout(java.time.Duration timeout);
/**
* Sets the timeout for the interval between sending a request and receiving response headers.
*
* <p>Defaults to 60 seconds. To set a timeout for a specific request, use {@link
* HttpRequest.Builder#timeout}.
*/
Builder setRequestTimeout(java.time.Duration timeout);
/**
* Adds a CA certificate file to the client's trust store.
*
* <p>The given file must contain <a href="https://en.wikipedia.org/wiki/X.509">X.509</a>
* certificates in PEM format.
*/
Builder addCertificates(Path file);
/**
* Adds a CA certificate file to the client's trust store.
*
* <p>The given file must contain <a href="https://en.wikipedia.org/wiki/X.509">X.509</a>
* certificates in PEM format.
*
* <p>This method is intended to be used for adding certificate files located on the class path.
* To add certificate files located on the file system, use {@link #addCertificates(Path)}.
*
* @throws HttpClientInitException if the given URI has a scheme other than {@code jar:} or
* {@code file:}
*/
Builder addCertificates(URI file);
/**
* Adds the CA certificate files in {@code ~/.pkl/cacerts/} to the client's trust store.
*
* <p>Each file must contain <a href="https://en.wikipedia.org/wiki/X.509">X.509</a>
* certificates in PEM format. If {@code ~/.pkl/cacerts/} does not exist or is empty, Pkl's
* {@link #addBuiltInCertificates() built-in certificates} are added instead.
*
* <p>This method implements the default behavior of Pkl CLIs.
*
* <p>NOTE: This method requires the optional {@code pkl-certs} JAR to be present on the class
* path.
*
* @throws HttpClientInitException if an I/O error occurs while scanning {@code ~/.pkl/cacerts/}
* or the {@code pkl-certs} JAR is not found on the class path
*/
Builder addDefaultCliCertificates();
/**
* Adds Pkl's built-in CA certificates to the client's trust store.
*
* <p>NOTE: This method requires the optional {@code pkl-certs} JAR to be present on the class
* path.
*
* @throws HttpClientInitException if the {@code pkl-certs} JAR is not found on the class path
*/
Builder addBuiltInCertificates();
/**
* Creates a new {@code HttpClient} from the current state of this builder.
*
* @throws HttpClientInitException if an error occurs while initializing the client
*/
HttpClient build();
/**
* Returns an {@code HTTPClient} wrapper that defers building the actual HTTP client until the
* wrapper's {@link HttpClient#send} method is called.
*
* <p>Note: When using this method, any exception thrown when building the actual HTTP client is
* equally deferred.
*/
HttpClient buildLazily();
}
/**
* Creates a new {@code HTTPClient} builder with default settings.
*
* <p>The default settings are:
*
* <ul>
* <li>Connect timeout: 60 seconds
* <li>Request timeout: 60 seconds
* <li>CA certificates: none (falls back to the JVM's {@linkplain SSLContext#getDefault()
* default SSL context})
* </ul>
*/
static Builder builder() {
return new HttpClientBuilder();
}
/** Returns a client that throws {@link AssertionError} on every attempt to send a request. */
static HttpClient dummyClient() {
return new DummyHttpClient();
}
/**
* Sends an HTTP request. The response body is processed by the given body handler.
*
* <p>If the request does not specify a {@linkplain HttpRequest#timeout timeout}, the client's
* {@linkplain Builder#setRequestTimeout request timeout} is used. If the request does not specify
* a preferred {@linkplain HttpRequest#version() HTTP version}, HTTP/2 is used. The request's
* {@code User-Agent} header is set to the client's {@link Builder#setUserAgent User-Agent}
* header.
*
* <p>Depending on the given body handler, this method blocks until response headers or the entire
* response body has been received. If response headers are not received within the request
* timeout, {@link HttpTimeoutException} is thrown.
*
* <p>For additional information on how to use this method, see {@link
* 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
*/
<T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler)
throws IOException;
/**
* Closes this client.
*
* <p>This method makes a best effort to release the resources held by this client in a timely
* manner. This may involve waiting for pending requests to complete.
*
* <p>Subsequent calls to this method have no effect. Subsequent calls to any other method throw
* {@link IllegalStateException}.
*/
void close();
}

View File

@@ -0,0 +1,135 @@
/**
* Copyright © 2024 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.
* You may obtain a copy of the License at
*
* https://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.pkl.core.http;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;
import org.pkl.core.Release;
import org.pkl.core.util.ErrorMessages;
final class HttpClientBuilder implements HttpClient.Builder {
private final Path userHome;
private String userAgent;
private Duration connectTimeout = Duration.ofSeconds(60);
private Duration requestTimeout = Duration.ofSeconds(60);
private final List<Path> certificateFiles = new ArrayList<>();
private final List<URI> certificateUris = new ArrayList<>();
HttpClientBuilder() {
this(Path.of(System.getProperty("user.home")));
}
// only exists for testing
HttpClientBuilder(Path userHome) {
this.userHome = userHome;
var release = Release.current();
userAgent = "Pkl/" + release.version() + " (" + release.os() + "; " + release.flavor() + ")";
}
public HttpClient.Builder setUserAgent(String userAgent) {
this.userAgent = userAgent;
return this;
}
@Override
public HttpClient.Builder setConnectTimeout(Duration timeout) {
this.connectTimeout = timeout;
return this;
}
@Override
public HttpClient.Builder setRequestTimeout(Duration timeout) {
this.requestTimeout = timeout;
return this;
}
@Override
public HttpClient.Builder addCertificates(Path file) {
certificateFiles.add(file);
return this;
}
@Override
public HttpClient.Builder addCertificates(URI url) {
var scheme = url.getScheme();
if (!"jar".equalsIgnoreCase(scheme) && !"file".equalsIgnoreCase(scheme)) {
throw new HttpClientInitException(ErrorMessages.create("expectedJarOrFileUrl", url));
}
certificateUris.add(url);
return this;
}
public HttpClient.Builder addDefaultCliCertificates() {
var directory = userHome.resolve(".pkl").resolve("cacerts");
var fileCount = certificateFiles.size();
if (Files.isDirectory(directory)) {
try (var files = Files.list(directory)) {
files.filter(Files::isRegularFile).forEach(certificateFiles::add);
} catch (IOException e) {
throw new HttpClientInitException(e);
}
}
if (certificateFiles.size() == fileCount) {
addBuiltInCertificates();
}
return this;
}
@Override
public HttpClient.Builder addBuiltInCertificates() {
certificateUris.add(getBuiltInCertificates());
return this;
}
@Override
public HttpClient build() {
return doBuild().get();
}
@Override
public HttpClient buildLazily() {
return new LazyHttpClient(doBuild());
}
private Supplier<HttpClient> doBuild() {
// make defensive copies because Supplier may get called after builder was mutated
var certificateFiles = List.copyOf(this.certificateFiles);
var certificateUris = List.copyOf(this.certificateUris);
return () -> {
var jdkClient = new JdkHttpClient(certificateFiles, certificateUris, connectTimeout);
return new RequestRewritingClient(userAgent, requestTimeout, jdkClient);
};
}
private static URI getBuiltInCertificates() {
var resource = HttpClientBuilder.class.getResource("/org/pkl/certs/PklCARoots.pem");
if (resource == null) {
throw new HttpClientInitException(ErrorMessages.create("cannotFindBuiltInCertificates"));
}
try {
return resource.toURI();
} catch (URISyntaxException e) {
throw new AssertionError("unreachable");
}
}
}

View File

@@ -0,0 +1,36 @@
/**
* Copyright © 2024 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.
* You may obtain a copy of the License at
*
* https://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.pkl.core.http;
import org.pkl.core.PklException;
/**
* Indicates that an error occurred while initializing an HTTP client. A common example is an error
* reading or parsing a certificate.
*/
public class HttpClientInitException extends PklException {
public HttpClientInitException(String message) {
super(message);
}
public HttpClientInitException(Throwable cause) {
super(cause);
}
public HttpClientInitException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,206 @@
/**
* Copyright © 2024 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.
* You may obtain a copy of the License at
*
* https://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.pkl.core.http;
import java.io.IOException;
import java.io.InputStream;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.net.ConnectException;
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandler;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.security.cert.CertPathBuilder;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.PKIXBuilderParameters;
import java.security.cert.PKIXRevocationChecker;
import java.security.cert.TrustAnchor;
import java.security.cert.X509CertSelector;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import javax.annotation.concurrent.ThreadSafe;
import javax.net.ssl.CertPathTrustManagerParameters;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.TrustManagerFactory;
import org.pkl.core.util.ErrorMessages;
import org.pkl.core.util.Exceptions;
/** An {@code HttpClient} implementation backed by {@link java.net.http.HttpClient}. */
@ThreadSafe
final class JdkHttpClient implements HttpClient {
// non-private for testing
final java.net.http.HttpClient underlying;
// call java.net.http.HttpClient.close() if available (JDK 21+)
private static final MethodHandle closeMethod;
static {
var methodType = MethodType.methodType(void.class, java.net.http.HttpClient.class);
MethodHandle result;
try {
//noinspection JavaLangInvokeHandleSignature
result =
MethodHandles.publicLookup()
.findVirtual(java.net.http.HttpClient.class, "close", methodType);
} catch (NoSuchMethodException | IllegalAccessException e) {
// use no-op close method
result = MethodHandles.empty(methodType);
}
closeMethod = result;
}
JdkHttpClient(List<Path> certificateFiles, List<URI> certificateUris, Duration connectTimeout) {
underlying =
java.net.http.HttpClient.newBuilder()
.sslContext(createSslContext(certificateFiles, certificateUris))
.connectTimeout(connectTimeout)
.build();
}
@Override
public <T> HttpResponse<T> send(HttpRequest request, BodyHandler<T> responseBodyHandler)
throws IOException {
try {
return underlying.send(request, responseBodyHandler);
} catch (ConnectException e) {
// original exception has no message
throw new ConnectException(
ErrorMessages.create("errorConnectingToHost", request.uri().getHost()));
} catch (SSLHandshakeException e) {
throw new SSLHandshakeException(
ErrorMessages.create(
"errorSslHandshake", request.uri().getHost(), Exceptions.getRootReason(e)));
} catch (SSLException e) {
throw new SSLException(Exceptions.getRootReason(e));
} catch (IOException e) {
// JDK 11 throws IOException instead of SSLHandshakeException
throw new IOException(Exceptions.getRootReason(e));
} catch (InterruptedException e) {
// next best thing after letting (checked) InterruptedException bubble up
Thread.currentThread().interrupt();
throw new IOException(e);
}
}
@Override
public void close() {
try {
closeMethod.invoke(underlying);
} catch (RuntimeException | Error e) {
throw e;
} catch (Throwable t) {
throw new AssertionError(t);
}
}
// https://docs.oracle.com/en/java/javase/11/docs/specs/security/standard-names.html#security-algorithm-implementation-requirements
private static SSLContext createSslContext(
List<Path> certificateFiles, List<URI> certificateUris) {
try {
if (certificateFiles.isEmpty() && certificateUris.isEmpty()) {
// fall back to JVM defaults (not Pkl built-in certs)
return SSLContext.getDefault();
}
var certPathBuilder = CertPathBuilder.getInstance("PKIX");
// create a non-legacy revocation checker that is configured via setOptions() instead of
// security property "ocsp.enabled"
var revocationChecker = (PKIXRevocationChecker) certPathBuilder.getRevocationChecker();
revocationChecker.setOptions(Set.of()); // prefer OCSP, fall back to CRLs
var certFactory = CertificateFactory.getInstance("X.509");
Set<TrustAnchor> trustAnchors =
createTrustAnchors(certFactory, certificateFiles, certificateUris);
var pkixParameters = new PKIXBuilderParameters(trustAnchors, new X509CertSelector());
// equivalent of "com.sun.net.ssl.checkRevocation=true"
pkixParameters.setRevocationEnabled(true);
pkixParameters.addCertPathChecker(revocationChecker);
var trustManagerFactory = TrustManagerFactory.getInstance("PKIX");
trustManagerFactory.init(new CertPathTrustManagerParameters(pkixParameters));
var sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustManagerFactory.getTrustManagers(), new SecureRandom());
return sslContext;
} catch (GeneralSecurityException e) {
throw new HttpClientInitException(
ErrorMessages.create("cannotInitHttpClient", Exceptions.getRootReason(e)), e);
}
}
private static Set<TrustAnchor> createTrustAnchors(
CertificateFactory factory, List<Path> certificateFiles, List<URI> certificateUris) {
var anchors = new HashSet<TrustAnchor>();
for (var file : certificateFiles) {
try (var stream = Files.newInputStream(file)) {
collectTrustAnchors(anchors, factory, stream, file);
} catch (NoSuchFileException e) {
throw new HttpClientInitException(ErrorMessages.create("cannotFindCertFile", file));
} catch (IOException e) {
throw new HttpClientInitException(
ErrorMessages.create("cannotReadCertFile", Exceptions.getRootReason(e)));
}
}
for (var uri : certificateUris) {
try (var stream = uri.toURL().openStream()) {
collectTrustAnchors(anchors, factory, stream, uri);
} catch (IOException e) {
throw new HttpClientInitException(
ErrorMessages.create("cannotReadCertFile", Exceptions.getRootReason(e)));
}
}
return anchors;
}
private static void collectTrustAnchors(
Collection<TrustAnchor> anchors,
CertificateFactory factory,
InputStream stream,
Object source) {
Collection<X509Certificate> certificates;
try {
//noinspection unchecked
certificates = (Collection<X509Certificate>) factory.generateCertificates(stream);
} catch (CertificateException e) {
throw new HttpClientInitException(
ErrorMessages.create("cannotParseCertFile", source, Exceptions.getRootReason(e)));
}
if (certificates.isEmpty()) {
throw new HttpClientInitException(ErrorMessages.create("emptyCertFile", source));
}
for (var certificate : certificates) {
anchors.add(new TrustAnchor(certificate, null));
}
}
}

View File

@@ -0,0 +1,81 @@
/**
* Copyright © 2024 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.
* You may obtain a copy of the License at
*
* https://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.pkl.core.http;
import java.io.IOException;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandler;
import java.util.Optional;
import java.util.function.Supplier;
import javax.annotation.concurrent.GuardedBy;
import javax.annotation.concurrent.ThreadSafe;
/**
* An {@code HttpClient} decorator that defers creating the underlying HTTP client until the first
* send.
*/
@ThreadSafe
class LazyHttpClient implements HttpClient {
private final Supplier<HttpClient> supplier;
private final Object lock = new Object();
@GuardedBy("lock")
private HttpClient client;
@GuardedBy("lock")
private RuntimeException exception;
LazyHttpClient(Supplier<HttpClient> supplier) {
this.supplier = supplier;
}
@Override
public <T> HttpResponse<T> send(HttpRequest request, BodyHandler<T> responseBodyHandler)
throws IOException {
return getOrCreateClient().send(request, responseBodyHandler);
}
@Override
public void close() {
getClient().ifPresent(HttpClient::close);
}
private HttpClient getOrCreateClient() {
synchronized (lock) {
// only try to create client once
if (exception != null) {
throw exception;
}
if (client == null) {
try {
client = supplier.get();
} catch (RuntimeException t) {
exception = t;
throw t;
}
}
return client;
}
}
private Optional<HttpClient> getClient() {
synchronized (lock) {
return Optional.ofNullable(client);
}
}
}

View File

@@ -0,0 +1,108 @@
/**
* Copyright © 2024 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.
* You may obtain a copy of the License at
*
* https://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.pkl.core.http;
import java.io.IOException;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandler;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.annotation.concurrent.ThreadSafe;
/**
* An {@code HttpClient} decorator that
*
* <ul>
* <li>overrides the {@code User-Agent} header of {@code HttpRequest}s
* <li>sets a request timeout if none is present
* <li>ensures that {@link #close()} is idempotent.
* </ul>
*
* <p>Both {@code User-Agent} header and default request timeout are configurable through {@link
* HttpClient.Builder}.
*/
@ThreadSafe
final class RequestRewritingClient implements HttpClient {
// non-private for testing
final String userAgent;
final Duration requestTimeout;
final HttpClient delegate;
private final AtomicBoolean closed = new AtomicBoolean();
RequestRewritingClient(String userAgent, Duration requestTimeout, HttpClient delegate) {
this.userAgent = userAgent;
this.requestTimeout = requestTimeout;
this.delegate = delegate;
}
@Override
public <T> HttpResponse<T> send(HttpRequest request, BodyHandler<T> responseBodyHandler)
throws IOException {
checkNotClosed(request);
return delegate.send(rewriteRequest(request), responseBodyHandler);
}
@Override
public void close() {
if (!closed.getAndSet(true)) delegate.close();
}
// Based on JDK 17's implementation of HttpRequest.newBuilder(HttpRequest, filter).
private HttpRequest rewriteRequest(HttpRequest original) {
HttpRequest.Builder builder = HttpRequest.newBuilder();
builder
.uri(original.uri())
.expectContinue(original.expectContinue())
.timeout(original.timeout().orElse(requestTimeout))
.version(original.version().orElse(java.net.http.HttpClient.Version.HTTP_2));
original
.headers()
.map()
.forEach((name, values) -> values.forEach(value -> builder.header(name, value)));
builder.setHeader("User-Agent", userAgent);
var method = original.method();
original
.bodyPublisher()
.ifPresentOrElse(
publisher -> builder.method(method, publisher),
() -> {
switch (method) {
case "GET":
builder.GET();
break;
case "DELETE":
builder.DELETE();
break;
default:
builder.method(method, HttpRequest.BodyPublishers.noBody());
}
});
return builder.build();
}
private void checkNotClosed(HttpRequest request) {
if (closed.get()) {
throw new IllegalStateException(
"Cannot send request " + request + " because this client has already been closed.");
}
}
}

View File

@@ -21,6 +21,8 @@ import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse.BodyHandlers;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.List;
@@ -34,6 +36,7 @@ import org.pkl.core.packages.PackageLoadError;
import org.pkl.core.packages.PackageResolver;
import org.pkl.core.runtime.VmContext;
import org.pkl.core.util.ErrorMessages;
import org.pkl.core.util.HttpUtils;
import org.pkl.core.util.IoUtils;
import org.pkl.core.util.Nullable;
import org.pkl.core.util.Pair;
@@ -481,6 +484,19 @@ public final class ModuleKeys {
throws IOException, SecurityManagerException {
securityManager.checkResolveModule(uri);
if (HttpUtils.isHttpUrl(uri)) {
var httpClient = VmContext.get(null).getHttpClient();
var request = HttpRequest.newBuilder(uri).build();
var response = httpClient.send(request, BodyHandlers.ofInputStream());
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);
}
}
var url = IoUtils.toUrl(uri);
var conn = url.openConnection();
conn.connect();
@@ -494,6 +510,7 @@ public final class ModuleKeys {
}
securityManager.checkResolveModule(redirected);
var text = IoUtils.readString(stream);
// intentionally use uri instead of redirected
return ResolvedModuleKeys.virtual(this, uri, text, true);
}
}

View File

@@ -18,9 +18,13 @@ package org.pkl.core.module;
import java.io.IOException;
import java.net.URI;
import java.net.URL;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse.BodyHandlers;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.pkl.core.runtime.VmContext;
import org.pkl.core.util.HttpUtils;
import org.pkl.core.util.IoUtils;
/** Utilities for obtaining and using resolved module keys. */
@@ -102,6 +106,14 @@ public final class ResolvedModuleKeys {
@Override
public String loadSource() throws IOException {
if (HttpUtils.isHttpUrl(url)) {
var httpClient = VmContext.get(null).getHttpClient();
var request = HttpRequest.newBuilder(uri).build();
var response = httpClient.send(request, BodyHandlers.ofString());
HttpUtils.checkHasStatusCode200(response);
return response.body();
}
return IoUtils.readString(url);
}
}

View File

@@ -22,6 +22,7 @@ import java.util.List;
import javax.naming.OperationNotSupportedException;
import org.pkl.core.SecurityManager;
import org.pkl.core.SecurityManagerException;
import org.pkl.core.http.HttpClient;
import org.pkl.core.module.PathElement;
import org.pkl.core.packages.PackageResolvers.DiskCachedPackageResolver;
import org.pkl.core.packages.PackageResolvers.InMemoryPackageResolver;
@@ -30,10 +31,11 @@ import org.pkl.core.util.Pair;
public interface PackageResolver extends Closeable {
static PackageResolver getInstance(SecurityManager securityManager, @Nullable Path cachedDir) {
static PackageResolver getInstance(
SecurityManager securityManager, HttpClient httpClient, @Nullable Path cachedDir) {
return cachedDir == null
? new InMemoryPackageResolver(securityManager)
: new DiskCachedPackageResolver(securityManager, cachedDir);
? new InMemoryPackageResolver(securityManager, httpClient)
: new DiskCachedPackageResolver(securityManager, httpClient, cachedDir);
}
DependencyMetadata getDependencyMetadata(PackageUri uri, @Nullable Checksums checksums)

View File

@@ -21,6 +21,9 @@ import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystem;
@@ -41,11 +44,10 @@ import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
import java.util.zip.ZipInputStream;
import javax.annotation.concurrent.GuardedBy;
import javax.net.ssl.HttpsURLConnection;
import org.graalvm.collections.EconomicMap;
import org.pkl.core.Release;
import org.pkl.core.SecurityManager;
import org.pkl.core.SecurityManagerException;
import org.pkl.core.http.HttpClient;
import org.pkl.core.module.FileResolver;
import org.pkl.core.module.PathElement;
import org.pkl.core.module.PathElement.TreePathElement;
@@ -53,6 +55,7 @@ import org.pkl.core.runtime.FileSystemManager;
import org.pkl.core.runtime.VmExceptionBuilder;
import org.pkl.core.util.ByteArrayUtils;
import org.pkl.core.util.EconomicMaps;
import org.pkl.core.util.HttpUtils;
import org.pkl.core.util.IoUtils;
import org.pkl.core.util.Nullable;
import org.pkl.core.util.Pair;
@@ -60,24 +63,20 @@ import org.pkl.core.util.json.Json.JsonParseException;
class PackageResolvers {
abstract static class AbstractPackageResolver implements PackageResolver {
private static final String USER_AGENT;
static {
var release = Release.current();
USER_AGENT = "Pkl/" + release.version() + " (" + release.os() + "; " + release.flavor() + ")";
}
@GuardedBy("lock")
private final EconomicMap<PackageUri, DependencyMetadata> cachedDependencyMetadata;
private final SecurityManager securityManager;
protected final HttpClient httpClient;
private final AtomicBoolean isClosed = new AtomicBoolean();
protected final Object lock = new Object();
protected AbstractPackageResolver(SecurityManager securityManager) {
protected AbstractPackageResolver(SecurityManager securityManager, HttpClient httpClient) {
this.securityManager = securityManager;
this.httpClient = httpClient;
cachedDependencyMetadata = EconomicMaps.create();
}
@@ -189,21 +188,26 @@ class PackageResolvers {
}
}
protected InputStream openExternalUri(URI uri) throws SecurityManagerException, IOException {
protected InputStream openExternalUri(URI uri) throws SecurityManagerException {
if (!HttpUtils.isHttpUrl(uri)) {
throw new IllegalArgumentException("Expected HTTP(S) URL, but got: " + uri);
}
// treat package assets as resources instead of modules
securityManager.checkReadResource(uri);
var connection = (HttpsURLConnection) uri.toURL().openConnection();
connection.setRequestProperty("User-Agent", USER_AGENT);
int responseCode;
var request = HttpRequest.newBuilder(uri).build();
HttpResponse<InputStream> response;
try {
responseCode = connection.getResponseCode();
if (responseCode != 200) {
throw new PackageLoadError("badHttpStatusCode", responseCode, uri);
}
response = httpClient.send(request, BodyHandlers.ofInputStream());
} catch (IOException e) {
throw new PackageLoadError(e, "ioErrorMakingHttpGet", uri, e.getMessage());
}
return connection.getInputStream();
try {
HttpUtils.checkHasStatusCode200(response);
} catch (IOException e) {
throw new PackageLoadError("badHttpStatusCode", response.statusCode(), response.uri());
}
return response.body();
}
protected IOException fileIsADirectory() {
@@ -251,8 +255,8 @@ class PackageResolvers {
private final EconomicMap<PackageUri, TreePathElement> cachedTreePathElementRoots =
EconomicMaps.create();
InMemoryPackageResolver(SecurityManager securityManager) {
super(securityManager);
InMemoryPackageResolver(SecurityManager securityManager, HttpClient httpClient) {
super(securityManager, httpClient);
}
private byte[] getPackageBytes(PackageUri packageUri, DependencyMetadata metadata)
@@ -419,8 +423,9 @@ class PackageResolvers {
PosixFilePermission.GROUP_READ,
PosixFilePermission.OTHERS_READ);
public DiskCachedPackageResolver(SecurityManager securityManager, Path cacheDir) {
super(securityManager);
public DiskCachedPackageResolver(
SecurityManager securityManager, HttpClient httpClient, Path cacheDir) {
super(securityManager, httpClient);
this.cacheDir = cacheDir;
this.tmpDir = cacheDir.resolve("tmp");
}
@@ -463,8 +468,8 @@ class PackageResolvers {
if (checksums != null) {
inputStream = newDigestInputStream(inputStream);
}
Files.createDirectories(path.getParent());
try (var in = inputStream) {
Files.createDirectories(path.getParent());
Files.copy(in, path);
if (checksums != null) {
var digestInputStream = (DigestInputStream) inputStream;

View File

@@ -43,6 +43,7 @@ import org.pkl.core.SecurityManager;
import org.pkl.core.SecurityManagerException;
import org.pkl.core.StackFrameTransformer;
import org.pkl.core.ast.builder.ImportsAndReadsParser;
import org.pkl.core.http.HttpClient;
import org.pkl.core.module.ModuleKeys;
import org.pkl.core.module.ProjectDependenciesManager;
import org.pkl.core.module.ResolvedModuleKeys;
@@ -105,6 +106,7 @@ public class ProjectPackager {
String outputPathPattern,
StackFrameTransformer stackFrameTransformer,
SecurityManager securityManager,
HttpClient httpClient,
boolean skipPublishCheck,
Writer outputWriter) {
this.projects = projects;
@@ -112,7 +114,7 @@ public class ProjectPackager {
this.outputPathPattern = outputPathPattern;
this.stackFrameTransformer = stackFrameTransformer;
// intentionally use InMemoryPackageResolver
this.packageResolver = PackageResolver.getInstance(securityManager, null);
this.packageResolver = PackageResolver.getInstance(securityManager, httpClient, null);
this.skipPublishCheck = skipPublishCheck;
this.outputWriter = outputWriter;
}

View File

@@ -33,6 +33,7 @@ import org.pkl.core.ast.builder.AstBuilder;
import org.pkl.core.ast.member.*;
import org.pkl.core.ast.repl.ResolveClassMemberNode;
import org.pkl.core.ast.type.TypeNode;
import org.pkl.core.http.HttpClient;
import org.pkl.core.module.*;
import org.pkl.core.packages.PackageResolver;
import org.pkl.core.parser.LexParseException;
@@ -67,6 +68,7 @@ public class ReplServer implements AutoCloseable {
public ReplServer(
SecurityManager securityManager,
HttpClient httpClient,
Logger logger,
Collection<ModuleKeyFactory> moduleKeyFactories,
Collection<ResourceReader> resourceReaders,
@@ -85,7 +87,7 @@ public class ReplServer implements AutoCloseable {
replState = new ReplState(createEmptyReplModule(BaseModule.getModuleClass().getPrototype()));
var languageRef = new MutableReference<VmLanguage>(null);
packageResolver = PackageResolver.getInstance(securityManager, moduleCacheDir);
packageResolver = PackageResolver.getInstance(securityManager, httpClient, moduleCacheDir);
projectDependenciesManager =
projectDependencies == null ? null : new ProjectDependenciesManager(projectDependencies);
polyglotContext =
@@ -97,6 +99,7 @@ public class ReplServer implements AutoCloseable {
new VmContext.Holder(
frameTransformer,
securityManager,
httpClient,
moduleResolver,
new ResourceManager(securityManager, resourceReaders),
logger,

View File

@@ -19,6 +19,8 @@ import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse.BodyHandlers;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -38,6 +40,7 @@ import org.pkl.core.packages.PackageAssetUri;
import org.pkl.core.packages.PackageResolver;
import org.pkl.core.runtime.VmContext;
import org.pkl.core.util.ErrorMessages;
import org.pkl.core.util.HttpUtils;
import org.pkl.core.util.IoUtils;
import org.pkl.core.util.Nullable;
@@ -291,6 +294,15 @@ public final class ResourceReaders {
private abstract static class UrlResource implements ResourceReader {
@Override
public Optional<Object> read(URI uri) throws IOException {
if (HttpUtils.isHttpUrl(uri)) {
var httpClient = VmContext.get(null).getHttpClient();
var request = HttpRequest.newBuilder(uri).build();
var response = httpClient.send(request, BodyHandlers.ofByteArray());
if (response.statusCode() == 404) return Optional.empty();
HttpUtils.checkHasStatusCode200(response);
return Optional.of(new Resource(uri, response.body()));
}
try {
var url = IoUtils.toUrl(uri);
var content = IoUtils.readBytes(url);

View File

@@ -1,93 +0,0 @@
/**
* Copyright © 2024 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.
* You may obtain a copy of the License at
*
* https://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.pkl.core.runtime;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyManagementException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.Security;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
public class CertificateUtils {
public static void setupAllX509CertificatesGlobally(List<Object> certs) {
try {
var certificates = new ArrayList<X509Certificate>(certs.size());
for (var cert : certs) {
try (var input = toInputStream(cert)) {
certificates.addAll(generateCertificates(input));
}
}
setupX509CertificatesGlobally(certificates);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static InputStream toInputStream(Object cert) throws IOException {
if (cert instanceof Path) {
var pathCert = (Path) cert;
return Files.newInputStream(pathCert);
}
if (cert instanceof InputStream) {
return (InputStream) cert;
}
throw new IllegalArgumentException(
"Unknown class for certificate: "
+ cert.getClass()
+ ". Valid types: java.nio.Path, java.io.InputStream");
}
private static Collection<X509Certificate> generateCertificates(InputStream inputStream)
throws CertificateException {
//noinspection unchecked
return (Collection<X509Certificate>)
CertificateFactory.getInstance("X.509").generateCertificates(inputStream);
}
private static void setupX509CertificatesGlobally(Collection<X509Certificate> certs)
throws KeyStoreException, CertificateException, IOException, NoSuchAlgorithmException,
KeyManagementException {
System.setProperty("com.sun.net.ssl.checkRevocation", "true");
Security.setProperty("ocsp.enable", "true");
var keystore = KeyStore.getInstance(KeyStore.getDefaultType());
keystore.load(null);
var count = 1;
for (var cert : certs) {
keystore.setCertificateEntry("Certificate" + count++, cert);
}
var tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(keystore);
var sc = SSLContext.getInstance("SSL");
sc.init(null, tmf.getTrustManagers(), new SecureRandom());
HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
}
}

View File

@@ -27,6 +27,7 @@ import java.util.Map;
import java.util.Optional;
import org.pkl.core.SecurityManager;
import org.pkl.core.SecurityManagerException;
import org.pkl.core.http.HttpClientInitException;
import org.pkl.core.module.ModuleKey;
import org.pkl.core.packages.PackageLoadError;
import org.pkl.core.resource.Resource;
@@ -105,7 +106,7 @@ public final class ResourceManager {
.withHint(e.getMessage())
.withLocation(readNode)
.build();
} catch (SecurityManagerException e) {
} catch (SecurityManagerException | HttpClientInitException e) {
throw new VmExceptionBuilder().withCause(e).withLocation(readNode).build();
} catch (IOException e) {
throw new VmExceptionBuilder()
@@ -151,7 +152,7 @@ public final class ResourceManager {
.withHint(e.getReason())
.withLocation(readNode)
.build();
} catch (SecurityManagerException | PackageLoadError e) {
} catch (SecurityManagerException | PackageLoadError | HttpClientInitException e) {
throw new VmExceptionBuilder().withCause(e).withLocation(readNode).build();
}
if (resource.isEmpty()) return resource;

View File

@@ -22,6 +22,7 @@ import java.util.Map;
import org.pkl.core.Loggers;
import org.pkl.core.SecurityManagers;
import org.pkl.core.StackFrameTransformers;
import org.pkl.core.http.HttpClient;
import org.pkl.core.module.ModuleKeyFactories;
import org.pkl.core.module.ModuleKeys;
import org.pkl.core.module.ResolvedModuleKey;
@@ -39,6 +40,7 @@ public abstract class StdLibModule {
new VmContext.Holder(
StackFrameTransformers.defaultTransformer,
SecurityManagers.defaultManager,
HttpClient.dummyClient(),
new ModuleResolver(List.of(ModuleKeyFactories.standardLibrary)),
new ResourceManager(SecurityManagers.defaultManager, List.of()),
Loggers.noop(),

View File

@@ -23,6 +23,7 @@ import java.util.Map;
import org.pkl.core.Logger;
import org.pkl.core.SecurityManager;
import org.pkl.core.StackFrameTransformer;
import org.pkl.core.http.HttpClient;
import org.pkl.core.module.ProjectDependenciesManager;
import org.pkl.core.packages.PackageResolver;
import org.pkl.core.util.LateInit;
@@ -39,6 +40,7 @@ public final class VmContext {
private final StackFrameTransformer frameTransformer;
private final SecurityManager securityManager;
private final HttpClient httpClient;
private final ModuleResolver moduleResolver;
private final ResourceManager resourceManager;
private final Logger logger;
@@ -52,6 +54,7 @@ public final class VmContext {
public Holder(
StackFrameTransformer frameTransformer,
SecurityManager securityManager,
HttpClient httpClient,
ModuleResolver moduleResolver,
ResourceManager resourceManager,
Logger logger,
@@ -64,6 +67,7 @@ public final class VmContext {
this.frameTransformer = frameTransformer;
this.securityManager = securityManager;
this.httpClient = httpClient;
this.moduleResolver = moduleResolver;
this.resourceManager = resourceManager;
this.logger = logger;
@@ -108,6 +112,10 @@ public final class VmContext {
return holder.securityManager;
}
public HttpClient getHttpClient() {
return holder.httpClient;
}
public ModuleResolver getModuleResolver() {
return holder.moduleResolver;
}

View File

@@ -17,10 +17,19 @@ package org.pkl.core.service;
import static org.pkl.core.module.ProjectDependenciesManager.PKL_PROJECT_FILENAME;
import java.net.URI;
import java.nio.file.Path;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.pkl.core.*;
import org.pkl.core.http.HttpClient;
import org.pkl.core.module.ModuleKeyFactories;
import org.pkl.core.module.ModulePathResolver;
import org.pkl.core.project.Project;
@@ -28,10 +37,30 @@ import org.pkl.core.resource.ResourceReaders;
import org.pkl.executor.spi.v1.ExecutorSpi;
import org.pkl.executor.spi.v1.ExecutorSpiException;
import org.pkl.executor.spi.v1.ExecutorSpiOptions;
import org.pkl.executor.spi.v1.ExecutorSpiOptions2;
public class ExecutorSpiImpl implements ExecutorSpi {
private static final int MAX_HTTP_CLIENTS = 3;
// Don't create a new HTTP client for every executor request.
// Instead, keep a cache of up to MAX_HTTP_CLIENTS clients.
// A cache size of 1 should be common.
private final Map<HttpClientKey, HttpClient> httpClients;
private final String pklVersion = Release.current().version().toString();
public ExecutorSpiImpl() {
// only LRU cache available in JDK
var map =
new LinkedHashMap<HttpClientKey, HttpClient>(8, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Entry<HttpClientKey, HttpClient> eldest) {
return size() > MAX_HTTP_CLIENTS;
}
};
httpClients = Collections.synchronizedMap(map);
}
@Override
public String getPklVersion() {
return pklVersion;
@@ -65,6 +94,7 @@ public class ExecutorSpiImpl implements ExecutorSpi {
EvaluatorBuilder.unconfigured()
.setStackFrameTransformer(transformer)
.setSecurityManager(securityManager)
.setHttpClient(getOrCreateHttpClient(options))
.addResourceReader(ResourceReaders.environmentVariable())
.addResourceReader(ResourceReaders.externalProperty())
.addResourceReader(ResourceReaders.modulePath(resolver))
@@ -98,4 +128,61 @@ public class ExecutorSpiImpl implements ExecutorSpi {
ModuleKeyFactories.closeQuietly(builder.getModuleKeyFactories());
}
}
private HttpClient getOrCreateHttpClient(ExecutorSpiOptions options) {
List<Path> certificateFiles;
List<URI> certificateUris;
if (options instanceof ExecutorSpiOptions2) {
var options2 = (ExecutorSpiOptions2) options;
certificateFiles = options2.getCertificateFiles();
certificateUris = options2.getCertificateUris();
} else {
certificateFiles = List.of();
certificateUris = List.of();
}
var clientKey = new HttpClientKey(certificateFiles, certificateUris);
return httpClients.computeIfAbsent(
clientKey,
(key) -> {
var builder = HttpClient.builder();
for (var file : key.certificateFiles) {
builder.addCertificates(file);
}
for (var uri : key.certificateUris) {
builder.addCertificates(uri);
}
// If the above didn't add any certificates,
// builder will use the JVM's default SSL context.
return builder.buildLazily();
});
}
private static final class HttpClientKey {
final Set<Path> certificateFiles;
final Set<URI> certificateUris;
HttpClientKey(List<Path> certificateFiles, List<URI> certificateUris) {
// also serve as defensive copies
this.certificateFiles = Set.copyOf(certificateFiles);
this.certificateUris = Set.copyOf(certificateUris);
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
HttpClientKey that = (HttpClientKey) obj;
return certificateFiles.equals(that.certificateFiles)
&& certificateUris.equals(that.certificateUris);
}
@Override
public int hashCode() {
return Objects.hash(certificateFiles, certificateUris);
}
}
}

View File

@@ -0,0 +1,36 @@
/**
* Copyright © 2024 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.
* You may obtain a copy of the License at
*
* https://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.pkl.core.util;
public final class Exceptions {
private Exceptions() {}
public static Throwable getRootCause(Throwable t) {
var result = t;
var cause = result.getCause();
while (cause != null) {
result = cause;
cause = cause.getCause();
}
return result;
}
public static String getRootReason(Throwable t) {
var reason = getRootCause(t).getMessage();
if (reason == null || reason.isEmpty()) reason = "(unknown reason)";
return reason;
}
}

View File

@@ -0,0 +1,50 @@
/**
* Copyright © 2024 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.
* You may obtain a copy of the License at
*
* https://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.pkl.core.util;
import java.io.IOException;
import java.net.URI;
import java.net.URL;
import java.net.http.HttpResponse;
public final class HttpUtils {
private HttpUtils() {}
public static boolean isHttpUrl(URL url) {
var protocol = url.getProtocol();
return "https".equalsIgnoreCase(protocol) || "http".equalsIgnoreCase(protocol);
}
public static boolean isHttpUrl(URI uri) {
var scheme = uri.getScheme();
return "https".equalsIgnoreCase(scheme) || "http".equalsIgnoreCase(scheme);
}
public static void checkHasStatusCode200(HttpResponse<?> response) throws IOException {
if (response.statusCode() == 200) return;
var body = response.body();
if (body instanceof AutoCloseable) {
try {
((AutoCloseable) body).close();
} catch (Exception ignored) {
}
}
throw new IOException(
ErrorMessages.create("badHttpStatusCode", response.statusCode(), response.uri()));
}
}

View File

@@ -100,6 +100,9 @@ public final class IoUtils {
}
public static String readString(URL url) throws IOException {
if (HttpUtils.isHttpUrl(url)) {
throw new IllegalArgumentException("Should use HTTP client to GET " + url);
}
try (var stream = url.openStream()) {
return readString(stream);
}
@@ -110,6 +113,9 @@ public final class IoUtils {
}
public static byte[] readBytes(URL url) throws IOException {
if (HttpUtils.isHttpUrl(url)) {
throw new IllegalArgumentException("Should use HTTP client to GET " + url);
}
try (var stream = url.openStream()) {
return stream.readAllBytes();
}

View File

@@ -940,6 +940,31 @@ ioErrorMakingHttpGet=\
Exception when making request `GET {0}`:\n\
{1}
errorConnectingToHost=\
Error connecting to host `{0}`.
errorSslHandshake=\
Error during SSL handshake with host `{0}`:\n\
{1}
cannotInitHttpClient=\
Error initializing HTTP client:\n\
{0}
cannotFindCertFile=\
Cannot find CA certificate file `{0}`.
cannotReadCertFile=\
Error reading CA certificate file `{0}`:\n\
{1}
cannotParseCertFile=\
Error parsing CA certificate file `{0}`:\n\
{1}
emptyCertFile=\
CA certificate file `{0}` is empty.
invalidPackageZipUrl=\
Expected the zip asset for package `{0}` to be an HTTPS URI, but got `{1}`.
@@ -1019,3 +1044,11 @@ The only supported checksum algorithm is sha256.
testsFailed=\
Tests failed.
expectedJarOrFileUrl=\
Certificates can only be loaded from `jar:` or `file:` URLs, but got:\n\
{0}
cannotFindBuiltInCertificates=\
Cannot find Pkl's trusted CA certificates on the class path.\n\
To fix this problem, add dependendy `org.pkl:pkl-certs`.

View File

@@ -3,6 +3,7 @@ package org.pkl.core
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.pkl.commons.toPath
import org.pkl.core.http.HttpClient
import org.pkl.core.module.ModuleKeyFactories
import org.pkl.core.repl.ReplRequest
import org.pkl.core.repl.ReplResponse
@@ -12,6 +13,7 @@ import org.pkl.core.resource.ResourceReaders
class ReplServerTest {
private val server = ReplServer(
SecurityManagers.defaultManager,
HttpClient.dummyClient(),
Loggers.stdErr(),
listOf(
ModuleKeyFactories.standardLibrary,

View File

@@ -0,0 +1,34 @@
package org.pkl.core.http
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertDoesNotThrow
import org.junit.jupiter.api.assertThrows
import java.net.URI
import java.net.http.HttpRequest
import java.net.http.HttpResponse
class DummyHttpClientTest {
@Test
fun `refuses to send messages`() {
val client = HttpClient.dummyClient()
val request = HttpRequest.newBuilder(URI("https://example.com")).build()
assertThrows<AssertionError> {
client.send(request, HttpResponse.BodyHandlers.discarding())
}
assertThrows<AssertionError> {
client.send(request, HttpResponse.BodyHandlers.discarding())
}
}
@Test
fun `can be closed`() {
val client = HttpClient.dummyClient()
assertDoesNotThrow {
client.close()
client.close()
}
}
}

View File

@@ -0,0 +1,148 @@
package org.pkl.core.http
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertDoesNotThrow
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.io.TempDir
import org.pkl.commons.test.FileTestUtils
import org.pkl.core.Release
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.copyTo
import kotlin.io.path.createDirectories
import kotlin.io.path.createFile
class HttpClientTest {
@Test
fun `can build default client`() {
val client = assertDoesNotThrow {
HttpClient.builder().build()
}
assertThat(client).isInstanceOf(RequestRewritingClient::class.java)
client as RequestRewritingClient
val release = Release.current()
assertThat(client.userAgent).isEqualTo("Pkl/${release.version()} (${release.os()}; ${release.flavor()})")
assertThat(client.requestTimeout).isEqualTo(Duration.ofSeconds(60))
assertThat(client.delegate).isInstanceOf(JdkHttpClient::class.java)
val delegate = client.delegate as JdkHttpClient
assertThat(delegate.underlying.connectTimeout()).hasValue(Duration.ofSeconds(60))
}
@Test
fun `can build custom client`() {
val client = HttpClient.builder()
.setUserAgent("Agent 1")
.setRequestTimeout(Duration.ofHours(86))
.setConnectTimeout(Duration.ofMinutes(42))
.build() as RequestRewritingClient
assertThat(client.userAgent).isEqualTo("Agent 1")
assertThat(client.requestTimeout).isEqualTo(Duration.ofHours(86))
val delegate = client.delegate as JdkHttpClient
assertThat(delegate.underlying.connectTimeout()).hasValue(Duration.ofMinutes(42))
}
@Test
fun `can load certificates from file system`() {
assertDoesNotThrow {
HttpClient.builder().addCertificates(FileTestUtils.selfSignedCertificate).build()
}
}
@Test
fun `certificate file located on file system cannot be empty`(@TempDir tempDir: Path) {
val file = tempDir.resolve("certs.pem").createFile()
val e = assertThrows<HttpClientInitException> {
HttpClient.builder().addCertificates(file).build()
}
assertThat(e).hasMessageContaining("empty")
}
@Test
fun `can load certificates from class path`() {
assertDoesNotThrow {
HttpClient.builder().addCertificates(javaClass.getResource("/org/pkl/certs/PklCARoots.pem")!!.toURI()).build()
}
}
@Test
fun `only allows loading jar and file certificate URIs`() {
assertThrows<HttpClientInitException> {
HttpClient.builder().addCertificates(URI("https://example.com"))
}
}
@Test
fun `certificate file located on class path cannot be empty`() {
val uri = javaClass.getResource("emptyCerts.pem")!!.toURI()
val e = assertThrows<HttpClientInitException> {
HttpClient.builder().addCertificates(uri).build()
}
assertThat(e).hasMessageContaining("empty")
}
@Test
fun `can load built-in certificates`() {
assertDoesNotThrow {
HttpClient.builder().addBuiltInCertificates().build()
}
}
@Test
fun `can load certificates from Pkl user home cacerts directory`(@TempDir tempDir: Path) {
val certFile = tempDir.resolve(".pkl")
.resolve("cacerts")
.createDirectories()
.resolve("certs.pem")
FileTestUtils.selfSignedCertificate.copyTo(certFile)
assertDoesNotThrow {
HttpClientBuilder(tempDir).addDefaultCliCertificates().build()
}
}
@Test
fun `loading certificates from cacerts directory falls back to built-in certificates`(@TempDir userHome: Path) {
assertDoesNotThrow {
HttpClientBuilder(userHome).addDefaultCliCertificates().build()
}
}
@Test
fun `can be closed multiple times`() {
val client = HttpClient.builder().build()
assertDoesNotThrow {
client.close()
client.close()
}
}
@Test
fun `refuses to send messages once closed`() {
val client = HttpClient.builder().build()
val request = HttpRequest.newBuilder(URI("https://example.com")).build()
client.close()
assertThrows<IllegalStateException> {
client.send(request, HttpResponse.BodyHandlers.discarding())
}
assertThrows<IllegalStateException> {
client.send(request, HttpResponse.BodyHandlers.discarding())
}
}
}

View File

@@ -0,0 +1,34 @@
package org.pkl.core.http
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertDoesNotThrow
import org.junit.jupiter.api.assertThrows
import java.net.URI
import java.net.http.HttpRequest
import java.net.http.HttpResponse.BodyHandlers
class LazyHttpClientTest {
@Test
fun `builds underlying client on first send`() {
val client = HttpClient.builder()
.addCertificates(javaClass.getResource("brokenCerts.pem")!!.toURI())
.buildLazily()
val request = HttpRequest.newBuilder(URI("https://example.com")).build()
assertThrows<HttpClientInitException> {
client.send(request, BodyHandlers.discarding())
}
}
@Test
fun `does not build underlying client unnecessarily`() {
val client = HttpClient.builder()
.addCertificates(javaClass.getResource("brokenCerts.pem")!!.toURI())
.buildLazily()
assertDoesNotThrow {
client.close()
client.close()
}
}
}

View File

@@ -0,0 +1,18 @@
package org.pkl.core.http
import java.net.http.HttpRequest
import java.net.http.HttpResponse
class RequestCapturingClient : HttpClient {
lateinit var request: HttpRequest
override fun <T : Any?> send(
request: HttpRequest,
responseBodyHandler: HttpResponse.BodyHandler<T>
): HttpResponse<T>? {
this.request = request
return null
}
override fun close() {}
}

View File

@@ -0,0 +1,108 @@
package org.pkl.core.http
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatList
import org.junit.jupiter.api.Test
import java.net.URI
import java.net.http.HttpRequest
import java.net.http.HttpRequest.BodyPublishers
import java.net.http.HttpResponse
import java.net.http.HttpResponse.BodyHandlers
import java.time.Duration
import java.net.http.HttpClient as JdkHttpClient
class RequestRewritingClientTest {
private val captured = RequestCapturingClient()
private val client = RequestRewritingClient("Pkl", Duration.ofSeconds(42), captured)
private val exampleUri = URI("https://example.com/foo/bar.html")
private val exampleRequest = HttpRequest.newBuilder(exampleUri).build()
@Test
fun `fills in missing User-Agent header`() {
client.send(exampleRequest, BodyHandlers.discarding())
assertThatList(captured.request.headers().allValues("User-Agent")).containsOnly("Pkl")
}
@Test
fun `overrides existing User-Agent headers`() {
val request = HttpRequest.newBuilder(exampleUri)
.header("User-Agent", "Agent 1")
.header("User-Agent", "Agent 2")
.build()
client.send(request, BodyHandlers.discarding())
assertThatList(captured.request.headers().allValues("User-Agent")).containsOnly("Pkl")
}
@Test
fun `fills in missing request timeout`() {
client.send(exampleRequest, BodyHandlers.discarding())
assertThat(captured.request.timeout()).hasValue(Duration.ofSeconds(42))
}
@Test
fun `leaves existing request timeout intact`() {
val request = HttpRequest.newBuilder(exampleUri)
.timeout(Duration.ofMinutes(33))
.build()
client.send(request, BodyHandlers.discarding())
assertThat(captured.request.timeout()).hasValue(Duration.ofMinutes(33))
}
@Test
fun `fills in missing HTTP version`() {
client.send(exampleRequest, BodyHandlers.discarding())
assertThat(captured.request.version()).hasValue(JdkHttpClient.Version.HTTP_2)
}
@Test
fun `leaves existing HTTP version intact`() {
val request = HttpRequest.newBuilder(exampleUri)
.version(JdkHttpClient.Version.HTTP_1_1)
.build()
client.send(request, BodyHandlers.discarding())
assertThat(captured.request.version()).hasValue(JdkHttpClient.Version.HTTP_1_1)
}
@Test
fun `leaves default method intact`() {
val request = HttpRequest.newBuilder(exampleUri).build()
client.send(request, BodyHandlers.discarding())
assertThat(captured.request.method()).isEqualTo("GET")
}
@Test
fun `leaves explicit method intact`() {
val request = HttpRequest.newBuilder(exampleUri)
.DELETE()
.build()
client.send(request, BodyHandlers.discarding())
assertThat(captured.request.method()).isEqualTo("DELETE")
}
@Test
fun `leaves body publisher intact`() {
val publisher = BodyPublishers.ofString("body")
val request = HttpRequest.newBuilder(exampleUri)
.PUT(publisher)
.build()
client.send(request, BodyHandlers.discarding())
assertThat(captured.request.bodyPublisher().get()).isSameAs(publisher)
}
}

View File

@@ -13,9 +13,9 @@ import org.pkl.commons.readString
import org.pkl.commons.test.FileTestUtils
import org.pkl.commons.test.PackageServer
import org.pkl.commons.test.listFilesRecursively
import org.pkl.core.http.HttpClient
import org.pkl.core.SecurityManagers
import org.pkl.core.module.PathElement
import org.pkl.core.runtime.CertificateUtils
import java.io.FileNotFoundException
import java.io.IOException
import java.nio.charset.StandardCharsets
@@ -34,9 +34,12 @@ class PackageResolversTest {
@JvmStatic
@BeforeAll
fun beforeAll() {
CertificateUtils.setupAllX509CertificatesGlobally(listOf(FileTestUtils.selfSignedCertificate))
PackageServer.ensureStarted()
}
val httpClient: HttpClient = HttpClient.builder()
.addCertificates(FileTestUtils.selfSignedCertificate)
.build()
}
@Test
@@ -196,16 +199,17 @@ class PackageResolversTest {
@BeforeAll
@JvmStatic
fun beforeAll() {
CertificateUtils.setupAllX509CertificatesGlobally(listOf(FileTestUtils.selfSignedCertificate))
PackageServer.ensureStarted()
cacheDir.deleteRecursively()
}
}
override val resolver: PackageResolver = PackageResolvers.DiskCachedPackageResolver(SecurityManagers.defaultManager, cacheDir)
override val resolver: PackageResolver = PackageResolvers.DiskCachedPackageResolver(
SecurityManagers.defaultManager, httpClient, cacheDir)
}
class InMemoryPackageResolverTest : AbstractPackageResolverTest() {
override val resolver: PackageResolver = PackageResolvers.InMemoryPackageResolver(SecurityManagers.defaultManager)
override val resolver: PackageResolver = PackageResolvers.InMemoryPackageResolver(
SecurityManagers.defaultManager, httpClient)
}
}

View File

@@ -6,10 +6,10 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.pkl.commons.test.FileTestUtils
import org.pkl.commons.test.PackageServer
import org.pkl.core.http.HttpClient
import org.pkl.core.PklException
import org.pkl.core.SecurityManagers
import org.pkl.core.packages.PackageResolver
import org.pkl.core.runtime.CertificateUtils
import java.io.ByteArrayOutputStream
import java.nio.charset.StandardCharsets
import java.nio.file.Path
@@ -19,16 +19,19 @@ class ProjectDependenciesResolverTest {
@JvmStatic
@BeforeAll
fun beforeAll() {
CertificateUtils.setupAllX509CertificatesGlobally(listOf(FileTestUtils.selfSignedCertificate))
PackageServer.ensureStarted()
}
val httpClient: HttpClient = HttpClient.builder()
.addCertificates(FileTestUtils.selfSignedCertificate)
.build()
}
@Test
fun resolveDependencies() {
val project2Path = Path.of(javaClass.getResource("project2/PklProject")!!.path)
val project = Project.loadFromPath(project2Path)
val packageResolver = PackageResolver.getInstance(SecurityManagers.defaultManager, null)
val packageResolver = PackageResolver.getInstance(SecurityManagers.defaultManager, httpClient, null)
val deps = ProjectDependenciesResolver(project, packageResolver, System.out.writer()).resolve()
val strDeps = ByteArrayOutputStream()
.apply { deps.writeTo(this) }
@@ -66,7 +69,7 @@ class ProjectDependenciesResolverTest {
fun `fails if project declares a package with an incorrect checksum`() {
val projectPath = Path.of(javaClass.getResource("badProjectChecksum/PklProject")!!.path)
val project = Project.loadFromPath(projectPath)
val packageResolver = PackageResolver.getInstance(SecurityManagers.defaultManager, null)
val packageResolver = PackageResolver.getInstance(SecurityManagers.defaultManager, httpClient, null)
val e = assertThrows<PklException> {
ProjectDependenciesResolver(project, packageResolver, System.err.writer()).resolve()
}

View File

@@ -9,6 +9,8 @@ import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatCode
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import org.pkl.commons.test.FileTestUtils
import org.pkl.core.http.HttpClient
import java.net.URI
import java.nio.file.Path
import java.util.regex.Pattern
@@ -137,9 +139,13 @@ class ProjectTest {
PackageServer.ensureStarted()
val projectDir = Path.of(javaClass.getResource("badProjectChecksum2/")!!.path)
val project = Project.loadFromPath(projectDir.resolve("PklProject"))
val httpClient = HttpClient.builder()
.addCertificates(FileTestUtils.selfSignedCertificate)
.build()
val evaluator = EvaluatorBuilder.preconfigured()
.applyFromProject(project)
.setModuleCacheDir(null)
.setHttpClient(httpClient)
.build()
assertThatCode { evaluator.evaluate(ModuleSource.path(projectDir.resolve("bug.pkl"))) }
.hasMessageStartingWith("""

View File

@@ -0,0 +1,48 @@
package org.pkl.core.util
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import java.io.IOException
import java.lang.Error
class ExceptionsTest {
@Test
fun `get root cause of simple exception`() {
val e = IOException("io")
assertThat(Exceptions.getRootCause(e)).isSameAs(e)
}
@Test
fun `get root cause of nested exception`() {
val e = IOException("io")
val e2 = RuntimeException("runtime")
val e3 = Error("error")
e.initCause(e2)
e2.initCause(e3)
assertThat(Exceptions.getRootCause(e)).isSameAs(e3)
}
@Test
fun `get root reason`() {
val e = IOException("io")
val e2 = RuntimeException("the root reason")
e.initCause(e2)
assertThat(Exceptions.getRootReason(e)).isEqualTo("the root reason")
}
@Test
fun `get root reason if null`() {
val e = IOException("io")
val e2 = RuntimeException(null as String?)
e.initCause(e2)
assertThat(Exceptions.getRootReason(e)).isEqualTo("(unknown reason)")
}
@Test
fun `get root reason if empty`() {
val e = IOException("io")
val e2 = RuntimeException("")
e.initCause(e2)
assertThat(Exceptions.getRootReason(e)).isEqualTo("(unknown reason)")
}
}

View File

@@ -0,0 +1,42 @@
package org.pkl.core.util
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertDoesNotThrow
import org.junit.jupiter.api.assertThrows
import org.pkl.commons.test.FakeHttpResponse
import java.io.IOException
import java.net.URI
import java.net.URL
class HttpUtilsTest {
@Test
fun isHttpUrl() {
assertThat(HttpUtils.isHttpUrl(URI("http://example.com"))).isTrue
assertThat(HttpUtils.isHttpUrl(URI("https://example.com"))).isTrue
assertThat(HttpUtils.isHttpUrl(URI("HtTpS://example.com"))).isTrue
assertThat(HttpUtils.isHttpUrl(URI("file://example.com"))).isFalse
assertThat(HttpUtils.isHttpUrl(URL("http://example.com"))).isTrue
assertThat(HttpUtils.isHttpUrl(URL("https://example.com"))).isTrue
assertThat(HttpUtils.isHttpUrl(URL("HtTpS://example.com"))).isTrue
assertThat(HttpUtils.isHttpUrl(URL("file://example.com"))).isFalse
}
@Test
fun checkHasStatusCode200() {
val response = FakeHttpResponse.withoutBody {
statusCode = 200
}
assertDoesNotThrow {
HttpUtils.checkHasStatusCode200(response)
}
val response2 = FakeHttpResponse.withoutBody {
statusCode = 404
}
assertThrows<IOException> {
HttpUtils.checkHasStatusCode200(response2)
}
}
}

View File

@@ -14,6 +14,7 @@ import org.pkl.core.runtime.ModuleResolver
import java.io.FileNotFoundException
import java.net.URI
import java.net.URISyntaxException
import java.net.URL
import java.nio.file.Path
import kotlin.io.path.createFile
@@ -410,4 +411,24 @@ class IoUtilsTest {
IoUtils.resolve(FakeSecurityManager, key2, URI("...NamedModuleResolversTest.pkl"))
}
}
@Test
fun `readBytes(URL) does not support HTTP URLs`() {
assertThrows<IllegalArgumentException> {
IoUtils.readBytes(URL("https://example.com"))
}
assertThrows<IllegalArgumentException> {
IoUtils.readBytes(URL("http://example.com"))
}
}
@Test
fun `readString(URL) does not support HTTP URLs`() {
assertThrows<IllegalArgumentException> {
IoUtils.readString(URL("https://example.com"))
}
assertThrows<IllegalArgumentException> {
IoUtils.readString(URL("http://example.com"))
}
}
}

View File

@@ -0,0 +1 @@
broken