feat(catalog): verify deterministic release manifests before Git export
CI / check (push) Failing after 22s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

This commit is contained in:
Simo committed 2026-09-13 17:48:23 +02:00
1 parent 422be3ce2d
commit a320e47c29
9 files changed
+491 -9

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
# Catalog release manifests
The Git export writes `Gamedata/catalog-release.json` alongside the existing SQL,
furniture data, icons and Nitro bundles. Its SHA-256 content identity includes a
sorted list of repository-relative paths, byte lengths and SHA-256 hashes. There
is no generated timestamp or local source path. Identical exported bytes produce
identical manifests.
The manifest describes the files captured by this export, not every file already
in the destination repository. Existing behavior is preserved: missing optional
sources do not delete previously exported files; FurnitureData remains required;
SQL updates existing rows and does not delete absent rows.
SQL tables retain the existing single InnoDB repeatable-read consistent snapshot.
Asset copies are checked against source metadata and content before publication.
This is not a transaction spanning MySQL and the filesystem: uncoordinated writes
or newly created files outside the export queue can require another export.
Before committing, the exporter verifies captured files against the manifest and
compares Git index object identities with raw captured bytes, including the
manifest. Git filters and newline conversions that alter staged bytes cause the
export to fail and remain queued for retry.
This manifest provides provenance for an exported snapshot. It does not activate
a live generation. Live atomic switching additionally requires immutable complete
generations, a shared activation pointer respected by readers, concurrency and
failure handling, and retention/rollback rules.
@@ -0,0 +1,43 @@
import { execFileSync } from "node:child_process";
import { mkdtemp, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { digestFile, verifyStagedRelease } from "./files";
describe("release file integrity", () => {
it("matches Git raw blob identity and rejects staged bytes changed by filters", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "release-index-"));
const git = (...args: string[]) =>
execFileSync("git", args, { cwd: root, encoding: "utf8" });
git("init");
git("config", "core.autocrlf", "false");
await writeFile(path.join(root, "payload"), "first\n");
const expected = await digestFile(path.join(root, "payload"));
expect(expected.gitSha1).toBe(
git("hash-object", "--no-filters", "payload").trim(),
);
git("add", "payload");
const files = new Map([["payload", expected]]);
expect(() =>
verifyStagedRelease(git("ls-files", "--stage", "-z"), "sha1", files),
).not.toThrow();
await writeFile(path.join(root, "payload"), "other\n");
git("add", "payload");
expect(() =>
verifyStagedRelease(git("ls-files", "--stage", "-z"), "sha1", files),
).toThrow("Staged catalog bytes mismatch");
});
it("rejects missing and nonregular staged entries", () => {
const digest = { bytes: 0, sha256: "", gitSha1: "abc", gitSha256: "def" };
const files = new Map([["Gamedata/icons/a.png", digest]]);
expect(() => verifyStagedRelease("", "sha1", files)).toThrow();
expect(() =>
verifyStagedRelease(
"120000 abc 0\tGamedata/icons/a.png\0",
"sha1",
files,
),
).toThrow();
});
});
+67
View File
@@ -0,0 +1,67 @@
import { createHash } from "node:crypto";
import { createReadStream, promises as fs } from "node:fs";
export interface FileDigest {
bytes: number;
sha256: string;
gitSha1: string;
gitSha256: string;
}
export async function sourceFingerprint(source: string): Promise<string> {
const stat = await fs.lstat(source, { bigint: true });
if (!stat.isFile() || stat.isSymbolicLink())
throw new Error("Invalid catalog source");
return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
}
export async function digestFile(source: string): Promise<FileDigest> {
const before = await sourceFingerprint(source);
const stat = await fs.stat(source);
const sha256 = createHash("sha256");
const header = `blob ${stat.size}\0`;
const gitSha1 = createHash("sha1").update(header);
const gitSha256 = createHash("sha256").update(header);
let bytes = 0;
for await (const chunk of createReadStream(source)) {
bytes += chunk.length;
sha256.update(chunk);
gitSha1.update(chunk);
gitSha256.update(chunk);
}
if (bytes !== stat.size || before !== (await sourceFingerprint(source)))
throw new Error("Assets changed during export; retry required");
return {
bytes,
sha256: sha256.digest("hex"),
gitSha1: gitSha1.digest("hex"),
gitSha256: gitSha256.digest("hex"),
};
}
export function verifyStagedRelease(
index: string,
format: string,
expected: Map<string, FileDigest>,
): void {
if (format !== "sha1" && format !== "sha256")
throw new Error("Unsupported Git object format");
const staged = new Map(
index
.split("\0")
.filter(Boolean)
.map((entry) => {
const tab = entry.indexOf("\t");
return [entry.slice(tab + 1), entry.slice(0, tab).split(" ")] as const;
}),
);
for (const [target, digest] of expected) {
const entry = staged.get(target);
const hash = format === "sha1" ? digest.gitSha1 : digest.gitSha256;
if (
!entry ||
!["100644", "100755"].includes(entry[0]) ||
entry[1] !== hash ||
entry[2] !== "0"
)
throw new Error(`Staged catalog bytes mismatch: ${target}`);
}
}
@@ -0,0 +1,56 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { createReleaseManifest, validateReleaseManifest } from "./manifest";
const digest = (value: string) => ({
bytes: Buffer.byteLength(value),
sha256: createHash("sha256").update(value).digest("hex"),
});
const entry = (path: string, value = "bytes") => ({ path, ...digest(value) });
describe("catalog release manifest", () => {
it("has stable identity regardless of enumeration order and changes when bytes change", () => {
const a = entry("Gamedata/icons/chair.png");
const b = entry("Gamedata/config/FurnitureData.json", "{}");
const first = createReleaseManifest([a, b]);
expect(first).toEqual(createReleaseManifest([b, a]));
expect(first.releaseId).toMatch(/^sha256:[a-f0-9]{64}$/);
expect(createReleaseManifest([a, entry(b.path, "[]")]).releaseId).not.toBe(
first.releaseId,
);
expect(JSON.stringify(first)).not.toContain("createdAt");
});
it.each([
"../escape",
"Gamedata/../escape",
"/absolute",
"Gamedata\\icons\\chair.png",
"Gamedata//chair.png",
"Gamedata/.git/config",
])("rejects invalid path %s", (target) => {
expect(() => createReleaseManifest([entry(target)])).toThrow();
});
it("rejects duplicate paths and invalid hashes", () => {
expect(() =>
createReleaseManifest([entry("Gamedata/a"), entry("Gamedata/a")]),
).toThrow();
expect(() =>
createReleaseManifest([{ ...entry("Gamedata/a"), sha256: "bad" }]),
).toThrow();
});
it("validates content hashes and manifest identity", async () => {
const manifest = createReleaseManifest([entry("Gamedata/a")]);
await expect(
validateReleaseManifest(manifest, async () => digest("bytes")),
).resolves.toBeUndefined();
await expect(
validateReleaseManifest(manifest, async () => digest("other")),
).rejects.toThrow("mismatch");
await expect(
validateReleaseManifest(
{ ...manifest, releaseId: "sha256:bad" },
async () => digest("bytes"),
),
).rejects.toThrow("identity");
});
});
+79
View File
@@ -0,0 +1,79 @@
import { createHash } from "node:crypto";
export const CATALOG_RELEASE_MANIFEST_PATH = "Gamedata/catalog-release.json";
export interface ReleaseFile {
path: string;
bytes: number;
sha256: string;
}
export interface ReleaseManifest {
schemaVersion: 1;
scope: "exported-files";
absentFiles: "preserve";
releaseId: string;
files: ReleaseFile[];
}
export function createReleaseManifest(entries: ReleaseFile[]): ReleaseManifest {
const seen = new Set<string>();
const files = entries
.map(({ path, bytes, sha256 }) => {
if (
typeof path !== "string" ||
!path ||
path.startsWith("/") ||
/[\\:]/.test(path) ||
Array.from(path).some((character) => character.charCodeAt(0) < 32) ||
path
.split("/")
.some(
(part) =>
!part ||
part === "." ||
part === ".." ||
part.toLowerCase() === ".git",
) ||
path === CATALOG_RELEASE_MANIFEST_PATH ||
seen.has(path)
)
throw new Error("Invalid release path");
if (
!Number.isSafeInteger(bytes) ||
bytes < 0 ||
!/^[a-f0-9]{64}$/.test(sha256)
)
throw new Error("Invalid release digest");
seen.add(path);
return { path, bytes, sha256 };
})
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
const body = {
schemaVersion: 1 as const,
scope: "exported-files" as const,
absentFiles: "preserve" as const,
files,
};
return {
...body,
releaseId: `sha256:${createHash("sha256").update(JSON.stringify(body)).digest("hex")}`,
};
}
export async function validateReleaseManifest(
manifest: ReleaseManifest,
readDigest: (path: string) => Promise<{ bytes: number; sha256: string }>,
): Promise<void> {
const expected = createReleaseManifest(manifest.files);
if (
manifest.schemaVersion !== 1 ||
manifest.scope !== expected.scope ||
manifest.absentFiles !== expected.absentFiles ||
manifest.releaseId !== expected.releaseId
)
throw new Error("Catalog release identity mismatch");
for (const file of expected.files) {
const actual = await readDigest(file.path);
if (actual.bytes !== file.bytes || actual.sha256 !== file.sha256)
throw new Error(`Catalog release bytes mismatch: ${file.path}`);
}
}