fix(elf): retain raw export name bytes

Fixes #6647
This commit is contained in:
morluto committed 2026-10-23 19:25:29 +08:00
1 parent 5d0d243a45
commit 22df16186f
6 files changed
+104 -5

No files matched your search

+8 -1
View File
@@ -332,7 +332,14 @@ count and SHA-256.
- ELF rows list defined `.dynsym` symbols with global, weak or GNU-unique
binding and default or protected visibility. Each row keeps `value`
(`st_value` as stored; bit 0 marks Thumb code on 32-bit ARM), `size`, `type`
and `binding`. `version` is null for an unversioned symbol. A versioned
and `binding`. ELF string tables do not declare a text encoding. Names that
contain non-ASCII bytes are decoded as UTF-8 when those bytes form valid
UTF-8; `name_utf8_decodable` records whether the non-ASCII name bytes are
valid UTF-8 and `name_bytes_base64` retains those exact bytes (`null` for
ASCII names). When they are not valid UTF-8, `name` uses a byte-preserving
Latin-1 mapping and the raw bytes remain available. Older retained results
report null for the newly added encoding fields. `version` is null for an
unversioned symbol. A versioned
symbol reports its GNU index, a name resolved from `.gnu.version_d` or
`.gnu.version_r`, and the `hidden` flag from `.gnu.version`. `"unknown"`
means the symbol-version table could not be read. A hidden version is not
+4
View File
@@ -49,6 +49,10 @@ const peExportSchema = z.strictObject({
*/
const elfExportSchema = z.strictObject({
name: z.string().min(1),
/** Exact non-ASCII name bytes; ELF string tables do not declare text encoding. */
name_bytes_base64: z.string().nullable().default(null),
/** Whether `name` was decoded as UTF-8; null means this field was not recorded in older Evidence. */
name_utf8_decodable: z.boolean().nullable().default(null),
/** `st_value` as stored; on 32-bit ARM, bit 0 of a function marks Thumb code. */
value: address,
size: z.string().regex(/^[0-9]+$/u),
@@ -518,6 +518,30 @@ describe("export inventory review regressions", () => {
});
});
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("refuses formats other than PE and ELF", () => {
const macho = Buffer.alloc(64);
macho.writeUInt32LE(0xfeedfacf, 0);
+21 -3
View File
@@ -5,7 +5,12 @@ import {
mappedFileOffset,
} from "../metadata/BinaryContainer.js";
import { architectureForElfMachine } from "../metadata/ElfImage.js";
import { readElfString, readElfSymbol } from "../metadata/ElfSymbol.js";
import { isUtf8 } from "node:buffer";
import {
readElfString,
readElfStringBytes,
readElfSymbol,
} from "../metadata/ElfSymbol.js";
import {
readNativeDirectoryFromBuffer,
type NativeSection,
@@ -561,8 +566,12 @@ const parseElfExports = (bytes: Buffer, artifact: Artifact): NativeExports => {
}
}
let name: string;
let rawName: Buffer;
let nameUtf8Decodable: boolean;
try {
name = readElfString(names, symbol.name_offset);
rawName = readElfStringBytes(names, symbol.name_offset);
nameUtf8Decodable = isUtf8(rawName);
name = rawName.toString(nameUtf8Decodable ? "utf8" : "latin1");
} catch (cause) {
issues.push({
code: "name_unreadable",
@@ -579,9 +588,18 @@ const parseElfExports = (bytes: Buffer, artifact: Artifact): NativeExports => {
});
continue;
}
retain(name, typeof version === "object" ? (version?.name ?? null) : null);
const rawNameBase64 = rawName.some((byte) => byte >= 0x80)
? rawName.toString("base64")
: null;
retain(
name,
typeof version === "object" ? (version?.name ?? null) : null,
rawNameBase64,
);
exports.push({
name,
name_bytes_base64: rawNameBase64,
name_utf8_decodable: nameUtf8Decodable,
value: hex(symbol.value),
size: symbol.size.toString(),
type: ELF_TYPES[symbol.type] ?? "other",
+6 -1
View File
@@ -19,6 +19,11 @@ export const readElfSymbol = (
};
};
export const readElfString = (bytes: Buffer, offset: number): string => {
return readElfStringBytes(bytes, offset).toString("latin1");
};
/** Read exact string-table bytes; ELF does not prescribe a character encoding. */
export const readElfStringBytes = (bytes: Buffer, offset: number): Buffer => {
if (offset >= bytes.length)
throw new BinaryFormatFailure(
"malformed",
@@ -32,5 +37,5 @@ export const readElfString = (bytes: Buffer, offset: number): string => {
"malformed",
"ELF string is unterminated or exceeds the string budget",
);
return bytes.toString("latin1", offset, offset + end);
return bytes.subarray(offset, offset + end);
};
@@ -54,6 +54,47 @@ describe("native export stable filesystem boundary", () => {
await service.close();
});
it("keeps ELF symbol bytes while exposing valid UTF-8 text", async () => {
const root = await createTestTempDirectory("rea-native-export-unicode-");
const path = join(root, "libfixture.so");
const bytes = elfExportImage({
symbols: [{ name: "café" }, { name: "\u00ff" }],
});
await writeFile(path, bytes);
const service = createNativeExportsService();
const result = await service.inspect({ path });
if (!result.ok) throw new Error(JSON.stringify(result.error));
expect(result.value.normalized_result).toMatchObject({
format: "elf",
exports: [
{
name: "café",
name_bytes_base64: "Y2Fmw6k=",
name_utf8_decodable: true,
},
{
name: "ÿ",
name_bytes_base64: "/w==",
name_utf8_decodable: false,
},
],
});
const olderRows = JSON.parse(
JSON.stringify(result.value.normalized_result),
) as { exports: Record<string, unknown>[] };
for (const row of olderRows.exports) {
delete row.name_bytes_base64;
delete row.name_utf8_decodable;
}
expect(nativeExportsSchema.parse(olderRows)).toMatchObject({
exports: [
{ name: "café", name_bytes_base64: null, name_utf8_decodable: null },
{ name: "ÿ", name_bytes_base64: null, name_utf8_decodable: null },
],
});
await service.close();
});
it("returns typed input, unsupported, budget and cancellation failures", async () => {
const root = await createTestTempDirectory("rea-native-export-failures-");
const path = join(root, "fixture.dll");