diff --git a/src/artifacts/pyinstaller/PyzArchive.test.ts b/src/artifacts/pyinstaller/PyzArchive.test.ts index f7ffe27202..1adc66e7eb 100644 --- a/src/artifacts/pyinstaller/PyzArchive.test.ts +++ b/src/artifacts/pyinstaller/PyzArchive.test.ts @@ -94,44 +94,83 @@ it.each(["PYZ\0", "BAD\0"])( }, ); -it("rejects a PYZ table whose back-references repeat one long row", async () => { - // One namespace row with a 16 KiB name, flagged for reference, then 999 - // back-references to it. The table is about 21 KiB but would expand to - // 16 MiB of entry names. - const name = Buffer.alloc(16 * 1024, 0x61); - const refs = 1_000; - const row = Buffer.concat([ - Buffer.from([0x28 | 0x80]), - int32(2), - Buffer.from([0x61]), - int32(name.length), - name, - Buffer.from([0x29, 3, 0x69]), - int32(3), - Buffer.from([0x69]), - int32(0), - Buffer.from([0x69]), - int32(0), - ]); - const reference = Buffer.concat([Buffer.from([0x72]), int32(0)]); - const table = Buffer.concat([ - Buffer.from([0x5b]), - int32(refs), - row, - ...Array.from({ length: refs - 1 }, () => reference), - ]); - const header = Buffer.alloc(12); - header.write("PYZ\0"); - PYTHON_39_MAGIC.copy(header, 4); - header.writeUInt32BE(12, 8); - const image = Buffer.concat([header, table]); - expect(image.length).toBeLessThan(32 * 1024); +it.each([0x61, 0xe9])( + "rejects a PYZ table whose back-references repeat one long row (byte %i)", + async (byte) => { + // One namespace row with a 16 KiB name, flagged for reference, then 999 + // back-references to it. The table is about 21 KiB but would expand to + // 16 or 32 MiB of UTF-8 entry names. + const name = Buffer.alloc(16 * 1024, byte); + const refs = 1_000; + const row = Buffer.concat([ + Buffer.from([0x28 | 0x80]), + int32(2), + Buffer.from([0x61]), + int32(name.length), + name, + Buffer.from([0x29, 3, 0x69]), + int32(3), + Buffer.from([0x69]), + int32(0), + Buffer.from([0x69]), + int32(0), + ]); + const reference = Buffer.concat([Buffer.from([0x72]), int32(0)]); + const table = Buffer.concat([ + Buffer.from([0x5b]), + int32(refs), + row, + ...Array.from({ length: refs - 1 }, () => reference), + ]); + const header = Buffer.alloc(12); + header.write("PYZ\0"); + PYTHON_39_MAGIC.copy(header, 4); + header.writeUInt32BE(12, 8); + const image = Buffer.concat([header, table]); + expect(image.length).toBeLessThan(32 * 1024); - const parsed = await readImage(image); - expect(parsed).toMatchObject({ ok: false, error: { reason: "limit" } }); - if (parsed.ok) return; - expect(parsed.error.message).toContain("expand past"); -}); + const parsed = await readImage(image); + expect(parsed).toMatchObject({ ok: false, error: { reason: "limit" } }); + if (parsed.ok) return; + expect(parsed.error.message).toContain("expand past"); + }, +); + +it.each([0x61, 0x41, 0x7a, 0x5a, 0x75])( + "preserves namespace text from one-byte and UTF-8 marshal encodings (tag %i)", + async (tag) => { + const name = "é".repeat(64); + const encoded = Buffer.from(name, tag === 0x75 ? "utf8" : "latin1"); + const table = Buffer.concat([ + Buffer.from([0x5b]), + int32(1), + Buffer.from([0x29, 2, tag]), + tag === 0x7a || tag === 0x5a + ? Buffer.from([encoded.length]) + : int32(encoded.length), + encoded, + Buffer.from([0x29, 3, 0x69]), + int32(3), + Buffer.from([0x69]), + int32(0), + Buffer.from([0x69]), + int32(0), + ]); + const image = pyzArchive({ + magic: PYTHON_39_MAGIC, + modules: [], + toc: table, + }); + expect(await readImage(image)).toMatchObject({ + ok: true, + value: { + kind: "entries", + sha256: createHash("sha256").update(image).digest("hex"), + entries: [{ name, role: "namespace", typeCode: 3, length: 0 }], + }, + }); + }, +); const int32 = (value: number): Buffer => { const bytes = Buffer.alloc(4); diff --git a/src/artifacts/pyinstaller/PyzArchive.ts b/src/artifacts/pyinstaller/PyzArchive.ts index f25d63fea9..f0408e1e54 100644 --- a/src/artifacts/pyinstaller/PyzArchive.ts +++ b/src/artifacts/pyinstaller/PyzArchive.ts @@ -180,8 +180,9 @@ const parsePyzParts = ( /** * Marshal back-references let one stored row appear many times, so a small - * table can name far more bytes than it holds. Each genuine entry stores its - * own name inline, so the expanded names can never exceed the table itself. + * table can name far more bytes than it holds. Inline one-byte Unicode names + * can double in UTF-8; UTF-8 marshal names do not expand. Allow that decoding + * expansion while bounding the total names repeated by back-references. */ const pyzEntries = ( value: MarshalValue, @@ -193,16 +194,17 @@ const pyzEntries = ( pyInstallerFailure("format", "PYZ table of contents is not a list"), ); const entries: PyzEntry[] = []; + const nameByteBudget = tableBytes * 2; let nameBytes = 0; for (const item of value) { const entry = onePyzEntry(item, payloadEnd, entries.length); if (!entry.ok) return entry; nameBytes += Buffer.byteLength(entry.value.name, "utf8"); - if (nameBytes > tableBytes) + if (nameBytes > nameByteBudget) return err( pyInstallerFailure( "limit", - `PYZ table entry names expand past the ${String(tableBytes)}-byte table that stores them`, + `PYZ table entry names expand past the ${String(nameByteBudget)}-byte decoded-name budget`, ), ); entries.push(entry.value);