diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 76f2ea7701..ee80f42f6e 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -335,6 +335,15 @@ Mach-O fixtures check each case against source-owned return values. ## Static inspection +`inspect_artifact` and `extract_artifact` retain universal Mach-O slices under +their exact `lipo` names, including 32-bit ARM implementations such as `armv7`, +`armv7s`, and `armv7k`. These names are reconciled with the FAT CPU subtype; +the structural reader's `arm` family label alone does not identify a slice. +Available CPU type, subtype/capability bits, byte range, and alignment remain +checked against the source bytes. Inventory records source ranges and hashes, and +extraction copies those ranges without running the target. This does not +expand any analysis provider's supported architectures. + `inspect_plist.format` describes the selected source, not plutil's conversion format. Sources explicitly identified as XML, binary or JSON retain those labels. For generic text, a bounded source-prefix probe recognizes leading XML diff --git a/src/artifacts/MachOSliceArchitecture.test.ts b/src/artifacts/MachOSliceArchitecture.test.ts new file mode 100644 index 0000000000..50b9a22527 --- /dev/null +++ b/src/artifacts/MachOSliceArchitecture.test.ts @@ -0,0 +1,108 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, onTestFinished } from "vitest"; + +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { ok } from "../domain/result.js"; +import { fatImage, machoImage } from "./apple/MachoImage.fixture.js"; +import { MachOSliceArtifactReader } from "./MachOSliceArtifactReader.js"; + +const armSlices = async ( + name: string, + subtype: number, + reported: { + readonly type?: number; + readonly subtype?: number; + readonly offset?: number; + readonly size?: number; + readonly alignment?: number; + } = {}, +) => { + const root = await createTestTempDirectory("rea-arm-slice-identity-"); + const path = join(root, "fat"); + const cpu = { type: 12, subtype }; + const bytes = machoImage({ cpu, wide: false }); + await writeFile(path, fatImage([{ cpu, bytes }])); + const reader = new MachOSliceArtifactReader( + path, + {}, + { + run: async () => + ok({ + tool: "lipo", + executable: "/usr/bin/lipo", + executableSha256: "1".repeat(64), + toolVersion: null, + versionReason: "fixture", + arguments: ["-detailed_info", path], + stdout: `architecture ${name}\n cputype ${reported.type ?? cpu.type}\n cpusubtype ${reported.subtype ?? subtype}\n offset ${reported.offset ?? 4096}\n size ${reported.size ?? bytes.length}\n align 2^${reported.alignment ?? 12}\n`, + stderr: "", + stdoutBytes: 1, + stderrBytes: 0, + exitCode: 0, + signal: null, + }), + }, + ); + onTestFinished(() => reader.close()); + const entries = []; + for await (const entry of reader.entries()) entries.push(entry); + return entries; +}; + +it.each([ + ["armv4t", 5], + ["armv5", 7], + ["xscale", 8], + ["armv6", 6], + ["armv7", 9], + ["armv7f", 10], + ["armv7s", 11], + ["armv7k", 12], + ["armv6m", 14], + ["armv7m", 15], + ["armv7em", 16], + ["armv8m.main", 17], + ["armv8m.base", 18], + ["armv8.1m.main", 19], + ["armv7", 0x80000009], +] as const)( + "matches %s to its physical ARM subtype %i", + async (name, subtype) => { + const entries = await armSlices(name, subtype); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + path: `slices/${name}`, + byteOffset: 4096, + declaredSize: 44, + }); + expect(entries[0]?.limitations).toEqual([]); + }, +); + +it.each([ + ["armv7s", 9], + ["armv7", 11], + ["armv7", 42], + ["armv99", 9], +] as const)( + "rejects the inconsistent name %s for subtype %i", + async (name, subtype) => { + await expect(armSlices(name, subtype)).rejects.toMatchObject({ + reason: "integrity", + }); + }, +); + +it.each([ + { type: 0x0100000c }, + { subtype: 11 }, + { subtype: 0x80000009 }, + { offset: 4097 }, + { size: 45 }, + { alignment: 11 }, +])("still rejects physical metadata disagreement %j", async (reported) => { + await expect(armSlices("armv7", 9, reported)).rejects.toMatchObject({ + reason: "integrity", + }); +}); diff --git a/src/artifacts/MachOSliceArtifactReader.ts b/src/artifacts/MachOSliceArtifactReader.ts index d56cbe808c..355fe04b69 100644 --- a/src/artifacts/MachOSliceArtifactReader.ts +++ b/src/artifacts/MachOSliceArtifactReader.ts @@ -523,7 +523,7 @@ const lipoMatchesSlice = ( reported.cpu_subtype_code !== null; return ( (hasPhysicalIdentity || structurallyUnique) && - lipoNameMatches(reported.name, observed.architecture) && + lipoNameMatches(reported.name, observed) && (reported.file_offset === null || reported.file_offset === observed.slice_offset) && (reported.size === null || reported.size === observed.slice_size) && @@ -552,11 +552,36 @@ const lipoSubtypeMatches = ( ); }; -/** - * Newer lipo names an arm64e ABI variant `arm64e.` (for example - * `arm64e.v1` for pointer-auth ABI version 1). The FAT table names that slice - * `arm64e`; its physical identity is still compared field by field. - */ -const lipoNameMatches = (reported: string, observed: string): boolean => - reported === observed || - (observed === "arm64e" && /^arm64e\.[A-Za-z0-9_]+$/u.test(reported)); +/** ARM implementation names from cctools libstuff/arch.c, keyed by lipo spelling. */ +const ARM_SUBTYPES: Readonly> = { + armv4t: 5, + armv5: 7, + xscale: 8, + armv6: 6, + armv7: 9, + armv7f: 10, + armv7s: 11, + armv7k: 12, + armv6m: 14, + armv7m: 15, + armv7em: 16, + "armv8m.main": 17, + "armv8m.base": 18, + "armv8.1m.main": 19, +}; + +/** Reconcile lipo's specific name with the structural reader's CPU family. */ +const lipoNameMatches = (reported: string, observed: MachoSlice): boolean => { + if (reported === observed.architecture) return true; + if (observed.cpu_type === 12 && observed.architecture === "arm") + return ( + ARM_SUBTYPES[reported] === + machoCpuSubtypeBase(observed.fat_cpu_subtype ?? observed.cpu_subtype) + ); + // Newer lipo names pointer-auth ABI variants arm64e.. Their + // physical type/subtype, range and alignment are still compared separately. + return ( + observed.architecture === "arm64e" && + /^arm64e\.[A-Za-z0-9_]+$/u.test(reported) + ); +}; diff --git a/tests/boundary/cli/artifactArmSlicesCli.test.ts b/tests/boundary/cli/artifactArmSlicesCli.test.ts new file mode 100644 index 0000000000..530b356885 --- /dev/null +++ b/tests/boundary/cli/artifactArmSlicesCli.test.ts @@ -0,0 +1,58 @@ +import { rm } from "node:fs/promises"; +import { describe, expect } from "vitest"; +import { z } from "zod"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { artifactExtractionResultSchema } from "../../../src/domain/artifactGraph.js"; +import { + createArmSliceFixture, + expectArmSliceExtraction, + expectArmSliceInventory, +} from "../../fixtures/machoArmSlices.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const envelope = z.object({ ok: z.literal(true), data: z.unknown() }); + +describe.skipIf(process.platform !== "darwin")( + "ARM slice CLI with system lipo", + () => { + cliTest( + "inspects and extracts exact ARM subtype slices", + async ({ cli, onTestFinished }) => { + const fixture = await createArmSliceFixture(); + const inspected = await cli.run({ + arguments: [ + "inspect-artifact", + fixture.path, + "--json", + "--full-output", + ], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(inspected.exitCode, inspected.stdout + inspected.stderr).toBe(0); + const manifest = expectArmSliceInventory( + parseEvidence(envelope.parse(inspected.json).data).normalized_result, + fixture, + ); + const result = await cli.run({ + arguments: [ + "extract-artifact", + fixture.path, + "--json", + "--full-output", + ], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(result.exitCode, result.stdout + result.stderr).toBe(0); + const extracted = artifactExtractionResultSchema.parse( + parseEvidence(envelope.parse(result.json).data).normalized_result, + ); + onTestFinished(() => + rm(extracted.output_root, { recursive: true, force: true }), + ); + expect(await expectArmSliceExtraction(extracted, fixture)).toEqual( + manifest, + ); + }, + ); + }, +); diff --git a/tests/boundary/mcp/artifactArmSlicesMcp.test.ts b/tests/boundary/mcp/artifactArmSlicesMcp.test.ts new file mode 100644 index 0000000000..a8546d5afa --- /dev/null +++ b/tests/boundary/mcp/artifactArmSlicesMcp.test.ts @@ -0,0 +1,64 @@ +import { rm } from "node:fs/promises"; +import { describe, expect } from "vitest"; +import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js"; +import { artifactExtractionResultSchema } from "../../../src/domain/artifactGraph.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { + createArmSliceFixture, + expectArmSliceExtraction, + expectArmSliceInventory, +} from "../../fixtures/machoArmSlices.js"; +import { mcpTest } from "../../support/mcp/mcpFixture.js"; + +describe.skipIf(process.platform !== "darwin")( + "ARM slice MCP with system lipo", + () => { + mcpTest( + "retains physical slice identities through inspection and extraction", + async ({ mcp, onTestFinished }) => { + const fixture = await createArmSliceFixture(); + const session = createTestBinarySession( + new ArtifactProvider(process.env), + ); + onTestFinished(async () => { + expect((await session.close()).ok).toBe(true); + }); + const client = await mcp.connect( + createServer({ kind: "session", session }), + ); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path: fixture.path }, + }); + expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true); + const inspected = await client.callTool({ + name: "inspect_artifact", + arguments: {}, + }); + expect(inspected.isError, JSON.stringify(inspected.content)).not.toBe( + true, + ); + const manifest = expectArmSliceInventory( + parseEvidence(inspected.structuredContent).normalized_result, + fixture, + ); + const result = await client.callTool({ + name: "extract_artifact", + arguments: {}, + }); + expect(result.isError, JSON.stringify(result.content)).not.toBe(true); + const extracted = artifactExtractionResultSchema.parse( + parseEvidence(result.structuredContent).normalized_result, + ); + onTestFinished(() => + rm(extracted.output_root, { recursive: true, force: true }), + ); + expect(await expectArmSliceExtraction(extracted, fixture)).toEqual( + manifest, + ); + }, + ); + }, +); diff --git a/tests/fixtures/machoArmSlices.ts b/tests/fixtures/machoArmSlices.ts new file mode 100644 index 0000000000..a7ff070af4 --- /dev/null +++ b/tests/fixtures/machoArmSlices.ts @@ -0,0 +1,105 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { + CPU, + fatImage, + machoImage, +} from "../../src/artifacts/apple/MachoImage.fixture.js"; +import { artifactExtractionResultSchema } from "../../src/domain/artifactGraph.js"; +import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js"; +import { createTestTempDirectory } from "./temporaryDirectory.js"; + +const sha256 = (bytes: Uint8Array): string => + createHash("sha256").update(bytes).digest("hex"); + +/** Self-owned Mach-O headers; lipo reads these bytes without executing the target. */ +export const createArmSliceFixture = async () => { + const root = await createTestTempDirectory("rea-arm-slices-"); + const path = join(root, "universal"); + const slices = [ + { name: "armv7", cpu: { type: 12, subtype: 9 }, wide: false }, + { name: "armv7s", cpu: { type: 12, subtype: 11 }, wide: false }, + { name: "arm64", cpu: CPU.arm64, wide: true }, + ].map(({ name, cpu, wide }, index) => ({ + name, + cpu, + bytes: machoImage({ cpu, wide }), + offset: 4096 * (index + 1), + })); + const bytes = Buffer.from(fatImage(slices)); + await writeFile(path, bytes); + return { path, bytes, slices }; +}; + +/** Assert byte-backed slice identity independently of the reader's reconciliation. */ +export const expectArmSliceInventory = ( + value: unknown, + fixture: Awaited>, +) => { + const inspected = artifactInspectionResultSchema.parse(value); + const inventory = inspected.substeps[0]?.evidence.normalized_result; + if (inventory === undefined) throw new Error("Expected inventory Evidence"); + expect(inventory.manifest.root_sha256).toBe(sha256(fixture.bytes)); + expect(inventory.coverage).toEqual({ + scope: "container-entries", + status: "complete", + }); + expect(inventory.integrity_contradictions).toEqual([]); + expect( + inventory.occurrences.filter(({ entry_kind }) => entry_kind === "slice"), + ).toHaveLength(fixture.slices.length); + for (const slice of fixture.slices) { + const occurrence = inventory.occurrences.find( + ({ logical_path }) => logical_path === `slices/${slice.name}`, + ); + expect(occurrence).toMatchObject({ + entry_kind: "slice", + hash_status: "verified", + source_location: { offset: slice.offset, length: slice.bytes.length }, + }); + expect( + inventory.nodes.find( + ({ artifact_id }) => artifact_id === occurrence?.artifact_id, + ), + ).toMatchObject({ sha256: sha256(slice.bytes), size: slice.bytes.length }); + expect( + inventory.edges.find( + ({ occurrence_id }) => occurrence_id === occurrence?.occurrence_id, + ), + ).toMatchObject({ relation: "slice-of" }); + } + expect(inventory.provenance).toEqual( + expect.arrayContaining([ + expect.objectContaining({ tool: "lipo", effects: ["read"] }), + ]), + ); + return inventory.manifest; +}; + +/** Verify every output's actual bytes, its reported digest, and the unchanged input. */ +export const expectArmSliceExtraction = async ( + value: unknown, + fixture: Awaited>, +) => { + const extracted = artifactExtractionResultSchema.parse(value); + expect(extracted.containment_verified).toBe(true); + expect(extracted.artifacts).toHaveLength(fixture.slices.length); + for (const slice of fixture.slices) { + const relativePath = `slices/${slice.name}`; + const bytes = await readFile(join(extracted.output_root, relativePath)); + expect(bytes).toEqual(Buffer.from(slice.bytes)); + expect( + extracted.artifacts.find( + ({ relative_path }) => relative_path === relativePath, + ), + ).toMatchObject({ + sha256: sha256(bytes), + bytes_written: slice.bytes.length, + }); + } + expect(await readFile(fixture.path)).toEqual(fixture.bytes); + return extracted.manifest; +};