Skip to content

Commit cb62e6f

Browse files
committed
feat(http): add checksum verification for downloads
Add parseChecksums() and fetchChecksums() functions for SHA256 checksum verification of downloaded files. This enables verifying downloads against published checksums files (e.g., from GitHub releases). - parseChecksums(text): Parse checksums text into filename→hash map - Supports GNU style (hash filename), BSD style, and single-space - Handles Windows CRLF line endings - Returns null-prototype object to prevent prototype pollution - fetchChecksums(url, options?): Fetch and parse checksums from URL - Supports headers and timeout options - httpDownload sha256 option: Verify downloaded file against expected hash - Fails fast if hash doesn't match (before atomic rename) - Accepts uppercase hashes (normalized to lowercase)
1 parent dbaffa8 commit cb62e6f

2 files changed

Lines changed: 566 additions & 1 deletion

File tree

src/http-request.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,22 @@ import type { IncomingMessage } from 'http'
3434

3535
import type { Logger } from './logger.js'
3636

37+
let _crypto: typeof import('node:crypto') | undefined
3738
let _http: typeof import('node:http') | undefined
3839
let _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+
/^SHA256\s+\((.+)\)\s+=\s+([a-fA-F0-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-fA-F0-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

Comments
 (0)