Files
reverse-engineering-anything/tests/boundary/providers/native/nativeMachoEncryptionBinding.test.ts
T
860fdd8fbe feat(macho): report per-slice encryption status in inspect_macho (#5359)
* refactor(macho): extract non-throwing container and slice load-command readers

Split readMachoImage into readMachoContainer, which classifies a file as
thin, FAT or not Mach-O and keeps FAT range issues per slice, and
readSliceLoadCommands, which reads one slice header and frames its load
commands, returning every command framed before a failure.

readMachoImage keeps its strict policy on top of them: commands framed
before a framing defect still decode first, an unsupported FAT slice
still yields to a later malformed one, and the first FAT range issue is
still reported. A characterization test pins that precedence for inputs
with several defects; it passes against both the previous and the new
implementation.

Part of #4306.

* feat(macho): report per-slice encryption status in inspect_macho

inspect_macho now reports the LC_ENCRYPTION_INFO(_64) state of every
slice in a new `encryption` collection, read in process from load
commands without reading code bytes.

- Each slice is encrypted, not-encrypted, absent, malformed or unknown.
  Absent requires a completed load-command walk; malformed and unknown
  carry a reason and keep commands read before the failure.
- The named range is end-exclusive in slice and input-file coordinates
  and maps to file-backed segment fragments, VM addresses and the
  overlapping nonempty sections.
- A ciphertext range adds a limitation naming its input-file bytes.
- The in-process read and the native tools are bracketed by one regular
  file descriptor and stat identity checks; a change reports
  artifact_changed. A missing or inaccessible target keeps the tool
  facts and reports encryption as non-exhaustive, never absent.
- Retained Evidence recorded before this field is parsed by the owning
  schema and reports encryption as unknown in the macho view and in
  managed/native verification.

Fixes #4306

* fix(macho): keep slice facts and failures under large tables and close errors

- Frame load commands through a visitor instead of retaining every
  command, restoring the pre-refactor memory profile (2M eight-byte
  commands: peak heap 370 MiB -> 0.9 MiB).
- Confirm a large FAT container from a valid slice's Mach-O header so a
  sibling's range fault stays per slice instead of hiding healthy slices.
- Name only sections whose bytes the range and their segment both cover.
- Throw inspection failures inside the owned descriptor callback so a
  close failure keeps them as its primary error.

* fix(macho): map encrypted ranges only within segment VM extents

mapRange gave every file-backed segment fragment VM addresses, even for a
segment with no VM size, whose file bytes dyld never maps, and past vmsize
when a segment declared more file bytes than memory.

Read vmsize with the segment. A segment with no VM size keeps its fragment
with null VM addresses. A file size beyond the VM size, or a VM range past
the address space, makes the mapping unknown with a reason while the slice
keeps its encryption status.

The docs also claimed the encryption read never reads code bytes. Like
every Mach-O container read, a 0xcafebabe input of at most 16 MiB is read
whole to rule out a JVM class file; say so.

* fix(macho): read encryption through the admitted descriptor facts

Main's withArtifactRegularFile now hands callers the admitted handle and
its initial stats. Use those facts instead of re-stating the handle, and
pass the legacy Evidence payload straight to createEvidence.

* fix(macho): retain a finished report when the target close fails

The file owner keeps the completed callback value when the descriptor close
fails or cancellation arrives during close. inspect_macho now validates that
value and retains the report as the error's partial observation, as
inspect_signature does, instead of returning only the cleanup or
cancellation error.

* test(native): supply readEncryption in merged dyld refusal cases

main's new dyld refusal cases build NativeMachoInspectionContext without
the encryption reader this branch requires.

* test(views): bind the Mach-O encryption page to its facet

main's facet-ownership case builds inspect_macho Evidence without the
encryption coverage this branch requires; the fixture now carries one
slice row and the loop checks the encryption page like the other facets.

* fix(macho): bound encryption commands and range mapping across slices

A slice retains at most two encryption load commands, since a second one
already makes it malformed; the reason keeps the full count. Range mapping
in every slice shares one budget of segment and section records, charged
from each segment command's capacity before it is decoded. A slice past
the budget keeps its encryption status and reports an unknown mapping.

* style(macho): format encryption reader and binding test

* fix(macho): stop retaining segments past the mapping budget

Segment commands are collected during framing only while their mapping
records still fit in the budget shared by all slices, and the budget is
charged only when the slice maps its range.

* docs(macho): clarify retained mapping allowance

* fix(ci): own the Mach-O encryption test in Apple scope

---------

Co-authored-by: t <t@t>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
2026-10-29 18:19:39 +08:00

201 lines
7.4 KiB
TypeScript

import { appendFile, open, writeFile, type FileHandle } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, onTestFinished, vi } from "vitest";
import { machoImage } from "../../../../src/artifacts/apple/MachoImage.fixture.js";
import type { AnalysisError } from "../../../../src/domain/analysisErrorBase.js";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import { inspectMachoSchema } from "../../../../src/domain/native/nativeInspection.js";
import { ProviderCleanupError } from "../../../../src/domain/providerCleanupError.js";
import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js";
import {
NativeFixtureRunner,
nativeMachoTargetForFile,
} from "../../../fixtures/nativeCommands.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
/** A thin arm64 image whose LC_ENCRYPTION_INFO_64 marks 0x4000-0x8000 as ciphertext. */
const encryptedImage = (): Uint8Array => {
const command = new Uint8Array(24);
const view = new DataView(command.buffer);
view.setUint32(0, 0x2c, true);
view.setUint32(4, 24, true);
view.setUint32(8, 0x4000, true);
view.setUint32(12, 0x4000, true);
view.setUint32(16, 1, true);
const bytes = new Uint8Array(0x10000);
bytes.set(machoImage({ commands: [command] }));
return bytes;
};
const writeTarget = async () => {
const root = await createTestTempDirectory("rea-native-macho-encryption-");
const path = join(root, "fixture.macho");
await writeFile(path, encryptedImage());
return path;
};
const thinLipo = { lipo: "Non-fat file: fixture is architecture: arm64\n" };
const inspect = async (
path: string,
runner: NativeFixtureRunner = new NativeFixtureRunner(thinLipo),
) => {
const client = new NativeMacOSProvider({}, runner, "darwin").createClient(
await nativeMachoTargetForFile(path),
);
onTestFinished(async () => {
await client.close();
});
return { client, result: await client.execute("inspect_macho", {}) };
};
it("reads encryption from the bound file and names the ciphertext bytes", async () => {
const { result } = await inspect(await writeTarget());
if (!result.ok) throw result.error;
const report = inspectMachoSchema.parse(result.value.result);
expect(report.encryption).toMatchObject({
total: 1,
exhaustive: true,
items: [
{
status: "encrypted",
container_file_offset: 0,
range: { content: "ciphertext", container_start: 0x4000 },
},
],
});
expect(result.value.limitations).toContain(
"Bytes 16384-32768 (input file offsets, end exclusive) of the arm64 slice are ciphertext (cryptid 1); disassembly, decompilation, strings and references derived from them do not describe program code.",
);
});
/** Replay fixture output while appending to the target once lipo reads it. */
class MutatingRunner extends NativeFixtureRunner {
constructor(private readonly path: string) {
super(thinLipo);
}
override async run(tool: string, arguments_: readonly string[]) {
if (tool === "lipo") await appendFile(this.path, "changed");
return super.run(tool, arguments_);
}
}
it("refuses facts when the file changes while the tools inspect it", async () => {
const path = await writeTarget();
const { result } = await inspect(path, new MutatingRunner(path));
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected a changed-artifact refusal");
expect(result.error).toMatchObject({
_tag: "AnalysisArtifactChangedError",
cleanupIncomplete: false,
});
});
/** Make only the admitted descriptor's close reject until released. */
const failingClose = async (onClosed?: () => void) => {
const probe = await open(await writeTarget(), "r");
const prototype = Object.getPrototypeOf(probe) as FileHandle;
await probe.close();
const state = { failing: onClosed === undefined, attempts: 0 };
const original = prototype.stat;
const stat = vi
.spyOn(prototype, "stat")
.mockImplementationOnce(async function (
this: FileHandle,
...args: Parameters<FileHandle["stat"]>
) {
const release = this.close.bind(this);
this.close = async () => {
state.attempts += 1;
if (state.failing) throw new Error("macho target close failed");
await release();
onClosed?.();
};
return Reflect.apply(original, this, args);
});
onTestFinished(() => {
stat.mockRestore();
state.failing = false;
});
return state;
};
/** The finished report survives a failure that arrives after the read completed. */
const expectRetainedReport = (error: AnalysisError) => {
const report = inspectMachoSchema.parse(
(error.partialObservation as { normalized_result?: unknown } | undefined)
?.normalized_result,
);
expect(report.encryption.items).toMatchObject([{ status: "encrypted" }]);
expect(projectAnalysisError(error).details?.partial_observation).toEqual(
error.partialObservation,
);
};
it("retains the target descriptor when close rejects and retries it on client close", async () => {
const path = await writeTarget();
const state = await failingClose();
const { client, result } = await inspect(path);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the close failure");
expect(result.error).toBeInstanceOf(ProviderCleanupError);
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupResources).toEqual([path]);
expect(state.attempts).toBe(1);
expectRetainedReport(result.error);
expect((await client.close()).ok).toBe(false);
expect(state.attempts).toBe(2);
state.failing = false;
expect((await client.close()).ok).toBe(true);
expect(state.attempts).toBe(3);
expect(result.error.cleanupIncomplete).toBe(false);
});
it("keeps the inspection failure primary when the descriptor close also rejects", async () => {
const path = await writeTarget();
const state = await failingClose();
const { client, result } = await inspect(path, new MutatingRunner(path));
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected change and close failures");
expect(result.error).toBeInstanceOf(ProviderCleanupError);
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupResources).toEqual([path]);
expect(result.error.cleanupPrimary?._tag).toBe(
"AnalysisArtifactChangedError",
);
expect(result.error.partialObservation).toBeUndefined();
state.failing = false;
expect((await client.close()).ok).toBe(true);
expect(result.error.cleanupIncomplete).toBe(false);
});
it("reports cancellation that arrives during the final close without retaining the descriptor", async () => {
const path = await writeTarget();
const controller = new AbortController();
const reason = { caller: "macho-final-close" };
await failingClose(() => controller.abort(reason));
const client = new NativeMacOSProvider(
{},
new NativeFixtureRunner(thinLipo),
"darwin",
).createClient(await nativeMachoTargetForFile(path));
onTestFinished(async () => {
await client.close();
});
const result = await client.execute(
"inspect_macho",
{},
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected final-close cancellation");
expect(result.error).toMatchObject({
_tag: "AnalysisCancelledError",
cleanupIncomplete: false,
});
expectRetainedReport(result.error);
});