diff --git a/src/python/PythonPycParser.ts b/src/python/PythonPycParser.ts index 1ab4ab1d5e..8f257fd6ef 100644 --- a/src/python/PythonPycParser.ts +++ b/src/python/PythonPycParser.ts @@ -26,6 +26,7 @@ const TYPE_TRUE = 0x54; const TYPE_STOPITER = 0x53; const TYPE_ELLIPSIS = 0x2e; const TYPE_INT = 0x69; +const TYPE_INT64 = 0x49; const TYPE_FLOAT_TEXT = 0x66; const TYPE_FLOAT = 0x67; const TYPE_COMPLEX_TEXT = 0x78; @@ -390,6 +391,10 @@ class MarshalReader { const value = BigInt(this.i32()); return { kind: "int", value, bits: smallBitLength(value) }; } + case TYPE_INT64: { + const value = this.i64(); + return { kind: "int", value, bits: smallBitLength(value) }; + } case TYPE_FLOAT_TEXT: return { kind: "float", value: this.textFloat() }; case TYPE_FLOAT: @@ -1514,6 +1519,14 @@ class MarshalReader { return value; } + i64(): bigint { + if (this.offset > this.bytes.length - 8) + malformed("Marshal stream ended inside a 64-bit integer."); + const value = this.bytes.readBigInt64LE(this.offset); + this.offset += 8; + return value; + } + /** CPython `r_long` count fields that its constructors require non-negative. */ nonNegativeInt32(field: string): number { const value = this.i32(); diff --git a/tests/acceptance/pythonPycCli.test.ts b/tests/acceptance/pythonPycCli.test.ts index 404cfdb567..5ff4ce8d8d 100644 --- a/tests/acceptance/pythonPycCli.test.ts +++ b/tests/acceptance/pythonPycCli.test.ts @@ -17,6 +17,102 @@ const marshalUtf8 = (bytes: Buffer): Buffer => { return Buffer.concat([Buffer.from([0x75]), length, bytes]); }; +const marshalInt64 = (value: bigint): Buffer => { + const payload = Buffer.alloc(8); + payload.writeBigInt64LE(value); + return Buffer.concat([Buffer.from([0x49]), payload]); +}; + +const marshalInt32 = (value: number): Buffer => { + const payload = Buffer.alloc(4); + payload.writeInt32LE(value); + return Buffer.concat([Buffer.from([0x69]), payload]); +}; + +const marshalSingleItemTuple = (item: Buffer): Buffer => + Buffer.concat([Buffer.from([0x29, 1]), item]); + +cliTest( + "reads backward-compatible signed 64-bit marshal integers", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-python-pyc-int64-"); + for (const [name, value, bits] of [ + ["positive", 42n, 6], + ["negative", -1n, 1], + ["minimum", -(1n << 63n), 64], + ["maximum", (1n << 63n) - 1n, 63], + ] as const) { + const path = join(root, `${name}.pyc`); + await writeFile( + path, + Buffer.concat([ + pythonPycHeader(3531), + marshalCode("py311", { + constsObject: marshalSingleItemTuple(marshalInt64(value)), + }), + ]), + ); + const result = await cli.run({ + arguments: ["inspect-python-pyc", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_python_pyc").outputSchema.parse( + result.json, + ); + expect(evidence.normalized_result.code_objects[0]?.consts).toContainEqual( + { kind: "int", bits, value: value.toString() }, + ); + } + + const ordinaryIntPath = join(root, "ordinary-int.pyc"); + await writeFile( + ordinaryIntPath, + Buffer.concat([ + pythonPycHeader(3531), + marshalCode("py311", { + constsObject: marshalSingleItemTuple(marshalInt32(-42)), + }), + ]), + ); + const ordinaryInt = await cli.run({ + arguments: ["inspect-python-pyc", ordinaryIntPath, "--json"], + }); + expect(ordinaryInt.exitCode, ordinaryInt.stderr).toBe(0); + const ordinaryEvidence = toolContract( + "inspect_python_pyc", + ).outputSchema.parse(ordinaryInt.json); + expect( + ordinaryEvidence.normalized_result.code_objects[0]?.consts, + ).toContainEqual({ kind: "int", bits: 6, value: "-42" }); + + const truncatedPath = join(root, "truncated.pyc"); + await writeFile( + truncatedPath, + Buffer.concat([ + pythonPycHeader(3531), + Buffer.from([0x49]), + Buffer.alloc(7), + ]), + ); + const truncated = await cli.run({ + arguments: ["inspect-python-pyc", truncatedPath, "--json"], + }); + expect(truncated.exitCode).toBe(1); + expect(truncated.json).toMatchObject({ + code: "invalid_request", + details: { + operation: "inspect_python_pyc", + issues: [ + { + reason: "invalid_format", + message: expect.stringContaining("64-bit integer"), + }, + ], + }, + }); + }, +); + cliTest( "uses magic-specific cache tables for synthetic pyc bytecode equality", async ({ cli }) => { diff --git a/tests/boundary/mcp/pythonPycMcp.test.ts b/tests/boundary/mcp/pythonPycMcp.test.ts index 3cd1084066..431bfd79b0 100644 --- a/tests/boundary/mcp/pythonPycMcp.test.ts +++ b/tests/boundary/mcp/pythonPycMcp.test.ts @@ -14,6 +14,21 @@ import { import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { mcpTest, type TestMcp } from "../../support/mcp/mcpFixture.js"; +const marshalInt64 = (value: bigint): Buffer => { + const payload = Buffer.alloc(8); + payload.writeBigInt64LE(value); + return Buffer.concat([Buffer.from([0x49]), payload]); +}; + +const marshalInt32 = (value: number): Buffer => { + const payload = Buffer.alloc(4); + payload.writeInt32LE(value); + return Buffer.concat([Buffer.from([0x69]), payload]); +}; + +const marshalSingleItemTuple = (item: Buffer): Buffer => + Buffer.concat([Buffer.from([0x29, 1]), item]); + const connect = async (mcp: TestMcp) => { const session = createTestBinarySession(() => { throw new Error("Python pyc inspection must not start a deep provider"); @@ -22,6 +37,97 @@ const connect = async (mcp: TestMcp) => { return { client, session }; }; +mcpTest( + "reads signed TYPE_INT64 constants and rejects a truncated payload", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const root = await createTestTempDirectory("rea-python-pyc-mcp-int64-"); + for (const [name, value, bits] of [ + ["positive", 42n, 6], + ["negative", -1n, 1], + ["minimum", -(1n << 63n), 64], + ["maximum", (1n << 63n) - 1n, 63], + ] as const) { + const path = join(root, `${name}.pyc`); + await writeFile( + path, + Buffer.concat([ + pythonPycHeader(3531), + marshalCode("py311", { + constsObject: marshalSingleItemTuple(marshalInt64(value)), + }), + ]), + ); + const response = await client.callTool({ + name: "inspect_python_pyc", + arguments: { path }, + }); + expect(response.isError, JSON.stringify(response.content)).not.toBe( + true, + ); + const evidence = toolContract("inspect_python_pyc").outputSchema.parse( + response.structuredContent, + ); + expect( + evidence.normalized_result.code_objects[0]?.consts, + ).toContainEqual({ kind: "int", bits, value: value.toString() }); + } + + const ordinaryPath = join(root, "ordinary-int.pyc"); + await writeFile( + ordinaryPath, + Buffer.concat([ + pythonPycHeader(3531), + marshalCode("py311", { + constsObject: marshalSingleItemTuple(marshalInt32(-42)), + }), + ]), + ); + const ordinary = await client.callTool({ + name: "inspect_python_pyc", + arguments: { path: ordinaryPath }, + }); + expect(ordinary.isError, JSON.stringify(ordinary.content)).not.toBe(true); + const ordinaryEvidence = toolContract( + "inspect_python_pyc", + ).outputSchema.parse(ordinary.structuredContent); + expect( + ordinaryEvidence.normalized_result.code_objects[0]?.consts, + ).toContainEqual({ kind: "int", bits: 6, value: "-42" }); + + const truncatedPath = join(root, "truncated.pyc"); + await writeFile( + truncatedPath, + Buffer.concat([ + pythonPycHeader(3531), + Buffer.from([0x49]), + Buffer.alloc(7), + ]), + ); + const truncated = await client.callTool({ + name: "inspect_python_pyc", + arguments: { path: truncatedPath }, + }); + expect(truncated.isError).toBe(true); + expect(parseMcpToolError(truncated).error).toMatchObject({ + code: "invalid_request", + details: { + operation: "inspect_python_pyc", + issues: [ + { + reason: "invalid_format", + message: expect.stringContaining("64-bit integer"), + }, + ], + }, + }); + } finally { + await session.close(); + } + }, +); + mcpTest( "advertises inspect_python_pyc and reads nested code objects", async ({ mcp }) => {