@@ -34,8 +34,22 @@ import type { IncomingMessage } from 'http'
3434
3535import type { Logger } from './logger.js'
3636
37+ let _crypto : typeof import ( 'node:crypto' ) | undefined
3738let _http : typeof import ( 'node:http' ) | undefined
3839let _https : typeof import ( 'node:https' ) | undefined
40+
41+ /**
42+ * Lazily load the crypto module to avoid Webpack errors.
43+ * @private
44+ */
45+ /*@__NO_SIDE_EFFECTS__ */
46+ function getCrypto ( ) {
47+ if ( _crypto === undefined ) {
48+ _crypto = /*@__PURE__ */ require ( 'crypto' )
49+ }
50+ return _crypto as typeof import ( 'node:crypto' )
51+ }
52+
3953/**
4054 * Lazily load http and https modules to avoid Webpack errors.
4155 * @private
@@ -472,6 +486,29 @@ export interface HttpDownloadOptions {
472486 * ```
473487 */
474488 timeout ?: number | undefined
489+ /**
490+ * Expected SHA256 hash of the downloaded file.
491+ * If provided, the download will fail if the computed hash doesn't match.
492+ * The hash should be a lowercase hex string (64 characters).
493+ *
494+ * Use `fetchChecksums()` to fetch hashes from a checksums URL, then pass
495+ * the specific hash here.
496+ *
497+ * @example
498+ * ```ts
499+ * // Verify download integrity with direct hash
500+ * await httpDownload('https://example.com/file.zip', '/tmp/file.zip', {
501+ * sha256: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
502+ * })
503+ *
504+ * // Verify using checksums from a URL
505+ * const checksums = await fetchChecksums('https://example.com/checksums.txt')
506+ * await httpDownload('https://example.com/file.zip', '/tmp/file.zip', {
507+ * sha256: checksums['file.zip']
508+ * })
509+ * ```
510+ */
511+ sha256 ?: string | undefined
475512}
476513
477514/**
@@ -500,6 +537,134 @@ export interface HttpDownloadResult {
500537 size : number
501538}
502539
540+ /**
541+ * Map of filenames to their SHA256 hashes.
542+ * Keys are filenames (not paths), values are lowercase hex-encoded SHA256 hashes.
543+ *
544+ * @example
545+ * ```ts
546+ * const checksums: Checksums = {
547+ * 'file.zip': 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
548+ * 'other.tar.gz': 'abc123...'
549+ * }
550+ * ```
551+ */
552+ export type Checksums = Record < string , string >
553+
554+ /**
555+ * Parse a checksums file text into a filename-to-hash map.
556+ *
557+ * Supports standard checksums file formats:
558+ * - BSD style: "SHA256 (filename) = hash"
559+ * - GNU style: "hash filename" (two spaces)
560+ * - Simple style: "hash filename" (single space)
561+ *
562+ * Lines starting with '#' are treated as comments and ignored.
563+ * Empty lines are ignored.
564+ *
565+ * @param text - Raw text content of a checksums file
566+ * @returns Map of filenames to lowercase SHA256 hashes
567+ *
568+ * @example
569+ * ```ts
570+ * const text = `
571+ * # SHA256 checksums
572+ * e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 file.zip
573+ * abc123def456... other.tar.gz
574+ * `
575+ * const checksums = parseChecksums(text)
576+ * console.log(checksums['file.zip']) // 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
577+ * ```
578+ */
579+ export function parseChecksums ( text : string ) : Checksums {
580+ const checksums : Checksums = { __proto__ : null } as Checksums
581+
582+ for ( const line of text . split ( '\n' ) ) {
583+ const trimmed = line . trim ( )
584+ if ( ! trimmed || trimmed . startsWith ( '#' ) ) {
585+ continue
586+ }
587+
588+ // Try BSD style: "SHA256 (filename) = hash"
589+ const bsdMatch = trimmed . match (
590+ / ^ S H A 2 5 6 \s + \( ( .+ ) \) \s + = \s + ( [ a - f A - F 0 - 9 ] { 64 } ) $ / ,
591+ )
592+ if ( bsdMatch ) {
593+ checksums [ bsdMatch [ 1 ] ] = bsdMatch [ 2 ] . toLowerCase ( )
594+ continue
595+ }
596+
597+ // Try GNU/simple style: "hash filename" or "hash filename"
598+ const gnuMatch = trimmed . match ( / ^ ( [ a - f A - F 0 - 9 ] { 64 } ) \s + ( .+ ) $ / )
599+ if ( gnuMatch ) {
600+ checksums [ gnuMatch [ 2 ] ] = gnuMatch [ 1 ] . toLowerCase ( )
601+ }
602+ }
603+
604+ return checksums
605+ }
606+
607+ /**
608+ * Options for fetching checksums from a URL.
609+ */
610+ export interface FetchChecksumsOptions {
611+ /**
612+ * HTTP headers to send with the request.
613+ */
614+ headers ?: Record < string , string > | undefined
615+ /**
616+ * Request timeout in milliseconds.
617+ * @default 30000
618+ */
619+ timeout ?: number | undefined
620+ }
621+
622+ /**
623+ * Fetch and parse a checksums file from a URL.
624+ *
625+ * This is useful for verifying downloads from GitHub releases which typically
626+ * publish a checksums.txt file alongside release assets.
627+ *
628+ * @param url - URL to the checksums file
629+ * @param options - Request options
630+ * @returns Map of filenames to lowercase SHA256 hashes
631+ * @throws {Error } When the checksums file cannot be fetched
632+ *
633+ * @example
634+ * ```ts
635+ * // Fetch checksums from GitHub release
636+ * const checksums = await fetchChecksums(
637+ * 'https://github.com/org/repo/releases/download/v1.0.0/checksums.txt'
638+ * )
639+ *
640+ * // Use with httpDownload
641+ * await httpDownload(
642+ * 'https://github.com/org/repo/releases/download/v1.0.0/tool_linux.tar.gz',
643+ * '/tmp/tool.tar.gz',
644+ * { sha256: checksums['tool_linux.tar.gz'] }
645+ * )
646+ * ```
647+ */
648+ export async function fetchChecksums (
649+ url : string ,
650+ options ?: FetchChecksumsOptions | undefined ,
651+ ) : Promise < Checksums > {
652+ const { headers = { } , timeout = 30_000 } = {
653+ __proto__ : null ,
654+ ...options ,
655+ } as FetchChecksumsOptions
656+
657+ const response = await httpRequest ( url , { headers, timeout } )
658+
659+ if ( ! response . ok ) {
660+ throw new Error (
661+ `Failed to fetch checksums from ${ url } : ${ response . status } ${ response . statusText } ` ,
662+ )
663+ }
664+
665+ return parseChecksums ( response . body . toString ( 'utf8' ) )
666+ }
667+
503668/**
504669 * Single download attempt (used internally by httpDownload with retry logic).
505670 * @private
@@ -898,6 +1063,7 @@ export async function httpDownload(
8981063 progressInterval = 10 ,
8991064 retries = 0 ,
9001065 retryDelay = 1000 ,
1066+ sha256,
9011067 timeout = 120_000 ,
9021068 } = { __proto__ : null , ...options } as HttpDownloadOptions
9031069
@@ -944,6 +1110,27 @@ export async function httpDownload(
9441110 timeout,
9451111 } )
9461112
1113+ // Verify checksum if sha256 hash is provided.
1114+ if ( sha256 ) {
1115+ const crypto = getCrypto ( )
1116+ // eslint-disable-next-line no-await-in-loop
1117+ const fileContent = await fs . promises . readFile ( tempPath )
1118+ const computedHash = crypto
1119+ . createHash ( 'sha256' )
1120+ . update ( fileContent )
1121+ . digest ( 'hex' )
1122+
1123+ if ( computedHash !== sha256 . toLowerCase ( ) ) {
1124+ // eslint-disable-next-line no-await-in-loop
1125+ await safeDelete ( tempPath )
1126+ throw new Error (
1127+ `Checksum verification failed for ${ url } \n` +
1128+ `Expected: ${ sha256 . toLowerCase ( ) } \n` +
1129+ `Computed: ${ computedHash } ` ,
1130+ )
1131+ }
1132+ }
1133+
9471134 // Download succeeded - atomically rename temp file to destination.
9481135 // This overwrites any existing file at destPath.
9491136 // eslint-disable-next-line no-await-in-loop
0 commit comments