11import { createHash } from "crypto"
22import { EventEmitter } from "events"
3- import { access , mkdtemp , mkdir , readFile , rm , writeFile } from "fs/promises"
3+ import { access , chmod , mkdtemp , mkdir , readFile , rm , stat , writeFile } from "fs/promises"
44import { tmpdir } from "os"
55import path from "path"
66import { PassThrough } from "stream"
77
88import { spawn } from "child_process"
9+ import { get } from "https"
10+ import type { IncomingMessage , RequestOptions } from "http"
911
1012import { DCG_ARCHIVES , DCG_MAX_ARCHIVE_BYTES , DCG_VERSION } from "../constants"
1113import {
@@ -19,19 +21,23 @@ import {
1921 isTrustedDownloadUrl ,
2022 promoteStagedInstallation ,
2123 resolveTrustedRedirect ,
24+ ensureDcgInstalled ,
2225 verifyChecksum ,
2326} from "../manager"
2427
2528vi . mock ( "child_process" , ( ) => ( { spawn : vi . fn ( ) } ) )
29+ vi . mock ( "https" , ( ) => ( { get : vi . fn ( ) } ) )
2630
2731const mockSpawn = vi . mocked ( spawn )
32+ const mockGet = vi . mocked ( get )
2833
2934describe ( "Destructive Command Guard manager" , ( ) => {
3035 let tempDir : string
3136
3237 beforeEach ( async ( ) => {
3338 tempDir = await mkdtemp ( path . join ( tmpdir ( ) , "dcg-manager-" ) )
3439 mockSpawn . mockReset ( )
40+ mockGet . mockReset ( )
3541 } )
3642
3743 afterEach ( async ( ) => {
@@ -67,6 +73,7 @@ describe("Destructive Command Guard manager", () => {
6773 expect ( isTrustedDownloadUrl ( "https://cdn.objects.githubusercontent.com/release" ) ) . toBe ( true )
6874 expect ( isTrustedDownloadUrl ( "http://github.com/release" ) ) . toBe ( false )
6975 expect ( isTrustedDownloadUrl ( "https://evilgithub.com/release" ) ) . toBe ( false )
76+ expect ( isTrustedDownloadUrl ( "not a URL" ) ) . toBe ( false )
7077 } )
7178
7279 it ( "rejects untrusted download URLs before opening a destination" , async ( ) => {
@@ -147,6 +154,47 @@ describe("Destructive Command Guard manager", () => {
147154 expect ( mockSpawn ) . toHaveBeenCalledTimes ( 1 )
148155 } )
149156
157+ it ( "extracts a validated tar archive containing only the managed binary" , async ( ) => {
158+ const children = [ 0 , 1 ] . map ( ( ) =>
159+ Object . assign ( new EventEmitter ( ) , {
160+ stdout : new PassThrough ( ) ,
161+ stderr : new PassThrough ( ) ,
162+ kill : vi . fn ( ) ,
163+ } ) ,
164+ )
165+ mockSpawn . mockReturnValueOnce ( children [ 0 ] as unknown as ReturnType < typeof spawn > )
166+ mockSpawn . mockReturnValueOnce ( children [ 1 ] as unknown as ReturnType < typeof spawn > )
167+
168+ const extraction = extractSingleBinary ( "/tmp/dcg.tar.xz" , tempDir , DCG_ARCHIVES [ "linux-x64" ] )
169+ children [ 0 ] . stdout . write ( "./dcg\n" )
170+ children [ 0 ] . emit ( "close" , 0 )
171+ await new Promise < void > ( ( resolve ) => setImmediate ( resolve ) )
172+ children [ 1 ] . emit ( "close" , 0 )
173+
174+ await extraction
175+ expect ( mockSpawn ) . toHaveBeenNthCalledWith (
176+ 2 ,
177+ "tar" ,
178+ [ "-xJf" , "/tmp/dcg.tar.xz" , "-C" , tempDir , "dcg" ] ,
179+ expect . objectContaining ( { shell : false } ) ,
180+ )
181+ } )
182+
183+ it ( "surfaces process failures during extraction" , async ( ) => {
184+ const child = Object . assign ( new EventEmitter ( ) , {
185+ stdout : new PassThrough ( ) ,
186+ stderr : new PassThrough ( ) ,
187+ kill : vi . fn ( ) ,
188+ } )
189+ mockSpawn . mockReturnValue ( child as unknown as ReturnType < typeof spawn > )
190+
191+ const extraction = extractSingleBinary ( "/tmp/dcg.tar.xz" , tempDir , DCG_ARCHIVES [ "linux-x64" ] )
192+ child . stderr . write ( "invalid archive" )
193+ child . emit ( "close" , 2 )
194+
195+ await expect ( extraction ) . rejects . toThrow ( "invalid archive" )
196+ } )
197+
150198 it ( "does not replace an installation completed by another process" , async ( ) => {
151199 const stagingDir = path . join ( tempDir , "staging" )
152200 const finalDir = path . join ( tempDir , "final" )
@@ -162,6 +210,19 @@ describe("Destructive Command Guard manager", () => {
162210 expect ( await readFile ( path . join ( stagingDir , "dcg" ) , "utf8" ) ) . toBe ( "staged" )
163211 } )
164212
213+ it ( "promotes a staged installation when no completed installation exists" , async ( ) => {
214+ const stagingDir = path . join ( tempDir , "staging" )
215+ const finalDir = path . join ( tempDir , "final" )
216+ const binaryPath = path . join ( finalDir , "dcg" )
217+ await mkdir ( stagingDir )
218+ await writeFile ( path . join ( stagingDir , "dcg" ) , "staged" )
219+
220+ await promoteStagedInstallation ( stagingDir , finalDir , binaryPath )
221+
222+ expect ( await readFile ( binaryPath , "utf8" ) ) . toBe ( "staged" )
223+ await expect ( access ( stagingDir ) ) . rejects . toThrow ( )
224+ } )
225+
165226 it ( "removes only stale version directories after a successful update" , async ( ) => {
166227 const currentDir = path . join ( tempDir , DCG_VERSION )
167228 const staleDir = path . join ( tempDir , "v0.6.0" )
@@ -176,4 +237,101 @@ describe("Destructive Command Guard manager", () => {
176237 Promise . all ( [ currentDir , stagingDir , unrelatedDir ] . map ( ( dir ) => access ( dir ) ) ) ,
177238 ) . resolves . toBeDefined ( )
178239 } )
240+
241+ it ( "treats stale-installation cleanup failures as cosmetic" , async ( ) => {
242+ await expect ( cleanupStaleInstallations ( path . join ( tempDir , "missing" ) ) ) . resolves . toBeUndefined ( )
243+ } )
244+
245+ it ( "reuses an existing managed binary and restores its executable permissions" , async ( ) => {
246+ const binaryPath = getDcgBinaryPath ( tempDir )
247+ expect ( binaryPath ) . toBeDefined ( )
248+ await mkdir ( path . dirname ( binaryPath ! ) , { recursive : true } )
249+ await writeFile ( binaryPath ! , "existing binary" )
250+ if ( process . platform !== "win32" ) {
251+ await chmod ( binaryPath ! , 0o600 )
252+ }
253+
254+ await expect ( ensureDcgInstalled ( tempDir ) ) . resolves . toBe ( binaryPath )
255+ expect ( mockSpawn ) . not . toHaveBeenCalled ( )
256+ if ( process . platform !== "win32" ) {
257+ expect ( ( await stat ( binaryPath ! ) ) . mode & 0o111 ) . toBe ( 0o111 )
258+ }
259+ } )
260+
261+ it ( "downloads, verifies, extracts, and deduplicates a new installation" , async ( ) => {
262+ const info = getDcgArchiveInfo ( )
263+ expect ( info ) . toBeDefined ( )
264+ if ( ! info || info . archive . endsWith ( ".zip" ) ) return
265+
266+ const archive = Buffer . from ( "test archive" )
267+ const originalChecksum = info . sha256
268+ Object . defineProperty ( info , "sha256" , {
269+ value : createHash ( "sha256" ) . update ( archive ) . digest ( "hex" ) ,
270+ configurable : true ,
271+ } )
272+ const now = vi . spyOn ( Date , "now" ) . mockReturnValue ( 1234 )
273+
274+ const response = Object . assign ( new PassThrough ( ) , {
275+ statusCode : 200 ,
276+ headers : { "content-length" : String ( archive . length ) } ,
277+ } )
278+ const request = Object . assign ( new EventEmitter ( ) , {
279+ setTimeout : vi . fn ( ) ,
280+ destroy : vi . fn ( ) ,
281+ } )
282+ mockGet . mockImplementation (
283+ (
284+ _url : string | URL ,
285+ optionsOrCallback : RequestOptions | ( ( response : IncomingMessage ) => void ) ,
286+ optionalCallback ?: ( response : IncomingMessage ) => void ,
287+ ) => {
288+ const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback
289+ setImmediate ( ( ) => {
290+ // The downloader uses only the response stream/status subset supplied here.
291+ callback ?.( response as unknown as IncomingMessage )
292+ response . end ( archive )
293+ } )
294+ // The downloader uses only timeout/error handling from ClientRequest.
295+ return request as unknown as ReturnType < typeof get >
296+ } ,
297+ )
298+
299+ mockSpawn . mockImplementation ( ( executable , args ) => {
300+ const child = Object . assign ( new EventEmitter ( ) , {
301+ stdout : new PassThrough ( ) ,
302+ stderr : new PassThrough ( ) ,
303+ kill : vi . fn ( ) ,
304+ } )
305+ setImmediate ( async ( ) => {
306+ if ( executable === "tar" && args [ 0 ] === "-tJf" ) {
307+ child . stdout . write ( `${ info . binary } \n` )
308+ } else if ( executable === "tar" ) {
309+ const stagingDir = args [ args . indexOf ( "-C" ) + 1 ]
310+ await writeFile ( path . join ( stagingDir , info . binary ) , "executable" )
311+ } else {
312+ child . stdout . write ( DCG_VERSION . replace ( / ^ v / , "" ) )
313+ }
314+ child . emit ( "close" , 0 )
315+ } )
316+ // The process runner uses only the event and stream subset supplied here.
317+ return child as unknown as ReturnType < typeof spawn >
318+ } )
319+
320+ try {
321+ const firstInstallation = ensureDcgInstalled ( tempDir )
322+ const concurrentInstallation = ensureDcgInstalled ( tempDir )
323+ expect ( concurrentInstallation ) . toBe ( firstInstallation )
324+
325+ const binaryPath = await firstInstallation
326+ expect ( await readFile ( binaryPath , "utf8" ) ) . toBe ( "executable" )
327+ expect ( mockGet ) . toHaveBeenCalledTimes ( 1 )
328+ expect ( mockSpawn ) . toHaveBeenCalledTimes ( 3 )
329+ await expect (
330+ access ( path . join ( tempDir , "destructive-command-guard" , `${ info . archive } .1234.download` ) ) ,
331+ ) . rejects . toThrow ( )
332+ } finally {
333+ now . mockRestore ( )
334+ Object . defineProperty ( info , "sha256" , { value : originalChecksum , configurable : true } )
335+ }
336+ } )
179337} )
0 commit comments