diff --git a/docs/binary-diagnostics.md b/docs/binary-diagnostics.md index e7de578be3..c4fddfd6d0 100644 --- a/docs/binary-diagnostics.md +++ b/docs/binary-diagnostics.md @@ -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 diff --git a/src/domain/native/nativeExports.ts b/src/domain/native/nativeExports.ts index 3790c65f61..05ee4617b6 100644 --- a/src/domain/native/nativeExports.ts +++ b/src/domain/native/nativeExports.ts @@ -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), diff --git a/src/native/exports/NativeExportParser.test.ts b/src/native/exports/NativeExportParser.test.ts index 151b9666bc..fc17048652 100644 --- a/src/native/exports/NativeExportParser.test.ts +++ b/src/native/exports/NativeExportParser.test.ts @@ -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); diff --git a/src/native/exports/NativeExportParser.ts b/src/native/exports/NativeExportParser.ts index 0c35ab9e45..528ea8c1c9 100644 --- a/src/native/exports/NativeExportParser.ts +++ b/src/native/exports/NativeExportParser.ts @@ -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", diff --git a/src/native/metadata/ElfSymbol.ts b/src/native/metadata/ElfSymbol.ts index 2729380921..d6962e73e4 100644 --- a/src/native/metadata/ElfSymbol.ts +++ b/src/native/metadata/ElfSymbol.ts @@ -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); }; diff --git a/tests/boundary/filesystem/nativeExports.test.ts b/tests/boundary/filesystem/nativeExports.test.ts index e79f80ac4d..d29f4221cf 100644 --- a/tests/boundary/filesystem/nativeExports.test.ts +++ b/tests/boundary/filesystem/nativeExports.test.ts @@ -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[] }; + 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");