diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index e3bae33c..b4b35705 100755 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -12,9 +12,11 @@ import { logger } from '../utils/logger.js'; import { getLatestRelease, getAssetUrl, + getChecksumUrl, downloadRelease, GitHubRateLimitError, GitHubDownloadError, + GitHubChecksumError, } from '../utils/github.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -52,8 +54,10 @@ async function tryGitHubInstall( spinner.text = `Downloading ${release.tag_name}...`; tempDir = await createTempDir(); const zipPath = join(tempDir, 'release.zip'); + const assetName = assetUrl.split('/').pop() ?? 'release.zip'; + const checksumUrl = getChecksumUrl(release, assetName) ?? undefined; - await downloadRelease(assetUrl, zipPath); + await downloadRelease(assetUrl, zipPath, checksumUrl); spinner.text = 'Extracting and installing files...'; const { copiedFolders, tempDir: extractedTempDir } = await installFromZip( @@ -72,6 +76,10 @@ async function tryGitHubInstall( await cleanup(tempDir); } + if (error instanceof GitHubChecksumError) { + throw error; + } + if (error instanceof GitHubRateLimitError) { spinner.warn('GitHub rate limit reached, using template generation...'); return null; diff --git a/cli/src/utils/github.ts b/cli/src/utils/github.ts index 5799d88e..c2a52e06 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'; @@ -19,6 +20,13 @@ export class GitHubDownloadError extends Error { } } +export class GitHubChecksumError extends Error { + constructor(message: string) { + super(message); + this.name = 'GitHubChecksumError'; + } +} + function checkRateLimit(response: Response): void { const remaining = response.headers.get('x-ratelimit-remaining'); if (response.status === 403 && remaining === '0') { @@ -69,7 +77,14 @@ export async function getLatestRelease(): Promise { return response.json(); } -export async function downloadRelease(url: string, dest: string): Promise { +export function getChecksumUrl(release: Release, assetName: string): string | null { + const checksumAsset = release.assets.find( + a => a.name === `${assetName}.sha256` || a.name === 'checksums.txt' + ); + return checksumAsset?.browser_download_url ?? null; +} + +export async function downloadRelease(url: string, dest: string, checksumUrl?: string): Promise { const response = await fetch(url, { headers: { 'User-Agent': 'uipro-cli', @@ -84,6 +99,21 @@ export async function downloadRelease(url: string, dest: string): Promise } const buffer = await response.arrayBuffer(); + + if (checksumUrl) { + const checksumResponse = await fetch(checksumUrl, { headers: { 'User-Agent': 'uipro-cli' } }); + if (checksumResponse.ok) { + const checksumText = (await checksumResponse.text()).trim(); + const expectedHash = checksumText.split(/\s+/)[0]; + const actualHash = createHash('sha256').update(Buffer.from(buffer)).digest('hex'); + if (actualHash !== expectedHash) { + throw new GitHubChecksumError( + `Checksum mismatch for downloaded release: expected ${expectedHash}, got ${actualHash}` + ); + } + } + } + await writeFile(dest, Buffer.from(buffer)); }