mirror of
https://github.com/morluto/rea.git
synced 2026-11-10 17:30:32 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac072ba228 | ||
|
|
12d2cacb0d | ||
|
|
d9344ffe17 | ||
|
|
fff18cc77f | ||
|
|
794260aef0 |
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]);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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 = (
|
||||
|
||||
@@ -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("/")) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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) },
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user