From b33e5c486703d450ffcf75ff2a231087df66ed63 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 15 Nov 2026 13:45:40 +0800 Subject: [PATCH] refactor(python): report pyc format failures through ParseFailure (#10664) --- .../verify/architecture-guard-baselines.json | 3 +- src/python/PythonPyc.errors.ts | 12 +---- src/python/PythonPycParser.test.ts | 53 ++++++++++--------- src/python/PythonPycParser.ts | 7 +-- src/python/PythonPycProvider.ts | 29 ++-------- 5 files changed, 39 insertions(+), 65 deletions(-) diff --git a/scripts/verify/architecture-guard-baselines.json b/scripts/verify/architecture-guard-baselines.json index 9eaeda6605..1aacd6efda 100644 --- a/scripts/verify/architecture-guard-baselines.json +++ b/scripts/verify/architecture-guard-baselines.json @@ -182,8 +182,7 @@ "src/browser/assets/WebArtifactReads.ts": 1, "src/domain/xmlDocument.ts": 1, "src/native/AppleMachoSelection.ts": 1, - "src/native/MachoCommandNames.ts": 1, - "src/python/PythonPyc.errors.ts": 1 + "src/native/MachoCommandNames.ts": 1 }, "parser-json-parse": { "src/android/JadxProtocol.ts": 2, diff --git a/src/python/PythonPyc.errors.ts b/src/python/PythonPyc.errors.ts index 54e62204d6..5d4772657d 100644 --- a/src/python/PythonPyc.errors.ts +++ b/src/python/PythonPyc.errors.ts @@ -1,15 +1,7 @@ import type { PYTHON_PYC_LIMIT_MAXIMA } from "../domain/python/pythonPyc.js"; -/** Distinguish corrupt recognized pycs from magics outside parser coverage. */ -export class PythonPycFormatFailure extends Error { - constructor( - readonly kind: "malformed" | "unsupported", - message: string, - ) { - super(message); - this.name = "PythonPycFormatFailure"; - } -} +/** The ParseFailure format of a standalone CPython pyc. */ +export const PYTHON_PYC_FORMAT = "python-pyc"; /** Resource boundary exceeded while walking marshal objects. */ export class PythonPycResourceFailure extends Error { diff --git a/src/python/PythonPycParser.test.ts b/src/python/PythonPycParser.test.ts index db176068f1..5f7ff4eca5 100644 --- a/src/python/PythonPycParser.test.ts +++ b/src/python/PythonPycParser.test.ts @@ -11,10 +11,8 @@ import { pythonPycHeader, } from "./PythonPyc.fixture.js"; import { parsePythonPyc } from "./PythonPycParser.js"; -import { - PythonPycFormatFailure, - PythonPycResourceFailure, -} from "./PythonPyc.errors.js"; +import { PythonPycResourceFailure } from "./PythonPyc.errors.js"; +import { ParseFailureError } from "../domain/parseFailure.js"; const defaults = inspectPythonPycInputSchema.parse({ path: "/artifacts/sample.pyc", @@ -133,7 +131,7 @@ it("decodes a hash-based header and a FLAG_REF top-level code object", () => { it("rejects truncated marshal without partial success", () => { expect(() => parse(pythonPycFixture("py311").subarray(0, 20))).toThrow( - PythonPycFormatFailure, + ParseFailureError, ); }); @@ -169,8 +167,13 @@ it("keeps CPython 3.16 pre-release magics outside standalone decoder coverage", it("does not qualify a magic outside the pinned identity table", () => { expect(() => parse(pythonPycHeader(1))).toThrow( expect.objectContaining({ - kind: "unsupported", - message: expect.stringMatching(/not a pinned CPython identity.*header/iu), + failure: expect.objectContaining({ + kind: "unsupported", + format: "python-pyc", + detail: expect.stringMatching( + /not a pinned CPython identity.*header/iu, + ), + }), }), ); }); @@ -481,10 +484,10 @@ it("charges long digits to the string budget and keeps large previews bounded", expect(root(report).consts[1]).toMatchObject({ kind: "int", value: null }); expect(pythonPycSchema.safeParse(report).success).toBe(true); expect(() => parse(withConsts([marshalLong([1, 0])]))).toThrow( - PythonPycFormatFailure, + ParseFailureError, ); expect(() => parse(withConsts([marshalLong([0x8000])]))).toThrow( - PythonPycFormatFailure, + ParseFailureError, ); }); @@ -522,10 +525,10 @@ it("decodes marshal strings with CPython surrogatepass rules", () => { }); expect(() => parse(withConsts([marshalUnicode(Buffer.from([0xf0, 0x90, 0x80]))])), - ).toThrow(PythonPycFormatFailure); + ).toThrow(ParseFailureError); expect(() => parse(withConsts([marshalUnicode(Buffer.from([0xc0, 0x80]))])), - ).toThrow(PythonPycFormatFailure); + ).toThrow(ParseFailureError); }); it.each([0x73, 0x74, 0x75, 0x61, 0x41, 0x28, 0x5b, 0x3c, 0x3e])( @@ -533,7 +536,7 @@ it.each([0x73, 0x74, 0x75, 0x61, 0x41, 0x28, 0x5b, 0x3c, 0x3e])( (tag) => { expect(() => parse(withConsts([Buffer.concat([Buffer.from([tag]), i32(-1)])])), - ).toThrow(PythonPycFormatFailure); + ).toThrow(ParseFailureError); }, ); @@ -589,7 +592,7 @@ it("rejects a code object whose location tables are not bytes", () => { marshalCode("py311", { exceptiontableObject: Buffer.from([0x53]) }), ]), ), - ).toThrow(PythonPycFormatFailure); + ).toThrow(ParseFailureError); }); it("hashes one shared byte string no matter how many times it is referenced", () => { @@ -658,7 +661,7 @@ it("rejects constant tables whose copies exceed max_objects", () => { it("rejects NULL tuple items and mismatched local kinds", () => { expect(() => parse(withConsts([Buffer.from([0x30])]))).toThrow( - PythonPycFormatFailure, + ParseFailureError, ); expect( root(parse(withConsts([Buffer.from([0x7b, 0x4e, 0x54, 0x30])]))).consts[0], @@ -676,7 +679,7 @@ it("rejects NULL tuple items and mismatched local kinds", () => { }), ]), ), - ).toThrow(PythonPycFormatFailure); + ).toThrow(ParseFailureError); const matched = parse( Buffer.concat([ pythonPycHeader(3531), @@ -881,7 +884,7 @@ it("reads marshal version 0 text floats inside a code object", () => { ]); for (const text of ["1.5x", "", "0x1.8p1", " 1.5"]) { expect(() => parse(withConsts([textFloat(text)]))).toThrow( - PythonPycFormatFailure, + ParseFailureError, ); } }); @@ -993,10 +996,10 @@ it("applies Python container rules before reporting lengths", () => { ]), ]), ), - ).toThrow(PythonPycFormatFailure); + ).toThrow(ParseFailureError); expect(() => parse(withConsts([setOf(Buffer.concat([Buffer.from([0x5b]), u32(0)]))])), - ).toThrow(PythonPycFormatFailure); + ).toThrow(ParseFailureError); }); it("rejects a shared tuple key before its expansion doubles without bound", () => { @@ -1164,14 +1167,12 @@ it("rejects code keys and members whose consts are unhashable", () => { Buffer.concat([Buffer.from([0x7b]), key, int, Buffer.from([0x30])]); for (const key of [code(list), code(tuple(int, list)), code(code(list))]) { expect(() => parse(withConsts([setOf(0x3c, key)]))).toThrow( - PythonPycFormatFailure, + ParseFailureError, ); expect(() => parse(withConsts([setOf(0x3e, key)]))).toThrow( - PythonPycFormatFailure, - ); - expect(() => parse(withConsts([dictKey(key)]))).toThrow( - PythonPycFormatFailure, + ParseFailureError, ); + expect(() => parse(withConsts([dictKey(key)]))).toThrow(ParseFailureError); } const cyclic = Buffer.concat([ Buffer.from([0x29 | 0x80, 1]), @@ -1183,7 +1184,7 @@ it("rejects code keys and members whose consts are unhashable", () => { }), ]); expect(() => parse(withConsts([cyclic, setOf(0x3c, marshalRef(1))]))).toThrow( - PythonPycFormatFailure, + ParseFailureError, ); // A set inside a tuple that reaches the unfinished tuple, directly, inside @@ -1211,7 +1212,7 @@ it("rejects code keys and members whose consts are unhashable", () => { ]), ]), ), - ).toThrow(PythonPycFormatFailure); + ).toThrow(ParseFailureError); const unhashed = parse(withConsts([tuple(code(list))])); expect(unhashed.coverage.status).toBe("complete"); @@ -1522,7 +1523,7 @@ it("rejects negative CPython code counts and keeps them out of the schema", () = "stacksize", ] as const) { expect(() => parse(negative("py311", { [field]: 0xffffffff }))).toThrow( - PythonPycFormatFailure, + ParseFailureError, ); } expect(() => parse(negative("py37", { nlocals: 0xffffffff }))).toThrow( diff --git a/src/python/PythonPycParser.ts b/src/python/PythonPycParser.ts index 9da9d01aeb..de53ebbdd3 100644 --- a/src/python/PythonPycParser.ts +++ b/src/python/PythonPycParser.ts @@ -15,8 +15,9 @@ import { type PythonPycCodeObject, type PythonPycConst, } from "../domain/python/pythonPyc.js"; +import { malformedInput, unsupportedInput } from "../domain/parseFailure.js"; import { - PythonPycFormatFailure, + PYTHON_PYC_FORMAT, PythonPycResourceFailure, } from "./PythonPyc.errors.js"; import { pythonPycBytecodeIdentity } from "./PythonPycBytecodeEquality.js"; @@ -179,7 +180,7 @@ type NameTable = { }; const malformed = (message: string): never => { - throw new PythonPycFormatFailure("malformed", message); + throw malformedInput(PYTHON_PYC_FORMAT, message); }; /** Locals CPython's 3.11+ validator counts toward `co_varnames`. */ @@ -193,7 +194,7 @@ const fastLocalCount = (kinds: Buffer): number => { }; const unsupported = (message: string): never => { - throw new PythonPycFormatFailure("unsupported", message); + throw unsupportedInput(PYTHON_PYC_FORMAT, message); }; const digests = new WeakMap(); diff --git a/src/python/PythonPycProvider.ts b/src/python/PythonPycProvider.ts index 842715a92d..a06ad6867b 100644 --- a/src/python/PythonPycProvider.ts +++ b/src/python/PythonPycProvider.ts @@ -19,21 +19,15 @@ import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import type { PythonPycPort } from "../application/python/PythonPycPort.js"; import type { ExecutionOptions } from "../application/AnalysisProvider.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; -import { - AnalysisInputError, - AnalysisResourceConstraintError, - AnalysisUnsupportedTargetError, -} from "../domain/analysisErrorCore.js"; +import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; import { PYTHON_PYC_LIMIT_MAXIMA, type InspectPythonPycInput, } from "../domain/python/pythonPyc.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { err, ok } from "../domain/result.js"; -import { - PythonPycFormatFailure, - PythonPycResourceFailure, -} from "./PythonPyc.errors.js"; +import { PythonPycResourceFailure } from "./PythonPyc.errors.js"; +import { parseFailureAnalysisError } from "../domain/parseFailureAnalysisError.js"; import { parsePythonPyc } from "./PythonPycParser.js"; const OPERATION = "inspect_python_pyc"; @@ -83,21 +77,8 @@ const failureForPrimary = ( cause.reported_limits, { cause, ...parserLimitRecovery(cause.reported_limits) }, ); - if (cause instanceof PythonPycFormatFailure) - return cause.kind === "unsupported" - ? new AnalysisUnsupportedTargetError( - OPERATION, - input.path, - cause.message, - { cause }, - ) - : new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_format", - message: `Selected pyc ${input.path}: ${cause.message}`, - }, - ]); + const formatFailure = parseFailureAnalysisError(OPERATION, input.path, cause); + if (formatFailure !== undefined) return formatFailure; if ( origin === "owned-or-classified" && cause instanceof ArtifactReaderFailure