From e3d8d20236e317e959e138d3dfb37e81d00a1cf3 Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:48:42 +0800 Subject: [PATCH] fix(native): preserve dispatch metadata format refusals (#8013) * fix(native): preserve dispatch metadata format refusals * test(native): normalize CLI format refusal envelopes * test(native): assert admitted metadata target paths --- docs/native-investigation.md | 5 + scripts/verify/apple/dispatch.mjs | 72 ++++++++++++ .../AppleDispatchMetadata.failure.test.ts | 22 ++++ src/native/AppleDispatchMetadata.ts | 20 +++- src/native/AppleMachoSelection.ts | 5 +- src/native/AppleObjcCategories.ts | 6 +- src/native/AppleObjcDispatchFacets.ts | 11 +- src/native/AppleSwiftDispatchFacets.ts | 6 +- .../nativeDispatchFormatFailures.test.ts | 103 ++++++++++++++++++ .../native/objcClassMetadataFallback.test.ts | 4 +- 10 files changed, 247 insertions(+), 7 deletions(-) create mode 100644 tests/boundary/providers/native/nativeDispatchFormatFailures.test.ts diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 15350f894e..24c4597445 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -757,6 +757,11 @@ complete observation. files and universal slices before metadata decoding. CPU subtypes and capability bits remain source facts; an `arm64` request may select an `arm64e` image under the existing slice preference policy. + Objective-C class, protocol and category lists require eight-byte entries; + Swift type and conformance sections require four-byte entries. Misaligned + section lengths and unsupported non-PowerPC big-endian CPU declarations return + `unsupported_target` with the selected path and precise reason. Recovery + selects a well-formed or supported artifact and reopens that target. The byte reader holds the whole selected file in memory, so it admits files up to 64 MiB / 67108864 bytes by default. Select a larger budget with `max_input_bytes` (CLI `--max-input-bytes`), at most 1 GiB / 1073741824 diff --git a/scripts/verify/apple/dispatch.mjs b/scripts/verify/apple/dispatch.mjs index 5f419e89ad..9d7c4a3054 100644 --- a/scripts/verify/apple/dispatch.mjs +++ b/scripts/verify/apple/dispatch.mjs @@ -10,10 +10,14 @@ import { ArtifactResourceScope } from "../../../dist/artifacts/ArtifactResourceS import { inspectAppleDispatchMetadata } from "../../../dist/native/AppleDispatchMetadata.js"; import { artifactCliEvidence, + artifactMcpCall, artifactMcpResult, withArtifactMcp, } from "../../lib/artifact-e2e.mjs"; import { nativeDispatchMetadataResultSchema } from "../../../dist/domain/native/objcSwiftMetadata.js"; +import { analysisErrorProjectionSchema } from "../../../dist/contracts/errorSchemas.js"; +import { cliOperationFailurePayload } from "../../../dist/cliLogging.js"; +import { requireMcpToolError } from "../../lib/mcp-verifier-results.mjs"; if (process.platform !== "darwin") throw new Error( @@ -344,6 +348,74 @@ try { commands, target_sha256: cliResult.target_sha256, }); + for (const { section, size, reason } of [ + { + section: "__objc_classlist", + size: 12, + reason: "Misaligned Objective-C class-list size", + }, + { + section: "__swift5_types", + size: 6, + reason: "Misaligned Swift type section", + }, + ]) { + const bytes = Buffer.alloc(4096); + bytes.writeUInt32LE(0xfeedfacf); + bytes.writeUInt32LE(0x0100000c, 4); + bytes.writeUInt32LE(2, 12); + bytes.writeUInt32LE(1, 16); + bytes.writeUInt32LE(152, 20); + bytes.writeUInt32LE(0x19, 32); // LC_SEGMENT_64 + bytes.writeUInt32LE(152, 36); + bytes.write("__DATA", 40); + bytes.writeBigUInt64LE(0x100000000n, 56); + bytes.writeBigUInt64LE(4096n, 64); + bytes.writeBigUInt64LE(4096n, 80); + bytes.writeUInt32LE(7, 88); + bytes.writeUInt32LE(5, 92); + bytes.writeUInt32LE(1, 96); + bytes.write(section, 104); + bytes.write("__DATA", 120); + bytes.writeBigUInt64LE(0x100000200n, 136); + bytes.writeBigUInt64LE(BigInt(size), 144); + bytes.writeUInt32LE(512, 152); + const path = join(root, `malformed-${section}.macho`); + await writeFile(path, bytes); + const target = await parseBinaryTarget(path); + assert.ok( + target.ok, + "Malformed section fixture must pass target admission", + ); + let cliFailure; + try { + await artifactCliEvidence("inspect-native-dispatch-metadata", path); + assert.fail("Malformed metadata unexpectedly succeeded"); + } catch (cause) { + assert.equal(cause.code, 1); + cliFailure = analysisErrorProjectionSchema.parse( + cliOperationFailurePayload(JSON.parse(cause.stdout)), + ); + } + assert.equal(cliFailure.code, "unsupported_target"); + assert.equal(cliFailure.category, "unsupported_target"); + assert.deepEqual(cliFailure.details, { + operation: "inspect_native_dispatch_metadata", + path: target.value.path, + reason, + }); + await withArtifactMcp(path, async (client) => { + const failure = requireMcpToolError( + await artifactMcpCall(client, { + name: "inspect_native_dispatch_metadata", + arguments: {}, + }), + ); + assert.equal(failure.code, cliFailure.code); + assert.deepEqual(failure.details, cliFailure.details); + }); + results.push({ variant: `malformed-${section}`, reason }); + } for (const mode of LINK_MODES) { const objcPath = join(root, `fixture-${mode.mode}`); await exec("/usr/bin/xcrun", [ diff --git a/src/native/AppleDispatchMetadata.failure.test.ts b/src/native/AppleDispatchMetadata.failure.test.ts index 8c9a36141a..f60cdd100a 100644 --- a/src/native/AppleDispatchMetadata.failure.test.ts +++ b/src/native/AppleDispatchMetadata.failure.test.ts @@ -1,6 +1,28 @@ import { expect, it } from "vitest"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { appleDispatchMetadataFailure } from "./AppleDispatchMetadata.js"; +import { BinaryFormatFailure } from "./metadata/BinaryContainer.js"; + +it.each(["malformed", "unsupported"] as const)( + "keeps an explicit %s metadata refusal target-specific", + (kind) => { + const cause = new BinaryFormatFailure(kind, "metadata format refused"); + expect(appleDispatchMetadataFailure(cause, "/selected")).toMatchObject({ + _tag: "AnalysisUnsupportedTargetError", + path: "/selected", + reason: "metadata format refused", + }); + }, +); + +it.each([new RangeError("unexpected range"), new TypeError("unexpected type")])( + "keeps an unclassified parser exception as a provider failure (%s)", + (cause) => { + expect(appleDispatchMetadataFailure(cause, "/selected")._tag).toBe( + "ProviderAdapterError", + ); + }, +); // The metadata target is already bound, so losing it is a change to that artifact rather than a new input error. it.each(["integrity", "path"] as const)( diff --git a/src/native/AppleDispatchMetadata.ts b/src/native/AppleDispatchMetadata.ts index 0c871d586b..f064327444 100644 --- a/src/native/AppleDispatchMetadata.ts +++ b/src/native/AppleDispatchMetadata.ts @@ -18,6 +18,7 @@ import { AnalysisAccessDeniedError, AnalysisCapabilityUnavailableError, AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, } from "../domain/analysisErrorCore.js"; import { ArtifactFileCleanupFailure, @@ -41,6 +42,7 @@ import { readBoundedFileBytes } from "../process/BoundedFileBytes.js"; import { EvidenceIntegrityError } from "../domain/evidenceErrors.js"; import { pushDispatchCoverage } from "./AppleDispatchCoverage.js"; import { parsePointerFixups } from "./AppleMachoFixups.js"; +import { BinaryFormatFailure } from "./metadata/BinaryContainer.js"; import { parseMachoLayout, UnsupportedMachoMetadataFormatError, @@ -230,7 +232,10 @@ export const decodeAppleDispatchMetadata = ( ({ name }) => name === "__swift5_types", )) { if (section.size % 4 !== 0) - throw new RangeError("Misaligned Swift type section"); + throw new BinaryFormatFailure( + "malformed", + "Misaligned Swift type section", + ); for (let index = 0; index < section.size / 4 && index < maxRecords; index++) typeEntries.push(section.address + BigInt(index * 4)); if (section.size / 4 > maxRecords) budget.truncated = true; @@ -445,6 +450,19 @@ const appleDispatchMetadataPrimaryFailure = ( ): AnalysisError => { if (origin === "owned-or-classified") { if (cause instanceof AnalysisError) return cause; + if (cause instanceof BinaryFormatFailure) + return new AnalysisUnsupportedTargetError( + OPERATION, + path, + cause.message, + { + cause, + remediationAction: + cause.kind === "malformed" + ? "Select a Mach-O artifact with well-formed metadata sections, reopen the target, then retry." + : "Select a supported little-endian 64-bit Mach-O artifact, reopen the target, then retry.", + }, + ); if (cause instanceof UnsupportedMachoMetadataFormatError) return new AnalysisCapabilityUnavailableError( IDENTITY.id, diff --git a/src/native/AppleMachoSelection.ts b/src/native/AppleMachoSelection.ts index 2cba27ad30..08f3169cbf 100644 --- a/src/native/AppleMachoSelection.ts +++ b/src/native/AppleMachoSelection.ts @@ -183,7 +183,10 @@ export const parseMachoLayout = ( } if (table.wide && !table.littleEndian) { if (cpuType !== 0x01000012) - throw new TypeError("Unsupported big-endian Mach-O CPU type"); + throw new BinaryFormatFailure( + "unsupported", + "Unsupported big-endian Mach-O CPU type", + ); throw new UnsupportedMachoMetadataFormatError( "Unsupported big-endian 64-bit PowerPC Mach-O metadata", ); diff --git a/src/native/AppleObjcCategories.ts b/src/native/AppleObjcCategories.ts index fc36c311d2..ae4b5dd329 100644 --- a/src/native/AppleObjcCategories.ts +++ b/src/native/AppleObjcCategories.ts @@ -3,6 +3,7 @@ import type { Section } from "./AppleMachoSelection.js"; import type { ObjcDispatchReaders } from "./AppleObjcDispatchFacets.js"; import { readObjcPropertiesOf } from "./AppleObjcProperties.js"; import { issue, type DecodeIssue } from "./AppleDispatchDecodeFacts.js"; +import { BinaryFormatFailure } from "./metadata/BinaryContainer.js"; const CLASS_SYMBOL = /^_OBJC_(?:META)?CLASS_\$_(.+)$/u; @@ -59,7 +60,10 @@ export const decodeObjcCategories = (input: { ({ name }) => name === "__objc_catlist", )) { if (section.size % 8 !== 0) - throw new RangeError("Misaligned Objective-C category list"); + throw new BinaryFormatFailure( + "malformed", + "Misaligned Objective-C category list", + ); for (let index = 0; index < section.size / 8; index++) { if (!input.admit()) break; examined++; diff --git a/src/native/AppleObjcDispatchFacets.ts b/src/native/AppleObjcDispatchFacets.ts index 047642bbd9..fd46e5eab8 100644 --- a/src/native/AppleObjcDispatchFacets.ts +++ b/src/native/AppleObjcDispatchFacets.ts @@ -6,6 +6,7 @@ import { import type { Segment, Section } from "./AppleMachoSelection.js"; import { boundClassName, decodeObjcCategories } from "./AppleObjcCategories.js"; import { readObjcPropertiesOf } from "./AppleObjcProperties.js"; +import { BinaryFormatFailure } from "./metadata/BinaryContainer.js"; import type { DecodeIssue } from "./AppleDispatchDecodeFacts.js"; import { createObjcProtocolReader } from "./AppleObjcProtocols.js"; import type { @@ -250,7 +251,10 @@ export const decodeObjcDispatchFacets = (input: { ({ name }) => name === "__objc_protolist", )) { if (section.size % 8 !== 0) - throw new RangeError("Misaligned Objective-C protocol list"); + throw new BinaryFormatFailure( + "malformed", + "Misaligned Objective-C protocol list", + ); for (let index = 0; index < section.size / 8; index++) { if (budget.isFull()) { budget.markTruncated(); @@ -406,7 +410,10 @@ export const decodeObjcDispatchFacets = (input: { ({ name }) => name === "__objc_classlist", )) { if (section.size % 8 !== 0) - throw new RangeError("Misaligned Objective-C class-list size"); + throw new BinaryFormatFailure( + "malformed", + "Misaligned Objective-C class-list size", + ); for (let index = 0; index < section.size / 8; index++) { if (budget.isFull()) { budget.markTruncated(); diff --git a/src/native/AppleSwiftDispatchFacets.ts b/src/native/AppleSwiftDispatchFacets.ts index 8d223a5b97..c014d49ba1 100644 --- a/src/native/AppleSwiftDispatchFacets.ts +++ b/src/native/AppleSwiftDispatchFacets.ts @@ -5,6 +5,7 @@ import { import type { ObjcSwiftMetadata } from "../domain/native/objcSwiftMetadata.js"; import type { PointerResolution } from "./AppleMachoFixups.js"; import type { Segment, Section } from "./AppleMachoSelection.js"; +import { BinaryFormatFailure } from "./metadata/BinaryContainer.js"; import { issue, type FacetDecodeFacts } from "./AppleDispatchDecodeFacts.js"; import { decodeSwiftConformanceTypeReference, @@ -325,7 +326,10 @@ export const decodeSwiftDispatchFacets = (input: { ({ name }) => name === "__swift5_proto", )) { if (section.size % 4 !== 0) - throw new RangeError("Misaligned Swift conformance section"); + throw new BinaryFormatFailure( + "malformed", + "Misaligned Swift conformance section", + ); for (let index = 0; index < section.size / 4; index++) { if (!budget.admit()) break; swiftExamined++; diff --git a/tests/boundary/providers/native/nativeDispatchFormatFailures.test.ts b/tests/boundary/providers/native/nativeDispatchFormatFailures.test.ts new file mode 100644 index 0000000000..b9def76798 --- /dev/null +++ b/tests/boundary/providers/native/nativeDispatchFormatFailures.test.ts @@ -0,0 +1,103 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; + +import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; +import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js"; +import { + NativeFixtureRunner, + nativeMachoTargetForFile, +} from "../../../fixtures/nativeCommands.js"; +import { objcRecordBudgetMacho } from "../../../fixtures/objcRecordBudgetMacho.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +const sectionCases = [ + { + section: "__objc_classlist", + size: 12, + reason: "Misaligned Objective-C class-list size", + }, + { + section: "__objc_protolist", + size: 12, + reason: "Misaligned Objective-C protocol list", + }, + { + section: "__objc_catlist", + size: 12, + reason: "Misaligned Objective-C category list", + }, + { + section: "__swift5_types", + size: 6, + reason: "Misaligned Swift type section", + }, + { + section: "__swift5_proto", + size: 6, + reason: "Misaligned Swift conformance section", + }, +]; + +const inspect = async (bytes: Buffer) => { + const root = await createTestTempDirectory("rea-dispatch-format-"); + const path = join(root, "fixture.macho"); + await writeFile(path, bytes); + const client = new NativeMacOSProvider( + {}, + new NativeFixtureRunner(), + "darwin", + ).createClient(await nativeMachoTargetForFile(path)); + try { + return { + path, + result: await client.execute("inspect_native_dispatch_metadata", {}), + }; + } finally { + expect((await client.close()).ok).toBe(true); + } +}; + +it.each(sectionCases)( + "reports malformed $section bytes as a target refusal with the producing reason", + async ({ section, size, reason }) => { + const bytes = objcRecordBudgetMacho("class"); + bytes.fill(0, 104, 120); + bytes.write(section, 104); + bytes.writeBigUInt64LE(BigInt(size), 144); + const { path, result } = await inspect(bytes); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected malformed target refusal"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "unsupported_target", + category: "unsupported_target", + details: { operation: "inspect_native_dispatch_metadata", path, reason }, + }); + }, +); + +it("keeps an unsupported big-endian CPU declaration distinct from provider failure", async () => { + const bytes = Buffer.alloc(32); + bytes.writeUInt32BE(0xfeedfacf); + bytes.writeUInt32BE(0x0100000c, 4); + bytes.writeUInt32BE(2, 12); + const { path, result } = await inspect(bytes); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected unsupported target refusal"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "unsupported_target", + category: "unsupported_target", + details: { path, reason: "Unsupported big-endian Mach-O CPU type" }, + }); +}); + +it("continues decoding the well-formed eight-byte class-list control", async () => { + const { result } = await inspect(objcRecordBudgetMacho("class")); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ + result: { + objc_classes: [expect.objectContaining({ name: "FixtureClass" })], + }, + }); +}); diff --git a/tests/boundary/providers/native/objcClassMetadataFallback.test.ts b/tests/boundary/providers/native/objcClassMetadataFallback.test.ts index afa8c31ac3..d3df378ae6 100644 --- a/tests/boundary/providers/native/objcClassMetadataFallback.test.ts +++ b/tests/boundary/providers/native/objcClassMetadataFallback.test.ts @@ -15,6 +15,7 @@ import { parseMachoLayout, } from "../../../../src/native/AppleMachoSelection.js"; import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js"; +import { BinaryFormatFailure } from "../../../../src/native/metadata/BinaryContainer.js"; import { NativeFixtureRunner } from "../../../fixtures/nativeCommands.js"; import { ok } from "../../../../src/domain/result.js"; @@ -171,7 +172,8 @@ describe("native Objective-C class metadata capability fallback", () => { } catch (cause: unknown) { mismatchedCpuFailure = cause; } - expect(mismatchedCpuFailure).toBeInstanceOf(TypeError); + expect(mismatchedCpuFailure).toBeInstanceOf(BinaryFormatFailure); + expect(mismatchedCpuFailure).toMatchObject({ kind: "unsupported" }); expect(mismatchedCpuFailure).not.toBeInstanceOf( UnsupportedMachoMetadataFormatError, );