From 2e03251b61288c350d832e456746fd329d2e7558 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:05:38 +0800 Subject: [PATCH] fix(zip): retain raw filename provenance for legacy decoding --- src/artifacts/ZipArtifactReader.ts | 13 ++ .../filesystem/artifactZipIntegrity.test.ts | 150 +++++++++++------- tests/boundary/mcp/artifactMcp.test.ts | 56 +++++++ tests/fixtures/zipNames.ts | 55 +++++++ 4 files changed, 218 insertions(+), 56 deletions(-) create mode 100644 tests/fixtures/zipNames.ts diff --git a/src/artifacts/ZipArtifactReader.ts b/src/artifacts/ZipArtifactReader.ts index e88b60bff0..763192af3d 100644 --- a/src/artifacts/ZipArtifactReader.ts +++ b/src/artifacts/ZipArtifactReader.ts @@ -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, diff --git a/tests/boundary/filesystem/artifactZipIntegrity.test.ts b/tests/boundary/filesystem/artifactZipIntegrity.test.ts index 31f31822ac..0f098fb05c 100644 --- a/tests/boundary/filesystem/artifactZipIntegrity.test.ts +++ b/tests/boundary/filesystem/artifactZipIntegrity.test.ts @@ -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 => { - 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 => { diff --git a/tests/boundary/mcp/artifactMcp.test.ts b/tests/boundary/mcp/artifactMcp.test.ts index 1a73ab8f43..d84c139100 100644 --- a/tests/boundary/mcp/artifactMcp.test.ts +++ b/tests/boundary/mcp/artifactMcp.test.ts @@ -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"); diff --git a/tests/fixtures/zipNames.ts b/tests/fixtures/zipNames.ts new file mode 100644 index 0000000000..bfe827de0c --- /dev/null +++ b/tests/fixtures/zipNames.ts @@ -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 => { + 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; +};