mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
* fix(plist): rethrow internal invariant violations from plist consumers KeyedArchiveReader reported any non-limit decode failure as a format failure, InterfaceBuilderAnalysis listed it as an invalid archive, ReleaseExposureReader fell back to scanning raw bytes, and the native plist parser reported it as provider output. An InternalInvariantError is a REA defect, not evidence about the input, so each consumer now rethrows it. The native parser's binary path now shares its one cause projection instead of a duplicate. * fix(native): rethrow internal invariant violations from the native output projection nativeOutputFailure wrapped any producer failure as AnalysisOutputError, so a REA defect thrown while reading a plist or dispatching a command was reported as native output parsing. It now rethrows InternalInvariantError, which reaches the provider's adapter-failure path with the invariant as its cause. * fix(artifacts): rethrow internal invariant violations from the shared artifact failure mapper translateArtifactFailure turned every unrecognised cause into an ArtifactOperationError with reason io, so the keyed-archive and Interface Builder rethrows still reached users as artifact read failures. The shared mapper now rethrows InternalInvariantError, and the deferred client reports it as a ProviderAdapterError. Provider-level tests drive both operations through that client. * refactor(dotnet): report managed reader defects through invariantViolation (#11030) * refactor(dotnet): report managed reader defects through invariantViolation The managed readers threw plain TypeError and RangeError for states their callers rule out: an allowance or method page outside the input schema, a missing snapshot after identity admission, and a missing CLI header after the caller already returned for it. These are REA defects, not malformed targets, so they throw invariantViolation. The two CLI re-checks are gone: the callers pass the CLI header they already narrowed. The dotnet parser-plain-throw baseline drops from 6 to 0. * refactor(providers): refuse unusable tool paths and record marker mismatches without throwing to their own catch (#11031) * refactor(firmware): refuse unusable tool paths without throwing to its own catch resolveFirmwareCommand threw a plain TypeError for a configured engine or limiter path that is not a regular file, only to catch it in the next line and wrap it as an unavailable capability. A helper now returns why a path cannot run, and each caller builds the one refusal directly. Refusal reasons are unchanged; the directory tests now pin them exactly. The firmware parser-plain-throw baseline drops from 2 to 0. * refactor(pwndbg): refuse unusable debugger paths without throwing to its own catch inspectPwndbgCore threw plain Errors for a relative or wrong-kind configured path only to catch them and wrap them as an unavailable capability. A helper now returns why a path cannot be used, with any host error as its cause, and the loop builds the one refusal. Cancellation between host checks still propagates. Refusal reasons are unchanged. The PwndbgCoreContext parser-plain-throw baseline entry is removed. * refactor(pwntools): record a mismatched failure marker without throwing to its own catch readPwntoolsFailureEvidence threw a plain Error when the private bridge marker disagreed with the exit status, only to catch it and record its message as the marker failure. The mismatch is now recorded directly. The recorded evidence is unchanged; a unit test pins both the matching and the mismatched marker. The PwntoolsResourceLimits parser-plain-throw baseline entry is removed. * fix(android): report an unobserved Java probe as a REA defect, not a Java refusal (#11032) * fix(android): report an unobserved Java probe as a REA defect, not a Java refusal When runOwnedCommand resolved without observing the completed run, the probe threw a plain Error that its catch then classified as a Java runtime refusal, telling the caller to review their launcher. The state is a broken internal assumption, so the probe throws invariantViolation and projects it as a jadx adapter failure. The JadxJavaProbe parser-plain-throw baseline entry is removed. * refactor(native): report native provider defects through invariantViolation (#11033) * refactor(native): report native provider defects through invariantViolation Native providers threw plain Error and TypeError for states their own earlier steps rule out: a client with no target, a measured slice with no selection, a read longer than requested, a decoder command without its runtime, an LLDB root released before its children, and an IPS integer fact from another reader. These are REA defects, so they throw invariantViolation. NativeUiInterruptionControl no longer needs a throw: recording the deadline interruption returns the interruption its error is built from. Six native parser-plain-throw baseline entries drop. * refactor(native): report malformed lipo, swift-demangle and UI helper output through ParseFailure (#11034) * refactor(native): report malformed lipo and swift-demangle output through ParseFailure The lipo and swift-demangle output parsers threw plain TypeError for output that breaks their framing, and the lipo result classifier caught every TypeError as malformed output, so a host TypeError from a REA defect was reported as lipo output. Both parsers now throw malformedInput with their own format, and the lipo classifier accepts ParseFailureError instead of TypeError. Messages and the consumers' output and format failure categories are unchanged. Four native parser-plain-throw baseline entries drop. * refactor(native): report the UI helper's non-JSON output through ParseFailure The native UI helper runtime threw a plain Error when the helper's stdout was not JSON. It now throws malformedInput with its own format and the JSON failure as cause. The observation boundary still reports it as a native-macos adapter failure with the same reason. The NativeUiHelperRuntime parser-plain-throw baseline entry is removed. * refactor(verify): keep process-lifecycle owners out of the parser plain-throw guard (#11035) * refactor(verify): keep process-lifecycle owners out of the parser plain-throw guard JadxWorkspaceRoot and PwntoolsDecoder sit in parser folders but own a private helper process; their remaining plain throws report the helper's startup, handshake and cleanup outcomes, not parse faults. The guard now names them as process-lifecycle owners and leaves them out, so their five baseline entries drop without inventing parse errors for process failures. * refactor(parsers): report the remaining artifact, il2cpp, model and domain defects through invariantViolation (#11036) * refactor(parsers): report the remaining artifact, il2cpp, model and domain defects through invariantViolation Nine plain Error, TypeError and RangeError throws in parser folders reported broken internal assumptions or caller contracts (an uninitialized ZIP or DMG reader, a missing scan result, a non-byte stream chunk, an invalid traversal or marshal limit, a header count or linking fact the reader itself should have produced, a misordered Safetensors budget, an append after fingerprinting). None is an input fault, and no catch site depends on their class. They now throw invariantViolation with the same messages, and their parser-plain-throw baseline entries drop. * refactor(evidence): refuse an earlier Evidence format through ParseFailure (#11037) * refactor(evidence): refuse an earlier Evidence format through ParseFailure UnsupportedEvidenceFormatError restated the ParseFailure algebra as its own TypeError subclass. The format owner now throws unsupportedInput for the evidence-bundle format at the evidence_format field and exports the predicate its consumers use. The bundle reader's unsupported-format kind and message are unchanged. The snapshot reader surfaced only TypeError messages, so it now also surfaces the format refusal and keeps its recreate guidance; a boundary test pins that. * refactor(native): report Mach-O reader and inspection defects through invariantViolation (#11042) The Mach-O inspection plan reads the lipo, nm and dyld_info captures it just ran, the dispatch metadata reader is called only for Mach-O executables, and the command-name reader takes limits its callers derive. A missing capture, a non-Mach-O target or a malformed limit is a REA defect, not an input fault, so these six sites throw invariantViolation with their existing messages instead of TypeError. No catch site depends on their class. Their parser-plain-throw baseline entries drop. * fix(artifacts): join the Interface Builder cleanup failure to a catalog defect When catalog work failed while the concurrent Interface Builder reader failed to close, analyzeAppleAssetCatalogs built the primary through translateArtifactFailure before joining cleanup. That mapper now rethrows an InternalInvariantError, so a defect skipped the join and the reader's retained resources were lost. The defect now becomes the artifact graph provider's adapter failure at that site and joins the cleanup failure as before.
798 lines
27 KiB
TypeScript
798 lines
27 KiB
TypeScript
import { EventEmitter, once } from "node:events";
|
|
import { PassThrough } from "node:stream";
|
|
import { buffer } from "node:stream/consumers";
|
|
import { truncate, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { describe, expect, it, onTestFinished, vi } from "vitest";
|
|
|
|
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
|
import { CPU, fatImage, machoImage } from "./apple/MachoImage.fixture.js";
|
|
import { MachOSliceArtifactReader } from "./MachOSliceArtifactReader.js";
|
|
import { err, ok } from "../domain/result.js";
|
|
import { openRegularFile } from "../filesystem/RegularFile.js";
|
|
import { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
|
|
import type { NativeCommandRunner } from "../native/CommandRunner.js";
|
|
import { NativeCommandFailure } from "../native/CommandRunner.js";
|
|
import { createDeferred } from "../../tests/fixtures/binarySession.js";
|
|
import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js";
|
|
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
|
import type { ArtifactEntry } from "./ArtifactReader.js";
|
|
|
|
const sliceOwnershipFixture = async (secondSlice = false) => {
|
|
const root = await createTestTempDirectory("rea-slice-ownership-");
|
|
const path = join(root, "fat");
|
|
const bytes = Buffer.concat([
|
|
Buffer.from(machoImage({ cpu: CPU.x86_64 })),
|
|
Buffer.alloc(256 * 1024),
|
|
]);
|
|
const slices = [
|
|
{ name: "x86_64", cpu: CPU.x86_64, bytes },
|
|
...(secondSlice
|
|
? [
|
|
{
|
|
name: "arm64",
|
|
cpu: CPU.arm64,
|
|
bytes: machoImage({ cpu: CPU.arm64 }),
|
|
},
|
|
]
|
|
: []),
|
|
];
|
|
await writeFile(path, fatImage(slices));
|
|
const runner: NativeCommandRunner = {
|
|
run: async () =>
|
|
ok({
|
|
tool: "lipo",
|
|
executable: "/usr/bin/lipo",
|
|
executableSha256: "1".repeat(64),
|
|
toolVersion: null,
|
|
versionReason: "fixture",
|
|
arguments: ["-detailed_info", path],
|
|
stdout: slices
|
|
.map(
|
|
({ name, cpu, bytes }, index) =>
|
|
`architecture ${name}\n cputype ${cpu.type}\n cpusubtype ${cpu.subtype}\n offset ${index === 0 ? 4096 : Math.ceil((4096 + slices[0]!.bytes.length) / 4096) * 4096}\n size ${bytes.length}\n align 2^12 (4096)\n`,
|
|
)
|
|
.join(""),
|
|
stderr: "",
|
|
stdoutBytes: 1,
|
|
stderrBytes: 0,
|
|
exitCode: 0,
|
|
signal: null,
|
|
}),
|
|
};
|
|
return { path, runner };
|
|
};
|
|
|
|
const sliceEntries = async (
|
|
reader: MachOSliceArtifactReader,
|
|
): Promise<ArtifactEntry[]> => {
|
|
const entries: ArtifactEntry[] = [];
|
|
for await (const entry of reader.entries()) entries.push(entry);
|
|
return entries;
|
|
};
|
|
|
|
const cleanupOwner = (runId: string): ProviderProcessSupervisor => {
|
|
const child = Object.assign(new EventEmitter(), {
|
|
stdout: new PassThrough(),
|
|
stderr: new PassThrough(),
|
|
exitCode: null as number | null,
|
|
signalCode: null as NodeJS.Signals | null,
|
|
kill: () => true,
|
|
});
|
|
return new ProviderProcessSupervisor({
|
|
process: child,
|
|
ownsProcessLifetime: true,
|
|
ownership: {
|
|
runId,
|
|
leaderPid: 45_000,
|
|
processGroupId: 45_000,
|
|
expectedParentPid: process.pid,
|
|
},
|
|
cleanup: async () => ({ cleaned: false, reason: "still running" }),
|
|
});
|
|
};
|
|
|
|
describe("Mach-O cleanup exceptions", () => {
|
|
it("retains an undefined cleanup rejection as an explicit failure", async () => {
|
|
const reader = new MachOSliceArtifactReader(
|
|
"/unused/macho",
|
|
{},
|
|
{
|
|
run: async () => {
|
|
throw new Error("Unexpected command");
|
|
},
|
|
retryCleanup: async () => {
|
|
throw undefined;
|
|
},
|
|
},
|
|
);
|
|
await expect(reader.close()).rejects.toMatchObject({
|
|
cleanup: {
|
|
reason: "undefined",
|
|
resources: ["native-command-process-cleanup"],
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Mach-O command cleanup lifecycle", () => {
|
|
it("waits for active lipo capture before cleanup and preserves its failure", async () => {
|
|
const root = await createTestTempDirectory("rea-slices-close-race-");
|
|
const binary = join(root, "input");
|
|
await writeFile(binary, Buffer.from("fixture"));
|
|
let finishCommand!: (
|
|
result: Awaited<ReturnType<NativeCommandRunner["run"]>>,
|
|
) => void;
|
|
let commandStarted!: () => void;
|
|
const started = new Promise<void>((resolve) => {
|
|
commandStarted = resolve;
|
|
});
|
|
const command = new Promise<
|
|
Awaited<ReturnType<NativeCommandRunner["run"]>>
|
|
>((resolve) => {
|
|
finishCommand = resolve;
|
|
});
|
|
let cleanupRetries = 0;
|
|
const runner: NativeCommandRunner = {
|
|
run: () => {
|
|
commandStarted();
|
|
return command;
|
|
},
|
|
retryCleanup: async () => {
|
|
cleanupRetries += 1;
|
|
if (cleanupRetries > 1) return [];
|
|
return [
|
|
{
|
|
owner: cleanupOwner("run:first"),
|
|
reason: "first process group remains live",
|
|
resources: ["run:first", "process-group:1234"],
|
|
},
|
|
{
|
|
owner: cleanupOwner("run:second"),
|
|
reason: "second process group remains live",
|
|
resources: ["run:second", "process-group:5678"],
|
|
},
|
|
];
|
|
},
|
|
};
|
|
const reader = new MachOSliceArtifactReader(binary, {}, runner);
|
|
const iteration = reader.entries();
|
|
const firstEntry = iteration[Symbol.asyncIterator]().next();
|
|
const commandResult = err(
|
|
new NativeCommandFailure(
|
|
"lipo",
|
|
"timeout",
|
|
null,
|
|
undefined,
|
|
{
|
|
stdout: "partial lipo output",
|
|
stderr: "partial lipo diagnostic",
|
|
stdoutBytes: 19,
|
|
stderrBytes: 23,
|
|
exitCode: null,
|
|
signal: null,
|
|
truncated: false,
|
|
},
|
|
"capture process group remains unverified",
|
|
["capture-group:9999"],
|
|
),
|
|
);
|
|
try {
|
|
await awaitAcknowledgement(started, firstEntry, "lipo capture start");
|
|
const closing = reader.close();
|
|
await Promise.resolve();
|
|
expect(cleanupRetries).toBe(0);
|
|
finishCommand(commandResult);
|
|
await expect(firstEntry).rejects.toMatchObject({
|
|
reason: "unavailable",
|
|
cause: expect.objectContaining({
|
|
cleanupFailure: "capture process group remains unverified",
|
|
}),
|
|
cleanup: {
|
|
reason: "capture process group remains unverified",
|
|
resources: ["capture-group:9999"],
|
|
},
|
|
});
|
|
await expect(closing).rejects.toMatchObject({
|
|
cleanup: {
|
|
reason:
|
|
"run:first, process-group:1234: first process group remains live; run:second, process-group:5678: second process group remains live",
|
|
resources: [
|
|
"run:first",
|
|
"process-group:1234",
|
|
"run:second",
|
|
"process-group:5678",
|
|
],
|
|
},
|
|
});
|
|
await reader.close();
|
|
expect(cleanupRetries).toBe(2);
|
|
} finally {
|
|
finishCommand(commandResult);
|
|
await firstEntry.catch(() => undefined);
|
|
await reader.close().catch(() => undefined);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("MachOSliceArtifactReader hidden architectures", () => {
|
|
it("retains Apple's hidden ARM64 row separately from the declared FAT count", async () => {
|
|
const root = await createTestTempDirectory("rea-hidden-arm64-slices-");
|
|
const binary = join(root, "fat");
|
|
const armv7 = { type: 12, subtype: 9 };
|
|
const armv7Image = machoImage({ cpu: armv7, wide: false });
|
|
const arm64Image = machoImage({ cpu: CPU.arm64 });
|
|
const bytes = Buffer.from(
|
|
fatImage([
|
|
{ cpu: armv7, bytes: armv7Image },
|
|
{ cpu: CPU.arm64, bytes: arm64Image },
|
|
]),
|
|
);
|
|
bytes.writeUInt32BE(1, 4);
|
|
await writeFile(binary, bytes);
|
|
const stdout = `fat_magic 0xcafebabe\nnfat_arch 1 (+1 hidden)\narchitecture armv7\n cputype 12\n cpusubtype 9\n offset 4096\n size ${String(armv7Image.length)}\n align 2^12 (4096)\narchitecture arm64 (hidden)\n cputype 16777228\n cpusubtype 0\n offset 8192\n size ${String(arm64Image.length)}\n align 2^12 (4096)\n`;
|
|
const reader = new MachOSliceArtifactReader(
|
|
binary,
|
|
{},
|
|
{
|
|
run: async () =>
|
|
ok({
|
|
tool: "lipo",
|
|
executable: "/usr/bin/lipo",
|
|
executableSha256: "1".repeat(64),
|
|
toolVersion: null,
|
|
versionReason: "fixture",
|
|
arguments: [],
|
|
stdout,
|
|
stderr: "",
|
|
stdoutBytes: Buffer.byteLength(stdout),
|
|
stderrBytes: 0,
|
|
exitCode: 0,
|
|
signal: null,
|
|
}),
|
|
},
|
|
);
|
|
onTestFinished(() => reader.close());
|
|
const entries = [];
|
|
for await (const entry of reader.entries()) entries.push(entry);
|
|
expect(entries.map(({ path }) => path)).toEqual([
|
|
"slices/armv7",
|
|
"slices/arm64",
|
|
]);
|
|
});
|
|
|
|
it("rejects a lipo hidden ARM64 claim when its physical header disagrees", async () => {
|
|
const root = await createTestTempDirectory("rea-hidden-arm64-mismatch-");
|
|
const binary = join(root, "fat");
|
|
const armv7 = { type: 12, subtype: 9 };
|
|
const armv7Image = machoImage({ cpu: armv7, wide: false });
|
|
const arm64Image = machoImage({ cpu: CPU.arm64 });
|
|
const bytes = Buffer.from(
|
|
fatImage([
|
|
{ cpu: armv7, bytes: armv7Image },
|
|
{ cpu: CPU.arm64, bytes: arm64Image },
|
|
]),
|
|
);
|
|
bytes.writeUInt32BE(1, 4);
|
|
bytes.writeUInt32LE(7, 8196); // Hidden FAT row claims ARM64; thin header says x86.
|
|
await writeFile(binary, bytes);
|
|
const stdout = `fat_magic 0xcafebabe\nnfat_arch 1 (+1 hidden)\narchitecture armv7\n cputype 12\n cpusubtype 9\n offset 4096\n size ${String(armv7Image.length)}\n align 2^12 (4096)\narchitecture arm64 (hidden)\n cputype 16777228\n cpusubtype 0\n offset 8192\n size ${String(arm64Image.length)}\n align 2^12 (4096)\n`;
|
|
const reader = new MachOSliceArtifactReader(
|
|
binary,
|
|
{},
|
|
{
|
|
run: async () =>
|
|
ok({
|
|
tool: "lipo",
|
|
executable: "/usr/bin/lipo",
|
|
executableSha256: "1".repeat(64),
|
|
toolVersion: null,
|
|
versionReason: "fixture",
|
|
arguments: [],
|
|
stdout,
|
|
stderr: "",
|
|
stdoutBytes: Buffer.byteLength(stdout),
|
|
stderrBytes: 0,
|
|
exitCode: 0,
|
|
signal: null,
|
|
}),
|
|
},
|
|
);
|
|
onTestFinished(() => reader.close());
|
|
const enumerate = async (): Promise<void> => {
|
|
for await (const _entry of reader.entries()) {
|
|
/* enumerate */
|
|
}
|
|
};
|
|
await expect(enumerate()).rejects.toMatchObject({ reason: "integrity" });
|
|
});
|
|
});
|
|
|
|
describe("MachOSliceArtifactReader", () => {
|
|
it("classifies malformed successful lipo output at the reader boundary", async () => {
|
|
const root = await createTestTempDirectory("rea-slices-malformed-lipo-");
|
|
const binary = join(root, "fat");
|
|
await writeFile(
|
|
binary,
|
|
fatImage([
|
|
{ cpu: CPU.x86_64, bytes: machoImage({ cpu: CPU.x86_64 }) },
|
|
{ cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) },
|
|
]),
|
|
);
|
|
const reader = new MachOSliceArtifactReader(
|
|
binary,
|
|
{},
|
|
{
|
|
run: async () =>
|
|
ok({
|
|
tool: "lipo",
|
|
executable: "/usr/bin/lipo",
|
|
executableSha256: "1".repeat(64),
|
|
toolVersion: null,
|
|
versionReason: "fixture",
|
|
arguments: ["-detailed_info", binary],
|
|
stdout: "Fat header in: fixture\n",
|
|
stderr: "",
|
|
stdoutBytes: 23,
|
|
stderrBytes: 0,
|
|
exitCode: 0,
|
|
signal: null,
|
|
}),
|
|
},
|
|
);
|
|
onTestFinished(() => reader.close());
|
|
|
|
await expect(
|
|
reader.entries()[Symbol.asyncIterator]().next(),
|
|
).rejects.toMatchObject({
|
|
reason: "format",
|
|
message: expect.stringMatching(
|
|
/lipo.*readable architecture records.*no architectures/i,
|
|
),
|
|
cause: { failure: { kind: "malformed", format: "lipo-output" } },
|
|
});
|
|
});
|
|
|
|
it("keeps a borrowed source usable after idle and active cancelled slice reads", async () => {
|
|
const root = await createTestTempDirectory("rea-slices-cancelled-");
|
|
const binary = join(root, "fat");
|
|
const x86 = machoImage({ cpu: CPU.x86_64 });
|
|
const arm = Buffer.concat([
|
|
Buffer.from(machoImage({ cpu: CPU.arm64 })),
|
|
Buffer.alloc(160_000, 0x5a),
|
|
]);
|
|
await writeFile(
|
|
binary,
|
|
fatImage([
|
|
{ cpu: CPU.x86_64, bytes: x86 },
|
|
{ cpu: CPU.arm64, bytes: arm },
|
|
]),
|
|
);
|
|
const source = await openRegularFile(binary, { symlinks: "reject" });
|
|
const { handle } = source;
|
|
const runner: NativeCommandRunner = {
|
|
run: () =>
|
|
Promise.resolve(
|
|
ok({
|
|
tool: "lipo",
|
|
executable: "/usr/bin/lipo",
|
|
executableSha256: "1".repeat(64),
|
|
toolVersion: null,
|
|
versionReason: "fixture",
|
|
arguments: ["-detailed_info", binary],
|
|
stdout: `architecture x86_64\n cputype 16777223\n cpusubtype 3\n offset 4096\n size ${String(x86.length)}\n align 2^12 (4096)\narchitecture arm64\n cputype 16777228\n cpusubtype 0\n offset 8192\n size ${String(arm.length)}\n align 2^12 (4096)\n`,
|
|
stderr: "",
|
|
stdoutBytes: 1,
|
|
stderrBytes: 0,
|
|
exitCode: 0,
|
|
signal: null,
|
|
}),
|
|
),
|
|
};
|
|
const reader = new MachOSliceArtifactReader(binary, {}, runner, source);
|
|
onTestFinished(async () => {
|
|
await reader.close();
|
|
await handle.close();
|
|
});
|
|
|
|
const entries = [];
|
|
for await (const entry of reader.entries()) entries.push(entry);
|
|
const armEntry = entries.find((entry) => entry.path === "slices/arm64");
|
|
if (armEntry === undefined) throw new Error("Expected an arm64 slice");
|
|
|
|
const idleController = new AbortController();
|
|
const idle = await reader.open(armEntry, idleController.signal);
|
|
const idleFailure = new Promise<Error>((resolve) =>
|
|
idle.once("error", resolve),
|
|
);
|
|
idleController.abort(null);
|
|
await expect(idleFailure).resolves.toMatchObject({
|
|
reason: "cancelled",
|
|
cancellation: { reason: null },
|
|
});
|
|
expect((await handle.stat()).isFile()).toBe(true);
|
|
|
|
const controller = new AbortController();
|
|
const cancelledRead = await reader.open(armEntry, controller.signal);
|
|
const iterator = cancelledRead[Symbol.asyncIterator]();
|
|
const firstChunk = await iterator.next();
|
|
expect(firstChunk.value).toHaveLength(64 * 1024);
|
|
controller.abort();
|
|
await expect(iterator.next()).rejects.toMatchObject({
|
|
reason: "cancelled",
|
|
});
|
|
|
|
const verifiedEntries = [];
|
|
for await (const entry of reader.entries()) verifiedEntries.push(entry);
|
|
const verifiedArmEntry = verifiedEntries.find(
|
|
(entry) => entry.path === "slices/arm64",
|
|
);
|
|
if (verifiedArmEntry === undefined)
|
|
throw new Error("Expected arm64 after re-verifying the borrowed source");
|
|
const verifiedChunks: Buffer[] = [];
|
|
const verifiedRead = await reader.open(verifiedArmEntry);
|
|
for await (const chunk of verifiedRead)
|
|
verifiedChunks.push(Buffer.from(chunk));
|
|
expect(Buffer.compare(Buffer.concat(verifiedChunks), arm)).toBe(0);
|
|
|
|
const truncatedController = new AbortController();
|
|
const truncated = await reader.open(
|
|
verifiedArmEntry,
|
|
truncatedController.signal,
|
|
);
|
|
await truncate(binary, 8193);
|
|
let readEOF = false;
|
|
const read = handle.read.bind(handle);
|
|
vi.spyOn(handle, "read").mockImplementation((async (...args: unknown[]) => {
|
|
const result = (await Reflect.apply(read, handle, args)) as {
|
|
bytesRead: number;
|
|
buffer: Buffer;
|
|
};
|
|
if (result.bytesRead === 0) readEOF = true;
|
|
return result;
|
|
}) as typeof handle.read);
|
|
const failed = new Promise<Error>((resolve) =>
|
|
truncated.once("error", resolve),
|
|
);
|
|
truncated.read(0);
|
|
await expect.poll(() => readEOF).toBe(true);
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
expect(truncated.readableLength).toBeGreaterThan(0);
|
|
truncatedController.abort(null);
|
|
await expect(failed).resolves.toMatchObject({ reason: "integrity" });
|
|
});
|
|
});
|
|
|
|
describe("universal slices outside the structural index", () => {
|
|
const PPC = { type: 18, subtype: 0 } as const;
|
|
/** A minimal big-endian 32-bit Mach-O header, as a PowerPC slice stores it. */
|
|
const bigEndianImage = (): Uint8Array => {
|
|
const bytes = Buffer.alloc(32);
|
|
bytes.writeUInt32BE(0xfeed_face, 0);
|
|
bytes.writeUInt32BE(PPC.type, 4);
|
|
bytes.writeUInt32BE(1, 12);
|
|
return bytes;
|
|
};
|
|
/** An arm64 image whose load commands claim more bytes than the slice holds. */
|
|
const malformedImage = (): Uint8Array => {
|
|
const bytes = machoImage({ cpu: CPU.arm64 });
|
|
new DataView(bytes.buffer).setUint32(20, 4096, true);
|
|
return bytes;
|
|
};
|
|
const lipoRunner = (
|
|
binary: string,
|
|
slices: readonly {
|
|
readonly name: string;
|
|
readonly cpu: { readonly type: number; readonly subtype: number };
|
|
readonly offset: number;
|
|
readonly size: number;
|
|
}[],
|
|
withRanges = true,
|
|
): NativeCommandRunner => ({
|
|
run: () =>
|
|
Promise.resolve(
|
|
ok({
|
|
tool: "lipo",
|
|
executable: "/usr/bin/lipo",
|
|
executableSha256: "1".repeat(64),
|
|
toolVersion: null,
|
|
versionReason: "fixture",
|
|
arguments: ["-detailed_info", binary],
|
|
stdout: slices
|
|
.map(
|
|
({ name, cpu, offset, size }) =>
|
|
`architecture ${name}\n cputype ${String(cpu.type)}\n cpusubtype ${String(cpu.subtype)}\n${withRanges ? ` offset ${String(offset)}\n size ${String(size)}\n` : ""} align 2^12 (4096)\n`,
|
|
)
|
|
.join(""),
|
|
stderr: "",
|
|
stdoutBytes: 1,
|
|
stderrBytes: 0,
|
|
exitCode: 0,
|
|
signal: null,
|
|
}),
|
|
),
|
|
});
|
|
const readAll = async (
|
|
slices: readonly {
|
|
readonly name: string;
|
|
readonly cpu: { readonly type: number; readonly subtype: number };
|
|
readonly bytes: Uint8Array;
|
|
}[],
|
|
withRanges = true,
|
|
) => {
|
|
const root = await createTestTempDirectory("rea-slices-unsupported-");
|
|
const binary = join(root, "fat");
|
|
await writeFile(binary, fatImage(slices));
|
|
const reader = new MachOSliceArtifactReader(
|
|
binary,
|
|
{},
|
|
lipoRunner(
|
|
binary,
|
|
slices.map(({ name, cpu, bytes }, index) => ({
|
|
name,
|
|
cpu,
|
|
offset: 4096 * (index + 1),
|
|
size: bytes.length,
|
|
})),
|
|
withRanges,
|
|
),
|
|
);
|
|
onTestFinished(() => reader.close());
|
|
const entries = [];
|
|
for await (const entry of reader.entries()) entries.push(entry);
|
|
return entries;
|
|
};
|
|
|
|
it("inventories every lipo range beside a big-endian slice", async () => {
|
|
const entries = await readAll([
|
|
{ name: "ppc", cpu: PPC, bytes: bigEndianImage() },
|
|
{ name: "arm64", cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) },
|
|
]);
|
|
expect(entries.map(({ path, byteOffset }) => [path, byteOffset])).toEqual([
|
|
["slices/ppc", 4096],
|
|
["slices/arm64", 8192],
|
|
]);
|
|
expect(entries[0]?.limitations).toContainEqual(
|
|
expect.stringMatching(
|
|
/big.endian Mach.O.*not supported.*slice range.*lipo/iu,
|
|
),
|
|
);
|
|
});
|
|
|
|
it("reports missing lipo ranges for an unindexed slice as unavailable support", async () => {
|
|
await expect(
|
|
readAll(
|
|
[
|
|
{ name: "ppc", cpu: PPC, bytes: bigEndianImage() },
|
|
{
|
|
name: "arm64",
|
|
cpu: CPU.arm64,
|
|
bytes: machoImage({ cpu: CPU.arm64 }),
|
|
},
|
|
],
|
|
false,
|
|
),
|
|
).rejects.toMatchObject({ reason: "unavailable" });
|
|
});
|
|
|
|
it.each([
|
|
["before", true],
|
|
["after", false],
|
|
])(
|
|
"rejects a malformed slice %s a big-endian slice",
|
|
async (_order, malformedFirst) => {
|
|
const ppc = { name: "ppc", cpu: PPC, bytes: bigEndianImage() };
|
|
const broken = { name: "arm64", cpu: CPU.arm64, bytes: malformedImage() };
|
|
await expect(
|
|
readAll(malformedFirst ? [broken, ppc] : [ppc, broken]),
|
|
).rejects.toMatchObject({ reason: "integrity" });
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("Mach-O slice stream ownership", () => {
|
|
it.each(["unread", "abandoned", "cancelled"] as const)(
|
|
"retires an %s opened slice before closing its carrier",
|
|
async (mode) => {
|
|
const fixture = await sliceOwnershipFixture();
|
|
const reader = new MachOSliceArtifactReader(
|
|
fixture.path,
|
|
{},
|
|
fixture.runner,
|
|
);
|
|
try {
|
|
const [entry] = await sliceEntries(reader);
|
|
const caller = new AbortController();
|
|
const stream = await reader.open(entry!, caller.signal);
|
|
await once(stream, "readable");
|
|
if (mode === "abandoned") {
|
|
for await (const _chunk of stream) break;
|
|
} else if (mode === "cancelled") {
|
|
const failure = once(stream, "error");
|
|
const reason = { caller: "slice owner cancellation" };
|
|
caller.abort(reason);
|
|
await expect(failure).resolves.toMatchObject([
|
|
{ reason: "cancelled", cause: reason },
|
|
]);
|
|
}
|
|
await Promise.all([reader.close(), reader.close()]);
|
|
expect(stream.destroyed).toBe(true);
|
|
expect(stream.closed).toBe(true);
|
|
await reader.close();
|
|
} finally {
|
|
await reader.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("joins an admitted descriptor read before reader close completes", async () => {
|
|
const fixture = await sliceOwnershipFixture();
|
|
const source = await openRegularFile(fixture.path, { symlinks: "reject" });
|
|
const reader = new MachOSliceArtifactReader(
|
|
fixture.path,
|
|
{},
|
|
fixture.runner,
|
|
source,
|
|
);
|
|
const reached = createDeferred<void>();
|
|
const release = createDeferred<void>();
|
|
const nativeRead = source.handle.read.bind(source.handle);
|
|
let reads: { mockRestore(): void } | undefined;
|
|
let consumed: Promise<Buffer> | undefined;
|
|
try {
|
|
const [entry] = await sliceEntries(reader);
|
|
reads = vi.spyOn(source.handle, "read").mockImplementation((async (
|
|
...args: unknown[]
|
|
) => {
|
|
reached.resolve();
|
|
await release.promise;
|
|
return Reflect.apply(nativeRead, source.handle, args);
|
|
}) as typeof source.handle.read);
|
|
const stream = await reader.open(entry!);
|
|
consumed = buffer(stream);
|
|
void consumed.catch(() => undefined);
|
|
await awaitAcknowledgement(reached.promise, consumed, "slice read");
|
|
let closed = false;
|
|
const closing = reader.close().then(() => {
|
|
closed = true;
|
|
});
|
|
await Promise.resolve();
|
|
expect(closed).toBe(false);
|
|
release.resolve();
|
|
await closing;
|
|
expect(stream.closed).toBe(true);
|
|
} finally {
|
|
release.resolve();
|
|
try {
|
|
await reader.close();
|
|
} finally {
|
|
await consumed?.catch(() => undefined);
|
|
reads?.mockRestore();
|
|
await source.handle.close();
|
|
}
|
|
}
|
|
});
|
|
|
|
it("keeps descriptor closure retryable after retiring its slice", async () => {
|
|
const fixture = await sliceOwnershipFixture();
|
|
const reader = new MachOSliceArtifactReader(
|
|
fixture.path,
|
|
{},
|
|
fixture.runner,
|
|
);
|
|
const [entry] = await sliceEntries(reader);
|
|
const stream = await reader.open(entry!);
|
|
const failure = new Error("Mach-O descriptor close denied");
|
|
const close = vi
|
|
.spyOn(OwnedFileHandle.prototype, "close")
|
|
.mockRejectedValueOnce(failure);
|
|
try {
|
|
await expect(reader.close()).rejects.toBe(failure);
|
|
expect(stream.closed).toBe(true);
|
|
await reader.close();
|
|
expect(close).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
close.mockRestore();
|
|
await reader.close();
|
|
}
|
|
});
|
|
|
|
it.runIf(process.platform === "darwin")(
|
|
"retires a slice discovered by the installed lipo",
|
|
async () => {
|
|
const fixture = await sliceOwnershipFixture();
|
|
const reader = new MachOSliceArtifactReader(fixture.path, process.env);
|
|
try {
|
|
const [entry] = await sliceEntries(reader);
|
|
const stream = await reader.open(entry!);
|
|
await once(stream, "readable");
|
|
await reader.close();
|
|
expect(stream.closed).toBe(true);
|
|
expect(reader.provenance()).toMatchObject([
|
|
{ tool: "lipo", exit_code: 0 },
|
|
]);
|
|
} finally {
|
|
await reader.close();
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
it("joins Mach-O structural enumeration reads admitted before close", async () => {
|
|
const fixture = await sliceOwnershipFixture();
|
|
const source = await openRegularFile(fixture.path, { symlinks: "reject" });
|
|
const reader = new MachOSliceArtifactReader(
|
|
fixture.path,
|
|
{},
|
|
fixture.runner,
|
|
source,
|
|
);
|
|
const reached = createDeferred<void>();
|
|
const release = createDeferred<void>();
|
|
const nativeRead = source.handle.read.bind(source.handle);
|
|
const reads = vi.spyOn(source.handle, "read").mockImplementation((async (
|
|
...args: unknown[]
|
|
) => {
|
|
reached.resolve();
|
|
await release.promise;
|
|
return Reflect.apply(nativeRead, source.handle, args);
|
|
}) as typeof source.handle.read);
|
|
const traversal = reader.entries()[Symbol.asyncIterator]();
|
|
const next = traversal.next();
|
|
try {
|
|
await awaitAcknowledgement(reached.promise, next, "slice enumeration read");
|
|
let closed = false;
|
|
const closing = reader.close().then(() => {
|
|
closed = true;
|
|
});
|
|
await Promise.resolve();
|
|
expect(closed).toBe(false);
|
|
release.resolve();
|
|
await expect(next).rejects.toMatchObject({ reason: "unavailable" });
|
|
await closing;
|
|
} finally {
|
|
release.resolve();
|
|
await next.catch(() => undefined);
|
|
await reader.close();
|
|
reads.mockRestore();
|
|
await source.handle.close();
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
["cancel", false],
|
|
["cancel", true],
|
|
["close", false],
|
|
] as const)(
|
|
"refuses to complete a paused slice enumeration after %s (more slices: %s)",
|
|
async (action, moreSlices) => {
|
|
const fixture = await sliceOwnershipFixture(moreSlices);
|
|
const reader = new MachOSliceArtifactReader(
|
|
fixture.path,
|
|
{},
|
|
fixture.runner,
|
|
);
|
|
const caller = new AbortController();
|
|
const reason = { stage: "after final slice" };
|
|
const traversal = reader.entries(caller.signal)[Symbol.asyncIterator]();
|
|
try {
|
|
expect((await traversal.next()).done).toBe(false);
|
|
if (action === "cancel") caller.abort(reason);
|
|
else await reader.close();
|
|
const failure: unknown = await traversal.next().then(
|
|
() => {
|
|
throw new Error("Expected paused enumeration to fail");
|
|
},
|
|
(cause: unknown) => cause,
|
|
);
|
|
expect(failure).toMatchObject({
|
|
reason: action === "cancel" ? "cancelled" : "unavailable",
|
|
});
|
|
if (action === "cancel") expect((failure as Error).cause).toBe(reason);
|
|
} finally {
|
|
await reader.close();
|
|
}
|
|
},
|
|
);
|