mirror of
https://github.com/morluto/rea.git
synced 2026-11-10 17:30:32 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2eb48add14 |
No files matched your search
@@ -651,13 +651,6 @@ be verified. See [MCP contracts](../reference/mcp-contracts.md) for session life
|
||||
[Ghidra first-query deadlines](../reference/mcp-contracts.md#ghidra-first-query-deadlines-and-recovery)
|
||||
for import, client timeouts and recovery.
|
||||
|
||||
One-shot binary analysis validates the selected operation's input before creating
|
||||
a provider session, opening the target, or replaying a snapshot. Invalid selectors
|
||||
and option combinations report their input errors before target or provider
|
||||
availability errors. An already-cancelled request keeps its cancellation reason.
|
||||
Caller-selected paths and request parameters remain separate from parsed execution
|
||||
arguments in Evidence.
|
||||
|
||||
A one-shot command attempts provider-session and runtime cleanup before reporting
|
||||
a failure. If cleanup remains incomplete, inspect the reported resources and
|
||||
follow the error's remediation. [Same-session cleanup retry](../reference/mcp-contracts.md#retained-cleanup-retry),
|
||||
|
||||
@@ -143,7 +143,6 @@ import {
|
||||
type MachoLoadCommandTableHeader,
|
||||
} from "../domain/apple/machoLoadCommands.js";
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
import { localPathStringSchema } from "../domain/localPath.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
|
||||
const XML_PLIST_PROBE_CHUNK_BYTES = 64 * 1024;
|
||||
@@ -174,25 +173,9 @@ export const parseBinaryTarget = async (
|
||||
): Promise<
|
||||
Result<BinaryTarget, BinaryTargetError | AnalysisCancelledError>
|
||||
> => {
|
||||
const { signal } = options;
|
||||
const admitted = localPathStringSchema.safeParse(input);
|
||||
if (!admitted.success) {
|
||||
if (signal?.aborted === true)
|
||||
return err(
|
||||
new AnalysisCancelledError("resolve_binary_target", {
|
||||
cause: signal.reason,
|
||||
}),
|
||||
);
|
||||
return err(
|
||||
new BinaryTargetError(
|
||||
input,
|
||||
admitted.error.issues[0]?.message ?? "Invalid local path",
|
||||
{ cause: admitted.error },
|
||||
),
|
||||
);
|
||||
}
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
const candidate = absoluteCallerPath(admitted.data, { cwd });
|
||||
const { signal } = options;
|
||||
const candidate = absoluteCallerPath(input, { cwd });
|
||||
// Failures before canonicalization concern the selected path itself; later
|
||||
// failures concern the canonical path opened with symbolic links rejected.
|
||||
let stage: TargetFilesystemStage = "resolve";
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
admitOneShotOperation,
|
||||
type DirectAnalysisTool,
|
||||
} from "./oneShotOperationInput.js";
|
||||
import { admitCompletedAnalysis } from "./investigation/admitCompletedAnalysis.js";
|
||||
import type { EspFlashSelection } from "../domain/firmware/espFlashLocation.js";
|
||||
import type { ExecutableFormatHint } from "../domain/dosCom.js";
|
||||
@@ -100,6 +96,30 @@ import {
|
||||
import { completeProviderEvidence } from "./ProviderOperationExecution.js";
|
||||
import { BUNDLE_RESOURCE_OPERATIONS } from "./binary/BundleResourceSession.js";
|
||||
|
||||
type DirectAnalysisTool =
|
||||
| "annotate_native_function"
|
||||
| "inspect_native_load_image"
|
||||
| "read_bytes"
|
||||
| "search_bytes"
|
||||
| "address_to_file_offset"
|
||||
| "binary_overview"
|
||||
| "procedure_pseudo_code"
|
||||
| "procedure_references"
|
||||
| "read_function_instructions"
|
||||
| "inspect_native_instruction"
|
||||
| "inspect_native_data_type"
|
||||
| "resolve_native_call_targets"
|
||||
| "analyze_function"
|
||||
| "inspect_native_api"
|
||||
| "inspect_native_dispatch_metadata"
|
||||
| "search_strings"
|
||||
| "search_procedures"
|
||||
| "xrefs"
|
||||
| "trace_feature"
|
||||
| "trace_native_ui_action"
|
||||
| "trace_native_values"
|
||||
| "decompile_all";
|
||||
|
||||
// These one-shot CLI questions require native utilities, not a deep engine.
|
||||
// Runtime observation retains the ordinary session and authority checks.
|
||||
const NATIVE_INSPECTION_OPERATIONS: ReadonlySet<string> = new Set([
|
||||
@@ -138,30 +158,20 @@ export const runDirectAnalysis = async (
|
||||
readonly includeEvidenceSources?: boolean;
|
||||
} = {},
|
||||
): Promise<AnalysisResultWithSources<Evidence, JsonValue>> =>
|
||||
withProcessCancellation(options.signal, (signal) => {
|
||||
const admitted = admitOneShotOperation(tool, arguments_, signal);
|
||||
if (!admitted.ok)
|
||||
return Promise.resolve(err(cliError(admitted.error, path)));
|
||||
return runAnalysis(
|
||||
dependencies,
|
||||
path,
|
||||
admitted.value.name,
|
||||
admitted.value.input,
|
||||
{
|
||||
evidenceParameters: arguments_,
|
||||
logger: options.logger ?? silentLogger,
|
||||
snapshotPath: options.snapshotPath,
|
||||
signal,
|
||||
...(options.includeEvidenceSources === true
|
||||
? { includeEvidenceSources: true }
|
||||
: {}),
|
||||
...targetSelection(options),
|
||||
...(options.providerId === undefined
|
||||
? {}
|
||||
: { providerId: options.providerId }),
|
||||
},
|
||||
);
|
||||
});
|
||||
withProcessCancellation(options.signal, (signal) =>
|
||||
runAnalysis(dependencies, path, tool, arguments_, {
|
||||
logger: options.logger ?? silentLogger,
|
||||
snapshotPath: options.snapshotPath,
|
||||
signal,
|
||||
...(options.includeEvidenceSources === true
|
||||
? { includeEvidenceSources: true }
|
||||
: {}),
|
||||
...targetSelection(options),
|
||||
...(options.providerId === undefined
|
||||
? {}
|
||||
: { providerId: options.providerId }),
|
||||
}),
|
||||
);
|
||||
|
||||
/** How the caller asks an artifact target to be read. */
|
||||
export interface ArtifactTargetSelection {
|
||||
@@ -258,7 +268,8 @@ export const runProviderAnalysis = async (
|
||||
),
|
||||
);
|
||||
return withProcessCancellation(signal, async (operationSignal) => {
|
||||
// Preserve the selected path and original arguments as request facts.
|
||||
// The selected CLI path is a request fact. Keep it out of provider inputs
|
||||
// and target identity, which may use the resolved executable instead.
|
||||
const evidenceParameters = { ...arguments_, input_path: path };
|
||||
if (isManagedToolName(tool)) {
|
||||
const original = await runManagedProviderAnalysis(
|
||||
@@ -298,26 +309,18 @@ export const runProviderAnalysis = async (
|
||||
);
|
||||
return result.ok ? ok(result.value) : err(cliError(result.error, path));
|
||||
}
|
||||
const admitted = admitOneShotOperation(tool, arguments_, operationSignal);
|
||||
if (!admitted.ok) return err(cliError(admitted.error, path));
|
||||
return runAnalysis(
|
||||
dependencies,
|
||||
path,
|
||||
admitted.value.name,
|
||||
admitted.value.input,
|
||||
{
|
||||
logger,
|
||||
snapshotPath: undefined,
|
||||
signal: operationSignal,
|
||||
evidenceParameters,
|
||||
...targetSelection(selection ?? {}),
|
||||
...(ARTIFACT_ANALYSIS_OPERATION_SET.has(tool)
|
||||
? { createSession: dependencies.createArtifactBinarySession }
|
||||
: NATIVE_INSPECTION_OPERATIONS.has(tool)
|
||||
? { createSession: dependencies.createNativeBinarySession }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
return runAnalysis(dependencies, path, tool, arguments_, {
|
||||
logger,
|
||||
snapshotPath: undefined,
|
||||
signal: operationSignal,
|
||||
evidenceParameters,
|
||||
...targetSelection(selection ?? {}),
|
||||
...(ARTIFACT_ANALYSIS_OPERATION_SET.has(tool)
|
||||
? { createSession: dependencies.createArtifactBinarySession }
|
||||
: NATIVE_INSPECTION_OPERATIONS.has(tool)
|
||||
? { createSession: dependencies.createNativeBinarySession }
|
||||
: {}),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -331,8 +334,6 @@ export const runManagedProviderExecution = async (
|
||||
recoveryParameters?: Readonly<Record<string, JsonValue>>,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> =>
|
||||
withProcessCancellation(signal, async (operationSignal) => {
|
||||
const admitted = admitOneShotOperation(tool, parameters, operationSignal);
|
||||
if (!admitted.ok) return admitted;
|
||||
const session = await dependencies.createManagedBinarySession();
|
||||
return withSessionCleanup(
|
||||
session,
|
||||
@@ -340,12 +341,10 @@ export const runManagedProviderExecution = async (
|
||||
const opened = await session.open(path, { signal: operationSignal });
|
||||
if (!opened.ok) return opened;
|
||||
return recoveryParameters === undefined
|
||||
? await session.execute(admitted.value.name, admitted.value.input, {
|
||||
signal: operationSignal,
|
||||
})
|
||||
? await session.execute(tool, parameters, { signal: operationSignal })
|
||||
: await session.executeForDirectObservation(
|
||||
admitted.value.name,
|
||||
admitted.value.input,
|
||||
tool,
|
||||
parameters,
|
||||
recoveryParameters,
|
||||
{ signal: operationSignal },
|
||||
);
|
||||
|
||||
@@ -13,9 +13,6 @@ import { err, ok } from "../../domain/result.js";
|
||||
import { EvidenceFileError } from "../../domain/evidenceErrors.js";
|
||||
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
||||
import type { Il2cppSeedWriterPort } from "./Il2cppSeedWriterPort.js";
|
||||
import { toolContract } from "../../contracts/toolContracts.js";
|
||||
import { z } from "zod";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
|
||||
const identity = {
|
||||
id: "fixture-il2cpp",
|
||||
@@ -24,29 +21,6 @@ const identity = {
|
||||
};
|
||||
const report = createEmptyIl2cppMetadataFixture();
|
||||
|
||||
it.each([{ binary_architecture: "arm64" }, { generic_instances: true }])(
|
||||
"binds binary-dependent selectors in the named input contract: %j",
|
||||
(selection) => {
|
||||
const schema = toolContract("inspect_il2cpp_metadata").inputSchema;
|
||||
const validate = new Ajv2020({ strict: false }).compile(
|
||||
z.toJSONSchema(schema, { io: "input" }),
|
||||
);
|
||||
const missingBinary = { path: report.artifact.path, ...selection };
|
||||
const selectedBinary = {
|
||||
...missingBinary,
|
||||
binary_path: "/artifacts/GameAssembly.dll",
|
||||
};
|
||||
expect(schema.safeParse(missingBinary).success).toBe(false);
|
||||
expect(validate(missingBinary)).toBe(false);
|
||||
expect(schema.safeParse(selectedBinary).success).toBe(true);
|
||||
expect(validate(selectedBinary)).toBe(true);
|
||||
expect(
|
||||
schema.safeParse({ path: report.artifact.path, generic_instances: false })
|
||||
.success,
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
const serviceReturning = (value: Il2cppMetadata, calls = { count: 0 }) =>
|
||||
new Il2cppMetadataService({
|
||||
close: async () => {},
|
||||
|
||||
@@ -106,6 +106,32 @@ export class Il2cppMetadataService {
|
||||
options?.signal,
|
||||
);
|
||||
if (!input.ok) return input;
|
||||
if (
|
||||
input.value.binary_architecture !== undefined &&
|
||||
input.value.binary_path === undefined
|
||||
)
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
{
|
||||
path: ["binary_architecture"],
|
||||
reason: "invalid_value",
|
||||
message: "binary_architecture selects a slice of binary_path.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
if (
|
||||
input.value.generic_instances === true &&
|
||||
input.value.binary_path === undefined
|
||||
)
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
{
|
||||
path: ["generic_instances"],
|
||||
reason: "invalid_value",
|
||||
message: "generic_instances links generic methods in binary_path.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
if (output !== undefined) {
|
||||
if (input.value.binary_path === undefined)
|
||||
return err(
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import type { z } from "zod";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/officialToolContracts.js";
|
||||
import { NATIVE_TOOL_CONTRACTS } from "../contracts/native/nativeToolContracts.js";
|
||||
import {
|
||||
ARTIFACT_TOOL_CONTRACTS,
|
||||
artifactInventoryInputSchema,
|
||||
type ArtifactAnalysisOperation,
|
||||
} from "../contracts/artifactToolContracts.js";
|
||||
import {
|
||||
MANAGED_TOOL_CONTRACTS,
|
||||
managedArtifactInputSchema,
|
||||
managedMembersInputSchema,
|
||||
type ManagedToolName,
|
||||
} from "../contracts/managed/managedToolContracts.js";
|
||||
import { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
|
||||
import type { NativeToolName } from "../contracts/native/nativeToolContracts.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { parseAnalysisInput } from "./AnalysisInput.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
|
||||
/** Operations the direct workflow can perform without a persistent session. */
|
||||
export type DirectAnalysisTool =
|
||||
| "annotate_native_function"
|
||||
| "inspect_native_load_image"
|
||||
| "read_bytes"
|
||||
| "search_bytes"
|
||||
| "address_to_file_offset"
|
||||
| "binary_overview"
|
||||
| "procedure_pseudo_code"
|
||||
| "procedure_references"
|
||||
| "read_function_instructions"
|
||||
| "inspect_native_instruction"
|
||||
| "inspect_native_data_type"
|
||||
| "resolve_native_call_targets"
|
||||
| "analyze_function"
|
||||
| "inspect_native_api"
|
||||
| "inspect_native_dispatch_metadata"
|
||||
| "search_strings"
|
||||
| "search_procedures"
|
||||
| "xrefs"
|
||||
| "trace_feature"
|
||||
| "trace_native_ui_action"
|
||||
| "trace_native_values"
|
||||
| "decompile_all";
|
||||
|
||||
type OneShotOperation =
|
||||
| DirectAnalysisTool
|
||||
| NativeToolName
|
||||
| ArtifactAnalysisOperation
|
||||
| ManagedToolName;
|
||||
type Schema = z.ZodType<Readonly<Record<string, unknown>>>;
|
||||
|
||||
type ContractSchemas<
|
||||
Contracts extends readonly {
|
||||
readonly name: string;
|
||||
readonly inputSchema: Schema;
|
||||
}[],
|
||||
> = {
|
||||
[Contract in Contracts[number] as Contract["name"]]: Contract["inputSchema"];
|
||||
};
|
||||
|
||||
const schemasByName = <
|
||||
const Contracts extends readonly {
|
||||
readonly name: string;
|
||||
readonly inputSchema: Schema;
|
||||
}[],
|
||||
>(
|
||||
contracts: Contracts,
|
||||
): ContractSchemas<Contracts> =>
|
||||
Object.fromEntries(
|
||||
contracts.map(({ name, inputSchema }) => [name, inputSchema]),
|
||||
) as ContractSchemas<Contracts>;
|
||||
|
||||
const schemas = {
|
||||
...schemasByName(OFFICIAL_TOOL_CONTRACTS),
|
||||
...schemasByName(NATIVE_TOOL_CONTRACTS),
|
||||
...schemasByName(ARTIFACT_TOOL_CONTRACTS),
|
||||
...schemasByName(MANAGED_TOOL_CONTRACTS),
|
||||
...enhancedInputSchemas,
|
||||
inspect_native_load_image: toolContract(
|
||||
"inspect_native_load_image",
|
||||
).inputSchema.omit({ detail: true }),
|
||||
inspect_managed_artifact: managedArtifactInputSchema.omit({ detail: true }),
|
||||
inspect_managed_members: managedMembersInputSchema.omit({ detail: true }),
|
||||
inventory_artifact: artifactInventoryInputSchema,
|
||||
} satisfies Record<OneShotOperation, Schema>;
|
||||
|
||||
/** Parse the named operation before target selection or snapshot replay. */
|
||||
export const admitOneShotOperation = <Name extends OneShotOperation>(
|
||||
name: Name,
|
||||
input: Readonly<Record<string, JsonValue>>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
type SelectedInput = Partial<z.output<(typeof schemas)[Name]>> &
|
||||
Readonly<Record<string, JsonValue>>;
|
||||
const parsed = parseAnalysisInput(
|
||||
name,
|
||||
input,
|
||||
schemas[name] as Schema,
|
||||
signal,
|
||||
);
|
||||
if (!parsed.ok) return parsed;
|
||||
// Admission owns the selected request: preserve top-level omissions for
|
||||
// Evidence and snapshot identity while carrying each selected parsed value.
|
||||
// Nested defaults remain in the owning field's parsed value.
|
||||
const selected = Object.fromEntries(
|
||||
Object.entries(parsed.value).filter(([key]) => Object.hasOwn(input, key)),
|
||||
) as SelectedInput;
|
||||
return ok({ name, input: selected });
|
||||
};
|
||||
@@ -66,6 +66,28 @@ const report: PythonPyc = {
|
||||
],
|
||||
};
|
||||
|
||||
it.each([
|
||||
{
|
||||
flags: 1,
|
||||
hash_based: true,
|
||||
source_hash_hex: "",
|
||||
source_mtime: null,
|
||||
source_size: null,
|
||||
},
|
||||
{ flags: 1 },
|
||||
])("refuses inconsistent provider header facts %j", async (header) => {
|
||||
const result = await new PythonPycService({
|
||||
close: async () => {},
|
||||
identity,
|
||||
inspect: async () =>
|
||||
ok({ ...report, header: { ...report.header, ...header } }),
|
||||
}).inspect({ path: report.artifact.path });
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a relative path and extra arguments before reading", async () => {
|
||||
let calls = 0;
|
||||
const service = new PythonPycService({
|
||||
|
||||
@@ -215,7 +215,8 @@ export const registerBinaryDiagnosticsCommands = (
|
||||
maxIntegerTextBytes: cliDecimalField(
|
||||
inspectAppleIpsInputSchema.shape.max_integer_text_bytes,
|
||||
),
|
||||
joinPath: localPathStringSchema
|
||||
joinPath: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional local Mach-O executable or dSYM whose slice LC_UUID matches report images",
|
||||
|
||||
@@ -59,12 +59,6 @@ export const registerCaptureCommands = (
|
||||
path: ["all"],
|
||||
message: "all and limit are mutually exclusive",
|
||||
});
|
||||
})
|
||||
.meta({
|
||||
not: {
|
||||
required: ["all", "limit"],
|
||||
properties: { all: { const: true } },
|
||||
},
|
||||
}),
|
||||
run: ({ options: { all, producerLimits, ...options } }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
@@ -125,13 +119,7 @@ export const registerCaptureCommands = (
|
||||
.refine(
|
||||
withinPacketCaptureSelectorBudget,
|
||||
packetCaptureSelectorBudgetIssue,
|
||||
)
|
||||
.meta({
|
||||
not: {
|
||||
required: ["all_matches", "match_limit"],
|
||||
properties: { all_matches: { const: true } },
|
||||
},
|
||||
}),
|
||||
),
|
||||
run: ({ args, options: { all_matches, producerLimits, ...options } }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.inspectPacketCapture, async () => {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { nativeInstructionInputSchema } from "../domain/native/nativeInstruction.js";
|
||||
import { nativeFunctionAnnotationsInputSchema } from "../domain/native/nativeFunctionAnnotations.js";
|
||||
import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
|
||||
import { nativeDispatchMetadataInputSchema } from "../domain/native/objcSwiftMetadata.js";
|
||||
import { nativeInvestigationTraceLimitsSchema } from "../domain/native/nativeInvestigationGraph.js";
|
||||
@@ -397,9 +395,7 @@ export const registerCoreNativeCommands = (
|
||||
path: localPathStringSchema.describe(
|
||||
"Local native executable or app path",
|
||||
),
|
||||
address: nativeInstructionInputSchema.shape.address.describe(
|
||||
"Exact native address",
|
||||
),
|
||||
address: z.string().describe("Exact native address"),
|
||||
}),
|
||||
options: z.object({
|
||||
...nativeOpenOptionFields,
|
||||
@@ -561,9 +557,11 @@ const registerAnnotationCommand = (
|
||||
description: "Edit function annotations and return refreshed analysis",
|
||||
args: z.object({
|
||||
path: localPathStringSchema.describe("Local executable path"),
|
||||
procedure: nativeFunctionAnnotationsInputSchema.shape.procedure.describe(
|
||||
"Function name or address; Ghidra resolves interior addresses to the containing function and edits its entry",
|
||||
),
|
||||
procedure: z
|
||||
.string()
|
||||
.describe(
|
||||
"Function name or address; Ghidra resolves interior addresses to the containing function and edits its entry",
|
||||
),
|
||||
}),
|
||||
options: z.object({
|
||||
name: z.string().min(1).optional().describe("Analyst function name"),
|
||||
|
||||
@@ -263,12 +263,14 @@ const withheldExportOptions = {
|
||||
.describe(
|
||||
"Seed the withheld files after all, which needs a seed_sign_in consent in --consent",
|
||||
),
|
||||
consent: localPathStringSchema
|
||||
consent: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"consent.json holding the person's consents, which the bundle ships",
|
||||
),
|
||||
withheldCopy: localPathStringSchema
|
||||
withheldCopy: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"New or empty folder outside the bundle the withheld files are copied into, for whoever supplies them at run time",
|
||||
@@ -338,7 +340,8 @@ const registerDesktopExportCommand = (
|
||||
.describe(
|
||||
"http or https URL the computer downloads the captured --package-file from when the bundle is put, for an application installed from a .deb file",
|
||||
),
|
||||
downloadInstall: localPathStringSchema
|
||||
downloadInstall: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"JSON file naming the AppImage or tarball a build computer unpacked the application from: kind, desktop_entry, url, sha256, architecture, and a tarball's program and name",
|
||||
@@ -355,7 +358,8 @@ const registerDesktopExportCommand = (
|
||||
.describe(
|
||||
"The seed was made for the task, as in a build computer, not taken from the user's own data; the README says so instead of keeping the bundle local",
|
||||
),
|
||||
screens: localPathStringSchema
|
||||
screens: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Folder holding checkpoint screenshots for screen-judge examples.",
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
|
||||
import { parseCliAnalysisEvidenceDelivery } from "./analysisEvidenceDelivery.js";
|
||||
import { javaScriptRoutesDeliverySchema } from "../domain/analysisView/javascriptRoutesView.js";
|
||||
|
||||
import { isAbsolute, resolve, sep } from "node:path";
|
||||
|
||||
import { Cli, z } from "incur";
|
||||
import {
|
||||
localPathStringSchema,
|
||||
nulFreeStringSchema,
|
||||
} from "../domain/localPath.js";
|
||||
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import {
|
||||
@@ -42,6 +37,7 @@ import {
|
||||
evidenceBundleOption,
|
||||
runEvidenceInputCommand,
|
||||
} from "./evidenceInputCommand.js";
|
||||
import { localPathStringSchema } from "../domain/localPath.js";
|
||||
|
||||
/** Register CLI equivalents of the Electron MCP tools. */
|
||||
export const registerElectronCommands = (
|
||||
@@ -69,9 +65,11 @@ const registerElectronFuseCommand = (
|
||||
description:
|
||||
"Inspect packaged Electron fuse bytes without launching the application",
|
||||
args: z.object({
|
||||
path: nulFreeStringSchema.describe(
|
||||
"Local Electron .app, Electron Framework binary, PE executable, or ELF executable",
|
||||
),
|
||||
path: z
|
||||
.string()
|
||||
.describe(
|
||||
"Local Electron .app, Electron Framework binary, PE executable, or ELF executable",
|
||||
),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { fridaCapturePolicyInputSchema } from "../domain/fridaCapturePolicy.js";
|
||||
import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
|
||||
import { z } from "incur";
|
||||
import { localPathStringSchema } from "../domain/localPath.js";
|
||||
|
||||
import { FridaInstrumentationService } from "../application/frida/FridaInstrumentationService.js";
|
||||
import type {
|
||||
@@ -167,7 +166,9 @@ export const registerFridaCommands = (
|
||||
.string()
|
||||
.optional()
|
||||
.describe(`Inline JavaScript to load. ${exactlyOneScriptSource}`),
|
||||
scriptFile: localPathStringSchema
|
||||
scriptFile: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
`Path to JavaScript source file. ${exactlyOneScriptSource}`,
|
||||
|
||||
+30
-43
@@ -27,49 +27,36 @@ export const registerIl2cppCommands = (
|
||||
"Local global-metadata.dat (version 24, 27, 29, 31 or 39)",
|
||||
),
|
||||
}),
|
||||
options: z
|
||||
.object({
|
||||
...analysisEvidenceDeliveryOptions,
|
||||
binary: localPathStringSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"Matching native binary (Mach-O GameAssembly.dylib, PE GameAssembly.dll or ELF libil2cpp.so) to link method addresses; read only",
|
||||
),
|
||||
architecture: z
|
||||
.enum(["arm64", "amd64"])
|
||||
.optional()
|
||||
.describe("Slice of a universal Mach-O binary"),
|
||||
genericInstances: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Also link generic method instances (Type<Args>$$Method<Args>) and include them in --ghidra-seeds; needs --binary",
|
||||
),
|
||||
ghidraSeeds: localPathStringSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"Also write REA_GHIDRA_SEED_FILE function seeds named Type$$Method for every linked method; needs --binary",
|
||||
),
|
||||
seedBase: z
|
||||
.string()
|
||||
.regex(/^0x[0-9a-fA-F]{1,16}$/u)
|
||||
.optional()
|
||||
.describe(
|
||||
"Address Ghidra loads the binary at; seeds are seed-base + RVA (default: Ghidra's loader default, 0x100000 for an ELF linked at 0, else the link base)",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
dependentRequired: {
|
||||
architecture: ["binary"],
|
||||
ghidraSeeds: ["binary"],
|
||||
seedBase: ["ghidraSeeds"],
|
||||
},
|
||||
if: {
|
||||
properties: { genericInstances: { const: true } },
|
||||
required: ["genericInstances"],
|
||||
},
|
||||
then: { required: ["binary"] },
|
||||
}),
|
||||
options: z.object({
|
||||
...analysisEvidenceDeliveryOptions,
|
||||
binary: localPathStringSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"Matching native binary (Mach-O GameAssembly.dylib, PE GameAssembly.dll or ELF libil2cpp.so) to link method addresses; read only",
|
||||
),
|
||||
architecture: z
|
||||
.enum(["arm64", "amd64"])
|
||||
.optional()
|
||||
.describe("Slice of a universal Mach-O binary"),
|
||||
genericInstances: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Also link generic method instances (Type<Args>$$Method<Args>) and include them in --ghidra-seeds; needs --binary",
|
||||
),
|
||||
ghidraSeeds: localPathStringSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"Also write REA_GHIDRA_SEED_FILE function seeds named Type$$Method for every linked method; needs --binary",
|
||||
),
|
||||
seedBase: z
|
||||
.string()
|
||||
.regex(/^0x[0-9a-fA-F]{1,16}$/u)
|
||||
.optional()
|
||||
.describe(
|
||||
"Address Ghidra loads the binary at; seeds are seed-base + RVA (default: Ghidra's loader default, 0x100000 for an ELF linked at 0, else the link base)",
|
||||
),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.inspectIl2cppMetadata, async () => {
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
|
||||
import type { CliInstance } from "./types.js";
|
||||
import { parseNativeMatchingAllowance } from "../config/nativeMatchingAllowance.js";
|
||||
import { localPathStringSchema } from "../domain/localPath.js";
|
||||
|
||||
/** Resolve portable input bundles, then use the same read-only matching service as MCP. */
|
||||
export const registerNativeFunctionMatchingCommands = (
|
||||
@@ -38,12 +37,14 @@ export const registerNativeFunctionMatchingCommands = (
|
||||
}),
|
||||
options: z.object({
|
||||
source_bundle: z
|
||||
.array(localPathStringSchema)
|
||||
.array(z.string())
|
||||
.default([])
|
||||
.describe(
|
||||
"Canonical source Evidence bundle for retained references; repeat for each input",
|
||||
),
|
||||
evidence_output: localPathStringSchema
|
||||
evidence_output: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
"Save full matching Evidence and selected sources; required for default summary; never overwrites",
|
||||
|
||||
@@ -33,13 +33,11 @@ const withOmittableDefaults = (schema: z.ZodObject): z.ZodObject => {
|
||||
fillsOmission(field),
|
||||
);
|
||||
if (omittable.length === 0) return schema;
|
||||
const metadata = schema.meta();
|
||||
const result = schema.safeExtend(
|
||||
return schema.safeExtend(
|
||||
Object.fromEntries(
|
||||
omittable.map(([key, field]) => [key, optionalWithMetadata(field)]),
|
||||
),
|
||||
);
|
||||
return metadata === undefined ? result : result.meta(metadata);
|
||||
};
|
||||
|
||||
/** Let the object parser decide omission; Incur advertises required outputs. */
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
|
||||
import { z } from "incur";
|
||||
import { localPathStringSchema } from "../domain/localPath.js";
|
||||
import { PcapAnalysisService } from "../application/pcap/PcapAnalysisService.js";
|
||||
import type { PcapAnalysisPort } from "../application/pcap/PcapAnalysisPort.js";
|
||||
import { createPcapAnalysisProvider } from "../composition/pcap.js";
|
||||
@@ -54,9 +53,10 @@ export const registerPcapCommands = (
|
||||
description:
|
||||
"Inspect one pcap/pcapng capture: endpoints, DNS, TLS SNI, and protocol distribution",
|
||||
args: z.object({
|
||||
path: localPathStringSchema.describe(
|
||||
"Local pcap or pcapng capture file to inspect",
|
||||
),
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Local pcap or pcapng capture file to inspect"),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
execute(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { z } from "incur";
|
||||
import { localPathStringSchema } from "../domain/localPath.js";
|
||||
import type { Logger } from "pino";
|
||||
import { OBJDUMP_PROVIDER_IDENTITY } from "../objdump/ObjdumpProviderIdentity.js";
|
||||
import { RIZIN_PROVIDER_IDENTITY } from "../rizin/RizinProviderIdentity.js";
|
||||
@@ -152,9 +151,10 @@ export const registerReverseEngineeringCommands = (
|
||||
description:
|
||||
"Inspect a local artifact using GNU-style objdump arguments and retain the selected command output as Evidence",
|
||||
args: z.object({
|
||||
path: localPathStringSchema.describe(
|
||||
"Path to the local artifact to inspect.",
|
||||
),
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Path to the local artifact to inspect."),
|
||||
operation: z
|
||||
.enum(OBJDUMP_ARTIFACT_OPERATIONS)
|
||||
.describe("GNU-style objdump operation to run."),
|
||||
@@ -274,9 +274,10 @@ export const registerReverseEngineeringCommands = (
|
||||
cli.command(CLI_COMMANDS.executeRizinCommand, {
|
||||
description: "Execute one Rizin command against a local artifact",
|
||||
args: z.object({
|
||||
path: localPathStringSchema.describe(
|
||||
"Path to the local artifact to analyze.",
|
||||
),
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Path to the local artifact to analyze."),
|
||||
command: z.string().min(1).describe("One caller-selected Rizin command."),
|
||||
}),
|
||||
options: z.object({
|
||||
@@ -406,9 +407,7 @@ export const registerReverseEngineeringCommands = (
|
||||
description:
|
||||
"Run one unrestricted Rizin debugger command in an ephemeral session",
|
||||
args: z.object({
|
||||
path: localPathStringSchema.describe(
|
||||
"Path to the local artifact to debug.",
|
||||
),
|
||||
path: z.string().min(1).describe("Path to the local artifact to debug."),
|
||||
command: z
|
||||
.string()
|
||||
.min(1)
|
||||
|
||||
@@ -61,12 +61,6 @@ export const registerSignalCommands = (
|
||||
path: ["all"],
|
||||
message: "all and limit are mutually exclusive",
|
||||
});
|
||||
})
|
||||
.meta({
|
||||
not: {
|
||||
required: ["all", "limit"],
|
||||
properties: { all: { const: true } },
|
||||
},
|
||||
}),
|
||||
run: ({ options: { all, producerLimits, ...options } }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
@@ -127,13 +121,7 @@ export const registerSignalCommands = (
|
||||
.refine(
|
||||
withinSignalRecordingSelectorBudget,
|
||||
signalRecordingSelectorBudgetIssue,
|
||||
)
|
||||
.meta({
|
||||
not: {
|
||||
required: ["all_lines", "line_limit"],
|
||||
properties: { all_lines: { const: true } },
|
||||
},
|
||||
}),
|
||||
),
|
||||
run: ({ args, options: { all_lines, producerLimits, ...options } }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.decodeSignalRecording, async () => {
|
||||
|
||||
+42
-38
@@ -347,45 +347,29 @@ const registerReferenceSourceCommand = (
|
||||
cli.command(CLI_COMMANDS.importReferenceSource, {
|
||||
description: "Import a source tree as historical reference only",
|
||||
args: z.object({
|
||||
root: localPathStringSchema.describe(
|
||||
"Local source directory to import as historical reference",
|
||||
),
|
||||
root: z
|
||||
.string()
|
||||
.describe("Local source directory to import as historical reference"),
|
||||
}),
|
||||
options: z.object({
|
||||
excludePaths: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"JSON array of Git-ignore rules to exclude. Rules use Git-ignore syntax, including wildcards, directory suffixes, and negations; they are not literal path matches. An empty array adds no caller exclusions.",
|
||||
),
|
||||
detail: z
|
||||
.enum(["full", "summary"])
|
||||
.default("full")
|
||||
.describe(
|
||||
"Complete committed graph or compact historical-source summary",
|
||||
),
|
||||
outlineDepth: cliIntegerArgument(0)
|
||||
.optional()
|
||||
.describe(
|
||||
"Summary outline path depth (default: 2; 0 omits entries); requires --detail summary",
|
||||
),
|
||||
}),
|
||||
options: z
|
||||
.object({
|
||||
excludePaths: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"JSON array of Git-ignore rules to exclude. Rules use Git-ignore syntax, including wildcards, directory suffixes, and negations; they are not literal path matches. An empty array adds no caller exclusions.",
|
||||
),
|
||||
detail: z
|
||||
.enum(["full", "summary"])
|
||||
.default("full")
|
||||
.describe(
|
||||
"Complete committed graph or compact historical-source summary",
|
||||
),
|
||||
outlineDepth: cliIntegerArgument(0)
|
||||
.optional()
|
||||
.describe(
|
||||
"Summary outline path depth (default: 2; 0 omits entries); requires --detail summary",
|
||||
),
|
||||
})
|
||||
.superRefine((options, context) => {
|
||||
if (options.outlineDepth !== undefined && options.detail !== "summary")
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["outlineDepth"],
|
||||
message: "--outline-depth requires --detail summary",
|
||||
});
|
||||
})
|
||||
.meta({
|
||||
if: { required: ["outlineDepth"] },
|
||||
then: {
|
||||
required: ["detail"],
|
||||
properties: { detail: { const: "summary" } },
|
||||
},
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(
|
||||
logger,
|
||||
@@ -395,6 +379,26 @@ const registerReferenceSourceCommand = (
|
||||
options.excludePaths,
|
||||
);
|
||||
if (!excludePaths.ok) return excludePaths.failure;
|
||||
if (
|
||||
options.outlineDepth !== undefined &&
|
||||
options.detail !== "summary"
|
||||
)
|
||||
return {
|
||||
error: "Import failed",
|
||||
...projectAnalysisError(
|
||||
new AnalysisInputError(
|
||||
"import-reference-source",
|
||||
{ userMessage: "--outline-depth requires --detail summary" },
|
||||
[
|
||||
{
|
||||
path: ["outlineDepth"],
|
||||
reason: "invalid_value",
|
||||
message: "--outline-depth requires --detail summary",
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
};
|
||||
const config = parseConfig(environment);
|
||||
if (!config.ok)
|
||||
return {
|
||||
|
||||
@@ -68,321 +68,3 @@ it.each(["draft-2020-12", "draft-07"] as const)(
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
const referenceWideningCases: readonly {
|
||||
name: string;
|
||||
schema: Record<string, unknown>;
|
||||
input: unknown;
|
||||
unchanged?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
name: "oneOf",
|
||||
schema: {
|
||||
definitions: { off: { const: false } },
|
||||
properties: { value: { oneOf: [{ $ref: "#/definitions/off" }, {}] } },
|
||||
},
|
||||
input: { value: true },
|
||||
},
|
||||
{
|
||||
name: "not",
|
||||
schema: {
|
||||
definitions: { off: { const: false } },
|
||||
properties: { value: { not: { $ref: "#/definitions/off" } } },
|
||||
},
|
||||
input: { value: true },
|
||||
},
|
||||
{
|
||||
name: "if",
|
||||
schema: {
|
||||
definitions: { off: { const: false } },
|
||||
properties: {
|
||||
value: { if: { $ref: "#/definitions/off" }, then: false, else: true },
|
||||
},
|
||||
},
|
||||
input: { value: true },
|
||||
},
|
||||
{
|
||||
name: "chain",
|
||||
schema: {
|
||||
definitions: {
|
||||
off: { const: false },
|
||||
alias: { $ref: "#/definitions/off" },
|
||||
},
|
||||
properties: { value: { not: { $ref: "#/definitions/alias" } } },
|
||||
},
|
||||
input: { value: true },
|
||||
},
|
||||
{
|
||||
name: "recursive affected",
|
||||
schema: {
|
||||
definitions: {
|
||||
node: {
|
||||
type: "object",
|
||||
properties: {
|
||||
flag: { const: false },
|
||||
next: { $ref: "#/definitions/node" },
|
||||
},
|
||||
required: ["flag"],
|
||||
},
|
||||
},
|
||||
properties: { value: { not: { $ref: "#/definitions/node" } } },
|
||||
},
|
||||
input: { value: { flag: true } },
|
||||
},
|
||||
{
|
||||
name: "literal target unchanged",
|
||||
schema: {
|
||||
default: { predicate: { const: false } },
|
||||
properties: { value: { not: { $ref: "#/default/predicate" } } },
|
||||
},
|
||||
input: { value: true },
|
||||
unchanged: true,
|
||||
},
|
||||
{
|
||||
name: "nested resource unaffected",
|
||||
schema: {
|
||||
$id: "https://example.test/root",
|
||||
definitions: { flag: { const: false } },
|
||||
properties: {
|
||||
value: {
|
||||
$id: "child",
|
||||
definitions: { flag: { const: 7 } },
|
||||
not: { $ref: "#/definitions/flag" },
|
||||
},
|
||||
},
|
||||
},
|
||||
input: { value: 8 },
|
||||
unchanged: true,
|
||||
},
|
||||
{
|
||||
name: "recursive unaffected",
|
||||
schema: {
|
||||
definitions: {
|
||||
node: {
|
||||
type: "object",
|
||||
properties: {
|
||||
flag: { const: 7 },
|
||||
next: { $ref: "#/definitions/node" },
|
||||
},
|
||||
required: ["flag"],
|
||||
},
|
||||
},
|
||||
properties: { value: { not: { $ref: "#/definitions/node" } } },
|
||||
},
|
||||
input: { value: { flag: 8 } },
|
||||
unchanged: true,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(
|
||||
referenceWideningCases.flatMap((entry) =>
|
||||
(["draft-2020-12", "draft-07"] as const).map((dialect) => ({
|
||||
...entry,
|
||||
dialect,
|
||||
})),
|
||||
),
|
||||
)(
|
||||
"preserves $name through scoped normalization references in $dialect",
|
||||
({ name, schema, input, unchanged, dialect }) => {
|
||||
const canonical: Record<string, unknown> = {
|
||||
$schema:
|
||||
dialect === "draft-07"
|
||||
? "http://json-schema.org/draft-07/schema#"
|
||||
: "https://json-schema.org/draft/2020-12/schema",
|
||||
type: "object",
|
||||
...schema,
|
||||
};
|
||||
const advertised = presentInputJsonSchema(canonical, () => undefined);
|
||||
const create = () =>
|
||||
dialect === "draft-07"
|
||||
? new Ajv({ strict: false })
|
||||
: new Ajv2020({ strict: false });
|
||||
expect(create().compile(canonical)(input), `${name} canonical`).toBe(true);
|
||||
expect(create().compile(advertised)(input), `${name} advertised`).toBe(
|
||||
true,
|
||||
);
|
||||
expect(advertised.default, `${name} literal data`).toEqual(
|
||||
canonical.default,
|
||||
);
|
||||
if (unchanged)
|
||||
expect(advertised.properties, name).toEqual(canonical.properties);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps an upper contains bound from rejecting canonical items after referenced literal widening", () => {
|
||||
const canonical = {
|
||||
type: "object",
|
||||
definitions: { off: { const: false } },
|
||||
properties: {
|
||||
values: {
|
||||
type: "array",
|
||||
contains: { $ref: "#/definitions/off" },
|
||||
minContains: 0,
|
||||
maxContains: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
const advertised = presentInputJsonSchema(canonical, () => undefined);
|
||||
const input = { values: [false, true] };
|
||||
expect(new Ajv2020({ strict: false }).compile(canonical)(input)).toBe(true);
|
||||
expect(new Ajv2020({ strict: false }).compile(advertised)(input)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"preserves conditional evaluation annotations (referenced: %s)",
|
||||
(referenced) => {
|
||||
const conditional = {
|
||||
if: { properties: { flag: { const: false } } },
|
||||
then: { properties: { extra: { type: "string" } } },
|
||||
};
|
||||
const canonical = {
|
||||
type: "object",
|
||||
definitions: { conditional },
|
||||
...(referenced ? { $ref: "#/definitions/conditional" } : conditional),
|
||||
unevaluatedProperties: false,
|
||||
};
|
||||
const advertised = presentInputJsonSchema(canonical, () => undefined);
|
||||
const input = { flag: false, extra: "kept" };
|
||||
expect(new Ajv2020({ strict: false }).compile(canonical)(input)).toBe(true);
|
||||
expect(new Ajv2020({ strict: false }).compile(advertised)(input)).toBe(
|
||||
true,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("retains item annotations when only the unsafe contains maximum leaves", () => {
|
||||
const canonical = {
|
||||
type: "object",
|
||||
properties: {
|
||||
values: {
|
||||
type: "array",
|
||||
contains: { const: false },
|
||||
maxContains: 1,
|
||||
unevaluatedItems: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
const advertised = presentInputJsonSchema(canonical, () => undefined);
|
||||
const input = { values: [false] };
|
||||
expect(new Ajv2020({ strict: false }).compile(canonical)(input)).toBe(true);
|
||||
expect(new Ajv2020({ strict: false }).compile(advertised)(input)).toBe(true);
|
||||
expect(advertised).toHaveProperty("properties.values.contains", {
|
||||
type: "boolean",
|
||||
});
|
||||
expect(advertised).toHaveProperty(
|
||||
"properties.values.unevaluatedItems",
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps unaffected root property annotations strict when a child conditional widens", () => {
|
||||
const canonical = {
|
||||
type: "object",
|
||||
properties: {
|
||||
child: {
|
||||
if: { const: false },
|
||||
then: { type: "string" },
|
||||
else: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
unevaluatedProperties: false,
|
||||
};
|
||||
const advertised = presentInputJsonSchema(canonical, () => undefined);
|
||||
const validate = new Ajv2020({ strict: false }).compile(advertised);
|
||||
expect(validate({ child: true })).toBe(true);
|
||||
expect(validate({ child: true, unknown: "reject" })).toBe(false);
|
||||
expect(advertised.unevaluatedProperties).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["draft-07", "draft-2020-12"] as const)(
|
||||
"observes reference sibling semantics in %s",
|
||||
(dialect) => {
|
||||
const canonical = {
|
||||
$schema:
|
||||
dialect === "draft-07"
|
||||
? "http://json-schema.org/draft-07/schema#"
|
||||
: "https://json-schema.org/draft/2020-12/schema",
|
||||
type: "object",
|
||||
definitions: {
|
||||
number: { type: "number" },
|
||||
alias: { $ref: "#/definitions/number", const: false },
|
||||
},
|
||||
properties: { value: { not: { $ref: "#/definitions/alias" } } },
|
||||
};
|
||||
const advertised = presentInputJsonSchema(canonical, () => undefined);
|
||||
const create = () =>
|
||||
dialect === "draft-07"
|
||||
? new Ajv({ strict: false, ignoreKeywordsWithRef: true })
|
||||
: new Ajv2020({ strict: false });
|
||||
// Under the specification's draft7 rules alias is number; in2020 it accepts no value.
|
||||
const input = { value: "kept" };
|
||||
expect(create().compile(canonical)(input)).toBe(true);
|
||||
expect(create().compile(advertised)(input)).toBe(true);
|
||||
if (dialect === "draft-07")
|
||||
expect(advertised.properties).toEqual(canonical.properties);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["draft-07", "draft-2020-12"] as const)(
|
||||
"reduces only an affected reference to opaque data in %s",
|
||||
(dialect) => {
|
||||
const literal = {
|
||||
conditional: {
|
||||
if: { $ref: "#/definitions/off" },
|
||||
then: false,
|
||||
else: true,
|
||||
},
|
||||
unaffected: { type: "string", minLength: 2 },
|
||||
};
|
||||
const canonical = {
|
||||
$schema:
|
||||
dialect === "draft-07"
|
||||
? "http://json-schema.org/draft-07/schema#"
|
||||
: "https://json-schema.org/draft/2020-12/schema",
|
||||
type: "object",
|
||||
definitions: { off: { const: false } },
|
||||
default: literal,
|
||||
properties: {
|
||||
value: { $ref: "#/default/conditional" },
|
||||
stable: { $ref: "#/default/unaffected" },
|
||||
},
|
||||
unevaluatedProperties: false,
|
||||
};
|
||||
const advertised = presentInputJsonSchema(canonical, () => undefined);
|
||||
const create = () =>
|
||||
dialect === "draft-07"
|
||||
? new Ajv({ strict: false })
|
||||
: new Ajv2020({ strict: false });
|
||||
const input = { value: true, stable: "kept" };
|
||||
expect(create().compile(canonical)(input)).toBe(true);
|
||||
const validate = create().compile(advertised);
|
||||
expect(validate(input)).toBe(true);
|
||||
expect(validate({ value: true, stable: 1 })).toBe(false);
|
||||
expect(advertised.default).toEqual(literal);
|
||||
expect(advertised).toHaveProperty(
|
||||
"properties.stable",
|
||||
canonical.properties.stable,
|
||||
);
|
||||
expect(advertised).toHaveProperty(
|
||||
"properties.value.description",
|
||||
expect.stringContaining("reduced form"),
|
||||
);
|
||||
expect(advertised.unevaluatedProperties).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not treat unused definitions as validation dependencies", () => {
|
||||
const canonical = {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: {
|
||||
not: { type: "number", definitions: { unused: { const: false } } },
|
||||
},
|
||||
},
|
||||
};
|
||||
const advertised = presentInputJsonSchema(canonical, () => undefined);
|
||||
expect(advertised).toHaveProperty("properties.value.not.type", "number");
|
||||
const validate = new Ajv2020({ strict: false }).compile(advertised);
|
||||
expect(validate({ value: "kept" })).toBe(true);
|
||||
expect(validate({ value: 1 })).toBe(false);
|
||||
});
|
||||
@@ -196,194 +196,25 @@ const preserveSharedDefault = (
|
||||
schema.default = defaults[0];
|
||||
};
|
||||
|
||||
/** Schema children, excluding definitions when following validation dependencies. */
|
||||
const normalizationChildren = (
|
||||
schema: Readonly<Record<string, unknown>>,
|
||||
definitions: boolean,
|
||||
): unknown[] =>
|
||||
Object.entries(schema).flatMap(([key, value]) => {
|
||||
if (schemaMaps.has(key) && isObject(value))
|
||||
return !definitions && (key === "$defs" || key === "definitions")
|
||||
? []
|
||||
: Object.values(value);
|
||||
if (schemaArrays.has(key) && Array.isArray(value)) return value;
|
||||
if (schemaChildren.has(key)) return Array.isArray(value) ? value : [value];
|
||||
return [];
|
||||
});
|
||||
|
||||
const hasNormalizedLiteral = (schema: Readonly<Record<string, unknown>>) =>
|
||||
typeof schema.const === "boolean" ||
|
||||
schema.const === "" ||
|
||||
(Array.isArray(schema.enum) &&
|
||||
((schema.enum.length > 0 &&
|
||||
schema.enum.every((item) => typeof item === "boolean")) ||
|
||||
schema.enum.includes("")));
|
||||
|
||||
/** One projection owns actual rewrite sites and their scoped validation dependencies. */
|
||||
const normalizationContext = (root: Readonly<Record<string, unknown>>) => {
|
||||
const references = new JsonSchemaReferences(root);
|
||||
type Fact = {
|
||||
changed: boolean;
|
||||
lostEvaluation: boolean;
|
||||
dependents: Set<Fact>;
|
||||
evaluationDependents: Set<Fact>;
|
||||
opaqueReference?: Fact;
|
||||
};
|
||||
const sites = new Map<JsonSchemaScope | undefined, WeakSet<object>>();
|
||||
const pending: { schema: unknown; scope: JsonSchemaScope | undefined }[] = [
|
||||
{ schema: root, scope: references.rootScope },
|
||||
];
|
||||
const entries: {
|
||||
schema: Readonly<Record<string, unknown>>;
|
||||
scope: JsonSchemaScope | undefined;
|
||||
}[] = [];
|
||||
// Literal payloads are not rewritten, even when a reference evaluates them.
|
||||
while (pending.length > 0) {
|
||||
const entry = pending.pop()!;
|
||||
if (!isObject(entry.schema)) continue;
|
||||
const scope = references.enter(entry.schema, entry.scope);
|
||||
let seen = sites.get(scope);
|
||||
if (seen === undefined) sites.set(scope, (seen = new WeakSet()));
|
||||
if (seen.has(entry.schema)) continue;
|
||||
seen.add(entry.schema);
|
||||
entries.push({ schema: entry.schema, scope });
|
||||
for (const schema of normalizationChildren(entry.schema, true))
|
||||
pending.push({ schema, scope });
|
||||
}
|
||||
const indexed = new Map<JsonSchemaScope | undefined, WeakMap<object, Fact>>();
|
||||
const work: {
|
||||
schema: Readonly<Record<string, unknown>>;
|
||||
scope: JsonSchemaScope | undefined;
|
||||
fact: Fact;
|
||||
}[] = [];
|
||||
const changed: Fact[] = [];
|
||||
const factAt = (
|
||||
schema: unknown,
|
||||
inherited: JsonSchemaScope | undefined,
|
||||
): Fact | undefined => {
|
||||
if (!isObject(schema)) return undefined;
|
||||
const scope = references.enter(schema, inherited);
|
||||
let facts = indexed.get(scope);
|
||||
if (facts === undefined) indexed.set(scope, (facts = new WeakMap()));
|
||||
const existing = facts.get(schema);
|
||||
if (existing !== undefined) return existing;
|
||||
const fact: Fact = {
|
||||
changed: false,
|
||||
lostEvaluation: false,
|
||||
dependents: new Set(),
|
||||
evaluationDependents: new Set(),
|
||||
};
|
||||
facts.set(schema, fact);
|
||||
work.push({ schema, scope, fact });
|
||||
return fact;
|
||||
};
|
||||
for (const { schema, scope } of entries) factAt(schema, scope);
|
||||
for (let index = 0; index < work.length; index++) {
|
||||
const { schema, scope, fact } = work[index]!;
|
||||
const ignoresSiblings =
|
||||
scope?.dialect === "draft-07" && typeof schema.$ref === "string";
|
||||
if (
|
||||
!ignoresSiblings &&
|
||||
sites.get(scope)?.has(schema) &&
|
||||
hasNormalizedLiteral(schema)
|
||||
) {
|
||||
fact.changed = true;
|
||||
changed.push(fact);
|
||||
}
|
||||
if (typeof schema.$ref === "string") {
|
||||
const target = references.resolve(scope, schema.$ref);
|
||||
if (target.kind === "target") {
|
||||
const dependency = factAt(target.value, scope);
|
||||
dependency?.dependents.add(fact);
|
||||
dependency?.evaluationDependents.add(fact);
|
||||
if (
|
||||
dependency !== undefined &&
|
||||
isObject(target.value) &&
|
||||
!sites.get(references.enter(target.value, scope))?.has(target.value)
|
||||
)
|
||||
fact.opaqueReference = dependency;
|
||||
}
|
||||
}
|
||||
if (!ignoresSiblings)
|
||||
for (const child of normalizationChildren(schema, false))
|
||||
factAt(child, scope)?.dependents.add(fact);
|
||||
if (!ignoresSiblings)
|
||||
for (const key of [
|
||||
"allOf",
|
||||
"anyOf",
|
||||
"oneOf",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
"dependentSchemas",
|
||||
"dependencies",
|
||||
]) {
|
||||
const value = schema[key];
|
||||
const children = Array.isArray(value)
|
||||
? value
|
||||
: schemaMaps.has(key) && isObject(value)
|
||||
? Object.values(value)
|
||||
: [value];
|
||||
for (const child of children)
|
||||
factAt(child, scope)?.evaluationDependents.add(fact);
|
||||
}
|
||||
}
|
||||
for (let index = 0; index < changed.length; index++)
|
||||
for (const dependent of changed[index]!.dependents)
|
||||
if (!dependent.changed) {
|
||||
dependent.changed = true;
|
||||
changed.push(dependent);
|
||||
}
|
||||
const lost: Fact[] = [];
|
||||
for (const { schema, scope, fact } of work)
|
||||
if (
|
||||
sites.get(scope)?.has(schema) &&
|
||||
(fact.opaqueReference?.changed ||
|
||||
(!(scope?.dialect === "draft-07" && typeof schema.$ref === "string") &&
|
||||
factAt(schema.if, scope)?.changed))
|
||||
) {
|
||||
fact.lostEvaluation = true;
|
||||
lost.push(fact);
|
||||
}
|
||||
for (let index = 0; index < lost.length; index++)
|
||||
for (const dependent of lost[index]!.evaluationDependents)
|
||||
if (!dependent.lostEvaluation) {
|
||||
dependent.lostEvaluation = true;
|
||||
lost.push(dependent);
|
||||
}
|
||||
return {
|
||||
references,
|
||||
changed: (schema: object, scope: JsonSchemaScope | undefined) =>
|
||||
indexed.get(scope)?.get(schema)?.changed === true,
|
||||
lostEvaluation: (schema: object, scope: JsonSchemaScope | undefined) =>
|
||||
indexed.get(scope)?.get(schema)?.lostEvaluation === true,
|
||||
reduceReference: (schema: object, scope: JsonSchemaScope | undefined) =>
|
||||
indexed.get(scope)?.get(schema)?.opaqueReference?.changed === true,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAdvertisedSchema = (
|
||||
value: unknown,
|
||||
context?: ReturnType<typeof normalizationContext>,
|
||||
inherited?: JsonSchemaScope,
|
||||
): SchemaNormalization => {
|
||||
const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => {
|
||||
if (!isObject(value)) return { schema: value, changed: false };
|
||||
const owner = context ?? normalizationContext(value);
|
||||
const scope = owner.references.enter(
|
||||
value,
|
||||
context === undefined ? owner.references.rootScope : inherited,
|
||||
);
|
||||
const normalize = (child: unknown) =>
|
||||
normalizeAdvertisedSchema(child, owner, scope);
|
||||
|
||||
const node: Record<string, unknown> = { ...value };
|
||||
const changed = owner.changed(value, scope);
|
||||
let changed = false;
|
||||
const normalizeMap = (map: Readonly<Record<string, unknown>>) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(map).map(([name, child]) => [
|
||||
name,
|
||||
normalize(child).schema,
|
||||
]),
|
||||
Object.entries(map).map(([name, child]) => {
|
||||
const result = normalizeAdvertisedSchema(child);
|
||||
changed ||= result.changed;
|
||||
return [name, result.schema];
|
||||
}),
|
||||
);
|
||||
const normalizeList = (list: readonly unknown[]) =>
|
||||
list.map((child) => {
|
||||
const result = normalizeAdvertisedSchema(child);
|
||||
changed ||= result.changed;
|
||||
return result;
|
||||
});
|
||||
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (schemaMaps.has(key) && isObject(child)) {
|
||||
@@ -394,7 +225,7 @@ const normalizeAdvertisedSchema = (
|
||||
key !== "oneOf" &&
|
||||
Array.isArray(child)
|
||||
) {
|
||||
node[key] = child.map(normalize).map((result) => result.schema);
|
||||
node[key] = normalizeList(child).map((result) => result.schema);
|
||||
} else if (
|
||||
schemaChildren.has(key) &&
|
||||
key !== "not" &&
|
||||
@@ -403,20 +234,23 @@ const normalizeAdvertisedSchema = (
|
||||
key !== "else"
|
||||
) {
|
||||
const results = Array.isArray(child)
|
||||
? child.map(normalize)
|
||||
: [normalize(child)];
|
||||
? normalizeList(child)
|
||||
: [normalizeAdvertisedSchema(child)];
|
||||
const nestedChanged = results.some((result) => result.changed);
|
||||
if (
|
||||
key === "contains" &&
|
||||
nestedChanged &&
|
||||
value.maxContains !== undefined
|
||||
) {
|
||||
delete node.contains;
|
||||
delete node.minContains;
|
||||
delete node.maxContains;
|
||||
node.contains = results[0]?.schema;
|
||||
changed = true;
|
||||
} else {
|
||||
node[key] = Array.isArray(child)
|
||||
? results.map((result) => result.schema)
|
||||
: results[0]?.schema;
|
||||
changed ||= nestedChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -424,10 +258,12 @@ const normalizeAdvertisedSchema = (
|
||||
if (typeof node.const === "boolean") {
|
||||
delete node.const;
|
||||
if (node.type === undefined) node.type = "boolean";
|
||||
changed = true;
|
||||
}
|
||||
if (node.const === "") {
|
||||
delete node.const;
|
||||
if (node.type === undefined) node.type = "string";
|
||||
changed = true;
|
||||
}
|
||||
if (
|
||||
Array.isArray(node.enum) &&
|
||||
@@ -436,6 +272,7 @@ const normalizeAdvertisedSchema = (
|
||||
) {
|
||||
delete node.enum;
|
||||
if (node.type === undefined) node.type = "boolean";
|
||||
changed = true;
|
||||
}
|
||||
if (Array.isArray(node.enum) && node.enum.includes("")) {
|
||||
// Removing only the empty member would reject a canonical-valid input.
|
||||
@@ -447,29 +284,32 @@ const normalizeAdvertisedSchema = (
|
||||
values.every((item) => typeof item === "string")
|
||||
)
|
||||
node.type = "string";
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const negated = Object.hasOwn(value, "not")
|
||||
? normalize(value.not)
|
||||
? normalizeAdvertisedSchema(value.not)
|
||||
: undefined;
|
||||
if (negated?.changed) {
|
||||
// Widening a predicate under `not` narrows the containing schema.
|
||||
changed = true;
|
||||
delete node.not;
|
||||
} else if (negated !== undefined) {
|
||||
node.not = negated.schema;
|
||||
}
|
||||
|
||||
const conditional = Object.hasOwn(value, "if")
|
||||
? normalize(value.if)
|
||||
? normalizeAdvertisedSchema(value.if)
|
||||
: undefined;
|
||||
const thenBranch = Object.hasOwn(value, "then")
|
||||
? normalize(value.then)
|
||||
? normalizeAdvertisedSchema(value.then)
|
||||
: undefined;
|
||||
const elseBranch = Object.hasOwn(value, "else")
|
||||
? normalize(value.else)
|
||||
? normalizeAdvertisedSchema(value.else)
|
||||
: undefined;
|
||||
if (conditional?.changed) {
|
||||
// Changing the predicate can route canonical-valid inputs into a rejecting branch.
|
||||
changed = true;
|
||||
delete node.if;
|
||||
delete node.then;
|
||||
delete node.else;
|
||||
@@ -477,16 +317,18 @@ const normalizeAdvertisedSchema = (
|
||||
if (conditional !== undefined) node.if = conditional.schema;
|
||||
if (thenBranch !== undefined) {
|
||||
node.then = thenBranch.schema;
|
||||
changed ||= thenBranch.changed;
|
||||
}
|
||||
if (elseBranch !== undefined) {
|
||||
node.else = elseBranch.schema;
|
||||
changed ||= elseBranch.changed;
|
||||
}
|
||||
}
|
||||
|
||||
for (const combinator of ["anyOf", "oneOf"] as const) {
|
||||
const branches = value[combinator];
|
||||
if (!Array.isArray(branches)) continue;
|
||||
const normalized = branches.map(normalize);
|
||||
const normalized = normalizeList(branches);
|
||||
const schemas = normalized.map((result) => result.schema);
|
||||
const branchesChanged = normalized.some((result) => result.changed);
|
||||
if (combinator === "oneOf" && branchesChanged) {
|
||||
@@ -495,6 +337,7 @@ const normalizeAdvertisedSchema = (
|
||||
node.type = "boolean";
|
||||
preserveSharedDefault(node, schemas);
|
||||
delete node.oneOf;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
// `anyOf` retains branch guidance without allowing widened branches to
|
||||
@@ -506,6 +349,7 @@ const normalizeAdvertisedSchema = (
|
||||
} else {
|
||||
node.anyOf = schemas;
|
||||
}
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
@@ -516,43 +360,13 @@ const normalizeAdvertisedSchema = (
|
||||
node.type = "boolean";
|
||||
preserveSharedDefault(node, schemas);
|
||||
delete node[combinator];
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
node[combinator] = schemas;
|
||||
changed ||= branchesChanged;
|
||||
}
|
||||
|
||||
if (owner.lostEvaluation(value, scope)) {
|
||||
delete node.unevaluatedProperties;
|
||||
delete node.unevaluatedItems;
|
||||
}
|
||||
if (owner.reduceReference(value, scope)) {
|
||||
// The target is also caller literal data. Keep that data intact and relax
|
||||
// this affected reference rather than copy or mutate its executable view.
|
||||
if (scope?.dialect === "draft-07") {
|
||||
for (const key of Object.keys(node))
|
||||
if (
|
||||
![
|
||||
"$id",
|
||||
"$schema",
|
||||
"$anchor",
|
||||
"$defs",
|
||||
"definitions",
|
||||
"description",
|
||||
"title",
|
||||
"default",
|
||||
"examples",
|
||||
"$comment",
|
||||
].includes(key)
|
||||
)
|
||||
delete node[key];
|
||||
} else delete node.$ref;
|
||||
node.description = [
|
||||
node.description,
|
||||
"This referenced input schema is advertised in reduced form; the server validates the complete canonical input.",
|
||||
]
|
||||
.filter((text) => typeof text === "string")
|
||||
.join(" ");
|
||||
}
|
||||
return { schema: node, changed };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import { processArgumentSchema } from "../process/processArgument.js";
|
||||
import { fixedTupleSchema } from "../fixedTupleSchema.js";
|
||||
import {
|
||||
boundedProducerLimitsSchema,
|
||||
@@ -10,7 +9,11 @@ import {
|
||||
localPathStringSchema,
|
||||
} from "../localPath.js";
|
||||
|
||||
const argument = processArgumentSchema.min(1).max(8192);
|
||||
const argument = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(8192)
|
||||
.refine((value) => !value.includes("\0"), "NUL is not an argument character");
|
||||
const captureInputShape = z.strictObject({
|
||||
producer_limits: boundedProducerLimitsSchema,
|
||||
path: localPathStringSchema.describe("Absolute local capture path"),
|
||||
|
||||
@@ -89,58 +89,34 @@ const optionalIndex = z.number().int().min(-1);
|
||||
const indexWidth = z.union([z.literal(1), z.literal(2), z.literal(4)]);
|
||||
|
||||
/** One explicit local file; detail selects delivery, never decoding coverage. */
|
||||
export const inspectIl2cppMetadataInputSchema = z
|
||||
.strictObject({
|
||||
path: absoluteLocalPathStringSchema.describe(
|
||||
"Absolute filesystem path to a Unity IL2CPP global-metadata.dat file",
|
||||
export const inspectIl2cppMetadataInputSchema = z.strictObject({
|
||||
path: absoluteLocalPathStringSchema.describe(
|
||||
"Absolute filesystem path to a Unity IL2CPP global-metadata.dat file",
|
||||
),
|
||||
detail: z
|
||||
.enum(["complete", "summary"])
|
||||
.default("summary")
|
||||
.describe(
|
||||
"summary (default) retains complete Evidence and returns identity and collection counts; complete returns every image, type, method, member, parameter and string-literal row inline",
|
||||
),
|
||||
detail: z
|
||||
.enum(["complete", "summary"])
|
||||
.default("summary")
|
||||
.describe(
|
||||
"summary (default) retains complete Evidence and returns identity and collection counts; complete returns every image, type, method, member, parameter and string-literal row inline",
|
||||
),
|
||||
binary_path: absoluteLocalPathStringSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"Absolute path to the caller-selected matching 64-bit IL2CPP binary (Mach-O GameAssembly.dylib, PE GameAssembly.dll or ELF libil2cpp.so); static reading, never executed",
|
||||
),
|
||||
binary_architecture: z
|
||||
.enum(["arm64", "amd64"])
|
||||
.optional()
|
||||
.describe(
|
||||
"Slice to read from a universal Mach-O binary_path; required for universal binaries and rejected otherwise",
|
||||
),
|
||||
generic_instances: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Also link generic method instances (genericMethodTable rows) to code addresses with Il2CppDumper-style Type<Args>$$Method<Args> names; needs binary_path",
|
||||
),
|
||||
})
|
||||
.superRefine((input, context) => {
|
||||
if (input.binary_path !== undefined) return;
|
||||
if (input.binary_architecture !== undefined)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["binary_architecture"],
|
||||
message: "binary_architecture selects a slice of binary_path.",
|
||||
});
|
||||
if (input.generic_instances === true)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["generic_instances"],
|
||||
message: "generic_instances links generic methods in binary_path.",
|
||||
});
|
||||
})
|
||||
.meta({
|
||||
dependentRequired: { binary_architecture: ["binary_path"] },
|
||||
if: {
|
||||
properties: { generic_instances: { const: true } },
|
||||
required: ["generic_instances"],
|
||||
},
|
||||
then: { required: ["binary_path"] },
|
||||
});
|
||||
binary_path: absoluteLocalPathStringSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"Absolute path to the caller-selected matching 64-bit IL2CPP binary (Mach-O GameAssembly.dylib, PE GameAssembly.dll or ELF libil2cpp.so); static reading, never executed",
|
||||
),
|
||||
binary_architecture: z
|
||||
.enum(["arm64", "amd64"])
|
||||
.optional()
|
||||
.describe(
|
||||
"Slice to read from a universal Mach-O binary_path; required for universal binaries and rejected otherwise",
|
||||
),
|
||||
generic_instances: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Also link generic method instances (genericMethodTable rows) to code addresses with Il2CppDumper-style Type<Args>$$Method<Args> names; needs binary_path",
|
||||
),
|
||||
});
|
||||
|
||||
export type IL2cppMetadataSectionName = z.infer<
|
||||
typeof il2cppMetadataSectionSchema
|
||||
|
||||
@@ -64,16 +64,3 @@ it("keeps the Windows absolute-path pattern valid in every client mode", () => {
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unpaired surrogates while preserving representable Unicode paths", () => {
|
||||
for (const path of ["/tmp/\ud800", "/tmp/\udc00", "/tmp/\ud800x"]) {
|
||||
expect(absoluteLocalPathStringSchema.safeParse(path).success, path).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
for (const path of ["/tmp/😀", "/tmp/�", "/tmp/ spaced name "]) {
|
||||
expect(absoluteLocalPathStringSchema.safeParse(path).success, path).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -16,13 +16,8 @@ export const nulFreeStringSchema = z
|
||||
.string()
|
||||
.regex(/^[^\x00]*$/u, "Local filesystem paths cannot contain NUL");
|
||||
|
||||
/** A selected local filesystem path: nonempty, NUL-free, and representable without replacement. */
|
||||
export const localPathStringSchema = nulFreeStringSchema
|
||||
.min(1)
|
||||
.regex(
|
||||
/^[^\uD800-\uDFFF]*$/u,
|
||||
"Local filesystem paths must contain well-formed Unicode",
|
||||
);
|
||||
/** A selected local filesystem path: nonempty and free of NUL bytes. */
|
||||
export const localPathStringSchema = nulFreeStringSchema.min(1);
|
||||
|
||||
/**
|
||||
* A Windows absolute path: rooted, UNC, or drive-qualified. The classes spell
|
||||
|
||||
@@ -2,5 +2,5 @@ import { z } from "zod";
|
||||
|
||||
/** Selected procedure for function dossier and native API analysis. */
|
||||
export const nativeProcedureInputSchema = z.strictObject({
|
||||
procedure: z.string().min(1).describe("A procedure name or address"),
|
||||
procedure: z.string().describe("A procedure name or address"),
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** One process argument; NUL cannot be represented by process launch APIs. */
|
||||
export const processArgumentSchema = z
|
||||
.string()
|
||||
.regex(/^[^\x00]*$/u, "NUL is not an argument character");
|
||||
@@ -3,9 +3,49 @@ import {
|
||||
inspectPythonPycInputSchema,
|
||||
pythonPycMarshalProfile,
|
||||
PYTHON_PYC_MARSHAL_PROFILES,
|
||||
pythonPycHeaderSchema,
|
||||
} from "./pythonPyc.js";
|
||||
import { pythonBytecodeIdentity } from "./pythonBytecodeIdentity.js";
|
||||
|
||||
const hashHeader = {
|
||||
magic: 3531,
|
||||
python_version: "3.12",
|
||||
code_layout: "py311",
|
||||
flags: 3,
|
||||
hash_based: true,
|
||||
check_source: true,
|
||||
source_hash_hex: "0123456789abcdef",
|
||||
source_mtime: null,
|
||||
source_size: null,
|
||||
};
|
||||
|
||||
it.each(["", "ab", "g".repeat(16)])(
|
||||
"rejects an invalid source hash %j",
|
||||
(source_hash_hex) => {
|
||||
expect(
|
||||
pythonPycHeaderSchema.safeParse({ ...hashHeader, source_hash_hex })
|
||||
.success,
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ hash_based: false },
|
||||
{ check_source: false },
|
||||
{ source_hash_hex: null },
|
||||
{ source_mtime: 0 },
|
||||
{ source_size: 0 },
|
||||
])("keeps header facts consistent with the recorded flags: %j", (changed) => {
|
||||
expect(
|
||||
pythonPycHeaderSchema.safeParse({ ...hashHeader, ...changed }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves unknown raw header flags and valid hash bytes", () => {
|
||||
const header = { ...hashHeader, flags: 0x80000003 };
|
||||
expect(pythonPycHeaderSchema.parse(header)).toEqual(header);
|
||||
});
|
||||
|
||||
it("keeps pinned CPython identity separate from standalone marshal coverage", () => {
|
||||
expect(pythonPycMarshalProfile(3394)).toEqual({
|
||||
code_layout: "py37",
|
||||
|
||||
@@ -205,17 +205,53 @@ const pythonCodeObjectSchema = z.strictObject({
|
||||
});
|
||||
export type PythonPycCodeObject = z.infer<typeof pythonCodeObjectSchema>;
|
||||
|
||||
export const pythonPycHeaderSchema = z.strictObject({
|
||||
magic: z.number().int().min(0).max(0xffff),
|
||||
python_version: z.string().min(1),
|
||||
code_layout: z.enum(PYTHON_PYC_CODE_LAYOUTS),
|
||||
flags: uint,
|
||||
hash_based: z.boolean(),
|
||||
check_source: z.boolean(),
|
||||
source_hash_hex: z.string().nullable(),
|
||||
source_mtime: uint.nullable(),
|
||||
source_size: uint.nullable(),
|
||||
});
|
||||
export const pythonPycHeaderSchema = z
|
||||
.strictObject({
|
||||
magic: z.number().int().min(0).max(0xffff),
|
||||
python_version: z.string().min(1),
|
||||
code_layout: z.enum(PYTHON_PYC_CODE_LAYOUTS),
|
||||
flags: uint,
|
||||
hash_based: z.boolean(),
|
||||
check_source: z.boolean(),
|
||||
source_hash_hex: z
|
||||
.string()
|
||||
.regex(/^[0-9a-f]{16}$/u)
|
||||
.nullable(),
|
||||
source_mtime: uint.nullable(),
|
||||
source_size: uint.nullable(),
|
||||
})
|
||||
.superRefine((header, context) => {
|
||||
for (const [field, flag] of [
|
||||
["hash_based", PYTHON_PYC_HASH_BASED],
|
||||
["check_source", PYTHON_PYC_CHECK_SOURCE],
|
||||
] as const)
|
||||
if (header[field] !== ((header.flags & flag) !== 0))
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: [field],
|
||||
message: `${field} disagrees with its recorded header flag.`,
|
||||
});
|
||||
if (header.hash_based === (header.source_hash_hex === null))
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["source_hash_hex"],
|
||||
message:
|
||||
"Hash-based pycs require an 8-byte source hash; others must omit it.",
|
||||
});
|
||||
if (header.hash_based === (header.source_mtime !== null))
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["source_mtime"],
|
||||
message: "Timestamp pycs require mtime; hash-based pycs must omit it.",
|
||||
});
|
||||
if (header.hash_based === (header.source_size !== null))
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["source_size"],
|
||||
message:
|
||||
"Timestamp pycs require source_size; hash-based pycs must omit it.",
|
||||
});
|
||||
});
|
||||
|
||||
/** Bounded CPython 3.7–3.13 pyc observation; marshal is walked, bytecode is not decompiled. */
|
||||
export const pythonPycSchema = z
|
||||
@@ -253,29 +289,6 @@ export const pythonPycSchema = z
|
||||
message: "Marshal byte count disagrees with the header-stripped file.",
|
||||
});
|
||||
}
|
||||
if (report.header.hash_based === (report.header.source_hash_hex === null)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["header", "source_hash_hex"],
|
||||
message:
|
||||
"Hash-based pycs require an 8-byte source hash; others must omit it.",
|
||||
});
|
||||
}
|
||||
if (report.header.hash_based === (report.header.source_mtime !== null)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["header", "source_mtime"],
|
||||
message: "Timestamp pycs require mtime; hash-based pycs must omit it.",
|
||||
});
|
||||
}
|
||||
if (report.header.hash_based === (report.header.source_size !== null)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["header", "source_size"],
|
||||
message:
|
||||
"Timestamp pycs require source_size; hash-based pycs must omit it.",
|
||||
});
|
||||
}
|
||||
if (report.coverage.code_objects !== report.code_objects.length) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import { processArgumentSchema } from "../process/processArgument.js";
|
||||
import {
|
||||
absoluteLocalPathStringSchema,
|
||||
localPathStringSchema,
|
||||
@@ -11,7 +10,11 @@ import {
|
||||
} from "../producerResourceLimits.js";
|
||||
|
||||
// These strings are single sigrok arguments, never shell fragments.
|
||||
const selection = processArgumentSchema.min(1).max(8192);
|
||||
const selection = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(8192)
|
||||
.refine((value) => !value.includes("\0"), "NUL is not an argument character");
|
||||
const signalInputShape = z.strictObject({
|
||||
producer_limits: boundedProducerLimitsSchema,
|
||||
path: localPathStringSchema.describe("Absolute local recording path"),
|
||||
|
||||
@@ -189,34 +189,3 @@ cliTest(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"validates native operation inputs before opening a target",
|
||||
async ({ cli }) => {
|
||||
for (const [selection, code] of [
|
||||
[
|
||||
["inspect-native-instruction", "missing-target", ""],
|
||||
"VALIDATION_ERROR",
|
||||
],
|
||||
[
|
||||
["resolve-native-call-targets", "missing-target", ""],
|
||||
"VALIDATION_ERROR",
|
||||
],
|
||||
[["annotate-native-function", "missing-target", ""], "VALIDATION_ERROR"],
|
||||
[
|
||||
["annotate-native-function", "missing-target", "main"],
|
||||
"invalid_request",
|
||||
],
|
||||
[
|
||||
["annotate-native-function", "missing-target", "main", "--comment="],
|
||||
"target_unavailable",
|
||||
],
|
||||
] as const) {
|
||||
const result = await cli.run({
|
||||
arguments: [...selection, "--json"],
|
||||
environment: { REA_LOG_LEVEL: "silent" },
|
||||
});
|
||||
expect(result.json, JSON.stringify(selection)).toMatchObject({ code });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -27,11 +27,9 @@ const serve = async (cli: CliInstance, argv: readonly string[]) => {
|
||||
.object({
|
||||
args: z
|
||||
.object({ properties: z.record(z.string(), z.unknown()) })
|
||||
.passthrough()
|
||||
.optional(),
|
||||
options: z
|
||||
.object({ properties: z.record(z.string(), z.unknown()) })
|
||||
.passthrough()
|
||||
.optional(),
|
||||
})
|
||||
.parse(JSON.parse(stdout));
|
||||
@@ -165,115 +163,3 @@ it("advertises the domain constraints for android, adb settings, and browser sel
|
||||
parser.args.safeParse({ path: "app.apk", className: "" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("advertises selection dependencies after omittable-default projection", async () => {
|
||||
const cli = createCli({});
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
for (const [command, invalid, valid] of [
|
||||
[
|
||||
"inspect-packet-fields",
|
||||
{ all: true, limit: 1 },
|
||||
{ all: false, limit: 1 },
|
||||
],
|
||||
[
|
||||
"inspect-signal-decoders",
|
||||
{ all: true, limit: 1 },
|
||||
{ all: false, limit: 1 },
|
||||
],
|
||||
[
|
||||
"inspect-packet-capture",
|
||||
{ fields: ["ip.src"], all_matches: true, match_limit: 1 },
|
||||
{ fields: ["ip.src"], all_matches: false, match_limit: 1 },
|
||||
],
|
||||
[
|
||||
"decode-signal-recording",
|
||||
{ decoders: "uart", all_lines: true, line_limit: 1 },
|
||||
{ decoders: "uart", all_lines: false, line_limit: 1 },
|
||||
],
|
||||
[
|
||||
"inspect-il2cpp-metadata",
|
||||
{ genericInstances: true },
|
||||
{ genericInstances: false },
|
||||
],
|
||||
[
|
||||
"inspect-il2cpp-metadata",
|
||||
{ architecture: "arm64" },
|
||||
{ binary: "lib.dylib", architecture: "arm64" },
|
||||
],
|
||||
[
|
||||
"inspect-il2cpp-metadata",
|
||||
{ ghidraSeeds: "seeds.txt" },
|
||||
{ binary: "lib.so", ghidraSeeds: "seeds.txt" },
|
||||
],
|
||||
[
|
||||
"inspect-il2cpp-metadata",
|
||||
{ seedBase: "0x1000" },
|
||||
{ binary: "lib.so", ghidraSeeds: "seeds.txt", seedBase: "0x1000" },
|
||||
],
|
||||
[
|
||||
"import-reference-source",
|
||||
{ outlineDepth: 2, detail: "full" },
|
||||
{ outlineDepth: 2, detail: "summary" },
|
||||
],
|
||||
] as const) {
|
||||
const schema = (await serve(cli, [command, "--schema", "--json"])).options;
|
||||
expect(schema, command).toBeDefined();
|
||||
const validate = ajv.compile(schema as AnySchema);
|
||||
expect(
|
||||
validate(invalid),
|
||||
`${command} invalid ${JSON.stringify(invalid)}`,
|
||||
).toBe(false);
|
||||
expect(validate(valid), `${command} valid ${JSON.stringify(valid)}`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid selected paths before inspecting a target", async () => {
|
||||
const cli = createCli({});
|
||||
for (const [args, field, code] of [
|
||||
[
|
||||
["inspect-apple-ips", "missing.ips", "--join-path="],
|
||||
"joinPath",
|
||||
"too_small",
|
||||
],
|
||||
[
|
||||
["match-native-functions", "missing.json", "--evidence_output="],
|
||||
"evidence_output",
|
||||
"too_small",
|
||||
],
|
||||
[
|
||||
["match-native-functions", "missing.json", "--evidence_output=bad\0path"],
|
||||
"evidence_output",
|
||||
"invalid_format",
|
||||
],
|
||||
[
|
||||
[
|
||||
"match-native-functions",
|
||||
"missing.json",
|
||||
"--evidence_output=bad\ud800path",
|
||||
],
|
||||
"evidence_output",
|
||||
"invalid_format",
|
||||
],
|
||||
[
|
||||
["match-native-functions", "missing.json", "--source_bundle="],
|
||||
"source_bundle.0",
|
||||
"too_small",
|
||||
],
|
||||
[["import-reference-source", ""], "root", "too_small"],
|
||||
] as const) {
|
||||
let stdout = "";
|
||||
await cli.serve([...args, "--json"], {
|
||||
env: {},
|
||||
exit: () => {},
|
||||
stdout: (text) => {
|
||||
stdout += text;
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(stdout), args.join(" ")).toMatchObject({
|
||||
code: "VALIDATION_ERROR",
|
||||
fieldErrors: [{ path: field, code }],
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -703,27 +703,15 @@ describe("reference-source outline-depth input schema", () => {
|
||||
[String(Number.MAX_SAFE_INTEGER), Number.MAX_SAFE_INTEGER],
|
||||
] as const;
|
||||
for (const [token, expected] of parsedValues) {
|
||||
const parsed = parser?.safeParse({
|
||||
outlineDepth: token,
|
||||
detail: "summary",
|
||||
});
|
||||
const parsed = parser?.safeParse({ outlineDepth: token });
|
||||
expect(parsed?.success).toBe(true);
|
||||
if (parsed === undefined || !parsed.success)
|
||||
throw new Error(`Expected outline-depth token ${token} to parse`);
|
||||
expect(parsed.data).toMatchObject({
|
||||
detail: "summary",
|
||||
outlineDepth: expected,
|
||||
});
|
||||
expect(parsed.data).toMatchObject({ outlineDepth: expected });
|
||||
}
|
||||
for (const token of ["", "-1", "9007199254740992"]) {
|
||||
expect(
|
||||
parser?.safeParse({ outlineDepth: token, detail: "summary" }).success,
|
||||
).toBe(false);
|
||||
expect(parser?.safeParse({ outlineDepth: token }).success).toBe(false);
|
||||
}
|
||||
expect(parser?.safeParse({ outlineDepth: "2" }).success).toBe(false);
|
||||
expect(
|
||||
parser?.safeParse({ outlineDepth: "2", detail: "full" }).success,
|
||||
).toBe(false);
|
||||
|
||||
const ajv = new Ajv2020({ strict: false });
|
||||
const validate = ajv.compile({
|
||||
@@ -731,18 +719,13 @@ describe("reference-source outline-depth input schema", () => {
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
});
|
||||
for (const [token] of parsedValues) {
|
||||
const parsed = parser?.safeParse({
|
||||
outlineDepth: token,
|
||||
detail: "summary",
|
||||
});
|
||||
const parsed = parser?.safeParse({ outlineDepth: token });
|
||||
if (parsed === undefined || !parsed.success)
|
||||
throw new Error(`Expected outline-depth token ${token} to parse`);
|
||||
expect(validate(parsed.data)).toBe(true);
|
||||
}
|
||||
expect(validate({ outlineDepth: -1 })).toBe(false);
|
||||
expect(validate({ outlineDepth: 9007199254740992 })).toBe(false);
|
||||
expect(validate({ outlineDepth: 2 })).toBe(false);
|
||||
expect(validate({ outlineDepth: 2, detail: "full" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1818,20 +1818,3 @@ it("refuses a declared DOS relocation header that is not present in the file", a
|
||||
error: { reason: "truncated DOS MZ load module" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not resolve malformed Unicode paths to a replacement-character sibling", async () => {
|
||||
const directory = await createTestTempDirectory("rea-target-unicode-");
|
||||
const actual = join(directory, "target-�");
|
||||
const selected = join(directory, "target-\ud800");
|
||||
await writeFile(actual, pe(0xaa64));
|
||||
const refused = await parseBinaryTarget(selected);
|
||||
expect(refused).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "BinaryTargetError",
|
||||
path: selected,
|
||||
reason: "Local filesystem paths must contain well-formed Unicode",
|
||||
},
|
||||
});
|
||||
expect(await parseBinaryTarget(actual)).toMatchObject({ ok: true });
|
||||
});
|
||||
@@ -121,58 +121,6 @@ const factories = (
|
||||
return { dependencies, session };
|
||||
};
|
||||
|
||||
it("rejects malformed one-shot operation inputs before creating a session", async () => {
|
||||
const { dependencies, session } = factories();
|
||||
const create = vi.fn(() => session);
|
||||
const guarded = {
|
||||
...dependencies,
|
||||
createBinarySession: create,
|
||||
createNativeBinarySession: create,
|
||||
createArtifactBinarySession: create,
|
||||
createManagedBinarySession: create,
|
||||
};
|
||||
for (const result of [
|
||||
await runDirectAnalysis(guarded, "/missing", "inspect_native_instruction", {
|
||||
address: "",
|
||||
}),
|
||||
await runDirectAnalysis(guarded, "/missing", "annotate_native_function", {
|
||||
procedure: "main",
|
||||
}),
|
||||
await runDirectAnalysis(guarded, "/missing", "trace_feature", {
|
||||
query: "",
|
||||
}),
|
||||
await runDirectAnalysis(guarded, "/missing", "analyze_function", {
|
||||
procedure: "",
|
||||
}),
|
||||
await runDirectAnalysis(guarded, "/missing", "inspect_native_api", {
|
||||
procedure: "",
|
||||
}),
|
||||
await runProviderAnalysis(guarded, "/missing", "observe_native_calls", {
|
||||
breakpoints: [],
|
||||
}),
|
||||
await runProviderAnalysis(guarded, "/missing", "inspect_asset_catalog", {
|
||||
limit: -1,
|
||||
}),
|
||||
]) {
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error).toMatchObject({ code: "invalid_request" });
|
||||
}
|
||||
const managed = await runManagedProviderExecution(
|
||||
guarded,
|
||||
"/missing",
|
||||
"inspect_managed_members",
|
||||
undefined,
|
||||
{ type_names: ["Duplicate", "Duplicate"] },
|
||||
);
|
||||
expect(managed.ok).toBe(false);
|
||||
if (!managed.ok)
|
||||
expect(projectAnalysisError(managed.error)).toMatchObject({
|
||||
code: "invalid_request",
|
||||
});
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"retains actual artifact sources and caller path after final-close cancellation (cleanup fails: %s)",
|
||||
async (closeFails) => {
|
||||
@@ -318,9 +266,9 @@ it.each([false, true])(
|
||||
...(closeFails ? { cleanupError } : {}),
|
||||
});
|
||||
const result = analysisCliErrorEnvelopeSchema.parse(
|
||||
await runDirectAnalysis(dependencies, callerPath, "read_bytes", {
|
||||
address: "0x1000",
|
||||
}).then(projectCliAnalysisResult),
|
||||
await runDirectAnalysis(dependencies, callerPath, "read_bytes", {}).then(
|
||||
projectCliAnalysisResult,
|
||||
),
|
||||
);
|
||||
const observation = parseEvidence(
|
||||
jsonObjectSchema.parse(result.details).partial_observation,
|
||||
@@ -394,9 +342,7 @@ describe("one-shot finalization cancellation", () => {
|
||||
async (tool) => {
|
||||
const [path] = await createBinarySessionTargets();
|
||||
const parameters =
|
||||
tool === "analyze_function"
|
||||
? { procedure: "fixture" }
|
||||
: { address: "0x1000" };
|
||||
tool === "analyze_function" ? { procedure: "fixture" } : {};
|
||||
const original = parseEvidence(
|
||||
await runDirectAnalysis(
|
||||
factories().dependencies,
|
||||
@@ -447,7 +393,7 @@ describe("one-shot finalization cancellation", () => {
|
||||
dependencies,
|
||||
path,
|
||||
"read_bytes",
|
||||
{ address: "0x1000" },
|
||||
{},
|
||||
{ signal: controller.signal },
|
||||
).then(projectCliAnalysisResult);
|
||||
expect(result).toMatchObject({
|
||||
@@ -474,7 +420,7 @@ describe("one-shot finalization cancellation", () => {
|
||||
dependencies,
|
||||
path,
|
||||
"read_bytes",
|
||||
{ address: "0x1000" },
|
||||
{},
|
||||
{ signal: controller.signal },
|
||||
).then(projectCliAnalysisResult);
|
||||
expect(result).toMatchObject({
|
||||
@@ -531,7 +477,7 @@ describe("snapshot publication finalization", () => {
|
||||
dependencies,
|
||||
path,
|
||||
"read_bytes",
|
||||
{ address: "0x1000" },
|
||||
{},
|
||||
{ signal: controller.signal, snapshotPath },
|
||||
).then(projectCliAnalysisResult);
|
||||
const loaded = await readAnalysisSnapshot(snapshotPath);
|
||||
@@ -593,7 +539,7 @@ describe("snapshot publication finalization", () => {
|
||||
dependencies,
|
||||
path,
|
||||
"read_bytes",
|
||||
{ address: "0x1000" },
|
||||
{},
|
||||
{ signal: controller.signal, snapshotPath: failure.path },
|
||||
).then(projectCliAnalysisResult),
|
||||
);
|
||||
@@ -628,9 +574,9 @@ it.each([false, true])(
|
||||
...(closeFails ? { cleanupError } : {}),
|
||||
});
|
||||
const result = analysisCliErrorEnvelopeSchema.parse(
|
||||
await runDirectAnalysis(dependencies, path, "read_bytes", {
|
||||
address: "0x1000",
|
||||
}).then(projectCliAnalysisResult),
|
||||
await runDirectAnalysis(dependencies, path, "read_bytes", {}).then(
|
||||
projectCliAnalysisResult,
|
||||
),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
@@ -655,9 +601,12 @@ describe("one-shot analysis cleanup outcomes", () => {
|
||||
it("retains successful evidence and reports CLI failure when close fails", async () => {
|
||||
const [path] = await createBinarySessionTargets();
|
||||
const { dependencies, session } = factories({ cleanupError });
|
||||
const result = await runDirectAnalysis(dependencies, path, "read_bytes", {
|
||||
address: "0x1000",
|
||||
}).then(projectCliAnalysisResult);
|
||||
const result = await runDirectAnalysis(
|
||||
dependencies,
|
||||
path,
|
||||
"read_bytes",
|
||||
{},
|
||||
).then(projectCliAnalysisResult);
|
||||
expect(result).toMatchObject({
|
||||
error: "Analysis failed",
|
||||
code: "cleanup_incomplete",
|
||||
@@ -682,9 +631,12 @@ describe("one-shot analysis cleanup outcomes", () => {
|
||||
cleanupError,
|
||||
});
|
||||
const primary = projectAnalysisError(primaryError);
|
||||
const result = await runDirectAnalysis(dependencies, path, "read_bytes", {
|
||||
address: "0x1000",
|
||||
}).then(projectCliAnalysisResult);
|
||||
const result = await runDirectAnalysis(
|
||||
dependencies,
|
||||
path,
|
||||
"read_bytes",
|
||||
{},
|
||||
).then(projectCliAnalysisResult);
|
||||
const callerError = analysisCliErrorEnvelopeSchema.parse(result);
|
||||
expect(callerError).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
@@ -711,9 +663,12 @@ describe("one-shot analysis cleanup outcomes", () => {
|
||||
const { dependencies } = factories({
|
||||
thrownCleanup: new Error("unexpected close rejection"),
|
||||
});
|
||||
const result = await runDirectAnalysis(dependencies, path, "read_bytes", {
|
||||
address: "0x1000",
|
||||
}).then(projectCliAnalysisResult);
|
||||
const result = await runDirectAnalysis(
|
||||
dependencies,
|
||||
path,
|
||||
"read_bytes",
|
||||
{},
|
||||
).then(projectCliAnalysisResult);
|
||||
expect(result).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
@@ -731,9 +686,9 @@ describe("one-shot analysis cleanup outcomes", () => {
|
||||
const thrownExecution = new Error("unexpected execution rejection");
|
||||
const { dependencies } = factories({ thrownExecution, cleanupError });
|
||||
await expect(
|
||||
runDirectAnalysis(dependencies, path, "read_bytes", {
|
||||
address: "0x1000",
|
||||
}).then(projectCliAnalysisResult),
|
||||
runDirectAnalysis(dependencies, path, "read_bytes", {}).then(
|
||||
projectCliAnalysisResult,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
cause: thrownExecution,
|
||||
errors: [thrownExecution, cleanupError],
|
||||
@@ -743,9 +698,12 @@ describe("one-shot analysis cleanup outcomes", () => {
|
||||
it("keeps successful evidence unchanged after confirmed cleanup", async () => {
|
||||
const [path] = await createBinarySessionTargets();
|
||||
const { dependencies, session } = factories();
|
||||
const result = await runDirectAnalysis(dependencies, path, "read_bytes", {
|
||||
address: "0x1000",
|
||||
}).then(projectCliAnalysisResult);
|
||||
const result = await runDirectAnalysis(
|
||||
dependencies,
|
||||
path,
|
||||
"read_bytes",
|
||||
{},
|
||||
).then(projectCliAnalysisResult);
|
||||
expect(parseEvidence(result)).toMatchObject({
|
||||
operation: "read_bytes",
|
||||
normalized_result: { observation: "retained result" },
|
||||
|
||||
@@ -65,28 +65,6 @@ const recordingFactories = (
|
||||
return { dependencies, sessions, clientsClosed: () => clientsClosed };
|
||||
};
|
||||
|
||||
/** Schema-valid read_bytes input so lifecycle tests reach the session. */
|
||||
const admittedReadBytes = { address: "0x1000" } as const;
|
||||
|
||||
/** Schema-valid inputs for the ordinary native session route. */
|
||||
const admittedNativeSessionInput = {
|
||||
observe_native_ui: { pid: 123, window_id: 456 },
|
||||
capture_native_ui_scenario: {
|
||||
pid: 123,
|
||||
window_id: 456,
|
||||
steps: [{ kind: "wait", milliseconds: 100 }],
|
||||
},
|
||||
observe_native_calls: {
|
||||
breakpoints: [
|
||||
{
|
||||
kind: "objc-method",
|
||||
class_name: "NSURLSession",
|
||||
selector: "dataTaskWithRequest:completionHandler:",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
it("requires a durable summary destination before acquiring a managed session", async () => {
|
||||
const factories = recordingFactories();
|
||||
const result = await runProviderAnalysis(
|
||||
@@ -147,13 +125,11 @@ describe("one-shot analysis factory boundary", () => {
|
||||
const factories = recordingFactories(failure);
|
||||
const interruptListeners = process.listenerCount("SIGINT");
|
||||
const terminateListeners = process.listenerCount("SIGTERM");
|
||||
const execution = runDirectAnalysis(
|
||||
factories.dependencies,
|
||||
path,
|
||||
"read_bytes",
|
||||
admittedReadBytes,
|
||||
).then(projectCliAnalysisResult);
|
||||
await expect(execution).rejects.toBe(failure);
|
||||
await expect(
|
||||
runDirectAnalysis(factories.dependencies, path, "read_bytes", {}).then(
|
||||
projectCliAnalysisResult,
|
||||
),
|
||||
).rejects.toBe(failure);
|
||||
expect(factories.clientsClosed()).toBe(1);
|
||||
expect(process.listenerCount("SIGINT")).toBe(interruptListeners);
|
||||
expect(process.listenerCount("SIGTERM")).toBe(terminateListeners);
|
||||
@@ -271,32 +247,32 @@ describe("one-shot analysis factory boundary", () => {
|
||||
expect(factories.sessions[0]?.activeTarget()).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"observe_native_ui",
|
||||
"capture_native_ui_scenario",
|
||||
"observe_native_calls",
|
||||
] satisfies readonly NativeToolName[])(
|
||||
"keeps %s on the ordinary session route",
|
||||
async (tool) => {
|
||||
const [path] = await createBinarySessionTargets();
|
||||
const factories = recordingFactories();
|
||||
const result = await runProviderAnalysis(
|
||||
{
|
||||
...factories.dependencies,
|
||||
createNativeBinarySession: () => {
|
||||
throw new Error("static inspection factory must stay unused");
|
||||
it.each([
|
||||
"observe_native_ui",
|
||||
"capture_native_ui_scenario",
|
||||
"observe_native_calls",
|
||||
] satisfies readonly NativeToolName[])(
|
||||
"keeps %s on the ordinary session route",
|
||||
async (tool) => {
|
||||
const [path] = await createBinarySessionTargets();
|
||||
const factories = recordingFactories();
|
||||
const result = await runProviderAnalysis(
|
||||
{
|
||||
...factories.dependencies,
|
||||
createNativeBinarySession: () => {
|
||||
throw new Error("static inspection factory must stay unused");
|
||||
},
|
||||
},
|
||||
},
|
||||
path,
|
||||
tool,
|
||||
admittedNativeSessionInput[tool],
|
||||
).then(projectCliAnalysisResult);
|
||||
expect(result).toMatchObject({ operation: tool });
|
||||
expect(factories.clientsClosed()).toBe(1);
|
||||
},
|
||||
);
|
||||
path,
|
||||
tool,
|
||||
{},
|
||||
).then(projectCliAnalysisResult);
|
||||
expect(result).toMatchObject({ operation: tool });
|
||||
expect(factories.clientsClosed()).toBe(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a managed execution profile in direct analysis Evidence", async () => {
|
||||
const [path] = await createBinarySessionTargets();
|
||||
@@ -331,7 +307,7 @@ it("direct analysis snapshot target mismatch retains actionable snapshot recover
|
||||
factories.dependencies,
|
||||
path,
|
||||
"read_bytes",
|
||||
admittedReadBytes,
|
||||
{},
|
||||
{ snapshotPath },
|
||||
).then(projectCliAnalysisResult),
|
||||
).not.toHaveProperty("code");
|
||||
@@ -339,7 +315,7 @@ it("direct analysis snapshot target mismatch retains actionable snapshot recover
|
||||
factories.dependencies,
|
||||
other,
|
||||
"read_bytes",
|
||||
admittedReadBytes,
|
||||
{},
|
||||
{ snapshotPath },
|
||||
).then(projectCliAnalysisResult);
|
||||
const sha = async (input: string) =>
|
||||
@@ -368,7 +344,7 @@ it("direct analysis snapshot target mismatch retains actionable snapshot recover
|
||||
factories.dependencies,
|
||||
path,
|
||||
"read_bytes",
|
||||
admittedReadBytes,
|
||||
{},
|
||||
{ snapshotPath },
|
||||
).then(projectCliAnalysisResult),
|
||||
).not.toHaveProperty("code");
|
||||
@@ -409,7 +385,7 @@ it("retries snapshot file cleanup before final close even when a published ident
|
||||
dependencies,
|
||||
path,
|
||||
"read_bytes",
|
||||
admittedReadBytes,
|
||||
{},
|
||||
{ snapshotPath: join(dirname(path), "excluded.snapshot") },
|
||||
).then(projectCliAnalysisResult);
|
||||
expect(events[0]).toBe("file-cleanup");
|
||||
|
||||
@@ -101,32 +101,12 @@ const fixture = (
|
||||
};
|
||||
|
||||
describe("direct CLI cross-reference selectors", () => {
|
||||
it("rejects unknown arguments before opening a session", async () => {
|
||||
it("retains validation issues unrelated to symbol resolution", async () => {
|
||||
const [path] = await createBinarySessionTargets();
|
||||
const provider = fixture({
|
||||
xrefsError: new AnalysisInputError("xrefs", undefined, [
|
||||
{ path: ["address"], reason: "invalid_format" },
|
||||
]),
|
||||
});
|
||||
const result = await runDirectAnalysis(
|
||||
provider.dependencies,
|
||||
path,
|
||||
"xrefs",
|
||||
{ address: "table_only", extra: true },
|
||||
).then(projectCliAnalysisResult);
|
||||
expect(result).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
operation: "xrefs",
|
||||
issues: [{ path: ["extra"], reason: "unknown_argument" }],
|
||||
},
|
||||
});
|
||||
expect(provider.closed()).toBe(0);
|
||||
});
|
||||
|
||||
it("retains provider validation issues unrelated to symbol resolution", async () => {
|
||||
const [path] = await createBinarySessionTargets();
|
||||
const issues = [{ path: ["address"], reason: "invalid_format" }] as const;
|
||||
const issues = [
|
||||
{ path: ["address"], reason: "invalid_format" },
|
||||
{ path: ["extra"], reason: "unknown_argument" },
|
||||
] as const;
|
||||
const provider = fixture({
|
||||
xrefsError: new AnalysisInputError("xrefs", undefined, issues),
|
||||
});
|
||||
@@ -134,7 +114,7 @@ describe("direct CLI cross-reference selectors", () => {
|
||||
provider.dependencies,
|
||||
path,
|
||||
"xrefs",
|
||||
{ address: "table_only" },
|
||||
{ address: "table_only", extra: true },
|
||||
).then(projectCliAnalysisResult);
|
||||
expect(result).toMatchObject({
|
||||
code: "invalid_request",
|
||||
|
||||
Reference in new issue
Block a user