11import { Buffer } from 'node:buffer'
22import { execFileSync } from 'node:child_process'
3+ import { createHash } from 'node:crypto'
34import { chmodSync , existsSync , mkdtempSync , renameSync , rmSync , writeFileSync } from 'node:fs'
45
56import process from 'node:process'
@@ -33,13 +34,13 @@ interface ConsentOptions {
3334 * `cacheName` overrides the cached filename, so version-pinned tools can key
3435 * their cache entry by version without affecting the `PATH` lookup.
3536 */
36- export function resolveTool ( name : string , options : { url ?: string , archive ?: boolean , cacheName ?: string , consent : ConsentOptions } ) : Promise < string | undefined > {
37+ export function resolveTool ( name : string , options : { url ?: string , archive ?: boolean , cacheName ?: string , sha256 ?: string , consent : ConsentOptions } ) : Promise < string | undefined > {
3738 // The consent prompt and the download indicator both redraw by moving the
3839 // cursor, so they need stdout back from consola for the duration.
3940 return withDirectStdout ( ( ) => locateTool ( name , options ) )
4041}
4142
42- async function locateTool ( name : string , options : { url ?: string , archive ?: boolean , cacheName ?: string , consent : ConsentOptions } ) : Promise < string | undefined > {
43+ async function locateTool ( name : string , options : { url ?: string , archive ?: boolean , cacheName ?: string , sha256 ?: string , consent : ConsentOptions } ) : Promise < string | undefined > {
4344 const existing = findInPath ( name )
4445 if ( existing ) {
4546 return existing
@@ -64,7 +65,7 @@ async function locateTool(name: string, options: { url?: string, archive?: boole
6465 if ( ! await confirmToolInstall ( options . consent ) ) {
6566 return undefined
6667 }
67- return downloadBinary ( url , destination , { archive : options . archive , name } )
68+ return downloadBinary ( url , destination , { archive : options . archive , name, sha256 : options . sha256 } )
6869 } )
6970}
7071
@@ -105,7 +106,7 @@ async function confirmToolInstall(options: ConsentOptions): Promise<boolean> {
105106
106107const RESPONSE_TIMEOUT_MS = 30_000
107108
108- async function downloadBinary ( url : string , destination : string , options : { archive ?: boolean , name ?: string } = { } ) : Promise < string | undefined > {
109+ async function downloadBinary ( url : string , destination : string , options : { archive ?: boolean , name ?: string , sha256 ?: string } = { } ) : Promise < string | undefined > {
109110 const label = options . name || url
110111 try {
111112 // The deadline covers connecting and headers only; once bytes are flowing the
@@ -123,6 +124,12 @@ async function downloadBinary(url: string, destination: string, options: { archi
123124 throw new Error ( `Unexpected response: ${ response . status } ` )
124125 }
125126 const data = await readWithProgress ( response , label )
127+ if ( options . sha256 ) {
128+ const digest = createHash ( 'sha256' ) . update ( data ) . digest ( 'hex' )
129+ if ( digest !== options . sha256 . toLowerCase ( ) ) {
130+ throw new Error ( `SHA-256 mismatch for \`${ label } \`: expected ${ options . sha256 } , got ${ digest } . The download was discarded.` )
131+ }
132+ }
126133 // Stage the install and rename into place: two dev servers racing to install
127134 // the same tool would otherwise interleave writes and cache a corrupt binary.
128135 // Staged inside the cache directory so the rename stays on one filesystem.
0 commit comments