feat: add multi-engine regex flavour support
This commit is contained in:
163
scripts/checksummed-engine-pack.mjs
Normal file
163
scripts/checksummed-engine-pack.mjs
Normal file
@@ -0,0 +1,163 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user