diff --git a/src/artifacts/SafeOutputTree.ts b/src/artifacts/SafeOutputTree.ts index 8fa40876df..73885ad05e 100644 --- a/src/artifacts/SafeOutputTree.ts +++ b/src/artifacts/SafeOutputTree.ts @@ -585,6 +585,11 @@ const normalizeDestinationPath = ( "path", `Windows destination cannot materialize artifact path as a regular file: ${path}; ':' denotes alternate data stream syntax.`, ); + if (platform === "win32" && /[\u0001-\u001f]/u.test(path)) + throw new ArtifactReaderFailure( + "path", + `Windows destination cannot materialize artifact path: ${JSON.stringify(path)}; Windows names cannot contain U+0001-U+001F.`, + ); return path; }; diff --git a/src/artifacts/ZipArtifactReader.ts b/src/artifacts/ZipArtifactReader.ts index 11c68557bc..dfbee8b277 100644 --- a/src/artifacts/ZipArtifactReader.ts +++ b/src/artifacts/ZipArtifactReader.ts @@ -255,10 +255,29 @@ const decodeZipEntryText = ( encoding: string, type: "filename" | "comment", ): string | undefined => { - if (type !== "filename" || encoding !== "utf-8") return undefined; - return decodeStrictZipUtf8(bytes, "ZIP filename is not valid UTF-8"); + if (type !== "filename") return undefined; + if (encoding === "utf-8") + return decodeStrictZipUtf8(bytes, "ZIP filename is not valid UTF-8"); + return encoding === "cp437" ? decodeCp437(bytes) : undefined; }; +/** CP437 characters for bytes 0x80-0xFF. */ +const CP437_HIGH_HALF = + "ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■\u00A0"; + +/** + * Decode a legacy ZIP name with the Unicode CP437 mapping, which Python's + * zipfile also uses: bytes below 0x80 are ASCII. zip.js shows 0x01-0x1F and + * 0x7F as IBM PC display glyphs, which turns the macOS folder icon file + * `Icon\r` into `Icon♪`. + */ +const decodeCp437 = (bytes: Uint8Array): string => + Array.from(bytes, (byte) => + byte < 0x80 + ? String.fromCharCode(byte) + : CP437_HIGH_HALF.charAt(byte - 0x80), + ).join(""); + const zipEntryPath = (entry: Entry): string => { const field = entry.extraFieldUnicodePath; if (field?.valid !== true) return entry.filename; diff --git a/tests/boundary/filesystem/artifactZipIntegrity.test.ts b/tests/boundary/filesystem/artifactZipIntegrity.test.ts index 0bb475518d..41397c5209 100644 --- a/tests/boundary/filesystem/artifactZipIntegrity.test.ts +++ b/tests/boundary/filesystem/artifactZipIntegrity.test.ts @@ -38,8 +38,19 @@ it("completes an empty ZIP extraction with an empty identity", async () => { expect(await readdir(output)).toEqual([]); }); +const legacyControlNames = [ + ["legacy ASCII control", Buffer.from("Icon\r"), false, "Icon\r"], + [ + "legacy CP437 control", + Buffer.from([0x82, 0x09, 0x7f, 0x2e, 0x61]), + false, + "é\t\u007F.a", + ], +] as const; + it.each([ ["legacy CP437", Buffer.from([0x82, 0x2e, 0x74, 0x78, 0x74]), false, "é.txt"], + ...legacyControlNames, [ "EFS with leading U+FEFF", Buffer.from([0xef, 0xbb, 0xbf, 0x61, 0x62]), @@ -68,6 +79,38 @@ it.each([ }, ); +it.skipIf(process.platform === "win32").each(legacyControlNames)( + "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 archive = await readFile(path); + await writeFile( + path, + rewriteSingleEntryName(archive, "x.txt", rawName, utf8Flag), + ); + const target = await parseBinaryTarget(path); + if (!target.ok) throw target.error; + + const output = join(root, "output"); + const result = await withArtifactClient(target.value, (client) => + client.execute("extract_artifact", { output_root: output }), + ); + if (!result.ok) throw result.error; + const extraction = artifactExtractionResultSchema.parse( + result.value.result, + ); + expect( + extraction.artifacts.map(({ relative_path }) => relative_path), + ).toEqual([logicalPath]); + expect(await readdir(output)).toEqual([logicalPath]); + expect(await readFile(join(output, logicalPath), "utf8")).toBe( + "member bytes", + ); + }, +); + it("preserves valid Info-ZIP Unicode Path overrides", async () => { const root = await createTestTempDirectory("rea-zip-unicode-path-"); const path = join(root, "unicode-path.zip"); diff --git a/tests/boundary/filesystem/safeOutputTree.test.ts b/tests/boundary/filesystem/safeOutputTree.test.ts index f677044566..3e82a62ab1 100644 --- a/tests/boundary/filesystem/safeOutputTree.test.ts +++ b/tests/boundary/filesystem/safeOutputTree.test.ts @@ -489,4 +489,28 @@ describe("safe artifact output Windows stream paths", () => { await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" }); expect(await readdir(parent)).toEqual([]); }); + + it.each(["Folder/Icon\r", "Fold\ter/file.txt"])( + "refuses Windows control characters before creating %j", + async (path) => { + const parent = await createTestTempDirectory("rea-safe-output-control-"); + const output = join(parent, "published"); + const tree = await SafeOutputTree.create(output, "win32"); + const bytes = Buffer.from("regular file content"); + await expect( + tree.write(path, Readable.from(bytes), { + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.byteLength, + }), + ).rejects.toMatchObject({ + reason: "path", + message: `Windows destination cannot materialize artifact path: ${JSON.stringify(path)}; Windows names cannot contain U+0001-U+001F.`, + }); + expect(await readdir(output)).toEqual([]); + expect(await tree.rollback()).toEqual({ + status: "complete", + residualPaths: [], + }); + }, + ); });