164 lines
5.0 KiB
JavaScript
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;
|
|
}
|