fix(cli): reject empty artifact command targets before resolution (#8834)

This commit is contained in:
Abduallah Jamal authored and GitHub committed 2026-11-04 16:47:46 +08:00
1 parent 6c3c5ebc69
commit 47cedcc93a
3 files changed
+147 -7

No files matched your search

+13 -6
View File
@@ -13,6 +13,7 @@ import { withCommandCancellation } from "./commandCancellation.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { ARTIFACT_GRAPH_PROVIDER } from "../domain/investigationProviders.js";
import { keyedArchiveInputSchema } from "../domain/apple/keyedArchive.js";
import { localPathStringSchema } from "../domain/localPath.js";
import { z } from "incur";
import type { DirectAnalysis } from "../composition/directAnalysis.js";
@@ -43,7 +44,9 @@ export const registerArtifactCommands = (
description:
"Inspect a Foundation keyed archive as an object-reference graph",
args: z.object({
path: z.string().describe("Foundation plist archive or Apple app bundle"),
path: localPathStringSchema.describe(
"Foundation plist archive or Apple app bundle",
),
archive: keyedArchiveInputSchema.shape.path.describe(
"Relative archive path when the target is a bundle",
),
@@ -87,7 +90,9 @@ const registerDylibResolutionCommand = (
description:
"Trace dyld load-path resolution for a Mach-O or every executable in an app bundle",
args: z.object({
path: z.string().describe("Mach-O file or Apple .app bundle path"),
path: localPathStringSchema.describe(
"Mach-O file or Apple .app bundle path",
),
}),
options: z.object({
root: z
@@ -125,7 +130,9 @@ const registerAssetCatalogCommand = (
): void => {
cli.command(CLI_COMMANDS.inspectAssetCatalog, {
description: "Inspect compiled Apple asset catalog metadata",
args: z.object({ path: z.string().describe("Apple .app bundle path") }),
args: z.object({
path: localPathStringSchema.describe("Apple .app bundle path"),
}),
options: z.object({
offset: z
.number()
@@ -162,7 +169,7 @@ const registerInterfaceBuilderCommand = (
description:
"Decode compiled storyboard and nib archives from an app bundle",
args: z.object({
path: z.string().describe("Apple .app bundle path"),
path: localPathStringSchema.describe("Apple .app bundle path"),
}),
options: z.object({
maxDocuments: z
@@ -257,7 +264,7 @@ const registerExtractionCommand = (
description:
"Extract regular contents of supported artifacts; DMG extraction is unavailable",
args: z.object({
path: z.string().describe("Application or package path"),
path: localPathStringSchema.describe("Application or package path"),
}),
options: z.object({
integrityPolicy: z
@@ -293,7 +300,7 @@ const registerInspectionCommand = (
description:
"Inspect one artifact and return all available observations and next probes",
args: z.object({
path: z.string().min(1).describe("Application or package path"),
path: localPathStringSchema.describe("Application or package path"),
}),
options: z.object({
...analysisEvidenceDeliveryOptions,
@@ -0,0 +1,119 @@
import { mkdir, readdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { build } from "plist";
import { expect } from "vitest";
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const createWorkingBundleFixture = async (prefix: string) => {
const root = await createTestTempDirectory(prefix);
const bundle = join(root, "Example.app");
const temp = join(root, "temp");
const contents = join(bundle, "Contents");
await mkdir(join(contents, "MacOS"), { recursive: true });
await mkdir(join(contents, "Resources"), { recursive: true });
await mkdir(temp, { recursive: true });
await writeFile(
join(contents, "Info.plist"),
build({ CFBundleExecutable: "Example" }),
);
await writeFile(
join(contents, "MacOS", "Example"),
thinMach(0xcffaedfe, 0x0100000c),
);
await writeFile(
join(contents, "Resources", "Archive.plist"),
build({
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: ["$null", "resource-only-value"],
$top: { root: { CF$UID: 1 } },
}),
);
const environment = {
HOME: join(root, "home"),
USERPROFILE: join(root, "home"),
TMPDIR: temp,
TMP: temp,
TEMP: temp,
REA_LOG_LEVEL: "silent",
};
return { bundle, temp, environment };
};
cliTest(
"compiled CLI rejects empty inspect-keyed-archive target in a bundle cwd while preserving explicit dot",
async ({ cli }) => {
const { bundle, environment } = await createWorkingBundleFixture(
"rea-empty-keyed-bundle-",
);
const archive = "Contents/Resources/Archive.plist";
const empty = await cli.run({
arguments: ["inspect-keyed-archive", "", archive, "--json"],
cwd: bundle,
environment,
});
expect(empty.exitCode).not.toBe(0);
expect(empty.json).toMatchObject({
code: "VALIDATION_ERROR",
fieldErrors: [
expect.objectContaining({ path: "path", code: "too_small" }),
],
});
const explicitDot = await cli.run({
arguments: ["inspect-keyed-archive", ".", archive, "--json"],
cwd: bundle,
environment,
});
expect(explicitDot.exitCode, explicitDot.stderr).toBe(0);
const evidence = parseEvidence(explicitDot.json);
expect(evidence.parameters).toMatchObject({ input_path: "." });
expect(evidence.normalized_result).toMatchObject({
total_objects: 2,
archive_path: archive,
});
},
);
cliTest(
"compiled CLI rejects empty extract-artifact and bundle artifact targets before touching the working directory",
async ({ cli }) => {
const { bundle, temp, environment } = await createWorkingBundleFixture(
"rea-empty-artifact-bundle-",
);
for (const command of [
"extract-artifact",
"decode-interface-builder",
"inspect-asset-catalog",
"trace-dylib-resolution",
]) {
const empty = await cli.run({
arguments: [command, "", "--json"],
cwd: bundle,
environment,
});
expect(empty.exitCode, command).not.toBe(0);
expect(empty.json).toMatchObject({
code: "VALIDATION_ERROR",
fieldErrors: [
expect.objectContaining({ path: "path", code: "too_small" }),
],
});
}
expect(await readdir(temp)).toEqual([]);
const explicitExtract = await cli.run({
arguments: ["extract-artifact", ".", "--json"],
cwd: bundle,
environment,
});
expect(explicitExtract.exitCode, explicitExtract.stderr).toBe(0);
expect(parseEvidence(explicitExtract.json).parameters).toMatchObject({
input_path: ".",
});
},
);
@@ -1,5 +1,6 @@
import { Cli } from "incur";
import { expect, it, vi } from "vitest";
import { registerArtifactCommands } from "../../../src/cli/artifactCommands.js";
import { registerCoreBinaryCommands } from "../../../src/cli/coreBinaryCommands.js";
import { registerCoreNativeCommands } from "../../../src/cli/coreNativeCommands.js";
import { registerManagedCommands } from "../../../src/cli/managedCommands.js";
@@ -16,6 +17,7 @@ const registry = () => {
const runDirectAnalysis = run();
const runProviderAnalysis = run();
const cli = Cli.create("empty-target-admission", { sync: false });
registerArtifactCommands(cli, silentLogger, runProviderAnalysis);
registerCoreBinaryCommands(cli, silentLogger, runDirectAnalysis, {});
registerCoreNativeCommands(cli, silentLogger, runDirectAnalysis);
registerManagedCommands(cli, silentLogger, runProviderAnalysis);
@@ -44,6 +46,12 @@ const registry = () => {
// Each command with its other required arguments; the target comes first.
const commands: readonly (readonly string[])[] = [
["inspect-artifact"],
["extract-artifact"],
["inspect-keyed-archive"],
["trace-dylib-resolution"],
["inspect-asset-catalog"],
["decode-interface-builder"],
["inspect"],
["decompile", "main"],
["xrefs", "0x1000"],
@@ -83,7 +91,7 @@ const commands: readonly (readonly string[])[] = [
];
it.each(commands)(
"rejects an empty %s target before resolution and admits explicit .",
"rejects an empty or NUL %s target before resolution and admits explicit .",
async (command, ...rest) => {
const { serve, runs } = registry();
// docs/cli.md: an empty string never selects the working directory.
@@ -93,6 +101,12 @@ it.each(commands)(
expect.objectContaining({ path: "path", code: "too_small" }),
],
});
expect(await serve([command, "target\x00name", ...rest])).toMatchObject({
code: "VALIDATION_ERROR",
fieldErrors: [
expect.objectContaining({ path: "path", code: "invalid_format" }),
],
});
for (const run of runs) expect(run).not.toHaveBeenCalled();
await serve([command, ".", ...rest]);
expect(runs.flatMap((run) => run.mock.calls.map(([path]) => path))).toEqual(