fix(exports): preserve ELF name byte identity

This commit is contained in:
morluto
2026-10-27 12:17:29 +08:00
parent 1cd82597da
commit 14dd07e740
4 changed files with 199 additions and 24 deletions
+39 -2
View File
@@ -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<string>(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({
+47 -2
View File
@@ -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<typeof nativeExportsSchema>;
export type PeExport = z.infer<typeof peExportSchema>;
+4 -19
View File
@@ -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<typeof decodeElfExportName>;
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;
+109 -1
View File
@@ -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({