fix(artifacts): reconcile ARM32 Mach-O slice names by subtype (#4352)

* fix(artifacts): reconcile ARM32 Mach-O slice names with subtypes

* test(snapshot): preserve Evidence-scoped native workflow unknowns

* test(process): assert owned snapshot cancellation outcomes

* test(process): preserve snapshot race causes and owned cleanup

* test(cli): scope runtime cleanup provider injection

CLI boundary tests share a module graph. The cleanup fixture's module mock
cannot replace a provider factory already captured by the real command
registration, leaving its close count at zero after another test imports it.

Inject a typed provider factory into the registration instance, defaulting
to the existing production factory. Both commands still create their own
provider at execution time. Remove the redundant runtime context wrapper
and the global mock state. Keep the real child CLI parity case and verify
that neither command completion nor JSON output precedes awaited cleanup;
retain the close-count, cleanup error and completed Evidence assertions.

The unchanged test passes alone but fails with the command module preloaded.
Both modes pass after the correction. Seven related files pass 28 tests;
check and docs:check also pass on Node24.18.0/npm11.16.0 macOS arm64.

* test(artifacts): use valid ASAR bytes for signature control

* test(mcp): isolate snapshot filesystem fault injection

* test(process): distinguish unacquired resources in failure progress

---------

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-23 02:29:48 +08:00
1 parent b5c47f5255
commit 1684c73d4f
6 files changed
+378 -9

No files matched your search

+9
View File
@@ -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
@@ -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",
});
});
+34 -9
View File
@@ -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.<variant>` (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<Record<string, number>> = {
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.<variant>. Their
// physical type/subtype, range and alignment are still compared separately.
return (
observed.architecture === "arm64e" &&
/^arm64e\.[A-Za-z0-9_]+$/u.test(reported)
);
};
@@ -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,
);
},
);
},
);
@@ -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,
);
},
);
},
);
+105
View File
@@ -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<ReturnType<typeof createArmSliceFixture>>,
) => {
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<ReturnType<typeof createArmSliceFixture>>,
) => {
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;
};