151 lines
4.3 KiB
JavaScript
151 lines
4.3 KiB
JavaScript
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 `<lowercase-sha256> <archive-basename>`.'
|
|
);
|
|
}
|
|
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();
|
|
}
|