diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index e3bae33c3..d5ef6f453 100755 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -13,6 +13,7 @@ import { getLatestRelease, getAssetUrl, downloadRelease, + findChecksumAsset, GitHubRateLimitError, GitHubDownloadError, } from '../utils/github.js'; @@ -53,7 +54,9 @@ async function tryGitHubInstall( tempDir = await createTempDir(); const zipPath = join(tempDir, 'release.zip'); - await downloadRelease(assetUrl, zipPath); + const assetFileName = assetUrl.split('/').pop() ?? 'release.zip'; + const expectedSha256 = await findChecksumAsset(release, assetFileName); + await downloadRelease(assetUrl, zipPath, expectedSha256 ?? undefined); spinner.text = 'Extracting and installing files...'; const { copiedFolders, tempDir: extractedTempDir } = await installFromZip( diff --git a/cli/src/utils/github.ts b/cli/src/utils/github.ts index 5799d88e0..fb1f2c5b6 100644 --- a/cli/src/utils/github.ts +++ b/cli/src/utils/github.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { writeFile } from 'node:fs/promises'; import type { Release } from '../types/index.js'; @@ -69,7 +70,7 @@ export async function getLatestRelease(): Promise { return response.json(); } -export async function downloadRelease(url: string, dest: string): Promise { +export async function downloadRelease(url: string, dest: string, expectedSha256?: string): Promise { const response = await fetch(url, { headers: { 'User-Agent': 'uipro-cli', @@ -84,9 +85,37 @@ export async function downloadRelease(url: string, dest: string): Promise } const buffer = await response.arrayBuffer(); + + if (expectedSha256) { + const actual = createHash('sha256').update(Buffer.from(buffer)).digest('hex'); + if (actual !== expectedSha256) { + throw new GitHubDownloadError( + `SHA-256 mismatch — download may be corrupted or tampered. +Expected: ${expectedSha256} +Actual: ${actual}` + ); + } + } + await writeFile(dest, Buffer.from(buffer)); } +export async function findChecksumAsset(release: Release, assetName: string): Promise { + const checksumNames = [`${assetName}.sha256`, 'checksums.sha256', 'SHA256SUMS']; + const checksumAsset = release.assets.find(a => checksumNames.includes(a.name)); + if (!checksumAsset) return null; + + const response = await fetch(checksumAsset.browser_download_url, { + headers: { 'User-Agent': 'uipro-cli' }, + }); + if (!response.ok) return null; + + const text = await response.text(); + // Format: " " or just "" + const match = text.trim().match(/^([0-9a-f]{64})/m); + return match ? match[1] : null; +} + export function getAssetUrl(release: Release): string | null { // First try to find an uploaded ZIP asset const asset = release.assets.find(a => a.name.endsWith('.zip'));