diff --git a/docs/erlang-beam.md b/docs/erlang-beam.md index 686bd3ff62..b37ee72a82 100644 --- a/docs/erlang-beam.md +++ b/docs/erlang-beam.md @@ -24,6 +24,9 @@ requires an absolute filesystem path and preserves that exact selection: The inline Evidence result contains the artifact path, SHA-256, and byte length. `chunks` lists every IFF chunk name, file range, and payload SHA-256. +Repeated names retain separate chunk occurrences. Decoded tables and the Code +header use the last occurrence of each name, matching OTP's loader; the chunk +inventory still exposes earlier payloads and their source ranges. When `AtU8` or `Atom` is present, `atoms` is the 1-based name table and `module` is atom 1. Both the pre-OTP 28 table and the OTP 28+ compact table @@ -43,4 +46,6 @@ import, and lambda rows together. Malformed `FOR1`/`BEAM` headers, truncated chunks, and `AtU8` names that are not valid UTF-8 fail without partial success. This workflow does not execute bytecode, inflate `LitT` literals, or decode -`Attr`/`CInf` external-term payloads. +`Attr`/`CInf` external-term payloads. Complete coverage describes this static +chunk and table report; it does not establish that an Erlang runtime can load +or execute the module. diff --git a/src/erlang/BeamParser.test.ts b/src/erlang/BeamParser.test.ts index 3a29fcc647..5051e52590 100644 --- a/src/erlang/BeamParser.test.ts +++ b/src/erlang/BeamParser.test.ts @@ -79,6 +79,52 @@ it("reads atoms, exports, imports, lambdas and the Code header", () => { ]); }); +it("selects the last table and Code payload for every repeated chunk name", () => { + const original = erlangBeamFixture("selected"); + const decoy = erlangBeamFixture("decoy"); + const changedFields = new Map([ + ["Code", 16], + ["ExpT", 8], + ["ImpT", 12], + ["FunT", 8], + ]); + for (let at = 12; at < decoy.length;) { + const name = decoy.toString("latin1", at, at + 4); + const size = decoy.readUInt32BE(at + 4); + const field = changedFields.get(name); + if (field !== undefined) decoy.writeUInt32BE(9, at + 8 + field); + at += 8 + size + ((4 - (size % 4)) % 4); + } + const repeated = Buffer.concat([ + original.subarray(0, 12), + decoy.subarray(12), + original.subarray(12), + ]); + repeated.writeUInt32BE(repeated.length - 8, 4); + const report = parse(repeated); + const control = parse(original); + expect(report.atoms).toEqual(control.atoms); + expect(report.exports).toEqual(control.exports); + expect(report.imports).toEqual(control.imports); + expect(report.lambdas).toEqual(control.lambdas); + expect(report.code).toEqual({ + ...control.code, + instructions: { + ...control.code!.instructions, + offset: control.code!.instructions.offset + decoy.length - 12, + }, + }); + expect(report.chunks).toHaveLength(control.chunks.length * 2); +}); + +it("rejects a malformed selected atom table instead of using an earlier table", () => { + const original = erlangBeamFixture(); + const invalid = singleChunkBeam("AtU8", Buffer.alloc(0)); + const repeated = Buffer.concat([original, invalid.subarray(12)]); + repeated.writeUInt32BE(repeated.length - 8, 4); + expect(() => parse(repeated)).toThrow(/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()); diff --git a/src/erlang/BeamParser.ts b/src/erlang/BeamParser.ts index 6f34fa69f3..abd94222a3 100644 --- a/src/erlang/BeamParser.ts +++ b/src/erlang/BeamParser.ts @@ -256,7 +256,9 @@ export const parseErlangBeam = ( sha256: sha256(payload), }, }); - if (!payloads.has(name)) payloads.set(name, { payload, offset: at }); + // OTP's loader replaces the selected payload on each matching chunk. + // Keep every occurrence above; decode the last occurrence of each name. + payloads.set(name, { payload, offset: at }); at = padded; } const atomChunk = payloads.get("AtU8") ?? payloads.get("Atom"); diff --git a/tests/acceptance/erlangBeamCli.test.ts b/tests/acceptance/erlangBeamCli.test.ts index ffe6945343..30154b94d1 100644 --- a/tests/acceptance/erlangBeamCli.test.ts +++ b/tests/acceptance/erlangBeamCli.test.ts @@ -72,6 +72,45 @@ cliTest("reports BEAM exports through the public CLI", async ({ cli }) => { ).toBe(true); }); +cliTest( + "decodes the last atom table while retaining every chunk occurrence", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-beam-repeated-atoms-"); + const original = erlangBeamOtp29Fixture(); + const decoy = oneAtomBeam(0).subarray(12); + const repeated = Buffer.concat([ + original.subarray(0, 12), + decoy, + original.subarray(12), + ]); + repeated.writeUInt32BE(repeated.length - 8, 4); + const path = join(root, "repeated.beam"); + await writeFile(path, repeated); + const result = await cli.run({ + arguments: ["inspect-erlang-beam", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const report = toolContract("inspect_erlang_beam").outputSchema.parse( + result.json, + ).normalized_result; + expect(report.module).toBe("rea_beam_fixture"); + expect(report.exports).toContainEqual({ + function_index: 2, + function: "hello", + arity: 1, + label: 2, + }); + const atomChunks = report.chunks.filter(({ name }) => name === "AtU8"); + expect(atomChunks).toHaveLength(2); + expect(atomChunks[0]?.payload.sha256).not.toBe( + atomChunks[1]?.payload.sha256, + ); + expect(atomChunks[0]?.payload.location.offset).toBe(20); + expect(atomChunks[1]?.payload.location.offset).toBe(20 + decoy.length); + expect(report.coverage.status).toBe("complete"); + }, +); + cliTest( "inspects an OTP 29 BEAM with an extended compact atom length and preserves source identity", async ({ cli }) => {