fix(artifacts): read ASCII control bytes in legacy ZIP names as ASCII (#2365)

* fix(artifacts): read ASCII control bytes in legacy ZIP names as ASCII

ZIP names without the UTF-8 flag were decoded with zip.js's CP437 table,
which maps bytes 0x01-0x1F and 0x7F to IBM PC display glyphs. A folder
zipped on macOS with a custom icon therefore listed and extracted
`Folder/Icon\r` as `Folder/Icon♪`, a name that is not in the archive and
differs from REA's directory inventory of the same folder. Tabs became `○`
and DEL became `⌂`.

Decode legacy names in REA's existing decodeText hook with the Unicode
CP437 mapping that Python's zipfile also uses: bytes below 0x80 as ASCII,
and the upper half with the same table zip.js uses.

Windows file names cannot contain U+0001-U+001F, so a Windows extraction
destination now refuses such a path with a typed path failure before
creating anything, as it already does for ':'.

* test(artifacts): check extracted legacy ZIP control names on disk

The control-name cases checked only the logical path that inventory
reports. Extraction writes through SafeOutputTree, so a change that
renamed the extracted files would still pass those cases.

Extract `Icon\r` and `é\t\u007F.a`, then compare the reported
relative_path, the directory listing, and the file contents. The new
cases skip on Windows, which refuses these names.
safeOutputTree.test.ts covers that refusal.
This commit is contained in:
Chaoqi authored and GitHub committed 2026-10-13 18:05:20 +08:00
1 parent d13c9f840d
commit 9928f357fb
4 files changed
+93 -2

No files matched your search

+5
View File
@@ -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;
};
+21 -2
View File
@@ -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;
@@ -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");
@@ -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: [],
});
},
);
});