blob: 1bdc7932b0e662dd346cb59b6571d349b65c8951 [file]
import { createHash } from 'node:crypto';
import { execFile } from 'node:child_process';
import { createReadStream } from 'node:fs';
import { access, chmod, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
export interface PinnedNodeCliToolchainDefinition<TSpec> {
label: string;
fingerprint: string;
spec: TSpec;
node: {
version: string;
archiveUrl: string;
archiveSha256: string;
binarySha256: string;
};
packageArchive?: {
url: string;
integrity: `sha512-${string}`;
};
packageFiles: readonly {
archivePath: string;
installedPath: string;
sha256: string;
sourceSha256?: string;
transform?: (source: string) => string;
executable?: boolean;
stripComponents?: number;
}[];
}
export interface PreparedNodeCliToolchain {
path: string;
fingerprint: string;
}
export async function validatePreparedNodeCliToolchain<TSpec>(
path: string,
definition: PinnedNodeCliToolchainDefinition<TSpec>,
): Promise<PreparedNodeCliToolchain> {
const manifest = parseManifest(
await readFile(join(path, 'manifest.json'), 'utf8'),
definition.label,
);
if (manifest.fingerprint !== definition.fingerprint) {
throw new Error(`${definition.label} toolchain fingerprint mismatch: ${manifest.fingerprint}`);
}
if (JSON.stringify(manifest.spec) !== JSON.stringify(definition.spec)) {
throw new Error(`${definition.label} toolchain spec does not match the pinned contract`);
}
const pinnedFiles = [
{ installedPath: 'bin/node', sha256: definition.node.binarySha256 },
...definition.packageFiles,
];
const checksums: string[] = [];
for (const file of pinnedFiles) {
if ((await sha256File(join(path, file.installedPath))) !== file.sha256) {
throw new Error(`${definition.label} toolchain ${file.installedPath} SHA-256 mismatch`);
}
checksums.push(`${file.sha256} ${file.installedPath}\n`);
}
if ((await readFile(join(path, 'checksums.sha256'), 'utf8')) !== checksums.join('')) {
throw new Error(`${definition.label} toolchain checksums.sha256 does not match its manifest`);
}
return { path, fingerprint: definition.fingerprint };
}
export async function prepareNodeCliToolchain<TSpec>(
path: string,
definition: PinnedNodeCliToolchainDefinition<TSpec>,
options: { fetchFn?: typeof fetch } = {},
): Promise<PreparedNodeCliToolchain> {
if (await exists(join(path, 'manifest.json')))
return validatePreparedNodeCliToolchain(path, definition);
await mkdir(dirname(path), { recursive: true });
const temporaryPath = await mkdtemp(join(dirname(path), `.${basename(path)}-`));
try {
const nodeArchive = join(temporaryPath, 'node.tar.gz');
const packageArchive = definition.packageArchive
? join(temporaryPath, 'package.tgz')
: undefined;
await downloadVerified({
url: definition.node.archiveUrl,
path: nodeArchive,
algorithm: 'sha256',
expected: definition.node.archiveSha256,
fetchFn: options.fetchFn ?? fetch,
});
if (definition.packageArchive && packageArchive) {
await downloadVerified({
url: definition.packageArchive.url,
path: packageArchive,
algorithm: 'sha512',
expected: definition.packageArchive.integrity.slice('sha512-'.length),
encoding: 'base64',
fetchFn: options.fetchFn ?? fetch,
});
} else if (definition.packageFiles.length > 0) {
throw new Error(`${definition.label} toolchain package files require a package archive`);
}
await mkdir(join(temporaryPath, 'bin'));
await execFileAsync('tar', [
'-xzf',
nodeArchive,
'-C',
join(temporaryPath, 'bin'),
'--strip-components=2',
`node-v${definition.node.version}-linux-x64/bin/node`,
]);
await chmod(join(temporaryPath, 'bin', 'node'), 0o755);
for (const file of definition.packageFiles) {
const installedPath = join(temporaryPath, file.installedPath);
const targetDir = dirname(installedPath);
await mkdir(targetDir, { recursive: true });
if (!packageArchive) {
throw new Error(`${definition.label} toolchain package files require a package archive`);
}
await execFileAsync('tar', [
'-xzf',
packageArchive,
'-C',
targetDir,
`--strip-components=${file.stripComponents ?? 2}`,
file.archivePath,
]);
if (file.transform !== undefined) {
if (file.sourceSha256 === undefined) {
throw new Error(`${definition.label} toolchain transform requires a source SHA-256`);
}
if ((await sha256File(installedPath)) !== file.sourceSha256) {
throw new Error(
`${definition.label} toolchain ${file.installedPath} source SHA-256 mismatch`,
);
}
await writeFile(
installedPath,
file.transform(await readFile(installedPath, 'utf8')),
'utf8',
);
}
if (file.executable) await chmod(installedPath, 0o755);
}
await writeFile(
join(temporaryPath, 'manifest.json'),
`${JSON.stringify(
{
schemaVersion: 1,
fingerprint: definition.fingerprint,
spec: definition.spec,
},
null,
2,
)}\n`,
'utf8',
);
await writeFile(
join(temporaryPath, 'checksums.sha256'),
[
{ installedPath: 'bin/node', sha256: definition.node.binarySha256 },
...definition.packageFiles,
]
.map((file) => `${file.sha256} ${file.installedPath}\n`)
.join(''),
'utf8',
);
await rm(nodeArchive);
if (packageArchive) await rm(packageArchive);
await validatePreparedNodeCliToolchain(temporaryPath, definition);
try {
await rename(temporaryPath, path);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'EEXIST' && code !== 'ENOTEMPTY') throw error;
await validatePreparedNodeCliToolchain(path, definition);
}
return { path, fingerprint: definition.fingerprint };
} finally {
await rm(temporaryPath, { recursive: true, force: true });
}
}
function parseManifest(raw: string, label: string): { fingerprint: string; spec: unknown } {
let value: unknown;
try {
value = JSON.parse(raw);
} catch (error) {
throw new Error(`${label} toolchain manifest is not valid JSON`, { cause: error });
}
if (
!isRecord(value) ||
value.schemaVersion !== 1 ||
!isRecord(value.spec) ||
typeof value.fingerprint !== 'string'
) {
throw new Error(`${label} toolchain manifest has an invalid shape`);
}
return { fingerprint: value.fingerprint, spec: value.spec };
}
async function downloadVerified(input: {
url: string;
path: string;
algorithm: 'sha256' | 'sha512';
expected: string;
encoding?: 'hex' | 'base64';
fetchFn: typeof fetch;
}): Promise<void> {
const response = await input.fetchFn(input.url);
if (!response.ok) throw new Error(`failed to download ${input.url}: HTTP ${response.status}`);
const bytes = new Uint8Array(await response.arrayBuffer());
const actual = createHash(input.algorithm)
.update(bytes)
.digest(input.encoding ?? 'hex');
if (actual !== input.expected) throw new Error(`archive checksum mismatch for ${input.url}`);
await writeFile(input.path, bytes);
}
async function sha256File(path: string): Promise<string> {
const hash = createHash('sha256');
for await (const chunk of createReadStream(path)) hash.update(chunk);
return hash.digest('hex');
}
async function exists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
throw error;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}