mirror of
https://github.com/morluto/rea
synced 2026-10-28 06:46:47 -04:00
fix(exports): preserve ELF name byte identity
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user