mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
fix(apple): validate physical slices and dylib platform compatibility
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MachOSliceArtifactReader } from "./MachOSliceArtifactReader.js";
|
||||
|
||||
describe("Mach-O slice reader with system lipo", () => {
|
||||
it.skipIf(process.platform !== "darwin")(
|
||||
"checks real /bin/ls lipo CPU symbols and ptrauth capabilities against the FAT table",
|
||||
async () => {
|
||||
const reader = new MachOSliceArtifactReader("/bin/ls");
|
||||
const entries = [];
|
||||
for await (const entry of reader.entries()) entries.push(entry);
|
||||
expect(entries.map(({ path }) => path)).toEqual([
|
||||
"slices/x86_64",
|
||||
"slices/arm64e",
|
||||
]);
|
||||
expect(
|
||||
entries.map(({ byteOffset, declaredSize }) => [
|
||||
byteOffset,
|
||||
declaredSize,
|
||||
]),
|
||||
).toEqual([
|
||||
[16384, 48112],
|
||||
[65536, 89088],
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { open as openFile, stat } from "node:fs/promises";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
import type { ArtifactCommand } from "../domain/artifactGraph.js";
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
XcrunCommandRunner,
|
||||
type NativeCommandRunner,
|
||||
} from "../native/CommandRunner.js";
|
||||
import { parseLipoArchitectures } from "../native/parsers/lipo.js";
|
||||
import {
|
||||
parseLipoArchitectures,
|
||||
type LipoArchitecture,
|
||||
} from "../native/parsers/lipo.js";
|
||||
import type { MachoSlice } from "../domain/apple/dylibResolution.js";
|
||||
import { readMachoImage, type ReadAt } from "./apple/MachoLoadCommandReader.js";
|
||||
import {
|
||||
ArtifactReaderFailure,
|
||||
type ArtifactEntry,
|
||||
@@ -46,19 +51,56 @@ export class MachOSliceArtifactReader implements ArtifactReader {
|
||||
effects: ["read"],
|
||||
};
|
||||
const fileSize = (await stat(this.path)).size;
|
||||
for (const architecture of parseLipoArchitectures(captured.value.stdout)) {
|
||||
if (architecture.file_offset === null || architecture.size === null)
|
||||
const architectures = parseLipoArchitectures(captured.value.stdout);
|
||||
const structural = await readStructuralSlices(this.path, fileSize);
|
||||
if (structural.status === "malformed")
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Mach-O structural slice index is malformed: ${structural.reason}`,
|
||||
);
|
||||
if (structural.status === "unsupported")
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`Mach-O structural slice index is unsupported: ${structural.reason}`,
|
||||
);
|
||||
const slices = structural.status === "parsed" ? structural.slices : null;
|
||||
const matchingSlices = new Map<LipoArchitecture, MachoSlice>();
|
||||
if (slices !== null) {
|
||||
if (slices.length !== architectures.length)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
"lipo architecture count disagrees with the Mach-O FAT table",
|
||||
);
|
||||
const unmatched = [...slices];
|
||||
for (const reported of architectures) {
|
||||
const match = unmatched.findIndex((observed) =>
|
||||
lipoMatchesSlice(reported, observed, slices.length === 1),
|
||||
);
|
||||
if (match < 0)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`lipo slice metadata disagrees with the Mach-O FAT table: ${reported.name} (cputype=${reported.cpu_type ?? "unknown"}, cpusubtype=${reported.cpu_subtype ?? "unknown"}, offset=${reported.file_offset ?? "unknown"}, size=${reported.size ?? "unknown"}, alignment=${reported.alignment ?? "unknown"})`,
|
||||
);
|
||||
const [observed] = unmatched.splice(match, 1);
|
||||
if (observed !== undefined) matchingSlices.set(reported, observed);
|
||||
}
|
||||
}
|
||||
for (const architecture of architectures) {
|
||||
const structuralSlice = matchingSlices.get(architecture);
|
||||
const offset = architecture.file_offset ?? structuralSlice?.slice_offset;
|
||||
const sliceSize = architecture.size ?? structuralSlice?.slice_size;
|
||||
if (offset === undefined || sliceSize === undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
"lipo omitted a universal slice byte range",
|
||||
);
|
||||
if (
|
||||
!Number.isSafeInteger(architecture.file_offset) ||
|
||||
!Number.isSafeInteger(architecture.size) ||
|
||||
architecture.file_offset < 0 ||
|
||||
architecture.size <= 0 ||
|
||||
!Number.isSafeInteger(architecture.file_offset + architecture.size) ||
|
||||
architecture.file_offset + architecture.size > fileSize
|
||||
!Number.isSafeInteger(offset) ||
|
||||
!Number.isSafeInteger(sliceSize) ||
|
||||
offset < 0 ||
|
||||
sliceSize <= 0 ||
|
||||
!Number.isSafeInteger(offset + sliceSize) ||
|
||||
offset + sliceSize > fileSize
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
@@ -67,15 +109,38 @@ export class MachOSliceArtifactReader implements ArtifactReader {
|
||||
yield {
|
||||
path: `slices/${architecture.name}`,
|
||||
kind: "slice",
|
||||
declaredSize: architecture.size,
|
||||
declaredSize: sliceSize,
|
||||
compressedSize: null,
|
||||
executable: true,
|
||||
encrypted: false,
|
||||
byteOffset: architecture.file_offset,
|
||||
byteOffset: offset,
|
||||
declaredSha256: null,
|
||||
unpacked: false,
|
||||
limitations: [],
|
||||
adapterKey: `${String(architecture.file_offset)}:${String(architecture.size)}`,
|
||||
limitations: [
|
||||
...(structural.status === "not-mach-o"
|
||||
? [
|
||||
"Structural Mach-O facts were unavailable; slice range is based on lipo output.",
|
||||
]
|
||||
: []),
|
||||
...(architecture.file_offset === null || architecture.size === null
|
||||
? [
|
||||
"Slice range was derived from structural Mach-O facts because lipo omitted it.",
|
||||
]
|
||||
: []),
|
||||
...(architecture.cpu_type !== null &&
|
||||
architecture.cpu_type_code === null
|
||||
? [
|
||||
`lipo CPU type is retained as unnormalized source text: ${architecture.cpu_type}`,
|
||||
]
|
||||
: []),
|
||||
...(architecture.cpu_subtype !== null &&
|
||||
architecture.cpu_subtype_code === null
|
||||
? [
|
||||
`lipo CPU subtype is retained as unnormalized source text: ${architecture.cpu_subtype}${architecture.capabilities === null ? "" : `; capabilities ${architecture.capabilities}`}`,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
adapterKey: `${String(offset)}:${String(sliceSize)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -157,6 +222,53 @@ export class MachOSliceArtifactReader implements ArtifactReader {
|
||||
}
|
||||
}
|
||||
|
||||
/** Compare lipo's independent ranges with structurally parsed slice identity. */
|
||||
const readStructuralSlices = async (
|
||||
path: string,
|
||||
size: number,
|
||||
): Promise<Awaited<ReturnType<typeof readMachoImage>>> => {
|
||||
const file = await openFile(path, "r");
|
||||
try {
|
||||
const readAt: ReadAt = async (offset, length) => {
|
||||
const bytes = Buffer.alloc(length);
|
||||
const { bytesRead } = await file.read(bytes, 0, length, offset);
|
||||
return bytes.subarray(0, bytesRead);
|
||||
};
|
||||
const facts = await readMachoImage(readAt, size);
|
||||
return facts;
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
};
|
||||
|
||||
const lipoMatchesSlice = (
|
||||
reported: LipoArchitecture,
|
||||
observed: MachoSlice,
|
||||
structurallyUnique: boolean,
|
||||
): boolean => {
|
||||
const observedCpuType = observed.fat_cpu_type ?? observed.cpu_type;
|
||||
const observedCpuSubtype = observed.fat_cpu_subtype ?? observed.cpu_subtype;
|
||||
const hasPhysicalIdentity =
|
||||
reported.file_offset !== null ||
|
||||
reported.size !== null ||
|
||||
reported.cpu_type_code !== null ||
|
||||
reported.cpu_subtype_code !== null;
|
||||
return (
|
||||
(hasPhysicalIdentity || structurallyUnique) &&
|
||||
reported.name === observed.architecture &&
|
||||
(reported.file_offset === null ||
|
||||
reported.file_offset === observed.slice_offset) &&
|
||||
(reported.size === null || reported.size === observed.slice_size) &&
|
||||
(reported.cpu_type_code === null ||
|
||||
reported.cpu_type_code === observedCpuType) &&
|
||||
(reported.cpu_subtype_code === null ||
|
||||
reported.cpu_subtype_code === observedCpuSubtype) &&
|
||||
(reported.alignment === null ||
|
||||
observed.fat_alignment_exponent === null ||
|
||||
reported.alignment === 2 ** observed.fat_alignment_exponent)
|
||||
);
|
||||
};
|
||||
|
||||
const parseSliceKeyInteger = (value: string | undefined): number | null => {
|
||||
if (value === undefined || !/^\d+$/u.test(value)) return null;
|
||||
const parsed = Number(value);
|
||||
|
||||
@@ -10,6 +10,7 @@ export const LC = {
|
||||
RPATH: 0x8000001c,
|
||||
DYLD_ENVIRONMENT: 0x27,
|
||||
CODE_SIGNATURE: 0x1d,
|
||||
BUILD_VERSION: 0x32,
|
||||
} as const;
|
||||
|
||||
export const CPU = {
|
||||
@@ -75,6 +76,16 @@ export const codeSignatureCommand = (): Uint8Array => {
|
||||
return bytes;
|
||||
};
|
||||
|
||||
/** `build_version_command` carrying the producer's raw platform number. */
|
||||
export const buildVersionCommand = (platform: number): Uint8Array => {
|
||||
const bytes = new Uint8Array(24);
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint32(0, LC.BUILD_VERSION, true);
|
||||
view.setUint32(4, bytes.length, true);
|
||||
view.setUint32(8, platform, true);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
/** A 64-bit (or 32-bit) little-endian Mach-O image made of the given commands. */
|
||||
export const machoImage = (options: {
|
||||
readonly cpu?: { readonly type: number; readonly subtype: number };
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
FILE_TYPE,
|
||||
LC,
|
||||
codeSignatureCommand,
|
||||
buildVersionCommand,
|
||||
dyldEnvironmentCommand,
|
||||
dylibCommand,
|
||||
dylibUseCommand,
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
const read = (bytes: Uint8Array) =>
|
||||
readMachoImage(readerOf(bytes), bytes.length);
|
||||
|
||||
describe("Mach-O load command reader", () => {
|
||||
describe("Mach-O load command reader: decodes thin-image load commands", () => {
|
||||
it("decodes dylib loading commands of a thin image", async () => {
|
||||
const image = machoImage({
|
||||
fileType: FILE_TYPE.dylib,
|
||||
@@ -124,7 +125,9 @@ describe("Mach-O load command reader", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mach-O load command reader: reads and validates universal binary slices", () => {
|
||||
it("reads every slice of FAT and FAT_64 containers in either byte order", async () => {
|
||||
for (const [wide, littleEndian] of [
|
||||
[false, false],
|
||||
@@ -163,6 +166,63 @@ describe("Mach-O load command reader", () => {
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("retains physical slice identity and declared build platform", async () => {
|
||||
const thin = machoImage({ commands: [buildVersionCommand(7)] });
|
||||
const parsedThin = await read(thin);
|
||||
expect(
|
||||
parsedThin.status === "parsed" && parsedThin.slices[0],
|
||||
).toMatchObject({
|
||||
slice_offset: 0,
|
||||
slice_size: thin.length,
|
||||
cpu_type: CPU.arm64.type,
|
||||
cpu_subtype: CPU.arm64.subtype,
|
||||
fat_cpu_type: null,
|
||||
fat_cpu_subtype: null,
|
||||
platform: 7,
|
||||
});
|
||||
const fat = fatImage([{ cpu: CPU.arm64, bytes: thin }]);
|
||||
const parsedFat = await read(fat);
|
||||
expect(parsedFat.status === "parsed" && parsedFat.slices[0]).toMatchObject({
|
||||
slice_offset: 4096,
|
||||
slice_size: thin.length,
|
||||
fat_cpu_type: CPU.arm64.type,
|
||||
fat_cpu_subtype: CPU.arm64.subtype,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects FAT table/header identity mismatches and unaligned ranges", async () => {
|
||||
const original = fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }]);
|
||||
for (const [fieldOffset, value] of [
|
||||
[8, CPU.x86_64.type],
|
||||
[12, 99],
|
||||
] as const) {
|
||||
const mismatch = original.slice();
|
||||
new DataView(mismatch.buffer).setUint32(fieldOffset, value, false);
|
||||
expect(await read(mismatch)).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("disagrees"),
|
||||
});
|
||||
}
|
||||
const unaligned = original.slice();
|
||||
new DataView(unaligned.buffer).setUint32(16, 4097, false);
|
||||
expect(await read(unaligned)).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("not aligned"),
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves unknown CPU table facts when the embedded slice is Mach-O", async () => {
|
||||
const cpu = { type: 0x7fffffff, subtype: 11 };
|
||||
const facts = await read(fatImage([{ cpu, bytes: machoImage({ cpu }) }]));
|
||||
expect(facts.status === "parsed" && facts.slices[0]).toMatchObject({
|
||||
architecture: "cpu-7fffffff",
|
||||
cpu_type: cpu.type,
|
||||
cpu_subtype: cpu.subtype,
|
||||
fat_cpu_type: cpu.type,
|
||||
fat_cpu_subtype: cpu.subtype,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mach-O load command reader inputs", () => {
|
||||
@@ -314,7 +374,7 @@ describe("Mach-O load command reader failures", () => {
|
||||
const fat = fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }]);
|
||||
expect(await read(fat.subarray(0, 4100))).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: "FAT architecture 0 extends beyond the file",
|
||||
reason: "FAT architecture 0 slice range extends beyond the file",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,11 @@ import type {
|
||||
MachoImageFacts,
|
||||
MachoSlice,
|
||||
} from "../../domain/apple/dylibResolution.js";
|
||||
import {
|
||||
readFatSliceDeclarations,
|
||||
validateFatSlice,
|
||||
type FatSliceDeclaration,
|
||||
} from "../../domain/apple/machoContainer.js";
|
||||
|
||||
/** Read `length` bytes at `offset`; shorter results mean end of file. */
|
||||
export type ReadAt = (offset: number, length: number) => Promise<Uint8Array>;
|
||||
@@ -34,6 +39,11 @@ const LC_LOAD_UPWARD_DYLIB = 0x80000023;
|
||||
const LC_RPATH = 0x8000001c;
|
||||
const LC_DYLD_ENVIRONMENT = 0x27;
|
||||
const LC_CODE_SIGNATURE = 0x1d;
|
||||
const LC_VERSION_MIN_MACOSX = 0x24;
|
||||
const LC_VERSION_MIN_IPHONEOS = 0x25;
|
||||
const LC_VERSION_MIN_TVOS = 0x2f;
|
||||
const LC_VERSION_MIN_WATCHOS = 0x30;
|
||||
const LC_BUILD_VERSION = 0x32;
|
||||
|
||||
/** `mach-o/loader.h` `dylib_use_command`: `nameoff == 28` and this marker. */
|
||||
const DYLIB_USE_MARKER = 0x1a741800;
|
||||
@@ -109,22 +119,55 @@ export const readMachoImage = async (
|
||||
};
|
||||
if (count > MAX_FAT_ARCHITECTURES || 8 + count * (wide ? 32 : 20) > size)
|
||||
return { status: "not-mach-o" };
|
||||
const table = viewOf(
|
||||
await readExact(readAt, 8, count * (wide ? 32 : 20), "FAT table"),
|
||||
const tableBytes = await readExact(
|
||||
readAt,
|
||||
8,
|
||||
count * (wide ? 32 : 20),
|
||||
"FAT table",
|
||||
);
|
||||
const declarations = readFatSliceDeclarations(tableBytes, {
|
||||
count,
|
||||
wide,
|
||||
littleEndian,
|
||||
});
|
||||
if (declarations.status !== "parsed")
|
||||
throw new MachoFormatIssue("malformed", declarations.reason);
|
||||
// A Java class file's version fields read as a FAT count; its "table" names no CPU.
|
||||
const cpuTypes = Array.from({ length: count }, (_, index) =>
|
||||
table.getUint32(index * (wide ? 32 : 20), littleEndian),
|
||||
);
|
||||
if (!cpuTypes.some((type) => KNOWN_CPU_TYPES.includes(type)))
|
||||
return { status: "not-mach-o" };
|
||||
if (
|
||||
!declarations.slices.some(({ cpuType }) =>
|
||||
KNOWN_CPU_TYPES.includes(cpuType),
|
||||
)
|
||||
) {
|
||||
let hasMachOSliceHeader = false;
|
||||
for (const declaration of declarations.slices) {
|
||||
if (
|
||||
validateFatSlice(
|
||||
declaration,
|
||||
size,
|
||||
8 + count * (wide ? 32 : 20),
|
||||
) !== null
|
||||
)
|
||||
continue;
|
||||
const bytes = await readAt(declaration.offset, 4);
|
||||
if (bytes.byteLength < 4) continue;
|
||||
const sliceMagic = viewOf(bytes).getUint32(0, false);
|
||||
if (
|
||||
[MH_MAGIC, MH_MAGIC_64, MH_CIGAM, MH_CIGAM_64].includes(sliceMagic)
|
||||
) {
|
||||
hasMachOSliceHeader = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasMachOSliceHeader) return { status: "not-mach-o" };
|
||||
}
|
||||
return {
|
||||
status: "parsed",
|
||||
slices: await readFatSlices(readAt, size, {
|
||||
wide,
|
||||
littleEndian,
|
||||
count,
|
||||
}),
|
||||
slices: await readFatSlices(
|
||||
readAt,
|
||||
size,
|
||||
8 + count * (wide ? 32 : 20),
|
||||
declarations.slices,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (magic === MH_CIGAM || magic === MH_CIGAM_64)
|
||||
@@ -145,32 +188,30 @@ export const readMachoImage = async (
|
||||
const readFatSlices = async (
|
||||
readAt: ReadAt,
|
||||
size: number,
|
||||
table: {
|
||||
readonly wide: boolean;
|
||||
readonly littleEndian: boolean;
|
||||
readonly count: number;
|
||||
},
|
||||
tableEnd: number,
|
||||
declarations: readonly FatSliceDeclaration[],
|
||||
): Promise<MachoSlice[]> => {
|
||||
const { wide, littleEndian, count } = table;
|
||||
const entrySize = wide ? 32 : 20;
|
||||
const view = viewOf(
|
||||
await readExact(readAt, 8, count * entrySize, "FAT table"),
|
||||
);
|
||||
const slices: MachoSlice[] = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
const base = index * entrySize;
|
||||
const offset = wide
|
||||
? safeNumber(view.getBigUint64(base + 8, littleEndian))
|
||||
: view.getUint32(base + 8, littleEndian);
|
||||
const length = wide
|
||||
? safeNumber(view.getBigUint64(base + 16, littleEndian))
|
||||
: view.getUint32(base + 12, littleEndian);
|
||||
if (offset + length > size)
|
||||
for (const declaration of declarations) {
|
||||
const { index, offset, size: length, cpuType, cpuSubtype } = declaration;
|
||||
const rangeIssue = validateFatSlice(declaration, size, tableEnd);
|
||||
if (rangeIssue !== null)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`FAT architecture ${index} extends beyond the file`,
|
||||
rangeIssue.includes("unsupported") ? "unsupported" : "malformed",
|
||||
rangeIssue,
|
||||
);
|
||||
slices.push(await readSlice(readAt, offset, length));
|
||||
const slice = await readSlice(readAt, offset, length, {
|
||||
fatCpuType: cpuType,
|
||||
fatCpuSubtype: cpuSubtype,
|
||||
alignmentExponent: declaration.alignmentExponent,
|
||||
});
|
||||
const identityIssue = validateFatSlice(declaration, size, tableEnd, {
|
||||
cpuType: slice.cpu_type,
|
||||
cpuSubtype: slice.cpu_subtype,
|
||||
});
|
||||
if (identityIssue !== null)
|
||||
throw new MachoFormatIssue("malformed", identityIssue);
|
||||
slices.push(slice);
|
||||
}
|
||||
return slices;
|
||||
};
|
||||
@@ -179,6 +220,11 @@ const readSlice = async (
|
||||
readAt: ReadAt,
|
||||
base: number,
|
||||
length: number,
|
||||
fat: {
|
||||
readonly fatCpuType: number;
|
||||
readonly fatCpuSubtype: number;
|
||||
readonly alignmentExponent: number;
|
||||
} | null = null,
|
||||
): Promise<MachoSlice> => {
|
||||
const header = viewOf(await readExact(readAt, base, 28, "Mach-O header"));
|
||||
const magic = header.getUint32(0, true);
|
||||
@@ -203,13 +249,22 @@ const readSlice = async (
|
||||
commandBytes,
|
||||
"load commands",
|
||||
);
|
||||
return decodeCommands(commands, {
|
||||
const slice = decodeCommands(commands, {
|
||||
wide,
|
||||
cpuType: header.getUint32(4, true),
|
||||
cpuSubtype: header.getUint32(8, true),
|
||||
fileType: header.getUint32(12, true),
|
||||
commandCount,
|
||||
sliceOffset: base,
|
||||
sliceSize: length,
|
||||
fatCpuType: fat?.fatCpuType ?? null,
|
||||
fatCpuSubtype: fat?.fatCpuSubtype ?? null,
|
||||
fatAlignmentExponent: fat?.alignmentExponent ?? null,
|
||||
});
|
||||
const distinctPlatforms = [...new Set(slice.platforms)];
|
||||
slice.platform =
|
||||
distinctPlatforms.length === 1 ? (distinctPlatforms[0] ?? null) : null;
|
||||
return slice;
|
||||
}
|
||||
throw new MachoFormatIssue(
|
||||
magic === swap(MH_MAGIC) || magic === swap(MH_MAGIC_64)
|
||||
@@ -229,6 +284,11 @@ const decodeCommands = (
|
||||
readonly cpuSubtype: number;
|
||||
readonly fileType: number;
|
||||
readonly commandCount: number;
|
||||
readonly sliceOffset: number;
|
||||
readonly sliceSize: number;
|
||||
readonly fatCpuType: number | null;
|
||||
readonly fatCpuSubtype: number | null;
|
||||
readonly fatAlignmentExponent: number | null;
|
||||
},
|
||||
): MachoSlice => {
|
||||
// loader.h: load commands are 8-byte aligned in 64-bit images, 4 in 32-bit.
|
||||
@@ -236,7 +296,17 @@ const decodeCommands = (
|
||||
const view = viewOf(bytes);
|
||||
const slice: MachoSlice = {
|
||||
architecture: architectureName(header.cpuType, header.cpuSubtype),
|
||||
slice_offset: header.sliceOffset,
|
||||
slice_size: header.sliceSize,
|
||||
cpu_type: header.cpuType,
|
||||
cpu_subtype: header.cpuSubtype,
|
||||
fat_cpu_type: header.fatCpuType,
|
||||
fat_cpu_subtype: header.fatCpuSubtype,
|
||||
fat_alignment_exponent: header.fatAlignmentExponent,
|
||||
file_type: fileTypeName(header.fileType),
|
||||
file_type_code: header.fileType,
|
||||
platform: null,
|
||||
platforms: [],
|
||||
install_name: null,
|
||||
dependencies: [],
|
||||
rpaths: [],
|
||||
@@ -292,6 +362,31 @@ const decodeCommand = (
|
||||
slice.code_signature_present = true;
|
||||
return;
|
||||
}
|
||||
if (command === LC_BUILD_VERSION) {
|
||||
if (body.byteLength < 24)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} is too short for LC_BUILD_VERSION`,
|
||||
);
|
||||
const platform = viewOf(body).getUint32(8, true);
|
||||
slice.platforms.push(platform);
|
||||
return;
|
||||
}
|
||||
const legacyPlatform = new Map<number, number>([
|
||||
[LC_VERSION_MIN_MACOSX, 1],
|
||||
[LC_VERSION_MIN_IPHONEOS, 2],
|
||||
[LC_VERSION_MIN_TVOS, 3],
|
||||
[LC_VERSION_MIN_WATCHOS, 4],
|
||||
]).get(command);
|
||||
if (legacyPlatform !== undefined) {
|
||||
if (body.byteLength < 16)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} is too short for a version-min command`,
|
||||
);
|
||||
slice.platforms.push(legacyPlatform);
|
||||
return;
|
||||
}
|
||||
const name = DEPENDENCY_COMMANDS.get(command);
|
||||
if (name !== undefined)
|
||||
slice.dependencies.push(decodeDependency(name, body, index));
|
||||
@@ -416,11 +511,5 @@ const swap = (value: number): number =>
|
||||
(value >>> 24)) >>>
|
||||
0;
|
||||
|
||||
const safeNumber = (value: bigint): number => {
|
||||
if (value > BigInt(Number.MAX_SAFE_INTEGER))
|
||||
throw new MachoFormatIssue("malformed", "FAT offset exceeds safe range");
|
||||
return Number(value);
|
||||
};
|
||||
|
||||
const viewOf = (bytes: Uint8Array): DataView =>
|
||||
new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
|
||||
@@ -11,7 +11,23 @@ import {
|
||||
|
||||
const slice = (overrides: Partial<MachoSlice> = {}): MachoSlice => ({
|
||||
architecture: "arm64",
|
||||
file_type: "dylib",
|
||||
slice_offset: 0,
|
||||
slice_size: 32,
|
||||
cpu_type: 0x0100000c,
|
||||
cpu_subtype: 0,
|
||||
fat_cpu_type: null,
|
||||
fat_cpu_subtype: null,
|
||||
fat_alignment_exponent: null,
|
||||
file_type: overrides.file_type ?? "dylib",
|
||||
file_type_code:
|
||||
overrides.file_type_code ??
|
||||
{ execute: 2, dylib: 6, bundle: 8, other: 1 }[
|
||||
overrides.file_type ?? "dylib"
|
||||
],
|
||||
platform: overrides.platform ?? 1,
|
||||
platforms:
|
||||
overrides.platforms ??
|
||||
(overrides.platform === null ? [] : [overrides.platform ?? 1]),
|
||||
install_name: null,
|
||||
dependencies: [],
|
||||
rpaths: [],
|
||||
@@ -234,7 +250,7 @@ describe("dyld path expansion", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("dyld resolution outcomes", () => {
|
||||
describe("dyld resolution outcomes: deriving path and weak-load outcomes", () => {
|
||||
it("keeps paths outside the root undetermined and in-root fallbacks conditional", async () => {
|
||||
const trace = await traceDylibLoading(
|
||||
memoryView({
|
||||
@@ -308,7 +324,9 @@ describe("dyld resolution outcomes", () => {
|
||||
"Contents/MacOS/../Overrides/libfound.dylib",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dyld resolution outcomes: checking candidate format and compatibility", () => {
|
||||
it("reports non-Mach-O, malformed and wrong-architecture candidates", async () => {
|
||||
const trace = await traceDylibLoading(
|
||||
memoryView({
|
||||
@@ -345,6 +363,101 @@ describe("dyld resolution outcomes", () => {
|
||||
"Contents/MacOS/c/lib.dylib",
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips non-loadable and wrong-platform rpath candidates", async () => {
|
||||
const trace = await traceDylibLoading(
|
||||
memoryView({
|
||||
[MAIN]: executable({
|
||||
rpaths: [
|
||||
"@executable_path/a",
|
||||
"@executable_path/b",
|
||||
"@executable_path/c",
|
||||
],
|
||||
dependencies: [dependency("@rpath/lib.dylib")],
|
||||
}),
|
||||
"Contents/MacOS/a/lib.dylib": parsed(
|
||||
slice({ file_type: "other", file_type_code: 1 }),
|
||||
),
|
||||
"Contents/MacOS/b/lib.dylib": parsed(slice({ platform: 7 })),
|
||||
"Contents/MacOS/c/lib.dylib": parsed(slice()),
|
||||
}),
|
||||
{ roots: [MAIN] },
|
||||
);
|
||||
const edge = edgeFor(trace, MAIN, "@rpath/lib.dylib");
|
||||
expect(edge.candidates.map(({ outcome }) => outcome)).toEqual([
|
||||
"not-loadable",
|
||||
"platform-mismatch",
|
||||
"resolved",
|
||||
]);
|
||||
expect(edge.resolution).toEqual({
|
||||
status: "resolved",
|
||||
image: "Contents/MacOS/c/lib.dylib",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves unknown platform compatibility before a later known candidate", async () => {
|
||||
const trace = await traceDylibLoading(
|
||||
memoryView({
|
||||
[MAIN]: executable({
|
||||
rpaths: ["@executable_path/a", "@executable_path/b"],
|
||||
dependencies: [dependency("@rpath/lib.dylib")],
|
||||
}),
|
||||
"Contents/MacOS/a/lib.dylib": parsed(slice({ platform: null })),
|
||||
"Contents/MacOS/b/lib.dylib": parsed(slice()),
|
||||
}),
|
||||
{ roots: [MAIN] },
|
||||
);
|
||||
const edge = edgeFor(trace, MAIN, "@rpath/lib.dylib");
|
||||
expect(edge.candidates.map(({ outcome }) => outcome)).toEqual([
|
||||
"undetermined",
|
||||
"resolved",
|
||||
]);
|
||||
expect(edge.resolution.status).toBe("conditional");
|
||||
});
|
||||
|
||||
it("applies dyld Catalyst and zippered platform compatibility", async () => {
|
||||
const catalystProcess = await traceDylibLoading(
|
||||
memoryView({
|
||||
[MAIN]: executable({
|
||||
platform: 6,
|
||||
platforms: [6],
|
||||
rpaths: ["@executable_path/catalyst"],
|
||||
dependencies: [dependency("@rpath/lib.dylib")],
|
||||
}),
|
||||
"Contents/MacOS/catalyst/lib.dylib": parsed(
|
||||
slice({ platform: 1, platforms: [1] }),
|
||||
),
|
||||
}),
|
||||
{ roots: [MAIN] },
|
||||
);
|
||||
expect(
|
||||
edgeFor(catalystProcess, MAIN, "@rpath/lib.dylib").resolution,
|
||||
).toEqual({
|
||||
status: "resolved",
|
||||
image: "Contents/MacOS/catalyst/lib.dylib",
|
||||
});
|
||||
|
||||
const zipperedImage = await traceDylibLoading(
|
||||
memoryView({
|
||||
[MAIN]: executable({
|
||||
platform: 1,
|
||||
platforms: [1],
|
||||
rpaths: ["@executable_path/zippered"],
|
||||
dependencies: [dependency("@rpath/lib.dylib")],
|
||||
}),
|
||||
"Contents/MacOS/zippered/lib.dylib": parsed(
|
||||
slice({ platform: null, platforms: [1, 6] }),
|
||||
),
|
||||
}),
|
||||
{ roots: [MAIN] },
|
||||
);
|
||||
expect(
|
||||
edgeFor(zipperedImage, MAIN, "@rpath/lib.dylib").resolution,
|
||||
).toMatchObject({
|
||||
status: "resolved",
|
||||
image: "Contents/MacOS/zippered/lib.dylib",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("dyld load order", () => {
|
||||
|
||||
@@ -48,7 +48,20 @@ const dependencySchema = z.strictObject({
|
||||
/** Observed dylib-loading facts of one Mach-O slice. */
|
||||
export const machoSliceSchema = z.strictObject({
|
||||
architecture: z.string().min(1),
|
||||
/** Physical identity observed in the thin header and, for FAT, its table. */
|
||||
slice_offset: z.number().int().nonnegative(),
|
||||
slice_size: z.number().int().positive(),
|
||||
cpu_type: z.number().int().nonnegative(),
|
||||
cpu_subtype: z.number().int().nonnegative(),
|
||||
fat_cpu_type: z.number().int().nonnegative().nullable(),
|
||||
fat_cpu_subtype: z.number().int().nonnegative().nullable(),
|
||||
fat_alignment_exponent: z.number().int().nonnegative().nullable(),
|
||||
file_type: z.enum(["execute", "dylib", "bundle", "other"]),
|
||||
file_type_code: z.number().int().nonnegative(),
|
||||
/** Raw LC_BUILD_VERSION platform number; null means no such command was observed. */
|
||||
platform: z.number().int().nonnegative().nullable(),
|
||||
/** Every distinct build platform declared by LC_BUILD_VERSION/version-min commands. */
|
||||
platforms: z.array(z.number().int().nonnegative()),
|
||||
install_name: z.string().nullable(),
|
||||
dependencies: z.array(dependencySchema),
|
||||
rpaths: z.array(z.string()),
|
||||
@@ -97,6 +110,8 @@ const candidateSchema = z.strictObject({
|
||||
"malformed",
|
||||
"unsupported",
|
||||
"architecture-missing",
|
||||
"not-loadable",
|
||||
"platform-mismatch",
|
||||
"outside-target",
|
||||
"escapes-target",
|
||||
"undetermined",
|
||||
@@ -192,6 +207,7 @@ interface ProcessContext {
|
||||
readonly view: DylibTreeView;
|
||||
readonly root: string;
|
||||
readonly architecture: string;
|
||||
readonly platforms: readonly number[];
|
||||
readonly executable: string | null;
|
||||
readonly loaded: Map<string, LoadedImage>;
|
||||
readonly byInstallName: Map<string, string>;
|
||||
@@ -269,15 +285,90 @@ const evaluateCandidate = async (
|
||||
if (lookup.kind !== "file")
|
||||
return { ...base, outcome: "absent", resolved_path: null };
|
||||
const facts = await imageFacts(context, lookup.path);
|
||||
const outcome: Candidate["outcome"] =
|
||||
facts.status !== "parsed"
|
||||
? facts.status
|
||||
: compatibleSlice(facts.slices, context.architecture) === undefined
|
||||
? "architecture-missing"
|
||||
: "resolved";
|
||||
let outcome: Candidate["outcome"];
|
||||
if (facts.status !== "parsed") outcome = facts.status;
|
||||
else {
|
||||
const slice = compatibleSlice(facts.slices, context.architecture);
|
||||
if (slice === undefined) outcome = "architecture-missing";
|
||||
else if (![2, 6, 7, 8].includes(slice.file_type_code))
|
||||
outcome = "not-loadable";
|
||||
else {
|
||||
const platformResult = platformLoadability(
|
||||
context.platforms,
|
||||
slice.platforms,
|
||||
slice.file_type_code,
|
||||
context.architecture,
|
||||
);
|
||||
outcome =
|
||||
platformResult === "compatible"
|
||||
? "resolved"
|
||||
: platformResult === "incompatible"
|
||||
? "platform-mismatch"
|
||||
: "undetermined";
|
||||
}
|
||||
}
|
||||
return { ...base, outcome, resolved_path: lookup.path };
|
||||
};
|
||||
|
||||
type PlatformLoadability = "compatible" | "incompatible" | "unknown";
|
||||
|
||||
/** Apply dyld's documented cross-platform load cases and retain uncertain host cases. */
|
||||
const platformLoadability = (
|
||||
processPlatforms: readonly number[],
|
||||
imagePlatforms: readonly number[],
|
||||
fileType: number,
|
||||
architecture: string,
|
||||
): PlatformLoadability => {
|
||||
if (processPlatforms.length === 0 || imagePlatforms.length === 0)
|
||||
return "unknown";
|
||||
const outcomes = processPlatforms.map((processPlatform) =>
|
||||
imageLoadabilityForPlatform(
|
||||
processPlatform,
|
||||
imagePlatforms,
|
||||
fileType,
|
||||
architecture,
|
||||
),
|
||||
);
|
||||
if (outcomes.every((outcome) => outcome === "compatible"))
|
||||
return "compatible";
|
||||
if (outcomes.every((outcome) => outcome === "incompatible"))
|
||||
return "incompatible";
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
const imageLoadabilityForPlatform = (
|
||||
processPlatform: number,
|
||||
imagePlatforms: readonly number[],
|
||||
fileType: number,
|
||||
architecture: string,
|
||||
): PlatformLoadability => {
|
||||
if (imagePlatforms.includes(processPlatform)) return "compatible";
|
||||
|
||||
// These are explicit dyld loadableIntoProcess cross-platform cases.
|
||||
if (processPlatform === 6 && imagePlatforms.includes(1)) return "compatible"; // macOS dylibs in Mac Catalyst processes
|
||||
if (processPlatform === 2 && imagePlatforms.includes(6)) return "compatible"; // Catalyst dylibs in iOS processes
|
||||
if (processPlatform === 2 && imagePlatforms.includes(11)) return "compatible"; // visionOS dylibs in iOS processes
|
||||
if (processPlatform === 7 && imagePlatforms.includes(12)) return "compatible"; // visionOS simulator dylibs in iOS simulator processes
|
||||
if (processPlatform === 1 && fileType === 2 && imagePlatforms.includes(6))
|
||||
return "compatible"; // Catalyst main executables run on macOS
|
||||
|
||||
// dyld's remaining exceptions depend on host architecture or a special path.
|
||||
if (
|
||||
(processPlatform === 2 &&
|
||||
imagePlatforms.includes(1) &&
|
||||
architecture.startsWith("arm64")) ||
|
||||
([7, 8, 9].includes(processPlatform) && imagePlatforms.includes(1))
|
||||
)
|
||||
return "unknown";
|
||||
|
||||
if (isKnownPlatform(processPlatform) && imagePlatforms.every(isKnownPlatform))
|
||||
return "incompatible";
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
const isKnownPlatform = (platform: number): boolean =>
|
||||
platform >= 1 && platform <= 12;
|
||||
|
||||
const imageFacts = async (
|
||||
context: ProcessContext,
|
||||
path: string,
|
||||
@@ -455,6 +546,7 @@ export const traceDylibLoading = async (
|
||||
view,
|
||||
root,
|
||||
architecture: slice.architecture,
|
||||
platforms: slice.platforms,
|
||||
executable: slice.file_type === "execute" ? root : null,
|
||||
loaded: new Map(),
|
||||
byInstallName: new Map(),
|
||||
@@ -503,7 +595,17 @@ const withoutDependencies = (
|
||||
slice: MachoSlice,
|
||||
): Omit<MachoSlice, "dependencies"> => ({
|
||||
architecture: slice.architecture,
|
||||
slice_offset: slice.slice_offset,
|
||||
slice_size: slice.slice_size,
|
||||
cpu_type: slice.cpu_type,
|
||||
cpu_subtype: slice.cpu_subtype,
|
||||
fat_cpu_type: slice.fat_cpu_type,
|
||||
fat_cpu_subtype: slice.fat_cpu_subtype,
|
||||
fat_alignment_exponent: slice.fat_alignment_exponent,
|
||||
file_type: slice.file_type,
|
||||
file_type_code: slice.file_type_code,
|
||||
platform: slice.platform,
|
||||
platforms: slice.platforms,
|
||||
install_name: slice.install_name,
|
||||
rpaths: slice.rpaths,
|
||||
dyld_environment: slice.dyld_environment,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/** Physical facts for one entry in a Mach-O FAT architecture table. */
|
||||
export interface FatSliceDeclaration {
|
||||
readonly index: number;
|
||||
readonly offset: number;
|
||||
readonly size: number;
|
||||
readonly alignmentExponent: number;
|
||||
readonly cpuType: number;
|
||||
readonly cpuSubtype: number;
|
||||
}
|
||||
|
||||
/** Decode FAT table entries once so artifact and native readers share identity facts. */
|
||||
export const readFatSliceDeclarations = (
|
||||
tableBytes: Uint8Array,
|
||||
options: {
|
||||
readonly count: number;
|
||||
readonly wide: boolean;
|
||||
readonly littleEndian: boolean;
|
||||
},
|
||||
):
|
||||
| {
|
||||
readonly status: "parsed";
|
||||
readonly slices: readonly FatSliceDeclaration[];
|
||||
}
|
||||
| { readonly status: "malformed"; readonly reason: string } => {
|
||||
const { count, wide, littleEndian } = options;
|
||||
const entrySize = wide ? 32 : 20;
|
||||
if (tableBytes.byteLength < count * entrySize)
|
||||
return { status: "malformed", reason: "FAT table is truncated" };
|
||||
const view = new DataView(
|
||||
tableBytes.buffer,
|
||||
tableBytes.byteOffset,
|
||||
tableBytes.byteLength,
|
||||
);
|
||||
const slices: FatSliceDeclaration[] = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
const base = index * entrySize;
|
||||
const offset = wide
|
||||
? safeNumber(view.getBigUint64(base + 8, littleEndian))
|
||||
: view.getUint32(base + 8, littleEndian);
|
||||
const size = wide
|
||||
? safeNumber(view.getBigUint64(base + 16, littleEndian))
|
||||
: view.getUint32(base + 12, littleEndian);
|
||||
if (offset === null || size === null)
|
||||
return {
|
||||
status: "malformed",
|
||||
reason: "FAT offset or size exceeds the safe range",
|
||||
};
|
||||
slices.push({
|
||||
index,
|
||||
offset,
|
||||
size,
|
||||
alignmentExponent: view.getUint32(base + (wide ? 24 : 16), littleEndian),
|
||||
cpuType: view.getUint32(base, littleEndian),
|
||||
cpuSubtype: view.getUint32(base + 4, littleEndian),
|
||||
});
|
||||
}
|
||||
return { status: "parsed", slices };
|
||||
};
|
||||
|
||||
/** Validate byte-range and table/header identity facts shared by Mach-O readers. */
|
||||
export const validateFatSlice = (
|
||||
declaration: FatSliceDeclaration,
|
||||
containerSize: number,
|
||||
tableEnd: number,
|
||||
embedded?: { readonly cpuType: number; readonly cpuSubtype: number },
|
||||
): string | null => {
|
||||
const { index, offset, size, alignmentExponent, cpuType, cpuSubtype } =
|
||||
declaration;
|
||||
if (
|
||||
!Number.isSafeInteger(offset) ||
|
||||
!Number.isSafeInteger(size) ||
|
||||
size <= 0 ||
|
||||
offset < tableEnd
|
||||
)
|
||||
return `FAT architecture ${index} has an invalid slice range`;
|
||||
if (offset > containerSize || size > containerSize - offset)
|
||||
return `FAT architecture ${index} slice range extends beyond the file`;
|
||||
if (
|
||||
!Number.isInteger(alignmentExponent) ||
|
||||
alignmentExponent < 0 ||
|
||||
alignmentExponent > 52
|
||||
)
|
||||
return `FAT architecture ${index} alignment exponent ${alignmentExponent} is unsupported`;
|
||||
if (offset % 2 ** alignmentExponent !== 0)
|
||||
return `FAT architecture ${index} offset is not aligned to 2^${alignmentExponent}`;
|
||||
if (
|
||||
embedded !== undefined &&
|
||||
(embedded.cpuType !== cpuType || embedded.cpuSubtype !== cpuSubtype)
|
||||
)
|
||||
return `FAT architecture ${index} CPU type/subtype disagrees with its Mach-O header`;
|
||||
return null;
|
||||
};
|
||||
|
||||
const safeNumber = (value: bigint): number | null =>
|
||||
value > BigInt(Number.MAX_SAFE_INTEGER) ? null : Number(value);
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CPU,
|
||||
fatImage,
|
||||
machoImage,
|
||||
} from "../artifacts/apple/MachoImage.fixture.js";
|
||||
import { parseMachoLayout } from "./AppleMachoSelection.js";
|
||||
|
||||
describe("native Mach-O slice selection", () => {
|
||||
it("accepts a FAT slice whose table identity and embedded header agree", () => {
|
||||
const fat = fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }]);
|
||||
expect(parseMachoLayout(Buffer.from(fat), "arm64").segments).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects FAT subtype disagreement and unaligned slice offsets", () => {
|
||||
const original = fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }]);
|
||||
const mismatched = original.slice();
|
||||
new DataView(mismatched.buffer).setUint32(12, 2, false);
|
||||
expect(() => parseMachoLayout(Buffer.from(mismatched), "arm64")).toThrow(
|
||||
/disagrees/u,
|
||||
);
|
||||
|
||||
const unaligned = original.slice();
|
||||
new DataView(unaligned.buffer).setUint32(16, 4097, false);
|
||||
expect(() => parseMachoLayout(Buffer.from(unaligned), "arm64")).toThrow(
|
||||
/not aligned/u,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { FixupCommands } from "./AppleMachoFixups.js";
|
||||
import {
|
||||
readFatSliceDeclarations,
|
||||
validateFatSlice,
|
||||
} from "../domain/apple/machoContainer.js";
|
||||
|
||||
/** One mapped Mach-O segment with file-backed VA range. */
|
||||
export interface Segment {
|
||||
@@ -19,6 +23,16 @@ export interface Section {
|
||||
export interface MachoSlice {
|
||||
slice: number;
|
||||
sliceEnd: number;
|
||||
/** FAT table identity, absent for a thin image. */
|
||||
fatDeclaration?: {
|
||||
offset: number;
|
||||
size: number;
|
||||
cpuType: number;
|
||||
cpuSubtype: number;
|
||||
alignmentExponent: number;
|
||||
index: number;
|
||||
tableEnd: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Parsed little-endian 64-bit Mach-O layout for the selected architecture slice. */
|
||||
@@ -66,9 +80,6 @@ export const selectMachoSlice = (
|
||||
const readUInt32 = littleEndian
|
||||
? (offset: number) => bytes.readUInt32LE(offset)
|
||||
: (offset: number) => bytes.readUInt32BE(offset);
|
||||
const readUInt64 = littleEndian
|
||||
? (offset: number) => bytes.readBigUInt64LE(offset)
|
||||
: (offset: number) => bytes.readBigUInt64BE(offset);
|
||||
const byteOrder = littleEndian ? "little-endian" : "big-endian";
|
||||
const malformed = (reason: string) =>
|
||||
new RangeError(`${reason} (FAT header byte order: ${byteOrder})`);
|
||||
@@ -80,21 +91,27 @@ export const selectMachoSlice = (
|
||||
if (count > 128 || headerEnd > bytes.length)
|
||||
throw malformed("Malformed FAT architecture table");
|
||||
const cpu = architecture === "arm64" ? 0x0100000c : 0x01000007;
|
||||
const declarations = readFatSliceDeclarations(bytes.subarray(8, headerEnd), {
|
||||
count,
|
||||
wide: fat64,
|
||||
littleEndian,
|
||||
});
|
||||
if (declarations.status !== "parsed") throw malformed(declarations.reason);
|
||||
let selected: MachoSlice | undefined;
|
||||
for (let index = 0; index < count; index++) {
|
||||
const offset = 8 + index * stride;
|
||||
if (readUInt32(offset) !== cpu) continue;
|
||||
for (const declaration of declarations.slices) {
|
||||
const issue = validateFatSlice(declaration, bytes.length, headerEnd);
|
||||
if (issue !== null) throw malformed(issue);
|
||||
if (declaration.cpuType !== cpu) continue;
|
||||
if (selected !== undefined)
|
||||
throw invalid("Ambiguous FAT architecture slice");
|
||||
const start = fat64
|
||||
? readUInt64(offset + 8)
|
||||
: BigInt(readUInt32(offset + 8));
|
||||
const size = fat64
|
||||
? readUInt64(offset + 16)
|
||||
: BigInt(readUInt32(offset + 12));
|
||||
if (start < BigInt(headerEnd) || start + size > BigInt(bytes.length))
|
||||
throw malformed("FAT slice exceeds file");
|
||||
selected = { slice: Number(start), sliceEnd: Number(start + size) };
|
||||
selected = {
|
||||
slice: declaration.offset,
|
||||
sliceEnd: declaration.offset + declaration.size,
|
||||
fatDeclaration: {
|
||||
...declaration,
|
||||
tableEnd: headerEnd,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (selected === undefined)
|
||||
throw invalid("Requested FAT architecture is absent");
|
||||
@@ -107,13 +124,22 @@ export const parseMachoLayout = (
|
||||
architecture: string,
|
||||
): MachoLayout => {
|
||||
if (bytes.length < 4) throw new RangeError("Truncated Mach-O header");
|
||||
const { slice, sliceEnd } = selectMachoSlice(bytes, architecture);
|
||||
const selection = selectMachoSlice(bytes, architecture);
|
||||
const { slice, sliceEnd } = selection;
|
||||
if (slice === 0 && sliceEnd === bytes.length && bytes.length < 32)
|
||||
throw new RangeError("Truncated Mach-O header");
|
||||
if (slice + 32 > sliceEnd || bytes.readUInt32LE(slice) !== 0xfeedfacf)
|
||||
throw new TypeError(
|
||||
"Only little-endian 64-bit Mach-O metadata is supported",
|
||||
);
|
||||
const fat = selection.fatDeclaration;
|
||||
if (fat !== undefined) {
|
||||
const issue = validateFatSlice(fat, bytes.length, fat.tableEnd, {
|
||||
cpuType: bytes.readUInt32LE(slice + 4),
|
||||
cpuSubtype: bytes.readUInt32LE(slice + 8),
|
||||
});
|
||||
if (issue !== null) throw new TypeError(issue);
|
||||
}
|
||||
const commands = bytes.readUInt32LE(slice + 16);
|
||||
const commandEnd = slice + 32 + bytes.readUInt32LE(slice + 20);
|
||||
if (commands > 4096 || commandEnd > sliceEnd)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseLipoArchitectures } from "./lipo.js";
|
||||
|
||||
describe("lipo architecture facts", () => {
|
||||
it("normalizes symbolic CPU fields and arm64e capability bits", () => {
|
||||
const architectures = parseLipoArchitectures(`Fat header in: /bin/ls
|
||||
fat_magic 0xcafebabe
|
||||
nfat_arch 2
|
||||
architecture x86_64
|
||||
cputype CPU_TYPE_X86_64
|
||||
cpusubtype CPU_SUBTYPE_X86_64_ALL
|
||||
capabilities 0x0
|
||||
offset 16384
|
||||
size 48112
|
||||
align 2^14 (16384)
|
||||
architecture arm64e
|
||||
cputype CPU_TYPE_ARM64
|
||||
cpusubtype CPU_SUBTYPE_ARM64E
|
||||
capabilities PTR_AUTH_VERSION USERSPACE 0
|
||||
offset 65536
|
||||
size 89088
|
||||
align 2^14 (16384)
|
||||
`);
|
||||
expect(architectures).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "x86_64",
|
||||
cpu_type: "CPU_TYPE_X86_64",
|
||||
cpu_type_code: 0x01000007,
|
||||
cpu_subtype: "CPU_SUBTYPE_X86_64_ALL",
|
||||
cpu_subtype_code: 3,
|
||||
capabilities: "0x0",
|
||||
file_offset: 16384,
|
||||
size: 48112,
|
||||
alignment: 16384,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "arm64e",
|
||||
cpu_type: "CPU_TYPE_ARM64",
|
||||
cpu_type_code: 0x0100000c,
|
||||
cpu_subtype: "CPU_SUBTYPE_ARM64E",
|
||||
cpu_subtype_code: 0x80000002,
|
||||
capabilities: "PTR_AUTH_VERSION USERSPACE 0",
|
||||
file_offset: 65536,
|
||||
size: 89088,
|
||||
alignment: 16384,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps unknown symbolic CPU values as source text without a code", () => {
|
||||
expect(
|
||||
parseLipoArchitectures(
|
||||
"architecture future\n cputype CPU_TYPE_FUTURE\n cpusubtype CPU_SUBTYPE_FUTURE\n offset 4096\n size 64\n align 2^12\n",
|
||||
),
|
||||
).toMatchObject([
|
||||
{
|
||||
cpu_type: "CPU_TYPE_FUTURE",
|
||||
cpu_type_code: null,
|
||||
cpu_subtype: "CPU_SUBTYPE_FUTURE",
|
||||
cpu_subtype_code: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,9 @@ const architectureSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
cpu_type: z.string().nullable(),
|
||||
cpu_subtype: z.string().nullable(),
|
||||
cpu_type_code: z.number().int().nonnegative().nullable(),
|
||||
cpu_subtype_code: z.number().int().nonnegative().nullable(),
|
||||
capabilities: z.string().nullable(),
|
||||
file_offset: z.number().int().min(0).nullable(),
|
||||
size: z.number().int().min(0).nullable(),
|
||||
alignment: z.number().int().min(0).nullable(),
|
||||
@@ -25,6 +28,9 @@ export const parseLipoArchitectures = (output: string): LipoArchitecture[] => {
|
||||
name: thinArchitecture,
|
||||
cpu_type: null,
|
||||
cpu_subtype: null,
|
||||
cpu_type_code: null,
|
||||
cpu_subtype_code: null,
|
||||
capabilities: null,
|
||||
file_offset: null,
|
||||
size: null,
|
||||
alignment: null,
|
||||
@@ -43,6 +49,12 @@ export const parseLipoArchitectures = (output: string): LipoArchitecture[] => {
|
||||
: name,
|
||||
cpu_type: current.cputype ?? null,
|
||||
cpu_subtype: current.cpusubtype ?? null,
|
||||
cpu_type_code: cpuTypeCode(current.cputype),
|
||||
cpu_subtype_code: cpuSubtypeCode(
|
||||
current.cpusubtype,
|
||||
current.capabilities,
|
||||
),
|
||||
capabilities: current.capabilities ?? null,
|
||||
file_offset: integer(current.offset),
|
||||
size: integer(current.size),
|
||||
alignment: alignment(current.align),
|
||||
@@ -62,7 +74,10 @@ export const parseLipoArchitectures = (output: string): LipoArchitecture[] => {
|
||||
current = { architecture: line.slice("architecture ".length).trim() };
|
||||
continue;
|
||||
}
|
||||
const match = /^(cputype|cpusubtype|offset|size|align)\s+(.+)$/u.exec(line);
|
||||
const match =
|
||||
/^(cputype|cpusubtype|capabilities|offset|size|align)\s+(.+)$/u.exec(
|
||||
line,
|
||||
);
|
||||
if (match?.[1] !== undefined && match[2] !== undefined) {
|
||||
current ??= {};
|
||||
current[match[1]] = match[2].trim();
|
||||
@@ -80,6 +95,72 @@ const integer = (value: string | undefined): number | null => {
|
||||
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
||||
};
|
||||
|
||||
const CPU_TYPES: Readonly<Record<string, number>> = {
|
||||
CPU_TYPE_X86: 7,
|
||||
CPU_TYPE_X86_64: 0x01000007,
|
||||
CPU_TYPE_ARM: 12,
|
||||
CPU_TYPE_ARM64: 0x0100000c,
|
||||
CPU_TYPE_ARM64_32: 0x0200000c,
|
||||
CPU_TYPE_POWERPC: 18,
|
||||
CPU_TYPE_POWERPC64: 0x01000012,
|
||||
};
|
||||
|
||||
const CPU_SUBTYPES: Readonly<Record<string, number>> = {
|
||||
CPU_SUBTYPE_X86_ALL: 3,
|
||||
CPU_SUBTYPE_X86_64_ALL: 3,
|
||||
CPU_SUBTYPE_X86_64_H: 8,
|
||||
CPU_SUBTYPE_ARM64_ALL: 0,
|
||||
CPU_SUBTYPE_ARM64_V8: 1,
|
||||
CPU_SUBTYPE_ARM64E: 2,
|
||||
CPU_SUBTYPE_ARM64_32_ALL: 0,
|
||||
CPU_SUBTYPE_ARM64_32_V8: 1,
|
||||
CPU_SUBTYPE_ARM_V7: 9,
|
||||
CPU_SUBTYPE_ARM_V7S: 11,
|
||||
CPU_SUBTYPE_ARM_V7K: 12,
|
||||
CPU_SUBTYPE_POWERPC_ALL: 0,
|
||||
CPU_SUBTYPE_POWERPC_970: 100,
|
||||
};
|
||||
|
||||
/** Normalize known lipo names while retaining the producer's original text. */
|
||||
const cpuTypeCode = (value: string | undefined): number | null => {
|
||||
if (value === undefined) return null;
|
||||
const numeric = unsigned32(value);
|
||||
return numeric ?? CPU_TYPES[value] ?? null;
|
||||
};
|
||||
|
||||
/** Normalize the subtype and its separately printed ptrauth capability bits. */
|
||||
const cpuSubtypeCode = (
|
||||
value: string | undefined,
|
||||
capabilities: string | undefined,
|
||||
): number | null => {
|
||||
if (value === undefined) return null;
|
||||
const numeric = unsigned32(value);
|
||||
if (numeric !== null) return numeric;
|
||||
const subtype = CPU_SUBTYPES[value];
|
||||
if (subtype === undefined) return null;
|
||||
if (value !== "CPU_SUBTYPE_ARM64E") return subtype;
|
||||
const version = /PTR_AUTH_VERSION\s+USERSPACE\s+(\d+)/u.exec(
|
||||
capabilities ?? "",
|
||||
)?.[1];
|
||||
if (version === undefined) return null;
|
||||
const parsedVersion = Number(version);
|
||||
if (
|
||||
!Number.isInteger(parsedVersion) ||
|
||||
parsedVersion < 0 ||
|
||||
parsedVersion > 15
|
||||
)
|
||||
return null;
|
||||
return (0x80000000 | (parsedVersion << 24) | subtype) >>> 0;
|
||||
};
|
||||
|
||||
const unsigned32 = (value: string): number | null => {
|
||||
if (!/^(?:\d+|0x[\da-f]+)$/iu.test(value)) return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 0xffffffff
|
||||
? parsed >>> 0
|
||||
: null;
|
||||
};
|
||||
|
||||
const alignment = (value: string | undefined): number | null => {
|
||||
if (value === undefined) return null;
|
||||
const exponent = /^2\^(\d+)(?: \((\d+)\))?$/u.exec(value);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { describe, expect } from "vitest";
|
||||
import {
|
||||
FILE_TYPE,
|
||||
LC,
|
||||
buildVersionCommand,
|
||||
dylibCommand,
|
||||
machoImage,
|
||||
rpathCommand,
|
||||
@@ -36,6 +37,7 @@ const fixtureApp = async (): Promise<string> => {
|
||||
"<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>",
|
||||
"Contents/MacOS/App": machoImage({
|
||||
commands: [
|
||||
buildVersionCommand(1),
|
||||
rpathCommand("@executable_path/../Frameworks"),
|
||||
dylibCommand(LC.LOAD_DYLIB, "@rpath/libcore.dylib"),
|
||||
dylibCommand(LC.LOAD_WEAK_DYLIB, "@rpath/libgone.dylib"),
|
||||
@@ -43,10 +45,16 @@ const fixtureApp = async (): Promise<string> => {
|
||||
}),
|
||||
"Contents/Frameworks/libcore.dylib": machoImage({
|
||||
fileType: FILE_TYPE.dylib,
|
||||
commands: [dylibCommand(LC.ID_DYLIB, "@rpath/libcore.dylib")],
|
||||
commands: [
|
||||
buildVersionCommand(1),
|
||||
dylibCommand(LC.ID_DYLIB, "@rpath/libcore.dylib"),
|
||||
],
|
||||
}),
|
||||
"Contents/Helpers/tool": machoImage({
|
||||
commands: [dylibCommand(LC.LOAD_DYLIB, "@loader_path/libnone.dylib")],
|
||||
commands: [
|
||||
buildVersionCommand(1),
|
||||
dylibCommand(LC.LOAD_DYLIB, "@loader_path/libnone.dylib"),
|
||||
],
|
||||
}),
|
||||
});
|
||||
return app;
|
||||
@@ -153,11 +161,15 @@ describe("trace-dylib-resolution CLI", () => {
|
||||
await writeFiles(directory, {
|
||||
tool: machoImage({
|
||||
commands: [
|
||||
buildVersionCommand(1),
|
||||
dylibCommand(LC.LOAD_DYLIB, "@loader_path/lib/libhelper.dylib"),
|
||||
dylibCommand(LC.LOAD_DYLIB, "@loader_path/../escape.dylib"),
|
||||
],
|
||||
}),
|
||||
"lib/libhelper.dylib": machoImage({ fileType: FILE_TYPE.dylib }),
|
||||
"lib/libhelper.dylib": machoImage({
|
||||
fileType: FILE_TYPE.dylib,
|
||||
commands: [buildVersionCommand(1)],
|
||||
}),
|
||||
"unrelated.txt": "not traversed",
|
||||
});
|
||||
const result = await cli.run({
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
|
||||
import {
|
||||
FILE_TYPE,
|
||||
LC,
|
||||
buildVersionCommand,
|
||||
dylibCommand,
|
||||
machoImage,
|
||||
rpathCommand,
|
||||
@@ -55,6 +56,7 @@ it("traces dylib resolution for an opened app bundle and rejects a changed targe
|
||||
executable,
|
||||
machoImage({
|
||||
commands: [
|
||||
buildVersionCommand(1),
|
||||
rpathCommand("@executable_path/../Frameworks"),
|
||||
dylibCommand(LC.LOAD_DYLIB, "@rpath/libcore.dylib"),
|
||||
],
|
||||
@@ -62,7 +64,10 @@ it("traces dylib resolution for an opened app bundle and rejects a changed targe
|
||||
);
|
||||
await writeFile(
|
||||
join(app, "Contents/Frameworks/libcore.dylib"),
|
||||
machoImage({ fileType: FILE_TYPE.dylib }),
|
||||
machoImage({
|
||||
fileType: FILE_TYPE.dylib,
|
||||
commands: [buildVersionCommand(1)],
|
||||
}),
|
||||
);
|
||||
await withClient(async (client) => {
|
||||
const opened = await client.callTool({
|
||||
@@ -129,12 +134,18 @@ it("traces a standalone Mach-O file whose name ends in .app", async () => {
|
||||
await writeFile(
|
||||
join(directory, "Tool.app"),
|
||||
machoImage({
|
||||
commands: [dylibCommand(LC.LOAD_DYLIB, "@loader_path/libcore.dylib")],
|
||||
commands: [
|
||||
buildVersionCommand(1),
|
||||
dylibCommand(LC.LOAD_DYLIB, "@loader_path/libcore.dylib"),
|
||||
],
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
join(directory, "libcore.dylib"),
|
||||
machoImage({ fileType: FILE_TYPE.dylib }),
|
||||
machoImage({
|
||||
fileType: FILE_TYPE.dylib,
|
||||
commands: [buildVersionCommand(1)],
|
||||
}),
|
||||
);
|
||||
await withClient(async (client) => {
|
||||
const opened = await client.callTool({
|
||||
|
||||
@@ -6,6 +6,11 @@ import { describe, expect, it } from "vitest";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { MachOSliceArtifactReader } from "../../../../src/artifacts/MachOSliceArtifactReader.js";
|
||||
import {
|
||||
CPU,
|
||||
fatImage,
|
||||
machoImage,
|
||||
} from "../../../../src/artifacts/apple/MachoImage.fixture.js";
|
||||
import { ok } from "../../../../src/domain/result.js";
|
||||
import type { NativeCommandRunner } from "../../../../src/native/CommandRunner.js";
|
||||
|
||||
@@ -46,7 +51,13 @@ describe("artifact Mach-O slices", () => {
|
||||
it("uses lipo metadata to read universal slice ranges", async () => {
|
||||
const root = await createTestTempDirectory("rea-slices-");
|
||||
const binary = join(root, "fat");
|
||||
await writeFile(binary, Buffer.from("0123456789abcdef"));
|
||||
const x86 = machoImage({ cpu: CPU.x86_64 });
|
||||
const arm = machoImage({ cpu: CPU.arm64 });
|
||||
const image = fatImage([
|
||||
{ cpu: CPU.x86_64, bytes: x86 },
|
||||
{ cpu: CPU.arm64, bytes: arm },
|
||||
]);
|
||||
await writeFile(binary, image);
|
||||
const runner: NativeCommandRunner = {
|
||||
run: () =>
|
||||
Promise.resolve(
|
||||
@@ -57,8 +68,7 @@ describe("artifact Mach-O slices", () => {
|
||||
toolVersion: null,
|
||||
versionReason: "fixture",
|
||||
arguments: ["-detailed_info", binary],
|
||||
stdout:
|
||||
"architecture x86_64\n cputype 16777223\n cpusubtype 3\n offset 0\n size 8\n align 2^2\narchitecture arm64\n cputype 16777228\n cpusubtype 0\n offset 8\n size 8\n align 2^2\n",
|
||||
stdout: `architecture x86_64\n cputype 16777223\n cpusubtype 3\n offset 4096\n size ${x86.length}\n align 2^12\narchitecture arm64\n cputype 16777228\n cpusubtype 0\n offset 8192\n size ${arm.length}\n align 2^12\n`,
|
||||
stderr: "",
|
||||
stdoutBytes: 1,
|
||||
stderrBytes: 0,
|
||||
@@ -76,17 +86,17 @@ describe("artifact Mach-O slices", () => {
|
||||
if (secondEntry === undefined) return;
|
||||
expect(secondEntry).toMatchObject({
|
||||
path: "slices/arm64",
|
||||
byteOffset: 8,
|
||||
declaredSize: 8,
|
||||
byteOffset: 8192,
|
||||
declaredSize: arm.length,
|
||||
});
|
||||
for (const adapterKey of ["0x8:8", "1e3:8", " 8 :8"])
|
||||
for (const adapterKey of ["0x2000:8", "1e3:8", " 8192 :8"])
|
||||
await expect(
|
||||
reader.open({ ...secondEntry, adapterKey }),
|
||||
).rejects.toMatchObject({ reason: "integrity" });
|
||||
const chunks: Buffer[] = [];
|
||||
const stream = await reader.open(secondEntry);
|
||||
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
|
||||
expect(Buffer.concat(chunks).toString()).toBe("89abcdef");
|
||||
expect(Buffer.concat(chunks)).toEqual(Buffer.from(arm));
|
||||
expect(reader.provenance()).toEqual([
|
||||
expect.objectContaining({ tool: "lipo", effects: ["read"] }),
|
||||
]);
|
||||
@@ -95,4 +105,84 @@ describe("artifact Mach-O slices", () => {
|
||||
Reflect.set(provenance[0], "tool", "forged");
|
||||
expect(reader.provenance()[0]?.tool).toBe("lipo");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["CPU type", "cputype 16777223"],
|
||||
["CPU subtype", "cpusubtype 2"],
|
||||
["alignment", "align 2^11 (2048)"],
|
||||
["offset", "offset 4097"],
|
||||
])(
|
||||
"rejects lipo %s that disagrees with physical Mach-O facts",
|
||||
async (_label, field) => {
|
||||
const root = await createTestTempDirectory("rea-slices-mismatch-");
|
||||
const binary = join(root, "fat");
|
||||
const thin = machoImage({ cpu: CPU.arm64 });
|
||||
await writeFile(binary, fatImage([{ cpu: CPU.arm64, bytes: thin }]));
|
||||
const details = [
|
||||
"architecture arm64",
|
||||
" cputype 16777228",
|
||||
" cpusubtype 0",
|
||||
" offset 4096",
|
||||
` size ${thin.length}`,
|
||||
" align 2^12 (4096)",
|
||||
];
|
||||
const index = field.startsWith("cputype")
|
||||
? 1
|
||||
: field.startsWith("cpusubtype")
|
||||
? 2
|
||||
: field.startsWith("align")
|
||||
? 5
|
||||
: 3;
|
||||
details[index] = ` ${field}`;
|
||||
const reader = new MachOSliceArtifactReader(
|
||||
binary,
|
||||
lipoRunner(`${details.join("\n")}\n`),
|
||||
);
|
||||
await expect(async () => {
|
||||
for await (const _entry of reader.entries()) {
|
||||
// The structural comparison runs before the reader yields a slice.
|
||||
}
|
||||
}).rejects.toMatchObject({ reason: "integrity" });
|
||||
},
|
||||
);
|
||||
|
||||
it("does not trust lipo after a malformed structural Mach-O parse", async () => {
|
||||
const root = await createTestTempDirectory("rea-slices-malformed-");
|
||||
const binary = join(root, "fat");
|
||||
const thin = machoImage({ cpu: CPU.arm64 });
|
||||
new DataView(thin.buffer).setUint32(16, 1, true);
|
||||
const image = fatImage([{ cpu: CPU.arm64, bytes: thin }]);
|
||||
await writeFile(binary, image);
|
||||
const reader = new MachOSliceArtifactReader(
|
||||
binary,
|
||||
lipoRunner(
|
||||
`architecture arm64\n cputype 16777228\n cpusubtype 0\n offset 4096\n size ${thin.length}\n align 2^12 (4096)\n`,
|
||||
),
|
||||
);
|
||||
await expect(async () => {
|
||||
for await (const _entry of reader.entries()) {
|
||||
// Malformed structural facts must fail before any slice is yielded.
|
||||
}
|
||||
}).rejects.toThrow(/structural slice index is malformed/u);
|
||||
});
|
||||
});
|
||||
|
||||
const lipoRunner = (stdout: string): NativeCommandRunner => ({
|
||||
run: () =>
|
||||
Promise.resolve(
|
||||
ok({
|
||||
tool: "lipo",
|
||||
executable: "/usr/bin/lipo",
|
||||
executableSha256: "1".repeat(64),
|
||||
toolVersion: null,
|
||||
versionReason: "fixture",
|
||||
arguments: ["-detailed_info", "fixture"],
|
||||
stdout,
|
||||
stderr: "",
|
||||
stdoutBytes: Buffer.byteLength(stdout),
|
||||
stderrBytes: 0,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user