From 1998a487578e7284c6b06777ca1ae79534ecc7b1 Mon Sep 17 00:00:00 2001 From: shaoohh <150606856+shaoohh@users.noreply.github.com> Date: Sun, 19 Jul 2026 01:49:32 +0800 Subject: [PATCH] fix: reject truncated downloads --- src/files/download.ts | 13 ++++++++++++- test/files/download.test.ts | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/files/download.ts b/src/files/download.ts index e34be1f6..b8202c89 100644 --- a/src/files/download.ts +++ b/src/files/download.ts @@ -37,7 +37,11 @@ export async function downloadFile( throw new CLIError(`Download failed: HTTP ${res.status}`, ExitCode.GENERAL); } - const contentLength = Number(res.headers.get('content-length') || 0); + // fetch transparently decodes compressed responses, so Content-Length + // only describes the readable body when no Content-Encoding is present. + const contentLength = res.headers.get('content-encoding') + ? 0 + : Number(res.headers.get('content-length') || 0); const reader = res.body?.getReader(); if (!reader) throw new CLIError('No response body', ExitCode.GENERAL); @@ -69,6 +73,13 @@ export async function downloadFile( received += value.byteLength; progress?.update(received); } + + if (contentLength > 0 && received !== contentLength) { + throw new CLIError( + `Download truncated: expected ${contentLength} bytes, received ${received}.`, + ExitCode.NETWORK, + ); + } readComplete = true; } finally { reader.releaseLock(); diff --git a/test/files/download.test.ts b/test/files/download.test.ts index 785afe82..a4f1029e 100644 --- a/test/files/download.test.ts +++ b/test/files/download.test.ts @@ -71,4 +71,41 @@ describe('downloadFile', () => { expect(existsSync(destPath)).toBe(true); expect(readdirSync(dir)).toEqual(['video.mp4']); }); + + it('rejects a cleanly ended response whose body is shorter than Content-Length', async () => { + const dir = makeTempDir(); + const destPath = join(dir, 'video.mp4'); + writeFileSync(destPath, 'original'); + + globalThis.fetch = (async () => new Response(new TextEncoder().encode('new'), { + status: 200, + headers: { 'content-length': '10' }, + })) as unknown as typeof fetch; + + await expect( + downloadFile('https://example.com/video.mp4', destPath, { quiet: true, retries: 0 }), + ).rejects.toThrow('Download truncated: expected 10 bytes, received 3'); + + expect(readFileSync(destPath, 'utf-8')).toBe('original'); + expect(readdirSync(dir)).toEqual(['video.mp4']); + }); + + it('does not compare decoded response bytes with compressed Content-Length', async () => { + const dir = makeTempDir(); + const destPath = join(dir, 'video.mp4'); + + globalThis.fetch = (async () => new Response(new TextEncoder().encode('new'), { + status: 200, + headers: { + 'content-encoding': 'gzip', + 'content-length': '10', + }, + })) as unknown as typeof fetch; + + await expect( + downloadFile('https://example.com/video.mp4', destPath, { quiet: true, retries: 0 }), + ).resolves.toEqual({ size: 3 }); + + expect(readFileSync(destPath, 'utf-8')).toBe('new'); + }); });