From 27f2c628249234bb4a59fd088bf12b35cf811bd1 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 29 Jul 2026 04:43:56 -0400 Subject: [PATCH] Verify JDK downloads with vendor checksums (#1167) * Verify JDK downloads with vendor checksums Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a800a031-600e-4d28-b23e-be309555d38d * Handle missing vendor checksum values Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a800a031-600e-4d28-b23e-be309555d38d * Preserve checksum error during cleanup failure Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a800a031-600e-4d28-b23e-be309555d38d * Validate checksum metadata value types Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a800a031-600e-4d28-b23e-be309555d38d * Clarify checksum documentation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a800a031-600e-4d28-b23e-be309555d38d * Expand vendor checksum verification Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a800a031-600e-4d28-b23e-be309555d38d * Accept SHA-256 or SHA-512 for JetBrains checksum sibling JetBrains publishes a single, generically-named ".checksum" sibling whose digest algorithm isn't disclosed by the filename. Older JBR 11 builds (e.g. jbrsdk_nomod-11_0_16-*-b2043.64.tar.gz) publish a SHA-256 digest there, while newer builds publish SHA-512. The JetBrains installer previously assumed SHA-512 unconditionally, so verification failed with "Malformed sha512 checksum metadata ... expected a 128-character hexadecimal digest" for those older builds, breaking the jetbrains 11 e2e job on macOS and Windows. fetchChecksum now accepts a list of candidate algorithms and infers the actual algorithm from the returned digest's length, preferring the strongest match. The JetBrains installer passes ['sha512', 'sha256']; all other callers are unaffected since they already pass a single, vendor-disclosed algorithm. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a800a031-600e-4d28-b23e-be309555d38d * Use SapMachine archive checksum files Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a800a031-600e-4d28-b23e-be309555d38d --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a800a031-600e-4d28-b23e-be309555d38d --- .github/workflows/e2e-versions.yml | 19 ++ README.md | 8 + __tests__/checksum.test.ts | 130 ++++++++ .../distributors/adopt-installer.test.ts | 8 + __tests__/distributors/base-installer.test.ts | 314 ++++++++++++++++++ .../distributors/corretto-installer.test.ts | 6 + .../distributors/dragonwell-installer.test.ts | 4 + .../distributors/graalvm-installer.test.ts | 133 +++++++- .../distributors/jetbrains-installer.test.ts | 89 +++++ __tests__/distributors/kona-installer.test.ts | 9 + .../distributors/microsoft-installer.test.ts | 40 +++ .../distributors/openjdk-installer.test.ts | 36 +- .../distributors/oracle-installer.test.ts | 32 ++ .../distributors/sapmachine-installer.test.ts | 29 ++ .../distributors/semeru-installer.test.ts | 8 + .../distributors/temurin-installer.test.ts | 8 + __tests__/distributors/zulu-installer.test.ts | 52 +++ .../distributors/zulu-linux-installer.test.ts | 52 +++ .../zulu-windows-installer.test.ts | 52 +++ dist/setup/index.js | 300 ++++++++++++++--- src/checksum.ts | 83 +++++ src/distributions/adopt/installer.ts | 9 +- src/distributions/base-installer.ts | 98 ++++++ src/distributions/base-models.ts | 9 + src/distributions/corretto/installer.ts | 18 +- src/distributions/dragonwell/installer.ts | 10 +- src/distributions/graalvm/installer.ts | 27 +- src/distributions/jetbrains/installer.ts | 14 +- src/distributions/kona/installer.ts | 23 +- src/distributions/liberica-nik/installer.ts | 2 +- src/distributions/liberica/installer.ts | 2 +- src/distributions/microsoft/installer.ts | 8 +- src/distributions/openjdk/installer.ts | 8 +- src/distributions/oracle/installer.ts | 8 +- src/distributions/sapmachine/installer.ts | 11 +- src/distributions/semeru/installer.ts | 9 +- src/distributions/temurin/installer.ts | 9 +- src/distributions/zulu/installer.ts | 51 ++- src/distributions/zulu/models.ts | 4 + 39 files changed, 1629 insertions(+), 103 deletions(-) create mode 100644 __tests__/checksum.test.ts create mode 100644 src/checksum.ts diff --git a/.github/workflows/e2e-versions.yml b/.github/workflows/e2e-versions.yml index 1e8fbd45..47f93d9f 100644 --- a/.github/workflows/e2e-versions.yml +++ b/.github/workflows/e2e-versions.yml @@ -125,6 +125,25 @@ jobs: run: bash __tests__/verify-java.sh "$JAVA_VERSION" "$JAVA_PATH" shell: bash + setup-java-checksum-verification: + name: Corretto checksum verification - ubuntu-latest + runs-on: ubuntu-latest + steps: + - *checkout_step + - name: setup-java with forced download + uses: ./ + id: setup-java + with: + java-version: '21' + distribution: corretto + force-download: true + - name: Verify Java + env: + JAVA_VERSION: '21' + JAVA_PATH: ${{ steps.setup-java.outputs.path }} + run: bash __tests__/verify-java.sh "$JAVA_VERSION" "$JAVA_PATH" + shell: bash + setup-java-alpine-linux: name: ${{ matrix.distribution }} ${{ matrix.version }} (jdk-${{ contains(matrix.os, 'macos') && !contains(matrix.os, 'intel') && 'arm64' || 'x64' }}) - alpine-linux - ${{ matrix.os }} runs-on: ${{ matrix.os }} diff --git a/README.md b/README.md index 40b6af49..bebb9386 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,14 @@ For more details, see the full release notes on the [releases page](https://git - `show-download-progress`: Set to `true` to keep Maven artifact download and transfer progress in build logs. Default value: `false`. By default, the action adds `-ntp` (`--no-transfer-progress`) to `MAVEN_ARGS`. This input has no effect on non-Maven builds. See [Maven transfer progress](docs/advanced-usage.md#maven-transfer-progress-download-logs) for more details. +### Download integrity verification + +When a selected distribution publishes an authoritative checksum for an archive, `setup-java` automatically verifies each downloaded JDK, JRE, or JMOD archive before extraction and caching. No input is required. Automatic checksum verification is currently available for `temurin`, `semeru`, `adopt`, `corretto`, `dragonwell`, `kona`, `sapmachine`, `graalvm`, `graalvm-community`, `zulu`, `oracle`, `oracle-openjdk`, `microsoft`, and `jetbrains`. + +Distributions or individual releases without an authoritative checksum continue to install normally, with the omission reported only in debug logs. Archives resolved directly from the runner tool cache are not downloaded again and therefore are not reverified. + +Checksums detect corrupted or unexpectedly modified downloads before they are persisted in the runner tool cache. + ### Basic Configuration #### Eclipse Temurin diff --git a/__tests__/checksum.test.ts b/__tests__/checksum.test.ts new file mode 100644 index 00000000..b8e3f018 --- /dev/null +++ b/__tests__/checksum.test.ts @@ -0,0 +1,130 @@ +import {afterEach, describe, expect, it, jest} from '@jest/globals'; +import {createHash} from 'crypto'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import {calculateChecksum, verifyChecksum} from '../src/checksum.js'; +import type {ChecksumMetadata} from '../src/distributions/base-models.js'; + +const temporaryPaths: string[] = []; + +async function temporaryFile(contents: string): Promise { + const directory = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'setup-java-checksum-') + ); + const file = path.join(directory, 'archive'); + await fs.promises.writeFile(file, contents); + temporaryPaths.push(directory); + return file; +} + +afterEach(async () => { + await Promise.all( + temporaryPaths + .splice(0) + .map(item => fs.promises.rm(item, {recursive: true, force: true})) + ); + jest.restoreAllMocks(); +}); + +describe('verifyChecksum', () => { + it.each(['sha256', 'sha512'] as const)( + 'verifies a matching %s digest', + async algorithm => { + const contents = `jdk archive for ${algorithm}`; + const file = await temporaryFile(contents); + const value = createHash(algorithm).update(contents).digest('hex'); + + await expect( + verifyChecksum( + file, + {algorithm, value: value.toUpperCase()}, + {distribution: 'Test', version: '21.0.1'} + ) + ).resolves.toBeUndefined(); + } + ); + + it('reports mismatch context and both digests', async () => { + const file = await temporaryFile('corrupt archive'); + const expected = 'a'.repeat(64); + const actual = await calculateChecksum(file, 'sha256'); + + await expect( + verifyChecksum( + file, + {algorithm: 'sha256', value: expected}, + {distribution: 'Corretto', version: '21.0.8'} + ) + ).rejects.toThrow( + `Checksum verification failed for Corretto version 21.0.8: sha256 expected ${expected}, actual ${actual}.` + ); + }); + + it('rejects malformed digest metadata before reading the file', async () => { + await expect( + verifyChecksum( + '/missing/archive', + {algorithm: 'sha512', value: 'not-a-digest'}, + {distribution: 'Test', version: '17'} + ) + ).rejects.toThrow( + 'Malformed sha512 checksum metadata: expected a 128-character hexadecimal digest.' + ); + }); + + it.each([undefined, null, 123])( + 'reports a malformed digest when the value is %p', + async value => { + const checksum = { + algorithm: 'sha256', + value + } as unknown as ChecksumMetadata; + + await expect( + verifyChecksum('/missing/archive', checksum, { + distribution: 'Test', + version: '17' + }) + ).rejects.toThrow( + 'Malformed sha256 checksum metadata: expected a 64-character hexadecimal digest.' + ); + } + ); + + it('rejects unsupported algorithms without leaking source query parameters', async () => { + const checksum = { + algorithm: 'md5', + value: 'a'.repeat(32), + source: 'https://vendor.example/checksum.txt?token=secret-value#private' + } as unknown as ChecksumMetadata; + + let message = ''; + try { + await verifyChecksum('/missing/archive', checksum, { + distribution: 'Test', + version: '17' + }); + } catch (error) { + message = (error as Error).message; + } + + expect(message).toContain( + "Unsupported checksum algorithm 'md5' from https://vendor.example/checksum.txt" + ); + expect(message).not.toContain('secret-value'); + expect(message).not.toContain('token='); + expect(message).not.toContain('#private'); + }); + + it('surfaces file read errors', async () => { + await expect( + verifyChecksum( + '/missing/archive', + {algorithm: 'sha256', value: 'a'.repeat(64)}, + {distribution: 'Test', version: '17'} + ) + ).rejects.toMatchObject({code: 'ENOENT'}); + }); +}); diff --git a/__tests__/distributors/adopt-installer.test.ts b/__tests__/distributors/adopt-installer.test.ts index 0d10b043..b59451b2 100644 --- a/__tests__/distributors/adopt-installer.test.ts +++ b/__tests__/distributors/adopt-installer.test.ts @@ -357,6 +357,14 @@ describe('findPackageForDownload', () => { distribution['getAvailableVersions'] = async () => manifestData as any; const resolvedVersion = await distribution['findPackageForDownload'](input); expect(resolvedVersion.version).toBe(expected); + const vendorPackage = (manifestData as any[]).find( + item => item.version_data.semver === expected + ).binaries[0].package; + expect(resolvedVersion.checksum).toEqual({ + algorithm: 'sha256', + value: vendorPackage.checksum, + source: vendorPackage.checksum_link + }); }); it('version is found but binaries list is empty', async () => { diff --git a/__tests__/distributors/base-installer.test.ts b/__tests__/distributors/base-installer.test.ts index baa156d2..f8aa0d24 100644 --- a/__tests__/distributors/base-installer.test.ts +++ b/__tests__/distributors/base-installer.test.ts @@ -16,6 +16,9 @@ import type { import path from 'path'; import * as semver from 'semver'; +import fs from 'fs'; +import {createHash} from 'crypto'; +import {HttpClient} from '@actions/http-client'; import os from 'os'; @@ -117,6 +120,17 @@ class EmptyJavaBase extends JavaBase { url: `some/random_url/java/${availableVersion}` }; } + + public downloadRelease(javaRelease: JavaDownloadRelease): Promise { + return this.downloadAndVerify(javaRelease); + } + + public fetchChecksumForTest( + checksumUrl: string, + algorithm: 'sha256' | 'sha512' | ('sha256' | 'sha512')[] + ) { + return this.fetchChecksum(checksumUrl, algorithm); + } } describe('findInToolcache', () => { @@ -817,6 +831,306 @@ describe('setupJava', () => { }); }); +describe('downloadAndVerify', () => { + const options: JavaInstallerOptions = { + version: '21', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false + }; + let temporaryDirectory: string; + let archivePath: string; + + beforeEach(async () => { + temporaryDirectory = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'setup-java-base-') + ); + archivePath = path.join(temporaryDirectory, 'archive'); + await fs.promises.writeFile(archivePath, 'downloaded archive'); + (tc.downloadTool as jest.Mock).mockResolvedValue(archivePath); + }); + + afterEach(async () => { + await fs.promises.rm(temporaryDirectory, {recursive: true, force: true}); + jest.resetAllMocks(); + }); + + it('returns a download after successful verification', async () => { + const distribution = new EmptyJavaBase(options); + const result = await distribution.downloadRelease({ + version: '21.0.8', + url: 'https://vendor.example/jdk.tar.gz', + checksum: { + algorithm: 'sha256', + value: createHash('sha256').update('downloaded archive').digest('hex') + } + }); + + expect(result).toBe(archivePath); + expect(fs.existsSync(archivePath)).toBe(true); + expect(core.debug).toHaveBeenCalledWith( + 'Verified sha256 checksum for Empty version 21.0.8.' + ); + }); + + it('removes the download after verification failure', async () => { + const distribution = new EmptyJavaBase(options); + + await expect( + distribution.downloadRelease({ + version: '21.0.8', + url: 'https://vendor.example/jdk.tar.gz?token=secret', + checksum: {algorithm: 'sha256', value: 'a'.repeat(64)} + }) + ).rejects.toThrow('Checksum verification failed for Empty version 21.0.8'); + + expect(fs.existsSync(archivePath)).toBe(false); + }); + + it('preserves the verification error when removing the download fails', async () => { + const distribution = new EmptyJavaBase(options); + const cleanupError = new Error('cleanup failed'); + jest.spyOn(fs.promises, 'rm').mockRejectedValueOnce(cleanupError); + + const result = distribution.downloadRelease({ + version: '21.0.8', + url: 'https://vendor.example/jdk.tar.gz', + checksum: {algorithm: 'sha256', value: 'a'.repeat(64)} + }); + + await expect(result).rejects.toMatchObject({ + message: expect.stringContaining( + 'Failed to remove the downloaded archive after verification failure: cleanup failed' + ), + cause: expect.objectContaining({ + message: expect.stringContaining( + 'Checksum verification failed for Empty version 21.0.8' + ) + }) + }); + }); + + it('logs when authoritative checksum metadata is unavailable', async () => { + const distribution = new EmptyJavaBase(options); + + await expect( + distribution.downloadRelease({ + version: '21.0.8', + url: 'https://vendor.example/jdk.tar.gz' + }) + ).resolves.toBe(archivePath); + + expect(core.debug).toHaveBeenCalledWith( + 'No authoritative checksum is available for Empty version 21.0.8; skipping checksum verification.' + ); + }); + + it.each([undefined, '', ' '])( + 'skips verification when the vendor digest is %p', + async value => { + const distribution = new EmptyJavaBase(options); + + await expect( + distribution.downloadRelease({ + version: '21.0.8', + url: 'https://vendor.example/jdk.tar.gz', + checksum: { + algorithm: 'sha256', + value + } as JavaDownloadRelease['checksum'] + }) + ).resolves.toBe(archivePath); + + expect(core.debug).toHaveBeenCalledWith( + 'No authoritative checksum is available for Empty version 21.0.8; skipping checksum verification.' + ); + } + ); +}); + +describe('fetchChecksum', () => { + const options: JavaInstallerOptions = { + version: '21', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false + }; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function mockGet(statusCode: number, body: string) { + return jest.spyOn(HttpClient.prototype, 'get').mockResolvedValue({ + message: {statusCode}, + readBody: async () => body + } as any); + } + + it('parses a bare hex digest', async () => { + const digest = 'a'.repeat(64); + const spy = mockGet(200, digest); + const distribution = new EmptyJavaBase(options); + + const checksum = await distribution.fetchChecksumForTest( + 'https://vendor.example/jdk.tar.gz.sha256', + 'sha256' + ); + + expect(spy).toHaveBeenCalledWith( + 'https://vendor.example/jdk.tar.gz.sha256' + ); + expect(checksum).toEqual({ + algorithm: 'sha256', + value: digest, + source: 'https://vendor.example/jdk.tar.gz.sha256' + }); + }); + + it('parses only the first token of a GNU-style checksum file', async () => { + const digest = 'b'.repeat(128); + mockGet(200, `${digest} jbrsdk-21.0.3-linux-x64-b465.3.tar.gz\n`); + const distribution = new EmptyJavaBase(options); + + const checksum = await distribution.fetchChecksumForTest( + 'https://vendor.example/jdk.tar.gz.checksum', + 'sha512' + ); + + expect(checksum).toEqual({ + algorithm: 'sha512', + value: digest, + source: 'https://vendor.example/jdk.tar.gz.checksum' + }); + }); + + it('trims surrounding whitespace and newlines', async () => { + const digest = 'c'.repeat(64); + mockGet(200, `\n ${digest} \n`); + const distribution = new EmptyJavaBase(options); + + const checksum = await distribution.fetchChecksumForTest( + 'https://vendor.example/jdk.tar.gz.sha256', + 'sha256' + ); + + expect(checksum.value).toBe(digest); + }); + + it('skips verification when the sibling checksum is not published', async () => { + mockGet(404, 'Not Found'); + const distribution = new EmptyJavaBase(options); + + await expect( + distribution.fetchChecksumForTest( + 'https://vendor.example/jdk.tar.gz.sha256', + 'sha256' + ) + ).resolves.toBeUndefined(); + expect(core.debug).toHaveBeenCalledWith( + 'No authoritative sha256 checksum is available for Empty from https://vendor.example/jdk.tar.gz.sha256; skipping checksum verification.' + ); + }); + + it('surfaces unexpected HTTP failures without query parameters', async () => { + mockGet(500, 'Server Error'); + const distribution = new EmptyJavaBase(options); + + await expect( + distribution.fetchChecksumForTest( + 'https://vendor.example/jdk.tar.gz.sha256?token=secret', + 'sha256' + ) + ).rejects.toThrow( + 'Failed to fetch the authoritative sha256 checksum for Empty from https://vendor.example/jdk.tar.gz.sha256 (HTTP 500).' + ); + }); + + it('rejects an empty successful checksum response', async () => { + mockGet(200, ' \n'); + const distribution = new EmptyJavaBase(options); + + await expect( + distribution.fetchChecksumForTest( + 'https://vendor.example/jdk.tar.gz.sha256', + 'sha256' + ) + ).rejects.toThrow( + 'Received an empty authoritative sha256 checksum for Empty from https://vendor.example/jdk.tar.gz.sha256.' + ); + }); + + describe('with a list of candidate algorithms', () => { + it('infers sha512 when the digest is 128 hex characters', async () => { + const digest = 'd'.repeat(128); + mockGet(200, `${digest} jbrsdk.tar.gz\n`); + const distribution = new EmptyJavaBase(options); + + const checksum = await distribution.fetchChecksumForTest( + 'https://vendor.example/jbrsdk.tar.gz.checksum', + ['sha512', 'sha256'] + ); + + expect(checksum).toEqual({ + algorithm: 'sha512', + value: digest, + source: 'https://vendor.example/jbrsdk.tar.gz.checksum' + }); + }); + + it('infers sha256 when the digest is 64 hex characters, even though sha512 was preferred', async () => { + // Reproduces older JetBrains JBR builds (e.g. JBR 11), which publish a + // SHA-256 digest at the generic `.checksum` sibling instead of SHA-512. + const digest = 'e'.repeat(64); + mockGet(200, `${digest} jbrsdk_nomod-11_0_16-osx-x64-b2043.64.tar.gz\n`); + const distribution = new EmptyJavaBase(options); + + const checksum = await distribution.fetchChecksumForTest( + 'https://vendor.example/jbrsdk_nomod-11_0_16-osx-x64-b2043.64.tar.gz.checksum', + ['sha512', 'sha256'] + ); + + expect(checksum).toEqual({ + algorithm: 'sha256', + value: digest, + source: + 'https://vendor.example/jbrsdk_nomod-11_0_16-osx-x64-b2043.64.tar.gz.checksum' + }); + }); + + it('falls back to the first candidate algorithm when the digest length matches none of them', async () => { + const digest = 'f'.repeat(40); // e.g. sha1, not supported + mockGet(200, `${digest} jbrsdk.tar.gz\n`); + const distribution = new EmptyJavaBase(options); + + const checksum = await distribution.fetchChecksumForTest( + 'https://vendor.example/jbrsdk.tar.gz.checksum', + ['sha512', 'sha256'] + ); + + // No candidate algorithm matches, so the first-listed one is kept; + // downstream verification will reject it as malformed. + expect(checksum.algorithm).toBe('sha512'); + expect(checksum.value).toBe(digest); + }); + + it('reports the checksum as unavailable using a combined algorithm label on 404', async () => { + mockGet(404, 'Not Found'); + const distribution = new EmptyJavaBase(options); + + await expect( + distribution.fetchChecksumForTest( + 'https://vendor.example/jbrsdk.tar.gz.checksum', + ['sha512', 'sha256'] + ) + ).resolves.toBeUndefined(); + expect(core.debug).toHaveBeenCalledWith( + 'No authoritative sha512 or sha256 checksum is available for Empty from https://vendor.example/jbrsdk.tar.gz.checksum; skipping checksum verification.' + ); + }); + }); +}); + describe('normalizeVersion', () => { const DummyJavaBase = JavaBase as any; diff --git a/__tests__/distributors/corretto-installer.test.ts b/__tests__/distributors/corretto-installer.test.ts index 57e66081..371ac86c 100644 --- a/__tests__/distributors/corretto-installer.test.ts +++ b/__tests__/distributors/corretto-installer.test.ts @@ -202,6 +202,12 @@ describe('getAvailableVersions', () => { await distribution['findPackageForDownload'](version); expect(availableVersion).not.toBeNull(); expect(availableVersion.url).toBe(expectedLink); + expect(availableVersion.checksum).toEqual({ + algorithm: 'sha256', + value: expect.stringMatching(/^[a-f0-9]{64}$/), + source: + 'https://corretto.github.io/corretto-downloads/latest_links/indexmap_with_checksum.json' + }); }); it('with latest resolves to the newest available major version', async () => { diff --git a/__tests__/distributors/dragonwell-installer.test.ts b/__tests__/distributors/dragonwell-installer.test.ts index 032ba323..69bada84 100644 --- a/__tests__/distributors/dragonwell-installer.test.ts +++ b/__tests__/distributors/dragonwell-installer.test.ts @@ -259,6 +259,10 @@ describe('getAvailableVersions', () => { await distribution['findPackageForDownload'](jdkVersion); expect(availableVersion).not.toBeNull(); expect(availableVersion.url).toBe(expectedLink); + expect(availableVersion.checksum).toEqual({ + algorithm: 'sha256', + value: expect.stringMatching(/^[a-f0-9]{64}$/) + }); } ); diff --git a/__tests__/distributors/graalvm-installer.test.ts b/__tests__/distributors/graalvm-installer.test.ts index 0882c6ca..bb80230d 100644 --- a/__tests__/distributors/graalvm-installer.test.ts +++ b/__tests__/distributors/graalvm-installer.test.ts @@ -129,6 +129,14 @@ describe('GraalVMDistribution', () => { (distribution as any).http = mockHttpClient; (communityDistribution as any).http = mockHttpClient; + // Default checksum sibling response for `${url}.sha256` requests made by + // GraalVM (Oracle) and GraalVM EA. Individual tests override this when + // they need to assert the exact URL/digest contract. + mockHttpClient.get.mockResolvedValue({ + message: {statusCode: 200}, + readBody: jest.fn().mockResolvedValue('a'.repeat(64)) + }); + (util.getDownloadArchiveExtension as jest.Mock).mockReturnValue( 'tar.gz' ); @@ -407,9 +415,16 @@ describe('GraalVMDistribution', () => { expect(result).toEqual({ url: 'https://download.oracle.com/graalvm/17/archive/graalvm-jdk-17.0.5_linux-x64_bin.tar.gz', - version: '17.0.5' + version: '17.0.5', + checksum: { + algorithm: 'sha256', + value: 'a'.repeat(64), + source: + 'https://download.oracle.com/graalvm/17/archive/graalvm-jdk-17.0.5_linux-x64_bin.tar.gz.sha256' + } }); expect(mockHttpClient.head).toHaveBeenCalledWith(result.url); + expect(mockHttpClient.get).toHaveBeenCalledWith(`${result.url}.sha256`); }); it('should construct correct URL for major version (latest)', async () => { @@ -422,7 +437,13 @@ describe('GraalVMDistribution', () => { expect(result).toEqual({ url: 'https://download.oracle.com/graalvm/21/latest/graalvm-jdk-21_linux-x64_bin.tar.gz', - version: '21' + version: '21', + checksum: { + algorithm: 'sha256', + value: 'a'.repeat(64), + source: + 'https://download.oracle.com/graalvm/21/latest/graalvm-jdk-21_linux-x64_bin.tar.gz.sha256' + } }); }); @@ -465,7 +486,13 @@ describe('GraalVMDistribution', () => { expect(result).toEqual({ url: 'https://download.oracle.com/graalvm/25/latest/graalvm-jdk-25_linux-x64_bin.tar.gz', - version: '25' + version: '25', + checksum: { + algorithm: 'sha256', + value: 'a'.repeat(64), + source: + 'https://download.oracle.com/graalvm/25/latest/graalvm-jdk-25_linux-x64_bin.tar.gz.sha256' + } }); }); @@ -637,13 +664,20 @@ describe('GraalVMDistribution', () => { expect(result).toEqual({ url: 'https://example.com/download/graalvm-jdk-23_linux-x64_bin.tar.gz', - version: '23-ea-20240716' + version: '23-ea-20240716', + checksum: { + algorithm: 'sha256', + value: 'a'.repeat(64), + source: + 'https://example.com/download/graalvm-jdk-23_linux-x64_bin.tar.gz.sha256' + } }); expect(mockHttpClient.getJson).toHaveBeenCalledWith( 'https://api.github.com/repos/graalvm/oracle-graalvm-ea-builds/contents/versions/23-ea.json?ref=main', {Accept: 'application/json'} ); + expect(mockHttpClient.get).toHaveBeenCalledWith(`${result.url}.sha256`); }); it('should throw error when no latest EA version found', async () => { @@ -876,8 +910,15 @@ describe('GraalVMDistribution', () => { expect(fetchEASpy).toHaveBeenCalledWith('23-ea'); expect(result).toEqual({ url: 'https://example.com/download/graalvm-jdk-23_linux-x64_bin.tar.gz', - version: '23-ea-20240716' + version: '23-ea-20240716', + checksum: { + algorithm: 'sha256', + value: 'a'.repeat(64), + source: + 'https://example.com/download/graalvm-jdk-23_linux-x64_bin.tar.gz.sha256' + } }); + expect(mockHttpClient.get).toHaveBeenCalledWith(`${result.url}.sha256`); // Verify debug logging expect(core.debug).toHaveBeenCalledWith('Searching for EA build: 23-ea'); @@ -976,7 +1017,13 @@ describe('GraalVMDistribution', () => { expect(result).toEqual({ url: 'https://example.com/download/graalvm-jdk-23_linux-aarch64_bin.tar.gz', - version: '23-ea-20240716' + version: '23-ea-20240716', + checksum: { + algorithm: 'sha256', + value: 'a'.repeat(64), + source: + 'https://example.com/download/graalvm-jdk-23_linux-aarch64_bin.tar.gz.sha256' + } }); }); @@ -1151,6 +1198,80 @@ describe('GraalVMDistribution', () => { url: 'https://github.com/graalvm/graalvm-ce-builds/releases/download/jdk-21.0.2/graalvm-community-jdk-21.0.2_linux-x64_bin.tar.gz', version: '21.0.2' }); + // The asset had no `digest` field, so no checksum should be attached, + // and the checksum sibling-URL fetch path (used by Oracle GraalVM) + // must not be consulted for GraalVM Community. + expect(result.checksum).toBeUndefined(); + expect(mockHttpClient.get).not.toHaveBeenCalled(); + }); + + it('strips the `sha256:` prefix from a GitHub release asset digest', async () => { + const digest = 'd'.repeat(64); + mockHttpClient.getJson.mockResolvedValue({ + result: [ + { + draft: false, + prerelease: false, + assets: [ + { + name: 'graalvm-community-jdk-21.0.2_linux-x64_bin.tar.gz', + browser_download_url: + 'https://github.com/graalvm/graalvm-ce-builds/releases/download/jdk-21.0.2/graalvm-community-jdk-21.0.2_linux-x64_bin.tar.gz', + digest: `sha256:${digest}` + } + ] + } + ], + statusCode: 200, + headers: {} + }); + + const result = await ( + communityDistribution as any + ).findPackageForDownload('21.0.2'); + + expect(result.checksum).toEqual({ + algorithm: 'sha256', + value: digest, + source: + 'https://api.github.com/repos/graalvm/graalvm-ce-builds/releases?per_page=100' + }); + // The digest came from the release listing itself, so no additional + // HTTP request should be made to resolve the checksum. + expect(mockHttpClient.get).not.toHaveBeenCalled(); + }); + + it('safely skips a missing or malformed release asset digest', async () => { + mockHttpClient.getJson.mockResolvedValue({ + result: [ + { + draft: false, + prerelease: false, + assets: [ + { + name: 'graalvm-community-jdk-21.0.2_linux-x64_bin.tar.gz', + browser_download_url: + 'https://github.com/graalvm/graalvm-ce-builds/releases/download/jdk-21.0.2/graalvm-community-jdk-21.0.2_linux-x64_bin.tar.gz', + digest: 'md5:not-a-sha256-digest' + } + ] + } + ], + statusCode: 200, + headers: {} + }); + + const result = await ( + communityDistribution as any + ).findPackageForDownload('21.0.2'); + + expect(result.checksum).toBeUndefined(); + expect(mockHttpClient.get).not.toHaveBeenCalled(); + expect(core.debug).toHaveBeenCalledWith( + expect.stringContaining( + 'No authoritative sha256 digest is available for graalvm-community-jdk-21.0.2_linux-x64_bin.tar.gz' + ) + ); }); it('should resolve the latest GraalVM Community release for a major version', async () => { diff --git a/__tests__/distributors/jetbrains-installer.test.ts b/__tests__/distributors/jetbrains-installer.test.ts index 8d5e1a12..2a526219 100644 --- a/__tests__/distributors/jetbrains-installer.test.ts +++ b/__tests__/distributors/jetbrains-installer.test.ts @@ -148,6 +148,27 @@ describe('getAvailableVersions', () => { }); describe('findPackageForDownload', () => { + let spyHttpClientGet: any; + + const JETBRAINS_CHECKSUM = 'c'.repeat(128); + + beforeEach(() => { + // Every resolved release fetches `${url}.checksum` (sha512, GNU + // ` ` format); stub it so tests never reach the real + // network, except the dedicated 'version %s can be downloaded' test + // below which intentionally exercises real HTTPS HEAD requests. + spyHttpClientGet = jest + .spyOn(HttpClient.prototype, 'get') + .mockResolvedValue({ + message: {statusCode: 200}, + readBody: async () => `${JETBRAINS_CHECKSUM} jbrsdk.tar.gz\n` + } as any); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + it.each([ ['17', '17.0.11+1207.24'], ['11.0', '11.0.16+2043.64'], @@ -231,4 +252,72 @@ describe('findPackageForDownload', () => { /No matching version found for SemVer */ ); }); + + it('fetches the authoritative sha512 checksum only for the resolved version', async () => { + const distribution = new JetBrainsDistribution({ + version: '21', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false + }); + distribution['getAvailableVersions'] = async () => manifestData as any; + + const result = await distribution['findPackageForDownload']('21'); + + expect(result.checksum).toEqual({ + algorithm: 'sha512', + value: JETBRAINS_CHECKSUM, + source: `${result.url}.checksum` + }); + // Only the single resolved/winning version's checksum is requested, + // not one per candidate considered during version resolution. + expect(spyHttpClientGet).toHaveBeenCalledWith(`${result.url}.checksum`); + expect(spyHttpClientGet).toHaveBeenCalledTimes(1); + }); + + it('parses only the first whitespace-delimited token from the GNU checksum payload', async () => { + spyHttpClientGet.mockResolvedValue({ + message: {statusCode: 200}, + readBody: async () => `${JETBRAINS_CHECKSUM} jbrsdk-21.tar.gz\n` + } as any); + + const distribution = new JetBrainsDistribution({ + version: '21', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false + }); + distribution['getAvailableVersions'] = async () => manifestData as any; + + const result = await distribution['findPackageForDownload']('21'); + + expect(result.checksum?.value).toBe(JETBRAINS_CHECKSUM); + }); + + it('falls back to a sha256 checksum for older JBR builds that only publish one', async () => { + // Older JBR 11 builds (e.g. jbrsdk_nomod-11_0_16-*-b2043.64.tar.gz) publish + // a SHA-256 digest at the generic `.checksum` sibling instead of SHA-512. + const sha256Checksum = 'a'.repeat(64); + spyHttpClientGet.mockResolvedValue({ + message: {statusCode: 200}, + readBody: async () => + `${sha256Checksum} jbrsdk_nomod-11_0_16-osx-x64-b2043.64.tar.gz\n` + } as any); + + const distribution = new JetBrainsDistribution({ + version: '21', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false + }); + distribution['getAvailableVersions'] = async () => manifestData as any; + + const result = await distribution['findPackageForDownload']('21'); + + expect(result.checksum).toEqual({ + algorithm: 'sha256', + value: sha256Checksum, + source: `${result.url}.checksum` + }); + }); }); diff --git a/__tests__/distributors/kona-installer.test.ts b/__tests__/distributors/kona-installer.test.ts index 88a6d5cb..ba5a8326 100644 --- a/__tests__/distributors/kona-installer.test.ts +++ b/__tests__/distributors/kona-installer.test.ts @@ -216,6 +216,15 @@ describe('Check findPackageForDownload', () => { await distribution['findPackageForDownload'](version); expect(availableRelease).not.toBeNull(); expect(availableRelease.url).toBe(expectedUrl); + if (availableRelease.checksum) { + expect(availableRelease.checksum).toEqual({ + algorithm: 'sha256', + value: expect.stringMatching(/^[a-f0-9]{64}$/), + source: 'https://tencent.github.io/konajdk/releases/kona-v1.json' + }); + } else { + expect(version).toBe('8.0.20'); + } } ); }); diff --git a/__tests__/distributors/microsoft-installer.test.ts b/__tests__/distributors/microsoft-installer.test.ts index f1aca778..0dc0ea53 100644 --- a/__tests__/distributors/microsoft-installer.test.ts +++ b/__tests__/distributors/microsoft-installer.test.ts @@ -103,9 +103,12 @@ const util = await import('../../src/util.js'); describe('findPackageForDownload', () => { let distribution: InstanceType; let spyGetManifestFromRepo: any; + let spyHttpClientGet: any; let spyDebug: any; let spyCoreError: any; + const MICROSOFT_CHECKSUM = 'b'.repeat(64); + beforeEach(() => { mockOsArch.mockReturnValue('x64'); mockOsPlatform.mockReturnValue(process.platform); @@ -124,6 +127,15 @@ describe('findPackageForDownload', () => { headers: {} }); + // Every resolved release fetches `${download_url}.sha256sum.txt`; stub + // it with a GNU-style ` ` payload so tests never reach + // the real network. + spyHttpClientGet = jest.spyOn(HttpClient.prototype, 'get'); + spyHttpClientGet.mockResolvedValue({ + message: {statusCode: 200}, + readBody: async () => `${MICROSOFT_CHECKSUM} microsoft-jdk.tar.gz\n` + }); + spyDebug = core.debug as jest.Mock; spyDebug.mockImplementation(() => {}); @@ -311,6 +323,34 @@ describe('findPackageForDownload', () => { 'https://example.test/jdk.tar.gz.custom.sig' ); }); + + it('fetches the authoritative sha256 checksum from the GNU-style sibling file', async () => { + mockOsPlatform.mockReturnValue(process.platform); + + const result = await distribution['findPackageForDownload']('17.0.7'); + + expect(result.checksum).toEqual({ + algorithm: 'sha256', + value: MICROSOFT_CHECKSUM, + source: `${result.url}.sha256sum.txt` + }); + expect(spyHttpClientGet).toHaveBeenCalledWith( + `${result.url}.sha256sum.txt` + ); + expect(spyHttpClientGet).toHaveBeenCalledTimes(1); + }); + + it('parses only the first whitespace-delimited token from the GNU checksum payload', async () => { + spyHttpClientGet.mockResolvedValue({ + message: {statusCode: 200}, + readBody: async () => + `${MICROSOFT_CHECKSUM} microsoft-jdk-17.0.7-linux-x64.tar.gz\n` + }); + + const result = await distribution['findPackageForDownload']('17.0.7'); + + expect(result.checksum?.value).toBe(MICROSOFT_CHECKSUM); + }); }); describe('downloadTool', () => { diff --git a/__tests__/distributors/openjdk-installer.test.ts b/__tests__/distributors/openjdk-installer.test.ts index 48720a41..65b7e2c4 100644 --- a/__tests__/distributors/openjdk-installer.test.ts +++ b/__tests__/distributors/openjdk-installer.test.ts @@ -50,6 +50,14 @@ const archivePage = ` 9.0.4 (build 9.0.4+11) tar.gz `; +const GA_CHECKSUM = 'c'.repeat(64); +const EA_CHECKSUM = 'd'.repeat(64); +const checksumPages: Record = { + 'https://download.java.net/java/GA/jdk26.0.2/hash/10/GPL/openjdk-26.0.2_linux-x64_bin.tar.gz.sha256': + GA_CHECKSUM, + 'https://download.java.net/java/early_access/jdk27/32/GPL/openjdk-27-ea+32_linux-x64_bin.tar.gz.sha256': + EA_CHECKSUM +}; function createDistribution( version = '26', @@ -82,8 +90,16 @@ describe('OpenJdkDistribution', () => { 'https://jdk.java.net/27/': earlyAccessPage, 'https://jdk.java.net/archive/': archivePage }; + if (url in pages) { + return { + message: {statusCode: 200}, + readBody: async () => pages[url] + } as Awaited>; + } + // Any other GET is a `${archiveUrl}.sha256` checksum sibling request. return { - readBody: async () => pages[url] ?? '' + message: {statusCode: 200}, + readBody: async () => checksumPages[url] ?? 'e'.repeat(64) } as Awaited>; }); }); @@ -97,8 +113,15 @@ describe('OpenJdkDistribution', () => { expect(result).toEqual({ version: '26.0.2+10', - url: 'https://download.java.net/java/GA/jdk26.0.2/hash/10/GPL/openjdk-26.0.2_linux-x64_bin.tar.gz' + url: 'https://download.java.net/java/GA/jdk26.0.2/hash/10/GPL/openjdk-26.0.2_linux-x64_bin.tar.gz', + checksum: { + algorithm: 'sha256', + value: GA_CHECKSUM, + source: + 'https://download.java.net/java/GA/jdk26.0.2/hash/10/GPL/openjdk-26.0.2_linux-x64_bin.tar.gz.sha256' + } }); + expect(getSpy).toHaveBeenCalledWith(`${result.url}.sha256`); }); it('resolves an archived GA release', async () => { @@ -144,9 +167,16 @@ describe('OpenJdkDistribution', () => { expect(result).toEqual({ version: '27.0.0+32', - url: 'https://download.java.net/java/early_access/jdk27/32/GPL/openjdk-27-ea+32_linux-x64_bin.tar.gz' + url: 'https://download.java.net/java/early_access/jdk27/32/GPL/openjdk-27-ea+32_linux-x64_bin.tar.gz', + checksum: { + algorithm: 'sha256', + value: EA_CHECKSUM, + source: + 'https://download.java.net/java/early_access/jdk27/32/GPL/openjdk-27-ea+32_linux-x64_bin.tar.gz.sha256' + } }); expect(getSpy).not.toHaveBeenCalledWith('https://jdk.java.net/archive/'); + expect(getSpy).toHaveBeenCalledWith(`${result.url}.sha256`); }); it('reports available versions when no release matches', async () => { diff --git a/__tests__/distributors/oracle-installer.test.ts b/__tests__/distributors/oracle-installer.test.ts index b5cb7989..7110f638 100644 --- a/__tests__/distributors/oracle-installer.test.ts +++ b/__tests__/distributors/oracle-installer.test.ts @@ -47,8 +47,11 @@ describe('findPackageForDownload', () => { let distribution: InstanceType; let spyDebug: any; let spyHttpClient: any; + let spyHttpClientGet: any; let spyCoreError: any; + const ORACLE_CHECKSUM = 'f'.repeat(64); + beforeEach(() => { distribution = new OracleDistribution({ version: '', @@ -63,6 +66,14 @@ describe('findPackageForDownload', () => { // Mock core.error to suppress error logs spyCoreError = core.error as jest.Mock; spyCoreError.mockImplementation(() => {}); + + // Every resolved release fetches its `${url}.sha256` sibling checksum; + // stub it so tests never reach the real network. + spyHttpClientGet = jest.spyOn(HttpClient.prototype, 'get'); + spyHttpClientGet.mockResolvedValue({ + message: {statusCode: 200}, + readBody: async () => ORACLE_CHECKSUM + }); }); it.each([ @@ -133,6 +144,23 @@ describe('findPackageForDownload', () => { expect(result.url).toBe(url); }); + it('fetches the authoritative sha256 checksum for the resolved archive', async () => { + spyHttpClient = jest.spyOn(HttpClient.prototype, 'head'); + spyHttpClient.mockResolvedValue({message: {statusCode: 200}}); + + const result = await distribution['findPackageForDownload']('21'); + + jest.restoreAllMocks(); + + expect(result.checksum).toEqual({ + algorithm: 'sha256', + value: ORACLE_CHECKSUM, + source: `${result.url}.sha256` + }); + expect(spyHttpClientGet).toHaveBeenCalledWith(`${result.url}.sha256`); + expect(spyHttpClientGet).toHaveBeenCalledTimes(1); + }); + it.each([ ['amd64', 'x64'], ['arm64', 'aarch64'] @@ -196,6 +224,10 @@ describe('findPackageForDownload with latest', () => { it('resolves the newest major version from the Adoptium API', async () => { spyHttpClientHead = jest.spyOn(HttpClient.prototype, 'head'); spyHttpClientHead.mockResolvedValue({message: {statusCode: 200}}); + jest.spyOn(HttpClient.prototype, 'get').mockResolvedValue({ + message: {statusCode: 200}, + readBody: async () => 'f'.repeat(64) + } as any); const distribution = new OracleDistribution({ version: 'latest', diff --git a/__tests__/distributors/sapmachine-installer.test.ts b/__tests__/distributors/sapmachine-installer.test.ts index 17dae8d2..e41419bf 100644 --- a/__tests__/distributors/sapmachine-installer.test.ts +++ b/__tests__/distributors/sapmachine-installer.test.ts @@ -52,8 +52,10 @@ const utils = await import('../../src/util.js'); describe('getAvailableVersions', () => { let spyHttpClient: any; + let spyHttpGet: any; let spyUtilGetDownloadArchiveExtension: any; let spyCoreError: any; + const archiveChecksum = 'f'.repeat(64); beforeEach(() => { spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson'); @@ -62,6 +64,11 @@ describe('getAvailableVersions', () => { headers: {}, result: manifestData }); + spyHttpGet = jest.spyOn(HttpClient.prototype, 'get'); + spyHttpGet.mockResolvedValue({ + message: {statusCode: 200}, + readBody: async () => `${archiveChecksum} archive` + }); spyUtilGetDownloadArchiveExtension = utils.getDownloadArchiveExtension as jest.Mock; @@ -282,9 +289,31 @@ describe('getAvailableVersions', () => { await distribution['findPackageForDownload'](normalizedVersion); expect(availableVersion).not.toBeNull(); expect(availableVersion.url).toBe(expectedLink); + expect(availableVersion.checksum).toEqual({ + algorithm: 'sha256', + value: archiveChecksum, + source: expectedLink.replace(/\.(?:tar\.gz|zip)$/, '.sha256.txt') + }); } ); + it('uses the checksum published beside the selected EA archive', async () => { + const distribution = new SapMachineDistribution({ + version: '21-ea', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false + }); + mockPlatform(distribution, 'linux'); + + const release = await distribution['findPackageForDownload']('21'); + + expect(spyHttpGet).toHaveBeenCalledWith( + release.url.replace(/\.(?:tar\.gz|zip)$/, '.sha256.txt') + ); + expect(release.checksum?.value).toBe(archiveChecksum); + }); + it.each([ ['8', 'linux', 'x64'], ['8', 'macos', 'aarch64'], diff --git a/__tests__/distributors/semeru-installer.test.ts b/__tests__/distributors/semeru-installer.test.ts index dd9a126a..7dde47d0 100644 --- a/__tests__/distributors/semeru-installer.test.ts +++ b/__tests__/distributors/semeru-installer.test.ts @@ -208,6 +208,14 @@ describe('findPackageForDownload', () => { distribution['getAvailableVersions'] = async () => manifestData as any; const resolvedVersion = await distribution['findPackageForDownload'](input); expect(resolvedVersion.version).toBe(expected); + const vendorPackage = (manifestData as any[]).find( + item => item.version_data.semver === expected + ).binaries[0].package; + expect(resolvedVersion.checksum).toEqual({ + algorithm: 'sha256', + value: vendorPackage.checksum, + source: vendorPackage.checksum_link + }); }); it('version is found but binaries list is empty', async () => { diff --git a/__tests__/distributors/temurin-installer.test.ts b/__tests__/distributors/temurin-installer.test.ts index 06b5ce0b..d32c0114 100644 --- a/__tests__/distributors/temurin-installer.test.ts +++ b/__tests__/distributors/temurin-installer.test.ts @@ -347,6 +347,14 @@ describe('findPackageForDownload', () => { const resolvedVersion = await distribution['findPackageForDownload'](input); expect(resolvedVersion.version).toBe(expected); expect(resolvedVersion.signatureUrl).toBeDefined(); + const vendorPackage = (manifestData as any[]).find( + item => item.version_data.semver === expected + ).binaries[0].package; + expect(resolvedVersion.checksum).toEqual({ + algorithm: 'sha256', + value: vendorPackage.checksum, + source: vendorPackage.checksum_link + }); }); it('version "latest" is normalized to the newest available version', async () => { diff --git a/__tests__/distributors/zulu-installer.test.ts b/__tests__/distributors/zulu-installer.test.ts index 863a1b79..30125b7d 100644 --- a/__tests__/distributors/zulu-installer.test.ts +++ b/__tests__/distributors/zulu-installer.test.ts @@ -241,6 +241,26 @@ describe('getArchitectureOptions', () => { }); describe('findPackageForDownload', () => { + let spyPackageDetails: any; + + const ZULU_CHECKSUM = 'a'.repeat(64); + + beforeEach(() => { + // The resolved winning package fetches sha256_hash from the Azul + // package-details endpoint; stub it so tests never reach the real + // network. + spyPackageDetails = jest.spyOn(HttpClient.prototype, 'getJson'); + spyPackageDetails.mockResolvedValue({ + statusCode: 200, + headers: {}, + result: {sha256_hash: ZULU_CHECKSUM} + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + it.each([ ['8', '8.0.282+8'], ['11.x', '11.0.10+9'], @@ -279,6 +299,38 @@ describe('findPackageForDownload', () => { expect(result.url).toBe( 'https://cdn.azul.com/zulu/bin/zulu11.35.15-ca-jdk11.0.5-macosx_x64.tar.gz' ); + expect(result.checksum).toEqual({ + algorithm: 'sha256', + value: ZULU_CHECKSUM, + source: 'https://api.azul.com/metadata/v1/zulu/packages/test-uuid-10933' + }); + // Only the winning package's UUID triggers a details request. + expect(spyPackageDetails).toHaveBeenCalledWith( + 'https://api.azul.com/metadata/v1/zulu/packages/test-uuid-10933' + ); + expect(spyPackageDetails).toHaveBeenCalledTimes(1); + }); + + it('skips checksum verification when sha256_hash is missing or malformed', async () => { + spyPackageDetails.mockResolvedValue({ + statusCode: 200, + headers: {}, + result: {sha256_hash: 'not-a-valid-digest'} + }); + + const distribution = new ZuluDistribution({ + version: '', + architecture: 'x86', + packageType: 'jdk', + checkLatest: false + }); + distribution['getAvailableVersions'] = async () => manifestData; + const result = await distribution['findPackageForDownload']('11.0.5'); + + expect(result.checksum).toBeUndefined(); + expect(core.debug).toHaveBeenCalledWith( + expect.stringContaining('No authoritative sha256 checksum') + ); }); it('should throw an error', async () => { diff --git a/__tests__/distributors/zulu-linux-installer.test.ts b/__tests__/distributors/zulu-linux-installer.test.ts index 547bf74a..2b818c7d 100644 --- a/__tests__/distributors/zulu-linux-installer.test.ts +++ b/__tests__/distributors/zulu-linux-installer.test.ts @@ -245,6 +245,26 @@ describe('getArchitectureOptions', () => { }); describe('findPackageForDownload', () => { + let spyPackageDetails: any; + + const ZULU_CHECKSUM = 'a'.repeat(64); + + beforeEach(() => { + // The resolved winning package fetches sha256_hash from the Azul + // package-details endpoint; stub it so tests never reach the real + // network. + spyPackageDetails = jest.spyOn(HttpClient.prototype, 'getJson'); + spyPackageDetails.mockResolvedValue({ + statusCode: 200, + headers: {}, + result: {sha256_hash: ZULU_CHECKSUM} + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + it.each([ ['8', '8.0.282+8'], ['11.x', '11.0.10+9'], @@ -283,6 +303,38 @@ describe('findPackageForDownload', () => { expect(result.url).toBe( 'https://cdn.azul.com/zulu/bin/zulu21.32.17-ca-jdk21.0.2-linux_aarch64.tar.gz' ); + expect(result.checksum).toEqual({ + algorithm: 'sha256', + value: ZULU_CHECKSUM, + source: 'https://api.azul.com/metadata/v1/zulu/packages/test-uuid-12447' + }); + // Only the winning package's UUID triggers a details request. + expect(spyPackageDetails).toHaveBeenCalledWith( + 'https://api.azul.com/metadata/v1/zulu/packages/test-uuid-12447' + ); + expect(spyPackageDetails).toHaveBeenCalledTimes(1); + }); + + it('skips checksum verification when sha256_hash is missing or malformed', async () => { + spyPackageDetails.mockResolvedValue({ + statusCode: 200, + headers: {}, + result: {} + }); + + const distribution = new ZuluDistribution({ + version: '', + architecture: 'arm64', + packageType: 'jdk', + checkLatest: false + }); + distribution['getAvailableVersions'] = async () => manifestData; + const result = await distribution['findPackageForDownload']('21.0.2'); + + expect(result.checksum).toBeUndefined(); + expect(core.debug).toHaveBeenCalledWith( + expect.stringContaining('No authoritative sha256 checksum') + ); }); it('should throw an error', async () => { diff --git a/__tests__/distributors/zulu-windows-installer.test.ts b/__tests__/distributors/zulu-windows-installer.test.ts index c6749edb..c90890d5 100644 --- a/__tests__/distributors/zulu-windows-installer.test.ts +++ b/__tests__/distributors/zulu-windows-installer.test.ts @@ -242,6 +242,26 @@ describe('getArchitectureOptions', () => { }); describe('findPackageForDownload', () => { + let spyPackageDetails: any; + + const ZULU_CHECKSUM = 'a'.repeat(64); + + beforeEach(() => { + // The resolved winning package fetches sha256_hash from the Azul + // package-details endpoint; stub it so tests never reach the real + // network. + spyPackageDetails = jest.spyOn(HttpClient.prototype, 'getJson'); + spyPackageDetails.mockResolvedValue({ + statusCode: 200, + headers: {}, + result: {sha256_hash: ZULU_CHECKSUM} + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + it.each([ ['8', '8.0.282+8'], ['11.x', '11.0.10+9'], @@ -280,6 +300,38 @@ describe('findPackageForDownload', () => { expect(result.url).toBe( 'https://cdn.azul.com/zulu/bin/zulu17.48.15-ca-jdk17.0.10-windows_aarch64.zip' ); + expect(result.checksum).toEqual({ + algorithm: 'sha256', + value: ZULU_CHECKSUM, + source: 'https://api.azul.com/metadata/v1/zulu/packages/test-uuid-12446' + }); + // Only the winning package's UUID triggers a details request. + expect(spyPackageDetails).toHaveBeenCalledWith( + 'https://api.azul.com/metadata/v1/zulu/packages/test-uuid-12446' + ); + expect(spyPackageDetails).toHaveBeenCalledTimes(1); + }); + + it('skips checksum verification when sha256_hash is missing or malformed', async () => { + spyPackageDetails.mockResolvedValue({ + statusCode: 200, + headers: {}, + result: {sha256_hash: '123'} + }); + + const distribution = new ZuluDistribution({ + version: '', + architecture: 'arm64', + packageType: 'jdk', + checkLatest: false + }); + distribution['getAvailableVersions'] = async () => manifestData; + const result = await distribution['findPackageForDownload']('17.0.10'); + + expect(result.checksum).toBeUndefined(); + expect(core.debug).toHaveBeenCalledWith( + expect.stringContaining('No authoritative sha256 checksum') + ); }); it('should throw an error', async () => { diff --git a/dist/setup/index.js b/dist/setup/index.js index bd129e37..09aad7f9 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -12040,7 +12040,7 @@ exports.NodeListStaticImpl = NodeListStaticImpl; /***/ }), -/***/ 2256: +/***/ 9875: /***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { @@ -13485,7 +13485,7 @@ const NodeListImpl_1 = __nccwpck_require__(5788); Object.defineProperty(exports, "NodeList", ({ enumerable: true, get: function () { return NodeListImpl_1.NodeListImpl; } })); const NodeListStaticImpl_1 = __nccwpck_require__(7654); Object.defineProperty(exports, "NodeListStatic", ({ enumerable: true, get: function () { return NodeListStaticImpl_1.NodeListStaticImpl; } })); -const NonDocumentTypeChildNodeImpl_1 = __nccwpck_require__(2256); +const NonDocumentTypeChildNodeImpl_1 = __nccwpck_require__(9875); const NonElementParentNodeImpl_1 = __nccwpck_require__(5325); const ParentNodeImpl_1 = __nccwpck_require__(1824); const ProcessingInstructionImpl_1 = __nccwpck_require__(2755); @@ -129411,6 +129411,58 @@ function retrying_http_client_getErrorMessage(error) { return error instanceof Error ? error.message : 'network error'; } +;// CONCATENATED MODULE: external "stream/promises" +const promises_namespaceObject = __WEBPACK_EXTERNAL_createRequire(import.meta.url)("stream/promises"); +;// CONCATENATED MODULE: ./src/checksum.ts + + + +function sanitizedSource(source) { + if (!source) { + return ''; + } + try { + const url = new URL(source); + return ` from ${url.origin}${url.pathname}`; + } + catch { + return ' from an invalid checksum source'; + } +} +// Length, in hex characters, of a digest produced by each supported algorithm. +// Exported so callers (e.g. fetchChecksum) can infer which algorithm a vendor +// actually used when it doesn't disclose it via the checksum URL/filename. +function expectedDigestLength(algorithm) { + return algorithm === 'sha256' ? 64 : algorithm === 'sha512' ? 128 : 0; +} +function normalizeExpectedDigest(checksum) { + const algorithm = checksum.algorithm; + const digest = typeof checksum.value === 'string' + ? checksum.value.trim().toLowerCase() + : ''; + const expectedLength = expectedDigestLength(algorithm); + if (expectedLength === 0) { + throw new Error(`Unsupported checksum algorithm '${String(algorithm)}'${sanitizedSource(checksum.source)}. Supported algorithms are sha256 and sha512.`); + } + if (!new RegExp(`^[a-f0-9]{${expectedLength}}$`).test(digest)) { + throw new Error(`Malformed ${algorithm} checksum metadata${sanitizedSource(checksum.source)}: expected a ${expectedLength}-character hexadecimal digest.`); + } + return digest; +} +async function calculateChecksum(filePath, algorithm) { + const hash = (0,external_crypto_namespaceObject.createHash)(algorithm); + await (0,promises_namespaceObject.pipeline)((0,external_fs_namespaceObject.createReadStream)(filePath), hash); + return hash.digest('hex'); +} +async function verifyChecksum(filePath, checksum, context) { + const expected = normalizeExpectedDigest(checksum); + const actual = await calculateChecksum(filePath, checksum.algorithm); + const matches = (0,external_crypto_namespaceObject.timingSafeEqual)(Buffer.from(expected, 'hex'), Buffer.from(actual, 'hex')); + if (!matches) { + throw new Error(`Checksum verification failed for ${context.distribution} version ${context.version}: ${checksum.algorithm} expected ${expected}, actual ${actual}.`); + } +} + ;// CONCATENATED MODULE: ./src/distributions/base-installer.ts @@ -129422,6 +129474,7 @@ function retrying_http_client_getErrorMessage(error) { + class JavaBase { distribution; http; @@ -129454,6 +129507,76 @@ class JavaBase { this.verifySignature = installerOptions.verifySignature ?? false; this.verifySignaturePublicKey = installerOptions.verifySignaturePublicKey; } + async downloadAndVerify(javaRelease) { + const archivePath = await downloadTool(javaRelease.url); + const checksum = javaRelease.checksum; + if (!checksum || !checksum.value?.trim()) { + core_debug(`No authoritative checksum is available for ${this.distribution} version ${javaRelease.version}; skipping checksum verification.`); + return archivePath; + } + try { + await verifyChecksum(archivePath, checksum, { + distribution: this.distribution, + version: javaRelease.version + }); + core_debug(`Verified ${checksum.algorithm} checksum for ${this.distribution} version ${javaRelease.version}.`); + return archivePath; + } + catch (error) { + let cleanupError; + let cleanupFailed = false; + try { + await external_fs_namespaceObject.promises.rm(archivePath, { force: true }); + } + catch (caughtCleanupError) { + cleanupError = caughtCleanupError; + cleanupFailed = true; + } + if (cleanupFailed) { + throw new Error(`${error.message} Failed to remove the downloaded archive after verification failure: ${cleanupError.message}`, { cause: error }); + } + throw error; + } + } + async fetchChecksum(checksumUrl, algorithm) { + // Some vendors (e.g. JetBrains) publish a single, generically-named + // checksum sibling (`.checksum`) whose digest algorithm isn't disclosed + // by the URL and has changed across releases. Accepting a list of + // candidate algorithms lets callers pass every algorithm the vendor is + // known to use; the actual algorithm is then inferred from the length of + // the returned digest. + const algorithms = Array.isArray(algorithm) ? algorithm : [algorithm]; + const algorithmLabel = algorithms.join(' or '); + const response = await this.http.get(checksumUrl); + const statusCode = response.message.statusCode; + const source = (() => { + try { + const url = new URL(checksumUrl); + return `${url.origin}${url.pathname}`; + } + catch { + return 'an invalid checksum URL'; + } + })(); + if (statusCode === HttpCodes.NotFound) { + core_debug(`No authoritative ${algorithmLabel} checksum is available for ${this.distribution} from ${source}; skipping checksum verification.`); + return undefined; + } + if (statusCode !== HttpCodes.OK) { + throw new Error(`Failed to fetch the authoritative ${algorithmLabel} checksum for ${this.distribution} from ${source} (HTTP ${statusCode}).`); + } + const body = await response.readBody(); + const value = body.trim().split(/\s+/, 1)[0] ?? ''; + if (!value) { + throw new Error(`Received an empty authoritative ${algorithmLabel} checksum for ${this.distribution} from ${source}.`); + } + // Prefer the strongest algorithm whose digest length matches what was + // actually returned; fall back to the first candidate (preserving prior + // behavior/error messages) when the digest doesn't match any of them. + const resolvedAlgorithm = algorithms.find(algo => value.length === expectedDigestLength(algo)) ?? + algorithms[0]; + return { algorithm: resolvedAlgorithm, value, source: checksumUrl }; + } async setupJava() { if (this.verifySignature && !this.supportsSignatureVerification()) { throw new Error(`Input 'verify-signature' is not supported for distribution '${this.distribution}'.`); @@ -129808,7 +129931,8 @@ class ZuluDistribution extends JavaBase { return { version: convertVersionToSemver(javaVersion), url: item.download_url, - zuluVersion: convertVersionToSemver(item.distro_version) + zuluVersion: convertVersionToSemver(item.distro_version), + packageUuid: item.package_uuid }; }); const satisfiedVersions = availableVersions @@ -129819,22 +129943,37 @@ class ZuluDistribution extends JavaBase { return (-semver_default().compareBuild(a.version, b.version) || -semver_default().compareBuild(a.zuluVersion, b.zuluVersion)); }) - .map(item => { - return { - version: item.version, - url: item.url - }; - }); + .map((item) => ({ + version: item.version, + url: item.url, + packageUuid: item.packageUuid + })); const resolvedFullVersion = satisfiedVersions.length > 0 ? satisfiedVersions[0] : null; if (!resolvedFullVersion) { const availableVersionStrings = availableVersions.map(item => item.version); throw this.createVersionNotFoundError(version, availableVersionStrings); } - return resolvedFullVersion; + const packageDetailsUrl = `https://api.azul.com/metadata/v1/zulu/packages/${resolvedFullVersion.packageUuid}`; + const packageDetails = (await this.http.getJson(packageDetailsUrl)).result; + const digest = packageDetails?.sha256_hash?.match(/^[a-f0-9]{64}$/i)?.[0]; + if (!digest) { + core_debug(`No authoritative sha256 checksum is available for Zulu version ${resolvedFullVersion.version} from ${packageDetailsUrl}; skipping checksum verification.`); + } + return { + version: resolvedFullVersion.version, + url: resolvedFullVersion.url, + checksum: digest + ? { + algorithm: 'sha256', + value: digest, + source: packageDetailsUrl + } + : undefined + }; } async downloadTool(javaRelease) { info(`Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...`); - let javaArchivePath = await downloadTool(javaRelease.url); + let javaArchivePath = await this.downloadAndVerify(javaRelease); info(`Extracting Java archive...`); const extension = getDownloadArchiveExtension(); if (process.platform === 'win32') { @@ -130017,7 +130156,12 @@ class TemurinDistribution extends JavaBase { return { version: formattedVersion, url: item.binaries[0].package.link, - signatureUrl: item.binaries[0].package.signature_link + signatureUrl: item.binaries[0].package.signature_link, + checksum: { + algorithm: 'sha256', + value: item.binaries[0].package.checksum, + source: item.binaries[0].package.checksum_link + } }; }); const satisfiedVersions = availableVersionsWithBinaries @@ -130057,7 +130201,7 @@ class TemurinDistribution extends JavaBase { return true; } async downloadPackage(release) { - const archivePath = await downloadTool(release.url); + const archivePath = await this.downloadAndVerify(release); if (this.verifySignature) { if (!release.signatureUrl) { throw new Error(`Input 'verify-signature' is enabled, but no signature URL was found for Temurin version ${release.version}.`); @@ -130222,7 +130366,12 @@ class AdoptDistribution extends JavaBase { .map(item => { return { version: item.version_data.semver, - url: item.binaries[0].package.link + url: item.binaries[0].package.link, + checksum: { + algorithm: 'sha256', + value: item.binaries[0].package.checksum, + source: item.binaries[0].package.checksum_link + } }; }); const satisfiedVersions = availableVersionsWithBinaries @@ -130239,7 +130388,7 @@ class AdoptDistribution extends JavaBase { } async downloadTool(javaRelease) { info(`Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...`); - let javaArchivePath = await downloadTool(javaRelease.url); + let javaArchivePath = await this.downloadAndVerify(javaRelease); info(`Extracting Java archive...`); const extension = getDownloadArchiveExtension(); if (process.platform === 'win32') { @@ -130348,7 +130497,7 @@ class LibericaDistributions extends JavaBase { } async downloadTool(javaRelease) { info(`Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...`); - let javaArchivePath = await downloadTool(javaRelease.url); + let javaArchivePath = await this.downloadAndVerify(javaRelease); info(`Extracting Java archive...`); const extension = getDownloadArchiveExtension(); if (process.platform === 'win32') { @@ -130477,7 +130626,7 @@ class LibericaNikDistributions extends JavaBase { } async downloadTool(javaRelease) { info(`Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...`); - let javaArchivePath = await downloadTool(javaRelease.url); + let javaArchivePath = await this.downloadAndVerify(javaRelease); info(`Extracting Java archive...`); const extension = getDownloadArchiveExtension(); if (process.platform === 'win32') { @@ -130623,7 +130772,7 @@ class MicrosoftDistributions extends JavaBase { } async downloadTool(javaRelease) { info(`Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...`); - let javaArchivePath = await downloadTool(javaRelease.url); + let javaArchivePath = await this.downloadAndVerify(javaRelease); if (this.verifySignature) { if (!javaRelease.signatureUrl) { throw new Error(`Input 'verify-signature' is enabled, but no signature URL was found for Microsoft Build of OpenJDK version ${javaRelease.version}.`); @@ -130672,7 +130821,8 @@ class MicrosoftDistributions extends JavaBase { return { url: file.download_url, signatureUrl, - version: foundRelease.version + version: foundRelease.version, + checksum: await this.fetchChecksum(`${file.download_url}.sha256sum.txt`, 'sha256') }; } supportsSignatureVerification() { @@ -130757,7 +130907,12 @@ class SemeruDistribution extends JavaBase { : item.version_data.semver.replace('-beta+', '+'); return { version: formattedVersion, - url: item.binaries[0].package.link + url: item.binaries[0].package.link, + checksum: { + algorithm: 'sha256', + value: item.binaries[0].package.checksum, + source: item.binaries[0].package.checksum_link + } }; }); const satisfiedVersions = availableVersionsWithBinaries @@ -130777,7 +130932,7 @@ class SemeruDistribution extends JavaBase { } async downloadTool(javaRelease) { info(`Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...`); - let javaArchivePath = await downloadTool(javaRelease.url); + let javaArchivePath = await this.downloadAndVerify(javaRelease); info(`Extracting Java archive...`); const extension = getDownloadArchiveExtension(); if (process.platform === 'win32') { @@ -130872,13 +131027,14 @@ class SemeruDistribution extends JavaBase { +const CORRETTO_VERSIONS_URL = 'https://corretto.github.io/corretto-downloads/latest_links/indexmap_with_checksum.json'; class CorrettoDistribution extends JavaBase { constructor(installerOptions) { super('Corretto', installerOptions); } async downloadTool(javaRelease) { info(`Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...`); - let javaArchivePath = await downloadTool(javaRelease.url); + let javaArchivePath = await this.downloadAndVerify(javaRelease); info(`Extracting Java archive...`); const extension = getDownloadArchiveExtension(); if (process.platform === 'win32') { @@ -130916,7 +131072,12 @@ class CorrettoDistribution extends JavaBase { .map(item => { return { version: convertVersionToSemver(item.correttoVersion), - url: item.downloadLink + url: item.downloadLink, + checksum: { + algorithm: 'sha256', + value: item.checksum_sha256, + source: CORRETTO_VERSIONS_URL + } }; }); const resolvedVersion = matchingVersions.length > 0 ? matchingVersions[0] : null; @@ -130933,11 +131094,10 @@ class CorrettoDistribution extends JavaBase { if (isDebug()) { console.time('Retrieving available versions for Corretto took'); // eslint-disable-line no-console } - const availableVersionsUrl = 'https://corretto.github.io/corretto-downloads/latest_links/indexmap_with_checksum.json'; - const fetchCurrentVersions = await this.http.getJson(availableVersionsUrl); + const fetchCurrentVersions = await this.http.getJson(CORRETTO_VERSIONS_URL); const fetchedCurrentVersions = fetchCurrentVersions.result; if (!fetchedCurrentVersions) { - throw Error(`Could not fetch latest corretto versions from ${availableVersionsUrl}`); + throw Error(`Could not fetch latest corretto versions from ${CORRETTO_VERSIONS_URL}`); } const eligibleVersions = fetchedCurrentVersions?.[platform]?.[arch]?.[imageType]; const availableVersions = this.getAvailableVersionsForPlatform(eligibleVersions); @@ -131012,7 +131172,7 @@ class OracleDistribution extends JavaBase { } async downloadTool(javaRelease) { info(`Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...`); - let javaArchivePath = await downloadTool(javaRelease.url); + let javaArchivePath = await this.downloadAndVerify(javaRelease); info(`Extracting Java archive...`); const extension = getDownloadArchiveExtension(); if (process.platform === 'win32') { @@ -131065,7 +131225,11 @@ class OracleDistribution extends JavaBase { for (const url of possibleUrls) { const response = await this.http.head(url); if (response.message.statusCode === HttpCodes.OK) { - return { url, version: range }; + return { + url, + version: range, + checksum: await this.fetchChecksum(`${url}.sha256`, 'sha256') + }; } if (response.message.statusCode !== HttpCodes.NotFound) { throw new Error(`Http request for Oracle JDK failed with status code: ${response.message.statusCode}`); @@ -131119,7 +131283,13 @@ class DragonwellDistribution extends JavaBase { .map(item => { return { version: item.jdk_version, - url: item.download_link + url: item.download_link, + checksum: item.checksum + ? { + algorithm: 'sha256', + value: item.checksum + } + : undefined }; }); if (!matchedVersions.length) { @@ -131150,7 +131320,7 @@ class DragonwellDistribution extends JavaBase { } async downloadTool(javaRelease) { info(`Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...`); - let javaArchivePath = await downloadTool(javaRelease.url); + let javaArchivePath = await this.downloadAndVerify(javaRelease); info(`Extracting Java archive...`); const extension = getDownloadArchiveExtension(); if (process.platform === 'win32') { @@ -131284,7 +131454,11 @@ class SapMachineDistribution extends JavaBase { throw this.createVersionNotFoundError(version, availableVersionStrings); } const resolvedVersion = matchedVersions[0]; - return resolvedVersion; + const checksumUrl = resolvedVersion.url.replace(/\.(?:tar\.gz|zip)$/, '.sha256.txt'); + return { + ...resolvedVersion, + checksum: await this.fetchChecksum(checksumUrl, 'sha256') + }; } async getAvailableVersions() { const platform = this.getPlatformOption(); @@ -131307,7 +131481,7 @@ class SapMachineDistribution extends JavaBase { } async downloadTool(javaRelease) { info(`Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...`); - let javaArchivePath = await downloadTool(javaRelease.url); + let javaArchivePath = await this.downloadAndVerify(javaRelease); info(`Extracting Java archive...`); const extension = getDownloadArchiveExtension(); if (process.platform === 'win32') { @@ -131443,7 +131617,7 @@ class GraalVMDistribution extends JavaBase { async downloadTool(javaRelease) { try { info(`Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...`); - let javaArchivePath = await downloadTool(javaRelease.url); + let javaArchivePath = await this.downloadAndVerify(javaRelease); info(`Extracting Java archive...`); const extension = getDownloadArchiveExtension(); if (installer_IS_WINDOWS) { @@ -131488,7 +131662,11 @@ class GraalVMDistribution extends JavaBase { const fileUrl = this.constructFileUrl(range, major, platform, arch, extension); const response = await this.http.head(fileUrl); this.handleHttpResponse(response, range); - return { url: fileUrl, version: range }; + return { + url: fileUrl, + version: range, + checksum: await this.fetchChecksum(`${fileUrl}.sha256`, 'sha256') + }; } validateVersionRange(range) { if (!range || typeof range !== 'string') { @@ -131569,7 +131747,8 @@ class GraalVMDistribution extends JavaBase { core_debug(`Download URL: ${downloadUrl}`); return { url: downloadUrl, - version: latestVersion.version + version: latestVersion.version, + checksum: await this.fetchChecksum(`${downloadUrl}.sha256`, 'sha256') }; } async fetchEAJson(javaEaVersion) { @@ -131681,9 +131860,20 @@ class GraalVMCommunityDistribution extends GraalVMDistribution { for (const asset of release.assets ?? []) { const version = this.extractAssetVersion(asset.name, assetSuffix); if (version) { + const digest = asset.digest?.match(/^sha256:([a-f0-9]{64})$/i)?.[1]; + if (!digest) { + core_debug(`No authoritative sha256 digest is available for ${asset.name}; skipping checksum verification for this asset.`); + } versions.set(version, { version, - url: asset.browser_download_url + url: asset.browser_download_url, + checksum: digest + ? { + algorithm: 'sha256', + value: digest, + source: GRAALVM_COMMUNITY_RELEASES_URL + } + : undefined }); } } @@ -131748,11 +131938,18 @@ class JetBrainsDistribution extends JavaBase { const availableVersionStrings = versionsRaw.map(item => `${item.tag_name} (${item.semver}+${item.build})`); throw this.createVersionNotFoundError(range, availableVersionStrings); } - return resolvedFullVersion; + return { + ...resolvedFullVersion, + // JetBrains' `.checksum` sibling doesn't disclose its algorithm via the + // filename, and older JBR builds (e.g. JBR 11) publish a SHA-256 digest + // there while newer builds publish SHA-512. Accept either, preferring + // the stronger SHA-512 when the digest length is ambiguous. + checksum: await this.fetchChecksum(`${resolvedFullVersion.url}.checksum`, ['sha512', 'sha256']) + }; } async downloadTool(javaRelease) { info(`Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...`); - const javaArchivePath = await downloadTool(javaRelease.url); + const javaArchivePath = await this.downloadAndVerify(javaRelease); info(`Extracting Java archive...`); const extractedJavaPath = await extractJdkFile(javaArchivePath, 'tar.gz'); const archiveName = external_fs_default().readdirSync(extractedJavaPath)[0]; @@ -131899,13 +132096,14 @@ class JetBrainsDistribution extends JavaBase { +const KONA_RELEASES_URL = 'https://tencent.github.io/konajdk/releases/kona-v1.json'; class KonaDistribution extends JavaBase { constructor(installerOptions) { super('Kona', installerOptions); } async downloadTool(javaRelease) { info(`Downloading Kona JDK ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...`); - const javaArchivePath = await downloadTool(javaRelease.url); + const javaArchivePath = await this.downloadAndVerify(javaRelease); info(`Extracting Java archive...`); const extension = getDownloadArchiveExtension(); const archivePath = process.platform === 'win32' @@ -131933,7 +132131,14 @@ class KonaDistribution extends JavaBase { .map(item => { return { version: item.version, - url: item.downloadUrl + url: item.downloadUrl, + checksum: item.checksum + ? { + algorithm: 'sha256', + value: item.checksum, + source: KONA_RELEASES_URL + } + : undefined }; }) .sort((a, b) => -semver_default().compareBuild(a.version, b.version)); @@ -131960,14 +132165,13 @@ class KonaDistribution extends JavaBase { return availableReleases; } async fetchReleaseInfo() { - const releasesInfoUrl = 'https://tencent.github.io/konajdk/releases/kona-v1.json'; try { - core_debug(`Fetching Kona release info from URL: ${releasesInfoUrl}`); - return (await this.http.getJson(releasesInfoUrl)) + core_debug(`Fetching Kona release info from URL: ${KONA_RELEASES_URL}`); + return (await this.http.getJson(KONA_RELEASES_URL)) .result; } catch (err) { - core_debug(`Fetching Kona release info from the URL: ${releasesInfoUrl} failed with the error: ${err.message}`); + core_debug(`Fetching Kona release info from the URL: ${KONA_RELEASES_URL} failed with the error: ${err.message}`); return null; } } @@ -132047,11 +132251,15 @@ class OpenJdkDistribution extends JavaBase { if (!matchingReleases.length) { throw this.createVersionNotFoundError(range, releases.map(release => release.version), `Platform: ${platform}`); } - return matchingReleases[0]; + const release = matchingReleases[0]; + return { + ...release, + checksum: await this.fetchChecksum(`${release.url}.sha256`, 'sha256') + }; } async downloadTool(javaRelease) { info(`Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...`); - let javaArchivePath = await downloadTool(javaRelease.url); + let javaArchivePath = await this.downloadAndVerify(javaRelease); info(`Extracting Java archive...`); const extension = javaRelease.url.endsWith('.zip') ? 'zip' : 'tar.gz'; if (extension === 'zip') { diff --git a/src/checksum.ts b/src/checksum.ts new file mode 100644 index 00000000..cd0b8c84 --- /dev/null +++ b/src/checksum.ts @@ -0,0 +1,83 @@ +import {createHash, timingSafeEqual} from 'crypto'; +import {createReadStream} from 'fs'; +import {pipeline} from 'stream/promises'; + +import {ChecksumMetadata} from './distributions/base-models.js'; + +export interface ChecksumVerificationContext { + distribution: string; + version: string; +} + +function sanitizedSource(source: string | undefined): string { + if (!source) { + return ''; + } + + try { + const url = new URL(source); + return ` from ${url.origin}${url.pathname}`; + } catch { + return ' from an invalid checksum source'; + } +} + +// Length, in hex characters, of a digest produced by each supported algorithm. +// Exported so callers (e.g. fetchChecksum) can infer which algorithm a vendor +// actually used when it doesn't disclose it via the checksum URL/filename. +export function expectedDigestLength( + algorithm: ChecksumMetadata['algorithm'] +): number { + return algorithm === 'sha256' ? 64 : algorithm === 'sha512' ? 128 : 0; +} + +function normalizeExpectedDigest(checksum: ChecksumMetadata): string { + const algorithm = checksum.algorithm; + const digest = + typeof checksum.value === 'string' + ? checksum.value.trim().toLowerCase() + : ''; + const expectedLength = expectedDigestLength(algorithm); + + if (expectedLength === 0) { + throw new Error( + `Unsupported checksum algorithm '${String(algorithm)}'${sanitizedSource(checksum.source)}. Supported algorithms are sha256 and sha512.` + ); + } + + if (!new RegExp(`^[a-f0-9]{${expectedLength}}$`).test(digest)) { + throw new Error( + `Malformed ${algorithm} checksum metadata${sanitizedSource(checksum.source)}: expected a ${expectedLength}-character hexadecimal digest.` + ); + } + + return digest; +} + +export async function calculateChecksum( + filePath: string, + algorithm: ChecksumMetadata['algorithm'] +): Promise { + const hash = createHash(algorithm); + await pipeline(createReadStream(filePath), hash); + return hash.digest('hex'); +} + +export async function verifyChecksum( + filePath: string, + checksum: ChecksumMetadata, + context: ChecksumVerificationContext +): Promise { + const expected = normalizeExpectedDigest(checksum); + const actual = await calculateChecksum(filePath, checksum.algorithm); + const matches = timingSafeEqual( + Buffer.from(expected, 'hex'), + Buffer.from(actual, 'hex') + ); + + if (!matches) { + throw new Error( + `Checksum verification failed for ${context.distribution} version ${context.version}: ${checksum.algorithm} expected ${expected}, actual ${actual}.` + ); + } +} diff --git a/src/distributions/adopt/installer.ts b/src/distributions/adopt/installer.ts index c02df473..84bb4f2c 100644 --- a/src/distributions/adopt/installer.ts +++ b/src/distributions/adopt/installer.ts @@ -105,7 +105,12 @@ export class AdoptDistribution extends JavaBase { .map(item => { return { version: item.version_data.semver, - url: item.binaries[0].package.link + url: item.binaries[0].package.link, + checksum: { + algorithm: 'sha256', + value: item.binaries[0].package.checksum, + source: item.binaries[0].package.checksum_link + } } as JavaDownloadRelease; }); @@ -133,7 +138,7 @@ export class AdoptDistribution extends JavaBase { core.info( `Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...` ); - let javaArchivePath = await tc.downloadTool(javaRelease.url); + let javaArchivePath = await this.downloadAndVerify(javaRelease); core.info(`Extracting Java archive...`); const extension = getDownloadArchiveExtension(); diff --git a/src/distributions/base-installer.ts b/src/distributions/base-installer.ts index f9cb55c4..2c8753fb 100644 --- a/src/distributions/base-installer.ts +++ b/src/distributions/base-installer.ts @@ -10,6 +10,8 @@ import { isVersionSatisfies } from '../util.js'; import { + ChecksumAlgorithm, + ChecksumMetadata, JavaDownloadRelease, JavaInstallerOptions, JavaInstallerResults @@ -17,6 +19,7 @@ import { import {MACOS_JAVA_CONTENT_POSTFIX} from '../constants.js'; import {RetryingHttpClient} from '../retrying-http-client.js'; import os from 'os'; +import {expectedDigestLength, verifyChecksum} from '../checksum.js'; export abstract class JavaBase { protected http: httpm.HttpClient; @@ -61,6 +64,101 @@ export abstract class JavaBase { range: string ): Promise; + protected async downloadAndVerify( + javaRelease: JavaDownloadRelease + ): Promise { + const archivePath = await tc.downloadTool(javaRelease.url); + const checksum = javaRelease.checksum; + if (!checksum || !checksum.value?.trim()) { + core.debug( + `No authoritative checksum is available for ${this.distribution} version ${javaRelease.version}; skipping checksum verification.` + ); + return archivePath; + } + + try { + await verifyChecksum(archivePath, checksum, { + distribution: this.distribution, + version: javaRelease.version + }); + core.debug( + `Verified ${checksum.algorithm} checksum for ${this.distribution} version ${javaRelease.version}.` + ); + return archivePath; + } catch (error) { + let cleanupError: unknown; + let cleanupFailed = false; + try { + await fs.promises.rm(archivePath, {force: true}); + } catch (caughtCleanupError) { + cleanupError = caughtCleanupError; + cleanupFailed = true; + } + if (cleanupFailed) { + throw new Error( + `${(error as Error).message} Failed to remove the downloaded archive after verification failure: ${(cleanupError as Error).message}`, + {cause: error} + ); + } + throw error; + } + } + + protected async fetchChecksum( + checksumUrl: string, + algorithm: ChecksumAlgorithm | ChecksumAlgorithm[] + ): Promise { + // Some vendors (e.g. JetBrains) publish a single, generically-named + // checksum sibling (`.checksum`) whose digest algorithm isn't disclosed + // by the URL and has changed across releases. Accepting a list of + // candidate algorithms lets callers pass every algorithm the vendor is + // known to use; the actual algorithm is then inferred from the length of + // the returned digest. + const algorithms = Array.isArray(algorithm) ? algorithm : [algorithm]; + const algorithmLabel = algorithms.join(' or '); + + const response = await this.http.get(checksumUrl); + const statusCode = response.message.statusCode; + const source = (() => { + try { + const url = new URL(checksumUrl); + return `${url.origin}${url.pathname}`; + } catch { + return 'an invalid checksum URL'; + } + })(); + + if (statusCode === httpm.HttpCodes.NotFound) { + core.debug( + `No authoritative ${algorithmLabel} checksum is available for ${this.distribution} from ${source}; skipping checksum verification.` + ); + return undefined; + } + + if (statusCode !== httpm.HttpCodes.OK) { + throw new Error( + `Failed to fetch the authoritative ${algorithmLabel} checksum for ${this.distribution} from ${source} (HTTP ${statusCode}).` + ); + } + + const body = await response.readBody(); + const value = body.trim().split(/\s+/, 1)[0] ?? ''; + if (!value) { + throw new Error( + `Received an empty authoritative ${algorithmLabel} checksum for ${this.distribution} from ${source}.` + ); + } + + // Prefer the strongest algorithm whose digest length matches what was + // actually returned; fall back to the first candidate (preserving prior + // behavior/error messages) when the digest doesn't match any of them. + const resolvedAlgorithm = + algorithms.find(algo => value.length === expectedDigestLength(algo)) ?? + algorithms[0]; + + return {algorithm: resolvedAlgorithm, value, source: checksumUrl}; + } + public async setupJava(): Promise { if (this.verifySignature && !this.supportsSignatureVerification()) { throw new Error( diff --git a/src/distributions/base-models.ts b/src/distributions/base-models.ts index 3bfe4bb9..2bec34f4 100644 --- a/src/distributions/base-models.ts +++ b/src/distributions/base-models.ts @@ -14,8 +14,17 @@ export interface JavaInstallerResults { path: string; } +export type ChecksumAlgorithm = 'sha256' | 'sha512'; + +export interface ChecksumMetadata { + algorithm: ChecksumAlgorithm; + value: string; + source?: string; +} + export interface JavaDownloadRelease { version: string; url: string; signatureUrl?: string; + checksum?: ChecksumMetadata; } diff --git a/src/distributions/corretto/installer.ts b/src/distributions/corretto/installer.ts index 66516a22..06542000 100644 --- a/src/distributions/corretto/installer.ts +++ b/src/distributions/corretto/installer.ts @@ -19,6 +19,9 @@ import { ICorrettoAvailableVersions } from './models.js'; +const CORRETTO_VERSIONS_URL = + 'https://corretto.github.io/corretto-downloads/latest_links/indexmap_with_checksum.json'; + export class CorrettoDistribution extends JavaBase { constructor(installerOptions: JavaInstallerOptions) { super('Corretto', installerOptions); @@ -30,7 +33,7 @@ export class CorrettoDistribution extends JavaBase { core.info( `Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...` ); - let javaArchivePath = await tc.downloadTool(javaRelease.url); + let javaArchivePath = await this.downloadAndVerify(javaRelease); core.info(`Extracting Java archive...`); const extension = getDownloadArchiveExtension(); @@ -86,7 +89,12 @@ export class CorrettoDistribution extends JavaBase { .map(item => { return { version: convertVersionToSemver(item.correttoVersion), - url: item.downloadLink + url: item.downloadLink, + checksum: { + algorithm: 'sha256', + value: item.checksum_sha256, + source: CORRETTO_VERSIONS_URL + } } as JavaDownloadRelease; }); @@ -110,16 +118,14 @@ export class CorrettoDistribution extends JavaBase { console.time('Retrieving available versions for Corretto took'); // eslint-disable-line no-console } - const availableVersionsUrl = - 'https://corretto.github.io/corretto-downloads/latest_links/indexmap_with_checksum.json'; const fetchCurrentVersions = await this.http.getJson( - availableVersionsUrl + CORRETTO_VERSIONS_URL ); const fetchedCurrentVersions = fetchCurrentVersions.result; if (!fetchedCurrentVersions) { throw Error( - `Could not fetch latest corretto versions from ${availableVersionsUrl}` + `Could not fetch latest corretto versions from ${CORRETTO_VERSIONS_URL}` ); } diff --git a/src/distributions/dragonwell/installer.ts b/src/distributions/dragonwell/installer.ts index 1f8df52b..ad042a4d 100644 --- a/src/distributions/dragonwell/installer.ts +++ b/src/distributions/dragonwell/installer.ts @@ -46,7 +46,13 @@ export class DragonwellDistribution extends JavaBase { .map(item => { return { version: item.jdk_version, - url: item.download_link + url: item.download_link, + checksum: item.checksum + ? { + algorithm: 'sha256', + value: item.checksum + } + : undefined } as JavaDownloadRelease; }); @@ -102,7 +108,7 @@ export class DragonwellDistribution extends JavaBase { core.info( `Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...` ); - let javaArchivePath = await tc.downloadTool(javaRelease.url); + let javaArchivePath = await this.downloadAndVerify(javaRelease); core.info(`Extracting Java archive...`); const extension = getDownloadArchiveExtension(); diff --git a/src/distributions/graalvm/installer.ts b/src/distributions/graalvm/installer.ts index d2255a22..319a4559 100644 --- a/src/distributions/graalvm/installer.ts +++ b/src/distributions/graalvm/installer.ts @@ -43,6 +43,7 @@ type OsVersions = 'linux' | 'macos' | 'windows'; interface GraalVMCommunityAsset { name: string; browser_download_url: string; + digest?: string; } interface GraalVMCommunityRelease { @@ -66,7 +67,7 @@ export class GraalVMDistribution extends JavaBase { core.info( `Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...` ); - let javaArchivePath = await tc.downloadTool(javaRelease.url); + let javaArchivePath = await this.downloadAndVerify(javaRelease); core.info(`Extracting Java archive...`); const extension = getDownloadArchiveExtension(); @@ -145,7 +146,11 @@ export class GraalVMDistribution extends JavaBase { const response = await this.http.head(fileUrl); this.handleHttpResponse(response, range); - return {url: fileUrl, version: range}; + return { + url: fileUrl, + version: range, + checksum: await this.fetchChecksum(`${fileUrl}.sha256`, 'sha256') + }; } protected validateVersionRange(range: string): void { @@ -284,7 +289,8 @@ export class GraalVMDistribution extends JavaBase { return { url: downloadUrl, - version: latestVersion.version + version: latestVersion.version, + checksum: await this.fetchChecksum(`${downloadUrl}.sha256`, 'sha256') }; } @@ -456,9 +462,22 @@ export class GraalVMCommunityDistribution extends GraalVMDistribution { for (const asset of release.assets ?? []) { const version = this.extractAssetVersion(asset.name, assetSuffix); if (version) { + const digest = asset.digest?.match(/^sha256:([a-f0-9]{64})$/i)?.[1]; + if (!digest) { + core.debug( + `No authoritative sha256 digest is available for ${asset.name}; skipping checksum verification for this asset.` + ); + } versions.set(version, { version, - url: asset.browser_download_url + url: asset.browser_download_url, + checksum: digest + ? { + algorithm: 'sha256', + value: digest, + source: GRAALVM_COMMUNITY_RELEASES_URL + } + : undefined }); } } diff --git a/src/distributions/jetbrains/installer.ts b/src/distributions/jetbrains/installer.ts index a93e43b5..fdd09917 100644 --- a/src/distributions/jetbrains/installer.ts +++ b/src/distributions/jetbrains/installer.ts @@ -50,7 +50,17 @@ export class JetBrainsDistribution extends JavaBase { throw this.createVersionNotFoundError(range, availableVersionStrings); } - return resolvedFullVersion; + return { + ...resolvedFullVersion, + // JetBrains' `.checksum` sibling doesn't disclose its algorithm via the + // filename, and older JBR builds (e.g. JBR 11) publish a SHA-256 digest + // there while newer builds publish SHA-512. Accept either, preferring + // the stronger SHA-512 when the digest length is ambiguous. + checksum: await this.fetchChecksum( + `${resolvedFullVersion.url}.checksum`, + ['sha512', 'sha256'] + ) + }; } protected async downloadTool( @@ -60,7 +70,7 @@ export class JetBrainsDistribution extends JavaBase { `Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...` ); - const javaArchivePath = await tc.downloadTool(javaRelease.url); + const javaArchivePath = await this.downloadAndVerify(javaRelease); core.info(`Extracting Java archive...`); const extractedJavaPath = await extractJdkFile(javaArchivePath, 'tar.gz'); diff --git a/src/distributions/kona/installer.ts b/src/distributions/kona/installer.ts index f7992f60..f45accd4 100644 --- a/src/distributions/kona/installer.ts +++ b/src/distributions/kona/installer.ts @@ -19,6 +19,9 @@ import { renameWinArchive } from '../../util.js'; +const KONA_RELEASES_URL = + 'https://tencent.github.io/konajdk/releases/kona-v1.json'; + export class KonaDistribution extends JavaBase { constructor(installerOptions: JavaInstallerOptions) { super('Kona', installerOptions); @@ -30,7 +33,7 @@ export class KonaDistribution extends JavaBase { core.info( `Downloading Kona JDK ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...` ); - const javaArchivePath = await tc.downloadTool(javaRelease.url); + const javaArchivePath = await this.downloadAndVerify(javaRelease); core.info(`Extracting Java archive...`); @@ -74,7 +77,14 @@ export class KonaDistribution extends JavaBase { .map(item => { return { version: item.version, - url: item.downloadUrl + url: item.downloadUrl, + checksum: item.checksum + ? { + algorithm: 'sha256', + value: item.checksum, + source: KONA_RELEASES_URL + } + : undefined } as JavaDownloadRelease; }) .sort((a, b) => -semver.compareBuild(a.version, b.version)); @@ -115,16 +125,13 @@ export class KonaDistribution extends JavaBase { } private async fetchReleaseInfo(): Promise { - const releasesInfoUrl = - 'https://tencent.github.io/konajdk/releases/kona-v1.json'; - try { - core.debug(`Fetching Kona release info from URL: ${releasesInfoUrl}`); - return (await this.http.getJson(releasesInfoUrl)) + core.debug(`Fetching Kona release info from URL: ${KONA_RELEASES_URL}`); + return (await this.http.getJson(KONA_RELEASES_URL)) .result; } catch (err) { core.debug( - `Fetching Kona release info from the URL: ${releasesInfoUrl} failed with the error: ${ + `Fetching Kona release info from the URL: ${KONA_RELEASES_URL} failed with the error: ${ (err as Error).message }` ); diff --git a/src/distributions/liberica-nik/installer.ts b/src/distributions/liberica-nik/installer.ts index 2187a3cf..d38af995 100644 --- a/src/distributions/liberica-nik/installer.ts +++ b/src/distributions/liberica-nik/installer.ts @@ -32,7 +32,7 @@ export class LibericaNikDistributions extends JavaBase { core.info( `Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...` ); - let javaArchivePath = await tc.downloadTool(javaRelease.url); + let javaArchivePath = await this.downloadAndVerify(javaRelease); core.info(`Extracting Java archive...`); const extension = getDownloadArchiveExtension(); diff --git a/src/distributions/liberica/installer.ts b/src/distributions/liberica/installer.ts index eb2254d2..3db9c620 100644 --- a/src/distributions/liberica/installer.ts +++ b/src/distributions/liberica/installer.ts @@ -32,7 +32,7 @@ export class LibericaDistributions extends JavaBase { core.info( `Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...` ); - let javaArchivePath = await tc.downloadTool(javaRelease.url); + let javaArchivePath = await this.downloadAndVerify(javaRelease); core.info(`Extracting Java archive...`); const extension = getDownloadArchiveExtension(); diff --git a/src/distributions/microsoft/installer.ts b/src/distributions/microsoft/installer.ts index 3a25186a..ddafa0f6 100644 --- a/src/distributions/microsoft/installer.ts +++ b/src/distributions/microsoft/installer.ts @@ -31,7 +31,7 @@ export class MicrosoftDistributions extends JavaBase { core.info( `Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...` ); - let javaArchivePath = await tc.downloadTool(javaRelease.url); + let javaArchivePath = await this.downloadAndVerify(javaRelease); if (this.verifySignature) { if (!javaRelease.signatureUrl) { @@ -114,7 +114,11 @@ export class MicrosoftDistributions extends JavaBase { return { url: file.download_url, signatureUrl, - version: foundRelease.version + version: foundRelease.version, + checksum: await this.fetchChecksum( + `${file.download_url}.sha256sum.txt`, + 'sha256' + ) }; } diff --git a/src/distributions/openjdk/installer.ts b/src/distributions/openjdk/installer.ts index 8c8abea6..6368429a 100644 --- a/src/distributions/openjdk/installer.ts +++ b/src/distributions/openjdk/installer.ts @@ -50,7 +50,11 @@ export class OpenJdkDistribution extends JavaBase { ); } - return matchingReleases[0]; + const release = matchingReleases[0]; + return { + ...release, + checksum: await this.fetchChecksum(`${release.url}.sha256`, 'sha256') + }; } protected async downloadTool( @@ -59,7 +63,7 @@ export class OpenJdkDistribution extends JavaBase { core.info( `Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...` ); - let javaArchivePath = await tc.downloadTool(javaRelease.url); + let javaArchivePath = await this.downloadAndVerify(javaRelease); core.info(`Extracting Java archive...`); const extension = javaRelease.url.endsWith('.zip') ? 'zip' : 'tar.gz'; diff --git a/src/distributions/oracle/installer.ts b/src/distributions/oracle/installer.ts index 55971870..27e21a3d 100644 --- a/src/distributions/oracle/installer.ts +++ b/src/distributions/oracle/installer.ts @@ -32,7 +32,7 @@ export class OracleDistribution extends JavaBase { core.info( `Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...` ); - let javaArchivePath = await tc.downloadTool(javaRelease.url); + let javaArchivePath = await this.downloadAndVerify(javaRelease); core.info(`Extracting Java archive...`); const extension = getDownloadArchiveExtension(); @@ -112,7 +112,11 @@ export class OracleDistribution extends JavaBase { const response = await this.http.head(url); if (response.message.statusCode === HttpCodes.OK) { - return {url, version: range}; + return { + url, + version: range, + checksum: await this.fetchChecksum(`${url}.sha256`, 'sha256') + }; } if (response.message.statusCode !== HttpCodes.NotFound) { diff --git a/src/distributions/sapmachine/installer.ts b/src/distributions/sapmachine/installer.ts index 6833067e..3d7030fb 100644 --- a/src/distributions/sapmachine/installer.ts +++ b/src/distributions/sapmachine/installer.ts @@ -56,7 +56,14 @@ export class SapMachineDistribution extends JavaBase { } const resolvedVersion = matchedVersions[0]; - return resolvedVersion; + const checksumUrl = resolvedVersion.url.replace( + /\.(?:tar\.gz|zip)$/, + '.sha256.txt' + ); + return { + ...resolvedVersion, + checksum: await this.fetchChecksum(checksumUrl, 'sha256') + }; } private async getAvailableVersions(): Promise { @@ -104,7 +111,7 @@ export class SapMachineDistribution extends JavaBase { core.info( `Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...` ); - let javaArchivePath = await tc.downloadTool(javaRelease.url); + let javaArchivePath = await this.downloadAndVerify(javaRelease); core.info(`Extracting Java archive...`); const extension = getDownloadArchiveExtension(); diff --git a/src/distributions/semeru/installer.ts b/src/distributions/semeru/installer.ts index 4f1ee758..2668a7fe 100644 --- a/src/distributions/semeru/installer.ts +++ b/src/distributions/semeru/installer.ts @@ -69,7 +69,12 @@ export class SemeruDistribution extends JavaBase { : item.version_data.semver.replace('-beta+', '+'); return { version: formattedVersion, - url: item.binaries[0].package.link + url: item.binaries[0].package.link, + checksum: { + algorithm: 'sha256', + value: item.binaries[0].package.checksum, + source: item.binaries[0].package.checksum_link + } } as JavaDownloadRelease; }); @@ -104,7 +109,7 @@ export class SemeruDistribution extends JavaBase { core.info( `Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...` ); - let javaArchivePath = await tc.downloadTool(javaRelease.url); + let javaArchivePath = await this.downloadAndVerify(javaRelease); core.info(`Extracting Java archive...`); const extension = getDownloadArchiveExtension(); diff --git a/src/distributions/temurin/installer.ts b/src/distributions/temurin/installer.ts index 8064e837..4762b0ca 100644 --- a/src/distributions/temurin/installer.ts +++ b/src/distributions/temurin/installer.ts @@ -69,7 +69,12 @@ export class TemurinDistribution extends JavaBase { return { version: formattedVersion, url: item.binaries[0].package.link, - signatureUrl: item.binaries[0].package.signature_link + signatureUrl: item.binaries[0].package.signature_link, + checksum: { + algorithm: 'sha256', + value: item.binaries[0].package.checksum, + source: item.binaries[0].package.checksum_link + } } as JavaDownloadRelease; }); @@ -132,7 +137,7 @@ export class TemurinDistribution extends JavaBase { } private async downloadPackage(release: JavaDownloadRelease): Promise { - const archivePath = await tc.downloadTool(release.url); + const archivePath = await this.downloadAndVerify(release); if (this.verifySignature) { if (!release.signatureUrl) { diff --git a/src/distributions/zulu/installer.ts b/src/distributions/zulu/installer.ts index f587b193..aa0e0e79 100644 --- a/src/distributions/zulu/installer.ts +++ b/src/distributions/zulu/installer.ts @@ -6,7 +6,7 @@ import fs from 'fs'; import semver from 'semver'; import {JavaBase} from '../base-installer.js'; -import {IZuluVersions} from './models.js'; +import {IZuluPackageDetails, IZuluVersions} from './models.js'; import { extractJdkFile, getDownloadArchiveExtension, @@ -20,6 +20,15 @@ import { JavaInstallerResults } from '../base-models.js'; +// The Azul Metadata API only reports the sha256 checksum on the +// package-details endpoint, keyed by package_uuid, so the resolved candidate +// must retain its UUID after sorting until the single follow-up request is made. +interface ZuluResolvedRelease { + version: string; + url: string; + packageUuid: string; +} + export class ZuluDistribution extends JavaBase { constructor(installerOptions: JavaInstallerOptions) { super('Zulu', installerOptions); @@ -40,7 +49,8 @@ export class ZuluDistribution extends JavaBase { return { version: convertVersionToSemver(javaVersion), url: item.download_url, - zuluVersion: convertVersionToSemver(item.distro_version) + zuluVersion: convertVersionToSemver(item.distro_version), + packageUuid: item.package_uuid }; }); @@ -54,12 +64,11 @@ export class ZuluDistribution extends JavaBase { -semver.compareBuild(a.zuluVersion, b.zuluVersion) ); }) - .map(item => { - return { - version: item.version, - url: item.url - } as JavaDownloadRelease; - }); + .map((item): ZuluResolvedRelease => ({ + version: item.version, + url: item.url, + packageUuid: item.packageUuid + })); const resolvedFullVersion = satisfiedVersions.length > 0 ? satisfiedVersions[0] : null; @@ -70,7 +79,29 @@ export class ZuluDistribution extends JavaBase { throw this.createVersionNotFoundError(version, availableVersionStrings); } - return resolvedFullVersion; + const packageDetailsUrl = `https://api.azul.com/metadata/v1/zulu/packages/${resolvedFullVersion.packageUuid}`; + const packageDetails = ( + await this.http.getJson(packageDetailsUrl) + ).result; + const digest = packageDetails?.sha256_hash?.match(/^[a-f0-9]{64}$/i)?.[0]; + + if (!digest) { + core.debug( + `No authoritative sha256 checksum is available for Zulu version ${resolvedFullVersion.version} from ${packageDetailsUrl}; skipping checksum verification.` + ); + } + + return { + version: resolvedFullVersion.version, + url: resolvedFullVersion.url, + checksum: digest + ? { + algorithm: 'sha256', + value: digest, + source: packageDetailsUrl + } + : undefined + }; } protected async downloadTool( @@ -79,7 +110,7 @@ export class ZuluDistribution extends JavaBase { core.info( `Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...` ); - let javaArchivePath = await tc.downloadTool(javaRelease.url); + let javaArchivePath = await this.downloadAndVerify(javaRelease); core.info(`Extracting Java archive...`); const extension = getDownloadArchiveExtension(); diff --git a/src/distributions/zulu/models.ts b/src/distributions/zulu/models.ts index 36d369e0..c03d12db 100644 --- a/src/distributions/zulu/models.ts +++ b/src/distributions/zulu/models.ts @@ -10,3 +10,7 @@ export interface IZuluVersions { latest: boolean; availability_type: string; } + +export interface IZuluPackageDetails { + sha256_hash?: string; +}