Merge pull request #1161 from morluto/fix/artifact-unsupported-target-errors

fix(artifacts): report wrong target kinds as unsupported targets
This commit is contained in:
Kaoru0822 2026-10-08 19:30:54 -07:00 committed by GitHub
commit b34bdcdb28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 144 additions and 27 deletions

View File

@ -24,6 +24,7 @@ import type { BinaryTarget } from "../domain/binaryTarget.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisInputError,
AnalysisUnsupportedTargetError,
} from "../domain/analysisErrorCore.js";
import { ArtifactOperationError } from "../domain/artifactOperationError.js";
import { type AnalysisError } from "../domain/analysisErrorBase.js";
@ -89,8 +90,8 @@ class ArtifactClient implements AnalysisClient {
this.target.sourcePath === undefined ||
!this.target.sourcePath.toLowerCase().endsWith(".app")
)
throw new ArtifactReaderFailure(
"unavailable",
throw this.unsupportedTarget(
operation,
"decode_interface_builder requires an active .app bundle target",
);
const limits = interfaceBuilderLimitsSchema.parse(parameters);
@ -135,8 +136,8 @@ class ArtifactClient implements AnalysisClient {
bundlePath === undefined ||
(!standalone && !bundlePath.toLowerCase().endsWith(".app"))
)
throw new ArtifactReaderFailure(
"unavailable",
throw this.unsupportedTarget(
operation,
"inspect_keyed_archive requires an active plist or .app bundle",
);
const result = await inspectBundleKeyedArchive({
@ -214,6 +215,18 @@ class ArtifactClient implements AnalysisClient {
return Promise.resolve();
}
/** 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,
@ -224,8 +237,8 @@ class ArtifactClient implements AnalysisClient {
bundlePath === undefined ||
!bundlePath.toLowerCase().endsWith(".app")
)
throw new ArtifactReaderFailure(
"unavailable",
throw this.unsupportedTarget(
"inspect_asset_catalog",
"inspect_asset_catalog requires an active .app bundle target",
);
const result = await analyzeAppleAssetCatalogs({
@ -250,8 +263,8 @@ class ArtifactClient implements AnalysisClient {
options?: ExecutionOptions,
) {
if (this.target.kind !== "executable" || this.target.format !== "mach-o")
throw new ArtifactReaderFailure(
"unavailable",
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;
@ -360,8 +373,13 @@ const translateFailure = (
cause.details,
cause.message,
);
// Caller-selection failures are already typed; keep their correction details.
if (cause instanceof AnalysisInputError) return cause;
// 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 AnalysisUnsupportedTargetError
)
return cause;
return new ArtifactOperationError(operation, "io");
};

View File

@ -22,6 +22,7 @@ import {
type ArtifactNode,
type ArtifactOccurrence,
} from "../../domain/artifactGraph.js";
import { AnalysisUnsupportedTargetError } from "../../domain/analysisErrorCore.js";
import type { BinaryTarget } from "../../domain/binaryTarget.js";
import { scanArtifactInventory } from "../inventory/ArtifactInventory.js";
@ -38,6 +39,7 @@ export const extractArtifact = async (
signal?: AbortSignal,
): Promise<ArtifactExtractionResult> => {
const sourcePath = await realpath(input.inputPath);
await requireExtractableFormat(sourcePath, input.inputFormat);
const snapshot = await scanArtifactInventory(sourcePath, {
signal,
});
@ -65,15 +67,22 @@ export const extractArtifact = async (
nodes,
};
const selected = selectedOccurrences.map((occurrence) => {
// REA never decrypts archive entries, so an encrypted entry makes the
// complete extraction unsupported rather than the archive invalid.
if (occurrence.encrypted)
throw new AnalysisUnsupportedTargetError(
"extract_artifact",
sourcePath,
`Archive entry ${occurrence.logical_path} is encrypted; REA does not decrypt archive entries, so the archive cannot be extracted completely`,
);
if (
(occurrence.entry_kind !== "file" && occurrence.entry_kind !== "slice") ||
occurrence.artifact_id === null ||
occurrence.encrypted ||
occurrence.logical_path === "."
)
throw new ArtifactReaderFailure(
"format",
`Selected occurrence is not an extractable regular child file: ${occurrence.occurrence_id}`,
`Selected occurrence is not an extractable regular child file: ${occurrence.logical_path} (${occurrence.occurrence_id})`,
);
const node = inventory.nodes.get(occurrence.artifact_id);
if (node === undefined)
@ -240,26 +249,45 @@ const collectNodes = (
if (selected.has(item.artifact_id)) output.set(item.artifact_id, item);
};
const ZIP_FORMATS = ["ipa", "apk", "msix", "appx", "zip"] as const;
const isZipFormat = (
format: BinaryTarget["format"],
): format is (typeof ZIP_FORMATS)[number] =>
(ZIP_FORMATS as readonly string[]).includes(format);
/**
* Refuse a format without an extraction reader before inventory work starts.
* The target kind, not the host, is unsupported by extraction.
*/
const requireExtractableFormat = async (
path: string,
format: BinaryTarget["format"],
): Promise<boolean> => {
const directory = (await lstat(path)).isDirectory();
if (
!directory &&
format !== "asar" &&
format !== "mach-o" &&
!isZipFormat(format)
)
throw new AnalysisUnsupportedTargetError(
"extract_artifact",
path,
`Artifact format has no extraction reader: ${format}`,
);
return directory;
};
const createReader = async (
path: string,
format: BinaryTarget["format"],
): Promise<ArtifactReader> => {
if ((await lstat(path)).isDirectory())
if (await requireExtractableFormat(path, format))
return new DirectoryArtifactReader(path);
if (format === "asar") return new AsarArtifactReader(path);
if (
format === "ipa" ||
format === "apk" ||
format === "msix" ||
format === "appx" ||
format === "zip"
)
return new ZipArtifactReader(path, format);
if (format === "mach-o") return new MachOSliceArtifactReader(path);
throw new ArtifactReaderFailure(
"unavailable",
`Artifact format has no extraction reader: ${format}`,
);
if (isZipFormat(format)) return new ZipArtifactReader(path, format);
return new MachOSliceArtifactReader(path);
};
const preflight = (entry: ArtifactEntry): void => {

View File

@ -89,6 +89,22 @@ it("preserves encrypted-member and corrupt-CRC observations", async () => {
hash_status: "unavailable",
}),
);
// REA does not decrypt entries, so complete extraction is unsupported.
const extractionRoot = join(root, "encrypted-output");
expect(
await new ArtifactProvider()
.createClient(encryptedTarget.value)
.execute("extract_artifact", { output_root: extractionRoot }),
).toMatchObject({
ok: false,
error: {
_tag: "AnalysisUnsupportedTargetError",
reason: expect.stringContaining("Archive entry secret.txt is encrypted"),
},
});
await expect(readFile(extractionRoot)).rejects.toMatchObject({
code: "ENOENT",
});
const corruptPath = join(root, "corrupt-crc.zip");
const corruptWriter = new ZipWriter(new Uint8ArrayWriter());

View File

@ -126,7 +126,41 @@ it("rejects targets that are not Mach-O", async () => {
arguments: {},
});
expect(called.isError).toBe(true);
expect(JSON.stringify(called.structuredContent)).toContain("unavailable");
expect(called.structuredContent).toMatchObject({
error: { code: "unsupported_target", category: "unsupported_target" },
});
});
});
it("reports target kinds that artifact operations cannot inspect as unsupported", async () => {
const directory = await createTestTempDirectory("rea-artifact-target-");
const plist = join(directory, "Info.plist");
await writeFile(plist, "<plist><dict/></plist>");
const tool = join(directory, "tool");
await writeFile(tool, machoImage({ commands: [buildVersionCommand(1)] }));
await withClient(async (client) => {
for (const [path, operation, requirement] of [
[plist, "decode_interface_builder", "requires an active .app bundle"],
// Elsewhere the session reports asset catalogs as unavailable on the
// host before the provider checks the target kind.
...(process.platform === "darwin"
? ([
[plist, "inspect_asset_catalog", "requires an active .app bundle"],
] as const)
: []),
[tool, "inspect_keyed_archive", "requires an active plist or .app"],
] as const) {
const open = { name: "open_binary", arguments: { path } };
expect((await client.callTool(open)).isError, path).not.toBe(true);
const called = await client.callTool({ name: operation, arguments: {} });
expect(called.structuredContent, operation).toMatchObject({
error: {
code: "unsupported_target",
message: expect.stringContaining(`${operation} at ${path}`),
details: { reason: expect.stringContaining(requirement) },
},
});
}
});
});

View File

@ -133,6 +133,27 @@ describe("artifact extraction", () => {
"source",
]);
});
it("refuses a format without an extraction reader before inventory or output", async () => {
const root = await createTestTempDirectory("rea-extract-dmg-");
const image = join(root, "Image.dmg");
await writeFile(image, "not mounted");
const output = join(root, "output");
const result = await new ArtifactProvider()
.createClient(target(image, "dmg"))
.execute(
"extract_artifact",
artifactExtractionExecutionSchema.parse({ output_root: output }),
);
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisUnsupportedTargetError",
operation: "extract_artifact",
reason: "Artifact format has no extraction reader: dmg",
},
});
await expect(access(output)).rejects.toThrow();
});
});
const inventory = async (targetValue: BinaryTarget) => {
const result = await new ArtifactProvider()