mirror of
https://github.com/morluto/rea.git
synced 2026-10-19 04:19:54 -05:00
fix(apple): share dylib read ownership and cancellation facts
Refs #4495. Normalize dylib file access once at the admitted descriptor boundary. Preserve full file identity, physical read outcomes, cancellation provenance and cleanup retry through artifact analysis.
This commit is contained in:
1 parent
2d163c12b1
commit
d292c445ee
6 files changed
+670
-239
No files matched your search
@@ -27,6 +27,7 @@ import {
|
||||
} from "../domain/binaryTargetTypes.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisUnsupportedTargetError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
@@ -37,6 +38,10 @@ import { interfaceBuilderLimitsSchema } from "../domain/apple/interfaceBuilderGr
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
|
||||
import {
|
||||
artifactFileCancellationCause,
|
||||
bindArtifactFileCleanup,
|
||||
} from "./ArtifactFileOperation.js";
|
||||
import { artifactCapabilities } from "./ArtifactProviderMetadata.js";
|
||||
import { ARTIFACT_GRAPH_PROVIDER } from "../application/InvestigationProviders.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
@@ -234,18 +239,18 @@ class ArtifactClient implements AnalysisClient {
|
||||
(cause: unknown) => {
|
||||
const failure =
|
||||
cause instanceof ArtifactReaderFailure ? cause : undefined;
|
||||
return err(
|
||||
new ProviderCleanupError(
|
||||
ARTIFACT_GRAPH_PROVIDER.id,
|
||||
failure?.cleanup?.resources ?? ["artifact resources"],
|
||||
{
|
||||
reason:
|
||||
failure?.cleanup?.reason ??
|
||||
(cause instanceof Error ? cause.message : String(cause)),
|
||||
},
|
||||
{ cause, operation: "close_binary" },
|
||||
),
|
||||
const error = new ProviderCleanupError(
|
||||
ARTIFACT_GRAPH_PROVIDER.id,
|
||||
failure?.cleanup?.resources ?? ["artifact resources"],
|
||||
{
|
||||
reason:
|
||||
failure?.cleanup?.reason ??
|
||||
(cause instanceof Error ? cause.message : String(cause)),
|
||||
},
|
||||
{ cause, operation: "close_binary" },
|
||||
);
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
return err(error);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -315,6 +320,7 @@ class ArtifactClient implements AnalysisClient {
|
||||
targetSha256: this.target.sha256,
|
||||
enumerateRoots: bundle !== undefined,
|
||||
parameters,
|
||||
resourceScope: this.#resourceScope,
|
||||
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
||||
});
|
||||
return ok(
|
||||
@@ -407,23 +413,31 @@ export const translateArtifactFailure = (
|
||||
operation: ArtifactAnalysisOperation,
|
||||
cause: unknown,
|
||||
): AnalysisError => {
|
||||
if (cause instanceof ArtifactReaderFailure)
|
||||
return new ArtifactOperationError(
|
||||
if (cause instanceof ArtifactReaderFailure) {
|
||||
const error = new ArtifactOperationError(
|
||||
operation,
|
||||
cause.reason,
|
||||
cause.details,
|
||||
cause.message,
|
||||
{
|
||||
cause:
|
||||
cause.reason === "cancelled"
|
||||
? artifactFileCancellationCause(cause)
|
||||
: cause,
|
||||
...(cause.cleanup === undefined ? {} : { cleanup: cause.cleanup }),
|
||||
...(cause.partialObservation === undefined
|
||||
? {}
|
||||
: { partialObservation: cause.partialObservation }),
|
||||
},
|
||||
);
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
return error;
|
||||
}
|
||||
// Caller-selection and unsupported-target failures are already typed; keep
|
||||
// their correction details instead of reducing them to an I/O failure.
|
||||
if (
|
||||
cause instanceof AnalysisInputError ||
|
||||
cause instanceof AnalysisCancelledError ||
|
||||
cause instanceof AnalysisUnsupportedTargetError
|
||||
)
|
||||
return cause;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { BigIntStats } from "node:fs";
|
||||
import { lstat, type FileHandle } from "node:fs/promises";
|
||||
import { isAbsolute, relative, sep } from "node:path";
|
||||
|
||||
import {
|
||||
NonRegularFileReadError,
|
||||
sameRegularFileState,
|
||||
} from "../../filesystem/RegularFile.js";
|
||||
import {
|
||||
ArtifactReaderFailure,
|
||||
sameArtifactSourceIdentity,
|
||||
type ArtifactEntry,
|
||||
} from "../ArtifactReader.js";
|
||||
import {
|
||||
assertArtifactReadActive,
|
||||
mapArtifactFileFailure,
|
||||
withArtifactRegularFile,
|
||||
} from "../ArtifactFileOperation.js";
|
||||
import type { ArtifactResourceScope } from "../ArtifactResourceScope.js";
|
||||
import { readArtifactFileChunks } from "../readArtifactFileChunks.js";
|
||||
import { hasMachoMagic, readMachoImage } from "./MachoLoadCommandReader.js";
|
||||
|
||||
/** One owner for dyld descriptor admission, physical identity and finalization. */
|
||||
export class DylibFileAccess {
|
||||
constructor(
|
||||
private readonly resources: ArtifactResourceScope,
|
||||
private readonly root: string,
|
||||
private readonly signal?: AbortSignal,
|
||||
) {}
|
||||
|
||||
startsWithMachoMagic(entry: ArtifactEntry): Promise<boolean> {
|
||||
return this.#read(entry.adapterKey, async (file, initial) => {
|
||||
if (!sameArtifactSourceIdentity(entry.sourceIdentity, initial))
|
||||
throw changed(entry.adapterKey);
|
||||
const bytes = new Uint8Array(4);
|
||||
const { bytesRead } = await file.read(bytes, 0, bytes.length, 0);
|
||||
return hasMachoMagic(bytes.subarray(0, bytesRead));
|
||||
});
|
||||
}
|
||||
|
||||
image(path: string) {
|
||||
return this.#read(path, async (file, identity) => {
|
||||
const size = Number(identity.size);
|
||||
if (!Number.isSafeInteger(size))
|
||||
throw new ArtifactReaderFailure(
|
||||
"limit",
|
||||
`Mach-O file extent is not safely addressable: ${path}`,
|
||||
);
|
||||
const facts = await readMachoImage(async (offset, length) => {
|
||||
assertArtifactReadActive(this.signal);
|
||||
const bytes = new Uint8Array(
|
||||
Math.max(0, Math.min(length, size - offset)),
|
||||
);
|
||||
const { bytesRead } = await file.read(bytes, 0, bytes.length, offset);
|
||||
assertArtifactReadActive(this.signal);
|
||||
return bytes.subarray(0, bytesRead);
|
||||
}, size);
|
||||
return { facts, identity };
|
||||
});
|
||||
}
|
||||
|
||||
sha256(path: string, expected: BigIntStats | undefined): Promise<string> {
|
||||
return this.#read(path, async (file, initial) => {
|
||||
if (expected !== undefined && !sameRegularFileState(expected, initial))
|
||||
throw changed(path);
|
||||
const hash = createHash("sha256");
|
||||
// Admit the extent plus one byte to detect growth without following an
|
||||
// actively appended file indefinitely. The descriptor remains owned.
|
||||
const source = readArtifactFileChunks(file, {
|
||||
end: initial.size,
|
||||
signal: this.signal,
|
||||
});
|
||||
for await (const chunk of source) hash.update(chunk);
|
||||
const coverage = source.readCoverage();
|
||||
if (coverage.kind !== "complete" || coverage.bytes !== initial.size)
|
||||
throw changed(path);
|
||||
return hash.digest("hex");
|
||||
});
|
||||
}
|
||||
|
||||
async #read<Value>(
|
||||
path: string,
|
||||
operation: (file: FileHandle, initial: BigIntStats) => Promise<Value>,
|
||||
): Promise<Value> {
|
||||
try {
|
||||
return await withArtifactRegularFile(
|
||||
path,
|
||||
this.resources,
|
||||
this.signal,
|
||||
async (file) => {
|
||||
const initial = await file.stat({ bigint: true });
|
||||
assertArtifactReadActive(this.signal);
|
||||
const result = await operation(file, initial);
|
||||
// Parsed headers, magic and digest must refer to the same physical
|
||||
// version still selected by the pathname when the read completes.
|
||||
const opened = await file.stat({ bigint: true });
|
||||
const named = await lstat(path, { bigint: true });
|
||||
if (
|
||||
!sameRegularFileState(initial, opened) ||
|
||||
!sameRegularFileState(initial, named)
|
||||
)
|
||||
throw changed(path);
|
||||
assertArtifactReadActive(this.signal);
|
||||
return result;
|
||||
},
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
throw classifyDylibReadFailure(this.root, cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize dyld admission and permission facts inside their physical outcome. */
|
||||
export const classifyDylibReadFailure = (
|
||||
root: string,
|
||||
cause: unknown,
|
||||
): unknown =>
|
||||
mapArtifactFileFailure(cause, (primary) => {
|
||||
if (primary instanceof NonRegularFileReadError)
|
||||
return new ArtifactReaderFailure(
|
||||
"format",
|
||||
`Dyld input is not a regular file: ${primary.path}`,
|
||||
{ cause: primary },
|
||||
);
|
||||
if (
|
||||
primary instanceof Error &&
|
||||
"code" in primary &&
|
||||
(primary.code === "EACCES" || primary.code === "EPERM")
|
||||
) {
|
||||
const path =
|
||||
"path" in primary && typeof primary.path === "string"
|
||||
? primary.path
|
||||
: undefined;
|
||||
const display =
|
||||
path === undefined
|
||||
? "a file in the analyzed root"
|
||||
: deniedDisplayPath(root, path);
|
||||
return new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
`Permission denied (${primary.code}) reading ${display}`,
|
||||
{ cause: primary },
|
||||
);
|
||||
}
|
||||
return primary;
|
||||
});
|
||||
|
||||
const deniedDisplayPath = (root: string, path: string): string => {
|
||||
const inside = relative(root, path);
|
||||
const outside =
|
||||
isAbsolute(inside) || inside === ".." || inside.startsWith(`..${sep}`);
|
||||
return inside === "" || outside ? path : inside;
|
||||
};
|
||||
|
||||
const changed = (path: string): ArtifactReaderFailure =>
|
||||
new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`${path} changed while its dyld load commands were traced`,
|
||||
);
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { lstat, open, readlink, realpath } from "node:fs/promises";
|
||||
import type { BigIntStats } from "node:fs";
|
||||
import { lstat, readlink, realpath } from "node:fs/promises";
|
||||
import { join, relative, sep } from "node:path";
|
||||
|
||||
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
||||
@@ -18,24 +17,26 @@ import { projectInputIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
||||
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
|
||||
import { consumeWithOrderedReads } from "../OrderedReads.js";
|
||||
import { hasMachoMagic, readMachoImage } from "./MachoLoadCommandReader.js";
|
||||
import {
|
||||
DylibFileAccess,
|
||||
classifyDylibReadFailure,
|
||||
} from "./DylibFileAccess.js";
|
||||
import { ArtifactResourceScope } from "../ArtifactResourceScope.js";
|
||||
import { assertArtifactReadActive } from "../ArtifactFileOperation.js";
|
||||
|
||||
const OPERATION = "trace_dylib_resolution";
|
||||
const HASH_CHUNK_BYTES = 1024 * 1024;
|
||||
/** Concurrent four-byte magic reads while enumerating bundle roots. */
|
||||
const MAGIC_READ_CONCURRENCY = 8;
|
||||
|
||||
/** Device, inode, size and change times of the file whose bytes were parsed. */
|
||||
type FileIdentity = string;
|
||||
|
||||
/** Lazily probed, symlink-preserving view of one analyzed directory. */
|
||||
class FilesystemTreeView implements DylibTreeView {
|
||||
readonly #entries = new Map<string, Promise<DylibTreeEntry | undefined>>();
|
||||
readonly #images = new Map<string, Promise<MachoImageFacts>>();
|
||||
readonly #identities = new Map<string, FileIdentity>();
|
||||
readonly #identities = new Map<string, BigIntStats>();
|
||||
|
||||
constructor(
|
||||
private readonly root: string,
|
||||
private readonly files: DylibFileAccess,
|
||||
private readonly signal?: AbortSignal,
|
||||
) {}
|
||||
|
||||
@@ -50,23 +51,23 @@ class FilesystemTreeView implements DylibTreeView {
|
||||
image(path: string): Promise<MachoImageFacts> {
|
||||
const cached = this.#images.get(path);
|
||||
if (cached !== undefined) return cached;
|
||||
const pending = readImage(join(this.root, path), this.signal).then(
|
||||
({ facts, identity }) => {
|
||||
const pending = this.files
|
||||
.image(join(this.root, path))
|
||||
.then(({ facts, identity }) => {
|
||||
this.#identities.set(path, identity);
|
||||
return facts;
|
||||
},
|
||||
);
|
||||
});
|
||||
this.#images.set(path, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Identity of the file whose header bytes `image()` parsed. */
|
||||
identity(path: string): FileIdentity | undefined {
|
||||
identity(path: string): BigIntStats | undefined {
|
||||
return this.#identities.get(path);
|
||||
}
|
||||
|
||||
async #readEntry(path: string): Promise<DylibTreeEntry | undefined> {
|
||||
cancelled(this.signal);
|
||||
assertArtifactReadActive(this.signal);
|
||||
const absolute = join(this.root, path);
|
||||
try {
|
||||
const metadata = await lstat(absolute);
|
||||
@@ -88,6 +89,7 @@ export const traceDylibResolution = async (options: {
|
||||
readonly targetSha256: string;
|
||||
readonly enumerateRoots: boolean;
|
||||
readonly parameters: unknown;
|
||||
readonly resourceScope?: ArtifactResourceScope;
|
||||
readonly signal?: AbortSignal;
|
||||
}): Promise<DylibResolutionResult> => {
|
||||
const parsed = dylibResolutionInputSchema.safeParse(options.parameters);
|
||||
@@ -97,82 +99,69 @@ export const traceDylibResolution = async (options: {
|
||||
{ cause: parsed.error },
|
||||
projectInputIssues(parsed.error.issues, options.parameters),
|
||||
);
|
||||
// Canonicalization is inside the translated region: a revoked permission
|
||||
// keeps its reason and path.
|
||||
let root = options.rootPath;
|
||||
try {
|
||||
root = await realpath(options.rootPath);
|
||||
const target = relative(root, await realpath(options.targetPath))
|
||||
.split(sep)
|
||||
.join("/");
|
||||
const view = new FilesystemTreeView(root, options.signal);
|
||||
const { roots: requested, unclassified } =
|
||||
parsed.data.roots !== undefined
|
||||
? { roots: parsed.data.roots, unclassified: [] }
|
||||
: options.enumerateRoots
|
||||
? await executableRoots(root, view, options.signal)
|
||||
: { roots: [target], unclassified: [] };
|
||||
const roots = await requireMachoRoots(
|
||||
view,
|
||||
requested,
|
||||
parsed.data.roots !== undefined,
|
||||
);
|
||||
const trace = await traceDylibLoading(view, {
|
||||
roots,
|
||||
unclassified,
|
||||
...(parsed.data.architecture === undefined
|
||||
? {}
|
||||
: { architecture: parsed.data.architecture }),
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
});
|
||||
// Each digest must describe the same file whose headers were parsed.
|
||||
const digests = new Map<string, string>();
|
||||
for (const { path } of trace.images)
|
||||
digests.set(
|
||||
path,
|
||||
await fileSha256(join(root, path), {
|
||||
path,
|
||||
expected: view.identity(path),
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
}),
|
||||
const resources = options.resourceScope ?? new ArtifactResourceScope();
|
||||
return await resources.run(async () => {
|
||||
// Canonicalization is inside the translated region: a revoked permission
|
||||
// keeps its reason and path.
|
||||
let root = options.rootPath;
|
||||
try {
|
||||
assertArtifactReadActive(options.signal);
|
||||
root = await realpath(options.rootPath);
|
||||
assertArtifactReadActive(options.signal);
|
||||
const files = new DylibFileAccess(resources, root, options.signal);
|
||||
const target = relative(root, await realpath(options.targetPath))
|
||||
.split(sep)
|
||||
.join("/");
|
||||
assertArtifactReadActive(options.signal);
|
||||
const view = new FilesystemTreeView(root, files, options.signal);
|
||||
const { roots: requested, unclassified } =
|
||||
parsed.data.roots !== undefined
|
||||
? { roots: parsed.data.roots, unclassified: [] }
|
||||
: options.enumerateRoots
|
||||
? await executableRoots(root, view, files, options.signal)
|
||||
: { roots: [target], unclassified: [] };
|
||||
const roots = await requireMachoRoots(
|
||||
view,
|
||||
requested,
|
||||
parsed.data.roots !== undefined,
|
||||
);
|
||||
const targetDigest =
|
||||
digests.get(target) ??
|
||||
(await fileSha256(join(root, target), {
|
||||
path: target,
|
||||
expected: undefined,
|
||||
const trace = await traceDylibLoading(view, {
|
||||
roots,
|
||||
unclassified,
|
||||
...(parsed.data.architecture === undefined
|
||||
? {}
|
||||
: { architecture: parsed.data.architecture }),
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
}));
|
||||
if (targetDigest !== options.targetSha256)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Active target digest changed: expected ${options.targetSha256}, observed ${targetDigest}`,
|
||||
);
|
||||
return dylibResolutionResultSchema.parse({
|
||||
...trace,
|
||||
root_path: options.rootPath,
|
||||
target_sha256: options.targetSha256,
|
||||
images: trace.images.map((image) => ({
|
||||
...image,
|
||||
sha256: digests.get(image.path),
|
||||
})),
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (options.signal?.aborted === true)
|
||||
throw new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"Dylib resolution was cancelled",
|
||||
{ cause },
|
||||
);
|
||||
const denied = permissionDenied(cause);
|
||||
if (denied !== undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
`Permission denied (${denied.code}) reading ${deniedPath(root, denied.path)}`,
|
||||
{ cause },
|
||||
);
|
||||
throw cause;
|
||||
}
|
||||
});
|
||||
// Each digest must describe the same file whose headers were parsed.
|
||||
const digests = new Map<string, string>();
|
||||
for (const { path } of trace.images)
|
||||
digests.set(
|
||||
path,
|
||||
await files.sha256(join(root, path), view.identity(path)),
|
||||
);
|
||||
const targetDigest =
|
||||
digests.get(target) ??
|
||||
(await files.sha256(join(root, target), undefined));
|
||||
if (targetDigest !== options.targetSha256)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Active target digest changed: expected ${options.targetSha256}, observed ${targetDigest}`,
|
||||
);
|
||||
assertArtifactReadActive(options.signal);
|
||||
return dylibResolutionResultSchema.parse({
|
||||
...trace,
|
||||
root_path: options.rootPath,
|
||||
target_sha256: options.targetSha256,
|
||||
images: trace.images.map((image) => ({
|
||||
...image,
|
||||
sha256: digests.get(image.path),
|
||||
})),
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
throw classifyDylibReadFailure(root, cause);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -183,6 +172,7 @@ export const traceDylibResolution = async (options: {
|
||||
const executableRoots = async (
|
||||
root: string,
|
||||
view: FilesystemTreeView,
|
||||
files: DylibFileAccess,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ readonly roots: string[]; readonly unclassified: string[] }> => {
|
||||
const roots: string[] = [];
|
||||
@@ -193,9 +183,7 @@ const executableRoots = async (
|
||||
new DirectoryArtifactReader(root).entries(signal),
|
||||
MAGIC_READ_CONCURRENCY,
|
||||
(entry) =>
|
||||
entry.kind === "file"
|
||||
? startsWithMachoMagic(entry.adapterKey)
|
||||
: undefined,
|
||||
entry.kind === "file" ? files.startsWithMachoMagic(entry) : undefined,
|
||||
async (entry, macho) => {
|
||||
if (!macho) return;
|
||||
const facts = await view.image(entry.path);
|
||||
@@ -254,129 +242,6 @@ const requireMachoRoots = async (
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const startsWithMachoMagic = async (path: string): Promise<boolean> => {
|
||||
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const buffer = new Uint8Array(4);
|
||||
const { bytesRead } = await handle.read(buffer, 0, 4, 0);
|
||||
return hasMachoMagic(buffer.subarray(0, bytesRead));
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
};
|
||||
|
||||
const identityOf = (metadata: {
|
||||
readonly dev: bigint;
|
||||
readonly ino: bigint;
|
||||
readonly size: bigint;
|
||||
readonly mtimeNs: bigint;
|
||||
readonly ctimeNs: bigint;
|
||||
}): FileIdentity =>
|
||||
[
|
||||
metadata.dev,
|
||||
metadata.ino,
|
||||
metadata.size,
|
||||
metadata.mtimeNs,
|
||||
metadata.ctimeNs,
|
||||
].join(":");
|
||||
|
||||
const readImage = async (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
readonly facts: MachoImageFacts;
|
||||
readonly identity: FileIdentity;
|
||||
}> => {
|
||||
cancelled(signal);
|
||||
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const metadata = await handle.stat({ bigint: true });
|
||||
const size = Number(metadata.size);
|
||||
const facts = await readMachoImage(async (offset, length) => {
|
||||
cancelled(signal);
|
||||
const buffer = new Uint8Array(
|
||||
Math.max(0, Math.min(length, size - offset)),
|
||||
);
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
|
||||
return buffer.subarray(0, bytesRead);
|
||||
}, size);
|
||||
return { facts, identity: identityOf(metadata) };
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
};
|
||||
|
||||
/** Hash a file, failing if it is not the file (or the version) that was parsed. */
|
||||
const fileSha256 = async (
|
||||
absolute: string,
|
||||
options: {
|
||||
readonly path: string;
|
||||
readonly expected: FileIdentity | undefined;
|
||||
readonly signal?: AbortSignal;
|
||||
},
|
||||
): Promise<string> => {
|
||||
const { signal } = options;
|
||||
const handle = await open(
|
||||
absolute,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW,
|
||||
);
|
||||
const unchanged = async (): Promise<void> => {
|
||||
if (
|
||||
options.expected !== undefined &&
|
||||
identityOf(await handle.stat({ bigint: true })) !== options.expected
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`${options.path} changed while its dyld load commands were traced`,
|
||||
);
|
||||
};
|
||||
try {
|
||||
await unchanged();
|
||||
const hash = createHash("sha256");
|
||||
const buffer = new Uint8Array(HASH_CHUNK_BYTES);
|
||||
for (;;) {
|
||||
cancelled(signal);
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
|
||||
if (bytesRead === 0) break;
|
||||
hash.update(buffer.subarray(0, bytesRead));
|
||||
}
|
||||
await unchanged();
|
||||
return hash.digest("hex");
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
};
|
||||
|
||||
const cancelled = (signal?: AbortSignal): void => {
|
||||
if (signal?.aborted === true)
|
||||
throw new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"Dylib resolution was cancelled",
|
||||
);
|
||||
};
|
||||
|
||||
/** A denied path relative to the analyzed root, or absolute when outside it. */
|
||||
const deniedPath = (root: string, path: string | undefined): string => {
|
||||
if (path === undefined) return "a file in the analyzed root";
|
||||
const inside = relative(root, path);
|
||||
return inside === "" || inside.startsWith("..") ? path : inside;
|
||||
};
|
||||
|
||||
/** Host permission denials, kept distinct from malformed or missing files. */
|
||||
const permissionDenied = (
|
||||
cause: unknown,
|
||||
): { readonly code: string; readonly path: string | undefined } | undefined => {
|
||||
if (!(cause instanceof Error) || !("code" in cause)) return undefined;
|
||||
if (cause.code !== "EACCES" && cause.code !== "EPERM") return undefined;
|
||||
return {
|
||||
code: cause.code,
|
||||
path:
|
||||
"path" in cause && typeof cause.path === "string"
|
||||
? cause.path
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const missing = (cause: unknown): boolean =>
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
|
||||
@@ -585,16 +585,22 @@ describe("dyld load order", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("stops when cancelled", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
traceDylibLoading(memoryView({ [MAIN]: executable() }), {
|
||||
roots: [MAIN],
|
||||
signal: controller.signal,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
it.each([undefined, null, "caller reason"])(
|
||||
"stops with the exact caller cancellation reason: %s",
|
||||
async (reason) => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(reason);
|
||||
await expect(
|
||||
traceDylibLoading(memoryView({ [MAIN]: executable() }), {
|
||||
roots: [MAIN],
|
||||
signal: controller.signal,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
_tag: "AnalysisCancelledError",
|
||||
cause: controller.signal.reason,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("loader-relative dylib resolution", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { AnalysisCancelledError } from "../analysisErrorCore.js";
|
||||
|
||||
import {
|
||||
compatibleSlice,
|
||||
@@ -516,7 +517,10 @@ const traceProcess = async (
|
||||
context.byInstallName.set(rootSlice.install_name, context.root);
|
||||
const pending = [context.root];
|
||||
while (pending.length > 0) {
|
||||
signal?.throwIfAborted();
|
||||
if (signal?.aborted)
|
||||
throw new AnalysisCancelledError("trace_dylib_resolution", {
|
||||
cause: signal.reason,
|
||||
});
|
||||
const loader = pending.pop() ?? "";
|
||||
const current = context.loaded.get(loader);
|
||||
if (current === undefined) continue;
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdir, rename, writeFile, type FileHandle } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const fault = vi.hoisted(() => ({
|
||||
path: "",
|
||||
handles: [] as FileHandle[],
|
||||
blocked: new Set<FileHandle>(),
|
||||
blockClose: false,
|
||||
readFailure: undefined as Error | undefined,
|
||||
controller: undefined as AbortController | undefined,
|
||||
abortOnRead: false,
|
||||
abortClose: 0,
|
||||
closeCount: 0,
|
||||
onClose: undefined as (() => Promise<void>) | undefined,
|
||||
beforeOpen: undefined as (() => Promise<void>) | undefined,
|
||||
onRead: undefined as (() => Promise<void>) | undefined,
|
||||
}));
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("node:fs/promises")>();
|
||||
return {
|
||||
...original,
|
||||
open: async (...args: Parameters<typeof original.open>) => {
|
||||
if (args[0] === fault.path) await fault.beforeOpen?.();
|
||||
const handle = await original.open(...args);
|
||||
if (args[0] !== fault.path) return handle;
|
||||
fault.handles.push(handle);
|
||||
if (fault.blockClose) fault.blocked.add(handle);
|
||||
const read = handle.read.bind(handle);
|
||||
handle.read = (async (...readArgs: unknown[]) => {
|
||||
if (fault.abortOnRead) fault.controller?.abort(null);
|
||||
if (fault.readFailure !== undefined) throw fault.readFailure;
|
||||
const result = await Reflect.apply(read, handle, readArgs);
|
||||
await fault.onRead?.();
|
||||
return result;
|
||||
}) as FileHandle["read"];
|
||||
const close = handle.close.bind(handle);
|
||||
handle.close = async () => {
|
||||
fault.closeCount += 1;
|
||||
await fault.onClose?.();
|
||||
if (fault.closeCount === fault.abortClose)
|
||||
fault.controller?.abort(null);
|
||||
if (fault.blocked.has(handle))
|
||||
throw new Error("controlled dyld close failure");
|
||||
await close();
|
||||
};
|
||||
return handle;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { traceDylibResolution } from "../../src/artifacts/apple/DylibResolutionReader.js";
|
||||
import { machoImage } from "../../src/artifacts/apple/MachoImage.fixture.js";
|
||||
import { ArtifactFileCleanupFailure } from "../../src/artifacts/ArtifactFileOperation.js";
|
||||
import { ArtifactReaderFailure } from "../../src/artifacts/ArtifactReader.js";
|
||||
import { ArtifactProvider } from "../../src/artifacts/ArtifactProvider.js";
|
||||
import type { AnalysisClient } from "../../src/application/AnalysisProvider.js";
|
||||
import { parseBinaryTarget } from "../../src/application/BinaryTargetResolver.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
|
||||
const clients: AnalysisClient[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
fault.path = "";
|
||||
fault.handles = [];
|
||||
fault.blocked.clear();
|
||||
fault.blockClose = false;
|
||||
fault.readFailure = undefined;
|
||||
fault.controller = undefined;
|
||||
fault.abortOnRead = false;
|
||||
fault.abortClose = 0;
|
||||
fault.closeCount = 0;
|
||||
fault.onClose = undefined;
|
||||
fault.beforeOpen = undefined;
|
||||
fault.onRead = undefined;
|
||||
});
|
||||
afterEach(async () => {
|
||||
fault.blocked.clear();
|
||||
fault.onClose = undefined;
|
||||
for (const client of clients.splice(0)) await client.close();
|
||||
for (const handle of fault.handles)
|
||||
if (handle.fd !== -1) await handle.close();
|
||||
});
|
||||
|
||||
const fixture = async () => {
|
||||
const root = await createTestTempDirectory("rea-dyld-lifetime-");
|
||||
const path = join(root, "Main");
|
||||
const bytes = machoImage({});
|
||||
await writeFile(path, bytes);
|
||||
const target = await parseBinaryTarget(path);
|
||||
if (!target.ok) throw target.error;
|
||||
const controller = new AbortController();
|
||||
fault.path = path;
|
||||
fault.controller = controller;
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
const client = new ArtifactProvider({}).createClient(target.value);
|
||||
clients.push(client);
|
||||
return {
|
||||
controller,
|
||||
client,
|
||||
trace: () =>
|
||||
traceDylibResolution({
|
||||
rootPath: root,
|
||||
targetPath: path,
|
||||
targetSha256: sha256,
|
||||
enumerateRoots: false,
|
||||
parameters: { roots: ["Main"] },
|
||||
signal: controller.signal,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
it("preserves a dyld read failure when the same read observes cancellation", async () => {
|
||||
const { trace } = await fixture();
|
||||
const cause = Object.assign(new Error("controlled dyld EIO"), {
|
||||
code: "EIO",
|
||||
});
|
||||
fault.readFailure = cause;
|
||||
fault.abortOnRead = true;
|
||||
await expect(trace()).rejects.toBe(cause);
|
||||
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
|
||||
});
|
||||
|
||||
it("observes cancellation during the final dyld digest close", async () => {
|
||||
const { trace } = await fixture();
|
||||
fault.abortClose = 2;
|
||||
await expect(trace()).rejects.toMatchObject({
|
||||
reason: "cancelled",
|
||||
cancellation: { reason: null },
|
||||
});
|
||||
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["completed", "failed", "cancelled"] as const)(
|
||||
"retains the exact dyld descriptor and %s outcome when close fails",
|
||||
async (kind) => {
|
||||
const { trace } = await fixture();
|
||||
fault.blockClose = true;
|
||||
const cause = new ArtifactReaderFailure("io", "controlled primary");
|
||||
if (kind === "failed") fault.readFailure = cause;
|
||||
if (kind === "cancelled") fault.abortClose = 1;
|
||||
const failure = await trace().catch((error: unknown) => error);
|
||||
expect(failure).toBeInstanceOf(ArtifactFileCleanupFailure);
|
||||
if (!(failure instanceof ArtifactFileCleanupFailure))
|
||||
throw new Error("Expected exact descriptor cleanup capability");
|
||||
expect(failure.fileCleanupFailure.owner.handle).toBe(fault.handles[0]);
|
||||
if (kind === "cancelled")
|
||||
expect(failure).toMatchObject({
|
||||
reason: "cancelled",
|
||||
cancellation: { reason: null },
|
||||
});
|
||||
expect(failure.fileCleanupFailure.outcome.kind).toBe(
|
||||
kind === "failed" ? "failed" : "completed",
|
||||
);
|
||||
if (kind === "failed")
|
||||
expect(failure.fileCleanupFailure.outcome).toEqual({
|
||||
kind: "failed",
|
||||
cause,
|
||||
});
|
||||
fault.blocked.clear();
|
||||
await failure.cleanupLease.release();
|
||||
expect(fault.handles[0]?.fd).toBe(-1);
|
||||
},
|
||||
);
|
||||
|
||||
it("projects cancellation with cleanup uncertainty when dyld final close fails", async () => {
|
||||
const { client, controller } = await fixture();
|
||||
fault.abortClose = 1;
|
||||
fault.blockClose = true;
|
||||
const result = await client.execute(
|
||||
"trace_dylib_resolution",
|
||||
{ roots: ["Main"] },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (result.ok) throw new Error("Expected interruption during finalization");
|
||||
expect(result.error).toMatchObject({
|
||||
reason: "cancelled",
|
||||
cause: null,
|
||||
cleanupIncomplete: true,
|
||||
});
|
||||
fault.blocked.clear();
|
||||
await expect(client.close()).resolves.toMatchObject({ ok: true });
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"retains nonregular admission classification and its exact failed close",
|
||||
async () => {
|
||||
const { client } = await fixture();
|
||||
fault.beforeOpen = async () => {
|
||||
fault.beforeOpen = undefined;
|
||||
await rename(fault.path, `${fault.path}.old`);
|
||||
await mkdir(fault.path);
|
||||
};
|
||||
fault.blockClose = true;
|
||||
const result = await client.execute("trace_dylib_resolution", {
|
||||
roots: ["Main"],
|
||||
});
|
||||
if (result.ok) throw new Error("Expected nonregular admission failure");
|
||||
expect(result.error).toMatchObject({
|
||||
reason: "format",
|
||||
cleanupIncomplete: true,
|
||||
});
|
||||
expect(result.error.cause).toBeInstanceOf(ArtifactFileCleanupFailure);
|
||||
expect(fault.handles).toHaveLength(1);
|
||||
fault.blocked.clear();
|
||||
await expect(client.close()).resolves.toMatchObject({ ok: true });
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(fault.handles[0]?.fd).toBe(-1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["EACCES", "EPERM"])(
|
||||
"retains %s semantics alongside failed cleanup",
|
||||
async (code) => {
|
||||
const { client } = await fixture();
|
||||
fault.readFailure = Object.assign(
|
||||
new Error("controlled permission failure"),
|
||||
{ code },
|
||||
);
|
||||
fault.blockClose = true;
|
||||
const result = await client.execute("trace_dylib_resolution", {
|
||||
roots: ["Main"],
|
||||
});
|
||||
if (result.ok) throw new Error("Expected permission failure");
|
||||
expect(result.error).toMatchObject({
|
||||
reason: "unavailable",
|
||||
cleanupIncomplete: true,
|
||||
});
|
||||
expect(result.error.cause).toBeInstanceOf(ArtifactFileCleanupFailure);
|
||||
fault.blocked.clear();
|
||||
await expect(client.close()).resolves.toMatchObject({ ok: true });
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a substituted pathname after parsing the original descriptor", async () => {
|
||||
const { trace } = await fixture();
|
||||
fault.onRead = async () => {
|
||||
fault.onRead = undefined;
|
||||
await rename(fault.path, `${fault.path}.old`);
|
||||
await writeFile(fault.path, machoImage({}));
|
||||
};
|
||||
await expect(trace()).rejects.toMatchObject({ reason: "integrity" });
|
||||
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32").each(["magic", "image", "digest"])(
|
||||
"rejects a FIFO substituted at the compiled dyld %s open without a writer",
|
||||
async (stage) => {
|
||||
await fixture();
|
||||
const path = fault.path;
|
||||
fault.path = "";
|
||||
const module = pathToFileURL(
|
||||
join(process.cwd(), "dist/artifacts/apple/DylibResolutionReader.js"),
|
||||
).href;
|
||||
const script = `
|
||||
import fs from 'node:fs';
|
||||
import { syncBuiltinESMExports } from 'node:module';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { dirname } from 'node:path';
|
||||
const [stage, path] = process.argv.slice(1);
|
||||
const sha256 = createHash('sha256').update(await fs.promises.readFile(path)).digest('hex');
|
||||
const nativeOpen = fs.promises.open;
|
||||
let opens = 0;
|
||||
let substituted = false;
|
||||
fs.promises.open = async (selected, ...args) => {
|
||||
if (selected === path && ++opens === (stage === 'digest' ? 2 : 1)) {
|
||||
await fs.promises.rename(path, path + '.old');
|
||||
execFileSync('mkfifo', [path]);
|
||||
substituted = true;
|
||||
}
|
||||
return await nativeOpen(selected, ...args);
|
||||
};
|
||||
syncBuiltinESMExports();
|
||||
const { traceDylibResolution } = await import(${JSON.stringify(module)});
|
||||
try {
|
||||
await traceDylibResolution({ rootPath: dirname(path), targetPath: path,
|
||||
targetSha256: sha256, enumerateRoots: stage === 'magic',
|
||||
parameters: stage === 'magic' ? {} : { roots: ['Main'] } });
|
||||
throw new Error('Unexpected FIFO success');
|
||||
} catch (error) {
|
||||
if (error.reason !== 'format') throw error;
|
||||
process.stdout.write(JSON.stringify({ stage, opens, substituted, reason: error.reason }));
|
||||
}
|
||||
`;
|
||||
const result = await promisify(execFile)(
|
||||
process.execPath,
|
||||
["--input-type=module", "--eval", script, stage, path],
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
expect(JSON.parse(result.stdout)).toEqual({
|
||||
stage,
|
||||
opens: stage === "digest" ? 2 : 1,
|
||||
substituted: true,
|
||||
reason: "format",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("binds both dyld failure and failed provider-close projections to the same descriptor", async () => {
|
||||
const { client } = await fixture();
|
||||
fault.blockClose = true;
|
||||
fault.readFailure = new ArtifactReaderFailure("io", "controlled primary");
|
||||
const result = await client.execute("trace_dylib_resolution", {
|
||||
roots: ["Main"],
|
||||
});
|
||||
if (result.ok) throw new Error("Expected failed dyld read");
|
||||
expect(result.error).toMatchObject({ reason: "io", cleanupIncomplete: true });
|
||||
expect(result.error.cause).toBeInstanceOf(ArtifactFileCleanupFailure);
|
||||
const close = await client.close();
|
||||
if (close.ok) throw new Error("Expected retained descriptor");
|
||||
expect(close.error.cleanupIncomplete).toBe(true);
|
||||
expect(fault.handles).toHaveLength(1);
|
||||
fault.blocked.clear();
|
||||
await expect(client.close()).resolves.toMatchObject({ ok: true });
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(close.error.cleanupIncomplete).toBe(false);
|
||||
expect(fault.handles[0]?.fd).toBe(-1);
|
||||
});
|
||||
|
||||
it("preserves the caller's explicit null cancellation reason through dyld provider projection", async () => {
|
||||
const { client, controller } = await fixture();
|
||||
fault.abortOnRead = true;
|
||||
fault.blockClose = true;
|
||||
const result = await client.execute(
|
||||
"trace_dylib_resolution",
|
||||
{ roots: ["Main"] },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (result.ok) throw new Error("Expected cancelled dyld read");
|
||||
expect(result.error).toMatchObject({
|
||||
reason: "cancelled",
|
||||
cleanupIncomplete: true,
|
||||
});
|
||||
expect(result.error.cause).toBeNull();
|
||||
fault.blocked.clear();
|
||||
await expect(client.close()).resolves.toMatchObject({ ok: true });
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(fault.handles[0]?.fd).toBe(-1);
|
||||
});
|
||||
|
||||
it("waits for an in-flight final dyld close before completing provider close", async () => {
|
||||
const { client, controller } = await fixture();
|
||||
let release: () => void = () => {};
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
fault.onClose = async () => {
|
||||
if (fault.closeCount === 2) await gate;
|
||||
};
|
||||
const running = client.execute(
|
||||
"trace_dylib_resolution",
|
||||
{ roots: ["Main"] },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
try {
|
||||
await expect.poll(() => fault.closeCount).toBe(2);
|
||||
let closed = false;
|
||||
const closing = client.close().then((result) => {
|
||||
closed = true;
|
||||
return result;
|
||||
});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(closed).toBe(false);
|
||||
controller.abort(null);
|
||||
release();
|
||||
await expect(running).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { reason: "cancelled", cause: null },
|
||||
});
|
||||
await expect(closing).resolves.toMatchObject({ ok: true });
|
||||
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
|
||||
} finally {
|
||||
release();
|
||||
await running;
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user