From 26519d567f34196a723da87253c2b5d9145af2aa Mon Sep 17 00:00:00 2001 From: Arnout Engelen Date: Thu, 6 Aug 2026 20:11:51 +0200 Subject: [PATCH] feat(tool-cache): optionally specify expected checksum Implements #427 A bit ugly with the `undefined` at the call site, though otoh having the download and the verification in the same invocation is somewhat satisfying. --- .../tool-cache/__tests__/tool-cache.test.ts | 28 +++++++++++++++++++ packages/tool-cache/src/tool-cache.ts | 19 +++++++++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/tool-cache/__tests__/tool-cache.test.ts b/packages/tool-cache/__tests__/tool-cache.test.ts index 07a3e23df9..5a9163bdb6 100644 --- a/packages/tool-cache/__tests__/tool-cache.test.ts +++ b/packages/tool-cache/__tests__/tool-cache.test.ts @@ -223,6 +223,34 @@ describe('@actions/tool-cache', function () { expect(fs.existsSync(binaryPath)).toBeTruthy() }) + it('checks downloaded file against expected hash', async () => { + const downPath: string = await tc.downloadTool( + 'http://example.com/bytes/35', + undefined, + undefined, + undefined, + "sha256-920719e26beaf4094a97dba323c96c0809c84ff347c6415bab79d6e01af78d98" + ) + + expect(fs.existsSync(downPath)).toBeTruthy() + expect(fs.statSync(downPath).size).toBe(35) + }) + + it('fails if downloaded file does not match expected hash', async () => { + try { + await tc.downloadTool( + 'http://example.com/bytes/35', + undefined, + undefined, + undefined, + "sha256-asdf" + ) + fail("Expected exception!") + } catch (err) { + // OK! + } + }) + if (IS_WINDOWS) { it('installs a 7z and finds it', async () => { const tempDir = path.join(__dirname, 'test-install-7z') diff --git a/packages/tool-cache/src/tool-cache.ts b/packages/tool-cache/src/tool-cache.ts index 91b415e197..f0e62c185f 100644 --- a/packages/tool-cache/src/tool-cache.ts +++ b/packages/tool-cache/src/tool-cache.ts @@ -38,7 +38,8 @@ export async function downloadTool( url: string, dest?: string, auth?: string, - headers?: OutgoingHttpHeaders + headers?: OutgoingHttpHeaders, + expected_hash?: string, ): Promise { dest = dest || path.join(_getTempDirectory(), crypto.randomUUID()) await io.mkdirP(path.dirname(dest)) @@ -57,7 +58,7 @@ export async function downloadTool( const retryHelper = new RetryHelper(maxAttempts, minSeconds, maxSeconds) return await retryHelper.execute( async () => { - return await downloadToolAttempt(url, dest || '', auth, headers) + return await downloadToolAttempt(url, dest || '', auth, headers, expected_hash) }, (err: Error) => { if (err instanceof HTTPError && err.httpStatusCode) { @@ -81,7 +82,8 @@ async function downloadToolAttempt( url: string, dest: string, auth?: string, - headers?: OutgoingHttpHeaders + headers?: OutgoingHttpHeaders, + expected_hash?: string, ): Promise { if (fs.existsSync(dest)) { throw new Error(`Destination file path ${dest} already exists`) @@ -120,6 +122,17 @@ async function downloadToolAttempt( try { await pipeline(readStream, fs.createWriteStream(dest)) core.debug('download complete') + + if (expected_hash) { + const [algo, expected_hex] = expected_hash.split('-') + const hasher = crypto.createHash(algo) + await pipeline(fs.createReadStream(dest), hasher) + const actual_hex = hasher.digest('hex') + if (actual_hex != expected_hex) { + throw new Error(`Hash mismatch for file downloaded from "${url}": expected "${expected_hash}", got "${algo}-${actual_hex}"`) + } + } + succeeded = true return dest } finally {