From cf3288d8cf7a09685acf89103b36c32359be56fa Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:16:44 +0800 Subject: [PATCH] fix(native): preserve Mach-O container framing across inspection --- src/domain/native/nativeInspection.ts | 2 +- src/native/NativeMachoInspection.test.ts | 37 +++++++++++++++++++ src/native/NativeMachoInspection.ts | 4 +- .../cli/nativeMachoDylibIdentityCli.test.ts | 23 +++++++++++- .../providers/native/nativeProvider.test.ts | 2 +- 5 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/domain/native/nativeInspection.ts b/src/domain/native/nativeInspection.ts index c0b7a3d46c..f89f494c6e 100644 --- a/src/domain/native/nativeInspection.ts +++ b/src/domain/native/nativeInspection.ts @@ -98,7 +98,7 @@ const buildMetadataSchema = z.object({ }); export const inspectMachoSchema = z.object({ - format: z.literal("mach-o"), + format: z.enum(["mach-o", "mach-o-universal"]), /** Exact lipo slice selected for inspection and its offset in the input container. */ selected_architecture: z.object({ name: z diff --git a/src/native/NativeMachoInspection.test.ts b/src/native/NativeMachoInspection.test.ts index 9e9ef8dd33..0a31b24e4c 100644 --- a/src/native/NativeMachoInspection.test.ts +++ b/src/native/NativeMachoInspection.test.ts @@ -143,6 +143,43 @@ describe("Mach-O tool architecture selection", () => { ); }); +describe("Mach-O inspection container format", () => { + const runInspection = async (lipo: string) => { + const runner = new NativeFixtureRunner({ lipo }); + return inspectNativeMacho({ + target: nativeMachoTarget("/fixture/app"), + run: (tool, args) => runner.run(tool, args), + invocation: (capture) => ({ + tool: capture.tool, + command: [capture.executable, ...capture.arguments], + tool_version: capture.toolVersion, + version_reason: capture.versionReason, + executable_sha256: capture.executableSha256, + exit: { code: capture.exitCode, signal: capture.signal }, + stdout_bytes: capture.stdoutBytes, + stderr_bytes: capture.stderrBytes, + }), + }); + }; + + it.each([ + { + framing: "one-slice FAT", + lipo: `Fat header in: /fixture/app\nfat_magic 0xcafebabe\nnfat_arch 1\narchitecture arm64\n cputype CPU_TYPE_ARM64\n cpusubtype CPU_SUBTYPE_ARM64_ALL\n offset 4096\n size 128\n align 2^12\n`, + expected: "mach-o-universal", + }, + { + framing: "thin", + lipo: "Non-fat file: /fixture/app is architecture: arm64\n", + expected: "mach-o", + }, + ] as const)("reports parsed $framing framing", async ({ lipo, expected }) => { + const result = await runInspection(lipo); + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ format: expected }); + }); +}); + describe("Mach-O UUID reconciliation", () => { const validUuid = "01234567-89AB-CDEF-0123-456789ABCDEF"; diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index 02c33998fd..60da647dce 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -174,6 +174,7 @@ export const inspectNativeMacho = async ( ?.file_offset ?? undefined); const normalized = normalizeMacho( captures, + format, context.invocation, limitations, load, @@ -205,6 +206,7 @@ export const inspectNativeMacho = async ( const normalizeMacho = ( captures: readonly NativeCommandCapture[], + containerFormat: "thin" | "fat", toInvocation: (capture: NativeCommandCapture) => NativeCommandInvocation, additionalLimitations: readonly string[], load: ReturnType, @@ -276,7 +278,7 @@ const normalizeMacho = ( : []), ]; return inspectMachoSchema.parse({ - format: "mach-o", + format: containerFormat === "fat" ? "mach-o-universal" : "mach-o", selected_architecture: selectedArchitecture, endian: load.endian, word_size: load.wordSize, diff --git a/tests/boundary/cli/nativeMachoDylibIdentityCli.test.ts b/tests/boundary/cli/nativeMachoDylibIdentityCli.test.ts index 8104786549..091897b891 100644 --- a/tests/boundary/cli/nativeMachoDylibIdentityCli.test.ts +++ b/tests/boundary/cli/nativeMachoDylibIdentityCli.test.ts @@ -14,7 +14,7 @@ describe.skipIf(process.platform !== "darwin")( "native Mach-O CLI dylib identity", () => { cliTest( - "keeps a dylib's own install name out of its dependencies", + "preserves dylib identity and thin or single-slice FAT framing", async ({ cli, processes }) => { const directory = await createTestTempDirectory("rea-dylib-identity-"); const source = join(directory, "library.c"); @@ -51,6 +51,27 @@ describe.skipIf(process.platform !== "darwin")( ({ kind }) => kind === "LC_ID_DYLIB", )?.fields.name, ).toBe("@rpath/libprobe.dylib (offset 24)"); + + // One architecture can still have a FAT container header. + const universal = join(directory, "libprobe-universal.dylib"); + const combined = await processes.run( + "/usr/bin/lipo", + ["-create", library, "-output", universal], + { timeoutMs: 30_000 }, + ); + expect(combined.exitCode, combined.stderr).toBe(0); + const fatResult = await cli.run({ + arguments: ["inspect-macho", universal, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(fatResult.exitCode, fatResult.stdout + fatResult.stderr).toBe(0); + const fat = evidenceSchema.parse(fatResult.json).normalized_result; + expect(fat.format).toBe("mach-o-universal"); + expect(fat.architectures.items).toHaveLength(1); + expect(fatResult.json).toMatchObject({ + subject: { format: fat.format }, + }); + expect(inspected.format).toBe("mach-o"); }, ); }, diff --git a/tests/boundary/providers/native/nativeProvider.test.ts b/tests/boundary/providers/native/nativeProvider.test.ts index 62966c949d..8e13d2b01a 100644 --- a/tests/boundary/providers/native/nativeProvider.test.ts +++ b/tests/boundary/providers/native/nativeProvider.test.ts @@ -631,7 +631,7 @@ describe("native macOS provider inspection", () => { if (!execution.ok) throw execution.error; expect(execution.value.provider.id).toBe("native-macos"); expect(execution.value.result).toMatchObject({ - format: "mach-o", + format: "mach-o-universal", word_size: 64, uuid: "01234567-89AB-CDEF-0123-456789ABCDEF", architectures: { total: 2, exhaustive: true },