mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
refactor(cli): project JSON-text options and pwntools inspectors from contracts (#10589)
Add a jsonText declaration to the contract command description: a structured contract field is taken as one JSON text argument, decoded at argv parsing, and admitted by the contract field's own schema, so --schema advertises the contract's structure. This generalizes the per-command producerLimitsOption wiring. Migrate inspect-binary-layout and inspect-recorded-crash (producer_limits as JSON text; recorded-crash keeps --debugger-context for include_debugger_context) and delete their hand-written options and input spreads. The parity guard probes JSON-text fields with valid, malformed and contract-refused JSON.
This commit is contained in:
1 parent
441aa170d3
commit
79cc07ecf1
3 files changed
+128
-87
No files matched your search
@@ -1,7 +1,3 @@
|
||||
import { z } from "incur";
|
||||
import { cliDecimalField } from "./options.js";
|
||||
import { producerLimitsOption } from "./producerLimitsOption.js";
|
||||
import { pwntoolsProducerLimitsSchema } from "../domain/native/pwntoolsResourceLimits.js";
|
||||
import {
|
||||
createByteEntropyService,
|
||||
createRecordedCrashService,
|
||||
@@ -22,17 +18,11 @@ import {
|
||||
createNativeExportsService,
|
||||
} from "../composition/binaryDiagnostics.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import type { Logger } from "pino";
|
||||
import type { CliInstance } from "./types.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
import { inspectBinaryLayoutInputSchema } from "../domain/native/binaryLayout.js";
|
||||
import { inspectRecordedCrashInputSchema } from "../domain/native/recordedCrash.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { withEvidenceCallerInputPath } from "../domain/evidence.js";
|
||||
import { BYTE_ENTROPY_PROVIDER } from "../domain/investigationProviders.js";
|
||||
import { localPathStringSchema } from "../domain/localPath.js";
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "../contracts/native/binaryDiagnosticsToolContracts.js";
|
||||
import { contractNamed } from "./contractCliDescription.js";
|
||||
import {
|
||||
@@ -319,81 +309,64 @@ export const registerBinaryDiagnosticsCommands = (
|
||||
},
|
||||
deliverWithService(createNativeExportsService, "rea-native-exports"),
|
||||
);
|
||||
const service = createBinaryLayoutService(environment);
|
||||
cli.command(CLI_COMMANDS.inspectBinaryLayout, {
|
||||
description:
|
||||
"Inspect binary file/linked layout without executing the selected object",
|
||||
args: z.object({
|
||||
path: localPathStringSchema.describe(
|
||||
"Local ELF binary or relocatable object",
|
||||
),
|
||||
}),
|
||||
options: z.object({
|
||||
producerLimits: producerLimitsOption(pwntoolsProducerLimitsSchema),
|
||||
pwntoolsTimeoutMs: cliDecimalField(
|
||||
inspectBinaryLayoutInputSchema.shape.pwntools_timeout_ms,
|
||||
),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.inspectBinaryLayout, async () => {
|
||||
const result = await service.inspectAndClose(
|
||||
{
|
||||
path: absoluteCallerPath(args.path),
|
||||
...(options.producerLimits === undefined
|
||||
? {}
|
||||
: { producer_limits: options.producerLimits }),
|
||||
...(options.pwntoolsTimeoutMs === undefined
|
||||
? {}
|
||||
: { pwntools_timeout_ms: options.pwntoolsTimeoutMs }),
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
return result.ok
|
||||
? withEvidenceCallerInputPath(result.value, args.path)
|
||||
: projectAnalysisError(result.error);
|
||||
}),
|
||||
),
|
||||
});
|
||||
const producerLimitsHelp =
|
||||
"JSON producer budget overrides. Keys and units follow this operation's producer_limits contract; changes apply on this call without restart and preserve tighter inherited OS limits.";
|
||||
const layout = createBinaryLayoutService(environment);
|
||||
registerContractCommand(
|
||||
cli,
|
||||
logger,
|
||||
contract("inspect_binary_layout"),
|
||||
{
|
||||
command: CLI_COMMANDS.inspectBinaryLayout,
|
||||
description:
|
||||
"Inspect binary file/linked layout without executing the selected object",
|
||||
positionals: ["path"],
|
||||
callerPaths: ["path"],
|
||||
jsonText: ["producer_limits"],
|
||||
fields: {
|
||||
path: { description: "Local ELF binary or relocatable object" },
|
||||
producer_limits: { description: producerLimitsHelp },
|
||||
},
|
||||
omit: {
|
||||
detail:
|
||||
"One-shot output is complete Evidence; this command has no summary delivery",
|
||||
},
|
||||
},
|
||||
async (input, { signal, callerInputs }) => {
|
||||
const result = await layout.inspectAndClose(input, { signal });
|
||||
return result.ok
|
||||
? withEvidenceCallerInputPath(result.value, callerInputs.path)
|
||||
: projectAnalysisError(result.error);
|
||||
},
|
||||
);
|
||||
const recorded = createRecordedCrashService(environment);
|
||||
cli.command(CLI_COMMANDS.inspectRecordedCrash, {
|
||||
description:
|
||||
"Inspect supplied core registers/signals and optional debugger mapping evidence",
|
||||
args: z.object({
|
||||
path: localPathStringSchema.describe(
|
||||
"Local Linux ELF64 amd64 or AArch64 little-endian core",
|
||||
),
|
||||
}),
|
||||
options: z.object({
|
||||
producerLimits: producerLimitsOption(pwntoolsProducerLimitsSchema),
|
||||
debuggerContext: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Add BYO core-only GDB/pwndbg mapping candidates"),
|
||||
pwntoolsTimeoutMs: cliDecimalField(
|
||||
inspectRecordedCrashInputSchema.shape.pwntools_timeout_ms,
|
||||
),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.inspectRecordedCrash, async () => {
|
||||
const result = await recorded.inspectAndClose(
|
||||
{
|
||||
path: absoluteCallerPath(args.path),
|
||||
include_debugger_context: options.debuggerContext,
|
||||
...(options.producerLimits === undefined
|
||||
? {}
|
||||
: { producer_limits: options.producerLimits }),
|
||||
...(options.pwntoolsTimeoutMs === undefined
|
||||
? {}
|
||||
: { pwntools_timeout_ms: options.pwntoolsTimeoutMs }),
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
return result.ok
|
||||
? withEvidenceCallerInputPath(result.value, args.path)
|
||||
: projectAnalysisError(result.error);
|
||||
}),
|
||||
),
|
||||
});
|
||||
registerContractCommand(
|
||||
cli,
|
||||
logger,
|
||||
contract("inspect_recorded_crash"),
|
||||
{
|
||||
command: CLI_COMMANDS.inspectRecordedCrash,
|
||||
description:
|
||||
"Inspect supplied core registers/signals and optional debugger mapping evidence",
|
||||
positionals: ["path"],
|
||||
callerPaths: ["path"],
|
||||
jsonText: ["producer_limits"],
|
||||
fields: {
|
||||
path: {
|
||||
description: "Local Linux ELF64 amd64 or AArch64 little-endian core",
|
||||
},
|
||||
producer_limits: { description: producerLimitsHelp },
|
||||
include_debugger_context: {
|
||||
name: "debuggerContext",
|
||||
description: "Add BYO core-only GDB/pwndbg mapping candidates",
|
||||
},
|
||||
},
|
||||
},
|
||||
async (input, { signal, callerInputs }) => {
|
||||
const result = await recorded.inspectAndClose(input, { signal });
|
||||
return result.ok
|
||||
? withEvidenceCallerInputPath(result.value, callerInputs.path)
|
||||
: projectAnalysisError(result.error);
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { localPathStringSchema } from "../domain/localPath.js";
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
import { analysisEvidenceDeliveryOptions } from "./analysisEvidenceDelivery.js";
|
||||
import { safeParseJson } from "../domain/safeJson.js";
|
||||
import { cliDecimalShape } from "./options.js";
|
||||
|
||||
/**
|
||||
@@ -50,6 +51,11 @@ export interface CliContractProjection<Field extends string> {
|
||||
* resolves it with `absoluteCallerPath` before the contract sees it.
|
||||
*/
|
||||
readonly callerPaths?: readonly Field[];
|
||||
/**
|
||||
* Structured fields taken as one JSON text argument. The text is decoded
|
||||
* at argv parsing and then admitted by the contract field's own schema.
|
||||
*/
|
||||
readonly jsonText?: readonly Field[];
|
||||
readonly fields?: { readonly [Key in Field]?: CliFieldProjection };
|
||||
/**
|
||||
* Optional fields this one-shot command does not expose, each with the
|
||||
@@ -105,6 +111,8 @@ export interface ContractCliParameter {
|
||||
readonly decimal: boolean;
|
||||
/** Resolved with `absoluteCallerPath` before the contract sees it. */
|
||||
readonly callerPath: boolean;
|
||||
/** The value is JSON text decoded into the contract field. */
|
||||
readonly jsonText: boolean;
|
||||
/** Selects CLI Evidence delivery and is not analysis input. */
|
||||
readonly delivery: boolean;
|
||||
}
|
||||
@@ -196,6 +204,39 @@ const core = (value: z.ZodType): z.ZodType => {
|
||||
* Accept only field forms argv can carry, so a new contract field cannot
|
||||
* reach the CLI with an unreviewed spelling.
|
||||
*/
|
||||
const kebabCase = (key: string): string =>
|
||||
key.replaceAll(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`);
|
||||
|
||||
/**
|
||||
* JSON text for a structured contract field. Argv carries text; the decoded
|
||||
* value is admitted by the contract field itself, so `--schema` advertises
|
||||
* the contract's structure.
|
||||
*/
|
||||
const jsonTextField = (declared: z.ZodType, flag: string): z.ZodType => {
|
||||
const value =
|
||||
declared instanceof z.ZodOptional || declared instanceof z.ZodExactOptional
|
||||
? (declared.unwrap() as z.ZodType)
|
||||
: declared;
|
||||
const expected =
|
||||
core(value) instanceof z.ZodObject ? "a JSON object" : "valid JSON";
|
||||
const decoded = z
|
||||
.string()
|
||||
.transform((text, context) => {
|
||||
const parsed = safeParseJson(text);
|
||||
if (parsed.ok) return parsed.value;
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `${flag} must be ${expected}`,
|
||||
});
|
||||
return z.NEVER;
|
||||
})
|
||||
.pipe(value);
|
||||
const admitted = admitsOmission(declared) ? decoded.optional() : decoded;
|
||||
return declared.description === undefined
|
||||
? admitted
|
||||
: admitted.describe(declared.description);
|
||||
};
|
||||
|
||||
const assertArgvField = (
|
||||
contract: string,
|
||||
field: string,
|
||||
@@ -312,6 +353,9 @@ export const describeContractCommand = <Contract extends ProjectedContract>(
|
||||
const fieldProjections: Readonly<
|
||||
Record<string, CliFieldProjection | undefined>
|
||||
> = projection.fields ?? {};
|
||||
const jsonTextFields: ReadonlySet<string> = new Set(
|
||||
projection.jsonText ?? [],
|
||||
);
|
||||
const omitted: Readonly<Record<string, string>> = Object.fromEntries(
|
||||
Object.entries(projection.omit ?? {}).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
@@ -320,6 +364,7 @@ export const describeContractCommand = <Contract extends ProjectedContract>(
|
||||
for (const field of [
|
||||
...positionals,
|
||||
...callerPaths,
|
||||
...jsonTextFields,
|
||||
...Object.keys(fieldProjections),
|
||||
...Object.keys(omitted),
|
||||
])
|
||||
@@ -335,8 +380,11 @@ export const describeContractCommand = <Contract extends ProjectedContract>(
|
||||
const projected = fieldProjections[field];
|
||||
const callerPath = callerPaths.has(field);
|
||||
const delivery = evidenceDelivery && field === "detail";
|
||||
const jsonText = jsonTextFields.has(field);
|
||||
const key = projected?.name ?? camelCase(field);
|
||||
let schema: z.ZodType;
|
||||
if (delivery) schema = analysisEvidenceDeliveryOptions.detail;
|
||||
else if (jsonText) schema = jsonTextField(declared, kebabCase(key));
|
||||
else if (callerPath) {
|
||||
schema = admitsOmission(declared)
|
||||
? localPathStringSchema.optional()
|
||||
@@ -353,7 +401,7 @@ export const describeContractCommand = <Contract extends ProjectedContract>(
|
||||
const value = core(declared);
|
||||
return {
|
||||
field,
|
||||
key: projected?.name ?? camelCase(field),
|
||||
key,
|
||||
positional: positionals.includes(field),
|
||||
alias: projected?.alias,
|
||||
description,
|
||||
@@ -363,6 +411,7 @@ export const describeContractCommand = <Contract extends ProjectedContract>(
|
||||
decimal: value instanceof z.ZodNumber,
|
||||
callerPath,
|
||||
delivery,
|
||||
jsonText,
|
||||
};
|
||||
};
|
||||
const parameters = [
|
||||
|
||||
@@ -62,6 +62,13 @@ const probes = (field: z.ZodType): readonly unknown[] => {
|
||||
" 1",
|
||||
"-1",
|
||||
"1.5",
|
||||
"{}",
|
||||
"[]",
|
||||
"null",
|
||||
"{",
|
||||
'{"input_bytes":1}',
|
||||
'{"input_bytes":0}',
|
||||
'{"unknown":1}',
|
||||
];
|
||||
for (const bound of ["minimum", "maximum", "minLength", "maxLength"]) {
|
||||
const value = json[bound];
|
||||
@@ -92,6 +99,18 @@ const expectedAdmission = (
|
||||
contractField: z.ZodType,
|
||||
value: unknown,
|
||||
): boolean => {
|
||||
if (parameter.jsonText) {
|
||||
// JSON text is decoded once, then the contract field admits the value.
|
||||
if (typeof value !== "string")
|
||||
return admits(contractField, value) && value === undefined;
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(value);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return admits(contractField, decoded) && decoded !== undefined;
|
||||
}
|
||||
if (parameter.callerPath) {
|
||||
// A caller path is validated as typed: an empty value never becomes the
|
||||
// working directory. A nonempty value is resolved, then the contract rules.
|
||||
|
||||
Reference in new issue
Block a user