import { createHash } from "node:crypto"; import { lstat, readFile, readdir, realpath, writeFile, } from "node:fs/promises"; import path from "node:path"; function compareText(left, right) { return left < right ? -1 : left > right ? 1 : 0; } function sha256(value) { return createHash("sha256").update(value).digest("hex"); } async function regularFiles(directory, relative = "") { const current = path.join(directory, relative); const entries = (await readdir(current, { withFileTypes: true })).sort( (left, right) => compareText(left.name, right.name), ); const result = []; for (const entry of entries) { const entryRelative = relative ? `${relative}/${entry.name}` : entry.name; const candidate = path.join(directory, entryRelative); const details = await lstat(candidate); if (details.isSymbolicLink()) { throw new Error(`Engine packs may not contain symlinks: ${candidate}`); } if (entry.isDirectory()) { result.push(...(await regularFiles(directory, entryRelative))); } else if (entry.isFile()) { result.push(entryRelative); } else { throw new Error( `Engine packs may contain only directories and regular files: ${candidate}`, ); } } return result; } export async function writeEngineChecksums(directory) { const pack = await realpath(directory); const details = await lstat(pack); if (!details.isDirectory() || details.isSymbolicLink()) { throw new Error(`Engine pack must be a real directory: ${directory}`); } const files = (await regularFiles(pack)) .filter((name) => name !== "SHA256SUMS") .sort(compareText); const records = await Promise.all( files.map(async (name) => ({ name, digest: sha256(await readFile(path.join(pack, name))), })), ); const text = `${records .map(({ digest, name }) => `${digest} ${name}`) .join("\n")}\n`; await writeFile(path.join(pack, "SHA256SUMS"), text, { encoding: "utf8", mode: 0o644, }); return records; } export async function verifyChecksummedEnginePack(directory, expectedEngine) { const packDetails = await lstat(directory).catch(() => null); if (!packDetails?.isDirectory() || packDetails.isSymbolicLink()) { throw new Error( `${expectedEngine} engine pack must be a real directory: ${directory}`, ); } const pack = await realpath(directory); const files = (await regularFiles(pack)).sort(compareText); if ( !files.includes("SHA256SUMS") || !files.includes("engine-metadata.json") ) { throw new Error( `${expectedEngine} engine pack requires SHA256SUMS and engine-metadata.json.`, ); } const checksumText = await readFile(path.join(pack, "SHA256SUMS"), "utf8"); const lines = checksumText.endsWith("\n") ? checksumText.slice(0, -1).split("\n") : []; const records = lines.map((line) => { const match = /^([0-9a-f]{64}) ([A-Za-z0-9._+/@-]+)$/u.exec(line); if ( !match || match[2] === "SHA256SUMS" || match[2].startsWith("/") || match[2].split("/").includes("..") ) { throw new Error( `${expectedEngine} SHA256SUMS contains a malformed entry.`, ); } return { digest: match[1], name: match[2] }; }); const names = records.map(({ name }) => name); if ( new Set(names).size !== names.length || [...names].sort(compareText).some((name, index) => name !== names[index]) ) { throw new Error(`${expectedEngine} SHA256SUMS must be unique and sorted.`); } const expectedFiles = files .filter((name) => name !== "SHA256SUMS") .sort(compareText); if ( expectedFiles.length !== names.length || expectedFiles.some((name, index) => name !== names[index]) ) { throw new Error( `${expectedEngine} engine pack closed file set does not match SHA256SUMS.`, ); } for (const record of records) { const actual = sha256(await readFile(path.join(pack, record.name))); if (actual !== record.digest) { throw new Error( `${expectedEngine} engine asset ${record.name} SHA-256 mismatch.`, ); } } const metadata = JSON.parse( await readFile(path.join(pack, "engine-metadata.json"), "utf8"), ); if ( metadata.schemaVersion !== 1 || metadata.engine !== expectedEngine || !Array.isArray(metadata.files) ) { throw new Error( `${expectedEngine} engine metadata has an unexpected identity.`, ); } for (const record of metadata.files) { if ( !record || typeof record.path !== "string" || typeof record.sha256 !== "string" || !Number.isSafeInteger(record.bytes) || !names.includes(record.path) ) { throw new Error( `${expectedEngine} engine metadata contains an invalid file record.`, ); } const value = await readFile(path.join(pack, record.path)); if (value.byteLength !== record.bytes || sha256(value) !== record.sha256) { throw new Error( `${expectedEngine} engine metadata does not match ${record.path}.`, ); } } return metadata; }