mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(apple): retain reader outcomes and cleanup ownership
This commit is contained in:
@@ -475,7 +475,11 @@ complete observation.
|
||||
when either source is incomplete. Resource wrappers retain their serialized
|
||||
class and authored runtime class, including AppKit `NSCustomResource` names.
|
||||
Invalid producer JSON and subprocess failures retain captured output with
|
||||
their typed failure reason. Image extraction and undocumented rendition
|
||||
their typed failure reason. Catalog-reader cleanup retains the primary
|
||||
integrity, utility or cancellation failure and its cleanup resources; the
|
||||
provider retains catalog, Interface Builder and keyed-archive readers until
|
||||
cleanup is confirmed. A catalog failure also retains cleanup failures from
|
||||
its parallel Interface Builder join. Image extraction and undocumented rendition
|
||||
interpretation are unsupported.
|
||||
- `inspect_keyed_archive` / `rea inspect-keyed-archive <plist>` reads XML or
|
||||
binary Foundation keyed archives without instantiating classes. For an active
|
||||
|
||||
@@ -127,6 +127,7 @@ class BundleResourceArtifactClient implements AnalysisClient {
|
||||
bundlePath: this.root.canonicalPath,
|
||||
targetSha256: null,
|
||||
resourceRoot,
|
||||
resources: this.#resourceScope,
|
||||
limits: interfaceBuilderLimitsSchema.parse(parameters),
|
||||
...(options?.signal === undefined
|
||||
? {}
|
||||
@@ -139,6 +140,7 @@ class BundleResourceArtifactClient implements AnalysisClient {
|
||||
resourceRoot,
|
||||
parameters,
|
||||
platform: this.platform,
|
||||
resources: this.#resourceScope,
|
||||
...(options?.signal === undefined
|
||||
? {}
|
||||
: { signal: options.signal }),
|
||||
@@ -149,6 +151,7 @@ class BundleResourceArtifactClient implements AnalysisClient {
|
||||
targetSha256: null,
|
||||
resourceRoot,
|
||||
environment: this.environment,
|
||||
resources: this.#resourceScope,
|
||||
page: parameters,
|
||||
...(options?.signal === undefined
|
||||
? {}
|
||||
@@ -227,6 +230,7 @@ class ArtifactClient implements AnalysisClient {
|
||||
bundlePath: this.target.sourcePath ?? this.target.path,
|
||||
targetSha256: this.target.sha256,
|
||||
limits,
|
||||
resources: this.#resourceScope,
|
||||
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
||||
});
|
||||
return ok(
|
||||
@@ -267,6 +271,7 @@ class ArtifactClient implements AnalysisClient {
|
||||
? { ...parameters, path: basename(this.target.path) }
|
||||
: parameters,
|
||||
platform: this.platform,
|
||||
resources: this.#resourceScope,
|
||||
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
||||
});
|
||||
if (standalone && result.archive_sha256 !== this.target.sha256)
|
||||
@@ -375,6 +380,7 @@ class ArtifactClient implements AnalysisClient {
|
||||
const result = await analyzeAppleAssetCatalogs({
|
||||
environment: this.environment,
|
||||
bundlePath,
|
||||
resources: this.#resourceScope,
|
||||
targetSha256: this.target.sha256,
|
||||
page: parameters,
|
||||
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
||||
|
||||
@@ -12,6 +12,9 @@ import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js
|
||||
import { createHash } from "node:crypto";
|
||||
import { lstat } from "node:fs/promises";
|
||||
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
|
||||
import { ArtifactResourceScope } from "../ArtifactResourceScope.js";
|
||||
import { translateArtifactFailure } from "../ArtifactOperationFailure.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
|
||||
import { ArtifactTraversalObservation } from "../ArtifactTraversalObservation.js";
|
||||
import {
|
||||
ArtifactReaderFailure,
|
||||
@@ -48,6 +51,7 @@ export const analyzeAppleAssetCatalogs = async (input: {
|
||||
readonly environment: Readonly<NodeJS.ProcessEnv>;
|
||||
readonly page?: unknown;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly resources?: ArtifactResourceScope;
|
||||
readonly runAssetUtil?: (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
@@ -60,6 +64,7 @@ export const analyzeAppleAssetCatalogs = async (input: {
|
||||
"Apple asset catalog inspection requires macOS assetutil",
|
||||
);
|
||||
const page = appleAssetCatalogInputSchema.parse(input.page ?? {});
|
||||
const resources = input.resources ?? new ArtifactResourceScope();
|
||||
// The Interface Builder graph only supplies resource keys for the join. Its
|
||||
// bundle walk is independent of the catalog walk, so both run together; a
|
||||
// catalog failure cancels it and is reported as the sequential order would.
|
||||
@@ -69,6 +74,7 @@ export const analyzeAppleAssetCatalogs = async (input: {
|
||||
)({
|
||||
bundlePath: input.bundlePath,
|
||||
targetSha256: input.targetSha256,
|
||||
resources,
|
||||
...(input.resourceRoot === undefined
|
||||
? {}
|
||||
: { resourceRoot: input.resourceRoot }),
|
||||
@@ -90,10 +96,30 @@ export const analyzeAppleAssetCatalogs = async (input: {
|
||||
uninspected,
|
||||
traversal,
|
||||
coverage: catalogCoverage,
|
||||
} = await collectAssetCatalogs(input));
|
||||
} = await resources.run(() =>
|
||||
collectAssetCatalogs({ ...input, resources }),
|
||||
));
|
||||
} catch (cause: unknown) {
|
||||
interfaceBuilderCancel.abort();
|
||||
await interfaceBuilderAnalysis;
|
||||
const joined = await interfaceBuilderAnalysis;
|
||||
if (
|
||||
!joined.ok &&
|
||||
((joined.error instanceof ArtifactReaderFailure &&
|
||||
joined.error.cleanup !== undefined) ||
|
||||
(joined.error instanceof AnalysisError &&
|
||||
joined.error.cleanupIncomplete))
|
||||
) {
|
||||
const primary = translateArtifactFailure("inspect_asset_catalog", cause);
|
||||
const cleanup = translateArtifactFailure(
|
||||
"inspect_asset_catalog",
|
||||
joined.error,
|
||||
);
|
||||
throw analysisErrorWithCleanupFailure(
|
||||
primary,
|
||||
cleanup,
|
||||
"inspect_asset_catalog",
|
||||
).bindCleanupDependencies([primary, cleanup]);
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
const analyzed = await interfaceBuilderAnalysis;
|
||||
@@ -152,6 +178,7 @@ const collectAssetCatalogs = async (input: {
|
||||
readonly bundlePath: string;
|
||||
readonly environment: Readonly<NodeJS.ProcessEnv>;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly resources: ArtifactResourceScope;
|
||||
readonly runAssetUtil?: (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
@@ -168,6 +195,9 @@ const collectAssetCatalogs = async (input: {
|
||||
let symlinks = 0;
|
||||
let firstSymlink: string | undefined;
|
||||
let totalMetadataBytes = 0;
|
||||
let outcome: { kind: "completed" } | { kind: "failed"; cause: unknown } = {
|
||||
kind: "completed",
|
||||
};
|
||||
try {
|
||||
for await (const entry of reader.entries(input.signal, {
|
||||
onCoverage: traversal.onCoverage,
|
||||
@@ -208,9 +238,29 @@ const collectAssetCatalogs = async (input: {
|
||||
);
|
||||
catalogs.push(inspected.catalog);
|
||||
}
|
||||
} finally {
|
||||
await reader.close();
|
||||
} catch (cause: unknown) {
|
||||
outcome = { kind: "failed", cause };
|
||||
}
|
||||
const released = await input.resources.release({
|
||||
kind: "reader",
|
||||
reader,
|
||||
resource: input.bundlePath,
|
||||
});
|
||||
if (released.kind === "failed") {
|
||||
if (outcome.kind === "failed" && outcome.cause instanceof AnalysisError) {
|
||||
const cleanup = translateArtifactFailure(
|
||||
"inspect_asset_catalog",
|
||||
input.resources.cleanupFailure({ kind: "completed" }, [released]),
|
||||
);
|
||||
throw analysisErrorWithCleanupFailure(
|
||||
outcome.cause,
|
||||
cleanup,
|
||||
"inspect_asset_catalog",
|
||||
).bindCleanupDependencies([outcome.cause, cleanup]);
|
||||
}
|
||||
throw input.resources.cleanupFailure(outcome, [released]);
|
||||
}
|
||||
if (outcome.kind === "failed") throw outcome.cause;
|
||||
if (symlinks > 0)
|
||||
traversal.onCoverage({
|
||||
// Source entries are represented even when their link targets were not
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
isUIKitRuntimeConnectionClass,
|
||||
} from "../../domain/apple/interfaceBuilderKeyedArchive.js";
|
||||
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
|
||||
import { ArtifactResourceScope } from "../ArtifactResourceScope.js";
|
||||
import { ArtifactTraversalObservation } from "../ArtifactTraversalObservation.js";
|
||||
import {
|
||||
projectPlistValue,
|
||||
@@ -77,14 +78,28 @@ interface OmittedArchive {
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
/** Decode compiled Interface Builder archives from a local app bundle. */
|
||||
export const analyzeInterfaceBuilderBundle = async (input: {
|
||||
interface InterfaceBuilderBundleInput {
|
||||
readonly bundlePath: string;
|
||||
readonly targetSha256: string | null;
|
||||
readonly resourceRoot?: import("../../domain/apple/bundleResourceRoot.js").BundleResourceRoot;
|
||||
readonly limits?: unknown;
|
||||
readonly signal?: AbortSignal;
|
||||
}) => {
|
||||
readonly resources?: ArtifactResourceScope;
|
||||
}
|
||||
|
||||
/** Own decoding and directory finalization under the caller's resource scope. */
|
||||
export const analyzeInterfaceBuilderBundle = (
|
||||
input: InterfaceBuilderBundleInput,
|
||||
) => {
|
||||
const resources = input.resources ?? new ArtifactResourceScope();
|
||||
return resources.run(() => decodeInterfaceBuilderBundle(input, resources));
|
||||
};
|
||||
|
||||
/** Decode compiled archives while keeping their reader's completion distinct from cleanup. */
|
||||
const decodeInterfaceBuilderBundle = async (
|
||||
input: InterfaceBuilderBundleInput,
|
||||
resources: ArtifactResourceScope,
|
||||
) => {
|
||||
const limits = interfaceBuilderLimitsSchema.parse(input.limits ?? {});
|
||||
const reader = new DirectoryArtifactReader(input.bundlePath);
|
||||
const documents: InterfaceBuilderDocumentInput[] = [];
|
||||
@@ -107,6 +122,9 @@ export const analyzeInterfaceBuilderBundle = async (input: {
|
||||
const decodeBudget = new InterfaceBuilderDecodeBudget(
|
||||
MAX_AGGREGATE_DECODE_BYTES,
|
||||
);
|
||||
let outcome: { kind: "completed" } | { kind: "failed"; cause: unknown } = {
|
||||
kind: "completed",
|
||||
};
|
||||
const recordOmittedArchive = (
|
||||
path: string,
|
||||
reason: string,
|
||||
@@ -277,9 +295,17 @@ export const analyzeInterfaceBuilderBundle = async (input: {
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await reader.close();
|
||||
} catch (cause: unknown) {
|
||||
outcome = { kind: "failed", cause };
|
||||
}
|
||||
const released = await resources.release({
|
||||
kind: "reader",
|
||||
reader,
|
||||
resource: input.bundlePath,
|
||||
});
|
||||
if (released.kind === "failed")
|
||||
throw resources.cleanupFailure(outcome, [released]);
|
||||
if (outcome.kind === "failed") throw outcome.cause;
|
||||
const result = buildInterfaceBuilderAnalysis({
|
||||
targetSha256: input.targetSha256,
|
||||
...(input.resourceRoot === undefined
|
||||
|
||||
@@ -38,6 +38,10 @@ import {
|
||||
import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
||||
import type { ArtifactEntry } from "../ArtifactReader.js";
|
||||
import { bindArtifactFileCleanup } from "../ArtifactFileOperation.js";
|
||||
import { ArtifactResourceScope } from "../ArtifactResourceScope.js";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { translateArtifactFailure } from "../ArtifactOperationFailure.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
|
||||
|
||||
import { decodeXmlPlistText } from "../../domain/propertyListXmlText.js";
|
||||
import {
|
||||
@@ -173,8 +177,7 @@ const keyedArchiveKindRemediation = (
|
||||
: "Select an NSKeyedArchiver archive; inspect_keyed_archive decodes only those. inspect_plist, which reads ordinary property lists, requires a macOS host.";
|
||||
};
|
||||
|
||||
/** Read exactly one regular, contained bundle entry without following symlinks. */
|
||||
export const inspectBundleKeyedArchive = async (input: {
|
||||
interface BundleKeyedArchiveInput {
|
||||
bundlePath: string;
|
||||
targetSha256: string | null;
|
||||
resourceRoot?: import("../../domain/apple/bundleResourceRoot.js").BundleResourceRoot;
|
||||
@@ -182,7 +185,57 @@ export const inspectBundleKeyedArchive = async (input: {
|
||||
signal?: AbortSignal;
|
||||
/** Host whose available workflows the remediation names. */
|
||||
platform?: NodeJS.Platform;
|
||||
}) => {
|
||||
resources?: ArtifactResourceScope;
|
||||
}
|
||||
|
||||
/** Keep the selected archive and its reader under the caller's cleanup owner. */
|
||||
export const inspectBundleKeyedArchive = (input: BundleKeyedArchiveInput) => {
|
||||
const resources = input.resources ?? new ArtifactResourceScope();
|
||||
return resources.run(async () => {
|
||||
const reader = new DirectoryArtifactReader(input.bundlePath);
|
||||
let outcome:
|
||||
| {
|
||||
kind: "completed";
|
||||
value: Awaited<ReturnType<typeof readSelectedKeyedArchive>>;
|
||||
}
|
||||
| { kind: "failed"; cause: unknown };
|
||||
try {
|
||||
outcome = {
|
||||
kind: "completed",
|
||||
value: await readSelectedKeyedArchive(input, reader),
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
outcome = { kind: "failed", cause };
|
||||
}
|
||||
const released = await resources.release({
|
||||
kind: "reader",
|
||||
reader,
|
||||
resource: input.bundlePath,
|
||||
});
|
||||
if (released.kind === "failed") {
|
||||
if (outcome.kind === "failed" && outcome.cause instanceof AnalysisError) {
|
||||
const cleanup = translateArtifactFailure(
|
||||
"inspect_keyed_archive",
|
||||
resources.cleanupFailure({ kind: "completed" }, [released]),
|
||||
);
|
||||
throw analysisErrorWithCleanupFailure(
|
||||
outcome.cause,
|
||||
cleanup,
|
||||
"inspect_keyed_archive",
|
||||
).bindCleanupDependencies([outcome.cause, cleanup]);
|
||||
}
|
||||
throw resources.cleanupFailure(outcome, [released]);
|
||||
}
|
||||
if (outcome.kind === "failed") throw outcome.cause;
|
||||
return outcome.value;
|
||||
});
|
||||
};
|
||||
|
||||
/** Read exactly one regular, contained bundle entry without following symlinks. */
|
||||
const readSelectedKeyedArchive = async (
|
||||
input: BundleKeyedArchiveInput,
|
||||
reader: DirectoryArtifactReader,
|
||||
) => {
|
||||
const selected = keyedArchiveInputSchema.parse(input.parameters);
|
||||
if (
|
||||
selected.path.startsWith("/") ||
|
||||
@@ -196,105 +249,95 @@ export const inspectBundleKeyedArchive = async (input: {
|
||||
? "Select a keyed archive path relative to the active app bundle."
|
||||
: `Archive path must be a canonical relative bundle path without empty, '.', or '..' segments: ${selected.path}`,
|
||||
);
|
||||
const reader = new DirectoryArtifactReader(input.bundlePath);
|
||||
try {
|
||||
// Match inventory spelling while retaining the raw path for I/O and evidence.
|
||||
const wanted = selected.path.normalize("NFC");
|
||||
let entry: ArtifactEntry | undefined;
|
||||
for await (const candidate of reader.entries(input.signal, (directory) =>
|
||||
wanted.startsWith(`${directory.normalize("NFC")}/`),
|
||||
)) {
|
||||
if (candidate.path.normalize("NFC") !== wanted) continue;
|
||||
if (candidate.kind !== "file")
|
||||
throw archivePathError(
|
||||
"invalid_value",
|
||||
`Archive path selects a ${candidate.kind}, not a regular file: ${selected.path}`,
|
||||
);
|
||||
if ((candidate.declaredSize ?? 0) > MAX_BYTES)
|
||||
throw new ArtifactReaderFailure(
|
||||
"limit",
|
||||
"Keyed archive exceeds 64 MiB",
|
||||
);
|
||||
if (entry !== undefined)
|
||||
throw archivePathError(
|
||||
"invalid_value",
|
||||
`Archive path matches multiple Unicode-equivalent bundle entries; selection is ambiguous: ${selected.path}`,
|
||||
);
|
||||
entry = candidate;
|
||||
}
|
||||
if (entry === undefined)
|
||||
// Match inventory spelling while retaining the raw path for I/O and evidence.
|
||||
const wanted = selected.path.normalize("NFC");
|
||||
let entry: ArtifactEntry | undefined;
|
||||
for await (const candidate of reader.entries(input.signal, (directory) =>
|
||||
wanted.startsWith(`${directory.normalize("NFC")}/`),
|
||||
)) {
|
||||
if (candidate.path.normalize("NFC") !== wanted) continue;
|
||||
if (candidate.kind !== "file")
|
||||
throw archivePathError(
|
||||
"invalid_value",
|
||||
`No regular file exists at ${selected.path} in the active app bundle.`,
|
||||
`Archive path selects a ${candidate.kind}, not a regular file: ${selected.path}`,
|
||||
);
|
||||
const stream = await reader
|
||||
.open(entry, input.signal)
|
||||
.catch((cause: unknown) => {
|
||||
throw selectedArchiveReadFailure(input.bundlePath, entry, cause);
|
||||
});
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
for await (const chunk of stream) {
|
||||
if (input.signal?.aborted) {
|
||||
stream.destroy();
|
||||
throw new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"Archive inspection cancelled",
|
||||
);
|
||||
}
|
||||
const bytes = z.instanceof(Buffer).parse(chunk);
|
||||
size += bytes.length;
|
||||
if (size > MAX_BYTES) {
|
||||
stream.destroy();
|
||||
throw new ArtifactReaderFailure(
|
||||
"limit",
|
||||
"Keyed archive exceeds 64 MiB",
|
||||
);
|
||||
}
|
||||
chunks.push(bytes);
|
||||
}
|
||||
const bytes = Buffer.concat(chunks, size);
|
||||
try {
|
||||
return keyedArchiveResultSchema.parse({
|
||||
target_sha256: input.targetSha256,
|
||||
...(input.resourceRoot === undefined
|
||||
? {}
|
||||
: { resource_root: input.resourceRoot }),
|
||||
archive_path: entry.path,
|
||||
archive_sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
...decodeKeyedArchiveBytes(bytes, selected),
|
||||
});
|
||||
} catch (cause) {
|
||||
if (cause instanceof AnalysisInputError) throw cause;
|
||||
if (cause instanceof UnsupportedBinaryPlistUidError)
|
||||
throw new AnalysisUnsupportedTargetError(
|
||||
"inspect_keyed_archive",
|
||||
join(input.bundlePath, entry.path),
|
||||
cause.message,
|
||||
{ cause },
|
||||
);
|
||||
// An intact file of another kind is not a damaged archive.
|
||||
if (cause instanceof KeyedArchiveKindError)
|
||||
throw new AnalysisUnsupportedTargetError(
|
||||
"inspect_keyed_archive",
|
||||
join(input.bundlePath, entry.path),
|
||||
cause.message,
|
||||
{
|
||||
cause,
|
||||
remediationAction: keyedArchiveKindRemediation(
|
||||
cause.kind,
|
||||
input.platform ?? process.platform,
|
||||
),
|
||||
},
|
||||
);
|
||||
if ((candidate.declaredSize ?? 0) > MAX_BYTES)
|
||||
throw new ArtifactReaderFailure("limit", "Keyed archive exceeds 64 MiB");
|
||||
if (entry !== undefined)
|
||||
throw archivePathError(
|
||||
"invalid_value",
|
||||
`Archive path matches multiple Unicode-equivalent bundle entries; selection is ambiguous: ${selected.path}`,
|
||||
);
|
||||
entry = candidate;
|
||||
}
|
||||
if (entry === undefined)
|
||||
throw archivePathError(
|
||||
"invalid_value",
|
||||
`No regular file exists at ${selected.path} in the active app bundle.`,
|
||||
);
|
||||
const stream = await reader
|
||||
.open(entry, input.signal)
|
||||
.catch((cause: unknown) => {
|
||||
throw selectedArchiveReadFailure(input.bundlePath, entry, cause);
|
||||
});
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
for await (const chunk of stream) {
|
||||
if (input.signal?.aborted) {
|
||||
stream.destroy();
|
||||
throw new ArtifactReaderFailure(
|
||||
cause instanceof RangeError ? "limit" : "format",
|
||||
`Cannot decode selected Foundation keyed archive: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
"cancelled",
|
||||
"Archive inspection cancelled",
|
||||
{ cause: input.signal.reason },
|
||||
);
|
||||
}
|
||||
const bytes = z.instanceof(Buffer).parse(chunk);
|
||||
size += bytes.length;
|
||||
if (size > MAX_BYTES) {
|
||||
stream.destroy();
|
||||
throw new ArtifactReaderFailure("limit", "Keyed archive exceeds 64 MiB");
|
||||
}
|
||||
chunks.push(bytes);
|
||||
}
|
||||
const bytes = Buffer.concat(chunks, size);
|
||||
try {
|
||||
return keyedArchiveResultSchema.parse({
|
||||
target_sha256: input.targetSha256,
|
||||
...(input.resourceRoot === undefined
|
||||
? {}
|
||||
: { resource_root: input.resourceRoot }),
|
||||
archive_path: entry.path,
|
||||
archive_sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
...decodeKeyedArchiveBytes(bytes, selected),
|
||||
});
|
||||
} catch (cause) {
|
||||
if (cause instanceof AnalysisInputError) throw cause;
|
||||
if (cause instanceof UnsupportedBinaryPlistUidError)
|
||||
throw new AnalysisUnsupportedTargetError(
|
||||
"inspect_keyed_archive",
|
||||
join(input.bundlePath, entry.path),
|
||||
cause.message,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await reader.close();
|
||||
// An intact file of another kind is not a damaged archive.
|
||||
if (cause instanceof KeyedArchiveKindError)
|
||||
throw new AnalysisUnsupportedTargetError(
|
||||
"inspect_keyed_archive",
|
||||
join(input.bundlePath, entry.path),
|
||||
cause.message,
|
||||
{
|
||||
cause,
|
||||
remediationAction: keyedArchiveKindRemediation(
|
||||
cause.kind,
|
||||
input.platform ?? process.platform,
|
||||
),
|
||||
},
|
||||
);
|
||||
throw new ArtifactReaderFailure(
|
||||
cause instanceof RangeError ? "limit" : "format",
|
||||
`Cannot decode selected Foundation keyed archive: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
|
||||
import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js";
|
||||
import { DirectoryArtifactReader } from "../../../src/artifacts/DirectoryArtifactReader.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import * as processOutput from "../../../src/process/ExecFileOutput.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
it.skipIf(process.platform !== "darwin").each([
|
||||
["success", false, false],
|
||||
["integrity", false, false],
|
||||
["timeout", false, false],
|
||||
["success", true, false],
|
||||
["integrity", true, false],
|
||||
["timeout", true, false],
|
||||
["integrity", true, true],
|
||||
] as const)(
|
||||
"preserves asset catalog %s with reader cleanup failure %s and join cleanup %s",
|
||||
async (primary, failCleanup, failJoinCleanup) => {
|
||||
const bundle = await createTestTempDirectory("rea-asset-finalization-");
|
||||
const resources = join(bundle, "Contents", "Resources");
|
||||
await mkdir(resources, { recursive: true });
|
||||
const catalog = join(resources, "Assets.car");
|
||||
await writeFile(catalog, "inert catalog bytes");
|
||||
let catalogReader: DirectoryArtifactReader | undefined;
|
||||
let denyCleanup = failCleanup;
|
||||
let closeAttempts = 0;
|
||||
const failedReaders = new Set<DirectoryArtifactReader>();
|
||||
const originalOpen = DirectoryArtifactReader.prototype.open;
|
||||
const open = vi
|
||||
.spyOn(DirectoryArtifactReader.prototype, "open")
|
||||
.mockImplementation(function (
|
||||
this: DirectoryArtifactReader,
|
||||
entry,
|
||||
signal,
|
||||
) {
|
||||
if (entry.path.endsWith("/Assets.car") || entry.path === "Assets.car")
|
||||
catalogReader = this;
|
||||
return originalOpen.call(this, entry, signal);
|
||||
});
|
||||
const originalClose = DirectoryArtifactReader.prototype.close;
|
||||
const close = vi
|
||||
.spyOn(DirectoryArtifactReader.prototype, "close")
|
||||
.mockImplementation(async function (this: DirectoryArtifactReader) {
|
||||
if (this === catalogReader || failJoinCleanup) {
|
||||
closeAttempts++;
|
||||
if (denyCleanup) {
|
||||
failedReaders.add(this);
|
||||
throw new ArtifactReaderFailure(
|
||||
"io",
|
||||
"controlled catalog reader close",
|
||||
{
|
||||
cleanup: {
|
||||
reason: "controlled catalog reader close",
|
||||
resources: [resources],
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
await originalClose.call(this);
|
||||
});
|
||||
const utility = vi
|
||||
.spyOn(processOutput, "execFileOutput")
|
||||
.mockImplementationOnce(async () => {
|
||||
if (primary === "integrity")
|
||||
await writeFile(
|
||||
catalog,
|
||||
"changed catalog bytes with a different length",
|
||||
);
|
||||
if (primary === "timeout")
|
||||
throw Object.assign(new Error("controlled assetutil deadline"), {
|
||||
code: "ETIMEDOUT",
|
||||
stdout: "",
|
||||
stderr: "assetutil timeout detail",
|
||||
signal: "SIGTERM",
|
||||
});
|
||||
return { stdout: "[]", stderr: "", stdoutBytes: 2, stderrBytes: 0 };
|
||||
});
|
||||
const client = new ArtifactProvider(
|
||||
{},
|
||||
"darwin",
|
||||
).createBundleResourceClient({
|
||||
callerPath: bundle,
|
||||
canonicalPath: bundle,
|
||||
infoPlistPath: join(bundle, "Contents", "Info.plist"),
|
||||
layout: "contents",
|
||||
executableSha256: null,
|
||||
});
|
||||
try {
|
||||
const result = await client.execute("inspect_asset_catalog", {});
|
||||
if (primary === "success" && !failCleanup) {
|
||||
expect(result.ok).toBe(true);
|
||||
return;
|
||||
}
|
||||
if (result.ok) throw new Error("Expected catalog failure");
|
||||
const projected = projectAnalysisError(result.error);
|
||||
expect(result.error.cleanupIncomplete).toBe(failCleanup);
|
||||
if (primary === "integrity")
|
||||
expect(projected).toMatchObject({
|
||||
category: "integrity_mismatch",
|
||||
details: {
|
||||
reason: "integrity",
|
||||
detail: expect.stringContaining("Asset catalog changed"),
|
||||
},
|
||||
});
|
||||
if (primary === "timeout")
|
||||
expect(projected).toMatchObject({
|
||||
category: "timeout",
|
||||
details: { operation: "inspect_asset_catalog", timeout_ms: 60000 },
|
||||
});
|
||||
if (failCleanup) {
|
||||
expect(projected.code).toBe("cleanup_incomplete");
|
||||
expect(result.error.cleanupResources).toContain(resources);
|
||||
expect((await client.close()).ok).toBe(false);
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
denyCleanup = false;
|
||||
expect((await client.close()).ok).toBe(true);
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(closeAttempts).toBeGreaterThanOrEqual(3);
|
||||
if (failJoinCleanup) expect(failedReaders.size).toBe(2);
|
||||
}
|
||||
} finally {
|
||||
denyCleanup = false;
|
||||
await client.close();
|
||||
utility.mockRestore();
|
||||
close.mockRestore();
|
||||
open.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["decode_interface_builder", "inspect_keyed_archive"] as const)(
|
||||
"retains the %s primary failure and its exact reader until cleanup succeeds",
|
||||
async (operation) => {
|
||||
const bundle = await createTestTempDirectory(
|
||||
"rea-apple-reader-finalization-",
|
||||
);
|
||||
let denyCleanup = true;
|
||||
const originalClose = DirectoryArtifactReader.prototype.close;
|
||||
const close = vi
|
||||
.spyOn(DirectoryArtifactReader.prototype, "close")
|
||||
.mockImplementation(async function (this: DirectoryArtifactReader) {
|
||||
if (denyCleanup)
|
||||
throw new ArtifactReaderFailure(
|
||||
"io",
|
||||
"controlled bundle reader close",
|
||||
{
|
||||
cleanup: {
|
||||
reason: "controlled bundle reader close",
|
||||
resources: [bundle],
|
||||
},
|
||||
},
|
||||
);
|
||||
await originalClose.call(this);
|
||||
});
|
||||
const entries =
|
||||
operation === "decode_interface_builder"
|
||||
? vi
|
||||
.spyOn(DirectoryArtifactReader.prototype, "entries")
|
||||
.mockImplementationOnce(async function* () {
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
"controlled Interface Builder source change",
|
||||
);
|
||||
})
|
||||
: undefined;
|
||||
const client = new ArtifactProvider(
|
||||
{},
|
||||
process.platform,
|
||||
).createBundleResourceClient({
|
||||
callerPath: bundle,
|
||||
canonicalPath: bundle,
|
||||
infoPlistPath: join(bundle, "Info.plist"),
|
||||
layout: "root",
|
||||
executableSha256: null,
|
||||
});
|
||||
try {
|
||||
const result = await client.execute(
|
||||
operation,
|
||||
operation === "inspect_keyed_archive" ? { path: "Missing.plist" } : {},
|
||||
);
|
||||
if (result.ok)
|
||||
throw new Error("Expected source/selection and cleanup failure");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
category:
|
||||
operation === "decode_interface_builder"
|
||||
? "integrity_mismatch"
|
||||
: "invalid_input",
|
||||
});
|
||||
expect(result.error.cleanupResources).toContain(bundle);
|
||||
expect((await client.close()).ok).toBe(false);
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
denyCleanup = false;
|
||||
expect((await client.close()).ok).toBe(true);
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
} finally {
|
||||
denyCleanup = false;
|
||||
await client.close();
|
||||
entries?.mockRestore();
|
||||
close.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user