fix(targets): admit distinct host slices and one-sided digests (#10016)

* fix(targets): select a preferred Mach-O host subtype

Host admission refused every extra slice of the host CPU type, so arm64
plus arm64e and arm64e ABI variants never became universal targets. Rank
known subtypes and keep the earliest capability variant. Identical
subtypes and ties between unlisted bases still fail closed.

* fix(artifacts): treat a one-sided declared digest as metadata

An all-zero Godot MD5 was visible only because limitation text differed.
After comparison stopped using that prose, a claim on one side and no
claim on the other lost its metadata dimension. Compare the structured
integrity claim instead.

* fix(browser): keep unavailable resource inventories unknown

Directional coverage treated a resource retained from an invalid protocol
tree as an addition against a complete peer. An unavailable collection is
not a truncated sample, so it cannot prove membership.

* fix(native): read the admitted Mach-O slice for metadata

Capability-variant admission chose the first slice, then dispatch
metadata and slice tools ranked the FAT file again and rejected that
pair. Pass the admitted offset, and refuse a winner set that repeats
a subtype.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
morlutoandCursor Agent authored and GitHub committed 2026-11-11 03:41:55 -05:00
1 parent 30f96aef39
commit ada8fcfe82
17 files changed
+536 -38

No files matched your search

+4 -2
View File
@@ -49,8 +49,10 @@ as the 4.3 and 4.4.1 exporters do, so the declared MD5 does not depend on the
encryption IV. Encrypted members whose declared MD5s differ therefore also
compare `unknown` in `metadata` and `availability`: the MD5s declare
different scripts, but REA cannot verify them, and equal MD5s add nothing for
the same reason. An encrypted member with an MD5 on only one side stays
`unknown`.
the same reason. An encrypted member with an MD5 on only one side, including
an all-zero MD5 that records no claim, stays `unknown` in `metadata` and
`availability`: the claim's presence differs, and one claim does not prove
the script changed.
## CLI
+3 -2
View File
@@ -164,8 +164,9 @@ export interface BinaryTargetResolverOptions {
/**
* Resolve and classify a readable local target before a provider is selected.
* FAT Mach-O inputs select the host-compatible architecture, otherwise their
* first declared supported slice, so setup remains non-interactive; unsupported
* FAT Mach-O inputs select the host-compatible slice. Several subtypes of that
* family use subtype preference, then the earliest capability variant.
* Otherwise the first foreign family declared once is selected. Unsupported
* or ambiguous inputs are returned as typed errors.
*/
export const parseBinaryTarget = async (
+33
View File
@@ -1,6 +1,39 @@
/** CPU subtype bits reserved for ABI/capability flags, not the base subtype. */
const CPU_SUBTYPE_CAPABILITY_MASK = 0xff000000;
/**
* Host-slice preference for one CPU type, best first.
* arm64, then arm64e, then arm64v8. Generic x86_64 and i386 outrank Haswell
* and other unlisted bases. Apple tool names stay with the native policy.
*/
export const ARM64_HOST_SUBTYPE_PREFERENCE = [0, 2, 1] as const;
export const X86_64_HOST_SUBTYPE_PREFERENCE = [3] as const;
export const X86_HOST_SUBTYPE_PREFERENCE = [3] as const;
export const ARM64_32_HOST_SUBTYPE_PREFERENCE = [1] as const;
/** Preference lists keyed by Mach-O CPU type. Unlisted types have no ranking. */
export const MACHO_HOST_SUBTYPE_PREFERENCE: Readonly<
Record<number, readonly number[]>
> = {
7: X86_HOST_SUBTYPE_PREFERENCE,
0x01000007: X86_64_HOST_SUBTYPE_PREFERENCE,
0x0100000c: ARM64_HOST_SUBTYPE_PREFERENCE,
0x0200000c: ARM64_32_HOST_SUBTYPE_PREFERENCE,
};
/**
* Rank of a slice for host admission. Lower is better. Unlisted bases share
* the rank after every preferred base, so they tie with each other.
*/
export const machoHostSubtypeRank = (
cpuType: number,
cpuSubtype: number,
): number => {
const preference = MACHO_HOST_SUBTYPE_PREFERENCE[cpuType] ?? [];
const rank = preference.indexOf(machoCpuSubtypeBase(cpuSubtype));
return rank < 0 ? preference.length : rank;
};
/** Return the CPU subtype identity shared across capability-flag variations. */
export const machoCpuSubtypeBase = (cpuSubtype: number): number =>
cpuSubtype & ~CPU_SUBTYPE_CAPABILITY_MASK;
+40
View File
@@ -429,6 +429,46 @@ it("retains known packing changes when content is unavailable", () => {
});
});
it("compares declared digests without using limitation prose", () => {
const claim = { algorithm: "md5" as const, digest: "ab".repeat(16) };
const edited = { algorithm: "md5" as const, digest: "cd".repeat(16) };
const unavailable = (integrity?: {
readonly algorithm: "md5";
readonly digest: string;
}) =>
observation({
hash_status: "unavailable",
limitations: ["same derived note"],
...(integrity === undefined ? {} : { declared_integrity: integrity }),
});
const changes = (
left: ReturnType<typeof unavailable>,
right: ReturnType<typeof unavailable>,
) =>
compareArtifacts(left, right).changes.map(
({ classification, dimensions }) => ({ classification, dimensions }),
);
const equal = changes(unavailable(claim), unavailable(claim));
const oneSided = changes(unavailable(claim), unavailable());
const different = changes(unavailable(claim), unavailable(edited));
expect(equal.length).toBeGreaterThan(0);
expect(oneSided).toHaveLength(equal.length);
expect(different).toHaveLength(equal.length);
expect(equal).toEqual(
equal.map(() => ({
classification: "unknown",
dimensions: ["availability"],
})),
);
for (const result of [oneSided, different])
expect(result).toEqual(
result.map(() => ({
classification: "unknown",
dimensions: ["metadata", "availability"],
})),
);
});
it("keeps unavailable content unknown when packing declarations agree", () => {
expect(
compareArtifacts(
+7 -9
View File
@@ -650,19 +650,17 @@ const classifyDeclarationRecord = (
/**
* Without observed content on both sides, the containers' declared digests
* are the remaining content evidence. Claims of one algorithm that differ
* are a metadata difference, but they are unverified, so they do not make
* the path changed; equal claims add nothing. Observed bytes replace both.
* are the remaining content evidence. Claims of one algorithm that differ,
* and a claim present on only one side, are a metadata difference. They are
* unverified, so they do not make the path changed. Equal claims add nothing.
* The signal is the structured claim, not a reader limitation that restates it.
* Observed bytes replace both.
*/
const declaredDigestsDiffer = (input: OccurrencePair): boolean => {
const left = input.leftOccurrence?.declared_integrity;
const right = input.rightOccurrence?.declared_integrity;
return (
left !== undefined &&
right !== undefined &&
left.algorithm === right.algorithm &&
left.digest !== right.digest
);
if (left === undefined || right === undefined) return left !== right;
return left.algorithm === right.algorithm && left.digest !== right.digest;
};
const changedDimensions = (input: {
+93
View File
@@ -188,6 +188,99 @@ describe("FAT Mach-O slice selection", () => {
parseExecutableHeader(fat([0x0100000c, 0x0100000c, 0x01000007]), "arm"),
).toMatchObject({ value: { architecture: "x86_64" } });
});
const withSubtype = (bytes: Buffer, index: number, subtype: number) => {
const entry = 8 + index * 20;
bytes.writeUInt32BE(subtype, entry + 4);
bytes.writeUInt32BE(subtype, bytes.readUInt32BE(entry + 8) + 8);
return bytes;
};
const selectedIdentity = (bytes: Buffer) => {
const parsed = parseExecutableHeader(bytes, "arm64");
if (!parsed.ok || parsed.value.format !== "mach-o-universal")
throw new Error(parsed.ok ? parsed.value.format : parsed.error);
const header = parsed.value;
return header.universalSlices.find(
(slice) => slice.offset === header.universalSlice.offset,
);
};
it("selects generic arm64 when an earlier slice is arm64e", () => {
const bytes = withSubtype(fat([0x0100000c, 0x0100000c, 0x01000007]), 0, 2);
expect(selectedIdentity(bytes)).toMatchObject({
cpuType: 0x0100000c,
cpuSubtype: 0,
});
expect(parseExecutableHeader(bytes, "arm64")).toMatchObject({
value: {
format: "mach-o-universal",
architecture: "arm64",
availableArchitectures: ["arm64", "x86_64"],
},
});
});
it("selects the earliest arm64e capability variant", () => {
const bytes = withSubtype(
withSubtype(fat([0x0100000c, 0x0100000c]), 0, 0x81000002),
1,
0x80000002,
);
expect(selectedIdentity(bytes)).toMatchObject({
cpuType: 0x0100000c,
cpuSubtype: 0x81000002,
});
});
it("refuses a capability-variant set that repeats a subtype", () => {
const bytes = fat([0x0100000c, 0x0100000c, 0x0100000c]);
withSubtype(bytes, 0, 0x81000002);
withSubtype(bytes, 1, 0x81000002);
withSubtype(bytes, 2, 0x80000002);
expect(parseExecutableHeader(bytes, "arm64")).toEqual({
ok: false,
error: "FAT binary declares several arm64 slices; no slice was selected",
});
});
it("refuses identical host subtypes instead of a foreign family", () => {
expect(
parseExecutableHeader(fat([0x0100000c, 0x0100000c, 0x01000007]), "arm64"),
).toEqual({
ok: false,
error: "FAT binary declares several arm64 slices; no slice was selected",
});
});
it("refuses a tie between unlisted arm64 subtypes", () => {
const bytes = withSubtype(
withSubtype(fat([0x0100000c, 0x0100000c]), 0, 42),
1,
43,
);
expect(parseExecutableHeader(bytes, "arm64")).toEqual({
ok: false,
error: "FAT binary declares several arm64 slices; no slice was selected",
});
});
it("prefers generic x86_64 over an earlier x86_64h slice", () => {
const bytes = withSubtype(
withSubtype(fat([0x01000007, 0x01000007]), 0, 8),
1,
3,
);
const parsed = parseExecutableHeader(bytes, "x64");
if (!parsed.ok || parsed.value.format !== "mach-o-universal")
throw new Error(parsed.ok ? parsed.value.format : parsed.error);
const header = parsed.value;
expect(
header.universalSlices.find(
(slice) => slice.offset === header.universalSlice.offset,
),
).toMatchObject({ cpuType: 0x01000007, cpuSubtype: 3 });
});
});
describe("executable header completeness", () => {
+62 -9
View File
@@ -35,6 +35,10 @@ import {
validateFatSliceIdentity,
type FatSliceHeaderEvidence,
} from "./apple/machoContainer.js";
import {
machoCpuSubtypeBase,
machoHostSubtypeRank,
} from "./apple/machoCpuIdentity.js";
import {
MachoLoadCommandCursor,
readMachoLoadCommandTableHeader,
@@ -192,6 +196,8 @@ interface FatDeclaration {
readonly architecture: HeaderArchitecture;
readonly offset: number;
readonly size: number;
readonly cpuType: number;
readonly cpuSubtype: number;
}
/** Validate a FAT table and its slice identities without selecting a slice. */
@@ -228,6 +234,8 @@ const validatedFatDeclarations = (
architecture,
offset: declaration.offset,
size: declaration.size,
cpuType: declaration.cpuType,
cpuSubtype: declaration.cpuSubtype,
},
];
}),
@@ -270,19 +278,20 @@ const parseFatMachO = (
if (!validated.ok) return validated;
const declarations = validated.value;
// Prefer the slice the host would load; a malformed host slice is an error.
// A container without one still declares its images, so select the first
// that can hold its Mach-O header, as a thin foreign image would be. A
// family declared more than once, such as arm64 and arm64e, has no
// provider-neutral choice and is not selected.
// Several slices of that family use subtype preference, not table order.
// Capability variants of the winning base follow FAT order, which is the
// slice codesign's family selector names. Identical subtypes, or a tie
// between different unlisted bases, are ambiguous. A container without a
// host slice still declares its images, so select the first foreign family
// declared once. A foreign family declared more than once, such as arm64
// and arm64e on an Intel host, has no provider-neutral choice.
const preferred = fatHostArchitecture(host);
const hostSlices = declarations.filter(
({ architecture }) => architecture === preferred,
);
if (hostSlices.length > 1)
const hostChoice = selectHostFatSlice(declarations, preferred);
if (hostChoice?.ambiguous === true)
return err(
`FAT binary declares several ${preferred} slices; no slice was selected`,
);
const hostSlice = hostSlices[0];
const hostSlice = hostChoice?.slice;
const unambiguous = declarations.filter(
({ architecture }) =>
declarations.filter((other) => other.architecture === architecture)
@@ -322,6 +331,50 @@ const parseFatMachO = (
});
};
/**
* The host slice, or an ambiguity when several host slices share the best
* rank without a single codesign selector.
*/
const selectHostFatSlice = (
declarations: readonly FatDeclaration[],
preferred: HeaderArchitecture | undefined,
):
| { readonly slice?: FatDeclaration; readonly ambiguous: boolean }
| undefined => {
if (preferred === undefined) return undefined;
const hostSlices = declarations.filter(
({ architecture }) => architecture === preferred,
);
const only = hostSlices[0];
if (hostSlices.length === 0 || only === undefined) return undefined;
if (hostSlices.length === 1) return { slice: only, ambiguous: false };
let bestRank = Number.POSITIVE_INFINITY;
const winners: FatDeclaration[] = [];
for (const slice of hostSlices) {
const rank = machoHostSubtypeRank(slice.cpuType, slice.cpuSubtype);
if (rank < bestRank) {
bestRank = rank;
winners.length = 0;
winners.push(slice);
} else if (rank === bestRank) winners.push(slice);
}
const first = winners[0];
if (first === undefined) return { ambiguous: true };
if (winners.length === 1) return { slice: first, ambiguous: false };
const base = machoCpuSubtypeBase(first.cpuSubtype);
if (winners.some((slice) => machoCpuSubtypeBase(slice.cpuSubtype) !== base))
return { ambiguous: true };
// A repeated full subtype is still ambiguous, even beside a distinct
// capability variant of the same base.
const subtypes = new Set<number>();
for (const slice of winners) {
if (subtypes.has(slice.cpuSubtype)) return { ambiguous: true };
subtypes.add(slice.cpuSubtype);
}
if (subtypes.size < 2) return { ambiguous: true };
return { slice: first, ambiguous: false };
};
const fatSliceIdentityError = (
bytes: Buffer,
header: Extract<
@@ -111,6 +111,26 @@ describe("web resource comparison identity and optional size", () => {
});
});
it("does not claim membership from an unavailable resource inventory", () => {
const before = capture([resource(128)]);
const after = capture([
resource(128),
resource(256, "https://app.example.test/child.css"),
]);
after.completeness.unavailable_sections.push("resources");
for (const [left, right] of [
[before, after],
[after, before],
[after, after],
] as const)
expect(compareResources(left, right)).toMatchObject({
status: "unknown",
total_changes: 0,
changes: [],
reason: expect.stringContaining("Resource inventory"),
});
});
it.each([
[[128, 256], [128], [false, true], "unknown"],
[[128], [256], [true, true], "unknown"],
+12
View File
@@ -215,6 +215,8 @@ const compareWebCaptureDimensions = (
after.resources,
sectionsComplete(before, ["resources"]),
sectionsComplete(after, ["resources"]),
resourceInventoryReadable(before),
resourceInventoryReadable(after),
),
network: networkDimension(before, after),
metadata: compareAggregate(
@@ -231,14 +233,24 @@ const compareWebCaptureDimensions = (
};
};
/** An invalid or unread collection is not a truncated sample of a real inventory. */
const resourceInventoryReadable = (inspection: WebPageInspection): boolean =>
!inspection.completeness.unavailable_sections.includes("resources");
const compareResources = (
before: WebPageInspection["resources"],
after: WebPageInspection["resources"],
beforeComplete: boolean,
afterComplete: boolean,
beforeReadable: boolean,
afterReadable: boolean,
): Dimension => {
const reason =
"Resource inventory or content-size evidence was incomplete in at least one observation.";
// Retained rows from an unavailable collection cannot prove addition,
// removal, or equality. Truncation still uses directional coverage.
if (!beforeReadable || !afterReadable)
return { status: "unknown", total_changes: 0, changes: [], reason };
const complete = beforeComplete && afterComplete;
const left = resourceGroups(before);
const right = resourceGroups(after);
+52
View File
@@ -2038,6 +2038,58 @@ describe("FAT64 dispatch slice validation", () => {
);
});
it("decodes the admitted capability variant rather than re-selecting", () => {
const variant = (subtype: number) => {
const bytes = fixture();
bytes.writeUInt32LE(subtype, 8);
return bytes;
};
const first = variant(0x81000002);
const second = variant(0x80000002);
const offset = 4096;
const secondOffset = offset + Math.ceil(first.length / 4096) * 4096;
const bytes = Buffer.alloc(secondOffset + second.length);
bytes.writeUInt32BE(0xcafebabe, 0);
bytes.writeUInt32BE(2, 4);
const writeEntry = (
index: number,
subtype: number,
sliceOffset: number,
size: number,
) => {
const base = 8 + index * 20;
bytes.writeUInt32BE(0x0100000c, base);
bytes.writeUInt32BE(subtype, base + 4);
bytes.writeUInt32BE(sliceOffset, base + 8);
bytes.writeUInt32BE(size, base + 12);
bytes.writeUInt32BE(12, base + 16);
};
writeEntry(0, 0x81000002, offset, first.length);
writeEntry(1, 0x80000002, secondOffset, second.length);
first.copy(bytes, offset);
second.copy(bytes, secondOffset);
expect(() => decodeAppleDispatchMetadata(bytes, 100, provenance)).toThrow(
"Ambiguous FAT architecture slice",
);
expect(
decodeAppleDispatchMetadata(
bytes,
100,
provenance,
"arm64",
undefined,
true,
{
offset: secondOffset,
size: second.length,
},
).objc_classes[0],
).toMatchObject({
name: "Fixture",
location: { file_offset: secondOffset + 512 },
});
});
it("rejects a missing selected architecture", () => {
expect(() =>
decodeAppleDispatchMetadata(wrapped(), 100, provenance, "x86_64"),
+4 -1
View File
@@ -45,6 +45,7 @@ import { BinaryFormatFailure } from "./metadata/BinaryContainer.js";
import {
parseMachoLayout,
UnsupportedMachoMetadataFormatError,
type AdmittedMachoSlice,
} from "./AppleMachoSelection.js";
import { decodeObjcDispatchFacets } from "./AppleObjcDispatchFacets.js";
import {
@@ -68,8 +69,9 @@ export function* decodeAppleDispatchMetadataSteps(
architecture = "arm64",
objcClassImportAddresses?: readonly string[],
objcClassImportAddressesComplete = true,
admittedSlice?: AdmittedMachoSlice,
): Generator<void, ObjcSwiftMetadata, void> {
const layout = parseMachoLayout(bytes, architecture);
const layout = parseMachoLayout(bytes, architecture, admittedSlice);
const { segments, sections, offset } = layout;
const fixups = yield* parsePointerFixupsSteps(
bytes,
@@ -393,6 +395,7 @@ export const inspectAppleDispatchMetadata = async (
target.architecture,
options.objcClassImportAddresses,
options.objcClassImportAddressesComplete ?? true,
target.format === "mach-o-universal" ? target.universalSlice : undefined,
);
let next = steps.next();
while (!next.done) {
+38
View File
@@ -513,6 +513,44 @@ describe("native Mach-O slice selection", () => {
});
});
describe("admitted Mach-O capability variants", () => {
it("reads the admitted capability variant instead of ranking again", () => {
const v1 = { type: CPU.arm64.type, subtype: 0x81000002 };
const v0 = { type: CPU.arm64.type, subtype: 0x80000002 };
const images = [v1, v0].map((cpu, index) =>
machoImage({
cpu,
commands: [
dylibCommand(LC.LOAD_DYLIB, `/fixture/variant-${index}.dylib`),
],
}),
);
const fat = Buffer.from(
fatImage([
{ cpu: v1, bytes: images[0]! },
{ cpu: v0, bytes: images[1]! },
]),
);
expect(() => parseMachoLayout(fat, "arm64")).toThrow(
/Ambiguous FAT architecture slice/u,
);
const secondOffset = 4096 + Math.ceil(images[0]!.length / 4096) * 4096;
expect(
parseMachoLayout(fat, "arm64", {
offset: secondOffset,
size: images[1]!.length,
}),
).toMatchObject({
selectedImage: {
container: "fat",
offset: secondOffset,
byteLength: images[1]!.length,
},
fixups: { dylibs: ["/fixture/variant-1.dylib"] },
});
});
});
describe("native Mach-O fixup load command boundaries", () => {
const decode = (command: Uint8Array) =>
decodeAppleDispatchMetadata(
+29 -1
View File
@@ -82,10 +82,17 @@ export interface MachoLayout {
const hex = (value: bigint) => `0x${value.toString(16)}`;
/** File range admission already chose. Metadata must not rank slices again. */
export interface AdmittedMachoSlice {
readonly offset: number;
readonly size: number;
}
/** Select the thin Mach-O slice for `architecture` inside a FAT or thin container. */
export const selectMachoSlice = (
bytes: Buffer,
architecture: string,
admitted?: AdmittedMachoSlice,
): MachoSlice => {
const header = readFatHeaderPrefix(bytes);
if (header.state === "unknown")
@@ -111,6 +118,26 @@ export const selectMachoSlice = (
littleEndian,
});
if (declarations.status !== "parsed") throw malformed(declarations.reason);
if (admitted !== undefined) {
const match = declarations.slices.find(
(declaration) =>
declaration.offset === admitted.offset &&
declaration.size === admitted.size,
);
if (match === undefined)
throw invalid("Admitted FAT slice is not declared");
if (match.cpuType !== policy.cpuType)
throw invalid(
"Admitted FAT slice does not match the requested architecture",
);
const issue = validateFatSlice(match, bytes.length, headerEnd);
if (issue !== null) throw malformed(issue);
return {
slice: match.offset,
sliceEnd: match.offset + match.size,
fatDeclaration: { ...match, tableEnd: headerEnd },
};
}
let best: FatSliceDeclaration | undefined;
let bestRank = Number.POSITIVE_INFINITY;
let ambiguous = false;
@@ -155,9 +182,10 @@ export class UnsupportedMachoMetadataFormatError extends Error {
export const parseMachoLayout = (
bytes: Buffer,
architecture: string,
admitted?: AdmittedMachoSlice,
): MachoLayout => {
if (bytes.length < 4) throw new RangeError("Truncated Mach-O header");
const selection = selectMachoSlice(bytes, architecture);
const selection = selectMachoSlice(bytes, architecture, admitted);
const { slice, sliceEnd } = selection;
if (slice + 4 > sliceEnd) throw new RangeError("Truncated Mach-O header");
const selectedBytes = bytes.subarray(slice, sliceEnd);
+29 -9
View File
@@ -1,6 +1,19 @@
import { machoCpuSubtypeBase } from "../domain/apple/machoCpuIdentity.js";
import {
ARM64_32_HOST_SUBTYPE_PREFERENCE,
ARM64_HOST_SUBTYPE_PREFERENCE,
machoCpuSubtypeBase,
X86_64_HOST_SUBTYPE_PREFERENCE,
X86_HOST_SUBTYPE_PREFERENCE,
} from "../domain/apple/machoCpuIdentity.js";
import type { LipoArchitecture } from "./parsers/lipo.js";
/** Apple tool names for the domain-owned host subtype preference. */
const preferredSubtypes = <Subtype extends number>(
order: readonly Subtype[],
names: { readonly [Key in Subtype]: string },
): { readonly subtype: Subtype; readonly name: string }[] =>
order.map((subtype) => ({ subtype, name: names[subtype] }));
interface MachoArchitecturePolicy {
readonly cpuType: number;
readonly subtypes: readonly {
@@ -14,24 +27,31 @@ interface MachoArchitecturePolicy {
export const MACHO_ARCHITECTURE_POLICIES: Readonly<
Record<string, MachoArchitecturePolicy>
> = {
x86: { cpuType: 7, subtypes: [{ subtype: 3, name: "i386" }] },
x86: {
cpuType: 7,
subtypes: preferredSubtypes(X86_HOST_SUBTYPE_PREFERENCE, { 3: "i386" }),
},
x86_64: {
cpuType: 0x01000007,
subtypes: [{ subtype: 3, name: "x86_64" }],
subtypes: preferredSubtypes(X86_64_HOST_SUBTYPE_PREFERENCE, {
3: "x86_64",
}),
},
arm: { cpuType: 12, subtypes: [], variantName: /^arm$/u },
arm64: {
cpuType: 0x0100000c,
subtypes: [
{ subtype: 0, name: "arm64" },
{ subtype: 2, name: "arm64e" },
{ subtype: 1, name: "arm64v8" },
],
subtypes: preferredSubtypes(ARM64_HOST_SUBTYPE_PREFERENCE, {
0: "arm64",
2: "arm64e",
1: "arm64v8",
}),
variantName: /^arm64e\.[A-Za-z0-9_]+$/u,
},
arm64_32: {
cpuType: 0x0200000c,
subtypes: [{ subtype: 1, name: "arm64_32" }],
subtypes: preferredSubtypes(ARM64_32_HOST_SUBTYPE_PREFERENCE, {
1: "arm64_32",
}),
},
};
+66
View File
@@ -76,6 +76,72 @@ const inspect = async (lipo: string, architecture: "x86" | "arm64") => {
return { result, commands };
};
describe("admitted Mach-O slice tools", () => {
it("binds slice tools to the admitted capability variant", async () => {
const lipo = [
"architecture arm64e.v1",
"cputype 16777228",
"cpusubtype 2164260866",
"offset 4096",
"size 4096",
"align 2^12",
"architecture arm64e",
"cputype 16777228",
"cpusubtype 2147483650",
"offset 8192",
"size 4096",
"align 2^12",
].join("\n");
const commands: {
readonly tool: string;
readonly args: readonly string[];
}[] = [];
const result = await inspectNativeMacho({
readCommandNames: inspectionReader(),
target: {
kind: "executable",
format: "mach-o-universal",
path: "/fixture/app",
sha256: "b".repeat(64),
architecture: "arm64",
availableArchitectures: ["arm64"],
universalSlice: { offset: 8192, size: 4096 },
universalSlices: [
{
offset: 4096,
size: 4096,
cpuType: 0x0100000c,
cpuSubtype: 0x81000002,
},
{
offset: 8192,
size: 4096,
cpuType: 0x0100000c,
cpuSubtype: 0x80000002,
},
],
},
run: (tool, args) => {
commands.push({ tool, args });
if (tool === "lipo") return Promise.resolve(ok(capture(tool, lipo)));
return Promise.resolve(
err(new AnalysisCancelledError("inspect_native")),
);
},
invocation: () => {
throw new Error("invocation is not reached");
},
readEncryption: unreadEncryption,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toMatchObject({ _tag: "AnalysisCancelledError" });
expect(commands.find(({ tool }) => tool === "nm")?.args).toEqual(
expect.arrayContaining(["-arch", "arm64e"]),
);
});
});
describe("Mach-O tool architecture selection", () => {
it("rejects a CPU lipo did not list instead of reading another slice", async () => {
const { result, commands } = await inspect("architecture arm64\n", "x86");
+15
View File
@@ -834,6 +834,21 @@ const selectArchitecture = (
const normalized =
target.architecture === "x86" ? "i386" : target.architecture;
const policy = MACHO_ARCHITECTURE_POLICIES[target.architecture];
if (target.format === "mach-o-universal") {
const admitted = available.find(
(candidate) =>
candidate.file_offset === target.universalSlice.offset &&
(candidate.size === null ||
candidate.size === target.universalSlice.size),
);
if (admitted === undefined)
return { status: "absent", architecture: normalized };
const toolName =
policy === undefined
? admitted.name
: machoToolArchitectureName(policy, admitted);
return { status: "selected", architecture: admitted, toolName };
}
const selected =
policy === undefined
? available.find(({ name }) => name === normalized)
+29 -5
View File
@@ -178,20 +178,44 @@ it.each([{ cpus: [0x0100000c] }, { cpus: [0x01000007, 0x0100000c] }])(
},
);
it("refuses ambiguous host subtypes even when a foreign family is selectable", async () => {
const directory = await createTestTempDirectory("rea-ambiguous-host-fat-");
it("selects the preferred arm64 subtype when arm64e and a foreign slice are present", async () => {
const directory = await createTestTempDirectory("rea-preferred-host-fat-");
const path = join(directory, "image");
const bytes = fat([0x0100000c, 0x0100000c, 0x01000007]);
bytes.writeUInt32BE(2, 8 + 20 + 4);
bytes.writeUInt32BE(2, bytes.readUInt32BE(8 + 20 + 8) + 8);
// The earlier host-family slice is arm64e. Admission still selects arm64.
bytes.writeUInt32BE(2, 8 + 4);
bytes.writeUInt32BE(2, bytes.readUInt32BE(8 + 8) + 8);
await writeFile(path, bytes);
const resolved = await parseBinaryTarget(path, { hostArchitecture: "arm64" });
if (!resolved.ok) throw resolved.error;
expect(resolved.value).toMatchObject({
kind: "executable",
format: "mach-o-universal",
architecture: "arm64",
availableArchitectures: ["arm64", "x86_64"],
});
expect(resolved.value.format).toBe("mach-o-universal");
if (resolved.value.format !== "mach-o-universal") return;
const target = resolved.value;
expect(
target.universalSlices?.find(
(slice) => slice.offset === target.universalSlice.offset,
),
).toMatchObject({ cpuType: 0x0100000c, cpuSubtype: 0 });
});
it("refuses identical host subtypes even when a foreign family is selectable", async () => {
const directory = await createTestTempDirectory("rea-ambiguous-host-fat-");
const path = join(directory, "image");
await writeFile(path, fat([0x0100000c, 0x0100000c, 0x01000007]));
expect(
await parseBinaryTarget(path, { hostArchitecture: "arm64" }),
).toMatchObject({
ok: false,
error: {
_tag: "BinaryTargetError",
reason: expect.stringContaining("several arm64 slices"),
constraint: "file_format_rejected",
reason: "FAT binary declares several arm64 slices; no slice was selected",
},
});
});