mirror of
https://github.com/morluto/rea.git
synced 2026-11-08 15:56:35 +00:00
* feat(artifacts): read Godot PCK packs Add a Godot Engine PCK artifact reader for standalone packs and packs embedded behind an executable trailer (formats 2 and 3). Members are uncompressed byte ranges verified against their declared MD5 on read. Encrypted directories, sparse bundles, patch removal entries, unknown flags and format 1 fail closed as unavailable; encrypted members are reported without being read. inspect-artifact and extract-artifact recognize standalone packs by their GDPC magic. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(artifacts): bound Godot PCK metadata and own member streams Cap member paths at 4096 bytes and the retained directory at 64 MiB before allocation. Read members with positional chunk reads through pipeline so cancellation is typed, abandoned consumers release their source, and the shared descriptor stays reader-owned. Retain the root descriptor for godot-pck inventory, as for ZIP and universal Mach-O. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(artifacts): complete Godot PCK facts, integrity and selected views * fix(artifacts): preserve atomic-root inventory Evidence * fix(artifacts): bound aggregate PCK member expansion --------- Co-authored-by: Samucahub <151155051+Samucahub@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
207 lines
8.2 KiB
TypeScript
207 lines
8.2 KiB
TypeScript
import { writeOrderedZip } from "../../tests/fixtures/artifactEntryOrder.js";
|
|
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
|
import { readFile, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import {
|
|
TextReader,
|
|
Uint8ArrayReader,
|
|
Uint8ArrayWriter,
|
|
ZipWriter,
|
|
} from "@zip.js/zip.js";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
|
|
|
import { ZipArtifactReader } from "./ZipArtifactReader.js";
|
|
import { MINIMUM_ARTIFACT_EXPANDED_BYTES } from "../domain/artifactExpansionBudget.js";
|
|
|
|
/** Small real ZIP carriers with forged sizes; metadata-only controls, never decompressed. */
|
|
const writeZipWithDeclaredSize = async (
|
|
directory: string,
|
|
declaredBytes: readonly number[],
|
|
): Promise<string> => {
|
|
const path = join(directory, "fixture.zip");
|
|
const writer = new ZipWriter(new Uint8ArrayWriter());
|
|
for (const [index] of declaredBytes.entries())
|
|
await writer.add(
|
|
`member-${index}.bin`,
|
|
new TextReader("real member bytes"),
|
|
);
|
|
await writeFile(path, await writer.close());
|
|
const bytes = await readFile(path);
|
|
let offset = 0;
|
|
for (const declared of declaredBytes) {
|
|
const centralDirectory = bytes.indexOf(
|
|
Buffer.from([0x50, 0x4b, 0x01, 0x02]),
|
|
offset,
|
|
);
|
|
if (centralDirectory < 0)
|
|
throw new Error("Expected a ZIP central directory");
|
|
// Metadata-only controls: these declarations do not describe valid member streams.
|
|
bytes.writeUInt32LE(declared, centralDirectory + 24);
|
|
offset = centralDirectory + 46;
|
|
}
|
|
await writeFile(path, bytes);
|
|
return path;
|
|
};
|
|
|
|
const enumerate = async (reader: ZipArtifactReader): Promise<number> => {
|
|
let files = 0;
|
|
for await (const entry of reader.entries())
|
|
if (entry.kind === "file") files += 1;
|
|
return files;
|
|
};
|
|
|
|
describe("ZipArtifactReader decompression budget", () => {
|
|
it("refuses an archive whose entries declare more than its size can justify", async () => {
|
|
const directory = await createTestTempDirectory("rea-zip-budget-bomb-");
|
|
// A ~160-byte archive cannot declare 2 GiB of members without being a bomb.
|
|
const path = await writeZipWithDeclaredSize(directory, [2_147_483_647]);
|
|
const reader = new ZipArtifactReader(path, "zip");
|
|
try {
|
|
await expect(enumerate(reader)).rejects.toMatchObject({
|
|
name: "ArtifactReaderFailure",
|
|
reason: "limit",
|
|
});
|
|
} finally {
|
|
await reader.close();
|
|
}
|
|
});
|
|
|
|
it("charges all declarations in one traversal before yielding an over-budget member", async () => {
|
|
const directory = await createTestTempDirectory("rea-zip-budget-total-");
|
|
const half = MINIMUM_ARTIFACT_EXPANDED_BYTES / 2;
|
|
const path = await writeZipWithDeclaredSize(directory, [half, half + 1]);
|
|
const reader = new ZipArtifactReader(path, "zip");
|
|
const iterator = reader.entries()[Symbol.asyncIterator]();
|
|
try {
|
|
expect((await iterator.next()).value?.declaredSize).toBe(half);
|
|
await expect(iterator.next()).rejects.toMatchObject({ reason: "limit" });
|
|
} finally {
|
|
await iterator.return?.();
|
|
await reader.close();
|
|
}
|
|
});
|
|
|
|
it("keeps repeated and interleaved metadata traversals independently charged", async () => {
|
|
const directory = await createTestTempDirectory(
|
|
"rea-zip-budget-iterators-",
|
|
);
|
|
const path = await writeZipWithDeclaredSize(directory, [
|
|
MINIMUM_ARTIFACT_EXPANDED_BYTES / 2,
|
|
MINIMUM_ARTIFACT_EXPANDED_BYTES / 2,
|
|
]);
|
|
const reader = new ZipArtifactReader(path, "zip");
|
|
const first = reader.entries()[Symbol.asyncIterator]();
|
|
const second = reader.entries()[Symbol.asyncIterator]();
|
|
try {
|
|
expect((await first.next()).done).toBe(false);
|
|
expect((await second.next()).done).toBe(false);
|
|
expect((await first.next()).done).toBe(false);
|
|
expect((await second.next()).done).toBe(false);
|
|
expect((await first.next()).done).toBe(true);
|
|
expect((await second.next()).done).toBe(true);
|
|
expect(await enumerate(reader)).toBe(2);
|
|
} finally {
|
|
await Promise.all([first.return?.(), second.return?.()]);
|
|
await reader.close();
|
|
}
|
|
});
|
|
|
|
it("streams a valid small member through open", async () => {
|
|
const directory = await createTestTempDirectory("rea-zip-budget-valid-");
|
|
const path = join(directory, "valid.zip");
|
|
const writer = new ZipWriter(new Uint8ArrayWriter());
|
|
await writer.add("member.bin", new TextReader("valid member bytes"));
|
|
await writeFile(path, await writer.close());
|
|
const reader = new ZipArtifactReader(path, "zip");
|
|
try {
|
|
const entries = [];
|
|
for await (const entry of reader.entries()) entries.push(entry);
|
|
const entry = entries[0];
|
|
if (entry === undefined) throw new Error("Expected a ZIP member");
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of await reader.open(entry))
|
|
chunks.push(Buffer.from(chunk));
|
|
expect(Buffer.concat(chunks).toString()).toBe("valid member bytes");
|
|
} finally {
|
|
await reader.close();
|
|
}
|
|
});
|
|
|
|
it("rejects under-declared member output before delivering its complete actual bytes", async () => {
|
|
const directory = await createTestTempDirectory("rea-zip-underdeclared-");
|
|
const path = join(directory, "underdeclared.zip");
|
|
const content = new Uint8Array(128 * 1024);
|
|
const writer = new ZipWriter(new Uint8ArrayWriter());
|
|
await writer.add("member.bin", new Uint8ArrayReader(content), {
|
|
dataDescriptor: false,
|
|
bufferedWrite: true,
|
|
});
|
|
const bytes = Buffer.from(await writer.close());
|
|
const local = bytes.indexOf(Buffer.from([0x50, 0x4b, 0x03, 0x04]));
|
|
const central = bytes.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02]));
|
|
if (local < 0 || central < 0) throw new Error("Expected ZIP entry headers");
|
|
// Both headers agree and no descriptor remains: rejection must enforce
|
|
// actual decompressed output, not merely detect conflicting metadata.
|
|
expect(bytes.readUInt16LE(local + 6) & 8).toBe(0);
|
|
expect(bytes.readUInt16LE(local + 8)).toBe(8);
|
|
expect(bytes.readUInt32LE(local + 22)).toBe(content.length);
|
|
expect(bytes.readUInt32LE(central + 24)).toBe(content.length);
|
|
bytes.writeUInt32LE(1, local + 22);
|
|
bytes.writeUInt32LE(1, central + 24);
|
|
await writeFile(path, bytes);
|
|
const reader = new ZipArtifactReader(path, "zip");
|
|
try {
|
|
const entries = [];
|
|
for await (const entry of reader.entries()) entries.push(entry);
|
|
const entry = entries[0];
|
|
if (entry === undefined) throw new Error("Expected a ZIP member");
|
|
expect(entry.declaredSize).toBe(1);
|
|
let deliveredBytes = 0;
|
|
await expect(
|
|
(async () => {
|
|
for await (const chunk of await reader.open(entry))
|
|
deliveredBytes += Buffer.from(chunk).length;
|
|
})(),
|
|
).rejects.toThrow("Invalid uncompressed size");
|
|
expect(deliveredBytes).toBeLessThan(content.length);
|
|
} finally {
|
|
await reader.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
it.each([null, { caller: "opaque ZIP interruption" }])(
|
|
"preserves the exact ZIP cancellation reason at enumeration and open: %j",
|
|
async (reason) => {
|
|
const root = await createTestTempDirectory("rea-zip-cancellation-");
|
|
const path = join(root, "fixture.zip");
|
|
await writeOrderedZip(path, ["member.txt"]);
|
|
const reader = new ZipArtifactReader(path, "zip");
|
|
const caller = new AbortController();
|
|
try {
|
|
const entries = [];
|
|
for await (const entry of reader.entries()) entries.push(entry);
|
|
const entry = entries[0];
|
|
if (entry === undefined) throw new Error("Expected real ZIP member");
|
|
caller.abort(reason);
|
|
for (const run of [
|
|
() => reader.entries(caller.signal)[Symbol.asyncIterator]().next(),
|
|
async () => reader.open(entry, caller.signal),
|
|
]) {
|
|
const error: unknown = await run().catch((cause: unknown) => cause);
|
|
expect(error).toBeInstanceOf(ArtifactReaderFailure);
|
|
if (!(error instanceof ArtifactReaderFailure))
|
|
throw new Error("Expected ZIP cancellation");
|
|
expect(error.reason).toBe("cancelled");
|
|
expect(error.cancellation?.reason).toBe(reason);
|
|
expect(error.cause).toBe(reason);
|
|
}
|
|
} finally {
|
|
await reader.close();
|
|
}
|
|
},
|
|
);
|