From cb90a47ce88d7cef5cb7d86caef28bcb55e26138 Mon Sep 17 00:00:00 2001 From: BrianHotopp Date: Sat, 13 Jun 2026 01:54:58 -0400 Subject: [PATCH] [1.x] fix: Delegate JDK platform classes to the platform loader in console (#9327) The console/consoleQuick REPL loader rejects JDK platform-module classes: ClasspathFilter's post-check refuses classes whose code source is a jrt: URL, so a top-down REPL reference to java.sql.* falls through to the REPL's own classloader, which finds the bytes in the runner-generated java9-rt-ext jar (via scala.ext.dirs) and dies in defineClass: java.lang.SecurityException: Prohibited package name: java.sql Wrap the REPL parent loader with a fallback that consults ClassLoader.getPlatformClassLoader (reflectively; absent on JDK 8, which has no platform loader) only after the existing chain misses, so project classes keep winning and platform classes resolve instead of being redefined. Linkage through the classpath loader (a JDBC driver implementing java.sql.Driver) was already sound: the launcher's topLoader delegates to the platform loader on JDK 9+; only the top-down path through ClasspathFilter was broken. Verified against sbt 1.11.7 (fails) and a patched 1.12.3-SNAPSHOT (passes) with the original report's repro: java.sql.Timestamp, a project class extending it, and DriverManager.getConnection with the postgresql driver, which now reaches the network layer. The console/* scripted group is added to CI; it was not in any scripted glob, so the new test (and the pre-existing console/project-compiler-bridge) never ran. Fixes #4328 (still reproducible on 1.11.7 before this change). Co-authored-by: Claude Fable 5 --- .github/workflows/ci.yml | 2 +- main/src/main/scala/sbt/Defaults.scala | 25 ++++++++++++++++++- .../console/jdk-platform-classes/build.sbt | 19 ++++++++++++++ .../src/main/scala/MyTimestamp.scala | 5 ++++ .../console/jdk-platform-classes/test | 2 ++ 5 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 sbt-app/src/sbt-test/console/jdk-platform-classes/build.sbt create mode 100644 sbt-app/src/sbt-test/console/jdk-platform-classes/src/main/scala/MyTimestamp.scala create mode 100644 sbt-app/src/sbt-test/console/jdk-platform-classes/test diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4097aa0a..01f6b82cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,7 +97,7 @@ jobs: if: ${{ matrix.jobtype == 2 }} shell: bash run: | - ./sbt -v "scripted actions/* apiinfo/* compiler-project/* ivy-deps-management/* reporter/* tests/* watch/* classloader-cache/* package/*" + ./sbt -v "scripted actions/* apiinfo/* compiler-project/* console/* ivy-deps-management/* reporter/* tests/* watch/* classloader-cache/* package/*" - name: Build and test (3) if: ${{ matrix.jobtype == 3 }} shell: bash diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index a651c1ff7..227b9e899 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -2185,6 +2185,28 @@ object Defaults extends BuildCommon { def consoleTask: Initialize[Task[Unit]] = consoleTask(fullClasspath, console) def consoleQuickTask = consoleTask(externalDependencyClasspath, consoleQuick) + + // The ScalaInstance loader chain bottoms out at the launcher's loader, which does not + // delegate to the JDK platform classloader. None on JDK 8 (no platform loader). + private[this] lazy val platformLoader: Option[ClassLoader] = + try { + Option( + classOf[ClassLoader] + .getMethod("getPlatformClassLoader") + .invoke(null) + .asInstanceOf[ClassLoader] + ) + } catch { case NonFatal(_) => None } + + private[this] def platformFallbackLoader(parent: ClassLoader): ClassLoader = + platformLoader match { + case Some(platform) => + new ClassLoader(parent) { + override def findClass(name: String): Class[_] = platform.loadClass(name) + } + case None => parent + } + def consoleTask(classpath: TaskKey[Classpath], task: TaskKey[_]): Initialize[Task[Unit]] = Def.task { val si = (task / scalaInstance).value @@ -2192,7 +2214,8 @@ object Defaults extends BuildCommon { val cpFiles = data((task / classpath).value) val fullcp = (cpFiles ++ si.allJars).distinct val tempDir = IO.createUniqueDirectory((task / taskTemporaryDirectory).value).toPath - val loader = ClasspathUtil.makeLoader(fullcp.map(_.toPath), si, tempDir) + val loader = + platformFallbackLoader(ClasspathUtil.makeLoader(fullcp.map(_.toPath), si, tempDir)) val compiler = (task / compilers).value.scalac match { case ac: AnalyzingCompiler => ac.onArgs(exported(s, "scala")) diff --git a/sbt-app/src/sbt-test/console/jdk-platform-classes/build.sbt b/sbt-app/src/sbt-test/console/jdk-platform-classes/build.sbt new file mode 100644 index 000000000..eb03d8cf6 --- /dev/null +++ b/sbt-app/src/sbt-test/console/jdk-platform-classes/build.sbt @@ -0,0 +1,19 @@ +scalaVersion := "2.13.12" + +lazy val markerFile = settingKey[java.io.File]("marker file written by the console REPL when JDK platform classes load") + +markerFile := target.value / "console-jdk-platform-ok" + +console / initialCommands := { + val path = markerFile.value.getAbsolutePath.replace("\\", "\\\\") + // java.sql lives in a JDK platform module. Loading it through the console REPL's + // classloader chain threw SecurityException("Prohibited package name: java.sql") + // before the platform-loader fallback (#4328): `ts` covers the top-down request, + // `mts` covers supertype resolution for a class defined from the project classpath. + // The REPL traps exceptions, so the assertion is a marker file: it is only written + // if both classes actually load. + s"""val ts = new java.sql.Timestamp(0L) + |val mts = new MyTimestamp + |java.nio.file.Files.write(java.nio.file.Paths.get("$path"), (ts.getClass.getName + " " + mts.getClass.getName).getBytes("UTF-8")) + |""".stripMargin +} diff --git a/sbt-app/src/sbt-test/console/jdk-platform-classes/src/main/scala/MyTimestamp.scala b/sbt-app/src/sbt-test/console/jdk-platform-classes/src/main/scala/MyTimestamp.scala new file mode 100644 index 000000000..fd6ee4908 --- /dev/null +++ b/sbt-app/src/sbt-test/console/jdk-platform-classes/src/main/scala/MyTimestamp.scala @@ -0,0 +1,5 @@ +// Linkage through the defining loader: resolving the java.sql.Timestamp supertype of a +// project-classpath class exercises the classpath loader's parent chain, not a top-down +// REPL request. This is the shape of sbt/sbt#4328's original repro (a JDBC driver +// implementing java.sql.Driver). +class MyTimestamp extends java.sql.Timestamp(0L) diff --git a/sbt-app/src/sbt-test/console/jdk-platform-classes/test b/sbt-app/src/sbt-test/console/jdk-platform-classes/test new file mode 100644 index 000000000..a43ca7c35 --- /dev/null +++ b/sbt-app/src/sbt-test/console/jdk-platform-classes/test @@ -0,0 +1,2 @@ +> console +$ exists target/console-jdk-platform-ok