mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(zip): retain raw filename provenance for legacy decoding
This commit is contained in:
1 parent
108c0e7e49
commit
2e03251b61
4 files changed
+218
-56
No files matched your search
@@ -298,11 +298,24 @@ const projectZipEntry = (
|
||||
: [
|
||||
`ZIP member name ${JSON.stringify(sourcePath)} has a leading current-directory prefix omitted from its logical path.`,
|
||||
]),
|
||||
...legacyNameLimitations(entry),
|
||||
],
|
||||
adapterKey: `zip-entry:${index}`,
|
||||
};
|
||||
};
|
||||
|
||||
/** Preserve source bytes when an unflagged legacy name may use another code page. */
|
||||
const legacyNameLimitations = (entry: Entry): readonly string[] => {
|
||||
if (entry.filenameUTF8 || entry.extraFieldUnicodePath?.valid === true)
|
||||
return [];
|
||||
const bytes = entry.rawFilename;
|
||||
if (!bytes.some((byte) => byte >= 0x80)) return [];
|
||||
const hex = Buffer.from(bytes).toString("hex");
|
||||
return [
|
||||
`ZIP name uses CP437 fallback; source encoding unverified; raw hex: ${hex}.`,
|
||||
];
|
||||
};
|
||||
|
||||
const decodeZipEntryText = (
|
||||
bytes: Uint8Array,
|
||||
encoding: string,
|
||||
|
||||
@@ -7,6 +7,10 @@ import { expect, it } from "vitest";
|
||||
import { writeOrderedZip } from "../../fixtures/artifactEntryOrder.js";
|
||||
import { inventoryArtifact } from "../../fixtures/artifactInventory.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import {
|
||||
createSingleEntryZip,
|
||||
rewriteSingleEntryZipName,
|
||||
} from "../../fixtures/zipNames.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
|
||||
@@ -54,6 +58,7 @@ const legacyControlNames = [
|
||||
|
||||
it.each([
|
||||
["legacy CP437", Buffer.from([0x82, 0x2e, 0x74, 0x78, 0x74]), false, "é.txt"],
|
||||
["unflagged ASCII", Buffer.from("plain.txt"), false, "plain.txt"],
|
||||
...legacyControlNames,
|
||||
[
|
||||
"EFS with leading U+FEFF",
|
||||
@@ -61,16 +66,23 @@ it.each([
|
||||
true,
|
||||
"\uFEFFab",
|
||||
],
|
||||
[
|
||||
"flagged UTF-8 high bytes",
|
||||
Buffer.from("한글.txt", "utf8"),
|
||||
true,
|
||||
"한글.txt",
|
||||
],
|
||||
] as const)(
|
||||
"preserves %s ZIP member names",
|
||||
async (_label, rawName, utf8Flag, logicalPath) => {
|
||||
const root = await createTestTempDirectory("rea-zip-decoded-name-");
|
||||
const path = join(root, "names.zip");
|
||||
await writeSingleEntryZip(path, "x.txt");
|
||||
const placeholderName = `${"x".repeat(rawName.length - 4)}.txt`;
|
||||
await writeSingleEntryZip(path, placeholderName);
|
||||
const archive = await readFile(path);
|
||||
await writeFile(
|
||||
path,
|
||||
rewriteSingleEntryName(archive, "x.txt", rawName, utf8Flag),
|
||||
rewriteSingleEntryZipName(archive, placeholderName, rawName, utf8Flag),
|
||||
);
|
||||
|
||||
const inventory = await inventoryArtifact(path);
|
||||
@@ -78,21 +90,36 @@ it.each([
|
||||
expect.objectContaining({
|
||||
logical_path: logicalPath,
|
||||
hash_status: "verified",
|
||||
limitations:
|
||||
!utf8Flag && rawName.some((byte) => byte >= 0x80)
|
||||
? [expect.stringContaining(rawName.toString("hex"))]
|
||||
: [],
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32").each(legacyControlNames)(
|
||||
it
|
||||
.skipIf(process.platform === "win32")
|
||||
.each([
|
||||
...legacyControlNames,
|
||||
[
|
||||
"unflagged CP437 high bytes",
|
||||
Buffer.from([0xc7, 0xd1, 0xb1, 0xdb, 0x2e, 0x74, 0x78, 0x74]),
|
||||
false,
|
||||
"╟╤▒█.txt",
|
||||
],
|
||||
] as const)(
|
||||
"extracts %s ZIP members under their stored names",
|
||||
async (_label, rawName, utf8Flag, logicalPath) => {
|
||||
const root = await createTestTempDirectory("rea-zip-extracted-name-");
|
||||
const path = join(root, "names.zip");
|
||||
await writeSingleEntryZip(path, "x.txt");
|
||||
const placeholderName = `${"x".repeat(rawName.length - 4)}.txt`;
|
||||
await writeSingleEntryZip(path, placeholderName);
|
||||
const archive = await readFile(path);
|
||||
await writeFile(
|
||||
path,
|
||||
rewriteSingleEntryName(archive, "x.txt", rawName, utf8Flag),
|
||||
rewriteSingleEntryZipName(archive, placeholderName, rawName, utf8Flag),
|
||||
);
|
||||
const target = await parseBinaryTarget(path);
|
||||
if (!target.ok) throw target.error;
|
||||
@@ -118,20 +145,20 @@ it.skipIf(process.platform === "win32").each(legacyControlNames)(
|
||||
it("preserves valid Info-ZIP Unicode Path overrides", async () => {
|
||||
const root = await createTestTempDirectory("rea-zip-unicode-path-");
|
||||
const path = join(root, "unicode-path.zip");
|
||||
const rawName = Buffer.from("legacy.txt");
|
||||
const rawName = Buffer.from([0xc7, 0xd1, 0xb1, 0xdb, 0x2e, 0x74, 0x78, 0x74]);
|
||||
const unicodeName = Buffer.from("café.txt", "utf8");
|
||||
const field = Buffer.alloc(5 + unicodeName.length);
|
||||
field[0] = 1;
|
||||
field.writeUInt32LE(zipCrc32(rawName), 1);
|
||||
unicodeName.copy(field, 5);
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add("legacy.txt", new TextReader("unicode path"), {
|
||||
await writer.add("name.txt", new TextReader("unicode path"), {
|
||||
extraField: new Map([[0x7075, field]]),
|
||||
});
|
||||
const archive = Buffer.from(await writer.close());
|
||||
await writeFile(
|
||||
path,
|
||||
rewriteSingleEntryName(archive, "legacy.txt", rawName, false),
|
||||
rewriteSingleEntryZipName(archive, "name.txt", rawName, false),
|
||||
);
|
||||
|
||||
const inventory = await inventoryArtifact(path);
|
||||
@@ -139,6 +166,62 @@ it("preserves valid Info-ZIP Unicode Path overrides", async () => {
|
||||
expect.objectContaining({
|
||||
logical_path: "café.txt",
|
||||
hash_status: "verified",
|
||||
limitations: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports unflagged high-byte ZIP names as CP437 readings with their source bytes", async () => {
|
||||
const root = await createTestTempDirectory("rea-zip-legacy-name-limitation-");
|
||||
const path = join(root, "legacy-name.zip");
|
||||
const rawName = Buffer.from([0xc7, 0xd1, 0xb1, 0xdb, 0x2e, 0x74, 0x78, 0x74]);
|
||||
await writeSingleEntryZip(path, "name.txt");
|
||||
const archive = await readFile(path);
|
||||
await writeFile(
|
||||
path,
|
||||
rewriteSingleEntryZipName(archive, "name.txt", rawName, false),
|
||||
);
|
||||
|
||||
const inventory = await inventoryArtifact(path);
|
||||
expect(inventory.occurrences).toContainEqual(
|
||||
expect.objectContaining({
|
||||
logical_path: "╟╤▒█.txt",
|
||||
hash_status: "verified",
|
||||
limitations: [
|
||||
"ZIP name uses CP437 fallback; source encoding unverified; raw hex: c7d1b1db2e747874.",
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the stored name and reports bytes when a Unicode Path field CRC is invalid", async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-zip-invalid-unicode-extra-crc-",
|
||||
);
|
||||
const path = join(root, "invalid-unicode-extra-crc.zip");
|
||||
const rawName = Buffer.from([0x82, 0x2e, 0x74, 0x78, 0x74]);
|
||||
const unicodeName = Buffer.from("café.txt", "utf8");
|
||||
const field = Buffer.alloc(5 + unicodeName.length);
|
||||
field[0] = 1;
|
||||
field.writeUInt32LE((zipCrc32(rawName) ^ 1) >>> 0, 1);
|
||||
unicodeName.copy(field, 5);
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add("x.txt", new TextReader("malformed extra"), {
|
||||
extraField: new Map([[0x7075, field]]),
|
||||
});
|
||||
const archive = Buffer.from(await writer.close());
|
||||
await writeFile(
|
||||
path,
|
||||
rewriteSingleEntryZipName(archive, "x.txt", rawName, false),
|
||||
);
|
||||
|
||||
const inventory = await inventoryArtifact(path);
|
||||
expect(inventory.occurrences).toContainEqual(
|
||||
expect.objectContaining({
|
||||
logical_path: "é.txt",
|
||||
limitations: [
|
||||
"ZIP name uses CP437 fallback; source encoding unverified; raw hex: 822e747874.",
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -151,7 +234,7 @@ it("rejects malformed UTF-8 in flagged ZIP member names", async () => {
|
||||
const malformedName = Buffer.from([0xc3, 0x28, 0x2e, 0x74, 0x78]);
|
||||
await writeFile(
|
||||
path,
|
||||
rewriteSingleEntryName(archive, "x.txt", malformedName, true),
|
||||
rewriteSingleEntryZipName(archive, "x.txt", malformedName, true),
|
||||
);
|
||||
|
||||
await expect(inventoryArtifact(path)).rejects.toMatchObject({
|
||||
@@ -177,7 +260,7 @@ it("rejects malformed UTF-8 in a CRC-valid Unicode Path extra field", async () =
|
||||
const archive = Buffer.from(await writer.close());
|
||||
await writeFile(
|
||||
path,
|
||||
rewriteSingleEntryName(archive, "legacy.txt", rawName, false),
|
||||
rewriteSingleEntryZipName(archive, "legacy.txt", rawName, false),
|
||||
);
|
||||
|
||||
await expect(inventoryArtifact(path)).rejects.toMatchObject({
|
||||
@@ -545,52 +628,7 @@ const writeSingleEntryZip = async (
|
||||
path: string,
|
||||
name: string,
|
||||
): Promise<void> => {
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add(name, new TextReader("member bytes"));
|
||||
await writeFile(path, await writer.close());
|
||||
};
|
||||
|
||||
const rewriteSingleEntryName = (
|
||||
archive: Buffer,
|
||||
originalName: string,
|
||||
rawName: Buffer,
|
||||
utf8Flag: boolean,
|
||||
): Buffer => {
|
||||
const rewritten = Buffer.from(archive);
|
||||
const expectedName = Buffer.from(originalName, "utf8");
|
||||
for (const { signature, nameOffset, flagsOffset, lengthOffset } of [
|
||||
{
|
||||
signature: Buffer.from([0x50, 0x4b, 0x03, 0x04]),
|
||||
nameOffset: 30,
|
||||
flagsOffset: 6,
|
||||
lengthOffset: 26,
|
||||
},
|
||||
{
|
||||
signature: Buffer.from([0x50, 0x4b, 0x01, 0x02]),
|
||||
nameOffset: 46,
|
||||
flagsOffset: 8,
|
||||
lengthOffset: 28,
|
||||
},
|
||||
]) {
|
||||
const header = rewritten.indexOf(signature);
|
||||
if (header < 0) throw new Error("Expected ZIP member header");
|
||||
const nameLength = rewritten.readUInt16LE(header + lengthOffset);
|
||||
if (
|
||||
nameLength !== expectedName.length ||
|
||||
nameLength !== rawName.length ||
|
||||
!rewritten
|
||||
.subarray(header + nameOffset, header + nameOffset + nameLength)
|
||||
.equals(expectedName)
|
||||
)
|
||||
throw new Error("Expected the original ZIP member name");
|
||||
rawName.copy(rewritten, header + nameOffset);
|
||||
const flags = rewritten.readUInt16LE(header + flagsOffset);
|
||||
rewritten.writeUInt16LE(
|
||||
utf8Flag ? flags | 0x0800 : flags & ~0x0800,
|
||||
header + flagsOffset,
|
||||
);
|
||||
}
|
||||
return rewritten;
|
||||
await writeFile(path, await createSingleEntryZip(name));
|
||||
};
|
||||
|
||||
const zipCrc32 = (bytes: Uint8Array): number => {
|
||||
|
||||
@@ -13,6 +13,10 @@ import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import {
|
||||
createSingleEntryZip,
|
||||
rewriteSingleEntryZipName,
|
||||
} from "../../fixtures/zipNames.js";
|
||||
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
|
||||
@@ -142,6 +146,58 @@ it("extracts an active archive through MCP when requested", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves raw legacy ZIP name bytes and CP437 path interpretation through MCP", async () => {
|
||||
const root = await createTestTempDirectory("rea-legacy-zip-name-mcp-");
|
||||
const archive = join(root, "legacy-name.zip");
|
||||
const rawName = Buffer.from([0xc7, 0xd1, 0xb1, 0xdb, 0x2e, 0x74, 0x78, 0x74]);
|
||||
await writeFile(
|
||||
archive,
|
||||
rewriteSingleEntryZipName(
|
||||
await createSingleEntryZip("name.txt"),
|
||||
"name.txt",
|
||||
rawName,
|
||||
false,
|
||||
),
|
||||
);
|
||||
|
||||
const session = createTestBinarySession(new ArtifactProvider(process.env));
|
||||
const server = createServer({ kind: "session", session });
|
||||
const client = new Client({ name: "legacy-zip-name-test", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: archive },
|
||||
});
|
||||
expect(opened.isError).not.toBe(true);
|
||||
const inspected = await client.callTool({
|
||||
name: "inspect_artifact",
|
||||
arguments: {},
|
||||
});
|
||||
expect(inspected.isError).not.toBe(true);
|
||||
const result = toolEvidence(inspected.structuredContent);
|
||||
const inspection = z
|
||||
.object({ substeps: z.array(z.object({ evidence: z.unknown() })) })
|
||||
.parse(result.normalized_result);
|
||||
const inventory = z
|
||||
.object({ normalized_result: artifactInventoryResultSchema })
|
||||
.parse(inspection.substeps[0]?.evidence).normalized_result;
|
||||
expect(inventory.occurrences).toContainEqual(
|
||||
expect.objectContaining({
|
||||
logical_path: "╟╤▒█.txt",
|
||||
limitations: [
|
||||
"ZIP name uses CP437 fallback; source encoding unverified; raw hex: c7d1b1db2e747874.",
|
||||
],
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await Promise.all([client.close(), server.close(), session.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
it("extracts a macOS app bundle through MCP", async () => {
|
||||
const root = await createTestTempDirectory("rea-artifact-extract-app-mcp-");
|
||||
const contents = join(root, "Fixture.app", "Contents");
|
||||
|
||||
Vendored
+55
@@ -0,0 +1,55 @@
|
||||
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
|
||||
|
||||
/** Build a one-entry ZIP for tests that need to rewrite its stored filename. */
|
||||
export const createSingleEntryZip = async (
|
||||
name: string,
|
||||
contents = "member bytes",
|
||||
): Promise<Buffer> => {
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add(name, new TextReader(contents));
|
||||
return Buffer.from(await writer.close());
|
||||
};
|
||||
|
||||
/** Rewrite both single-entry ZIP headers while preserving all other bytes. */
|
||||
export const rewriteSingleEntryZipName = (
|
||||
archive: Buffer,
|
||||
originalName: string,
|
||||
rawName: Buffer,
|
||||
utf8Flag: boolean,
|
||||
): Buffer => {
|
||||
const rewritten = Buffer.from(archive);
|
||||
const expectedName = Buffer.from(originalName, "utf8");
|
||||
for (const { signature, nameOffset, flagsOffset, lengthOffset } of [
|
||||
{
|
||||
signature: Buffer.from([0x50, 0x4b, 0x03, 0x04]),
|
||||
nameOffset: 30,
|
||||
flagsOffset: 6,
|
||||
lengthOffset: 26,
|
||||
},
|
||||
{
|
||||
signature: Buffer.from([0x50, 0x4b, 0x01, 0x02]),
|
||||
nameOffset: 46,
|
||||
flagsOffset: 8,
|
||||
lengthOffset: 28,
|
||||
},
|
||||
]) {
|
||||
const header = rewritten.indexOf(signature);
|
||||
if (header < 0) throw new Error("Expected ZIP member header");
|
||||
const nameLength = rewritten.readUInt16LE(header + lengthOffset);
|
||||
if (
|
||||
nameLength !== expectedName.length ||
|
||||
nameLength !== rawName.length ||
|
||||
!rewritten
|
||||
.subarray(header + nameOffset, header + nameOffset + nameLength)
|
||||
.equals(expectedName)
|
||||
)
|
||||
throw new Error("Expected the original ZIP member name");
|
||||
rawName.copy(rewritten, header + nameOffset);
|
||||
const flags = rewritten.readUInt16LE(header + flagsOffset);
|
||||
rewritten.writeUInt16LE(
|
||||
utf8Flag ? flags | 0x0800 : flags & ~0x0800,
|
||||
header + flagsOffset,
|
||||
);
|
||||
}
|
||||
return rewritten;
|
||||
};
|
||||
Reference in new issue
Block a user