mirror of
https://github.com/morluto/rea.git
synced 2026-11-03 05:16:16 +00:00
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.
458 lines
16 KiB
TypeScript
458 lines
16 KiB
TypeScript
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
|
import {
|
|
createAnalysisExecution,
|
|
type AnalysisClient,
|
|
type AnalysisOperation,
|
|
type AnalysisProvider,
|
|
type CapabilityDescriptor,
|
|
type ProviderIdentity,
|
|
type ExecutionOptions,
|
|
} from "../application/AnalysisProvider.js";
|
|
import { inspectBundleKeyedArchive } from "./apple/KeyedArchiveReader.js";
|
|
import { traceDylibResolution } from "./apple/DylibResolutionReader.js";
|
|
import { basename, dirname } from "node:path";
|
|
import { inventoryArtifact } from "./inventory/ArtifactInventory.js";
|
|
import { extractArtifact } from "./extraction/ArtifactExtraction.js";
|
|
import { analyzeInterfaceBuilderBundle } from "./apple/InterfaceBuilderAnalysis.js";
|
|
import { analyzeAppleAssetCatalogs } from "./apple/AppleAssetCatalogAnalysis.js";
|
|
import {
|
|
ARTIFACT_ANALYSIS_OPERATIONS,
|
|
artifactInventoryInputSchema,
|
|
artifactExtractionExecutionSchema,
|
|
type ArtifactAnalysisOperation,
|
|
} from "../contracts/artifactToolContracts.js";
|
|
import {
|
|
isMachOFormat,
|
|
type BinaryTarget,
|
|
} from "../domain/binaryTargetTypes.js";
|
|
import {
|
|
AnalysisCapabilityUnavailableError,
|
|
AnalysisCancelledError,
|
|
AnalysisInputError,
|
|
AnalysisUnsupportedTargetError,
|
|
} from "../domain/analysisErrorCore.js";
|
|
import { ArtifactOperationError } from "../domain/artifactOperationError.js";
|
|
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
|
import type { JsonValue } from "../domain/jsonValue.js";
|
|
import { interfaceBuilderLimitsSchema } from "../domain/apple/interfaceBuilderGraph.js";
|
|
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";
|
|
import { freezeJsonSnapshot } from "../domain/immutableJson.js";
|
|
import { createArtifactInspection } from "../domain/artifactInspection.js";
|
|
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
|
|
|
/** Read-only inventory and exclusively owned extraction provider. */
|
|
export class ArtifactProvider implements AnalysisProvider {
|
|
readonly #capabilities: readonly CapabilityDescriptor[];
|
|
readonly #platform: NodeJS.Platform;
|
|
private readonly environment: Readonly<NodeJS.ProcessEnv>;
|
|
|
|
constructor(
|
|
environment: Readonly<NodeJS.ProcessEnv>,
|
|
platform: NodeJS.Platform = process.platform,
|
|
) {
|
|
this.environment = snapshotEnvironment(environment, platform);
|
|
this.#capabilities = artifactCapabilities(platform);
|
|
this.#platform = platform;
|
|
}
|
|
|
|
identity(): ProviderIdentity {
|
|
return ARTIFACT_GRAPH_PROVIDER;
|
|
}
|
|
|
|
capabilities(): readonly CapabilityDescriptor[] {
|
|
return this.#capabilities;
|
|
}
|
|
|
|
createClient(target: BinaryTarget): AnalysisClient {
|
|
return new ArtifactClient(target, this.environment, this.#platform);
|
|
}
|
|
}
|
|
|
|
class ArtifactClient implements AnalysisClient {
|
|
readonly #resourceScope = new ArtifactResourceScope();
|
|
|
|
constructor(
|
|
private readonly target: BinaryTarget,
|
|
private readonly environment: Readonly<NodeJS.ProcessEnv>,
|
|
private readonly platform: NodeJS.Platform,
|
|
) {}
|
|
|
|
async execute(
|
|
operation: AnalysisOperation,
|
|
parameters: Readonly<Record<string, JsonValue>>,
|
|
options?: ExecutionOptions,
|
|
) {
|
|
if (operation === "health")
|
|
return ok(createAnalysisExecution(null, ARTIFACT_GRAPH_PROVIDER));
|
|
if (!isArtifactOperation(operation))
|
|
return err(
|
|
new AnalysisCapabilityUnavailableError(
|
|
ARTIFACT_GRAPH_PROVIDER.id,
|
|
operation,
|
|
"Operation is not implemented by artifact graph provider.",
|
|
),
|
|
);
|
|
try {
|
|
if (operation === "inspect_artifact") {
|
|
const inspected = await this.inspectArtifact(parameters, options);
|
|
return inspected;
|
|
}
|
|
if (operation === "decode_interface_builder") {
|
|
if (
|
|
this.target.kind !== "executable" ||
|
|
this.target.sourcePath === undefined ||
|
|
!this.target.sourcePath.toLowerCase().endsWith(".app")
|
|
)
|
|
throw this.unsupportedTarget(
|
|
operation,
|
|
"decode_interface_builder requires an active .app bundle target",
|
|
);
|
|
const limits = interfaceBuilderLimitsSchema.parse(parameters);
|
|
const result = await analyzeInterfaceBuilderBundle({
|
|
bundlePath: this.target.sourcePath,
|
|
targetSha256: this.target.sha256,
|
|
limits,
|
|
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
|
});
|
|
return ok(
|
|
createAnalysisExecution(result, ARTIFACT_GRAPH_PROVIDER, {
|
|
limitations: result.limitations,
|
|
locations: result.documents.map(({ relative_path: path }) => ({
|
|
kind: "artifact-path" as const,
|
|
path,
|
|
})),
|
|
}),
|
|
);
|
|
}
|
|
if (operation === "inspect_keyed_archive") {
|
|
const standalone =
|
|
this.target.kind === "artifact" && this.target.format === "plist";
|
|
if (
|
|
standalone &&
|
|
parameters.path !== undefined &&
|
|
parameters.path !== "." &&
|
|
parameters.path !== basename(this.target.path)
|
|
)
|
|
throw new AnalysisInputError(operation, undefined, [
|
|
{
|
|
path: ["path"],
|
|
reason: "invalid_value",
|
|
message:
|
|
"For an active plist, path must select that archive (omit path or use its basename).",
|
|
expected: basename(this.target.path),
|
|
},
|
|
]);
|
|
const bundlePath = standalone
|
|
? dirname(this.target.path)
|
|
: this.target.sourcePath;
|
|
if (
|
|
bundlePath === undefined ||
|
|
(!standalone && !bundlePath.toLowerCase().endsWith(".app"))
|
|
)
|
|
throw this.unsupportedTarget(
|
|
operation,
|
|
"inspect_keyed_archive requires an active plist or .app bundle",
|
|
);
|
|
const result = await inspectBundleKeyedArchive({
|
|
bundlePath,
|
|
targetSha256: this.target.sha256,
|
|
parameters: standalone
|
|
? { ...parameters, path: basename(this.target.path) }
|
|
: parameters,
|
|
platform: this.platform,
|
|
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
|
});
|
|
if (standalone && result.archive_sha256 !== this.target.sha256)
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
`Active archive digest changed: expected ${this.target.sha256}, observed ${result.archive_sha256}`,
|
|
);
|
|
return ok(
|
|
createAnalysisExecution(result, ARTIFACT_GRAPH_PROVIDER, {
|
|
limitations: result.limitations,
|
|
locations: [{ kind: "artifact-path", path: result.archive_path }],
|
|
}),
|
|
);
|
|
}
|
|
if (operation === "inspect_asset_catalog") {
|
|
return await this.inspectAssetCatalog(parameters, options);
|
|
}
|
|
if (operation === "trace_dylib_resolution")
|
|
return await this.traceDylibResolution(parameters, options);
|
|
if (operation === "extract_artifact") {
|
|
const parsed = artifactExtractionExecutionSchema.parse(parameters);
|
|
const result = await extractArtifact(
|
|
{
|
|
inputPath: this.target.sourcePath ?? this.target.path,
|
|
inputFormat: this.target.format,
|
|
outputRoot: parsed.output_root,
|
|
environment: this.environment,
|
|
integrityPolicy: parsed.integrity_policy,
|
|
resourceScope: this.#resourceScope,
|
|
},
|
|
options?.signal,
|
|
);
|
|
return ok(
|
|
createAnalysisExecution(result, ARTIFACT_GRAPH_PROVIDER, {
|
|
rawResult: null,
|
|
limitations: result.limitations,
|
|
subject: subjectFor(
|
|
this.target.sourcePath ?? this.target.path,
|
|
result.manifest,
|
|
),
|
|
// Each extracted file's path is already in artifacts.
|
|
locations: [],
|
|
}),
|
|
);
|
|
}
|
|
const parsed = artifactInventoryInputSchema.parse(parameters);
|
|
const result = await this.inventory(parsed, options);
|
|
return ok(
|
|
createAnalysisExecution(result, ARTIFACT_GRAPH_PROVIDER, {
|
|
rawResult: null,
|
|
limitations: result.limitations,
|
|
subject: subjectFor(
|
|
this.target.sourcePath ?? this.target.path,
|
|
result.manifest,
|
|
),
|
|
// Each occurrence's logical path is already in the result.
|
|
locations: [],
|
|
}),
|
|
);
|
|
} catch (cause: unknown) {
|
|
return err(translateArtifactFailure(operation, cause));
|
|
}
|
|
}
|
|
|
|
close(): Promise<Result<null, AnalysisError>> {
|
|
return this.#resourceScope.close().then(
|
|
() => ok(null),
|
|
(cause: unknown) => {
|
|
const failure =
|
|
cause instanceof ArtifactReaderFailure ? cause : undefined;
|
|
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);
|
|
},
|
|
);
|
|
}
|
|
|
|
/** The active target's kind is outside this operation's supported targets. */
|
|
private unsupportedTarget(
|
|
operation: string,
|
|
reason: string,
|
|
): AnalysisUnsupportedTargetError {
|
|
return new AnalysisUnsupportedTargetError(
|
|
operation,
|
|
this.target.sourcePath ?? this.target.path,
|
|
reason,
|
|
);
|
|
}
|
|
|
|
private async inspectAssetCatalog(
|
|
parameters: Readonly<Record<string, JsonValue>>,
|
|
options?: ExecutionOptions,
|
|
) {
|
|
const bundlePath = this.target.sourcePath;
|
|
if (
|
|
this.target.kind !== "executable" ||
|
|
bundlePath === undefined ||
|
|
!bundlePath.toLowerCase().endsWith(".app")
|
|
)
|
|
throw this.unsupportedTarget(
|
|
"inspect_asset_catalog",
|
|
"inspect_asset_catalog requires an active .app bundle target",
|
|
);
|
|
const result = await analyzeAppleAssetCatalogs({
|
|
environment: this.environment,
|
|
bundlePath,
|
|
targetSha256: this.target.sha256,
|
|
page: parameters,
|
|
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
|
});
|
|
return ok(
|
|
createAnalysisExecution(result, ARTIFACT_GRAPH_PROVIDER, {
|
|
limitations: result.limitations,
|
|
locations: result.catalogs.map(({ path }) => ({
|
|
kind: "artifact-path" as const,
|
|
path,
|
|
})),
|
|
}),
|
|
);
|
|
}
|
|
|
|
private async traceDylibResolution(
|
|
parameters: Readonly<Record<string, JsonValue>>,
|
|
options?: ExecutionOptions,
|
|
) {
|
|
if (this.target.kind !== "executable" || !isMachOFormat(this.target.format))
|
|
throw this.unsupportedTarget(
|
|
"trace_dylib_resolution",
|
|
"trace_dylib_resolution requires an active Mach-O or .app bundle target",
|
|
);
|
|
// Only a target opened from an app bundle directory carries its Info.plist;
|
|
// a regular file whose name ends in .app is a standalone image.
|
|
const bundle =
|
|
this.target.bundleInfoPlist === undefined
|
|
? undefined
|
|
: this.target.sourcePath;
|
|
const result = await traceDylibResolution({
|
|
rootPath: bundle ?? dirname(this.target.path),
|
|
targetPath: this.target.path,
|
|
targetSha256: this.target.sha256,
|
|
enumerateRoots: bundle !== undefined,
|
|
parameters,
|
|
resourceScope: this.#resourceScope,
|
|
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
|
});
|
|
return ok(
|
|
createAnalysisExecution(result, ARTIFACT_GRAPH_PROVIDER, {
|
|
limitations: result.limitations,
|
|
locations: result.images.map(({ path }) => ({
|
|
kind: "artifact-path" as const,
|
|
path,
|
|
})),
|
|
}),
|
|
);
|
|
}
|
|
|
|
private async inspectArtifact(
|
|
parameters: Readonly<Record<string, JsonValue>>,
|
|
options?: ExecutionOptions,
|
|
) {
|
|
const parsed = artifactInventoryInputSchema.parse(parameters);
|
|
const inventoryParameters = {
|
|
integrity_policy: parsed.integrity_policy,
|
|
};
|
|
await options?.progress?.report({
|
|
phase: "inspect_artifact.inventory",
|
|
completed: 0,
|
|
total: 1,
|
|
message: "inventory substep started",
|
|
});
|
|
// The freshly parsed inventory is exclusively owned here. Sealing it lets
|
|
// the nested Evidence share it instead of cloning and re-digesting it.
|
|
const inventory = freezeJsonSnapshot(
|
|
await this.inventory(inventoryParameters, options),
|
|
);
|
|
const subject = subjectFor(
|
|
this.target.sourcePath ?? this.target.path,
|
|
inventory.manifest,
|
|
);
|
|
const inventoryEvidence = createEvidence(subject, ARTIFACT_GRAPH_PROVIDER, {
|
|
operation: "inventory_artifact",
|
|
parameters: inventoryParameters,
|
|
result: inventory,
|
|
rawResult: null,
|
|
limitations: inventory.limitations,
|
|
// The nested inventory already names every occurrence's logical path.
|
|
locations: [],
|
|
});
|
|
// The returned execution and its Evidence then reuse this owned report.
|
|
const result = freezeJsonSnapshot(
|
|
createArtifactInspection(inventoryEvidence),
|
|
);
|
|
await options?.progress?.report({
|
|
phase: "inspect_artifact.inventory",
|
|
completed: 1,
|
|
total: 1,
|
|
message: "inventory substep completed",
|
|
});
|
|
return ok(
|
|
createAnalysisExecution(result, ARTIFACT_GRAPH_PROVIDER, {
|
|
rawResult: null,
|
|
limitations: result.limitations,
|
|
subject,
|
|
locations: [],
|
|
}),
|
|
);
|
|
}
|
|
|
|
private inventory(
|
|
parsed: {
|
|
readonly integrity_policy: "fail" | "record-and-continue";
|
|
},
|
|
options?: ExecutionOptions,
|
|
) {
|
|
return inventoryArtifact(this.target.sourcePath ?? this.target.path, {
|
|
resourceScope: this.#resourceScope,
|
|
environment: this.environment,
|
|
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
|
integrity: { mode: parsed.integrity_policy },
|
|
});
|
|
}
|
|
}
|
|
|
|
const isArtifactOperation = (
|
|
operation: AnalysisOperation,
|
|
): operation is ArtifactAnalysisOperation =>
|
|
ARTIFACT_ANALYSIS_OPERATIONS.includes(
|
|
operation as (typeof ARTIFACT_ANALYSIS_OPERATIONS)[number],
|
|
);
|
|
|
|
/** Translate one artifact boundary failure while retaining cleanup evidence. */
|
|
export const translateArtifactFailure = (
|
|
operation: ArtifactAnalysisOperation,
|
|
cause: unknown,
|
|
): AnalysisError => {
|
|
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;
|
|
return new ArtifactOperationError(operation, "io");
|
|
};
|
|
|
|
const subjectFor = (
|
|
path: string,
|
|
manifest: {
|
|
readonly root_sha256: string;
|
|
readonly root_format: import("../domain/artifactGraph.js").ArtifactInventoryResult["manifest"]["root_format"];
|
|
},
|
|
) => ({
|
|
path,
|
|
sha256: manifest.root_sha256,
|
|
format: manifest.root_format,
|
|
});
|