@@ -16,7 +16,7 @@ import {
1616} from "node:fs" ;
1717import { arch , platform } from "node:os" ;
1818import { join } from "node:path" ;
19- import { Readable } from "node:stream" ;
19+ import { PassThrough , Readable } from "node:stream" ;
2020import { pipeline } from "node:stream/promises" ;
2121import type { ReadableStream as NodeReadableStream } from "node:stream/web" ;
2222import chalk from "chalk" ;
@@ -27,6 +27,10 @@ import { APP_NAME, getBinDir } from "../config.js";
2727const TOOLS_DIR = getBinDir ( ) ;
2828const NETWORK_TIMEOUT_MS = 10_000 ;
2929const DOWNLOAD_TIMEOUT_MS = 120_000 ;
30+ // Maximum compressed archive size allowed for tool downloads (100 MB).
31+ // Matches the limit enforced during extraction so oversized archives are
32+ // rejected before hitting disk, not just when extractArchiveSafe runs.
33+ const MAX_ARCHIVE_BYTES = 100 * 1024 * 1024 ;
3034
3135async function cancelUnreadResponseBody ( response : Response ) : Promise < void > {
3236 if ( ! response . bodyUsed ) {
@@ -158,8 +162,12 @@ async function getLatestVersion(repo: string): Promise<string> {
158162 }
159163}
160164
161- // Download a file from URL
162- async function downloadFile ( url : string , dest : string ) : Promise < void > {
165+ // Download a file from URL with an optional byte cap. When maxBytes is set the
166+ // Content-Length header is checked before the body is read and a streaming byte
167+ // counter aborts the pipeline mid-transfer if the cap is exceeded. Without this
168+ // check an oversized release asset would fully download to disk before the
169+ // compression-bomb guard in extractArchiveSafe has a chance to reject it.
170+ async function downloadFile ( url : string , dest : string , maxBytes ?: number ) : Promise < void > {
163171 const guarded = await fetchWithSsrFGuard ( {
164172 url,
165173 timeoutMs : DOWNLOAD_TIMEOUT_MS ,
@@ -177,8 +185,46 @@ async function downloadFile(url: string, dest: string): Promise<void> {
177185 throw new Error ( "No response body" ) ;
178186 }
179187
188+ // Check Content-Length before reading the body so oversized responses are
189+ // rejected without consuming any download bandwidth or disk space.
190+ if ( maxBytes !== undefined ) {
191+ const contentLength = response . headers . get ( "content-length" ) ;
192+ if ( contentLength !== null ) {
193+ const length = parseInt ( contentLength , 10 ) ;
194+ if ( ! isNaN ( length ) && length > maxBytes ) {
195+ await cancelUnreadResponseBody ( response ) ;
196+ throw new Error (
197+ `Download aborted: content-length ${ length } exceeds ${ maxBytes } byte limit` ,
198+ ) ;
199+ }
200+ }
201+ }
202+
180203 const fileStream = createWriteStream ( dest ) ;
181- await pipeline ( Readable . fromWeb ( response . body as NodeReadableStream < Uint8Array > ) , fileStream ) ;
204+
205+ if ( maxBytes !== undefined ) {
206+ // Streaming byte counter — catches oversized responses even when the
207+ // Content-Length header is missing or under-reported.
208+ let downloaded = 0 ;
209+ const byteCap = new PassThrough ( ) ;
210+ byteCap . on ( "data" , ( chunk : Buffer ) => {
211+ downloaded += chunk . length ;
212+ if ( downloaded > maxBytes ) {
213+ byteCap . destroy (
214+ new Error (
215+ `Download aborted: exceeded ${ maxBytes } byte limit after ${ downloaded } bytes` ,
216+ ) ,
217+ ) ;
218+ }
219+ } ) ;
220+ await pipeline (
221+ Readable . fromWeb ( response . body as NodeReadableStream < Uint8Array > ) ,
222+ byteCap ,
223+ fileStream ,
224+ ) ;
225+ } else {
226+ await pipeline ( Readable . fromWeb ( response . body as NodeReadableStream < Uint8Array > ) , fileStream ) ;
227+ }
182228 } finally {
183229 await guarded . release ( ) ;
184230 }
@@ -223,7 +269,7 @@ async function extractArchiveSafe(
223269 destDir : extractDir ,
224270 timeoutMs : 60_000 ,
225271 limits : {
226- maxArchiveBytes : 100 * 1024 * 1024 ,
272+ maxArchiveBytes : MAX_ARCHIVE_BYTES ,
227273 maxExtractedBytes : 500 * 1024 * 1024 ,
228274 maxEntries : 1000 ,
229275 } ,
@@ -265,8 +311,9 @@ async function downloadTool(tool: "fd" | "rg"): Promise<string> {
265311 const binaryExt = plat === "win32" ? ".exe" : "" ;
266312 const binaryPath = join ( TOOLS_DIR , config . binaryName + binaryExt ) ;
267313
268- // Download
269- await downloadFile ( downloadUrl , archivePath ) ;
314+ // Download with byte cap so oversized archives are rejected before
315+ // hitting disk, not just during extraction.
316+ await downloadFile ( downloadUrl , archivePath , MAX_ARCHIVE_BYTES ) ;
270317
271318 // Extract into a unique temp directory. fd and rg downloads can run concurrently
272319 // during startup, so sharing a fixed directory causes races.
0 commit comments