Files
regex-tools/scripts/checksummed-engine-pack.mjs

164 lines
5.0 KiB
JavaScript

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;
}