diff --git a/packages/artifact/__tests__/download-artifact.test.ts b/packages/artifact/__tests__/download-artifact.test.ts index 1e4d8d1ebe..29b269ce60 100644 --- a/packages/artifact/__tests__/download-artifact.test.ts +++ b/packages/artifact/__tests__/download-artifact.test.ts @@ -1,7 +1,10 @@ import fs from 'fs' +import * as crypto from 'crypto' import * as http from 'http' import * as net from 'net' import * as path from 'path' +import * as stream from 'stream' +import {spawn} from 'child_process' import * as github from '@actions/github' import {HttpClient} from '@actions/http-client' import type {RestEndpointMethods} from '@octokit/plugin-rest-endpoint-methods/dist-types/generated/method-types' @@ -87,6 +90,41 @@ const expectExtractedArchive = async (dir: string): Promise => { } } +const runProcessFixture = async ( + mode: 'success' | 'failure' +): Promise<{code: number | null; stderr: string}> => { + const fixture = path.join( + __dirname, + 'fixtures', + 'download-attempt-process.mjs' + ) + + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [fixture, mode], { + stdio: ['ignore', 'ignore', 'pipe'] + }) + let stderr = '' + child.stderr.setEncoding('utf8') + child.stderr.on('data', chunk => { + stderr += chunk + }) + + const timer = setTimeout(() => { + child.kill() + reject(new Error(`${mode} fixture retained a handle after completion`)) + }, 5000) + + child.once('error', error => { + clearTimeout(timer) + reject(error) + }) + child.once('exit', code => { + clearTimeout(timer) + resolve({code, stderr}) + }) + }) +} + const setup = async (): Promise => { noopLogs() await fs.promises.mkdir(testDir, {recursive: true}) @@ -96,8 +134,9 @@ const setup = async (): Promise => { } const cleanup = async (): Promise => { + jest.useRealTimers() jest.restoreAllMocks() - await fs.promises.rm(testDir, {recursive: true}) + await fs.promises.rm(testDir, {recursive: true, force: true}) delete process.env['GITHUB_WORKSPACE'] } @@ -145,6 +184,21 @@ const mockGetArtifactMalicious = jest.fn(() => { }) describe('download-artifact', () => { + describe('process cleanup', () => { + it('should exit naturally after a timeout followed by success', async () => { + await expect(runProcessFixture('success')).resolves.toEqual({ + code: 0, + stderr: '' + }) + }) + + it('should exit nonzero naturally after timeout exhaustion', async () => { + const result = await runProcessFixture('failure') + expect(result.code).toBe(1) + expect(result.stderr).toContain('did not respond in 25ms') + }) + }) + describe('public', () => { beforeEach(setup) afterEach(cleanup) @@ -164,7 +218,8 @@ describe('download-artifact', () => { const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( () => { return { - get: mockGetArtifactSuccess + get: mockGetArtifactSuccess, + dispose: jest.fn() } } ) @@ -208,7 +263,8 @@ describe('download-artifact', () => { const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( () => { return { - get: mockGetArtifactMalicious + get: mockGetArtifactMalicious, + dispose: jest.fn() } } ) @@ -263,7 +319,8 @@ describe('download-artifact', () => { const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( () => { return { - get: mockGetArtifactSuccess + get: mockGetArtifactSuccess, + dispose: jest.fn() } } ) @@ -342,7 +399,8 @@ describe('download-artifact', () => { const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( () => { return { - get: mockGet + get: mockGet, + dispose: jest.fn() } } ) @@ -369,7 +427,8 @@ describe('download-artifact', () => { const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( () => { return { - get: mockGetArtifactFailure + get: mockGetArtifactFailure, + dispose: jest.fn() } } ) @@ -418,7 +477,8 @@ describe('download-artifact', () => { const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( () => { return { - get: mockGetArtifact + get: mockGetArtifact, + dispose: jest.fn() } } ) @@ -495,7 +555,8 @@ describe('download-artifact', () => { const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( () => { return { - get: mockGetArtifactSuccess + get: mockGetArtifactSuccess, + dispose: jest.fn() } } ) @@ -544,7 +605,8 @@ describe('download-artifact', () => { const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( () => { return { - get: mockGetArtifactSuccess + get: mockGetArtifactSuccess, + dispose: jest.fn() } } ) @@ -603,7 +665,8 @@ describe('download-artifact', () => { const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( () => { return { - get: mockGetArtifactFailure + get: mockGetArtifactFailure, + dispose: jest.fn() } } ) @@ -633,13 +696,327 @@ describe('download-artifact', () => { }) afterEach(cleanup) + const mockClient = ( + get: jest.Mock + ): {get: jest.Mock; dispose: jest.Mock} => ({ + get, + dispose: jest.fn() + }) + + const waitForTimer = async (): Promise => { + for (let turn = 0; turn < 20; turn++) { + if (jest.getTimerCount() > 0) { + return + } + await jest.advanceTimersByTimeAsync(0) + } + throw new Error('Attempt did not install its timer') + } + + it('should dispose a timed-out attempt before a later attempt succeeds', async () => { + jest.useFakeTimers() + const downloadArtifactMock = github.getOctokit(fixtures.token).rest + .actions.downloadArtifact as MockedDownloadArtifact + downloadArtifactMock.mockResolvedValueOnce({ + headers: {location: fixtures.blobStorageUrl}, + status: 302, + url: '', + data: Buffer.from('') + }) + + const clients = [ + mockClient(jest.fn(mockGetArtifactHung)), + mockClient(jest.fn(mockGetArtifactSuccess)) + ] + ;(HttpClient as jest.Mock).mockImplementation(() => clients.shift()) + + const download = downloadArtifactPublic( + fixtures.artifactID, + fixtures.repositoryOwner, + fixtures.repositoryName, + fixtures.token, + {skipDecompress: true} + ) + await waitForTimer() + await jest.advanceTimersByTimeAsync(30 * 1000) + await waitForTimer() + await jest.advanceTimersByTimeAsync(5 * 1000) + + await expect(download).resolves.toMatchObject({ + downloadPath: fixtures.workspaceDir, + digestMismatch: false + }) + expect(HttpClient).toHaveBeenCalledTimes(2) + expect(clients).toHaveLength(0) + expect(mockGetArtifactHung.mock.results[0].value.message.destroyed).toBe( + true + ) + for (const client of (HttpClient as jest.Mock).mock.results) { + expect(client.value.dispose).toHaveBeenCalledTimes(1) + } + expect(jest.getTimerCount()).toBe(0) + }) + + it('should dispose every attempt when all attempts time out', async () => { + jest.useFakeTimers() + const downloadArtifactMock = github.getOctokit(fixtures.token).rest + .actions.downloadArtifact as MockedDownloadArtifact + downloadArtifactMock.mockResolvedValueOnce({ + headers: {location: fixtures.blobStorageUrl}, + status: 302, + url: '', + data: Buffer.from('') + }) + + const clients = Array.from({length: 5}, () => + mockClient(jest.fn(mockGetArtifactHung)) + ) + const pendingClients = [...clients] + ;(HttpClient as jest.Mock).mockImplementation(() => + pendingClients.shift() + ) + + const outcome = downloadArtifactPublic( + fixtures.artifactID, + fixtures.repositoryOwner, + fixtures.repositoryName, + fixtures.token + ).catch(error => error as Error) + for (let attempt = 0; attempt < 5; attempt++) { + await waitForTimer() + await jest.advanceTimersByTimeAsync(30 * 1000) + await waitForTimer() + await jest.advanceTimersByTimeAsync(5 * 1000) + } + + await expect(outcome).resolves.toThrow( + 'Unable to download and extract artifact: Artifact download failed after 5 retries.' + ) + expect(HttpClient).toHaveBeenCalledTimes(5) + for (const client of clients) { + expect(client.dispose).toHaveBeenCalledTimes(1) + expect(client.get.mock.results[0].value.message.destroyed).toBe(true) + } + expect(jest.getTimerCount()).toBe(0) + }) + + it('should clean up a response error before completion', async () => { + jest.useFakeTimers() + const responseError = new Error('response failed') + const message = mockGetArtifactHung().message + const client = mockClient(jest.fn(() => ({message}))) + ;(HttpClient as jest.Mock).mockImplementation(() => client) + + const extraction = streamExtractExternal( + fixtures.blobStorageUrl, + fixtures.workspaceDir, + {timeout: 1000} + ) + await waitForTimer() + message.destroy(responseError) + + await expect(extraction).rejects.toBe(responseError) + expect(message.destroyed).toBe(true) + expect(client.dispose).toHaveBeenCalledTimes(1) + expect(jest.getTimerCount()).toBe(0) + }) + + it('should destroy every stream owned by a timed-out attempt', async () => { + jest.useFakeTimers() + const passThroughDestroy = jest.spyOn( + stream.PassThrough.prototype, + 'destroy' + ) + const transformDestroy = jest.spyOn(stream.Transform.prototype, 'destroy') + const message = mockGetArtifactHung().message + const client = mockClient(jest.fn(() => ({message}))) + ;(HttpClient as jest.Mock).mockImplementation(() => client) + + const outcome = streamExtractExternal( + fixtures.blobStorageUrl, + fixtures.workspaceDir, + {timeout: 1000} + ).catch(error => error as Error) + await waitForTimer() + await jest.advanceTimersByTimeAsync(1000) + + await expect(outcome).resolves.toBeInstanceOf(Error) + expect(message.destroyed).toBe(true) + expect(passThroughDestroy).toHaveBeenCalledTimes(1) + expect(transformDestroy).toHaveBeenCalledTimes(2) + expect(client.dispose).toHaveBeenCalledTimes(1) + expect(jest.getTimerCount()).toBe(0) + }) + + it('should dispose the client and response for a non-200 response', async () => { + const response = mockGetArtifactFailure() + const client = mockClient(jest.fn(() => response)) + ;(HttpClient as jest.Mock).mockImplementation(() => client) + + await expect( + streamExtractExternal(fixtures.blobStorageUrl, fixtures.workspaceDir) + ).rejects.toThrow('Unexpected HTTP response from blob storage: 500') + expect(response.message.destroyed).toBe(true) + expect(client.dispose).toHaveBeenCalledTimes(1) + }) + + it('should clean up an extraction failure', async () => { + jest.useFakeTimers() + const message = new http.IncomingMessage(new net.Socket()) + message.statusCode = 200 + message.headers['content-type'] = 'application/zip' + message.push(Buffer.from('not a zip archive')) + message.push(null) + const client = mockClient(jest.fn(() => ({message}))) + ;(HttpClient as jest.Mock).mockImplementation(() => client) + + await expect( + streamExtractExternal(fixtures.blobStorageUrl, fixtures.workspaceDir, { + timeout: 1000 + }) + ).rejects.toBeInstanceOf(Error) + expect(client.dispose).toHaveBeenCalledTimes(1) + expect(jest.getTimerCount()).toBe(0) + }) + + it('should clean up an output write failure', async () => { + jest.useFakeTimers() + const writeError = new Error('write failed') + const failingOutput = new stream.Writable({ + write(_chunk, _encoding, callback) { + callback(writeError) + } + }) + jest + .spyOn(fs, 'createWriteStream') + .mockReturnValue(failingOutput as fs.WriteStream) + + const message = new http.IncomingMessage(new net.Socket()) + message.statusCode = 200 + message.headers['content-type'] = 'text/plain' + message.push(Buffer.from('artifact contents')) + message.push(null) + const client = mockClient(jest.fn(() => ({message}))) + ;(HttpClient as jest.Mock).mockImplementation(() => client) + + await expect( + streamExtractExternal(fixtures.blobStorageUrl, fixtures.workspaceDir, { + timeout: 1000 + }) + ).rejects.toBe(writeError) + expect(failingOutput.destroyed).toBe(true) + expect(client.dispose).toHaveBeenCalledTimes(1) + expect(jest.getTimerCount()).toBe(0) + }) + + it('should not clean up a successful attempt before output completes', async () => { + jest.useFakeTimers() + let completeWrite: (() => void) | undefined + const controlledOutput = new stream.Writable({ + write(_chunk, _encoding, callback) { + completeWrite = callback + } + }) + jest + .spyOn(fs, 'createWriteStream') + .mockReturnValue(controlledOutput as fs.WriteStream) + + const message = new http.IncomingMessage(new net.Socket()) + message.statusCode = 200 + message.headers['content-type'] = 'text/plain' + message.push(Buffer.from('artifact contents')) + message.push(null) + const client = mockClient(jest.fn(() => ({message}))) + ;(HttpClient as jest.Mock).mockImplementation(() => client) + + const extraction = streamExtractExternal( + fixtures.blobStorageUrl, + fixtures.workspaceDir, + {timeout: 1000} + ) + for (let turn = 0; turn < 20 && !completeWrite; turn++) { + await jest.advanceTimersByTimeAsync(0) + } + + expect(completeWrite).toBeDefined() + expect(controlledOutput.destroyed).toBe(false) + expect(client.dispose).not.toHaveBeenCalled() + + completeWrite?.() + await expect(extraction).resolves.toMatchObject({ + sha256Digest: expect.stringMatching(/^sha256:[0-9a-f]{64}$/) + }) + expect(client.dispose).toHaveBeenCalledTimes(1) + expect(jest.getTimerCount()).toBe(0) + }) + + it('should clean up a hash failure', async () => { + jest.useFakeTimers() + const hashPrototype = Object.getPrototypeOf(crypto.createHash('sha256')) + jest.spyOn(hashPrototype, 'update').mockImplementationOnce(() => { + throw new Error('hash failed') + }) + const client = mockClient(jest.fn(mockGetArtifactSuccess)) + ;(HttpClient as jest.Mock).mockImplementation(() => client) + + await expect( + streamExtractExternal(fixtures.blobStorageUrl, fixtures.workspaceDir, { + timeout: 1000 + }) + ).rejects.toThrow('hash failed') + expect(client.dispose).toHaveBeenCalledTimes(1) + expect(jest.getTimerCount()).toBe(0) + }) + + it('should settle cleanup once when timeout races response completion', async () => { + jest.useFakeTimers() + const message = mockGetArtifactHung().message + const client = mockClient(jest.fn(() => ({message}))) + ;(HttpClient as jest.Mock).mockImplementation(() => client) + + const outcome = streamExtractExternal( + fixtures.blobStorageUrl, + fixtures.workspaceDir, + {timeout: 1000} + ).catch(error => error as Error) + await waitForTimer() + message.push(fs.readFileSync(fixtures.exampleArtifact.path)) + message.push(null) + await jest.advanceTimersByTimeAsync(1000) + + await expect(outcome).resolves.toBeDefined() + expect(client.dispose).toHaveBeenCalledTimes(1) + expect(jest.getTimerCount()).toBe(0) + }) + + it('should settle cleanup once when timeout races a response error', async () => { + jest.useFakeTimers() + const message = mockGetArtifactHung().message + const client = mockClient(jest.fn(() => ({message}))) + ;(HttpClient as jest.Mock).mockImplementation(() => client) + + const outcome = streamExtractExternal( + fixtures.blobStorageUrl, + fixtures.workspaceDir, + {timeout: 1000} + ).catch(error => error as Error) + await waitForTimer() + message.destroy(new Error('response failed during timeout')) + await jest.advanceTimersByTimeAsync(1000) + + await expect(outcome).resolves.toBeInstanceOf(Error) + expect(client.dispose).toHaveBeenCalledTimes(1) + expect(jest.getTimerCount()).toBe(0) + }) + it('should fail if the timeout is exceeded', async () => { const mockSlowGetArtifact = jest.fn(mockGetArtifactHung) const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( () => { return { - get: mockSlowGetArtifact + get: mockSlowGetArtifact, + dispose: jest.fn() } } ) @@ -664,7 +1041,8 @@ describe('download-artifact', () => { const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( () => { return { - get: mockGetArtifactSuccess + get: mockGetArtifactSuccess, + dispose: jest.fn() } } ) @@ -699,7 +1077,8 @@ describe('download-artifact', () => { const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( () => { return { - get: mockGetRawFile + get: mockGetRawFile, + dispose: jest.fn() } } ) @@ -734,7 +1113,8 @@ describe('download-artifact', () => { const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( () => { return { - get: mockGetRawFileNoDisposition + get: mockGetRawFileNoDisposition, + dispose: jest.fn() } } ) @@ -774,7 +1154,8 @@ describe('download-artifact', () => { const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( () => { return { - get: mockGetPngFile + get: mockGetPngFile, + dispose: jest.fn() } } ) @@ -806,7 +1187,8 @@ describe('download-artifact', () => { const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( () => { return { - get: mockGetZipCompressed + get: mockGetZipCompressed, + dispose: jest.fn() } } ) @@ -840,7 +1222,8 @@ describe('download-artifact', () => { const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( () => { return { - get: mockGetZipByUrl + get: mockGetZipByUrl, + dispose: jest.fn() } } ) @@ -859,7 +1242,8 @@ describe('download-artifact', () => { const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( () => { return { - get: mockGetArtifactSuccess + get: mockGetArtifactSuccess, + dispose: jest.fn() } } ) @@ -901,7 +1285,8 @@ describe('download-artifact', () => { const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( () => { return { - get: mockGetMaliciousFile + get: mockGetMaliciousFile, + dispose: jest.fn() } } ) @@ -947,7 +1332,8 @@ describe('download-artifact', () => { const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( () => { return { - get: mockGetEncodedMaliciousFile + get: mockGetEncodedMaliciousFile, + dispose: jest.fn() } } ) @@ -1000,7 +1386,8 @@ describe('download-artifact', () => { const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( () => { return { - get: mockGetRfc5987File + get: mockGetRfc5987File, + dispose: jest.fn() } } ) @@ -1038,7 +1425,8 @@ describe('download-artifact', () => { const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( () => { return { - get: mockGetZip + get: mockGetZip, + dispose: jest.fn() } } ) @@ -1081,7 +1469,8 @@ describe('download-artifact', () => { const mockHttpClient = (HttpClient as jest.Mock).mockImplementation( () => { return { - get: mockGetFile + get: mockGetFile, + dispose: jest.fn() } } ) diff --git a/packages/artifact/__tests__/fixtures/download-attempt-process.mjs b/packages/artifact/__tests__/fixtures/download-attempt-process.mjs new file mode 100644 index 0000000000..d87650fae7 --- /dev/null +++ b/packages/artifact/__tests__/fixtures/download-attempt-process.mjs @@ -0,0 +1,68 @@ +import fs from 'fs/promises' +import http from 'http' +import os from 'os' +import path from 'path' + +import {streamExtractExternal} from '../../lib/internal/download/download-artifact.js' + +const mode = process.argv[2] +if (mode !== 'success' && mode !== 'failure') { + throw new Error(`Unknown fixture mode: ${mode}`) +} + +const directory = await fs.mkdtemp( + path.join(os.tmpdir(), 'artifact-download-cleanup-') +) +let requestCount = 0 + +const server = http.createServer((_request, response) => { + requestCount++ + response.writeHead(200, { + 'content-type': 'text/plain', + 'content-disposition': 'attachment; filename="artifact.txt"' + }) + + if (mode === 'success' && requestCount === 2) { + response.end('downloaded artifact') + return + } + + response.write('partial response') +}) + +await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) +}) + +try { + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Fixture server did not bind a TCP port') + } + const url = `http://127.0.0.1:${address.port}/artifact` + const attempts = mode === 'success' ? 2 : 3 + + let lastError + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + await streamExtractExternal(url, directory, {timeout: 25}) + lastError = undefined + break + } catch (error) { + lastError = error + } + } + + if (lastError) { + throw lastError + } +} catch (error) { + console.error(error.message) + process.exitCode = 1 +} finally { + await new Promise((resolve, reject) => { + server.close(error => (error ? reject(error) : resolve())) + }) + await fs.rm(directory, {recursive: true, force: true}) +} diff --git a/packages/artifact/src/internal/download/download-artifact.ts b/packages/artifact/src/internal/download/download-artifact.ts index 9e85347038..0d1bb6783b 100644 --- a/packages/artifact/src/internal/download/download-artifact.ts +++ b/packages/artifact/src/internal/download/download-artifact.ts @@ -72,105 +72,161 @@ export async function streamExtractExternal( ): Promise { const {timeout = 30 * 1000, skipDecompress = false} = opts const client = new httpClient.HttpClient(getUserAgentString()) - const response = await client.get(url) - if (response.message.statusCode !== 200) { - throw new Error( - `Unexpected HTTP response from blob storage: ${response.message.statusCode} ${response.message.statusMessage}` - ) - } + let responseMessage: stream.Readable | undefined + let passThrough: stream.PassThrough | undefined + let hashStream: stream.Transform | undefined + let outputStream: stream.Writable | undefined + let timer: NodeJS.Timeout | undefined + let settled = false + + const cleanup = (failed: boolean): boolean => { + if (settled) { + return false + } + settled = true - const contentType = response.message.headers['content-type'] || '' - const mimeType = contentType.split(';', 1)[0].trim().toLowerCase() - - // Check if the URL path ends with .zip (ignoring query parameters) - const urlPath = new URL(url).pathname.toLowerCase() - const urlEndsWithZip = urlPath.endsWith('.zip') - - const isZip = - mimeType === 'application/zip' || - mimeType === 'application/x-zip-compressed' || - mimeType === 'application/zip-compressed' || - urlEndsWithZip - - // Extract filename from Content-Disposition header - // Prefer filename* (RFC 5987) which supports UTF-8 encoded filenames, - // fall back to filename which may contain ASCII-only replacements - const contentDisposition = - response.message.headers['content-disposition'] || '' - let fileName = 'artifact' - const filenameStar = contentDisposition.match( - /filename\*\s*=\s*UTF-8''([^;\r\n]*)/i - ) - const filenamePlain = contentDisposition.match( - /(? { - const timerFn = (): void => { - const timeoutError = new Error( - `Blob storage chunk did not respond in ${timeout}ms` + client.dispose() + return true + } + + try { + const response = await client.get(url) + responseMessage = response.message + if (response.message.statusCode !== 200) { + throw new Error( + `Unexpected HTTP response from blob storage: ${response.message.statusCode} ${response.message.statusMessage}` ) - response.message.destroy(timeoutError) - reject(timeoutError) } - const timer = setTimeout(timerFn, timeout) - const onError = (error: Error): void => { - core.debug(`response.message: Artifact download failed: ${error.message}`) - clearTimeout(timer) - reject(error) + const contentType = response.message.headers['content-type'] || '' + const mimeType = contentType.split(';', 1)[0].trim().toLowerCase() + + // Check if the URL path ends with .zip (ignoring query parameters) + const urlPath = new URL(url).pathname.toLowerCase() + const urlEndsWithZip = urlPath.endsWith('.zip') + + const isZip = + mimeType === 'application/zip' || + mimeType === 'application/x-zip-compressed' || + mimeType === 'application/zip-compressed' || + urlEndsWithZip + + // Extract filename from Content-Disposition header + // Prefer filename* (RFC 5987) which supports UTF-8 encoded filenames, + // fall back to filename which may contain ASCII-only replacements + const contentDisposition = + response.message.headers['content-disposition'] || '' + let fileName = 'artifact' + const filenameStar = contentDisposition.match( + /filename\*\s*=\s*UTF-8''([^;\r\n]*)/i + ) + const filenamePlain = contentDisposition.match( + /(? { - timer.refresh() - }) - .on('error', onError) + core.debug( + `Content-Type: ${contentType}, mimeType: ${mimeType}, urlEndsWithZip: ${urlEndsWithZip}, isZip: ${isZip}, skipDecompress: ${skipDecompress}` + ) + core.debug( + `Content-Disposition: ${contentDisposition}, fileName: ${fileName}` + ) - response.message.pipe(passThrough) - passThrough.pipe(hashStream) + const hash = crypto.createHash('sha256') - const onClose = (): void => { - clearTimeout(timer) - if (hashStream) { - hashStream.end() - sha256Digest = hashStream.read() as string - core.info(`SHA256 digest of downloaded artifact is ${sha256Digest}`) + return await new Promise((resolve, reject) => { + const onError = (error: Error): void => { + core.debug(`Artifact download failed: ${error.message}`) + if (cleanup(true)) { + reject(error) + } } - resolve({sha256Digest: `sha256:${sha256Digest}`}) - } - if (isZip && !skipDecompress) { - // Extract zip file - passThrough - .pipe(unzip.Extract({path: directory})) - .on('close', onClose) - .on('error', onError) - } else { - // Save raw file without extracting - const filePath = path.join(directory, fileName) - const writeStream = fsSync.createWriteStream(filePath) + const onComplete = (): void => { + if (settled) { + return + } + + let sha256Digest: string + try { + sha256Digest = hash.digest('hex') + } catch (error) { + onError(error as Error) + return + } + + if (cleanup(false)) { + core.info(`SHA256 digest of downloaded artifact is ${sha256Digest}`) + resolve({sha256Digest: `sha256:${sha256Digest}`}) + } + } - core.info(`Downloading raw file (non-zip) to: ${filePath}`) - passThrough.pipe(writeStream).on('close', onClose).on('error', onError) - } - }) + passThrough = new stream.PassThrough().on('data', () => { + timer?.refresh() + }) + hashStream = new stream.Transform({ + transform(chunk, _encoding, callback) { + try { + hash.update(chunk) + callback(null, chunk) + } catch (error) { + callback(error as Error) + } + } + }) + + let attemptOutputStream: stream.Writable + if (isZip && !skipDecompress) { + attemptOutputStream = unzip.Extract({ + path: directory + }) as stream.Writable + attemptOutputStream.once('finish', onComplete) + } else { + const filePath = path.join(directory, fileName) + attemptOutputStream = fsSync.createWriteStream(filePath) + attemptOutputStream.once('close', onComplete) + core.info(`Downloading raw file (non-zip) to: ${filePath}`) + } + outputStream = attemptOutputStream + + response.message.once('error', onError) + passThrough.once('error', onError) + hashStream.once('error', onError) + attemptOutputStream.once('error', onError) + + timer = setTimeout(() => { + onError(new Error(`Blob storage chunk did not respond in ${timeout}ms`)) + }, timeout) + + response.message + .pipe(passThrough) + .pipe(hashStream) + .pipe(attemptOutputStream) + }) + } catch (error) { + cleanup(true) + throw error + } } export async function downloadArtifactPublic(