fix(erlang): distinguish empty optional BEAM chunks

Select legacy Atom when AtU8 is empty and skip zero-byte optional FunT. Preserve explicitly empty counted arrays, malformed nonempty failures, last-occurrence selection and original chunk provenance. Share structural fixtures across producer and public workflow regressions.

Fixes #6816
Fixes #6874
This commit is contained in:
morluto committed 2026-10-24 02:34:35 +08:00
1 parent 75a757d8ef
commit e737e6c1c6
5 files changed
+264 -35

No files matched your search

+14 -1
View File
@@ -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
+22
View File
@@ -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 = [
+128 -25
View File
@@ -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", () => {
+15 -8
View File
@@ -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]!,
+85 -1
View File
@@ -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 }) => {