mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
1 parent
75a757d8ef
commit
e737e6c1c6
5 files changed
+264
-35
No files matched your search
+14
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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]!,
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in new issue
Block a user