From 823231dfd5f2b1aa5ad2c0c1c84b74c4ec9a9782 Mon Sep 17 00:00:00 2001 From: Andrey Dodonov Date: Wed, 13 May 2026 13:18:13 +0200 Subject: [PATCH 1/2] Propagate download error and verify length Signed-off-by: Andrey Dodonov --- .../src/internal/download/download-artifact.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/artifact/src/internal/download/download-artifact.ts b/packages/artifact/src/internal/download/download-artifact.ts index 9e85347038..9bc8a307bb 100644 --- a/packages/artifact/src/internal/download/download-artifact.ts +++ b/packages/artifact/src/internal/download/download-artifact.ts @@ -136,10 +136,16 @@ export async function streamExtractExternal( reject(error) } + response.message.on('error', onError) + + const expectedBytes = + Number(response.message.headers['content-length']) || undefined + let bytesReceived = 0 const hashStream = crypto.createHash('sha256').setEncoding('hex') const passThrough = new stream.PassThrough() - .on('data', () => { + .on('data', (chunk: Buffer) => { timer.refresh() + bytesReceived += chunk.length }) .on('error', onError) @@ -148,6 +154,15 @@ export async function streamExtractExternal( const onClose = (): void => { clearTimeout(timer) + if (expectedBytes && bytesReceived !== expectedBytes) { + reject( + new Error( + `Incomplete download: received ${bytesReceived} bytes but expected ${expectedBytes}` + ) + ) + return + } + if (hashStream) { hashStream.end() sha256Digest = hashStream.read() as string From 3e7163ad3f7886f896a89beeadafcc8cd84cd16b Mon Sep 17 00:00:00 2001 From: Andrey Dodonov Date: Tue, 2 Jun 2026 10:32:45 +0200 Subject: [PATCH 2/2] Add tests for applied fixes --- .../__tests__/download-artifact.test.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/artifact/__tests__/download-artifact.test.ts b/packages/artifact/__tests__/download-artifact.test.ts index 1e4d8d1ebe..fcaf425812 100644 --- a/packages/artifact/__tests__/download-artifact.test.ts +++ b/packages/artifact/__tests__/download-artifact.test.ts @@ -1050,6 +1050,69 @@ describe('download-artifact', () => { await expectExtractedArchive(fixtures.workspaceDir) }) + it('should reject when content-length does not match bytes received (incomplete download)', async () => { + const rawFileContent = 'partial content' + + const mockGetIncomplete = jest.fn(() => { + const message = new http.IncomingMessage(new net.Socket()) + message.statusCode = 200 + message.headers['content-type'] = 'text/plain' + message.headers['content-disposition'] = 'attachment; filename="data.txt"' + // Advertise more bytes than we actually send + message.headers['content-length'] = '9999' + message.push(Buffer.from(rawFileContent)) + message.push(null) + return { + message + } + }) + + ;(HttpClient as jest.Mock).mockImplementation(() => { + return { + get: mockGetIncomplete + } + }) + + await expect( + streamExtractExternal( + fixtures.blobStorageUrl, + fixtures.workspaceDir + ) + ).rejects.toThrow( + `Incomplete download: received ${Buffer.byteLength(rawFileContent)} bytes but expected 9999` + ) + }) + + it('should reject when the response stream emits an error', async () => { + const mockGetStreamError = jest.fn(() => { + const message = new http.IncomingMessage(new net.Socket()) + message.statusCode = 200 + message.headers['content-type'] = 'text/plain' + message.headers['content-disposition'] = + 'attachment; filename="data.txt"' + // Emit an error after a short delay + process.nextTick(() => { + message.destroy(new Error('connection reset')) + }) + return { + message + } + }) + + ;(HttpClient as jest.Mock).mockImplementation(() => { + return { + get: mockGetStreamError + } + }) + + await expect( + streamExtractExternal( + fixtures.blobStorageUrl, + fixtures.workspaceDir + ) + ).rejects.toThrow('connection reset') + }) + it.each([ ['土', '_'], // U+571F - known to cause 400 errors ['日', '_'], // U+65E5 - reported to work fine