@@ -63,6 +63,7 @@ describe("Destructive Command Guard manager", () => {
6363 expect ( isTrustedDownloadUrl ( "https://cdn.objects.githubusercontent.com/release" ) ) . toBe ( true )
6464 expect ( isTrustedDownloadUrl ( "http://github.com/release" ) ) . toBe ( false )
6565 expect ( isTrustedDownloadUrl ( "https://evilgithub.com/release" ) ) . toBe ( false )
66+ expect ( isTrustedDownloadUrl ( "https://github.com.evil.com/release" ) ) . toBe ( false )
6667 expect ( isTrustedDownloadUrl ( "not a URL" ) ) . toBe ( false )
6768 } )
6869
@@ -72,6 +73,61 @@ describe("Destructive Command Guard manager", () => {
7273 )
7374 } )
7475
76+ it ( "rejects non-successful HTTP responses" , async ( ) => {
77+ const response = Object . assign ( new PassThrough ( ) , { statusCode : 503 , headers : { } , destroy : vi . fn ( ) } )
78+ const request = Object . assign ( new EventEmitter ( ) , { setTimeout : vi . fn ( ) , destroy : vi . fn ( ) } )
79+ mockGet . mockImplementation ( ( _url , optionsOrCallback , optionalCallback ) => {
80+ const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback
81+ setImmediate ( ( ) => callback ?.( response as unknown as IncomingMessage ) )
82+ return request as unknown as ReturnType < typeof get >
83+ } )
84+
85+ await expect ( downloadFile ( "https://github.com/release" , path . join ( tempDir , "archive" ) ) ) . rejects . toThrow (
86+ "DCG download failed with HTTP 503" ,
87+ )
88+ } )
89+
90+ it ( "rejects request errors" , async ( ) => {
91+ const request = Object . assign ( new EventEmitter ( ) , { setTimeout : vi . fn ( ) , destroy : vi . fn ( ) } )
92+ mockGet . mockReturnValue ( request as unknown as ReturnType < typeof get > )
93+
94+ const download = downloadFile ( "https://github.com/release" , path . join ( tempDir , "archive" ) )
95+ request . emit ( "error" , new Error ( "socket failed" ) )
96+
97+ await expect ( download ) . rejects . toThrow ( "socket failed" )
98+ } )
99+
100+ it ( "times out stalled requests" , async ( ) => {
101+ const request = Object . assign ( new EventEmitter ( ) , {
102+ setTimeout : vi . fn ( ( _timeout : number , callback : ( ) => void ) => setImmediate ( callback ) ) ,
103+ destroy : vi . fn ( ( error : Error ) => request . emit ( "error" , error ) ) ,
104+ } )
105+ mockGet . mockReturnValue ( request as unknown as ReturnType < typeof get > )
106+
107+ await expect ( downloadFile ( "https://github.com/release" , path . join ( tempDir , "archive" ) ) ) . rejects . toThrow (
108+ "DCG download timed out" ,
109+ )
110+ expect ( request . setTimeout ) . toHaveBeenCalledWith ( 120_000 , expect . any ( Function ) )
111+ } )
112+
113+ it ( "rejects archives larger than 50 MiB" , async ( ) => {
114+ const response = Object . assign ( new PassThrough ( ) , {
115+ statusCode : 200 ,
116+ headers : { "content-length" : String ( 50 * 1024 * 1024 + 1 ) } ,
117+ destroy : vi . fn ( ) ,
118+ } )
119+ const request = Object . assign ( new EventEmitter ( ) , { setTimeout : vi . fn ( ) , destroy : vi . fn ( ) } )
120+ mockGet . mockImplementation ( ( _url , optionsOrCallback , optionalCallback ) => {
121+ const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback
122+ setImmediate ( ( ) => callback ?.( response as unknown as IncomingMessage ) )
123+ return request as unknown as ReturnType < typeof get >
124+ } )
125+
126+ await expect ( downloadFile ( "https://github.com/release" , path . join ( tempDir , "archive" ) ) ) . rejects . toThrow (
127+ "DCG archive exceeds the download size limit" ,
128+ )
129+ } )
130+
75131 it ( "allows trusted relative redirects and rejects unsafe or exhausted redirects" , ( ) => {
76132 expect ( resolveTrustedRedirect ( "https://github.com/release" , "/asset" , 5 ) ) . toBe ( "https://github.com/asset" )
77133 expect ( ( ) => resolveTrustedRedirect ( "https://github.com/release" , "https://example.com/asset" , 5 ) ) . toThrow (
@@ -92,9 +148,7 @@ describe("Destructive Command Guard manager", () => {
92148 const checksum = createHash ( "sha256" ) . update ( contents ) . digest ( "hex" )
93149
94150 await expect ( verifyChecksum ( filePath , checksum ) ) . resolves . toBeUndefined ( )
95- await expect ( verifyChecksum ( filePath , "0" . repeat ( 64 ) ) ) . rejects . toThrow (
96- "DCG archive checksum verification failed" ,
97- )
151+ await expect ( verifyChecksum ( filePath , "0" . repeat ( 64 ) ) ) . rejects . toThrow ( `got ${ checksum } ` )
98152 } )
99153
100154 it ( "uses the platform ZIP extractor" , async ( ) => {
@@ -180,6 +234,22 @@ describe("Destructive Command Guard manager", () => {
180234 }
181235 } )
182236
237+ it ( "warns when the current platform is unsupported" , async ( ) => {
238+ const platformKey = `${ process . platform } -${ process . arch } `
239+ const info = DCG_ARCHIVES [ platformKey ]
240+ if ( ! info ) return
241+ const warnSpy = vi . spyOn ( console , "warn" ) . mockImplementation ( ( ) => { } )
242+ Reflect . deleteProperty ( DCG_ARCHIVES , platformKey )
243+
244+ try {
245+ await expect ( ensureDcgInstalled ( tempDir ) ) . resolves . toBeUndefined ( )
246+ expect ( warnSpy ) . toHaveBeenCalledWith ( `[DCG] Unsupported platform: ${ platformKey } ` )
247+ } finally {
248+ Reflect . set ( DCG_ARCHIVES , platformKey , info )
249+ warnSpy . mockRestore ( )
250+ }
251+ } )
252+
183253 it ( "downloads, verifies, extracts, and deduplicates a new installation" , async ( ) => {
184254 const info = getDcgArchiveInfo ( )
185255 expect ( info ) . toBeDefined ( )
@@ -251,4 +321,57 @@ describe("Destructive Command Guard manager", () => {
251321 Object . defineProperty ( info , "sha256" , { value : originalChecksum , configurable : true } )
252322 }
253323 } )
324+
325+ it ( "downloads, verifies, extracts, and installs a ZIP archive" , async ( ) => {
326+ const info = getDcgArchiveInfo ( )
327+ if ( ! info ?. archive . endsWith ( ".zip" ) ) return
328+
329+ const archive = Buffer . from ( "test ZIP archive" )
330+ const originalChecksum = info . sha256
331+ Object . defineProperty ( info , "sha256" , {
332+ value : createHash ( "sha256" ) . update ( archive ) . digest ( "hex" ) ,
333+ configurable : true ,
334+ } )
335+ const response = Object . assign ( new PassThrough ( ) , {
336+ statusCode : 200 ,
337+ headers : { "content-length" : String ( archive . length ) } ,
338+ } )
339+ const request = Object . assign ( new EventEmitter ( ) , { setTimeout : vi . fn ( ) , destroy : vi . fn ( ) } )
340+ mockGet . mockImplementation ( ( _url , optionsOrCallback , optionalCallback ) => {
341+ const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback
342+ setImmediate ( ( ) => {
343+ callback ?.( response as unknown as IncomingMessage )
344+ response . end ( archive )
345+ } )
346+ return request as unknown as ReturnType < typeof get >
347+ } )
348+ mockSpawn . mockImplementation ( ( _executable , args ) => {
349+ const child = Object . assign ( new EventEmitter ( ) , {
350+ stdout : new PassThrough ( ) ,
351+ stderr : new PassThrough ( ) ,
352+ kill : vi . fn ( ) ,
353+ } )
354+ setImmediate ( async ( ) => {
355+ const destinationIndex = args . indexOf (
356+ "$archivePath = $args[0]; $destination = $args[1]; Expand-Archive -LiteralPath $archivePath -DestinationPath $destination -Force" ,
357+ )
358+ const stagingDir = args [ destinationIndex + 2 ]
359+ await writeFile ( path . join ( stagingDir , info . binary ) , "ZIP executable" )
360+ child . emit ( "close" , 0 )
361+ } )
362+ return child as unknown as ReturnType < typeof spawn >
363+ } )
364+
365+ try {
366+ const binaryPath = await ensureDcgInstalled ( tempDir )
367+ if ( ! binaryPath ) throw new Error ( "Expected DCG to be supported in this test" )
368+ expect ( await readFile ( binaryPath , "utf8" ) ) . toBe ( "ZIP executable" )
369+ expect ( await readFile ( path . join ( tempDir , "destructive-command-guard" , ".dcg-version" ) , "utf8" ) ) . toBe (
370+ DCG_VERSION ,
371+ )
372+ await expect ( access ( path . join ( tempDir , `${ DCG_VERSION } -${ info . archive } ` ) ) ) . rejects . toThrow ( )
373+ } finally {
374+ Object . defineProperty ( info , "sha256" , { value : originalChecksum , configurable : true } )
375+ }
376+ } )
254377} )
0 commit comments