Files
rea/src/artifacts/ZipArtifactReader.test.ts
T
Afloat 82e4e032df fix(artifacts): complete positional ZIP reads (#4414)
fix(artifacts): complete ZIP positional reads
2026-10-22 06:13:24 +08:00

367 lines
13 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, vi } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { ZipArtifactReader } from "./ZipArtifactReader.js";
import { openRegularFile } from "../filesystem/RegularFile.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;
};
const admittedZip = async () => {
const directory = await createTestTempDirectory("rea-zip-short-read-");
const path = join(directory, "valid.zip");
const content = "Complete ZIP member bytes, including café.";
const writer = new ZipWriter(new Uint8ArrayWriter());
await writer.add("member.txt", new TextReader(content));
await writeFile(path, await writer.close());
const handle = await openRegularFile(path, { symlinks: "reject" });
// Keep the actual descriptor, but select the positional overload used by
// the ZIP adapter so the spy does not infer FileHandle.read's options form.
const positional: {
read(
buffer: Buffer,
offset: number,
length: number,
position: number,
): Promise<{ bytesRead: number; buffer: Buffer }>;
} = handle;
return { path, content, handle, positional, initial: await handle.stat() };
};
describe("ZipArtifactReader positional reads", () => {
it("enumerates and extracts a real ZIP when each descriptor read returns a short prefix", async () => {
const fixture = await admittedZip();
const nativeRead = fixture.positional.read.bind(fixture.handle);
const reads = vi
.spyOn(fixture.positional, "read")
.mockImplementation(async (...args) => {
const [buffer, offset, length, position] = args;
return nativeRead(buffer, offset, Math.min(length, 3), position);
});
const reader = new ZipArtifactReader(
fixture.path,
"zip",
undefined,
fixture,
);
try {
const entries = [];
for await (const entry of reader.entries()) entries.push(entry);
expect(entries.map(({ path }) => path)).toEqual(["member.txt"]);
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)).toEqual(Buffer.from(fixture.content));
expect(reads.mock.calls.length).toBeGreaterThan(3);
} finally {
reads.mockRestore();
await reader.close();
await fixture.handle.close();
}
});
it("reports premature EOF inside the admitted extent as an integrity failure", async () => {
const fixture = await admittedZip();
const nativeRead = fixture.positional.read.bind(fixture.handle);
let calls = 0;
const reads = vi
.spyOn(fixture.positional, "read")
.mockImplementation(async (...args) => {
const [buffer, offset, length, position] = args;
if (calls++ > 0) return { buffer, bytesRead: 0 };
return nativeRead(buffer, offset, Math.min(length, 3), position);
});
const reader = new ZipArtifactReader(
fixture.path,
"zip",
undefined,
fixture,
);
try {
await expect(enumerate(reader)).rejects.toMatchObject({
reason: "integrity",
});
expect(calls).toBe(2);
} finally {
reads.mockRestore();
await reader.close();
await fixture.handle.close();
}
});
it("keeps metadata allocation admission ahead of short reads", async () => {
const fixture = await admittedZip();
const reads = vi.spyOn(fixture.positional, "read");
const reader = new ZipArtifactReader(fixture.path, "zip", 2, fixture);
try {
await expect(enumerate(reader)).rejects.toMatchObject({
reason: "limit",
});
expect(reads).not.toHaveBeenCalled();
} finally {
reads.mockRestore();
await reader.close();
await fixture.handle.close();
}
});
it("does not publish an entry cancelled during short metadata reads", async () => {
const fixture = await admittedZip();
const controller = new AbortController();
const nativeRead = fixture.positional.read.bind(fixture.handle);
const reads = vi
.spyOn(fixture.positional, "read")
.mockImplementation(async (...args) => {
const [buffer, offset, length, position] = args;
const result = await nativeRead(
buffer,
offset,
Math.min(length, 3),
position,
);
controller.abort();
return result;
});
const reader = new ZipArtifactReader(
fixture.path,
"zip",
undefined,
fixture,
);
const iterator = reader.entries(controller.signal)[Symbol.asyncIterator]();
try {
await expect(iterator.next()).rejects.toMatchObject({
reason: "cancelled",
});
} finally {
await iterator.return?.();
reads.mockRestore();
await reader.close();
await fixture.handle.close();
}
});
it("rejects a changed admitted descriptor before reading ZIP bytes", async () => {
const fixture = await admittedZip();
await writeFile(fixture.path, Buffer.alloc(0));
const reads = vi.spyOn(fixture.positional, "read");
const reader = new ZipArtifactReader(
fixture.path,
"zip",
undefined,
fixture,
);
try {
await expect(enumerate(reader)).rejects.toMatchObject({
reason: "integrity",
});
expect(reads).not.toHaveBeenCalled();
} finally {
reads.mockRestore();
await reader.close();
await fixture.handle.close();
}
});
});
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();
}
},
);