Compare commits

...
40 changed files with 1180 additions and 243 deletions

No files matched your search

@@ -1031,6 +1031,11 @@ complete observation.
arm64e formats;
- legacy `LC_DYLD_INFO` rebase and bind opcodes.
Text relocation records retain their import identity without claiming a
pointer value. Bind types are explicit: a non-lazy binding with no declared
type is invalid. Reads that overlap only part of an encoded chained-fixup word remain
unresolved rather than treating those stored bytes as an ordinary pointer.
[dyld symbol names are byte strings](https://github.com/apple-oss-distributions/dyld/blob/dyld-1330/mach_o/BindOpcodes.cpp#L203-L208),
compared with [`strcmp`](https://github.com/apple-oss-distributions/dyld/blob/dyld-1330/mach_o/BindOpcodes.cpp#L42-L56).
Dependent library install names likewise originate as
@@ -132,6 +132,7 @@ describe("Mach-O encryption reader: thin images", () => {
container_file_offset: 0,
size: expect.any(Number),
status: "absent",
malformed_kind: null,
reason: null,
commands: [],
range: null,
@@ -479,6 +480,27 @@ describe("Mach-O encryption reader: segment VM extent", () => {
});
describe("Mach-O encryption reader: defects are never absent", () => {
it.each([0, 1, 2])(
"keeps a structural encryption defect at index %i ahead of duplicate ambiguity",
async (index) => {
const commands = Array.from({ length: 3 }, () =>
encryption({ cryptoff: 0, cryptsize: 0, cryptid: 1 }),
);
commands[index] = encryption(
{ cryptoff: 0, cryptsize: 0, cryptid: 1 },
true,
8,
);
const slice = await onlySlice(machoImage({ commands }));
expect(slice).toMatchObject({
status: "malformed",
malformed_kind: "structure",
reason: `slice has 3 encryption load commands; load command ${index} LC_ENCRYPTION_INFO_64 has cmdsize 8, expected 24`,
range: null,
});
expect(slice.commands.map(({ index }) => index)).toEqual([0, 1]);
},
);
it.each([
{
name: "a 64-bit command in a 32-bit image",
@@ -544,6 +566,7 @@ describe("Mach-O encryption reader: defects are never absent", () => {
expect(slice).toMatchObject({
status: "malformed",
reason: "slice has 1000 encryption load commands",
malformed_kind: "duplicate-commands",
range: null,
});
expect(slice.commands.map(({ index }) => index)).toEqual([0, 1]);
+54 -22
View File
@@ -117,6 +117,7 @@ const unread = (
container_file_offset: declaration.offset,
size: declaration.size,
status,
malformed_kind: status === "malformed" ? "structure" : null,
reason,
commands: [],
range: null,
@@ -143,6 +144,10 @@ const readSlice = async (
// and dropped so a large table of small commands stays one buffer.
const commands: MachoEncryptionCommand[] = [];
let encryptionCommands = 0;
const encryption: {
first: ValidEncryptionCommand | null;
failure: string | null;
} = { first: null, failure: null };
// Null once this slice's segments need more records than the shared
// mapping budget has left; none are retained past that point.
let segments: MachoLoadCommand[] | null = [];
@@ -155,6 +160,7 @@ const readSlice = async (
}: MachoReadIssue): MachoSliceEncryption => ({
...base,
status: status === "unsupported" ? "unknown" : "malformed",
malformed_kind: status === "unsupported" ? null : "structure",
reason,
commands,
range: null,
@@ -164,8 +170,12 @@ const readSlice = async (
const framed = frameLoadCommands(table, (command) => {
if (isEncryptionCommand(command.command)) {
encryptionCommands += 1;
const decoded = decodeEncryptionCommand(command);
if (commands.length < RETAINED_ENCRYPTION_COMMANDS)
commands.push(decodeEncryptionCommand(command));
commands.push(decoded);
const checked = classifyCommand(decoded, header, slice.size);
if (checked.status === "malformed") encryption.failure ??= checked.reason;
else encryption.first ??= checked.command;
} else if (
segments !== null &&
(command.command === LC_SEGMENT || command.command === LC_SEGMENT_64)
@@ -187,15 +197,44 @@ const readSlice = async (
return {
...base,
status: "malformed",
malformed_kind: "structure",
reason: identityIssue,
commands,
range: null,
};
const outcome = classify(commands, encryptionCommands, header, slice.size);
if (outcome.status !== "valid")
return { ...base, ...outcome, commands, range: null };
if (encryption.failure !== null)
return {
...base,
status: "malformed",
malformed_kind: "structure",
reason:
encryptionCommands > 1
? `slice has ${encryptionCommands} encryption load commands; ${encryption.failure}`
: encryption.failure,
commands,
range: null,
};
if (encryptionCommands > 1)
return {
...base,
status: "malformed",
malformed_kind: "duplicate-commands",
reason: `slice has ${encryptionCommands} encryption load commands`,
commands,
range: null,
};
const command = encryption.first;
if (command === null)
return {
...base,
status: "absent",
malformed_kind: null,
reason: null,
commands,
range: null,
};
const range = encryptedRange(
outcome.command,
command,
header,
segments,
slice.offset,
@@ -206,7 +245,8 @@ const readSlice = async (
if (range.mapping.status === "mapped") budget.remaining -= segmentRecords;
return {
...base,
status: outcome.command.cryptid === 0 ? "not-encrypted" : "encrypted",
status: command.cryptid === 0 ? "not-encrypted" : "encrypted",
malformed_kind: null,
reason: null,
commands,
range,
@@ -236,31 +276,23 @@ const decodeEncryptionCommand = ({
};
};
type ValidEncryptionCommand = MachoEncryptionCommand & {
readonly cryptoff: number;
readonly cryptsize: number;
readonly cryptid: number;
};
type Classification =
| {
readonly status: "valid";
readonly command: MachoEncryptionCommand & {
readonly cryptoff: number;
readonly cryptsize: number;
readonly cryptid: number;
};
readonly command: ValidEncryptionCommand;
}
| { readonly status: "absent"; readonly reason: null }
| { readonly status: "malformed"; readonly reason: string };
const classify = (
commands: readonly MachoEncryptionCommand[],
count: number,
const classifyCommand = (
command: MachoEncryptionCommand,
header: MachoSliceHeader,
sliceSize: number,
): Classification => {
if (count === 0) return { status: "absent", reason: null };
const [command] = commands;
if (command === undefined || count > 1)
return {
status: "malformed",
reason: `slice has ${count} encryption load commands`,
};
const width = header.wide ? 64 : 32;
const expectedKind = header.wide
? "LC_ENCRYPTION_INFO_64"
+37
View File
@@ -68,6 +68,42 @@ it("keeps historical symbol defaults scoped to their selected Mach-O facet", ()
).toBe(false);
});
it.each(["not-encrypted", "malformed"] as const)(
"projects historical encryption rows with %s status",
(status) => {
const current = inspectMachoSchema.parse(report);
const { malformed_kind: _unrecorded, ...recorded } =
current.encryption.items[0]!;
const historical = {
...recorded,
status,
...(status === "malformed"
? { reason: "slice has 3 encryption load commands", range: null }
: {}),
};
const source = { ...current, encryption: coverage([historical]) };
expect(inspectMachoSchema.safeParse(source).success).toBe(false);
const projected = projectAnalysisView(parent(source), {
kind: "macho",
facet: "encryption",
offset: 0,
limit: 10,
});
if (!projected.ok || projected.value.kind !== "macho")
throw new Error("Expected a historical Mach-O encryption page");
expect(projected.value.item).toEqual([
{
...historical,
malformed_kind: status === "malformed" ? "unrecorded" : null,
},
]);
expect(analysisViewResultSchema.safeParse(projected.value).success).toBe(
true,
);
expect(historical).not.toHaveProperty("malformed_kind");
},
);
const symbol = (name: string) => ({
name,
runtime_name: name.slice(1),
@@ -148,6 +184,7 @@ const report = {
container_file_offset: 0,
size: 4096,
status: "not-encrypted",
malformed_kind: null,
reason: null,
commands: [
{
+3 -2
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { uniqueSorted } from "../canonicalOrdering.js";
import { isMachOFormat } from "../binaryTargetTypes.js";
import { machoSliceEncryptionViewSchema } from "../apple/machoEncryption.js";
import {
inspectMachoSchema,
retainedInspectMachoSchema,
@@ -56,7 +57,7 @@ export const machoViewRows = {
imports: exactRows(shape.imports.shape.items),
exports: exactRows(shape.exports.shape.items),
segments: exactRows(shape.segments.shape.items),
encryption: exactRows(shape.encryption.shape.items),
encryption: z.array(machoSliceEncryptionViewSchema),
provenance: exactRows(shape.provenance),
};
@@ -90,7 +91,7 @@ export const machoSummarySchema = z.strictObject({
limitation_count: z.int().nonnegative(),
});
type MachoReport = z.output<typeof inspectMachoSchema>;
type MachoReport = z.output<typeof retainedInspectMachoSchema>;
/** Admit a well-formed report whose recorded subject, if any, is a Mach-O. */
const parseMachoReport = (
+13 -4
View File
@@ -77,7 +77,10 @@ export const directoryOf = (path: string): string => {
export const joinPath = (directory: string, rest: string): string =>
directory === "" ? rest : rest === "" ? directory : `${directory}/${rest}`;
export type Expansion =
export type Expansion = {
/** Recognized dyld token, owned by the same parse that expands the path. */
readonly prefix?: "loader_path" | "executable_path";
} & (
| {
readonly scope: "target";
/** Source-facing expansion retained in candidate Evidence. */
@@ -86,7 +89,8 @@ export type Expansion =
readonly lookupPath?: string;
}
| { readonly scope: "outside"; readonly path: string }
| { readonly scope: "undetermined"; readonly path: string };
| { readonly scope: "undetermined"; readonly path: string }
);
/** Expand one dyld path prefix relative to its owning image. */
export const expandPrefix = (
@@ -102,12 +106,17 @@ export const expandPrefix = (
if (variable !== null) {
const rest = variable[2] ?? "";
if (variable[1] === "loader_path")
return { scope: "target", path: joinPath(context.ownerDirectory, rest) };
return {
scope: "target",
path: joinPath(context.ownerDirectory, rest),
prefix: "loader_path",
};
return context.executable === null
? { scope: "undetermined", path: raw }
? { scope: "undetermined", path: raw, prefix: "executable_path" }
: {
scope: "target",
path: joinPath(directoryOf(context.executable), rest),
prefix: "executable_path",
};
}
if (raw.startsWith("/")) {
+9 -3
View File
@@ -222,9 +222,9 @@ it.each(["@loader_path_suffix", "@executable_path_suffix", "@loader_path\n"])(
}),
{ roots: [MAIN] },
);
expect(edgeFor(trace, MAIN, name).resolution).toEqual({
status: "undetermined",
image: null,
expect(edgeFor(trace, MAIN, name)).toMatchObject({
candidates: [{ source: "literal", path: name }],
resolution: { status: "undetermined", image: null },
});
},
);
@@ -1020,6 +1020,12 @@ describe("dyld slice compatibility and coverage", () => {
],
resolution: { status: "undetermined", image: null },
});
expect(trace.coverage.status).toBe("partial");
expect(trace.limitations).toContainEqual(
expect.stringContaining(
"Contents/MacOS/libarm.dylib have unknown architecture compatibility",
),
);
});
it("lets an x86_64h process load generic x86_64 but not arm64e load arm64", async () => {
+18 -9
View File
@@ -268,6 +268,7 @@ interface ProcessContext {
>;
readonly searchOverrides: readonly DyldSearchOverride[];
readonly environmentRoots: Set<string>;
readonly unknownArchitectureCandidates: Set<string>;
readonly unknownPlatformCandidates: Set<string>;
readonly undecodableSymlinkTargets: Map<string, string>;
readonly implicitMacOSPlatformCandidates: Set<string>;
@@ -310,15 +311,11 @@ const candidateTemplates = (
): CandidateTemplate[] => {
const name = dependency.install_name;
if (!name.startsWith("@rpath/")) {
const source = name.startsWith("@executable_path")
? "executable_path"
: name.startsWith("@loader_path")
? "loader_path"
: "literal";
const expansion = expandCandidatePath(name, directoryOf(loader), context);
return [
{
expansion: expandCandidatePath(name, directoryOf(loader), context),
source,
expansion,
source: expansion.prefix ?? "literal",
rpath: null,
rpathOwner: null,
},
@@ -379,8 +376,10 @@ const evaluateCandidate = async (
machoImageSlices(facts),
context.processIdentity,
);
if (selection.kind === "unknown") outcome = "undetermined";
else if (selection.kind === "missing") outcome = "architecture-missing";
if (selection.kind === "unknown") {
outcome = "undetermined";
context.unknownArchitectureCandidates.add(lookup.path);
} else if (selection.kind === "missing") outcome = "architecture-missing";
// Dependency commands require a dylib; generic dyld image loadability
// also admits main executables and bundles for different operations.
else if (selection.slice.file_type_code !== 6) outcome = "not-loadable";
@@ -672,6 +671,7 @@ const projectTraceResult = (input: {
readonly edges: readonly Edge[];
readonly withoutArchitecture: readonly string[];
readonly environmentRoots: ReadonlySet<string>;
readonly unknownArchitectureCandidates: ReadonlySet<string>;
readonly unknownPlatformCandidates: ReadonlySet<string>;
readonly undecodableSymlinkTargets: ReadonlyMap<string, string>;
readonly implicitMacOSPlatformCandidates: ReadonlySet<string>;
@@ -705,6 +705,7 @@ const projectTraceResult = (input: {
unparsed.length === 0 &&
input.withoutArchitecture.length === 0 &&
input.environmentRoots.size === 0 &&
input.unknownArchitectureCandidates.size === 0 &&
input.unknownPlatformCandidates.size === 0 &&
input.undecodableSymlinkTargets.size === 0 &&
input.rootEnumeration?.status !== "unknown"
@@ -726,6 +727,11 @@ const projectTraceResult = (input: {
: [
`Roots ${[...input.environmentRoots].join(", ")} request applicable image-selection inputs through LC_DYLD_ENVIRONMENT that are not modeled; affected dependencies or inserted images remain uncertain.`,
]),
...(input.unknownArchitectureCandidates.size === 0
? []
: [
`In-root dylib candidates ${[...input.unknownArchitectureCandidates].sort(compare).join(", ")} have unknown architecture compatibility and are not treated as incompatible. Their dependent graphs may be incomplete.`,
]),
...(input.unknownPlatformCandidates.size === 0
? []
: [
@@ -760,6 +766,7 @@ export const traceDylibLoading = async (
const edges: Edge[] = [];
const withoutArchitecture: string[] = [];
const environmentRoots = new Set<string>();
const unknownArchitectureCandidates = new Set<string>();
const unknownPlatformCandidates = new Set<string>();
const undecodableSymlinkTargets = new Map<string, string>();
const implicitMacOSPlatformCandidates = new Set<string>();
@@ -802,6 +809,7 @@ export const traceDylibLoading = async (
},
searchOverrides,
environmentRoots,
unknownArchitectureCandidates,
unknownPlatformCandidates,
undecodableSymlinkTargets,
implicitMacOSPlatformCandidates,
@@ -824,6 +832,7 @@ export const traceDylibLoading = async (
edges,
withoutArchitecture,
environmentRoots,
unknownArchitectureCandidates,
unknownPlatformCandidates,
undecodableSymlinkTargets,
implicitMacOSPlatformCandidates,
+1 -1
View File
@@ -164,7 +164,7 @@ const lazyConsequence = (weak: boolean): string =>
export const DYLIB_RESOLUTION_LIMITATIONS = [
"LC_LAZY_LOAD_DYLIB and LC_LAZY_LOAD_DYLIB_INFO dependencies are resolved but not traversed, because dyld loads them only on first use.",
"Slices are matched by dyld's graded architectures (an x86_64h process also loads x86_64). arm64e processes that disable pointer authentication can also load arm64 slices; that fallback is not modeled.",
"Absolute install names and rpaths are outside the analyzed root and are not evaluated, including /System and /usr/lib libraries that the dyld shared cache usually provides.",
"Absolute install names and rpaths are evaluated only when spelled beneath the analyzed root. Other absolute paths remain outside the analyzed root, including /System and /usr/lib libraries that the dyld shared cache usually provides.",
"Leaf and relative install names depend on dyld fallback paths, DYLD_* variables, and the working directory; they are undetermined.",
"Load order follows dyld's dependents-first traversal in load-command order. An image reached through several chains is resolved once per process, with the rpath stack and conditionality of the first chain; a later @rpath request whose install name matches an already loaded image reuses it.",
"@loader_path uses each image's symlink-resolved path within the analyzed root.",
+37
View File
@@ -0,0 +1,37 @@
import { expect, it } from "vitest";
import { machoSliceEncryptionSchema } from "./machoEncryption.js";
it("requires a malformed cause exactly for malformed slice encryption", () => {
const slice = {
architecture: null,
cpu_type: null,
cpu_subtype: null,
container_file_offset: 0,
size: 32,
reason: null,
commands: [],
range: null,
};
for (const status of [
"absent",
"encrypted",
"not-encrypted",
"unknown",
"malformed",
]) {
for (const malformed_kind of [null, "structure", "duplicate-commands"]) {
expect(
machoSliceEncryptionSchema.safeParse({
...slice,
status,
malformed_kind,
}).success,
`${status}/${String(malformed_kind)}`,
).toBe((status === "malformed") === (malformed_kind !== null));
}
}
expect(
machoSliceEncryptionSchema.safeParse({ ...slice, status: "absent" })
.success,
).toBe(false);
});
+62 -4
View File
@@ -78,8 +78,15 @@ export const machoEncryptionStatusSchema = z.enum([
"unknown",
]);
/** Load-command encryption state of one Mach-O slice. */
export const machoSliceEncryptionSchema = z.object({
const malformedKinds = ["structure", "duplicate-commands"] as const;
const malformedKind = z
.enum(malformedKinds)
.nullable()
.describe(
"Malformed cause owned by the structural reader; duplicate-commands means every encryption command was structurally valid. Null for all other statuses.",
);
const sliceEncryptionFields = {
architecture: z
.string()
.nullable()
@@ -93,14 +100,65 @@ export const machoSliceEncryptionSchema = z.object({
status: machoEncryptionStatusSchema.describe(
"absent only when every load command was read; malformed and unknown carry a reason.",
),
malformed_kind: malformedKind,
reason: z.string().nullable(),
commands: z
.array(machoEncryptionCommandSchema)
.describe(
"Encryption load commands in table order; a slice with more than two keeps the first two and states the count in reason.",
"Encryption load commands in table order; a completed slice with more than two keeps the first two and states the total count in reason. Structural failures identify the invalid command, including commands beyond the retained pair.",
),
range: machoEncryptedRangeSchema.nullable(),
});
};
const validateMalformedKind = (
slice: { status: string; malformed_kind?: string | null | undefined },
context: z.RefinementCtx,
): void => {
if (
slice.malformed_kind !== undefined &&
(slice.status === "malformed") !== (slice.malformed_kind !== null)
)
context.addIssue({
code: "custom",
path: ["malformed_kind"],
message: "A malformed cause is required exactly when status is malformed",
});
};
/** Load-command encryption state of one newly inspected Mach-O slice. */
export const machoSliceEncryptionSchema = z
.object(sliceEncryptionFields)
.superRefine(validateMalformedKind);
/** View rows also report when a historical malformed cause was not recorded. */
export const machoSliceEncryptionViewSchema = z
.strictObject({
...sliceEncryptionFields,
malformed_kind: z
.enum([...malformedKinds, "unrecorded"])
.nullable()
.describe(
"Structural cause or duplicate-command ambiguity. Unrecorded means the historical inspection did not classify the cause. Null for other statuses.",
),
})
.superRefine(validateMalformedKind);
/** Interpret earlier slice records without inferring a cause from their command count. */
export const retainedMachoSliceEncryptionSchema = z
.object({
...sliceEncryptionFields,
malformed_kind: malformedKind.exactOptional(),
})
.superRefine(validateMalformedKind)
.transform((slice): z.output<typeof machoSliceEncryptionViewSchema> => ({
...slice,
malformed_kind:
slice.malformed_kind === undefined
? slice.status === "malformed"
? "unrecorded"
: null
: slice.malformed_kind,
}));
export type MachoEncryptionCommand = z.infer<
typeof machoEncryptionCommandSchema
+7 -2
View File
@@ -2,7 +2,10 @@ import { z } from "zod";
import { bundleResourceRootSchema } from "../apple/bundleResourceRoot.js";
import { jsonRecordSchema } from "../jsonRecord.js";
import { jsonValueSchema } from "../jsonValue.js";
import { machoSliceEncryptionSchema } from "../apple/machoEncryption.js";
import {
machoSliceEncryptionSchema,
retainedMachoSliceEncryptionSchema,
} from "../apple/machoEncryption.js";
const hexAddress = z.string().regex(/^0x[a-fA-F0-9]+$/u);
const bytesHex = z.string().regex(/^(?:[0-9a-f]{2})*$/u);
@@ -223,7 +226,9 @@ export const inspectMachoSchema = z.object({
* slice is encrypted.
*/
export const retainedInspectMachoSchema = inspectMachoSchema
.extend({ encryption: inspectMachoSchema.shape.encryption.optional() })
.extend({
encryption: coverage(retainedMachoSliceEncryptionSchema).optional(),
})
.transform(({ encryption, ...report }) => ({
...report,
encryption: encryption ?? {
+40
View File
@@ -307,6 +307,7 @@ const swiftExternalSwiftBindingImage = (
...Buffer.from(
`${boundField === "protocol" ? "_$sSQMp" : boundField === "objc-class" ? "_OBJC_CLASS_$_NSObject" : "_$sSiMn"}\0`,
),
0x51, // SET_TYPE_IMM pointer
0x70,
0x80,
0x16, // SET_SEGMENT_AND_OFFSET_ULEB segment 0 + offset 0xb00
@@ -373,6 +374,7 @@ const swiftBoundWitnessSlotImage = () => {
0x11,
0x40,
...Buffer.from("_externalWitnessImpl\0"),
0x51, // SET_TYPE_IMM pointer
0x70,
0x88,
0x1c, // SET_SEGMENT_AND_OFFSET_ULEB segment 0 + witness slot 0xe08
@@ -1107,6 +1109,7 @@ describe("Apple dispatch pointer fixup metadata", () => {
const bindings = Buffer.from([
0x40,
...Buffer.from("_OBJC_CLASS_$_NSObject\0"),
0x51, // SET_TYPE_IMM pointer
0x70,
0x88,
0x04,
@@ -1456,6 +1459,7 @@ describe("Objective-C category class references without a class name", () => {
const bindings = Buffer.from([
0x40,
...Buffer.from("_$s4Main3FooCN\0"),
0x51, // SET_TYPE_IMM pointer
0x70,
0xc8,
0x16,
@@ -1653,6 +1657,42 @@ describe("Objective-C category-local record-budget coverage", () => {
});
describe("malformed Objective-C property records", () => {
it.each(["class", "category"] as const)(
"retains the %s identity when its property-list pointer field is unmapped",
(owner) => {
const bytes = fixture(false, "partial-method");
if (owner === "class") {
bytes.writeBigUInt64LE(base + 0xfc0n, 0x220);
bytes.writeUInt32LE(2, 0xfc0);
bytes.writeUInt32LE(16, 0xfc8);
bytes.writeBigUInt64LE(base + 0x450n, 0xfd8);
} else {
bytes.writeBigUInt64LE(base + 0xfd8n, 0xb00);
bytes.writeBigUInt64LE(base + 0xbc0n, 0xfd8);
bytes.writeBigUInt64LE(base + 0x200n, 0xfe0);
}
const result = decodeAppleDispatchMetadata(bytes, 100, provenance);
if (owner === "class")
expect(result.objc_classes).toContainEqual(
expect.objectContaining({ name: "Fixture", is_meta_class: false }),
);
else
expect(result.objc_categories).toMatchObject([
{ name: "FixtureCategory", class_name: "Fixture", properties: [] },
]);
expect(
result.coverage.find(
({ facet }) => facet === "objc_properties_categories",
),
).toMatchObject({
status: "partial",
reason: expect.stringContaining(
"property_decode_failed at 0x100001000",
),
});
},
);
it.each([
["an unmapped name", { secondNameOffset: 0x2000 }, "first", "0x100000518"],
["an empty name", { firstName: "" }, "second", "0x100000508"],
+102 -5
View File
@@ -209,6 +209,7 @@ it.each(["bind", "weak", "lazy"] as const)(
"base64",
);
const opcodes = Buffer.from([
0x51,
0x11,
0x40,
...Buffer.from("_first\0"),
@@ -354,6 +355,7 @@ it.each(["bind", "weak", "lazy"] as const)(
]);
const literalName = "_OBJC_CLASS_$_�";
const opcodes = Buffer.from([
0x51,
0x11,
0x71,
0x08,
@@ -806,6 +808,7 @@ describe("incomplete legacy bind applicability", () => {
it("retains a positive bind prefix while leaving unmatched slots unknown", () => {
const opcodes = Buffer.from([
0x51,
0x11,
0x40,
...Buffer.from("_x\0"),
@@ -1085,11 +1088,20 @@ describe("parsed chained pointer membership", () => {
target: BASE + 0x4321n,
},
);
const plain = data.readBigUInt64LE(0x400b);
expect(fixups.decode(BASE + 0x400bn, plain)).toEqual({
kind: "raw",
target: plain,
});
for (const offset of [0x4002, 0x400b, 0x4012])
expect(
fixups.decode(BASE + BigInt(offset), data.readBigUInt64LE(offset)),
).toEqual({
kind: "unsupported",
reason: "A chained fixup partially overlaps the pointer slot",
});
for (const offset of [0x4000, 0x4019]) {
const plain = data.readBigUInt64LE(offset);
expect(fixups.decode(BASE + BigInt(offset), plain)).toEqual({
kind: "raw",
target: plain,
});
}
});
it.each(["shared", "exhausted"])(
@@ -1613,6 +1625,85 @@ it("keeps both pointer slots unresolved when a legacy bind overlaps them", () =>
expect(fixups.decode(BASE + 0x4008n, 456n).kind).toBe("unsupported");
});
describe("legacy bind write types", () => {
it.each([2, 3])(
"keeps four-byte bind type %i unresolved without claiming writes to adjacent bytes",
(type) => {
const opcodes = Buffer.from([
0x50 | type,
0x11,
0x40,
...Buffer.from("_text\0"),
0x71,
0x08,
0x90,
0x51,
0x90,
0,
]);
const bytes = Buffer.alloc(0x8000);
opcodes.copy(bytes, 0x7000);
const fixups = parsePointerFixups(
bytes,
{
chained: null,
rebases: [],
binds: [{ offset: 0x7000, size: opcodes.length, stream: "bind" }],
dylibs: ["/library"],
},
SEGMENTS,
BASE,
);
expect(fixups.decode(BASE + 0x4008n, 0n)).toMatchObject({
kind: "unsupported",
reason: "Legacy non-pointer bind is not a 64-bit metadata pointer",
});
expect(fixups.decode(BASE + 0x400cn, 0x1234n)).toEqual({
kind: "unsupported",
reason: "A legacy fixup partially overlaps the pointer slot",
});
expect(fixups.hasUnresolvedLegacyBytes(BASE + 0x400bn, 1)).toBe(true);
expect(fixups.hasUnresolvedLegacyBytes(BASE + 0x400cn, 4)).toBe(false);
expect(fixups.decode(BASE + 0x4010n, 0n)).toMatchObject({
kind: "bind",
symbol: "_text",
});
expect(fixups.issues).toMatchObject([{ code: "non_pointer_bind" }]);
expect(fixups.decoded).toBe(1);
},
);
it.each(["bind", "weak", "lazy"] as const)(
"preserves %s type defaults instead of inventing a pointer bind",
(stream) => {
const opcodes = Buffer.from([
0x11,
0x40,
...Buffer.from("_x\0"),
0x71,
0x08,
0x90,
0,
]);
const fixups = parsePointerFixups(
opcodes,
{
chained: null,
rebases: [],
binds: [{ offset: 0, size: opcodes.length, stream }],
dylibs: ["/library"],
},
SEGMENTS,
BASE,
);
expect(fixups.decode(BASE + 0x4008n, 0n).kind).toBe(
stream === "lazy" ? "bind" : "unknown",
);
expect(fixups.decoded).toBe(stream === "lazy" ? 1 : 0);
},
);
});
describe("legacy pointer fixup streams", () => {
it("interprets legacy bind, weak-bind and lazy-bind opcodes", () => {
const opcodes = Buffer.from([
@@ -1685,6 +1776,7 @@ describe("legacy pointer fixup streams", () => {
// The unsigned 64-bit encoding of -8 revisits the current pointer slot.
const back = [0xf8, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01];
const opcodes = Buffer.from([
0x51,
0x11, // SET_DYLIB_ORDINAL_IMM 1
0x40,
...Buffer.from("__ZdlPv\0"),
@@ -1724,6 +1816,7 @@ describe("legacy pointer fixup streams", () => {
it("retains weak-bind stream provenance independently from its default ordinal", () => {
const opcodes = Buffer.from([
0x51,
0x40,
...Buffer.from("_OBJC_CLASS_$_NSObject\0"),
0x71,
@@ -1754,6 +1847,7 @@ describe("legacy pointer fixup streams", () => {
describe("legacy bind operand decoding", () => {
const addendStream = (addend: readonly number[]) =>
Buffer.from([
0x51,
0x11, // SET_DYLIB_ORDINAL_IMM 1
0x40,
...Buffer.from("_x\0"),
@@ -1859,6 +1953,7 @@ describe("legacy bind operand decoding", () => {
const addressStream = (operand: readonly number[]) =>
Buffer.from([
0x51,
0x40,
...Buffer.from("_x\0"),
0x71,
@@ -1907,6 +2002,7 @@ it("preserves classic special ordinals and returns unsupported at invalid bind l
const parseOrdinal = (ordinal: number, dylibs: readonly string[]) => {
const immediate = ordinal < 0 ? ordinal + 16 : ordinal;
const opcodes = Buffer.from([
0x51,
ordinal > 0 ? 0x10 | ordinal : 0x30 | immediate,
0x40,
...Buffer.from("_OBJC_CLASS_$_NSObject\0"),
@@ -1955,6 +2051,7 @@ it("preserves classic special ordinals and returns unsupported at invalid bind l
}
encoded.push(Number(remaining));
const oversizedStream = Buffer.from([
0x51,
0x20,
...encoded,
0x40,
+128 -25
View File
@@ -116,6 +116,8 @@ interface BindRun {
readonly start: bigint;
count: bigint;
readonly stride: bigint;
readonly type: number;
readonly width: 4 | 8;
readonly binding:
| {
readonly kind: "bind";
@@ -377,6 +379,28 @@ const readChainedPage = (
return { locations, lastOffset, failure };
};
/** A known eight-byte chain word starting elsewhere overlaps this pointer read. */
const hasPartialChainedPointer = (
chain: ChainedSegment,
within: number,
readPage: (page: number) => ChainedPage | null,
): boolean => {
let page = -1;
let facts: ChainedPage | null = null;
for (let at = Math.max(0, within - 7); at <= within + 7; at++) {
if (at === within || BigInt(at) >= chain.segment.size) continue;
const candidatePage = Math.floor(at / chain.pageSize);
if (candidatePage !== page) {
page = candidatePage;
facts = readPage(page);
}
const offset = at % chain.pageSize;
if (((facts?.locations[offset >>> 3] ?? 0) & (1 << (offset & 7))) !== 0)
return true;
}
return false;
};
const chainedFixups = (
image: FixupImage,
range: { readonly offset: number; readonly size: number },
@@ -654,6 +678,45 @@ const chainedFixups = (
raw,
reason: `Legacy bind opcodes could overlap this rebase location: ${binds.failures.join("; ")}`,
};
// Reuse one transient view within this lookup even when retained page
// storage is full; overlap inspection and value decoding share its facts.
let queried:
| { chain: ChainedSegment; page: number; facts: ChainedPage }
| undefined;
const queryPageFacts = (
chain: ChainedSegment,
page: number,
start: number,
): ChainedPage => {
if (queried?.chain === chain && queried.page === page)
return queried.facts;
const facts = pageFacts(chain, page, start);
queried = { chain, page, facts };
return facts;
};
if (
ranges.some(
(chain) =>
POINTER_FORMATS[chain.format] !== undefined &&
address + 8n > chain.segment.address &&
address < chain.segment.address + chain.segment.size &&
hasPartialChainedPointer(
chain,
Number(address - chain.segment.address),
(page) => {
if (page >= chain.pageCount) return null;
const start = bytes.readUInt16LE(chain.pageStarts + page * 2);
return start === 0xffff
? null
: queryPageFacts(chain, page, start);
},
),
)
)
return {
kind: "unsupported",
reason: "A chained fixup partially overlaps the pointer slot",
};
const chainIndex = ranges.findIndex(
({ segment }) =>
address >= segment.address &&
@@ -675,7 +738,7 @@ const chainedFixups = (
reason: `Unsupported chained pointer format ${chain.format}`,
};
try {
const facts = pageFacts(chain, page, start);
const facts = queryPageFacts(chain, page, start);
const at = within % chain.pageSize;
const locations = facts.locations;
if (((locations[at >>> 3] ?? 0) & (1 << (at & 7))) !== 0) {
@@ -1022,6 +1085,7 @@ const sleb = (bytes: Buffer, cursor: { at: number }, end: number): bigint => {
/** Registers of the `LC_DYLD_INFO` bind state machine. */
interface BindState {
type: number;
ordinal: number;
ordinalFailure: string | null;
segmentIndex: number;
@@ -1072,6 +1136,7 @@ const setBindRegister = (
return true;
}
case 0x50:
state.type = immediate;
return true;
case 0x60:
state.addend = sleb(bytes, cursor, end);
@@ -1106,6 +1171,7 @@ function* readBindStreamSteps(
throw new RangeError("Bind opcodes exceed the target");
const cursor = { at: range.offset };
const state: BindState = {
type: range.stream === "lazy" ? 1 : 0,
ordinal: 0,
ordinalFailure: null,
segmentIndex: -1,
@@ -1116,11 +1182,14 @@ function* readBindStreamSteps(
};
const preflightRun = (count: bigint, skip: bigint): void => {
if (count === 0n) return;
if (state.type < 1 || state.type > 3)
throw new RangeError(`Unsupported legacy bind type ${state.type}`);
const segment = image.segments[state.segmentIndex];
if (segment === undefined)
throw new RangeError("Bind location has no selected segment");
const stride = 8n + skip;
const end = state.address + (count - 1n) * stride + 8n;
const width = state.type === 1 ? 8n : 4n;
const end = state.address + (count - 1n) * stride + width;
if (state.address < segment.address || end > segment.address + segment.size)
throw new RangeError("Bind expansion leaves its file-backed segment");
};
@@ -1128,22 +1197,29 @@ function* readBindStreamSteps(
if (count === 0n) return;
preflightRun(count, skip);
const stride = 8n + skip;
const width = state.type === 1 ? 8 : 4;
let binding: BindRun["binding"];
try {
if (state.ordinalFailure !== null)
throw new RangeError(state.ordinalFailure);
const resolvedLibrary = libraryFacts(state.ordinal, image.dylibs);
const decoded = resolvedBind(
{
...state.symbol,
binding_source: range.stream,
library_ordinal: state.ordinal,
...(range.stream === "weak" ? { library: null } : resolvedLibrary),
weak: state.weak,
addend: state.addend,
},
0n,
);
const imported: Import = {
...state.symbol,
binding_source: range.stream,
library_ordinal: state.ordinal,
...(range.stream === "weak" ? { library: null } : resolvedLibrary),
weak: state.weak,
addend: state.addend,
};
const decoded =
state.type === 1
? resolvedBind(imported, 0n)
: {
kind: "unsupported" as const,
reason:
"Legacy non-pointer bind is not a 64-bit metadata pointer",
binding: imported,
};
binding =
decoded.kind === "bind" ? { kind: "bind", value: decoded } : decoded;
} catch (cause: unknown) {
@@ -1167,13 +1243,21 @@ function* readBindStreamSteps(
: false;
if (
previous !== undefined &&
previous.type === state.type &&
previous.stride === stride &&
previous.start + previous.count * stride === state.address &&
sameBinding(previous.binding, binding)
) {
previous.count += count;
} else {
binds.push({ start: state.address, count, stride, binding });
binds.push({
start: state.address,
count,
stride,
type: state.type,
width,
binding,
});
}
// The dyld bind stream advances addresses modulo 64 bits; ld uses -8 to
// bind the current pointer slot again with DO_BIND_ADD_ADDR_ULEB.
@@ -1237,6 +1321,18 @@ const indexBindRuns = (
};
};
/** First producer-owned bind start in an inclusive address interval. */
const firstBindIn = (
run: BindRun,
low: bigint,
high: bigint,
): bigint | undefined => {
const index =
low <= run.start ? 0n : (low - run.start + run.stride - 1n) / run.stride;
const address = run.start + index * run.stride;
return index < run.count && address <= high ? address : undefined;
};
/** Visit runs with a bind start in the inclusive interval, without expanding runs. */
function* bindRunsIn(
index: BindRunIndex,
@@ -1263,9 +1359,7 @@ function* bindRunsIn(
index.remaining--;
candidates++;
const run = index.runs[at]!;
const first =
low <= run.start ? 0n : (low - run.start + run.stride - 1n) / run.stride;
if (first < run.count && run.start + first * run.stride <= high) yield run;
if (firstBindIn(run, low, high) !== undefined) yield run;
}
}
@@ -1318,9 +1412,11 @@ function* parseLegacyBindsSteps(
issues.push(
issue(
"pointer_fixups",
run.binding.binding.symbol === null
? "bind_symbol_text_unavailable"
: "bind_library_text_unavailable",
run.type !== 1
? "non_pointer_bind"
: run.binding.binding.symbol === null
? "bind_symbol_text_unavailable"
: "bind_library_text_unavailable",
run.binding.reason,
`0x${run.start.toString(16)}`,
),
@@ -1350,7 +1446,11 @@ const hasUnresolvedLegacyBytes = (
return true;
const first = address - 7n;
const end = address + BigInt(size);
for (const _run of bindRunsIn(binds.index, first, end - 1n)) return true;
for (const run of bindRunsIn(binds.index, first, end - 1n))
if (
firstBindIn(run, address - BigInt(run.width) + 1n, end - 1n) !== undefined
)
return true;
if (binds.index.exhausted) return true;
if (rebases.membership !== null)
for (let slot = first; slot < end; slot++) {
@@ -1376,10 +1476,13 @@ const hasPartialLegacyPointerWrite = (
const low = address - 7n;
const high = address + 7n;
for (const run of bindRunsIn(binds.index, low, high)) {
const first =
low <= run.start ? 0n : (low - run.start + run.stride - 1n) / run.stride;
const at = run.start + first * run.stride;
if (at !== address || (first + 1n < run.count && at + run.stride <= high))
const at = firstBindIn(run, address - BigInt(run.width) + 1n, high);
if (at === undefined) continue;
if (
run.width !== 8 ||
at !== address ||
firstBindIn(run, at + 1n, high) !== undefined
)
return true;
}
if (binds.index.exhausted) return true;
+14
View File
@@ -234,6 +234,7 @@ describe("Mach-O metadata span mapping", () => {
const opcodes = Buffer.from([
0x40,
...Buffer.from("_local\0"),
0x51,
0x72,
8,
0x90,
@@ -340,6 +341,19 @@ describe("Mach-O metadata span mapping", () => {
});
});
it("rejects a segment command of the wrong width before claiming metadata absence", () => {
const segment = Buffer.alloc(56);
segment.writeUInt32LE(1, 0);
segment.writeUInt32LE(segment.length, 4);
const bytes = Buffer.from(machoImage({ commands: [segment] }));
expect(() => readMachoMetadataImage(bytes)).toThrow(
"Mach-O segment command does not match the header width",
);
expect(() => parseMachoLayout(bytes, "arm64")).toThrow(
"Mach-O segment command does not match the header width",
);
});
describe("native Mach-O slice selection", () => {
it("rejects misaligned load commands", () => {
expect(() =>
+4
View File
@@ -227,6 +227,10 @@ export const parseMachoLayout = (
kind: frame.value.command,
size: frame.value.size,
};
if (command.kind === 1)
throw new RangeError(
"Mach-O segment command does not match the header width",
);
if (command.kind === 0x19) collectSegment(bytes, command, state);
else collectFixupCommand(bytes, command, state);
}
+6 -11
View File
@@ -134,17 +134,12 @@ export const decodeObjcCategories = (input: {
`0x${(category + 32n).toString(16)}`,
),
);
const properties = readObjcPropertiesOf(
read,
read.pointer(category + 40n),
{
owner: `category ${name}`,
admit: input.admit,
issues: input.issues,
location: `0x${read.pointer(category + 40n).toString(16)}`,
onTruncated,
},
);
const properties = readObjcPropertiesOf(read, category + 40n, {
owner: `category ${name}`,
admit: input.admit,
issues: input.issues,
onTruncated,
});
const decodeReasons = [
...(className === null ? ["category_class_unresolved"] : []),
...(protocols.ok ? [] : ["protocol_list_unreadable"]),
+1 -2
View File
@@ -354,11 +354,10 @@ export const decodeObjcDispatchFacets = (input: {
undefined,
onTruncated,
);
const properties = readObjcPropertiesOf(read, read.pointer(ro + 64n), {
const properties = readObjcPropertiesOf(read, ro + 64n, {
owner: name,
admit: () => budget.admit(),
issues: categoryIssues,
location: hex(read.pointer(ro + 64n)),
onTruncated,
});
const adoptedProtocols = protocolReader.readList(ro + 40n, onTruncated);
+6 -4
View File
@@ -154,19 +154,21 @@ export const readObjcPropertyList = (
return { properties, issues };
};
/** Decode a property list, recording a failure for `owner` instead of throwing. */
/** Own the property-list pointer field and list, retaining the declaring owner on failure. */
export const readObjcPropertiesOf = (
read: ObjcPropertyReaders,
list: bigint,
field: bigint,
context: {
readonly owner: string;
readonly admit: () => boolean;
readonly issues: DecodeIssue[];
readonly location?: string;
readonly onTruncated?: () => void;
},
): ObjcProperty[] => {
let location = `0x${field.toString(16)}`;
try {
const list = read.pointer(field);
location = `0x${list.toString(16)}`;
const { properties, issues } = readObjcPropertyList(
read,
list,
@@ -189,7 +191,7 @@ export const readObjcPropertiesOf = (
"objc_properties_categories",
"property_decode_failed",
`${context.owner}: ${cause instanceof Error ? cause.message : String(cause)}`,
context.location,
location,
),
);
return [];
+26 -18
View File
@@ -234,9 +234,29 @@ export const createSwiftRelativeReader = (input: {
);
let fileOffset: number | null = null;
let reason: string | null = null;
const unknownIndirect = (reason: string): SwiftRelativeValue => {
if (budget.admit())
result.relative_pointers.push({
field: read.location(field),
target: { address: null, file_offset: null },
displacement,
indirectable,
indirect,
decode: { status: "partial", reason },
evidence: read.evidence(field, "Swift ABI indirect relative pointer"),
});
return { kind: "unknown", reason };
};
try {
if (indirect && target !== null) {
const pointer = read.resolvePointer(target);
let pointer: PointerResolution;
try {
pointer = read.resolvePointer(target);
} catch (cause) {
const failure = expectedSwiftMetadataReadFailure(cause);
if (failure === null) throw cause;
return unknownIndirect(failure);
}
if (pointer.kind === "bind") {
if (budget.admit())
result.relative_pointers.push({
@@ -259,21 +279,7 @@ export const createSwiftRelativeReader = (input: {
};
}
if (pointer.kind === "unknown" || pointer.kind === "unsupported") {
const unresolved = pointer.reason;
if (budget.admit())
result.relative_pointers.push({
field: read.location(field),
target: { address: null, file_offset: null },
displacement,
indirectable,
indirect,
decode: { status: "partial", reason: unresolved },
evidence: read.evidence(
field,
"Swift ABI indirect relative pointer",
),
});
return { kind: "unknown", reason: unresolved };
return unknownIndirect(pointer.reason);
}
const indirectTarget = pointer.target;
target =
@@ -283,7 +289,8 @@ export const createSwiftRelativeReader = (input: {
}
if (target !== null) fileOffset = read.offset(target);
} catch (cause) {
reason = cause instanceof Error ? cause.message : String(cause);
reason = expectedSwiftMetadataReadFailure(cause);
if (reason === null) throw cause;
}
if (budget.admit())
result.relative_pointers.push({
@@ -553,7 +560,8 @@ export const decodeSwiftDispatchFacets = (input: {
}
}
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
const message = expectedSwiftMetadataReadFailure(cause);
if (message === null) throw cause;
issues.push(
issue(
"swift_conformances_static_witness_slots",
@@ -276,3 +276,37 @@ it("retains unsafe target addresses as partial decode facts", () => {
decode: { status: "partial", reason: "outside fixture bytes" },
});
});
it.each([
new RangeError("indirect pointer cell is unreadable"),
new Error("reader invariant failed"),
])(
"distinguishes indirect pointer read failure from a known unmapped target: %s",
(failure) => {
const bytes = Buffer.alloc(0x80);
bytes.writeInt32LE(0x25, 0x24);
const result = objcSwiftMetadataSchema.parse({ db_save_result: null });
const relative = createSwiftRelativeReader({
bytes,
readers: {
...readers(bytes),
resolvePointer: () => {
throw failure;
},
},
budget: budget(),
result,
});
if (failure instanceof RangeError) {
expect(relative(0x24n, "conformanceProtocol")).toEqual({
kind: "unknown",
reason: failure.message,
});
expect(result.relative_pointers[0]).toMatchObject({
target: { address: null, file_offset: null },
decode: { status: "partial", reason: failure.message },
});
} else
expect(() => relative(0x24n, "conformanceProtocol")).toThrow(failure);
},
);
+58 -1
View File
@@ -9,9 +9,66 @@ import {
type SwiftNominalReaders,
} from "./AppleSwiftNominalTypes.js";
import { decodeSwiftDispatchFacets } from "./AppleSwiftDispatchFacets.js";
import type { DispatchRecordBudget } from "./AppleSwiftDispatchFacets.js";
import type {
DispatchRecordBudget,
SwiftDispatchReaders,
} from "./AppleSwiftDispatchFacets.js";
import { issue } from "./AppleDispatchDecodeFacts.js";
it("propagates unexpected conformance reader failures", () => {
const failure = new Error("conformance reader invariant failed");
expect(() =>
decodeSwiftDispatchFacets({
sections: [{ name: "__swift5_proto", address: 0x100n, size: 4 }],
segments: [],
readers: {} as SwiftDispatchReaders,
relative: () => {
throw failure;
},
budget: {
truncated: false,
isFull: () => false,
markTruncated: () => {},
admit: () => true,
},
result: objcSwiftMetadataSchema.parse({ db_save_result: null }),
}),
).toThrow(failure);
});
it("propagates unexpected vtable reader failures", () => {
const failure = new Error("vtable reader invariant failed");
const vtables = createSwiftClassVtableDecoder({
result: objcSwiftMetadataSchema.parse({ db_save_result: null }),
readers: {
u32: () => {
throw failure;
},
relative: () => ({ kind: "null" }),
location: () => ({ address: null, file_offset: null }),
evidence: () => [],
executable: () => false,
admit: () => true,
},
});
expect(() =>
vtables.visitor.descriptor({
record: 0x100n,
descriptor: 0x200n,
flags: 0x80000010,
name: "Widget",
context: {
module: "Test",
path: "Widget",
depth: 2,
anonymous: false,
reason: null,
walkLimited: false,
},
}),
).toThrow(failure);
});
it("preserves unsupported type-metadata tag reasons instead of reporting null", () => {
const result = objcSwiftMetadataSchema.parse({ db_save_result: null });
const vtables = createSwiftClassVtableDecoder({
+5 -3
View File
@@ -14,6 +14,7 @@ import type {
SwiftNominalRecordFailure,
SwiftNominalVisitor,
} from "./AppleSwiftNominalTypes.js";
import { expectedSwiftMetadataReadFailure } from "./AppleSwiftMetadataReadFailure.js";
/** Byte readers shared with the validated Mach-O metadata boundary. */
export interface SwiftMetadataReaders {
@@ -63,13 +64,13 @@ export const createSwiftClassVtableDecoder = (input: {
try {
const specific = flags >>> 16;
if ((flags & 0x80) !== 0 || (specific & 0x2000) !== 0)
throw new TypeError(
throw new RangeError(
"Generic class or resilient superclass metadata is unsupported",
);
if ((specific & 0x8000) === 0) return;
const initialization = specific & 3;
if (initialization === 3)
throw new TypeError("Unknown metadata initialization kind");
throw new RangeError("Unknown metadata initialization kind");
const header =
descriptor +
44n +
@@ -167,7 +168,8 @@ export const createSwiftClassVtableDecoder = (input: {
);
}
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
const message = expectedSwiftMetadataReadFailure(cause);
if (message === null) throw cause;
issues.push(
issue(
"swift_class_vtable_descriptors",
+44 -32
View File
@@ -214,15 +214,44 @@ export const readMachoCommandNames = async (
chargeProjection(table.commandCount, PROJECTED_COMMAND_BYTES);
let records = table.commandCount;
let retainedStringBytes = 0;
const retain = (bytes: Uint8Array): Uint8Array => {
if (bytes.byteLength > maximumStringBytes - retainedStringBytes)
const stringAllowance = (): number =>
Math.min(
maximumStringBytes - retainedStringBytes,
Math.floor(
(maximumProjectedBytes - projectedBytes) / PROJECTED_STRING_EXPANSION,
),
);
const admitStringBytes = (count: number): void => {
if (count > maximumStringBytes - retainedStringBytes)
throw new MachoCommandNamesLimitError(maximumStringBytes, "bytes");
if (count > stringAllowance())
throw new MachoCommandNamesLimitError(
maximumProjectedBytes,
"projected_bytes",
);
};
const retain = (bytes: Uint8Array): Uint8Array => {
admitStringBytes(bytes.byteLength);
chargeProjection(bytes.byteLength, PROJECTED_STRING_EXPANSION);
retainedStringBytes += bytes.byteLength;
// Range readers may return Buffer views into a much larger read window.
// Charge the retained length before allocating an independently owned copy.
return Uint8Array.from(bytes);
};
const readString = async (
offset: number,
extent: number,
label: string,
): Promise<Uint8Array> => {
const length = Math.min(extent, stringAllowance() + 1);
const bytes = await exact(offset, length);
const end = bytes.indexOf(0);
if (end < 0) {
if (length < extent) admitStringBytes(length);
throw new MachoBinaryFormatError(`${label} is unterminated`);
}
return retain(bytes.subarray(0, end));
};
const commands: MachoCommandNames["commands"][number][] = [];
while (cursor.nextOffset !== null) {
const row = cursor.read(
@@ -372,17 +401,11 @@ export const readMachoCommandNames = async (
throw new MachoBinaryFormatError(
"Mach-O command string offset is invalid",
);
const limit = Math.min(frame.size - offset, maximumStringBytes + 1);
const bytes = await exact(at + offset, limit);
const end = bytes.indexOf(0);
if (end < 0) {
if (limit < frame.size - offset)
throw new MachoCommandNamesLimitError(maximumStringBytes, "bytes");
throw new MachoBinaryFormatError(
"Mach-O command string is unterminated",
);
}
commandString = retain(bytes.subarray(0, end));
commandString = await readString(
at + offset,
frame.size - offset,
"Mach-O command string",
);
commandStringOffset = offset;
if (DYLIB_COMMANDS.has(frame.command)) {
const fixed = await exact(at + 12, 12);
@@ -434,8 +457,7 @@ export const readMachoCommandNames = async (
throw new MachoBinaryFormatError(
"Mach-O prebound module bitmap exceeds its command",
);
if (length > maximumStringBytes - retainedStringBytes)
throw new MachoCommandNamesLimitError(maximumStringBytes, "bytes");
admitStringBytes(length);
prebound = {
moduleCount,
linkedModulesOffset,
@@ -501,12 +523,8 @@ export const readMachoCommandNames = async (
currentLength = 0;
if (linkerOptions.length === count) break;
} else {
const remaining = maximumStringBytes - retainedStringBytes;
if (currentLength >= remaining)
throw new MachoCommandNamesLimitError(
maximumStringBytes,
"bytes",
);
const remaining = stringAllowance();
if (currentLength >= remaining) admitStringBytes(currentLength + 1);
if (currentLength === current.byteLength) {
const capacity = Math.min(
remaining,
@@ -565,20 +583,14 @@ export const readMachoCommandNames = async (
throw new MachoBinaryFormatError(
"Mach-O fileset entry string offset is invalid",
);
const length = Math.min(frame.size - offset, maximumStringBytes + 1);
const value = await exact(at + offset, length);
const end = value.indexOf(0);
if (end < 0) {
if (length < frame.size - offset)
throw new MachoCommandNamesLimitError(maximumStringBytes, "bytes");
throw new MachoBinaryFormatError(
"Mach-O fileset entry string is unterminated",
);
}
fileset = {
address: view.getBigUint64(0, table.littleEndian),
fileOffset: view.getBigUint64(8, table.littleEndian),
entryId: retain(value.subarray(0, end)),
entryId: await readString(
at + offset,
frame.size - offset,
"Mach-O fileset entry string",
),
reserved: view.getUint32(20, table.littleEndian),
};
}
+66
View File
@@ -100,3 +100,69 @@ it("reserves the projection envelope for an empty command table", async () => {
});
}
});
it("bounds a growing linker option before reading its remaining payload", async () => {
const commandSize = 8208;
const image = Buffer.alloc(32 + commandSize);
image.writeUInt32LE(0xfeedfacf, 0);
image.writeUInt32LE(1, 16);
image.writeUInt32LE(commandSize, 20);
image.writeUInt32LE(0x2d, 32);
image.writeUInt32LE(commandSize, 36);
image.writeUInt32LE(1, 40);
image.fill(0x41, 44, 44 + 8000);
const projectedBudget = 8192 + 2048 + 256 + 32 * 24;
const read = async (offset: number, size: number) => {
if (offset >= 44 + 4096)
throw new Error("linker option read beyond its projection allowance");
return image.subarray(offset, offset + size);
};
await expect(
readMachoCommandNames(read, image.length, 16384, 2, projectedBudget),
).rejects.toMatchObject({
maximum: projectedBudget,
unit: "projected_bytes",
});
});
it.each([
["rpath", 0x8000001c, 12],
["fileset", 0x80000035, 32],
] as const)(
"bounds %s strings before reading their declared extent",
async (_name, command, nameOffset) => {
const commandSize = 8208;
const image = Buffer.alloc(32 + commandSize);
image.writeUInt32LE(0xfeedfacf, 0);
image.writeUInt32LE(1, 16);
image.writeUInt32LE(commandSize, 20);
image.writeUInt32LE(command, 32);
image.writeUInt32LE(commandSize, 36);
image.writeUInt32LE(nameOffset, command === 0x80000035 ? 56 : 40);
const stringAt = 32 + nameOffset;
image.fill(0x41, stringAt, stringAt + 8000);
const projectedBudget = 8192 + 2048 + 32 * 24;
const read = async (offset: number, size: number) => {
if (offset === stringAt && size > 33)
throw new Error("string read beyond its projection allowance");
return image.subarray(offset, offset + size);
};
await expect(
readMachoCommandNames(read, image.length, 16384, 1, projectedBudget),
).rejects.toMatchObject({
maximum: projectedBudget,
unit: "projected_bytes",
});
image[stringAt + 32] = 0;
const parsed = await readMachoCommandNames(
async (offset, size) => image.subarray(offset, offset + size),
image.length,
16384,
1,
projectedBudget,
);
const name =
parsed.commands[0]?.commandString ?? parsed.commands[0]?.fileset?.entryId;
expect(name).toHaveLength(32);
},
);
+9 -2
View File
@@ -1,11 +1,15 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { fat } from "../domain/binaryTarget.fixture.js";
import type { JsonValue } from "../domain/jsonValue.js";
import {
NativeFixtureRunner,
nativeMachoTarget,
nativeMachoTargetForFile,
} from "../../tests/fixtures/nativeCommands.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import {
OwnedCommandFailure,
runOwnedCommand,
@@ -45,8 +49,11 @@ const execute = async (
runner: NativeCommandRunner,
parameters: Readonly<Record<string, JsonValue>>,
) => {
const root = await createTestTempDirectory("rea-native-deadline-");
const path = join(root, "app");
await writeFile(path, fat([0x01000007, 0x0100000c]));
const client = new NativeMacOSProvider({}, runner, "darwin").createClient(
nativeMachoTarget("/fixture/app"),
await nativeMachoTargetForFile(path),
);
try {
return await client.execute("list_architectures", parameters);
+108 -51
View File
@@ -141,6 +141,7 @@ import {
inspectNativeMacho,
} from "./NativeMachoInspection.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
@@ -654,13 +655,14 @@ class NativeMacOSClient implements AnalysisClient {
case "inspect_plist":
return this.#inspectPlist(parameters, signal, timeoutMs);
case "list_architectures":
return this.#listArchitectures(signal, timeoutMs);
return this.#listArchitectures(parameters, signal, timeoutMs);
case "demangle_swift":
return this.#demangle(parameters, signal, timeoutMs);
}
}
async #listArchitectures(
parameters: Readonly<Record<string, JsonValue>>,
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<Result<NativeObservation, AnalysisError>> {
@@ -674,39 +676,59 @@ class NativeMacOSClient implements AnalysisClient {
"Active artifact is not Mach-O.",
),
);
const capture = await this.#run(
"list_architectures",
"lipo",
["-detailed_info", this.target.path],
{ signal, timeoutMs },
);
if (!capture.ok) return capture;
const provenance = [invocation(capture.value, this.target.path)];
const parsed = parseLipoCapture(
capture.value,
"list_architectures",
this.target.path,
);
if (!parsed.ok) return parsed;
const { architectures } = parsed.value;
const limitations = architectures.flatMap(
(architecture) => architecture.limitations,
);
const result = listArchitecturesSchema.parse({
architectures: {
items: architectures,
total: architectures.length,
exhaustive: true,
limitations: [],
},
provenance,
limitations,
});
return ok({
result: jsonValueSchema.parse(result),
limitations,
locations: architectureLocations(result.architectures.items),
});
try {
return await this.#machoResources.run(() =>
withNativeMachoSource(
this.target,
this.#machoResources,
signal,
async () => {
const capture = await this.#run(
"list_architectures",
"lipo",
["-detailed_info", this.target.path],
{ signal, timeoutMs },
);
if (!capture.ok) return capture;
const provenance = [invocation(capture.value, this.target.path)];
const parsed = parseLipoCapture(
capture.value,
"list_architectures",
this.target.path,
);
if (!parsed.ok) return parsed;
const { architectures } = parsed.value;
const limitations = architectures.flatMap(
(architecture) => architecture.limitations,
);
const result = listArchitecturesSchema.parse({
architectures: {
items: architectures,
total: architectures.length,
exhaustive: true,
limitations: [],
},
provenance,
limitations,
});
return ok({
result: jsonValueSchema.parse(result),
limitations,
locations: architectureLocations(result.architectures.items),
});
},
),
);
} catch (cause: unknown) {
return err(
nativeMachoSourceFailure(
cause,
this.target,
parameters,
"list_architectures",
),
);
}
}
async #demangle(
@@ -1409,14 +1431,26 @@ class NativeMacOSClient implements AnalysisClient {
return await inspect(operand);
} catch (cause: unknown) {
return err(
nativeMachoSourceFailure(cause, this.target, parameters),
nativeMachoSourceFailure(
cause,
this.target,
parameters,
"inspect_macho",
),
);
}
})
: inspect(),
);
} catch (cause: unknown) {
return err(nativeMachoSourceFailure(cause, this.target, parameters));
return err(
nativeMachoSourceFailure(
cause,
this.target,
parameters,
"inspect_macho",
),
);
}
}
@@ -1676,13 +1710,14 @@ const nativeMachoCompletedFailure = (
const nativeMachoSourcePrimary = (
{ cause }: ArtifactFilePrimaryFacts,
path: string,
operation: "inspect_macho" | "list_architectures",
): AnalysisError => {
if (cause instanceof AnalysisError) return cause;
if (cause instanceof MachoBinaryFormatError)
return new AnalysisOutputError("inspect_macho", cause.message, { cause });
return new AnalysisOutputError(operation, cause.message, { cause });
if (cause instanceof MachoCommandNamesLimitError)
return new AnalysisResourceConstraintError(
"inspect_macho",
operation,
"memory",
cause.message,
{ maximum: cause.maximum, unit: cause.unit },
@@ -1698,9 +1733,11 @@ const nativeMachoSourcePrimary = (
? nativeMachoSourcePrimary(
artifactFilePrimaryFacts(cause.outcome.cause),
path,
operation,
)
: undefined;
const previous = failed ?? nativeMachoCompletedFailure(completed);
const completedFailure = nativeMachoCompletedFailure(completed);
const previous = failed ?? completedFailure;
const options = {
cause,
...(previous === undefined
@@ -1713,21 +1750,31 @@ const nativeMachoSourcePrimary = (
},
}),
};
if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled")
return new AnalysisCancelledError("inspect_macho", {
if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") {
// A returned failure is already the producer's physical outcome. A later
// cancelled identity check must not replace its output or cleanup owner.
if (completedFailure !== undefined)
return analysisErrorWithCancellation(
completedFailure,
new AnalysisCancelledError(operation, {
cause: artifactFileCancellationCause(cause),
}),
);
return new AnalysisCancelledError(operation, {
...options,
cause: artifactFileCancellationCause(cause),
});
}
if (cause instanceof ArtifactReaderFailure && cause.reason === "format")
return new AnalysisOutputError("inspect_macho", cause.message, options);
return new AnalysisOutputError(operation, cause.message, options);
if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity")
return new AnalysisArtifactChangedError(
"inspect_macho",
operation,
path,
cause.message,
options,
);
return new ProviderAdapterError(IDENTITY.id, "inspect_macho", {
return new ProviderAdapterError(IDENTITY.id, operation, {
...options,
userMessage: "The selected Mach-O source could not be read safely.",
remediationAction:
@@ -1739,17 +1786,18 @@ const nativeMachoCompletion = (
cause: unknown,
target: BinaryTarget,
parameters: Readonly<Record<string, JsonValue>>,
operation: "inspect_macho" | "list_architectures",
) => {
const completion = artifactFileCompletion(cause);
// The file operation only completes after the producer result has passed
// the final identity check. Its callback is owned by #inspectMacho.
// the final identity check. Both Mach-O command workflows own their callbacks.
const outcome = completion?.value as
| Result<NativeObservation, AnalysisError>
| undefined;
const observation =
outcome?.ok === true
? createAnalysisExecutionEvidence(
"inspect_macho",
operation,
parameters,
{
...createAnalysisExecution(outcome.value.result, IDENTITY, {
@@ -1766,7 +1814,7 @@ const nativeMachoCompletion = (
const completionCancellation =
completion?.cancellation === undefined
? undefined
: new AnalysisCancelledError("inspect_macho", {
: new AnalysisCancelledError(operation, {
cause: completion.cancellation.reason,
...(completedFailure === undefined ||
(cause instanceof ArtifactReaderFailure &&
@@ -1787,9 +1835,10 @@ const nativeMachoSourceFailure = (
cause: unknown,
target: BinaryTarget,
parameters: Readonly<Record<string, JsonValue>>,
operation: "inspect_macho" | "list_architectures",
): AnalysisError => {
const { observation, completedFailure, completionCancellation } =
nativeMachoCompletion(cause, target, parameters);
nativeMachoCompletion(cause, target, parameters, operation);
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) {
const physical =
cause instanceof ArtifactFileCleanupFailure
@@ -1797,7 +1846,11 @@ const nativeMachoSourceFailure = (
: undefined;
const previous =
physical?.outcome.kind === "failed"
? nativeMachoSourcePrimary(artifactFilePrimaryFacts(cause), target.path)
? nativeMachoSourcePrimary(
artifactFilePrimaryFacts(cause),
target.path,
operation,
)
: physical?.outcome.kind === "completed"
? (completedFailure ?? completionCancellation)
: completionCancellation;
@@ -1820,7 +1873,7 @@ const nativeMachoSourceFailure = (
: {}),
},
{
operation: "inspect_macho",
operation,
cause,
cleanupPrimary: previous,
partialObservation: observation,
@@ -1841,7 +1894,11 @@ const nativeMachoSourceFailure = (
completionCancellation.retainPartialObservation(observation);
return completionCancellation;
}
return nativeMachoSourcePrimary(artifactFilePrimaryFacts(cause), target.path);
return nativeMachoSourcePrimary(
artifactFilePrimaryFacts(cause),
target.path,
operation,
);
};
interface NativeObservation {
@@ -170,3 +170,47 @@ it("declares the temporary private operand write in inspect_macho provider capab
)?.effects,
).toMatchObject({ mayWriteFilesystem: true, mutatesArtifact: false });
});
it.each(["before command", "during command"] as const)(
"rejects architecture facts when the admitted target changes %s",
async (phase) => {
const root = await createTestTempDirectory("rea-lipo-target-identity-");
const path = join(root, "fixture.macho");
const original = inspectionImage();
await writeFile(path, original);
const target = await nativeMachoTargetForFile(path);
const runner = new NativeFixtureRunner({
lipo: `Non-fat file: ${path} is architecture: arm64\n`,
});
const run = runner.run.bind(runner);
const capture = vi
.spyOn(runner, "run")
.mockImplementation(async (...args) => {
if (phase === "during command")
await writeFile(
path,
Buffer.concat([original, Buffer.from("changed")]),
);
return run(...args);
});
const client = new NativeMacOSProvider({}, runner, "darwin").createClient(
target,
);
try {
if (phase === "before command")
await writeFile(
path,
Buffer.concat([original, Buffer.from("changed")]),
);
const result = await client.execute("list_architectures", {});
if (result.ok) throw new Error("Expected target identity refusal");
expect(result.error).toMatchObject({
_tag: "AnalysisArtifactChangedError",
operation: "list_architectures",
});
expect(capture).toHaveBeenCalledTimes(phase === "before command" ? 0 : 1);
} finally {
await client.close();
}
},
);
+2 -2
View File
@@ -9,12 +9,12 @@ export const APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE = createAnalysisProfile(
{
id: NATIVE_MACOS_PROVIDER_IDENTITY.id,
name: NATIVE_MACOS_PROVIDER_IDENTITY.name,
version: "apple-metadata-reader-3",
version: "apple-metadata-reader-4",
},
{
operation: "inspect_native_dispatch_metadata",
reader: "apple-dispatch-metadata",
revision: 3,
revision: 4,
},
);
/** Operations whose target process can show UI, use the network and write files. */
@@ -0,0 +1,34 @@
import { expect, it } from "vitest";
import type { NativeCommandCapture } from "./CommandRunner.js";
import { parseDyldFileTypeRefusal } from "./NativeMachoCommandDiagnostics.js";
const capture = (stdout: string, stderr = ""): NativeCommandCapture => ({
tool: "dyld_info",
executable: "/usr/bin/dyld_info",
executableSha256: "a".repeat(64),
toolVersion: null,
versionReason: null,
arguments: ["-imports", "/selected/target"],
stdout,
stderr,
stdoutBytes: Buffer.byteLength(stdout),
stderrBytes: Buffer.byteLength(stderr),
exitCode: 1,
signal: null,
});
it.each([
["unknown filetype 3", "3"],
["unknown mh.filetype 4", "4"],
])("recognizes a sole dyld file-type refusal (%s)", (diagnostic, fileType) => {
expect(
parseDyldFileTypeRefusal(capture(`/selected/target:\n${diagnostic}\n`)),
).toEqual({ fileType, diagnostic });
});
it.each([
["unknown mh.filetype 4\nmalformed load command\n", ""],
["unknown mh.filetype 4\n", "read failed\n"],
])("keeps mixed dyld diagnostics unclassified (%s, %s)", (stdout, stderr) => {
expect(parseDyldFileTypeRefusal(capture(stdout, stderr))).toBeNull();
});
+5 -10
View File
@@ -91,22 +91,17 @@ export const isUnbackedTextDyldFailure = (
dyldFailureDiagnostic(capture) ===
"section '__TEXT/__text' has offset=0 but is not a zero-fill section type";
/** Reported CORE/FVMLIB refusals still require an independently matching header. */
/** A sole CORE/FVMLIB refusal still requires an independently matching header. */
export const parseDyldFileTypeRefusal = (
capture: NativeCommandCapture,
): {
readonly fileType: "3" | "4";
readonly diagnostic: string;
} | null => {
for (const line of dyldFailureDiagnostics(capture, [
capture.stdout,
capture.stderr,
])) {
const fileType = /^unknown (?:mh\.)?filetype ([34])$/u.exec(line)?.[1];
if (fileType === "3" || fileType === "4")
return { fileType, diagnostic: line };
}
return null;
const diagnostic = dyldFailureDiagnostic(capture);
if (diagnostic === null) return null;
const fileType = /^unknown (?:mh\.)?filetype ([34])$/u.exec(diagnostic)?.[1];
return fileType === "3" || fileType === "4" ? { fileType, diagnostic } : null;
};
/** Complete captures come from the settled native command owner. */
+2
View File
@@ -519,6 +519,7 @@ describe("Mach-O encryption reporting", () => {
container_file_offset: 32768,
size: 8192,
status: "absent",
malformed_kind: null,
reason: null,
commands: [],
range: null,
@@ -602,6 +603,7 @@ describe("Mach-O encryption reporting", () => {
slice({
container_file_offset: 49152,
status: "malformed",
malformed_kind: "structure",
reason: "load commands is truncated",
}),
],
+21 -10
View File
@@ -16,9 +16,16 @@ const control = vi.hoisted(() => ({
read: undefined as undefined | (() => Promise<never>),
}));
it.each([false, true])(
"retains verified inspection evidence through close retry (late cancellation: %s)",
async (cancelDuringClose) => {
it.each(
(["inspect_macho", "list_architectures"] as const).flatMap((operation) =>
[false, true].map((cancelDuringClose) => ({
operation,
cancelDuringClose,
})),
),
)(
"retains verified $operation evidence through close retry (late cancellation: $cancelDuringClose)",
async ({ operation, cancelDuringClose }) => {
const root = await createTestTempDirectory("rea-macho-completed-close-");
const path = join(root, "fixture.macho");
await writeFile(path, inspectionImage());
@@ -60,7 +67,7 @@ it.each([false, true])(
await client.close();
});
const result = await client.execute(
"inspect_macho",
operation,
{ native_command_timeout_ms: 5000 },
{ signal: controller.signal },
);
@@ -71,7 +78,7 @@ it.each([false, true])(
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupResources).toEqual([path]);
expect(result.error.partialObservation).toMatchObject({
operation: "inspect_macho",
operation,
parameters: { native_command_timeout_ms: 5000 },
subject: { local_path: path, digest: { sha256: target.sha256 } },
normalized_result: { provenance: expect.any(Array) },
@@ -108,9 +115,13 @@ it.each([false, true])(
},
);
it.each([false, true])(
"retains the producer outcome when cancellation arrives during successful close (producer fails: %s)",
async (producerFails) => {
it.each(
(["inspect_macho", "list_architectures"] as const).flatMap((operation) =>
[false, true].map((producerFails) => ({ operation, producerFails })),
),
)(
"retains the $operation producer outcome when cancellation arrives during successful close (producer fails: $producerFails)",
async ({ operation, producerFails }) => {
const root = await createTestTempDirectory("rea-macho-cancel-close-");
const path = join(root, "fixture.macho");
await writeFile(path, inspectionImage());
@@ -148,7 +159,7 @@ it.each([false, true])(
await client.close();
});
const result = await client.execute(
"inspect_macho",
operation,
{},
{ signal: controller.signal },
);
@@ -174,7 +185,7 @@ it.each([false, true])(
});
} else {
expect(result.error.partialObservation).toMatchObject({
operation: "inspect_macho",
operation,
subject: { local_path: path, digest: { sha256: target.sha256 } },
normalized_result: { provenance: expect.any(Array) },
});
+62
View File
@@ -0,0 +1,62 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import * as machoReader from "../../artifacts/apple/MachoLoadCommandReader.js";
import { inspectAppleIpsInputSchema } from "../../domain/native/appleIps.js";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { appleIpsBytes } from "../../../tests/fixtures/binaryDiagnostics/appleIps.js";
import { AppleIpsProvider } from "./AppleIpsProvider.js";
it("retains completed join facts when cancellation arrives during join parsing", async () => {
const root = await createTestTempDirectory("rea-ips-join-cancel-");
const path = join(root, "report.ips");
const joinPath = join(root, "image.bin");
await writeFile(path, appleIpsBytes());
const image = Buffer.alloc(32);
image.writeUInt32LE(0xfeedfacf, 0);
image.writeUInt32LE(0x0100000c, 4);
image.writeUInt32LE(2, 12);
await writeFile(joinPath, image);
const input = inspectAppleIpsInputSchema.parse({ path, join_path: joinPath });
const provider = new AppleIpsProvider();
const controller = new AbortController();
const reason = new Error("cancel while joining selected image");
const readContainer = machoReader.readMachoContainer;
const spy = vi
.spyOn(machoReader, "readMachoContainer")
.mockImplementationOnce(async (...arguments_) => {
const container = await readContainer(...arguments_);
controller.abort(reason);
return container;
});
try {
const cancelled = await provider.inspect(input, {
signal: controller.signal,
});
if (cancelled.ok) throw new Error("Expected cancellation during join");
expect(cancelled.error.cause).toBe(reason);
expect(cancelled.error).toMatchObject({
_tag: "AnalysisCancelledError",
cause: reason,
partialObservation: {
artifact: { path },
join: {
status: "selected",
path: joinPath,
slices: [{ arch: "arm64" }],
},
join_artifact: { path: joinPath },
},
});
const recovered = await provider.inspect(input);
if (!recovered.ok) throw recovered.error;
expect(recovered.value.join).toMatchObject({
status: "selected",
path: joinPath,
});
} finally {
spy.mockRestore();
await provider.close();
}
});
+4 -2
View File
@@ -112,7 +112,7 @@ export class AppleIpsProvider implements AppleIpsPort {
);
completedSnapshot = joinArtifact;
assertArtifactReadActive(options?.signal);
return ok({
completedReport = {
...report,
join: await resolveAppleIpsJoin(
report,
@@ -123,7 +123,9 @@ export class AppleIpsProvider implements AppleIpsPort {
},
read,
),
});
};
assertArtifactReadActive(options?.signal);
return ok(completedReport);
});
} catch (cause) {
const primary = appleIpsFailurePrimary(
@@ -1186,6 +1186,20 @@ it("keeps header rows when a slice has conflicting encryption commands", async (
});
});
it("keeps a later malformed encryption command distinct from duplicate ambiguity", async () => {
const bytes = machoHardeningFixture({
extraCommands: [
machoCommand(LC_ENCRYPTION_INFO_64, 24),
machoCommand(LC_ENCRYPTION_INFO_64, 24),
machoCommand(LC_ENCRYPTION_INFO_64, 8),
],
});
await expect(readMachoHardening(bytes)).rejects.toMatchObject({
kind: "malformed",
message: expect.stringContaining("cmdsize 8"),
});
});
it("keeps a parsed fat slice when another slice is empty or past EOF", async () => {
const arm = machoHardeningFixture();
const images = await readMachoHardening(
@@ -1414,6 +1428,32 @@ it("sizes an empty DT_GNU_HASH table only from an agreeing SHT_DYNSYM", () => {
});
});
it.each([32, 64] as const)(
"does not interpret %i-bit Mach-O debug symbol type bits as an import",
async (bits) => {
const bytes = machoHardeningFixture({ bits, stackChk: true });
let commandAt = bits === 64 ? 32 : 28;
let symbolAt: number | undefined;
for (let index = 0; index < bytes.readUInt32LE(16); index += 1) {
if (bytes.readUInt32LE(commandAt) === 2)
symbolAt = bytes.readUInt32LE(commandAt + 8);
commandAt += bytes.readUInt32LE(commandAt + 4);
}
if (symbolAt === undefined) throw new Error("Missing LC_SYMTAB");
expect(
findRow(
(await readMachoHardening(bytes))[0]?.rows ?? [],
"stack_protector",
).state,
).toBe("enabled");
// N_STAB takes precedence over ordinary N_TYPE and N_EXT interpretation.
bytes.writeUInt8(0x21, symbolAt + 4);
const image = (await readMachoHardening(bytes))[0];
expect(findRow(image?.rows ?? [], "stack_protector").state).toBe("unknown");
expect(findRow(image?.rows ?? [], "pie").state).toBe("enabled");
},
);
it("keeps an unresolved symbol count distinct from an absent table", () => {
const image = readElfHardening(
withEmptyGnuHash(elfHardeningFixture({ sections: false, stackChk: true })),
+8 -4
View File
@@ -343,7 +343,10 @@ const readCommandFacts = (
* wrong width, is structural and fails the slice before any row exists.
*/
const admitEncryption = (encrypted: MachoSliceEncryption): void => {
if (encrypted.status === "malformed" && encrypted.commands.length < 2)
if (
encrypted.status === "malformed" &&
encrypted.malformed_kind !== "duplicate-commands"
)
malformed(
encrypted.reason ?? "Mach-O encryption load command is malformed",
);
@@ -595,10 +598,11 @@ const stackRow = (
const entry = symbolAt + index * width;
const nameOffset = bytes.readUInt32LE(entry);
if (nameOffset === 0) continue;
// N_EXT with N_TYPE clear is an undefined external. A defined export of
// the same name, including libc itself, is not an import into this image.
// N_STAB records use a different type namespace. Only an ordinary N_EXT
// with N_TYPE clear is an undefined external; defined exports are not imports.
const nType = bytes.readUInt8(entry + 4);
if ((nType & 0x0e) !== 0 || (nType & 0x01) === 0) continue;
if ((nType & 0xe0) !== 0 || (nType & 0x0e) !== 0 || (nType & 0x01) === 0)
continue;
// A common definition shares those type bits but carries its size in
// n_value, so only a zero n_value names an import.
if (
+18 -2
View File
@@ -1656,6 +1656,7 @@ it("advertises Mach-O pages whose selected facet owns their exact rows", async (
size: 4096,
status: "absent",
reason: null,
malformed_kind: null,
commands: [],
range: null,
},
@@ -1663,10 +1664,21 @@ it("advertises Mach-O pages whose selected facet owns their exact rows", async (
provenance: [],
limitations: [],
});
const { malformed_kind: _unrecorded, ...recordedEncryption } =
report.encryption.items[0]!;
const historicalEncryption = {
...recordedEncryption,
status: "malformed" as const,
reason: "slice has 3 encryption load commands",
};
const parent = createEvidence(
{ path: "/fixtures/tool", format: "mach-o", sha256: "a".repeat(64) },
{ id: "native", name: "Native fixture", version: "1" },
{ operation: "inspect_macho", parameters: {}, result: report },
{
operation: "inspect_macho",
parameters: {},
result: { ...report, encryption: coverage([historicalEncryption]) },
},
);
const original = structuredClone(parent);
expect(session.recordEvidence(parent).ok).toBe(true);
@@ -1696,7 +1708,11 @@ it("advertises Mach-O pages whose selected facet owns their exact rows", async (
const delivered = parseEvidence(response.structuredContent);
const page = analysisViewResultSchema.parse(delivered.normalized_result);
if (page.kind !== "macho") throw new Error("Expected Mach-O page");
expect(page.item).toEqual(report[facet].items.slice(offset, offset + 64));
const rows =
facet === "encryption"
? [{ ...historicalEncryption, malformed_kind: "unrecorded" }]
: report[facet].items;
expect(page.item).toEqual(rows.slice(offset, offset + 64));
if (page.item.length > 0) {
const mismatched = {
...delivered,
@@ -30,6 +30,7 @@ import {
machoImage,
} from "../../../../src/artifacts/apple/MachoImage.fixture.js";
import { inspectionImage } from "../../../../src/native/NativeMachoInspection.fixture.js";
import { fat } from "../../../../src/domain/binaryTarget.fixture.js";
import {
inspectPlistSchema,
inspectSignatureSchema,
@@ -94,6 +95,13 @@ const inspectionFixture = async (high = false) => {
return nativeMachoTargetForFile(path);
};
const architectureTargetFixture = async () => {
const root = await createTestTempDirectory("rea-native-architectures-");
const path = join(root, "fixture");
await writeFile(path, fat([0x01000007, 0x0100000c]));
return nativeMachoTargetForFile(path);
};
const cleanupOwner = (runId: string): ProviderProcessSupervisor => {
const child = Object.assign(new EventEmitter(), {
stdout: new PassThrough(),
@@ -538,7 +546,7 @@ describe("native owned cancellation boundary", () => {
launcher,
);
const client = new NativeMacOSProvider({}, runner, "darwin").createClient(
machoTarget("/private/fixture"),
await architectureTargetFixture(),
);
const controller = new AbortController();
const cancellationReason = {
@@ -653,7 +661,7 @@ describe("native cancellation cleanup provenance", () => {
launcher,
);
const client = new NativeMacOSProvider({}, runner, "darwin").createClient(
machoTarget("/private/fixture"),
await architectureTargetFixture(),
);
const controllers = [new AbortController(), new AbortController()];
const cancellationReason = { source: "mixed native owner retry" };
@@ -790,7 +798,7 @@ describe("native returned cleanup ownership", () => {
launcher,
);
const client = new NativeMacOSProvider({}, runner, "darwin").createClient(
machoTarget("/private/fixture"),
await architectureTargetFixture(),
);
const controller = new AbortController();
const cancellationReason = { source: "native cleanup retry regression" };
@@ -934,7 +942,7 @@ describe("native late cancellation with cleanup", () => {
launcher,
);
const client = new NativeMacOSProvider({}, runner, "darwin").createClient(
machoTarget("/private/fixture"),
await architectureTargetFixture(),
);
try {
const pending = client.execute(
@@ -1130,7 +1138,7 @@ describe("native command cleanup lifecycle", () => {
},
};
const client = new NativeMacOSProvider({}, runner, "darwin").createClient(
machoTarget("/private/fixture"),
await architectureTargetFixture(),
);
const execution = client.execute("list_architectures", {});
@@ -1216,7 +1224,7 @@ describe("native command failure projection", () => {
retryCleanup: async () => failures,
};
const client = new NativeMacOSProvider({}, runner, "darwin").createClient(
machoTarget("/private/fixture"),
await architectureTargetFixture(),
);
try {
const result = await client.execute("list_architectures", {});
@@ -1268,7 +1276,7 @@ describe("native command failure projection", () => {
},
};
const result = await new NativeMacOSProvider({}, runner, "darwin")
.createClient(machoTarget("/private/fixture"))
.createClient(await architectureTargetFixture())
.execute("list_architectures", {});
expect(result.ok).toBe(false);
@@ -1309,7 +1317,7 @@ describe("native command failure projection", () => {
},
};
const result = await new NativeMacOSProvider({}, runner, "darwin")
.createClient(machoTarget("/private/fixture"))
.createClient(await architectureTargetFixture())
.execute("list_architectures", {});
expect(result.ok).toBe(false);
@@ -1429,7 +1437,7 @@ describe("native macOS provider inspection", () => {
const app = join(directory, "Fixture.app");
const executable = join(app, "Contents/MacOS/Fixture");
await mkdir(join(app, "Contents/MacOS"), { recursive: true });
await writeFile(executable, "fixture");
await writeFile(executable, fat([0x01000007, 0x0100000c]));
await writeFixturePlist(join(app, "Contents/Info.plist"));
const externalPlist = join(directory, "Outside.plist");
await writeFixturePlist(externalPlist);
@@ -1697,7 +1705,7 @@ describe("native macOS provider failures and parsing", () => {
{},
new FailingRunner("unavailable"),
"darwin",
).createClient(machoTarget("/fixture"));
).createClient(await architectureTargetFixture());
const unavailableResult = await unavailable.execute(
"list_architectures",
{},
@@ -1714,7 +1722,7 @@ describe("native macOS provider failures and parsing", () => {
{},
new FixtureRunner({ lipo: "malformed" }),
"darwin",
).createClient(machoTarget("/fixture"));
).createClient(await architectureTargetFixture());
const malformedResult = await malformed.execute("list_architectures", {});
expect(!malformedResult.ok && malformedResult.error._tag).toBe(
"AnalysisOutputError",
@@ -1728,7 +1736,7 @@ describe("native macOS provider failures and parsing", () => {
{},
new FailingRunner(reason),
"darwin",
).createClient(machoTarget("/fixture"));
).createClient(await architectureTargetFixture());
const result = await client.execute("list_architectures", {});
expect(!result.ok && result.error._tag).toBe(tag);
}