From 5b02119b69bac4210715057e390ac2db23cbda76 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:19:16 +0800 Subject: [PATCH] fix(native): bind initializer evidence to selected Mach-O slices (#10013) * fix(native): bind initializer evidence to selected Mach-O slices * fix(native): reject initializer slices outside the artifact * test(native): retain completed initializer evidence on late cancellation completeProviderEvidence keeps the finished report on AnalysisCancelledError.partialObservation and the caller's abort reason. The regression only checked the error tag, so dropping that evidence would pass. Restore both structured assertions. * fix(native): bind initializer facts to the selected slice label and range A universal report could keep an arm64 cpu_type beside an x86_64 slice label, and entry file offsets only had to fall inside the container. Require the slice architecture to match the request, and require each slot and value file offset to lie in that slice. --------- Co-authored-by: Cursor Agent --- .../NativeInitializersService.test.ts | 209 +++++++++++++++++- .../NativeInitializersService.ts | 30 ++- 2 files changed, 233 insertions(+), 6 deletions(-) diff --git a/src/application/binaryDiagnostics/NativeInitializersService.test.ts b/src/application/binaryDiagnostics/NativeInitializersService.test.ts index c01de60fb0..317b52f62e 100644 --- a/src/application/binaryDiagnostics/NativeInitializersService.test.ts +++ b/src/application/binaryDiagnostics/NativeInitializersService.test.ts @@ -9,6 +9,8 @@ import { AnalysisInputError, AnalysisOutputError, } from "../../domain/analysisErrorCore.js"; +import { readNativeInitializers } from "../../native/initializers/NativeInitializersProvider.js"; +import { machoArm64FatInitializers } from "../../native/initializers/NativeInitializers.fixture.js"; const identity = NATIVE_INITIALIZERS_PROVIDER; const report: NativeInitializers = { @@ -55,6 +57,23 @@ const serviceFor = (value: NativeInitializers) => inspect: () => Promise.resolve(ok(value)), }); +const selectedArm64Report = async () => { + const bytes = machoArm64FatInitializers(); + const selected = await readNativeInitializers( + bytes, + { ...report.artifact, bytes: bytes.length }, + { + path: report.artifact.path, + offset: 0, + limit: 64, + architecture: "arm64", + }, + ); + if (selected.slice === null) + throw new Error("Expected the authored universal slice"); + return { ...selected, slice: selected.slice }; +}; + it("rejects a relative path and extra arguments before reading", async () => { let calls = 0; const service = new NativeInitializersService({ @@ -91,6 +110,48 @@ it("records the selected image and each slot range as Evidence", async () => { }); }); +it.each(["arm64", "x86_64", undefined] as const)( + "binds a universal initializer report to the caller's architecture %s", + async (architecture) => { + const bytes = machoArm64FatInitializers(); + const parsed = await readNativeInitializers( + bytes, + { ...report.artifact, bytes: bytes.length }, + { + path: report.artifact.path, + offset: 0, + limit: 64, + architecture: "arm64", + }, + ); + const result = await serviceFor(parsed).inspect({ + path: report.artifact.path, + ...(architecture === undefined ? {} : { architecture }), + }); + expect(result.ok).toBe(architecture === "arm64"); + if (!result.ok) { + expect(result.error).toBeInstanceOf(AnalysisOutputError); + return; + } + expect(result.value).toMatchObject({ + subject: { format: "mach-o-universal", architecture: "arm64" }, + normalized_result: parsed, + parameters: { architecture: "arm64" }, + }); + }, +); + +it("rejects an architecture selection when the report has no universal slice", async () => { + const result = await serviceFor(report).inspect({ + path: report.artifact.path, + architecture: "arm64", + }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisOutputError" }, + }); +}); + it.each([ ["a page that does not match the request", { offset: 1 }], ["a returned count that differs from the entries", { returned_entries: 0 }], @@ -116,7 +177,7 @@ it("rejects a slot offset outside the artifact", async () => { it("retains a completed observation when the caller cancels as the provider completes", async () => { const controller = new AbortController(); - const reason = { request: "late NativeInitializers cancellation" }; + const reason = { request: "late native initializer cancellation" }; const service = new NativeInitializersService({ identity, inspect: () => { @@ -136,3 +197,149 @@ it("retains a completed observation when the caller cancels as the provider comp normalized_result: report, }); }); + +it.each([ + { machine: 0x01000007 }, + { architecture: "x86_64" as const }, + { format: "elf" as const }, +])( + "refuses contradictory selected-image facts %j before Evidence", + async (changes) => { + const bytes = machoArm64FatInitializers(); + const selected = await readNativeInitializers( + bytes, + { ...report.artifact, bytes: bytes.length }, + { + path: report.artifact.path, + offset: 0, + limit: 64, + architecture: "arm64", + }, + ); + const result = await serviceFor({ ...selected, ...changes }).inspect({ + path: report.artifact.path, + architecture: "arm64", + }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisOutputError" }, + }); + }, +); + +it("refuses a slice architecture label that disagrees with the selected image", async () => { + const selected = await selectedArm64Report(); + const result = await serviceFor({ + ...selected, + slice: { ...selected.slice, architecture: "x86_64" }, + }).inspect({ + path: report.artifact.path, + architecture: "arm64", + }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisOutputError" }, + }); +}); + +it.each([ + { field: "slot_file_offset" as const }, + { field: "value_file_offset" as const }, +])( + "refuses $field that lies in the container but outside the selected slice", + async ({ field }) => { + const selected = await selectedArm64Report(); + const [entry] = selected.entries; + if (entry === undefined) + throw new Error("Expected an initializer in the selected slice"); + const result = await serviceFor({ + ...selected, + entries: selected.entries.map((item, index) => + index === 0 ? { ...item, [field]: 0 } : item, + ), + }).inspect({ + path: report.artifact.path, + architecture: "arm64", + }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisOutputError" }, + }); + }, +); + +it("keeps an entry offset that starts at the selected slice", async () => { + const selected = await selectedArm64Report(); + const [entry] = selected.entries; + if (entry === undefined) + throw new Error("Expected an initializer in the selected slice"); + const result = await serviceFor({ + ...selected, + entries: selected.entries.map((item, index) => + index === 0 + ? { ...item, value_file_offset: selected.slice.file_offset } + : item, + ), + }).inspect({ + path: report.artifact.path, + architecture: "arm64", + }); + if (!result.ok) throw result.error; + const observed = result.value.normalized_result as NativeInitializers; + expect(observed.slice).toMatchObject({ + architecture: "arm64", + file_offset: selected.slice.file_offset, + }); + expect(observed.entries[0]?.value_file_offset).toBe( + selected.slice.file_offset, + ); + expect(observed.entries).toHaveLength(selected.entries.length); +}); + +it.each([ + { label: "ends at EOF", extraOffset: 0, extraSize: 0, accepted: true }, + { + label: "starts one byte too late", + extraOffset: 1, + extraSize: 0, + accepted: false, + }, + { + label: "extends one byte too far", + extraOffset: 0, + extraSize: 1, + accepted: false, + }, +])( + "binds the selected slice extent when it $label", + async ({ extraOffset, extraSize, accepted }) => { + const bytes = machoArm64FatInitializers(); + const selected = await readNativeInitializers( + bytes, + { ...report.artifact, bytes: bytes.length }, + { + path: report.artifact.path, + offset: 0, + limit: 64, + architecture: "arm64", + }, + ); + if (selected.slice === null) + throw new Error("Expected the authored universal slice"); + const value = { + ...selected, + slice: { + ...selected.slice, + file_offset: + selected.artifact.bytes - selected.slice.size + extraOffset, + size: selected.slice.size + extraSize, + }, + }; + const result = await serviceFor(value).inspect({ + path: report.artifact.path, + architecture: "arm64", + }); + expect(result.ok).toBe(accepted); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError); + }, +); diff --git a/src/application/binaryDiagnostics/NativeInitializersService.ts b/src/application/binaryDiagnostics/NativeInitializersService.ts index 06da93aa7b..e61db3ac0d 100644 --- a/src/application/binaryDiagnostics/NativeInitializersService.ts +++ b/src/application/binaryDiagnostics/NativeInitializersService.ts @@ -12,6 +12,7 @@ import { import { err, type Result } from "../../domain/result.js"; import { completeProviderEvidence } from "../ProviderOperationExecution.js"; import { parseAnalysisInput } from "../AnalysisInput.js"; +import { MACHO_CPU_TYPES } from "../../domain/apple/machoTargetSelection.js"; const OPERATION = "inspect_native_initializers"; @@ -67,7 +68,7 @@ export class NativeInitializersService { createEvidence( { path: value.artifact.path, - format: value.format, + format: value.slice === null ? value.format : "mach-o-universal", sha256: value.artifact.sha256, ...(value.architecture === null ? {} @@ -93,9 +94,28 @@ const validReport = ( input: InspectNativeInitializersInput, ): boolean => { const { coverage } = report; - const inside = (offset: number | null, bytes: number): boolean => - offset === null || offset <= report.artifact.bytes - bytes; + const slice = report.slice; if ( + slice === null + ? input.architecture !== undefined + : report.format !== "mach-o" || + input.architecture === undefined || + slice.cpu_type !== MACHO_CPU_TYPES[input.architecture] || + slice.architecture !== input.architecture || + report.machine !== slice.cpu_type || + report.architecture !== input.architecture + ) + return false; + const insideArtifact = (offset: number, bytes: number): boolean => + offset <= report.artifact.bytes - bytes; + // Entry file offsets name the selected image, not another slice in the container. + const imageStart = slice?.file_offset ?? 0; + const imageEnd = + slice === null ? report.artifact.bytes : slice.file_offset + slice.size; + const insideImage = (offset: number | null, bytes: number): boolean => + offset === null || (offset >= imageStart && bytes <= imageEnd - offset); + if ( + (slice !== null && !insideArtifact(slice.file_offset, slice.size)) || report.artifact.bytes > input.max_file_bytes || coverage.offset !== input.offset || coverage.limit !== input.limit || @@ -109,7 +129,7 @@ const validReport = ( return report.entries.every( (entry, position) => entry.index === coverage.offset + position && - inside(entry.slot_file_offset, entry.slot_bytes) && - inside(entry.value_file_offset, 1), + insideImage(entry.slot_file_offset, entry.slot_bytes) && + insideImage(entry.value_file_offset, 1), ); };