feat(objdump): select native disassembly by symbol (#5171)
Push optional native symbol selection into GNU objdump before collecting Evidence. Preserve native matching, raw output, artifact identity, scoped limitations and the existing full-disassembly default through the shared CLI/MCP workflow. Fixes #5159 Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
1 parent
dc936f433c
commit
3ea0d9489e
9 files changed
+253
-25
No files matched your search
@@ -18,8 +18,11 @@ can run.
|
||||
|
||||
For DWARF, REA does not follow local debug links by default. Set `follow_debug_links` to `true` to pass `-WK`; external debug-file identities are not bound to Evidence when following links is enabled. The CLI flag is `--follow-debug-links`; `--no-follow-debug-links` or `--follow-debug-links=false` keeps the default. A separate `true` or `false` word after the flag is not its value. REA clears `DEBUGINFOD_URLS` in the objdump child environment to disable debuginfod network access on GNU binutils versions that do not support `-WE`. The complete raw output is authoritative; REA does not normalize it into a version-independent disassembly schema.
|
||||
|
||||
For a known native symbol, use `operation: "disassemble", symbol: "my_function"` in MCP or `--symbol my_function` in CLI. REA passes `--disassemble=my_function` to objdump before collecting output, rather than producing the whole disassembly and filtering it afterward. Omit `symbol` to keep full code-section disassembly. Other operations reject this selector. Use a nonempty, NUL-free name with well-formed Unicode; unsupported values are rejected before reading the artifact or starting objdump. Symbol names are passed unchanged; matching, versioned symbols, aliases and the end of the disassembly follow the installed GNU objdump's semantics. Evidence records the selector and actual arguments. An empty match does not establish artifact-wide absence, and the selected observation does not contain the omitted disassembly; request the complete operation when needed.
|
||||
|
||||
```sh
|
||||
rea inspect-with-objdump ./program disassemble
|
||||
rea inspect-with-objdump ./program disassemble --symbol my_function
|
||||
rea inspect-with-objdump ./program dwarf
|
||||
rea inspect-with-objdump ./program dwarf --follow-debug-links
|
||||
```
|
||||
|
||||
@@ -190,11 +190,16 @@ export class ReverseEngineeringService {
|
||||
);
|
||||
const input = parsed.data;
|
||||
const command = this.#commands.objdump(input);
|
||||
let parameters: Readonly<Record<string, JsonValue>>;
|
||||
if (input.operation === "disassemble") {
|
||||
const { symbol, ...base } = input;
|
||||
parameters = symbol === undefined ? base : { ...base, symbol };
|
||||
} else parameters = input;
|
||||
return this.#observe(
|
||||
"inspect_with_objdump",
|
||||
command,
|
||||
input.operation === "architectures" ? undefined : input.path,
|
||||
input,
|
||||
parameters,
|
||||
options.signal,
|
||||
);
|
||||
}
|
||||
@@ -581,6 +586,12 @@ export class ReverseEngineeringService {
|
||||
]),
|
||||
"A non-zero process exit is preserved as an observed command result.",
|
||||
...(operation === "inspect_with_objdump" &&
|
||||
typeof parameters.symbol === "string"
|
||||
? [
|
||||
"Disassembly is scoped to the caller-selected symbol using the provider's matching and symbol-boundary semantics; an empty match does not establish artifact-wide absence.",
|
||||
]
|
||||
: []),
|
||||
...(operation === "inspect_with_objdump" &&
|
||||
parameters.follow_debug_links === true
|
||||
? [
|
||||
"DWARF output may include local debug-link files whose identities are not bound to Evidence.",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { evidenceSchema, type Evidence } from "../domain/evidence.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import { err } from "../domain/result.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { OBJDUMP_ARTIFACT_OPERATIONS } from "../domain/reverseEngineering.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import type { CliInstance } from "./types.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
@@ -112,18 +113,16 @@ export const registerReverseEngineeringCommands = (
|
||||
.min(1)
|
||||
.describe("Path to the local artifact to inspect."),
|
||||
operation: z
|
||||
.enum([
|
||||
"file_headers",
|
||||
"section_headers",
|
||||
"symbols",
|
||||
"relocations",
|
||||
"dwarf",
|
||||
"disassemble",
|
||||
"disassemble_all",
|
||||
])
|
||||
.enum(OBJDUMP_ARTIFACT_OPERATIONS)
|
||||
.describe("GNU objdump inspection operation to run."),
|
||||
}),
|
||||
options: z.object({
|
||||
symbol: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Native symbol to select with the disassemble operation; GNU objdump controls matching and boundaries.",
|
||||
),
|
||||
followDebugLinks: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
@@ -139,6 +138,9 @@ export const registerReverseEngineeringCommands = (
|
||||
{
|
||||
...args,
|
||||
follow_debug_links: options.followDebugLinks,
|
||||
...(options.symbol === undefined
|
||||
? {}
|
||||
: { symbol: options.symbol }),
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
|
||||
@@ -29,7 +29,7 @@ export const REVERSE_ENGINEERING_TOOL_CONTRACTS = [
|
||||
...toolContractMetadata("inspect_with_objdump"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Run caller-selected GNU objdump inspection against a local artifact, or request operation=architectures without a path to list BFD targets advertised by the installed binary. Preserves decoded stdout and stderr inline with SHA-256 Evidence for artifact inspections, up to a shared 16 MiB combined limit; invalid UTF-8 bytes are represented by replacement characters and flagged in Evidence limitations. output_truncated reports output beyond that limit and completion_status is unknown if buffer enforcement terminated the process. The raw output is authoritative and REA does not claim a stable normalized format. DWARF does not follow local debug links by default. Set follow_debug_links to true to follow them; identities of external debug files are not bound to Evidence. REA clears DEBUGINFOD_URLS for objdump so DWARF processing does not make debuginfod network requests. Architecture output is subjectless and does not establish support for any artifact.",
|
||||
"Run caller-selected GNU objdump inspection against a local artifact, or request operation=architectures without a path to list BFD targets advertised by the installed binary. For operation=disassemble, optional symbol selects native symbol-scoped disassembly before output collection; omission preserves all code sections. Matching and symbol boundaries follow GNU objdump. Preserves decoded stdout and stderr inline with SHA-256 Evidence for artifact inspections, up to a shared 16 MiB combined limit; invalid UTF-8 bytes are represented by replacement characters and flagged in Evidence limitations. output_truncated reports output beyond that limit and completion_status is unknown if buffer enforcement terminated the process. The raw output is authoritative and REA does not claim a stable normalized format. DWARF does not follow local debug links by default. Set follow_debug_links to true to follow them; identities of external debug files are not bound to Evidence. REA clears DEBUGINFOD_URLS for objdump so DWARF processing does not make debuginfod network requests. Architecture output is subjectless and does not establish support for any artifact.",
|
||||
inputSchema: objdumpInputSchema,
|
||||
outputSchema: evidenceResultOf(commandObservationSchema),
|
||||
examples: [
|
||||
|
||||
@@ -67,6 +67,45 @@ it.each(["draft-2020-12", "draft-07"] as const)(
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["draft-2020-12", "draft-07"] as const)(
|
||||
"keeps objdump Unicode validation and portable SDK patterns in %s",
|
||||
(target) => {
|
||||
const canonical = toolInputSchemaWithMetadata(
|
||||
toolContract("inspect_with_objdump"),
|
||||
);
|
||||
const schema = canonical["~standard"].jsonSchema.input({ target });
|
||||
const input = { path: "sample.elf", operation: "disassemble" };
|
||||
for (const unicodeRegExp of [false, true]) {
|
||||
const options = { strict: false, validateFormats: false, unicodeRegExp };
|
||||
const ajv =
|
||||
target === "draft-2020-12" ? new Ajv2020(options) : new Ajv(options);
|
||||
expect(ajv.validateSchema(schema)).toBe(true);
|
||||
const validate = ajv.compile(schema);
|
||||
for (const symbol of [
|
||||
"known_function",
|
||||
"selector_\u{1F680}",
|
||||
"selector_\uFFFD",
|
||||
]) {
|
||||
expect(
|
||||
validate({ ...input, symbol }),
|
||||
JSON.stringify(validate.errors),
|
||||
).toBe(true);
|
||||
expect(canonical.safeParse({ ...input, symbol }).success).toBe(true);
|
||||
}
|
||||
expect(validate({ ...input, symbol: "a\u0000b" })).toBe(false);
|
||||
}
|
||||
// Scalar Unicode engines cannot express unpaired UTF-16 surrogates. The
|
||||
// registered canonical validator owns that runtime constraint.
|
||||
for (const symbol of [
|
||||
"\ud800",
|
||||
"\udc00",
|
||||
"prefix\ud800suffix",
|
||||
"\udc00\ud800",
|
||||
])
|
||||
expect(canonical.safeParse({ ...input, symbol }).success).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
describe("advertised object dependencies", () => {
|
||||
it.each([
|
||||
["draft-07", "dependencies"],
|
||||
|
||||
@@ -1,24 +1,44 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const OBJDUMP_ARTIFACT_OPERATIONS = [
|
||||
"file_headers",
|
||||
"section_headers",
|
||||
"symbols",
|
||||
"relocations",
|
||||
"dwarf",
|
||||
"disassemble",
|
||||
"disassemble_all",
|
||||
] as const;
|
||||
|
||||
const objdumpArtifactInputSchema = z
|
||||
.object({
|
||||
path: z.string().min(1),
|
||||
operation: z.enum([
|
||||
"file_headers",
|
||||
"section_headers",
|
||||
"symbols",
|
||||
"relocations",
|
||||
"dwarf",
|
||||
"disassemble",
|
||||
"disassemble_all",
|
||||
]),
|
||||
operation: z.enum(OBJDUMP_ARTIFACT_OPERATIONS).exclude(["disassemble"]),
|
||||
follow_debug_links: z.boolean().default(false),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const objdumpDisassemblyInputSchema = objdumpArtifactInputSchema.extend({
|
||||
operation: z.literal("disassemble"),
|
||||
symbol: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^[^\x00]*$/u)
|
||||
// Keep UTF-16 checks out of patterns consumed by scalar Unicode engines.
|
||||
.refine(
|
||||
(symbol) => !/[\uD800-\uDFFF]/u.test(symbol),
|
||||
"Symbol must be well-formed Unicode.",
|
||||
)
|
||||
.describe(
|
||||
"Native symbol to disassemble, expressed as well-formed Unicode. Matching and symbol boundaries follow GNU objdump; omitted means all code sections.",
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/** Caller-selected artifact facet or pathless provider capability query. */
|
||||
export const objdumpInputSchema = z.discriminatedUnion("operation", [
|
||||
objdumpArtifactInputSchema,
|
||||
objdumpDisassemblyInputSchema,
|
||||
z.strictObject({ operation: z.literal("architectures") }),
|
||||
]);
|
||||
|
||||
|
||||
@@ -16,7 +16,10 @@ export const objdumpCommand = (input: ObjdumpInput): readonly string[] => {
|
||||
symbols: ["-t"],
|
||||
relocations: ["-r"],
|
||||
dwarf: ["-W", input.follow_debug_links ? "-WK" : "-WN"],
|
||||
disassemble: ["-d"],
|
||||
disassemble:
|
||||
input.operation === "disassemble" && input.symbol !== undefined
|
||||
? [`--disassemble=${input.symbol}`]
|
||||
: ["-d"],
|
||||
disassemble_all: ["-D"],
|
||||
} satisfies Record<typeof input.operation, readonly string[]>;
|
||||
return [
|
||||
|
||||
@@ -50,13 +50,14 @@ const observation = (completionStatus: "complete" | "unknown") =>
|
||||
const runObjdump = async (
|
||||
service: Partial<ReverseEngineeringService>,
|
||||
path = "/tmp/sample",
|
||||
selection: { operation?: string; symbol?: string } = {},
|
||||
) => {
|
||||
const commands = new Map<
|
||||
string,
|
||||
{
|
||||
run(input: {
|
||||
args: { path: string; operation: string };
|
||||
options: { followDebugLinks: boolean };
|
||||
options: { followDebugLinks: boolean; symbol?: string };
|
||||
}): Promise<unknown>;
|
||||
}
|
||||
>();
|
||||
@@ -66,7 +67,7 @@ const runObjdump = async (
|
||||
definition: {
|
||||
run(input: {
|
||||
args: { path: string; operation: string };
|
||||
options: { followDebugLinks: boolean };
|
||||
options: { followDebugLinks: boolean; symbol?: string };
|
||||
}): Promise<unknown>;
|
||||
},
|
||||
) => {
|
||||
@@ -85,11 +86,31 @@ const runObjdump = async (
|
||||
if (command === undefined)
|
||||
throw new Error("objdump CLI command was not registered");
|
||||
return command.run({
|
||||
args: { path, operation: "file_headers" },
|
||||
options: { followDebugLinks: false },
|
||||
args: { path, operation: selection.operation ?? "file_headers" },
|
||||
options: {
|
||||
followDebugLinks: false,
|
||||
...(selection.symbol === undefined ? {} : { symbol: selection.symbol }),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it("forwards the selected native symbol to the shared workflow", async () => {
|
||||
const inspectWithObjdump = vi.fn(async (_input: unknown) =>
|
||||
ok(observation("complete")),
|
||||
);
|
||||
await runObjdump(
|
||||
{ inspectWithObjdump, close: async () => undefined },
|
||||
"/tmp/sample",
|
||||
{ operation: "disassemble", symbol: "_Z3foov@@VERSION_1" },
|
||||
);
|
||||
expect(inspectWithObjdump.mock.calls[0]?.[0]).toEqual({
|
||||
path: "/tmp/sample",
|
||||
operation: "disassemble",
|
||||
follow_debug_links: false,
|
||||
symbol: "_Z3foov@@VERSION_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("lists objdump architectures without inventing an artifact path", async () => {
|
||||
const evidence = observation("complete");
|
||||
const inspectWithObjdump = vi.fn(async (_input: unknown) => ok(evidence));
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { rm } from "node:fs/promises";
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
import { createArtifact } from "../support/reverseEngineeringCompositionFixtures.js";
|
||||
import { TestReverseEngineeringService } from "../support/reverseEngineeringService.js";
|
||||
|
||||
it("keeps omitted and undefined selection out of default command Evidence", async () => {
|
||||
const { directory, path } = await createArtifact();
|
||||
const run = vi.fn(async (_command: string, _args: readonly string[]) => ({
|
||||
stdout: "complete disassembly\n",
|
||||
stderr: "",
|
||||
}));
|
||||
const service = new TestReverseEngineeringService({ run });
|
||||
try {
|
||||
for (const selector of [{}, { symbol: undefined }]) {
|
||||
const result = await service.inspectWithObjdump({
|
||||
path,
|
||||
operation: "disassemble",
|
||||
...selector,
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.parameters).not.toHaveProperty("symbol");
|
||||
expect(result.value.parameters.command_arguments).toEqual(["-d", path]);
|
||||
}
|
||||
} finally {
|
||||
await service.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
"curl_version_info",
|
||||
"_Z3foov@@VERSION_1",
|
||||
"--leading-option with spaces\nand-newline",
|
||||
"native_\u{1F680}_symbol",
|
||||
"native_\uFFFD_symbol",
|
||||
])(
|
||||
"preserves native symbol %j as one argument and scoped Evidence",
|
||||
async (symbol) => {
|
||||
const { directory, path } = await createArtifact();
|
||||
const run = vi.fn(async (_command: string, _args: readonly string[]) => ({
|
||||
stdout: "selected disassembly\n",
|
||||
stderr: "",
|
||||
}));
|
||||
const service = new TestReverseEngineeringService({ run });
|
||||
try {
|
||||
const result = await service.inspectWithObjdump({
|
||||
path,
|
||||
operation: "disassemble",
|
||||
symbol,
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(run.mock.calls[0]?.[1]).toEqual([`--disassemble=${symbol}`, path]);
|
||||
expect(result.value.parameters).toMatchObject({
|
||||
operation: "disassemble",
|
||||
symbol,
|
||||
command_arguments: [`--disassemble=${symbol}`, path],
|
||||
});
|
||||
expect(result.value.normalized_result).toMatchObject({
|
||||
stdout: "selected disassembly\n",
|
||||
output_truncated: false,
|
||||
artifact_changed: false,
|
||||
completion_status: "complete",
|
||||
});
|
||||
expect(result.value.limitations).toContain(
|
||||
"Disassembly is scoped to the caller-selected symbol using the provider's matching and symbol-boundary semantics; an empty match does not establish artifact-wide absence.",
|
||||
);
|
||||
} finally {
|
||||
await service.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"architectures",
|
||||
"file_headers",
|
||||
"section_headers",
|
||||
"symbols",
|
||||
"relocations",
|
||||
"dwarf",
|
||||
"disassemble_all",
|
||||
])(
|
||||
"rejects symbol selection for %s before reading or launching",
|
||||
async (operation) => {
|
||||
const digest = vi.fn();
|
||||
const run = vi.fn();
|
||||
const service = new TestReverseEngineeringService({ digest, run });
|
||||
const result = await service.inspectWithObjdump({
|
||||
...(operation === "architectures" ? {} : { path: "/unread-artifact" }),
|
||||
operation,
|
||||
symbol: "my_function",
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisInputError" },
|
||||
});
|
||||
expect(digest).not.toHaveBeenCalled();
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
await service.close();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"",
|
||||
"a\u0000b",
|
||||
"\ud800",
|
||||
"\udc00",
|
||||
"prefix\ud800suffix",
|
||||
"prefix\udc00\ud800suffix",
|
||||
null,
|
||||
1,
|
||||
])("rejects invalid symbol %j before artifact admission", async (symbol) => {
|
||||
const digest = vi.fn();
|
||||
const run = vi.fn();
|
||||
const service = new TestReverseEngineeringService({ digest, run });
|
||||
const result = await service.inspectWithObjdump({
|
||||
path: "/unread-artifact",
|
||||
operation: "disassemble",
|
||||
symbol,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisInputError" },
|
||||
});
|
||||
expect(digest).not.toHaveBeenCalled();
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
await service.close();
|
||||
});
|
||||
Reference in new issue
Block a user