/* * Copyright (c) 2022, 2024, Oracle and/or its affiliates. * * This source code is licensed under the UPL license found in the * LICENSE.txt file in the root directory of this source tree. */ import static java.nio.charset.StandardCharsets.UTF_8; import static java.nio.file.StandardOpenOption.APPEND; import static java.nio.file.StandardOpenOption.CREATE; import static java.nio.file.StandardOpenOption.WRITE; import java.io.BufferedInputStream; import java.io.FileInputStream; import java.io.IOException; import java.io.OutputStream; import java.io.StringReader; import java.io.UncheckedIOException; import java.math.BigInteger; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.file.Files; import java.nio.file.Path; import java.security.DigestOutputStream; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.Security; import java.util.ArrayDeque; import java.util.List; import java.util.Optional; import java.util.Properties; import java.util.StringJoiner; import java.util.TreeMap; import java.util.regex.Pattern; /** Download a JDK build. */ public class Download { /** Main entry-point. */ public static void main(String... args) { main(Boolean.getBoolean(/*-D*/ "ry-run"), args); } /** Entry-point also used by tests. */ static void main(boolean dryRun, String... args) { // Pre-allocate action outputs var outputs = new TreeMap(); outputs.put("archive", "NOT-SET"); outputs.put("version", "NOT-SET"); try { if (args.length == 0) { throw new Error("Usage: Download URI or WEBSITE RELEASE VERSION"); } var deque = new ArrayDeque<>(List.of(args)); var first = deque.removeFirst(); // URI or WEBSITE // Determine website from first argument var website = Website.find(first).orElseThrow(() -> new Error("Could not find website for " + first)); GitHub.debug("website: " + website); // Create JDK descriptor var jdk = new JDK( deque.isEmpty() ? "ga" : deque.removeFirst().toLowerCase(), deque.isEmpty() ? "latest" : deque.removeFirst().toLowerCase(), deque.isEmpty() ? JDK.computeOsName() : deque.removeFirst(), deque.isEmpty() ? JDK.computeOsArch() : deque.removeFirst(), deque.isEmpty() ? JDK.computeFileType() : deque.removeFirst()); GitHub.debug("jdk: " + jdk); // Select or find URI based on the JDK descriptor var uri = args.length == 1 ? first : website.findUri(jdk).orElseThrow(() -> new Error("Could not find URI of " + jdk)); GitHub.debug("uri: " + uri); if (!(uri.endsWith(".tar.gz") || uri.endsWith(".zip"))) { throw new IllegalArgumentException("URI must end with `.tar.gz` or `.zip`: " + uri); } // Emit warning when using an archived JDK build if (website.isArchivedUri(uri)) { GitHub.warn( """ JDK resolved to an archived build! These older versions of the JDK are provided to help developers debug issues in older systems. They are not updated with the latest security patches and are not recommended for use in production. """); } // Acquire JDK archive var archive = website.computeArchivePath(uri); GitHub.debug("archive: " + archive); var downloader = new Downloader(archive, uri); if (website.isMovingResourceUri(uri)) { downloader.checkSizeAndDeleteIfDifferent(); } downloader.downloadArchive(dryRun); downloader.verifyChecksums(website.getChecksum(uri)); System.out.printf("Archive %s in %s%n", archive.getFileName(), archive.getParent().toUri()); // Set outputs outputs.put("archive", archive.toString()); var digit = Character.isDigit(jdk.release.charAt(0)); outputs.put("version", website.computeVersionString(uri, digit ? "PARSE_URI" : "HASH_URI")); } catch (Exception exception) { GitHub.error("Error detected: " + exception); throw new Error(exception); // ensure non-zero result code is returned } finally { if (dryRun) { System.out.println("Dry-run of run with " + List.of(args)); for (var output : outputs.entrySet()) { System.out.println(" - " + output.getKey() + '=' + output.getValue()); } } else { outputs.forEach(GitHub::setOutput); } } } record JDK(String release, String version, String os, String arch, String type) { static String computeOsName() { var name = System.getProperty("os.name").toLowerCase(); if (name.contains("win")) return "windows"; if (name.contains("mac")) return "macos"; return "linux"; } static String computeOsArch() { var arch = System.getProperty("os.arch", "x64"); if (arch.equals("amd64")) return "x64"; if (arch.equals("x86_64")) return "x64"; return arch; } static String computeFileType() { var name = System.getProperty("os.name").toLowerCase(); return name.contains("win") ? "zip" : "tar.gz"; } } /** Download helper. */ static class Downloader { final Path archive; final String uri; final Browser browser; Downloader(Path archive, String uri) { this.archive = archive; this.uri = uri; this.browser = new Browser(); } void checkSizeAndDeleteIfDifferent() throws Exception { if (Files.notExists(archive)) return; var cachedSize = Files.size(archive); GitHub.debug("Cached size: " + cachedSize); var remoteSize = browser.head(uri).headers().firstValueAsLong("content-length").orElse(-1); GitHub.debug("Remote size: " + remoteSize); if (cachedSize == remoteSize) return; Files.delete(archive); } void downloadArchive(boolean dryRun) throws Exception { if (Files.exists(archive)) return; var head = browser.head(uri); GitHub.debug(head.toString()); if (dryRun) { return; } int retry = 0; while (true) { try { GitHub.debug("Downloading " + uri); var response = browser.download(uri, archive); GitHub.debug(response.toString()); return; } catch (IOException exception) { var message = Optional.ofNullable(exception.getMessage()).orElseGet(exception::toString); if (++retry == 3) { GitHub.error("Download failed due to: " + message); throw exception; } var seconds = retry * 10; GitHub.warn(String.format("Retrying in %d seconds due to: %s", seconds, message)); //noinspection BusyWait Thread.sleep(seconds * 1000L); } } } void verifyChecksums(String checksum) throws Exception { if (Files.notExists(archive)) return; var cached = computeChecksum(archive); GitHub.debug("Cached checksum: " + cached); var remoteChecksum = findRemoteChecksum(checksum); if (remoteChecksum.isEmpty()) { GitHub.warn("Checksum not available for: " + uri); } else { var remote = remoteChecksum.get(); GitHub.debug("Remote checksum: " + remote); if (cached.equals(remote)) { return; } } var message = "Checksum verification failed, deleting cached archive"; Files.delete(archive); GitHub.error(message); throw new AssertionError(message); } String computeChecksum(Path path) { try { var md = MessageDigest.getInstance("SHA-256"); try (var input = new BufferedInputStream(new FileInputStream(path.toFile())); var output = new DigestOutputStream(OutputStream.nullOutputStream(), md)) { input.transferTo(output); } var length = md.getDigestLength() * 2; return String.format("%0" + length + "x", new BigInteger(1, md.digest())); } catch (IOException exception) { throw new UncheckedIOException(exception); } catch (NoSuchAlgorithmException exception) { var algorithms = Security.getAlgorithms("MessageDigest"); throw new IllegalArgumentException(exception + ": " + algorithms); } } Optional findRemoteChecksum(String checksum) throws Exception { if (!checksum.startsWith("https://")) return Optional.of(checksum); if (browser.head(checksum).statusCode() == 200) return Optional.of(browser.browse(checksum)); return Optional.empty(); } } /** * GitHub Actions helper. * * @see Workflow * commands for GitHub Actions */ static class GitHub { /** Sets an action's output parameter. */ static void setOutput(String name, Object value) { if (name.isBlank() || value.toString().isBlank()) { // implicit null checks included throw new IllegalArgumentException("name or value are blank: " + name + "=" + value); } var githubOutput = System.getenv("GITHUB_OUTPUT"); if (githubOutput == null) { throw new AssertionError("No such environment variable: GITHUB_OUTPUT"); } try { var file = Path.of(githubOutput); if (file.getParent() != null) Files.createDirectories(file.getParent()); var lines = (name + "=" + value).lines().toList(); if (lines.size() != 1) { throw new UnsupportedOperationException("Multiline strings are no supported"); } debug("Write output %s to %s".formatted(lines, file)); Files.write(file, lines, UTF_8, CREATE, APPEND, WRITE); } catch (IOException exception) { throw new UncheckedIOException(exception); } } /** Creates a debug message and prints the message to the log. */ static void debug(String message) { System.out.printf("::debug::%s%n", message.replaceAll("\\R", "%0A")); } /** Creates a warning message and prints the message to the log. */ static void warn(String message) { System.out.printf("::warning::%s%n", message.replaceAll("\\R", "%0A")); } /** Creates an error message and prints the message to the log. */ static void error(String message) { System.out.printf("::error::%s%n", message.replaceAll("\\R", "%0A")); } } /** HTTP-related helper. */ static class Browser { final HttpClient client; Browser() { this.client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build(); } String browse(String uri) throws Exception { var request = HttpRequest.newBuilder(URI.create(uri)).build(); return client.send(request, HttpResponse.BodyHandlers.ofString()).body(); } HttpResponse download(String uri, Path file) throws Exception { var parent = file.getParent(); if (parent != null) Files.createDirectories(parent); var request = HttpRequest.newBuilder(URI.create(uri)).build(); return client.send(request, HttpResponse.BodyHandlers.ofFile(file)); } HttpResponse head(String uri) throws Exception { var request = HttpRequest.newBuilder(URI.create(uri)) .method("HEAD", HttpRequest.BodyPublishers.noBody()) .build(); return client.send(request, HttpResponse.BodyHandlers.discarding()); } } /** A website hosting JDK builds. */ interface Website { /** Try to instantiate a website implementation for the given hint. */ static Optional find(String hint) { if (hint.equals(OracleComWebsite.NAME) || hint.startsWith(OracleComWebsite.URI_PREFIX)) { return Optional.of(new OracleComWebsite()); } if (hint.equals(JavaNetWebsite.NAME) || hint.startsWith(JavaNetWebsite.URI_PREFIX)) { return Optional.of(new JavaNetWebsite()); } return Optional.empty(); } Optional findUri(JDK jdk); default Path computeArchivePath(String uri) { var file = uri.substring(uri.lastIndexOf('/') + 1); var home = System.getProperty("user.home"); var hash = Integer.toHexString(uri.hashCode()); var cache = Path.of(home, ".oracle-actions", "setup-java", hash); return cache.resolve(file); } default String computeVersionString(String uri, String defaultVersion) { var property = System.getProperty("install-as-version"); GitHub.debug("install-as-version: " + property); var version = property == null || property.isBlank() ? defaultVersion : property; return switch (version) { case "PARSE_URI" -> parseVersion(uri).orElse("UNKNOWN-VERSION"); case "HASH_URI" -> Integer.toString(Math.abs(uri.hashCode())); default -> version; }; } /** Try to parse version information from the given uri. */ default Optional parseVersion(String uri) { for (var versionPattern : parseVersionPatterns()) { var matcher = versionPattern.matcher(uri); if (matcher.matches()) { // "$FEATURE.$INTERIM.$UPDATE.$PATCH" var version = Runtime.Version.parse(matcher.group(1)); var joiner = new StringJoiner("."); joiner.add(String.valueOf(version.feature())); if (version.interim() != 0 || version.update() != 0) { joiner.add(String.valueOf(version.interim())); if (version.update() != 0) { joiner.add(String.valueOf(version.update())); } } return Optional.of(joiner.toString()); } } return Optional.empty(); } /** A list of patterns with each has at least one version-defining capture group. */ default List parseVersionPatterns() { return List.of(); } /** Test the given uri for potentially pointing to different resources over time. */ default boolean isMovingResourceUri(String uri) { return false; } /** Test the given uri for pointing to an archived JDK build. */ default boolean isArchivedUri(String uri) { return false; } /** The checksum for the given uri, possibly a uri pointing to a remote file. */ default String getChecksum(String uri) { return uri + ".sha256"; } } /** JDK builds hosted at {@code https://oracle.com}. */ static class OracleComWebsite implements Website { static String NAME = "oracle.com"; static String URI_PREFIX = "https://download.oracle.com/java/"; @Override public List parseVersionPatterns() { return List.of(Pattern.compile("\\Q" + URI_PREFIX + "\\E.+?/jdk-([\\d.]+).+")); } @Override public boolean isMovingResourceUri(String uri) { return uri.contains("/latest/"); } @Override public boolean isArchivedUri(String uri) { return uri.contains("/archive/"); } @Override public Optional findUri(JDK jdk) { if (Integer.parseInt(jdk.release) < 17) return Optional.empty(); if (jdk.version.equals("latest")) return Optional.of(computeLatestUri(jdk)); return Optional.of(computeArchiveUri(jdk)); } String computeLatestUri(JDK jdk) { var format = URI_PREFIX + "%s/latest/jdk-%s_%s-%s_bin.%s"; return String.format(format, jdk.release, jdk.release, jdk.os, jdk.arch, jdk.type); } String computeArchiveUri(JDK jdk) { var format = URI_PREFIX + "%s/archive/jdk-%s_%s-%s_bin.%s"; return String.format(format, jdk.release, jdk.version, jdk.os, jdk.arch, jdk.type); } } /** JDK builds hosted at {@code https://jdk.java.net}. */ static class JavaNetWebsite implements Website { static String NAME = "jdk.java.net"; static String URI_PREFIX = "https://download.java.net"; static /*lazy*/ Properties URI_MAPPING = null; @Override public List parseVersionPatterns() { return List.of(Pattern.compile("\\Q" + URI_PREFIX + "\\E.+?/openjdk-([\\d.]+).+")); } @Override public synchronized Optional findUri(JDK jdk) { var key = new StringJoiner(",") .add(jdk.release) .add(jdk.version) .add(jdk.os) .add(jdk.arch) .toString(); if (URI_MAPPING == null) { try { URI_MAPPING = new Properties(); var browser = new Browser(); var s = browser.browse( "https://raw.githubusercontent.com" + "/oracle-actions/setup-java/main" // user/repo/branch + "/jdk.java.net-uri.properties"); URI_MAPPING.load(new StringReader(s)); } catch (Exception exception) { GitHub.warn("Caught exception: " + exception); return Optional.empty(); } } var value = URI_MAPPING.getProperty(key); if (value == null) { GitHub.warn("No URI mapped for key: " + key); } return Optional.ofNullable(value); } } }