|
| 1 | +/** Verify every file referenced by an electron-updater release manifest. */ |
| 2 | + |
| 3 | +import { createHash } from 'node:crypto' |
| 4 | +import { createReadStream } from 'node:fs' |
| 5 | +import { lstat, readFile } from 'node:fs/promises' |
| 6 | +import { basename, join, resolve } from 'node:path' |
| 7 | +import { pathToFileURL, fileURLToPath } from 'node:url' |
| 8 | +import { getFileList, parseUpdateInfo } from 'electron-updater/out/providers/Provider.js' |
| 9 | + |
| 10 | +export interface VerifyUpdateManifestOptions { |
| 11 | + readonly artifactsDir: string |
| 12 | + readonly expectedVersion: string |
| 13 | + readonly platform: 'mac' | 'win' |
| 14 | +} |
| 15 | + |
| 16 | +function assertSafeFilename(filename: string): void { |
| 17 | + if ( |
| 18 | + basename(filename) !== filename |
| 19 | + || !/^[A-Za-z0-9][A-Za-z0-9._+()-]*$/u.test(filename) |
| 20 | + ) throw new Error(`Update manifest contains an unsafe artifact URL: ${filename}`) |
| 21 | +} |
| 22 | + |
| 23 | +async function sha512(path: string): Promise<string> { |
| 24 | + const hash = createHash('sha512') |
| 25 | + for await (const chunk of createReadStream(path)) hash.update(chunk) |
| 26 | + return hash.digest('base64') |
| 27 | +} |
| 28 | + |
| 29 | +/** Validate version, file references, sizes, checksums, aliases, and release date. */ |
| 30 | +export async function verifyUpdateManifest(options: VerifyUpdateManifestOptions): Promise<void> { |
| 31 | + const manifestName = options.platform === 'mac' ? 'latest-mac.yml' : 'latest.yml' |
| 32 | + const manifestPath = join(options.artifactsDir, manifestName) |
| 33 | + const raw = await readFile(manifestPath, 'utf8') |
| 34 | + const info = parseUpdateInfo(raw, manifestName, pathToFileURL(manifestPath)) |
| 35 | + if (info.version !== options.expectedVersion) { |
| 36 | + throw new Error(`${manifestName} version ${info.version} does not match ${options.expectedVersion}`) |
| 37 | + } |
| 38 | + |
| 39 | + const releaseDate = info.releaseDate |
| 40 | + if ( |
| 41 | + typeof releaseDate !== 'string' |
| 42 | + || Number.isNaN(Date.parse(releaseDate)) |
| 43 | + || new Date(releaseDate).toISOString() !== releaseDate |
| 44 | + ) throw new Error(`${manifestName} has an invalid releaseDate`) |
| 45 | + |
| 46 | + const files = getFileList(info) |
| 47 | + const seen = new Set<string>() |
| 48 | + for (const file of files) { |
| 49 | + assertSafeFilename(file.url) |
| 50 | + if (seen.has(file.url)) throw new Error(`${manifestName} contains a duplicate artifact URL: ${file.url}`) |
| 51 | + seen.add(file.url) |
| 52 | + if (!Number.isSafeInteger(file.size) || (file.size ?? 0) <= 0) { |
| 53 | + throw new Error(`${manifestName} has an invalid size for ${file.url}`) |
| 54 | + } |
| 55 | + if (typeof file.sha512 !== 'string' || Buffer.from(file.sha512, 'base64').byteLength !== 64) { |
| 56 | + throw new Error(`${manifestName} has an invalid SHA-512 for ${file.url}`) |
| 57 | + } |
| 58 | + |
| 59 | + const artifactPath = join(options.artifactsDir, file.url) |
| 60 | + const artifact = await lstat(artifactPath) |
| 61 | + if (!artifact.isFile()) throw new Error(`Update artifact is not a regular file: ${file.url}`) |
| 62 | + if (artifact.size !== file.size) throw new Error(`${file.url} size does not match ${manifestName}`) |
| 63 | + if (await sha512(artifactPath) !== file.sha512) { |
| 64 | + throw new Error(`${file.url} SHA-512 does not match ${manifestName}`) |
| 65 | + } |
| 66 | + } |
| 67 | + |
| 68 | + const required = options.platform === 'mac' |
| 69 | + ? [['-mac.zip', 'macOS ZIP'], ['.dmg', 'macOS DMG']] as const |
| 70 | + : [['-Setup.exe', 'Windows installer']] as const |
| 71 | + for (const [suffix, label] of required) { |
| 72 | + if (![...seen].some(filename => filename.endsWith(suffix))) { |
| 73 | + throw new Error(`${manifestName} does not reference the required ${label}`) |
| 74 | + } |
| 75 | + } |
| 76 | + |
| 77 | + const legacy = info as typeof info & { readonly path?: unknown; readonly sha512?: unknown } |
| 78 | + if (typeof legacy.path !== 'string' || !seen.has(legacy.path)) { |
| 79 | + throw new Error(`${manifestName} top-level path does not match a file entry`) |
| 80 | + } |
| 81 | + const pathEntry = files.find(file => file.url === legacy.path)! |
| 82 | + if (legacy.sha512 !== pathEntry.sha512) { |
| 83 | + throw new Error(`${manifestName} top-level SHA-512 does not match ${legacy.path}`) |
| 84 | + } |
| 85 | + const requiredAliasSuffix = options.platform === 'mac' ? '-mac.zip' : '-Setup.exe' |
| 86 | + if (!legacy.path.endsWith(requiredAliasSuffix)) { |
| 87 | + throw new Error(`${manifestName} top-level path must reference ${requiredAliasSuffix}`) |
| 88 | + } |
| 89 | +} |
| 90 | + |
| 91 | +async function main(): Promise<void> { |
| 92 | + const [platform, artifactsDir, expectedVersion] = process.argv.slice(2) |
| 93 | + if ((platform !== 'mac' && platform !== 'win') || artifactsDir === undefined || expectedVersion === undefined) { |
| 94 | + throw new Error('Usage: verify-update-manifest.ts <mac|win> <artifacts-directory> <version>') |
| 95 | + } |
| 96 | + await verifyUpdateManifest({ artifactsDir: resolve(artifactsDir), expectedVersion, platform }) |
| 97 | + console.log(`${platform} update manifest verified for ${expectedVersion}`) |
| 98 | +} |
| 99 | + |
| 100 | +const invokedPath = process.argv[1] |
| 101 | +if (invokedPath !== undefined && resolve(invokedPath) === fileURLToPath(import.meta.url)) { |
| 102 | + void main().catch((error: unknown) => { |
| 103 | + console.error(error instanceof Error ? error.message : String(error)) |
| 104 | + process.exitCode = 1 |
| 105 | + }) |
| 106 | +} |
0 commit comments