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
477 lines
15 KiB
TypeScript
477 lines
15 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { expect, it, vi } from "vitest";
|
|
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
|
|
import {
|
|
MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS,
|
|
inspectErlangBeamInputSchema,
|
|
} from "../domain/erlang/beam.js";
|
|
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
|
import {
|
|
erlangBeamChunksFixture,
|
|
erlangBeamDeclaredRowsFixture,
|
|
erlangBeamFixture,
|
|
erlangBeamLegacyAtomsFixture,
|
|
erlangBeamManyAtomsFixture,
|
|
erlangBeamOtp29Fixture,
|
|
} from "./Beam.fixture.js";
|
|
import { BeamCapacityFailure, parseErlangBeam } from "./BeamParser.js";
|
|
import { BeamProvider } from "./BeamProvider.js";
|
|
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
|
|
|
const defaults = inspectErlangBeamInputSchema.parse({
|
|
path: "/artifacts/app.beam",
|
|
});
|
|
|
|
const parse = (bytes: Buffer, input = defaults) =>
|
|
parseErlangBeam(
|
|
bytes,
|
|
{
|
|
path: input.path,
|
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
bytes: bytes.length,
|
|
},
|
|
input,
|
|
);
|
|
|
|
it("reads atoms, exports, imports, lambdas and the Code header", () => {
|
|
const report = parse(erlangBeamFixture("demo"));
|
|
expect(report.atom_encoding).toBe("utf8");
|
|
expect(report.module).toBe("demo");
|
|
expect(report.atoms?.map((atom) => atom.name)).toEqual([
|
|
"demo",
|
|
"module_info",
|
|
"start",
|
|
"erlang",
|
|
"get_module_info",
|
|
]);
|
|
expect(report.exports).toEqual([
|
|
{ function_index: 3, function: "start", arity: 0, label: 2 },
|
|
{ function_index: 2, function: "module_info", arity: 0, label: 1 },
|
|
{ function_index: 2, function: "module_info", arity: 1, label: 3 },
|
|
]);
|
|
expect(report.imports).toEqual([
|
|
{
|
|
module_index: 4,
|
|
module: "erlang",
|
|
function_index: 5,
|
|
function: "get_module_info",
|
|
arity: 1,
|
|
},
|
|
]);
|
|
expect(report.lambdas?.[0]).toMatchObject({
|
|
function: "start",
|
|
arity: 1,
|
|
label: 4,
|
|
old_unique: 42,
|
|
});
|
|
expect(report.code).toMatchObject({
|
|
instruction_set: 0,
|
|
opcode_max: 169,
|
|
labels: 4,
|
|
functions: 2,
|
|
extra_header_bytes: 0,
|
|
instructions: { bytes: 4 },
|
|
});
|
|
expect(report.chunks.map((chunk) => chunk.name)).toEqual([
|
|
"AtU8",
|
|
"Code",
|
|
"ExpT",
|
|
"ImpT",
|
|
"FunT",
|
|
]);
|
|
});
|
|
|
|
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 = 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());
|
|
expect(report.module).toBe("rea_beam_fixture");
|
|
expect(report.atoms?.map((atom) => atom.name)).toEqual([
|
|
"rea_beam_fixture",
|
|
"hello",
|
|
"lists",
|
|
"reverse",
|
|
"module_info",
|
|
"erlang",
|
|
"get_module_info",
|
|
]);
|
|
expect(report.exports).toEqual([
|
|
{ function_index: 5, function: "module_info", arity: 1, label: 6 },
|
|
{ function_index: 5, function: "module_info", arity: 0, label: 4 },
|
|
{ function_index: 2, function: "hello", arity: 1, label: 2 },
|
|
]);
|
|
expect(
|
|
report.imports?.map((row) => [row.module, row.function, row.arity]),
|
|
).toEqual([
|
|
["lists", "reverse", 1],
|
|
["erlang", "get_module_info", 1],
|
|
["erlang", "get_module_info", 2],
|
|
]);
|
|
expect(report.code).toMatchObject({
|
|
instruction_set: 0,
|
|
opcode_max: 181,
|
|
labels: 7,
|
|
functions: 3,
|
|
extra_header_bytes: 0,
|
|
instructions: { bytes: 49 },
|
|
});
|
|
});
|
|
|
|
it("accepts extended compact atom lengths and rejects truncated or overflowing values", () => {
|
|
const compactAtom = (length: Buffer, name: Buffer): Buffer =>
|
|
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"));
|
|
expect(() => parse(compactAtom(Buffer.from([0x18, 0x00]), name))).toThrow(
|
|
/BEAM atom table is truncated/,
|
|
);
|
|
expect(() => parse(compactAtom(Buffer.from([0x19]), name))).toThrow(
|
|
/BEAM atom length is not a u tag/,
|
|
);
|
|
expect(() =>
|
|
parse(
|
|
compactAtom(
|
|
Buffer.from([0xd8, 0x80, 0, 0, 0, 0, 0, 0, 0]),
|
|
Buffer.alloc(0),
|
|
),
|
|
),
|
|
).toThrow(/BEAM atom length encoding overflows/);
|
|
expect(() =>
|
|
parse(
|
|
compactAtom(
|
|
Buffer.from([0xf8, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
|
|
Buffer.alloc(0),
|
|
),
|
|
),
|
|
).toThrow(/BEAM atom length encoding overflows/);
|
|
});
|
|
|
|
it("fails a truncated FOR1 container without partial success", () => {
|
|
expect(() => parse(Buffer.from("FOR1"))).toThrow(ArtifactReaderFailure);
|
|
});
|
|
|
|
it("fails a FOR1 form that is not BEAM", () => {
|
|
const bytes = Buffer.alloc(16);
|
|
bytes.write("FOR1");
|
|
bytes.writeUInt32BE(8, 4);
|
|
bytes.write("WAVE", 8);
|
|
expect(() => parse(bytes)).toThrow(ArtifactReaderFailure);
|
|
});
|
|
|
|
it("rejects a short remainder inside the BEAM form", () => {
|
|
const bytes = Buffer.alloc(16);
|
|
bytes.write("FOR1");
|
|
bytes.writeUInt32BE(8, 4);
|
|
bytes.write("BEAM", 8);
|
|
expect(() => parse(bytes)).toThrow(/BEAM chunk header is truncated/);
|
|
});
|
|
|
|
it("rejects an AtU8 atom name that is not UTF-8", () => {
|
|
expect(() =>
|
|
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(
|
|
erlangBeamChunksFixture([["Atom", Buffer.from([0, 0, 0, 1, 1, 0xff])]]),
|
|
);
|
|
expect(report.atom_encoding).toBe("latin1");
|
|
expect(report.module).toBe("ÿ");
|
|
});
|
|
|
|
it("rejects combined table rows before allocating them", () => {
|
|
const below = Math.floor(MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS / 2);
|
|
const overflow = MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS - below * 2 + 1;
|
|
expect(() =>
|
|
parse(
|
|
erlangBeamDeclaredRowsFixture([
|
|
["ExpT", below],
|
|
["ImpT", below],
|
|
["FunT", overflow],
|
|
]),
|
|
),
|
|
).toThrow(
|
|
expect.objectContaining({
|
|
reason: "limit",
|
|
boundary: "capacity",
|
|
message: expect.stringContaining("row budget"),
|
|
}),
|
|
);
|
|
const count = Buffer.alloc(4);
|
|
count.writeUInt32BE(MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS + 1);
|
|
expect(() => parse(erlangBeamChunksFixture([["ExpT", count]]))).toThrow(
|
|
/row budget/,
|
|
);
|
|
count.writeUInt32BE(MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS);
|
|
expect(() => parse(erlangBeamChunksFixture([["ExpT", count]]))).toThrow(
|
|
/truncated/,
|
|
);
|
|
});
|
|
|
|
it("fails when the atom budget is exhausted", () => {
|
|
const bytes = erlangBeamManyAtomsFixture(3);
|
|
expect(() =>
|
|
parse(
|
|
bytes,
|
|
inspectErlangBeamInputSchema.parse({
|
|
path: defaults.path,
|
|
max_atoms: 1,
|
|
}),
|
|
),
|
|
).toThrow(ArtifactReaderFailure);
|
|
});
|
|
|
|
it("does not infer a BEAM budget from diagnostic prose", async () => {
|
|
const provider = new BeamProvider();
|
|
const injection = vi
|
|
.spyOn(ArtifactResourceScope.prototype, "run")
|
|
.mockRejectedValueOnce(
|
|
new ArtifactReaderFailure(
|
|
"limit",
|
|
"atom row budget wording from another boundary",
|
|
{ boundary: "capacity" },
|
|
),
|
|
);
|
|
try {
|
|
const result = await provider.inspect(defaults);
|
|
if (result.ok) throw new Error("Expected unknown capacity failure");
|
|
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
|
|
if (!(result.error instanceof AnalysisResourceConstraintError))
|
|
throw new Error("Missing resource error");
|
|
expect(result.error.retryable).toBeUndefined();
|
|
expect(result.error.remediationAction).toBeUndefined();
|
|
} finally {
|
|
injection.mockRestore();
|
|
await provider.close();
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
resource: "atoms",
|
|
bytes: erlangBeamManyAtomsFixture(3),
|
|
settings: { max_atoms: 1 },
|
|
maximum: 1,
|
|
remediation: "Raise max_atoms above 1",
|
|
},
|
|
{
|
|
resource: "chunks",
|
|
bytes: erlangBeamFixture(),
|
|
settings: { max_chunks: 1 },
|
|
maximum: 1,
|
|
remediation: "Raise max_chunks above 1",
|
|
},
|
|
{
|
|
resource: "table-rows",
|
|
bytes: erlangBeamDeclaredRowsFixture([
|
|
["ExpT", MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS + 1],
|
|
]),
|
|
settings: {},
|
|
maximum: MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS,
|
|
remediation: `${String(MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS)}-row ceiling`,
|
|
},
|
|
])(
|
|
"retains the producing $resource allowance when diagnostic wording changes",
|
|
async ({ resource, bytes, settings, maximum, remediation }) => {
|
|
const input = inspectErlangBeamInputSchema.parse({
|
|
path: defaults.path,
|
|
...settings,
|
|
});
|
|
let cause: unknown;
|
|
try {
|
|
parse(bytes, input);
|
|
} catch (failure) {
|
|
cause = failure;
|
|
}
|
|
expect(cause).toBeInstanceOf(BeamCapacityFailure);
|
|
if (!(cause instanceof BeamCapacityFailure))
|
|
throw new Error("Missing named parser allowance");
|
|
expect(cause).toMatchObject({
|
|
resource,
|
|
maximum,
|
|
reason: "limit",
|
|
boundary: "capacity",
|
|
});
|
|
cause.message = "BEAM parser allowance exhausted.";
|
|
const provider = new BeamProvider();
|
|
const injection = vi
|
|
.spyOn(ArtifactResourceScope.prototype, "run")
|
|
.mockRejectedValueOnce(cause);
|
|
try {
|
|
const result = await provider.inspect(input);
|
|
if (result.ok) throw new Error("Expected named capacity failure");
|
|
expect(result.error).toMatchObject({
|
|
_tag: "AnalysisResourceConstraintError",
|
|
retryable: false,
|
|
remediationAction: expect.stringContaining(remediation),
|
|
});
|
|
} finally {
|
|
injection.mockRestore();
|
|
await provider.close();
|
|
}
|
|
},
|
|
);
|