diff --git a/docs/erlang-beam.md b/docs/erlang-beam.md index 6317dd2f92..b8c6837dd3 100644 --- a/docs/erlang-beam.md +++ b/docs/erlang-beam.md @@ -31,7 +31,7 @@ The BEAM form size bounds the parsed chunks. Bytes after the declared form are ignored by the parser but remain part of the selected artifact's byte length and SHA-256. -When `AtU8` or `Atom` is present, `atoms` is the 1-based name table and +When a selected `AtU8` or `Atom` table is decoded, `atoms` is the 1-based name table and `module` is atom 1. Both the pre-OTP 28 table and the OTP 28+ compact table (negated count, compact-encoded name lengths) are read. `exports`, `imports`, and `lambdas` resolve atom indices to those names when the table is present; unresolved indices keep `function` or @@ -41,6 +41,19 @@ those names when the table is present; unresolved indices keep `function` or function count, and the instruction stream's file range. Missing optional tables are null, not empty-everywhere. +A zero-byte `AtU8` falls back to an available legacy `Atom` table, following +[OTP 20.3's atom selection](https://github.com/erlang/otp/blob/a113f6117fd696ea6f84ed754055b4ec97a7ccb2/erts/emulator/beam/beam_load.c). +A nonempty `AtU8` retains UTF-8 precedence, including an explicit zero atom +count; malformed nonempty data fails instead of falling back. An empty atom +chunk without an alternate table still fails its required count header. +Absent and zero-byte optional `FunT` chunks produce `lambdas: null`, while a +four-byte zero row count produces `lambdas: []`. This follows +[OTP 29's positive-size lambda parsing rule](https://github.com/erlang/otp/blob/550d7b7898706c7822362c42e7b93120c1d1f29a/erts/emulator/beam/beam_file.c). +Empty payloads retain their ranges and SHA-256 in the chunk inventory. These +rules apply to the last occurrence of each chunk name; an empty final occurrence +does not revive an earlier payload of that name. Nonempty truncated tables remain +format failures. + ## Limits and failures The selected file is limited to 64 MiB by default (512 MiB maximum). At most diff --git a/src/erlang/Beam.fixture.ts b/src/erlang/Beam.fixture.ts index da70e33661..dd921624e2 100644 --- a/src/erlang/Beam.fixture.ts +++ b/src/erlang/Beam.fixture.ts @@ -33,6 +33,28 @@ const table = (name: string, rows: readonly (readonly number[])[]): Buffer => { return chunk(name, payload); }; +/** Inert structural container with exact chunk payloads, not executable code. */ +export const erlangBeamChunksFixture = ( + chunks: readonly (readonly [name: string, payload: Buffer])[], +): Buffer => { + const form = concat( + Buffer.from("BEAM", "latin1"), + ...chunks.map(([name, payload]) => chunk(name, payload)), + ); + return concat(Buffer.from("FOR1", "latin1"), u32(form.length), form); +}; + +/** Legacy atom/export container, with an optional exact AtU8 alternate payload. */ +export const erlangBeamLegacyAtomsFixture = (utf8Payload?: Buffer): Buffer => { + const chunks = concat( + atoms(["demo", "ÿ"], false), + utf8Payload === undefined ? Buffer.alloc(0) : chunk("AtU8", utf8Payload), + table("ExpT", [[2, 0, 1]]), + ); + const form = concat(Buffer.from("BEAM", "latin1"), chunks); + return concat(Buffer.from("FOR1", "latin1"), u32(form.length), form); +}; + /** Hand-authored BEAM IFF with AtU8, Code, ExpT, ImpT and FunT chunks. */ export const erlangBeamFixture = (module = "beam_fixture"): Buffer => { const atomNames = [ diff --git a/src/erlang/BeamParser.test.ts b/src/erlang/BeamParser.test.ts index 5051e52590..07775fab3c 100644 --- a/src/erlang/BeamParser.test.ts +++ b/src/erlang/BeamParser.test.ts @@ -7,8 +7,10 @@ import { } from "../domain/erlang/beam.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { + erlangBeamChunksFixture, erlangBeamDeclaredRowsFixture, erlangBeamFixture, + erlangBeamLegacyAtomsFixture, erlangBeamManyAtomsFixture, erlangBeamOtp29Fixture, } from "./Beam.fixture.js"; @@ -119,12 +121,121 @@ it("selects the last table and Code payload for every repeated chunk name", () = it("rejects a malformed selected atom table instead of using an earlier table", () => { const original = erlangBeamFixture(); - const invalid = singleChunkBeam("AtU8", Buffer.alloc(0)); + const invalid = erlangBeamChunksFixture([["AtU8", Buffer.from([0])]]); const repeated = Buffer.concat([original, invalid.subarray(12)]); repeated.writeUInt32BE(repeated.length - 8, 4); expect(() => parse(repeated)).toThrow(/truncated/); }); +it("falls back from an empty AtU8 to legacy Atom, while nonempty alternates keep precedence", () => { + const control = parse(erlangBeamLegacyAtomsFixture()); + const empty = parse(erlangBeamLegacyAtomsFixture(Buffer.alloc(0))); + expect(empty.atoms).toEqual(control.atoms); + expect(empty.atom_encoding).toBe("latin1"); + expect(empty.module).toBe("demo"); + expect(empty.exports?.[0]?.function).toBe("ÿ"); + expect(empty.chunks.map(({ name }) => name)).toEqual([ + "Atom", + "AtU8", + "ExpT", + ]); + expect(empty.chunks[1]?.payload.bytes).toBe(0); + const alternate = parse( + erlangBeamLegacyAtomsFixture( + Buffer.concat([ + Buffer.from([0, 0, 0, 2, 4]), + Buffer.from("demo"), + Buffer.from([3]), + Buffer.from("new"), + ]), + ), + ); + expect(alternate.atom_encoding).toBe("utf8"); + expect(alternate.exports?.[0]?.function).toBe("new"); + const zeroCount = parse(erlangBeamLegacyAtomsFixture(Buffer.alloc(4))); + expect(zeroCount.atom_encoding).toBe("utf8"); + expect(zeroCount.atoms).toEqual([]); + expect(zeroCount.exports?.[0]?.function).toBeNull(); +}); + +it.each([Buffer.from([0]), Buffer.from([0, 0, 0, 1])])( + "rejects a malformed nonempty AtU8 without falling back: %j", + (payload) => { + expect(() => parse(erlangBeamLegacyAtomsFixture(payload))).toThrow( + /truncated/, + ); + }, +); + +it.each(["AtU8", "Atom"])( + "does not turn an empty standalone %s atom table into decoded facts", + (name) => { + expect(() => + parse(erlangBeamChunksFixture([[name, Buffer.alloc(0)]])), + ).toThrow(/atom table is truncated/); + }, +); + +it("uses the last AtU8 occurrence even when empty", () => { + const control = erlangBeamLegacyAtomsFixture(Buffer.from([0, 0, 0, 0])); + const empty = erlangBeamChunksFixture([["AtU8", Buffer.alloc(0)]]); + const bytes = Buffer.concat([control, empty.subarray(12)]); + bytes.writeUInt32BE(bytes.length - 8, 4); + const report = parse(bytes); + expect(report.atom_encoding).toBe("latin1"); + expect(report.exports?.[0]?.function).toBe("ÿ"); + expect( + report.chunks + .filter(({ name }) => name === "AtU8") + .map(({ payload }) => payload.bytes), + ).toEqual([4, 0]); +}); + +it.each([ + { payload: Buffer.alloc(0), expected: null }, + { payload: Buffer.alloc(4), expected: [] }, +])( + "distinguishes empty optional FunT from a zero row count: $payload", + ({ payload, expected }) => { + const original = erlangBeamOtp29Fixture(); + const extension = erlangBeamChunksFixture([["FunT", payload]]); + const bytes = Buffer.concat([original, extension.subarray(12)]); + bytes.writeUInt32BE(bytes.length - 8, 4); + const control = parse(original); + const report = parse(bytes); + expect(control.lambdas).toBeNull(); + expect(report.lambdas).toEqual(expected); + expect(report.atoms).toEqual(control.atoms); + expect(report.exports).toEqual(control.exports); + expect(report.imports).toEqual(control.imports); + expect(report.code).toEqual(control.code); + expect(report.chunks.at(-1)?.payload.bytes).toBe(payload.length); + }, +); + +it("does not reuse an earlier FunT when its last occurrence is empty", () => { + const original = erlangBeamFixture(); + const extension = erlangBeamChunksFixture([["FunT", Buffer.alloc(0)]]); + const bytes = Buffer.concat([original, extension.subarray(12)]); + bytes.writeUInt32BE(bytes.length - 8, 4); + const report = parse(bytes); + expect(report.lambdas).toBeNull(); + expect( + report.chunks + .filter(({ name }) => name === "FunT") + .map(({ payload }) => payload.bytes), + ).toEqual([28, 0]); +}); + +it.each([Buffer.from([0]), Buffer.from([0, 0, 0, 1])])( + "rejects truncated nonempty FunT payloads: %j", + (payload) => { + expect(() => parse(erlangBeamChunksFixture([["FunT", payload]]))).toThrow( + /lambda table is truncated/, + ); + }, +); + // Expected values come from beam_lib:chunks/2 on the same file under OTP 29. it("reads a compiler-produced OTP 29 BEAM with the compact atom table", () => { const report = parse(erlangBeamOtp29Fixture()); @@ -162,10 +273,12 @@ it("reads a compiler-produced OTP 29 BEAM with the compact atom table", () => { it("accepts extended compact atom lengths and rejects truncated or overflowing values", () => { const compactAtom = (length: Buffer, name: Buffer): Buffer => - singleChunkBeam( - "AtU8", - Buffer.concat([Buffer.from([0xff, 0xff, 0xff, 0xff]), length, name]), - ); + erlangBeamChunksFixture([ + [ + "AtU8", + Buffer.concat([Buffer.from([0xff, 0xff, 0xff, 0xff]), length, name]), + ], + ]); const name = Buffer.from("0123456789abcdef", "ascii"); const extended = parse(compactAtom(Buffer.from([0x18, 0x00, 0x10]), name)); expect(extended.module).toBe(name.toString("ascii")); @@ -205,22 +318,6 @@ it("fails a FOR1 form that is not BEAM", () => { expect(() => parse(bytes)).toThrow(ArtifactReaderFailure); }); -const singleChunkBeam = (name: string, payload: Buffer): Buffer => { - const header = Buffer.alloc(8); - header.write(name, 0, "latin1"); - header.writeUInt32BE(payload.length, 4); - const pad = (4 - (payload.length % 4)) % 4; - const form = Buffer.concat([ - Buffer.from("BEAM"), - header, - payload, - Buffer.alloc(pad), - ]); - const size = Buffer.alloc(4); - size.writeUInt32BE(form.length); - return Buffer.concat([Buffer.from("FOR1"), size, form]); -}; - it("rejects a short remainder inside the BEAM form", () => { const bytes = Buffer.alloc(16); bytes.write("FOR1"); @@ -231,13 +328,15 @@ it("rejects a short remainder inside the BEAM form", () => { it("rejects an AtU8 atom name that is not UTF-8", () => { expect(() => - parse(singleChunkBeam("AtU8", Buffer.from([0, 0, 0, 1, 1, 0xff]))), + parse( + erlangBeamChunksFixture([["AtU8", Buffer.from([0, 0, 0, 1, 1, 0xff])]]), + ), ).toThrow(/BEAM atom name is not valid UTF-8/); }); it("keeps a latin1 atom byte unchanged", () => { const report = parse( - singleChunkBeam("Atom", Buffer.from([0, 0, 0, 1, 1, 0xff])), + erlangBeamChunksFixture([["Atom", Buffer.from([0, 0, 0, 1, 1, 0xff])]]), ); expect(report.atom_encoding).toBe("latin1"); expect(report.module).toBe("ÿ"); @@ -263,9 +362,13 @@ it("rejects combined table rows before allocating them", () => { ); const count = Buffer.alloc(4); count.writeUInt32BE(MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS + 1); - expect(() => parse(singleChunkBeam("ExpT", count))).toThrow(/row budget/); + expect(() => parse(erlangBeamChunksFixture([["ExpT", count]]))).toThrow( + /row budget/, + ); count.writeUInt32BE(MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS); - expect(() => parse(singleChunkBeam("ExpT", count))).toThrow(/truncated/); + expect(() => parse(erlangBeamChunksFixture([["ExpT", count]]))).toThrow( + /truncated/, + ); }); it("fails when the atom budget is exhausted", () => { diff --git a/src/erlang/BeamParser.ts b/src/erlang/BeamParser.ts index 47c52d8995..f1c8485e49 100644 --- a/src/erlang/BeamParser.ts +++ b/src/erlang/BeamParser.ts @@ -256,12 +256,16 @@ export const parseErlangBeam = ( payloads.set(name, { payload, offset: at }); at = padded; } - const atomChunk = payloads.get("AtU8") ?? payloads.get("Atom"); - const atomEncoding = payloads.has("AtU8") - ? "utf8" - : payloads.has("Atom") - ? "latin1" - : null; + const utf8AtomChunk = payloads.get("AtU8"); + const legacyAtomChunk = payloads.get("Atom"); + // Historical OTP selects Atom when AtU8 has no payload. Without a legacy + // table, a present empty AtU8 still fails its required count header. + const useUtf8Atoms = + utf8AtomChunk !== undefined && + (utf8AtomChunk.payload.length > 0 || legacyAtomChunk === undefined); + const atomChunk = useUtf8Atoms ? utf8AtomChunk : legacyAtomChunk; + const atomEncoding = + atomChunk === undefined ? null : useUtf8Atoms ? "utf8" : "latin1"; const atoms = atomChunk === undefined || atomEncoding === null ? null @@ -300,10 +304,13 @@ export const parseErlangBeam = ( function: atomAt(atoms, row[1]!), arity: row[2]!, })); + const lambdaChunk = payloads.get("FunT"); + // OTP skips the optional lambda parser for a zero-byte selected chunk. + // A four-byte zero count remains a decoded, explicitly empty table. const lambdas = - payloads.get("FunT") === undefined + lambdaChunk === undefined || lambdaChunk.payload.length === 0 ? null - : parseRows(payloads.get("FunT")!.payload, 6, "lambda").map((row) => ({ + : parseRows(lambdaChunk.payload, 6, "lambda").map((row) => ({ function_index: row[0]!, function: atomAt(atoms, row[0]!), arity: row[1]!, diff --git a/tests/acceptance/erlangBeamCli.test.ts b/tests/acceptance/erlangBeamCli.test.ts index 44209897cd..d7cc3c040c 100644 --- a/tests/acceptance/erlangBeamCli.test.ts +++ b/tests/acceptance/erlangBeamCli.test.ts @@ -1,11 +1,15 @@ -import { writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect } from "vitest"; import { toolContract } from "../../src/contracts/toolContracts.js"; import { + erlangBeamChunksFixture, erlangBeamFixture, + erlangBeamLegacyAtomsFixture, erlangBeamOtp29Fixture, } from "../../src/erlang/Beam.fixture.js"; +import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; import { cliTest } from "../support/cli/cliFixture.js"; @@ -72,6 +76,86 @@ cliTest("reports BEAM exports through the public CLI", async ({ cli }) => { ).toBe(true); }); +cliTest( + "inspects empty optional BEAM chunks through CLI and MCP without losing source evidence", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-beam-empty-chunks-"); + // Append an inert chunk to the compiler fixture; no bytecode is executed. + const original = erlangBeamOtp29Fixture(); + const extension = erlangBeamChunksFixture([["FunT", Buffer.alloc(0)]]); + const emptyLambda = Buffer.concat([original, extension.subarray(12)]); + emptyLambda.writeUInt32BE(emptyLambda.length - 8, 4); + const { call } = await connectLocalToolsMcp(); + for (const { + label, + bytes, + chunk, + module, + atomEncoding, + exportedFunction, + } of [ + { + label: "lambda", + bytes: emptyLambda, + chunk: "FunT", + module: "rea_beam_fixture", + atomEncoding: "utf8", + exportedFunction: "hello", + }, + // This reduced legacy container asserts structural facts, not runtime loading. + { + label: "legacy-atoms", + bytes: erlangBeamLegacyAtomsFixture(Buffer.alloc(0)), + chunk: "AtU8", + module: "demo", + atomEncoding: "latin1", + exportedFunction: "ÿ", + }, + ]) { + const path = join(root, `${label}.beam`); + await writeFile(path, bytes); + const output = await cli.run({ + arguments: ["inspect-erlang-beam", path, "--json"], + }); + expect(output.exitCode, output.stderr).toBe(0); + const report = toolContract("inspect_erlang_beam").outputSchema.parse( + output.json, + ).normalized_result; + const reply = await call("inspect_erlang_beam", { path }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + expect( + toolContract("inspect_erlang_beam").outputSchema.parse( + reply.structuredContent, + ).normalized_result, + ).toEqual(report); + expect(report).toMatchObject({ + module, + atom_encoding: atomEncoding, + lambdas: null, + coverage: { status: "complete" }, + artifact: { + path, + bytes: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + }, + }); + expect(report.exports).toContainEqual( + expect.objectContaining({ function: exportedFunction }), + ); + expect(report.chunks).toContainEqual( + expect.objectContaining({ + name: chunk, + payload: expect.objectContaining({ + bytes: 0, + sha256: createHash("sha256").update(Buffer.alloc(0)).digest("hex"), + }), + }), + ); + expect(await readFile(path)).toEqual(bytes); + } + }, +); + cliTest( "decodes the last atom table while retaining every chunk occurrence", async ({ cli }) => {