diff --git a/__tests__/distributors/adopt-installer.test.ts b/__tests__/distributors/adopt-installer.test.ts index ff477be05..05be8d53c 100644 --- a/__tests__/distributors/adopt-installer.test.ts +++ b/__tests__/distributors/adopt-installer.test.ts @@ -4,6 +4,7 @@ import { AdoptDistribution, AdoptImplementation } from '../../src/distributions/adopt/installer'; +import {TemurinDistribution} from '../../src/distributions/temurin/installer'; import {JavaInstallerOptions} from '../../src/distributions/base-models'; import os from 'os'; @@ -14,6 +15,7 @@ import * as core from '@actions/core'; describe('getAvailableVersions', () => { let spyHttpClient: jest.SpyInstance; let spyCoreError: jest.SpyInstance; + let spyCoreWarning: jest.SpyInstance; beforeEach(() => { spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson'); @@ -26,6 +28,8 @@ describe('getAvailableVersions', () => { // Mock core.error to suppress error logs spyCoreError = jest.spyOn(core, 'error'); spyCoreError.mockImplementation(() => {}); + spyCoreWarning = jest.spyOn(core, 'warning'); + spyCoreWarning.mockImplementation(() => {}); }); afterEach(() => { @@ -136,22 +140,19 @@ describe('getAvailableVersions', () => { ); it('load available versions', async () => { + const nextPageUrl = + 'https://api.adoptopenjdk.net/v3/assets/version/%5B1.0,100.0%5D?page=1&page_size=20'; spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson'); spyHttpClient .mockReturnValueOnce({ statusCode: 200, - headers: {}, + headers: {link: `<${nextPageUrl}>; rel="next"`}, result: manifestData as any }) .mockReturnValueOnce({ statusCode: 200, headers: {}, result: manifestData as any - }) - .mockReturnValueOnce({ - statusCode: 200, - headers: {}, - result: [] }); const distribution = new AdoptDistribution( @@ -166,6 +167,34 @@ describe('getAvailableVersions', () => { const availableVersions = await distribution['getAvailableVersions'](); expect(availableVersions).not.toBeNull(); expect(availableVersions.length).toBe(manifestData.length * 2); + expect(spyHttpClient).toHaveBeenNthCalledWith(2, nextPageUrl); + }); + + it('stops pagination after 1000 pages as a safeguard', async () => { + const nextPageUrl = + 'https://api.adoptopenjdk.net/v3/assets/version/%5B1.0,100.0%5D?page=2&page_size=20'; + spyHttpClient.mockReturnValue({ + statusCode: 200, + headers: {link: `<${nextPageUrl}>; rel="next"`}, + result: [{version_data: {semver: '17.0.1'}, binaries: []}] as any + }); + + const distribution = new AdoptDistribution( + { + version: '11', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false + }, + AdoptImplementation.Hotspot + ); + + await distribution['getAvailableVersions'](); + + expect(spyHttpClient).toHaveBeenCalledTimes(1000); + expect(spyCoreWarning).toHaveBeenCalledWith( + expect.stringContaining('Reached pagination safeguard limit (1000 pages)') + ); }); it.each([ @@ -228,6 +257,38 @@ describe('getAvailableVersions', () => { }); describe('findPackageForDownload', () => { + it('returns Temurin result and does not query Adopt API when Temurin succeeds', async () => { + const temurinRelease = { + version: '11.0.31+11', + url: 'https://example.test/temurin-11.tar.gz' + }; + const temurinFindPackageForDownload = jest + .fn() + .mockResolvedValue(temurinRelease); + const temurinDistribution = { + findPackageForDownload: temurinFindPackageForDownload + } as unknown as TemurinDistribution; + + const distribution = new AdoptDistribution( + { + version: '11', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false + }, + AdoptImplementation.Hotspot, + temurinDistribution + ); + const adoptLookupSpy = jest.fn(); + distribution['getAvailableVersions'] = adoptLookupSpy; + + const resolvedVersion = await distribution['findPackageForDownload']('11'); + + expect(resolvedVersion).toEqual(temurinRelease); + expect(temurinFindPackageForDownload).toHaveBeenCalledWith('11'); + expect(adoptLookupSpy).not.toHaveBeenCalled(); + }); + it.each([ ['9', '9.0.7+10'], ['15', '15.0.2+7'], @@ -250,6 +311,11 @@ describe('findPackageForDownload', () => { }, AdoptImplementation.Hotspot ); + // Mock Temurin to fail so fallback to AdoptOpenJDK is tested + distribution['temurinDistribution']!['findPackageForDownload'] = + async () => { + throw new Error('No matching version found for SemVer'); + }; distribution['getAvailableVersions'] = async () => manifestData as any; const resolvedVersion = await distribution['findPackageForDownload'](input); expect(resolvedVersion.version).toBe(expected); @@ -265,6 +331,11 @@ describe('findPackageForDownload', () => { }, AdoptImplementation.Hotspot ); + // Mock Temurin to fail so fallback to AdoptOpenJDK is tested + distribution['temurinDistribution']!['findPackageForDownload'] = + async () => { + throw new Error('No matching version found for SemVer'); + }; distribution['getAvailableVersions'] = async () => manifestData as any; await expect( distribution['findPackageForDownload']('9.0.8') @@ -281,6 +352,11 @@ describe('findPackageForDownload', () => { }, AdoptImplementation.Hotspot ); + // Mock Temurin to fail so fallback to AdoptOpenJDK is tested + distribution['temurinDistribution']!['findPackageForDownload'] = + async () => { + throw new Error('No matching version found for SemVer'); + }; distribution['getAvailableVersions'] = async () => manifestData as any; await expect(distribution['findPackageForDownload']('7.x')).rejects.toThrow( /No matching version found for SemVer */ @@ -297,6 +373,11 @@ describe('findPackageForDownload', () => { }, AdoptImplementation.Hotspot ); + // Mock Temurin to fail so fallback to AdoptOpenJDK is tested + distribution['temurinDistribution']!['findPackageForDownload'] = + async () => { + throw new Error('No matching version found for SemVer'); + }; distribution['getAvailableVersions'] = async () => []; await expect(distribution['findPackageForDownload']('11')).rejects.toThrow( /No matching version found for SemVer */ diff --git a/__tests__/distributors/semeru-installer.test.ts b/__tests__/distributors/semeru-installer.test.ts index 1c26c79a3..03b08b728 100644 --- a/__tests__/distributors/semeru-installer.test.ts +++ b/__tests__/distributors/semeru-installer.test.ts @@ -9,6 +9,7 @@ import * as core from '@actions/core'; describe('getAvailableVersions', () => { let spyHttpClient: jest.SpyInstance; let spyCoreError: jest.SpyInstance; + let spyCoreWarning: jest.SpyInstance; beforeEach(() => { spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson'); @@ -20,6 +21,8 @@ describe('getAvailableVersions', () => { // Mock core.error to suppress error logs spyCoreError = jest.spyOn(core, 'error'); spyCoreError.mockImplementation(() => {}); + spyCoreWarning = jest.spyOn(core, 'warning'); + spyCoreWarning.mockImplementation(() => {}); }); afterEach(() => { @@ -82,22 +85,19 @@ describe('getAvailableVersions', () => { ); it('load available versions', async () => { + const nextPageUrl = + 'https://api.adoptopenjdk.net/v3/assets/version/%5B1.0,100.0%5D?page=1&page_size=20'; spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson'); spyHttpClient .mockReturnValueOnce({ statusCode: 200, - headers: {}, + headers: {link: `<${nextPageUrl}>; rel="next"`}, result: manifestData as any }) .mockReturnValueOnce({ statusCode: 200, headers: {}, result: manifestData as any - }) - .mockReturnValueOnce({ - statusCode: 200, - headers: {}, - result: [] }); const distribution = new SemeruDistribution({ @@ -109,6 +109,31 @@ describe('getAvailableVersions', () => { const availableVersions = await distribution['getAvailableVersions'](); expect(availableVersions).not.toBeNull(); expect(availableVersions.length).toBe(manifestData.length * 2); + expect(spyHttpClient).toHaveBeenNthCalledWith(2, nextPageUrl); + }); + + it('stops pagination after 1000 pages as a safeguard', async () => { + const nextPageUrl = + 'https://api.adoptopenjdk.net/v3/assets/version/%5B1.0,100.0%5D?page=2&page_size=20'; + spyHttpClient.mockReturnValue({ + statusCode: 200, + headers: {link: `<${nextPageUrl}>; rel="next"`}, + result: [{version_data: {semver: '17.0.1'}, binaries: []}] as any + }); + + const distribution = new SemeruDistribution({ + version: '8', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false + }); + + await distribution['getAvailableVersions'](); + + expect(spyHttpClient).toHaveBeenCalledTimes(1000); + expect(spyCoreWarning).toHaveBeenCalledWith( + expect.stringContaining('Reached pagination safeguard limit (1000 pages)') + ); }); it.each([ diff --git a/__tests__/distributors/temurin-installer.test.ts b/__tests__/distributors/temurin-installer.test.ts index 0c6ef3f50..161a2d087 100644 --- a/__tests__/distributors/temurin-installer.test.ts +++ b/__tests__/distributors/temurin-installer.test.ts @@ -12,6 +12,7 @@ import * as core from '@actions/core'; describe('getAvailableVersions', () => { let spyHttpClient: jest.SpyInstance; let spyCoreError: jest.SpyInstance; + let spyCoreWarning: jest.SpyInstance; beforeEach(() => { spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson'); @@ -23,6 +24,8 @@ describe('getAvailableVersions', () => { // Mock core.error to suppress error logs spyCoreError = jest.spyOn(core, 'error'); spyCoreError.mockImplementation(() => {}); + spyCoreWarning = jest.spyOn(core, 'warning'); + spyCoreWarning.mockImplementation(() => {}); }); afterEach(() => { @@ -93,22 +96,19 @@ describe('getAvailableVersions', () => { ); it('load available versions', async () => { + const nextPageUrl = + 'https://api.adoptium.net/v3/assets/version/%5B1.0,100.0%5D?page=1&page_size=20'; spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson'); spyHttpClient .mockReturnValueOnce({ statusCode: 200, - headers: {}, + headers: {link: `<${nextPageUrl}>; rel="next"`}, result: manifestData as any }) .mockReturnValueOnce({ statusCode: 200, headers: {}, result: manifestData as any - }) - .mockReturnValueOnce({ - statusCode: 200, - headers: {}, - result: [] }); const distribution = new TemurinDistribution( @@ -123,6 +123,34 @@ describe('getAvailableVersions', () => { const availableVersions = await distribution['getAvailableVersions'](); expect(availableVersions).not.toBeNull(); expect(availableVersions.length).toBe(manifestData.length * 2); + expect(spyHttpClient).toHaveBeenNthCalledWith(2, nextPageUrl); + }); + + it('stops pagination after 1000 pages as a safeguard', async () => { + const nextPageUrl = + 'https://api.adoptium.net/v3/assets/version/%5B1.0,100.0%5D?page=2&page_size=20'; + spyHttpClient.mockReturnValue({ + statusCode: 200, + headers: {link: `<${nextPageUrl}>; rel="next"`}, + result: [{version_data: {semver: '17.0.1'}, binaries: []}] as any + }); + + const distribution = new TemurinDistribution( + { + version: '8', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false + }, + TemurinImplementation.Hotspot + ); + + await distribution['getAvailableVersions'](); + + expect(spyHttpClient).toHaveBeenCalledTimes(1000); + expect(spyCoreWarning).toHaveBeenCalledWith( + expect.stringContaining('Reached pagination safeguard limit (1000 pages)') + ); }); it.each([ diff --git a/__tests__/util.test.ts b/__tests__/util.test.ts index 85b76069e..f41d2c918 100644 --- a/__tests__/util.test.ts +++ b/__tests__/util.test.ts @@ -4,10 +4,12 @@ import * as fs from 'fs'; import * as path from 'path'; import { convertVersionToSemver, + getNextPageUrlFromLinkHeader, getVersionFromFileContent, isVersionSatisfies, isCacheFeatureAvailable, - isGhes + isGhes, + validatePaginationUrl } from '../src/util'; jest.mock('@actions/cache'); @@ -85,6 +87,78 @@ describe('convertVersionToSemver', () => { }); }); +describe('getNextPageUrlFromLinkHeader', () => { + it.each([ + [ + { + link: '; rel="next"' + }, + 'https://api.adoptium.net/v3/info/release_versions?page=1&page_size=10' + ], + [ + { + Link: '; rel="last", ; rel="next"' + }, + 'https://example.com/next?page=2' + ], + [ + { + link: '; type="application/json"; rel="next"' + }, + 'https://api.adoptium.net/v3/versions?page=3' + ], + [{link: '; rel="last"'}, null], + [{link: '; rel="nextsomething"'}, null], + [undefined, null] + ])('returns %s -> %s', (headers, expected) => { + expect(getNextPageUrlFromLinkHeader(headers)).toBe(expected); + }); +}); + +describe('validatePaginationUrl', () => { + it('accepts URL with matching origin', () => { + expect( + validatePaginationUrl( + 'https://api.adoptium.net/v3/assets?page=2', + 'https://api.adoptium.net' + ) + ).toBe(true); + }); + + it('rejects URL with different host', () => { + expect( + validatePaginationUrl( + 'https://evil.example.com/steal?data=1', + 'https://api.adoptium.net' + ) + ).toBe(false); + }); + + it('rejects URL with different protocol', () => { + expect( + validatePaginationUrl( + 'http://api.adoptium.net/v3/assets?page=2', + 'https://api.adoptium.net' + ) + ).toBe(false); + }); + + it('returns false for invalid URL', () => { + expect(validatePaginationUrl('not-a-url', 'https://api.adoptium.net')).toBe( + false + ); + }); + + it('accepts URL with explicit default port', () => { + expect( + validatePaginationUrl( + 'https://api.adoptium.net:443/v3/assets?page=2', + 'https://api.adoptium.net' + ) + ).toBe(true); + }); +}); + describe('getVersionFromFileContent', () => { describe('.sdkmanrc', () => { it.each([ diff --git a/dist/cleanup/index.js b/dist/cleanup/index.js index 9b11cf71e..eff7dac80 100644 --- a/dist/cleanup/index.js +++ b/dist/cleanup/index.js @@ -52134,7 +52134,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.renameWinArchive = exports.getGitHubHttpHeaders = exports.convertVersionToSemver = exports.getVersionFromFileContent = exports.isCacheFeatureAvailable = exports.isGhes = exports.isJobStatusSuccess = exports.getToolcachePath = exports.isVersionSatisfies = exports.getDownloadArchiveExtension = exports.extractJdkFile = exports.getVersionFromToolcachePath = exports.getBooleanInput = exports.getTempDir = void 0; +exports.renameWinArchive = exports.validatePaginationUrl = exports.getNextPageUrlFromLinkHeader = exports.MAX_PAGINATION_PAGES = exports.getGitHubHttpHeaders = exports.convertVersionToSemver = exports.getVersionFromFileContent = exports.isCacheFeatureAvailable = exports.isGhes = exports.isJobStatusSuccess = exports.getToolcachePath = exports.isVersionSatisfies = exports.getDownloadArchiveExtension = exports.extractJdkFile = exports.getVersionFromToolcachePath = exports.getBooleanInput = exports.getTempDir = void 0; const os_1 = __importDefault(__nccwpck_require__(22037)); const path_1 = __importDefault(__nccwpck_require__(71017)); const fs = __importStar(__nccwpck_require__(57147)); @@ -52301,6 +52301,47 @@ function getGitHubHttpHeaders() { return headers; } exports.getGitHubHttpHeaders = getGitHubHttpHeaders; +exports.MAX_PAGINATION_PAGES = 1000; +function getNextPageUrlFromLinkHeader(headers) { + var _a; + if (!headers) { + return null; + } + const linkHeader = (_a = headers.link) !== null && _a !== void 0 ? _a : headers.Link; + if (!linkHeader) { + return null; + } + const normalizedLinkHeader = Array.isArray(linkHeader) + ? linkHeader.join(',') + : linkHeader; + // Split into individual link-values and find the one with rel="next" + // RFC 8288 allows rel to appear anywhere among the parameters + const linkValues = normalizedLinkHeader.split(/,(?=\s*<)/); + for (const linkValue of linkValues) { + const urlMatch = linkValue.match(/<([^>]+)>/); + if (!urlMatch) + continue; + const params = linkValue.slice(urlMatch[0].length); + // Use word boundary to match "next" as a standalone relation type + // RFC 8288 allows space-separated relation types like rel="next prev" + if (/;\s*rel="?[^"]*\bnext\b/i.test(params)) { + return urlMatch[1]; + } + } + return null; +} +exports.getNextPageUrlFromLinkHeader = getNextPageUrlFromLinkHeader; +function validatePaginationUrl(url, allowedOrigin) { + try { + const parsed = new URL(url); + const allowed = new URL(allowedOrigin); + return parsed.origin === allowed.origin; + } + catch (_a) { + return false; + } +} +exports.validatePaginationUrl = validatePaginationUrl; // Rename archive to add extension because after downloading // archive does not contain extension type and it leads to some issues // on Windows runners without PowerShell Core. diff --git a/dist/setup/index.js b/dist/setup/index.js index eb6cab20b..a2efc14ae 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -77815,17 +77815,54 @@ const path_1 = __importDefault(__nccwpck_require__(71017)); const semver_1 = __importDefault(__nccwpck_require__(11383)); const base_installer_1 = __nccwpck_require__(59741); const util_1 = __nccwpck_require__(92629); +const installer_1 = __nccwpck_require__(18579); var AdoptImplementation; (function (AdoptImplementation) { AdoptImplementation["Hotspot"] = "Hotspot"; AdoptImplementation["OpenJ9"] = "OpenJ9"; })(AdoptImplementation || (exports.AdoptImplementation = AdoptImplementation = {})); class AdoptDistribution extends base_installer_1.JavaBase { - constructor(installerOptions, jvmImpl) { + constructor(installerOptions, jvmImpl, temurinDistribution = null) { super(`Adopt-${jvmImpl}`, installerOptions); this.jvmImpl = jvmImpl; + if (temurinDistribution !== null && + jvmImpl !== AdoptImplementation.Hotspot) { + throw new Error('Only Hotspot JVM is supported by Temurin.'); + } + // Only use the temurin repo for Hotspot JVMs + this.temurinDistribution = + temurinDistribution !== null && temurinDistribution !== void 0 ? temurinDistribution : (jvmImpl === AdoptImplementation.Hotspot + ? new installer_1.TemurinDistribution(installerOptions, installer_1.TemurinImplementation.Hotspot) + : null); } findPackageForDownload(version) { + return __awaiter(this, void 0, void 0, function* () { + if (this.jvmImpl === AdoptImplementation.Hotspot) { + core.notice("AdoptOpenJDK has moved to Eclipse Temurin https://github.com/actions/setup-java#supported-distributions please consider changing to the 'temurin' distribution type in your setup-java configuration."); + } + if (this.jvmImpl === AdoptImplementation.Hotspot && + this.temurinDistribution !== null) { + try { + return yield this.temurinDistribution.findPackageForDownload(version); + } + catch (error) { + // Log the failure but always fall back to legacy AdoptOpenJDK for resilience + const errorMessage = error instanceof Error ? error.message : String(error); + if (error instanceof Error && error.name === 'VersionNotFoundError') { + core.notice('The JVM you are looking for could not be found in the Temurin repository, this likely indicates ' + + 'that you are using an out of date version of Java, consider updating and moving to using the Temurin distribution type in setup-java.'); + } + else { + // Log other errors for debugging but gracefully fall back + core.debug(`Temurin lookup failed: ${errorMessage}. Falling back to AdoptOpenJDK API.`); + } + } + } + // failed to find a Temurin version, so fall back to AdoptOpenJDK + return this.findPackageForDownloadOldAdoptOpenJdk(version); + }); + } + findPackageForDownloadOldAdoptOpenJdk(version) { return __awaiter(this, void 0, void 0, function* () { const availableVersionsRaw = yield this.getAvailableVersions(); const availableVersionsWithBinaries = availableVersionsRaw @@ -77896,24 +77933,34 @@ class AdoptDistribution extends base_installer_1.JavaBase { `release_type=${releaseType}`, `jvm_impl=${this.jvmImpl.toLowerCase()}` ].join('&'); - // need to iterate through all pages to retrieve the list of all versions - // Adopt API doesn't provide way to retrieve the count of pages to iterate so infinity loop - let page_index = 0; + const requestArguments = `${baseRequestArguments}&page_size=20&page=0`; + let availableVersionsUrl = `https://api.adoptopenjdk.net/v3/assets/version/${versionRange}?${requestArguments}`; const availableVersions = []; - while (true) { - const requestArguments = `${baseRequestArguments}&page_size=20&page=${page_index}`; - const availableVersionsUrl = `https://api.adoptopenjdk.net/v3/assets/version/${versionRange}?${requestArguments}`; - if (core.isDebug() && page_index === 0) { - // url is identical except page_index so print it once for debug - core.debug(`Gathering available versions from '${availableVersionsUrl}'`); + let pageCount = 0; + if (core.isDebug()) { + core.debug(`Gathering available versions from '${availableVersionsUrl}'`); + } + while (availableVersionsUrl) { + pageCount++; + const response = yield this.http.getJson(availableVersionsUrl); + const paginationPage = response.result; + const nextUrl = (0, util_1.getNextPageUrlFromLinkHeader)(response.headers); + if (nextUrl && + !(0, util_1.validatePaginationUrl)(nextUrl, 'https://api.adoptopenjdk.net')) { + core.warning(`Ignoring pagination link with unexpected origin: ${nextUrl}`); + availableVersionsUrl = null; + } + else { + availableVersionsUrl = nextUrl; } - const paginationPage = (yield this.http.getJson(availableVersionsUrl)).result; if (paginationPage === null || paginationPage.length === 0) { - // break infinity loop because we have reached end of pagination break; } availableVersions.push(...paginationPage); - page_index++; + if (pageCount >= util_1.MAX_PAGINATION_PAGES) { + core.warning(`Reached pagination safeguard limit (${util_1.MAX_PAGINATION_PAGES} pages) while listing Adopt releases.`); + break; + } } if (core.isDebug()) { core.startGroup('Print information about available versions'); @@ -78213,7 +78260,9 @@ class JavaBase { parts.push(`(showing first ${maxVersionsToShow} of ${availableVersions.length} versions, enable debug mode to see all)`); } } - return new Error(parts.join('\n')); + const error = new Error(parts.join('\n')); + error.name = 'VersionNotFoundError'; + return error; } setJavaDefault(version, toolPath) { const majorVersion = version.split('.')[0]; @@ -80071,24 +80120,34 @@ class SemeruDistribution extends base_installer_1.JavaBase { `release_type=${releaseType}`, `jvm_impl=openj9` ].join('&'); - // need to iterate through all pages to retrieve the list of all versions - // Adoptium API doesn't provide way to retrieve the count of pages to iterate so infinity loop - let page_index = 0; + const requestArguments = `${baseRequestArguments}&page_size=20&page=0`; + let availableVersionsUrl = `https://api.adoptopenjdk.net/v3/assets/version/${versionRange}?${requestArguments}`; const availableVersions = []; - while (true) { - const requestArguments = `${baseRequestArguments}&page_size=20&page=${page_index}`; - const availableVersionsUrl = `https://api.adoptopenjdk.net/v3/assets/version/${versionRange}?${requestArguments}`; - if (core.isDebug() && page_index === 0) { - // url is identical except page_index so print it once for debug - core.debug(`Gathering available versions from '${availableVersionsUrl}'`); + let pageCount = 0; + if (core.isDebug()) { + core.debug(`Gathering available versions from '${availableVersionsUrl}'`); + } + while (availableVersionsUrl) { + pageCount++; + const response = yield this.http.getJson(availableVersionsUrl); + const paginationPage = response.result; + const nextUrl = (0, util_1.getNextPageUrlFromLinkHeader)(response.headers); + if (nextUrl && + !(0, util_1.validatePaginationUrl)(nextUrl, 'https://api.adoptopenjdk.net')) { + core.warning(`Ignoring pagination link with unexpected origin: ${nextUrl}`); + availableVersionsUrl = null; + } + else { + availableVersionsUrl = nextUrl; } - const paginationPage = (yield this.http.getJson(availableVersionsUrl)).result; if (paginationPage === null || paginationPage.length === 0) { - // break infinity loop because we have reached end of pagination break; } availableVersions.push(...paginationPage); - page_index++; + if (pageCount >= util_1.MAX_PAGINATION_PAGES) { + core.warning(`Reached pagination safeguard limit (${util_1.MAX_PAGINATION_PAGES} pages) while listing Semeru releases.`); + break; + } } if (core.isDebug()) { core.startGroup('Print information about available versions'); @@ -80175,6 +80234,9 @@ class TemurinDistribution extends base_installer_1.JavaBase { super(`Temurin-${jvmImpl}`, installerOptions); this.jvmImpl = jvmImpl; } + /** + * @internal For cross-distribution reuse only. Not intended as a public API. + */ findPackageForDownload(version) { return __awaiter(this, void 0, void 0, function* () { const availableVersionsRaw = yield this.getAvailableVersions(); @@ -80245,24 +80307,34 @@ class TemurinDistribution extends base_installer_1.JavaBase { `release_type=${releaseType}`, `jvm_impl=${this.jvmImpl.toLowerCase()}` ].join('&'); - // need to iterate through all pages to retrieve the list of all versions - // Adoptium API doesn't provide way to retrieve the count of pages to iterate so infinity loop - let page_index = 0; + const requestArguments = `${baseRequestArguments}&page_size=20&page=0`; + let availableVersionsUrl = `https://api.adoptium.net/v3/assets/version/${versionRange}?${requestArguments}`; const availableVersions = []; - while (true) { - const requestArguments = `${baseRequestArguments}&page_size=20&page=${page_index}`; - const availableVersionsUrl = `https://api.adoptium.net/v3/assets/version/${versionRange}?${requestArguments}`; - if (core.isDebug() && page_index === 0) { - // url is identical except page_index so print it once for debug - core.debug(`Gathering available versions from '${availableVersionsUrl}'`); + let pageCount = 0; + if (core.isDebug()) { + core.debug(`Gathering available versions from '${availableVersionsUrl}'`); + } + while (availableVersionsUrl) { + pageCount++; + const response = yield this.http.getJson(availableVersionsUrl); + const paginationPage = response.result; + const nextUrl = (0, util_1.getNextPageUrlFromLinkHeader)(response.headers); + if (nextUrl && + !(0, util_1.validatePaginationUrl)(nextUrl, 'https://api.adoptium.net')) { + core.warning(`Ignoring pagination link with unexpected origin: ${nextUrl}`); + availableVersionsUrl = null; + } + else { + availableVersionsUrl = nextUrl; } - const paginationPage = (yield this.http.getJson(availableVersionsUrl)).result; if (paginationPage === null || paginationPage.length === 0) { - // break infinity loop because we have reached end of pagination break; } availableVersions.push(...paginationPage); - page_index++; + if (pageCount >= util_1.MAX_PAGINATION_PAGES) { + core.warning(`Reached pagination safeguard limit (${util_1.MAX_PAGINATION_PAGES} pages) while listing Temurin releases.`); + break; + } } if (core.isDebug()) { core.startGroup('Print information about available versions'); @@ -80893,7 +80965,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.renameWinArchive = exports.getGitHubHttpHeaders = exports.convertVersionToSemver = exports.getVersionFromFileContent = exports.isCacheFeatureAvailable = exports.isGhes = exports.isJobStatusSuccess = exports.getToolcachePath = exports.isVersionSatisfies = exports.getDownloadArchiveExtension = exports.extractJdkFile = exports.getVersionFromToolcachePath = exports.getBooleanInput = exports.getTempDir = void 0; +exports.renameWinArchive = exports.validatePaginationUrl = exports.getNextPageUrlFromLinkHeader = exports.MAX_PAGINATION_PAGES = exports.getGitHubHttpHeaders = exports.convertVersionToSemver = exports.getVersionFromFileContent = exports.isCacheFeatureAvailable = exports.isGhes = exports.isJobStatusSuccess = exports.getToolcachePath = exports.isVersionSatisfies = exports.getDownloadArchiveExtension = exports.extractJdkFile = exports.getVersionFromToolcachePath = exports.getBooleanInput = exports.getTempDir = void 0; const os_1 = __importDefault(__nccwpck_require__(22037)); const path_1 = __importDefault(__nccwpck_require__(71017)); const fs = __importStar(__nccwpck_require__(57147)); @@ -81060,6 +81132,47 @@ function getGitHubHttpHeaders() { return headers; } exports.getGitHubHttpHeaders = getGitHubHttpHeaders; +exports.MAX_PAGINATION_PAGES = 1000; +function getNextPageUrlFromLinkHeader(headers) { + var _a; + if (!headers) { + return null; + } + const linkHeader = (_a = headers.link) !== null && _a !== void 0 ? _a : headers.Link; + if (!linkHeader) { + return null; + } + const normalizedLinkHeader = Array.isArray(linkHeader) + ? linkHeader.join(',') + : linkHeader; + // Split into individual link-values and find the one with rel="next" + // RFC 8288 allows rel to appear anywhere among the parameters + const linkValues = normalizedLinkHeader.split(/,(?=\s*<)/); + for (const linkValue of linkValues) { + const urlMatch = linkValue.match(/<([^>]+)>/); + if (!urlMatch) + continue; + const params = linkValue.slice(urlMatch[0].length); + // Use word boundary to match "next" as a standalone relation type + // RFC 8288 allows space-separated relation types like rel="next prev" + if (/;\s*rel="?[^"]*\bnext\b/i.test(params)) { + return urlMatch[1]; + } + } + return null; +} +exports.getNextPageUrlFromLinkHeader = getNextPageUrlFromLinkHeader; +function validatePaginationUrl(url, allowedOrigin) { + try { + const parsed = new URL(url); + const allowed = new URL(allowedOrigin); + return parsed.origin === allowed.origin; + } + catch (_a) { + return false; + } +} +exports.validatePaginationUrl = validatePaginationUrl; // Rename archive to add extension because after downloading // archive does not contain extension type and it leads to some issues // on Windows runners without PowerShell Core. diff --git a/src/distributions/adopt/installer.ts b/src/distributions/adopt/installer.ts index 34c1716cd..78798bfc1 100644 --- a/src/distributions/adopt/installer.ts +++ b/src/distributions/adopt/installer.ts @@ -14,10 +14,14 @@ import { } from '../base-models'; import { extractJdkFile, + getNextPageUrlFromLinkHeader, getDownloadArchiveExtension, isVersionSatisfies, - renameWinArchive + renameWinArchive, + MAX_PAGINATION_PAGES, + validatePaginationUrl } from '../../util'; +import {TemurinDistribution, TemurinImplementation} from '../temurin/installer'; export enum AdoptImplementation { Hotspot = 'Hotspot', @@ -25,15 +29,72 @@ export enum AdoptImplementation { } export class AdoptDistribution extends JavaBase { + private readonly temurinDistribution: TemurinDistribution | null; + constructor( installerOptions: JavaInstallerOptions, - private readonly jvmImpl: AdoptImplementation + private readonly jvmImpl: AdoptImplementation, + temurinDistribution: TemurinDistribution | null = null ) { super(`Adopt-${jvmImpl}`, installerOptions); + + if ( + temurinDistribution !== null && + jvmImpl !== AdoptImplementation.Hotspot + ) { + throw new Error('Only Hotspot JVM is supported by Temurin.'); + } + + // Only use the temurin repo for Hotspot JVMs + this.temurinDistribution = + temurinDistribution ?? + (jvmImpl === AdoptImplementation.Hotspot + ? new TemurinDistribution( + installerOptions, + TemurinImplementation.Hotspot + ) + : null); } protected async findPackageForDownload( version: string + ): Promise { + if (this.jvmImpl === AdoptImplementation.Hotspot) { + core.notice( + "AdoptOpenJDK has moved to Eclipse Temurin https://github.com/actions/setup-java#supported-distributions please consider changing to the 'temurin' distribution type in your setup-java configuration." + ); + } + + if ( + this.jvmImpl === AdoptImplementation.Hotspot && + this.temurinDistribution !== null + ) { + try { + return await this.temurinDistribution.findPackageForDownload(version); + } catch (error) { + // Log the failure but always fall back to legacy AdoptOpenJDK for resilience + const errorMessage = + error instanceof Error ? error.message : String(error); + if (error instanceof Error && error.name === 'VersionNotFoundError') { + core.notice( + 'The JVM you are looking for could not be found in the Temurin repository, this likely indicates ' + + 'that you are using an out of date version of Java, consider updating and moving to using the Temurin distribution type in setup-java.' + ); + } else { + // Log other errors for debugging but gracefully fall back + core.debug( + `Temurin lookup failed: ${errorMessage}. Falling back to AdoptOpenJDK API.` + ); + } + } + } + + // failed to find a Temurin version, so fall back to AdoptOpenJDK + return this.findPackageForDownloadOldAdoptOpenJdk(version); + } + + private async findPackageForDownloadOldAdoptOpenJdk( + version: string ): Promise { const availableVersionsRaw = await this.getAvailableVersions(); const availableVersionsWithBinaries = availableVersionsRaw @@ -125,30 +186,46 @@ export class AdoptDistribution extends JavaBase { `jvm_impl=${this.jvmImpl.toLowerCase()}` ].join('&'); - // need to iterate through all pages to retrieve the list of all versions - // Adopt API doesn't provide way to retrieve the count of pages to iterate so infinity loop - let page_index = 0; + const requestArguments = `${baseRequestArguments}&page_size=20&page=0`; + let availableVersionsUrl: string | null = + `https://api.adoptopenjdk.net/v3/assets/version/${versionRange}?${requestArguments}`; const availableVersions: IAdoptAvailableVersions[] = []; - while (true) { - const requestArguments = `${baseRequestArguments}&page_size=20&page=${page_index}`; - const availableVersionsUrl = `https://api.adoptopenjdk.net/v3/assets/version/${versionRange}?${requestArguments}`; - if (core.isDebug() && page_index === 0) { - // url is identical except page_index so print it once for debug - core.debug( - `Gathering available versions from '${availableVersionsUrl}'` + let pageCount = 0; + if (core.isDebug()) { + core.debug(`Gathering available versions from '${availableVersionsUrl}'`); + } + + while (availableVersionsUrl) { + pageCount++; + const response = + await this.http.getJson( + availableVersionsUrl ); + const paginationPage = response.result; + const nextUrl = getNextPageUrlFromLinkHeader(response.headers); + if ( + nextUrl && + !validatePaginationUrl(nextUrl, 'https://api.adoptopenjdk.net') + ) { + core.warning( + `Ignoring pagination link with unexpected origin: ${nextUrl}` + ); + availableVersionsUrl = null; + } else { + availableVersionsUrl = nextUrl; } - - const paginationPage = ( - await this.http.getJson(availableVersionsUrl) - ).result; if (paginationPage === null || paginationPage.length === 0) { - // break infinity loop because we have reached end of pagination break; } availableVersions.push(...paginationPage); - page_index++; + + if (pageCount >= MAX_PAGINATION_PAGES) { + core.warning( + `Reached pagination safeguard limit (${MAX_PAGINATION_PAGES} pages) while listing Adopt releases.` + ); + break; + } } if (core.isDebug()) { diff --git a/src/distributions/base-installer.ts b/src/distributions/base-installer.ts index 10e37d996..5d9f3c82a 100644 --- a/src/distributions/base-installer.ts +++ b/src/distributions/base-installer.ts @@ -292,7 +292,9 @@ export abstract class JavaBase { } } - return new Error(parts.join('\n')); + const error = new Error(parts.join('\n')); + error.name = 'VersionNotFoundError'; + return error; } protected setJavaDefault(version: string, toolPath: string) { diff --git a/src/distributions/semeru/installer.ts b/src/distributions/semeru/installer.ts index edb294803..a043f16e4 100644 --- a/src/distributions/semeru/installer.ts +++ b/src/distributions/semeru/installer.ts @@ -7,9 +7,12 @@ import { import semver from 'semver'; import { extractJdkFile, + getNextPageUrlFromLinkHeader, getDownloadArchiveExtension, isVersionSatisfies, - renameWinArchive + renameWinArchive, + MAX_PAGINATION_PAGES, + validatePaginationUrl } from '../../util'; import * as core from '@actions/core'; import * as tc from '@actions/tool-cache'; @@ -155,32 +158,46 @@ export class SemeruDistribution extends JavaBase { `jvm_impl=openj9` ].join('&'); - // need to iterate through all pages to retrieve the list of all versions - // Adoptium API doesn't provide way to retrieve the count of pages to iterate so infinity loop - let page_index = 0; + const requestArguments = `${baseRequestArguments}&page_size=20&page=0`; + let availableVersionsUrl: string | null = + `https://api.adoptopenjdk.net/v3/assets/version/${versionRange}?${requestArguments}`; const availableVersions: ISemeruAvailableVersions[] = []; - while (true) { - const requestArguments = `${baseRequestArguments}&page_size=20&page=${page_index}`; - const availableVersionsUrl = `https://api.adoptopenjdk.net/v3/assets/version/${versionRange}?${requestArguments}`; - if (core.isDebug() && page_index === 0) { - // url is identical except page_index so print it once for debug - core.debug( - `Gathering available versions from '${availableVersionsUrl}'` - ); - } + let pageCount = 0; + if (core.isDebug()) { + core.debug(`Gathering available versions from '${availableVersionsUrl}'`); + } - const paginationPage = ( + while (availableVersionsUrl) { + pageCount++; + const response = await this.http.getJson( availableVersionsUrl - ) - ).result; + ); + const paginationPage = response.result; + const nextUrl = getNextPageUrlFromLinkHeader(response.headers); + if ( + nextUrl && + !validatePaginationUrl(nextUrl, 'https://api.adoptopenjdk.net') + ) { + core.warning( + `Ignoring pagination link with unexpected origin: ${nextUrl}` + ); + availableVersionsUrl = null; + } else { + availableVersionsUrl = nextUrl; + } if (paginationPage === null || paginationPage.length === 0) { - // break infinity loop because we have reached end of pagination break; } availableVersions.push(...paginationPage); - page_index++; + + if (pageCount >= MAX_PAGINATION_PAGES) { + core.warning( + `Reached pagination safeguard limit (${MAX_PAGINATION_PAGES} pages) while listing Semeru releases.` + ); + break; + } } if (core.isDebug()) { diff --git a/src/distributions/temurin/installer.ts b/src/distributions/temurin/installer.ts index 51d523f6e..109c2d413 100644 --- a/src/distributions/temurin/installer.ts +++ b/src/distributions/temurin/installer.ts @@ -14,9 +14,12 @@ import { } from '../base-models'; import { extractJdkFile, + getNextPageUrlFromLinkHeader, getDownloadArchiveExtension, isVersionSatisfies, - renameWinArchive + renameWinArchive, + MAX_PAGINATION_PAGES, + validatePaginationUrl } from '../../util'; export enum TemurinImplementation { @@ -31,7 +34,10 @@ export class TemurinDistribution extends JavaBase { super(`Temurin-${jvmImpl}`, installerOptions); } - protected async findPackageForDownload( + /** + * @internal For cross-distribution reuse only. Not intended as a public API. + */ + public async findPackageForDownload( version: string ): Promise { const availableVersionsRaw = await this.getAvailableVersions(); @@ -123,32 +129,47 @@ export class TemurinDistribution extends JavaBase { `jvm_impl=${this.jvmImpl.toLowerCase()}` ].join('&'); - // need to iterate through all pages to retrieve the list of all versions - // Adoptium API doesn't provide way to retrieve the count of pages to iterate so infinity loop - let page_index = 0; + const requestArguments = `${baseRequestArguments}&page_size=20&page=0`; + let availableVersionsUrl: string | null = + `https://api.adoptium.net/v3/assets/version/${versionRange}?${requestArguments}`; const availableVersions: ITemurinAvailableVersions[] = []; - while (true) { - const requestArguments = `${baseRequestArguments}&page_size=20&page=${page_index}`; - const availableVersionsUrl = `https://api.adoptium.net/v3/assets/version/${versionRange}?${requestArguments}`; - if (core.isDebug() && page_index === 0) { - // url is identical except page_index so print it once for debug - core.debug( - `Gathering available versions from '${availableVersionsUrl}'` - ); - } + let pageCount = 0; + if (core.isDebug()) { + core.debug(`Gathering available versions from '${availableVersionsUrl}'`); + } - const paginationPage = ( + while (availableVersionsUrl) { + pageCount++; + const response = await this.http.getJson( availableVersionsUrl - ) - ).result; + ); + const paginationPage = response.result; + const nextUrl = getNextPageUrlFromLinkHeader(response.headers); + if ( + nextUrl && + !validatePaginationUrl(nextUrl, 'https://api.adoptium.net') + ) { + core.warning( + `Ignoring pagination link with unexpected origin: ${nextUrl}` + ); + availableVersionsUrl = null; + } else { + availableVersionsUrl = nextUrl; + } + if (paginationPage === null || paginationPage.length === 0) { - // break infinity loop because we have reached end of pagination break; } availableVersions.push(...paginationPage); - page_index++; + + if (pageCount >= MAX_PAGINATION_PAGES) { + core.warning( + `Reached pagination safeguard limit (${MAX_PAGINATION_PAGES} pages) while listing Temurin releases.` + ); + break; + } } if (core.isDebug()) { diff --git a/src/util.ts b/src/util.ts index 0325f7f42..5fe84c520 100644 --- a/src/util.ts +++ b/src/util.ts @@ -201,6 +201,55 @@ export function getGitHubHttpHeaders(): OutgoingHttpHeaders { return headers; } +export const MAX_PAGINATION_PAGES = 1000; + +export function getNextPageUrlFromLinkHeader( + headers?: Record +): string | null { + if (!headers) { + return null; + } + + const linkHeader = headers.link ?? headers.Link; + if (!linkHeader) { + return null; + } + + const normalizedLinkHeader = Array.isArray(linkHeader) + ? linkHeader.join(',') + : linkHeader; + + // Split into individual link-values and find the one with rel="next" + // RFC 8288 allows rel to appear anywhere among the parameters + const linkValues = normalizedLinkHeader.split(/,(?=\s*<)/); + for (const linkValue of linkValues) { + const urlMatch = linkValue.match(/<([^>]+)>/); + if (!urlMatch) continue; + + const params = linkValue.slice(urlMatch[0].length); + // Use word boundary to match "next" as a standalone relation type + // RFC 8288 allows space-separated relation types like rel="next prev" + if (/;\s*rel="?[^"]*\bnext\b/i.test(params)) { + return urlMatch[1]; + } + } + + return null; +} + +export function validatePaginationUrl( + url: string, + allowedOrigin: string +): boolean { + try { + const parsed = new URL(url); + const allowed = new URL(allowedOrigin); + return parsed.origin === allowed.origin; + } catch { + return false; + } +} + // Rename archive to add extension because after downloading // archive does not contain extension type and it leads to some issues // on Windows runners without PowerShell Core.