mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(kuna): omit Mach-O data symbols from the procedure inventory (#8335)
* fix(kuna): omit Mach-O data symbols from the procedure inventory Kuna's `functions --json` registers every Mach-O export as a function entry, so globals in `__DATA,__data`, constants in `__TEXT,__const` and `__mh_execute_header` were listed, counted and decompiled as procedures. The Kuna session now classifies each inventory entry with the target's own section table, read by the shared `inspect_macho` load-command reader from the header bytes kept while its private copy is hashed. Entries in sections that declare no instructions, or in the header and load commands, are data: listings and searches omit them and report per-region counts, and procedure operations refuse them by name or address while `address_name` and `xrefs` still accept them. Instruction sections, symbol stubs and symbol-pointer import slots are kept; addresses outside every section stay with an explicit limitation, an unreadable section table keeps the whole inventory unclassified, and ELF inventories are unchanged. Composed workflows such as `decompile_all` and `binary_overview` follow the listing. Fixes #8168 Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(kuna): keep an entry's classification caveat on direct answers Resolution now returns the selected entry's Mach-O placement and the caveat it leaves open, built by the same constructor as the listings: the one-entry unplaced wording, or the unreadable section table's reason. Pseudocode, assembly, analyze_function (including the dossier's own limitations), procedure_address, call queries by name and address_name carry it; code and import-slot entries carry none. 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
8a6ddec83a
commit
a36c9dd9c8
12 files changed
+1397
-80
No files matched your search
+13
-2
@@ -38,7 +38,7 @@ Windows is not supported yet. Supported targets are ELF and thin Mach-O executab
|
||||
|
||||
| Tool | Source |
|
||||
| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `list_procedures`, `search_procedures`, `procedure_address` | Kuna's callable inventory (`kuna functions --json`), including import slots it does not decompile |
|
||||
| `list_procedures`, `search_procedures`, `procedure_address` | Kuna's callable inventory (`kuna functions --json`), including import slots but not [Mach-O data](#mach-o-data-entries) |
|
||||
| `address_name` | Function entries, then the data labels Kuna's export assigns |
|
||||
| `procedure_pseudo_code`, `batch_decompile` | The project export, or a single-function run while the export builds |
|
||||
| `procedure_assembly` | The project export's labeled listing |
|
||||
@@ -62,7 +62,7 @@ The first text or assembly request in a session looks for this target's export i
|
||||
- Assembly and data names wait for the export; progress notifications report the wait every ten seconds. A session does not rebuild a failed export: requests that need it fail with `retryable: false`, the export's failure, its captured Kuna output and what to change before reopening the target. A setting such as `REA_KUNA_PROJECT_TIMEOUT_MS` or `REA_KUNA_JOBS` is read when REA starts, so changing it also needs a restart. Pseudocode keeps using single-function runs.
|
||||
- A single-function answer says so in its limitations. It lacks the export's whole-program prototypes, so a later request can return different text for the same function.
|
||||
|
||||
Pseudocode, assembly, `analyze_function` and data-name answers record where their text came from in `raw_result.source`: `single-function`, or `project-export` with the cache directory. `procedure_pseudo_code` and `procedure_assembly` answers from the export also name the file, byte span and the file's SHA-256. Combined `analyze_function` answers retain both facet records in `raw_result.source.files`, each with its file, digest and byte span. Both texts come from one export; losing that project retries the complete report. Listings and procedure lookups carry no `raw_result`. Agents can open or search the export's `.c` and `.asm` there directly when that is cheaper than calling a tool per function. Text read that way is target-derived, like any decompiler output, but it bypasses the untrusted-content marker that REA's tool results carry.
|
||||
Pseudocode, assembly, `analyze_function` and data-name answers record where their text came from in `raw_result.source`: `single-function`, or `project-export` with the cache directory. `procedure_pseudo_code` and `procedure_assembly` answers from the export also name the file, byte span and the file's SHA-256. Combined `analyze_function` answers retain both facet records in `raw_result.source.files`, each with its file, digest and byte span. Both texts come from one export; losing that project retries the complete report. Listings and procedure lookups carry no `raw_result`; omitted [Mach-O data entries](#mach-o-data-entries) are reported in their limitations. Agents can open or search the export's `.c` and `.asm` there directly when that is cheaper than calling a tool per function. Text read that way is target-derived, like any decompiler output, but it bypasses the untrusted-content marker that REA's tool results carry.
|
||||
|
||||
The background export belongs to the session. `close_binary`, or the end of a CLI command, cancels it unless another session still waits for it. A one-shot CLI decompile therefore answers from a single-function run; any request that needs the export, such as `procedure_assembly` or `analyze_function`, keeps it.
|
||||
|
||||
@@ -72,6 +72,17 @@ Regex procedure and string search uses ECMAScript Unicode matching in a cancella
|
||||
|
||||
Single-function subprocess output and each cached source facet share a 16 MiB buffered-text capacity. Cached spans are checked before allocation; larger text remains in the local export and the error identifies the file and range. `analyze_function` with `detail: "complete"` (the CLI default) returns the complete dossier inline, which can still be large; later retained views do not remove that first transfer. The MCP default retains the dossier and returns its summary, so page the facets you need through `inspect_analysis_view`, or prefer the individual text tools for a focused question.
|
||||
|
||||
## Mach-O data entries
|
||||
|
||||
Kuna's inventory also lists Mach-O exports that are data, such as globals in `__DATA,__data`, constants in `__TEXT,__const` and `__mh_execute_header`. REA classifies each entry with the target's own section table, the same load-command facts `inspect_macho` reports, read from the leading bytes kept while the session's private copy is hashed:
|
||||
|
||||
- A section with `S_ATTR_PURE_INSTRUCTIONS` or `S_ATTR_SOME_INSTRUCTIONS`, or of type `S_SYMBOL_STUBS`, holds code; the entry stays.
|
||||
- A lazy or non-lazy symbol-pointer section (`__got`, `__la_symbol_ptr`) holds import slots; the entry stays.
|
||||
- Any other section, or the header and load commands before the first section, holds data. Listings and searches omit the entry and say how many they omitted in each region. Pseudocode, assembly, `analyze_function` and `procedure_address` refuse it by name or address, and call queries refuse its name, naming its region; call queries by exact address stay validated by Kuna's graph. `address_name` and `xrefs` still accept it.
|
||||
- An address outside every section and the header stays, with a limitation saying REA cannot classify it. If the section table cannot be read, every entry stays and listings say why. Answers about one such entry, including pseudocode, assembly, `analyze_function`, `procedure_address`, call queries by name and `address_name`, carry the same limitation; code and import slots carry none.
|
||||
|
||||
A section's attributes are the format's declaration, not proof of what runs there, and a kept entry is not proven to be a function. ELF inventories are kept as Kuna reports them. On a Mach-O target, the first listing or lookup establishes the private copy that Kuna commands also use, once per session, even when a warm export answers it.
|
||||
|
||||
## Strings
|
||||
|
||||
`list_strings` and `search_strings` share one lazy `kuna strings --json --encoding all --min-length 5 --termination any --no-xrefs` scan of the digest-checked private input. Skipping references avoids program lifting, a call graph and a C export. REA validates the input, scan policy, every row and the total before caching any facts. Encoding, character count, byte length and terminator facts must agree; supplementary UTF-8 characters count as Unicode scalars. Malformed output is an error, not an empty search. REA parses the JSON as Kuna writes it instead of buffering it, so the scan has no fixed size: Kuna writes about 290 bytes per row, 31 MB for a 20 MB Ghostty binary and 179 MB (611k rows) for an arm64 Firefox `XUL`. Rows are validated one at a time, and the first contradiction stops Kuna. Text REA does not keep is dropped unassembled; kept text and retained rows are admitted against the REA process's live heap headroom as they arrive; a refusal is a `memory` resource constraint with `retryable: false`, and closing other targets or starting REA with a larger Node.js heap makes room. String scans use the whole-program `REA_KUNA_PROJECT_TIMEOUT_MS` deadline; `REA_KUNA_TIMEOUT_MS` applies to single-function decompilation. The parsed inventory belongs to one selected target/executable profile and expires on session close; Kuna diagnostics beyond their 8 MiB buffer are retained as a failure until close, while other failures are not cached.
|
||||
|
||||
+47
-2
@@ -275,25 +275,70 @@ export const runKunaCommand = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* How many leading bytes of a copy to keep, decided from its first
|
||||
* `probeBytes` bytes (or the whole file when it is shorter).
|
||||
*/
|
||||
export interface KunaInputPrefix {
|
||||
readonly probeBytes: number;
|
||||
readonly length: (head: Buffer) => number;
|
||||
}
|
||||
|
||||
/** A copy's SHA-256 and size, and the leading bytes kept from the same stream. */
|
||||
export interface KunaCopiedInput {
|
||||
readonly sha256: string;
|
||||
readonly bytes: number;
|
||||
/** Empty unless a prefix was requested; the digest covers these exact bytes. */
|
||||
readonly prefix: Buffer;
|
||||
}
|
||||
|
||||
/** Copy a file and return the SHA-256 of exactly the bytes copied. */
|
||||
export const copyAndHash = async (
|
||||
source: string,
|
||||
destination: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<string> => {
|
||||
keep?: KunaInputPrefix,
|
||||
): Promise<KunaCopiedInput> => {
|
||||
const hash = createHash("sha256");
|
||||
let bytes = 0;
|
||||
const kept: Buffer[] = [];
|
||||
let keptBytes = 0;
|
||||
// Undefined until the probe has been seen; the prefix then stops growing.
|
||||
let wanted: number | undefined = keep === undefined ? 0 : undefined;
|
||||
const decide = (prefix: KunaInputPrefix) => {
|
||||
wanted = Math.max(0, prefix.length(Buffer.concat(kept, keptBytes)));
|
||||
};
|
||||
await pipeline(
|
||||
createReadStream(source),
|
||||
new Transform({
|
||||
transform(chunk: Buffer, _encoding, callback) {
|
||||
hash.update(chunk);
|
||||
bytes += chunk.length;
|
||||
if (
|
||||
keep !== undefined &&
|
||||
(wanted === undefined || keptBytes < wanted)
|
||||
) {
|
||||
const take =
|
||||
wanted === undefined
|
||||
? chunk.length
|
||||
: Math.min(chunk.length, wanted - keptBytes);
|
||||
kept.push(chunk.subarray(0, take));
|
||||
keptBytes += take;
|
||||
if (wanted === undefined && keptBytes >= keep.probeBytes)
|
||||
decide(keep);
|
||||
}
|
||||
callback(null, chunk);
|
||||
},
|
||||
}),
|
||||
createWriteStream(destination, { flags: "wx", mode: 0o600 }),
|
||||
{ signal },
|
||||
);
|
||||
return hash.digest("hex");
|
||||
if (keep !== undefined && wanted === undefined) decide(keep);
|
||||
return {
|
||||
sha256: hash.digest("hex"),
|
||||
bytes,
|
||||
prefix: Buffer.concat(kept, Math.min(wanted ?? 0, keptBytes)),
|
||||
};
|
||||
};
|
||||
|
||||
/** Map an owned-command failure to its typed error, keeping unproven cleanup owned. */
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import { MAX_LOAD_COMMAND_BYTES } from "../artifacts/apple/MachoLoadCommandReader.js";
|
||||
import {
|
||||
readMachoHeaderDeclarations,
|
||||
readMachoLoadCommandTableHeader,
|
||||
} from "../domain/apple/machoLoadCommands.js";
|
||||
import { decodeMachoFixedName } from "../native/AppleMachoNames.js";
|
||||
import {
|
||||
MachoBinaryFormatError,
|
||||
MachoCommandNamesLimitError,
|
||||
readMachoCommandNames,
|
||||
} from "../native/MachoCommandNames.js";
|
||||
import type { KunaInputPrefix } from "./KunaCommand.js";
|
||||
import type { KunaInventoryFunction } from "./KunaProjectFiles.js";
|
||||
|
||||
// Section types and attributes from <mach-o/loader.h>.
|
||||
const SECTION_TYPE = 0xff;
|
||||
const S_NON_LAZY_SYMBOL_POINTERS = 0x6;
|
||||
const S_LAZY_SYMBOL_POINTERS = 0x7;
|
||||
const S_SYMBOL_STUBS = 0x8;
|
||||
const S_LAZY_DYLIB_SYMBOL_POINTERS = 0x10;
|
||||
const S_ATTR_PURE_INSTRUCTIONS = 0x80000000;
|
||||
const S_ATTR_SOME_INSTRUCTIONS = 0x00000400;
|
||||
|
||||
/** The pointer slots dyld binds to imported symbols; Kuna names calls through them. */
|
||||
const IMPORT_SLOT_TYPES: ReadonlySet<number> = new Set([
|
||||
S_NON_LAZY_SYMBOL_POINTERS,
|
||||
S_LAZY_SYMBOL_POINTERS,
|
||||
S_LAZY_DYLIB_SYMBOL_POINTERS,
|
||||
]);
|
||||
|
||||
/** Load commands, sections and their names are a small part of any real image. */
|
||||
const MAX_LAYOUT_RECORDS = 65_536;
|
||||
|
||||
/** The region before the first section that holds `mach_header` and its commands. */
|
||||
const MACHO_HEADER_REGION = "Mach-O header and load commands";
|
||||
|
||||
/** What the target's own layout says about one inventory address. */
|
||||
type KunaEntryPlacement =
|
||||
| { readonly kind: "code" }
|
||||
| { readonly kind: "import-slot" }
|
||||
| { readonly kind: "data"; readonly region: string }
|
||||
/** No section or the header holds the address, so its content is unknown. */
|
||||
| { readonly kind: "unplaced" };
|
||||
|
||||
interface PlacedRegion {
|
||||
readonly start: bigint;
|
||||
readonly end: bigint;
|
||||
readonly name: string;
|
||||
readonly placement: Exclude<KunaEntryPlacement, { kind: "unplaced" }>;
|
||||
}
|
||||
|
||||
/** The section table REA classifies Kuna's inventory with, or why it has none. */
|
||||
export type KunaInventoryLayout =
|
||||
/** Only Mach-O inventories are classified; others are kept as Kuna reports them. */
|
||||
| { readonly status: "not-applicable" }
|
||||
| { readonly status: "unavailable"; readonly reason: string }
|
||||
| {
|
||||
readonly status: "mach-o";
|
||||
/** Non-empty sections, sorted by address and pairwise disjoint. */
|
||||
readonly regions: readonly PlacedRegion[];
|
||||
readonly header: { readonly start: bigint; readonly end: bigint } | null;
|
||||
};
|
||||
|
||||
export const KUNA_LAYOUT_NOT_APPLICABLE: KunaInventoryLayout = Object.freeze({
|
||||
status: "not-applicable",
|
||||
});
|
||||
|
||||
const CODE = Object.freeze({ kind: "code" } as const);
|
||||
const IMPORT_SLOT = Object.freeze({ kind: "import-slot" } as const);
|
||||
const UNPLACED = Object.freeze({ kind: "unplaced" } as const);
|
||||
|
||||
const unavailable = (reason: string): KunaInventoryLayout => ({
|
||||
status: "unavailable",
|
||||
reason,
|
||||
});
|
||||
|
||||
/**
|
||||
* Keep a thin Mach-O header and its load commands while the private copy is
|
||||
* hashed. Commands beyond REA's load-command limit are not kept; the layout
|
||||
* then reports that limit.
|
||||
*/
|
||||
export const MACHO_LOAD_COMMAND_PREFIX: KunaInputPrefix = Object.freeze({
|
||||
probeBytes: 32,
|
||||
length: (head: Buffer) => {
|
||||
const declared = readMachoHeaderDeclarations(head, head.length);
|
||||
// The layout read reports what is wrong with the header it keeps.
|
||||
if (!declared.ok || declared.value === null)
|
||||
return Math.min(head.length, 32);
|
||||
const { headerSize, commandBytes } = declared.value;
|
||||
return commandBytes > MAX_LOAD_COMMAND_BYTES
|
||||
? headerSize
|
||||
: headerSize + commandBytes;
|
||||
},
|
||||
});
|
||||
|
||||
const sectionPlacement = (
|
||||
flags: number,
|
||||
name: string,
|
||||
): PlacedRegion["placement"] => {
|
||||
const type = flags & SECTION_TYPE;
|
||||
if (
|
||||
(flags & (S_ATTR_PURE_INSTRUCTIONS | S_ATTR_SOME_INSTRUCTIONS)) !== 0 ||
|
||||
type === S_SYMBOL_STUBS
|
||||
)
|
||||
return CODE;
|
||||
if (IMPORT_SLOT_TYPES.has(type)) return IMPORT_SLOT;
|
||||
return { kind: "data", region: name };
|
||||
};
|
||||
|
||||
/**
|
||||
* Read a thin Mach-O's section table from its header and load commands, as
|
||||
* inspect_macho reports them. A section that declares no instructions holds
|
||||
* data; a format failure leaves the inventory unclassified with its reason.
|
||||
*/
|
||||
export const readKunaMachoLayout = async (head: {
|
||||
readonly bytes: number;
|
||||
readonly prefix: Buffer;
|
||||
}): Promise<KunaInventoryLayout> => {
|
||||
const { bytes, prefix } = head;
|
||||
const declared = readMachoLoadCommandTableHeader(prefix, bytes);
|
||||
if (!declared.ok) return unavailable(declared.error);
|
||||
if (declared.value === null) return unavailable("it has no Mach-O header");
|
||||
const { headerSize, commandBytes } = declared.value;
|
||||
if (commandBytes > MAX_LOAD_COMMAND_BYTES)
|
||||
return unavailable(
|
||||
`its load commands occupy ${String(commandBytes)} bytes, above the ${String(MAX_LOAD_COMMAND_BYTES)}-byte load-command limit`,
|
||||
);
|
||||
let commands: Awaited<ReturnType<typeof readMachoCommandNames>>["commands"];
|
||||
try {
|
||||
({ commands } = await readMachoCommandNames(
|
||||
(offset, size) => Promise.resolve(prefix.subarray(offset, offset + size)),
|
||||
bytes,
|
||||
MAX_LOAD_COMMAND_BYTES,
|
||||
MAX_LAYOUT_RECORDS,
|
||||
));
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
cause instanceof MachoBinaryFormatError ||
|
||||
cause instanceof MachoCommandNamesLimitError
|
||||
)
|
||||
return unavailable(cause.message);
|
||||
throw cause;
|
||||
}
|
||||
const name = (field: Uint8Array) =>
|
||||
decodeMachoFixedName(
|
||||
Buffer.from(field.buffer, field.byteOffset, field.byteLength),
|
||||
);
|
||||
const regions: PlacedRegion[] = [];
|
||||
let header: { start: bigint; end: bigint } | null = null;
|
||||
for (const { segment, sections } of commands) {
|
||||
if (segment === undefined) continue;
|
||||
// The segment that maps file offset 0 maps the header and its commands.
|
||||
if (header === null && segment.fileOffset === 0n && segment.fileSize > 0n)
|
||||
header = {
|
||||
start: segment.address,
|
||||
end: segment.address + BigInt(headerSize + commandBytes),
|
||||
};
|
||||
for (const section of sections ?? []) {
|
||||
if (section.size === 0n) continue;
|
||||
const region = `${name(section.segmentName)},${name(section.name)}`;
|
||||
regions.push({
|
||||
start: section.address,
|
||||
end: section.address + section.size,
|
||||
name: region,
|
||||
placement: sectionPlacement(section.flags, region),
|
||||
});
|
||||
}
|
||||
}
|
||||
regions.sort((left, right) =>
|
||||
left.start < right.start ? -1 : left.start > right.start ? 1 : 0,
|
||||
);
|
||||
// Sorted by start, any overlap shows between neighbors. Overlapping
|
||||
// declarations cannot say which content an address holds.
|
||||
for (let index = 1; index < regions.length; index += 1) {
|
||||
const previous = regions[index - 1]!;
|
||||
const current = regions[index]!;
|
||||
if (current.start < previous.end)
|
||||
return unavailable(
|
||||
`sections ${previous.name} and ${current.name} overlap`,
|
||||
);
|
||||
}
|
||||
return { status: "mach-o", regions, header };
|
||||
};
|
||||
|
||||
/** Where a Mach-O layout places one address; a section takes precedence over the header. */
|
||||
const placeAddress = (
|
||||
layout: Extract<KunaInventoryLayout, { status: "mach-o" }>,
|
||||
address: bigint,
|
||||
): KunaEntryPlacement => {
|
||||
const { regions, header } = layout;
|
||||
let low = 0;
|
||||
let high = regions.length - 1;
|
||||
let found: PlacedRegion | undefined;
|
||||
while (low <= high) {
|
||||
const middle = (low + high) >> 1;
|
||||
const region = regions[middle]!;
|
||||
if (region.start <= address) {
|
||||
found = region;
|
||||
low = middle + 1;
|
||||
} else high = middle - 1;
|
||||
}
|
||||
if (found !== undefined && address < found.end) return found.placement;
|
||||
if (header !== null && address >= header.start && address < header.end)
|
||||
return { kind: "data", region: MACHO_HEADER_REGION };
|
||||
return UNPLACED;
|
||||
};
|
||||
|
||||
/** What REA knows about one selected entry: its Mach-O placement, or why it has none. */
|
||||
export type KunaSelectedPlacement =
|
||||
| KunaEntryPlacement
|
||||
/** The section table could not be read, so the entry is unclassified. */
|
||||
| { readonly kind: "unclassified"; readonly reason: string }
|
||||
/** Not a Mach-O target; Kuna's entry is kept as reported. */
|
||||
| { readonly kind: "not-applicable" };
|
||||
|
||||
const NOT_APPLICABLE = Object.freeze({ kind: "not-applicable" } as const);
|
||||
|
||||
/** Place one selected entry with the same rules the listings use. */
|
||||
export const placeKunaEntry = (
|
||||
layout: KunaInventoryLayout,
|
||||
entry: KunaInventoryFunction,
|
||||
): KunaSelectedPlacement =>
|
||||
layout.status === "mach-o"
|
||||
? placeAddress(layout, BigInt(entry.address))
|
||||
: layout.status === "unavailable"
|
||||
? { kind: "unclassified", reason: layout.reason }
|
||||
: NOT_APPLICABLE;
|
||||
|
||||
/** Kuna's inventory split by what the target's layout says each entry is. */
|
||||
interface KunaInventoryPlacements {
|
||||
readonly layout: KunaInventoryLayout;
|
||||
/** The data region of each entry the layout places outside code and import slots. */
|
||||
readonly data: ReadonlyMap<KunaInventoryFunction, string>;
|
||||
/** Entries a Mach-O layout holds in no section and not in its header. */
|
||||
readonly unplaced: ReadonlySet<KunaInventoryFunction>;
|
||||
}
|
||||
|
||||
/** Place every inventory entry in the target's layout. */
|
||||
export const placeKunaInventory = (
|
||||
inventory: readonly KunaInventoryFunction[],
|
||||
layout: KunaInventoryLayout,
|
||||
): KunaInventoryPlacements => {
|
||||
const data = new Map<KunaInventoryFunction, string>();
|
||||
const unplaced = new Set<KunaInventoryFunction>();
|
||||
if (layout.status === "mach-o")
|
||||
for (const entry of inventory) {
|
||||
const placement = placeAddress(layout, BigInt(entry.address));
|
||||
if (placement.kind === "data") data.set(entry, placement.region);
|
||||
else if (placement.kind === "unplaced") unplaced.add(entry);
|
||||
}
|
||||
return { layout, data, unplaced };
|
||||
};
|
||||
|
||||
const describeRegion = (region: string): string =>
|
||||
region === MACHO_HEADER_REGION
|
||||
? `the ${MACHO_HEADER_REGION}`
|
||||
: `${region}, a Mach-O section whose attributes declare no instructions`;
|
||||
|
||||
/** Why a procedure operation refuses an entry Kuna lists in a data region. */
|
||||
export const kunaDataEntryMessage = (
|
||||
entry: KunaInventoryFunction,
|
||||
region: string,
|
||||
): string =>
|
||||
`${entry.name} (${entry.address}) lies in ${describeRegion(region)}, so it is data that Kuna lists, not a procedure. address_name and xrefs accept its address.`;
|
||||
|
||||
/** How address_name qualifies a name Kuna lists for data. */
|
||||
export const kunaDataNameLimitation = (
|
||||
entry: KunaInventoryFunction,
|
||||
region: string,
|
||||
): string =>
|
||||
`Kuna lists ${entry.name} in its function inventory, but ${entry.address} lies in ${describeRegion(region)}, so the name labels data.`;
|
||||
|
||||
/**
|
||||
* The caveat a direct answer about one kept entry carries: the listings'
|
||||
* wording for one unplaced entry, or for an unreadable section table. Code
|
||||
* and import slots carry none.
|
||||
*/
|
||||
export const kunaEntryLimitations = (
|
||||
layout: KunaInventoryLayout,
|
||||
placement: KunaSelectedPlacement,
|
||||
): readonly string[] =>
|
||||
placement.kind === "unplaced" || placement.kind === "unclassified"
|
||||
? kunaInventoryLimitations(
|
||||
layout,
|
||||
[],
|
||||
placement.kind === "unplaced" ? 1 : 0,
|
||||
)
|
||||
: [];
|
||||
|
||||
/**
|
||||
* What a listing omitted as data, and what it kept without a classification,
|
||||
* among the inventory entries its query selected.
|
||||
*/
|
||||
export const kunaInventoryLimitations = (
|
||||
layout: KunaInventoryLayout,
|
||||
omittedRegions: readonly string[],
|
||||
unplaced: number,
|
||||
): readonly string[] => {
|
||||
if (layout.status === "unavailable")
|
||||
return [
|
||||
`REA could not read the target's Mach-O section table (${layout.reason}), so Kuna's inventory is kept unclassified and may include data symbols.`,
|
||||
];
|
||||
const limitations: string[] = [];
|
||||
if (omittedRegions.length > 0) {
|
||||
const counts = new Map<string, number>();
|
||||
for (const region of omittedRegions)
|
||||
counts.set(region, (counts.get(region) ?? 0) + 1);
|
||||
const one = omittedRegions.length === 1;
|
||||
limitations.push(
|
||||
`Kuna's inventory also lists ${String(omittedRegions.length)} ${one ? "entry" : "entries"} in Mach-O data regions (${[
|
||||
...counts,
|
||||
]
|
||||
.map(([region, count]) => `${region}: ${String(count)}`)
|
||||
.join(
|
||||
"; ",
|
||||
)}): the header, or sections whose attributes declare no instructions. REA omits ${one ? "it as data rather than a procedure entry" : "them as data rather than procedure entries"}; address_name and xrefs still accept ${one ? "its address" : "their addresses"}.`,
|
||||
);
|
||||
}
|
||||
if (unplaced > 0) {
|
||||
const one = unplaced === 1;
|
||||
limitations.push(
|
||||
`${String(unplaced)} Kuna inventory ${one ? "entry lies" : "entries lie"} outside every Mach-O section and the header, so REA cannot tell whether ${one ? "it is" : "they are"} code or data and keeps ${one ? "it" : "them"}.`,
|
||||
);
|
||||
}
|
||||
return limitations;
|
||||
};
|
||||
@@ -221,7 +221,7 @@ export const exportKunaProject = async (
|
||||
await mkdir(request.output, { recursive: true, mode: 0o700 });
|
||||
|
||||
const copied = await copyAndHash(request.targetPath, input, signal);
|
||||
if (copied !== request.targetSha256)
|
||||
if (copied.sha256 !== request.targetSha256)
|
||||
return {
|
||||
kind: "done",
|
||||
result: err(
|
||||
|
||||
+232
-66
@@ -68,6 +68,19 @@ import {
|
||||
parseKunaInventory,
|
||||
type KunaInventoryFunction,
|
||||
} from "./KunaProjectFiles.js";
|
||||
import {
|
||||
KUNA_LAYOUT_NOT_APPLICABLE,
|
||||
kunaDataEntryMessage,
|
||||
kunaDataNameLimitation,
|
||||
kunaEntryLimitations,
|
||||
kunaInventoryLimitations,
|
||||
MACHO_LOAD_COMMAND_PREFIX,
|
||||
placeKunaEntry,
|
||||
placeKunaInventory,
|
||||
readKunaMachoLayout,
|
||||
type KunaInventoryLayout,
|
||||
type KunaSelectedPlacement,
|
||||
} from "./KunaInventoryPlacement.js";
|
||||
import {
|
||||
isKunaOperation,
|
||||
KUNA_RELATIONSHIP_LIMITATION,
|
||||
@@ -103,12 +116,31 @@ type AnswerSource =
|
||||
| { readonly kind: "project-export"; readonly project: KunaProject }
|
||||
| { readonly kind: "single-function" };
|
||||
|
||||
/**
|
||||
* Lookups over one inventory as the target's layout classifies it. Every
|
||||
* entry stays addressable for names and references; procedure operations
|
||||
* refuse the entries the layout places in data.
|
||||
*/
|
||||
interface InventoryIndex {
|
||||
readonly inventory: readonly KunaInventoryFunction[];
|
||||
readonly layout: KunaInventoryLayout;
|
||||
/** The data region of each entry the layout places in data. */
|
||||
readonly data: ReadonlyMap<KunaInventoryFunction, string>;
|
||||
readonly unplaced: ReadonlySet<KunaInventoryFunction>;
|
||||
readonly byAddress: ReadonlyMap<string, KunaInventoryFunction>;
|
||||
readonly byName: ReadonlyMap<string, readonly KunaInventoryFunction[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* One inventory entry a procedure request selected, with its placement and
|
||||
* the classification caveats every answer about it carries.
|
||||
*/
|
||||
interface ResolvedEntry {
|
||||
readonly entry: KunaInventoryFunction;
|
||||
readonly placement: KunaSelectedPlacement;
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
/** A queued single-function request found the export finished first. */
|
||||
const EXPORT_READY = Symbol("kuna-export-ready");
|
||||
|
||||
@@ -305,6 +337,8 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
#inventory:
|
||||
| Promise<Result<readonly KunaInventoryFunction[], AnalysisError>>
|
||||
| undefined;
|
||||
/** The target's section table, read once from the verified private input. */
|
||||
#layout: Promise<Result<KunaInventoryLayout, AnalysisError>> | undefined;
|
||||
#project: Promise<Result<KunaProject, AnalysisError>> | undefined;
|
||||
/** The cache lookup that decides #project, shared by concurrent first requests. */
|
||||
#starting: Promise<void> | undefined;
|
||||
@@ -348,6 +382,9 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
...(dependencies.launcher === undefined
|
||||
? {}
|
||||
: { launcher: dependencies.launcher }),
|
||||
...(target.format === "mach-o"
|
||||
? { inputPrefix: MACHO_LOAD_COMMAND_PREFIX }
|
||||
: {}),
|
||||
});
|
||||
this.#deadline = dependencies.deadline;
|
||||
this.#heapRequest = dependencies.heapRequest;
|
||||
@@ -677,26 +714,23 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
return this.#stringAnswer(operation, parameters, installation, options);
|
||||
if (operation === "list_procedures" || operation === "search_procedures")
|
||||
return this.#procedureList(operation, parameters, installation, options);
|
||||
const inventory = await this.#ensureInventory(installation, options);
|
||||
if (!inventory.ok) return inventory;
|
||||
const index = await this.#ensureIndex(installation, options);
|
||||
if (!index.ok) return index;
|
||||
if (operation === "address_name")
|
||||
return this.#addressName(
|
||||
parameters,
|
||||
inventory.value,
|
||||
installation,
|
||||
options,
|
||||
);
|
||||
const entry = this.#resolve(
|
||||
operation,
|
||||
parameters.procedure,
|
||||
inventory.value,
|
||||
);
|
||||
return this.#addressName(parameters, index.value, installation, options);
|
||||
const entry = this.#resolve(operation, parameters.procedure, index.value);
|
||||
if (!entry.ok) {
|
||||
const selectedAddress =
|
||||
operation === "procedure_address"
|
||||
? normalizeKunaAddress(parameters.procedure)
|
||||
: undefined;
|
||||
if (selectedAddress === undefined) return entry;
|
||||
// An entry the layout places in data is not a procedure the graph
|
||||
// could still own.
|
||||
if (
|
||||
selectedAddress === undefined ||
|
||||
this.#isDataAddress(index.value, selectedAddress)
|
||||
)
|
||||
return entry;
|
||||
const graph = await this.#references.functionAt(
|
||||
installation,
|
||||
selectedAddress,
|
||||
@@ -713,36 +747,44 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
referenceSource: graph.value.source,
|
||||
});
|
||||
}
|
||||
const locations = [
|
||||
{ kind: "address", address: entry.value.address } as const,
|
||||
];
|
||||
// Every answer about the entry keeps what its classification left open.
|
||||
const { entry: selected, limitations: classification } = entry.value;
|
||||
const locations = [{ kind: "address", address: selected.address } as const];
|
||||
switch (operation) {
|
||||
case "procedure_address":
|
||||
return ok({
|
||||
result: entry.value.address,
|
||||
result: selected.address,
|
||||
source: null,
|
||||
locations,
|
||||
limitations: [],
|
||||
limitations: classification,
|
||||
});
|
||||
case "procedure_pseudo_code": {
|
||||
const pseudocode = await this.#pseudocode(
|
||||
operation,
|
||||
entry.value,
|
||||
selected,
|
||||
installation,
|
||||
options,
|
||||
);
|
||||
if (!pseudocode.ok) return pseudocode;
|
||||
return ok({ ...pseudocode.value, locations });
|
||||
return ok({
|
||||
...pseudocode.value,
|
||||
locations,
|
||||
limitations: [...pseudocode.value.limitations, ...classification],
|
||||
});
|
||||
}
|
||||
case "procedure_assembly": {
|
||||
const assembly = await this.#assemblyText(
|
||||
operation,
|
||||
entry.value,
|
||||
selected,
|
||||
installation,
|
||||
options,
|
||||
);
|
||||
if (!assembly.ok) return assembly;
|
||||
return ok({ ...assembly.value, locations });
|
||||
return ok({
|
||||
...assembly.value,
|
||||
locations,
|
||||
limitations: [...assembly.value.limitations, ...classification],
|
||||
});
|
||||
}
|
||||
case "analyze_function":
|
||||
return this.#withProject(operation, installation, options, (reader) =>
|
||||
@@ -799,30 +841,47 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
return err(
|
||||
analysisInputErrorFromIssues(operation, query.error.issues, parameters),
|
||||
);
|
||||
const inventory = await this.#ensureInventory(installation, options);
|
||||
if (!inventory.ok) return inventory;
|
||||
let selected = inventory.value;
|
||||
const index = await this.#ensureIndex(installation, options);
|
||||
if (!index.ok) return index;
|
||||
const { inventory, data, unplaced } = index.value;
|
||||
let selected = inventory;
|
||||
if (query?.success === true) {
|
||||
if (query.data.mode === "regex") {
|
||||
const matches = await this.#queryWork.match({
|
||||
operation,
|
||||
pattern: query.data.pattern,
|
||||
caseSensitive: query.data.case_sensitive,
|
||||
values: () => inventory.value.map(({ name }) => name),
|
||||
values: () => inventory.map(({ name }) => name),
|
||||
signal: options.signal,
|
||||
});
|
||||
if (!matches.ok) return matches;
|
||||
selected = matches.value.map((index) => inventory.value[index]!);
|
||||
selected = matches.value.map((position) => inventory[position]!);
|
||||
} else {
|
||||
const matches = analysisSearchPredicate(query.data);
|
||||
selected = inventory.value.filter(({ name }) => matches(name));
|
||||
selected = inventory.filter(({ name }) => matches(name));
|
||||
}
|
||||
}
|
||||
const procedures: KunaInventoryFunction[] = [];
|
||||
const omitted: string[] = [];
|
||||
let unclassified = 0;
|
||||
for (const entry of selected) {
|
||||
const region = data.get(entry);
|
||||
if (region !== undefined) {
|
||||
omitted.push(region);
|
||||
continue;
|
||||
}
|
||||
if (unplaced.has(entry)) unclassified += 1;
|
||||
procedures.push(entry);
|
||||
}
|
||||
return ok({
|
||||
result: selected.map(({ address, name }) => ({ address, value: name })),
|
||||
result: procedures.map(({ address, name }) => ({ address, value: name })),
|
||||
source: null,
|
||||
locations: [],
|
||||
limitations: [],
|
||||
limitations: kunaInventoryLimitations(
|
||||
index.value.layout,
|
||||
omitted,
|
||||
unclassified,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -841,7 +900,7 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
options,
|
||||
);
|
||||
if (!selected.ok) return selected;
|
||||
const address = selected.value;
|
||||
const { address, limitations } = selected.value;
|
||||
const locations = [{ kind: "address" as const, address }];
|
||||
if (operation === "xrefs") {
|
||||
const references = await this.#references.incoming(
|
||||
@@ -854,7 +913,7 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
result: [...references.value.addresses],
|
||||
source: null,
|
||||
locations,
|
||||
limitations: [],
|
||||
limitations,
|
||||
referenceSource: {
|
||||
kind: "kuna-xrefs",
|
||||
direction: "to",
|
||||
@@ -882,7 +941,7 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
source: null,
|
||||
locations,
|
||||
referenceSource: relationships.value.source,
|
||||
limitations: [],
|
||||
limitations,
|
||||
relationshipCoverage:
|
||||
operation === "procedure_callers"
|
||||
? relationships.value.callersCoverage
|
||||
@@ -891,14 +950,20 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
}
|
||||
|
||||
/** Exact addresses are validated by the producing graph, which also owns
|
||||
* synthetic import nodes absent from the quick function inventory. */
|
||||
* synthetic import nodes absent from the quick function inventory. A name
|
||||
* resolves through the inventory and keeps its classification caveats. */
|
||||
async #referenceAddress(
|
||||
operation: string,
|
||||
raw: unknown,
|
||||
field: "address" | "procedure",
|
||||
installation: KunaInstallation,
|
||||
options: ExecutionOptions,
|
||||
): Promise<Result<string, AnalysisError>> {
|
||||
): Promise<
|
||||
Result<
|
||||
{ readonly address: string; readonly limitations: readonly string[] },
|
||||
AnalysisError
|
||||
>
|
||||
> {
|
||||
if (typeof raw !== "string" || raw.length === 0)
|
||||
return err(
|
||||
new AnalysisInputError(
|
||||
@@ -908,7 +973,7 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
),
|
||||
);
|
||||
const address = normalizeKunaAddress(raw);
|
||||
if (address !== undefined) return ok(address);
|
||||
if (address !== undefined) return ok({ address, limitations: [] });
|
||||
if (/^0x[0-9a-f]+$/iu.test(raw))
|
||||
return err(
|
||||
new AnalysisInputError(
|
||||
@@ -917,19 +982,30 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
[{ path: [field], reason: "invalid_value" }],
|
||||
),
|
||||
);
|
||||
const inventory = await this.#ensureInventory(installation, options);
|
||||
if (!inventory.ok) return inventory;
|
||||
const entry = this.#resolve(operation, raw, inventory.value, field);
|
||||
return entry.ok ? ok(entry.value.address) : entry;
|
||||
const index = await this.#ensureIndex(installation, options);
|
||||
if (!index.ok) return index;
|
||||
// References to data are meaningful, so xrefs also accepts data names.
|
||||
const entry = this.#resolve(operation, raw, index.value, field);
|
||||
return entry.ok
|
||||
? ok({
|
||||
address: entry.value.entry.address,
|
||||
limitations: entry.value.limitations,
|
||||
})
|
||||
: entry;
|
||||
}
|
||||
|
||||
/** Resolve an exact inventory name, alias or entry address. */
|
||||
/**
|
||||
* Resolve an exact inventory name, alias or entry address. A procedure
|
||||
* selector excludes entries the layout places in data and returns the
|
||||
* caveats its placement leaves open; an address selector accepts every
|
||||
* entry and claims no procedure.
|
||||
*/
|
||||
#resolve(
|
||||
operation: string,
|
||||
raw: unknown,
|
||||
inventory: readonly KunaInventoryFunction[],
|
||||
index: InventoryIndex,
|
||||
field: "procedure" | "address" = "procedure",
|
||||
): Result<KunaInventoryFunction, AnalysisError> {
|
||||
): Result<ResolvedEntry, AnalysisError> {
|
||||
const parsed = procedureSelectorSchema.safeParse(raw);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
@@ -941,31 +1017,56 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
);
|
||||
const selector = parsed.data;
|
||||
const address = normalizeKunaAddress(selector);
|
||||
const refused = (entry: KunaInventoryFunction) =>
|
||||
field === "procedure" && index.data.has(entry);
|
||||
const dataRefusal = (entry: KunaInventoryFunction) =>
|
||||
err(
|
||||
new AnalysisInputError(operation, {
|
||||
userMessage: kunaDataEntryMessage(entry, index.data.get(entry)!),
|
||||
}),
|
||||
);
|
||||
const selected = (entry: KunaInventoryFunction) => {
|
||||
const placement = placeKunaEntry(index.layout, entry);
|
||||
return ok({
|
||||
entry,
|
||||
placement,
|
||||
limitations:
|
||||
field === "procedure"
|
||||
? kunaEntryLimitations(index.layout, placement)
|
||||
: [],
|
||||
});
|
||||
};
|
||||
if (address !== undefined) {
|
||||
const entry = this.#index(inventory).byAddress.get(address);
|
||||
return entry === undefined
|
||||
? err(
|
||||
new AnalysisInputError(operation, {
|
||||
userMessage: `${address} is not a procedure entry Kuna found; list_procedures shows the entries it found.`,
|
||||
}),
|
||||
)
|
||||
: ok(entry);
|
||||
const entry = index.byAddress.get(address);
|
||||
if (entry === undefined)
|
||||
return err(
|
||||
new AnalysisInputError(operation, {
|
||||
userMessage: `${address} is not a procedure entry Kuna found; list_procedures shows the entries it found.`,
|
||||
}),
|
||||
);
|
||||
return refused(entry) ? dataRefusal(entry) : selected(entry);
|
||||
}
|
||||
const matches = this.#index(inventory).byName.get(selector) ?? [];
|
||||
if (matches.length === 1 && matches[0] !== undefined) return ok(matches[0]);
|
||||
const named = index.byName.get(selector) ?? [];
|
||||
const matches = named.filter((entry) => !refused(entry));
|
||||
if (matches.length === 1 && matches[0] !== undefined)
|
||||
return selected(matches[0]);
|
||||
if (matches.length === 0 && named.length === 1 && named[0] !== undefined)
|
||||
return dataRefusal(named[0]);
|
||||
return err(
|
||||
new AnalysisInputError(operation, {
|
||||
userMessage:
|
||||
matches.length === 0
|
||||
matches.length === 0 && named.length === 0
|
||||
? `Kuna found no procedure named ${selector}; search_procedures finds similar names.`
|
||||
: `${selector} names ${String(matches.length)} Kuna entries (${matches.map(({ address }) => address).join(", ")}); select one by address.`,
|
||||
: matches.length === 0
|
||||
? `${selector} names ${String(named.length)} Kuna entries that lie in Mach-O data regions (${named.map(({ address }) => address).join(", ")}); they are data, not procedures.`
|
||||
: `${selector} names ${String(matches.length)} Kuna entries (${matches.map(({ address }) => address).join(", ")}); select one by address.`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async #addressName(
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
inventory: readonly KunaInventoryFunction[],
|
||||
index: InventoryIndex,
|
||||
installation: KunaInstallation,
|
||||
options: ExecutionOptions,
|
||||
): Promise<Result<Answer, AnalysisError>> {
|
||||
@@ -983,14 +1084,19 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
),
|
||||
);
|
||||
const locations = [{ kind: "address", address } as const];
|
||||
const entry = this.#index(inventory).byAddress.get(address);
|
||||
if (entry !== undefined)
|
||||
const entry = index.byAddress.get(address);
|
||||
if (entry !== undefined) {
|
||||
const placement = placeKunaEntry(index.layout, entry);
|
||||
return ok({
|
||||
result: entry.name,
|
||||
source: null,
|
||||
locations,
|
||||
limitations: [],
|
||||
limitations:
|
||||
placement.kind === "data"
|
||||
? [kunaDataNameLimitation(entry, placement.region)]
|
||||
: kunaEntryLimitations(index.layout, placement),
|
||||
});
|
||||
}
|
||||
await this.#startProject(installation);
|
||||
return this.#withProject(
|
||||
"address_name",
|
||||
@@ -1256,7 +1362,7 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
|
||||
async #dossier(
|
||||
operation: string,
|
||||
entry: KunaInventoryFunction,
|
||||
{ entry, limitations: classification }: ResolvedEntry,
|
||||
reader: KunaProjectReader,
|
||||
installation: KunaInstallation,
|
||||
signal: AbortSignal | undefined,
|
||||
@@ -1348,6 +1454,7 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
...(relationships.ok
|
||||
? []
|
||||
: [relationships.error.userMessage ?? relationships.error.message]),
|
||||
...classification,
|
||||
],
|
||||
});
|
||||
return ok({
|
||||
@@ -1364,6 +1471,7 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
...(relationships.ok
|
||||
? []
|
||||
: [relationships.error.userMessage ?? relationships.error.message]),
|
||||
...classification,
|
||||
],
|
||||
referenceSource: relationships.ok
|
||||
? relationships.value.source
|
||||
@@ -1386,10 +1494,14 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
});
|
||||
}
|
||||
|
||||
/** Address and name lookups over one inventory, built once. */
|
||||
#index(inventory: readonly KunaInventoryFunction[]): InventoryIndex {
|
||||
if (this.#inventoryIndex?.inventory === inventory)
|
||||
return this.#inventoryIndex;
|
||||
/** Address and name lookups over one classified inventory, built once. */
|
||||
#index(
|
||||
inventory: readonly KunaInventoryFunction[],
|
||||
layout: KunaInventoryLayout,
|
||||
): InventoryIndex {
|
||||
const kept = this.#inventoryIndex;
|
||||
if (kept?.inventory === inventory && kept.layout === layout) return kept;
|
||||
const { data, unplaced } = placeKunaInventory(inventory, layout);
|
||||
const byAddress = new Map<string, KunaInventoryFunction>();
|
||||
const byName = new Map<string, KunaInventoryFunction[]>();
|
||||
for (const entry of inventory) {
|
||||
@@ -1400,10 +1512,64 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
else named.push(entry);
|
||||
}
|
||||
}
|
||||
this.#inventoryIndex = { inventory, byAddress, byName };
|
||||
this.#inventoryIndex = {
|
||||
inventory,
|
||||
layout,
|
||||
data,
|
||||
unplaced,
|
||||
byAddress,
|
||||
byName,
|
||||
};
|
||||
return this.#inventoryIndex;
|
||||
}
|
||||
|
||||
/** Whether the layout places the inventory entry at an address in data. */
|
||||
#isDataAddress(index: InventoryIndex, address: string): boolean {
|
||||
const entry = index.byAddress.get(address);
|
||||
return entry !== undefined && index.data.has(entry);
|
||||
}
|
||||
|
||||
/** The inventory, classified by the target's layout when it has one. */
|
||||
async #ensureIndex(
|
||||
installation: KunaInstallation,
|
||||
options: ExecutionOptions,
|
||||
): Promise<Result<InventoryIndex, AnalysisError>> {
|
||||
const inventory = await this.#ensureInventory(installation, options);
|
||||
if (!inventory.ok) return inventory;
|
||||
const layout = await this.#ensureLayout(options);
|
||||
if (!layout.ok) return layout;
|
||||
return ok(this.#index(inventory.value, layout.value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a Mach-O target's section table once, from the leading bytes kept
|
||||
* while its private copy was hashed. Other formats keep Kuna's inventory
|
||||
* as reported. A failed read is retried by the next request.
|
||||
*/
|
||||
#ensureLayout(
|
||||
options: ExecutionOptions,
|
||||
): Promise<Result<KunaInventoryLayout, AnalysisError>> {
|
||||
if (this.#target.format !== "mach-o")
|
||||
return Promise.resolve(ok(KUNA_LAYOUT_NOT_APPLICABLE));
|
||||
if (this.#layout === undefined) {
|
||||
const read = this.#own(this.#readLayout());
|
||||
this.#layout = read;
|
||||
void read.then((layout) => {
|
||||
if (!layout.ok && this.#layout === read) this.#layout = undefined;
|
||||
});
|
||||
}
|
||||
return waitFor(this.#layout, options.signal, "list_procedures");
|
||||
}
|
||||
|
||||
async #readLayout(): Promise<Result<KunaInventoryLayout, AnalysisError>> {
|
||||
try {
|
||||
const head = await this.#commands.inputHead("list_procedures");
|
||||
return head.ok ? ok(await readKunaMachoLayout(head.value)) : head;
|
||||
} catch (cause: unknown) {
|
||||
return err(failure("list_procedures", cause));
|
||||
}
|
||||
}
|
||||
|
||||
/** One installation producer; only the final departing caller stops it. */
|
||||
async #ensureInstallation(
|
||||
operation: string,
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js";
|
||||
import {
|
||||
copyAndHash,
|
||||
runKunaCommand,
|
||||
type KunaInputPrefix,
|
||||
type KunaProcessLauncher,
|
||||
} from "./KunaCommand.js";
|
||||
import type { KunaDeadline } from "./KunaConfiguration.js";
|
||||
@@ -39,6 +40,15 @@ interface WorkspaceRoot {
|
||||
}
|
||||
interface Workspace extends WorkspaceRoot {
|
||||
readonly input: string;
|
||||
readonly bytes: number;
|
||||
/** Leading bytes of the input kept while it was copied and hashed. */
|
||||
readonly prefix: Buffer;
|
||||
}
|
||||
|
||||
/** The private input's size and the leading bytes kept from its verified copy. */
|
||||
export interface KunaInputHead {
|
||||
readonly bytes: number;
|
||||
readonly prefix: Buffer;
|
||||
}
|
||||
|
||||
export interface KunaTargetCommandSnapshot extends ProviderProcessSnapshot {
|
||||
@@ -76,6 +86,8 @@ export class KunaTargetCommands {
|
||||
private readonly lifetime: {
|
||||
readonly signal: AbortSignal;
|
||||
readonly launcher?: KunaProcessLauncher;
|
||||
/** Leading bytes in-process readers need, kept while the copy is hashed. */
|
||||
readonly inputPrefix?: KunaInputPrefix;
|
||||
},
|
||||
) {}
|
||||
|
||||
@@ -102,6 +114,22 @@ export class KunaTargetCommands {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The verified input's size and kept leading bytes, establishing the input
|
||||
* first. They come from the stream that was hashed, so they are exactly
|
||||
* the bytes Kuna analyzes, and reading them opens no file.
|
||||
*/
|
||||
inputHead(operation: string): Promise<Result<KunaInputHead, AnalysisError>> {
|
||||
if (this.#closing || this.lifetime.signal.aborted)
|
||||
return Promise.resolve(err(new AnalysisCancelledError(operation)));
|
||||
return this.#admit(operation, async () => {
|
||||
const workspace = await this.#ensureWorkspace();
|
||||
if (!workspace.ok) return workspace;
|
||||
const { bytes, prefix } = workspace.value;
|
||||
return ok({ bytes, prefix });
|
||||
});
|
||||
}
|
||||
|
||||
async #run(
|
||||
request: KunaTargetCommandRequest,
|
||||
): Promise<Result<KunaTargetCommandSnapshot, AnalysisError>> {
|
||||
@@ -286,12 +314,13 @@ export class KunaTargetCommands {
|
||||
cleanup: new AnalysisCleanupLease(() => root.close(), [root.path]),
|
||||
};
|
||||
const input = join(root.path, basename(this.target.path));
|
||||
const digest = await copyAndHash(
|
||||
const copied = await copyAndHash(
|
||||
this.target.path,
|
||||
input,
|
||||
this.lifetime.signal,
|
||||
this.lifetime.inputPrefix,
|
||||
);
|
||||
if (digest !== this.target.sha256)
|
||||
if (copied.sha256 !== this.target.sha256)
|
||||
return await this.#workspaceFailure(
|
||||
new AnalysisArtifactChangedError(
|
||||
"kuna:workspace",
|
||||
@@ -301,7 +330,12 @@ export class KunaTargetCommands {
|
||||
owned,
|
||||
);
|
||||
await prepareProcessOwnershipInspection(this.lifetime.signal);
|
||||
return ok({ ...owned, input: await realpath(input) });
|
||||
return ok({
|
||||
...owned,
|
||||
input: await realpath(input),
|
||||
bytes: copied.bytes,
|
||||
prefix: copied.prefix,
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
const primary = new ProviderAdapterError(
|
||||
KUNA_PROVIDER_ID,
|
||||
|
||||
@@ -8,6 +8,13 @@ export interface FakeKunaFunction {
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
/** One inventory-only row; Kuna reports a size of 0 for an unmeasured entry. */
|
||||
export interface FakeKunaInventoryEntry {
|
||||
readonly name: string;
|
||||
readonly addr: string;
|
||||
readonly size?: number;
|
||||
}
|
||||
|
||||
export interface FakeKunaString {
|
||||
readonly address: number;
|
||||
readonly text: string;
|
||||
@@ -24,6 +31,11 @@ export interface FakeKunaBehavior {
|
||||
| { readonly ignoreTerm: true };
|
||||
readonly inventory?: "ok" | "not-json" | "hang";
|
||||
readonly inventoryImports?: boolean;
|
||||
/**
|
||||
* Rows only `functions --json` lists, as Kuna lists import slots and Mach-O
|
||||
* exported data symbols that its export never decompiles.
|
||||
*/
|
||||
readonly inventoryEntries?: readonly FakeKunaInventoryEntry[];
|
||||
readonly project?:
|
||||
| "ok"
|
||||
| "exit-failure"
|
||||
@@ -107,7 +119,7 @@ if (args[0] === "--version") {
|
||||
if (mode === "hang") hang();
|
||||
else if (mode === "not-json") process.stdout.write("functions: 2\n");
|
||||
else {
|
||||
const listed = [...functions, ...(behavior.inventoryImports === false ? [] : [{ name: "puts", addr: "0x2000" }])].map((fn) => ({ name: fn.name, address: parseInt(fn.addr, 16), address_hex: fn.addr, aliases: [], object_location: null, size: fn.name === "puts" ? 0 : 16 }));
|
||||
const listed = [...functions, ...(behavior.inventoryEntries ?? []), ...(behavior.inventoryImports === false ? [] : [{ name: "puts", addr: "0x2000" }])].map((fn) => ({ name: fn.name, address: parseInt(fn.addr, 16), address_hex: fn.addr, aliases: [], object_location: null, size: fn.size ?? (fn.name === "puts" ? 0 : 16) }));
|
||||
setTimeout(() => process.stdout.write(JSON.stringify({ binary: args[1], count: listed.length, total: listed.length, error: null, functions: listed }, null, 2) + "\n"), behavior.inventoryDelayMs ?? 0);
|
||||
}
|
||||
} else if (args[0] === "strings") {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { copyAndHash } from "../../../../src/kuna/KunaCommand.js";
|
||||
import {
|
||||
MACHO_LOAD_COMMAND_PREFIX,
|
||||
placeKunaInventory,
|
||||
readKunaMachoLayout,
|
||||
} from "../../../../src/kuna/KunaInventoryPlacement.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
import { machoInventoryImage } from "./kunaMachoFixture.js";
|
||||
|
||||
/** One `__TEXT` segment holding `count` adjacent 16-byte sections. */
|
||||
const manySectionImage = (
|
||||
count: number,
|
||||
flagsOf: (index: number) => number,
|
||||
) => {
|
||||
const command = Buffer.alloc(72 + count * 80);
|
||||
command.writeUInt32LE(0x19, 0);
|
||||
command.writeUInt32LE(command.length, 4);
|
||||
command.write("__TEXT", 8, "latin1");
|
||||
command.writeBigUInt64LE(0x100000000n, 24);
|
||||
command.writeBigUInt64LE(0x100000n, 32);
|
||||
command.writeBigUInt64LE(0n, 40);
|
||||
command.writeBigUInt64LE(0x100000n, 48);
|
||||
command.writeUInt32LE(5, 56);
|
||||
command.writeUInt32LE(5, 60);
|
||||
command.writeUInt32LE(count, 64);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const at = 72 + index * 80;
|
||||
command.write(`__s${String(index)}`, at, 16, "latin1");
|
||||
command.write("__TEXT", at + 16, "latin1");
|
||||
command.writeBigUInt64LE(0x100080000n + BigInt(index * 16), at + 32);
|
||||
command.writeBigUInt64LE(16n, at + 40);
|
||||
command.writeUInt32LE(flagsOf(index), at + 64);
|
||||
}
|
||||
const image = Buffer.alloc(32 + command.length + 0x20000);
|
||||
image.writeUInt32LE(0xfeedfacf, 0);
|
||||
image.writeUInt32LE(0x0100000c, 4);
|
||||
image.writeUInt32LE(2, 12);
|
||||
image.writeUInt32LE(1, 16);
|
||||
image.writeUInt32LE(command.length, 20);
|
||||
command.copy(image, 32);
|
||||
return image;
|
||||
};
|
||||
|
||||
const entry = (address: string) => ({
|
||||
name: address,
|
||||
address,
|
||||
aliases: [],
|
||||
size: 0,
|
||||
});
|
||||
|
||||
it("keeps load commands that span stream chunks from the hashed copy", async () => {
|
||||
// 1000 sections need about 80 KB of commands, more than one 64 KiB read.
|
||||
const image = manySectionImage(1000, (index) =>
|
||||
index === 999 ? 0 : 0x80000400,
|
||||
);
|
||||
const root = await createTestTempDirectory("rea-kuna-input-prefix-");
|
||||
const source = join(root, "program");
|
||||
await writeFile(source, image);
|
||||
const copied = await copyAndHash(
|
||||
source,
|
||||
join(root, "copy"),
|
||||
new AbortController().signal,
|
||||
MACHO_LOAD_COMMAND_PREFIX,
|
||||
);
|
||||
expect(copied.sha256).toBe(createHash("sha256").update(image).digest("hex"));
|
||||
expect(copied.bytes).toBe(image.length);
|
||||
expect(copied.prefix).toEqual(image.subarray(0, 32 + image.readUInt32LE(20)));
|
||||
|
||||
const layout = await readKunaMachoLayout(copied);
|
||||
const last = entry("0x100083e70");
|
||||
const first = entry("0x100080000");
|
||||
const placed = placeKunaInventory([first, last], layout);
|
||||
expect(placed.data).toEqual(new Map([[last, "__TEXT,__s999"]]));
|
||||
expect(placed.unplaced.size).toBe(0);
|
||||
|
||||
// Without a request nothing is kept, and a file shorter than the probe
|
||||
// keeps what it has.
|
||||
expect(
|
||||
(
|
||||
await copyAndHash(
|
||||
source,
|
||||
join(root, "plain"),
|
||||
new AbortController().signal,
|
||||
)
|
||||
).prefix,
|
||||
).toHaveLength(0);
|
||||
await writeFile(join(root, "short"), image.subarray(0, 12));
|
||||
expect(
|
||||
await readKunaMachoLayout(
|
||||
await copyAndHash(
|
||||
join(root, "short"),
|
||||
join(root, "short-copy"),
|
||||
new AbortController().signal,
|
||||
MACHO_LOAD_COMMAND_PREFIX,
|
||||
),
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "unavailable",
|
||||
reason: expect.stringContaining("truncated Mach-O header"),
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses overlapping sections and load commands above the limit as unclassifiable", async () => {
|
||||
const overlapping = manySectionImage(2, () => 0);
|
||||
// Grow the first 16-byte section over the second.
|
||||
overlapping.writeBigUInt64LE(32n, 32 + 72 + 40);
|
||||
expect(
|
||||
await readKunaMachoLayout({
|
||||
bytes: overlapping.length,
|
||||
prefix: overlapping.subarray(0, 32 + overlapping.readUInt32LE(20)),
|
||||
}),
|
||||
).toEqual({
|
||||
status: "unavailable",
|
||||
reason: "sections __TEXT,__s0 and __TEXT,__s1 overlap",
|
||||
});
|
||||
|
||||
const oversized = Buffer.from(machoInventoryImage());
|
||||
oversized.writeUInt32LE(16 * 1024 * 1024 + 8, 20);
|
||||
const head = oversized.subarray(0, 32);
|
||||
expect(MACHO_LOAD_COMMAND_PREFIX.length(head)).toBe(32);
|
||||
expect(
|
||||
await readKunaMachoLayout({ bytes: 32 * 1024 * 1024, prefix: head }),
|
||||
).toEqual({
|
||||
status: "unavailable",
|
||||
reason:
|
||||
"its load commands occupy 16777224 bytes, above the 16777216-byte load-command limit",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { FakeKunaFunction, FakeKunaInventoryEntry } from "./fakeKuna.js";
|
||||
|
||||
/** One `section_64` record: `[segment, section, address, size, flags]`. */
|
||||
type SectionRow = readonly [string, string, bigint, bigint, number];
|
||||
|
||||
interface SegmentRow {
|
||||
readonly name: string;
|
||||
readonly address: bigint;
|
||||
readonly memorySize: bigint;
|
||||
readonly fileOffset: bigint;
|
||||
readonly fileSize: bigint;
|
||||
readonly protection: number;
|
||||
readonly sections: readonly SectionRow[];
|
||||
}
|
||||
|
||||
const S_ATTR_CODE = 0x80000400;
|
||||
const S_SYMBOL_STUBS_CODE = 0x80000408;
|
||||
const S_NON_LAZY_SYMBOL_POINTERS = 0x6;
|
||||
const S_ZEROFILL = 0x1;
|
||||
|
||||
/**
|
||||
* The layout ld64 gives the reproduction in #8168: code and stubs in
|
||||
* `__TEXT`, a GOT slot in `__DATA_CONST`, globals in `__DATA`, and the
|
||||
* Mach-O header at the start of the executable `__TEXT` segment.
|
||||
*/
|
||||
const SEGMENTS: readonly SegmentRow[] = [
|
||||
{
|
||||
name: "__PAGEZERO",
|
||||
address: 0n,
|
||||
memorySize: 0x100000000n,
|
||||
fileOffset: 0n,
|
||||
fileSize: 0n,
|
||||
protection: 0,
|
||||
sections: [],
|
||||
},
|
||||
{
|
||||
name: "__TEXT",
|
||||
address: 0x100000000n,
|
||||
memorySize: 0x4000n,
|
||||
fileOffset: 0n,
|
||||
fileSize: 0x4000n,
|
||||
protection: 5,
|
||||
sections: [
|
||||
["__TEXT", "__text", 0x100000400n, 0x80n, S_ATTR_CODE],
|
||||
["__TEXT", "__stubs", 0x100000480n, 0xcn, S_SYMBOL_STUBS_CODE],
|
||||
["__TEXT", "__const", 0x1000004a0n, 0x10n, 0],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "__DATA_CONST",
|
||||
address: 0x100004000n,
|
||||
memorySize: 0x4000n,
|
||||
fileOffset: 0x4000n,
|
||||
fileSize: 0x4000n,
|
||||
protection: 3,
|
||||
sections: [
|
||||
["__DATA_CONST", "__got", 0x100004000n, 0x8n, S_NON_LAZY_SYMBOL_POINTERS],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "__DATA",
|
||||
address: 0x100008000n,
|
||||
memorySize: 0x4000n,
|
||||
fileOffset: 0x8000n,
|
||||
fileSize: 0x4000n,
|
||||
protection: 3,
|
||||
sections: [
|
||||
["__DATA", "__data", 0x100008000n, 0x10n, 0],
|
||||
["__DATA", "__bss", 0x100008010n, 0x10n, S_ZEROFILL],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "__LINKEDIT",
|
||||
address: 0x10000c000n,
|
||||
memorySize: 0x4000n,
|
||||
fileOffset: 0xc000n,
|
||||
fileSize: 0x100n,
|
||||
protection: 1,
|
||||
sections: [],
|
||||
},
|
||||
];
|
||||
|
||||
const writeName = (bytes: Buffer, offset: number, name: string) => {
|
||||
bytes.write(name, offset, 16, "latin1");
|
||||
};
|
||||
|
||||
/** A thin arm64 `MH_EXECUTE` image with that segment and section table. */
|
||||
export const machoInventoryImage = (): Buffer => {
|
||||
const commands = SEGMENTS.map((segment) => {
|
||||
const command = Buffer.alloc(72 + segment.sections.length * 80);
|
||||
command.writeUInt32LE(0x19, 0);
|
||||
command.writeUInt32LE(command.length, 4);
|
||||
writeName(command, 8, segment.name);
|
||||
command.writeBigUInt64LE(segment.address, 24);
|
||||
command.writeBigUInt64LE(segment.memorySize, 32);
|
||||
command.writeBigUInt64LE(segment.fileOffset, 40);
|
||||
command.writeBigUInt64LE(segment.fileSize, 48);
|
||||
command.writeUInt32LE(segment.protection, 56);
|
||||
command.writeUInt32LE(segment.protection, 60);
|
||||
command.writeUInt32LE(segment.sections.length, 64);
|
||||
segment.sections.forEach(([segmentName, name, address, size, flags], i) => {
|
||||
const at = 72 + i * 80;
|
||||
writeName(command, at, name);
|
||||
writeName(command, at + 16, segmentName);
|
||||
command.writeBigUInt64LE(address, at + 32);
|
||||
command.writeBigUInt64LE(size, at + 40);
|
||||
const fileOffset =
|
||||
(flags & 0xff) === S_ZEROFILL
|
||||
? 0
|
||||
: Number(address - segment.address + segment.fileOffset);
|
||||
command.writeUInt32LE(fileOffset, at + 48);
|
||||
command.writeUInt32LE(flags, at + 64);
|
||||
if (name === "__stubs") command.writeUInt32LE(12, at + 72);
|
||||
});
|
||||
return command;
|
||||
});
|
||||
const commandBytes = commands.reduce(
|
||||
(total, { length }) => total + length,
|
||||
0,
|
||||
);
|
||||
const image = Buffer.alloc(0xc100);
|
||||
image.writeUInt32LE(0xfeedfacf, 0);
|
||||
image.writeUInt32LE(0x0100000c, 4);
|
||||
image.writeUInt32LE(0, 8);
|
||||
image.writeUInt32LE(2, 12);
|
||||
image.writeUInt32LE(commands.length, 16);
|
||||
image.writeUInt32LE(commandBytes, 20);
|
||||
Buffer.concat(commands).copy(image, 32);
|
||||
return image;
|
||||
};
|
||||
|
||||
/** Functions the fake export decompiles, at addresses inside `__TEXT,__text`. */
|
||||
export const MACHO_FUNCTIONS: readonly FakeKunaFunction[] = [
|
||||
{ name: "main", addr: "0x100000400" },
|
||||
{ name: "helper", addr: "0x100000440" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Rows Kuna lists without bodies: a `puts` stub and its GOT slot, exported
|
||||
* data in four non-instruction regions, and one address outside every
|
||||
* section and the header.
|
||||
*/
|
||||
export const MACHO_INVENTORY_ENTRIES: readonly FakeKunaInventoryEntry[] = [
|
||||
{ name: "puts", addr: "0x100000480", size: 0 },
|
||||
{ name: "puts", addr: "0x100004000", size: 0 },
|
||||
{ name: "_mh_execute_header", addr: "0x100000000", size: 0 },
|
||||
{ name: "table", addr: "0x1000004a0", size: 0 },
|
||||
{ name: "global_count", addr: "0x100008000", size: 0 },
|
||||
{ name: "message", addr: "0x100008008", size: 0 },
|
||||
{ name: "counter", addr: "0x100008010", size: 0 },
|
||||
{ name: "linkedit_entry", addr: "0x10000c010", size: 0 },
|
||||
];
|
||||
@@ -0,0 +1,237 @@
|
||||
import { expect, it as test } from "vitest";
|
||||
|
||||
import { functionDossierSchema } from "../../../../src/domain/native/functionDossier.js";
|
||||
import { fakeKunaCalls } from "./fakeKuna.js";
|
||||
import {
|
||||
MACHO_FUNCTIONS,
|
||||
MACHO_INVENTORY_ENTRIES,
|
||||
machoInventoryImage,
|
||||
} from "./kunaMachoFixture.js";
|
||||
import { exportsOf, run, session } from "./kunaSessionFixture.js";
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
|
||||
const machoSession = (
|
||||
prefix: string,
|
||||
image: Uint8Array = machoInventoryImage(),
|
||||
) =>
|
||||
session(
|
||||
prefix,
|
||||
{
|
||||
functions: MACHO_FUNCTIONS,
|
||||
inventoryEntries: MACHO_INVENTORY_ENTRIES,
|
||||
inventoryImports: false,
|
||||
},
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
{ bytes: image, format: "mach-o", architecture: "arm64" },
|
||||
);
|
||||
|
||||
const PROCEDURES = [
|
||||
{ address: "0x100000400", value: "main" },
|
||||
{ address: "0x100000440", value: "helper" },
|
||||
{ address: "0x100000480", value: "puts" },
|
||||
{ address: "0x100004000", value: "puts" },
|
||||
{ address: "0x10000c010", value: "linkedit_entry" },
|
||||
];
|
||||
|
||||
const OMITTED =
|
||||
"Kuna's inventory also lists 5 entries in Mach-O data regions (Mach-O header and load commands: 1; __TEXT,__const: 1; __DATA,__data: 2; __DATA,__bss: 1): the header, or sections whose attributes declare no instructions. REA omits them as data rather than procedure entries; address_name and xrefs still accept their addresses.";
|
||||
const UNPLACED =
|
||||
"1 Kuna inventory entry lies outside every Mach-O section and the header, so REA cannot tell whether it is code or data and keeps it.";
|
||||
|
||||
it("keeps code, stubs and import slots but omits Mach-O data symbols from procedures", async () => {
|
||||
const { kuna, open } = await machoSession("rea-kuna-macho-inventory-");
|
||||
const client = open();
|
||||
try {
|
||||
const listed = await run(client, "list_procedures");
|
||||
expect(listed.result).toEqual(PROCEDURES);
|
||||
expect(listed.limitations).toEqual(
|
||||
expect.arrayContaining([OMITTED, UNPLACED]),
|
||||
);
|
||||
expect(listed.rawResult).toBeNull();
|
||||
|
||||
// A search reports only the omissions its own query matched.
|
||||
const searched = await run(client, "search_procedures", {
|
||||
pattern: "^(global|main)",
|
||||
mode: "regex",
|
||||
});
|
||||
expect(searched.result).toEqual([
|
||||
{ address: "0x100000400", value: "main" },
|
||||
]);
|
||||
expect(searched.limitations).toContain(
|
||||
"Kuna's inventory also lists 1 entry in Mach-O data regions (__DATA,__data: 1): the header, or sections whose attributes declare no instructions. REA omits it as data rather than a procedure entry; address_name and xrefs still accept its address.",
|
||||
);
|
||||
expect(searched.limitations).not.toContain(UNPLACED);
|
||||
const unmatched = await run(client, "search_procedures", {
|
||||
pattern: "helper",
|
||||
});
|
||||
expect(
|
||||
unmatched.limitations.filter((limitation) =>
|
||||
limitation.startsWith("Kuna's inventory also lists"),
|
||||
),
|
||||
).toEqual([]);
|
||||
|
||||
// Procedure operations refuse a data symbol by name or by address,
|
||||
// without running Kuna on its bytes.
|
||||
for (const procedure of ["global_count", "0x100008000"])
|
||||
expect(
|
||||
await client.execute("procedure_pseudo_code", { procedure }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisInputError",
|
||||
userMessage:
|
||||
"global_count (0x100008000) lies in __DATA,__data, a Mach-O section whose attributes declare no instructions, so it is data that Kuna lists, not a procedure. address_name and xrefs accept its address.",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
await client.execute("procedure_address", {
|
||||
procedure: "_mh_execute_header",
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
userMessage:
|
||||
"_mh_execute_header (0x100000000) lies in the Mach-O header and load commands, so it is data that Kuna lists, not a procedure. address_name and xrefs accept its address.",
|
||||
},
|
||||
});
|
||||
// An exact data address is not handed to the graph as a procedure.
|
||||
expect(
|
||||
await client.execute("procedure_address", { procedure: "0x100008010" }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { userMessage: expect.stringContaining("__DATA,__bss") },
|
||||
});
|
||||
expect(
|
||||
await client.execute("procedure_callers", { procedure: "table" }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { userMessage: expect.stringContaining("__TEXT,__const") },
|
||||
});
|
||||
expect(
|
||||
(await fakeKunaCalls(kuna)).filter(
|
||||
({ argv }) => argv[0] === "decompile" || argv[0] === "decompile-graph",
|
||||
),
|
||||
).toEqual([]);
|
||||
|
||||
// The name stays available for the address it names.
|
||||
const named = await run(client, "address_name", { address: "0x100008008" });
|
||||
expect(named.result).toBe("message");
|
||||
expect(named.limitations).toContain(
|
||||
"Kuna lists message in its function inventory, but 0x100008008 lies in __DATA,__data, a Mach-O section whose attributes declare no instructions, so the name labels data.",
|
||||
);
|
||||
expect(
|
||||
(await run(client, "address_name", { address: "0x100000440" })).result,
|
||||
).toBe("helper");
|
||||
|
||||
// Import slots keep their documented ambiguity; real code still resolves.
|
||||
expect(
|
||||
await client.execute("procedure_address", { procedure: "puts" }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
userMessage:
|
||||
"puts names 2 Kuna entries (0x100000480, 0x100004000); select one by address.",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
(await run(client, "procedure_address", { procedure: "helper" })).result,
|
||||
).toBe("0x100000440");
|
||||
expect(
|
||||
(await run(client, "procedure_pseudo_code", { procedure: "main" }))
|
||||
.result,
|
||||
).toEqual(expect.stringContaining("int main(void)"));
|
||||
} finally {
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves a data symbol's name for xrefs, which accept data addresses", async () => {
|
||||
const { kuna, open } = await machoSession("rea-kuna-macho-xrefs-");
|
||||
const client = open();
|
||||
try {
|
||||
const references = await run(client, "xrefs", { address: "global_count" });
|
||||
expect(references.locations).toContainEqual({
|
||||
kind: "address",
|
||||
address: "0x100008000",
|
||||
});
|
||||
expect(
|
||||
(await fakeKunaCalls(kuna)).find(({ argv }) => argv[0] === "xrefs")?.argv,
|
||||
).toContain("0x100008000");
|
||||
} finally {
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
});
|
||||
|
||||
it("classifies the published export's inventory with the section table it read", async () => {
|
||||
const { kuna, open } = await machoSession("rea-kuna-macho-warm-");
|
||||
const cold = open();
|
||||
try {
|
||||
await run(cold, "procedure_assembly", { procedure: "main" });
|
||||
} finally {
|
||||
expect(await cold.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
expect(await exportsOf(kuna)).toHaveLength(1);
|
||||
const warm = open();
|
||||
try {
|
||||
expect((await run(warm, "list_procedures")).result).toEqual(PROCEDURES);
|
||||
expect(await exportsOf(kuna)).toHaveLength(1);
|
||||
} finally {
|
||||
expect(await warm.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every entry with an explicit limitation when the section table is unreadable", async () => {
|
||||
const image = machoInventoryImage();
|
||||
// Declare one more load command than the table holds.
|
||||
image.writeUInt32LE(image.readUInt32LE(16) + 1, 16);
|
||||
const { open } = await machoSession("rea-kuna-macho-unreadable-", image);
|
||||
const client = open();
|
||||
try {
|
||||
const listed = await run(client, "list_procedures");
|
||||
expect(listed.result).toHaveLength(
|
||||
MACHO_FUNCTIONS.length + MACHO_INVENTORY_ENTRIES.length,
|
||||
);
|
||||
const unreadable = listed.limitations.find((limitation) =>
|
||||
/^REA could not read the target's Mach-O section table \(.+\), so Kuna's inventory is kept unclassified and may include data symbols\.$/u.test(
|
||||
limitation,
|
||||
),
|
||||
);
|
||||
expect(unreadable).toBeDefined();
|
||||
const named = await run(client, "address_name", { address: "0x100008000" });
|
||||
expect(named.result).toBe("global_count");
|
||||
// Direct answers keep the same caveat, including the dossier's own list.
|
||||
const dossier = await run(client, "analyze_function", {
|
||||
procedure: "main",
|
||||
});
|
||||
for (const limitations of [
|
||||
named.limitations,
|
||||
dossier.limitations,
|
||||
functionDossierSchema.parse(dossier.result).limitations,
|
||||
])
|
||||
expect(limitations).toContain(unreadable);
|
||||
} finally {
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps an ELF inventory exactly as Kuna reports it", async () => {
|
||||
const { open } = await session("rea-kuna-elf-inventory-", {
|
||||
inventoryEntries: [{ name: "false_func", addr: "0x34a0", size: 0 }],
|
||||
});
|
||||
const client = open();
|
||||
try {
|
||||
const listed = await run(client, "list_procedures");
|
||||
expect(listed.result).toContainEqual({
|
||||
address: "0x34a0",
|
||||
value: "false_func",
|
||||
});
|
||||
expect(
|
||||
listed.limitations.some((limitation) => limitation.includes("Mach-O")),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { AnalysisProviderRegistry } from "../../../../src/application/binary/AnalysisProviderRegistry.js";
|
||||
import { composeBinarySession } from "../../../../src/application/binary/BinarySessionComposition.js";
|
||||
import {
|
||||
DECOMPILE_DIRECTORY_INDEX_NAME,
|
||||
decompileDirectoryIndexSchema,
|
||||
} from "../../../../src/contracts/decompileDirectory.js";
|
||||
import { parseEvidence } from "../../../../src/domain/evidence.js";
|
||||
import { KunaProvider } from "../../../../src/kuna/KunaProvider.js";
|
||||
import { createServer } from "../../../../src/server/createServer.js";
|
||||
import { parseMcpToolError } from "../../../fixtures/mcpToolError.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
import { mcpTest, type TestMcp } from "../../../support/mcp/mcpFixture.js";
|
||||
import { fakeKunaCalls, writeFakeKuna } from "./fakeKuna.js";
|
||||
import {
|
||||
MACHO_FUNCTIONS,
|
||||
MACHO_INVENTORY_ENTRIES,
|
||||
machoInventoryImage,
|
||||
} from "./kunaMachoFixture.js";
|
||||
|
||||
const test = mcpTest.skipIf(process.platform === "win32");
|
||||
|
||||
const UNPLACED =
|
||||
"1 Kuna inventory entry lies outside every Mach-O section and the header, so REA cannot tell whether it is code or data and keeps it.";
|
||||
const UNREADABLE =
|
||||
"REA could not read the target's Mach-O section table (sections __TEXT,__text and __TEXT,__stubs overlap), so Kuna's inventory is kept unclassified and may include data symbols.";
|
||||
|
||||
const classificationCaveats = (limitations: readonly string[]) =>
|
||||
limitations.filter((limitation) => limitation.includes("Mach-O"));
|
||||
|
||||
/** An MCP session on a Kuna-backed synthetic Mach-O. */
|
||||
const machoSession = async (
|
||||
mcp: TestMcp,
|
||||
prefix: string,
|
||||
image: Uint8Array = machoInventoryImage(),
|
||||
) => {
|
||||
const root = await createTestTempDirectory(prefix);
|
||||
const kuna = await writeFakeKuna(root, {
|
||||
functions: MACHO_FUNCTIONS,
|
||||
inventoryEntries: MACHO_INVENTORY_ENTRIES,
|
||||
inventoryImports: false,
|
||||
});
|
||||
const target = join(root, "program");
|
||||
await writeFile(target, image);
|
||||
const session = composeBinarySession(
|
||||
new AnalysisProviderRegistry(
|
||||
[
|
||||
new KunaProvider(
|
||||
{ REA_KUNA_COMMAND: kuna, REA_KUNA_CACHE_DIR: join(root, "cache") },
|
||||
{ platform: process.platform },
|
||||
),
|
||||
],
|
||||
"kuna",
|
||||
),
|
||||
);
|
||||
const client = await mcp.connect(createServer({ kind: "session", session }));
|
||||
const call = async (name: string, arguments_: Record<string, unknown>) => {
|
||||
const response = await client.callTool({ name, arguments: arguments_ });
|
||||
if (response.isError === true)
|
||||
throw new Error(`${name} failed: ${JSON.stringify(response)}`);
|
||||
return response.structuredContent;
|
||||
};
|
||||
const observe = async (name: string, arguments_: Record<string, unknown>) =>
|
||||
parseEvidence(await call(name, arguments_));
|
||||
await call("open_binary", { path: target });
|
||||
return { root, kuna, client, observe };
|
||||
};
|
||||
|
||||
test("keeps Mach-O data symbols out of listings, overviews and decompile_all", async ({
|
||||
mcp,
|
||||
}) => {
|
||||
const { root, kuna, client, observe } = await machoSession(
|
||||
mcp,
|
||||
"rea-kuna-macho-mcp-",
|
||||
);
|
||||
const listed = await observe("list_procedures", {});
|
||||
expect(listed.normalized_result).toEqual([
|
||||
{ address: "0x100000400", value: "main" },
|
||||
{ address: "0x100000440", value: "helper" },
|
||||
{ address: "0x100000480", value: "puts" },
|
||||
{ address: "0x100004000", value: "puts" },
|
||||
{ address: "0x10000c010", value: "linkedit_entry" },
|
||||
]);
|
||||
const omitted = listed.limitations.find((limitation) =>
|
||||
limitation.startsWith("Kuna's inventory also lists 5 entries"),
|
||||
);
|
||||
expect(omitted).toBeDefined();
|
||||
|
||||
const refused = await client.callTool({
|
||||
name: "procedure_pseudo_code",
|
||||
arguments: { procedure: "global_count" },
|
||||
});
|
||||
expect(refused.isError).toBe(true);
|
||||
expect(parseMcpToolError(refused).error).toMatchObject({
|
||||
category: "invalid_input",
|
||||
message: expect.stringContaining(
|
||||
"lies in __DATA,__data, a Mach-O section whose attributes declare no instructions",
|
||||
),
|
||||
});
|
||||
|
||||
// Composed workflows count and decompile only the listed entries, and
|
||||
// keep the listing's account of what it omitted.
|
||||
const overview = await observe("binary_overview", {});
|
||||
expect(overview.normalized_result).toMatchObject({ procedure_count: 5 });
|
||||
expect(overview.limitations).toContainEqual(
|
||||
expect.stringContaining(omitted!),
|
||||
);
|
||||
const output = join(root, "out");
|
||||
const exported = await observe("decompile_all", {
|
||||
output_directory: output,
|
||||
});
|
||||
expect(exported.normalized_result).toMatchObject({
|
||||
counts: { selected: 5, written: 2, failed: 3 },
|
||||
});
|
||||
expect(exported.limitations).toContainEqual(
|
||||
expect.stringContaining(omitted!),
|
||||
);
|
||||
expect((await readdir(join(output, "functions"))).sort()).toEqual([
|
||||
"0x100000400.c",
|
||||
"0x100000440.c",
|
||||
]);
|
||||
// Only the real functions reached Kuna's single-function decompiler.
|
||||
expect(
|
||||
(await fakeKunaCalls(kuna))
|
||||
.filter(({ argv }) => argv[0] === "decompile")
|
||||
.map(({ argv }) => argv[2]),
|
||||
).not.toContain("0x100008000");
|
||||
// Each function's record keeps the caveats of the reads it used.
|
||||
const index = decompileDirectoryIndexSchema.parse(
|
||||
JSON.parse(
|
||||
await readFile(join(output, DECOMPILE_DIRECTORY_INDEX_NAME), "utf8"),
|
||||
),
|
||||
);
|
||||
const records = new Map(
|
||||
index.functions.map((record) => [record.address, record]),
|
||||
);
|
||||
expect(records.get("0x10000c010")?.limitations).toContain(UNPLACED);
|
||||
expect(
|
||||
classificationCaveats(records.get("0x100000440")?.limitations ?? []),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("keeps an unplaced entry's classification caveat on direct answers", async ({
|
||||
mcp,
|
||||
}) => {
|
||||
const { observe } = await machoSession(mcp, "rea-kuna-macho-unplaced-mcp-");
|
||||
// Waiting for assembly completes the export, so later text reads use it.
|
||||
const assembly = await observe("procedure_assembly", { procedure: "main" });
|
||||
expect(classificationCaveats(assembly.limitations)).toEqual([]);
|
||||
const code = await observe("procedure_pseudo_code", { procedure: "main" });
|
||||
expect(classificationCaveats(code.limitations)).toEqual([]);
|
||||
const helper = await observe("procedure_address", { procedure: "helper" });
|
||||
expect(classificationCaveats(helper.limitations)).toEqual([]);
|
||||
|
||||
const unplaced = await observe("procedure_pseudo_code", {
|
||||
procedure: "linkedit_entry",
|
||||
});
|
||||
expect(unplaced.normalized_result).toBeNull();
|
||||
expect(classificationCaveats(unplaced.limitations)).toEqual([UNPLACED]);
|
||||
const located = await observe("procedure_address", {
|
||||
procedure: "0x10000c010",
|
||||
});
|
||||
expect(located.normalized_result).toBe("0x10000c010");
|
||||
expect(classificationCaveats(located.limitations)).toEqual([UNPLACED]);
|
||||
});
|
||||
|
||||
test("keeps an unreadable section table's caveat on direct answers", async ({
|
||||
mcp,
|
||||
}) => {
|
||||
// Admission accepts the image, but its section table claims __text runs
|
||||
// into __stubs, so REA cannot say which content an address holds.
|
||||
const image = machoInventoryImage();
|
||||
const text = image.indexOf("__text\0", 0, "latin1");
|
||||
image.writeBigUInt64LE(0x90n, text + 40);
|
||||
const { observe } = await machoSession(
|
||||
mcp,
|
||||
"rea-kuna-macho-unreadable-mcp-",
|
||||
image,
|
||||
);
|
||||
for (const [operation, procedure] of [
|
||||
["procedure_pseudo_code", "main"],
|
||||
["procedure_assembly", "main"],
|
||||
["procedure_address", "helper"],
|
||||
["procedure_callers", "main"],
|
||||
] as const) {
|
||||
const answer = await observe(operation, { procedure });
|
||||
expect(classificationCaveats(answer.limitations), operation).toEqual([
|
||||
UNREADABLE,
|
||||
]);
|
||||
}
|
||||
});
|
||||
@@ -35,18 +35,23 @@ export const session = async (
|
||||
environment: Readonly<Record<string, string>> = {},
|
||||
launcher?: KunaProcessLauncher,
|
||||
singleFunctionRuns?: number,
|
||||
image: {
|
||||
readonly bytes: Uint8Array | string;
|
||||
readonly format: "elf" | "mach-o";
|
||||
readonly architecture: "x86_64" | "arm64";
|
||||
} = { bytes: "program bytes", format: "elf", architecture: "x86_64" },
|
||||
) => {
|
||||
const root = await createTestTempDirectory(prefix);
|
||||
const kuna = await writeFakeKuna(root, { functions: FUNCTIONS, ...behavior });
|
||||
const targetPath = join(root, "program");
|
||||
await writeFile(targetPath, "program bytes");
|
||||
await writeFile(targetPath, image.bytes);
|
||||
const target: BinaryTarget = {
|
||||
path: targetPath,
|
||||
sha256: createHash("sha256").update("program bytes").digest("hex"),
|
||||
sha256: createHash("sha256").update(image.bytes).digest("hex"),
|
||||
kind: "executable",
|
||||
format: "elf",
|
||||
architecture: "x86_64",
|
||||
availableArchitectures: ["x86_64"],
|
||||
format: image.format,
|
||||
architecture: image.architecture,
|
||||
availableArchitectures: [image.architecture],
|
||||
};
|
||||
const cacheRoot = join(root, "cache");
|
||||
const provider = new KunaProvider(
|
||||
|
||||
Reference in new issue
Block a user