From 8425c36da2dbcf9dffc6d40140604d5108e437eb Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:31:44 +0800 Subject: [PATCH] fix(pyinstaller): attribute malformed TOC rows at their owner --- docs/python-packaged-applications.md | 3 ++ src/artifacts/pyinstaller/CArchiveLayout.ts | 37 +++++++----------- .../PyInstallerFormatBoundaries.test.ts | 39 +++++++++++++++++++ 3 files changed, 56 insertions(+), 23 deletions(-) diff --git a/docs/python-packaged-applications.md b/docs/python-packaged-applications.md index b3488a70fa..f33713addd 100644 --- a/docs/python-packaged-applications.md +++ b/docs/python-packaged-applications.md @@ -44,6 +44,9 @@ they do not claim a destination directory. Their `relative_offset` and `compressed_size` preserve any payload declared by older producers, while `declared_size` is zero because no bytecode is materialized for a namespace. Every declared span must end before the PYZ table of contents. +Malformed CArchive TOC rows identify their zero-based table index and byte offset +within the TOC, including truncated headers, invalid names, compression flags, +and type codes. The failure retains its original format reason. PYZ `compression_flag` is derived from storage: nonempty payloads are compressed, including legacy namespaces; an empty namespace has no compressed payload. diff --git a/src/artifacts/pyinstaller/CArchiveLayout.ts b/src/artifacts/pyinstaller/CArchiveLayout.ts index 887fdea825..a6ac959cb2 100644 --- a/src/artifacts/pyinstaller/CArchiveLayout.ts +++ b/src/artifacts/pyinstaller/CArchiveLayout.ts @@ -137,6 +137,13 @@ export const parseCArchiveToc = ( ): Result => { const entries: CArchiveEntry[] = []; let offset = 0; + const malformedEntry = (message: string): Result => + err( + pyInstallerFailure( + "format", + `CArchive table index ${String(entries.length)} at TOC offset ${String(offset)}: ${message}`, + ), + ); while (offset < bytes.length) { if (entries.length >= MAX_TOC_ENTRIES) return err( @@ -146,42 +153,26 @@ export const parseCArchiveToc = ( ), ); if (offset + TOC_HEADER_BYTES > bytes.length) - return err( - pyInstallerFailure( - "format", - "CArchive table of contents entry is truncated", - ), - ); + return malformedEntry("CArchive table of contents entry is truncated"); const entryLength = bytes.readUInt32BE(offset); if (entryLength < TOC_HEADER_BYTES || offset + entryLength > bytes.length) - return err( - pyInstallerFailure( - "format", - "CArchive table of contents entry extends outside the table", - ), + return malformedEntry( + "CArchive table of contents entry extends outside the table", ); const nameBytes = bytes.subarray( offset + TOC_HEADER_BYTES, offset + entryLength, ); const name = tocName(nameBytes); - if (!name.ok) return name; + if (!name.ok) return malformedEntry(name.error.message); const compressionFlag = bytes[offset + 16] ?? 0; if (compressionFlag !== 0 && compressionFlag !== 1) - return err( - pyInstallerFailure( - "format", - `CArchive compression flag ${String(compressionFlag)} is not stored or zlib`, - ), + return malformedEntry( + `CArchive compression flag ${String(compressionFlag)} is not stored or zlib`, ); const typeCode = bytes[offset + 17] ?? 0; if (typeCode < 0x20 || typeCode > 0x7e) - return err( - pyInstallerFailure( - "format", - "CArchive type code is not a printable character", - ), - ); + return malformedEntry("CArchive type code is not a printable character"); entries.push({ index: entries.length, tocRecordOffset: offset, diff --git a/src/artifacts/pyinstaller/PyInstallerFormatBoundaries.test.ts b/src/artifacts/pyinstaller/PyInstallerFormatBoundaries.test.ts index 1432986d2a..9bbe295259 100644 --- a/src/artifacts/pyinstaller/PyInstallerFormatBoundaries.test.ts +++ b/src/artifacts/pyinstaller/PyInstallerFormatBoundaries.test.ts @@ -26,6 +26,45 @@ import { type ArtifactEntry, } from "../ArtifactReader.js"; +it.each([0, 1, 2])( + "identifies malformed CArchive compression at table index %i", + async (index) => { + const original = cArchiveExecutable({ + layout: "2.1", + pythonVersion: 309, + members: ["a", "b", "c"].map((name) => ({ + name, + typeCode: "x", + payload: Buffer.from(name), + compress: false, + })), + }); + const toc = 64 + original.readUInt32BE(original.length - 88 + 12); + let record = toc; + for (let row = 0; row < index; row++) + record += original.readUInt32BE(record); + await withPyInstallerReader(original, async (reader) => { + const entries = []; + for await (const entry of reader.entries()) entries.push(entry); + expect(entries).toHaveLength(3); + }); + const malformed = Buffer.from(original); + malformed[record + 16] = 2; + await expect( + withPyInstallerReader(malformed, async (reader) => { + for await (const _entry of reader.entries()) { + /* force TOC admission */ + } + }), + ).rejects.toMatchObject({ + reason: "format", + message: expect.stringContaining( + `table index ${index} at TOC offset ${record - toc}`, + ), + }); + }, +); + it.each([ ["empty", Buffer.alloc(0)], ["short", Buffer.from("630000", "hex")],