mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(ghidra): report unbacked file-offset addresses without schema repair (#8308)
* fix(ghidra): report unbacked file-offset addresses without schema repair address_to_file_offset projected a well-formed address in uninitialized or unmapped Ghidra memory as generic invalid input: the headline said the arguments were invalid and recovery pointed at the input schema, while the real reason survived only in a nested issue. The bridge now gives each outcome its own code (address_unmapped, address_uninitialized, file_mapping_absent, file_mapping_ambiguous, file_offset_unrepresentable) instead of folding them into invalid_request. The Ghidra projection keeps the bridge reason as the headline, points to inspect_native_load_image, records the caller's exact address and the file_mapping outcome, and scopes diagnostics to the request. AnalysisInputError gains conformsToInputContract so a conforming request omits recovery.kind=inspect_input_contract. Malformed addresses and other bridge refusals keep their existing projection. Fixes #8244 Co-Authored-By: Claude Opus 5.5 <[email protected]> * test(ghidra): match fixture block ends to bridge conventions The fake bridge treated .text's exclusive list_segments end (0x1100) as an inclusive bound, so address_to_file_offset mapped an address outside the block. Give the fixture's blocks one owner (start and size) and derive each reply from the production bridge convention: exclusive list_segments ends, inclusive byte-search coverage, half-open containment for file offsets. Add an MCP boundary test that reads the block edges from list_segments and search_bytes, maps the last file-backed byte and refuses the first address past each block with its outcome code. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
7c9ed94259
commit
c287a73276
15 files changed
+688
-22
No files matched your search
@@ -928,17 +928,33 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
requireKeys(params, Set.of("document", "address"));
|
||||
requireDocument(params);
|
||||
Address address = requireAddress(params, "address");
|
||||
String at = canonicalAddress(address);
|
||||
// A well-formed address can still lack one exact source offset. Each
|
||||
// outcome has its own code, so REA reports the reason rather than
|
||||
// schema repair. Initialization, not the loaded flag, decides whether
|
||||
// source bytes can exist: file-backed overlays are not loaded.
|
||||
MemoryBlock block = currentProgram.getMemory().getBlock(address);
|
||||
if (block == null || !block.isInitialized())
|
||||
throw new RequestFailure("invalid_request", "Address has no initialized file-backed memory: " + canonicalAddress(address));
|
||||
if (block == null)
|
||||
throw new RequestFailure("address_unmapped",
|
||||
"Address " + at + " is outside every Ghidra memory block");
|
||||
if (!block.isInitialized())
|
||||
throw new RequestFailure("address_uninitialized",
|
||||
"Address " + at + " is in uninitialized Ghidra memory block " + block.getName());
|
||||
TargetFileOffset mapping = targetFileOffset(block, address);
|
||||
if (mapping.mappings() != 1)
|
||||
throw new RequestFailure("invalid_request", "Address has " + mapping.mappings() + " authoritative target-file mappings: " + canonicalAddress(address));
|
||||
if (mapping.mappings() == 0)
|
||||
throw new RequestFailure("file_mapping_absent",
|
||||
"Address " + at + " in initialized Ghidra memory block " + block.getName() +
|
||||
" has no authoritative mapping to the admitted target file");
|
||||
if (mapping.mappings() > 1)
|
||||
throw new RequestFailure("file_mapping_ambiguous",
|
||||
"Address " + at + " has " + mapping.mappings() +
|
||||
" authoritative mappings to the admitted target file");
|
||||
Long fileOffset = mapping.offset();
|
||||
if (fileOffset == null)
|
||||
throw new RequestFailure("invalid_request", "Address lacks a safe file offset: " + canonicalAddress(address));
|
||||
throw new RequestFailure("file_offset_unrepresentable",
|
||||
"Address " + at + " maps to an admitted target-file offset beyond the exact JSON integer range");
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("address", canonicalAddress(address));
|
||||
result.addProperty("address", at);
|
||||
result.addProperty("file_offset", fileOffset);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@ operation-scoped JVM `OutOfMemoryError` observations retain their separate resou
|
||||
classification. Automatic kernel or cgroup OOM attribution is not implemented.
|
||||
|
||||
After the authenticated startup handshake, rejected inputs (`invalid_request`,
|
||||
`not_found`, `ambiguous`, or an invalid function name) use
|
||||
`not_found`, `ambiguous`, an invalid function name, or an address without one
|
||||
exact file offset) use
|
||||
`diagnostics.diagnostic_scope: "request"` when their active output checkpoint is
|
||||
available. They preserve the active request's
|
||||
`failure_output`, target/profile identity and runtime/log coordinates, while
|
||||
|
||||
+12
-1
@@ -161,7 +161,9 @@ for the error code, target details, partial observations, remediation, and the
|
||||
optional bounded `recovery` next step. `recovery.kind=inspect_input_contract`
|
||||
names the rejected operation: re-read that operation's advertised input schema
|
||||
(call `tools/list` if the catalog may be stale), correct the listed fields, and
|
||||
do not repeat the unchanged call. `recovery.kind=inspect_timeout_state` names
|
||||
do not repeat the unchanged call. A well-formed request the selected target
|
||||
cannot answer, such as an address without one exact file offset, omits this
|
||||
hint; follow its `remediation.action`. `recovery.kind=inspect_timeout_state` names
|
||||
the operation and the REA server/provider deadline. Inspect partial Evidence
|
||||
and reported cleanup state, then follow the returned remediation. Change a
|
||||
request field or REA_* setting only when the returned remediation explicitly
|
||||
@@ -1119,6 +1121,15 @@ mapping. A successful start-address mapping does not establish that an entire
|
||||
range is contiguous in the original file, or that its bytes equal analysis
|
||||
memory after loader fixups. Uninitialized, missing, and ambiguous mappings fail
|
||||
explicitly; preserve the reported failure rather than guessing an offset.
|
||||
For Ghidra, such a well-formed address returns `invalid_request` whose message
|
||||
states the bridge's reason and whose remediation points to
|
||||
`inspect_native_load_image`. It carries no `inspect_input_contract` recovery.
|
||||
`details.issues[0].observed` keeps the caller's exact `address` and a
|
||||
`file_mapping` outcome: `unmapped` (outside every memory block),
|
||||
`uninitialized`, `absent` (no authoritative target-file source), `ambiguous`,
|
||||
or `unrepresentable` (beyond the exact JSON integer range). Initialization, not
|
||||
the loaded flag, decides whether source bytes can exist, so an initialized
|
||||
file-backed overlay still maps.
|
||||
|
||||
For constructor-populated pointer or resource slots, a static zero is not proof
|
||||
of a later null value. Inspect the slot's `xrefs` and the writer's assembly or
|
||||
|
||||
+3
-2
@@ -1545,8 +1545,9 @@ real-provider coverage.
|
||||
For source-identity boundaries that a normal single-file import cannot create,
|
||||
`tests/conformance/ghidra/ReaFileOffsetSourceProbe.java` exercises the production
|
||||
bridge against real Ghidra `FileBytes`. It rejects auxiliary and partial sources,
|
||||
checks the original target's round trip, and retains ambiguity when duplicate
|
||||
target bytes have distinct loaded mappings. Injected decompiler failures and
|
||||
keeps distinct outcome codes for uninitialized and unmapped addresses, checks
|
||||
the original target's round trip, and retains ambiguity when duplicate target
|
||||
bytes have distinct loaded mappings. Injected decompiler failures and
|
||||
cancellation verify that completed source/function resolution facts and backend
|
||||
failure codes survive; these injections do not establish natural engine failures.
|
||||
On Windows, after creating the
|
||||
|
||||
@@ -119,6 +119,7 @@ Then rerun this installer.",implemented,verified by shell branch inspection
|
||||
90.5,@modelcontextprotocol/server:tool input validation,Caller sends arguments that violate a declared tool schema,"Canonical invalid_request with the SDK diagnostic plus recovery.kind=inspect_input_contract and a bounded instruction to read the advertised schema or call tools/list",Keeps field/type correction while telling the agent how to recover; diagnostics over 4 KiB stay on the existing retained-error path,Normalize ordinary validation failures and retain oversized diagnostics,implemented,verified by real in-memory and stdio MCP client/server calls with wrong argument types
|
||||
90.6,incur:unknown CLI command,Caller enters an unknown REA command,"'COMMAND' is not a command for 'rea'. Suggested command: rea --help",Low risk; identifies input and exact discovery action,Keep framework-generated copy,unchanged,verified by real packaged dispatcher invocation
|
||||
90.7,src/ghidra/GhidraProviderClient.ts:projectPrimarySessionError,One procedure's decompile reaches REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS,"Ghidra decompilation of {entry} exceeded the {N}-second limit set by REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS. Only this procedure's decompilation was stopped; the Ghidra session remains usable.",Low risk; names the setting and keeps the session,Keep current copy,implemented,provider projection test and real-provider lane
|
||||
90.8,src/ghidra/GhidraFileOffsetFailure.ts:ghidraFileOffsetUnavailable,"address_to_file_offset receives a well-formed address that is unmapped, uninitialized, without an authoritative target-file source, ambiguous or beyond the exact JSON integer range",{bridge reason}. REA establishes no single exact original-file offset for this well-formed address and does not derive or choose one.,Previous copy called the input invalid and directed schema repair for a valid address,"Keep the bridge reason as the headline, point to inspect_native_load_image, omit recovery.kind=inspect_input_contract and record details.issues[0].observed.file_mapping",implemented,provider projection tests; MCP and CLI analysis path through the real Ghidra client with a simulated bridge; real Ghidra not run
|
||||
91,src/application/binaryDiagnostics/ByteEntropyService.ts (issues); src/domain/analysisErrorPresentation.ts (recovery),Invalid file or window,Expected a regular file.; Selected file could not be found.; Selected byte window starts beyond the end of the file.; Selected byte window extends beyond the end of the file.; File size exceeds exact numeric offset representation.,Invalid selection cannot produce complete entropy Evidence,"Read the declared input schema or CLI usage for inspect_byte_entropy; if using MCP and the catalog may be stale, call tools/list. Correct the listed fields, then retry. Do not repeat the unchanged call.",unchanged,Executed CLI/stdio MCP: path/offset/length refusals and tiny + system-binary controls; shared canonical recovery; service/MCP tests check classification and fields; remaining listed errors source-inspected
|
||||
92,src/domain/analysisView/byteEntropyView.ts,Non-summary entropy view,Byte entropy Evidence supports the summary view; retrieve parent Evidence for the complete block series.,Unsupported view selection,Use the summary view or retrieve complete parent Evidence,implemented,typed input rejection
|
||||
93,src/windows/WindowsNativeLoader.ts:load refused,Windows refuses to load the digest-verified native addon (for example Smart App Control or App Control policy),"REA Windows native controls are unavailable: Windows refused to load the verified native/windows/build/rea-windows-x64.node: {OS error} The file matched the package manifest, so restoring or reinstalling it will not help. ...",Previous artifact copy told users to restore or rebuild files that were already intact,"Name the OS refusal, say restoring cannot help, state that Ghidra and JADX need native controls while setup can still register clients, and point to a policy-allowed host or a maintainer-signed build",implemented,simulated loader refusal unit test; real Smart App Control block not reproduced
|
||||
|
@@ -146,18 +146,26 @@ export class AnalysisInputError extends AnalysisError {
|
||||
override readonly userMessage: string | undefined;
|
||||
/** Refusal-specific correction, when the generic input-contract advice cannot fix it. */
|
||||
readonly remediationAction: string | undefined;
|
||||
/**
|
||||
* The refused values satisfy the advertised input contract; the selected
|
||||
* target lacks the requested fact for them. Re-reading the schema cannot
|
||||
* correct such a refusal, so it carries no input-contract recovery.
|
||||
*/
|
||||
readonly conformsToInputContract: boolean;
|
||||
|
||||
constructor(
|
||||
readonly operation: string,
|
||||
options?: AnalysisErrorOptions & {
|
||||
readonly userMessage?: string;
|
||||
readonly remediationAction?: string;
|
||||
readonly conformsToInputContract?: boolean;
|
||||
},
|
||||
readonly issues: readonly AnalysisInputIssue[] = [],
|
||||
) {
|
||||
super(`Invalid analysis input for ${operation}`, options);
|
||||
this.userMessage = options?.userMessage;
|
||||
this.remediationAction = options?.remediationAction;
|
||||
this.conformsToInputContract = options?.conformsToInputContract ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,38 @@ describe("agent recovery metadata", () => {
|
||||
expect(analysisErrorProjectionSchema.parse(projected)).toEqual(projected);
|
||||
});
|
||||
|
||||
it("omits schema recovery when a conforming request lacks a target fact", () => {
|
||||
const issue = {
|
||||
path: ["address"],
|
||||
reason: "invalid_value",
|
||||
message: "Address 0x2000 is in uninitialized memory block .bss",
|
||||
observed: { address: "0x2000", file_mapping: "uninitialized" },
|
||||
} as const;
|
||||
const projected = projectAnalysisError(
|
||||
new AnalysisInputError(
|
||||
"address_to_file_offset",
|
||||
{
|
||||
userMessage: "No exact original-file offset exists.",
|
||||
remediationAction: "Select an address inside a file-backed mapping.",
|
||||
conformsToInputContract: true,
|
||||
},
|
||||
[issue],
|
||||
),
|
||||
);
|
||||
|
||||
expect(projected).toEqual({
|
||||
code: "invalid_request",
|
||||
category: "invalid_input",
|
||||
message: "No exact original-file offset exists.",
|
||||
retryable: false,
|
||||
remediation: {
|
||||
action: "Select an address inside a file-backed mapping.",
|
||||
},
|
||||
details: { operation: "address_to_file_offset", issues: [issue] },
|
||||
});
|
||||
expect(analysisErrorProjectionSchema.parse(projected)).toEqual(projected);
|
||||
});
|
||||
|
||||
it("separates timeout-state inspection from client deadline recovery", () => {
|
||||
const projected = projectAnalysisError(
|
||||
new AnalysisTimeoutError("inspect_binary_layout", 2_500),
|
||||
|
||||
@@ -157,10 +157,9 @@ const errorRecovery = (
|
||||
error: AnalysisError,
|
||||
): AnalysisErrorRecovery | undefined => {
|
||||
if (error instanceof AnalysisInputError)
|
||||
return {
|
||||
kind: "inspect_input_contract",
|
||||
operation: error.operation,
|
||||
};
|
||||
return error.conformsToInputContract
|
||||
? undefined
|
||||
: { kind: "inspect_input_contract", operation: error.operation };
|
||||
if (error instanceof AnalysisTimeoutError)
|
||||
return {
|
||||
kind: "inspect_timeout_state",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { AnalysisErrorOptions } from "../domain/analysisErrorBase.js";
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
|
||||
/**
|
||||
* Bridge codes for a well-formed address without one exact original-file
|
||||
* offset. The bridge owns the classification; consumers read these codes,
|
||||
* never its message text.
|
||||
*/
|
||||
const FILE_OFFSET_OUTCOMES = {
|
||||
address_unmapped: "unmapped",
|
||||
address_uninitialized: "uninitialized",
|
||||
file_mapping_absent: "absent",
|
||||
file_mapping_ambiguous: "ambiguous",
|
||||
file_offset_unrepresentable: "unrepresentable",
|
||||
} as const;
|
||||
|
||||
/** Why Ghidra established no single exact offset for a well-formed address. */
|
||||
export type GhidraFileOffsetOutcome =
|
||||
(typeof FILE_OFFSET_OUTCOMES)[keyof typeof FILE_OFFSET_OUTCOMES];
|
||||
|
||||
/** Classify a bridge code, or undefined for any other remote failure. */
|
||||
export const ghidraFileOffsetOutcome = (
|
||||
code: string | undefined,
|
||||
): GhidraFileOffsetOutcome | undefined =>
|
||||
code !== undefined && Object.hasOwn(FILE_OFFSET_OUTCOMES, code)
|
||||
? FILE_OFFSET_OUTCOMES[code as keyof typeof FILE_OFFSET_OUTCOMES]
|
||||
: undefined;
|
||||
|
||||
const LOAD_IMAGE =
|
||||
"inspect_native_load_image (CLI: `rea inspect-native-load-image <path> --provider ghidra --json`)";
|
||||
const UNCHANGED = "Do not repeat the unchanged call.";
|
||||
const SELECT_MAPPED = `Review Ghidra's memory blocks and source-file mappings with ${LOAD_IMAGE}, then select an address inside an initialized mapping of the admitted file. ${UNCHANGED}`;
|
||||
|
||||
const REMEDIATION: Readonly<Record<GhidraFileOffsetOutcome, string>> = {
|
||||
unmapped: SELECT_MAPPED,
|
||||
uninitialized: SELECT_MAPPED,
|
||||
absent: SELECT_MAPPED,
|
||||
ambiguous: `Review the candidate source-file mappings with ${LOAD_IMAGE} and keep each candidate offset distinct; REA does not choose one. ${UNCHANGED}`,
|
||||
unrepresentable: `Review Ghidra's source-file mappings with ${LOAD_IMAGE}; REA reports offsets only within the exact JSON integer range. ${UNCHANGED}`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Report the bridge's reason as the headline. The request satisfied its
|
||||
* contract, so the projection omits schema-repair recovery and names the
|
||||
* mapping outcome beside the caller's exact address.
|
||||
*/
|
||||
export const ghidraFileOffsetUnavailable = (
|
||||
operation: string,
|
||||
outcome: GhidraFileOffsetOutcome,
|
||||
reason: string,
|
||||
address: string | undefined,
|
||||
options: AnalysisErrorOptions,
|
||||
): AnalysisInputError =>
|
||||
new AnalysisInputError(
|
||||
operation,
|
||||
{
|
||||
...options,
|
||||
userMessage: `${reason.trim().replace(/\.$/u, "")}. REA establishes no single exact original-file offset for this well-formed address and does not derive or choose one.`,
|
||||
remediationAction: REMEDIATION[outcome],
|
||||
conformsToInputContract: true,
|
||||
},
|
||||
[
|
||||
{
|
||||
path: ["address"],
|
||||
reason: "invalid_value",
|
||||
message: reason,
|
||||
observed: {
|
||||
...(address === undefined ? {} : { address }),
|
||||
file_mapping: outcome,
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
@@ -1338,6 +1338,121 @@ describe("Ghidra result projection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra file-offset outcome projection", () => {
|
||||
const remoteFailure = (remoteCode: string, message: string) =>
|
||||
provider(installationHost(), () => ({
|
||||
start: () => Promise.resolve(ok(sessionInfo())),
|
||||
callTool: () =>
|
||||
Promise.resolve(
|
||||
err(
|
||||
new GhidraSessionError(
|
||||
"remote",
|
||||
message,
|
||||
{ remote_code: remoteCode },
|
||||
{ remoteCode },
|
||||
),
|
||||
),
|
||||
),
|
||||
close: () => Promise.resolve(ok(null)),
|
||||
}));
|
||||
|
||||
it.each([
|
||||
[
|
||||
"address_unmapped",
|
||||
"unmapped",
|
||||
"Address 0x403030 is outside every Ghidra memory block",
|
||||
],
|
||||
[
|
||||
"address_uninitialized",
|
||||
"uninitialized",
|
||||
"Address 0x403010 is in uninitialized Ghidra memory block .bss",
|
||||
],
|
||||
[
|
||||
"file_mapping_absent",
|
||||
"absent",
|
||||
"Address 0x403010 in initialized Ghidra memory block auxiliary has no authoritative mapping to the admitted target file",
|
||||
],
|
||||
[
|
||||
"file_mapping_ambiguous",
|
||||
"ambiguous",
|
||||
"Address 0x403010 has 2 authoritative mappings to the admitted target file",
|
||||
],
|
||||
[
|
||||
"file_offset_unrepresentable",
|
||||
"unrepresentable",
|
||||
"Address 0x403010 maps to an admitted target-file offset beyond the exact JSON integer range",
|
||||
],
|
||||
] as const)(
|
||||
"reports %s as a conforming address without an exact offset",
|
||||
async (remoteCode, outcome, message) => {
|
||||
const client = await createElfClient(remoteFailure(remoteCode, message));
|
||||
// A non-canonical spelling the bridge accepts survives verbatim.
|
||||
const result = await client.execute("address_to_file_offset", {
|
||||
address: "0X00403010",
|
||||
});
|
||||
if (result.ok) throw new Error("Expected a file-offset outcome");
|
||||
const projected = projectAnalysisError(result.error);
|
||||
expect(projected).toMatchObject({
|
||||
code: "invalid_request",
|
||||
category: "invalid_input",
|
||||
retryable: false,
|
||||
details: {
|
||||
operation: "address_to_file_offset",
|
||||
issues: [
|
||||
{
|
||||
path: ["address"],
|
||||
reason: "invalid_value",
|
||||
message,
|
||||
observed: { address: "0X00403010", file_mapping: outcome },
|
||||
},
|
||||
],
|
||||
diagnostics: { remote_code: remoteCode },
|
||||
},
|
||||
});
|
||||
expect(projected.message.startsWith(`${message}. `)).toBe(true);
|
||||
expect(projected.message).not.toContain("Analysis input is invalid");
|
||||
expect(projected).not.toHaveProperty("recovery");
|
||||
expect(projected.remediation.action).toContain(
|
||||
"inspect_native_load_image",
|
||||
);
|
||||
expect(projected.remediation.action).not.toContain("schema");
|
||||
expect(projected.remediation.action).not.toContain("tools/list");
|
||||
await client.close();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["address_to_file_offset", "invalid_request"],
|
||||
["procedure_pseudo_code", "not_found"],
|
||||
] as const)(
|
||||
"keeps schema recovery for an ordinary %s %s refusal",
|
||||
async (operation, remoteCode) => {
|
||||
const client = await createElfClient(
|
||||
remoteFailure(remoteCode, "Fixture bridge rejected the request"),
|
||||
);
|
||||
const result = await client.execute(
|
||||
operation,
|
||||
operation === "address_to_file_offset"
|
||||
? { address: "0x403010" }
|
||||
: { procedure: "missing" },
|
||||
);
|
||||
if (result.ok) throw new Error("Expected an input refusal");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "invalid_request",
|
||||
message:
|
||||
"Analysis input is invalid. Check the arguments and try again.",
|
||||
recovery: { kind: "inspect_input_contract", operation },
|
||||
details: {
|
||||
issues: [
|
||||
{ path: [], message: "Fixture bridge rejected the request" },
|
||||
],
|
||||
},
|
||||
});
|
||||
await client.close();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Ghidra decompiler failure projection", () => {
|
||||
it("reports a procedure decompiler failure without blaming the environment", async () => {
|
||||
const code = "decompile_failed";
|
||||
|
||||
@@ -111,6 +111,10 @@ import {
|
||||
isGhidraInputFailureCode,
|
||||
type GhidraSessionError,
|
||||
} from "./GhidraSessionError.js";
|
||||
import {
|
||||
ghidraFileOffsetOutcome,
|
||||
ghidraFileOffsetUnavailable,
|
||||
} from "./GhidraFileOffsetFailure.js";
|
||||
import type { GhidraSessionInfo } from "./GhidraSessionValues.js";
|
||||
import { ghidraExtensionFailure } from "./extensions/GhidraExtensionFailures.js";
|
||||
import {
|
||||
@@ -827,6 +831,7 @@ export const createGhidraProviderClient = (input: {
|
||||
called.error,
|
||||
projection,
|
||||
lostProviderHealth(),
|
||||
input.value,
|
||||
),
|
||||
);
|
||||
if (operation === "inspect_native_load_image")
|
||||
@@ -1037,12 +1042,14 @@ const projectSessionError = (
|
||||
failure: GhidraSessionError,
|
||||
projection: GhidraProjection,
|
||||
health?: ProviderOperationHealth,
|
||||
request?: Readonly<Record<string, JsonValue>>,
|
||||
): AnalysisError => {
|
||||
const primary = projectPrimarySessionError(
|
||||
operation,
|
||||
failure,
|
||||
projection,
|
||||
health,
|
||||
request,
|
||||
);
|
||||
return failure.cleanupFailure === undefined
|
||||
? primary
|
||||
@@ -1058,6 +1065,7 @@ const projectPrimarySessionError = (
|
||||
failure: GhidraSessionError,
|
||||
{ limits, programName }: GhidraProjection,
|
||||
health?: ProviderOperationHealth,
|
||||
request?: Readonly<Record<string, JsonValue>>,
|
||||
): AnalysisError => {
|
||||
const startupTimeoutMs = limits.ghidraStartupTimeoutMs;
|
||||
const context = {
|
||||
@@ -1101,6 +1109,20 @@ const projectPrimarySessionError = (
|
||||
physicalCause.reason,
|
||||
context,
|
||||
);
|
||||
// A well-formed address that lacks one exact source offset keeps the
|
||||
// bridge's reason and the caller's exact spelling, not schema advice.
|
||||
const fileOffset =
|
||||
failure.kind === "remote"
|
||||
? ghidraFileOffsetOutcome(failure.remoteCode)
|
||||
: undefined;
|
||||
if (fileOffset !== undefined)
|
||||
return ghidraFileOffsetUnavailable(
|
||||
operation,
|
||||
fileOffset,
|
||||
failure.message,
|
||||
typeof request?.address === "string" ? request.address : undefined,
|
||||
context,
|
||||
);
|
||||
if (
|
||||
operation === "annotate_native_function" &&
|
||||
failure.kind === "remote" &&
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js";
|
||||
import { boundedUtf8Text } from "../process/boundedUtf8Text.js";
|
||||
import type { GhidraDiagnosticsScope } from "./GhidraDiagnostics.js";
|
||||
import { ghidraFileOffsetOutcome } from "./GhidraFileOffsetFailure.js";
|
||||
import { GhidraAdmissionError } from "./GhidraAdmissionError.js";
|
||||
import { GhidraLaunchSupportError } from "./GhidraJavaLaunch.js";
|
||||
import {
|
||||
@@ -214,7 +215,8 @@ export const bindGhidraSessionFailure =
|
||||
...diagnostics(
|
||||
kind === "remote" &&
|
||||
(isGhidraInputFailureCode(options.remoteCode) ||
|
||||
options.remoteCode === "invalid_function_name")
|
||||
options.remoteCode === "invalid_function_name" ||
|
||||
ghidraFileOffsetOutcome(options.remoteCode) !== undefined)
|
||||
? "request"
|
||||
: "session",
|
||||
),
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { access } from "node:fs/promises";
|
||||
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js";
|
||||
import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js";
|
||||
import { runDirectAnalysis } from "../../../src/application/DirectAnalysis.js";
|
||||
import type { DirectAnalysisDependencies } from "../../../src/application/DirectAnalysisDependencies.js";
|
||||
import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js";
|
||||
import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { analysisCliErrorEnvelopeSchema } from "../../../src/contracts/errorSchemas.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { GhidraClient } from "../../../src/ghidra/GhidraClient.js";
|
||||
import { ghidraProfileInstallationHost } from "../../../src/ghidra/GhidraInstallation.fixture.js";
|
||||
import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { GhidraFixtureLauncher } from "../providers/ghidra/ghidraClient.fixture.js";
|
||||
|
||||
// Only installation discovery and the Java bridge are simulated. The bridge
|
||||
// fixture answers with the production address_to_file_offset codes; requests
|
||||
// cross the real client socket, provider projection and public adapters.
|
||||
const ghidraSession = (launcher: GhidraFixtureLauncher) => {
|
||||
const config = parseConfig({ GHIDRA_INSTALL_DIR: "/fixture/ghidra" });
|
||||
if (!config.ok) throw config.error;
|
||||
return composeBinarySession(
|
||||
new AnalysisProviderRegistry([
|
||||
new GhidraProvider(
|
||||
config.value,
|
||||
silentLogger,
|
||||
{},
|
||||
ghidraProfileInstallationHost,
|
||||
(options) =>
|
||||
new GhidraClient({
|
||||
...options,
|
||||
platform: process.platform,
|
||||
launcher,
|
||||
}),
|
||||
),
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
const expectReleased = async (launcher: GhidraFixtureLauncher) => {
|
||||
for (const root of launcher.runtimeRoots)
|
||||
await expect(access(root)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
for (const child of launcher.processes)
|
||||
expect(child.exitCode !== null || child.signalCode !== null).toBe(true);
|
||||
};
|
||||
|
||||
const sessionStatus = z.object({
|
||||
result: z.object({
|
||||
analysis_run: z.object({ run_id: z.string() }),
|
||||
capabilities: z.array(
|
||||
z.object({
|
||||
groups: z.array(
|
||||
z.object({
|
||||
operations: z.array(z.string()),
|
||||
available: z.boolean(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
/** Open the process executable through the fixture bridge and public MCP. */
|
||||
const connectFileOffsetMcp = async () => {
|
||||
const launcher = new GhidraFixtureLauncher();
|
||||
const harness = await createApplicationMcpHarness({
|
||||
session: ghidraSession(launcher),
|
||||
serverOptions: { logger: silentLogger },
|
||||
clientIdentity: { name: "ghidra-file-offset", version: "1" },
|
||||
});
|
||||
const tool = (name: string, arguments_: Record<string, string> = {}) =>
|
||||
harness.client.callTool({ name, arguments: arguments_ });
|
||||
const call = (address: string) => tool("address_to_file_offset", { address });
|
||||
const mapped = async (address: string) => {
|
||||
const reply = await call(address);
|
||||
expect(reply.isError, JSON.stringify(reply)).not.toBe(true);
|
||||
return parseEvidence(reply.structuredContent).normalized_result;
|
||||
};
|
||||
const refused = async (address: string) =>
|
||||
parseMcpToolError(await call(address)).error;
|
||||
// A per-address outcome must not mark the operation unavailable.
|
||||
const runId = async () => {
|
||||
const status = sessionStatus.parse(
|
||||
(await tool("binary_session")).structuredContent,
|
||||
).result;
|
||||
expect(
|
||||
status.capabilities
|
||||
.flatMap(({ groups }) => groups)
|
||||
.filter(({ operations }) =>
|
||||
operations.includes("address_to_file_offset"),
|
||||
),
|
||||
).toEqual([expect.objectContaining({ available: true })]);
|
||||
return status.analysis_run.run_id;
|
||||
};
|
||||
const bridgeCalls = () =>
|
||||
launcher.requests.filter((method) => method === "address_to_file_offset");
|
||||
const close = async () => {
|
||||
await harness.close();
|
||||
await expectReleased(launcher);
|
||||
};
|
||||
try {
|
||||
const opened = await tool("open_binary", {
|
||||
path: process.execPath,
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
expect(opened.isError).not.toBe(true);
|
||||
} catch (cause) {
|
||||
await close();
|
||||
throw cause;
|
||||
}
|
||||
return { launcher, tool, mapped, refused, runId, bridgeCalls, close };
|
||||
};
|
||||
|
||||
const BSS_MESSAGE =
|
||||
"Address 0x2004 is in uninitialized Ghidra memory block .bss";
|
||||
|
||||
it("reports file-backed, uninitialized and unmapped addresses through MCP without schema repair", async () => {
|
||||
const mcp = await connectFileOffsetMcp();
|
||||
try {
|
||||
expect(await mcp.mapped("0x1005")).toEqual({
|
||||
address: "0x1005",
|
||||
file_offset: 5,
|
||||
});
|
||||
const run = await mcp.runId();
|
||||
|
||||
// The caller's non-canonical spelling survives beside the canonical reason.
|
||||
const bss = await mcp.refused("0X2004");
|
||||
expect(bss).toMatchObject({
|
||||
code: "invalid_request",
|
||||
category: "invalid_input",
|
||||
retryable: false,
|
||||
details: {
|
||||
operation: "address_to_file_offset",
|
||||
issues: [
|
||||
{
|
||||
path: ["address"],
|
||||
reason: "invalid_value",
|
||||
message: BSS_MESSAGE,
|
||||
observed: { address: "0X2004", file_mapping: "uninitialized" },
|
||||
},
|
||||
],
|
||||
diagnostics: {
|
||||
diagnostic_scope: "request",
|
||||
remote_code: "address_uninitialized",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(bss.message.startsWith(`${BSS_MESSAGE}. `)).toBe(true);
|
||||
expect(bss).not.toHaveProperty("recovery");
|
||||
expect(bss.remediation.action).toContain("inspect_native_load_image");
|
||||
expect(bss.remediation.action).not.toContain("tools/list");
|
||||
expect(bss.details?.diagnostics).not.toHaveProperty("stdout");
|
||||
|
||||
const unmapped = await mcp.refused("0x3000");
|
||||
expect(unmapped).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
issues: [
|
||||
{
|
||||
message: "Address 0x3000 is outside every Ghidra memory block",
|
||||
observed: { address: "0x3000", file_mapping: "unmapped" },
|
||||
},
|
||||
],
|
||||
diagnostics: { remote_code: "address_unmapped" },
|
||||
},
|
||||
});
|
||||
expect(unmapped).not.toHaveProperty("recovery");
|
||||
|
||||
// Malformed syntax is still an input-contract violation.
|
||||
expect(await mcp.refused("not-an-address")).toMatchObject({
|
||||
code: "invalid_request",
|
||||
message: "Analysis input is invalid. Check the arguments and try again.",
|
||||
recovery: {
|
||||
kind: "inspect_input_contract",
|
||||
operation: "address_to_file_offset",
|
||||
},
|
||||
details: {
|
||||
issues: [{ path: ["address"], reason: "invalid_format" }],
|
||||
},
|
||||
});
|
||||
|
||||
// Refusals keep the same provider session and operation availability.
|
||||
expect(await mcp.runId()).toBe(run);
|
||||
expect(await mcp.mapped("0x1000")).toEqual({
|
||||
address: "0x1000",
|
||||
file_offset: 0,
|
||||
});
|
||||
expect(mcp.launcher.processes).toHaveLength(1);
|
||||
// The malformed spelling never reached the bridge.
|
||||
await vi.waitFor(() => expect(mcp.bridgeCalls()).toHaveLength(4));
|
||||
} finally {
|
||||
await mcp.close();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
const segments = z.array(
|
||||
z.object({ name: z.string(), start: z.string(), end: z.string() }),
|
||||
);
|
||||
const skippedCoverage = z.object({
|
||||
searched: z.object({
|
||||
skipped_uninitialized: z.array(
|
||||
z.object({ segment: z.string(), start: z.string(), end: z.string() }),
|
||||
),
|
||||
}),
|
||||
});
|
||||
const hex = (value: bigint) => `0x${value.toString(16)}`;
|
||||
|
||||
it("maps the last byte of a file-backed block and refuses the first address past each block", async () => {
|
||||
const mcp = await connectFileOffsetMcp();
|
||||
try {
|
||||
// Take block edges from the bridge's own replies: list_segments ends are
|
||||
// exclusive and byte-search coverage ends are inclusive.
|
||||
const text = segments
|
||||
.parse(
|
||||
parseEvidence((await mcp.tool("list_segments")).structuredContent)
|
||||
.normalized_result,
|
||||
)
|
||||
.find(({ name }) => name === ".text");
|
||||
const bss = skippedCoverage
|
||||
.parse(
|
||||
parseEvidence(
|
||||
(await mcp.tool("search_bytes", { pattern: "48 8b" }))
|
||||
.structuredContent,
|
||||
).normalized_result,
|
||||
)
|
||||
.searched.skipped_uninitialized.find(({ segment }) => segment === ".bss");
|
||||
expect(text).toMatchObject({ start: "0x1000", end: "0x1100" });
|
||||
expect(bss).toMatchObject({ start: "0x2000", end: "0x200f" });
|
||||
if (text === undefined || bss === undefined)
|
||||
throw new Error("Fixture omitted its memory blocks");
|
||||
|
||||
const lastText = BigInt(text.end) - 1n;
|
||||
expect(await mcp.mapped(hex(lastText))).toEqual({
|
||||
address: hex(lastText),
|
||||
file_offset: Number(lastText - BigInt(text.start)),
|
||||
});
|
||||
for (const [value, remoteCode, outcome] of [
|
||||
[BigInt(text.end), "address_unmapped", "unmapped"],
|
||||
[BigInt(bss.start), "address_uninitialized", "uninitialized"],
|
||||
[BigInt(bss.end), "address_uninitialized", "uninitialized"],
|
||||
[BigInt(bss.end) + 1n, "address_unmapped", "unmapped"],
|
||||
] as const)
|
||||
expect(await mcp.refused(hex(value))).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
issues: [
|
||||
{ observed: { address: hex(value), file_mapping: outcome } },
|
||||
],
|
||||
diagnostics: { remote_code: remoteCode },
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(mcp.bridgeCalls()).toHaveLength(5));
|
||||
} finally {
|
||||
await mcp.close();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("gives the CLI analysis path the same unbacked-address outcome as MCP", async () => {
|
||||
const launcher = new GhidraFixtureLauncher();
|
||||
const create = () => ghidraSession(launcher);
|
||||
const dependencies: DirectAnalysisDependencies = {
|
||||
readConfiguration: () =>
|
||||
parseConfig({ GHIDRA_INSTALL_DIR: "/fixture/ghidra" }),
|
||||
createBinarySession: create,
|
||||
createNativeDispatchBinarySession: create,
|
||||
createArtifactBinarySession: create,
|
||||
createNativeBinarySession: create,
|
||||
createManagedBinarySession: create,
|
||||
};
|
||||
try {
|
||||
const projected = analysisCliErrorEnvelopeSchema.parse(
|
||||
projectCliAnalysisResult(
|
||||
await runDirectAnalysis(
|
||||
dependencies,
|
||||
process.execPath,
|
||||
"address_to_file_offset",
|
||||
{ address: "0X2004" },
|
||||
{ providerId: "ghidra" },
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(projected).toMatchObject({
|
||||
error: "Analysis failed",
|
||||
code: "invalid_request",
|
||||
category: "invalid_input",
|
||||
message: expect.stringMatching(/^Address 0x2004 is in uninitialized/u),
|
||||
remediation: {
|
||||
action: expect.stringContaining("inspect_native_load_image"),
|
||||
},
|
||||
details: {
|
||||
issues: [
|
||||
{
|
||||
path: ["address"],
|
||||
message: BSS_MESSAGE,
|
||||
observed: { address: "0X2004", file_mapping: "uninitialized" },
|
||||
},
|
||||
],
|
||||
diagnostics: { remote_code: "address_uninitialized" },
|
||||
},
|
||||
});
|
||||
expect(projected).not.toHaveProperty("recovery");
|
||||
} finally {
|
||||
await expectReleased(launcher);
|
||||
}
|
||||
}, 30_000);
|
||||
@@ -50,14 +50,23 @@ public final class ReaFileOffsetSourceProbe extends HeadlessScript {
|
||||
currentProgram.getFunctionManager().createFunction("foreign_only", entry,
|
||||
new AddressSet(entry), SourceType.USER_DEFINED);
|
||||
rejected(resolve, bridge, "no loaded source mapping", "file-offset:0x910", new JsonObject());
|
||||
rejected(reverse, bridge, "0 authoritative target-file mappings", reverseInput("0x140100910"));
|
||||
rejectedCode(reverse, bridge, "file_mapping_absent",
|
||||
"has no authoritative mapping to the admitted target file", reverseInput("0x140100910"));
|
||||
|
||||
// Well-formed addresses without source bytes keep distinct outcomes.
|
||||
memory.createUninitializedBlock("rea_uninitialized", toAddr(0x140400000L), 0x100, false);
|
||||
rejectedCode(reverse, bridge, "address_uninitialized",
|
||||
"is in uninitialized Ghidra memory block rea_uninitialized", reverseInput("0x140400010"));
|
||||
rejectedCode(reverse, bridge, "address_unmapped",
|
||||
"is outside every Ghidra memory block", reverseInput("0x140500000"));
|
||||
|
||||
// A source object's declared original-file start does not make its local
|
||||
// offsets a complete, digest-matched target representation.
|
||||
var partial = memory.createFileBytes("partial.bin", 0x200, 0x200,
|
||||
new ByteArrayInputStream(bytes, 0x200, 0x200), monitor);
|
||||
memory.createInitializedBlock("partial", toAddr(0x140300000L), partial, 0, 0x200, false);
|
||||
rejected(reverse, bridge, "0 authoritative target-file mappings", reverseInput("0x140300010"));
|
||||
rejectedCode(reverse, bridge, "file_mapping_absent",
|
||||
"has no authoritative mapping to the admitted target file", reverseInput("0x140300010"));
|
||||
var selected = (Function)resolve.invoke(bridge, "file-offset:0x210", new JsonObject());
|
||||
require(selected.getEntryPoint().equals(toAddr(0x140001010L)), "original target selected despite other sources");
|
||||
var mapped = (JsonObject)reverse.invoke(bridge, reverseInput("0x140001010"));
|
||||
@@ -92,7 +101,7 @@ public final class ReaFileOffsetSourceProbe extends HeadlessScript {
|
||||
new ByteArrayInputStream(bytes), monitor);
|
||||
memory.createInitializedBlock("duplicate", toAddr(0x140201000L), duplicate, 0x200, 0x200, false);
|
||||
rejected(resolve, bridge, "multiple loaded mappings", "file-offset:0x210", new JsonObject());
|
||||
println("REA_FILE_OFFSET_SOURCE_OK foreign/partial rejected; original round trip; duplicate ambiguity; injected backend failure context retained");
|
||||
println("REA_FILE_OFFSET_SOURCE_OK foreign/partial/uninitialized/unmapped rejected; original round trip; duplicate ambiguity; injected backend failure context retained");
|
||||
}
|
||||
|
||||
private JsonObject reverseInput(String address) {
|
||||
@@ -112,6 +121,13 @@ public final class ReaFileOffsetSourceProbe extends HeadlessScript {
|
||||
throw new AssertionError("Expected mapping failure: " + reason);
|
||||
}
|
||||
|
||||
private void rejectedCode(Method method, Object bridge, String code, String reason, Object... arguments) throws Exception {
|
||||
Throwable failure = rejected(method, bridge, reason, arguments);
|
||||
var field = failure.getClass().getDeclaredField("code");
|
||||
field.setAccessible(true);
|
||||
require(field.get(failure).equals(code), "expected mapping failure code: " + code);
|
||||
}
|
||||
|
||||
private void require(boolean condition, String message) {
|
||||
if (!condition) throw new AssertionError(message);
|
||||
}
|
||||
|
||||
Vendored
+62
-4
@@ -264,6 +264,12 @@ const handleRequest = (socket, server, request, state) => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (request.method === "address_to_file_offset") {
|
||||
socket.write(
|
||||
`${JSON.stringify({ id: request.id, ...fileOffsetReply(request.params.address) })}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const inventory =
|
||||
request.method === "list_strings" && state.mode === "fragmented"
|
||||
? [stringItem("0x2000", "fixture 雪🦊")]
|
||||
@@ -479,7 +485,12 @@ const inventoryResultFor = (method, params) => {
|
||||
],
|
||||
bytes: continued ? 5 : 10,
|
||||
skipped_uninitialized: [
|
||||
{ segment: ".bss", start: "0x2000", end: "0x200f", bytes: 16 },
|
||||
{
|
||||
segment: BSS_BLOCK.name,
|
||||
start: hex(BSS_BLOCK.start),
|
||||
end: hex(BSS_BLOCK.start + BSS_BLOCK.size - 1n),
|
||||
bytes: Number(BSS_BLOCK.size),
|
||||
},
|
||||
],
|
||||
skipped_not_loaded: [],
|
||||
},
|
||||
@@ -512,6 +523,53 @@ const inventoryResultFor = (method, params) => {
|
||||
}
|
||||
};
|
||||
|
||||
// One owner for the fixture's memory blocks. Each reply uses the production
|
||||
// bridge's end convention: list_segments reports an exclusive end, byte-search
|
||||
// coverage an inclusive one, and address_to_file_offset tests containment.
|
||||
const TEXT_BLOCK = {
|
||||
name: ".text",
|
||||
start: 0x1000n,
|
||||
size: 0x100n,
|
||||
fileOffset: 0n,
|
||||
};
|
||||
const BSS_BLOCK = {
|
||||
name: ".bss",
|
||||
start: 0x2000n,
|
||||
size: 0x10n,
|
||||
fileOffset: null,
|
||||
};
|
||||
const FIXTURE_BLOCKS = [TEXT_BLOCK, BSS_BLOCK];
|
||||
const hex = (value) => `0x${value.toString(16)}`;
|
||||
|
||||
const fileOffsetReply = (address) => {
|
||||
const digits = /^(?:0[xX])?([0-9a-fA-F]+)$/u.exec(address)?.[1];
|
||||
const failure = (code, message) => ({ ok: false, error: { code, message } });
|
||||
if (digits === undefined)
|
||||
return failure("invalid_request", `Invalid address: ${address}`);
|
||||
const value = BigInt(`0x${digits}`);
|
||||
const at = hex(value);
|
||||
const block = FIXTURE_BLOCKS.find(
|
||||
({ start, size }) => value >= start && value < start + size,
|
||||
);
|
||||
if (block === undefined)
|
||||
return failure(
|
||||
"address_unmapped",
|
||||
`Address ${at} is outside every Ghidra memory block`,
|
||||
);
|
||||
if (block.fileOffset === null)
|
||||
return failure(
|
||||
"address_uninitialized",
|
||||
`Address ${at} is in uninitialized Ghidra memory block ${block.name}`,
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
address: at,
|
||||
file_offset: Number(block.fileOffset + value - block.start),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const nameItem = () => ({
|
||||
address: "0x1000",
|
||||
value: "fixture_main",
|
||||
@@ -531,9 +589,9 @@ const procedureItem = () => ({
|
||||
});
|
||||
|
||||
const segmentItem = () => ({
|
||||
name: ".text",
|
||||
start: "0x1000",
|
||||
end: "0x1100",
|
||||
name: TEXT_BLOCK.name,
|
||||
start: hex(TEXT_BLOCK.start),
|
||||
end: hex(TEXT_BLOCK.start + TEXT_BLOCK.size),
|
||||
readable: true,
|
||||
writable: false,
|
||||
executable: true,
|
||||
|
||||
Reference in new issue
Block a user