import {createHash} from 'crypto'; import fs from 'fs'; import path from 'path'; import * as cache from '@actions/cache'; import * as core from '@actions/core'; import {isCacheFeatureAvailable} from './cache-feature.js'; const STATE_JDK_CACHES = 'jdk-caches'; const JDK_CACHE_KEY_VERSION = 1; export interface JdkCache { distribution: string; packageType: string; architecture: string; version: string; source: string; verification: string; path: string; } interface JdkCacheState { key: string; path: string; architecture: string; matchedKey?: string; // Cheap identity of the installation that occupied `path` when the entry was // registered. The tool-cache path is shared per version/architecture, so a // later step (e.g. one using `force-download`) can replace those bytes; the // post-job save must not upload content that does not match the identity the // key was computed for. installation?: string; } const restoredCaches: JdkCacheState[] = []; export async function restoreJdk(jdk: JdkCache): Promise { if (!jdk.path || !isCacheFeatureAvailable()) { return false; } const key = buildJdkCacheKey(jdk); let matchedKey: string | undefined; try { matchedKey = await cache.restoreCache([jdk.path], key); } catch (error) { core.warning(`Failed to restore JDK cache: ${(error as Error).message}`); } const architecturePath = path.join(jdk.path, jdk.architecture); if ( matchedKey && (!fs.existsSync(architecturePath) || !fs.existsSync(`${architecturePath}.complete`)) ) { core.warning( `JDK cache key ${matchedKey} was restored without the expected tool-cache path; downloading the JDK instead.` ); matchedKey = undefined; } recordJdkCache({ key, path: jdk.path, architecture: jdk.architecture, matchedKey }); if (matchedKey) { core.info(`JDK cache restored from key: ${matchedKey}`); return true; } core.info(`JDK cache is not found for ${jdk.distribution} ${jdk.version}`); return false; } export function registerJdk(jdk: JdkCache): void { if (!jdk.path) { return; } recordJdkCache({ key: buildJdkCacheKey(jdk), path: jdk.path, architecture: jdk.architecture, installation: getInstallationIdentity(jdk.path, jdk.architecture) }); } /** * Cheap fingerprint of the installation stored at a tool-cache path. The * `.complete` marker is (re)created by `tc.cacheDir` every time an * installation is written, so its inode and timestamps change whenever the * installation is replaced. This avoids rehashing a multi-hundred-megabyte JDK * directory while still detecting that the bytes behind a key were swapped. */ function getInstallationIdentity( jdkPath: string, architecture: string ): string | undefined { const architecturePath = path.join(jdkPath, architecture); try { const marker = fs.statSync(`${architecturePath}.complete`); const installation = fs.statSync(architecturePath); return [ marker.ino, marker.mtimeMs, marker.ctimeMs, marker.size, installation.ino, installation.mtimeMs, installation.ctimeMs ].join(':'); } catch { return undefined; } } export function getJdkVerificationIdentity( verifySignature: boolean, publicKey?: string ): string { if (!verifySignature) { return 'unverified'; } if (!publicKey) { return 'verified:bundled'; } const normalizedKey = publicKey.replace(/\r\n?/g, '\n').trim(); const fingerprint = createHash('sha256').update(normalizedKey).digest('hex'); return `verified:custom:sha256:${fingerprint}`; } export async function saveJdkCaches(): Promise { const state = core.getState(STATE_JDK_CACHES); if (!state) { return; } const caches = parseJdkCacheState(state); for (const jdk of caches) { if (jdk.matchedKey === jdk.key) { core.info( `Cache hit occurred on the JDK primary key ${jdk.key}, not saving cache.` ); continue; } if (!fs.existsSync(jdk.path)) { core.debug(`JDK cache path does not exist, not saving: ${jdk.path}`); continue; } if (!jdk.installation) { core.debug( `No JDK installation was registered for the key ${jdk.key}, not saving cache.` ); continue; } if ( getInstallationIdentity(jdk.path, jdk.architecture) !== jdk.installation ) { core.warning( `The JDK installation in ${jdk.path} was replaced after it was registered for the key ${jdk.key}; not saving cache.` ); continue; } try { const cacheId = await cache.saveCache([jdk.path], jdk.key); if (cacheId !== -1) { core.info(`JDK cache saved with the key: ${jdk.key}`); } } catch (error) { const err = error as Error; if (err.name === cache.ReserveCacheError.name) { core.info(err.message); } else { // Saving is best-effort and per entry: one failure must not suppress // the remaining JDK caches. core.warning( `Failed to save the JDK cache with the key ${jdk.key}: ${err.message}` ); } } } } export function buildJdkCacheKey(jdk: JdkCache): string { const runnerOs = process.env['RUNNER_OS'] ?? process.platform; const normalizedArchitecture = jdk.architecture.toLowerCase(); const identity = JSON.stringify({ keyVersion: JDK_CACHE_KEY_VERSION, runnerOs, distribution: jdk.distribution.toLowerCase(), packageType: jdk.packageType.toLowerCase(), architecture: normalizedArchitecture, version: jdk.version, source: jdk.source, verification: jdk.verification }); const digest = createHash('sha256').update(identity).digest('hex'); return `setup-java-jdk-v${JDK_CACHE_KEY_VERSION}-${runnerOs}-${normalizedArchitecture}-${digest}`; } function recordJdkCache(jdk: JdkCacheState): void { const existing = restoredCaches.findIndex( item => item.key === jdk.key && item.path === jdk.path ); if (existing === -1) { restoredCaches.push(jdk); } else { restoredCaches[existing] = {...restoredCaches[existing], ...jdk}; } core.saveState(STATE_JDK_CACHES, JSON.stringify(restoredCaches)); } function parseJdkCacheState(state: string): JdkCacheState[] { const value: unknown = JSON.parse(state); if ( !Array.isArray(value) || !value.every( item => typeof item === 'object' && item !== null && typeof (item as JdkCacheState).key === 'string' && typeof (item as JdkCacheState).path === 'string' && typeof (item as JdkCacheState).architecture === 'string' && ((item as JdkCacheState).matchedKey === undefined || typeof (item as JdkCacheState).matchedKey === 'string') && ((item as JdkCacheState).installation === undefined || typeof (item as JdkCacheState).installation === 'string') ) ) { throw new Error('Invalid JDK cache information retrieved from state.'); } return value as JdkCacheState[]; }