Files

157 lines
7.8 KiB
JavaScript

import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
export const digest = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
export function stableJson(value) {
const sort = (item) => {
if (Array.isArray(item)) return item.map(sort);
if (item && typeof item === "object") {
return Object.fromEntries(Object.keys(item).sort().map((key) => [key, sort(item[key])]));
}
return item;
};
return `${JSON.stringify(sort(value))}\n`;
}
function safeChild(root, relative) {
const resolvedRoot = path.resolve(root);
const resolved = path.resolve(root, relative);
if (!resolved.startsWith(`${resolvedRoot}${path.sep}`)) throw new Error(`Unsafe reference path: ${relative}`);
return resolved;
}
function verifyFiles(directory, manifest) {
if (manifest.schemaVersion !== 1 || !/^[a-f0-9]{64}$/.test(manifest.snapshotId)) {
throw new Error("Invalid CoA snapshot manifest");
}
for (const name of ["reference.json", "audit.json", "audit.md"]) {
const file = safeChild(directory, name);
const expected = manifest.files[name];
if (!expected || !fs.existsSync(file)) throw new Error(`Missing reference file: ${name}`);
const bytes = fs.readFileSync(file);
if (bytes.length !== expected.bytes || digest(bytes) !== expected.sha256) {
throw new Error(`Corrupt reference file: ${name}; regenerate from verified sources using a fresh --output-root`);
}
}
const expectedId = digest(stableJson(manifest.files));
if (expectedId !== manifest.snapshotId) throw new Error("Snapshot manifest identity mismatch");
}
export function readSnapshot(outputRoot) {
const pointerPath = path.join(outputRoot, "current.json");
if (!fs.existsSync(pointerPath)) throw new Error("No CoA reference snapshot; run coa:reference:build first");
const pointer = JSON.parse(fs.readFileSync(pointerPath, "utf8"));
if (!/^[a-f0-9]{64}$/.test(pointer.snapshotId)) throw new Error("Invalid reference snapshot pointer");
const directory = safeChild(outputRoot, `snapshots/${pointer.snapshotId}`);
const manifest = JSON.parse(fs.readFileSync(path.join(directory, "manifest.json"), "utf8"));
if (pointer.snapshotId !== manifest.snapshotId) throw new Error("Reference pointer and manifest disagree");
verifyFiles(directory, manifest);
return {
directory,
manifest,
reference: JSON.parse(fs.readFileSync(path.join(directory, "reference.json"), "utf8")),
report: JSON.parse(fs.readFileSync(path.join(directory, "audit.json"), "utf8")),
};
}
/** Publish immutable files before changing the current pointer; failures keep the old snapshot usable. */
export function publishSnapshot({ outputRoot, reference, report, markdown }) {
const files = {
"reference.json": stableJson(reference),
"audit.json": stableJson(report),
"audit.md": `${markdown.trimEnd()}\n`,
};
const fileManifest = Object.fromEntries(Object.entries(files).map(([name, bytes]) => [name, {
sha256: digest(bytes), bytes: Buffer.byteLength(bytes),
}]));
const snapshotId = digest(stableJson(fileManifest));
const manifest = { schemaVersion: 1, snapshotId, files: fileManifest };
const snapshotsRoot = path.join(outputRoot, "snapshots");
fs.mkdirSync(snapshotsRoot, { recursive: true });
const directory = safeChild(snapshotsRoot, snapshotId);
if (fs.existsSync(directory)) {
const previous = JSON.parse(fs.readFileSync(path.join(directory, "manifest.json"), "utf8"));
verifyFiles(directory, previous);
if (stableJson(previous) !== stableJson(manifest)) throw new Error("Existing snapshot identity collision");
} else {
const staging = fs.mkdtempSync(path.join(snapshotsRoot, ".staging-"));
try {
for (const [name, bytes] of Object.entries(files)) fs.writeFileSync(path.join(staging, name), bytes);
fs.writeFileSync(path.join(staging, "manifest.json"), stableJson(manifest));
verifyFiles(staging, manifest);
fs.renameSync(staging, directory);
} finally {
if (fs.existsSync(staging)) {
safeChild(snapshotsRoot, path.relative(snapshotsRoot, staging));
fs.rmSync(staging, { recursive: true });
}
}
}
const pointer = path.join(outputRoot, "current.json");
const temporary = path.join(outputRoot, `.current-${crypto.randomUUID()}.tmp`);
try {
fs.writeFileSync(temporary, stableJson({ schemaVersion: 1, snapshotId }));
fs.renameSync(temporary, pointer);
} finally {
if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
}
return { snapshotId, directory, manifest };
}
export function lookupReference(reference, options = {}) {
const needle = (value) => String(value ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
const requestedClass = options.class;
const classes = reference.classes.filter((item) => !requestedClass || [
item.classId, item.gameId, item.className, ...(item.aliases ?? []),
].some((value) => needle(value) === needle(requestedClass)));
if (requestedClass && classes.length === 0) throw new Error(`Unknown CoA class: ${requestedClass}`);
const classIds = new Set(classes.map((item) => item.classId));
let nodes = reference.nodes.filter((node) => classIds.has(node.classId));
if (options.tree) {
const tabs = new Set(classes.flatMap((item) => item.tabs).filter((tab) => [tab.tabId, tab.tabName]
.some((value) => needle(value) === needle(options.tree))).map((tab) => tab.tabId));
if (!tabs.size) throw new Error(`Unknown tree: ${options.tree}`);
nodes = nodes.filter((node) => tabs.has(node.tabId));
}
if (options.ability) nodes = nodes.filter((node) => needle(node.name).includes(needle(options.ability)));
const limit = Number(options.limit ?? 20);
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) throw new Error("Lookup limit must be 1..1000");
const rankIds = new Set(nodes.flatMap((node) => node.rankDescriptions.map((rank) => rank.spellId)));
let abilities = (reference.abilities ?? []).filter((item) => classIds.has(item.classId)
&& (!options.ability || needle(item.name).includes(needle(options.ability)))
&& (!options.tree || item.nodeIds.some((id) => nodes.some((node) => node.id === id))));
for (const ability of abilities) {
for (const id of [...(ability.spellIds ?? []), ...(ability.rankSpellIds ?? [])]) rankIds.add(id);
}
const bySpellId = new Map(reference.spells.map((spell) => [spell.id, spell]));
const pending = [...rankIds];
for (let index = 0; index < pending.length; index += 1) {
for (const effect of bySpellId.get(pending[index])?.dbc?.effects ?? []) {
const id = effect.triggerSpellId;
if (id > 0 && !rankIds.has(id)) {
rankIds.add(id);
pending.push(id);
}
}
}
let spells = reference.spells.filter((spell) => options.spell
? spell.id === Number(options.spell)
: rankIds.has(spell.id) || (options.ability && needle(spell.name).includes(needle(options.ability)) && !requestedClass));
if (options.spell) {
if (!spells.length) throw new Error(`Spell ${options.spell} is not in this reference snapshot`);
const spellId = Number(options.spell);
nodes = nodes.filter((node) => node.rankDescriptions.some((rank) => rank.spellId === spellId));
abilities = abilities.filter((ability) => [...(ability.spellIds ?? []), ...(ability.rankSpellIds ?? [])].includes(spellId)
|| ability.nodeIds.some((id) => nodes.some((node) => node.id === id)));
if (requestedClass && !rankIds.has(spellId)) throw new Error(`Spell ${spellId} has no evidence for class ${requestedClass}`);
}
return {
baseline: reference.baseline,
classes: classes.map(({ classId, gameId, className, aliases, tabs }) => ({ classId, gameId, className, aliases, tabs })),
totals: { nodes: nodes.length, abilities: abilities.length, spells: spells.length },
nodes: nodes.slice(0, limit), abilities: abilities.slice(0, limit), spells: spells.slice(0, limit),
};
}