import { createHash } from 'node:crypto'; import { access, lstat, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; const repositoryRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), '..' ); export function sha256(data) { return createHash('sha256').update(data).digest('hex'); } export async function sha256File(file) { return sha256(await readFile(file)); } async function requireRegularFile(file, label) { const details = await lstat(file); if (details.isSymbolicLink() || !details.isFile()) { throw new Error(`${label} must be a regular file: ${file}`); } } export function checksumLine(digest, archiveName) { if (!/^[a-f0-9]{64}$/.test(digest)) { throw new Error('SHA-256 digest must be 64 lowercase hexadecimal digits.'); } if ( typeof archiveName !== 'string' || archiveName.length === 0 || archiveName.includes('/') || archiveName.includes('\\') || archiveName.includes('\n') || archiveName.includes('\r') ) { throw new Error('Checksum archive name must be one safe basename.'); } return `${digest} ${archiveName}\n`; } export function parseChecksumLine(contents) { const match = /^([a-f0-9]{64}) ([^/\\\r\n]+)\n?$/.exec(contents); if (!match) { throw new Error( 'Checksum sidecar must contain ` `.' ); } return { digest: match[1], archiveName: match[2] }; } export async function verifyChecksum(archivePath, sidecarPath) { await requireRegularFile(archivePath, 'Release archive'); await requireRegularFile(sidecarPath, 'Checksum sidecar'); const expected = parseChecksumLine(await readFile(sidecarPath, 'utf8')); if (expected.archiveName !== path.basename(archivePath)) { throw new Error( `Checksum names ${expected.archiveName}, not ${path.basename(archivePath)}.` ); } const actual = await sha256File(archivePath); if (actual !== expected.digest) { throw new Error( `Checksum mismatch for ${path.basename(archivePath)}: expected ${expected.digest}, received ${actual}.` ); } return actual; } export async function writeChecksum(archivePath, options = {}) { const sidecarPath = options.sidecarPath ?? `${archivePath}.sha256`; await requireRegularFile(archivePath, 'Release archive'); if (options.force && (await exists(sidecarPath))) { await requireRegularFile(sidecarPath, 'Existing checksum sidecar'); } const digest = await sha256File(archivePath); await writeFile( sidecarPath, checksumLine(digest, path.basename(archivePath)), { encoding: 'utf8', flag: options.force ? 'w' : 'wx', } ); return { digest, sidecarPath }; } async function exists(file) { try { await access(file); return true; } catch (error) { if (error && typeof error === 'object' && error.code === 'ENOENT') { return false; } throw error; } } async function main() { const packageJson = JSON.parse( await readFile(path.join(repositoryRoot, 'package.json'), 'utf8') ); const defaultArchive = path.join( repositoryRoot, 'release', `av-tools-${packageJson.version}.zip` ); const argumentsList = process.argv.slice(2); const write = argumentsList.includes('--write'); const force = argumentsList.includes('--force'); const positional = argumentsList.filter( (argument) => argument !== '--write' && argument !== '--force' ); if (positional.length > 1) { throw new Error( 'Usage: node scripts/checksum-release.mjs [archive.zip] [--write] [--force]' ); } const archivePath = path.resolve( repositoryRoot, positional[0] ?? defaultArchive ); const sidecarPath = `${archivePath}.sha256`; if (write) { const result = await writeChecksum(archivePath, { force, sidecarPath }); console.log( `Wrote ${path.relative(repositoryRoot, result.sidecarPath)} (${result.digest})` ); return; } if (!(await exists(sidecarPath))) { throw new Error( `${path.relative(repositoryRoot, sidecarPath)} is missing; pass --write to create it.` ); } const digest = await verifyChecksum(archivePath, sidecarPath); console.log( `Verified ${path.relative(repositoryRoot, archivePath)} (${digest})` ); } if ( process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href ) { await main(); }