fix(native): preserve mapping and literal coverage

This commit is contained in:
morluto
2026-10-27 10:26:29 +08:00
parent ded6542d93
commit b0c48e8b65
5 changed files with 161 additions and 23 deletions
+15
View File
@@ -1121,6 +1121,21 @@ const stringsDimension = (
).dimensions.find(({ dimension }) => dimension === "strings_names");
describe("function comparison Swift string literals", () => {
it("retains literal comparisons when legacy dossiers omit facet declarations", () => {
const legacy = (base: "0x1000" | "0x2000", value: string) => {
const result = withLiterals(base, [value]);
delete result.facet_availability;
return result;
};
const left = legacy("0x1000", "SleepDisabled");
expect(
stringsDimension("1", left, "2", legacy("0x2000", "SleepDisabled")),
).toMatchObject({ status: "unchanged" });
expect(
stringsDimension("1", left, "2", legacy("0x2000", "Holding awake")),
).toMatchObject({ status: "changed" });
});
it("treats differing decoded literals as a strings and names change", () => {
expect(
stringsDimension(
+4 -10
View File
@@ -14,6 +14,7 @@ import {
normalizeCfg,
referencedStringProjection,
sorted,
swiftLiteralCoverage,
swiftLiteralSitesConflict,
} from "./functionComparisonNormalization.js";
import type {
@@ -101,7 +102,9 @@ const facetLimitations = (
strings_names: [
[
"strings_names",
dossier.referenced_strings.length + dossier.referenced_names.length,
dossier.referenced_strings.length +
dossier.referenced_names.length +
(dossier.swift_string_literals?.length ?? 0),
],
],
cfg: [["cfg", dossier.basic_blocks.length]],
@@ -515,15 +518,6 @@ const groupStringsByOffset = (
return groups;
};
const swiftLiteralCoverage = (
snapshot: FunctionSnapshot,
): "absent" | "complete" | "partial" =>
(snapshot.dossier.swift_string_literal_omissions ?? []).length > 0
? "partial"
: snapshot.dossier.swift_string_literals === undefined
? "absent"
: "complete";
const compareReferences = (
compared: FunctionRelationshipComparison,
context: ComparisonContext,
+11 -4
View File
@@ -191,9 +191,18 @@ export const swiftLiteralSitesConflict = (
return false;
};
/** Omitted literal inventory differs from an observed empty inventory. */
export const swiftLiteralCoverage = (
snapshot: FunctionSnapshot,
): "absent" | "complete" | "partial" =>
(snapshot.dossier.swift_string_literal_omissions ?? []).length > 0
? "partial"
: snapshot.dossier.swift_string_literals === undefined
? "absent"
: "complete";
const swiftLiteralSites = (snapshot: FunctionSnapshot) => {
const base = snapshot.dossier.procedure.address;
const omissions = snapshot.dossier.swift_string_literal_omissions ?? [];
const site = (sourceAddress: string, registers: readonly string[]) => {
const offset = relativeAddress(sourceAddress, base);
return offset === null ? null : canonicalJson([offset, registers]);
@@ -211,9 +220,7 @@ const swiftLiteralSites = (snapshot: FunctionSnapshot) => {
].sort(compareUnicodeCodePoints),
);
}
const complete =
snapshot.dossier.swift_string_literals !== undefined &&
omissions.length === 0;
const complete = swiftLiteralCoverage(snapshot) === "complete";
return { values, complete };
};
+100
View File
@@ -133,6 +133,106 @@ it.each([
},
);
/** File ranges remain inside the padded source while VM ranges may alias. */
const mappingSegment = (
address: bigint,
fileOffset: number,
fileSize: number,
memorySize = fileSize,
): Buffer => {
const command = Buffer.alloc(72);
command.writeUInt32LE(0x19);
command.writeUInt32LE(command.length, 4);
command.writeBigUInt64LE(address, 24);
command.writeBigUInt64LE(BigInt(memorySize), 32);
command.writeBigUInt64LE(BigInt(fileOffset), 40);
command.writeBigUInt64LE(BigInt(fileSize), 48);
command.writeUInt32LE(1, 60);
return command;
};
const mappingImage = (commands: readonly Buffer[]): Buffer => {
const bytes = Buffer.alloc(512);
Buffer.from(machoImage({ commands })).copy(bytes);
return bytes;
};
describe("Mach-O metadata span mapping", () => {
it("rejects a conflicting alias covering only the end of a pointer", () => {
const layout = parseMachoLayout(
mappingImage([
mappingSegment(0x1000n, 0, 512),
mappingSegment(0x1104n, 400, 4),
]),
"arm64",
);
expect(layout.offset(0x1100n, 4)).toBe(256);
expect(() => layout.offset(0x1100n, 8)).toThrow(RangeError);
});
it("maps adjacent ranges and duplicate aliases with the same file translation", () => {
const layout = parseMachoLayout(
mappingImage([
mappingSegment(0x1000n, 0, 128),
mappingSegment(0x1080n, 128, 128),
mappingSegment(0x107cn, 124, 8),
]),
"arm64",
);
expect(layout.offset(0x107cn, 8)).toBe(124);
});
it("rejects file aliases into another segment's zero-filled memory", () => {
const layout = parseMachoLayout(
mappingImage([
mappingSegment(0x1000n, 0, 128, 256),
mappingSegment(0x1080n, 256, 8),
]),
"arm64",
);
expect(() => layout.offset(0x1080n, 8)).toThrow(RangeError);
});
it.each([-1, 0.5, Number.POSITIVE_INFINITY])(
"rejects an invalid requested span size %s",
(size) => {
const layout = parseMachoLayout(
mappingImage([mappingSegment(0x1000n, 0, 512)]),
"arm64",
);
expect(() => layout.offset(0x1000n, size)).toThrow(RangeError);
},
);
it("keeps dispatch coverage partial when a class-list pointer has a partial alias", () => {
const main = Buffer.concat([
mappingSegment(0x1000n, 0, 512),
Buffer.alloc(80),
]);
main.writeUInt32LE(main.length, 4);
main.writeUInt32LE(1, 64);
main.write("__objc_classlist", 72, "ascii");
main.writeBigUInt64LE(0x1100n, 72 + 32);
main.writeBigUInt64LE(8n, 72 + 40);
const result = decodeAppleDispatchMetadata(
mappingImage([main, mappingSegment(0x1104n, 400, 4)]),
10,
{ path: "/aliased", sha256: "fixture" },
);
expect(result.objc_classes).toEqual([]);
expect(result.coverage).toEqual(
expect.arrayContaining([
expect.objectContaining({
status: "partial",
reason: expect.stringContaining(
"Unmapped or ambiguous metadata pointer",
),
}),
]),
);
});
});
describe("native Mach-O slice selection", () => {
it("rejects misaligned load commands", () => {
expect(() =>
+31 -9
View File
@@ -9,6 +9,11 @@ import {
readMachoLoadCommandDeclarations,
} from "../domain/apple/machoLoadCommands.js";
import { createHash } from "node:crypto";
import {
BinaryFormatFailure,
createFileMappingIndex,
type ZeroFillMapping,
} from "./metadata/BinaryContainer.js";
import type { FixupCommands } from "./AppleMachoFixups.js";
import { decodeMachoFixedName, decodeMachoName } from "./AppleMachoNames.js";
@@ -197,6 +202,7 @@ export const parseMachoLayout = (
slice,
sliceEnd,
segments: [],
zeroFills: [],
sections: [],
dylibs: [],
binds: [],
@@ -220,19 +226,27 @@ export const parseMachoLayout = (
const complete = framing.finish();
if (!complete.ok) throw new RangeError(complete.error);
const { segments, sections, chained, binds, rebases, dylibs } = state;
const mappings = createFileMappingIndex(
segments.map((segment) => ({
address: segment.address,
offset: segment.offset,
size: Number(segment.size),
})),
state.zeroFills,
);
const offset = (address: bigint, size = 1): number => {
const matches = segments.filter(
(segment) =>
address >= segment.address &&
address + BigInt(size) <= segment.address + segment.size,
);
if (matches.length !== 1)
try {
return mappings.offset(address, size);
} catch (cause) {
// Metadata consumers use RangeError to retain partial facts when a
// pointer is unavailable. Mapping interpretation belongs to the shared
// image reader, including aliases that only overlap part of the span.
if (!(cause instanceof BinaryFormatFailure)) throw cause;
throw new RangeError(
`Unmapped or ambiguous metadata pointer ${hex(address)}`,
{ cause },
);
const segment = matches[0];
if (segment === undefined) throw new RangeError("Missing metadata segment");
return segment.offset + Number(address - segment.address);
}
};
return {
segments,
@@ -261,6 +275,7 @@ interface LayoutState {
readonly slice: number;
readonly sliceEnd: number;
readonly segments: Segment[];
readonly zeroFills: ZeroFillMapping[];
readonly sections: Section[];
readonly dylibs: FixupCommands["dylibs"][number][];
readonly binds: FixupCommands["binds"][number][];
@@ -290,6 +305,8 @@ const collectSegment = (
if (!parsed.ok) throw new RangeError("Truncated segment command");
const {
address,
memorySize,
nameBytes,
fileSize,
fileOffset,
protection,
@@ -304,6 +321,11 @@ const collectSegment = (
offset: state.slice + Number(fileOffset),
executable: (protection & 4) !== 0,
});
if (memorySize > fileSize && decodeMachoFixedName(nameBytes) !== "__PAGEZERO")
state.zeroFills.push({
address: address + fileSize,
size: memorySize - fileSize,
});
if (fileOffset === 0n && fileSize > 0n) state.baseAddress ??= address;
if (headerSize + count * 80 > size)
throw new RangeError("Truncated section table");