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

@@ -23,6 +23,7 @@ import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.*; import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.util.TempFile; import org.openjdk.jmh.util.TempFile;
import org.openjdk.jmh.util.TempFileManager; import org.openjdk.jmh.util.TempFileManager;
import org.pkl.core.http.HttpClient;
import org.pkl.core.module.ModuleKeyFactories; import org.pkl.core.module.ModuleKeyFactories;
import org.pkl.core.repl.ReplRequest; import org.pkl.core.repl.ReplRequest;
import org.pkl.core.repl.ReplResponse; import org.pkl.core.repl.ReplResponse;
@@ -39,6 +40,7 @@ public class ListSort {
private static final ReplServer repl = private static final ReplServer repl =
new ReplServer( new ReplServer(
SecurityManagers.defaultManager, SecurityManagers.defaultManager,
HttpClient.dummyClient(),
Loggers.stdErr(), Loggers.stdErr(),
List.of(ModuleKeyFactories.standardLibrary), List.of(ModuleKeyFactories.standardLibrary),
List.of(ResourceReaders.file()), List.of(ResourceReaders.file()),

View File

@@ -25,6 +25,7 @@ import org.pkl.core.repl.ReplServer
import org.pkl.core.resource.ResourceReaders import org.pkl.core.resource.ResourceReaders
import org.pkl.core.util.IoUtils import org.pkl.core.util.IoUtils
import org.antlr.v4.runtime.ParserRuleContext import org.antlr.v4.runtime.ParserRuleContext
import org.pkl.core.http.HttpClient
import java.nio.file.Files import java.nio.file.Files
import kotlin.io.path.isDirectory import kotlin.io.path.isDirectory
import kotlin.io.path.isRegularFile import kotlin.io.path.isRegularFile
@@ -78,6 +79,7 @@ class DocSnippetTestsEngine : HierarchicalTestEngine<DocSnippetTestsEngine.Execu
override fun createExecutionContext(request: ExecutionRequest): ExecutionContext { override fun createExecutionContext(request: ExecutionRequest): ExecutionContext {
val replServer = ReplServer( val replServer = ReplServer(
SecurityManagers.defaultManager, SecurityManagers.defaultManager,
HttpClient.dummyClient(),
Loggers.stdErr(), Loggers.stdErr(),
listOf( listOf(
ModuleKeyFactories.standardLibrary, ModuleKeyFactories.standardLibrary,

View File

@@ -81,6 +81,7 @@ msgpack = { group = "org.msgpack", name = "msgpack-core", version.ref = "msgpack
nuValidator = { group = "nu.validator", name = "validator", version.ref = "nuValidator" } nuValidator = { group = "nu.validator", name = "validator", version.ref = "nuValidator" }
# to be replaced with https://github.com/usethesource/capsule or https://github.com/lacuna/bifurcan # to be replaced with https://github.com/usethesource/capsule or https://github.com/lacuna/bifurcan
paguro = { group = "org.organicdesign", name = "Paguro", version.ref = "paguro" } paguro = { group = "org.organicdesign", name = "Paguro", version.ref = "paguro" }
pklConfigJavaAll025 = { group = "org.pkl-lang", name = "pkl-config-java-all", version = "0.25.0" }
shadowPlugin = { group = "gradle.plugin.com.github.johnrengelman", name = "shadow", version.ref = "shadowPlugin" } shadowPlugin = { group = "gradle.plugin.com.github.johnrengelman", name = "shadow", version.ref = "shadowPlugin" }
slf4jApi = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" } slf4jApi = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" }
slf4jSimple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" } slf4jSimple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" }

View File

@@ -0,0 +1,19 @@
plugins {
pklAllProjects
pklJavaLibrary
pklPublishLibrary
}
publishing {
publications {
named<MavenPublication>("library") {
pom {
url.set("https://github.com/apple/pkl/tree/main/pkl-certs")
description.set("""
Pkl's built-in CA certificates.
Used by Pkl CLIs and optionally supported by pkl-core.")
""".trimIndent())
}
}
}
}

View File

@@ -156,7 +156,7 @@ fun Exec.configureExecutable(isEnabled: Boolean, outputFile: File, extraArgs: Li
,"--no-fallback" ,"--no-fallback"
,"-H:IncludeResources=org/pkl/core/stdlib/.*\\.pkl" ,"-H:IncludeResources=org/pkl/core/stdlib/.*\\.pkl"
,"-H:IncludeResources=org/jline/utils/.*" ,"-H:IncludeResources=org/jline/utils/.*"
,"-H:IncludeResources=org/pkl/commons/cli/commands/IncludedCARoots.pem" ,"-H:IncludeResources=org/pkl/certs/PklCARoots.pem"
//,"-H:IncludeResources=org/pkl/core/Release.properties" //,"-H:IncludeResources=org/pkl/core/Release.properties"
,"-H:IncludeResourceBundles=org.pkl.core.errorMessages" ,"-H:IncludeResourceBundles=org.pkl.core.errorMessages"
,"--macro:truffle" ,"--macro:truffle"

View File

@@ -31,7 +31,7 @@ class CliPackageDownloader(
if (moduleCacheDir == null) { if (moduleCacheDir == null) {
throw CliException("Cannot download packages because no cache directory is specified.") throw CliException("Cannot download packages because no cache directory is specified.")
} }
val packageResolver = PackageResolver.getInstance(securityManager, moduleCacheDir) val packageResolver = PackageResolver.getInstance(securityManager, httpClient, moduleCacheDir)
val errors = mutableMapOf<PackageUri, Throwable>() val errors = mutableMapOf<PackageUri, Throwable>()
for (pkg in packageUris) { for (pkg in packageUris) {
try { try {

View File

@@ -82,6 +82,7 @@ class CliProjectPackager(
outputPath, outputPath,
stackFrameTransformer, stackFrameTransformer,
securityManager, securityManager,
httpClient,
skipPublishCheck, skipPublishCheck,
consoleWriter consoleWriter
) )

View File

@@ -40,6 +40,7 @@ class CliProjectResolver(
SecurityManagers.defaultTrustLevels, SecurityManagers.defaultTrustLevels,
rootDir rootDir
), ),
httpClient,
moduleCacheDir moduleCacheDir
) )
val dependencies = ProjectDependenciesResolver(project, packageResolver, errWriter).resolve() val dependencies = ProjectDependenciesResolver(project, packageResolver, errWriter).resolve()

View File

@@ -36,6 +36,7 @@ internal class CliRepl(private val options: CliEvaluatorOptions) : CliCommand(op
SecurityManagers.defaultTrustLevels, SecurityManagers.defaultTrustLevels,
rootDir rootDir
), ),
httpClient,
Loggers.stdErr(), Loggers.stdErr(),
listOf( listOf(
ModuleKeyFactories.standardLibrary, ModuleKeyFactories.standardLibrary,

View File

@@ -25,7 +25,7 @@ import org.pkl.server.Server
class CliServer(options: CliBaseOptions) : CliCommand(options) { class CliServer(options: CliBaseOptions) : CliCommand(options) {
override fun doRun() = override fun doRun() =
try { try {
val server = Server(MessageTransports.stream(System.`in`, System.out)) val server = Server(MessageTransports.stream(System.`in`, System.out), httpClient)
server.use { it.start() } server.use { it.start() }
} catch (e: ProtocolException) { } catch (e: ProtocolException) {
throw CliException(e.message!!) throw CliException(e.message!!)

View File

@@ -21,19 +21,18 @@ import java.net.URI
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.time.Duration import java.time.Duration
import kotlin.io.path.createDirectories import kotlin.io.path.*
import kotlin.io.path.listDirectoryEntries
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatCode import org.assertj.core.api.Assertions.assertThatCode
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.io.TempDir
import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.EnumSource import org.junit.jupiter.params.provider.EnumSource
import org.pkl.commons.* import org.pkl.commons.*
import org.pkl.commons.cli.CliBaseOptions import org.pkl.commons.cli.CliBaseOptions
import org.pkl.commons.cli.CliException import org.pkl.commons.cli.CliException
import org.pkl.commons.cli.commands.BaseOptions
import org.pkl.commons.test.FileTestUtils import org.pkl.commons.test.FileTestUtils
import org.pkl.commons.test.PackageServer import org.pkl.commons.test.PackageServer
import org.pkl.core.OutputFormat import org.pkl.core.OutputFormat
@@ -1158,8 +1157,46 @@ result = someLib.x
} }
@Test @Test
fun `not including the self signed certificate will result in a error`() { fun `gives decent error message if certificate file contains random text`() {
val certsFile = tempDir.writeFile("random.pem", "RANDOM")
val err = assertThrows<CliException> { evalModuleThatImportsPackage(certsFile) }
assertThat(err)
.hasMessageContaining("Error parsing CA certificate file `${certsFile.pathString}`:")
.hasMessageContaining("No certificate data found")
.hasMessageNotContainingAny("java.", "sun.") // class names have been filtered out
}
@Test
fun `gives decent error message if certificate file is emtpy`(@TempDir tempDir: Path) {
val emptyCerts = tempDir.writeEmptyFile("empty.pem")
val err = assertThrows<CliException> { evalModuleThatImportsPackage(emptyCerts) }
assertThat(err).hasMessageContaining("CA certificate file `${emptyCerts.pathString}` is empty.")
}
@Test
fun `gives decent error message if certificate cannot be parsed`(@TempDir tempDir: Path) {
val invalidCerts = FileTestUtils.writeCertificateWithMissingLines(tempDir)
val err = assertThrows<CliException> { evalModuleThatImportsPackage(invalidCerts) }
assertThat(err)
// no assert for detail message because it differs between JDK implementations
.hasMessageContaining("Error parsing CA certificate file `${invalidCerts.pathString}`:")
.hasMessageNotContainingAny("java.", "sun.") // class names have been filtered out
}
@Test
fun `gives decent error message if CLI doesn't have the required CA certificate`() {
PackageServer.ensureStarted() PackageServer.ensureStarted()
// provide SOME certs to prevent CliEvaluator from falling back to ~/.pkl/cacerts
val builtInCerts = FileTestUtils.writePklBuiltInCertificates(tempDir)
val err = assertThrows<CliException> { evalModuleThatImportsPackage(builtInCerts) }
assertThat(err)
// on some JDK11's this doesn't cause SSLHandshakeException but some other SSLException
// .hasMessageContaining("Error during SSL handshake with host `localhost`:")
.hasMessageContaining("unable to find valid certification path to requested target")
.hasMessageNotContainingAny("java.", "sun.") // class names have been filtered out
}
private fun evalModuleThatImportsPackage(certsFile: Path) {
val moduleUri = val moduleUri =
writePklFile( writePklFile(
"test.pkl", "test.pkl",
@@ -1168,22 +1205,17 @@ result = someLib.x
res = Swallow res = Swallow
""" """
.trimIndent()
) )
val buffer = StringWriter()
val options = val options =
CliEvaluatorOptions( CliEvaluatorOptions(
CliBaseOptions( CliBaseOptions(
sourceModules = listOf(moduleUri), sourceModules = listOf(moduleUri),
workingDir = tempDir, caCertificates = listOf(certsFile),
moduleCacheDir = tempDir, noCache = true
noCache = true,
// ensure we override any previously set root cert to the default buundle.
caCertificates = listOf(BaseOptions.Companion.includedCARootCerts())
), ),
) )
val err = assertThrows<CliException> { CliEvaluator(options, consoleWriter = buffer).run() } CliEvaluator(options).run()
assertThat(err.message).contains("unable to find valid certification path to requested target")
} }
private fun writePklFile(fileName: String, contents: String = defaultContents): URI { private fun writePklFile(fileName: String, contents: String = defaultContents): URI {

View File

@@ -202,7 +202,7 @@ class CliPackageDownloaderTest {
Failed to download package://bogus.domain/notAPackage@1.0.0 because: Failed to download package://bogus.domain/notAPackage@1.0.0 because:
Exception when making request `GET https://bogus.domain/notAPackage@1.0.0`: Exception when making request `GET https://bogus.domain/notAPackage@1.0.0`:
bogus.domain Error connecting to host `bogus.domain`.
""" """
.trimIndent() .trimIndent()

View File

@@ -34,7 +34,6 @@ import org.pkl.commons.readString
import org.pkl.commons.test.FileTestUtils import org.pkl.commons.test.FileTestUtils
import org.pkl.commons.test.PackageServer import org.pkl.commons.test.PackageServer
import org.pkl.commons.writeString import org.pkl.commons.writeString
import org.pkl.core.runtime.CertificateUtils
class CliProjectPackagerTest { class CliProjectPackagerTest {
@Test @Test
@@ -868,7 +867,6 @@ class CliProjectPackagerTest {
@Test @Test
fun `publish checks`(@TempDir tempDir: Path) { fun `publish checks`(@TempDir tempDir: Path) {
PackageServer.ensureStarted() PackageServer.ensureStarted()
CertificateUtils.setupAllX509CertificatesGlobally(listOf(FileTestUtils.selfSignedCertificate))
tempDir.writeFile("project/main.pkl", "res = 1") tempDir.writeFile("project/main.pkl", "res = 1")
tempDir.writeFile( tempDir.writeFile(
"project/PklProject", "project/PklProject",
@@ -888,7 +886,10 @@ class CliProjectPackagerTest {
val e = val e =
assertThrows<CliException> { assertThrows<CliException> {
CliProjectPackager( CliProjectPackager(
CliBaseOptions(workingDir = tempDir), CliBaseOptions(
workingDir = tempDir,
caCertificates = listOf(FileTestUtils.selfSignedCertificate)
),
listOf(tempDir.resolve("project")), listOf(tempDir.resolve("project")),
CliTestOptions(), CliTestOptions(),
".out/%{name}@%{version}", ".out/%{name}@%{version}",
@@ -912,7 +913,6 @@ class CliProjectPackagerTest {
@Test @Test
fun `publish check when package is not yet published`(@TempDir tempDir: Path) { fun `publish check when package is not yet published`(@TempDir tempDir: Path) {
PackageServer.ensureStarted() PackageServer.ensureStarted()
CertificateUtils.setupAllX509CertificatesGlobally(listOf(FileTestUtils.selfSignedCertificate))
tempDir.writeFile("project/main.pkl", "res = 1") tempDir.writeFile("project/main.pkl", "res = 1")
tempDir.writeFile( tempDir.writeFile(
"project/PklProject", "project/PklProject",
@@ -930,7 +930,10 @@ class CliProjectPackagerTest {
) )
val out = StringWriter() val out = StringWriter()
CliProjectPackager( CliProjectPackager(
CliBaseOptions(workingDir = tempDir), CliBaseOptions(
workingDir = tempDir,
caCertificates = listOf(FileTestUtils.selfSignedCertificate)
),
listOf(tempDir.resolve("project")), listOf(tempDir.resolve("project")),
CliTestOptions(), CliTestOptions(),
".out/%{name}@%{version}", ".out/%{name}@%{version}",

View File

@@ -21,6 +21,7 @@ import org.pkl.commons.toPath
import org.pkl.core.Loggers import org.pkl.core.Loggers
import org.pkl.core.SecurityManagers import org.pkl.core.SecurityManagers
import org.pkl.core.StackFrameTransformers import org.pkl.core.StackFrameTransformers
import org.pkl.core.http.HttpClient
import org.pkl.core.module.ModuleKeyFactories import org.pkl.core.module.ModuleKeyFactories
import org.pkl.core.repl.ReplRequest import org.pkl.core.repl.ReplRequest
import org.pkl.core.repl.ReplResponse import org.pkl.core.repl.ReplResponse
@@ -30,6 +31,7 @@ class ReplMessagesTest {
private val server = private val server =
ReplServer( ReplServer(
SecurityManagers.defaultManager, SecurityManagers.defaultManager,
HttpClient.dummyClient(),
Loggers.stdErr(), Loggers.stdErr(),
listOf(ModuleKeyFactories.standardLibrary), listOf(ModuleKeyFactories.standardLibrary),
listOf(), listOf(),

View File

@@ -14,6 +14,7 @@ dependencies {
implementation(projects.pklCommons) implementation(projects.pklCommons)
testImplementation(projects.pklCommonsTest) testImplementation(projects.pklCommonsTest)
runtimeOnly(projects.pklCerts)
} }
publishing { publishing {

View File

@@ -20,6 +20,7 @@ import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.time.Duration import java.time.Duration
import java.util.regex.Pattern import java.util.regex.Pattern
import org.pkl.core.http.HttpClient
import org.pkl.core.module.ProjectDependenciesManager import org.pkl.core.module.ProjectDependenciesManager
import org.pkl.core.util.IoUtils import org.pkl.core.util.IoUtils
@@ -113,15 +114,15 @@ data class CliBaseOptions(
val testMode: Boolean = false, val testMode: Boolean = false,
/** /**
* [X.509 certificates](https://en.wikipedia.org/wiki/X.509) in PEM format. * The CA certificates to trust.
* *
* Elements can either be a [Path] or a [java.io.InputStream]. Input streams are closed when * The given files must contain [X.509](https://en.wikipedia.org/wiki/X.509) certificates in PEM
* [CliCommand] is initialized. * format.
* *
* If not empty, this determines the CA root certs used for all HTTPS requests. Warning: this * If [caCertificates] is the empty list, the certificate files in `~/.pkl/cacerts/` are used. If
* affects the whole Java runtime, not just the Pkl API! * `~/.pkl/cacerts/` does not exist or is empty, Pkl's built-in CA certificates are used.
*/ */
val caCertificates: List<Any> = emptyList(), val caCertificates: List<Path> = listOf(),
) { ) {
companion object { companion object {
@@ -167,4 +168,26 @@ data class CliBaseOptions(
projectDir?.resolve(ProjectDependenciesManager.PKL_PROJECT_FILENAME) projectDir?.resolve(ProjectDependenciesManager.PKL_PROJECT_FILENAME)
?: normalizedWorkingDir.getProjectFile(rootDir) ?: normalizedWorkingDir.getProjectFile(rootDir)
} }
/** [caCertificates] after normalization. */
val normalizedCaCertificates: List<Path> = caCertificates.map(normalizedWorkingDir::resolve)
/**
* The HTTP client shared between CLI commands created with this [CliBaseOptions] instance.
*
* To release the resources held by the HTTP client in a timely manner, call its `close()` method.
*/
val httpClient: HttpClient by lazy {
with(HttpClient.builder()) {
if (normalizedCaCertificates.isEmpty()) {
addDefaultCliCertificates()
} else {
for (file in normalizedCaCertificates) addCertificates(file)
}
// Lazy building significantly reduces execution time of commands that do minimal work.
// However, it means that HTTP client initialization errors won't surface until an HTTP
// request is made.
buildLazily()
}
}
} }

View File

@@ -18,24 +18,18 @@ package org.pkl.commons.cli
import java.nio.file.Path import java.nio.file.Path
import java.util.regex.Pattern import java.util.regex.Pattern
import org.pkl.core.* import org.pkl.core.*
import org.pkl.core.http.HttpClient
import org.pkl.core.module.ModuleKeyFactories import org.pkl.core.module.ModuleKeyFactories
import org.pkl.core.module.ModuleKeyFactory import org.pkl.core.module.ModuleKeyFactory
import org.pkl.core.module.ModulePathResolver import org.pkl.core.module.ModulePathResolver
import org.pkl.core.project.Project import org.pkl.core.project.Project
import org.pkl.core.resource.ResourceReader import org.pkl.core.resource.ResourceReader
import org.pkl.core.resource.ResourceReaders import org.pkl.core.resource.ResourceReaders
import org.pkl.core.runtime.CertificateUtils
import org.pkl.core.settings.PklSettings import org.pkl.core.settings.PklSettings
import org.pkl.core.util.IoUtils import org.pkl.core.util.IoUtils
/** Building block for CLI commands. Configured programmatically to allow for embedding. */ /** Building block for CLI commands. Configured programmatically to allow for embedding. */
abstract class CliCommand(protected val cliOptions: CliBaseOptions) { abstract class CliCommand(protected val cliOptions: CliBaseOptions) {
init {
if (cliOptions.caCertificates.isNotEmpty()) {
CertificateUtils.setupAllX509CertificatesGlobally(cliOptions.caCertificates)
}
}
/** Runs this command. */ /** Runs this command. */
fun run() { fun run() {
if (cliOptions.testMode) { if (cliOptions.testMode) {
@@ -158,6 +152,10 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) {
) )
} }
// share HTTP client with other commands with the same cliOptions
protected val httpClient: HttpClient
get() = cliOptions.httpClient
protected fun moduleKeyFactories(modulePathResolver: ModulePathResolver): List<ModuleKeyFactory> { protected fun moduleKeyFactories(modulePathResolver: ModulePathResolver): List<ModuleKeyFactory> {
return buildList { return buildList {
add(ModuleKeyFactories.standardLibrary) add(ModuleKeyFactories.standardLibrary)
@@ -195,6 +193,7 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) {
.setStackFrameTransformer(stackFrameTransformer) .setStackFrameTransformer(stackFrameTransformer)
.apply { project?.let { setProjectDependencies(it.dependencies) } } .apply { project?.let { setProjectDependencies(it.dependencies) } }
.setSecurityManager(securityManager) .setSecurityManager(securityManager)
.setHttpClient(httpClient)
.setExternalProperties(externalProperties) .setExternalProperties(externalProperties)
.setEnvironmentVariables(environmentVariables) .setEnvironmentVariables(environmentVariables)
.addModuleKeyFactories(moduleKeyFactories(modulePathResolver)) .addModuleKeyFactories(moduleKeyFactories(modulePathResolver))

View File

@@ -20,27 +20,15 @@ import com.github.ajalt.clikt.parameters.options.*
import com.github.ajalt.clikt.parameters.types.long import com.github.ajalt.clikt.parameters.types.long
import com.github.ajalt.clikt.parameters.types.path import com.github.ajalt.clikt.parameters.types.path
import java.io.File import java.io.File
import java.io.InputStream
import java.net.URI import java.net.URI
import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.time.Duration import java.time.Duration
import java.util.regex.Pattern import java.util.regex.Pattern
import java.util.stream.Collectors
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
import kotlin.io.path.isRegularFile
import org.pkl.commons.cli.CliBaseOptions import org.pkl.commons.cli.CliBaseOptions
import org.pkl.core.util.IoUtils import org.pkl.core.util.IoUtils
@Suppress("MemberVisibilityCanBePrivate") @Suppress("MemberVisibilityCanBePrivate")
class BaseOptions : OptionGroup() { class BaseOptions : OptionGroup() {
companion object {
fun includedCARootCerts(): InputStream {
return BaseOptions::class.java.getResourceAsStream("IncludedCARoots.pem")!!
}
}
private val defaults = CliBaseOptions() private val defaults = CliBaseOptions()
private val output = private val output =
@@ -142,28 +130,11 @@ class BaseOptions : OptionGroup() {
option( option(
names = arrayOf("--ca-certificates"), names = arrayOf("--ca-certificates"),
metavar = "<path>", metavar = "<path>",
help = "Replaces the built-in CA certificates with the provided certificate file." help = "Only trust CA certificates from the provided file(s)."
) )
.path() .path()
.multiple() .multiple()
/**
* 1. If `--ca-certificates` option is not empty, use that.
* 2. If directory `~/.pkl/cacerts` is not empty, use that.
* 3. Use the bundled CA certificates.
*/
private fun getEffectiveCaCertificates(): List<Any> {
return caCertificates
.ifEmpty {
val home = System.getProperty("user.home")
val cacerts = Path.of(home, ".pkl", "cacerts")
if (cacerts.exists() && cacerts.isDirectory())
Files.list(cacerts).filter(Path::isRegularFile).collect(Collectors.toList())
else emptyList()
}
.ifEmpty { listOf(includedCARootCerts()) }
}
fun baseOptions( fun baseOptions(
modules: List<URI>, modules: List<URI>,
projectOptions: ProjectOptions? = null, projectOptions: ProjectOptions? = null,
@@ -186,7 +157,7 @@ class BaseOptions : OptionGroup() {
testMode = testMode, testMode = testMode,
omitProjectSettings = projectOptions?.omitProjectSettings ?: false, omitProjectSettings = projectOptions?.omitProjectSettings ?: false,
noProject = projectOptions?.noProject ?: false, noProject = projectOptions?.noProject ?: false,
caCertificates = getEffectiveCaCertificates() caCertificates = caCertificates
) )
} }
} }

View File

@@ -13,6 +13,7 @@ dependencies {
api(libs.junitParams) api(libs.junitParams)
api(projects.pklCommons) // for convenience api(projects.pklCommons) // for convenience
implementation(libs.assertj) implementation(libs.assertj)
runtimeOnly(projects.pklCerts)
} }

View File

@@ -0,0 +1,58 @@
/**
* 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.commons.test
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpHeaders
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.util.*
import javax.net.ssl.SSLSession
class FakeHttpResponse<T : Any> : HttpResponse<T> {
companion object {
fun <T : Any> withBody(block: FakeHttpResponse<T>.() -> Unit): FakeHttpResponse<T> =
FakeHttpResponse<T>().apply(block)
fun withoutBody(block: FakeHttpResponse<Unit>.() -> Unit): FakeHttpResponse<Unit> =
FakeHttpResponse<Unit>().apply { body = Unit }.apply(block)
}
var statusCode: Int = 200
var request: HttpRequest = HttpRequest.newBuilder().uri(URI("https://example.com")).build()
var uri: URI = URI("https://example.com")
var version: HttpClient.Version = HttpClient.Version.HTTP_2
lateinit var headers: HttpHeaders
lateinit var body: T
override fun statusCode(): Int = statusCode
override fun request(): HttpRequest = request
override fun previousResponse(): Optional<HttpResponse<T>> = Optional.empty()
override fun headers(): HttpHeaders = headers
override fun body(): T = body
override fun sslSession(): Optional<SSLSession> = Optional.empty()
override fun uri(): URI = uri
override fun version(): HttpClient.Version = version
}

View File

@@ -16,8 +16,8 @@
package org.pkl.commons.test package org.pkl.commons.test
import java.nio.file.Path import java.nio.file.Path
import java.util.stream.Collectors
import kotlin.io.path.* import kotlin.io.path.*
import kotlin.streams.toList
import org.assertj.core.api.Assertions.fail import org.assertj.core.api.Assertions.fail
import org.pkl.commons.* import org.pkl.commons.*
@@ -32,10 +32,23 @@ object FileTestUtils {
val selfSignedCertificate: Path by lazy { val selfSignedCertificate: Path by lazy {
rootProjectDir.resolve("pkl-commons-test/build/keystore/localhost.pem") rootProjectDir.resolve("pkl-commons-test/build/keystore/localhost.pem")
} }
fun writeCertificateWithMissingLines(dir: Path): Path {
val lines = selfSignedCertificate.readLines()
// drop some lines in the middle
return dir.resolve("invalidCerts.pem").writeLines(lines.take(5) + lines.takeLast(5))
}
fun writePklBuiltInCertificates(dir: Path): Path {
val text = javaClass.getResource("/org/pkl/certs/PklCARoots.pem")!!.readText()
return dir.resolve("PklCARoots.pem").apply { writeText(text) }
}
} }
fun Path.listFilesRecursively(): List<Path> = fun Path.listFilesRecursively(): List<Path> =
walk(99).use { paths -> paths.filter { it.isRegularFile() || it.isSymbolicLink() }.toList() } walk(99).use { paths ->
paths.filter { it.isRegularFile() || it.isSymbolicLink() }.collect(Collectors.toList())
}
data class SnippetOutcome(val expectedOutFile: Path, val actual: String, val success: Boolean) { data class SnippetOutcome(val expectedOutFile: Path, val actual: String, val success: Boolean) {
private val expectedErrFile = private val expectedErrFile =

View File

@@ -0,0 +1,29 @@
/**
* 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.commons.test
class FilteringClassLoader(parent: ClassLoader, private val includeFilter: (String) -> Boolean) :
ClassLoader(parent) {
init {
registerAsParallelCapable()
}
override fun loadClass(name: String, resolve: Boolean): Class<*> {
if (!includeFilter(name)) throw ClassNotFoundException(name)
return super.loadClass(name, resolve)
}
}

View File

@@ -58,7 +58,7 @@ dependencies {
implementation(libs.snakeYaml) implementation(libs.snakeYaml)
testImplementation(projects.pklCommonsTest) testImplementation(projects.pklCommonsTest)
add("generatorImplementation", libs.javaPoet) add("generatorImplementation", libs.javaPoet)
add("generatorImplementation", libs.truffleApi) add("generatorImplementation", libs.truffleApi)
add("generatorImplementation", libs.kotlinStdLib) add("generatorImplementation", libs.kotlinStdLib)

View File

@@ -20,6 +20,7 @@ import java.nio.file.Path;
import java.util.*; import java.util.*;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import org.pkl.core.SecurityManagers.StandardBuilder; import org.pkl.core.SecurityManagers.StandardBuilder;
import org.pkl.core.http.HttpClient;
import org.pkl.core.module.ModuleKeyFactories; import org.pkl.core.module.ModuleKeyFactories;
import org.pkl.core.module.ModuleKeyFactory; import org.pkl.core.module.ModuleKeyFactory;
import org.pkl.core.module.ModulePathResolver; import org.pkl.core.module.ModulePathResolver;
@@ -38,6 +39,10 @@ public final class EvaluatorBuilder {
private @Nullable SecurityManager securityManager; 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 Logger logger = Loggers.noop();
private final List<ModuleKeyFactory> moduleKeyFactories = new ArrayList<>(); private final List<ModuleKeyFactory> moduleKeyFactories = new ArrayList<>();
@@ -226,6 +231,21 @@ public final class EvaluatorBuilder {
return logger; 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 * Adds the given module key factory. Factories will be asked to resolve module keys in the order
* they have been added to this builder. * they have been added to this builder.
@@ -468,6 +488,7 @@ public final class EvaluatorBuilder {
return new EvaluatorImpl( return new EvaluatorImpl(
stackFrameTransformer, stackFrameTransformer,
securityManager, securityManager,
httpClient,
new LoggerImpl(logger, stackFrameTransformer), new LoggerImpl(logger, stackFrameTransformer),
// copy to shield against subsequent modification through builder // copy to shield against subsequent modification through builder
new ArrayList<>(moduleKeyFactories), new ArrayList<>(moduleKeyFactories),

View File

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

View File

@@ -23,4 +23,8 @@ public class PklException extends RuntimeException {
public PklException(String message) { public PklException(String message) {
super(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.VmModifier;
import org.pkl.core.ast.member.ObjectMember; import org.pkl.core.ast.member.ObjectMember;
import org.pkl.core.ast.member.UntypedObjectMemberNode; import org.pkl.core.ast.member.UntypedObjectMemberNode;
import org.pkl.core.http.HttpClientInitException;
import org.pkl.core.module.ResolvedModuleKey; import org.pkl.core.module.ResolvedModuleKey;
import org.pkl.core.packages.PackageLoadError; import org.pkl.core.packages.PackageLoadError;
import org.pkl.core.runtime.BaseModule; import org.pkl.core.runtime.BaseModule;
@@ -113,7 +114,7 @@ public class ImportGlobNode extends AbstractImportNode {
frame.materialize(), BaseModule.getMappingClass().getPrototype(), members); frame.materialize(), BaseModule.getMappingClass().getPrototype(), members);
} catch (IOException e) { } catch (IOException e) {
throw exceptionBuilder().evalError("ioErrorResolvingGlob", importUri).withCause(e).build(); throw exceptionBuilder().evalError("ioErrorResolvingGlob", importUri).withCause(e).build();
} catch (SecurityManagerException e) { } catch (SecurityManagerException | HttpClientInitException e) {
throw exceptionBuilder().withCause(e).build(); throw exceptionBuilder().withCause(e).build();
} catch (PackageLoadError e) { } catch (PackageLoadError e) {
throw exceptionBuilder().adhocEvalError(e.getMessage()).build(); 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 com.oracle.truffle.api.source.SourceSection;
import java.net.URI; import java.net.URI;
import org.pkl.core.SecurityManagerException; import org.pkl.core.SecurityManagerException;
import org.pkl.core.http.HttpClientInitException;
import org.pkl.core.module.ResolvedModuleKey; import org.pkl.core.module.ResolvedModuleKey;
import org.pkl.core.packages.PackageLoadError; import org.pkl.core.packages.PackageLoadError;
import org.pkl.core.runtime.VmContext; import org.pkl.core.runtime.VmContext;
@@ -60,7 +61,7 @@ public final class ImportNode extends AbstractImportNode {
context.getSecurityManager().checkImportModule(currentModule.getUri(), importUri); context.getSecurityManager().checkImportModule(currentModule.getUri(), importUri);
var moduleToImport = context.getModuleResolver().resolve(importUri, this); var moduleToImport = context.getModuleResolver().resolve(importUri, this);
importedModule = language.loadModule(moduleToImport, this); importedModule = language.loadModule(moduleToImport, this);
} catch (SecurityManagerException | PackageLoadError e) { } catch (SecurityManagerException | PackageLoadError | HttpClientInitException e) {
throw exceptionBuilder().withCause(e).build(); 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.io.InputStream;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse.BodyHandlers;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; import java.util.List;
@@ -34,6 +36,7 @@ import org.pkl.core.packages.PackageLoadError;
import org.pkl.core.packages.PackageResolver; import org.pkl.core.packages.PackageResolver;
import org.pkl.core.runtime.VmContext; import org.pkl.core.runtime.VmContext;
import org.pkl.core.util.ErrorMessages; import org.pkl.core.util.ErrorMessages;
import org.pkl.core.util.HttpUtils;
import org.pkl.core.util.IoUtils; import org.pkl.core.util.IoUtils;
import org.pkl.core.util.Nullable; import org.pkl.core.util.Nullable;
import org.pkl.core.util.Pair; import org.pkl.core.util.Pair;
@@ -481,6 +484,19 @@ public final class ModuleKeys {
throws IOException, SecurityManagerException { throws IOException, SecurityManagerException {
securityManager.checkResolveModule(uri); 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 url = IoUtils.toUrl(uri);
var conn = url.openConnection(); var conn = url.openConnection();
conn.connect(); conn.connect();
@@ -494,6 +510,7 @@ public final class ModuleKeys {
} }
securityManager.checkResolveModule(redirected); securityManager.checkResolveModule(redirected);
var text = IoUtils.readString(stream); var text = IoUtils.readString(stream);
// intentionally use uri instead of redirected
return ResolvedModuleKeys.virtual(this, uri, text, true); return ResolvedModuleKeys.virtual(this, uri, text, true);
} }
} }

View File

@@ -18,9 +18,13 @@ package org.pkl.core.module;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.URL; import java.net.URL;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse.BodyHandlers;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import org.pkl.core.runtime.VmContext;
import org.pkl.core.util.HttpUtils;
import org.pkl.core.util.IoUtils; import org.pkl.core.util.IoUtils;
/** Utilities for obtaining and using resolved module keys. */ /** Utilities for obtaining and using resolved module keys. */
@@ -102,6 +106,14 @@ public final class ResolvedModuleKeys {
@Override @Override
public String loadSource() throws IOException { 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); return IoUtils.readString(url);
} }
} }

View File

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

View File

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

View File

@@ -43,6 +43,7 @@ import org.pkl.core.SecurityManager;
import org.pkl.core.SecurityManagerException; import org.pkl.core.SecurityManagerException;
import org.pkl.core.StackFrameTransformer; import org.pkl.core.StackFrameTransformer;
import org.pkl.core.ast.builder.ImportsAndReadsParser; import org.pkl.core.ast.builder.ImportsAndReadsParser;
import org.pkl.core.http.HttpClient;
import org.pkl.core.module.ModuleKeys; import org.pkl.core.module.ModuleKeys;
import org.pkl.core.module.ProjectDependenciesManager; import org.pkl.core.module.ProjectDependenciesManager;
import org.pkl.core.module.ResolvedModuleKeys; import org.pkl.core.module.ResolvedModuleKeys;
@@ -105,6 +106,7 @@ public class ProjectPackager {
String outputPathPattern, String outputPathPattern,
StackFrameTransformer stackFrameTransformer, StackFrameTransformer stackFrameTransformer,
SecurityManager securityManager, SecurityManager securityManager,
HttpClient httpClient,
boolean skipPublishCheck, boolean skipPublishCheck,
Writer outputWriter) { Writer outputWriter) {
this.projects = projects; this.projects = projects;
@@ -112,7 +114,7 @@ public class ProjectPackager {
this.outputPathPattern = outputPathPattern; this.outputPathPattern = outputPathPattern;
this.stackFrameTransformer = stackFrameTransformer; this.stackFrameTransformer = stackFrameTransformer;
// intentionally use InMemoryPackageResolver // intentionally use InMemoryPackageResolver
this.packageResolver = PackageResolver.getInstance(securityManager, null); this.packageResolver = PackageResolver.getInstance(securityManager, httpClient, null);
this.skipPublishCheck = skipPublishCheck; this.skipPublishCheck = skipPublishCheck;
this.outputWriter = outputWriter; 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.member.*;
import org.pkl.core.ast.repl.ResolveClassMemberNode; import org.pkl.core.ast.repl.ResolveClassMemberNode;
import org.pkl.core.ast.type.TypeNode; import org.pkl.core.ast.type.TypeNode;
import org.pkl.core.http.HttpClient;
import org.pkl.core.module.*; import org.pkl.core.module.*;
import org.pkl.core.packages.PackageResolver; import org.pkl.core.packages.PackageResolver;
import org.pkl.core.parser.LexParseException; import org.pkl.core.parser.LexParseException;
@@ -67,6 +68,7 @@ public class ReplServer implements AutoCloseable {
public ReplServer( public ReplServer(
SecurityManager securityManager, SecurityManager securityManager,
HttpClient httpClient,
Logger logger, Logger logger,
Collection<ModuleKeyFactory> moduleKeyFactories, Collection<ModuleKeyFactory> moduleKeyFactories,
Collection<ResourceReader> resourceReaders, Collection<ResourceReader> resourceReaders,
@@ -85,7 +87,7 @@ public class ReplServer implements AutoCloseable {
replState = new ReplState(createEmptyReplModule(BaseModule.getModuleClass().getPrototype())); replState = new ReplState(createEmptyReplModule(BaseModule.getModuleClass().getPrototype()));
var languageRef = new MutableReference<VmLanguage>(null); var languageRef = new MutableReference<VmLanguage>(null);
packageResolver = PackageResolver.getInstance(securityManager, moduleCacheDir); packageResolver = PackageResolver.getInstance(securityManager, httpClient, moduleCacheDir);
projectDependenciesManager = projectDependenciesManager =
projectDependencies == null ? null : new ProjectDependenciesManager(projectDependencies); projectDependencies == null ? null : new ProjectDependenciesManager(projectDependencies);
polyglotContext = polyglotContext =
@@ -97,6 +99,7 @@ public class ReplServer implements AutoCloseable {
new VmContext.Holder( new VmContext.Holder(
frameTransformer, frameTransformer,
securityManager, securityManager,
httpClient,
moduleResolver, moduleResolver,
new ResourceManager(securityManager, resourceReaders), new ResourceManager(securityManager, resourceReaders),
logger, logger,

View File

@@ -19,6 +19,8 @@ import java.io.FileNotFoundException;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse.BodyHandlers;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
@@ -38,6 +40,7 @@ import org.pkl.core.packages.PackageAssetUri;
import org.pkl.core.packages.PackageResolver; import org.pkl.core.packages.PackageResolver;
import org.pkl.core.runtime.VmContext; import org.pkl.core.runtime.VmContext;
import org.pkl.core.util.ErrorMessages; import org.pkl.core.util.ErrorMessages;
import org.pkl.core.util.HttpUtils;
import org.pkl.core.util.IoUtils; import org.pkl.core.util.IoUtils;
import org.pkl.core.util.Nullable; import org.pkl.core.util.Nullable;
@@ -291,6 +294,15 @@ public final class ResourceReaders {
private abstract static class UrlResource implements ResourceReader { private abstract static class UrlResource implements ResourceReader {
@Override @Override
public Optional<Object> read(URI uri) throws IOException { 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 { try {
var url = IoUtils.toUrl(uri); var url = IoUtils.toUrl(uri);
var content = IoUtils.readBytes(url); 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 java.util.Optional;
import org.pkl.core.SecurityManager; import org.pkl.core.SecurityManager;
import org.pkl.core.SecurityManagerException; import org.pkl.core.SecurityManagerException;
import org.pkl.core.http.HttpClientInitException;
import org.pkl.core.module.ModuleKey; import org.pkl.core.module.ModuleKey;
import org.pkl.core.packages.PackageLoadError; import org.pkl.core.packages.PackageLoadError;
import org.pkl.core.resource.Resource; import org.pkl.core.resource.Resource;
@@ -105,7 +106,7 @@ public final class ResourceManager {
.withHint(e.getMessage()) .withHint(e.getMessage())
.withLocation(readNode) .withLocation(readNode)
.build(); .build();
} catch (SecurityManagerException e) { } catch (SecurityManagerException | HttpClientInitException e) {
throw new VmExceptionBuilder().withCause(e).withLocation(readNode).build(); throw new VmExceptionBuilder().withCause(e).withLocation(readNode).build();
} catch (IOException e) { } catch (IOException e) {
throw new VmExceptionBuilder() throw new VmExceptionBuilder()
@@ -151,7 +152,7 @@ public final class ResourceManager {
.withHint(e.getReason()) .withHint(e.getReason())
.withLocation(readNode) .withLocation(readNode)
.build(); .build();
} catch (SecurityManagerException | PackageLoadError e) { } catch (SecurityManagerException | PackageLoadError | HttpClientInitException e) {
throw new VmExceptionBuilder().withCause(e).withLocation(readNode).build(); throw new VmExceptionBuilder().withCause(e).withLocation(readNode).build();
} }
if (resource.isEmpty()) return resource; if (resource.isEmpty()) return resource;

View File

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

View File

@@ -23,6 +23,7 @@ import java.util.Map;
import org.pkl.core.Logger; import org.pkl.core.Logger;
import org.pkl.core.SecurityManager; import org.pkl.core.SecurityManager;
import org.pkl.core.StackFrameTransformer; import org.pkl.core.StackFrameTransformer;
import org.pkl.core.http.HttpClient;
import org.pkl.core.module.ProjectDependenciesManager; import org.pkl.core.module.ProjectDependenciesManager;
import org.pkl.core.packages.PackageResolver; import org.pkl.core.packages.PackageResolver;
import org.pkl.core.util.LateInit; import org.pkl.core.util.LateInit;
@@ -39,6 +40,7 @@ public final class VmContext {
private final StackFrameTransformer frameTransformer; private final StackFrameTransformer frameTransformer;
private final SecurityManager securityManager; private final SecurityManager securityManager;
private final HttpClient httpClient;
private final ModuleResolver moduleResolver; private final ModuleResolver moduleResolver;
private final ResourceManager resourceManager; private final ResourceManager resourceManager;
private final Logger logger; private final Logger logger;
@@ -52,6 +54,7 @@ public final class VmContext {
public Holder( public Holder(
StackFrameTransformer frameTransformer, StackFrameTransformer frameTransformer,
SecurityManager securityManager, SecurityManager securityManager,
HttpClient httpClient,
ModuleResolver moduleResolver, ModuleResolver moduleResolver,
ResourceManager resourceManager, ResourceManager resourceManager,
Logger logger, Logger logger,
@@ -64,6 +67,7 @@ public final class VmContext {
this.frameTransformer = frameTransformer; this.frameTransformer = frameTransformer;
this.securityManager = securityManager; this.securityManager = securityManager;
this.httpClient = httpClient;
this.moduleResolver = moduleResolver; this.moduleResolver = moduleResolver;
this.resourceManager = resourceManager; this.resourceManager = resourceManager;
this.logger = logger; this.logger = logger;
@@ -108,6 +112,10 @@ public final class VmContext {
return holder.securityManager; return holder.securityManager;
} }
public HttpClient getHttpClient() {
return holder.httpClient;
}
public ModuleResolver getModuleResolver() { public ModuleResolver getModuleResolver() {
return holder.moduleResolver; 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 static org.pkl.core.module.ProjectDependenciesManager.PKL_PROJECT_FILENAME;
import java.net.URI;
import java.nio.file.Path; 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.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.pkl.core.*; import org.pkl.core.*;
import org.pkl.core.http.HttpClient;
import org.pkl.core.module.ModuleKeyFactories; import org.pkl.core.module.ModuleKeyFactories;
import org.pkl.core.module.ModulePathResolver; import org.pkl.core.module.ModulePathResolver;
import org.pkl.core.project.Project; 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.ExecutorSpi;
import org.pkl.executor.spi.v1.ExecutorSpiException; import org.pkl.executor.spi.v1.ExecutorSpiException;
import org.pkl.executor.spi.v1.ExecutorSpiOptions; import org.pkl.executor.spi.v1.ExecutorSpiOptions;
import org.pkl.executor.spi.v1.ExecutorSpiOptions2;
public class ExecutorSpiImpl implements ExecutorSpi { 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(); 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 @Override
public String getPklVersion() { public String getPklVersion() {
return pklVersion; return pklVersion;
@@ -65,6 +94,7 @@ public class ExecutorSpiImpl implements ExecutorSpi {
EvaluatorBuilder.unconfigured() EvaluatorBuilder.unconfigured()
.setStackFrameTransformer(transformer) .setStackFrameTransformer(transformer)
.setSecurityManager(securityManager) .setSecurityManager(securityManager)
.setHttpClient(getOrCreateHttpClient(options))
.addResourceReader(ResourceReaders.environmentVariable()) .addResourceReader(ResourceReaders.environmentVariable())
.addResourceReader(ResourceReaders.externalProperty()) .addResourceReader(ResourceReaders.externalProperty())
.addResourceReader(ResourceReaders.modulePath(resolver)) .addResourceReader(ResourceReaders.modulePath(resolver))
@@ -98,4 +128,61 @@ public class ExecutorSpiImpl implements ExecutorSpi {
ModuleKeyFactories.closeQuietly(builder.getModuleKeyFactories()); 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 { 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()) { try (var stream = url.openStream()) {
return readString(stream); return readString(stream);
} }
@@ -110,6 +113,9 @@ public final class IoUtils {
} }
public static byte[] readBytes(URL url) throws IOException { 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()) { try (var stream = url.openStream()) {
return stream.readAllBytes(); return stream.readAllBytes();
} }

View File

@@ -940,6 +940,31 @@ ioErrorMakingHttpGet=\
Exception when making request `GET {0}`:\n\ Exception when making request `GET {0}`:\n\
{1} {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=\ invalidPackageZipUrl=\
Expected the zip asset for package `{0}` to be an HTTPS URI, but got `{1}`. 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=\ testsFailed=\
Tests failed. 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.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.pkl.commons.toPath import org.pkl.commons.toPath
import org.pkl.core.http.HttpClient
import org.pkl.core.module.ModuleKeyFactories import org.pkl.core.module.ModuleKeyFactories
import org.pkl.core.repl.ReplRequest import org.pkl.core.repl.ReplRequest
import org.pkl.core.repl.ReplResponse import org.pkl.core.repl.ReplResponse
@@ -12,6 +13,7 @@ import org.pkl.core.resource.ResourceReaders
class ReplServerTest { class ReplServerTest {
private val server = ReplServer( private val server = ReplServer(
SecurityManagers.defaultManager, SecurityManagers.defaultManager,
HttpClient.dummyClient(),
Loggers.stdErr(), Loggers.stdErr(),
listOf( listOf(
ModuleKeyFactories.standardLibrary, 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.FileTestUtils
import org.pkl.commons.test.PackageServer import org.pkl.commons.test.PackageServer
import org.pkl.commons.test.listFilesRecursively import org.pkl.commons.test.listFilesRecursively
import org.pkl.core.http.HttpClient
import org.pkl.core.SecurityManagers import org.pkl.core.SecurityManagers
import org.pkl.core.module.PathElement import org.pkl.core.module.PathElement
import org.pkl.core.runtime.CertificateUtils
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.IOException import java.io.IOException
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
@@ -34,9 +34,12 @@ class PackageResolversTest {
@JvmStatic @JvmStatic
@BeforeAll @BeforeAll
fun beforeAll() { fun beforeAll() {
CertificateUtils.setupAllX509CertificatesGlobally(listOf(FileTestUtils.selfSignedCertificate))
PackageServer.ensureStarted() PackageServer.ensureStarted()
} }
val httpClient: HttpClient = HttpClient.builder()
.addCertificates(FileTestUtils.selfSignedCertificate)
.build()
} }
@Test @Test
@@ -196,16 +199,17 @@ class PackageResolversTest {
@BeforeAll @BeforeAll
@JvmStatic @JvmStatic
fun beforeAll() { fun beforeAll() {
CertificateUtils.setupAllX509CertificatesGlobally(listOf(FileTestUtils.selfSignedCertificate))
PackageServer.ensureStarted() PackageServer.ensureStarted()
cacheDir.deleteRecursively() cacheDir.deleteRecursively()
} }
} }
override val resolver: PackageResolver = PackageResolvers.DiskCachedPackageResolver(SecurityManagers.defaultManager, cacheDir) override val resolver: PackageResolver = PackageResolvers.DiskCachedPackageResolver(
SecurityManagers.defaultManager, httpClient, cacheDir)
} }
class InMemoryPackageResolverTest : AbstractPackageResolverTest() { 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.junit.jupiter.api.assertThrows
import org.pkl.commons.test.FileTestUtils import org.pkl.commons.test.FileTestUtils
import org.pkl.commons.test.PackageServer import org.pkl.commons.test.PackageServer
import org.pkl.core.http.HttpClient
import org.pkl.core.PklException import org.pkl.core.PklException
import org.pkl.core.SecurityManagers import org.pkl.core.SecurityManagers
import org.pkl.core.packages.PackageResolver import org.pkl.core.packages.PackageResolver
import org.pkl.core.runtime.CertificateUtils
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.nio.file.Path import java.nio.file.Path
@@ -19,16 +19,19 @@ class ProjectDependenciesResolverTest {
@JvmStatic @JvmStatic
@BeforeAll @BeforeAll
fun beforeAll() { fun beforeAll() {
CertificateUtils.setupAllX509CertificatesGlobally(listOf(FileTestUtils.selfSignedCertificate))
PackageServer.ensureStarted() PackageServer.ensureStarted()
} }
val httpClient: HttpClient = HttpClient.builder()
.addCertificates(FileTestUtils.selfSignedCertificate)
.build()
} }
@Test @Test
fun resolveDependencies() { fun resolveDependencies() {
val project2Path = Path.of(javaClass.getResource("project2/PklProject")!!.path) val project2Path = Path.of(javaClass.getResource("project2/PklProject")!!.path)
val project = Project.loadFromPath(project2Path) 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 deps = ProjectDependenciesResolver(project, packageResolver, System.out.writer()).resolve()
val strDeps = ByteArrayOutputStream() val strDeps = ByteArrayOutputStream()
.apply { deps.writeTo(this) } .apply { deps.writeTo(this) }
@@ -66,7 +69,7 @@ class ProjectDependenciesResolverTest {
fun `fails if project declares a package with an incorrect checksum`() { fun `fails if project declares a package with an incorrect checksum`() {
val projectPath = Path.of(javaClass.getResource("badProjectChecksum/PklProject")!!.path) val projectPath = Path.of(javaClass.getResource("badProjectChecksum/PklProject")!!.path)
val project = Project.loadFromPath(projectPath) val project = Project.loadFromPath(projectPath)
val packageResolver = PackageResolver.getInstance(SecurityManagers.defaultManager, null) val packageResolver = PackageResolver.getInstance(SecurityManagers.defaultManager, httpClient, null)
val e = assertThrows<PklException> { val e = assertThrows<PklException> {
ProjectDependenciesResolver(project, packageResolver, System.err.writer()).resolve() 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.assertj.core.api.Assertions.assertThatCode
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir 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.net.URI
import java.nio.file.Path import java.nio.file.Path
import java.util.regex.Pattern import java.util.regex.Pattern
@@ -137,9 +139,13 @@ class ProjectTest {
PackageServer.ensureStarted() PackageServer.ensureStarted()
val projectDir = Path.of(javaClass.getResource("badProjectChecksum2/")!!.path) val projectDir = Path.of(javaClass.getResource("badProjectChecksum2/")!!.path)
val project = Project.loadFromPath(projectDir.resolve("PklProject")) val project = Project.loadFromPath(projectDir.resolve("PklProject"))
val httpClient = HttpClient.builder()
.addCertificates(FileTestUtils.selfSignedCertificate)
.build()
val evaluator = EvaluatorBuilder.preconfigured() val evaluator = EvaluatorBuilder.preconfigured()
.applyFromProject(project) .applyFromProject(project)
.setModuleCacheDir(null) .setModuleCacheDir(null)
.setHttpClient(httpClient)
.build() .build()
assertThatCode { evaluator.evaluate(ModuleSource.path(projectDir.resolve("bug.pkl"))) } assertThatCode { evaluator.evaluate(ModuleSource.path(projectDir.resolve("bug.pkl"))) }
.hasMessageStartingWith(""" .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.io.FileNotFoundException
import java.net.URI import java.net.URI
import java.net.URISyntaxException import java.net.URISyntaxException
import java.net.URL
import java.nio.file.Path import java.nio.file.Path
import kotlin.io.path.createFile import kotlin.io.path.createFile
@@ -410,4 +411,24 @@ class IoUtilsTest {
IoUtils.resolve(FakeSecurityManager, key2, URI("...NamedModuleResolversTest.pkl")) 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

View File

@@ -35,7 +35,8 @@ import org.pkl.core.packages.*
*/ */
class CliDocGenerator(private val options: CliDocGeneratorOptions) : CliCommand(options.base) { class CliDocGenerator(private val options: CliDocGeneratorOptions) : CliCommand(options.base) {
private val packageResolver = PackageResolver.getInstance(securityManager, moduleCacheDir) private val packageResolver =
PackageResolver.getInstance(securityManager, httpClient, moduleCacheDir)
private val stdlibDependency = private val stdlibDependency =
DocPackageInfo.PackageDependency( DocPackageInfo.PackageDependency(

View File

@@ -5,14 +5,17 @@ plugins {
pklKotlinTest pklKotlinTest
} }
val pklDistribution: Configuration by configurations.creating val pklDistributionCurrent: Configuration by configurations.creating
val pklDistribution025: Configuration by configurations.creating
// Because pkl-executor doesn't depend on other Pkl modules // Because pkl-executor doesn't depend on other Pkl modules
// (nor has overlapping dependencies that could cause a version conflict), // (nor has overlapping dependencies that could cause a version conflict),
// clients are free to use different versions of pkl-executor and (say) pkl-config-java-all. // clients are free to use different versions of pkl-executor and (say) pkl-config-java-all.
// (Pkl distributions used by EmbeddedExecutor are isolated via class loaders.) // (Pkl distributions used by EmbeddedExecutor are isolated via class loaders.)
dependencies { dependencies {
pklDistribution(project(":pkl-config-java", "fatJar")) pklDistributionCurrent(project(":pkl-config-java", "fatJar"))
@Suppress("UnstableApiUsage")
pklDistribution025(libs.pklConfigJavaAll025)
implementation(libs.slf4jApi) implementation(libs.slf4jApi)
@@ -49,7 +52,14 @@ sourceSets {
} }
} }
tasks.test { // this task could be folded into tasks.test by switching to IntelliJ's Gradle test runner
val prepareTest by tasks.registering {
// used by EmbeddedExecutorTest // used by EmbeddedExecutorTest
dependsOn(pklDistribution) dependsOn(pklDistributionCurrent, pklDistribution025)
}
tasks.test {
dependsOn(prepareTest)
systemProperty("pklDistributionCurrent", pklDistributionCurrent.singleFile)
systemProperty("pklDistribution025", pklDistribution025.singleFile)
} }

View File

@@ -27,7 +27,6 @@ import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.pkl.executor.spi.v1.ExecutorSpi; import org.pkl.executor.spi.v1.ExecutorSpi;
import org.pkl.executor.spi.v1.ExecutorSpiException; import org.pkl.executor.spi.v1.ExecutorSpiException;
import org.pkl.executor.spi.v1.ExecutorSpiOptions;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -40,12 +39,17 @@ final class EmbeddedExecutor implements Executor {
private final List<PklDistribution> pklDistributions = new ArrayList<>(); private final List<PklDistribution> pklDistributions = new ArrayList<>();
/** /**
* @throws IllegalArgumentException if a Jar file cannot be found or is not a valid PklPkl * @throws IllegalArgumentException if a Jar file cannot be found or is not a valid Pkl
* distribution * distribution
*/ */
public EmbeddedExecutor(List<Path> pklFatJars) { public EmbeddedExecutor(List<Path> pklFatJars) {
this(pklFatJars, Executor.class.getClassLoader());
}
// for testing only
EmbeddedExecutor(List<Path> pklFatJars, ClassLoader pklExecutorClassLoader) {
for (var jarFile : pklFatJars) { for (var jarFile : pklFatJars) {
pklDistributions.add(new PklDistribution(jarFile)); pklDistributions.add(new PklDistribution(jarFile, pklExecutorClassLoader));
} }
} }
@@ -72,6 +76,7 @@ final class EmbeddedExecutor implements Executor {
// (but not any modules imported by it) and only requires parsing (but not evaluating) the // (but not any modules imported by it) and only requires parsing (but not evaluating) the
// module. // module.
requestedVersion = detectRequestedPklVersion(modulePath, options); requestedVersion = detectRequestedPklVersion(modulePath, options);
//noinspection resource
distribution = findCompatibleDistribution(modulePath, requestedVersion, options); distribution = findCompatibleDistribution(modulePath, requestedVersion, options);
output = distribution.evaluatePath(modulePath, options); output = distribution.evaluatePath(modulePath, options);
} catch (RuntimeException e) { } catch (RuntimeException e) {
@@ -163,22 +168,23 @@ final class EmbeddedExecutor implements Executor {
} }
private static final class PklDistribution implements AutoCloseable { private static final class PklDistribution implements AutoCloseable {
final URLClassLoader classLoader; final URLClassLoader pklDistributionClassLoader;
final ExecutorSpi executorSpi; final /* @Nullable */ ExecutorSpi executorSpi;
final Version version; final Version version;
/** /**
* @throws IllegalArgumentException if the Jar file does not exist or is not a valid Pkl * @throws IllegalArgumentException if the Jar file does not exist or is not a valid Pkl
* distribution * distribution
*/ */
PklDistribution(Path pklFatJar) { PklDistribution(Path pklFatJar, ClassLoader pklExecutorClassLoader) {
if (!Files.isRegularFile(pklFatJar)) { if (!Files.isRegularFile(pklFatJar)) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
String.format("Invalid Pkl distribution: Cannot find Jar file `%s`.", pklFatJar)); String.format("Invalid Pkl distribution: Cannot find Jar file `%s`.", pklFatJar));
} }
classLoader = new PklDistributionClassLoader(pklFatJar); pklDistributionClassLoader =
var serviceLoader = ServiceLoader.load(ExecutorSpi.class, classLoader); new PklDistributionClassLoader(pklFatJar, pklExecutorClassLoader);
var serviceLoader = ServiceLoader.load(ExecutorSpi.class, pklDistributionClassLoader);
try { try {
executorSpi = serviceLoader.iterator().next(); executorSpi = serviceLoader.iterator().next();
@@ -208,9 +214,9 @@ final class EmbeddedExecutor implements Executor {
var currentThread = Thread.currentThread(); var currentThread = Thread.currentThread();
var prevContextClassLoader = currentThread.getContextClassLoader(); var prevContextClassLoader = currentThread.getContextClassLoader();
// Truffle loads stuff from context class loader, so set it to our class loader // Truffle loads stuff from context class loader, so set it to our class loader
currentThread.setContextClassLoader(classLoader); currentThread.setContextClassLoader(pklDistributionClassLoader);
try { try {
return executorSpi.evaluatePath(modulePath, toEvaluatorOptions(options)); return executorSpi.evaluatePath(modulePath, options.toSpiOptions());
} catch (ExecutorSpiException e) { } catch (ExecutorSpiException e) {
throw new ExecutorException(e.getMessage(), e.getCause()); throw new ExecutorException(e.getMessage(), e.getCause());
} finally { } finally {
@@ -220,30 +226,21 @@ final class EmbeddedExecutor implements Executor {
@Override @Override
public void close() throws IOException { public void close() throws IOException {
classLoader.close(); pklDistributionClassLoader.close();
}
ExecutorSpiOptions toEvaluatorOptions(ExecutorOptions options) {
return new ExecutorSpiOptions(
options.getAllowedModules(),
options.getAllowedResources(),
options.getEnvironmentVariables(),
options.getExternalProperties(),
options.getModulePath(),
options.getRootDir(),
options.getTimeout(),
options.getOutputFormat(),
options.getModuleCacheDir(),
options.getProjectDir());
} }
} }
private static final class PklDistributionClassLoader extends URLClassLoader { private static final class PklDistributionClassLoader extends URLClassLoader {
final ClassLoader spiClassLoader = ExecutorSpi.class.getClassLoader(); final ClassLoader pklExecutorClassLoader;
PklDistributionClassLoader(Path pklFatJar) { static {
registerAsParallelCapable();
}
PklDistributionClassLoader(Path pklFatJar, ClassLoader pklExecutorClassLoader) {
// pass `null` to make bootstrap class loader the effective parent // pass `null` to make bootstrap class loader the effective parent
super(toUrls(pklFatJar), null); super(toUrls(pklFatJar), null);
this.pklExecutorClassLoader = pklExecutorClassLoader;
} }
@Override @Override
@@ -253,7 +250,15 @@ final class EmbeddedExecutor implements Executor {
if (clazz == null) { if (clazz == null) {
if (name.startsWith("org.pkl.executor.spi.")) { if (name.startsWith("org.pkl.executor.spi.")) {
clazz = spiClassLoader.loadClass(name); try {
// give pkl-executor a chance to load the SPI clasa
clazz = pklExecutorClassLoader.loadClass(name);
} catch (ClassNotFoundException ignored) {
// The SPI class exists in this distribution but not in pkl-executor,
// so load it from the distribution.
// This can happen if the pkl-executor version is lower than the distribution version.
clazz = findClass(name);
}
} else if (name.startsWith("java.") } else if (name.startsWith("java.")
|| name.startsWith("jdk.") || name.startsWith("jdk.")
|| name.startsWith("sun.") || name.startsWith("sun.")
@@ -282,18 +287,14 @@ final class EmbeddedExecutor implements Executor {
@Override @Override
public URL getResource(String name) { public URL getResource(String name) {
// try bootstrap class loader first var resource = getPlatformClassLoader().getResource(name);
// once we move to JDK 9+, should use `getPlatformClassLoader().getResource()` instead of
// `super.getResource()`
var resource = super.getResource(name);
return resource != null ? resource : findResource(name); return resource != null ? resource : findResource(name);
} }
@Override @Override
public Enumeration<URL> getResources(String name) throws IOException { public Enumeration<URL> getResources(String name) throws IOException {
// once we move to JDK 9+, should use `getPlatformClassLoader().getResources()` instead of return ConcatenatedEnumeration.create(
// `super.getResources()` getPlatformClassLoader().getResources(name), findResources(name));
return ConcatenatedEnumeration.create(super.getResources(name), findResources(name));
} }
static URL[] toUrls(Path pklFatJar) { static URL[] toUrls(Path pklFatJar) {

View File

@@ -20,27 +20,33 @@ import java.time.Duration;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import org.pkl.executor.spi.v1.ExecutorSpiOptions;
/** Options for {@link Executor#evaluatePath}. */ /**
public final class ExecutorOptions { * Options for {@link Executor#evaluatePath}.
private final List<String> allowedModules; *
* <p>Note that subclasses of {@code ExecutorOptions} offer additional options.
*/
public class ExecutorOptions {
protected final List<String> allowedModules;
private final List<String> allowedResources; protected final List<String> allowedResources;
private final Map<String, String> environmentVariables; protected final Map<String, String> environmentVariables;
private final Map<String, String> externalProperties; protected final Map<String, String> externalProperties;
private final List<Path> modulePath; protected final List<Path> modulePath;
private final /* @Nullable */ Path rootDir; protected final /* @Nullable */ Path rootDir;
private final /* @Nullable */ Duration timeout; protected final /* @Nullable */ Duration timeout;
private final /* @Nullable */ String outputFormat; protected final /* @Nullable */ String outputFormat;
private final /* @Nullable */ Path moduleCacheDir; protected final /* @Nullable */ Path moduleCacheDir;
private final /* @Nullable */ Path projectDir;
protected final /* @Nullable */ Path projectDir;
/** Returns the module cache dir that the CLI uses by default. */ /** Returns the module cache dir that the CLI uses by default. */
public static Path defaultModuleCacheDir() { public static Path defaultModuleCacheDir() {
@@ -148,7 +154,7 @@ public final class ExecutorOptions {
@Override @Override
public boolean equals(/* @Nullable */ Object obj) { public boolean equals(/* @Nullable */ Object obj) {
if (this == obj) return true; if (this == obj) return true;
if (!(obj instanceof ExecutorOptions)) return false; if (obj.getClass() != ExecutorOptions.class) return false;
var other = (ExecutorOptions) obj; var other = (ExecutorOptions) obj;
return allowedModules.equals(other.allowedModules) return allowedModules.equals(other.allowedModules)
@@ -203,4 +209,18 @@ public final class ExecutorOptions {
+ projectDir + projectDir
+ '}'; + '}';
} }
ExecutorSpiOptions toSpiOptions() {
return new ExecutorSpiOptions(
allowedModules,
allowedResources,
environmentVariables,
externalProperties,
modulePath,
rootDir,
timeout,
outputFormat,
moduleCacheDir,
projectDir);
}
} }

View File

@@ -0,0 +1,174 @@
/**
* 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.executor;
import java.net.URI;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.pkl.executor.spi.v1.ExecutorSpiOptions2;
/**
* Options for {@link Executor#evaluatePath}.
*
* <p>This class offers additional options not available in {@code ExecutorOptions}.
*/
public class ExecutorOptions2 extends ExecutorOptions {
protected final List<Path> certificateFiles;
protected final List<URI> certificateUris;
/**
* Constructs an options object.
*
* @param allowedModules API equivalent of the {@code --allowed-modules} CLI option
* @param allowedResources API equivalent of the {@code --allowed-resources} CLI option
* @param environmentVariables API equivalent of the repeatable {@code --env-var} CLI option
* @param externalProperties API equivalent of the repeatable {@code --property} CLI option
* @param modulePath API equivalent of the {@code --module-path} CLI option
* @param rootDir API equivalent of the {@code --root-dir} CLI option
* @param timeout API equivalent of the {@code --timeout} CLI option
* @param outputFormat API equivalent of the {@code --format} CLI option
* @param moduleCacheDir API equivalent of the {@code --cache-dir} CLI option. Passing {@link
* #defaultModuleCacheDir()} is equivalent to omitting {@code --cache-dir}. Passing {@code
* null} is equivalent to {@code --no-cache}.
* @param projectDir API equivalent of the {@code --project-dir} CLI option.
* @param certificateFiles API equivalent of the {@code --ca-certificates} CLI option
* @param certificateUris API equivalent of the {@code --ca-certificates} CLI option
*/
public ExecutorOptions2(
List<String> allowedModules,
List<String> allowedResources,
Map<String, String> environmentVariables,
Map<String, String> externalProperties,
List<Path> modulePath,
/* @Nullable */ Path rootDir,
/* @Nullable */ Duration timeout,
/* @Nullable */ String outputFormat,
/* @Nullable */ Path moduleCacheDir,
/* @Nullable */ Path projectDir,
List<Path> certificateFiles,
List<URI> certificateUris) {
super(
allowedModules,
allowedResources,
environmentVariables,
externalProperties,
modulePath,
rootDir,
timeout,
outputFormat,
moduleCacheDir,
projectDir);
this.certificateFiles = certificateFiles;
this.certificateUris = certificateUris;
}
/** API equivalent of the {@code --ca-certificates} CLI option. */
public List<Path> getCertificateFiles() {
return certificateFiles;
}
/** API equivalent of the {@code --ca-certificates} CLI option. */
public List<URI> getCertificateUris() {
return certificateUris;
}
@Override
public boolean equals(/* @Nullable */ Object obj) {
if (this == obj) return true;
if (obj.getClass() != ExecutorOptions2.class) return false;
var other = (ExecutorOptions2) obj;
return allowedModules.equals(other.allowedModules)
&& allowedResources.equals(other.allowedResources)
&& environmentVariables.equals(other.environmentVariables)
&& externalProperties.equals(other.externalProperties)
&& modulePath.equals(other.modulePath)
&& Objects.equals(rootDir, other.rootDir)
&& Objects.equals(timeout, other.timeout)
&& Objects.equals(outputFormat, other.outputFormat)
&& Objects.equals(moduleCacheDir, other.moduleCacheDir)
&& Objects.equals(projectDir, other.projectDir)
&& Objects.equals(certificateFiles, other.certificateFiles)
&& Objects.equals(certificateUris, other.certificateUris);
}
@Override
public int hashCode() {
return Objects.hash(
allowedModules,
allowedResources,
environmentVariables,
externalProperties,
modulePath,
rootDir,
timeout,
outputFormat,
moduleCacheDir,
projectDir,
certificateFiles,
certificateUris);
}
@Override
public String toString() {
return "ExecutorOptions2{"
+ "allowedModules="
+ allowedModules
+ ", allowedResources="
+ allowedResources
+ ", environmentVariables="
+ environmentVariables
+ ", externalProperties="
+ externalProperties
+ ", modulePath="
+ modulePath
+ ", rootDir="
+ rootDir
+ ", timeout="
+ timeout
+ ", outputFormat="
+ outputFormat
+ ", cacheDir="
+ moduleCacheDir
+ ", projectDir="
+ projectDir
+ ", certificateFiles="
+ certificateFiles
+ ", certificateUris="
+ certificateUris
+ '}';
}
ExecutorSpiOptions2 toSpiOptions() {
return new ExecutorSpiOptions2(
allowedModules,
allowedResources,
environmentVariables,
externalProperties,
modulePath,
rootDir,
timeout,
outputFormat,
moduleCacheDir,
projectDir,
certificateFiles,
certificateUris);
}
}

View File

@@ -0,0 +1,64 @@
/**
* 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.executor.spi.v1;
import java.net.URI;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;
public class ExecutorSpiOptions2 extends ExecutorSpiOptions {
private final List<Path> certificateFiles;
private final List<URI> certificateUris;
public ExecutorSpiOptions2(
List<String> allowedModules,
List<String> allowedResources,
Map<String, String> environmentVariables,
Map<String, String> externalProperties,
List<Path> modulePath,
Path rootDir,
Duration timeout,
String outputFormat,
Path moduleCacheDir,
Path projectDir,
List<Path> certificateFiles,
List<URI> certificateUris) {
super(
allowedModules,
allowedResources,
environmentVariables,
externalProperties,
modulePath,
rootDir,
timeout,
outputFormat,
moduleCacheDir,
projectDir);
this.certificateFiles = certificateFiles;
this.certificateUris = certificateUris;
}
public List<Path> getCertificateFiles() {
return certificateFiles;
}
public List<URI> getCertificateUris() {
return certificateUris;
}
}

View File

@@ -1,42 +1,159 @@
package org.pkl.executor package org.pkl.executor
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.api.io.TempDir
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import org.pkl.commons.test.FileTestUtils import org.pkl.commons.test.FileTestUtils
import org.pkl.commons.test.FilteringClassLoader
import org.pkl.commons.test.PackageServer import org.pkl.commons.test.PackageServer
import org.pkl.commons.toPath import org.pkl.commons.toPath
import org.pkl.commons.walk import org.pkl.core.Release
import org.pkl.core.runtime.CertificateUtils
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.time.Duration import java.time.Duration
import kotlin.io.path.createDirectories import kotlin.io.path.createDirectories
import kotlin.io.path.exists
class EmbeddedExecutorTest { class EmbeddedExecutorTest {
private val pklDistribution by lazy { /**
val libsDir = FileTestUtils.rootProjectDir.resolve("pkl-config-java/build/libs") * A combination of ExecutorOptions version, pkl-executor version,
if (!Files.isDirectory(libsDir)) { * and Pkl distribution version that parameterized tests should be run against.
throw AssertionError( */
"JAR `pkl-config-java-all` does not exist. Run `./gradlew :pkl-config-java:build` to create it." data class ExecutionContext(
val executor: Executor,
val options: (ExecutorOptions) -> ExecutorOptions,
val name: String
) {
override fun toString(): String = name
}
companion object {
@JvmStatic
private val allExecutionContexts: List<ExecutionContext> by lazy {
listOf(
ExecutionContext(executor1_1.value, ::convertToOptions1, "Options1, Executor1, Distribution1"),
// This context has a pkl-executor version that is lower than the distribution version.
// It can be enabled once there is a distribution that includes pkl-executor.
//ExecutionContext(executor1_2.value, ::convertToOptions1, "Options1, Executor1, Distribution2"),
ExecutionContext(executor2_1.value, ::convertToOptions1, "Options1, Executor2, Distribution1"),
ExecutionContext(executor2_1.value, ::convertToOptions2, "Options2, Executor2, Distribution1"),
ExecutionContext(executor2_2.value, ::convertToOptions1, "Options1, Executor2, Distribution2"),
ExecutionContext(executor2_2.value, ::convertToOptions2, "Options2, Executor2, Distribution2")
) )
} }
libsDir.walk()
.filter { path -> private val currentExecutor: Executor by lazy { executor2_2.value }
path.toString().let {
it.contains("-all") && // A pkl-executor library that supports ExecutorSpiOptions up to v1
it.endsWith(".jar") && // and a Pkl distribution that supports ExecutorSpiOptions up to v1.
!it.contains("-sources") && private val executor1_1: Lazy<Executor> = lazy {
!it.contains("-javadoc") EmbeddedExecutor(listOf(pklDistribution1), pklExecutorClassLoader1)
}
// A pkl-executor library that supports ExecutorSpiOptions up to v1
// and a Pkl distribution that supports ExecutorSpiOptions up to v2.
private val executor1_2: Lazy<Executor> = lazy {
EmbeddedExecutor(listOf(pklDistribution2), pklExecutorClassLoader1)
}
// A pkl-executor library that supports ExecutorSpiOptions up to v2
// and a Pkl distribution that supports ExecutorSpiOptions up to v1.
private val executor2_1: Lazy<Executor> = lazy {
EmbeddedExecutor(listOf(pklDistribution1), pklExecutorClassLoader2)
}
// A pkl-executor library that supports ExecutorSpiOptions up to v2
// and a Pkl distribution that supports ExecutorSpiOptions up to v.
private val executor2_2: Lazy<Executor> = lazy {
EmbeddedExecutor(listOf(pklDistribution2), pklExecutorClassLoader2)
}
private val allExecutors by lazy {
listOf(executor1_1, executor1_2, executor2_1, executor2_2)
}
// a pkl-executor class loader that supports ExecutorSpiOptions up to v1
private val pklExecutorClassLoader1: ClassLoader by lazy {
FilteringClassLoader(pklExecutorClassLoader2) { className ->
!className.endsWith("ExecutorSpiOptions2")
}
}
// a pkl-executor class loader that supports ExecutorSpiOptions up to v2
private val pklExecutorClassLoader2: ClassLoader by lazy {
EmbeddedExecutor::class.java.classLoader
}
@AfterAll
@JvmStatic
fun afterAll() {
for (executor in allExecutors) {
if (executor.isInitialized()) executor.value.close()
}
}
// a Pkl distribution that supports ExecutorSpiOptions up to v1
private val pklDistribution1: Path by lazy {
val path = System.getProperty("pklDistribution025")?.toPath() ?:
// can get rid of this path by switching to IntelliJ's Gradle test runner
System.getProperty("user.home").toPath()
.resolve(".gradle/caches/modules-2/files-2.1/org.pkl-lang/pkl-config-java-all/" +
"0.25.0/e9451dda554f1659e49ff5bdd30accd26be7bf0f/pkl-config-java-all-0.25.0.jar")
path.apply {
if (!exists()) throw AssertionError("Missing test fixture. " +
"To fix this problem, run `./gradlew :pkl-executor:prepareTest`.")
} }
}
// a Pkl distribution that supports ExecutorSpiOptions up to v2
private val pklDistribution2: Path by lazy {
val path = System.getProperty("pklDistributionCurrent")?.toPath() ?:
// can get rid of this path by switching to IntelliJ's Gradle test runner
FileTestUtils.rootProjectDir
.resolve("pkl-config-java/build/libs/pkl-config-java-all-" +
"${Release.current().version().withBuild(null).toString().replaceFirst("dev", "SNAPSHOT")}.jar")
path.apply {
if (!exists()) throw AssertionError("Missing test fixture. " +
"To fix this problem, run `./gradlew :pkl-executor:prepareTest`.")
} }
.findFirst() }
.orElseThrow {
AssertionError( private fun convertToOptions2(options: ExecutorOptions): ExecutorOptions2 =
"JAR `pkl-config-java-all` does not exist. Run `./gradlew :pkl-config-java:build` to create it." if (options is ExecutorOptions2) options else ExecutorOptions2(
) options.allowedModules,
} options.allowedResources,
options.environmentVariables,
options.externalProperties,
options.modulePath,
options.rootDir,
options.timeout,
options.outputFormat,
options.moduleCacheDir,
options.projectDir,
listOf(),
listOf()
)
private fun convertToOptions1(options: ExecutorOptions): ExecutorOptions =
if (options.javaClass == ExecutorOptions::class.java) options else ExecutorOptions(
options.allowedModules,
options.allowedResources,
options.environmentVariables,
options.externalProperties,
options.modulePath,
options.rootDir,
options.timeout,
options.outputFormat,
options.moduleCacheDir,
options.projectDir
)
} }
@Test @Test
@@ -121,8 +238,9 @@ class EmbeddedExecutorTest {
.contains("pkl.jar") .contains("pkl.jar")
} }
@Test @ParameterizedTest
fun `evaluate a module that is missing a ModuleInfo annotation`(@TempDir tempDir: Path) { @MethodSource("getAllExecutionContexts")
fun `evaluate a module that is missing a ModuleInfo annotation`(context: ExecutionContext, @TempDir tempDir: Path) {
val pklFile = tempDir.resolve("test.pkl") val pklFile = tempDir.resolve("test.pkl")
pklFile.toFile().writeText( pklFile.toFile().writeText(
""" """
@@ -132,12 +250,10 @@ class EmbeddedExecutorTest {
""".trimIndent() """.trimIndent()
) )
val executor = Executors.embedded(listOf(pklDistribution))
val e = assertThrows<ExecutorException> { val e = assertThrows<ExecutorException> {
executor.use { context.executor.evaluatePath(
it.evaluatePath(
pklFile, pklFile,
ExecutorOptions( context.options(ExecutorOptions2(
listOf("file:"), listOf("file:"),
listOf("prop:"), listOf("prop:"),
mapOf(), mapOf(),
@@ -147,18 +263,20 @@ class EmbeddedExecutorTest {
null, null,
null, null,
null, null,
null null,
listOf(),
listOf()
) )
) ))
}
} }
assertThat(e.message) assertThat(e.message)
.contains("Pkl module `test.pkl` does not state which Pkl version it requires.") .contains("Pkl module `test.pkl` does not state which Pkl version it requires.")
} }
@Test @ParameterizedTest
fun `evaluate a module that requests an incompatible Pkl version`(@TempDir tempDir: Path) { @MethodSource("getAllExecutionContexts")
fun `evaluate a module that requests an incompatible Pkl version`(context: ExecutionContext, @TempDir tempDir: Path) {
val pklFile = tempDir.resolve("test.pkl") val pklFile = tempDir.resolve("test.pkl")
pklFile.toFile().writeText( pklFile.toFile().writeText(
""" """
@@ -169,12 +287,10 @@ class EmbeddedExecutorTest {
""".trimIndent() """.trimIndent()
) )
val executor = Executors.embedded(listOf(pklDistribution))
val e = assertThrows<ExecutorException> { val e = assertThrows<ExecutorException> {
executor.use { context.executor.evaluatePath(
it.evaluatePath(
pklFile, pklFile,
ExecutorOptions( context.options(ExecutorOptions2(
listOf("file:"), listOf("file:"),
listOf("prop:"), listOf("prop:"),
mapOf(), mapOf(),
@@ -184,18 +300,20 @@ class EmbeddedExecutorTest {
null, null,
null, null,
null, null,
null null,
) listOf(),
listOf()
))
) )
}
} }
assertThat(e.message) assertThat(e.message)
.contains("Pkl version `99.99.99` requested by module `test.pkl` is not supported.") .contains("Pkl version `99.99.99` requested by module `test.pkl` is not supported.")
} }
@Test @ParameterizedTest
fun `evaluate a module that reads environment variables and external properties`(@TempDir tempDir: Path) { @MethodSource("getAllExecutionContexts")
fun `evaluate a module that reads environment variables and external properties`(context: ExecutionContext, @TempDir tempDir: Path) {
val pklFile = tempDir.resolve("test.pkl") val pklFile = tempDir.resolve("test.pkl")
pklFile.toFile().writeText( pklFile.toFile().writeText(
""" """
@@ -207,11 +325,9 @@ class EmbeddedExecutorTest {
""".trimIndent() """.trimIndent()
) )
val executor = Executors.embedded(listOf(pklDistribution)) val result = context.executor.evaluatePath(
val result = executor.use {
it.evaluatePath(
pklFile, pklFile,
ExecutorOptions( context.options(ExecutorOptions2(
listOf("file:"), listOf("file:"),
// should `prop:pkl.outputFormat` be allowed automatically? // should `prop:pkl.outputFormat` be allowed automatically?
listOf("prop:", "env:"), listOf("prop:", "env:"),
@@ -222,10 +338,11 @@ class EmbeddedExecutorTest {
null, null,
null, null,
null, null,
null null,
) listOf(),
listOf()
))
) )
}
assertThat(result.trim()).isEqualTo( assertThat(result.trim()).isEqualTo(
""" """
@@ -235,8 +352,9 @@ class EmbeddedExecutorTest {
) )
} }
@Test @ParameterizedTest
fun `evaluate a module that depends on another module`(@TempDir tempDir: Path) { @MethodSource("getAllExecutionContexts")
fun `evaluate a module that depends on another module`(context: ExecutionContext, @TempDir tempDir: Path) {
val pklFile = tempDir.resolve("test.pkl") val pklFile = tempDir.resolve("test.pkl")
pklFile.toFile().writeText( pklFile.toFile().writeText(
""" """
@@ -260,11 +378,9 @@ class EmbeddedExecutorTest {
""".trimIndent() """.trimIndent()
) )
val executor = Executors.embedded(listOf(pklDistribution)) val result = context.executor.evaluatePath(
val result = executor.use {
it.evaluatePath(
pklFile, pklFile,
ExecutorOptions( context.options(ExecutorOptions2(
listOf("file:"), listOf("file:"),
listOf("prop:"), listOf("prop:"),
mapOf(), mapOf(),
@@ -274,10 +390,12 @@ class EmbeddedExecutorTest {
null, null,
null, null,
null, null,
null null,
listOf(),
listOf()
) )
) )
} )
assertThat(result.trim()).isEqualTo( assertThat(result.trim()).isEqualTo(
""" """
@@ -288,8 +406,9 @@ class EmbeddedExecutorTest {
) )
} }
@Test @ParameterizedTest
fun `evaluate a module whose evaluation fails`(@TempDir tempDir: Path) { @MethodSource("getAllExecutionContexts")
fun `evaluate a module whose evaluation fails`(context: ExecutionContext, @TempDir tempDir: Path) {
val pklFile = tempDir.resolve("test.pkl") val pklFile = tempDir.resolve("test.pkl")
pklFile.toFile().writeText( pklFile.toFile().writeText(
""" """
@@ -300,12 +419,10 @@ class EmbeddedExecutorTest {
""".trimIndent() """.trimIndent()
) )
val executor = Executors.embedded(listOf(pklDistribution))
val e = assertThrows<ExecutorException> { val e = assertThrows<ExecutorException> {
executor.use { context.executor.evaluatePath(
it.evaluatePath(
pklFile, pklFile,
ExecutorOptions( context.options(ExecutorOptions2(
listOf("file:"), listOf("file:"),
listOf("prop:"), listOf("prop:"),
mapOf(), mapOf(),
@@ -315,10 +432,11 @@ class EmbeddedExecutorTest {
null, null,
null, null,
null, null,
null null,
listOf(),
listOf()
) )
) ))
}
} }
assertThat(e.message) assertThat(e.message)
@@ -328,8 +446,9 @@ class EmbeddedExecutorTest {
.doesNotContain(tempDir.toString()) .doesNotContain(tempDir.toString())
} }
@Test @ParameterizedTest
fun `time out a module`(@TempDir tempDir: Path) { @MethodSource("getAllExecutionContexts")
fun `time out a module`(context: ExecutionContext, @TempDir tempDir: Path) {
val pklFile = tempDir.resolve("test.pkl") val pklFile = tempDir.resolve("test.pkl")
pklFile.toFile().writeText( pklFile.toFile().writeText(
""" """
@@ -342,12 +461,10 @@ class EmbeddedExecutorTest {
""".trimIndent() """.trimIndent()
) )
val executor = Executors.embedded(listOf(pklDistribution))
val e = assertThrows<ExecutorException> { val e = assertThrows<ExecutorException> {
executor.use { context.executor.evaluatePath(
it.evaluatePath(
pklFile, pklFile,
ExecutorOptions( context.options(ExecutorOptions2(
listOf("file:"), listOf("file:"),
listOf("prop:"), listOf("prop:"),
mapOf(), mapOf(),
@@ -357,59 +474,20 @@ class EmbeddedExecutorTest {
Duration.ofSeconds(1), Duration.ofSeconds(1),
null, null,
null, null,
null null,
) listOf(),
listOf()
))
) )
}
} }
assertThat(e.message) assertThat(e.message)
.contains("Evaluation timed out after 1 second(s).") .contains("Evaluation timed out after 1 second(s).")
} }
// Only packages are cached.
// Because this test doesn't import a package, it doesn't really test
// that the `moduleCacheDir` option takes effect.
@Test
fun `evaluate a module with enabled module cache`(@TempDir tempDir: Path) {
val pklFile = tempDir.resolve("test.pkl")
pklFile.toFile().writeText(
"""
@ModuleInfo { minPklVersion = "0.16.0" }
module test
x = 42
""".trimIndent()
)
val executor = Executors.embedded(listOf(pklDistribution))
val result = executor.use {
it.evaluatePath(
pklFile,
ExecutorOptions(
listOf("file:"),
listOf("prop:"),
mapOf(),
mapOf(),
listOf(),
null,
null,
null,
ExecutorOptions.defaultModuleCacheDir(),
null
)
)
}
assertThat(result.trim()).isEqualTo(
"""
x = 42
""".trimIndent().trim()
)
}
@Test @Test
fun `evaluate a module that loads a package`(@TempDir tempDir: Path) { fun `evaluate a module that loads a package`(@TempDir tempDir: Path) {
val cacheDir = tempDir.resolve("cache")
val pklFile = tempDir.resolve("test.pkl") val pklFile = tempDir.resolve("test.pkl")
pklFile.toFile().writeText( pklFile.toFile().writeText(
""" """
@@ -422,11 +500,8 @@ class EmbeddedExecutorTest {
""".trimIndent() """.trimIndent()
) )
PackageServer.ensureStarted() PackageServer.ensureStarted()
CertificateUtils.setupAllX509CertificatesGlobally(listOf(FileTestUtils.selfSignedCertificate)) val result = currentExecutor.evaluatePath(pklFile,
val executor = Executors.embedded(listOf(pklDistribution)) ExecutorOptions2(
val result = executor.use {
it.evaluatePath(pklFile,
ExecutorOptions(
listOf("file:", "package:", "https:"), listOf("file:", "package:", "https:"),
listOf("prop:", "package:", "https:"), listOf("prop:", "package:", "https:"),
mapOf(), mapOf(),
@@ -435,10 +510,11 @@ class EmbeddedExecutorTest {
null, null,
null, null,
null, null,
ExecutorOptions.defaultModuleCacheDir(), cacheDir,
null) null,
listOf(FileTestUtils.selfSignedCertificate),
listOf())
) )
}
assertThat(result.trim()).isEqualTo(""" assertThat(result.trim()).isEqualTo("""
chirpy { chirpy {
name = "Chirpy" name = "Chirpy"
@@ -447,10 +523,14 @@ class EmbeddedExecutorTest {
} }
} }
""".trimIndent()) """.trimIndent())
// verify that cache was populated
assertThat(cacheDir.toFile().list()).isNotEmpty()
} }
@Test @ParameterizedTest
fun `evaluate a project dependency`(@TempDir tempDir: Path) { @MethodSource("getAllExecutionContexts")
fun `evaluate a project dependency`(context: ExecutionContext, @TempDir tempDir: Path) {
val cacheDir = tempDir.resolve("packages") val cacheDir = tempDir.resolve("packages")
PackageServer.populateCacheDir(cacheDir) PackageServer.populateCacheDir(cacheDir)
val projectDir = tempDir.resolve("project/") val projectDir = tempDir.resolve("project/")
@@ -495,10 +575,8 @@ class EmbeddedExecutorTest {
result = Swallow result = Swallow
""".trimIndent() """.trimIndent()
) )
val executor = Executors.embedded(listOf(pklDistribution)) val result = context.executor.evaluatePath(pklFile,
val result = executor.use { context.options(ExecutorOptions2(
it.evaluatePath(pklFile,
ExecutorOptions(
listOf("file:", "package:", "projectpackage:", "https:"), listOf("file:", "package:", "projectpackage:", "https:"),
listOf("prop:", "package:", "projectpackage:", "https:"), listOf("prop:", "package:", "projectpackage:", "https:"),
mapOf(), mapOf(),
@@ -508,9 +586,10 @@ class EmbeddedExecutorTest {
null, null,
null, null,
cacheDir, cacheDir,
projectDir) projectDir,
) listOf(),
} listOf())
))
assertThat(result).isEqualTo(""" assertThat(result).isEqualTo("""
result { result {
name = "Swallow" name = "Swallow"

View File

@@ -20,6 +20,7 @@ import java.time.Duration
import org.msgpack.core.MessagePacker import org.msgpack.core.MessagePacker
import org.pkl.core.* import org.pkl.core.*
import org.pkl.core.ast.member.ObjectMember import org.pkl.core.ast.member.ObjectMember
import org.pkl.core.http.HttpClient
import org.pkl.core.module.ModuleKeyFactory import org.pkl.core.module.ModuleKeyFactory
import org.pkl.core.project.DeclaredDependencies import org.pkl.core.project.DeclaredDependencies
import org.pkl.core.resource.ResourceReader import org.pkl.core.resource.ResourceReader
@@ -28,6 +29,7 @@ import org.pkl.core.runtime.*
internal class BinaryEvaluator( internal class BinaryEvaluator(
transformer: StackFrameTransformer, transformer: StackFrameTransformer,
manager: SecurityManager, manager: SecurityManager,
httpClient: HttpClient,
logger: Logger, logger: Logger,
factories: Collection<ModuleKeyFactory?>, factories: Collection<ModuleKeyFactory?>,
readers: Collection<ResourceReader?>, readers: Collection<ResourceReader?>,
@@ -41,6 +43,7 @@ internal class BinaryEvaluator(
EvaluatorImpl( EvaluatorImpl(
transformer, transformer,
manager, manager,
httpClient,
logger, logger,
factories, factories,
readers, readers,

View File

@@ -21,6 +21,7 @@ import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.random.Random import kotlin.random.Random
import org.pkl.core.* import org.pkl.core.*
import org.pkl.core.http.HttpClient
import org.pkl.core.module.ModuleKeyFactories import org.pkl.core.module.ModuleKeyFactories
import org.pkl.core.module.ModuleKeyFactory import org.pkl.core.module.ModuleKeyFactory
import org.pkl.core.module.ModulePathResolver import org.pkl.core.module.ModulePathResolver
@@ -29,7 +30,8 @@ import org.pkl.core.project.DeclaredDependencies
import org.pkl.core.resource.ResourceReader import org.pkl.core.resource.ResourceReader
import org.pkl.core.resource.ResourceReaders import org.pkl.core.resource.ResourceReaders
class Server(private val transport: MessageTransport) : AutoCloseable { class Server(private val transport: MessageTransport, private val httpClient: HttpClient) :
AutoCloseable {
private val evaluators: MutableMap<Long, BinaryEvaluator> = ConcurrentHashMap() private val evaluators: MutableMap<Long, BinaryEvaluator> = ConcurrentHashMap()
// https://github.com/jano7/executor would be the perfect executor here // https://github.com/jano7/executor would be the perfect executor here
@@ -173,6 +175,7 @@ class Server(private val transport: MessageTransport) : AutoCloseable {
SecurityManagers.defaultTrustLevels, SecurityManagers.defaultTrustLevels,
rootDir rootDir
), ),
httpClient,
ClientLogger(evaluatorId, transport), ClientLogger(evaluatorId, transport),
createModuleKeyFactories(message, evaluatorId, resolver), createModuleKeyFactories(message, evaluatorId, resolver),
createResourceReaders(message, evaluatorId, resolver), createResourceReaders(message, evaluatorId, resolver),

View File

@@ -16,13 +16,12 @@
package org.pkl.server package org.pkl.server
import java.nio.file.Path import java.nio.file.Path
import kotlin.Pair
import kotlin.reflect.KClass import kotlin.reflect.KClass
import org.junit.platform.commons.annotation.Testable import org.junit.platform.commons.annotation.Testable
import org.pkl.commons.test.InputOutputTestEngine import org.pkl.commons.test.InputOutputTestEngine
import org.pkl.core.Loggers import org.pkl.core.*
import org.pkl.core.ModuleSource import org.pkl.core.http.HttpClient
import org.pkl.core.SecurityManagers
import org.pkl.core.StackFrameTransformers
import org.pkl.core.module.ModuleKeyFactories import org.pkl.core.module.ModuleKeyFactories
@Testable class BinaryEvaluatorSnippetTests @Testable class BinaryEvaluatorSnippetTests
@@ -47,6 +46,7 @@ class BinaryEvaluatorSnippetTestEngine : InputOutputTestEngine() {
BinaryEvaluator( BinaryEvaluator(
StackFrameTransformers.empty, StackFrameTransformers.empty,
SecurityManagers.defaultManager, SecurityManagers.defaultManager,
HttpClient.dummyClient(),
Loggers.stdErr(), Loggers.stdErr(),
listOf(ModuleKeyFactories.file), listOf(ModuleKeyFactories.file),
listOf(), listOf(),

View File

@@ -21,6 +21,7 @@ import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.assertThrows
import org.pkl.core.* import org.pkl.core.*
import org.pkl.core.http.HttpClient
import org.pkl.core.module.ModuleKeyFactories import org.pkl.core.module.ModuleKeyFactories
import org.pkl.core.resource.ResourceReaders import org.pkl.core.resource.ResourceReaders
@@ -34,6 +35,7 @@ class BinaryEvaluatorTest {
SecurityManagers.defaultTrustLevels, SecurityManagers.defaultTrustLevels,
Path.of("") Path.of("")
), ),
HttpClient.dummyClient(),
Loggers.noop(), Loggers.noop(),
listOf(ModuleKeyFactories.standardLibrary), listOf(ModuleKeyFactories.standardLibrary),
listOf(ResourceReaders.environmentVariable(), ResourceReaders.externalProperty()), listOf(ResourceReaders.environmentVariable(), ResourceReaders.externalProperty()),

View File

@@ -33,6 +33,7 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.api.io.TempDir
import org.msgpack.core.MessagePack import org.msgpack.core.MessagePack
import org.pkl.commons.test.PackageServer import org.pkl.commons.test.PackageServer
import org.pkl.core.http.HttpClient
import org.pkl.core.module.PathElement import org.pkl.core.module.PathElement
class ServerTest { class ServerTest {
@@ -67,7 +68,7 @@ class ServerTest {
} }
private val client: TestTransport = TestTransport(transports.first) private val client: TestTransport = TestTransport(transports.first)
private val server: Server = Server(transports.second) private val server: Server = Server(transports.second, HttpClient.dummyClient())
@BeforeEach @BeforeEach
fun before() { fun before() {

View File

@@ -4,6 +4,7 @@ include("bench")
include("docs") include("docs")
include("stdlib") include("stdlib")
include("pkl-certs")
include("pkl-cli") include("pkl-cli")
include("pkl-codegen-java") include("pkl-codegen-java")
include("pkl-codegen-kotlin") include("pkl-codegen-kotlin")