feat(catalog): verify deterministic release manifests before Git export
This commit is contained in:
1 parent
422be3ce2d
commit
a320e47c29
9 files changed
+491
-9
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user