From 14dd07e740a67b3e60fa1cdcd5cd4a2f19641eaf Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:17:29 +0800 Subject: [PATCH] fix(exports): preserve ELF name byte identity --- src/domain/analysisView/nativeExportsView.ts | 41 ++++++- src/domain/native/nativeExports.ts | 49 ++++++++- src/native/exports/NativeExportParser.ts | 23 +--- src/native/exports/NativeExportsView.test.ts | 110 ++++++++++++++++++- 4 files changed, 199 insertions(+), 24 deletions(-) diff --git a/src/domain/analysisView/nativeExportsView.ts b/src/domain/analysisView/nativeExportsView.ts index 579ef62c7f..d0d8d1ee97 100644 --- a/src/domain/analysisView/nativeExportsView.ts +++ b/src/domain/analysisView/nativeExportsView.ts @@ -38,7 +38,12 @@ export const nativeExportsViewRequestSchema = z.discriminatedUnion( z.strictObject({ ...page, collection: z.literal("exports"), - names: z.array(z.string()).optional(), + names: z + .array(z.string()) + .optional() + .describe( + "Exact PE names, or UTF-8 ELF source names. ELF display fallbacks and names with unknown encoding are not identities; use indices for those rows. Omitted includes all names; [] matches none.", + ), indices: z.array(z.int().nonnegative()).optional(), fields: z.array(z.union([peRow.keyof(), elfRow.keyof()])).optional(), }), @@ -174,6 +179,32 @@ export const projectNativeExportsView = ( `Selected export fields do not belong to ${report.format}; available fields: ${allowed.join(", ")}.`, ); const names = view.names === undefined ? undefined : new Set(view.names); + const encodedNames = + names === undefined + ? undefined + : new Set( + [...names].flatMap((name) => { + const bytes = Buffer.from(name, "utf8"); + return bytes.toString("utf8") === name + ? [bytes.toString("base64")] + : []; + }), + ); + if ( + names !== undefined && + names.size > 0 && + report.format === "elf" && + report.exports.some( + (row) => + row.name_utf8_decodable === null || + (row.name_utf8_decodable && + row.name_bytes_base64 === null && + !/^[\x00-\x7f]*$/u.test(row.name)), + ) + ) + shared.unknowns.push( + "Some ELF names lack retained encoding facts and cannot be matched by UTF-8 identity; select their source indices. A no-match page does not establish their absence.", + ); // A projected decoded name still carries its exact bytes and encoding qualification. const columns = new Set(fields); if (report.format === "elf" && columns.has("name")) { @@ -204,7 +235,13 @@ export const projectNativeExportsView = ( view, (row, index) => (indices === undefined || indices.has(index)) && - (names === undefined || (row.name !== null && names.has(row.name))), + (names === undefined || + ("name_utf8_decodable" in row + ? row.name_utf8_decodable === true && + (row.name_bytes_base64 !== null + ? encodedNames!.has(row.name_bytes_base64) + : /^[\x00-\x7f]*$/u.test(row.name) && names.has(row.name)) + : row.name !== null && names.has(row.name))), projectRow, ); return ok({ diff --git a/src/domain/native/nativeExports.ts b/src/domain/native/nativeExports.ts index 532eed4f13..9b61911b36 100644 --- a/src/domain/native/nativeExports.ts +++ b/src/domain/native/nativeExports.ts @@ -1,6 +1,20 @@ import { z } from "zod"; +import { isUtf8 } from "node:buffer"; import { absoluteLocalPathStringSchema } from "../localPath.js"; +import { canonicalBase64Schema } from "../stringPatterns.js"; + +/** ELF strings declare bytes rather than an encoding; Latin-1 is only a lossless display fallback. */ +export const decodeElfExportName = (bytes: Buffer) => { + const utf8 = isUtf8(bytes); + return { + name: bytes.toString(utf8 ? "utf8" : "latin1"), + name_bytes_base64: bytes.some((byte) => byte >= 0x80) + ? bytes.toString("base64") + : null, + name_utf8_decodable: utf8, + }; +}; const uint = z.number().int().min(0).max(0xffffffff); const address = z.string().regex(/^0x[0-9a-f]+$/u); @@ -50,7 +64,7 @@ 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), + name_bytes_base64: canonicalBase64Schema.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. */ @@ -65,7 +79,7 @@ const elfExportSchema = z.strictObject({ index: z.number().int().min(2).max(0x7fff), name: z.string().min(1).nullable(), /** Exact non-ASCII version-name bytes; ELF strings do not declare encoding. */ - name_bytes_base64: z.string().nullable().default(null), + name_bytes_base64: canonicalBase64Schema.nullable().default(null), /** Null means older Evidence did not retain version-name encoding facts. */ name_utf8_decodable: z.boolean().nullable().default(null), hidden: z.boolean(), @@ -143,6 +157,37 @@ export const nativeExportsSchema = z message: "Export coverage count must equal the retained export row count.", }); + if (value.format !== "elf") return; + const checkName = ( + row: { + name: string | null; + name_bytes_base64: string | null; + name_utf8_decodable: boolean | null; + }, + path: (string | number)[], + ) => { + // Historical omissions remain unknown. Complete encoding facts must agree + // with the producer's UTF-8-or-Latin-1 display interpretation. + if (row.name_bytes_base64 === null || row.name_utf8_decodable === null) + return; + const bytes = Buffer.from(row.name_bytes_base64, "base64"); + const decoded = decodeElfExportName(bytes); + if ( + decoded.name_utf8_decodable !== row.name_utf8_decodable || + decoded.name !== row.name + ) + context.addIssue({ + code: "custom", + path, + message: + "ELF name text and encoding must agree with retained source bytes", + }); + }; + value.exports.forEach((row, index) => { + checkName(row, ["exports", index]); + if (row.version !== null && typeof row.version === "object") + checkName(row.version, ["exports", index, "version"]); + }); }); export type NativeExports = z.infer; export type PeExport = z.infer; diff --git a/src/native/exports/NativeExportParser.ts b/src/native/exports/NativeExportParser.ts index 5302adc06a..7488cc81da 100644 --- a/src/native/exports/NativeExportParser.ts +++ b/src/native/exports/NativeExportParser.ts @@ -7,7 +7,6 @@ import { readMappedCString, } from "../metadata/BinaryContainer.js"; import { architectureForElfMachine } from "../metadata/ElfImage.js"; -import { isUtf8 } from "node:buffer"; import { readElfStringBytes } from "../metadata/ElfStringTable.js"; import { readElfSymbol } from "../../domain/elfSymbol.js"; import { @@ -17,6 +16,7 @@ import { import { readPeImage } from "../metadata/PeImage.js"; import { MAX_NATIVE_EXPORT_ROWS, + decodeElfExportName, type ElfExport, type NativeExportIssue, type NativeExports, @@ -304,22 +304,7 @@ const versionSectionData = ( }; /** Version names by index, read from `.gnu.version_d` and `.gnu.version_r`. */ -type DecodedElfString = { - readonly name: string; - readonly name_bytes_base64: string | null; - readonly name_utf8_decodable: boolean; -}; - -const decodeElfString = (bytes: Buffer): DecodedElfString => { - const utf8 = isUtf8(bytes); - return { - name: bytes.toString(utf8 ? "utf8" : "latin1"), - name_bytes_base64: bytes.some((byte) => byte >= 0x80) - ? bytes.toString("base64") - : null, - name_utf8_decodable: utf8, - }; -}; +type DecodedElfString = ReturnType; const readVersionNames = ( reader: BinaryReader, @@ -331,7 +316,7 @@ const readVersionNames = ( // output rows. Replacing an index releases its previous decoded name. let textBytes = 0; const addName = (index: number, name: Buffer): void => { - const decoded = name.length === 0 ? null : decodeElfString(name); + const decoded = name.length === 0 ? null : decodeElfExportName(name); const previous = namesByIndex.get(index); const nextTextBytes = textBytes - @@ -641,7 +626,7 @@ const parseElfExports = (bytes: Buffer, artifact: Artifact): NativeExports => { let rawNameBase64: string | null; try { const rawName = readElfStringBytes(names, symbol.name_offset); - const decodedName = decodeElfString(rawName); + const decodedName = decodeElfExportName(rawName); nameUtf8Decodable = decodedName.name_utf8_decodable; name = decodedName.name; rawNameBase64 = decodedName.name_bytes_base64; diff --git a/src/native/exports/NativeExportsView.test.ts b/src/native/exports/NativeExportsView.test.ts index f451cbd461..53f3a4e0db 100644 --- a/src/native/exports/NativeExportsView.test.ts +++ b/src/native/exports/NativeExportsView.test.ts @@ -118,7 +118,7 @@ it("qualifies projected names with exact encoding facts and normalizes historica analysisViewRequestSchema.parse({ kind: "native-exports", collection: "exports", - names: ["café"], + indices: [3], fields: ["name"], }), ); @@ -131,6 +131,66 @@ it("qualifies projected names with exact encoding facts and normalizes historica } }); +it("matches ELF UTF-8 identities without conflating Latin-1 display names or unknown encodings", () => { + const report = parseNativeExports( + elfExportImage({ + symbols: [{ name: "café" }, { name: "café" }, { name: "same" }], + }), + artifact, + ); + expect( + project(report, { + kind: "native-exports", + collection: "exports", + names: ["café"], + fields: ["name"], + }), + ).toMatchObject({ + items: [{ source_index: 0, name: "café", name_utf8_decodable: true }], + coverage: { total: 1 }, + }); + if (report.format !== "elf") throw new Error("Expected ELF fixture"); + const historical = { + ...report, + exports: report.exports.map( + ({ name_bytes_base64: _bytes, name_utf8_decodable: _utf8, ...row }) => + row, + ), + }; + expect( + project(historical, { + kind: "native-exports", + collection: "exports", + names: ["café"], + }), + ).toMatchObject({ + items: [], + unknowns: [expect.stringContaining("lack retained encoding facts")], + }); + expect( + project(historical, { + kind: "native-exports", + collection: "exports", + names: [], + }), + ).toMatchObject({ items: [], unknowns: [] }); + const missingBytes = { + ...report, + exports: report.exports.map((row) => ({ ...row, name_bytes_base64: null })), + }; + expect( + project(missingBytes, { + kind: "native-exports", + collection: "exports", + names: ["café", "same"], + fields: [], + }), + ).toMatchObject({ + items: [{ source_index: 2 }], + unknowns: [expect.stringContaining("lack retained encoding facts")], + }); +}); + it("does not fill historical name defaults into a projection that omits names", () => { const result = project(elf(), { kind: "native-exports", @@ -146,6 +206,54 @@ it("does not fill historical name defaults into a projection that omits names", expect(view_digest).toBe(digestCanonicalValue(content, "Analysis view")); }); +it.each(["Y2Fmw6k", "Y2Fmw6l=", "not base64"])( + "rejects untrustworthy imported ELF name bytes %s before selection", + (encoded) => { + for (const target of ["symbol", "version"]) { + const report = elf(); + const row = report.exports[0]!; + if (target === "symbol") row.name_bytes_base64 = encoded; + else if (typeof row.version === "object" && row.version !== null) + row.version.name_bytes_base64 = encoded; + const result = projectAnalysisView( + parent(report), + analysisViewRequestSchema.parse({ + kind: "native-exports", + collection: "exports", + names: ["missing"], + }), + ); + expect(result.ok).toBe(false); + } + }, +); + +it.each(["text", "encoding"])( + "rejects contradictory ELF %s facts at the source owner", + (fact) => { + for (const target of ["symbol", "version"]) { + const report = elf(); + const row = report.exports[0]!; + const name = target === "symbol" ? row : row.version; + if (name === null || typeof name !== "object") + throw new Error("Expected named ELF row"); + name.name_bytes_base64 = Buffer.from(name.name!).toString("base64"); + if (fact === "text") name.name = "different"; + else name.name_utf8_decodable = false; + expect( + projectAnalysisView( + parent(report), + analysisViewRequestSchema.parse({ + kind: "native-exports", + collection: "exports", + names: ["missing"], + }), + ).ok, + ).toBe(false); + } + }, +); + it("preserves PE ordinal-only, forwarder, alias and same-address distinctions", () => { const report = parseNativeExports( peExportImage({