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
This commit is contained in:
Bruno Borges
2026-07-29 04:43:56 -04:00
committed by GitHub
parent 19c23b379e
commit 27f2c62824
39 changed files with 1629 additions and 103 deletions
+130
View File
@@ -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<string> {
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'});
});
});
@@ -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 () => {
@@ -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<string> {
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<any>).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;
@@ -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 () => {
@@ -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}$/)
});
}
);
@@ -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<any>).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 () => {
@@ -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
// `<hex> <filename>` 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`
});
});
});
@@ -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');
}
}
);
});
@@ -103,9 +103,12 @@ const util = await import('../../src/util.js');
describe('findPackageForDownload', () => {
let distribution: InstanceType<typeof MicrosoftDistributions>;
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 `<hex> <filename>` 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', () => {
@@ -50,6 +50,14 @@ const archivePage = `
<th>9.0.4 (build 9.0.4+11)</th>
<a href="https://download.java.net/java/GA/jdk9/9.0.4/binaries/openjdk-9.0.4_linux-x64_bin.tar.gz">tar.gz</a>
`;
const GA_CHECKSUM = 'c'.repeat(64);
const EA_CHECKSUM = 'd'.repeat(64);
const checksumPages: Record<string, string> = {
'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<ReturnType<HttpClient['get']>>;
}
// 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<ReturnType<HttpClient['get']>>;
});
});
@@ -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 () => {
@@ -47,8 +47,11 @@ describe('findPackageForDownload', () => {
let distribution: InstanceType<typeof OracleDistribution>;
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',
@@ -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<any>;
@@ -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'],
@@ -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 () => {
@@ -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 () => {
@@ -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 () => {
@@ -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 () => {
@@ -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 () => {