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), ); };