Revise DumpPlatformClassPath

see https://github.com/bazelbuild/bazel/issues/6179

PiperOrigin-RevId: 213946706
diff --git a/tools/jdk/BUILD b/tools/jdk/BUILD
index 41b21b5..585596c 100644
--- a/tools/jdk/BUILD
+++ b/tools/jdk/BUILD
@@ -7,6 +7,7 @@
     "JDK8_JVM_OPTS",
     "JDK9_JVM_OPTS",
     "DEFAULT_JAVACOPTS",
+    "bootclasspath",
 )
 
 # Used to distinguish toolchains used for Java development, ie the JavaToolchainProvider.
@@ -199,28 +200,23 @@
 # in RELEASES, using javac to read those APIs via the infrastructure added
 # for the --release flag (see http://openjdk.java.net/jeps/247).
 [
-    genrule(
+    bootclasspath(
         name = "platformclasspath%d" % release,
-        srcs = ["DumpPlatformClassPath.java"],
-        outs = ["platformclasspath%d.jar" % release],
-        cmd = """
-set -eu
-TMPDIR=$$(mktemp -d -t tmp.XXXXXXXX)
-$(JAVABASE)/bin/javac -source 8 -target 8 \
-    -Xlint:-options \
-    -cp $(JAVABASE)/lib/tools.jar \
-    -d $$TMPDIR $<
-$(JAVA) -XX:+IgnoreUnrecognizedVMOptions \
-    --add-exports=jdk.compiler/com.sun.tools.javac.platform=ALL-UNNAMED \
-    -cp $$TMPDIR DumpPlatformClassPath %d $@
-rm -rf $$TMPDIR
-""" % release,
-        toolchains = ["@bazel_tools//tools/jdk:current_host_java_runtime"],
-        tools = ["@bazel_tools//tools/jdk:current_host_java_runtime"],
+        src = "DumpPlatformClassPath.java",
+        host_javabase = "current_java_runtime",
+        release = "%s" % release,
     )
     for release in RELEASES
 ]
 
+bootclasspath(
+    name = "platformclasspath",
+    src = "DumpPlatformClassPath.java",
+    host_javabase = "current_java_runtime",
+    release = "8",  # fall-back only
+    target_javabase = "current_java_runtime",
+)
+
 default_java_toolchain(
     name = "toolchain_hostjdk8",
     bootclasspath = [":platformclasspath8"],
@@ -231,9 +227,12 @@
 
 # Default to the Java 8 language level.
 # TODO(cushon): consider if/when we should increment this?
-alias(
+default_java_toolchain(
     name = "toolchain_hostjdk9",
-    actual = "toolchain_java8",
+    bootclasspath = [":platformclasspath"],
+    jvm_opts = JDK9_JVM_OPTS,
+    source_version = "8",
+    target_version = "8",
 )
 
 # The 'vanilla' toolchain is an unsupported alternative to the default.
diff --git a/tools/jdk/DumpPlatformClassPath.java b/tools/jdk/DumpPlatformClassPath.java
index 2a0a474..4640e48 100644
--- a/tools/jdk/DumpPlatformClassPath.java
+++ b/tools/jdk/DumpPlatformClassPath.java
@@ -12,8 +12,6 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import static java.util.Comparator.comparing;
-
 import com.sun.tools.javac.api.JavacTool;
 import com.sun.tools.javac.util.Context;
 import java.io.BufferedOutputStream;
@@ -23,8 +21,6 @@
 import java.io.OutputStream;
 import java.io.UncheckedIOException;
 import java.lang.reflect.Method;
-import java.net.URI;
-import java.nio.file.FileSystems;
 import java.nio.file.FileVisitResult;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -33,11 +29,13 @@
 import java.nio.file.attribute.BasicFileAttributes;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.EnumSet;
 import java.util.GregorianCalendar;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.SortedMap;
+import java.util.TreeMap;
 import java.util.jar.JarEntry;
 import java.util.jar.JarFile;
 import java.util.jar.JarOutputStream;
@@ -53,157 +51,153 @@
 /**
  * Output a jar file containing all classes on the platform classpath of the given JDK release.
  *
- * <p>usage: DumpPlatformClassPath <release version> <output jar>
+ * <p>usage: DumpPlatformClassPath <release version> <output jar> <path to target JDK>?
  */
 public class DumpPlatformClassPath {
 
   public static void main(String[] args) throws Exception {
-    if (args.length != 2) {
-      System.err.println("usage: DumpPlatformClassPath <release version> <output jar>");
+    if (args.length < 2 || args.length > 3) {
+      System.err.println(
+          "usage: DumpPlatformClassPath <release version> <output jar> <path to target JDK>?");
       System.exit(1);
     }
     int release = Integer.parseInt(args[0]);
     Path output = Paths.get(args[1]);
-
-    Map<String, byte[]> entries = new HashMap<>();
-
-    // Legacy JDK 8 bootclasspath handling.
-    // TODO(cushon): make sure this has test coverage.
-    Path javaHome = Paths.get(System.getProperty("java.home"));
-    if (javaHome.endsWith("jre")) {
-      javaHome = javaHome.getParent();
+    Path targetJavabase = null;
+    if (args.length == 3) {
+      targetJavabase = Paths.get(args[2]);
     }
 
-    List<Path> jars = new ArrayList<>();
+    int hostMajorVersion = hostMajorVersion();
+    boolean ok;
+    if (hostMajorVersion == 8) {
+      ok = dumpJDK8BootClassPath(release, output, targetJavabase);
+    } else {
+      ok = dumpJDK9AndNewerBootClassPath(hostMajorVersion, release, output, targetJavabase);
+    }
+    System.exit(ok ? 0 : 1);
+  }
 
-    Path extDir = javaHome.resolve("jre/lib/ext");
-    if (Files.exists(extDir)) {
-      for (Path extJar : Files.newDirectoryStream(extDir, "*.jar")) {
-        jars.add(extJar);
+  // JDK 8 bootclasspath handling.
+  // * JDK 8 represents a bootclasspath as a search path of jars (rt.jar, etc.).
+  // * It does not support --release or --system.
+  static boolean dumpJDK8BootClassPath(int release, Path output, Path targetJavabase)
+      throws IOException {
+    if (release != 8) {
+      System.err.printf("error: --release=%s is not supported on --host_javabase=8\n", release);
+      return false;
+    }
+    List<Path> bootClassPathJars;
+    if (targetJavabase != null) {
+      bootClassPathJars = getBootClassPathJars(targetJavabase);
+    } else {
+      Path hostJavabase = Paths.get(System.getProperty("java.home"));
+      if (hostJavabase.endsWith("jre")) {
+        hostJavabase = hostJavabase.getParent();
       }
+      bootClassPathJars = getBootClassPathJars(hostJavabase);
     }
+    writeClassPathJars(output, bootClassPathJars);
+    return true;
+  }
 
-    for (String jar : Arrays.asList(
-        "rt.jar",
-        "resources.jar",
-        "jsse.jar",
-        "jce.jar",
-        "charsets.jar")) {
-      Path path = javaHome.resolve("jre/lib").resolve(jar);
-      if (Files.exists(path)) {
-        jars.add(path);
+  // JDK > 8 --host_javabase bootclasspath handling.
+  // (The default --host_javabase is currently JDK 9.)
+  static boolean dumpJDK9AndNewerBootClassPath(
+      int hostMajorVersion, int release, Path output, Path targetJavabase) throws IOException {
+
+    // JDK 9 and newer support cross-compiling to older platform versions using the --system
+    // and --release flags.
+    // * --system takes the path to a JDK root for JDK 9 and up, and causes the compilation
+    //     to target the APIs from that JDK.
+    // * --release takes a language level (e.g. '9') and uses the API information baked in to
+    //     the host JDK (in lib/ct.sym).
+
+    // Since --system only supports JDK >= 9, first check of the target JDK defines a JDK 8
+    // bootclasspath.
+    if (targetJavabase != null) {
+      List<Path> bootClassPathJars = getBootClassPathJars(targetJavabase);
+      if (!bootClassPathJars.isEmpty()) {
+        writeClassPathJars(output, bootClassPathJars);
+        return true;
       }
-    }
-
-    for (Path path : jars) {
-      try (JarFile jf = new JarFile(path.toFile())) {
-        jf.stream()
-            .forEachOrdered(
-                entry -> {
-                  try {
-                    entries.put(entry.getName(), toByteArray(jf.getInputStream(entry)));
-                  } catch (IOException e) {
-                    throw new UncheckedIOException(e);
-                  }
-                });
-      }
-    }
-
-    if (!entries.isEmpty()) {
-      // If we found a JDK 8 bootclasspath (rt.jar, etc.) then we're done.
-      //
-      // However JDK 8 only contains bootclasspath API information for the current release,
-      // so we're always going to get a JDK 8 API level regardless of what the user requested.
-      // Emit a warning if they wanted to target a different version.
-      if (release != 8) {
+      if (release == 8) {
         System.err.printf(
-            "warning: ignoring release %s on --host_javabase=%s\n",
-            release, System.getProperty("java.version"));
+            "warning: could not find a JDK 8 bootclasspath in %s, falling back to --release\n",
+            targetJavabase);
+      }
+    }
+
+    // Initialize a FileManager to process the --release or --system arguments, and then read the
+    // initialized bootclasspath data back out.
+
+    List<String> javacOptions =
+        targetJavabase != null
+            ? Arrays.asList("--system", String.valueOf(targetJavabase))
+            : Arrays.asList("--release", String.valueOf(release));
+
+    Context context = new Context();
+    JavacTool.create()
+        .getTask(
+            /* out = */ null,
+            /* fileManager = */ null,
+            /* diagnosticListener = */ null,
+            /* options = */ javacOptions,
+            /* classes = */ null,
+            /* compilationUnits = */ null,
+            context);
+    StandardJavaFileManager fileManager =
+        (StandardJavaFileManager) context.get(JavaFileManager.class);
+
+    SortedMap<String, InputStream> entries = new TreeMap<>();
+    if (hostMajorVersion == 9 && targetJavabase == null && release == 8) {
+      // Work-around: when running on a JDK 9 host_javabase with --release 8, the ct.sym
+      // handling isn't compatible with the FileManager#list code path in the branch below.
+      for (Path path : getLocationAsPaths(fileManager)) {
+        Files.walkFileTree(
+            path,
+            new SimpleFileVisitor<Path>() {
+              @Override
+              public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
+                  throws IOException {
+                if (file.getFileName().toString().endsWith(".sig")) {
+                  String outputPath = path.relativize(file).toString();
+                  outputPath =
+                      outputPath.substring(0, outputPath.length() - ".sig".length()) + ".class";
+                  entries.put(outputPath, Files.newInputStream(file));
+                }
+                return FileVisitResult.CONTINUE;
+              }
+            });
       }
     } else {
-      // JDK > 8 --host_javabase bootclasspath handling.
-      // The default --host_javabase is currently JDK 10.
-
-      // Set up a compilation with --release to initialize a filemanager
-      Context context = new Context();
-      JavacTool.create()
-          .getTask(
-              /* out = */ null,
-              /* fileManager = */ null,
-              /* diagnosticListener = */ null,
-              /* options = */ Arrays.asList("--release", String.valueOf(release)),
-              /* classes = */ null,
-              /* compilationUnits = */ null,
-              context);
-      StandardJavaFileManager fileManager =
-          (StandardJavaFileManager) context.get(JavaFileManager.class);
-
-      int majorVersion = majorVersion();
-      if (majorVersion == 9 && release == 8) {
-        // Work-around: when running on a JDK 9 host_javabase with --release 8, the ct.sym
-        // handling isn't compatible with the FileManager#list code path in the branch below.
-        for (Path path : getLocationAsPaths(fileManager)) {
-          Files.walkFileTree(
-              path,
-              new SimpleFileVisitor<Path>() {
-                @Override
-                public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
-                    throws IOException {
-                  if (file.getFileName().toString().endsWith(".sig")) {
-                    String outputPath = path.relativize(file).toString();
-                    outputPath =
-                        outputPath.substring(0, outputPath.length() - ".sig".length()) + ".class";
-                    entries.put(outputPath, Files.readAllBytes(file));
-                  }
-                  return FileVisitResult.CONTINUE;
-                }
-              });
-        }
-      } else {
-        for (JavaFileObject fileObject :
-            fileManager.list(
-                StandardLocation.PLATFORM_CLASS_PATH,
-                "",
-                EnumSet.of(Kind.CLASS),
-                /* recurse= */ true)) {
-          String binaryName =
-              fileManager.inferBinaryName(StandardLocation.PLATFORM_CLASS_PATH, fileObject);
-          entries.put(
-              binaryName.replace('.', '/') + ".class", toByteArray(fileObject.openInputStream()));
-        }
+      for (JavaFileObject fileObject :
+          fileManager.list(
+              StandardLocation.PLATFORM_CLASS_PATH,
+              "",
+              EnumSet.of(Kind.CLASS),
+              /* recurse= */ true)) {
+        String binaryName =
+            fileManager.inferBinaryName(StandardLocation.PLATFORM_CLASS_PATH, fileObject);
+        entries.put(binaryName.replace('.', '/') + ".class", fileObject.openInputStream());
       }
-
-      // Include the jdk.unsupported module for compatibility with JDK 8.
-      // (see: https://bugs.openjdk.java.net/browse/JDK-8206937)
-      // `--release 8` only provides access to supported APIs, which excludes e.g. sun.misc.Unsafe.
-      Path module =
-          FileSystems.getFileSystem(URI.create("jrt:/")).getPath("modules/jdk.unsupported");
-      Files.walkFileTree(
-          module,
-          new SimpleFileVisitor<Path>() {
-            @Override
-            public FileVisitResult visitFile(Path path, BasicFileAttributes attrs)
-                throws IOException {
-              String name = path.getFileName().toString();
-              if (name.endsWith(".class") && !name.equals("module-info.class")) {
-                entries.put(module.relativize(path).toString(), Files.readAllBytes(path));
-              }
-              return super.visitFile(path, attrs);
-            }
-          });
     }
+    writeEntries(output, entries);
+    return true;
+  }
 
+  /** Writes the given entry names and data to a jar archive at the given path. */
+  private static void writeEntries(Path output, Map<String, InputStream> entries)
+      throws IOException {
     if (!entries.containsKey("java/lang/Object.class")) {
       throw new AssertionError(
           "\nCould not find java.lang.Object on bootclasspath; something has gone terribly wrong.\n"
               + "Please file a bug: https://github.com/bazelbuild/bazel/issues");
     }
-
     try (OutputStream os = Files.newOutputStream(output);
         BufferedOutputStream bos = new BufferedOutputStream(os, 65536);
         JarOutputStream jos = new JarOutputStream(bos)) {
       entries.entrySet().stream()
-          .sorted(comparing(Map.Entry::getKey))
           .forEachOrdered(
               entry -> {
                 try {
@@ -215,14 +209,64 @@
     }
   }
 
+  /** Collects the entries of the given jar files into a map from jar entry names to their data. */
+  private static void writeClassPathJars(Path output, Collection<Path> paths) throws IOException {
+    List<JarFile> jars = new ArrayList<>();
+    for (Path path : paths) {
+      jars.add(new JarFile(path.toFile()));
+    }
+    SortedMap<String, InputStream> entries = new TreeMap<>();
+    for (JarFile jar : jars) {
+      jar.stream()
+          .filter(p -> p.getName().endsWith(".class"))
+          .forEachOrdered(
+              entry -> {
+                try {
+                  entries.put(entry.getName(), jar.getInputStream(entry));
+                } catch (IOException e) {
+                  throw new UncheckedIOException(e);
+                }
+              });
+    }
+    writeEntries(output, entries);
+    for (JarFile jar : jars) {
+      jar.close();
+    }
+  }
+
+  /** Returns paths to the entries of a JDK 8-style bootclasspath. */
+  private static List<Path> getBootClassPathJars(Path javaHome) throws IOException {
+    List<Path> jars = new ArrayList<>();
+    Path extDir = javaHome.resolve("jre/lib/ext");
+    if (Files.exists(extDir)) {
+      for (Path extJar : Files.newDirectoryStream(extDir, "*.jar")) {
+        jars.add(extJar);
+      }
+    }
+    for (String jar :
+        Arrays.asList("rt.jar", "resources.jar", "jsse.jar", "jce.jar", "charsets.jar")) {
+      Path path = javaHome.resolve("jre/lib").resolve(jar);
+      if (Files.exists(path)) {
+        jars.add(path);
+      }
+    }
+    return jars;
+  }
+
   // Use a fixed timestamp for deterministic jar output.
   private static final long FIXED_TIMESTAMP =
       new GregorianCalendar(2010, 0, 1, 0, 0, 0).getTimeInMillis();
 
-  private static void addEntry(JarOutputStream jos, String name, byte[] bytes) throws IOException {
+  /**
+   * Add a jar entry to the given {@link JarOutputStream}, normalizing the entry timestamps to
+   * ensure deterministic build output.
+   */
+  private static void addEntry(JarOutputStream jos, String name, InputStream input)
+      throws IOException {
     JarEntry je = new JarEntry(name);
     je.setTime(FIXED_TIMESTAMP);
     je.setMethod(ZipEntry.STORED);
+    byte[] bytes = toByteArray(input);
     je.setSize(bytes.length);
     CRC32 crc = new CRC32();
     crc.update(bytes);
@@ -244,6 +288,10 @@
     return boas.toByteArray();
   }
 
+  /**
+   * Reflectively calls {@code StandardJavaFileManager#getLocationAsPaths}, which is only available
+   * in JDK 9 and newer.
+   */
   @SuppressWarnings("unchecked")
   private static Iterable<Path> getLocationAsPaths(StandardJavaFileManager fileManager) {
     try {
@@ -256,7 +304,12 @@
     }
   }
 
-  static int majorVersion() {
+  /**
+   * Returns the major version of the host Java runtime (e.g. '8' for JDK 8), using {@link
+   * Runtime#version} if it is available, and otherwise falling back to the {@code
+   * java.class.version} system. property.
+   */
+  static int hostMajorVersion() {
     try {
       Method versionMethod = Runtime.class.getMethod("version");
       Object version = versionMethod.invoke(null);
diff --git a/tools/jdk/default_java_toolchain.bzl b/tools/jdk/default_java_toolchain.bzl
index fce644d..4f03257 100644
--- a/tools/jdk/default_java_toolchain.bzl
+++ b/tools/jdk/default_java_toolchain.bzl
@@ -106,3 +106,73 @@
             cmd = "cp $(JAVABASE)/%s $@" % src,
             outs = [src],
         )
+
+def _bootclasspath(ctx):
+    host_javabase = ctx.attr.host_javabase[java_common.JavaRuntimeInfo]
+
+    class_dir = ctx.actions.declare_directory("%s_classes" % ctx.label.name)
+
+    args = ctx.actions.args()
+    args.add("-source")
+    args.add("8")
+    args.add("-target")
+    args.add("8")
+    args.add("-Xlint:-options")
+    args.add("-cp")
+    args.add("%s/lib/tools.jar" % host_javabase.java_home)
+    args.add("-d")
+    args.add(class_dir)
+    args.add(ctx.file.src)
+
+    ctx.actions.run(
+        executable = "%s/bin/javac" % host_javabase.java_home,
+        inputs = [ctx.file.src] + ctx.files.host_javabase,
+        outputs = [class_dir],
+        arguments = [args],
+    )
+
+    bootclasspath = ctx.outputs.jar
+
+    inputs = [class_dir] + ctx.files.host_javabase
+
+    args = ctx.actions.args()
+    args.add("-XX:+IgnoreUnrecognizedVMOptions")
+    args.add("--add-exports=jdk.compiler/com.sun.tools.javac.platform=ALL-UNNAMED")
+    args.add_joined(
+        "-cp",
+        [class_dir, "%s/lib/tools.jar" % host_javabase.java_home],
+        join_with = ctx.configuration.host_path_separator,
+    )
+    args.add("DumpPlatformClassPath")
+    args.add(ctx.attr.release)
+    args.add(bootclasspath)
+
+    if ctx.attr.target_javabase:
+        inputs.extend(ctx.files.target_javabase)
+        args.add(ctx.attr.target_javabase[java_common.JavaRuntimeInfo].java_home)
+
+    ctx.actions.run(
+        executable = str(host_javabase.java_executable_exec_path),
+        inputs = inputs,
+        outputs = [bootclasspath],
+        arguments = [args],
+    )
+
+bootclasspath = rule(
+    implementation = _bootclasspath,
+    attrs = {
+        "host_javabase": attr.label(
+            cfg = "host",
+            providers = [java_common.JavaRuntimeInfo],
+        ),
+        "release": attr.string(),
+        "src": attr.label(
+            cfg = "host",
+            allow_single_file = True,
+        ),
+        "target_javabase": attr.label(
+            providers = [java_common.JavaRuntimeInfo],
+        ),
+    },
+    outputs = {"jar": "%{name}.jar"},
+)