diff --git a/docs/godot-pck.md b/docs/godot-pck.md index 5143861737..dfd482a4f7 100644 --- a/docs/godot-pck.md +++ b/docs/godot-pck.md @@ -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 diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 7302bb19af..5c88ac40d4 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -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 ( diff --git a/src/domain/apple/machoCpuIdentity.ts b/src/domain/apple/machoCpuIdentity.ts index fef823f92f..cee540aec3 100644 --- a/src/domain/apple/machoCpuIdentity.ts +++ b/src/domain/apple/machoCpuIdentity.ts @@ -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 +> = { + 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; diff --git a/src/domain/artifactComparison.test.ts b/src/domain/artifactComparison.test.ts index 303df9222a..6b562d6c94 100644 --- a/src/domain/artifactComparison.test.ts +++ b/src/domain/artifactComparison.test.ts @@ -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, + right: ReturnType, + ) => + 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( diff --git a/src/domain/artifactComparison.ts b/src/domain/artifactComparison.ts index 92106acea2..2cfd99de6e 100644 --- a/src/domain/artifactComparison.ts +++ b/src/domain/artifactComparison.ts @@ -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: { diff --git a/src/domain/binaryTarget.test.ts b/src/domain/binaryTarget.test.ts index 9cbb80ac15..c22a49dd45 100644 --- a/src/domain/binaryTarget.test.ts +++ b/src/domain/binaryTarget.test.ts @@ -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", () => { diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index a72c46261d..a1723e9d71 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -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(); + 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< diff --git a/src/domain/webCaptureDiff.resources.test.ts b/src/domain/webCaptureDiff.resources.test.ts index f840b6308f..7475f55ad1 100644 --- a/src/domain/webCaptureDiff.resources.test.ts +++ b/src/domain/webCaptureDiff.resources.test.ts @@ -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"], diff --git a/src/domain/webCaptureDiff.ts b/src/domain/webCaptureDiff.ts index 781fb30345..61a93e97d6 100644 --- a/src/domain/webCaptureDiff.ts +++ b/src/domain/webCaptureDiff.ts @@ -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); diff --git a/src/native/AppleDispatchMetadata.test.ts b/src/native/AppleDispatchMetadata.test.ts index 13cff3d7eb..4e40ef577d 100644 --- a/src/native/AppleDispatchMetadata.test.ts +++ b/src/native/AppleDispatchMetadata.test.ts @@ -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"), diff --git a/src/native/AppleDispatchMetadata.ts b/src/native/AppleDispatchMetadata.ts index 3f814833ac..9d5dddce90 100644 --- a/src/native/AppleDispatchMetadata.ts +++ b/src/native/AppleDispatchMetadata.ts @@ -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 { - 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) { diff --git a/src/native/AppleMachoSelection.test.ts b/src/native/AppleMachoSelection.test.ts index 42e5465dcc..0e55740191 100644 --- a/src/native/AppleMachoSelection.test.ts +++ b/src/native/AppleMachoSelection.test.ts @@ -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( diff --git a/src/native/AppleMachoSelection.ts b/src/native/AppleMachoSelection.ts index dabe31a00f..34c3ca4fc2 100644 --- a/src/native/AppleMachoSelection.ts +++ b/src/native/AppleMachoSelection.ts @@ -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); diff --git a/src/native/MachoArchitectureSelection.ts b/src/native/MachoArchitectureSelection.ts index 8463faa0ec..6f10841f14 100644 --- a/src/native/MachoArchitectureSelection.ts +++ b/src/native/MachoArchitectureSelection.ts @@ -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 = ( + 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 > = { - 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", + }), }, }; diff --git a/src/native/NativeMachoInspection.test.ts b/src/native/NativeMachoInspection.test.ts index 9ac3f7a78e..bb5a0d7850 100644 --- a/src/native/NativeMachoInspection.test.ts +++ b/src/native/NativeMachoInspection.test.ts @@ -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"); diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index 55f363b0ab..5fc1371c6b 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -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) diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index bcc7bc3c72..305a6ceee1 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -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", }, }); });