Files
rea/src/native/exports/NativeExportParser.test.ts
T

731 lines
21 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
elfExportImage,
peExportImage,
} from "../../../tests/fixtures/binaryDiagnostics/nativeExports.js";
import { nativeExportsSchema } from "../../domain/native/nativeExports.js";
import {
BinaryFormatFailure,
BinaryResourceFailure,
} from "../metadata/BinaryContainer.js";
import { parseNativeExports } from "./NativeExportParser.js";
const artifact = { path: "/fixture/lib", sha256: "a".repeat(64), bytes: 1 };
const parse = (bytes: Buffer) =>
nativeExportsSchema.parse(parseNativeExports(bytes, artifact));
const peExportNameAcrossSections = (overlapAtRva?: number): Buffer => {
const bytes = Buffer.concat([
peExportImage({
functions: [{ rva: 0x2000 }],
names: [{ name: "BoundaryName", index: 0 }],
}),
Buffer.alloc(overlapAtRva === undefined ? 0x400 : 0x800),
]);
const pe = 128;
const optional = pe + 24;
const section = optional + bytes.readUInt16LE(pe + 20);
const second = section + 40;
bytes.writeUInt16LE(2, pe + 6);
bytes.write(".data", second, "ascii");
bytes.writeUInt32LE(0x400, second + 8);
bytes.writeUInt32LE(0x1400, second + 12);
bytes.writeUInt32LE(0x400, second + 16);
bytes.writeUInt32LE(0x800, second + 20);
bytes.writeUInt32LE(0x40000040, second + 36);
bytes.writeUInt32LE(0x13fc, 0x480);
bytes.write("BoundaryName\0", 0x7fc, "latin1");
if (overlapAtRva !== undefined) {
const third = second + 40;
bytes.writeUInt16LE(3, pe + 6);
bytes.write(".alias", third, "ascii");
bytes.writeUInt32LE(0x100, third + 8);
bytes.writeUInt32LE(overlapAtRva, third + 12);
bytes.writeUInt32LE(0x100, third + 16);
bytes.writeUInt32LE(0xc00, third + 20);
bytes.writeUInt32LE(0x40000040, third + 36);
bytes.fill(0x41, 0xc00, 0xd00);
}
return bytes;
};
describe("PE export name mapping coverage", () => {
it("reads a bounded export name across adjacent section mappings", () => {
const result = parse(peExportNameAcrossSections());
expect(result).toMatchObject({
exports: [
{
name: "BoundaryName",
ordinal: 1,
address: "0x180002000",
forwarder: null,
},
],
coverage: { status: "complete", examined_entries: 1, exports: 1 },
issues: [],
});
});
it("ignores conflicting mappings after the export name terminator", () => {
const result = parse(peExportNameAcrossSections(0x1409));
expect(result.exports).toMatchObject([{ name: "BoundaryName" }]);
expect(result.issues).toEqual([]);
expect(result.coverage.status).toBe("complete");
});
it("rejects conflicting mappings that overlap the export name", () => {
const result = parse(peExportNameAcrossSections(0x1404));
expect(result.exports).toEqual([]);
expect(result.issues).toMatchObject([{ code: "name_unreadable" }]);
expect(result.coverage.status).toBe("partial");
});
});
describe("PE export directories", () => {
it.each([32, 64] as const)(
"reports named, aliased, ordinal-only and forwarded PE%d exports",
(bits) => {
const result = parse(
peExportImage({
bits,
ordinalBase: 5,
functions: [
{ rva: 0x2000 },
{ rva: 0x2010 },
null,
{ forwarder: "OTHER.Target" },
],
names: [
{ name: "Alias", index: 0 },
{ name: "Forwarded", index: 3 },
{ name: "Primary", index: 0 },
],
}),
);
const base = bits === 64 ? 0x180000000 : 0x10000000;
expect(result).toMatchObject({
format: "pe",
bits,
export_directory: {
rva: 0x1000,
size: 0x200,
dll_name: "fixture.dll",
ordinal_base: 5,
},
coverage: {
status: "complete",
examined_entries: 4,
exports: 4,
},
issues: [],
});
expect(result.exports).toEqual([
{
name: "Alias",
ordinal: 5,
address: `0x${(base + 0x2000).toString(16)}`,
forwarder: null,
},
{
name: "Primary",
ordinal: 5,
address: `0x${(base + 0x2000).toString(16)}`,
forwarder: null,
},
{
name: null,
ordinal: 6,
address: `0x${(base + 0x2010).toString(16)}`,
forwarder: null,
},
{
name: "Forwarded",
ordinal: 8,
address: null,
forwarder: "OTHER.Target",
},
]);
},
);
it("counts unreadable and out-of-range names without inventing exports", () => {
const result = parse(
peExportImage({
functions: [{ rva: 0x2000 }, { rva: 0x2010 }],
names: [
{ name: "Unmapped", index: 0 },
{ name: "Stray", index: 9 },
{ name: "Kept", index: 1 },
],
nameRvaOverride: { position: 0, rva: 0x9000 },
}),
);
expect(result.exports.map(({ name }) => name)).toEqual(["Kept"]);
expect(result.coverage).toMatchObject({ status: "partial", exports: 1 });
expect(result.issues.map(({ code }) => code)).toEqual([
"name_unreadable",
"malformed_entry",
]);
});
it("reports an image without an export directory as exporting nothing", () => {
const result = parse(peExportImage({ exportDirectory: false }));
expect(result).toMatchObject({
export_directory: null,
exports: [],
coverage: { status: "complete", examined_entries: 0 },
});
expect(result.limitations[0]).toContain("declares no export directory");
});
it("preserves biased ordinals beyond WORD and DWORD", () => {
const cases = [
{ ordinalBase: 0x10000, index: 0, ordinal: 0x10000 },
{ ordinalBase: 0xffffffff, index: 0, ordinal: 0xffffffff },
{
ordinalBase: 0xffffffff,
index: 1,
ordinal: 0x1_0000_0000,
},
];
for (const { ordinalBase, index, ordinal } of cases) {
const result = parse(
peExportImage({
ordinalBase,
functions: Array.from({ length: index + 1 }, (_, row) =>
row === index ? { rva: 0x2000 } : null,
),
names: [{ name: "Named", index }],
}),
);
expect(result.exports).toMatchObject([{ name: "Named", ordinal }]);
}
});
it("ignores a trailing unused slot after the last 16-bit ordinal", () => {
const result = parse(
peExportImage({
ordinalBase: 0xffff,
functions: [{ rva: 0x2000 }, null],
names: [{ name: "Kept", index: 0 }],
}),
);
expect(result).toMatchObject({
coverage: { status: "complete", examined_entries: 2, exports: 1 },
issues: [],
exports: [{ name: "Kept", ordinal: 0xffff }],
});
});
it("refuses an export table above the inventory allowance", () => {
expect(() =>
parseNativeExports(
peExportImage({ functions: [], declaredFunctions: 0x40001 }),
artifact,
),
).toThrow(BinaryResourceFailure);
});
});
describe("ELF dynamic symbol exports", () => {
const symbols = [
{ name: "plain_fn" },
{ name: "versioned_fn", version: 2 },
{ name: "old_fn", version: 0x8003 },
{ name: "weak_obj", binding: 2, type: 1 },
{ name: "ifunc_sym", type: 10 },
{ name: "protected_fn", visibility: 3 },
{ name: "hidden_fn", visibility: 2 },
{ name: "undefined_fn", section: 0 },
{ name: "local_fn", binding: 0 },
{ name: "local_version_fn", version: 0 },
];
it("lists defined default and protected globals with their versions", () => {
const result = parse(
elfExportImage({
symbols,
versionDefinitions: ["libfixture.so", "VER_1", "VER_0"],
}),
);
expect(result).toMatchObject({
format: "elf",
bits: 64,
byte_order: "little",
architecture: "amd64",
coverage: { status: "complete", examined_entries: 10, exports: 6 },
issues: [],
});
expect(
result.format === "elf" &&
result.exports.map(({ name, type, binding, version }) => ({
name,
type,
binding,
version,
})),
).toEqual([
{ name: "plain_fn", type: "function", binding: "global", version: null },
{
name: "versioned_fn",
type: "function",
binding: "global",
version: {
index: 2,
name: "VER_1",
name_bytes_base64: null,
name_utf8_decodable: true,
hidden: false,
},
},
{
name: "old_fn",
type: "function",
binding: "global",
version: {
index: 3,
name: "VER_0",
name_bytes_base64: null,
name_utf8_decodable: true,
hidden: true,
},
},
{ name: "weak_obj", type: "object", binding: "weak", version: null },
{ name: "ifunc_sym", type: "ifunc", binding: "global", version: null },
{
name: "protected_fn",
type: "function",
binding: "global",
version: null,
},
]);
});
it("reads a big-endian ELF32 table", () => {
const result = parse(
elfExportImage({
bits: 32,
little: false,
machine: 20,
symbols: [{ name: "be_fn", value: 0x10000n, size: 8n }],
}),
);
expect(result).toMatchObject({
bits: 32,
byte_order: "big",
architecture: "ppc",
exports: [{ name: "be_fn", value: "0x10000", size: "8" }],
});
});
});
describe("ELF export version and name coverage", () => {
it("marks versions unknown when the version table does not fit", () => {
const result = parse(
elfExportImage({
symbols: [{ name: "a" }, { name: "b", version: 2 }],
versionDefinitions: ["lib.so", "V1"],
truncatedVersions: true,
}),
);
expect(
result.format === "elf" && result.exports.map(({ version }) => version),
).toEqual(["unknown", "unknown"]);
expect(result.coverage.status).toBe("partial");
expect(result.issues).toMatchObject([{ code: "version_unresolved" }]);
});
it("reports one issue per undefined version index", () => {
const result = parse(
elfExportImage({
symbols: [
{ name: "a", version: 7 },
{ name: "b", version: 7 },
],
versionDefinitions: ["lib.so"],
}),
);
expect(
result.format === "elf" && result.exports.map(({ version }) => version),
).toEqual([
{
index: 7,
name: null,
name_bytes_base64: null,
name_utf8_decodable: null,
hidden: false,
},
{
index: 7,
name: null,
name_bytes_base64: null,
name_utf8_decodable: null,
hidden: false,
},
]);
expect(result.issues).toHaveLength(1);
});
it("counts a symbol whose name is outside the string table", () => {
const result = parse(
elfExportImage({
symbols: [{ name: "kept" }, { name: "lost", nameOffset: 0x7000 }],
}),
);
expect(result.exports.map(({ name }) => name)).toEqual(["kept"]);
expect(result.issues).toMatchObject([{ code: "name_unreadable" }]);
expect(result.coverage.status).toBe("partial");
});
it("separates an examined empty symbol table from unavailable symbol declarations", () => {
expect(parse(elfExportImage({ symbols: [] }))).toMatchObject({
exports: [],
coverage: { status: "complete" },
});
expect(
parse(elfExportImage({ symbols: [], dynamicSymbols: false })),
).toMatchObject({ exports: [], coverage: { status: "complete" } });
for (const bytes of [
elfExportImage({ symbols: [], sectionHeaders: false }),
elfExportImage({
symbols: [],
dynamicSymbols: false,
dynamicSegment: true,
}),
])
expect(parse(bytes)).toMatchObject({
exports: [],
coverage: {
status: "unsupported",
reason: expect.stringContaining("dynamic segment"),
examined_entries: 0,
},
});
});
});
describe("ELF version requirements", () => {
it("resolves defined copy-relocated symbols through version requirements", () => {
const result = parse(
elfExportImage({
symbols: [
{ name: "stdout", type: 1, value: 0x28268n, version: 3 },
{ name: "stderr", type: 1, value: 0x282a0n, version: 0x8003 },
],
versionRequirements: [{ index: 3, name: "GLIBC_2.2.5" }],
}),
);
expect(result).toMatchObject({
coverage: { status: "complete", exports: 2 },
issues: [],
exports: [
{
name: "stdout",
value: "0x28268",
version: { index: 3, name: "GLIBC_2.2.5", hidden: false },
},
{
name: "stderr",
value: "0x282a0",
version: { index: 3, name: "GLIBC_2.2.5", hidden: true },
},
],
});
});
it("keeps version definitions and requirements in the same symbol lookup", () => {
const result = parse(
elfExportImage({
symbols: [
{ name: "defined_version", version: 2 },
{ name: "required_version", version: 4 },
],
versionDefinitions: ["libfixture.so", "FIXTURE_1"],
versionRequirements: [
{ index: 3, name: "GLIBC_2.2.5" },
{ index: 4, name: "GLIBC_2.3" },
],
}),
);
expect(result).toMatchObject({
coverage: { status: "complete", exports: 2 },
issues: [],
exports: [
{ name: "defined_version", version: { index: 2, name: "FIXTURE_1" } },
{
name: "required_version",
version: { index: 4, name: "GLIBC_2.3" },
},
],
});
});
it("uses a definition name when a requirement shares its index", () => {
const result = parse(
elfExportImage({
symbols: [{ name: "ambiguous", version: 2 }],
versionDefinitions: ["libfixture.so", "DEFINITION"],
versionRequirements: [{ index: 2, name: "REQUIREMENT" }],
}),
);
expect(result.exports).toMatchObject([
{
name: "ambiguous",
version: { index: 2, name: "DEFINITION", hidden: false },
},
]);
expect(result.coverage.status).toBe("complete");
expect(result.issues).toEqual([]);
});
it("retains exports and reports a truncated version-requirement chain", () => {
const result = parse(
elfExportImage({
symbols: [{ name: "stdout", type: 1, version: 3 }],
versionRequirements: [{ index: 3, name: "GLIBC_2.2.5" }],
truncatedVersionRequirements: true,
}),
);
expect(result.exports).toMatchObject([
{ name: "stdout", version: { index: 3, name: null, hidden: false } },
]);
expect(result.coverage.status).toBe("partial");
expect(result.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "version_unresolved",
message: expect.stringContaining(
"auxiliary chain leaves its section",
),
}),
]),
);
});
it("marks a definition chain that ends before its declared count", () => {
const result = parse(
elfExportImage({
symbols: [{ name: "defined_version", version: 2 }],
versionDefinitions: ["libfixture.so", "FIXTURE_1"],
truncatedVersionDefinitions: true,
}),
);
expect(result.exports).toMatchObject([
{
name: "defined_version",
version: {
index: 2,
name: null,
name_bytes_base64: null,
name_utf8_decodable: null,
hidden: false,
},
},
]);
expect(result.coverage.status).toBe("partial");
expect(result.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "version_unresolved",
message: expect.stringContaining(
"chain ends before its declared count",
),
}),
]),
);
});
});
describe("export inventory review regressions", () => {
it("reads an export directory stored in the PE headers", () => {
const result = parse(
peExportImage({
inHeaders: true,
functions: [{ rva: 0x2000 }, { forwarder: "OTHER.Target" }],
names: [
{ name: "HeaderExport", index: 0 },
{ name: "HeaderForward", index: 1 },
],
}),
);
expect(result).toMatchObject({
export_directory: { rva: 0x200, dll_name: "fixture.dll" },
coverage: { status: "complete", exports: 2 },
});
expect(result.exports.map(({ name }) => name)).toEqual([
"HeaderExport",
"HeaderForward",
]);
});
it("bounds retained name text before storing rows", () => {
// 300 rows naming one 60000-byte string retain about 18 MB of text.
const long = "n".repeat(60000);
const bytes = elfExportImage({
symbols: Array.from({ length: 300 }, () => ({ name: long })),
});
expect(bytes.length).toBeLessThan(100_000);
expect(() => parseNativeExports(bytes, artifact)).toThrow(
BinaryResourceFailure,
);
});
it("keeps exports when the version table lies outside the file", () => {
const result = parse(
elfExportImage({
symbols: [{ name: "a" }, { name: "b", version: 2 }],
versionDefinitions: ["lib.so", "V1"],
versionsOutsideFile: true,
}),
);
expect(
result.format === "elf" && result.exports.map(({ version }) => version),
).toEqual(["unknown", "unknown"]);
expect(result.coverage.status).toBe("partial");
});
it("reports an empty version name as unresolved", () => {
const result = parse(
elfExportImage({
symbols: [{ name: "a", version: 2 }],
versionDefinitions: ["lib.so", ""],
}),
);
expect(
result.format === "elf" && result.exports.map(({ version }) => version),
).toEqual([
{
index: 2,
name: null,
name_bytes_base64: null,
name_utf8_decodable: null,
hidden: false,
},
]);
expect(result.issues).toMatchObject([{ code: "version_unresolved" }]);
expect(result.coverage.status).toBe("partial");
});
it("rejects a dynamic symbol table without its null entry", () => {
expect(() =>
parseNativeExports(
elfExportImage({ symbols: [], emptyDynamicSymbols: true }),
artifact,
),
).toThrow(BinaryFormatFailure);
});
});
describe("ELF symbol-name text", () => {
it("decodes UTF-8 names and retains exact bytes", () => {
const result = parse(elfExportImage({ symbols: [{ name: "café" }] }));
expect(result.format).toBe("elf");
if (result.format !== "elf") throw new TypeError("expected ELF result");
expect(result.exports[0]).toMatchObject({
name: "café",
name_bytes_base64: "Y2Fmw6k=",
name_utf8_decodable: true,
});
});
it("retains invalid UTF-8 bytes without replacement", () => {
const result = parse(elfExportImage({ symbols: [{ name: "\u00ff" }] }));
expect(result.format).toBe("elf");
if (result.format !== "elf") throw new TypeError("expected ELF result");
expect(result.exports[0]).toMatchObject({
name: "ÿ",
name_bytes_base64: "/w==",
name_utf8_decodable: false,
});
});
it.each([
{
source: "definition",
result: () =>
parse(
elfExportImage({
symbols: [{ name: "defined", version: 2 }],
versionDefinitions: ["libfixture.so", "libcafé.so"],
}),
),
versionIndex: 2,
},
{
source: "requirement",
result: () =>
parse(
elfExportImage({
symbols: [{ name: "required", version: 3 }],
versionRequirements: [{ index: 3, name: "libcafé.so" }],
}),
),
versionIndex: 3,
},
])(
"decodes UTF-8 version names from the $source table",
({ result, versionIndex }) => {
const parsed = result();
expect(parsed.format).toBe("elf");
if (parsed.format !== "elf") throw new TypeError("expected ELF result");
expect(parsed.exports[0]?.version).toEqual({
index: versionIndex,
name: "libcafé.so",
name_bytes_base64: "bGliY2Fmw6kuc28=",
name_utf8_decodable: true,
hidden: false,
});
expect(parsed.issues).toEqual([]);
expect(parsed.coverage.status).toBe("complete");
},
);
it("retains invalid UTF-8 version-name bytes with a byte-preserving display", () => {
const result = parse(
elfExportImage({
symbols: [{ name: "defined", version: 2 }],
versionDefinitions: ["libfixture.so", "\u00ff"],
}),
);
expect(result.format).toBe("elf");
if (result.format !== "elf") throw new TypeError("expected ELF result");
expect(result.exports[0]?.version).toEqual({
index: 2,
name: "ÿ",
name_bytes_base64: "/w==",
name_utf8_decodable: false,
hidden: false,
});
});
});
it("refuses formats other than PE and ELF", () => {
const macho = Buffer.alloc(64);
macho.writeUInt32LE(0xfeedfacf, 0);
expect(() => parseNativeExports(macho, artifact)).toThrow(
BinaryFormatFailure,
);
});