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:
1 parent
d13c9f840d
commit
9928f357fb
4 files changed
+93
-2
No files matched your search
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user