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:
commit
b34bdcdb28
|
|
@ -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");
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue